* fix(#4709): retire the Gemini CLI reviewer lane
Google stopped serving Gemini CLI for the free/Pro/Ultra tiers on 2026-06-18 —
the same sunset that removed the gemini RUNTIME in #1928 (shipped 1.8.0). GSD
targets solo developers, so those tiers ARE the user path: the lane spawned
`gemini {{model}} -p -`, a binary that no longer answers for the majority of
users, and five locales documented it as a supported choice.
The lane was re-created after #1928 by the reviewer-lane-as-manifest-data work
(6a9babda69, #2798/#2837, ADR-2782). Per the maintainer that re-creation was an
error in that buildout rather than a considered decision, so this corrects a
mistake and needs no ADR-2782 amendment.
Reviewer roster: 12 lanes / 13 flags -> 11 lanes / 12 flags.
TWO sources of truth had to be removed, not one. Deleting
capabilities/gemini/capability.json left the capability registry at 11 lanes
while src/review-lane-descriptor.cts's hand-maintained REVIEWER_LANES array
still carried its own complete gemini entry at 12 — precisely the disagreement
checkReviewerLaneParity exists to catch. Both are gone; both parity checkers
now run clean against the real tree (lane parity ok/0 violations, docs parity
0 violations).
Surfaces stripped of the dead flag:
- capabilities/gemini/ deleted; registry and capability-matrix regenerated
- src/review-lane-descriptor.cts: REVIEWER_LANES entry, docblock count, and the
three doc comments that used --gemini as a live example
- commands/gsd/{review,plan-review-convergence,autonomous,progress}.md and the
four matching skills/*/SKILL.md: argument-hint frontmatter and flag bullets
- gsd-core/workflows/help/modes/{full,full.compact}.md: /gsd-help signatures,
the detected-CLI list, and the reviewer-title list
- gsd-core/workflows/settings-integrations.md: the integrations wizard no longer
offers "Gemini" as a model option, and the settable-keys list drops it
- gsd-core/workflows/review.md: the `command -v gemini` probe, the --gemini
flag, the roster frontmatter, the install pointer to the sunset repo, and the
jq-less / precedence / self-skip lane lists
- gsd-core/workflows/sync-skills.md: "two runtimes (grok, gemini) resolve to
ANOTHER runtime's skills root" is now one runtime; gemini never aliased
anything, it fell through canonicalizeRuntimeName to a fail-closed default
- docs/{CONFIGURATION,COMMANDS,CLI-TOOLS}.md, docs/reference/capability-matrix.md,
docs/how-to/set-up-cross-ai-review.md — including its `npm install -g
@google/gemini-cli` instruction and the two rows recommending --gemini
- docs/features/{cross-ai-peer-review,opt-in-parallel-reviewer-lanes}.md as the
generator inputs behind docs/FEATURES.md, plus the three locale FEATURES.md
signature lines the docs-parity gate covers (the #2781 class: a flag change
that never reaches the mirrors)
Counts reconciled against measurement rather than arithmetic: 8 timeout keys of
11 lanes, 11 budget keys, 9 model keys, and four hardcoded literals in
tests/reviewer-lane-declarations.test.cjs (NEW_LANE_ONLY_IDS 5->4, LITERAL_ROSTER
12->11, two roster counts 12->11).
BEHAVIOR CHANGE, accepted deliberately: `gsd config-set review.models.gemini`
now errors with "Unknown config key". An existing key already in
.planning/config.json still parses and is simply never read, so no project fails
to load. This is the repo's own documented policy for exactly this case
(docs/CONFIGURATION.md:327 — "a key left over from a removed reviewer validated
silently and was never read. Such a key is now rejected by config-set"), so no
installer migration ships. Note my first measurement of this was WRONG: I tested
config-get, which reads undeclared keys fine, and generalised. Read and write are
different surfaces and gave different answers.
Antigravity is untouched throughout — its --antigravity/--agy flags,
review.models.agy, ~/.gemini/antigravity configHome, ~/.gemini/config global
skills root (#3738), hookEvents "gemini", GEMINI.md instruction file, and every
gemini-* model id it actually runs on.
Refs #4709
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(#4709): changeset for the reviewer-lane retirement
Type Removed: the --gemini flag and its three config keys are user-visible
surface that no longer exists.
Refs #4709
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(#4709): close the 24 test failures and the locale-doc gap the gates found
An adversarial review and a full matrix run between them found substantially
more fallout than inspection had. All of it is this PR's own, and all of it is
fixed rather than waved off.
THE MATRIX RUN FOUND 24 FAILURES ACROSS 6 FILES. Inspection had predicted two.
The dominant class was a test helper that looks up a lane by slug and throws
`no declared lane 'gemini'`:
- tests/feat-2483-review-claude-mds-guard.test.cjs (6) — used gemini as the
"other declared first-party lane" to contrast against claude's env
suppression. Now qwen, verified from source as a lane that declares no `env`
(only claude does), so the contrast still holds.
- tests/review-lane-descriptor.test.cjs (6) — the duplicate-flag and
duplicate-section fixtures deliberately COLLIDED with a real declared lane to
prove the parity checker reports a duplicate. `--gemini`/`Gemini` no longer
collide with anything, so the checker reported
`descriptor_lane_not_in_registry:acme` instead and the tests proved nothing.
Now collide with `--codex`/`Codex`, reproduced against the real checker.
- tests/review-reviewer-selection.test.cjs (3) — these distinguish KNOWN-but-
undetected from UNKNOWN. gemini flipped categories, inverting what they
proved. The known case now uses qwen; `__nope__` stays the unknown fixture.
- tests/review-default-reviewers-resolution.test.cjs (2), and
tests/settings-integrations.test.cjs (3) — the wizard now offers three
reviewer CLIs, not four, so the test and its name say three.
- Two count assertions the earlier sweep missed outright:
reviewer-lane-declarations.test.cjs:359 (`length, 12`) and
reviewer-docs-parity.test.cjs:681 (`>= 12`).
THE LOCALE-DOC GAP, and why the parity gate stayed green over it. All four
locale mirrors still documented `--gemini` as a live reviewer flag. The
docs-parity checker asserts the PRESENCE of every current flag and never the
ABSENCE of a retired one, so "0 violations" was never evidence those files were
clean — my earlier reading of it as such was wrong. This is the #2781
locale-drift class in the opposite direction. Fixed across 12 locale files:
COMMANDS.md flag lists and table rows, CONFIGURATION.md `review.models.gemini`
rows and reviewer prose, CLI-TOOLS.md config examples, and
set-up-cross-ai-review.md including its install block and its
which-reviewer-to-choose row, which now recommends Antigravity.
ALSO FOUND, and instructive about my own method: docs/CONFIGURATION.md:297 still
carried a `review.models.gemini` row. My sweep had missed it because my grep
excluded lines matching `gemini-[0-9]` to spare Google's model ids — and that
row's example value is `"gemini-2.5-pro"` on the same line. The exclusion built
to avoid false positives created a false negative.
Remaining comment/example sites: src/review-reviewer-selection.cts:309 and
src/config.cts:598 named the dead flag and key as examples;
gsd-core/references/planning-config.md:269 likewise; and
review-reviewer-selection.cts:22 claimed in the PRESENT tense that gemini is a
lane-only reviewer capability. Line 38 of that same docblock says "Before this
phase the five non-runtime reviewers (gemini, ...)" and is left exactly as is —
that is past-tense history, and rewriting it would falsify the record.
Deliberately still deferred to Phase 4, because it is the RUNTIME axis rather
than the reviewer lane: the locale install-on-your-runtime.md `--gemini --global`
instructions, the USER-GUIDE colon-form notes, and the ARCHITECTURE
runtime-detection flag lists.
Both parity checkers green against the real tree; lint:ci exit 0.
Refs #4709
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(#4709): backfill the changeset PR number
pr: 0 -> 4716, now that the PR exists. Never guessed ahead of the number.
Refs #4709
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
532 lines
25 KiB
JavaScript
532 lines
25 KiB
JavaScript
/**
|
|
* #1928 — Gemini CLI runtime removal + Antigravity redirect.
|
|
*
|
|
* Google sunset Gemini CLI on 2026-06-18; Antigravity CLI is the official
|
|
* successor. GSD removes the `gemini` runtime and turns `--gemini` into an
|
|
* explicit deprecation redirect (NOT a silent alias — Hyrum's Law, per the
|
|
* issue's rejected alternative #2).
|
|
*
|
|
* Coverage:
|
|
* A. CLI redirect contract (spawned installer): the sunset notice, the
|
|
* no-silent-install failure path, clean UX (no stack trace), and that a
|
|
* co-selected valid runtime still installs.
|
|
* B. The `gemini` runtime is gone from every runtime-name-policy surface.
|
|
* C. Antigravity is PRESERVED everywhere it shared surface with gemini
|
|
* (GEMINI.md instruction file + the shared convertGeminiToolName tool
|
|
* vocabulary) — the shared-infra regression this change had to avoid.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const { describe, test, before } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const path = require('node:path');
|
|
const fs = require('node:fs');
|
|
const { spawnSync } = require('node:child_process');
|
|
const { runNode } = require('./helpers/process-seam.cjs');
|
|
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
|
|
|
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
|
const { runMinimalInstall, BUILD_SCRIPT } = require('./helpers/install-shared.cjs');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
const INSTALL_JS = path.join(ROOT, 'bin', 'install.js');
|
|
|
|
// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs.
|
|
const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS, INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
|
|
// hooks/dist is gitignored + built; build it idempotently so a real install
|
|
// emits hooks (mirrors golden-install-parity / install-minimal-hooks).
|
|
before(() => {
|
|
throwIfFailed(runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }), `node ${BUILD_SCRIPT}`);
|
|
});
|
|
|
|
const {
|
|
canonicalizeRuntimeName,
|
|
getRuntimeLabel,
|
|
getGlobalConfigHomeFragment,
|
|
getRuntimeNewProjectCommand,
|
|
runtimeFlags,
|
|
getProjectInstructionFile,
|
|
} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-name-policy.cjs'));
|
|
|
|
const registry = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'capability-registry.cjs'));
|
|
const catalog = require(path.join(ROOT, 'gsd-core', 'bin', 'shared', 'model-catalog.json'));
|
|
|
|
const { convertClaudeAgentToAntigravityAgent } = require('../bin/install.js');
|
|
|
|
// Run the installer as a subprocess with an isolated HOME so no install can
|
|
// touch the real machine. Runtime-config env overrides are stripped so the
|
|
// child resolves config dirs strictly under the temp HOME.
|
|
function runInstaller(args, homeDir) {
|
|
const env = { ...process.env, HOME: homeDir, USERPROFILE: homeDir, GSD_TEST_MODE: '1' };
|
|
for (const k of [
|
|
'CLAUDE_CONFIG_DIR', 'GEMINI_CONFIG_DIR', 'ANTIGRAVITY_CONFIG_DIR',
|
|
'XDG_CONFIG_HOME', 'CODEX_CONFIG_DIR', 'OPENCODE_CONFIG_DIR', 'KILO_CONFIG_DIR',
|
|
]) delete env[k];
|
|
return spawnSync(process.execPath, [INSTALL_JS, ...args], {
|
|
cwd: homeDir, env, encoding: 'utf8', timeout: INSTALL_TIMEOUT_MS,
|
|
});
|
|
}
|
|
|
|
describe('#1928 --gemini CLI deprecation redirect', () => {
|
|
test('--gemini alone prints the sunset notice and exits non-zero without installing', (t) => {
|
|
const home = createTempDir('gsd-1928-gemini-only-');
|
|
t.after(() => cleanup(home));
|
|
|
|
const r = runInstaller(['--gemini'], home);
|
|
const out = `${r.stdout || ''}${r.stderr || ''}`;
|
|
|
|
assert.strictEqual(r.status, 1, 'a bare --gemini must exit 1, not silently fall through to a Claude install');
|
|
assert.match(out, /sunset by Google on 2026-06-18/, 'must cite the 2026-06-18 sunset date');
|
|
assert.match(out, /--antigravity/, 'must redirect the user to --antigravity');
|
|
assert.match(out, /Antigravity CLI \(the official successor\)/);
|
|
// No silent install: nothing was written under the isolated HOME.
|
|
assert.ok(!fs.existsSync(path.join(home, '.gemini')), 'must not create a .gemini runtime dir');
|
|
assert.ok(!fs.existsSync(path.join(home, '.claude')), 'bare --gemini must not silently install Claude');
|
|
});
|
|
|
|
test('--gemini --global still exits 1 (removed flag regardless of scope)', (t) => {
|
|
const home = createTempDir('gsd-1928-gemini-global-');
|
|
t.after(() => cleanup(home));
|
|
|
|
const r = runInstaller(['--gemini', '--global'], home);
|
|
assert.strictEqual(r.status, 1);
|
|
assert.match(`${r.stdout || ''}${r.stderr || ''}`, /sunset by Google on 2026-06-18/);
|
|
});
|
|
|
|
test('the redirect is a clean message — no stack trace leaks to the user', (t) => {
|
|
const home = createTempDir('gsd-1928-gemini-clean-');
|
|
t.after(() => cleanup(home));
|
|
|
|
const r = runInstaller(['--gemini'], home);
|
|
const err = r.stderr || '';
|
|
assert.doesNotMatch(err, /^\s+at .+:\d+:\d+/m, 'no V8 stack frame in redirect output');
|
|
assert.doesNotMatch(err, /\bError:|\bTypeError:|\bthrow\b/, 'no thrown-error prose in redirect output');
|
|
});
|
|
|
|
test('--gemini --help still prints usage (the redirect must not suppress help)', (t) => {
|
|
const home = createTempDir('gsd-1928-gemini-help-');
|
|
t.after(() => cleanup(home));
|
|
|
|
const r = runInstaller(['--gemini', '--help'], home);
|
|
assert.strictEqual(r.status, 0, '--help must exit 0, not the redirect error code');
|
|
assert.match(`${r.stdout || ''}`, /Usage:/, 'the usage/help block must still print to stdout');
|
|
assert.match(`${r.stderr || ''}`, /sunset by Google on 2026-06-18/, 'the notice also prints');
|
|
});
|
|
|
|
test('--gemini --uninstall guides manual cleanup and does NOT uninstall Claude', (t) => {
|
|
const home = createTempDir('gsd-1928-gemini-uninstall-');
|
|
t.after(() => cleanup(home));
|
|
|
|
// Sentinel: a pre-existing Claude install that must survive. Run WITHOUT
|
|
// GSD_TEST_MODE so the real uninstall dispatch is active — the redirect must
|
|
// exit before it (the dispatch defaults an empty selection to 'claude').
|
|
fs.mkdirSync(path.join(home, '.claude'), { recursive: true });
|
|
fs.writeFileSync(path.join(home, '.claude', 'sentinel.txt'), 'keep me');
|
|
const env = { ...process.env, HOME: home, USERPROFILE: home };
|
|
delete env.GSD_TEST_MODE;
|
|
delete env.CLAUDE_CONFIG_DIR;
|
|
const r = spawnSync(process.execPath, [INSTALL_JS, '--gemini', '--uninstall', '--global'], {
|
|
cwd: home, env, encoding: 'utf8', timeout: INSTALL_TIMEOUT_MS,
|
|
});
|
|
|
|
assert.strictEqual(r.status, 1, 'must exit 1, not fall through to the uninstall dispatch');
|
|
assert.match(`${r.stderr || ''}`, /`--gemini --uninstall` is no longer available/, 'must guide manual cleanup');
|
|
assert.ok(fs.existsSync(path.join(home, '.claude', 'sentinel.txt')),
|
|
'the Claude install must NOT be uninstalled (the dispatch defaults empty selection to claude)');
|
|
});
|
|
|
|
test('--gemini co-selected with a valid runtime prints the notice AND still installs the other runtime', (t) => {
|
|
// Hermetic install via the repo harness (explicit --config-dir + isolated
|
|
// HOME). `--gemini` is added alongside a valid runtime (codex): the installer
|
|
// prints the notice but does NOT exit 1 (runMinimalInstall asserts status 0
|
|
// internally) and installs codex.
|
|
const { manifest, root, stderr } = runMinimalInstall({ runtime: 'codex', scope: 'global', extraArgs: ['--gemini'] });
|
|
t.after(() => cleanup(root));
|
|
|
|
assert.match(stderr, /sunset by Google on 2026-06-18/, 'the redirect notice still prints alongside the valid install');
|
|
assert.match(stderr, /--antigravity/);
|
|
assert.ok(manifest, 'the co-selected codex runtime must be installed (manifest written)');
|
|
});
|
|
|
|
test('control: an install WITHOUT --gemini does not print the sunset notice', (t) => {
|
|
const { root, stderr } = runMinimalInstall({ runtime: 'codex', scope: 'global' });
|
|
t.after(() => cleanup(root));
|
|
assert.doesNotMatch(stderr, /sunset by Google/, 'the notice must be conditional on --gemini');
|
|
});
|
|
});
|
|
|
|
describe('#1928 gemini removed from every runtime-name-policy surface', () => {
|
|
test('gemini aliases no longer canonicalize', () => {
|
|
for (const alias of ['gemini', 'gemini-cli', 'gemini-code']) {
|
|
assert.strictEqual(canonicalizeRuntimeName(alias), null, `${alias} must not resolve to a known runtime`);
|
|
}
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
test('runtimeFlags has no isGemini and covers exactly the non-claude, CLI-installable registry runtimes (count-agnostic)', () => {
|
|
const flags = runtimeFlags('claude');
|
|
assert.ok(!('isGemini' in flags), 'isGemini flag must be gone');
|
|
// The flag set tracks the non-claude registry runtimes (one is<Runtime> per
|
|
// id), so adding a runtime updates the count automatically — no hand-pinned
|
|
// number that would break on the next runtime addition.
|
|
// #2103: registry runtimes with installSurface === 'none' (e.g. vscode —
|
|
// Marketplace/VSIX-distributed, never CLI-installed) have no --<rt> flag
|
|
// by design (see tests/runtime-flags.test.cjs's NON_INSTALLABLE_RUNTIMES)
|
|
// and are excluded from this count too.
|
|
const expectedNonClaudeCount = Object.keys(registry.runtimes)
|
|
.filter((id) => id !== 'claude' && registry.runtimes[id].runtime.installSurface !== 'none')
|
|
.length;
|
|
assert.strictEqual(Object.keys(flags).length, expectedNonClaudeCount,
|
|
'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');
|
|
});
|
|
});
|
|
|
|
describe('#1928 Antigravity preserved (shared surface with the removed gemini runtime)', () => {
|
|
test('antigravity still resolves and keeps its GEMINI.md instruction file', () => {
|
|
assert.strictEqual(canonicalizeRuntimeName('antigravity'), 'antigravity');
|
|
assert.strictEqual(canonicalizeRuntimeName('antigravity-cli'), 'antigravity');
|
|
assert.strictEqual(getProjectInstructionFile('antigravity'), 'GEMINI.md',
|
|
'Antigravity CLI reads GEMINI.md as its contextFileName — this mapping must survive gemini removal');
|
|
assert.strictEqual(getRuntimeLabel('antigravity'), 'Antigravity');
|
|
});
|
|
|
|
test('the shared Gemini-backend tool vocabulary still powers Antigravity agent conversion', () => {
|
|
const input = ['---', 'name: gsd-x', 'description: d', 'tools: Read, Write, WebFetch, Skill', '---', '', 'body'].join('\n');
|
|
const toolsLine = convertClaudeAgentToAntigravityAgent(input).split('\n').find((l) => l.startsWith('tools:')) || '';
|
|
assert.ok(toolsLine.includes('read_file'), 'Read → read_file via the retained convertGeminiToolName');
|
|
assert.ok(toolsLine.includes('write_file'), 'Write → write_file');
|
|
assert.ok(toolsLine.includes('web_fetch'), 'WebFetch → web_fetch');
|
|
assert.ok(!/\bskill\b/.test(toolsLine), 'Skill is still excluded (would be an invalid backend tool name)');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* #4709 — the #1928 removal reached the installer and the runtime enum, but runtime-loaded
|
|
* workflow text kept MINTING the retired id: `RUNTIME="gemini"` from `$GEMINI_CONFIG_DIR`, a
|
|
* runtime selection menu offering "Gemini CLI.", a runtime->model-tier table row keyed `gemini`,
|
|
* and `config-set runtime gemini` examples.
|
|
*
|
|
* The name policy's unknown-id fallbacks are DELIBERATE and stay unchanged — see the
|
|
* 'gemini no longer maps to GEMINI.md (defaults to AGENTS.md)' test above, and
|
|
* src/runtime-name-policy.cts:220-222, which calls the label default "the always-safe default,
|
|
* fail-closed". This block removes the REACHABILITY instead: nothing shipped may mint an id the
|
|
* policy does not recognize.
|
|
*
|
|
* Every assertion is STRUCTURAL (the literal must be canonical / the runtime must exist as a
|
|
* catalog key), never "the string gemini is absent" — that string is load-bearing across
|
|
* Antigravity's real on-disk contract, which the final test pins.
|
|
*/
|
|
describe('#4709 no shipped surface mints a retired runtime id', () => {
|
|
/** Recursively collect every `.md` file under `dir` (missing dir -> []). */
|
|
function markdownFilesUnder(dir) {
|
|
if (!fs.existsSync(dir)) return [];
|
|
const out = [];
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) out.push(...markdownFilesUnder(full));
|
|
else if (entry.isFile() && entry.name.endsWith('.md')) out.push(full);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* The shipped, runtime-loaded markdown corpus this block governs. `agents/` is included
|
|
* deliberately: it ships runtime-loaded markdown too, including `.compact.md` variants, and
|
|
* leaving it out was a coverage gap an adversarial review caught.
|
|
*/
|
|
function shippedMarkdown() {
|
|
return [
|
|
...markdownFilesUnder(path.join(ROOT, 'gsd-core', 'workflows')),
|
|
...markdownFilesUnder(path.join(ROOT, 'commands')),
|
|
...markdownFilesUnder(path.join(ROOT, 'skills')),
|
|
...markdownFilesUnder(path.join(ROOT, 'agents')),
|
|
];
|
|
}
|
|
|
|
const relPath = (p) => path.relative(ROOT, p).split(path.sep).join('/');
|
|
const linesOf = (file) => fs.readFileSync(file, 'utf8').split(/\r?\n/);
|
|
|
|
const SETTINGS_ADVANCED = path.join(ROOT, 'gsd-core', 'workflows', 'settings-advanced.md');
|
|
|
|
test('every RUNTIME= assignment in workflow text names a canonical runtime', () => {
|
|
// Bare-literal assignments only: RUNTIME=codex / RUNTIME="codex". A `$VAR`, `$(cmd)` or
|
|
// `<placeholder>` assignment is resolved at runtime and carries no id to validate here.
|
|
const ASSIGN = /\bRUNTIME=("?)([a-z][a-z0-9-]*)\1(?![\w-])/g;
|
|
const offenders = [];
|
|
|
|
for (const file of shippedMarkdown()) {
|
|
linesOf(file).forEach((line, i) => {
|
|
for (const m of line.matchAll(ASSIGN)) {
|
|
if (canonicalizeRuntimeName(m[2]) === null) {
|
|
offenders.push(`${relPath(file)}:${i + 1} mints RUNTIME=${m[2]}`);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
assert.deepStrictEqual(offenders, [],
|
|
'shipped workflow text assigns a runtime id the name policy does not recognize. A '
|
|
+ 'non-canonical id does not fail loudly — it resolves to Claude Code defaults, so the '
|
|
+ `wrong config dir and instruction file are used silently. Offenders:\n ${offenders.join('\n ')}`);
|
|
});
|
|
|
|
test('the runtime tier table names only runtimes the model catalog defines', () => {
|
|
const known = new Set(Object.keys(catalog.runtimeTierDefaults));
|
|
const offenders = [];
|
|
|
|
// Rows of the runtime -> model-tier table: | `<id>` | `<opus>` | `<sonnet>` | `<haiku>` |
|
|
const ROW = /^\|\s*`([a-z][a-z0-9-]*)`\s*\|/;
|
|
linesOf(SETTINGS_ADVANCED).forEach((line, i) => {
|
|
const m = ROW.exec(line);
|
|
if (m && !known.has(m[1])) {
|
|
offenders.push(`${relPath(SETTINGS_ADVANCED)}:${i + 1} tables runtime \`${m[1]}\``);
|
|
}
|
|
});
|
|
|
|
assert.deepStrictEqual(offenders, [],
|
|
'the runtime->model-tier table documents built-in defaults for a runtime the model catalog '
|
|
+ 'has no entry for, so `config-set runtime <id>` would be ignored. The retired `gemini` '
|
|
+ 'row carried the three model IDs of the `google` PROVIDER preset — a provider axis '
|
|
+ `rendered as a runtime axis. Offenders:\n ${offenders.join('\n ')}`);
|
|
});
|
|
|
|
test('the runtime selection menu offers only canonical runtimes', () => {
|
|
const offenders = [];
|
|
|
|
// Scoped to RUNTIME menus by tracking the nearest preceding `question:`. The same file also
|
|
// carries a provider menu (anthropic / openai) and a budget menu (high / medium / low) whose
|
|
// labels are single lowercase tokens too; neither names a runtime, so validating those
|
|
// against the runtime policy would be a false positive, not extra rigor.
|
|
const QUESTION = /^\s*question:\s*"(.*)"\s*,?\s*$/;
|
|
const OPTION = /\{\s*label:\s*"([a-z][a-z0-9-]*)"\s*,\s*description:/;
|
|
let inRuntimeMenu = false;
|
|
|
|
linesOf(SETTINGS_ADVANCED).forEach((line, i) => {
|
|
const q = QUESTION.exec(line);
|
|
if (q) {
|
|
inRuntimeMenu = /runtime/i.test(q[1]);
|
|
return;
|
|
}
|
|
if (!inRuntimeMenu) return;
|
|
const m = OPTION.exec(line);
|
|
if (m && canonicalizeRuntimeName(m[1]) === null) {
|
|
offenders.push(`${relPath(SETTINGS_ADVANCED)}:${i + 1} offers \`${m[1]}\``);
|
|
}
|
|
});
|
|
|
|
assert.deepStrictEqual(offenders, [],
|
|
'a runtime selection menu offers a runtime GSD does not support — selecting it writes a '
|
|
+ `config value that silently resolves to Claude Code. Offenders:\n ${offenders.join('\n ')}`);
|
|
});
|
|
|
|
test('documented config examples name only canonical runtimes', () => {
|
|
const offenders = [];
|
|
const SET_RUNTIME = /config-set\s+runtime\s+([a-z][a-z0-9-]*)/g;
|
|
const OVERRIDE = /model_profile_overrides\.([a-z][a-z0-9-]*)\./g;
|
|
|
|
for (const file of shippedMarkdown()) {
|
|
linesOf(file).forEach((line, i) => {
|
|
for (const m of line.matchAll(SET_RUNTIME)) {
|
|
if (canonicalizeRuntimeName(m[1]) === null) {
|
|
offenders.push(`${relPath(file)}:${i + 1} \`config-set runtime ${m[1]}\``);
|
|
}
|
|
}
|
|
for (const m of line.matchAll(OVERRIDE)) {
|
|
if (canonicalizeRuntimeName(m[1]) === null) {
|
|
offenders.push(`${relPath(file)}:${i + 1} \`model_profile_overrides.${m[1]}\``);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
assert.deepStrictEqual(offenders, [],
|
|
'a documented example sets a runtime id the name policy does not recognize; a user who '
|
|
+ `copies it lands on Claude Code defaults. Offenders:\n ${offenders.join('\n ')}`);
|
|
});
|
|
|
|
test("Antigravity's Gemini-family descriptor contract is preserved", () => {
|
|
// Negative space for every test above: Antigravity's real on-disk contract IS Google's
|
|
// Gemini surface, so an over-broad gemini -> antigravity replacement must fail HERE rather
|
|
// than ship. Asserted against the DESCRIPTOR, never a resolved path — getGlobalConfigDir()
|
|
// reads $ANTIGRAVITY_CONFIG_DIR and the real $HOME, which is the #4312 defect class.
|
|
const agy = JSON.parse(
|
|
fs.readFileSync(path.join(ROOT, 'capabilities', 'antigravity', 'capability.json'), 'utf8'),
|
|
);
|
|
assert.strictEqual(agy.runtime.configHome.parent, '.gemini',
|
|
"Antigravity's config home is nested under ~/.gemini");
|
|
assert.strictEqual(agy.runtime.configHome.name, 'antigravity');
|
|
assert.strictEqual(agy.runtime.hookEvents, 'gemini',
|
|
'Antigravity speaks the Gemini hook-event dialect');
|
|
assert.strictEqual(agy.runtime.hostBehaviors.projectInstructionFile, 'GEMINI.md');
|
|
for (const kind of agy.runtime.artifactLayout.global) {
|
|
assert.strictEqual(kind.home, '.gemini/config',
|
|
'global skills/agents install to ~/.gemini/config, the dir agy scans (#3738)');
|
|
}
|
|
|
|
assert.ok(Object.prototype.hasOwnProperty.call(catalog.runtimeTierDefaults, 'antigravity'),
|
|
'antigravity must remain a model-catalog runtime');
|
|
|
|
// The three model IDs the stale `gemini` table row carried belong to the google PROVIDER
|
|
// preset and must survive — they name real Google models, not a GSD runtime.
|
|
const google = JSON.stringify(catalog.providerPresets.google);
|
|
for (const model of ['gemini-3.1-pro-preview', 'gemini-3-flash', 'gemini-2.5-flash-lite']) {
|
|
assert.ok(google.includes(model), `google provider preset must still offer ${model}`);
|
|
}
|
|
});
|
|
|
|
test('PR template runtime checklists name only supported runtimes', () => {
|
|
// #1928's follow-up dropped Gemini CLI from .github/ISSUE_TEMPLATE/*.yml but missed the PR
|
|
// templates, which kept offering it under "Runtimes tested" -- a contributor-facing surface
|
|
// still advertising a retired runtime two releases later. Labels here are DISPLAY names
|
|
// ("Claude Code", not "claude"), so they are checked against the label table, not the id set.
|
|
const labels = new Set(
|
|
Object.keys(registry.runtimes).map((id) => getRuntimeLabel(id)),
|
|
);
|
|
// Non-runtime checklist entries that legitimately appear in the same list.
|
|
const NON_RUNTIME = /^(Other:|N\/A\b)/;
|
|
const offenders = [];
|
|
|
|
const templateDir = path.join(ROOT, '.github', 'PULL_REQUEST_TEMPLATE');
|
|
for (const name of fs.readdirSync(templateDir).filter((f) => f.endsWith('.md'))) {
|
|
const file = path.join(templateDir, name);
|
|
const fileLines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
|
|
let inRuntimeSection = false;
|
|
fileLines.forEach((line, i) => {
|
|
if (/^#+\s*Runtimes tested/i.test(line)) {
|
|
inRuntimeSection = true;
|
|
return;
|
|
}
|
|
// The section ends at the next heading or horizontal rule.
|
|
if (inRuntimeSection && /^(#+\s|---\s*$)/.test(line)) {
|
|
inRuntimeSection = false;
|
|
return;
|
|
}
|
|
if (!inRuntimeSection) return;
|
|
const m = /^\s*-\s*\[\s*\]\s*(.+?)\s*$/.exec(line);
|
|
if (!m) return;
|
|
const label = m[1];
|
|
if (NON_RUNTIME.test(label)) return;
|
|
if (!labels.has(label)) {
|
|
offenders.push(`.github/PULL_REQUEST_TEMPLATE/${name}:${i + 1} offers "${label}"`);
|
|
}
|
|
});
|
|
}
|
|
|
|
assert.deepStrictEqual(offenders, [],
|
|
'a PR template asks contributors which runtime they tested and lists one GSD does not '
|
|
+ 'support. Labels must match the runtime label table (src/runtime-name-policy.cts '
|
|
+ `RUNTIME_LABELS), so a retired runtime cannot linger here. Offenders:\n ${offenders.join('\n ')}`);
|
|
});
|
|
|
|
/**
|
|
* #4709 Phase 3 — the Gemini CLI reviewer lane is retired.
|
|
*
|
|
* #1928 removed the gemini RUNTIME in 1.8.0 after Google sunset Gemini CLI on 2026-06-18. The
|
|
* reviewer lane was re-created afterwards by the reviewer-lane-as-manifest-data work (6a9babda69,
|
|
* #2798/#2837) — per the maintainer that re-creation was an error in that buildout, not a
|
|
* decision, so retiring it corrects a mistake and needs no ADR-2782 amendment.
|
|
*
|
|
* The lane spawned `gemini {{model}} -p -`, a binary Google no longer serves for the
|
|
* free/Pro/Ultra tiers that ARE GSD's audience.
|
|
*
|
|
* Every assertion below is STRUCTURAL — a declared lane, an owned config key, a capability count.
|
|
* None asserts that the string "gemini" is absent, because that string is load-bearing across
|
|
* Antigravity's real on-disk contract (~/.gemini/antigravity, ~/.gemini/config, hookEvents
|
|
* "gemini", GEMINI.md) and across Google's own model IDs. The Antigravity block below is the
|
|
* negative space that keeps this removal from overreaching.
|
|
*/
|
|
describe('#4709 the Gemini CLI reviewer lane is retired', () => {
|
|
const reviewerIds = () => Object.keys(registry.capabilities)
|
|
.filter((id) => registry.capabilities[id] && registry.capabilities[id].reviewer);
|
|
|
|
test('capabilities/gemini/ no longer exists', () => {
|
|
assert.strictEqual(
|
|
fs.existsSync(path.join(ROOT, 'capabilities', 'gemini')),
|
|
false,
|
|
'the gemini capability directory must be deleted, not emptied',
|
|
);
|
|
});
|
|
|
|
test('no capability declares a gemini reviewer lane', () => {
|
|
const offenders = reviewerIds().filter((id) => {
|
|
const rev = registry.capabilities[id].reviewer;
|
|
return id === 'gemini' || rev.slug === 'gemini' || (rev.flags || []).includes('--gemini');
|
|
});
|
|
assert.deepStrictEqual(
|
|
offenders,
|
|
[],
|
|
'a reviewer lane still resolves for the retired Gemini CLI; --gemini would spawn a binary '
|
|
+ `Google stopped serving on 2026-06-18. Offenders: ${offenders.join(', ')}`,
|
|
);
|
|
});
|
|
|
|
test('no config key is owned for the retired lane', () => {
|
|
const offenders = Object.keys(registry.configKeys).filter((k) => /\.gemini$/.test(k));
|
|
assert.deepStrictEqual(
|
|
offenders,
|
|
[],
|
|
'the retired lane still owns config keys, so `gsd config-set` would accept settings for a '
|
|
+ `lane that cannot run. Offenders:\n ${offenders.join('\n ')}`,
|
|
);
|
|
});
|
|
|
|
test('exactly 11 reviewer lanes remain', () => {
|
|
// Counted from the registry, not hardcoded per-name, so adding a 12th lane later cannot
|
|
// silently re-admit gemini under cover of the count still "looking right".
|
|
const ids = reviewerIds().sort();
|
|
assert.strictEqual(
|
|
ids.length,
|
|
11,
|
|
`expected 11 reviewer lanes after retiring gemini, got ${ids.length}: ${ids.join(', ')}`,
|
|
);
|
|
assert.ok(!ids.includes('gemini'), 'gemini must not be among them');
|
|
});
|
|
|
|
test("Antigravity's reviewer lane is untouched (negative space)", () => {
|
|
const agy = registry.capabilities.antigravity;
|
|
assert.ok(agy && agy.reviewer, 'antigravity must still declare a reviewer lane');
|
|
assert.strictEqual(agy.reviewer.slug, 'antigravity');
|
|
for (const flag of ['--antigravity', '--agy']) {
|
|
assert.ok(
|
|
(agy.reviewer.flags || []).includes(flag),
|
|
`antigravity must keep its ${flag} flag`,
|
|
);
|
|
}
|
|
// Its own keys survive, including the deliberately `agy`-suffixed model key.
|
|
for (const key of [
|
|
'review.models.agy',
|
|
'review.timeouts.antigravity',
|
|
'review.max_prompt_tokens_per_reviewer.antigravity',
|
|
]) {
|
|
assert.ok(
|
|
Object.prototype.hasOwnProperty.call(registry.configKeys, key),
|
|
`antigravity must still own ${key}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('the other ten lanes are untouched (negative space)', () => {
|
|
const expected = [
|
|
'antigravity', 'claude', 'coderabbit', 'codex', 'cursor',
|
|
'kimi-code', 'llama-cpp', 'lm-studio', 'ollama', 'opencode', 'qwen',
|
|
];
|
|
assert.deepStrictEqual(
|
|
reviewerIds().sort(),
|
|
expected,
|
|
'retiring gemini must remove exactly one lane and disturb no other',
|
|
);
|
|
});
|
|
});
|
|
});
|