* 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>
479 lines
22 KiB
JavaScript
479 lines
22 KiB
JavaScript
// docs-guard-exempt: kilo.ai/docs/... is an external URL citation in a comment, not a repo path.
|
|
'use strict';
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const path = require('node:path');
|
|
const fs = require('node:fs');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
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', () => {
|
|
assert.strictEqual(canonicalizeRuntimeName('kimi'), 'kimi');
|
|
assert.strictEqual(canonicalizeRuntimeName(' KIMI '), 'kimi');
|
|
assert.strictEqual(resolveRuntimeNameFromCandidates('', null, 'kimi'), 'kimi');
|
|
assert.strictEqual(canonicalizeRuntimeName('kimi-cli'), null);
|
|
});
|
|
|
|
test('canonicalizes devin-desktop to windsurf (#792)', () => {
|
|
assert.strictEqual(canonicalizeRuntimeName('devin-desktop'), 'windsurf');
|
|
assert.strictEqual(canonicalizeRuntimeName('DEVIN-DESKTOP'), 'windsurf');
|
|
assert.strictEqual(resolveRuntimeNameFromCandidates('devin-desktop'), 'windsurf');
|
|
});
|
|
});
|
|
|
|
describe('runtime-name-policy windsurf alias parity — manifest vs FALLBACK_ALIASES (#792)', () => {
|
|
// DEFECT.GENERATIVE-FIX: manifest and FALLBACK_ALIASES are manually mirrored;
|
|
// this test fails if they diverge for the windsurf key.
|
|
const manifestPath = path.join(ROOT, 'gsd-core', 'bin', 'shared', 'runtime-aliases.manifest.json');
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
|
|
test('manifest windsurf array includes devin-desktop', () => {
|
|
assert.ok(
|
|
Array.isArray(manifest.windsurf) && manifest.windsurf.includes('devin-desktop'),
|
|
`runtime-aliases.manifest.json windsurf array must include 'devin-desktop'; got: ${JSON.stringify(manifest.windsurf)}`,
|
|
);
|
|
});
|
|
|
|
test('FALLBACK_ALIASES windsurf includes devin-desktop (via canonicalization round-trip)', () => {
|
|
// The built module merges manifest over FALLBACK_ALIASES; if the manifest is present
|
|
// this verifies the combined set. The manifest test above separately guards the manifest.
|
|
// Here we verify the live canonicalizer sees devin-desktop -> windsurf.
|
|
assert.strictEqual(
|
|
canonicalizeRuntimeName('devin-desktop'),
|
|
'windsurf',
|
|
'devin-desktop must resolve to windsurf via alias lookup',
|
|
);
|
|
});
|
|
|
|
test('manifest and FALLBACK_ALIASES windsurf alias sets are identical', () => {
|
|
// Read FALLBACK_ALIASES from source to detect manual drift before a build.
|
|
const srcPath = path.join(ROOT, 'src', 'runtime-name-policy.cts');
|
|
// allow-test-rule: source-text-is-the-product (#3464)
|
|
// FALLBACK_ALIASES source text IS the product contract for runtimes that can't load the manifest at runtime; verifying
|
|
// both surfaces contain the same windsurf aliases catches manual-mirror drift.
|
|
const src = fs.readFileSync(srcPath, 'utf8');
|
|
// eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own bounded src/runtime-name-policy.cts source, not adversarial input
|
|
const match = src.match(/windsurf:\s*\[([^\]]+)\]/);
|
|
assert.ok(match, 'FALLBACK_ALIASES windsurf row must exist in src/runtime-name-policy.cts');
|
|
const srcAliases = match[1]
|
|
.split(',')
|
|
.map(s => s.trim().replace(/^['"]|['"]$/g, ''))
|
|
.filter(Boolean);
|
|
const manifestAliases = [...manifest.windsurf].sort();
|
|
assert.deepStrictEqual(
|
|
[...srcAliases].sort(),
|
|
manifestAliases,
|
|
`FALLBACK_ALIASES windsurf=${JSON.stringify(srcAliases.sort())} must match manifest windsurf=${JSON.stringify(manifestAliases)}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('runtime-name-policy getProjectInstructionFile (#1529)', () => {
|
|
test('claude maps to .claude/CLAUDE.md (kept-as-is boundary case)', () => {
|
|
assert.strictEqual(getProjectInstructionFile('claude'), '.claude/CLAUDE.md');
|
|
});
|
|
|
|
test('codex maps to AGENTS.md', () => {
|
|
assert.strictEqual(getProjectInstructionFile('codex'), 'AGENTS.md');
|
|
});
|
|
|
|
test('opencode maps to AGENTS.md (the #1529 bug surface)', () => {
|
|
assert.strictEqual(getProjectInstructionFile('opencode'), 'AGENTS.md');
|
|
});
|
|
|
|
test('kilo maps to AGENTS.md', () => {
|
|
assert.strictEqual(getProjectInstructionFile('kilo'), 'AGENTS.md');
|
|
});
|
|
|
|
test('kimi maps to AGENTS.md', () => {
|
|
assert.strictEqual(getProjectInstructionFile('kimi'), 'AGENTS.md');
|
|
});
|
|
|
|
test('copilot maps to .github/copilot-instructions.md (GitHub docs read path)', () => {
|
|
assert.strictEqual(getProjectInstructionFile('copilot'), '.github/copilot-instructions.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', () => {
|
|
assert.strictEqual(getProjectInstructionFile('antigravity'), 'GEMINI.md');
|
|
});
|
|
|
|
test('unknown runtime maps to AGENTS.md (safe cross-agent default, boundary case)', () => {
|
|
assert.strictEqual(getProjectInstructionFile('future-runtime-xyz'), 'AGENTS.md');
|
|
assert.strictEqual(getProjectInstructionFile(''), 'AGENTS.md');
|
|
assert.strictEqual(getProjectInstructionFile(null), 'AGENTS.md');
|
|
assert.strictEqual(getProjectInstructionFile(undefined), 'AGENTS.md');
|
|
});
|
|
|
|
test('aliases normalize via canonicalizeRuntimeName before mapping', () => {
|
|
// codex-cli is an alias for codex; it must resolve to the codex mapping.
|
|
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 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');
|
|
});
|
|
});
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-783-kilo-global-skills-base.test.cjs — consolidation epic #1969 (B3 #1972)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-783-kilo-global-skills-base (consolidation epic #1969 B3 #1972)", () => {
|
|
'use strict';
|
|
// Regression guard for bug #783.
|
|
//
|
|
// getGlobalSkillsBase('kilo') was returning ~/.config/kilo/skills (the XDG
|
|
// config dir) instead of ~/.kilo/skills — where Kilo Code actually discovers
|
|
// global skills per its docs:
|
|
// https://kilo.ai/docs/customize/skills
|
|
// "Global skills are located in the `.kilo` directory within your Home
|
|
// directory: ~/.kilo/skills/"
|
|
//
|
|
// The fix adds a special case in getGlobalSkillsBase() that resolves kilo's
|
|
// skills dir from HOME (not from the XDG config dir). The config dir at
|
|
// ~/.config/kilo is still CORRECT for commands (command/) and must stay
|
|
// unchanged — this test verifies both roles are separate.
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
const {
|
|
getGlobalConfigDir,
|
|
getGlobalSkillsBase,
|
|
} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs'));
|
|
|
|
// Helper: temporarily override env vars for a test, restoring them afterwards.
|
|
function withEnv(overrides, fn) {
|
|
const saved = {};
|
|
for (const [key, value] of Object.entries(overrides)) {
|
|
saved[key] = process.env[key];
|
|
if (value === undefined) delete process.env[key];
|
|
else process.env[key] = value;
|
|
}
|
|
try {
|
|
return fn();
|
|
} finally {
|
|
for (const [key] of Object.entries(overrides)) {
|
|
if (saved[key] === undefined) delete process.env[key];
|
|
else process.env[key] = saved[key];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clear all kilo-relevant env vars so tests are hermetic.
|
|
const kiloEnvClears = {
|
|
KILO_CONFIG_DIR: undefined,
|
|
XDG_CONFIG_HOME: undefined,
|
|
};
|
|
|
|
describe('bug #783: kilo global skills dir is ~/.kilo/skills, not ~/.config/kilo/skills', () => {
|
|
test('getGlobalSkillsBase("kilo") resolves to ~/.kilo/skills', () => {
|
|
withEnv(kiloEnvClears, () => {
|
|
assert.strictEqual(
|
|
getGlobalSkillsBase('kilo'),
|
|
path.join(os.homedir(), '.kilo', 'skills'),
|
|
);
|
|
});
|
|
});
|
|
|
|
test('getGlobalConfigDir("kilo") still resolves to ~/.config/kilo (config dir unchanged)', () => {
|
|
withEnv(kiloEnvClears, () => {
|
|
assert.strictEqual(
|
|
getGlobalConfigDir('kilo'),
|
|
path.join(os.homedir(), '.config', 'kilo'),
|
|
);
|
|
});
|
|
});
|
|
|
|
test('kilo skills dir and config dir are decoupled (not equal, not nested)', () => {
|
|
withEnv(kiloEnvClears, () => {
|
|
const skillsBase = getGlobalSkillsBase('kilo');
|
|
const configDir = getGlobalConfigDir('kilo');
|
|
|
|
assert.notStrictEqual(skillsBase, configDir, 'skills dir must differ from config dir');
|
|
assert.ok(
|
|
!skillsBase.startsWith(configDir + path.sep),
|
|
`skills dir (${skillsBase}) must not be nested under config dir (${configDir})`,
|
|
);
|
|
assert.ok(
|
|
!configDir.startsWith(skillsBase + path.sep),
|
|
`config dir (${configDir}) must not be nested under skills dir (${skillsBase})`,
|
|
);
|
|
});
|
|
});
|
|
|
|
test('getGlobalSkillsBase("kilo") is NOT affected by KILO_CONFIG_DIR override', () => {
|
|
// Skills always live in ~/.kilo/skills regardless of XDG/config-dir overrides.
|
|
withEnv({ KILO_CONFIG_DIR: '/tmp/custom-kilo-config', XDG_CONFIG_HOME: undefined }, () => {
|
|
assert.strictEqual(
|
|
getGlobalSkillsBase('kilo'),
|
|
path.join(os.homedir(), '.kilo', 'skills'),
|
|
);
|
|
});
|
|
});
|
|
|
|
test('getGlobalSkillsBase("kilo") is NOT affected by XDG_CONFIG_HOME override', () => {
|
|
withEnv({ KILO_CONFIG_DIR: undefined, XDG_CONFIG_HOME: '/tmp/custom-xdg' }, () => {
|
|
assert.strictEqual(
|
|
getGlobalSkillsBase('kilo'),
|
|
path.join(os.homedir(), '.kilo', 'skills'),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// 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',
|
|
);
|
|
});
|
|
});
|