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
+111
View File
@@ -0,0 +1,111 @@
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
warn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2)
Warning: malformed TOML config at /tmp/tofu-test-config-bad-201829.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:53:05] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:53:05] "GET /recipes/hello/package.lua HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:53:05] "GET /recipes/hello/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:53:05] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:53:05] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:53:05] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:53:05] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:53:05] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:53:05] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:53:05] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:53:05] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:53:06] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:53:06] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:53:06] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:53:06] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:53:06] "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:37645//index.lua
 ok index loaded: 3 packages
 . fetching index http://127.0.0.1:36355//index.lua
 . fetching index http://127.0.0.1:52041//index.lua
 . fetching index http://127.0.0.1:53177//index.lua
 . fetching index http://127.0.0.1:55829//index.lua
 ok index loaded: 0 packages
 . fetching index http://127.0.0.1:53801//index.lua
 ok index loaded: 1 packages
 . fetching index http://127.0.0.1:44861//index.lua
 ok index loaded: 2 packages
 . fetching index http://127.0.0.1:35139//index.lua
 . fetching index http://127.0.0.1:41715//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)
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
+3
View File
@@ -21,4 +21,7 @@ One exception class for the entire types module — no separate subclasses per e
### Task 4: Manual char scanning for dep spec parsing
Rather than pulling in `std.regex`, the parser uses hand-written `isNameChar()` and `isWhite()` helpers with a simple position cursor. This keeps the module dependency-light (only `std.ascii`). The logic is a direct 1:1 port of the Lua reference patterns: `[A-Za-z0-9_.+-]` for names/versions, whitespace `[ \t]` for separators.
### Task 12: `delegate` keyword in test lambdas for delegate-typed parameters
D non-capturing lambdas infer as `function` pointers, which cannot implicitly convert to `delegate`-typed parameters. The explicit `delegate` keyword (`scope f = delegate (...) { ... };`) forces the correct type. This pattern is needed for resolution seam delegates that production code will instantiate with captures (e.g. closing over `Config cfg`).
---
+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`.
+510
View File
@@ -0,0 +1,510 @@
/// tofu.resolve — Version-constraint-aware dependency source resolution.
///
/// Annotates a dependency tree with source decisions (binary vs recipe)
/// by checking zuur/binary package versions against dependency constraints
/// via an injectable delegate. Falls back to recipe builds when binaries
/// are missing or too old.
///
/// Design:
/// - `constrainDepTree` returns one `ConstrainedNode` per unique package
/// name in the tree, including the root (which is always recipe).
/// - The `binaryCheck` delegate is the testability seam — production
/// wires `tofu.binary.checkBinaryVersion`; tests inject mocks.
/// - Deduplication: the same dep name appearing in multiple parent
/// constraints is resolved ONCE. If ANY constraint fails binary
/// satisfaction, the dep is marked recipe.
/// - The `PackageIndex[]` parameter enables recipe-existence checks
/// without network I/O — the caller passes the already-fetched index.
module tofu.resolve;
import tofu.types; // DepConstraint, DepOp, PackageIndex, Pool, BinaryCheckResult
import tofu.deps; // DepTree, DepNode
import tofu.log; // logInfo
import std.string : indexOf;
// ────────────────────────────────────────────────────────────
// Exception
// ────────────────────────────────────────────────────────────
/// Thrown when a dependency cannot be resolved from either
/// binary repository or recipe index.
class ResolveException : Exception
{
@safe this(string msg)
{
super(msg);
}
}
// ────────────────────────────────────────────────────────────
// Data structures
// ────────────────────────────────────────────────────────────
/// Where a resolved dependency comes from.
enum DepSource
{
binary,
recipe,
}
/// A dependency tree node annotated with its resolved source.
struct ConstrainedNode
{
string name = "";
DepSource source = DepSource.recipe;
}
// ────────────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────────────
/// Check whether a package name appears in the index with a
/// pool that includes recipes (`recipes` or `both`).
private @safe bool hasRecipe(string name, scope const PackageIndex[] index)
{
foreach (entry; index)
{
if (entry.name == name
&& (entry.pool == Pool.recipes || entry.pool == Pool.both))
return true;
}
return false;
}
/// Format a dependency constraint for human-readable log messages.
private @safe string formatConstraint(DepConstraint c)
{
final switch (c.op)
{
case DepOp.none:
return c.name ~ " (unconstrained)";
case DepOp.ge:
return c.name ~ ">=" ~ c.ver;
case DepOp.le:
return c.name ~ "<=" ~ c.ver;
case DepOp.eq:
return c.name ~ "==" ~ c.ver;
case DepOp.ne:
return c.name ~ "~=" ~ c.ver;
case DepOp.gt:
return c.name ~ ">" ~ c.ver;
case DepOp.lt:
return c.name ~ "<" ~ c.ver;
}
}
// ────────────────────────────────────────────────────────────
// Public API
// ────────────────────────────────────────────────────────────
/// Annotate a dependency tree with source decisions (binary vs recipe).
///
/// For each unique package name in the tree, all applicable constraints
/// (aggregated from every parent node that depends on it) are tested
/// against the binary repository via the `binaryCheck` delegate. If ANY
/// constraint is unsatisfied, the dep is marked recipe. If ALL constraints
/// are satisfied, the dep is marked binary.
///
/// The root (last node in the topological order) is always recipe — it is
/// the target the user asked to build from ZUUR recipes.
///
/// If a dep cannot be resolved as binary AND has no recipe in the index,
/// throws `ResolveException`.
///
/// Params:
/// tree = Topologically ordered dependency tree (from deps.resolveDepTree).
/// index = ZUUR package index for recipe existence checks.
/// binaryCheck = Delegate that checks binary version against a constraint
/// (testability seam — production wires tofu.binary.checkBinaryVersion).
///
/// Returns:
/// `ConstrainedNode[]` with one entry per unique package name in the tree,
/// ordered by first appearance in the tree's topological order.
@safe
ConstrainedNode[] constrainDepTree(DepTree tree, scope const PackageIndex[] index,
scope BinaryCheckResult delegate(string, DepConstraint) @safe binaryCheck)
{
if (tree.nodes.length == 0)
return [];
// Root is the target package — always recipe.
string rootName = tree.nodes[$ - 1].name;
// ── Pass 1: Aggregate all constraints per dep name ──
// For each dep name, collect every DepConstraint from every
// parent node that lists it as a dependency.
DepConstraint[][string] allConstraints;
foreach (node; tree.nodes)
{
foreach (constraint; node.constraints)
{
allConstraints[constraint.name] ~= constraint;
}
}
// ── Pass 2: Resolve source for each unique dep name ──
DepSource[string] resolved;
// Root is always recipe.
resolved[rootName] = DepSource.recipe;
// Walk the tree in order. The first time we encounter a dep name
// (via some parent's constraint), resolve it using ALL accumulated
// constraints from ALL parents.
foreach (node; tree.nodes)
{
foreach (constraint; node.constraints)
{
string depName = constraint.name;
// Already resolved — skip.
if (depName in resolved)
continue;
// Retrieve the full constraint set for this dep.
auto constraints = depName in allConstraints;
assert(constraints !is null, "dep in tree must have constraints");
bool allSatisfied = true;
foreach (c; *constraints)
{
auto result = binaryCheck(depName, c);
if (!result.satisfies)
{
if (result.exists)
logInfo("%s: binary %s too old, building from recipe",
depName, result.ver);
else
logInfo("%s: no binary available, building from recipe",
depName);
allSatisfied = false;
break;
}
logInfo("binary %s-%s satisfies %s",
depName, result.ver, formatConstraint(c));
}
if (allSatisfied)
{
resolved[depName] = DepSource.binary;
}
else
{
// Recipe fallback — must exist in the index.
if (!hasRecipe(depName, index))
{
throw new ResolveException(
"dependency '" ~ depName
~ "' not found in ZUUR (neither binary nor recipe)");
}
resolved[depName] = DepSource.recipe;
}
}
}
// ── Pass 3: Build output array in tree order ──
// Include every distinct node (including root).
ConstrainedNode[] result;
bool[string] seen;
foreach (node; tree.nodes)
{
if (node.name in seen)
continue;
seen[node.name] = true;
ConstrainedNode cn;
cn.name = node.name;
// Use resolved map; fall back to recipe for safety.
cn.source = resolved.get(node.name, DepSource.recipe);
result ~= cn;
}
return result;
}
// ────────────────────────────────────────────────────────────
// Unittests
// ────────────────────────────────────────────────────────────
// Helper: build a DepConstraint with explicit fields.
private @safe DepConstraint mkConstraint(string name,
DepOp op = DepOp.none,
string ver = "")
{
DepConstraint c;
c.name = name;
c.op = op;
c.ver = ver;
return c;
}
// Helper: build a simple two-node DepTree (dep → root).
private @safe DepTree mkTree2(string depName, DepConstraint depConstraint,
string rootName)
{
DepNode depNode;
depNode.name = depName;
DepNode rootNode;
rootNode.name = rootName;
rootNode.constraints ~= depConstraint;
DepTree t;
t.nodes = [depNode, rootNode]; // dep before root (topo order)
return t;
}
// Helper: build an index entry with a given name and pool.
private @safe PackageIndex mkIndexEntry(string name, Pool pool)
{
PackageIndex e;
e.name = name;
e.pool = pool;
return e;
}
// ── Test (1): dep libfoo>=2.0, binary 2.1 → binary ──
@safe unittest
{
auto tree = mkTree2("libfoo",
mkConstraint("libfoo", DepOp.ge, "2.0"),
"mypkg");
PackageIndex[] index = [mkIndexEntry("libfoo", Pool.recipes)];
scope binaryCheck = delegate (string name, DepConstraint c) @safe {
assert(name == "libfoo");
BinaryCheckResult r;
r.exists = true;
r.ver = "2.1";
r.satisfies = true;
return r;
};
auto result = constrainDepTree(tree, index, binaryCheck);
assert(result.length == 2);
// libfoo first (dep), mypkg second (root)
assert(result[0].name == "libfoo");
assert(result[0].source == DepSource.binary,
"libfoo-2.1 satisfies >=2.0 → should be binary");
assert(result[1].name == "mypkg");
assert(result[1].source == DepSource.recipe,
"root is always recipe");
}
// ── Test (2): dep libfoo>=2.0, binary 1.9, recipe in index → recipe ──
@safe unittest
{
auto tree = mkTree2("libfoo",
mkConstraint("libfoo", DepOp.ge, "2.0"),
"mypkg");
PackageIndex[] index = [mkIndexEntry("libfoo", Pool.recipes)];
scope binaryCheck = delegate (string name, DepConstraint c) @safe {
BinaryCheckResult r;
r.exists = true;
r.ver = "1.9";
r.satisfies = false; // 1.9 < 2.0
return r;
};
auto result = constrainDepTree(tree, index, binaryCheck);
assert(result.length == 2);
assert(result[0].name == "libfoo");
assert(result[0].source == DepSource.recipe,
"binary too old, recipe in index → recipe");
assert(result[1].source == DepSource.recipe);
}
// ── Test (3): dep libfoo>=2.0, binary 1.9, NOT in index → ResolveException ──
@safe unittest
{
auto tree = mkTree2("libfoo",
mkConstraint("libfoo", DepOp.ge, "2.0"),
"mypkg");
PackageIndex[] index; // empty — no recipe available
scope binaryCheck = delegate (string name, DepConstraint c) @safe {
BinaryCheckResult r;
r.exists = true;
r.ver = "1.9";
r.satisfies = false;
return r;
};
bool caught = false;
try
{
constrainDepTree(tree, index, binaryCheck);
assert(false, "expected ResolveException");
}
catch (ResolveException e)
{
caught = true;
assert(e.msg.indexOf("neither binary nor recipe") >= 0,
"message should mention 'neither binary nor recipe', got: " ~ e.msg);
assert(e.msg.indexOf("libfoo") >= 0,
"message should name the missing dep, got: " ~ e.msg);
}
assert(caught, "should have thrown ResolveException");
}
// ── Test (4): unconstrained dep with binary → binary ──
@safe unittest
{
auto tree = mkTree2("libbar",
mkConstraint("libbar", DepOp.none, ""),
"mypkg");
PackageIndex[] index = [mkIndexEntry("libbar", Pool.recipes)];
scope binaryCheck = delegate (string name, DepConstraint c) @safe {
BinaryCheckResult r;
r.exists = true;
r.ver = "3.0";
r.satisfies = true; // unconstrained always satisfies
return r;
};
auto result = constrainDepTree(tree, index, binaryCheck);
assert(result.length == 2);
assert(result[0].name == "libbar");
assert(result[0].source == DepSource.binary,
"unconstrained dep with binary → binary");
}
// ── Test (5): unconstrained dep without binary but in index → recipe ──
@safe unittest
{
auto tree = mkTree2("libbaz",
mkConstraint("libbaz", DepOp.none, ""),
"mypkg");
PackageIndex[] index = [mkIndexEntry("libbaz", Pool.both)];
scope binaryCheck = delegate (string name, DepConstraint c) @safe {
BinaryCheckResult r;
r.exists = false; // 404
r.ver = "";
r.satisfies = false;
return r;
};
auto result = constrainDepTree(tree, index, binaryCheck);
assert(result.length == 2);
assert(result[0].name == "libbaz");
assert(result[0].source == DepSource.recipe,
"unconstrained dep no binary, in index → recipe");
}
// ── Test (6): multiple constraints on same dep, one unsatisfied → recipe ──
@safe unittest
{
// parentA deps=[libfoo>=1.0]
// parentB deps=[libfoo>=2.0]
// root deps=[parentA, parentB]
// Binary version: 1.5
// >=1.0 → satisfies, >=2.0 → !satisfies → libfoo goes recipe.
DepNode libfooNode;
libfooNode.name = "libfoo";
DepNode parentANode;
parentANode.name = "parentA";
parentANode.constraints ~= mkConstraint("libfoo", DepOp.ge, "1.0");
DepNode parentBNode;
parentBNode.name = "parentB";
parentBNode.constraints ~= mkConstraint("libfoo", DepOp.ge, "2.0");
DepNode rootNode;
rootNode.name = "mypkg";
rootNode.constraints ~= mkConstraint("parentA", DepOp.none, "");
rootNode.constraints ~= mkConstraint("parentB", DepOp.none, "");
DepTree tree;
tree.nodes = [libfooNode, parentANode, parentBNode, rootNode];
PackageIndex[] index = [mkIndexEntry("libfoo", Pool.recipes)];
scope binaryCheck = delegate (string name, DepConstraint c) @safe {
BinaryCheckResult r;
r.exists = true;
r.ver = "1.5";
if (name == "libfoo" && c.op == DepOp.ge && c.ver == "2.0")
r.satisfies = false;
else
r.satisfies = true;
return r;
};
auto result = constrainDepTree(tree, index, binaryCheck);
// libfoo must be recipe because >=2.0 fails.
auto libfooEntry = result[0];
assert(libfooEntry.name == "libfoo");
assert(libfooEntry.source == DepSource.recipe,
"one constraint unsatisfied → recipe");
// parentA and parentB: unconstrained, binary exists → binary.
assert(result[1].name == "parentA");
assert(result[1].source == DepSource.binary);
assert(result[2].name == "parentB");
assert(result[2].source == DepSource.binary);
// root → recipe.
assert(result[3].name == "mypkg");
assert(result[3].source == DepSource.recipe);
}
// ── Test (7): root marked recipe ──
@safe unittest
{
// Single-node tree: root with no deps.
DepNode rootNode;
rootNode.name = "solopkg";
DepTree tree;
tree.nodes = [rootNode];
PackageIndex[] index;
scope binaryCheck = delegate (string name, DepConstraint c) @safe {
assert(false, "binaryCheck should not be called for root with no deps");
return BinaryCheckResult();
};
auto result = constrainDepTree(tree, index, binaryCheck);
assert(result.length == 1);
assert(result[0].name == "solopkg");
assert(result[0].source == DepSource.recipe,
"root is always recipe");
}
// ── Test (8): empty tree → empty result ──
@safe unittest
{
DepTree tree; // nodes.length == 0
PackageIndex[] index;
scope binaryCheck = delegate (string name, DepConstraint c) @safe {
assert(false, "binaryCheck should never be called on empty tree");
return BinaryCheckResult();
};
auto result = constrainDepTree(tree, index, binaryCheck);
assert(result.length == 0, "empty tree → empty result");
}