diff --git a/.omo/evidence/task-13-tofu-core.log b/.omo/evidence/task-13-tofu-core.log new file mode 100644 index 0000000..d897b08 --- /dev/null +++ b/.omo/evidence/task-13-tofu-core.log @@ -0,0 +1,152 @@ + Warning + Warning ## Warning for package tofu ## + Warning + Warning The following compiler flags have been specified in the package description + Warning file. They are handled by DUB and direct use in packages is discouraged. + Warning Alternatively, you can set the DFLAGS environment variable to pass custom flags + Warning to the compiler, or use one of the suggestions below: + Warning + Warning warningsAsErrors: Use "buildRequirements" to control the warning level + Warning + Generating test runner configuration 'tofu-test-application' for 'application' (executable). + Warning Excluding main source file src/main.d from test. + Starting Performing "unittest" build using /usr/bin/dmd for x86_64. + Up-to-date toml 1.0.0: target for configuration [library] is up to date. + Building tofu ~main: building configuration [tofu-test-application] +src/tofu/build.d(126,33): Error: `;` expected + import std.process : Config as ProcConfig; + ^ +src/tofu/build.d(243,59): Error: conflicting attribute `@safe` + string exitCode = "0", string argsFile = "") @safe { + ^ +Error /usr/bin/dmd failed with exit code 1. + Warning + Warning ## Warning for package tofu ## + Warning + Warning The following compiler flags have been specified in the package description + Warning file. They are handled by DUB and direct use in packages is discouraged. + Warning Alternatively, you can set the DFLAGS environment variable to pass custom flags + Warning to the compiler, or use one of the suggestions below: + Warning + Warning warningsAsErrors: Use "buildRequirements" to control the warning level + Warning + Generating test runner configuration 'tofu-test-application' for 'application' (executable). + Warning Excluding main source file src/main.d from test. + Starting Performing "unittest" build using /usr/bin/dmd for x86_64. + Up-to-date toml 1.0.0: target for configuration [library] is up to date. + Building tofu ~main: building configuration [tofu-test-application] + Linking tofu-test-application + Finished To force a rebuild of up-to-date targets, run again with --force + Running tofu-test-application +warn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2) +Warning: malformed TOML config at /tmp/tofu-test-config-bad-204774.toml: Invalid table key declaration (2:0) +Warning: invalid TOFU_DEFAULT_JOBS 'not-a-number', using default 1 +127.0.0.1 - - [08/Aug/2026 17:57:40] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 17:57:40] "GET /recipes/hello/package.lua HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 17:57:40] "GET /recipes/hello/build.sh HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 17:57:40] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 17:57:40] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 17:57:40] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 17:57:40] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 17:57:40] "GET /recipes/hello/package.lua HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 17:57:40] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 17:57:40] "GET /recipes/hello/build.sh HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 17:57:40] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 17:57:40] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 17:57:40] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 17:57:40] "GET /recipes/hello/package.lua HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 17:57:40] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 17:57:40] "GET /recipes/hello/build.sh HTTP/1.1" 404 - +warn skipping index entry with empty name +warn skipping index entry with empty name +warn skipping index entry 'badpool': invalid pool 'bad_pool_value' +11 modules passed unittests + . checking binary firefox ... + - binary firefox-2.1.0 satisfies firefox>=2.0 + . checking binary libfoo ... + - binary libfoo-1.9 does not satisfy libfoo>=2.0 + . checking binary noexist ... + . checking binary brokenpkg ... + . checking binary badpkg ... + . checking binary testpkg ... + - binary testpkg-2.1.0 satisfies testpkg +==> fetching recipe hello + . downloading hello.recipe + . downloading package.lua + . downloading build.sh + ok recipe hello downloaded +==> fetching recipe hello + . downloading hello.recipe +==> fetching recipe hello + . downloading hello.recipe + . downloading package.lua + - no package.lua for hello (optional manifest) + . downloading build.sh + - no build.sh for hello (optional build script) + . custom build system: fetching scripts/build.sh + ok recipe hello downloaded +==> fetching recipe hello + . downloading hello.recipe + . downloading package.lua + - no package.lua for hello (optional manifest) + . downloading build.sh + - no build.sh for hello (optional build script) + ok recipe hello downloaded +==> fetching recipe hello + . downloading hello.recipe + . fetching index http://127.0.0.1:33945//index.lua + ok index loaded: 3 packages + . fetching index http://127.0.0.1:33079//index.lua + . fetching index http://127.0.0.1:39107//index.lua + . fetching index http://127.0.0.1:58239//index.lua + . fetching index http://127.0.0.1:52265//index.lua + ok index loaded: 0 packages + . fetching index http://127.0.0.1:55027//index.lua + ok index loaded: 1 packages + . fetching index http://127.0.0.1:54741//index.lua + ok index loaded: 2 packages + . fetching index http://127.0.0.1:46869//index.lua + . fetching index http://127.0.0.1:58081//index.lua + ok index loaded: 1 packages + - binary libfoo-2.1 satisfies libfoo>=2.0 + - libfoo: binary 1.9 too old, building from recipe + - libfoo: binary 1.9 too old, building from recipe + - binary libbar-3.0 satisfies libbar (unconstrained) + - libbaz: no binary available, building from recipe + - binary libfoo-1.5 satisfies libfoo>=1.0 + - libfoo: binary 1.5 too old, building from recipe + - binary parentA-1.5 satisfies parentA (unconstrained) + - binary parentB-1.5 satisfies parentB (unconstrained) +==> generating build plan + - + C (recipe) + - + A (recipe) + ok build plan: 2 packages +==> generating build plan + - recipe libfoo not in cache, re-fetching + - + libfoo (recipe) + ok build plan: 1 packages +==> generating build plan +==> generating build plan + - nothing to build (all binary) +==> generating build plan + - + mypkg (recipe) + ok build plan: 1 packages +==> generating build plan + - + libfoo (recipe) + - + mypkg (recipe) + ok build plan: 2 packages + Warning + Warning ## Warning for package tofu ## + Warning + Warning The following compiler flags have been specified in the package description + Warning file. They are handled by DUB and direct use in packages is discouraged. + Warning Alternatively, you can set the DFLAGS environment variable to pass custom flags + Warning to the compiler, or use one of the suggestions below: + Warning + Warning warningsAsErrors: Use "buildRequirements" to control the warning level + Warning + Starting Performing "debug" build using /usr/bin/dmd for x86_64. + Up-to-date toml 1.0.0: target for configuration [library] is up to date. + Building tofu ~main: building configuration [application] + Linking tofu + Finished To force a rebuild of up-to-date targets, run again with --force diff --git a/.omo/notepads/tofu-core/learnings.md b/.omo/notepads/tofu-core/learnings.md index a5e745a..2ed55ac 100644 --- a/.omo/notepads/tofu-core/learnings.md +++ b/.omo/notepads/tofu-core/learnings.md @@ -516,3 +516,57 @@ The sandbox test (test 2) creates a sentinel file, serves an index containing `o - `dub test` passes — all 11 modules (config, log, types, vercmp, http, index, fetch, cache, binary, deps, resolve) pass unittests. - No D LSP server configured for `.d` files — diagnostics verified via compiler. - Evidence logged to `.omo/evidence/task-12-tofu-core.log`. + +--- + +## Task 13 — `tofu.resolve.generateBuildPlan` (build plan from constrained tree) + +### Architecture +- New function `generateBuildPlan(constrained, tree, cfg, fetchRecipe = null)` in `tofu.resolve`. +- Input: `ConstrainedNode[]` from `constrainDepTree` (already in topological order, deps-first, root last). The `DepTree` parameter is preserved for future context but not used for logic — ordering comes from the constrained array. +- Output: `BuildPlan` with `Source.recipe` entries for every recipe-sourced node (binary nodes excluded). +- The `fetchRecipe` delegate (`scope string delegate(string) @safe`) is the testability seam. Default `null` means "no fetcher available" — missing recipes throw `FetchException`. +- Production wires: `delegate (string name) @safe { return tofu.fetch.fetchRecipe(name, cfg); }`. + +### Algorithm +1. Iterate constrained nodes in order (preserves topological ordering). +2. Skip nodes with `source == DepSource.binary` — Zeta handles binary deps. +3. For recipe nodes: compute cache path via `cfg.recipesCacheDir(name) ~ "/" ~ name ~ ".recipe"`. +4. Check `std.file.exists(path)` via `@trusted` wrapper. +5. Missing → if `fetchRecipe` delegate provided, call it; else throw `FetchException("recipe not cached and no fetcher provided")`. +6. Add to `BuildPlan` via `plan.add(name, recipePath, Source.recipe)`. +7. Empty plan → log `"nothing to build (all binary)"`. +8. Non-empty → log `"build plan: N packages"` via `logOk`. + +### `@safe` / `@trusted` architecture +- `generateBuildPlan` is `@safe`. +- Only `std.file.exists` requires `@trusted` wrapper — inline lambda `() @trusted { return exists(path); }()`. +- Follows existing patterns from `fetch.d`, `cache.d`, `index.d`. + +### FetchException reuse +- Uses `tofu.fetch.FetchException` (imported via `import tofu.fetch;`). No new exception class needed — the message `"recipe not cached and no fetcher provided"` is distinct and searchable. + +### New imports in resolve.d +- `import tofu.config;` — for `Config` type (parameter in function signature). +- `import tofu.fetch;` — for `FetchException`. +- `import std.file : exists;` — for recipe cache existence check. +- `import tofu.log;` already had `logInfo`; now also uses `logStep`, `logOk`. + +### Test strategy (6 new unittests, numbered 9–14) +- Reused the temp-dir + `scope(exit)` cleanup pattern from other modules. +- `version(unittest)` block with `@trusted` helpers: `createCachedRecipe(dir, name)`, `testTempDir(suffix)`, `testRmdir(path)`, `testConfig(cacheDir)`. +- Test (9): [C(recipe), B(binary), A(recipe)] → plan [C, A] — B excluded, order preserved. +- Test (10): missing cache + fetch delegate → fetch called, returned path used in plan. +- Test (11): missing cache + no fetch delegate → FetchException thrown. +- Test (12): all binary → empty plan. +- Test (13): root included even when dep is binary. +- Test (14): recipe files exist in cache → fetch delegate NOT called (delegate throws assert on invocation). + +### `scope` on constrained parameter +- `scope const ConstrainedNode[] constrained` — DMD 2.112 requires `scope` on array/class reference parameters for `@safe` inference when the function does not escape them. Same pattern as `constrainDepTree` (which uses `scope const PackageIndex[] index`). + +### Build verified +- `dub test` passes — all 11 modules, including 6 new generateBuildPlan unittests. +- `dub build` passes with `warningsAsErrors`. +- Pre-existing breakage in `build.d` (parallel task 15 artefact) excluded via `.skip` rename for testing — NOT caused by task 13. +- Evidence logged to `.omo/evidence/task-13-tofu-core.log`. diff --git a/src/tofu/resolve.d b/src/tofu/resolve.d index 361faf5..68751a3 100644 --- a/src/tofu/resolve.d +++ b/src/tofu/resolve.d @@ -19,7 +19,10 @@ module tofu.resolve; import tofu.types; // DepConstraint, DepOp, PackageIndex, Pool, BinaryCheckResult import tofu.deps; // DepTree, DepNode -import tofu.log; // logInfo +import tofu.config; // Config +import tofu.fetch; // FetchException +import tofu.log; // logInfo, logStep, logOk +import std.file : exists; import std.string : indexOf; // ──────────────────────────────────────────────────────────── @@ -226,6 +229,80 @@ ConstrainedNode[] constrainDepTree(DepTree tree, scope const PackageIndex[] inde return result; } +// ──────────────────────────────────────────────────────────── +// Build Plan Generation +// ──────────────────────────────────────────────────────────── + +/// Generate a BuildPlan from a constrained dependency tree. +/// +/// Iterates over the constrained nodes in their existing topological +/// order (deps-first, root last). For each node whose source is +/// recipe (excluding binary-satisfied deps), verifies the recipe +/// file exists in the local cache and adds it to the build plan. +/// +/// If a recipe file is missing from cache: +/// - With a `fetchRecipe` delegate provided: calls it to re-fetch. +/// - Without a delegate: throws `FetchException`. +/// +/// If all constrained nodes are binary-satisfied, returns an empty +/// BuildPlan — the caller should print "nothing to build". +/// +/// Params: +/// constrained = Source-annotated nodes from constrainDepTree +/// (already in topological / deps-first order). +/// tree = The original dependency tree (preserved for context). +/// cfg = Runtime configuration (used for cache paths). +/// fetchRecipe = Optional delegate to re-fetch a recipe if missing +/// from cache. Takes package name, returns recipe path. +/// Tests inject mocks; production wires a lambda over +/// tofu.fetch.fetchRecipe. +@safe +BuildPlan generateBuildPlan(scope const ConstrainedNode[] constrained, + scope const DepTree tree, scope const Config cfg, + scope string delegate(string) @safe fetchRecipe = null) +{ + logStep("generating build plan"); + + BuildPlan plan; + + foreach (node; constrained) + { + // Exclude binary-satisfied deps — Zeta handles those. + if (node.source == DepSource.binary) + continue; + + // Build the expected cache path for the recipe file. + string recipePath = cfg.recipesCacheDir(node.name) + ~ "/" ~ node.name ~ ".recipe"; + + // Verify the recipe exists in the local cache. + bool recipeExists = () @trusted { + return exists(recipePath); + }(); + + if (!recipeExists) + { + if (fetchRecipe is null) + { + throw new FetchException( + "recipe not cached and no fetcher provided"); + } + logInfo("recipe %s not in cache, re-fetching", node.name); + recipePath = fetchRecipe(node.name); + } + + logInfo(" + %s (recipe)", node.name); + plan.add(node.name, recipePath, Source.recipe); + } + + if (plan.isEmpty()) + logInfo("nothing to build (all binary)"); + else + logOk("build plan: %d packages", plan.entries.length); + + return plan; +} + // ──────────────────────────────────────────────────────────── // Unittests // ──────────────────────────────────────────────────────────── @@ -508,3 +585,267 @@ private @safe PackageIndex mkIndexEntry(string name, Pool pool) assert(result.length == 0, "empty tree → empty result"); } + +// ──────────────────────────────────────────────────────────── +// generateBuildPlan helpers (unittest-only) +// ──────────────────────────────────────────────────────────── + +version (unittest) +{ + import std.file : mkdirRecurse, write, rmdirRecurse, tempDir; + import std.conv : to; + import std.process : thisProcessID; + + /// Create a recipe file in a temp cache dir that matches + /// what `Config.recipesCacheDir` produces. + private @trusted void createCachedRecipe(string cacheDir, string name) + { + auto dir = cacheDir ~ "/recipes/" ~ name; + if (!exists(dir)) + mkdirRecurse(dir); + write(dir ~ "/" ~ name ~ ".recipe", "return {}"); + } + + /// Create a unique temp directory for test isolation. + private @trusted string testTempDir(string suffix) + { + auto dir = tempDir() ~ "/tofu-resolve-" ~ suffix ~ "-" + ~ thisProcessID.to!string; + if (exists(dir)) + rmdirRecurse(dir); + mkdirRecurse(dir); + return dir; + } + + /// Recursively remove a temp test directory. + private @trusted void testRmdir(string path) + { + try { rmdirRecurse(path); } catch (Exception) {} + } + + /// Build a minimal test Config pointing at a temp cache dir. + private @safe Config testConfig(string cacheDir) + { + Config cfg; + cfg.cacheDir = cacheDir; + return cfg; + } +} + +// ── Test (9): [C(recipe), B(binary), A(recipe)] → [C, A] ── +@safe unittest +{ + auto tmpDir = testTempDir("t9"); + scope (exit) testRmdir(tmpDir); + + createCachedRecipe(tmpDir, "C"); + createCachedRecipe(tmpDir, "A"); + + ConstrainedNode[] constrained; + { + ConstrainedNode cn; + cn.name = "C"; + cn.source = DepSource.recipe; + constrained ~= cn; + } + { + ConstrainedNode cn; + cn.name = "B"; + cn.source = DepSource.binary; + constrained ~= cn; + } + { + ConstrainedNode cn; + cn.name = "A"; + cn.source = DepSource.recipe; + constrained ~= cn; + } + + DepTree tree; + auto cfg = testConfig(tmpDir); + + auto plan = generateBuildPlan(constrained, tree, cfg); + + assert(plan.entries.length == 2, + "should contain C and A, got " ~ plan.entries.length.to!string); + assert(plan.entries[0].name == "C", "C should be first (dep before root)"); + assert(plan.entries[0].source == Source.recipe); + assert(plan.entries[1].name == "A", "A is root, should be last"); + assert(plan.entries[1].source == Source.recipe); +} + +// ── Test (10): missing cache + fetch delegate → fetch called ── +@safe unittest +{ + auto tmpDir = testTempDir("t10"); + scope (exit) testRmdir(tmpDir); + + // No recipe file on disk — must trigger fetch delegate. + + ConstrainedNode[] constrained; + { + ConstrainedNode cn; + cn.name = "libfoo"; + cn.source = DepSource.recipe; + constrained ~= cn; + } + + DepTree tree; + auto cfg = testConfig(tmpDir); + + bool fetchCalled = false; + string fetchResult; + scope fetchRecipe = delegate (string name) @safe { + fetchCalled = true; + fetchResult = tmpDir ~ "/re-fetched/" ~ name ~ ".recipe"; + return fetchResult; + }; + + auto plan = generateBuildPlan(constrained, tree, cfg, fetchRecipe); + + assert(fetchCalled, "fetch delegate should have been called"); + assert(plan.entries.length == 1); + assert(plan.entries[0].name == "libfoo"); + assert(plan.entries[0].recipePath == fetchResult, + "plan should use the path returned by the fetch delegate"); +} + +// ── Test (11): missing cache + no fetch delegate → FetchException ── +@safe unittest +{ + auto tmpDir = testTempDir("t11"); + scope (exit) testRmdir(tmpDir); + + // No recipe file on disk, no delegate → exception. + + ConstrainedNode[] constrained; + { + ConstrainedNode cn; + cn.name = "libfoo"; + cn.source = DepSource.recipe; + constrained ~= cn; + } + + DepTree tree; + auto cfg = testConfig(tmpDir); + + bool caught = false; + try + { + generateBuildPlan(constrained, tree, cfg); + assert(false, "expected FetchException"); + } + catch (FetchException e) + { + caught = true; + assert(e.msg.indexOf("not cached") >= 0, + "exception message should mention 'not cached', got: " ~ e.msg); + } + assert(caught, "should have thrown FetchException"); +} + +// ── Test (12): all binary → empty plan ── +@safe unittest +{ + auto tmpDir = testTempDir("t12"); + scope (exit) testRmdir(tmpDir); + + ConstrainedNode[] constrained; + { + ConstrainedNode cn; + cn.name = "B"; + cn.source = DepSource.binary; + constrained ~= cn; + } + { + ConstrainedNode cn; + cn.name = "A"; + cn.source = DepSource.binary; + constrained ~= cn; + } + + DepTree tree; + auto cfg = testConfig(tmpDir); + + auto plan = generateBuildPlan(constrained, tree, cfg); + + assert(plan.isEmpty(), "all-binary deps → empty build plan"); +} + +// ── Test (13): root included even when all deps are binary ── +@safe unittest +{ + auto tmpDir = testTempDir("t13"); + scope (exit) testRmdir(tmpDir); + + createCachedRecipe(tmpDir, "mypkg"); + + ConstrainedNode[] constrained; + { + ConstrainedNode cn; + cn.name = "dep"; + cn.source = DepSource.binary; + constrained ~= cn; + } + { + ConstrainedNode cn; + cn.name = "mypkg"; + cn.source = DepSource.recipe; + constrained ~= cn; + } + + DepTree tree; + auto cfg = testConfig(tmpDir); + + auto plan = generateBuildPlan(constrained, tree, cfg); + + assert(plan.entries.length == 1, + "root is always recipe — must appear even with binary-only deps"); + assert(plan.entries[0].name == "mypkg"); + assert(plan.entries[0].source == Source.recipe); +} + +// ── Test (14): recipe files already in cache → no fetch call ── +@safe unittest +{ + auto tmpDir = testTempDir("t14"); + scope (exit) testRmdir(tmpDir); + + createCachedRecipe(tmpDir, "libfoo"); + createCachedRecipe(tmpDir, "mypkg"); + + ConstrainedNode[] constrained; + { + ConstrainedNode cn; + cn.name = "libfoo"; + cn.source = DepSource.recipe; + constrained ~= cn; + } + { + ConstrainedNode cn; + cn.name = "mypkg"; + cn.source = DepSource.recipe; + constrained ~= cn; + } + + DepTree tree; + auto cfg = testConfig(tmpDir); + + // Fetch delegate that throws if called — must not be invoked. + scope fetchRecipe = delegate (string name) @safe { + assert(false, "fetchRecipe should NOT be called when recipe exists in cache"); + return ""; + }; + + auto plan = generateBuildPlan(constrained, tree, cfg, fetchRecipe); + + assert(plan.entries.length == 2); + assert(plan.entries[0].name == "libfoo"); + assert(plan.entries[0].recipePath == + tmpDir ~ "/recipes/libfoo/libfoo.recipe"); + assert(plan.entries[0].source == Source.recipe); + assert(plan.entries[1].name == "mypkg"); + assert(plan.entries[1].recipePath == + tmpDir ~ "/recipes/mypkg/mypkg.recipe"); + assert(plan.entries[1].source == Source.recipe); +}