fix(pre-deploy): make 12b/12f staleness hybrid — mtime fast-path + git content confirm (axiom-ufzt)

This commit is contained in:
Charles Wiltgen
2026-06-08 09:41:05 -07:00
parent 673d5e7824
commit df840ac0ba
3 changed files with 259 additions and 22 deletions
+68 -22
View File
@@ -30,6 +30,7 @@ import {
checkSkillInvocations,
findSkillNameCollisions,
} from "./skill-invocations.ts";
import { parsePorcelain, resolveStaleness } from "./staleness.ts";
const root = path.resolve(import.meta.dirname!, "..");
const pluginDir = path.join(root, ".claude-plugin/plugins/axiom");
@@ -53,6 +54,30 @@ function heading(title: string): void {
console.log(`\n── ${title} ──`);
}
// One `git status --porcelain` for the whole repo, parsed into the set of
// dirty/untracked paths. Shared by the hybrid staleness checks (12b/12f) to
// confirm whether a source file that's newer-by-mtime than a derived artifact
// has ACTUALLY changed, vs. merely been rewritten by a git checkout/stash/
// rebase. Returns gitAvailable=false (e.g. no .git) so callers fall back to the
// conservative mtime verdict.
function gitDirtySet(cwd: string): { gitAvailable: boolean; dirty: Set<string> } {
try {
// `-c core.quotepath=false` makes git emit non-ASCII paths as literal UTF-8
// instead of octal-escaped + quoted (its default). Without it, a dirty
// `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", {
cwd,
stdio: "pipe",
encoding: "utf8",
});
return { gitAvailable: true, dirty: parsePorcelain(out) };
} catch {
return { gitAvailable: false, dirty: new Set<string>() };
}
}
interface Frontmatter {
[key: string]: string;
}
@@ -747,12 +772,18 @@ if (staleRefCount === 0) {
heading("12b. MCP Bundle Staleness");
// Shared content-confirmation state for the hybrid staleness checks (12b/12f):
// one git call, reused. mtime is a fast pre-filter, but git checkout/stash/
// rebase rewrite files identically with fresh mtimes — so a source that's
// "newer" than the artifact is only really stale if git also sees it changed.
const gitStatus = gitDirtySet(root);
const bundlePath = path.join(root, "axiom-mcp/dist/bundle.json");
if (fs.existsSync(bundlePath)) {
const bundleMtime = fs.statSync(bundlePath).mtimeMs;
// Find the newest skill, agent, or command file
let newestSource = 0;
// Collect source files whose mtime is newer than the built bundle.
const newerFiles: string[] = [];
const sourceDirs = [
path.join(pluginDir, "skills"),
path.join(pluginDir, "agents"),
@@ -764,30 +795,38 @@ if (fs.existsSync(bundlePath)) {
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
const full = path.join(d, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.name.endsWith(".md")) {
const mtime = fs.statSync(full).mtimeMs;
if (mtime > newestSource) newestSource = mtime;
else if (entry.name.endsWith(".md") && fs.statSync(full).mtimeMs > bundleMtime) {
newerFiles.push(path.relative(root, full));
}
}
};
walk(dir);
}
// Also check skill-annotations.json
// skill-annotations.json also feeds the bundle.
const annotationsPath = path.join(root, "axiom-mcp/skill-annotations.json");
if (fs.existsSync(annotationsPath)) {
const annotMtime = fs.statSync(annotationsPath).mtimeMs;
if (annotMtime > newestSource) newestSource = annotMtime;
if (
fs.existsSync(annotationsPath) &&
fs.statSync(annotationsPath).mtimeMs > bundleMtime
) {
newerFiles.push(path.relative(root, annotationsPath));
}
if (newestSource > bundleMtime) {
const staleMinutes = Math.round((newestSource - bundleMtime) / 60000);
const dirtyFiles = newerFiles.filter((f) => gitStatus.dirty.has(f));
const verdict = resolveStaleness({
newerFiles,
dirtyFiles,
gitAvailable: gitStatus.gitAvailable,
});
if (verdict.stale) {
error(
"bundle-staleness",
`MCP bundle is ${staleMinutes}min older than newest source file. Run: cd axiom-mcp && pnpm run build:bundle`,
`MCP bundle is stale — ${verdict.reason}. Run: cd axiom-mcp && pnpm run build:bundle`,
);
} else {
console.log(" ✓ MCP bundle is up-to-date with source files");
console.log(
` ✓ MCP bundle is up-to-date with source files${newerFiles.length ? ` (${verdict.reason})` : ""}`,
);
}
} else {
warn("bundle-staleness", "MCP bundle not found at axiom-mcp/dist/bundle.json — build with: cd axiom-mcp && pnpm run build:bundle");
@@ -1053,8 +1092,9 @@ if (fs.existsSync(codexManifest)) {
const codexMtime = fs.statSync(codexManifest).mtimeMs;
// The Codex variant is rebuilt from skills + agents (npm run build:codex).
// Mirror 12b: if any source file is newer than the built variant, it is stale.
let newestCodexSource = 0;
// Same hybrid as 12b: collect sources newer-by-mtime, then confirm via git
// (reusing the shared gitStatus) before declaring real staleness.
const newerFiles: string[] = [];
const codexSourceDirs = [
path.join(pluginDir, "skills"),
path.join(pluginDir, "agents"),
@@ -1065,23 +1105,29 @@ if (fs.existsSync(codexManifest)) {
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
const full = path.join(d, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.name.endsWith(".md")) {
const mtime = fs.statSync(full).mtimeMs;
if (mtime > newestCodexSource) newestCodexSource = mtime;
else if (entry.name.endsWith(".md") && fs.statSync(full).mtimeMs > codexMtime) {
newerFiles.push(path.relative(root, full));
}
}
};
walk(dir);
}
if (newestCodexSource > codexMtime) {
const staleMinutes = Math.round((newestCodexSource - codexMtime) / 60000);
const dirtyFiles = newerFiles.filter((f) => gitStatus.dirty.has(f));
const verdict = resolveStaleness({
newerFiles,
dirtyFiles,
gitAvailable: gitStatus.gitAvailable,
});
if (verdict.stale) {
error(
"codex-staleness",
`Codex variant is ${staleMinutes}min older than newest source file. Run: npm run build:codex`,
`Codex variant is stale — ${verdict.reason}. Run: npm run build:codex`,
);
} else {
console.log(" ✓ Codex variant is up-to-date with source files");
console.log(
` ✓ Codex variant is up-to-date with source files${newerFiles.length ? ` (${verdict.reason})` : ""}`,
);
}
} else {
warn(
+97
View File
@@ -0,0 +1,97 @@
/**
* Tests for scripts/staleness.ts.
*
* 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.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { parsePorcelain, resolveStaleness } from "./staleness.ts";
describe("parsePorcelain", () => {
it("returns an empty set for clean output", () => {
assert.deepEqual(parsePorcelain(""), new Set());
});
it("extracts a modified path", () => {
assert.deepEqual(
parsePorcelain(" M scripts/pre-deploy.ts"),
new Set(["scripts/pre-deploy.ts"]),
);
});
it("extracts an untracked path", () => {
assert.deepEqual(
parsePorcelain("?? scripts/new.ts"),
new Set(["scripts/new.ts"]),
);
});
it("takes the destination path of a rename", () => {
assert.deepEqual(
parsePorcelain("R old/a.md -> new/b.md"),
new Set(["new/b.md"]),
);
});
it("unquotes paths git quotes for special chars", () => {
assert.deepEqual(
parsePorcelain('?? "weird name.md"'),
new Set(["weird name.md"]),
);
});
it("keeps a literal non-ASCII path (caller forces core.quotepath=false)", () => {
assert.deepEqual(parsePorcelain(" M café.md"), new Set(["café.md"]));
});
it("parses multiple lines and ignores blanks", () => {
const out = " M a.md\n?? b.md\n\nMM c.md\n";
assert.deepEqual(parsePorcelain(out), new Set(["a.md", "b.md", "c.md"]));
});
});
describe("resolveStaleness", () => {
it("is not stale when nothing is newer than the artifact", () => {
const v = resolveStaleness({
newerFiles: [],
dirtyFiles: [],
gitAvailable: true,
});
assert.equal(v.stale, false);
});
it("is not stale when newer files are all git-clean (mtime skew)", () => {
const v = resolveStaleness({
newerFiles: ["skills/a/SKILL.md", "skills/b/SKILL.md"],
dirtyFiles: [],
gitAvailable: true,
});
assert.equal(v.stale, false);
assert.match(v.reason, /content matches HEAD|git-clean|mtime/i);
});
it("is stale when at least one newer file is dirty/untracked", () => {
const v = resolveStaleness({
newerFiles: ["skills/a/SKILL.md", "skills/b/SKILL.md"],
dirtyFiles: ["skills/b/SKILL.md"],
gitAvailable: true,
});
assert.equal(v.stale, true);
assert.match(v.reason, /1 source file/);
});
it("falls back to stale (conservative) when git is unavailable", () => {
const v = resolveStaleness({
newerFiles: ["skills/a/SKILL.md"],
dirtyFiles: [],
gitAvailable: false,
});
assert.equal(v.stale, true);
assert.match(v.reason, /git unavailable/i);
});
});
+94
View File
@@ -0,0 +1,94 @@
/**
* Derived-artifact staleness — pure decision logic.
*
* pre-deploy.ts guards two derived artifacts (the MCP bundle, §12b; the Codex
* variant, §12f) against being shipped out of sync with the skill/agent/command
* sources. The cheap signal is mtime: if any source `.md` is newer than the
* built artifact, the artifact *might* be stale.
*
* But mtime is a leaky proxy for "content changed". `git checkout`, stash,
* rebase, and `restore` all rewrite files byte-for-byte identically with fresh
* mtimes, so a pure mtime check false-positives on no-op git operations — which
* is exactly the documented "trap" this module exists to retire.
*
* Hybrid resolution: mtime stays the fast pre-filter. When it trips, the caller
* runs ONE `git status` and passes the results here. If the newer-than-artifact
* sources are git-clean (content still matches the committed baseline the
* artifact was built from), it's mtime skew, not staleness. If any are
* modified/untracked, it's a real change. If git is unavailable, fall back to
* the conservative mtime verdict so a genuinely stale artifact never ships.
*
* This module is I/O free. The caller (pre-deploy.ts) does the fs walk and the
* git call. Tests in staleness.test.ts exercise these functions.
*/
/**
* Parse `git status --porcelain` output into the set of dirty/untracked paths
* (repo-relative, matching git's own output). Each record is `XY <path>`; a
* rename (`R old -> new`) contributes its destination; git quotes paths with
* spaces, so those are unquoted.
*
* Assumes the caller ran git with `-c core.quotepath=false`, so non-ASCII
* paths arrive as literal UTF-8 (not octal-escaped). The ` -> ` split assumes
* git's rename format — a non-rename file literally containing ` -> ` would
* mis-parse, but that can't occur for skill/agent/command filenames.
*/
export function parsePorcelain(porcelain: string): Set<string> {
const dirty = new Set<string>();
for (const line of porcelain.split("\n")) {
if (line.trim() === "") continue;
let p = line.slice(3); // strip the 2 status chars + separating space
const arrow = p.indexOf(" -> ");
if (arrow !== -1) p = p.slice(arrow + 4); // rename → destination path
if (p.startsWith('"') && p.endsWith('"')) p = p.slice(1, -1);
dirty.add(p);
}
return dirty;
}
export interface StalenessInput {
/** Source files newer than the artifact by mtime (repo-relative). */
newerFiles: string[];
/** The subset of `newerFiles` that git reports modified/untracked. */
dirtyFiles: string[];
/** Whether the `git status` call succeeded. */
gitAvailable: boolean;
}
export interface StalenessVerdict {
stale: boolean;
/** Human-readable explanation for the pass/fail line. */
reason: string;
}
/**
* Decide whether a derived artifact is genuinely stale, given the mtime
* pre-filter results (`newerFiles`) and the git content confirmation
* (`dirtyFiles`, `gitAvailable`). See module docstring for the rationale.
*/
export function resolveStaleness(input: StalenessInput): StalenessVerdict {
const { newerFiles, dirtyFiles, gitAvailable } = input;
if (newerFiles.length === 0) {
return { stale: false, reason: "no source newer than artifact" };
}
if (!gitAvailable) {
return {
stale: true,
reason: `${newerFiles.length} source file(s) newer than artifact; git unavailable to confirm content`,
};
}
if (dirtyFiles.length === 0) {
return {
stale: false,
reason: `${newerFiles.length} source file(s) have newer mtimes but content matches HEAD (git-clean) — mtime skew, not a real change`,
};
}
return {
stale: true,
reason: `${dirtyFiles.length} source file(s) changed since the artifact was built`,
};
}