a73x

tools/docscheck.zig

Ref:   Size: 21.1 KiB   History

//! The comment-discipline gate. Two tiers, both gates:
//!
//!   1. Every symbol a comment cites must resolve: a `module.symbol` rooted
//!      at a module of this repo, or a file reference with no foreign
//!      directory prefix. The name must appear in the indexed CODE.
//!   2. No project-history codenames — a bare milestone letter and number,
//!      or a phase and its ordinal. The EVENT ("the multi-session daemon")
//!      survives; the codename names nothing a reader can look up.
//!
//! Both tiers ask whether a comment still refers to something real. Neither
//! has an opinion about length: the byte-budget and comment-block ratchets
//! that used to live here rewarded compression, and what compressed English
//! turns into is metaphor (docs/decisions.md, 2026-08-31).
//!
//! --check is inspected; --index answers "does this name exist". Both are
//! build-graph file args, so the step re-runs when their CONTENTS change.
//! An empty group is fatal: a check that never ran is green forever.

const std = @import("std");

fn writeAll(fd: std.posix.fd_t, bytes: []const u8) !void {
    var off: usize = 0;
    while (off < bytes.len) off += try std.posix.write(fd, bytes[off..]);
}

fn err(comptime fmt: []const u8, args: anytype) void {
    var buf: [4096]u8 = undefined;
    const s = std.fmt.bufPrint(&buf, fmt, args) catch return;
    writeAll(std.posix.STDERR_FILENO, s) catch {};
}

fn usage() u8 {
    err("usage: docscheck --check FILE... --index FILE...\n", .{});
    return 2;
}

// ---------------------------------------------------------------- lexical

fn isWordByte(c: u8) bool {
    return std.ascii.isAlphanumeric(c) or c == '_';
}

/// Whole-line only: a trailing comment in src/ is overwhelmingly a wire
/// spelling inside a literal.
fn commentBody(line: []const u8) ?[]const u8 {
    const t = std.mem.trimLeft(u8, line, " \t");
    if (!std.mem.startsWith(u8, t, "//")) return null;
    return t;
}

/// The code half of a line, for the corpus: literal spans are blanked and
/// everything from an unquoted `//` on is cut off. A word that exists only
/// inside quotes is no evidence a symbol exists. Blanking IN PLACE is
/// deliberate — the corpus stores slices of these buffers.
fn codeOf(line: []u8) []u8 {
    // A `\\` line is multiline-string content to its end, quotes and `//`
    // included. There is no code on it to keep.
    if (std.mem.startsWith(u8, std.mem.trimLeft(u8, line, " \t"), "\\\\")) return line[0..0];
    var i: usize = 0;
    while (i < line.len) : (i += 1) switch (line[i]) {
        '/' => if (i + 1 < line.len and line[i + 1] == '/') return line[0..i],
        // One arm for both quote flavours: a `"` inside a char literal would
        // otherwise open a span that swallows the rest of the line.
        '"', '\'' => {
            const quote = line[i];
            const start = i;
            i += 1;
            while (i < line.len and line[i] != quote) : (i += 1) {
                if (line[i] == '\\') i += 1;
            }
            const end = @min(i + 1, line.len);
            @memset(line[start..end], ' ');
            i = end - 1;
        },
        else => {},
    };
    return line;
}

const keywords = [_][]const u8{
    "align",          "allowzero", "and",         "anyframe",  "anytype",   "asm",
    "async",          "await",     "break",       "callconv",  "catch",     "comptime",
    "const",          "continue",  "defer",       "else",      "enum",      "errdefer",
    "error",          "export",    "extern",      "false",     "fn",        "for",
    "if",             "inline",    "linksection", "noalias",   "noreturn",  "nosuspend",
    "null",           "opaque",    "or",          "orelse",    "packed",    "pub",
    "resume",         "return",    "struct",      "suspend",   "switch",    "test",
    "threadlocal",    "true",      "try",         "undefined", "union",     "unreachable",
    "usingnamespace", "var",       "volatile",    "while",     "void",      "bool",
    "type",           "usize",     "isize",       "anyerror",  "anyopaque", "comptime_int",
    "comptime_float",
};

fn isKeyword(tok: []const u8) bool {
    for (keywords) |k| if (std.mem.eql(u8, k, tok)) return true;
    // Primitive integer/float types are generated, not listed: u8, i32, f64…
    if (tok.len >= 2 and (tok[0] == 'u' or tok[0] == 'i' or tok[0] == 'f')) {
        var all_digits = true;
        for (tok[1..]) |c| if (!std.ascii.isDigit(c)) {
            all_digits = false;
        };
        if (all_digits) return true;
    }
    return false;
}

