mirror of
https://github.com/CharlesWiltgen/Axiom.git
synced 2026-09-20 19:58:20 +08:00
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:
@@ -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
@@ -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
@@ -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"]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user