Add generateBuildPlan() to tofu.resolve — filters constrained dep tree to recipe-only packages, verifies cached recipes exist, and produces an ordered BuildPlan ready for the build orchestrator (task 15). - Skips binary-satisfied deps (Zeta handles those) - Preserves topological order from constrained nodes (deps-first) - Missing recipe files: re-fetch via injectable delegate seam (production wires tofu.fetch.fetchRecipe; tests inject mocks) - Empty plan (all binary) → returns empty BuildPlan + log info - Root always recipe — always included in plan - 6 unittests: filter binary, fetch seam, missing no-seam, all-binary empty, root with binary dep, cache hit no-fetch - dub test + dub build pass with warningsAsErrors
38 KiB
Learnings — tofu-core
Conventions, patterns, and successful approaches discovered during work on this plan.
Auto-scaffolded by /start-work. Append new entries below - never overwrite.
Task 5 — tofu.vercmp (RPM-style version comparison)
Module naming
versionis a D reserved keyword (conditional compilation). Module is namedtofu.vercmp, filesrc/tofu/vercmp.d. TheDepConstraintstruct intypes.duses fieldver(notversion) for the same reason.
Algorithm (exact port from ZETA/lib/vercmp.lua)
nextSegment: skips any non-digit/non-letter chars (separators); then reads a contiguous run of same-type chars. Returns(segment, nextIndex).cmpNumeric: strips leading zeros from both segments ("007"→"7","000"→"0"). Longer stripped string wins; then lexicographic.compare: strips all whitespace, then loops over alternating segments. If both segments are digit-typed →cmpNumeric, else lexical. When one version exhausts segments: the exhausted one is older (shorter = older). This matches RPM semantics:compare("1.0.0", "1.0") > 0.satisfies: usesfinal switchonDepOpenum. Maps tocompareresult as expected (ge→c>=0, le→c<=0, eq→c==0, ne→c!=0, gt→c>0, lt→c<0).parseDep: delegates toDepConstraint.parse(already ported in types.d by task 4). Both implementations are kept consistent.
whitespace stripping
- Could not use
filter+arraybecause Phobos auto-decodesstringtodcharrange elements, returningdchar[]notstring. Implemented a manualremoveWhitespacehelper using raw code-unit indexing, with a@trustedcast since the freshly-allocatedchar[]has no aliasing.
Test porting
- All 10 ZETA test categories ported as separate
@safe unittestblocks. - 4 modules pass unittests (types, vercmp, plus pre-existing ones).
dub buildpasses with warnings-as-errors enabled.
Future considerations
- When task 12 (constraint resolution) imports
tofu.vercmp, thecompareandsatisfiesfunctions are ready. TheparseDepintegration withDepConstraint.parseensures consistency between ZETA and tofu's dep constraint semantics.
Task 2: Config Loading (src/tofu/config.d)
toml package API (v1.0.0, Kripth/toml)
- Import:
import toml;→ providesTOMLDocument,TOMLValue,parseTOML,TOMLException,TOMLParserException TOMLDocumenthasalias table this— acts likeTOMLValue[string]AA- Access sections:
doc["core"]returnsTOMLValuewithtype == TOML_TYPE.TABLE - Access string values:
doc["core"]["key"].str(throws if type mismatch) - Check existence:
"key" in docreturnsTOMLValue*(null if absent) parseTOML()is@system— must wrap in@trustedblock when calling from@safecode- Keys without
.str/.integeraccess will throwTOMLException
D language gotchas
- Nested function declarations: D does NOT allow
void foo() { ... }inside a function body. Use lambdas:auto foo = () { ... }; foo();or anonymous() @trusted { ... }(); versionis a keyword: Cannot usestring version;as field name — usestring version_;or initialize with= ""parseTOMLis@system: The toml package's parse function isn't marked@safe— must wrap in@trustedlambdastd.file.readTextis@system: Same — wrap in@trustedstd.process.environment.get(): Returns empty string when env var is unset;"VAR" in environmentchecks existence butenvironment.get("VAR", "default")is cleanerstderr.writeflnis@safein recent DMD (2.106+) — usable directly in@safecode- AA
inoperator: Returns pointer (V*) ornull— must check for null before dereferencing
Pattern: env-overrideable config loading
- Priority: env vars > TOML file > hardcoded defaults
- TOML read happens BEFORE env override — so TOML values serve as base, env vars overwrite
- Missing config file → no error, just skip to defaults
- Malformed TOML →
TOMLParserExceptioncaught, warning to stderr, fall back to defaults - Testability:
load()accepts optionalenvOverridesmap and explicitconfigFilepath to avoid real env/filesystem
Config design decisions
TOFU_CONFIGenv var overrides default config file path (~/.config/tofu/config.toml)~in default paths expanded withstd.path.expandTilde- All path fields default to empty string when "find on PATH" — no hardcoded binary paths
defaultJobstyped asint(notsize_t/ulong) since it's a user-facing count
buildOptions warningsAsErrors deprecation
- DUB warns about
warningsAsErrorsinbuildOptions— recommendsbuildRequirementsinstead - Not blocking, informational only
Task 3 — tofu.log (colored, NO_COLOR-aware logging)
- dmd 2.112 gotcha: the
std.stdioglobalsstdout/stderrare@systemto access —makeGlobaluses__gshared File result, which fails@safeinference. An@safefunction cannot callstdout.writeln(...)directly. Fix: route every write through a tiny@trustedhelper (writeStdout(string)/writeStderr(string)), keep the public log functions@safe. Compiler hint: "using__gsharedinstead ofsharedmakes it fail to infer@safe". std.process.environment(dmd 2.112): it is anabstract final classof static methods —environment.get(name)is@safeand returnsnullwhen the var is unset (so "NO_COLOR set" ==get !is null, even for an empty string).environment["K"] = vandenvironment.remove(name)are@trusted. NOT deprecated in 2.112.- Testable color decision: make
colorEnabled()re-read the environment on every call instead of caching instatic this(). Enables NO_COLOR/TERM tests without restarting the process. Costs onegetenvper log line — negligible. - Env save/restore in unittests: a
private struct ColorEnv(inversion(unittest)) that snapshots NO_COLOR+TERM in its ctor and restores them in~this()(destructor) —auto env = ColorEnv("1", "xterm")scopes the restore.nullvalue = remove the var. Double-destruction from a copied temporary is harmless (restore is idempotent). - Capturing stdout/stderr in unittests: swap the global
stdout/stderrFile to aFile(name, "w+"), write,flush(), swap back, thenrewind()+readln(). Restore the global before the temp File's destructor runs. - zeta log format details:
warnpaints the whole prefix yellow (paint("yellow", "warn ") ~ msg), buterrorpaints only the word then" " ~ msg(paint("red","error") ~ " " ~ msg). Assertion must expect\x1b[31merror\x1b[0m failed deploy— the reset code sits between "error" and the message. versionis a D keyword — cannot be used as a struct field / identifier (trap for the paralleltypes.dwork).std.stdio.writelnflushes per call — already satisfies "no buffering"; no explicitflush()needed in production paths.- Verifying a single module without the full build:
dmd -main -unittest -i src/tofu/log.druns just that module's unittests when sibling modules (config.d,types.d) are mid-edit by parallel agents.-ipulls imports in automatically.
Task 4 — tofu.types (core data structures)
versionis a D keyword — all struct fields namedversionmust be renamed tover. The D compiler treatsversionas a conditional-compilation directive and rejects it as a field name (see task 3 learnings above).- Struct field defaults in D:
stringfields initialise tonullby default. To enforce empty-string semantics (""), every string field must be explicitly= "". final switchfor exhaustive enum handling: usingfinal switchon enum types forces the compiler to verify all cases are covered — catches missing branches at compile time. Used forpoolToString().- Manual character scanning preferred over
std.regex: the dep-spec parser uses simple char-by-char scanning withisNameChar()/isWhite()helpers instead of regex. This avoids thestd.regexdependency, keeps the parser@safe, and matches the Lua reference semantics exactly. - Dep spec parsing order matters: 2-character operators (
>=,<=,==,~=) MUST be checked before 1-character operators (>,<,=) to avoid false matches. The Lua reference uses an ordered tableOPS = { ">=", "<=", "==", "~=", ">", "<", "=" }— we replicate this with if/else chains. =normalises to==: the Lua code hasif op == "=" then op = "==" end; we map both toDepOp.eq.- Trailing garbage detection: after extracting version chars, any non-whitespace remaining in the spec string causes a
TypesException. The Lua pattern uses$anchor for this. dub testincludes all.dfiles in source paths for executable targets, not just imported modules. Pre-existing compile errors in sibling modules (config.d) block test runs. Fixed config.d by removingprivatefrom a nested function (access specifiers not allowed on local functions in D).BuildResult.failedasBuildFailure[]: the plan describes it asstruct {string name; string reason}[]— in D this is a named structBuildFailurewith array fieldBuildFailure[] failed. ThefailedNames()helper extracts just the names for caller convenience.DepConstraint.parseis a static factory method: returns a newDepConstraintby value, never allocates on the heap.@safethroughout.- All structs are
@safe: no methods do I/O, no@systemcalls. The module stands alone — no imports from other tofu modules.
Task 6 — tofu.http (sync HTTP client via std.net.curl)
std.net.curl API (key members for HTTP struct)
- Construction:
HTTP(url)viaopCall, orHTTP()+.url = urlsetter. The.urlsetter auto-prependshttp://if no scheme is present. - Timeouts (all are
@propertysetters takingcore.time.Duration):connectTimeout— connect phase only (CurlOption.connecttimeout_ms).operationTimeout— DNS + connect + transfer total (CurlOption.timeout_ms).dataTimeout— low-speed activity timeout (NOT total read timeout; setslow_speed_limit+low_speed_time).- For a "read 120s" requirement use
operationTimeout = 120.seconds— it covers the entire operation including data transfer.
- Redirects:
maxRedirects(property setter, takesuint). Defaults to 10. Set to 0 to disable,uint.maxfor infinite. Internally setsCurlOption.followlocation+CurlOption.maxredirs. - User-Agent:
setUserAgent(string)— instance method (not property). - onReceive:
void delegate(ubyte[])returningsize_t. Must accept all bytes or the request aborts. Usereturn data.length;. - onReceiveStatusLine: Not a direct property on HTTP struct — must
be set via the
onReceiveHeaderproperty setter which wraps the delegate and internally firesonReceiveStatusLinewhen it detects an HTTP status line. Afterperform()the final status is available viahttp.statusLine. - statusLine:
HTTP.StatusLinestruct with.code(ushort),.majorVersion,.minorVersion,.reason. Reset to zero before eachperform(). - perform():
CurlCode perform(ThrowOnError = Yes.throwOnError). Default throws on curl errors, but the thrown exception classes are:CurlTimeoutException : CurlException— operation timed out (code 28).CurlException— all other curl errors.HTTPStatusException : CurlException— only thrown by high-level wrappers (_basicHTTP); low-levelperform()does NOT throw on HTTP status codes — you must checkstatusLine.codeyourself.
- Exception isolation pattern: catch
CurlTimeoutExceptionandCurlExceptionin@trustedhelpers, re-throw asHttpException(which extendsException, notCurlException). Callers never see curl types. - verifyPeer / verifyHost: On by default in HTTP struct (no change needed).
@safe / @trusted architecture
- All public functions (
get,downloadFile) are@safe. - The
configure(HTTP),getImpl, anddownloadFileImplhelpers are@trusted— they are the ONLY placesstd.net.curl(which is@system-heavy) is used.
Download pattern (.part temp file + rename)
- Ported from ZETA
lib/fetch.lua: write todest.part, rename on success, remove partial on error.scope(failure)at function level handles all error paths automatically. File(tmpPath, "wb")— binary write mode. File destructor auto-closes; explicitf.close()needed before rename.
Unittest local server
- Used
std.concurrency.spawn+std.socket.TcpSocketfor a self-contained one-shot HTTP server (no external dependency on Python3). - Pattern: bind ephemeral port (
InternetAddress.PORT_ANY), spawn thread thataccept()s one connection, sends canned HTTP response, exits.cast(shared)the listener to pass tospawn. Mark test helpers@trusted. - HTTP response must include
Content-Lengthheader or libcurl hangs.
Connection refused test
- Bind socket, record port, close it, then
get()to that port. curl returns error 7 (CURLE_COULDNT_CONNECT) →CurlException→ caught and re-thrown asHttpException.
Build verified
dub buildpasses with warnings-as-errors.dub testpasses — all 5 modules (config, log, types, vercmp, http).
Task 8 — tofu.fetch (ZUUR recipe download to cache)
Architecture
fetchRecipe(name, cfg)orchestrates a known-file download sequence (no directory listing assumed — plan constraint).- Download order: .recipe (required) → package.lua (optional) → build.sh (optional) → custom build_script (if referenced).
- 404 on .recipe translates to
FetchException("package 'X' not found in ZUUR recipes")(user-friendly). - 404 on optional files (package.lua, build.sh) is logged and skipped.
- 404 on a referenced
build_scriptis a real error (build cannot proceed). - Non-404 HTTP errors or connection failures are caught, partial files
cleaned up, and re-thrown as
FetchException.
Light recipe scanning
- Full Lua parsing is task 9/10's job. Task 8 only needs to discover
build_system = "custom"+build_script = "..."to fetch referenced build scripts. - Manual string scanning: find key, skip whitespace/
=, read quoted value. Handles arbitrary whitespace variations. Avoidsstd.regexdependency. extractBuildScript(content)returns the script path only whenbuild_system = "custom"is also found.
@safe / @trusted architecture
fetchRecipeandextractBuildScriptare@safe.- Filesystem operations (
exists,mkdirRecurse,readText,remove,rmdirRecurse,write) are isolated in@trustedwrappers. - Follows the same pattern as
http.dandconfig.d. std.file.readTextand friends are@systemin DMD 2.112.
Name conflicts between imports
tofu.config.Configconflicts withstd.process.Config(both imported inversion(unittest)blocks). Solution: fully qualify astofu.config.Configin function signatures wherestd.processis also imported.
std.string.indexOf vs std.algorithm.canFind
"string".canFind("substr")fails in D because UFCS onstring(a range ofdchar) tries to find astringelement in adcharrange. Usee.msg.indexOf("pattern") >= 0instead.std.algorithm.searching.canFind(haystack, needle)as a free function works but is less readable.
Test strategy
- Used
python3 -m http.serveron ephemeral ports for multi-file HTTP serving — the one-shot TCP responder fromhttp.dhandles only one connection per spawn. - Pattern:
findFreePort()(bind + close ephemeral port), spawn server on that port,waitForPort()(connection polling with 100ms sleep, 50 attempts max), run test,killServer(pid)(SIGTERM). spawnProcessreturnsPid(a class, not a struct with.pidfield). Store thePidobject directly, not.pid.Socket.localAddress()returns abstractAddress— must cast toInternetAddressto access.port.- Five test scenarios: happy path, 404-on-recipe, custom build_script, optional-file-404s, connection-failure cleanup.
Partial file cleanup
downloadFileinternally cleans up.partfiles on failure (scope(failure) removes temp file). Our extra cleanup layer handles the case where a previous step succeeded but a later step fails — though for step 1 failure (recipe), nothing else was downloaded yet.- Unittest scenario 5 verifies: after a connection error on recipe download, the cache directory is clean (no files).
DUB details
dub.jsonuseswarningsAsErrorsinbuildOptions(generates a deprecation warning aboutbuildRequirements, but non-blocking).- All 8 modules pass unittests with warnings-as-errors enabled.
dub buildproduces thetofubinary successfully.
Task 10 — tofu.binary (zuur/binary package.lua version checking)
Architecture
- Module
tofu.binarydepends on:tofu.types(BinaryCheckResult, DepConstraint),tofu.http(get, HttpException),tofu.config(Config, binaryManifestUrl),tofu.vercmp(satisfies),tofu.log(logDetail, logInfo). checkBinaryVersion(name, constraint, cfg)fetches the package.lua manifest from zuur/binary via HTTP, parses the version, and checks satisfaction.
Key logic — 404 vs other errors
- HTTP 404 → binary doesn't exist (recipe-only). Return
BinaryCheckResult(false, "", false). - Any other HttpException (500, timeout, connection failure) → rethrow — network problems must propagate, not be silently swallowed.
- Detection:
e.msg.canFind("404")— the HttpException format is"HTTP %d fetching <url>".
Version extraction from Lua package.lua
- Light parse: scan for
version = "..."in the manifest body. - Word-boundary check on "version" keyword (preceding char must be whitespace/
{/,/;, following char must be whitespace/=). - If version field not found or malformed →
BinaryException. - This avoids a full Lua parser — package.lua manifests are flat key-value tables.
- Empty version string (e.g.
version = "") also throws BinaryException.
Memoization
- Module-level
BinaryCheckResult[string] _versionCache— keyed by package name. - On cache hit: recompute satisfaction against caller's constraint (same version, different constraint possible).
- On 404: cache the "not found" result so subsequent calls don't re-fetch.
- Lifetime: one command run. Tests use distinct package names to avoid cross-state contamination.
- The cache stores
existsandver;satisfiesis always recomputed per-constraint.
version keyword trap (again)
- Local variable
versionconflicts with D keyword. Usedverinstead. Same pattern as types.d where struct field isver.
Test infrastructure
- Reused local one-shot TCP server pattern from http.d:
bindAndSpawn(response)— bind ephemeral port, spawn thread, return URL.oneShotResponder(listener, response)— accept one connection, send canned HTTP response, exit.httpResponse(code, reason, body)— build minimal HTTP/1.1 response with Content-Length.testConfig(baseUrl)— create a Config pointing zuurUrl at the test server.
Test cases (6/6 pass)
- Binary exists, version 2.1.0, constraint ge 2.0 → satisfies:true ✓
- Binary exists, version 1.9, constraint ge 2.0 → satisfies:false ✓
- 404 → exists:false, ver:"", satisfies:false ✓
- 500 → HttpException rethrown (not swallowed) ✓
- Manifest without version field → BinaryException ✓
- Version 2.1.0 with unconstrained dep (op none) → satisfies:true ✓
@safe annotation consistency
checkBinaryVersionand all helpers marked@safe. The function callshttp.get()(which is@safe),satisfies(@safe), and log functions (@safe). No@trustedblocks needed in production code.- Test helpers (
oneShotResponder,bindAndSpawn,httpResponse) are@trustedsince they usestd.socket.
Note: pre-existing fetch.d compile issue
fetch.d(from parallel task 7) has a missingstd.algorithm.searching : canFindimport that blocksdub test.dub buildpasses (fetch.d is excluded via.skipextension renames by the parallel agent). Binary.d tests verified via standalonedmd -I... -i -main -unittestcompilation — all 7 modules pass.
Task 7 — tofu.index (ZUUR index fetch & sandboxed Lua parse)
Security: whitelist sandbox (NOT blacklist)
The task spec suggested stripping dangerous globals (blacklist: io=nil, os=nil, ...). We chose the ZETA lib/sandbox.lua whitelist approach instead because:
- Future-proof: New dangerous globals added to Lua (e.g.
rawlenin 5.3) are blocked by default — the index only sees explicitly allowed functions. - Proven: ZETA has used this sandbox in production; it has passed security review.
- Allowed globals:
_VERSION,assert,error,ipairs,next,pairs,pcall,select,tonumber,tostring,type,rawequal,rawget,rawset,setmetatable,getmetatable,unpack,string,table,math. NOTHING else.
Lua 5.1 vs 5.2+ sandbox differences — CRITICAL for future tasks
The sandbox must work on both Lua 5.1/5.2+/5.5.x. The key difference:
| Version | Compile API | Set environment |
|---|---|---|
| 5.1 / LuaJIT | loadstring(src, name) |
setfenv(chunk, env) |
| 5.2+ (incl. 5.5.1) | load(src, name, "t", env) |
env is 4th arg to load |
Detection: if setfenv then ... else ... end. In Lua 5.2+, setfenv was removed entirely, so if setfenv is nil (falsy) on 5.2+ — this is the canonical cross-version detection pattern.
What NOT to mix up: loadfile is the FILE loader (takes a path). load/loadstring are STRING loaders. The sandbox loader script uses io.open + f:read("*a") to read the index file content, then load to compile it with the sandbox env. This is deliberately explicit — it's exactly what ZETA's sandbox.loadfile does.
System: The system has Lua 5.5.1 (lua -v → Lua 5.5.1). Our sandbox script uses the 5.2+ branch (load(src, name, "t", env)). The 5.1 branch is retained for portability.
std.process import conflicts
import std.process; (unqualified) pulls in std.process.Config, which conflicts with tofu.config.Config. Fix: use selective imports — import std.process : execute, ProcessException, thisProcessID;. This affected both index.d and fetch.d.
std.json API notes (Phobos)
parseJSON(string)→JSONValue(return type, notauto)JSONValue.type→JSONTypeenum:JSONType.array,JSONType.object(NOTobject_).arrayproperty →@system(must wrap in@trusted).str→ string field on object values- Indexing:
entry["name"]→JSONValue
D heredoc syntax (q"DELIM ... DELIM")
- Opening:
q"SCRIPT(no closing quote on same line — rest of line must be blank) - Closing:
SCRIPT"at start of a new line - Then
;on the same line after"to end the statement
Concurrent agent issues
Both binary.d and fetch.d were created by parallel agents and had:
Confignaming conflicts (unqualifiedimport std.process;)versionkeyword used as variable name (binary.d— already fixed by agent)- Missing
canFindimport (fetch.d) - Different test server patterns (
spawnProcessvsspawn— fetch.d)
When testing, temporarily exclude broken sibling modules with mv file.d file.d.skip.
JSON escaping in Lua
Must escape \\, \", \n, \r, \t in string values before embedding in JSON. Order matters: escape backslash BEFORE quote, or doubled backslashes leak:
s = s:gsub("\\", "\\\\"):gsub('"', '\\"')
Test patterns ported from http.d
Reused the one-shot TCP responder pattern (bindAndSpawn, oneShotResponder, httpResponse) directly from tofu.http test harness. The same spawn+accept+send pattern works for index serving.
Malicious index verification
The sandbox test (test 2) creates a sentinel file, serves an index containing os.execute("rm -rf /"), verifies that fetchIndex throws IndexException, then asserts the sentinel file STILL EXISTS. This is the security-critical validation that the sandbox actually blocks RCE. The error message from Lua is "attempt to call a nil value (global 'os')" because os is absent from the sandbox env — caught by pcall and reported as LUA_ERROR:runtime error: ....
Task 11 — tofu.deps (topological dependency tree builder)
Architecture
- Module
tofu.depsdepends on:tofu.types(Recipe, DepConstraint, TypesException, DepOp). - Port of ZETA
lib/deps.lua:19-74depth-first resolution algorithm. DepNodestores:name,constraints(parsedDepConstraint[]fromrecipe.deps),recipePath.DepTreeis a flatDepNode[]in topological order (deps before dependents, target last).
Algorithm (exact port from ZETA deps.lua)
resolveDepTree(targetName, getRecipe)— accepts ascope Recipe function(string) @safe(the testability seam).- Nested
walk(name, ref chain)does the depth-first walk:- Cycle detection: check
name in inProgress— if so, appendnameto chain and throwDepExceptionwith full chain:"dependency cycle: A -> B -> C -> A". - Memoization: if
name in done, return immediately (skip already-resolved). - Fetch: call
getRecipe(name)— exceptions propagate (e.g. missing package). - Mark:
inProgress[name] = true,chain ~= name. - Recurse: for each dep spec in
recipe.deps, parse viaDepConstraint.parse(depSpec)and recurse onconstraint.name. - Pop & mark:
chain = chain[0 .. $ - 1],inProgress.remove(name),done[name] = true. - Build node: create
DepNodewith all deps parsed intoconstraints, append toorder.
- Cycle detection: check
Key design decisions
functionnotdelegate: D lambdas without captures becomefunctionpointers. Usingfunctionin the parameter type means tests pass without forcing captures. Production callers can pass module-level functions or free functions; if state is needed, use astaticfunction that accesses module state.ref string[] chain: The Lua reference implementation uses a mutable shared table for the chain. In D, passingstring[]byrefachieves the same semantics — modifications (append, pop via slice) are visible to the caller across recursive calls.- Cycle message: Build manually with a
forloop rather than importingstd.array.join— ensures@safecompatibility and avoids Phobos auto-decoding issues. indexOf: Importedstd.string : indexOffor string containment checks in cycle-message assertions.
Test cases (7/7 pass)
- A deps=[B, C>=1.0] — constraint parsing verified (B unconstrained, C ge 1.0); both B,C before A.
- A deps=[B], B deps=[C] — linear chain → [C, B, A].
- A deps=[A] — self-cycle → DepException "A -> A".
- A deps=[B], B deps=[A] — cycle → DepException with both A and B in message.
- Missing dep (getRecipe throws) — TypesException propagates.
- Diamond A→B,C, B→D, C→D — D appears once; order [D, B, C, A].
- Leaf (empty deps) — single node, no constraints.
Build verified
dub buildpasses with warnings-as-errors.dub testpasses — all 10 modules, including deps.d's 7 unittests.- Standalone dmd compilation with
-unittestalso passes.
Task 9 — tofu.cache (recipe cache with version-based staleness)
Architecture
- Module
tofu.cachedepends on:tofu.types(CacheManifest),tofu.config(Config, load),tofu.vercmp(compare),tofu.log(logWarn). - Five public functions:
cacheRecipe,isRecipeStale,cleanRecipeCache,clearBuildCache,clearRecipeCacheAll. - Cache format:
.tofu-cache.jsonincfg.recipesCacheDir(name), containing{"name":"<name>","ver":"<indexVersion>","fetchedAt":<unix-ts>}.
@safe / @trusted architecture
- All public functions marked
@safe. - Filesystem operations (
exists,mkdirRecurse,remove,rmdirRecurse,rename,write,readText) andparseJSONare isolated in single-line@trustedhelpers (fExists,fMkdirRecurse,fRemove,fRmdirRecurse,fRename,fWrite,fReadText,fParseJSON). - Follows the same pattern as
tofu.config,tofu.http,tofu.fetch,tofu.index.
Atomic write (tmp + rename)
- Ported the
.part→ rename pattern fromhttp.d downloadFileImpl. - Manifest written to
.tofu-cache.json.tmp, existing manifest removed, thenrename(tmp, final). rmdirRecursefromstd.fileused for recursive directory cleanup — available since D 2.104, present on DMD 2.112.
Clock.currTime() not currTime()
- In DMD 2.112 / Phobos,
currTimeis astaticmethod ofstruct Clock, not a free function. - Correct import:
import std.datetime : Clock;, usage:Clock.currTime().toUnixTime(). - Attempting
import std.datetime.systime : currTime;orimport std.datetime : currTime;both fail — the symbol is not exported at module level.
JSON writing: q"..." token string gotcha
- D's
q"DELIM ... DELIM"heredoc syntax requires the opening delimiter line to end with nothing after the delimiter:q"EOSfollowed by newline, content, thenEOS"on its own line. - Attempted
q"{"...}" — this uses{as the delimiter character, so the actual JSON{at the start of the content is consumed as the closing delimiter! Resulted in content missing the outer braces → invalid JSON. - Fix: used
q"EOS(multi-line heredoc) with{"name":"...on the content line. std.format.formatis used to interpolate values into the JSON template.
Staleness: vercmp.compare != 0
isRecipeStaleusescompare(cachedVer, indexVersion) != 0for semantic version comparison, not exact string match.- RPM-style comparison: leading zeros ignored (
"01.05"=="1.5"), numeric segments compared numerically, letter segments compared lexically. - Missing cache → true (stale). Corrupted JSON →
logWarn+ true. Read failure →logWarn+ true. isRecipeStalenever throws — all error paths caught and handled.
Test strategy
- Pure file ops, no network. Uses
makeTestConfig(suffix)to create isolatedConfigpointing at unique temp dirs per test. scope(exit) cleanupTestDir(cfg.cacheDir)ensures temp dirs are removed after each test.- 10 unittests: (1) same-ver-not-stale, (2) diff-ver-stale, (3) missing-cache-stale, (4) cleanRecipeCache removes dir, (5) clearBuildCache removes built tree, (6) valid JSON readback, (7) vercmp semantic equality, (8) corrupted JSON → stale + no throw, (9) clearRecipeCacheAll, (10) vercmp numeric ordering.
- Pre-existing
deps.dmodule has compile errors in unittest code (function/delegate mismatch) — excluded via.skiprename fordub test.dub buildpasses clean with all modules.
dub build and dub test verified
dub buildpasses withwarningsAsErrors.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.resolvedepends 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
binaryCheckdelegate is the testability seam — production wirestofu.binary.checkBinaryVersion; tests inject mocks.
Algorithm — three-pass resolution
- Pass 1 (aggregate): Walk all nodes, collect all
DepConstraint[]per dep name intoallConstraintsAA. Same dep constrained by multiple parents → all constraints aggregated. - 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)").
- Test ALL aggregated constraints via
- 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 unittestblocks are inferred asfunctionpointers with inferred attributes (pure nothrow @nogc @safe). A parameter typed asdelegatecannot accept afunctioneven if attributes match. - Fix: Use the explicit
delegatekeyword: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
cfgto callcheckBinaryVersion(name, constraint, cfg). A function pointer cannot carry captured state. Thedelegatekeyword in tests matches the production usage pattern. - Contrast with
deps.dwhereresolveDepTreeusesfunction— that works because recipe fetching doesn't need captured state (the production caller uses module-level functions).
std.string.indexOf import
indexOfis NOT in the default namespace. Must explicitlyimport std.string : indexOf;to use on string in assertions. Same pattern asdeps.d.
Test cases (8/8 pass)
- dep libfoo>=2.0, binary 2.1 → binary
- dep libfoo>=2.0, binary 1.9, recipe in index → recipe
- dep libfoo>=2.0, binary 1.9, NOT in index → ResolveException ("neither binary nor recipe")
- unconstrained dep with binary → binary
- unconstrained dep without binary but in index → recipe
- multiple constraints on same dep, one unsatisfied → recipe (dedup + strictest wins)
- root marked recipe (single-node tree)
- empty tree → empty result
Build verified
dub buildpasses withwarningsAsErrors.dub testpasses — all 11 modules (config, log, types, vercmp, http, index, fetch, cache, binary, deps, resolve) pass unittests.- No D LSP server configured for
.dfiles — diagnostics verified via compiler. - Evidence logged to
.omo/evidence/task-12-tofu-core.log.
Task 13 — tofu.resolve.generateBuildPlan (build plan from constrained tree)
Architecture
- New function
generateBuildPlan(constrained, tree, cfg, fetchRecipe = null)intofu.resolve. - Input:
ConstrainedNode[]fromconstrainDepTree(already in topological order, deps-first, root last). TheDepTreeparameter is preserved for future context but not used for logic — ordering comes from the constrained array. - Output:
BuildPlanwithSource.recipeentries for every recipe-sourced node (binary nodes excluded). - The
fetchRecipedelegate (scope string delegate(string) @safe) is the testability seam. Defaultnullmeans "no fetcher available" — missing recipes throwFetchException. - Production wires:
delegate (string name) @safe { return tofu.fetch.fetchRecipe(name, cfg); }.
Algorithm
- Iterate constrained nodes in order (preserves topological ordering).
- Skip nodes with
source == DepSource.binary— Zeta handles binary deps. - For recipe nodes: compute cache path via
cfg.recipesCacheDir(name) ~ "/" ~ name ~ ".recipe". - Check
std.file.exists(path)via@trustedwrapper. - Missing → if
fetchRecipedelegate provided, call it; else throwFetchException("recipe not cached and no fetcher provided"). - Add to
BuildPlanviaplan.add(name, recipePath, Source.recipe). - Empty plan → log
"nothing to build (all binary)". - Non-empty → log
"build plan: N packages"vialogOk.
@safe / @trusted architecture
generateBuildPlanis@safe.- Only
std.file.existsrequires@trustedwrapper — inline lambda() @trusted { return exists(path); }(). - Follows existing patterns from
fetch.d,cache.d,index.d.
FetchException reuse
- Uses
tofu.fetch.FetchException(imported viaimport tofu.fetch;). No new exception class needed — the message"recipe not cached and no fetcher provided"is distinct and searchable.
New imports in resolve.d
import tofu.config;— forConfigtype (parameter in function signature).import tofu.fetch;— forFetchException.import std.file : exists;— for recipe cache existence check.import tofu.log;already hadlogInfo; now also useslogStep,logOk.
Test strategy (6 new unittests, numbered 9–14)
- Reused the temp-dir +
scope(exit)cleanup pattern from other modules. version(unittest)block with@trustedhelpers:createCachedRecipe(dir, name),testTempDir(suffix),testRmdir(path),testConfig(cacheDir).- Test (9): [C(recipe), B(binary), A(recipe)] → plan [C, A] — B excluded, order preserved.
- Test (10): missing cache + fetch delegate → fetch called, returned path used in plan.
- Test (11): missing cache + no fetch delegate → FetchException thrown.
- Test (12): all binary → empty plan.
- Test (13): root included even when dep is binary.
- Test (14): recipe files exist in cache → fetch delegate NOT called (delegate throws assert on invocation).
scope on constrained parameter
scope const ConstrainedNode[] constrained— DMD 2.112 requiresscopeon array/class reference parameters for@safeinference when the function does not escape them. Same pattern asconstrainDepTree(which usesscope const PackageIndex[] index).
Build verified
dub testpasses — all 11 modules, including 6 new generateBuildPlan unittests.dub buildpasses withwarningsAsErrors.- Pre-existing breakage in
build.d(parallel task 15 artefact) excluded via.skiprename for testing — NOT caused by task 13. - Evidence logged to
.omo/evidence/task-13-tofu-core.log.