const Class = enum { check, phrase, flag, wire, enumlit, numeric, short, keyword, placeholder, other };

fn classify(tok: []const u8) Class {
    if (tok.len == 0) return .other;
    for (tok) |c| if (c == ' ' or c == '\t') return .phrase;
    if (tok[0] == '-') return .flag;
    for (tok) |c| switch (c) {
        '/', ':', '#', '?', '\\', '@', '%', '"', '\'', '(', ')', '[', ']', '{', '}', '<', '>', '|', '&', '*', '+', '=', ',', ';', '!', '~', '^', '$' => return .wire,
        else => {},
    };
    if (tok[0] == '.') return .enumlit;
    if (std.ascii.isDigit(tok[0])) return .numeric;
    if (tok.len < 3) return .short;
    if (isKeyword(tok)) return .keyword;
    var has_lower = false;
    for (tok) |c| if (std.ascii.isLower(c)) {
        has_lower = true;
    };
    if (!has_lower) return .placeholder;
    for (tok) |c| if (!isWordByte(c) and c != '.') return .other;
    return .check;
}

// ------------------------------------------------------------------ tiers

const Finding = struct {
    file: []const u8,
    line: usize,
    text: []const u8,
};

/// Tier 2's codename patterns; each returns the matched span's length at `i`,
/// or 0. `M<digits>` is uppercase-only, because lowercase `m8` would be a coin
/// flip. `Phase N` / `Task N` require the NUMBER: bare "phase" is domain
/// vocabulary here, since `cmd.phase` is a live field.
fn codenameAt(s: []const u8, i: usize) usize {
    // The browser-client milestone spells its letter with a word after the
    // dash, so both ends are checked or a real word would read as a codename.
    if (std.ascii.startsWithIgnoreCase(s[i..], "M-web")) {
        if ((i == 0 or !isWordByte(s[i - 1])) and
            (i + 5 == s.len or !isWordByte(s[i + 5]))) return 5;
    }
    // M<digit><digit?> as a milestone name.
    if (s[i] == 'M' and (i == 0 or !isWordByte(s[i - 1]))) {
        var n: usize = 1;
        while (i + n < s.len and n <= 2 and std.ascii.isDigit(s[i + n])) n += 1;
        if (n > 1 and (i + n == s.len or !isWordByte(s[i + n]))) return n;
    }
    // Phase N / Task N, plural or not, any case, optional a/b/c suffix.
    for ([_][]const u8{ "phases", "phase", "tasks", "task" }) |w| {
        if (!std.ascii.startsWithIgnoreCase(s[i..], w)) continue;
        if (i != 0 and isWordByte(s[i - 1])) continue;
        var j = i + w.len;
        const before_space = j;
        while (j < s.len and (s[j] == ' ' or s[j] == '\t')) j += 1;
        if (j == before_space or j >= s.len or !std.ascii.isDigit(s[j])) continue;
        while (j < s.len and std.ascii.isDigit(s[j])) j += 1;
        if (j < s.len and s[j] >= 'a' and s[j] <= 'c') j += 1;
        return j - i;
    }
    return 0;
}

/// Where a `.zig` reference sits: the basename, and the directory prefix before
/// it. `replica.zig/wasm_core.zig` yields an empty prefix for the second name,
/// because a prefix segment that is itself a .zig file is not a directory.
const ZigRef = struct { name: []const u8, prefix: []const u8 };

fn zigRefAt(s: []const u8, dot: usize) ?ZigRef {
    if (dot + 4 > s.len or !std.mem.eql(u8, s[dot .. dot + 4], ".zig")) return null;
    if (dot + 4 < s.len and isWordByte(s[dot + 4])) return null; // .zigzag
    var start = dot;
    while (start > 0 and isWordByte(s[start - 1])) start -= 1;
    if (start == dot) return null;
    var prefix: []const u8 = "";
    if (start > 0 and s[start - 1] == '/') {
        var p = start - 1;
        while (p > 0 and (isWordByte(s[p - 1]) or s[p - 1] == '.' or s[p - 1] == '-' or s[p - 1] == '/')) p -= 1;
        const cand = s[p .. start - 1];
        if (!std.mem.endsWith(u8, cand, ".zig")) prefix = cand;
    }
    return .{ .name = s[start .. dot + 4], .prefix = prefix };
}

