Files
sporel-lib-management.dep-f…/init.lua
Axel Meyer 1cab8610b1 lib-management.dep-fetcher v0.1.0: closure walk + conflict + state-check
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<X.Y.Z> 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.
2026-05-31 01:46:49 +02:00

548 lines
19 KiB
Lua

-- 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<X.Y.Z> 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 <install>/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 <install_root>/libs/<id-path>/.
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<X.Y.Z> → 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 "<tag>-<n>-g<sha>[-dirty]" where
-- <tag> 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 <staged>/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 = "<module>",
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 = "<module>",
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 = "<module>", 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