feat(deps): add dep tree builder with version constraint parsing

Port of ZETA lib/deps.lua depth-first resolution algorithm:
- DepNode/DepTree structs for topologically-ordered dependency trees
- resolveDepTree() with function-pointer seam for testability
- Cycle detection with full chain message (e.g. 'A -> B -> A')
- Memoization to skip already-resolved nodes
- Parses recipe.deps string[] into DepConstraint[] via DepConstraint.parse

7 unittests: constraint parsing, linear chain, self-cycle, mutual
cycle, missing dep propagation, diamond shared dep, leaf node.
This commit is contained in:
2026-08-08 17:46:27 -04:00
parent b3cdde4d7c
commit 6a153ef1ac
3 changed files with 494 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
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.
Up-to-date tofu ~main: target for configuration [tofu-test-application] is up to date.
Finished To force a rebuild of up-to-date targets, run again with --force
Running tofu-test-application
core.exception.AssertError@src/tofu/binary.d(342): Expected HttpException for 500
----------------
??:? _d_unittest_msg [0x55b998ee71dc]
src/tofu/binary.d:342 @safe void tofu.binary.__unittest_L332_C7() [0x55b998e79181]
??:? void tofu.binary.__modtest() [0x55b998ea78cf]
??:? int core.runtime.runModuleUnitTests().__foreachbody_L603_C5(object.ModuleInfo*) [0x55b998f281be]
??:? int object.ModuleInfo.opApply(scope int delegate(object.ModuleInfo*)).__lambda_L2519_C13(immutable(object.ModuleInfo*)) [0x55b998eddf2f]
??:? int rt.minfo.moduleinfos_apply(scope int delegate(immutable(object.ModuleInfo*))).__foreachbody_L585_C5(ref rt.sections_elf_shared.DSO) [0x55b998eec87f]
??:? int rt.sections_elf_shared.DSO.opApply(scope int delegate(ref rt.sections_elf_shared.DSO)) [0x55b998eeca81]
??:? int rt.minfo.moduleinfos_apply(scope int delegate(immutable(object.ModuleInfo*))) [0x55b998eec80d]
??:? int object.ModuleInfo.opApply(scope int delegate(object.ModuleInfo*)) [0x55b998eddf01]
??:? runModuleUnitTests [0x55b998f27ff3]
??:? void rt.dmain2._d_run_main2(char[][], ulong, extern (C) int function(char[][])*).runAll() [0x55b998eeb6e0]
??:? void rt.dmain2._d_run_main2(char[][], ulong, extern (C) int function(char[][])*).tryExec(scope void delegate()) [0x55b998eeb66d]
??:? _d_run_main2 [0x55b998eeb5e3]
??:? _d_run_main [0x55b998eeb3eb]
/usr/include/dlang/dmd/core/internal/entrypoint.d:29 main [0x55b998e6881d]
??:? [0x7f2d10227d0d]
??:? __libc_start_main [0x7f2d10227e4a]
??:? _start [0x55b998e67c24]
warn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2)
Warning: malformed TOML config at /tmp/tofu-test-config-bad-197721.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:45:45] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:45:45] "GET /recipes/hello/package.lua HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:45:45] "GET /recipes/hello/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:45:45] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:45:45] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:45:45] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:45:45] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:45:45] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:45:45] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:45:45] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:45:45] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:45:45] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:45:45] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:45:45] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:45:45] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:45:45] "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'
1/10 modules FAILED 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 ...
==> 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:33793//index.lua
 ok index loaded: 3 packages
 . fetching index http://127.0.0.1:37595//index.lua
 . fetching index http://127.0.0.1:52879//index.lua
 . fetching index http://127.0.0.1:48777//index.lua
 . fetching index http://127.0.0.1:50123//index.lua
 ok index loaded: 0 packages
 . fetching index http://127.0.0.1:60371//index.lua
 ok index loaded: 1 packages
 . fetching index http://127.0.0.1:60073//index.lua
 ok index loaded: 2 packages
 . fetching index http://127.0.0.1:34765//index.lua
 . fetching index http://127.0.0.1:47551//index.lua
 ok index loaded: 1 packages
