Files
Charles Wiltgen 73bcab39b0 fix(scripts): regenerate Codex on version bump and close the version-parity class
set-version.js regenerated the Cursor variant but never Codex, so
axiom-codex/.codex-plugin/plugin.json kept the PREVIOUS version through a
version bump — invisibly. Gate 12f compares skills/ and agents/ mtimes against
that manifest and a pure version bump touches neither, while the version-parity
gate (check 8) covered Cursor's manifest but not Codex's. Confirmed by reverting
the manifest to the prior version and watching Phase 1 pass green.

Root package.json had the identical hole: written by set-version.js, but read by
pre-deploy only for the `pi` manifest block, never into the parity map. Also
confirmed empirically. `pi install` resolves against that file.

- set-version.js now runs build-cursor.ts AND build-codex.ts
- --tag preflight absolves axiom-codex/ output via a new codex-output.js,
  mirroring cursor-output.js (regression the fix itself introduced: the
  preflight would have refused on ~350 files the same run had just written)
- check 8 reads every version carrier from one table; parse wrapped in
  try/catch because build-codex rmSync's and rebuilds in place with no staging
- version-parity.test.ts fails if a carrier is added without a matching read
- methodology-leak.test.ts keeps behavioral-test methodology out of
  .claude/rules/skill-development.md, which the harness appends to every
  skill-file read and therefore reaches a test's GREEN arm but never its control

Parity gate now reports 9 files, up from 7. Unit suite 488/488 (was 478).
Reviewed with code-reviewer; the root package.json hole was its finding.
2026-09-05 10:49:43 -07:00

114 lines
4.5 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
/**
* Every file in the repo that carries the Axiom plugin version.
*
* This list is the CLASS, and it exists because the class kept leaking one
* member at a time. Two independent instances were found on 2026-09-05, both
* fully green under the whole gate suite while desynced:
*
* - `axiom-codex/.codex-plugin/plugin.json` — written only by build-codex.ts,
* which set-version.js did not run. Gate 12f (Codex staleness) compares
* skill/agent mtimes against the manifest and a version bump touches
* neither, so it could not fire.
* - `package.json` (root) — written by set-version.js, but pre-deploy read it
* only for the `pi` manifest block, never into the version-parity map.
* `pi install git:github.com/CharlesWiltgen/Axiom` resolves against it.
*
* A file that carries the version but is absent from pre-deploy's check 8 is
* invisible to every gate. This test fails when the two drift.
*/
const VERSION_CARRYING_FILES = [
".claude-plugin/plugins/axiom/claude-code.json",
".claude-plugin/plugins/axiom/.claude-plugin/plugin.json",
".claude-plugin/marketplace.json",
".claude-plugin/plugins/axiom/hooks/metadata.txt",
"docs/.vitepress/config.ts",
"axiom-mcp/package.json",
"axiom-cursor/.cursor-plugin/plugin.json",
"axiom-codex/.codex-plugin/plugin.json",
"package.json",
] as const;
function canonicalVersion(): string {
const manifest = JSON.parse(
fs.readFileSync(path.join(root, ".claude-plugin/plugins/axiom/claude-code.json"), "utf8"),
);
return manifest.version;
}
/** Read the version out of a file, whatever shape it stores it in. */
function versionOf(relPath: string): string | undefined {
const abs = path.join(root, relPath);
if (!fs.existsSync(abs)) return undefined;
const raw = fs.readFileSync(abs, "utf8");
if (relPath.endsWith("metadata.txt")) return raw.trim().split("\n")[0];
if (relPath.endsWith("config.ts")) return raw.match(/• v([0-9][^\s"'`<]*)/)?.[1];
if (relPath === ".claude-plugin/marketplace.json") {
return JSON.parse(raw).plugins?.find((p: { name: string }) => p.name === "axiom")?.version;
}
return JSON.parse(raw).version;
}
test("every version-carrying file exists", () => {
for (const relPath of VERSION_CARRYING_FILES) {
assert.ok(
fs.existsSync(path.join(root, relPath)),
`${relPath} is missing — regenerate the variant that owns it`,
);
}
});
test("every version-carrying file matches the canonical version", () => {
const canonical = canonicalVersion();
for (const relPath of VERSION_CARRYING_FILES) {
assert.equal(
versionOf(relPath),
canonical,
`${relPath} is out of sync with claude-code.json (${canonical})`,
);
}
});
test("pre-deploy check 8 covers exactly the version-carrying files", () => {
// The guard that actually closes the class: a carrier added to the list above
// without a matching read in check 8 — or dropped from check 8 — fails here
// rather than shipping green.
//
// Check 8 populates `versions` two ways, so count both: direct literal-key
// assignments (`versions["claude-code.json"] = …`, which use short display
// labels rather than repo paths, so a path-substring match cannot see them),
// plus the VERSION_CARRYING_FILES table it loops over.
const preDeploy = fs.readFileSync(path.join(root, "scripts/pre-deploy.ts"), "utf8");
const directKeys = [...preDeploy.matchAll(/\bversions\[\s*"([^"]+)"\s*\]\s*=/g)].map((m) => m[1]);
const tableBlock = preDeploy.match(
/const VERSION_CARRYING_FILES[\s\S]*?\n\];/,
)?.[0];
assert.ok(tableBlock, "pre-deploy.ts no longer declares VERSION_CARRYING_FILES");
const tableEntries = [...tableBlock.matchAll(/\[\s*"[^"]+",\s*"([^"]+)"/g)].map((m) => m[1]);
const covered = new Set([...directKeys, ...tableEntries]);
assert.equal(
covered.size,
VERSION_CARRYING_FILES.length,
`check 8 reads ${covered.size} version carriers but this test lists ` +
`${VERSION_CARRYING_FILES.length}. Covered: ${[...covered].sort().join(", ")}`,
);
// The table half must match by real repo path; the direct half uses labels.
for (const relPath of tableEntries) {
assert.ok(
(VERSION_CARRYING_FILES as readonly string[]).includes(relPath),
`check 8 reads ${relPath}, which this test does not list as a version carrier`,
);
}
});