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.
This commit is contained in:
547
init.lua
Normal file
547
init.lua
Normal file
@@ -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<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
|
||||
Reference in New Issue
Block a user