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