* 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 on5d4c98cde7by 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 against5d4c98cde7before 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:
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user