feat(resolve): add version-constraint-aware dep resolution with binary fallback

This commit is contained in:
2026-08-08 17:53:45 -04:00
parent 406a87ca41
commit ba411e9fc4
4 changed files with 677 additions and 0 deletions
+53
View File
@@ -463,3 +463,56 @@ The sandbox test (test 2) creates a sentinel file, serves an index containing `o
- `dub build` passes with `warningsAsErrors`.
- `dub test` (with deps.d.skip) — 9 modules passed unittests.
- Evidence logged to `.omo/evidence/task-9-tofu-core.log`.
---
## Task 12 — `tofu.resolve` (version-constraint-aware dep resolution)
### Architecture
- Module `tofu.resolve` depends on: `tofu.types` (DepConstraint, DepOp, PackageIndex, Pool, BinaryCheckResult), `tofu.deps` (DepTree, DepNode), `tofu.log` (logInfo), `std.string` (indexOf).
- `constrainDepTree(tree, index, binaryCheck)` annotates a dep tree with source decisions: binary (pre-built zuur binary satisfied) vs recipe (build from source).
- The `binaryCheck` delegate is the testability seam — production wires `tofu.binary.checkBinaryVersion`; tests inject mocks.
### Algorithm — three-pass resolution
1. **Pass 1 (aggregate)**: Walk all nodes, collect all `DepConstraint[]` per dep name into `allConstraints` AA. Same dep constrained by multiple parents → all constraints aggregated.
2. **Pass 2 (resolve)**: Walk nodes in topological order. First time a dep name is encountered, resolve it:
- Test ALL aggregated constraints via `binaryCheck(depName, constraint)`.
- If ANY constraint returns `!satisfies` → dep goes recipe (after verifying recipe exists in index).
- If ALL constraints return `satisfies` → dep goes binary.
- Root (last node) is always recipe (it's what the user asked to build from ZUUR recipes).
- Neither binary nor recipe in index → `ResolveException("dependency '<name>' not found in ZUUR (neither binary nor recipe)")`.
3. **Pass 3 (output)**: Build `ConstrainedNode[]` array — one entry per unique node name in tree order, source from resolved map (fallback recipe).
### Deduplication semantics
- Same dep appearing under multiple parents → resolved ONCE. All constraints tested; if ANY fails → recipe. This implements the "strictest wins" rule from the plan.
- Unconstrained dep (`DepOp.none`) with binary → binary (satisfies always true). Without binary → recipe (if in index).
### Log format
- `"binary libfoo-2.1 satisfies libfoo>=2.0"` — binary satisfied
- `"libfoo: binary 1.9 too old, building from recipe"` — binary exists but too old
- `"libfoo: no binary available, building from recipe"` — binary 404/skip
### `delegate` vs `function` in testability seams
- **CRITICAL gotcha**: Non-capturing D lambdas in `@safe unittest` blocks are inferred as `function` pointers with inferred attributes (`pure nothrow @nogc @safe`). A parameter typed as `delegate` cannot accept a `function` even if attributes match.
- **Fix**: Use the explicit `delegate` keyword: `scope binaryCheck = delegate (string name, DepConstraint c) @safe { ... };`. This forces delegate type regardless of captures.
- **Why delegate, not function?** The production caller (install command) must capture `cfg` to call `checkBinaryVersion(name, constraint, cfg)`. A function pointer cannot carry captured state. The `delegate` keyword in tests matches the production usage pattern.
- Contrast with `deps.d` where `resolveDepTree` uses `function` — that works because recipe fetching doesn't need captured state (the production caller uses module-level functions).
### `std.string.indexOf` import
- `indexOf` is NOT in the default namespace. Must explicitly `import std.string : indexOf;` to use on string in assertions. Same pattern as `deps.d`.
### Test cases (8/8 pass)
1. dep libfoo>=2.0, binary 2.1 → binary
2. dep libfoo>=2.0, binary 1.9, recipe in index → recipe
3. dep libfoo>=2.0, binary 1.9, NOT in index → ResolveException ("neither binary nor recipe")
4. unconstrained dep with binary → binary
5. unconstrained dep without binary but in index → recipe
6. multiple constraints on same dep, one unsatisfied → recipe (dedup + strictest wins)
7. root marked recipe (single-node tree)
8. empty tree → empty result
### Build verified
- `dub build` passes with `warningsAsErrors`.
- `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`.