Artifact 4c0b8e9f6e330392f5e3780526bec7f1ee3103a90fc76bf696e324cc4561c793:
- File
src/packaging_test.zig
— part of check-in
[122998a428]
at
2026-07-18 20:12:07
on branch release-0.1
— Add BSD-2-Clause LICENSE, ship it in .paths, drop scratch file, ignore the pattern
A release without a license grants nobody any rights, so this is the legally gating step of the 0.1.0 sequence. zit is an independent reimplementation from the Fossil RFCs rather than a derivation of Fossil source, so the choice was open; BSD-2-Clause matches the Fossil heritage and the systems-Zig ecosystem.
What changed:
- LICENSE at the repo root, BSD 2-Clause, copyright Daniel Mestas 2026. - build.zig.zon .paths now names LICENSE, so the file actually ships inside a fetched package instead of only existing in the checkout. - The leftover commit-description-413190fb92be991f.txt scratch file is gone and .fossil-settings/ignore-glob carries commit-description-* so the class of file cannot drift back in. - src/packaging_test.zig pins the above as executable facts: LICENSE content, LICENSE named inside the manifest's brace-matched .paths block, a line-anchored ignore-glob pin, and a negative-space pin asserting every repo-root path build.zig embeds is also named in .paths. These acceptance criteria are file-structure facts, which rot silently because nothing executes them; the battery alone cannot catch a .paths regression. - build.zig grows build_unit_test_module: the pinned repository files attach as anonymous imports to a test-only compile of root.zig, never to the exported zit module. Attaching them to zit made every consumer's build depend on files outside .paths and a fetched package failed on a missing .fossil-settings/ignore-glob before compiling a line. The negative-space pin exists to catch exactly that recurrence. - docs/RELEASE-0.1.0-PLAN.md, the plan of record for the 0.1.0 release, is committed alongside the first step it describes.
Test evidence: zig build and zig build test green in the working checkout. This commit is additionally gated on a clean-checkout proof — fossil open into a fresh temp directory, then zig build and zig build test there — which is the check that catches a file referenced but never added.
No borrowed techniques in this change, so no attribution is owed. (user: dmestas, size: 12684) [annotate] [blame] [check-ins using] [more...]
//! 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"); /// 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"}; /// 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; } /// 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: usize = 0; var checked: usize = 0; while (std.mem.indexOfPos(u8, build_source, scan, ".path(\"")) |hit| { const start = hit + ".path(\"".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: `break` here would silently skip the rest of the // scan, and a truncation after four passing checks slips past the // `checked < 4` vacuity guard below. 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; }; scan = end + 1; const root = root_component(build_source[start..end]); 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; } } 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 "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"); }