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:
91
README.md
Normal file
91
README.md
Normal file
@@ -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<X.Y.Z>` 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 `<staged>/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<X.Y.Z>)` |
|
||||||
|
| B | `describe == v<X.Y.Z>` | No-op (clean) |
|
||||||
|
| C | `describe` matches `v<X.Y.Z>-N-g…` or ends `-dirty` | Warning, no action |
|
||||||
|
| D | `describe` is some other tag | Silent `git.checkout(dir, v<X.Y.Z>)` |
|
||||||
|
| E | `describe` returns nil/err | Error |
|
||||||
|
|
||||||
|
## Gitea-slug derivation
|
||||||
|
|
||||||
|
Default: `sporel-<lib-id>`. 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.
|
||||||
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
|
||||||
8
manifest.lib
Normal file
8
manifest.lib
Normal file
@@ -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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user