Artifact b06cb2ef2add944ad0599c99026fadea3abddf2c4dc4c13fb1dc8ed368626e84:
- File
src/packaging_test.zig
— part of check-in
[f1ac625093]
at
2026-08-10 17:25:19
on branch sub-manifest
— control files: registry, provenance opt-in, one scratch prefix, advisory dir
Tasks 1-5 of the control-file consolidation plan. The checkout root goes from twelve zit-owned names to six live ones; the authoritative-tier container (task 6) and legacy removal (task 7) are deliberately not in this change.
Registry: control_paths now carries every reserved root name with its tier -- authoritative, advisory, scratch, legacy -- and is the single authority the case table, the extras exclusions and the ignore-glob coverage pin all derive from or test against. A name added without its glob line or case coverage is a test failure, proven by mutation. The pin caught two live gaps on arrival: a crashed update's transaction directory had no ignore-glob line, and a crashed import's staging directory was never reserved at all.
import-git --provenance: the per-object map and the source ref manifest are now instrumentation, published only on request. The default destination is the repository and its checkout record -- fossil's own shape. Both files are still written and verified in staging every run; without the flag they leave with the raw generation, and the staging manifest check enforces whichever set was chosen.
Scratch: one prefix. .zit-tmp-txn- (update transactions), .zit-tmp-import- (import staging), .zit-tmp-pack (pack scratch; name only, the truncate-reuse and rewrite-exclusion lock protocol are untouched). One registry prefix row and one glob line cover the tier including names that do not exist yet.
Advisory: .zit-cache is a directory -- repo (derived-state cache) and vstat (materialization plan), with the tree-checkpoint's member reserved. A legacy cache FILE is served through a read fallback and migrated by the first write via rename through a scratch name: the derivation survives with no bytes copied and no re-derive. Verified on a 66k-file pkgsrc checkout: identical SHA-256 across migration of a 35.8MB cache, fallback reads at parity, no-op update 7.69s vs 7.74s.
Legacy spellings of every renamed or relocated name stay reserved and ignore-globbed until the removal release. (user: dmestas, size: 24427) [annotate] [blame] [check-ins using]
//! Packaging-hygiene pins for the shipped source distribution. //! //! These are structural assertions over repository *files*, not over runtime //! behavior: the release checklist items they encode (a license exists, the //! license ships in the package, the scratch-file class stays ignored) are the //! kind that rot silently because nothing executes them. Embedding the files //! into the unit-test binary makes them compile-time facts instead — a //! `zig build test` failure is the only way they can regress. //! //! The embedded files arrive as anonymous imports wired in build.zig's //! `build_unit_test_module`, which builds a test-only module precisely so these //! repo-root inputs never attach to the exported `zit` module: a consumer //! fetching the package gets exactly `.paths`, and a public module depending on //! a file outside `.paths` fails their build before it compiles a line. //! //! Deliberately NOT pinned here: the absence of any particular scratch file. //! A comptime pin cannot assert a file is missing, and enumerating today's //! stray filenames would be a list that goes stale. The versioned ignore-glob //! asserted below is the durable mechanism for that class; `fossil extras` //! before a commit is the operational check. //! //! Those two mechanisms trade against each other, deliberately: every pattern //! added to the ignore-glob is a filename class `fossil extras` stops //! reporting. `commit-description-*` is editor scratch that can only ever be //! noise, so silencing it sharpens the check rather than blunting it — but the //! next glob added deserves the same explicit judgment, not a reflex. const std = @import("std"); const control_paths = @import("control_paths.zig"); /// The repository root, wired in build.zig's `build_unit_test_module`. Only the /// version-control pin below uses it; everything else here is a compile-time /// fact about embedded file contents. const repo_options = @import("repo_options"); /// The shipped files this module pins, embedded through build.zig's anonymous /// imports (they live above `src/`, outside the module root). const license_text = @embedFile("license_text"); const build_manifest = @embedFile("build_manifest"); const ignore_glob = @embedFile("ignore_glob"); const build_source = @embedFile("build_source"); /// `std.mem.indexOf` with a failure message that names what was missing and /// where it was looked for. A bare `testing.expect(indexOf(..) != null)` prints /// only `expected true`, which tells a future reader neither the file nor the /// absent substring. fn expect_contains(haystack: []const u8, needle: []const u8, where: []const u8) !void { if (std.mem.indexOf(u8, haystack, needle) != null) return; std.debug.print("\npackaging pin: {s} does not contain \"{s}\"\n", .{ where, needle }); return error.PackagingPinMissing; } /// True when `haystack` has a whole line equal to `needle`, ignoring leading /// and trailing horizontal whitespace. An unanchored substring search would be /// satisfied by a commented-out or partial line, which the consumer of the file /// (fossil, here) does not honor. fn contains_line(haystack: []const u8, needle: []const u8) bool { var lines = std.mem.splitScalar(u8, haystack, '\n'); while (lines.next()) |line| { if (std.mem.eql(u8, std.mem.trim(u8, line, " \t\r"), needle)) return true; } return false; } fn expect_line(haystack: []const u8, needle: []const u8, where: []const u8) !void { if (contains_line(haystack, needle)) return; std.debug.print("\npackaging pin: {s} has no line equal to \"{s}\"\n", .{ where, needle }); return error.PackagingPinMissing; } /// The interior of build.zig.zon's `.paths = .{ ... }` field, brace-matched. /// Asserting against the whole manifest text would let a name that appears in a /// comment, a dependency name, or any other field satisfy a `.paths` claim. /// /// The matcher counts braces literally: it does not skip string literals or /// `//` comments. A brace inside either, within the `.paths` block, would /// mis-terminate the extracted region. That block holds only quoted path /// entries today and a path containing a brace is not a thing we ship, so the /// simpler matcher is the deliberate choice — stated here rather than assumed. fn manifest_paths_block() ![]const u8 { const marker_at = std.mem.indexOf(u8, build_manifest, ".paths") orelse { std.debug.print("\npackaging pin: build.zig.zon has no `.paths` field\n", .{}); return error.PackagingPinMissing; }; const open = std.mem.indexOfScalarPos(u8, build_manifest, marker_at, '{') orelse { std.debug.print("\npackaging pin: build.zig.zon `.paths` has no opening brace\n", .{}); return error.PackagingPinMissing; }; // Loop invariant carrying `depth`'s non-underflow: the scan starts *on* the // opening brace, so the first iteration increments before any decrement can // run. `indexOfScalarPos` guarantees it; assert rather than leave a future // reader to re-derive it. std.debug.assert(build_manifest[open] == '{'); var depth: usize = 0; var i = open; while (i < build_manifest.len) : (i += 1) { switch (build_manifest[i]) { '{' => depth += 1, '}' => { depth -= 1; if (depth == 0) return build_manifest[open + 1 .. i]; }, else => {}, } } std.debug.print("\npackaging pin: build.zig.zon `.paths` block is unterminated\n", .{}); return error.PackagingPinMissing; } /// Repo-root entries build.zig may reference without being named in `.paths`, /// because they feed the *test-only* module and never the exported `zit` one. /// Anything reaching a shipped artifact must be in `.paths`; this is the /// deliberate, reviewed set of exceptions. const test_only_roots = [_][]const u8{ ".fossil-settings", ".github" }; /// Whether `haystack` contains `needle` starting at an identifier boundary — /// the preceding byte must not itself be an identifier character. Plain /// substring search finds `mod.addAnonymousImport` inside /// `test_mod.addAnonymousImport`, which is the false positive this avoids. /// /// `.` is deliberately NOT an identifier character here: build.zig reaches the /// exported module as `env.mod` at nearly every use site, so treating the dot /// as part of the identifier would make `env.mod.addAnonymousImport` — the form /// a real regression would take — invisible to this matcher. Suffix matches /// like `test_mod.` stay excluded by the explicit `_` term. fn contains_at_word_start(haystack: []const u8, needle: []const u8) bool { var scan: usize = 0; while (std.mem.indexOfPos(u8, haystack, scan, needle)) |hit| { scan = hit + 1; if (hit == 0) return true; const prev = haystack[hit - 1]; const is_ident = std.ascii.isAlphanumeric(prev) or prev == '_'; if (!is_ident) return true; } return false; } /// Iterates the `b.path("...")` literals build.zig declares, yielding each /// referenced path. Two pins consume it — the `.paths` pin (does this ship?) and /// the version-control pin (does this exist for anyone else?) — and a single /// scanner is what keeps them asking about the same set of files. /// /// Text matching, as everywhere in this module: a path assembled at runtime /// rather than written as a literal is invisible here. build.zig writes them all /// literally today. const BuildPathScan = struct { const marker = ".path(\""; scan: usize = 0, fn next(self: *BuildPathScan) !?[]const u8 { const hit = std.mem.indexOfPos(u8, build_source, self.scan, marker) orelse return null; const start = hit + marker.len; const end = std.mem.indexOfScalarPos(u8, build_source, start, '"') orelse { // Unreachable while build.zig compiles — an unterminated string // literal would not survive the build that produced this test. Fail // loudly anyway: returning null here would silently truncate the // scan, and a truncation after a few passing checks slips past the // vacuity guards its callers rely on. std.debug.print("\npackaging pin: build.zig has an unterminated b.path(\"...) literal " ++ "at byte offset {d}; the scan cannot continue\n", .{start}); return error.PackagingPinMissing; }; self.scan = end + 1; return build_source[start..end]; } }; /// The first path component of a build-graph path (`src/root.zig` → `src`); /// `.paths` names roots, not individual files under them. fn root_component(path: []const u8) []const u8 { const slash = std.mem.indexOfScalar(u8, path, '/') orelse return path; return path[0..slash]; } test "LICENSE is BSD-2-Clause and carries the project copyright" { try expect_contains(license_text, "BSD 2-Clause License", "LICENSE"); try expect_contains(license_text, "Copyright (c) 2026, Daniel Mestas", "LICENSE"); // The two substantive clauses and the warranty disclaimer, so a truncated // or placeholder LICENSE cannot pass. try expect_contains(license_text, "Redistributions of source code must retain", "LICENSE"); try expect_contains(license_text, "Redistributions in binary form must reproduce", "LICENSE"); try expect_contains(license_text, "AS IS", "LICENSE"); } test "LICENSE ships in the package: build.zig.zon .paths names it" { // A package consumer fetching the tarball gets exactly `.paths`; a license // that exists in the repo but is absent from `.paths` does not ship. Scoped // to the `.paths` block so the bare word LICENSE elsewhere in the manifest // cannot stand in for the entry. try expect_contains(try manifest_paths_block(), "\"LICENSE\"", "build.zig.zon `.paths`"); } test "every repo-root path build.zig embeds is named in .paths" { // The negative-space pin for the packaging class as a whole. A `b.path(...)` // in build.zig reaching a file outside `.paths` builds fine in this tree and // fails for every consumer fetching the package, with a cache-hash error // naming the file but not the cause. Enumerating build.zig's own path // references keeps the two files honest with each other; without this pin // only the LICENSE entry was ever asserted. const paths_block = try manifest_paths_block(); var scan: BuildPathScan = .{}; var checked: usize = 0; while (try scan.next()) |path| { const root = root_component(path); for (test_only_roots) |allowed| { if (std.mem.eql(u8, root, allowed)) break; } else { checked += 1; var quoted_buf: [256]u8 = undefined; const quoted = std.fmt.bufPrint("ed_buf, "\"{s}\"", .{root}) catch { std.debug.print("\npackaging pin: build.zig references a path whose root component " ++ "is longer than {d} bytes (\"{s}\"); widen quoted_buf\n", .{ quoted_buf.len, root }); return error.PackagingPinMissing; }; try expect_contains(paths_block, quoted, "build.zig.zon `.paths`"); } } // A scan that matched nothing would pass vacuously; build.zig references // src/root.zig, src/main.zig, LICENSE and build.zig.zon at minimum. if (checked < 4) { std.debug.print("\npackaging pin: only {d} b.path(...) references found in build.zig; " ++ "the scan pattern has drifted from the source\n", .{checked}); return error.PackagingPinMissing; } } /// True when `listing` — one path per line, as `fossil ls` prints it — has a /// line naming exactly `path`. fn listing_names(listing: []const u8, path: []const u8) bool { var lines = std.mem.splitScalar(u8, listing, '\n'); while (lines.next()) |line| { if (std.mem.eql(u8, std.mem.trim(u8, line, " \t\r"), path)) return true; } return false; } test "every path build.zig embeds is version-controlled" { // The pin the `.paths` check above cannot make. `test_only_roots` grants a // *packaging* exemption — "this file feeds the test module, so it need not // ship in the tarball" — and nothing stopped that from being read as a // *tracking* exemption too. It is not one: a build input that exists on one // developer's disk and in no commit makes every clean checkout fail to // build, with a `failed to check cache: ... FileNotFound` error that names // the file but not the cause. That happened to .github/workflows/ci.yml. // // "Tracked" is a property of the repository, so unlike the rest of this // module the check has to ask fossil rather than read an embedded file. // `fossil ls` lists the checkout's files including ones added but not yet // committed, which is what makes this usable as a pre-commit gate: the // local commit guard runs `zig build test`, so an unadded build input fails // the commit that would have introduced it. // // The check skips rather than fails where it cannot run — no fossil on // PATH, or a working tree that is not a fossil checkout (GitHub Actions // checks out git, so this pin is a local gate by construction). A skip is // honest about what was verified; failing there would only teach people to // ignore it. // One constant, used by both the limit and the message that reports it // breaching: two literals would let the diagnostic name a bound that is not // the bound in force. const listing_limit = 4 << 20; const listing = blk: { const result = std.process.run(std.testing.allocator, std.testing.io, .{ .argv = &.{ "fossil", "ls" }, .cwd = .{ .path = repo_options.root }, .stdout_limit = .limited(listing_limit), }) catch |err| switch (err) { // A listing larger than the limit is not "fossil is unavailable" — // it is this check's own bound being wrong, and collapsing it into // the skip below would turn the pin off permanently with no // diagnostic. At 73 tracked files the headroom is ~1000x, so this // is a tripwire rather than an expected path. error.StreamTooLong => { std.debug.print("\npackaging pin: `fossil ls` produced more than {d} bytes; " ++ "the repository outgrew this check's stdout_limit. Raise it — do not " ++ "let the version-control pin skip itself into silence.\n", .{listing_limit}); return error.PackagingPinMissing; }, // The legitimate skip, and only it: no `fossil` on PATH, or a root // that is not a directory. Those are the two ways "this machine // cannot answer the question" actually presents (GitHub Actions // checks out git, so this pin is a local gate by construction). error.FileNotFound, error.NotDir => return error.SkipZigTest, // Anything else is a fault in the check or the machine running it — // OutOfMemory, a failed fork, a permissions problem. Folding those // into the skip would disable the version-control pin exactly as // quietly as a missing binary does, which is the failure mode the // StreamTooLong arm above exists to refuse. else => { std.debug.print("\npackaging pin: `fossil ls` could not be run: {s}. That is not " ++ "\"fossil is unavailable\" — it is a fault in this check or the machine " ++ "running it, and skipping on it would turn the version-control pin off " ++ "silently.\n", .{@errorName(err)}); return error.PackagingPinMissing; }, }; std.testing.allocator.free(result.stderr); const clean = switch (result.term) { .exited => |code| code == 0, else => false, }; if (!clean) { std.testing.allocator.free(result.stdout); return error.SkipZigTest; } break :blk result.stdout; }; defer std.testing.allocator.free(listing); // Vacuity guard, and the "is this really a checkout?" test in one: build.zig // is the file this scan reads, so a listing without it is not this repo. if (!listing_names(listing, "build.zig")) return error.SkipZigTest; var scan: BuildPathScan = .{}; while (try scan.next()) |path| { if (listing_names(listing, path)) continue; std.debug.print("\npackaging pin: build.zig reads \"{s}\", which fossil does not track. " ++ "Every clean checkout of this commit fails to build with a cache error naming " ++ "that file. Run `fossil add {s}` (and note that bare `fossil extras` omits " ++ "dotfiles — use `fossil extras --dotfiles`).\n", .{ path, path }); return error.PackagingPinMissing; } } test "the exported zit module takes no anonymous imports" { // The pin above allowlists `.fossil-settings` by root component, so it // cannot by itself catch the original blocker recurring: those embeds being // re-attached to the exported `zit` module (`mod` in build.zig) instead of // the test-only one. That shape builds green here and breaks every consumer. // Pinning the attachment site directly is what closes the gap. // // What the matcher does and does not see, precisely: it fires on // `mod.addAnonymousImport` preceded by a non-identifier byte, which covers // both the bare receiver and the qualified `env.mod.` form build.zig // actually uses at nearly every site. It stays silent if `mod` is renamed // in build.zig, so pair it with the clean-`.paths`-copy build check when // touching the module wiring. // // It can also misfire: a build.zig *comment* mentioning // `mod.addAnonymousImport` trips it, since this is text matching, not // parsing. That failure is loud and names the file, so diagnosing it costs // one look at build.zig — an acceptable trade for a pin that would // otherwise need a Zig parser to be exact. if (contains_at_word_start(build_source, "mod.addAnonymousImport")) { std.debug.print("\npackaging pin: build.zig attaches an anonymous import to the exported " ++ "`zit` module; repo-root embeds belong on the test-only module " ++ "(build_unit_test_module) or consumers fetching the package fail on " ++ "a file outside `.paths`\n", .{}); return error.PackagingPinMissing; } } test ".paths ships the RFCs without dragging in the working-docs directory" { // `.paths` names directories, and a Zig package is cut from the *working // tree* — the fossil ignore-glob does not govern it. So a bare "docs" entry // ships whatever happens to sit under docs/ at package time, including the // ignore-glob'd docs/plans/*.md working documents, which are internal and // must never leave the repository. // // Naming docs/rfcs directly is also what the repo convention already says // (0ad7fa92c6: "docs/ is for the RFCs only"); this pin makes the manifest // state that convention instead of relying on docs/ happening to stay clean. const paths_block = try manifest_paths_block(); try expect_contains(paths_block, "\"docs/rfcs\"", "build.zig.zon `.paths`"); // Entries are one-per-line and trailing-comma'd; a bare "docs" line is the // regression this pin exists to catch. if (contains_line(paths_block, "\"docs\",")) { std.debug.print("\npackaging pin: build.zig.zon `.paths` names bare \"docs\", which ships " ++ "the ignore-glob'd docs/plans working documents to package consumers; " ++ "name \"docs/rfcs\" instead\n", .{}); return error.PackagingPinMissing; } } test "the commit-description scratch-file class is ignored" { // `commit-description-<hex>.txt` files are editor leftovers from // `fossil commit`; one of them was committed-adjacent at the repo root // before 0.1.0. The versioned glob keeps the whole class out of // `fossil extras` noise and out of an accidental `addremove`. try expect_line(ignore_glob, "commit-description-*", ".fossil-settings/ignore-glob"); } test "the editor-tooling classes are ignored" { // Two working-tree classes that must never enter an append-only history. // // `.agent-config/` accumulates per-session editor tooling traces at the // repo root. Being dotfiles, they are invisible to a bare `fossil extras` // — it skips dotfiles unless `--dotfiles` is passed — so for most of this // repository's life they were held out of commits by that default alone // and not by any rule. That is an accident, not a guard: one // `fossil addremove --dotfiles`, or any tool that does not share fossil's // default, lands the whole directory permanently. The glob makes the // intent explicit and survives a change to that default. // // `CLAUDE.local.md` carries this repository's working rules. It is // deliberately uncommitted — it is checkout-local operator configuration, // not a project artifact — and the same reasoning that keeps // `docs/plans/*` out applies to it. try expect_line(ignore_glob, ".agent-config/*", ".fossil-settings/ignore-glob"); try expect_line(ignore_glob, "CLAUDE.local.md", ".fossil-settings/ignore-glob"); } test "the crash-leftover working files are ignored" { // Two dotfile classes a run can leave behind when it dies partway. Both are // invisible to a bare `fossil extras` (it skips dotfiles), which is exactly // why they need a glob rather than vigilance. // // `.zit-dst-checkout-ws/` is the process directory `zig build dst` makes // for the crash-recovery sweeps' throwaway workspaces (`src/dst.zig`, // `checkout_root_name`). It is created at run start and removed by a // `defer` at run end, so it exists only while a sweep is in flight — but a // killed or crashed run leaves the whole tree, `.zit` stores and all. // // `.zit-tmp-pack` (`src/extras.zig`, `pack_tmp_filename`) is the fixed-name // temp `pack` and `shun --force` write the new generation into before the // atomic rename. The fixed name is deliberate — it is what stops temps // accumulating, since the next run truncates it — but a run that dies // between create and rename leaves one beside `.zit`. try expect_line(ignore_glob, ".zit-dst-checkout-ws/*", ".fossil-settings/ignore-glob"); try expect_line(ignore_glob, ".zit-pack-tmp", ".fossil-settings/ignore-glob"); // legacy spelling, until the removal release try expect_line(ignore_glob, ".zit-tmp-*", ".fossil-settings/ignore-glob"); // `.zit-vstat` (`src/update.zig`, `VStat.filename`) is the materialization // stat/plan sidecar every successful `open`/`update` writes beside `.zit`. // Advisory and disposable, but always present in a live checkout -- the // same class as the two above: a dotfile a run leaves that `fossil extras` // alone would never surface. try expect_line(ignore_glob, ".zit-vstat", ".fossil-settings/ignore-glob"); } test "every advisory and scratch registry entry has an ignore-glob line" { // The registry is the authority on which names zit strews beside `.zit`; // this pin makes "added a disposable file class, forgot the glob" a test // failure instead of a stranger's surprise. Authoritative names are // deliberately absent: those are the checkout's own state, and hiding // them from fossil would be hiding the working copy from its VCS. inline for (control_paths.reserved) |entry| { switch (entry.tier) { .advisory, .scratch => { const exact = contains_line(ignore_glob, entry.name); const starred = contains_line(ignore_glob, entry.name ++ "*"); if (!exact and !starred) { std.debug.print("\nregistry entry \"{s}\" ({s}) has no ignore-glob line\n", .{ entry.name, @tagName(entry.tier) }); return error.PackagingPinMissing; } }, .authoritative, .legacy => {}, } } }