feat(resolve): add version-constraint-aware dep resolution with binary fallback
This commit is contained in:
@@ -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
|
||||||
|
[33mwarn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2)[0m
|
||||||
|
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 -
|
||||||
|
[33mwarn skipping index entry with empty name[0m
|
||||||
|
[33mwarn skipping index entry with empty name[0m
|
||||||
|
[33mwarn skipping index entry 'badpool': invalid pool 'bad_pool_value'[0m
|
||||||
|
11 modules passed unittests
|
||||||
|
[2m . checking binary firefox ...[0m
|
||||||
|
- binary firefox-2.1.0 satisfies firefox>=2.0
|
||||||
|
[2m . checking binary libfoo ...[0m
|
||||||
|
- binary libfoo-1.9 does not satisfy libfoo>=2.0
|
||||||
|
[2m . checking binary noexist ...[0m
|
||||||
|
[2m . checking binary brokenpkg ...[0m
|
||||||
|
[2m . checking binary badpkg ...[0m
|
||||||
|
[2m . checking binary testpkg ...[0m
|
||||||
|
- binary testpkg-2.1.0 satisfies testpkg
|
||||||
|
[36m==> fetching recipe hello[0m
|
||||||
|
[2m . downloading hello.recipe[0m
|
||||||
|
[2m . downloading package.lua[0m
|
||||||
|
[2m . downloading build.sh[0m
|
||||||
|
[32m ok recipe hello downloaded[0m
|
||||||
|
[36m==> fetching recipe hello[0m
|
||||||
|
[2m . downloading hello.recipe[0m
|
||||||
|
[36m==> fetching recipe hello[0m
|
||||||
|
[2m . downloading hello.recipe[0m
|
||||||
|
[2m . downloading package.lua[0m
|
||||||
|
- no package.lua for hello (optional manifest)
|
||||||
|
[2m . downloading build.sh[0m
|
||||||
|
- no build.sh for hello (optional build script)
|
||||||
|
[2m . custom build system: fetching scripts/build.sh[0m
|
||||||
|
[32m ok recipe hello downloaded[0m
|
||||||
|
[36m==> fetching recipe hello[0m
|
||||||
|
[2m . downloading hello.recipe[0m
|
||||||
|
[2m . downloading package.lua[0m
|
||||||
|
- no package.lua for hello (optional manifest)
|
||||||
|
[2m . downloading build.sh[0m
|
||||||
|
- no build.sh for hello (optional build script)
|
||||||
|
[32m ok recipe hello downloaded[0m
|
||||||
|
[36m==> fetching recipe hello[0m
|
||||||
|
[2m . downloading hello.recipe[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:37645//index.lua[0m
|
||||||
|
[32m ok index loaded: 3 packages[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:36355//index.lua[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:52041//index.lua[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:53177//index.lua[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:55829//index.lua[0m
|
||||||
|
[32m ok index loaded: 0 packages[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:53801//index.lua[0m
|
||||||
|
[32m ok index loaded: 1 packages[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:44861//index.lua[0m
|
||||||
|
[32m ok index loaded: 2 packages[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:35139//index.lua[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:41715//index.lua[0m
|
||||||
|
[32m ok index loaded: 1 packages[0m
|
||||||
|
- 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
|
||||||
@@ -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
|
### 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.
|
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`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -463,3 +463,56 @@ The sandbox test (test 2) creates a sentinel file, serves an index containing `o
|
|||||||
- `dub build` passes with `warningsAsErrors`.
|
- `dub build` passes with `warningsAsErrors`.
|
||||||
- `dub test` (with deps.d.skip) — 9 modules passed unittests.
|
- `dub test` (with deps.d.skip) — 9 modules passed unittests.
|
||||||
- Evidence logged to `.omo/evidence/task-9-tofu-core.log`.
|
- 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`.
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user