build: gate the shipped Go binaries against their source

The binary in bin/ is what ships — the plugin, the MCP bundle and the
Codex and Cursor variants all carry it, never the Go source — and
nothing compared the two. A Go edit whose binary was never rebuilt
shipped the old tool with every check green: the args.go parity check
compares copies, the MCP coverage check confirms presence, and the Go
test step reads the source.

New static check, so it runs on `npm test` and on every commit through
the pre-commit hook. It leads with content — a compiled source changed
while the binary's bytes still match HEAD means the rebuild never
happened, which a `touch` on the binary cannot fake — and falls back to
the existing mtime-plus-dirtiness rule for a binary rebuilt once and
then left behind by a further edit. Source staged without the rebuilt
binary is its own failure, because the working tree can look right while
the commit ships the old binary. Deletions count too: a removed file
cannot appear in a directory walk.

It excludes what `go build` excludes — `_test.go`, `testdata/`,
underscore- and dot-prefixed files, other platforms' GOOS suffixes — so
a flagged binary is always one a rebuild fixes, and it exempts library
modules that ship no binary at all. The walk is guarded: a dangling
symlink used to throw out of Phase 1, skipping every later check and
reporting a crash in the same words as a finding.

`git status` now lists untracked files individually, so a brand-new
package directory is visible to this check and to the bundle and Codex
staleness checks, which a collapsed directory entry hid from all three.