/// Whitelisted rather than "anything under src/": an unrecognised prefix is a
/// path into another repo, and that is the whole discriminator.
const our_prefixes = [_][]const u8{
    "src",       "src/engine", "src/server", "src/client",   "src/tui",      "src/cli",
    "test",      "./src",      "./test",     "./src/engine", "./src/server", "./src/client",
    "./src/tui", "./src/cli",
};

fn prefixIsOurs(prefix: []const u8) bool {
    if (prefix.len == 0) return true;
    for (our_prefixes) |p| if (std.mem.eql(u8, prefix, p)) return true;
    return false;
}

// ------------------------------------------------------------------- main

pub fn main() !u8 {
    var gpa: std.heap.DebugAllocator(.{}) = .init;
    defer if (gpa.deinit() == .leak)
        err("docscheck: LEAK: allocations outlived deinit\n", .{});
    const alloc = gpa.allocator();

    const args = try std.process.argsAlloc(alloc);
    defer std.process.argsFree(alloc, args);

    var check_files: std.ArrayList([]const u8) = .empty;
    defer check_files.deinit(alloc);
    var index_files: std.ArrayList([]const u8) = .empty;
    defer index_files.deinit(alloc);

    var group: enum { none, check, index } = .none;
    var ai: usize = 1;
    while (ai < args.len) : (ai += 1) {
        const a = args[ai];
        if (std.mem.eql(u8, a, "--check")) {
            group = .check;
        } else if (std.mem.eql(u8, a, "--index")) {
            group = .index;
        } else switch (group) {
            .none => return usage(),
            .check => try check_files.append(alloc, a),
            .index => try index_files.append(alloc, a),
        }
    }
    // A gate handed nothing to gate is the "check that never ran" failure
    // mode, and it passes green forever. Refuse instead.
    if (check_files.items.len == 0 or index_files.items.len == 0) {
        err("docscheck: empty --check or --index group ({d} check, {d} index) — " ++
            "a gate with no inputs passes green forever\n", .{ check_files.items.len, index_files.items.len });
        return 2;
    }

    // The corpus: every word in src/ and test/, and every .zig basename.
    var words: std.StringHashMapUnmanaged(void) = .empty;
    defer words.deinit(alloc);
    var basenames: std.StringHashMapUnmanaged(void) = .empty;
    defer basenames.deinit(alloc);
    var bufs: std.ArrayList([]u8) = .empty;
    defer {
        for (bufs.items) |b| alloc.free(b);
        bufs.deinit(alloc);
    }

    for (index_files.items) |p| {
        const f = std.fs.cwd().openFile(p, .{}) catch |e| {
            err("docscheck: cannot open index file {s} ({s})\n", .{ p, @errorName(e) });
            return 2;
        };
        defer f.close();
        const src = try f.readToEndAlloc(alloc, 64 * 1024 * 1024);
        try bufs.append(alloc, src);
        try basenames.put(alloc, std.fs.path.basename(p), {});
        // CODE only, and `codeOf` is what makes that exact: a citation that
        // resolved against the very comment making it would pass every invented
        // name. Iterated by hand rather than with `splitScalar`, because
        // `codeOf` blanks in place and the lines must stay mutable.
        var ls: usize = 0;
        while (ls <= src.len) {
            const nl = std.mem.indexOfScalarPos(u8, src, ls, '\n') orelse src.len;
            const line = if (commentBody(src[ls..nl]) != null) src[ls..ls] else codeOf(src[ls..nl]);
            ls = nl + 1;
            var i: usize = 0;
            while (i < line.len) {
                if (!isWordByte(line[i]) or std.ascii.isDigit(line[i])) {
                    i += 1;
                    continue;
                }
                const s = i;
                while (i < line.len and isWordByte(line[i])) i += 1;
                try words.put(alloc, line[s..i], {});
            }
        }
    }

    var t1: std.ArrayList(Finding) = .empty;
    defer t1.deinit(alloc);
    var t2: std.ArrayList(Finding) = .empty;
    defer t2.deinit(alloc);
    var lines: std.ArrayList([]const u8) = .empty;
    defer lines.deinit(alloc);

    for (check_files.items) |p| {
        const f = std.fs.cwd().openFile(p, .{}) catch |e| {
            err("docscheck: cannot open {s} ({s})\n", .{ p, @errorName(e) });
            return 2;
        };
        defer f.close();
        const src = try f.readToEndAlloc(alloc, 64 * 1024 * 1024);
        try bufs.append(alloc, src);
        const name = std.fs.path.basename(p);

        lines.clearRetainingCapacity();
        var it = std.mem.splitScalar(u8, src, '\n');
        while (it.next()) |l| try lines.append(alloc, l);

        for (lines.items, 1..) |line, lineno| {
            const body = commentBody(line) orelse continue;

            // Tier 2 — history codenames.
            var i: usize = 0;
            while (i < body.len) {
                const n = codenameAt(body, i);
                if (n == 0) {
                    i += 1;
                    continue;
                }
                try t2.append(alloc, .{ .file = name, .line = lineno, .text = body[i .. i + n] });
                i += n;
            }

            // Tier 1a — .zig file references, backticked or bare.
            i = 0;
            while (i < body.len) : (i += 1) {
                const r = zigRefAt(body, i) orelse continue;
                if (!prefixIsOurs(r.prefix)) continue; // foreign tree, deliberate
                if (!basenames.contains(r.name))
                    try t1.append(alloc, .{ .file = name, .line = lineno, .text = r.name });
            }

            // Tier 1b — backtick-quoted symbol citations.
            i = 0;
            while (i < body.len) {
                if (body[i] != '`') {
                    i += 1;
                    continue;
                }
                const close = std.mem.indexOfScalarPos(u8, body, i + 1, '`') orelse break;
                const tok = body[i + 1 .. close];
                i = close + 1;
                if (classify(tok) != .check) continue;
                // A backticked `hosts.zig` is a file reference, and tier 1a
                // above already owns those. Left here it reads as
                // `module.symbol` and asks whether `zig` is a symbol.
                if (std.mem.endsWith(u8, tok, ".zig")) continue;
                // Only `module.symbol`, rooted at one of THIS repo's modules:
                // a bare identifier gives no way to tell "renamed last week"
                // from "belongs to a library this repo wraps" (decisions.md).
                var segs = std.mem.splitScalar(u8, tok, '.');
                const root = segs.next() orelse continue;
                var modfile: [64]u8 = undefined;
                if (root.len + 4 > modfile.len) continue;
                @memcpy(modfile[0..root.len], root);
                @memcpy(modfile[root.len..][0..4], ".zig");
                if (!basenames.contains(modfile[0 .. root.len + 4])) continue;
                while (segs.next()) |seg| {
                    if (classify(seg) != .check) continue;
                    if (!words.contains(seg)) {
                        // The citation is reported, not the segment, so a
                        // second unresolved segment would print it twice.
                        try t1.append(alloc, .{ .file = name, .line = lineno, .text = tok });
                        break;
                    }
                }
            }
        }
    }

    for (t1.items) |v|
        err("{s}:{d}: comment cites `{s}`, which appears nowhere in src/ or test/\n", .{ v.file, v.line, v.text });
    for (t2.items) |v|
        err("{s}:{d}: comment names the project-history codename \"{s}\" — " ++
            "name the event instead; codenames belong in docs/decisions.md\n", .{ v.file, v.line, v.text });

    if (t1.items.len + t2.items.len > 0) {
        err("docscheck: {d} unresolved citation(s), {d} codename(s)\n", .{ t1.items.len, t2.items.len });
        return 1;
    }
    return 0;
}