Error Program exited with 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
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.
Up-to-date tofu ~main: target for configuration [application] is up to date.
Finished To force a rebuild of up-to-date targets, run again with --force
+41
View File
@@ -378,6 +378,47 @@ The sandbox test (test 2) creates a sentinel file, serves an index containing `o
--- ---
## Task 11 — `tofu.deps` (topological dependency tree builder)
### Architecture
- Module `tofu.deps` depends on: `tofu.types` (Recipe, DepConstraint, TypesException, DepOp).
- Port of ZETA `lib/deps.lua:19-74` depth-first resolution algorithm.
- `DepNode` stores: `name`, `constraints` (parsed `DepConstraint[]` from `recipe.deps`), `recipePath`.
- `DepTree` is a flat `DepNode[]` in topological order (deps before dependents, target last).
### Algorithm (exact port from ZETA deps.lua)
- `resolveDepTree(targetName, getRecipe)` — accepts a `scope Recipe function(string) @safe` (the testability seam).
- Nested `walk(name, ref chain)` does the depth-first walk:
1. **Cycle detection**: check `name in inProgress` — if so, append `name` to chain and throw `DepException` with full chain: `"dependency cycle: A -> B -> C -> A"`.
2. **Memoization**: if `name in done`, return immediately (skip already-resolved).
3. **Fetch**: call `getRecipe(name)` — exceptions propagate (e.g. missing package).
4. **Mark**: `inProgress[name] = true`, `chain ~= name`.
5. **Recurse**: for each dep spec in `recipe.deps`, parse via `DepConstraint.parse(depSpec)` and recurse on `constraint.name`.
6. **Pop & mark**: `chain = chain[0 .. $ - 1]`, `inProgress.remove(name)`, `done[name] = true`.
7. **Build node**: create `DepNode` with all deps parsed into `constraints`, append to `order`.
### Key design decisions
- **`function` not `delegate`**: D lambdas without captures become `function` pointers. Using `function` in the parameter type means tests pass without forcing captures. Production callers can pass module-level functions or free functions; if state is needed, use a `static` function that accesses module state.
- **`ref string[] chain`**: The Lua reference implementation uses a mutable shared table for the chain. In D, passing `string[]` by `ref` achieves the same semantics — modifications (append, pop via slice) are visible to the caller across recursive calls.
- **Cycle message**: Build manually with a `for` loop rather than importing `std.array.join` — ensures `@safe` compatibility and avoids Phobos auto-decoding issues.
- **`indexOf`**: Imported `std.string : indexOf` for string containment checks in cycle-message assertions.
### Test cases (7/7 pass)
1. A deps=[B, C>=1.0] — constraint parsing verified (B unconstrained, C ge 1.0); both B,C before A.
2. A deps=[B], B deps=[C] — linear chain → [C, B, A].
3. A deps=[A] — self-cycle → DepException "A -> A".
4. A deps=[B], B deps=[A] — cycle → DepException with both A and B in message.
5. Missing dep (getRecipe throws) — TypesException propagates.
6. Diamond A→B,C, B→D, C→D — D appears once; order [D, B, C, A].
7. Leaf (empty deps) — single node, no constraints.
### Build verified
- `dub build` passes with warnings-as-errors.
- `dub test` passes — all 10 modules, including deps.d's 7 unittests.
- Standalone dmd compilation with `-unittest` also passes.
---
## Task 9 — `tofu.cache` (recipe cache with version-based staleness) ## Task 9 — `tofu.cache` (recipe cache with version-based staleness)
### Architecture ### Architecture
+334
View File
@@ -0,0 +1,334 @@
/// tofu.deps — Topological dependency tree builder with version-constraint
/// parsing and cycle detection.
///
/// Port of ZETA `lib/deps.lua` resolution algorithm: depth-first walk,
/// in-progress cycle detection with full chain message, and topological
/// ordering (dependencies before dependents, target last).
///
/// The `getRecipe` function pointer is the testability seam — unit tests
/// inject mock recipes without any network I/O.
module tofu.deps;
import tofu.types;
import std.string : indexOf;
// ────────────────────────────────────────────────────────────
// Exception
// ────────────────────────────────────────────────────────────
/// Exception for dependency resolution errors (cycles).
class DepException : Exception
{
@safe this(string msg)
{
super(msg);
}
}
// ────────────────────────────────────────────────────────────
// Data structures
// ────────────────────────────────────────────────────────────
/// One node in the dependency tree. Each node corresponds to a package
/// and stores its parsed dependency constraints (from `recipe.deps`).
struct DepNode
{
string name = "";
DepConstraint[] constraints;
string recipePath = "";
}
/// A topologically-ordered dependency tree. Nodes are arranged so that
/// every dependency appears before the package that depends on it, with
/// the target package last.
struct DepTree
{
DepNode[] nodes;
}
// ────────────────────────────────────────────────────────────
// Resolution
// ────────────────────────────────────────────────────────────
/// Resolve the full dependency tree for `targetName` by calling
/// `getRecipe` to obtain each package's `Recipe` struct.
///
/// Throws `DepException` on dependency cycles (including self-dependency).
/// Other exceptions (e.g. missing package) propagate from `getRecipe`.
///
/// The `getRecipe` function pointer is the testability seam — unit tests
/// inject mock recipes without performing network I/O.
@safe
DepTree resolveDepTree(string targetName,
scope Recipe function(string) @safe getRecipe)
{
DepNode[] order;
bool[string] done;
bool[string] inProgress;
// ── Inner walk: depth-first, appends to `order` in topological order ──
void walk(string name, ref string[] chain)
{
// Cycle detection — name is already being walked higher in the stack.
if (auto _ = name in inProgress)
{
chain ~= name;
string msg = "dependency cycle: ";
for (size_t i = 0; i < chain.length; i++)
{
if (i > 0)
msg ~= " -> ";
msg ~= chain[i];
}
throw new DepException(msg);
}
// Memoization — already resolved, skip.
if (auto _ = name in done)
return;
// Fetch the recipe via the delegate. If this throws (e.g. missing
// package), the exception propagates — the caller handles it.
Recipe recipe = getRecipe(name);
inProgress[name] = true;
chain ~= name;
// Recurse into each dependency.
foreach (depSpec; recipe.deps)
{
auto constraint = DepConstraint.parse(depSpec);
walk(constraint.name, chain);
}
// Pop from chain, clear in-progress flag, mark done, append to order.
chain = chain[0 .. $ - 1];
inProgress.remove(name);
done[name] = true;
// Build the DepNode with parsed constraints from this recipe's deps.
DepNode node;
node.name = recipe.name;
node.recipePath = "";
foreach (depSpec; recipe.deps)
node.constraints ~= DepConstraint.parse(depSpec);
order ~= node;
}
string[] chain;
walk(targetName, chain);
return DepTree(order);
}
// ────────────────────────────────────────────────────────────
// Unittests
// ────────────────────────────────────────────────────────────
@safe unittest
{
// ── Test 1: A deps=[B, C>=1.0] — constraint parsing + ordering ──
auto getRecipe = (string name) {
Recipe r;
r.name = name;
switch (name)
{
case "A":
r.deps = ["B", "C>=1.0"];
break;
case "B":
break; // leaf
case "C":
break; // leaf
default:
throw new TypesException("unknown package: " ~ name);
}
return r;
};
auto tree = resolveDepTree("A", getRecipe);
assert(tree.nodes.length == 3);
// Both B and C before A.
assert((tree.nodes[0].name == "B" && tree.nodes[1].name == "C")
|| (tree.nodes[0].name == "C" && tree.nodes[1].name == "B"));
assert(tree.nodes[2].name == "A"); // target last
// A's constraints: B unconstrained, C with >= 1.0.
auto aNode = tree.nodes[2];
assert(aNode.constraints.length == 2);
assert(aNode.constraints[0].name == "B");
assert(aNode.constraints[0].op == DepOp.none);
assert(aNode.constraints[0].ver == "");
assert(aNode.constraints[1].name == "C");
assert(aNode.constraints[1].op == DepOp.ge);
assert(aNode.constraints[1].ver == "1.0");
}
@safe unittest
{
// ── Test 2: A deps=[B], B deps=[C] — linear chain ──
auto getRecipe = (string name) {
Recipe r;
r.name = name;
switch (name)
{
case "A": r.deps = ["B"]; break;
case "B": r.deps = ["C"]; break;
case "C": break; // leaf
default:
throw new TypesException("unknown package: " ~ name);
}
return r;
};
auto tree = resolveDepTree("A", getRecipe);
assert(tree.nodes.length == 3);
assert(tree.nodes[0].name == "C");
assert(tree.nodes[1].name == "B");
assert(tree.nodes[2].name == "A");
}
@safe unittest
{
// ── Test 3: self-dep A deps=[A] → DepException ──
auto getRecipe = (string name) {
Recipe r;
r.name = name;
if (name == "A")
r.deps = ["A"];
else
throw new TypesException("unknown package: " ~ name);
return r;
};
bool caught = false;
try
{
resolveDepTree("A", getRecipe);
assert(false, "expected DepException");
}
catch (DepException e)
{
caught = true;
// Must mention "A -> A" (self-cycle).
assert(e.msg.indexOf("A -> A") >= 0, e.msg);
}
catch (Exception)
{
assert(false, "expected DepException, got other exception");
}
assert(caught, "self-dep should throw DepException");
}
@safe unittest
{
// ── Test 4: cycle A deps=[B], B deps=[A] → DepException ──
auto getRecipe = (string name) {
Recipe r;
r.name = name;
switch (name)
{
case "A": r.deps = ["B"]; break;
case "B": r.deps = ["A"]; break;
default:
throw new TypesException("unknown package: " ~ name);
}
return r;
};
bool caught = false;
try
{
resolveDepTree("A", getRecipe);
assert(false, "expected DepException");
}
catch (DepException e)
{
caught = true;
// Must mention both A and B in the cycle message.
assert(e.msg.indexOf("A") >= 0, e.msg);
assert(e.msg.indexOf("B") >= 0, e.msg);
// Accept either "A -> B -> A" or "B -> A -> B".
assert((e.msg.indexOf("A -> B -> A") >= 0)
|| (e.msg.indexOf("B -> A -> B") >= 0), e.msg);
}
catch (Exception)
{
assert(false, "expected DepException, got other exception");
}
assert(caught, "cycle should throw DepException");
}
@safe unittest
{
// ── Test 5: missing dep — getRecipe throws → propagate ──
auto getRecipe = (string name) {
if (name == "A")
{
Recipe r;
r.name = "A";
r.deps = ["X"];
return r;
}
throw new TypesException("not found: " ~ name);
};
bool caught = false;
try
{
resolveDepTree("A", getRecipe);
assert(false, "expected TypesException");
}
catch (TypesException e)
{
caught = true;
assert(e.msg.indexOf("not found: X") >= 0, e.msg);
}
assert(caught, "missing dep should propagate getRecipe throw");
}
@safe unittest
{
// ── Test 6: shared dep diamond — D appears ONCE ──
// A deps=[B, C]
// B deps=[D]
// C deps=[D]
auto getRecipe = (string name) {
Recipe r;
r.name = name;
switch (name)
{
case "A": r.deps = ["B", "C"]; break;
case "B": r.deps = ["D"]; break;
case "C": r.deps = ["D"]; break;
case "D": break; // leaf
default:
throw new TypesException("unknown package: " ~ name);
}
return r;
};
auto tree = resolveDepTree("A", getRecipe);
assert(tree.nodes.length == 4);
assert(tree.nodes[0].name == "D"); // shared dep first
assert(tree.nodes[1].name == "B");
assert(tree.nodes[2].name == "C");
assert(tree.nodes[3].name == "A"); // target last
}
@safe unittest
{
// ── Test 7: empty deps (leaf) — single node ──
auto getRecipe = (string name) {
Recipe r;
r.name = name;
if (name != "leaf")
throw new TypesException("unknown package: " ~ name);
return r;
};
auto tree = resolveDepTree("leaf", getRecipe);
assert(tree.nodes.length == 1);
assert(tree.nodes[0].name == "leaf");
assert(tree.nodes[0].constraints.length == 0);
}