From 1cab8610b1474afb3a8027a74763221a35ae72ce Mon Sep 17 00:00:00 2001 From: Axel Meyer Date: Sun, 31 May 2026 01:46:49 +0200 Subject: [PATCH] lib-management.dep-fetcher v0.1.0: closure walk + conflict + state-check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure-Lua manifest-walker for the dep-fetcher slice 5. Reads a module's manifest.module, walks the transitive lib-dep tree, detects pin conflicts (hard-fail), and uses lib-core.git to ensure every lib in the closure is at the pinned v tag. State-check covers cases A-E from the dep-fetcher architecture spec: missing (clone), clean (no-op), dirty (warn), wrong-tag (silent checkout), broken (error). Ships an inline JSON decoder (~80 lines, deterministic, no deps) because the engine has no generic engine.json.decode binding yet — engine.asset.load_json is sandboxed to the asset-tree and cannot read fixture/install paths. Stop-gaps documented in the lib (resolved by slice 6): - opts.install_root substitutes for engine.install_root() binding - gitea_base hardcoded; slice 6 sources from engine.json via engine.config(key) Gitea-slug derivation handles the .git-suffix exception: lib-core.git maps to sporel-lib-core.git-lib because Gitea reserves the *.git suffix pattern. Encoded as generic predicate so future .git-suffixed libs work without code change. --- README.md | 91 +++++++++ init.lua | 547 +++++++++++++++++++++++++++++++++++++++++++++++++++ manifest.lib | 8 + 3 files changed, 646 insertions(+) create mode 100644 README.md create mode 100644 init.lua create mode 100644 manifest.lib diff --git a/README.md b/README.md new file mode 100644 index 0000000..80e77a9 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# lib-management.dep-fetcher + +Pure-Lua manifest-walker + dep-closure resolver. Reads a module's +`manifest.module`, walks the transitive lib-dep tree, detects pin +conflicts, then uses `lib-core.git` to ensure every lib in the closure +is at the pinned `v` tag locally — cloning missing libs, +checking out wrong-tag libs, surfacing dirty libs as warnings. + +Slice 5 of the dep-fetcher plan +(`sporel-meta/docs/superpowers/plans/2026-05-30-dep-fetcher-implementation.md`). + +## API + +```lua +local depf = require("lib-management.dep-fetcher") + +-- Primary entry point +local result = depf.ensure_for_module_at(manifest_path, opts) + +-- Convenience wrapper for install-layout resolution (slice 6: switches +-- to engine.install_root() once that binding lands). +local result = depf.ensure_for_module(module_id, opts) +``` + +### `opts` + +| Field | Type | Notes | +|---|---|---| +| `install_root` | string | Root of `/libs/`. Falls back to env `SPOREL_INSTALL_ROOT`. Slice 6 will add `engine.install_root()` and drop this stop-gap. | +| `check_only` | bool | Skip all git operations. Closure walk + conflict detection only — used by unit tests and by the launcher's dep-diagnose surface. | +| `gitea_base` | string | Override base URL (default `https://git.davoryn.de/sporel`). Slice 6 will source from `engine.json` via `engine.config(key)`. | + +### `result` + +```lua +{ + ok : bool, -- false if conflict or error + conflicts : { {lib_id, pins=[{source,version}]}, ... }, + warnings : { {lib_id, kind="dirty", describe}, ... }, + errors : { {lib_id, kind, message}, ... }, + closure : { {source, lib_id, version}, ... }, +} +``` + +`errors[].kind ∈ { "config", "manifest-read", "net-fail", +"describe-fail", "checkout-fail" }`. + +## State-check cases + +Per spec +`sporel-meta/docs/superpowers/specs/2026-05-30-dep-fetcher-architecture.md` §5: + +| Case | Condition | Action | +|---|---|---| +| A | `is_repo == false` | `git.clone(url, dir, v)` | +| B | `describe == v` | No-op (clean) | +| C | `describe` matches `v-N-g…` or ends `-dirty` | Warning, no action | +| D | `describe` is some other tag | Silent `git.checkout(dir, v)` | +| E | `describe` returns nil/err | Error | + +## Gitea-slug derivation + +Default: `sporel-`. Exception for libs whose ID ends in `.git` +(currently only `lib-core.git`): the Gitea repo name pattern +`*.git` is reserved by the protocol, so the slug appends `-lib`: + +``` +lib-core.maps → sporel-lib-core.maps +lib-management.input → sporel-lib-management.input +lib-core.git → sporel-lib-core.git-lib (exception) +``` + +This rule is encoded as a generic predicate (`_gitea_slug_for`) so any +future `.git`-suffixed library uses the same translation without +code change. + +## Visibility model (slice 5 status) + +Only the **Vagrant transitive closure** is public. Everything else is +private (paid content, Steam-backed Gitea-SSO planned). Slice 5 +intentionally does not plumb credentials; if a private repo is in the +closure and not locally present, the clone returns `errors[]` with +`kind="net-fail"`. That is the documented limit of this slice. + +## JSON parser + +Bundled inline (see `init.lua::json_decode`). The engine has no +generic `engine.json.decode` binding yet — `engine.asset.load_json` is +sandboxed to the asset-tree and cannot read fixture/install paths. +A future slice may extract this parser into a shared utility lib; +until then it lives here. diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..0f89982 --- /dev/null +++ b/init.lua @@ -0,0 +1,547 @@ +-- lib-management.dep-fetcher v0.1.0 +-- +-- Walks a module's transitive lib-dep closure, detects pin conflicts +-- (hard-fail), and ensures each lib in the closure is at the pinned +-- v tag via lib-core.git. Slice 5 of the dep-fetcher plan. +-- +-- Public API: +-- ensure_for_module_at(manifest_path, opts) -> result +-- ensure_for_module(module_id) -> result (slice 6 once +-- engine.install_root +-- is bound) +-- +-- result = { +-- ok : bool, +-- conflicts : [{lib_id, pins=[{source, version}, ...]}], +-- warnings : [{lib_id, kind, ...}], +-- errors : [{lib_id, kind, message}], +-- closure : [{source, lib_id, version}], +-- } +-- +-- Stop-gap notes (resolved in slice 6): +-- - opts.install_root replaces a not-yet-existing engine.install_root() +-- binding; falls back to SPOREL_INSTALL_ROOT env var. +-- - gitea_base is hardcoded; future slice 6 will move it to engine.json +-- + engine.config(key) binding. +-- +-- Per the dep-fetcher architecture (sporel-distribution-model.md), only +-- vagrant + its transitive closure are public. Private repos (paid +-- content via Steam, Gitea-SSO provisioning) fail with a clear network +-- error during the slice-5 implementation — that is acceptable; auth +-- plumbing lands in a later slice. + +local M = {} + +-- ===================================================================== +-- Pure-Lua JSON decoder (strict subset: object / array / string / +-- number / true / false / null). Sufficient for manifest.lib / +-- manifest.module files which are simple JSON. +-- +-- The engine does NOT expose a generic engine.json.decode binding yet +-- (engine.asset.load_json is sandboxed to the asset-tree, which fixture +-- paths sit outside of). Slice 6 may add engine.json.decode; until then +-- we ship our own parser inline. ~80 lines, deterministic, no +-- dependencies. +-- ===================================================================== + +local function json_decode(text) + local pos = 1 + local len = #text + + local function err(msg) + return nil, string.format("json: %s at offset %d", msg, pos) + end + + local function skip_ws() + while pos <= len do + local c = text:byte(pos) + -- ' ', '\t', '\n', '\r' + if c == 32 or c == 9 or c == 10 or c == 13 then + pos = pos + 1 + else + return + end + end + end + + local parse_value -- forward decl + + local function parse_string() + if text:byte(pos) ~= 34 then return err("expected '\"'") end + pos = pos + 1 + local start = pos + local parts = nil -- accumulate only if escapes present + while pos <= len do + local c = text:byte(pos) + if c == 34 then -- closing quote + local out + if parts then + table.insert(parts, text:sub(start, pos - 1)) + out = table.concat(parts) + else + out = text:sub(start, pos - 1) + end + pos = pos + 1 + return out + elseif c == 92 then -- backslash + parts = parts or {} + table.insert(parts, text:sub(start, pos - 1)) + pos = pos + 1 + local esc = text:byte(pos) + if esc == 34 then table.insert(parts, '"') + elseif esc == 92 then table.insert(parts, '\\') + elseif esc == 47 then table.insert(parts, '/') + elseif esc == 98 then table.insert(parts, '\b') + elseif esc == 102 then table.insert(parts, '\f') + elseif esc == 110 then table.insert(parts, '\n') + elseif esc == 114 then table.insert(parts, '\r') + elseif esc == 116 then table.insert(parts, '\t') + elseif esc == 117 then + -- \uXXXX — emit as raw UTF-8 for BMP codepoints. + -- Surrogate pairs are not handled (manifests do + -- not need them). + local hex = text:sub(pos + 1, pos + 4) + local code = tonumber(hex, 16) + if not code then return err("invalid \\u escape") end + if code < 0x80 then + table.insert(parts, string.char(code)) + elseif code < 0x800 then + table.insert(parts, string.char( + 0xC0 + math.floor(code / 0x40), + 0x80 + (code % 0x40))) + else + table.insert(parts, string.char( + 0xE0 + math.floor(code / 0x1000), + 0x80 + math.floor(code / 0x40) % 0x40, + 0x80 + (code % 0x40))) + end + pos = pos + 4 + else + return err("invalid escape") + end + pos = pos + 1 + start = pos + else + pos = pos + 1 + end + end + return err("unterminated string") + end + + local function parse_number() + local start = pos + local c = text:byte(pos) + if c == 45 then pos = pos + 1 end -- leading minus + while pos <= len do + c = text:byte(pos) + -- digit / '.' / 'e' / 'E' / '+' / '-' + if (c >= 48 and c <= 57) or c == 46 or c == 43 or c == 45 + or c == 101 or c == 69 then + pos = pos + 1 + else + break + end + end + local n = tonumber(text:sub(start, pos - 1)) + if not n then return err("invalid number") end + return n + end + + local function parse_literal() + if text:sub(pos, pos + 3) == "true" then + pos = pos + 4; return true + elseif text:sub(pos, pos + 4) == "false" then + pos = pos + 5; return false + elseif text:sub(pos, pos + 3) == "null" then + pos = pos + 4; return nil + end + return err("invalid literal") + end + + local function parse_array() + pos = pos + 1 -- consume '[' + local arr = {} + skip_ws() + if pos <= len and text:byte(pos) == 93 then -- ']' + pos = pos + 1; return arr + end + while pos <= len do + skip_ws() + local v, e = parse_value() + if e then return nil, e end + table.insert(arr, v) + skip_ws() + local c = text:byte(pos) + if c == 44 then -- ',' + pos = pos + 1 + elseif c == 93 then -- ']' + pos = pos + 1 + return arr + else + return err("expected ',' or ']' in array") + end + end + return err("unterminated array") + end + + local function parse_object() + pos = pos + 1 -- consume '{' + local obj = {} + skip_ws() + if pos <= len and text:byte(pos) == 125 then -- '}' + pos = pos + 1; return obj + end + while pos <= len do + skip_ws() + local k, e = parse_string() + if not k then return nil, e end + skip_ws() + if text:byte(pos) ~= 58 then -- ':' + return err("expected ':' in object") + end + pos = pos + 1 + skip_ws() + local v, e2 = parse_value() + if e2 then return nil, e2 end + obj[k] = v + skip_ws() + local c = text:byte(pos) + if c == 44 then -- ',' + pos = pos + 1 + elseif c == 125 then -- '}' + pos = pos + 1 + return obj + else + return err("expected ',' or '}' in object") + end + end + return err("unterminated object") + end + + parse_value = function() + skip_ws() + if pos > len then return err("unexpected end of input") end + local c = text:byte(pos) + if c == 34 then return parse_string() + elseif c == 123 then return parse_object() + elseif c == 91 then return parse_array() + elseif c == 116 or c == 102 or c == 110 then return parse_literal() + elseif c == 45 or (c >= 48 and c <= 57) then return parse_number() + else return err("unexpected character") end + end + + skip_ws() + local result, e = parse_value() + if e then return nil, e end + skip_ws() + if pos <= len then + return nil, string.format( + "json: trailing data at offset %d", pos) + end + return result +end + +M._json_decode = json_decode -- exposed for tests + +-- ===================================================================== +-- Manifest reading +-- ===================================================================== + +-- Reads a manifest.lib or manifest.module file from an absolute path. +local function read_manifest(path) + local f, err = io.open(path, "rb") + if not f then return nil, err end + local content = f:read("*a") + f:close() + local m, jerr = json_decode(content) + if not m then return nil, jerr end + return m +end + +-- ===================================================================== +-- Path / slug helpers +-- ===================================================================== + +-- Convert "lib-core.maps" into "lib-core/maps" (filesystem-relative +-- under /libs/). +local function id_to_path(lib_id) + return (lib_id:gsub("%.", "/")) +end +M._id_to_path = id_to_path + +-- Locate a lib's manifest under /libs//. +local function lib_manifest_path(install_root, lib_id) + return install_root .. "/libs/" .. id_to_path(lib_id) .. "/manifest.lib" +end + +-- Gitea-slug derivation. Default: "sporel-" + lib-id verbatim. +-- +-- Slice-4 finding: Gitea rejects repo names ending in ".git" (the suffix +-- is reserved by the git protocol). For libs whose ID ends in ".git" +-- (currently: `lib-core.git`), the actual slug appends "-lib". Generic +-- rule so future ".git"-suffixed libs (none planned) Just Work. +local function gitea_slug_for(lib_id) + if lib_id:sub(-4) == ".git" then + return "sporel-" .. lib_id .. "-lib" + end + return "sporel-" .. lib_id +end +M._gitea_slug_for = gitea_slug_for + +-- ===================================================================== +-- Closure walk +-- ===================================================================== + +-- Walk the transitive dep closure starting from a module-manifest path. +-- Each closure entry records the (source, lib_id, version) pin that the +-- closure walk observed; multi-pin entries for the same lib_id are kept +-- intact so detect_conflicts() can group them. +local function walk_closure(module_manifest_path, install_root) + local closure = {} + local visited = {} + + local root, err = read_manifest(module_manifest_path) + if not root then return nil, err end + + local queue = {} + for _, d in ipairs(root.deps or {}) do + table.insert(queue, { source = root.id, dep = d }) + end + + while #queue > 0 do + local entry = table.remove(queue, 1) + local d = entry.dep + table.insert(closure, { + source = entry.source, + lib_id = d.id, + version = d.version, + }) + if not visited[d.id] then + visited[d.id] = true + local m = read_manifest(lib_manifest_path(install_root, d.id)) + if m and m.deps then + for _, sub in ipairs(m.deps) do + table.insert(queue, { source = m.id, dep = sub }) + end + end + -- If m is nil, the lib isn't installed yet — closure walk + -- ignores that (we still record the pin from the parent + -- so the state check downstream can clone the lib). + end + end + return closure +end +M._walk_closure = walk_closure + +-- ===================================================================== +-- Conflict detection +-- ===================================================================== + +-- Returns a list of conflict-entries: {lib_id, pins=[{source, version}]}. +-- A conflict exists when two parents pin the same lib to different +-- versions. +local function detect_conflicts(closure) + local by_lib = {} + for _, e in ipairs(closure) do + by_lib[e.lib_id] = by_lib[e.lib_id] or {} + table.insert(by_lib[e.lib_id], { + source = e.source, version = e.version, + }) + end + local conflicts = {} + for lib_id, pins in pairs(by_lib) do + local first = pins[1].version + local mismatched = false + for i = 2, #pins do + if pins[i].version ~= first then + mismatched = true + break + end + end + if mismatched then + table.insert(conflicts, { lib_id = lib_id, pins = pins }) + end + end + return conflicts +end +M._detect_conflicts = detect_conflicts + +-- ===================================================================== +-- Per-lib state check (cases A-E from spec §5) +-- ===================================================================== + +-- Cases (from sporel-meta/docs/superpowers/specs/2026-05-30-dep-fetcher- +-- architecture.md §5): +-- A — missing : lib dir does not exist → clone @ pinned-tag +-- B — clean : repo present, describe == v → OK +-- C — dirty : commits-past-tag or working-tree dirty → warn +-- D — wrong-tag : describe != pinned-tag → silent checkout +-- E — broken : describe fails entirely → error +local function run_state_check(unique_closure, install_root, + gitea_base, warnings, errors) + local git = require("lib-core.git") + + for _, e in ipairs(unique_closure) do + local lib_dir = install_root .. "/libs/" .. id_to_path(e.lib_id) + local url = gitea_base .. "/" .. gitea_slug_for(e.lib_id) .. ".git" + local pinned = "v" .. e.version + + if not git.is_repo(lib_dir) then + -- Case A: clone @ pinned-tag. + local ok, gerr = git.clone(url, lib_dir, pinned) + if not ok then + table.insert(errors, { + lib_id = e.lib_id, + kind = "net-fail", + message = tostring(gerr), + }) + end + else + local desc, derr = git.describe(lib_dir) + if not desc then + -- Case E: broken. + table.insert(errors, { + lib_id = e.lib_id, + kind = "describe-fail", + message = tostring(derr), + }) + elseif desc == pinned then + -- Case B: clean exact match. + else + -- Distinguish C (dirty) from D (wrong-tag). "dirty" + -- means describe is "--g[-dirty]" where + -- matches the pinned version (commits past + -- pinned-tag and/or working-tree dirty). + local pin_escaped = pinned:gsub("([%.%-%+])", "%%%1") + local is_dirty_past_tag = desc:match( + "^" .. pin_escaped .. "%-%d+%-g") ~= nil + local is_workdir_dirty = desc:sub(-6) == "-dirty" + if is_dirty_past_tag or is_workdir_dirty then + -- Case C: dirty — warn, do nothing. + table.insert(warnings, { + lib_id = e.lib_id, + kind = "dirty", + describe = desc, + }) + else + -- Case D: at a different tag → silent checkout. + local ok, gerr = git.checkout(lib_dir, pinned) + if not ok then + table.insert(errors, { + lib_id = e.lib_id, + kind = "checkout-fail", + message = tostring(gerr), + }) + end + end + end + end + end +end + +-- ===================================================================== +-- Public API +-- ===================================================================== + +-- ensure_for_module_at — full integrity check. +-- module_manifest_path : absolute path to manifest.module +-- opts: +-- install_root : root of /libs/ — defaults to env +-- SPOREL_INSTALL_ROOT (slice 6: engine.install_root()). +-- check_only : skip per-lib git operations entirely +-- (used by closure-shape unit tests). +-- gitea_base : override default base URL. +function M.ensure_for_module_at(module_manifest_path, opts) + opts = opts or {} + local install_root = opts.install_root + or os.getenv("SPOREL_INSTALL_ROOT") + if not install_root then + return { + ok = false, conflicts = {}, warnings = {}, + errors = {{ + lib_id = "", + kind = "config", + message = "no install_root: pass opts.install_root or " .. + "set SPOREL_INSTALL_ROOT (slice 6 will add " .. + "engine.install_root() binding)", + }}, + closure = {}, + } + end + + local closure, werr = walk_closure(module_manifest_path, install_root) + if not closure then + return { + ok = false, conflicts = {}, warnings = {}, + errors = {{ + lib_id = "", + kind = "manifest-read", + message = tostring(werr), + }}, + closure = {}, + } + end + + local conflicts = detect_conflicts(closure) + if #conflicts > 0 then + return { + ok = false, conflicts = conflicts, + warnings = {}, errors = {}, closure = closure, + } + end + + if opts.check_only then + return { + ok = true, conflicts = {}, warnings = {}, + errors = {}, closure = closure, + } + end + + -- Deduplicate by lib_id — conflict-free closure means same pin + -- everywhere, so each lib needs exactly one state-check pass. + local seen, unique = {}, {} + for _, e in ipairs(closure) do + if not seen[e.lib_id] then + seen[e.lib_id] = true + table.insert(unique, e) + end + end + + local gitea_base = opts.gitea_base + or "https://git.davoryn.de/sporel" + + local warnings, errors = {}, {} + run_state_check(unique, install_root, gitea_base, warnings, errors) + + return { + ok = (#errors == 0), + conflicts = {}, + warnings = warnings, + errors = errors, + closure = closure, + } +end + +-- Convenience wrapper resolving the module-id via install layout. +-- Slice 6 will replace SPOREL_INSTALL_ROOT with engine.install_root(). +function M.ensure_for_module(module_id, opts) + opts = opts or {} + local install_root = opts.install_root + or os.getenv("SPOREL_INSTALL_ROOT") + if not install_root then + return { + ok = false, conflicts = {}, warnings = {}, + errors = {{ + lib_id = "", kind = "config", + message = "no install_root for ensure_for_module", + }}, + closure = {}, + } + end + local path = install_root .. "/modules/" .. module_id + .. "/manifest.module" + opts.install_root = install_root + return M.ensure_for_module_at(path, opts) +end + +return M diff --git a/manifest.lib b/manifest.lib new file mode 100644 index 0000000..b6b8be6 --- /dev/null +++ b/manifest.lib @@ -0,0 +1,8 @@ +{ + "id": "lib-management.dep-fetcher", + "version": "0.1.0", + "api_min": "0.1", + "deps": [ + {"id": "lib-core.git", "version": "0.1.0"} + ] +}