Also ignore xclog's build artifacts, which the rebuild instruction left
untracked in the tree.
This commit is contained in:
Charles Wiltgen
2026-09-19 15:50:51 -07:00
parent 5ab32d56bd
commit 876d863a29
4 changed files with 523 additions and 5 deletions
+2
View File
@@ -82,6 +82,8 @@ axiom-mcp/docs/
# Local tool build artifacts (distributed binaries live in plugins/axiom/bin/)
tools/xclog/xclog
tools/xclog/xclog-amd64
tools/xclog/xclog-arm64
tools/xcui/xcui
tools/xcui/xcui-amd64
tools/xcui/xcui-arm64
+158 -2
View File
@@ -92,7 +92,7 @@ import {
upsertRouterNote,
validateHomeCoverage,
} from "./inline-auditors.ts";
import { parsePorcelain, resolveStaleness } from "./staleness.ts";
import { parsePorcelain, resolveGoBinaryStaleness, resolveStaleness } from "./staleness.ts";
import { findDashViolations } from "./docs-dashes.ts";
import { renderCursorDistribution } from "./cursor/render.ts";
import { compareCursorPaths } from "./cursor/compare.ts";
@@ -134,7 +134,12 @@ function gitDirtySet(cwd: string): { gitAvailable: boolean; dirty: Set<string> }
// `café.md` would arrive as `caf\303\251.md`, never match path.relative()'s
// real UTF-8, get filtered out, and a genuinely-stale artifact would ship
// green. Paths with spaces are still quoted — parsePorcelain unquotes those.
const out = execSync("git -c core.quotepath=false status --porcelain", {
// `-uall` lists untracked FILES individually. At git's default (-unormal) a new
// untracked directory collapses to one "dir/" entry, so a brand-new source file
// inside it never matches a walked path and the staleness gates (12b/12f/12t)
// read it as "nothing changed" — a new skill directory or Go package would be
// invisible to the very checks meant to see it. Ignored files stay excluded.
const out = execSync("git -c core.quotepath=false status --porcelain -uall", {
cwd,
stdio: "pipe",
encoding: "utf8",
@@ -1753,6 +1758,157 @@ if (argsGoFiles.length < 2) {
}
}
// ── 12t. Go Tool Binary Staleness ──
// Files staged for THIS commit. The staleness clauses read the working tree, but a
// commit ships HEAD: staging source without the rebuilt binary lands both out of
// step while every worktree check reads green. Empty outside a commit, so the
// staged clause is vacuous during a plain `npm test`.
const stagedPaths = (() => {
try {
return new Set<string>(
execSync("git -c core.quotepath=false diff --cached --name-only", { cwd: root, stdio: "pipe", encoding: "utf8" })
.split("\n")
.map((l: string) => l.trim())
.filter(Boolean),
);
} catch {
return new Set<string>();
}
})();
heading("12t. Go Tool Binary Staleness");
// The compiled binary in bin/ is what SHIPS — the plugin, the MCP bundle, and the
// Codex/Cursor variants all carry bin/<tool>, never tools/<tool>/*.go. So a Go edit
// whose binary was never rebuilt ships the OLD tool while the repo and the skills
// document the new behavior, and every other gate stays green: 12g compares args.go
// copies, 12h confirms the binary is present and listed, and step 15 (Phase 2 only)
// runs `go test` against the SOURCE. None of them compares source to binary.
//
// Found 2026-09-19: a session rewrote tools/xcui tap handling and rebuilt bin/xcui by
// hand; nothing would have caught skipping that step. Same hybrid rule as 12b/12f —
// mtime pre-filters, git dirtiness confirms — so a fresh clone is not flagged.
const goToolsRoot = path.join(root, "tools");
const goBinDir = path.join(pluginDir, "bin");
if (fs.existsSync(goToolsRoot) && fs.statSync(goToolsRoot).isDirectory()) {
// Files `go build` ignores, so an edit to one cannot make the binary stale and
// flagging it would be unfixable: it is content, so no rebuild clears it.
// testdata/ is excluded wholesale (Go never compiles it); leading _ or . likewise;
// and a GOOS-suffixed file for another platform is not part of a darwin build.
const goIgnoresFile = (name: string) =>
name.startsWith("_") ||
name.startsWith(".") ||
/_(linux|windows|plan9|js|wasm|aix|android|freebsd|netbsd|openbsd|solaris)(_[a-z0-9]+)?\.go$/.test(name);
let goModules: string[] = [];
const goInputs: {
tool: string;
binaryMtimeMs: number | null;
binaryDirty: boolean;
binaryStaged: boolean;
sources: { path: string; mtimeMs: number }[];
deletedSources: string[];
stagedSources: string[];
}[] = [];
// The whole block is guarded: an unreadable dir, a dangling *.go symlink, or a
// file removed mid-walk would otherwise throw out of Phase 1, skipping every
// later check and bypassing the error() channel entirely — a crash the hook
// reports in the same words as a finding.
try {
goModules = fs
.readdirSync(goToolsRoot, { withFileTypes: true })
.filter((d: fs.Dirent) => d.isDirectory() && fs.existsSync(path.join(goToolsRoot, d.name, "go.mod")))
.map((d: fs.Dirent) => d.name)
.sort();
for (const name of goModules) {
const moduleDir = path.join(goToolsRoot, name);
const binaryPath = path.join(goBinDir, name);
const relBinary = path.relative(root, binaryPath);
const sources: { path: string; mtimeMs: number }[] = [];
let isCommand = false;
const walkModule = (dir: string) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === "testdata" || goIgnoresFile(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkModule(full);
} else if (entry.name.endsWith(".go") || entry.name === "go.mod" || entry.name === "go.sum") {
sources.push({ path: path.relative(root, full), mtimeMs: fs.statSync(full).mtimeMs });
if (!isCommand && entry.name.endsWith(".go") && !entry.name.endsWith("_test.go")) {
isCommand = /^package main\b/m.test(fs.readFileSync(full, "utf8"));
}
}
}
};
walkModule(moduleDir);
// A library module under tools/ ships no binary, so requiring one would block
// every commit until someone committed a 6 MB file that should not exist.
if (!isCommand && !fs.existsSync(binaryPath)) continue;
const modulePrefix = path.relative(root, moduleDir) + path.sep;
const deletedSources = [...gitStatus.dirty].filter(
(p) => p.startsWith(modulePrefix) && p.endsWith(".go") && !fs.existsSync(path.join(root, p)),
);
const binaryStat = fs.existsSync(binaryPath) ? fs.statSync(binaryPath) : null;
goInputs.push({
tool: name,
binaryMtimeMs: binaryStat?.isFile() ? binaryStat.mtimeMs : null,
binaryDirty: gitStatus.dirty.has(relBinary),
binaryStaged: stagedPaths.has(relBinary),
sources,
deletedSources,
stagedSources: [...stagedPaths].filter((p) => p.startsWith(modulePrefix) && p.endsWith(".go")),
});
}
} catch (err) {
error(
"go-binary-staleness",
`could not read the Go tool sources under tools/ (${(err as Error).message}) — the binary staleness check could not run`,
);
}
if (goInputs.length === 0) {
console.log(" ✓ no Go command modules under tools/ — nothing to compare");
} else {
const goVerdicts = resolveGoBinaryStaleness(goInputs, gitStatus.dirty, gitStatus.gitAvailable);
let goProblems = 0;
for (const verdict of goVerdicts) {
if (verdict.state === "stale") {
goProblems++;
error(
"go-binary-staleness",
`bin/${verdict.tool} is stale — ${verdict.reason}. Run: cd tools/${verdict.tool} && make install`,
);
} else if (verdict.state === "binary-not-staged") {
goProblems++;
error(
"go-binary-staleness",
`bin/${verdict.tool}${verdict.reason}. Stage it too: git add .claude-plugin/plugins/axiom/bin/${verdict.tool}`,
);
} else if (verdict.state === "missing-binary") {
goProblems++;
error(
"go-binary-staleness",
`tools/${verdict.tool} builds a command but has no committed bin/${verdict.tool} — build it with: cd tools/${verdict.tool} && make install`,
);
}
}
if (goProblems === 0) {
console.log(
`${goVerdicts.length} Go tool binar${goVerdicts.length === 1 ? "y matches its" : "ies match their"} source (${goVerdicts.map((v) => v.tool).join(", ")})`,
);
}
}
} else {
warn("go-binary-staleness", "no tools/ directory — skipping Go binary staleness check");
}
// ── 12h. MCP Tool Binary Coverage ──
heading("12h. MCP Tool Binary Coverage");
+255 -3
View File
@@ -4,13 +4,13 @@
* Run via `node --test scripts/staleness.test.ts` (Node 24 native, no extra
* deps). Wired into npm `test:unit` via the scripts/*.test.ts glob.
*
* Both functions are pure the caller (pre-deploy.ts) does the fs walk and the
* single `git status` call, then passes strings/arrays in.
* All three exported functions are pure the caller (pre-deploy.ts) does the fs
* walk and the single `git status` call, then passes strings/arrays in.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { parsePorcelain, resolveStaleness } from "./staleness.ts";
import { parsePorcelain, resolveGoBinaryStaleness, resolveStaleness } from "./staleness.ts";
describe("parsePorcelain", () => {
it("returns an empty set for clean output", () => {
@@ -95,3 +95,255 @@ describe("resolveStaleness", () => {
assert.match(v.reason, /git unavailable/i);
});
});
describe("resolveGoBinaryStaleness", () => {
const dirty = (...p: string[]) => new Set(p);
it("reports a tool stale when a changed .go source is newer than its binary", () => {
const verdicts = resolveGoBinaryStaleness(
[{
tool: "xcui",
binaryMtimeMs: 100,
binaryDirty: true, // rebuilt once, then left behind by a further edit
sources: [
{ path: "tools/xcui/sim.go", mtimeMs: 200 },
{ path: "tools/xcui/main.go", mtimeMs: 50 },
],
}],
dirty("tools/xcui/sim.go"),
true,
);
assert.equal(verdicts.length, 1);
assert.equal(verdicts[0].state, "stale");
assert.match(verdicts[0].reason, /1 source file\(s\) changed/);
});
it("ignores _test.go files — go build does not compile them into the binary", () => {
// A test-only edit leaves the shipped binary correct. Flagging it would train
// the reader to rebuild (and re-commit a 6 MB binary) for nothing.
const verdicts = resolveGoBinaryStaleness(
[{
tool: "xcui",
binaryMtimeMs: 100,
binaryDirty: false,
sources: [{ path: "tools/xcui/sim_test.go", mtimeMs: 900 }],
}],
dirty("tools/xcui/sim_test.go"),
true,
);
assert.equal(verdicts[0].state, "ok");
});
it("treats go.mod and go.sum as compiled inputs — a dependency change rebuilds the binary", () => {
const verdicts = resolveGoBinaryStaleness(
[{
tool: "xcsym",
binaryMtimeMs: 100,
binaryDirty: true,
sources: [{ path: "tools/xcsym/go.mod", mtimeMs: 900 }, { path: "tools/xcsym/go.sum", mtimeMs: 900 }],
}],
dirty("tools/xcsym/go.mod"),
true,
);
assert.equal(verdicts[0].state, "stale");
});
it("is not stale when mtimes are newer but every file matches HEAD (fresh clone)", () => {
// A clone gives every file the checkout time, so mtime alone would fail a
// tree nobody has edited.
const verdicts = resolveGoBinaryStaleness(
[{
tool: "xcui",
binaryMtimeMs: 100,
binaryDirty: false,
sources: [{ path: "tools/xcui/sim.go", mtimeMs: 900 }],
}],
new Set<string>(),
true,
);
assert.equal(verdicts[0].state, "ok");
assert.match(verdicts[0].reason, /mtime skew/);
});
it("is stale without git, where content cannot be confirmed", () => {
const verdicts = resolveGoBinaryStaleness(
[{
tool: "xcui",
binaryMtimeMs: 100,
binaryDirty: false,
sources: [{ path: "tools/xcui/sim.go", mtimeMs: 900 }],
}],
new Set<string>(),
false,
);
assert.equal(verdicts[0].state, "stale");
});
it("reports a module with no shipped binary — the tool cannot reach users", () => {
const verdicts = resolveGoBinaryStaleness(
[{ tool: "xcnew", binaryMtimeMs: null, binaryDirty: false, sources: [{ path: "tools/xcnew/main.go", mtimeMs: 10 }] }],
new Set<string>(),
true,
);
assert.equal(verdicts[0].state, "missing-binary");
});
it("is stale when source is dirty but the committed binary is not — the `touch` bypass", () => {
// mtime alone cannot see this: `touch bin/xcui` makes the binary the newest
// file, so the mtime clause reports ok on a binary nobody rebuilt. git content
// can: a rebuilt binary has different bytes, so it would be dirty too.
const verdicts = resolveGoBinaryStaleness(
[{
tool: "xcui",
binaryMtimeMs: 9_000, // binary "newer" than every source
binaryDirty: false, // …but its bytes never changed
sources: [{ path: "tools/xcui/sim.go", mtimeMs: 100 }],
}],
dirty("tools/xcui/sim.go"),
true,
);
assert.equal(verdicts[0].state, "stale");
assert.match(verdicts[0].reason, /not rebuilt/);
});
it("is ok when source and binary are both dirty — the binary was rebuilt", () => {
const verdicts = resolveGoBinaryStaleness(
[{
tool: "xcui",
binaryMtimeMs: 9_000,
binaryDirty: true,
sources: [{ path: "tools/xcui/sim.go", mtimeMs: 100 }],
}],
dirty("tools/xcui/sim.go", ".claude-plugin/plugins/axiom/bin/xcui"),
true,
);
assert.equal(verdicts[0].state, "ok");
});
it("ignores a dirty _test.go for the rebuild check too", () => {
const verdicts = resolveGoBinaryStaleness(
[{
tool: "xcui",
binaryMtimeMs: 9_000,
binaryDirty: false,
sources: [{ path: "tools/xcui/sim_test.go", mtimeMs: 100 }],
}],
dirty("tools/xcui/sim_test.go"),
true,
);
assert.equal(verdicts[0].state, "ok");
});
it("is stale when an already-rebuilt binary is left behind by a further edit (clause 2's own job)", () => {
// Pins the mtime clause on the case clause 1 cannot see: the binary is dirty
// (rebuilt once), so only the mtime comparison can catch the later edit. A
// mutation making binaryDirty short-circuit to ok passed every other test.
const verdicts = resolveGoBinaryStaleness(
[{
tool: "xcui",
binaryMtimeMs: 500,
binaryDirty: true,
sources: [{ path: "tools/xcui/sim.go", mtimeMs: 900 }],
}],
dirty("tools/xcui/sim.go", ".claude-plugin/plugins/axiom/bin/xcui"),
true,
);
assert.equal(verdicts[0].state, "stale");
});
it("is stale when a compiled source was DELETED — it cannot appear in the fs walk", () => {
// git reports " D tools/xcui/helper.go", but the walker can never list a file
// that is gone, so the deletion has to arrive separately or it is invisible.
const verdicts = resolveGoBinaryStaleness(
[{
tool: "xcui",
binaryMtimeMs: 9_000,
binaryDirty: false,
sources: [{ path: "tools/xcui/main.go", mtimeMs: 100 }],
deletedSources: ["tools/xcui/helper.go"],
}],
dirty("tools/xcui/helper.go"),
true,
);
assert.equal(verdicts[0].state, "stale");
assert.match(verdicts[0].reason, /not rebuilt/);
});
it("ignores a deleted _test.go — go build never compiled it", () => {
const verdicts = resolveGoBinaryStaleness(
[{
tool: "xcui",
binaryMtimeMs: 9_000,
binaryDirty: false,
sources: [{ path: "tools/xcui/main.go", mtimeMs: 100 }],
deletedSources: ["tools/xcui/helper_test.go"],
}],
dirty("tools/xcui/helper_test.go"),
true,
);
assert.equal(verdicts[0].state, "ok");
});
it("flags source staged for commit without the rebuilt binary — HEAD would ship the old tool", () => {
// The gate reads the WORKING TREE, but what ships is HEAD. Staging sim.go alone
// (binary rebuilt but left unstaged) lands a commit whose source and binary
// disagree, and every worktree-based clause reads green.
const verdicts = resolveGoBinaryStaleness(
[{
tool: "xcui",
binaryMtimeMs: 9_000,
binaryDirty: true,
binaryStaged: false,
stagedSources: ["tools/xcui/sim.go"],
sources: [{ path: "tools/xcui/sim.go", mtimeMs: 100 }],
}],
dirty("tools/xcui/sim.go", ".claude-plugin/plugins/axiom/bin/xcui"),
true,
);
assert.equal(verdicts[0].state, "binary-not-staged");
});
it("is ok when source and binary are staged together", () => {
const verdicts = resolveGoBinaryStaleness(
[{
tool: "xcui",
binaryMtimeMs: 9_000,
binaryDirty: true,
binaryStaged: true,
stagedSources: ["tools/xcui/sim.go"],
sources: [{ path: "tools/xcui/sim.go", mtimeMs: 100 }],
}],
dirty("tools/xcui/sim.go", ".claude-plugin/plugins/axiom/bin/xcui"),
true,
);
assert.equal(verdicts[0].state, "ok");
});
it("ignores a staged _test.go — it changes no binary, so it needs no binary staged", () => {
const verdicts = resolveGoBinaryStaleness(
[{
tool: "xcui",
binaryMtimeMs: 9_000,
binaryDirty: false,
binaryStaged: false,
stagedSources: ["tools/xcui/sim_test.go"],
sources: [{ path: "tools/xcui/main.go", mtimeMs: 100 }],
}],
new Set<string>(),
true,
);
assert.equal(verdicts[0].state, "ok");
});
it("returns one verdict per tool, in the order given", () => {
const verdicts = resolveGoBinaryStaleness(
[
{ tool: "xclog", binaryMtimeMs: 100, binaryDirty: false, sources: [{ path: "tools/xclog/main.go", mtimeMs: 10 }] },
{ tool: "xcui", binaryMtimeMs: 100, binaryDirty: true, sources: [{ path: "tools/xcui/main.go", mtimeMs: 900 }] },
],
dirty("tools/xcui/main.go"),
true,
);
assert.deepEqual(verdicts.map((v) => [v.tool, v.state]), [["xclog", "ok"], ["xcui", "stale"]]);
});
});
+108
View File
@@ -92,3 +92,111 @@ export function resolveStaleness(input: StalenessInput): StalenessVerdict {
reason: `${dirtyFiles.length} source file(s) changed since the artifact was built`,
};
}
/** One Go module's shipped binary and the sources it is built from. */
export interface GoToolInput {
/** Module (and binary) name, e.g. "xcui". */
tool: string;
/** mtime of the committed `bin/<tool>`, or null when it is absent. */
binaryMtimeMs: number | null;
/** Whether git sees the committed binary's BYTES as changed (rebuilt but not yet committed). */
binaryDirty: boolean;
/** Every file in the module that `go build` reads: *.go plus go.mod/go.sum. */
sources: { path: string; mtimeMs: number }[];
/**
* Compiled files git reports as DELETED. They cannot appear in `sources` the
* fs walk can only list files that still exist so a deletion would otherwise
* be invisible, though it changes the build as surely as an edit.
*/
deletedSources?: string[];
/** Compiled files STAGED for the next commit (empty outside a commit). */
stagedSources?: string[];
/** Whether the binary is staged alongside them. */
binaryStaged?: boolean;
}
export interface GoToolVerdict {
tool: string;
state: "ok" | "stale" | "missing-binary" | "binary-not-staged";
reason: string;
}
/**
* Decides, per Go tool, whether the committed binary still matches its source.
*
* The binary is what ships the plugin, the MCP bundle, and the Codex/Cursor
* variants all carry `bin/<tool>`, never the source. So a Go edit whose binary
* was not rebuilt ships the OLD tool while the repo and the skills document the
* new behavior, and every other gate stays green: the source compiles, its tests
* pass, and the binary is present and correctly listed.
*
* Two complementary clauses, because neither alone is enough:
*
* 1. CONTENT a compiled source is dirty while the binary's bytes still match
* HEAD. A rebuild changes those bytes, so a clean binary beside edited source
* means the rebuild never happened. This is the clause `touch bin/<tool>`
* cannot beat: touching a file changes its mtime, not what git sees.
* (With git unavailable this clause is skipped and only clause 2 applies.)
* 1b. STAGED the gate reads the working tree, but what ships is HEAD. Source
* staged without the rebuilt binary lands a commit whose source and binary
* disagree, which both worktree clauses read as green.
* 2. MTIME the resolveStaleness hybrid (mtime pre-filters, git dirtiness
* confirms), which catches the case clause 1 misses: a binary rebuilt once
* (so already dirty) and then left behind by a further source edit.
*
* A fresh clone trips neither: everything looks newer, nothing is dirty.
*
* `_test.go` is excluded deliberately `go build` ignores it, so a test-only
* edit leaves the shipped binary correct, and flagging it would teach the reader
* to re-commit a multi-megabyte binary for a change it cannot contain.
*/
export function resolveGoBinaryStaleness(
tools: GoToolInput[],
dirty: Set<string>,
gitAvailable: boolean,
): GoToolVerdict[] {
return tools.map((tool) => {
if (tool.binaryMtimeMs === null) {
return {
tool: tool.tool,
state: "missing-binary" as const,
reason: "no committed bin/" + tool.tool + " — the module exists but nothing ships it",
};
}
const compiled = tool.sources.filter((s) => !s.path.endsWith("_test.go"));
const deleted = (tool.deletedSources ?? []).filter((p) => !p.endsWith("_test.go"));
const changedCount = compiled.filter((s) => dirty.has(s.path)).length + deleted.length;
if (gitAvailable && changedCount > 0 && !tool.binaryDirty) {
return {
tool: tool.tool,
state: "stale" as const,
reason:
`${changedCount} source file(s) changed but bin/${tool.tool} was not rebuilt ` +
`(the committed binary still matches HEAD)`,
};
}
const stagedCompiled = (tool.stagedSources ?? []).filter((p) => !p.endsWith("_test.go"));
if (stagedCompiled.length > 0 && tool.binaryStaged === false) {
return {
tool: tool.tool,
state: "binary-not-staged" as const,
reason:
`${stagedCompiled.length} source file(s) staged for commit without bin/${tool.tool}` +
`the commit would ship the previous binary`,
};
}
const newerFiles = compiled
.filter((s) => s.mtimeMs > (tool.binaryMtimeMs as number))
.map((s) => s.path);
const verdict = resolveStaleness({
newerFiles,
dirtyFiles: newerFiles.filter((f) => dirty.has(f)),
gitAvailable,
});
return {
tool: tool.tool,
state: verdict.stale ? ("stale" as const) : ("ok" as const),
reason: verdict.reason,
};
});
}