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
+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)
### Architecture