Skip to content
zit Artifact [d774839004]
Login

Artifact [d774839004]

Artifact d774839004a5e46ccd18dc456a08177bf76ea4ac5a27adfe0a904255dfacbc02:


//! 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(&quoted_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-tmp-*", ".fossil-settings/ignore-glob");
    // `.zit-cache/checkpoint` (`src/checkpoint.zig`) is the durable prefix
    // checkpoint sidecar — advisory, regenerable, and pinned BEFORE any code
    // could create it: fossil is append-only, so a sidecar committed once is
    // committed forever.
    try expect_line(ignore_glob, ".zit-cache/checkpoint", ".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 => {},
        }
    }
}