* 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>
213 lines
9.2 KiB
JavaScript
213 lines
9.2 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* #3024 review finding 2 — drift guard for LEGACY_NON_REGISTRY_RUNTIME_IDS.
|
|
*
|
|
* isRegisteredRuntimeId() accepts an id if it is either a capability-registry
|
|
* key or a member of the hand-maintained LEGACY_NON_REGISTRY_RUNTIME_IDS set
|
|
* (currently just `grok`). That set is a SECOND hand-maintained proxy for the
|
|
* real predicate — "does this id have a genuine runtime-specific resolution
|
|
* in getGlobalConfigDir, distinct from the generic claude fallback?" —
|
|
* mirroring the exact mistake that caused the grok regression (the registry
|
|
* was the first such proxy, and it silently misclassified grok). Nothing
|
|
* currently stops a third hardcoded branch being added to getGlobalConfigDir
|
|
* without anyone updating the Set.
|
|
*
|
|
* DESIGN (do not "fix" by making production logic derive the answer at
|
|
* runtime): production code stays an explicit, greppable Set. Deriving the
|
|
* predicate at runtime by diffing against a sentinel resolution would make
|
|
* validation depend on a heuristic comparison against that sentinel, which is
|
|
* harder to reason about and could misfire for an id that legitimately
|
|
* shares claude's directory. Instead, THIS TEST derives the ground truth from
|
|
* the compiled module's actual behavior and fails loudly the moment the
|
|
* hand-maintained Set falls out of sync with it, in either direction.
|
|
*/
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
const LIB_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs');
|
|
const CAPABILITY_REGISTRY_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'capability-registry.cjs');
|
|
|
|
const { getGlobalConfigDir, LEGACY_NON_REGISTRY_RUNTIME_IDS } = require(LIB_PATH);
|
|
const { runtimes } = require(CAPABILITY_REGISTRY_PATH);
|
|
|
|
// A sentinel id that is definitely unregistered and has no dedicated branch:
|
|
// resolving it teaches us what the generic (claude) fallback path is.
|
|
const SENTINEL_ID = 'zzz-not-a-runtime-3024-drift-guard';
|
|
|
|
/**
|
|
* Every env var a descriptor-driven runtime, or a hardcoded branch, reads to
|
|
* override its resolved directory. Derived from the registry itself (not
|
|
* hand-copied) plus the one variable consumed by getGlobalConfigDir's grok
|
|
* branch, which lives outside the registry entirely. Cleared for the
|
|
* duration of each test so ambient env vars in the test-runner's environment
|
|
* cannot change a runtime's resolved path out from under the assertions.
|
|
*/
|
|
function collectDescriptorEnvVars() {
|
|
const vars = new Set(['GROK_AGENTS_HOME']);
|
|
for (const entry of Object.values(runtimes)) {
|
|
const configHome = entry.runtime?.configHome;
|
|
if (configHome?.env) configHome.env.forEach((v) => vars.add(v));
|
|
if (configHome?.skillsHome?.env) configHome.skillsHome.env.forEach((v) => vars.add(v));
|
|
}
|
|
assert.ok(vars.size > 1, 'EMPTY CAPTURE: derived zero descriptor env vars from the capability registry');
|
|
return vars;
|
|
}
|
|
|
|
function clearEnv(keys) {
|
|
const saved = {};
|
|
for (const k of keys) {
|
|
saved[k] = process.env[k];
|
|
delete process.env[k];
|
|
}
|
|
return saved;
|
|
}
|
|
|
|
function restoreEnv(saved) {
|
|
for (const [k, v] of Object.entries(saved)) {
|
|
if (v === undefined) delete process.env[k];
|
|
else process.env[k] = v;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse getGlobalConfigDir's compiled source body for literal
|
|
* `runtime === '<id>'` hardcoded branches (grok's, and any future one). This
|
|
* is enumeration scaffolding only — every id it turns up is then verified
|
|
* BEHAVIORALLY below by actually calling getGlobalConfigDir, never trusted
|
|
* on its own.
|
|
*/
|
|
function parseHardcodedBranchIds(libSource) {
|
|
const fnMarker = 'function getGlobalConfigDir(';
|
|
const fnStart = libSource.indexOf(fnMarker);
|
|
assert.notStrictEqual(
|
|
fnStart,
|
|
-1,
|
|
'EMPTY CAPTURE: could not locate getGlobalConfigDir in the compiled lib source',
|
|
);
|
|
const nextFn = libSource.indexOf('\nfunction ', fnStart + fnMarker.length);
|
|
const fnBody = nextFn === -1 ? libSource.slice(fnStart) : libSource.slice(fnStart, nextFn);
|
|
assert.ok(fnBody.length > 50, 'EMPTY CAPTURE: getGlobalConfigDir body implausibly short');
|
|
|
|
const ids = [];
|
|
const branchIdRe = /runtime\s*===\s*'([^']+)'/g;
|
|
let m;
|
|
while ((m = branchIdRe.exec(fnBody))) ids.push(m[1]);
|
|
assert.ok(
|
|
ids.length > 0,
|
|
'EMPTY CAPTURE: parsed zero hardcoded-branch ids out of getGlobalConfigDir — the regex or function-body bound is broken',
|
|
);
|
|
return ids;
|
|
}
|
|
|
|
/** Resolve `id`, classifying it runtime-specific if it differs from `fallbackPath`. */
|
|
function resolveCandidate(id, fallbackPath) {
|
|
try {
|
|
const resolved = getGlobalConfigDir(id);
|
|
return { id, resolved, runtimeSpecific: resolved !== fallbackPath };
|
|
} catch (err) {
|
|
// configHome.kind === 'none' (e.g. vscode) throws instead of resolving —
|
|
// a distinct, deliberate, definitely-not-the-fallback outcome.
|
|
return { id, resolved: `<throws: ${err.message}>`, runtimeSpecific: true };
|
|
}
|
|
}
|
|
|
|
describe('#3024 review finding 2: LEGACY_NON_REGISTRY_RUNTIME_IDS drift guard', () => {
|
|
test('every runtime-specific id is registered or legacy-listed, and every legacy entry still earns its exemption', (t) => {
|
|
const registryIds = Object.keys(runtimes);
|
|
assert.ok(registryIds.length > 0, 'EMPTY CAPTURE: capability registry produced zero runtime ids');
|
|
|
|
const legacyIds = Array.from(LEGACY_NON_REGISTRY_RUNTIME_IDS);
|
|
assert.ok(legacyIds.length > 0, 'EMPTY CAPTURE: LEGACY_NON_REGISTRY_RUNTIME_IDS is empty');
|
|
|
|
const libSource = fs.readFileSync(LIB_PATH, 'utf-8');
|
|
const sourceParsedIds = parseHardcodedBranchIds(libSource);
|
|
|
|
assert.ok(
|
|
!registryIds.includes(SENTINEL_ID) &&
|
|
!legacyIds.includes(SENTINEL_ID) &&
|
|
// allow-test-rule: source-text-is-the-product (#3545) — sourceParsedIds
|
|
// is enumeration scaffolding derived from readFileSync'd source text
|
|
// (see parseHardcodedBranchIds doc comment above); every id it turns
|
|
// up is verified BEHAVIORALLY below via getGlobalConfigDir, never
|
|
// trusted on its own
|
|
!sourceParsedIds.includes(SENTINEL_ID),
|
|
`sentinel id ${SENTINEL_ID} unexpectedly collides with a real candidate id — pick a different sentinel`,
|
|
);
|
|
|
|
const saved = clearEnv(collectDescriptorEnvVars());
|
|
t.after(() => restoreEnv(saved));
|
|
|
|
const fallbackPath = getGlobalConfigDir(SENTINEL_ID);
|
|
assert.ok(
|
|
typeof fallbackPath === 'string' && fallbackPath.length > 0,
|
|
'sentinel resolution produced no usable fallback path',
|
|
);
|
|
|
|
const candidates = Array.from(new Set([...registryIds, ...legacyIds, ...sourceParsedIds]));
|
|
const allowed = new Set([...registryIds, ...legacyIds]);
|
|
const resolutions = candidates.map((id) => resolveCandidate(id, fallbackPath));
|
|
|
|
const undeclaredSpecific = resolutions
|
|
.filter((r) => r.runtimeSpecific && !allowed.has(r.id))
|
|
.map((r) => r.id);
|
|
assert.deepStrictEqual(
|
|
undeclaredSpecific,
|
|
[],
|
|
`id(s) resolve runtime-specifically but are in neither the capability registry nor ` +
|
|
`LEGACY_NON_REGISTRY_RUNTIME_IDS: ${JSON.stringify(undeclaredSpecific)}. Remedy: add the id(s) to ` +
|
|
`LEGACY_NON_REGISTRY_RUNTIME_IDS in src/runtime-homes.cts (only after confirming the branch is real ` +
|
|
`and intended).`,
|
|
);
|
|
|
|
const staleLegacy = legacyIds.filter(
|
|
(id) => !resolutions.find((r) => r.id === id)?.runtimeSpecific,
|
|
);
|
|
assert.deepStrictEqual(
|
|
staleLegacy,
|
|
[],
|
|
`LEGACY_NON_REGISTRY_RUNTIME_IDS entry(ies) no longer resolve runtime-specifically: ` +
|
|
`${JSON.stringify(staleLegacy)}. Remedy: remove the stale entry(ies) from ` +
|
|
`LEGACY_NON_REGISTRY_RUNTIME_IDS in src/runtime-homes.cts.`,
|
|
);
|
|
|
|
const grok = resolutions.find((r) => r.id === 'grok');
|
|
assert.ok(grok, 'grok must appear among the resolved candidates');
|
|
assert.strictEqual(
|
|
grok.runtimeSpecific,
|
|
true,
|
|
'grok must resolve runtime-specifically (its hardcoded branch is the reason LEGACY_NON_REGISTRY_RUNTIME_IDS exists)',
|
|
);
|
|
});
|
|
|
|
// #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('notarealruntime'),
|
|
fallbackPath,
|
|
'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',
|
|
);
|
|
});
|
|
});
|