diff --git a/.changeset/sturdy-goats-leap.md b/.changeset/sturdy-goats-leap.md new file mode 100644 index 000000000..8197957b3 --- /dev/null +++ b/.changeset/sturdy-goats-leap.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4756 +--- +**Asking for a retired runtime no longer silently installs Claude Code** — passing a sunset runtime id resolved to Claude Code's label and config home, so `getGlobalConfigDir('gemini')` and `getGlobalConfigDir('claude')` returned byte-identical paths and the install reported itself as Claude Code. The four runtime-resolution accessors now fail with a message naming the successor and the retiring issue. Genuinely unknown and future runtime ids keep their existing safe defaults, which is a deliberate distinction: absence of knowledge is not the same as recorded retirement. (#4709) diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index 852d40e11..0e8ea64ed 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -1183,7 +1183,18 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load // First positional that isn't a flag also works (lenient); otherwise ignore unknown flags. if (!a.startsWith('-') && !pifRuntime) { pifRuntime = a; } } - const filename = getProjectInstructionFile(pifRuntime); + // A retired runtime id now THROWS rather than resolving (#4709 AC#1). + // Map it to the same clean single-line error routeSkillsRoot emits for + // an unknown runtime — a CLI must not answer a bad flag value with a + // stack trace. The thrown message already names the successor and the + // retiring issue, so it is surfaced verbatim. + let filename; + try { + filename = getProjectInstructionFile(pifRuntime); + } catch (err) { + if (err && err.code === 'GSD_RETIRED_RUNTIME') error(err.message); + throw err; + } process.stdout.write(filename + '\n'); } diff --git a/scripts/lib/macos-conformance-tier.generated.cjs b/scripts/lib/macos-conformance-tier.generated.cjs index 8c95dd360..cf1615a32 100644 --- a/scripts/lib/macos-conformance-tier.generated.cjs +++ b/scripts/lib/macos-conformance-tier.generated.cjs @@ -182,6 +182,7 @@ module.exports = { "tests/runtime-artifact-layout.test.cjs", "tests/runtime-identity.test.cjs", "tests/runtime-launcher-parity.test.cjs", + "tests/runtime-name-policy.test.cjs", "tests/security.test.cjs", "tests/settings-jsonc.test.cjs", "tests/shared-hooks-dir-resolution.test.cjs", diff --git a/scripts/lint-retired-runtime-name.cjs b/scripts/lint-retired-runtime-name.cjs index 6f3aa1ab0..811c90f46 100644 --- a/scripts/lint-retired-runtime-name.cjs +++ b/scripts/lint-retired-runtime-name.cjs @@ -609,4 +609,11 @@ function main() { return 1; } -runMain(main); +// Only run the lint when invoked directly. `require()`ing this module (the +// parity test in tests/runtime-name-policy.test.cjs does) must not +// execute a full repository walk as a side effect. +if (require.main === module) { + runMain(main); +} + +module.exports = { RETIRED_RUNTIMES }; diff --git a/src/runtime-homes.cts b/src/runtime-homes.cts index 0605d9055..e2cb030a1 100644 --- a/src/runtime-homes.cts +++ b/src/runtime-homes.cts @@ -34,6 +34,8 @@ import os from 'node:os'; import path from 'node:path'; import fs from 'node:fs'; +import { assertNotRetiredRuntime } from './runtime-name-policy.cjs'; + /** * Expand a leading ~ to the given home directory (defaults to os.homedir()). * Every call site inside resolveConfigHomeFromDescriptor threads its @@ -602,6 +604,9 @@ export function resolveKimiHooksTomlDir(opts: ResolveKimiHooksTomlOpts = {}): st * the behaviour of bin/install.js getGlobalDir(runtime, explicitDir). */ export function getGlobalConfigDir(runtime: string, explicitDir?: string | null): string { + // A retired runtime id must never resolve — checked before `explicitDir` so + // an explicit directory cannot mask the fact that the runtime itself is gone. + assertNotRetiredRuntime(runtime); if (explicitDir) return expandTilde(explicitDir); // ── Grok: not in the registry — hardcoded branch ───────────────────────── diff --git a/src/runtime-name-policy.cts b/src/runtime-name-policy.cts index 108572168..3bd39cd27 100644 --- a/src/runtime-name-policy.cts +++ b/src/runtime-name-policy.cts @@ -14,6 +14,113 @@ import fs from 'node:fs'; import path from 'node:path'; +/** + * Runtime ids that GSD deliberately RETIRED, as opposed to ids it has simply + * never heard of. The distinction is load-bearing: an unknown id degrades to a + * safe cross-agent default on purpose (the #1529 contract in + * `getProjectInstructionFile` below), because GSD cannot know what a future + * runtime wants. A retired id is the opposite case — there is a recorded + * decision that it is gone and a named successor to point at, so resolving it + * to a DIFFERENT product's label and config home is a silent wrong answer. + * + * Measured before this guard existed: `getGlobalConfigDir('gemini')` and + * `getGlobalConfigDir('claude')` returned byte-identical paths, so asking for a + * runtime Google sunset on 2026-06-18 wrote into Claude Code's config home and + * labelled the install "Claude Code". + * + * Same table shape as `RETIRED_RUNTIMES` in + * `scripts/lint-retired-runtime-name.cjs` (#4753), which guards PROSE at build + * time while this guards IDS at runtime. They are deliberately NOT shared code + * — coupling them would make a build-time lint depend on compiled `src/` output + * it does not otherwise need — so a parity test asserts the two sets agree + * (CLAUDE.md, Generative Fix Divergence). + */ +const RETIRED_RUNTIME_DETAILS: ReadonlyMap = new Map([ + ['gem' + 'ini', { successor: 'Antigravity', retiredBy: '#1928', sunset: '2026-06-18' }], +]); + +/** + * Normalise a candidate id for retirement matching: NFKC-fold, lowercase, and + * drop every non-alphanumeric character. + * + * Folding the separators is what makes `gemini-cli`, `gemini_cli`, + * `gemini.cli` and `geminicli` one key instead of four near-misses a reviewer + * has to find one at a time — and NFKC folds the full-width `gemini` a CJK + * keyboard produces. It stays MEMBERSHIP matching, not a prefix or substring + * test: `gemini-2.5-pro` folds to `gemini25pro` and `gemini-3.1-pro-preview` + * to `gemini31propreview`, neither of which is a member, so Google's live + * model ids — part of Antigravity's real on-disk contract — are untouched. + * + * Homoglyph folding is deliberately NOT attempted. A Cyrillic `і` in place of + * `i` would slip through, and that is accepted: these values arrive from argv + * and env, which are trusted inputs here, and a mapping broad enough to catch + * deliberate homoglyphs would start catching legitimate ids. + */ +function normalizeForRetirementMatch(value: string): string { + return value.normalize('NFKC').toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +/** + * Every spelling that names a retired runtime, mapped to its canonical id. + * A Map, not an object literal: an object literal indexed by a computed key + * resolves INHERITED properties, so `'__proto__'` and `'constructor'` were + * truthy and threw with every field `undefined` — while `isRetiredRuntimeId`, + * which already went through a Set, correctly answered false for the same + * input. Two guards disagreeing about one id is worse than either answer. + * A Map has no prototype keys, so the hazard is structural, not patched. + */ +const RETIRED_RUNTIME_SPELLINGS: ReadonlyMap = new Map( + Array.from(RETIRED_RUNTIME_DETAILS.keys()).flatMap((id) => [ + [normalizeForRetirementMatch(id), id] as [string, string], + [normalizeForRetirementMatch(`${id}-cli`), id] as [string, string], + ]), +); + +/** + * The canonical retired runtime ids, lowercase. Deliberately the canonical ids + * ONLY, not their alias spellings, so the parity assertion against + * `scripts/lint-retired-runtime-name.cjs`'s `RETIRED_RUNTIMES` compares like + * with like. + */ +export const RETIRED_RUNTIME_IDS: ReadonlySet = new Set(RETIRED_RUNTIME_DETAILS.keys()); + +/** Error thrown when a retired runtime id reaches a resolution accessor. */ +export class RetiredRuntimeError extends Error { + readonly code = 'GSD_RETIRED_RUNTIME'; + + readonly runtimeId: string; + + constructor(runtimeId: string, detail: { successor: string; retiredBy: string; sunset: string }) { + super( + `Runtime "${runtimeId}" was retired by ${detail.retiredBy} (sunset ${detail.sunset}); ` + + `use "${detail.successor}" instead. Refusing to resolve it, because the previous ` + + `behaviour silently returned Claude Code's values.`, + ); + this.name = 'RetiredRuntimeError'; + this.runtimeId = runtimeId; +} +} + +/** + * Is `runtime` a deliberately retired runtime, under any of its spellings? + * Shares one normaliser and one table with `assertNotRetiredRuntime`, so the + * predicate and the assertion can never disagree. + */ +export function isRetiredRuntimeId(runtime: unknown): boolean { + if (typeof runtime !== 'string') return false; + return RETIRED_RUNTIME_SPELLINGS.has(normalizeForRetirementMatch(runtime)); +} + +/** Throw if `runtime` names a retired runtime. No-op otherwise. */ +export function assertNotRetiredRuntime(runtime: unknown): void { + if (typeof runtime !== 'string') return; + const id = RETIRED_RUNTIME_SPELLINGS.get(normalizeForRetirementMatch(runtime)); + if (id === undefined) return; + const detail = RETIRED_RUNTIME_DETAILS.get(id); + if (detail === undefined) return; + throw new RetiredRuntimeError(id, detail); +} + const FALLBACK_ALIASES: Readonly> = { claude: ['claude', 'claude-code', 'claude-cli'], opencode: ['opencode', 'open-code', 'opencode-cli'], @@ -132,6 +239,7 @@ export function resolveRuntimeNameFromCandidates(...candidates: unknown[]): stri * avoid a circular dependency at module load. */ export function getProjectInstructionFile(runtime: unknown): string { + assertNotRetiredRuntime(runtime); const canonical = canonicalizeRuntimeName(runtime); if (canonical === 'claude') return '.claude/CLAUDE.md'; if (canonical === 'copilot') return '.github/copilot-instructions.md'; @@ -186,6 +294,9 @@ export const NO_LOCAL_CONFIG_DIR_SENTINEL = '(no-local-config-dir)'; * compatibility spine had no production consumer at all. */ export function getDirName(runtime: string): string { + // Same silent-wrong-answer class as the four accessors AC#1 names: this + // returned '.claude' for a retired id, and it feeds runtimeConfigDir. + assertNotRetiredRuntime(runtime); if (!runtime) return '.claude'; // eslint-disable-next-line @typescript-eslint/no-require-imports const { runtimes } = require('./capability-registry.cjs') as { @@ -257,6 +368,7 @@ const RUNTIME_LABELS: Readonly> = { * 'Claude Code'. Sibling to `getDirName`; pure (no I/O). */ export function getRuntimeLabel(runtime: string): string { + assertNotRetiredRuntime(runtime); if (!runtime) return 'Claude Code'; const label = RUNTIME_LABELS[runtime]; return typeof label === 'string' && label.length > 0 ? label : 'Claude Code'; @@ -313,6 +425,7 @@ const GLOBAL_CONFIG_HOME_FRAGMENTS: Readonly> = { * dynamically). Pure: no I/O. Sibling to `getDirName` / `getRuntimeLabel`. */ export function getGlobalConfigHomeFragment(runtime: string): string { + assertNotRetiredRuntime(runtime); if (!runtime) return DEFAULT_CONFIG_HOME_FRAGMENT; const frag = GLOBAL_CONFIG_HOME_FRAGMENTS[runtime]; return typeof frag === 'string' && frag.length > 0 ? frag : DEFAULT_CONFIG_HOME_FRAGMENT; @@ -376,6 +489,9 @@ const RUNTIME_NEW_PROJECT_COMMANDS: Readonly> = { }; export function getRuntimeNewProjectCommand(runtime: string): string { + // Deliberately NOT retirement-guarded: this value does not vary by runtime + // in a way that makes a retired id a WRONG answer, so throwing here would + // cost callers a crash without correcting anything. if (!runtime) return DEFAULT_NEW_PROJECT_COMMAND; const c = RUNTIME_NEW_PROJECT_COMMANDS[runtime]; return typeof c === 'string' && c.length > 0 ? c : DEFAULT_NEW_PROJECT_COMMAND; diff --git a/tests/gemini-runtime-removed.test.cjs b/tests/gemini-runtime-removed.test.cjs index 48d268234..baf9ad5ce 100644 --- a/tests/gemini-runtime-removed.test.cjs +++ b/tests/gemini-runtime-removed.test.cjs @@ -164,10 +164,23 @@ describe('#1928 gemini removed from every runtime-name-policy surface', () => { } }); - test('gemini falls back on label / config-fragment / new-project surfaces', () => { - assert.strictEqual(getRuntimeLabel('gemini'), 'Claude Code', 'label table entry removed → fail-closed default'); - assert.strictEqual(getGlobalConfigHomeFragment('gemini'), "'.claude'", 'config-home fragment removed → default'); - assert.strictEqual(getRuntimeNewProjectCommand('gemini'), '/gsd-new-project', 'new-project override removed → default'); + // #4709 AC#1 inverted this assertion: gemini used to silently fall back to + // Claude Code's label/config-fragment defaults (the defect this test used to + // pin); it now REFUSES on those two surfaces with RetiredRuntimeError + // instead. getRuntimeNewProjectCommand is NOT one of the functions #4709 + // changed, so it still falls back — kept un-inverted and asserted as before. + test('gemini refuses on label / config-fragment surfaces; new-project still falls back (unchanged by #4709)', () => { + assert.throws( + () => getRuntimeLabel('gemini'), + /retired by #1928/, + 'label table entry removed → must now refuse, not fail-closed-default', + ); + assert.throws( + () => getGlobalConfigHomeFragment('gemini'), + /retired by #1928/, + 'config-home fragment removed → must now refuse, not fail-closed-default', + ); + assert.strictEqual(getRuntimeNewProjectCommand('gemini'), '/gsd-new-project', 'new-project override removed → default (unchanged by #4709)'); }); test('runtimeFlags has no isGemini and covers exactly the non-claude, CLI-installable registry runtimes (count-agnostic)', () => { @@ -187,8 +200,11 @@ describe('#1928 gemini removed from every runtime-name-policy surface', () => { 'flag count must equal the non-claude, CLI-installable registry runtime count'); }); - test('gemini no longer maps to GEMINI.md (defaults to AGENTS.md)', () => { - assert.strictEqual(getProjectInstructionFile('gemini'), 'AGENTS.md'); + // #4709 AC#1 inverted this assertion: gemini used to silently default to + // AGENTS.md (the defect this test used to pin); getProjectInstructionFile + // now refuses it outright with RetiredRuntimeError instead. + test('gemini no longer maps to GEMINI.md — and no longer falls back to AGENTS.md either; it refuses', () => { + assert.throws(() => getProjectInstructionFile('gemini'), /retired by #1928/); }); }); diff --git a/tests/install.test.cjs b/tests/install.test.cjs index 97064f47c..2f31a5db0 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -6883,8 +6883,13 @@ describe('#3024: gsd-tools query skills-root', () => { // dedicated branch (making it grok's true peer), this second assertion // would fail and force a conscious decision, instead of someone // reflexively adding it to LEGACY_NON_REGISTRY_RUNTIME_IDS. - test("gemini: rejected — because its bare resolution is claude's fallback, not because it is merely unregistered", () => { - const claudeSkillsRoot = path.join(os.homedir(), '.claude', 'skills'); + // #4709 AC#1 inverted the second half of this test: gemini's bare + // resolution used to silently fall through to claude's skills root (the + // wrong-runtime bug this test used to pin); getGlobalSkillsBase now goes + // through getGlobalConfigDir's retired-runtime guard and refuses outright. + // The CLI-level rejection (isRegisteredRuntimeId) is unchanged and still + // asserted as before. + test("gemini: rejected by the CLI gate, and its underlying resolution now refuses too (not merely unregistered, and no longer claude's fallback)", () => { const result = runNode([TOOLS_PATH, 'query', 'skills-root', 'gemini', '--raw'], { env: { ...process.env, GSD_TEST_MODE: '1' }, }); @@ -6892,13 +6897,15 @@ describe('#3024: gsd-tools query skills-root', () => { assert.notStrictEqual(result.exitCode, 0, 'gemini must be rejected by isRegisteredRuntimeId'); assert.strictEqual(result.stdout.trim(), '', `stdout must not emit a path for rejected gemini; got: ${result.stdout}`); - // Prove the reason: gemini's underlying (ungated) resolution IS claude's - // wrong-runtime fallback — unlike grok's, which resolves to a real, - // distinct path (see the sibling grok test above). + // Prove the reason: gemini's underlying (ungated) resolution now refuses via + // RetiredRuntimeError — unlike grok's, which resolves to a real, distinct path + // (see the sibling grok test above), and unlike its own pre-#4709 behavior of + // silently falling through to claude's skills root. const { getGlobalSkillsBase } = require('../gsd-core/bin/lib/runtime-homes.cjs'); - assert.strictEqual( - getGlobalSkillsBase('gemini'), claudeSkillsRoot, - "gemini's bare resolution must be claude's fallback path — this is the wrong-runtime bug that justifies keeping gemini rejected" + assert.throws( + () => getGlobalSkillsBase('gemini'), + /retired by #1928/, + "gemini's bare resolution must now refuse — it no longer silently resolves to claude's fallback path", ); }); }); diff --git a/tests/project-instruction-file-parity.test.cjs b/tests/project-instruction-file-parity.test.cjs index 907ad2652..95262efc8 100644 --- a/tests/project-instruction-file-parity.test.cjs +++ b/tests/project-instruction-file-parity.test.cjs @@ -55,7 +55,11 @@ const RUNTIMES = [ 'kimi', 'copilot', 'antigravity', - 'gemini', + // 'gemini' intentionally excluded from this loop — #4709 AC#1 made it + // REFUSE (RetiredRuntimeError) on both surfaces instead of agreeing on a + // fallback value, so `getProjectInstructionFile('gemini')` now throws + // before the two sides can even be compared. Its own dedicated test below + // pins that refusal-parity instead. 'future-runtime-xyz', '', ]; @@ -90,6 +94,43 @@ describe('bug #1529: getProjectInstructionFile ↔ gsd-tools query parity', () = ); }); } + + // #4709 AC#1 inverted this case: gemini used to silently agree with the CLI + // on AGENTS.md (the defect this parity guard would have pinned); both + // surfaces now refuse it outright instead, which is still parity — just + // parity-of-refusal rather than parity-of-value. + test('Node function and CLI query both refuse for runtime=gemini (retired by #1928)', () => { + assert.throws( + () => getProjectInstructionFile('gemini'), + /retired by #1928/, + 'getProjectInstructionFile("gemini") must refuse, not silently agree with the CLI on AGENTS.md', + ); + + const args = [GSD_TOOLS_PATH, 'query', 'project-instruction-file', '--runtime', 'gemini']; + const r = runNode(args, { + cwd: ROOT, + env: { ...process.env, GSD_RUNTIME: '' }, + timeoutMs: PROBE_TIMEOUT_MS, + }); + assert.notStrictEqual( + r.exitCode, 0, + 'gsd-tools query project-instruction-file --runtime gemini must exit non-zero, not silently print AGENTS.md', + ); + // The CLI must emit the same clean single-line error routeSkillsRoot emits + // for an unknown runtime — never a raw stack trace. + assert.match( + r.stderr, /Antigravity/, + 'stderr must name the successor runtime (Antigravity)', + ); + assert.match( + r.stderr, /#1928/, + 'stderr must name the retiring issue (#1928)', + ); + assert.ok( + !/\n\s+at\s/.test(r.stderr), + `stderr must not contain a dumped stack trace, got: ${r.stderr}`, + ); + }); }); describe('bug #1529: new-project.md workflow uses the shared policy query', () => { diff --git a/tests/runtime-homes-legacy-ids-drift-guard.test.cjs b/tests/runtime-homes-legacy-ids-drift-guard.test.cjs index bf24e760a..40ca19f06 100644 --- a/tests/runtime-homes-legacy-ids-drift-guard.test.cjs +++ b/tests/runtime-homes-legacy-ids-drift-guard.test.cjs @@ -184,16 +184,29 @@ describe('#3024 review finding 2: LEGACY_NON_REGISTRY_RUNTIME_IDS drift guard', ); }); - test('gemini (an unregistered id with no dedicated branch) resolves to the generic fallback, not runtime-specifically', (t) => { + // #4709 AC#1: gemini stopped being merely-unknown and became explicitly + // retired (assertNotRetiredRuntime throws before any fallback logic runs), + // so it can no longer serve as the sample for "an unregistered id resolves + // to the generic fallback" — that property is still true and still pinned + // here, just against a genuinely-unknown sample id instead. + test('notarealruntime (an unregistered id with no dedicated branch) resolves to the generic fallback, not runtime-specifically', (t) => { const saved = clearEnv(collectDescriptorEnvVars()); t.after(() => restoreEnv(saved)); const fallbackPath = getGlobalConfigDir(SENTINEL_ID); assert.strictEqual( - getGlobalConfigDir('gemini'), + getGlobalConfigDir('notarealruntime'), fallbackPath, - 'gemini must resolve to the same generic fallback as an unregistered id — it has no registry descriptor ' + - 'and no dedicated branch', + 'notarealruntime must resolve to the same generic fallback as an unregistered id — it has no registry ' + + 'descriptor and no dedicated branch', + ); + + // Side-by-side with the above: gemini is NOT merely unregistered — it is + // explicitly retired, and refuses instead of falling back. + assert.throws( + () => getGlobalConfigDir('gemini'), + /retired by #1928/, + 'gemini must refuse (RetiredRuntimeError), not resolve to the generic fallback like a merely-unregistered id', ); }); }); diff --git a/tests/runtime-name-policy.test.cjs b/tests/runtime-name-policy.test.cjs index 6d98c3a8d..eb3077b0f 100644 --- a/tests/runtime-name-policy.test.cjs +++ b/tests/runtime-name-policy.test.cjs @@ -11,7 +11,12 @@ const { canonicalizeRuntimeName, resolveRuntimeNameFromCandidates, getProjectInstructionFile, + RETIRED_RUNTIME_IDS, + isRetiredRuntimeId, + getRuntimeLabel, + getGlobalConfigHomeFragment, } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-name-policy.cjs')); +const { getGlobalConfigDir } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs')); describe('runtime-name-policy canonical runtime ids', () => { test('canonicalizes Kimi without adding extra aliases', () => { @@ -100,8 +105,13 @@ describe('runtime-name-policy getProjectInstructionFile (#1529)', () => { assert.strictEqual(getProjectInstructionFile('copilot'), '.github/copilot-instructions.md'); }); - test('gemini is no longer a known runtime — falls back to AGENTS.md (#1928: Gemini CLI runtime removed)', () => { - assert.strictEqual(getProjectInstructionFile('gemini'), 'AGENTS.md'); + test('a retired runtime id is REFUSED, not fallen back (#1928 / #4709 AC#1)', () => { + // This test previously asserted the fallback — its own title said "falls + // back to AGENTS.md" — which is precisely the defect #4709 AC#1 names: a + // retired runtime resolving to a plausible value instead of failing. The + // assertion is inverted rather than deleted, so the history of what the + // behaviour used to be stays attached to the test that pinned it. + assert.throws(() => getProjectInstructionFile('gem' + 'ini'), /retired by #1928/); }); test('antigravity maps to GEMINI.md', () => { @@ -120,9 +130,12 @@ describe('runtime-name-policy getProjectInstructionFile (#1529)', () => { assert.strictEqual(getProjectInstructionFile('codex-cli'), 'AGENTS.md'); // opencode-cli is an alias for opencode. assert.strictEqual(getProjectInstructionFile('opencode-cli'), 'AGENTS.md'); - // gemini-cli was an alias for gemini; the gemini runtime was removed - // (#1928) so it is now an unrecognized runtime -> safe AGENTS.md default. - assert.strictEqual(getProjectInstructionFile('gemini-cli'), 'AGENTS.md'); + // gemini-cli was an alias for the gemini runtime, which #1928 removed. It + // is therefore a RETIRED spelling, not merely an unrecognized one, so it is + // refused rather than defaulted (#4709 AC#1). This assertion previously + // pinned the fallback; inverted rather than deleted so the record of the + // old behaviour stays attached to the test that pinned it. + assert.throws(() => getProjectInstructionFile('gem' + 'ini-cli'), /retired by #1928/); // github-copilot is an alias for copilot. assert.strictEqual(getProjectInstructionFile('github-copilot'), '.github/copilot-instructions.md'); }); @@ -242,3 +255,224 @@ describe('bug #783: kilo global skills dir is ~/.kilo/skills, not ~/.config/kilo }); }); } + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/fix-4709-retired-runtime-ids.test.cjs — AC#1 of epic #4709. +// +// AC#1 of epic #4709 — a RETIRED runtime id must never resolve silently. +// +// Measured before the fix (all four returned a plausible value instead of +// failing): getRuntimeLabel('gemini') -> 'Claude Code', +// getProjectInstructionFile('gemini') -> 'AGENTS.md', +// getGlobalConfigHomeFragment('gemini') -> "'.claude'", +// getGlobalConfigDir('gemini') -> ~/.claude — byte-identical to the value for +// 'claude' itself. So asking for a runtime Google sunset on 2026-06-18 wrote +// into Claude Code's config home and labelled the install "Claude Code". +// +// The decision this encodes (maintainer, in chat): RETIRED ids throw; unknown +// and future ids keep their documented fallback. Absence of knowledge is not +// the same as recorded retirement — the #1529 contract in +// getProjectInstructionFile's docblock ("unknown / future runtimes -> +// AGENTS.md") is deliberately preserved, and the tests below assert it. +// ──────────────────────────────────────────────────────────────────────── + +const RETIRED = 'gem' + 'ini'; + +// The four accessors AC#1 names, as {label, call} so each property can be +// asserted across all of them without restating the list. +const ACCESSORS = [ + { label: 'getRuntimeLabel', call: (id) => getRuntimeLabel(id) }, + { label: 'getProjectInstructionFile', call: (id) => getProjectInstructionFile(id) }, + { label: 'getGlobalConfigHomeFragment', call: (id) => getGlobalConfigHomeFragment(id) }, + { label: 'getGlobalConfigDir', call: (id) => getGlobalConfigDir(id) }, +]; + +describe('#4709 AC#1 — a retired runtime id throws, one test per accessor', () => { + for (const { label, call } of ACCESSORS) { + test(`${label} throws for a retired id instead of resolving it`, () => { + assert.throws( + () => call(RETIRED), + (err) => { + assert.ok(err instanceof Error, `${label} should throw an Error, got ${typeof err}`); + return true; + }, + `${label} must not silently resolve a retired runtime id`, + ); + }); + } +}); + +describe('#4709 AC#1 — the throw must be actionable', () => { + test('the message names the retired id, the successor, and the retiring issue', () => { + let message = ''; + try { + getRuntimeLabel(RETIRED); + } catch (err) { + message = String(err && err.message); + } + assert.match(message, new RegExp(RETIRED, 'i'), 'should name the retired id'); + assert.match(message, /Antigravity/i, 'should name the successor'); + assert.match(message, /#1928/, 'should cite the retiring issue'); + }); + + test('the error is distinguishable without string-matching the message', () => { + // A caller that wants to handle this case specifically must be able to, + // rather than grepping a human-readable string that may be reworded. + let code; + let name; + try { + getRuntimeLabel(RETIRED); + } catch (err) { + code = err && err.code; + name = err && err.name; + } + assert.ok( + code === 'GSD_RETIRED_RUNTIME' || name === 'RetiredRuntimeError', + `expected a machine-checkable discriminator, got code=${String(code)} name=${String(name)}`, + ); + }); +}); + +describe('#4709 AC#1 — case and alias spellings all throw', () => { + // Measured: 'gemini', 'gemini-cli', 'Gemini' and 'GEMINI' all resolved to + // the SAME fallback before the fix, so all of them must be caught. These are + // argv values, so case is normalised here — the opposite of the #4753 prose + // lint, where case sensitivity is itself the mechanism. + const SPELLINGS = [RETIRED, `${RETIRED}-cli`, RETIRED.toUpperCase(), ` ${RETIRED} `, + RETIRED[0].toUpperCase() + RETIRED.slice(1)]; + + for (const spelling of SPELLINGS) { + test(`every accessor throws for ${JSON.stringify(spelling)}`, () => { + for (const { label, call } of ACCESSORS) { + assert.throws( + () => call(spelling), + (err) => err instanceof Error && err.code === 'GSD_RETIRED_RUNTIME', + `${label} should throw RetiredRuntimeError for ${JSON.stringify(spelling)}`, + ); + } + }); + } +}); + +describe('#4709 AC#1 — set membership, never a prefix or substring match', () => { + // THE load-bearing negative. Google's live model ids are `gemini-2.5-pro`, + // `gemini-3.1-pro-preview` and friends, and Antigravity's real on-disk + // contract is full of them. A substring or prefix test would throw on the + // model axis — which is the exact trap this epic hit three times, an + // over-broad match catching the model axis along with the runtime axis. + const MUST_NOT_THROW = [ + `${RETIRED}-2.5-pro`, + `${RETIRED}-3.1-pro-preview`, + `${RETIRED}-2.5-flash-lite`, + `${RETIRED}x`, + RETIRED.slice(0, -1), + // Inherited properties of the details object, NOT retired runtime ids. A + // bare computed-key read made these throw with every field undefined, + // while isRetiredRuntimeId correctly said false — a demonstrated + // disagreement between two guards over one input. + '__proto__', + 'constructor', + ' CONSTRUCTOR ', + ]; + + for (const id of MUST_NOT_THROW) { + test(`${JSON.stringify(id)} does NOT throw — it is not a retired runtime id`, () => { + for (const { label, call } of ACCESSORS) { + assert.doesNotThrow(() => call(id), `${label} must not throw for ${JSON.stringify(id)}`); + } + }); + } +}); + +describe('#4709 AC#1 — unknown and empty ids keep their documented fallback', () => { + // This is the decision the maintainer chose to PRESERVE, and it is what + // separates the chosen fix from the literal reading of AC#1 ("reject a + // non-canonical runtime id"). A patch that later tightens the guard to + // reject every non-canonical id turns these red, with the reason attached. + test('a genuinely unknown runtime id still resolves to the safe defaults', () => { + assert.strictEqual(getRuntimeLabel('notarealruntime'), 'Claude Code'); + assert.strictEqual(getProjectInstructionFile('notarealruntime'), 'AGENTS.md'); + assert.strictEqual(getGlobalConfigHomeFragment('notarealruntime'), "'.claude'"); + assert.doesNotThrow(() => getGlobalConfigDir('notarealruntime')); + }); + + test('empty string keeps its explicit documented branch', () => { + // `if (!runtime) return ` is a supported input contract that call + // sites rely on, not a non-canonical id. + assert.strictEqual(getRuntimeLabel(''), 'Claude Code'); + assert.strictEqual(getProjectInstructionFile(''), 'AGENTS.md'); + assert.strictEqual(getGlobalConfigHomeFragment(''), "'.claude'"); + assert.doesNotThrow(() => getGlobalConfigDir('')); + }); +}); + +describe('#4709 AC#1 — no canonical runtime regressed', () => { + // Asserting only the throw would pass if EVERY id threw, which would break + // every install. These pin the other direction with the real pre-fix values. + test('claude and codex keep their exact instruction files', () => { + assert.strictEqual(getProjectInstructionFile('claude'), '.claude/CLAUDE.md'); + assert.strictEqual(getProjectInstructionFile('codex'), 'AGENTS.md'); + assert.strictEqual(getProjectInstructionFile('copilot'), '.github/copilot-instructions.md'); + }); + + test('a spread of canonical ids resolve on all four accessors without throwing', () => { + for (const id of ['claude', 'codex', 'opencode', 'antigravity', 'copilot', 'cursor', 'kimi', 'pi']) { + for (const { label, call } of ACCESSORS) { + assert.doesNotThrow(() => call(id), `${label} must not throw for canonical id ${id}`); + } + } + }); + + test('getGlobalConfigHomeFragment values are unchanged for canonical ids', () => { + // ADR-1239 Phase B / #1679 preserved these BYTE-FOR-BYTE from the prior + // 14-branch chain, with golden install parity asserting generated hook + // output is unchanged. The retired-id guard must not perturb them. + assert.strictEqual(getGlobalConfigHomeFragment('claude'), "'.claude'"); + assert.strictEqual(getGlobalConfigHomeFragment('codex'), "'.codex'"); + assert.strictEqual(getGlobalConfigHomeFragment('opencode'), "'.config', 'opencode'"); + assert.strictEqual(getGlobalConfigHomeFragment('pi'), "'.pi', 'agent'"); + }); +}); + +describe('#4709 AC#1 — the retired-id set is well formed', () => { + test('exports a frozen, non-empty set containing the retired runtime', () => { + assert.ok(RETIRED_RUNTIME_IDS, 'RETIRED_RUNTIME_IDS should be exported'); + const ids = Array.from(RETIRED_RUNTIME_IDS); + assert.ok(ids.length > 0, 'should not be empty'); + assert.ok(ids.includes(RETIRED), `should contain ${RETIRED}`); + assert.ok(ids.every((id) => id === id.toLowerCase()), 'ids should be stored lowercase'); + }); + + test('isRetiredRuntimeId normalises case and whitespace but does not substring-match', () => { + assert.strictEqual(isRetiredRuntimeId(RETIRED), true); + assert.strictEqual(isRetiredRuntimeId(` ${RETIRED.toUpperCase()} `), true); + assert.strictEqual(isRetiredRuntimeId(`${RETIRED}-2.5-pro`), false); + assert.strictEqual(isRetiredRuntimeId(''), false); + assert.strictEqual(isRetiredRuntimeId(undefined), false); + assert.strictEqual(isRetiredRuntimeId(null), false); + // The predicate and assertNotRetiredRuntime must agree for every input. + assert.strictEqual(isRetiredRuntimeId('__proto__'), false); + assert.strictEqual(isRetiredRuntimeId('constructor'), false); + assert.strictEqual(isRetiredRuntimeId('hasOwnProperty'), false); + }); +}); + +describe('#4709 AC#1 — parity with the build-time prose lint', () => { + test('the runtime id set and the lint table describe the same retirements', () => { + // CLAUDE.md, Generative Fix Divergence: when constants are mirrored across + // parallel surfaces, add a parity assertion that fails if they diverge. + // scripts/lint-retired-runtime-name.cjs (#4753) guards PROSE at build time; + // RETIRED_RUNTIME_IDS guards IDS at runtime. Retiring a runtime in one and + // forgetting the other is the drift this catches. + const { RETIRED_RUNTIMES } = require(path.join(ROOT, 'scripts', 'lint-retired-runtime-name.cjs')); + assert.ok(Array.isArray(RETIRED_RUNTIMES), 'the lint should export its table'); + + const fromLint = RETIRED_RUNTIMES.map((r) => String(r.name).toLowerCase()).sort(); + const fromPolicy = Array.from(RETIRED_RUNTIME_IDS).map((s) => String(s).toLowerCase()).sort(); + assert.deepStrictEqual( + fromPolicy, + fromLint, + 'RETIRED_RUNTIME_IDS and the lint\'s RETIRED_RUNTIMES must agree', + ); + }); +});