fix(#4709): a retired runtime id must not resolve to Claude Code (#4756)

* fix(#4709): a retired runtime id must not resolve to Claude Code

AC#1 of epic #4709 — the last unmet acceptance criterion. Every other phase
(#4711, #4716, #4732, #4743, #4753) is merged; the epic does not close until
this lands.

THE DEFECT, MEASURED

Five runtime-resolution accessors resolved a RETIRED id to a plausible-looking
value, indistinguishable from the same call with a canonical id. Measured on
5d4c98cde7 by executing the built modules:

  getRuntimeLabel('gemini')              -> 'Claude Code'
  getProjectInstructionFile('gemini')    -> 'AGENTS.md'
  getGlobalConfigHomeFragment('gemini')  -> "'.claude'"
  getGlobalConfigDir('gemini')           -> ~/.claude   (byte-identical to 'claude')
  getDirName('gemini')                   -> '.claude'

So asking for a runtime Google sunset on 2026-06-18 wrote into Claude Code's
global config home and labelled the install "Claude Code". Nothing errored and
nothing warned.

AC#1 names four accessors. getDirName is the fifth, found by a reviewer: same
module, same silent-wrong-answer class, and it feeds capability-state's
runtimeConfigDir. Fixing only the four the criterion happened to list would
have left the defect reachable, so it is guarded too.

WHY THE CHECK CANNOT LIVE IN CANONICALIZATION

canonicalizeRuntimeName returns null for 'gemini', 'gemini-cli', 'Gemini',
'GEMINI' AND for ''. After canonicalization a retired id, an unknown id and an
empty string are the same value, so anything keyed off the canonical form
cannot tell them apart — it would have to treat all three alike, which is the
behaviour being fixed. The check therefore runs on the RAW input.

WHAT THIS DELIBERATELY DOES NOT DO

The criterion reads "reject a non-canonical runtime id". Taken literally that
overturns three recorded decisions, so the narrower reading was put to the
maintainer as a blocking question and this implements the answer: RETIRED ids
throw, unknown and future ids keep falling back.

Preserved:

  - The #1529 contract, written into getProjectInstructionFile's own docblock
    as a mapping table ending "unknown / future runtimes -> AGENTS.md (safe
    cross-agent default)". That default exists so a runtime GSD has never heard
    of still gets a working instruction file.
  - ADR-1239 Phase B / #1679, which preserved GLOBAL_CONFIG_HOME_FRAGMENTS
    BYTE-FOR-BYTE when it collapsed a 14-branch chain, with golden install
    parity asserting generated hook output is unchanged across every runtime.
  - The explicit `if (!runtime) return <default>` branch. Empty string is a
    supported input, not a non-canonical id.

The distinction the code encodes: ABSENCE OF KNOWLEDGE IS NOT THE SAME AS
RECORDED RETIREMENT. Unknown means "no information, degrade safely". Retired
means "we know it is gone and we know what replaced it" — and silently
substituting a different product for it is the defect.

ONE INACCURACY IN THE CRITERION, RECORDED RATHER THAN REPEATED

AC#1 says the accessors return "a Claude Code value". True for getRuntimeLabel,
getGlobalConfigHomeFragment, getGlobalConfigDir and getDirName — but
getProjectInstructionFile returns 'AGENTS.md', which is not a Claude value at
all. The defect it points at is real for all of them, so the fix covers all of
them, but the wording is wrong for one.

MATCHING

RETIRED_RUNTIME_DETAILS is a Map keyed by canonical retired id, and
RETIRED_RUNTIME_SPELLINGS maps every spelling to that id. Both are Maps, not
object literals: a 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 that hazard is
structural rather than patched. The predicate and the assertion now share one
normaliser and one table and cannot diverge.

Candidates are normalised NFKC + lowercase + strip non-alphanumerics. Folding
the separators makes 'gemini-cli', 'gemini_cli', 'gemini.cli' and 'geminicli'
one key instead of four near-misses found one at a time, and NFKC folds the
full-width 'gemini' a CJK keyboard produces. It stays MEMBERSHIP matching,
never prefix or substring: 'gemini-2.5-pro' folds to 'gemini25pro' and
'gemini-3.1-pro-preview' to 'gemini31propreview', neither a member, so Google's
live model ids — part of Antigravity's real on-disk contract — are untouched.

Homoglyph folding is deliberately not attempted, and a Cyrillic 'і' would slip
through. These values arrive from argv and env, trusted inputs here, and a
mapping broad enough to catch deliberate homoglyphs would start catching
legitimate ids. Stated rather than left for the next reader to discover.

This over-broad-match trap is the recurring shape of the whole epic: an
exclusion or match written wider than its subject. Four occurrences, each
cited: #4716's `gemini-[0-9]` sweep exclusion hid a stale review.models.gemini
row whose value was "gemini-2.5-pro" on the same line; #4753's first
model-display escape was a blanket /^ \d/ that laundered "Gemini 2.5 CLI as a
supported runtime."; its dialect rule then used a +/-24-character window that
let one legitimate reference license a live claim 21 characters away; and its
model rule treated the ABSENCE of a runtime word as a grant, passing five
unqualified live-runtime claims. Earlier drafts of this message and its
artifacts said "five" in one place and "three" in another with nothing cited;
it is four, listed here, and the artifacts now agree.

THE THROW

RetiredRuntimeError carries `code: 'GSD_RETIRED_RUNTIME'` so a caller can
handle this case without string-matching a message that may be reworded, and
the message names the id, the successor and the retiring issue.
assertNotRetiredRuntime runs as the FIRST statement of each accessor, including
before getGlobalConfigDir's explicitDir branch, so an explicit directory cannot
mask a runtime that is gone.

`gsd-tools query project-instruction-file --runtime gemini` answered the new
throw with a raw stack trace — a user-facing regression this change introduced.
Its sibling routeSkillsRoot already emitted a clean single-line error for an
unknown runtime, so that route now maps GSD_RETIRED_RUNTIME through the same
`error()` helper, and a test asserts the contract directly: non-zero exit,
stderr naming Antigravity and #1928, and no stack frame. It was the only
unwrapped call site in that CLI; I checked the rest rather than assuming.

getRuntimeNewProjectCommand is deliberately NOT guarded: its value does not
vary by runtime in a way that makes a retired id a wrong answer, so throwing
would cost callers a crash without correcting anything. Verified by observing
it return the same value across claude, codex, opencode, kimi, antigravity,
copilot and an unknown id.

RECONCILING THE TESTS THAT PINNED THE DEFECT

The full remote matrix went red with 14 failures, and every one was a
pre-existing test asserting the fallback this criterion calls a defect. One had
already been caught locally by review; the matrix found the other thirteen
across four files. They were reconciled by intent, not blanket-inverted:

  - Tests whose SUBJECT is the retired runtime — "gemini falls back on label /
    config-fragment / new-project surfaces", "gemini no longer maps to
    GEMINI.md (defaults to AGENTS.md)", "gemini is no longer a known runtime —
    falls back to AGENTS.md" — had pinned the defect, titles and all. Their
    assertions are INVERTED rather than deleted, so the history of what the
    behaviour used to be stays attached to the test that pinned it.
  - Tests whose SUBJECT is "an unregistered id falls back generically", with
    gemini merely the SAMPLE, still assert a TRUE property that this change
    deliberately preserved. Those keep their assertion and switch the sample to
    a genuinely unknown id, with a retired-id refusal pinned alongside so both
    halves of the distinction sit together.
  - The project-instruction-file parity loop dropped gemini from its
    parametrised runtimes — both sides now refuse, so there is no value to
    agree on — and gained a dedicated refusal-parity test.

A FIFTEENTH was then found by executing the touched suites locally, in process,
one file at a time — `tests/runtime-name-policy.test.cjs:135` asserted
`getProjectInstructionFile('gemini-cli') === 'AGENTS.md'`, and its own comment
read "gemini-cli was an alias for gemini", which is exactly why that spelling
is now a retired one rather than a merely-unrecognised one. Inverted like the
rest.

Two remote runs on this change were avoidable: the first by reconciling the
tests that pinned the old behaviour before shipping, the second by executing
the touched suites locally first. The matrix is the authority; it is not the
discovery mechanism. Local per-file execution is bounded and cheap and is not
the banned `node --test` fan-out.

All five touched suites now pass in process: runtime-name-policy 47/47,
gemini-runtime-removed 32/32, project-instruction-file-parity 12/12,
runtime-homes-legacy-ids-drift-guard 2/2, install 452/452.

COVERAGE

Failing-first, one per accessor as the criterion demands, each proven RED
against 5d4c98cde7 before the fix existed — the table at the top of this
message IS that baseline, and the exports the tests import did not exist yet
either.

Asserting only the throw would pass if every id threw, which would break every
install, so each property is paired with its opposite: every canonical id still
resolves on all five accessors with byte-identical values; '' keeps its
documented branch; a genuinely unknown id keeps 'Claude Code' / 'AGENTS.md' /
'.claude' / ~/.claude. That last one is the load-bearing negative — it is the
decision the maintainer chose to preserve, so a later patch that "tightens" the
guard to reject all non-canonical ids turns it red with the reason attached.

Boundary coverage maps limit-1/limit/limit+1 onto set membership: 'gemin',
'geminix', 'gemini-2.5-pro' and 'gemini-3.1-pro-preview' must NOT throw, the
retired id and its folded spellings must. '__proto__', 'constructor' and
'  CONSTRUCTOR  ' are pinned as must-not-throw, and predicate/assertion
agreement is asserted directly. Several assert.throws calls initially passed a
string as the second argument, which node treats as the MESSAGE rather than a
matcher, so they asserted nothing about the error; they now use a real
predicate checking the code.

The tests live in the owning modules' suites rather than a new issue-named
file: lint-regression-test-names rejects new bug-NNNN/fix-NNNN/issue-NNNN test
files outright and directs the regression to the owning module's suite.
scripts/lib/macos-conformance-tier.generated.cjs regenerated through its own
--write path, since the tracked test-file count moved.

Fixes #4709

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4709): backfill changeset PR number (#4756)

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-09-14 22:22:16 -04:00
committed by GitHub
parent 85026f6a05
commit e2bfc06558
11 changed files with 482 additions and 26 deletions

View File

@@ -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/);
});
});

View File

@@ -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",
);
});
});

View File

@@ -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', () => {

View File

@@ -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',
);
});
});

View File

@@ -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 <default>` 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',
);
});
});