test "codenames are recognised, domain vocabulary is not" {
    // The corpus that set these rules: `cmd.phase` is a live field with ten
    // comment mentions, so bare "phase" must never fire.
    try std.testing.expect(codenameAt("M18 landed", 0) == 3);
    try std.testing.expect(codenameAt("pre-M18 payload", 4) == 3);
    try std.testing.expect(codenameAt("M-web Task 1", 0) == 5);
    try std.testing.expect(codenameAt("Phase 3c of the wall", 0) == 8);
    try std.testing.expect(codenameAt("Tasks 6-7", 0) == 7);
    try std.testing.expect(codenameAt("phase is back to at_prompt", 0) == 0);
    try std.testing.expect(codenameAt("the attach-or-create task on", 24) == 0);
    // A word that merely starts with M, and a symbol that embeds one.
    try std.testing.expect(codenameAt("MAX8 is a bound", 0) == 0);
    try std.testing.expect(codenameAt("frame_M18", 6) == 0);
    // A codename is a whole word at BOTH ends.
    try std.testing.expect(codenameAt("M-website copy", 0) == 0);
}

test "the corpus is code: quotes and trailing comments contribute nothing" {
    // `zig` is the one that mattered: it reached the corpus only through
    // path literals, and it is the tail of every module citation.
    var path = "    const p = b.path(\"src/main.zig\");".*;
    const c0 = codeOf(&path);
    try std.testing.expect(std.mem.indexOf(u8, c0, "path") != null);
    try std.testing.expect(std.mem.indexOf(u8, c0, "zig") == null);

    var tail = "    ov.setMode(); // ISIG stays off here".*;
    const c1 = codeOf(&tail);
    try std.testing.expect(std.mem.indexOf(u8, c1, "setMode") != null);
    try std.testing.expect(std.mem.indexOf(u8, c1, "ISIG") == null);

    // A `//` inside a literal is not a comment, and the code after it stays.
    var url = "    const u = \"quic://box\"; keepMe();".*;
    const c2 = codeOf(&url);
    try std.testing.expect(std.mem.indexOf(u8, c2, "keepMe") != null);
    try std.testing.expect(std.mem.indexOf(u8, c2, "quic") == null);

    // A quote inside a char literal must not open a span that eats the line.
    var ch = "    if (c == '\"') keepMe();".*;
    try std.testing.expect(std.mem.indexOf(u8, codeOf(&ch), "keepMe") != null);

    // A multiline-string line is content, `//` and all.
    var ml = "        \\\\<script>keepMe()</script>".*;
    try std.testing.expectEqual(@as(usize, 0), codeOf(&ml).len);
}

