From 4047df286c9e3b7c086f30175bc420d1bfd0df1d Mon Sep 17 00:00:00 2001 From: Axel Meyer Date: Sun, 31 May 2026 14:08:57 +0200 Subject: [PATCH] dep-fetcher 0.1.2: multi-path resolution + accept untagged dev-state when manifest matches pin - resolve_libs_dirs / resolve_modules_dirs return arrays of candidate paths (priority: opts override > opts.install_root-derived > engine.libs_dirs() / modules_dirs() > SPOREL_*_DIR env > install_root-derived fallback). first_existing walks the array to find the candidate that actually contains the requested manifest, so dev-tree layouts with libs and modules in different roots work without forcing the caller to pre-flatten. - New case G: when describe fails because the repo has no tags at all (common during dev between tagged releases), accept the lib if its manifest.version matches the pin. Previously errored as "describe-fail" which made the pre-launch dep-check unusable in dev-tree runs. - Dep pin: lib-core.git 0.1.0 -> 0.2.0 (token-auth + set_token). --- init.lua | 178 +++++++++++++++++++++++++++++++++++++++++++-------- manifest.lib | 4 +- 2 files changed, 153 insertions(+), 29 deletions(-) diff --git a/init.lua b/init.lua index ca73e96..060a1aa 100644 --- a/init.lua +++ b/init.lua @@ -57,6 +57,85 @@ local function resolve_install_root(opts) return os.getenv("SPOREL_INSTALL_ROOT") end +-- Slice 6.1: libs and modules can live in many roots simultaneously +-- (multi-path engine discovery, dev-tree SPOREL_*_DIR overrides). Each +-- resolver returns an ARRAY of candidate paths (priority-ordered); +-- callers iterate to find the first that contains the requested file. +-- +-- Priority order: +-- 1. opts.libs_dir / opts.modules_dir explicit override (always wins) +-- 2. opts.install_root explicit override -> derive `/{libs,modules}` +-- (tests pass install_root pointing at a fixture; the engine +-- bindings would otherwise return the real engine paths, masking +-- the fixture) +-- 3. engine.{libs,modules}_dirs() — runtime discovery candidates +-- 4. SPOREL_{LIBS,MODULES}_DIR env var +-- 5. Auto-resolved install_root -> `/{libs,modules}` (last resort) +local function resolve_libs_dirs(opts, install_root) + local list = {} + if opts and opts.libs_dir then table.insert(list, opts.libs_dir) end + if opts and opts.install_root then + table.insert(list, opts.install_root .. "/libs") + end + if type(engine) == "table" + and type(engine.libs_dirs) == "function" then + local ok, dirs = pcall(engine.libs_dirs) + if ok and type(dirs) == "table" then + for _, d in ipairs(dirs) do + if type(d) == "string" and d ~= "" then + table.insert(list, d) + end + end + end + end + local env = os.getenv("SPOREL_LIBS_DIR") + if env and env ~= "" then table.insert(list, env) end + if install_root then table.insert(list, install_root .. "/libs") end + return list +end + +local function resolve_modules_dirs(opts, install_root) + local list = {} + if opts and opts.modules_dir then table.insert(list, opts.modules_dir) end + if opts and opts.install_root then + table.insert(list, opts.install_root .. "/modules") + end + if type(engine) == "table" + and type(engine.modules_dirs) == "function" then + local ok, dirs = pcall(engine.modules_dirs) + if ok and type(dirs) == "table" then + for _, d in ipairs(dirs) do + if type(d) == "string" and d ~= "" then + table.insert(list, d) + end + end + end + end + local env = os.getenv("SPOREL_MODULES_DIR") + if env and env ~= "" then table.insert(list, env) end + if install_root then table.insert(list, install_root .. "/modules") end + return list +end + +-- Helper: filesystem existence check via io.open. Used to pick the +-- first multi-path candidate that actually has the requested file. +local function file_exists(path) + local f = io.open(path, "rb") + if not f then return false end + f:close() + return true +end + +-- Given a list of candidate dirs and a relative file-path, return the +-- first / that exists, or nil if none. +local function first_existing(dirs, rel_path) + for _, d in ipairs(dirs) do + local p = d .. "/" .. rel_path + if file_exists(p) then return p, d end + end + return nil, nil +end + local function resolve_gitea_base(opts) -- 1. explicit override if opts and opts.gitea_base then return opts.gitea_base end @@ -73,6 +152,8 @@ local function resolve_gitea_base(opts) end M._resolve_install_root = resolve_install_root -- exposed for tests +M._resolve_libs_dirs = resolve_libs_dirs -- exposed for tests +M._resolve_modules_dirs = resolve_modules_dirs -- exposed for tests M._resolve_gitea_base = resolve_gitea_base -- exposed for tests -- ===================================================================== @@ -312,9 +393,12 @@ local function id_to_path(lib_id) 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" +-- Find a lib's manifest across multiple candidate libs_dirs. Returns +-- (full_path, owning_libs_dir) for the first candidate that exists, or +-- (nil, nil) if absent everywhere. +local function find_lib_manifest(libs_dirs, lib_id) + return first_existing(libs_dirs, + id_to_path(lib_id) .. "/manifest.lib") end -- Gitea-slug derivation. Default: "sporel-" + lib-id verbatim. @@ -339,7 +423,7 @@ M._gitea_slug_for = gitea_slug_for -- 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 function walk_closure(module_manifest_path, libs_dirs) local closure = {} local visited = {} @@ -361,7 +445,8 @@ local function walk_closure(module_manifest_path, install_root) }) if not visited[d.id] then visited[d.id] = true - local m = read_manifest(lib_manifest_path(install_root, d.id)) + local mpath = find_lib_manifest(libs_dirs, d.id) + local m = mpath and read_manifest(mpath) or nil if m and m.deps then for _, sub in ipairs(m.deps) do table.insert(queue, { source = m.id, dep = sub }) @@ -431,12 +516,24 @@ local function manifest_version_matches(lib_dir, expected_version) return m ~= nil and m.version == expected_version end -local function run_state_check(unique_closure, install_root, +local function run_state_check(unique_closure, libs_dirs, 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) + -- Resolve the lib's home: first libs_dir that contains its + -- manifest. Falls back to the first candidate so clone targets + -- a deterministic location when the lib is missing entirely. + local _, owning_dir = find_lib_manifest(libs_dirs, e.lib_id) + local libs_dir = owning_dir or libs_dirs[1] + if not libs_dir then + table.insert(errors, { + lib_id = e.lib_id, kind = "config", + message = "no libs_dir candidate for state-check", + }) + goto continue + end + local lib_dir = libs_dir .. "/" .. id_to_path(e.lib_id) local url = gitea_base .. "/" .. gitea_slug_for(e.lib_id) .. ".git" local pinned = "v" .. e.version @@ -460,12 +557,22 @@ local function run_state_check(unique_closure, install_root, 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), - }) + -- describe failed. Two sub-cases: + -- G — untagged dev-state: repo has no tags at all (common + -- when working on master between tagged releases). + -- If manifest.version matches pin, the install IS at + -- the right code regardless of git refs → accept. + -- E — genuinely broken: manifest mismatch + no tags → + -- caller has no way to recover, surface error. + if manifest_version_matches(lib_dir, e.version) then + -- Case G: untagged but manifest authoritative — accept. + else + table.insert(errors, { + lib_id = e.lib_id, + kind = "describe-fail", + message = tostring(derr), + }) + end elseif desc == pinned then -- Case B: clean exact match. else @@ -497,6 +604,7 @@ local function run_state_check(unique_closure, install_root, end end end + ::continue:: end end @@ -520,21 +628,23 @@ end function M.ensure_for_module_at(module_manifest_path, opts) opts = opts or {} local install_root = resolve_install_root(opts) - if not install_root then + local libs_dirs = resolve_libs_dirs(opts, install_root) + if #libs_dirs == 0 then return { ok = false, conflicts = {}, warnings = {}, errors = {{ lib_id = "", kind = "config", - message = "no install_root: pass opts.install_root, " .. - "set SPOREL_INSTALL_ROOT, or run under an engine " .. - "that provides engine.install_root()", + message = "no libs_dir candidates: pass opts.libs_dir, set " .. + "SPOREL_LIBS_DIR / SPOREL_INSTALL_ROOT, or run " .. + "under an engine that provides engine.libs_dirs() / " .. + "engine.install_root()", }}, closure = {}, } end - local closure, werr = walk_closure(module_manifest_path, install_root) + local closure, werr = walk_closure(module_manifest_path, libs_dirs) if not closure then return { ok = false, conflicts = {}, warnings = {}, @@ -575,7 +685,7 @@ function M.ensure_for_module_at(module_manifest_path, opts) local gitea_base = resolve_gitea_base(opts) local warnings, errors = {}, {} - run_state_check(unique, install_root, gitea_base, warnings, errors) + run_state_check(unique, libs_dirs, gitea_base, warnings, errors) return { ok = (#errors == 0), @@ -592,22 +702,36 @@ end function M.ensure_for_module(module_id, opts) opts = opts or {} local install_root = resolve_install_root(opts) - if not install_root then + local modules_dirs = resolve_modules_dirs(opts, install_root) + if #modules_dirs == 0 then return { ok = false, conflicts = {}, warnings = {}, errors = {{ lib_id = "", kind = "config", - message = "no install_root for ensure_for_module: " .. - "pass opts.install_root, set SPOREL_INSTALL_ROOT, " .. - "or run under an engine providing " .. - "engine.install_root()", + message = "no modules_dir candidates for ensure_for_module: " .. + "pass opts.modules_dir, set SPOREL_MODULES_DIR / " .. + "SPOREL_INSTALL_ROOT, or run under an engine " .. + "providing engine.modules_dirs() / engine.install_root()", }}, closure = {}, } end - local path = install_root .. "/modules/" .. module_id - .. "/manifest.module" - opts.install_root = install_root + -- Find the first candidate that actually has this module. + local path = first_existing(modules_dirs, + module_id .. "/manifest.module") + if not path then + return { + ok = false, conflicts = {}, warnings = {}, + errors = {{ + lib_id = "", kind = "manifest-read", + message = "module '" .. module_id .. + "' manifest not found in any of " .. + tostring(#modules_dirs) .. " candidate dirs", + }}, + closure = {}, + } + end + if install_root then opts.install_root = install_root end return M.ensure_for_module_at(path, opts) end diff --git a/manifest.lib b/manifest.lib index 487fc26..7502cab 100644 --- a/manifest.lib +++ b/manifest.lib @@ -1,8 +1,8 @@ { "id": "lib-management.dep-fetcher", - "version": "0.1.1", + "version": "0.1.2", "api_min": "0.1", "deps": [ - {"id": "lib-core.git", "version": "0.1.0"} + {"id": "lib-core.git", "version": "0.2.0"} ] }