Skip to content
zit Artifact [0697e7e19a]
Login

Artifact [0697e7e19a]

Artifact 0697e7e19ac575d0b09f957256a90de524192b03128b15210bd37fec767097a4:


//! 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(&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;
    }
}

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");
}