test "a CLI, wire or enum spelling is not a citation; a bare name is" {
    try std.testing.expectEqual(Class.check, classify("sendResync"));
    try std.testing.expectEqual(Class.check, classify("state_since_attach"));
    try std.testing.expectEqual(Class.check, classify("MsgType"));
    try std.testing.expectEqual(Class.check, classify("deinit"));
    try std.testing.expectEqual(Class.phrase, classify("zig build test"));
    try std.testing.expectEqual(Class.flag, classify("--sock"));
    try std.testing.expectEqual(Class.wire, classify("quic://"));
    try std.testing.expectEqual(Class.wire, classify("HOST#SESSION"));
    try std.testing.expectEqual(Class.enumlit, classify(".none"));
    try std.testing.expectEqual(Class.short, classify("fd"));
    try std.testing.expectEqual(Class.keyword, classify("unreachable"));
    try std.testing.expectEqual(Class.keyword, classify("u16"));
    try std.testing.expectEqual(Class.placeholder, classify("CMDLINE"));
    try std.testing.expectEqual(Class.numeric, classify("0x1b"));
}

/// Resolve the LAST `.zig` in `s`, the way the scanner reaches it.
fn lastRef(s: []const u8) ZigRef {
    return zigRefAt(s, std.mem.lastIndexOf(u8, s, ".zig").?).?;
}

test "a .zig reference knows whose tree it names" {
    // The prefix is the discriminator, and this repo already writes it:
    // client_core.zig cites ghostty as osc/parsers/clipboard_operation.zig.
    const ours = lastRef("see server.zig for");
    try std.testing.expectEqualStrings("server.zig", ours.name);
    try std.testing.expect(prefixIsOurs(ours.prefix));

    const foreign = lastRef("(osc/parsers/clipboard_operation.zig)");
    try std.testing.expectEqualStrings("clipboard_operation.zig", foreign.name);
    try std.testing.expectEqualStrings("osc/parsers", foreign.prefix);
    try std.testing.expect(!prefixIsOurs(foreign.prefix));

    // "both of these" is written with a slash and must not read as a path.
    const pair = lastRef("replica.zig/wasm_core.zig call");
    try std.testing.expectEqualStrings("wasm_core.zig", pair.name);
    try std.testing.expect(prefixIsOurs(pair.prefix));

    try std.testing.expectEqualStrings("src", lastRef("src/quic.zig").prefix);
    // Every module lives a directory down now, and a citation of one must
    // still be checked rather than waved through as somebody else's tree.
    for ([_][]const u8{
        "src/cli/main.zig",    "src/engine/protocol.zig", "src/server/server.zig",
        "src/client/wall.zig", "src/tui/paint.zig",
    }) |cite| try std.testing.expect(prefixIsOurs(lastRef(cite).prefix));
}

test "only leading // is a comment, so string literals are left alone" {
    try std.testing.expect(commentBody("    /// why, not how") != null);
    try std.testing.expect(commentBody("//! module header") != null);
    try std.testing.expect(commentBody("    const u = \"quic://box:4433\";") == null);
    try std.testing.expect(commentBody("    ov.setMode(); // raw") == null);
}