mirror of
https://github.com/CharlesWiltgen/Axiom.git
synced 2026-09-20 19:58:20 +08:00
fix(pre-deploy): make 12b/12f staleness hybrid — mtime fast-path + git content confirm (axiom-ufzt)
This commit is contained in:
+68
-22
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user