From e02b59df4b60c23f898f06da9c5ee5c485efd554 Mon Sep 17 00:00:00 2001 From: ayobamiseun Date: Fri, 25 Sep 2026 08:33:12 +0100 Subject: [PATCH] ci: guard artifact paths on every command surface, not only Claude Code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate-artifact-paths.js exists to stop the #93 class of drift, where a producer moves SPEC.md or tasks/plan.md and a consumer keeps reading the old path. It guarded only the .claude/commands copies plus two skills and two docs. The same commands ship twice more — .gemini/commands/*.toml and the root commands/*.toml — each with its own prompt body that names the same artifacts, and validate-commands.js deliberately compares only descriptions, so a drift on either TOML surface merged green while the Claude Code copy stayed canonical. docs/copilot-setup.md was also unguarded although its prompt-file aliases spell the artifact paths out for users. Add the six TOML producers and consumers and the Copilot doc to GUARDED_FILES, grouped by surface. Rule, allowlist and regex are unchanged. Four new tests cover a Gemini producer drifting alone, a root TOML consumer drifting alone, all three surfaces agreeing, and the Copilot doc. All seven newly guarded files already use canonical paths: 14 files checked, 0 errors. --- scripts/validate-artifact-paths-test.js | 63 +++++++++++++++++++++++++ scripts/validate-artifact-paths.js | 17 +++++++ 2 files changed, 80 insertions(+) diff --git a/scripts/validate-artifact-paths-test.js b/scripts/validate-artifact-paths-test.js index 448e69f3..b2251472 100644 --- a/scripts/validate-artifact-paths-test.js +++ b/scripts/validate-artifact-paths-test.js @@ -114,3 +114,66 @@ test('skips guarded files that do not exist', () => { assert.equal(result.status, 0, result.stdout + result.stderr); assert.match(result.stdout, /1 files checked — 0 error\(s\) — PASSED/); }); + +// ─── Every command surface is guarded, not only the Claude Code copy ──────── +// +// The Gemini and root TOML command sets carry their own prompt bodies, which +// validate-commands.js deliberately does not compare. A producer can therefore +// drift on one surface while `.claude/commands/` stays correct, and before +// these files were guarded that drift merged silently. + +test('fails when a Gemini command drifts while the Claude Code copy stays canonical', () => { + const root = makeSandbox(); + writeFile(root, '.claude/commands/spec.md', 'Save the spec as `SPEC.md` in the project root.\n'); + writeFile( + root, + '.gemini/commands/spec.toml', + 'description = "Start spec-driven development"\n\nprompt = """\nSave the spec to `docs/features/[feature-name]/spec.md`.\n"""\n', + ); + + const result = run(root); + + assert.equal(result.status, 1, result.stdout + result.stderr); + assert.match(result.stdout, /\.gemini\/commands\/spec\.toml/); + assert.match(result.stdout, /L4: docs\/features\/\[feature-name\]\/spec\.md/); + assert.match(result.stdout, /1 error\(s\) — FAILED/); +}); + +test('fails when a root TOML consumer drifts away from the producers', () => { + const root = makeSandbox(); + writeFile(root, 'commands/planning.toml', 'prompt = """\nSave the plan to `tasks/plan.md` and the task list to `tasks/todo.md`.\n"""\n'); + writeFile(root, 'commands/build.toml', 'prompt = """\nRead the plan from `planning/plan.md` before starting.\n"""\n'); + + const result = run(root); + + assert.equal(result.status, 1, result.stdout + result.stderr); + assert.match(result.stdout, /commands\/build\.toml/); + assert.match(result.stdout, /planning\/plan\.md/); +}); + +test('passes when all three command surfaces agree on the canonical paths', () => { + const root = makeSandbox(); + for (const dir of ['.claude/commands', '.gemini/commands', 'commands']) { + const ext = dir === '.claude/commands' ? 'md' : 'toml'; + const planStem = dir === '.claude/commands' ? 'plan' : 'planning'; + writeFile(root, `${dir}/spec.${ext}`, 'Save the spec as `SPEC.md`.\n'); + writeFile(root, `${dir}/${planStem}.${ext}`, 'Save to `tasks/plan.md` and `tasks/todo.md`.\n'); + writeFile(root, `${dir}/build.${ext}`, 'Require `SPEC.md` or `docs/SPEC.md`, and `tasks/plan.md`.\n'); + } + + const result = run(root); + + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.match(result.stdout, /9 files checked — 0 error\(s\) — PASSED/); +}); + +test('guards the Copilot setup doc, whose prompt-file aliases name the artifacts', () => { + const root = makeSandbox(); + writeFile(root, 'docs/copilot-setup.md', 'Read `tasks/plan.md`, then write the spec to `specs/[name]/spec.md`.\n'); + + const result = run(root); + + assert.equal(result.status, 1, result.stdout + result.stderr); + assert.match(result.stdout, /docs\/copilot-setup\.md/); + assert.match(result.stdout, /specs\/\[name\]\/spec\.md/); +}); diff --git a/scripts/validate-artifact-paths.js b/scripts/validate-artifact-paths.js index dbc270a4..354c14b9 100644 --- a/scripts/validate-artifact-paths.js +++ b/scripts/validate-artifact-paths.js @@ -41,14 +41,31 @@ const ARTIFACT_ALLOWLIST = new Set([ // The files that make up the spec -> plan -> build pipeline. Absent files are // skipped, not failed: this validator checks path consistency, not presence. +// +// The command bodies exist once per host surface (validate-commands.js checks +// that the three sets stay in step on descriptions, but their prompt bodies +// are allowed to differ), so a producer or consumer can drift on one surface +// while the Claude Code copy stays correct. Every surface is guarded here. const GUARDED_FILES = [ + // Claude Code commands '.claude/commands/spec.md', '.claude/commands/plan.md', '.claude/commands/build.md', + // Gemini CLI commands + '.gemini/commands/spec.toml', + '.gemini/commands/planning.toml', + '.gemini/commands/build.toml', + // Root command set (Antigravity, Codex, and other TOML-based hosts) + 'commands/spec.toml', + 'commands/planning.toml', + 'commands/build.toml', + // Skills the commands invoke 'skills/spec-driven-development/SKILL.md', 'skills/planning-and-task-breakdown/SKILL.md', + // Docs that tell users where the artifacts live 'docs/getting-started.md', 'docs/adoption-guide.md', + 'docs/copilot-setup.md', // its prompt-file aliases name the artifacts directly ]; // Matches a path-like token ending in a spec/plan/todo artifact filename,