diff --git a/.changeset/sturdy-voles-dart.md b/.changeset/sturdy-voles-dart.md new file mode 100644 index 000000000..608c1b962 --- /dev/null +++ b/.changeset/sturdy-voles-dart.md @@ -0,0 +1,7 @@ +--- +type: Changed +pr: 1800 +--- +**Internal: install/uninstall runtime labels are now sourced from a single `getRuntimeLabel` lookup** — the two duplicated `runtimeLabel` assignment chains in `bin/install.js` (uninstall + install) are collapsed into one curated label table in `runtime-name-policy.cts`, sibling to the registry-derived `getDirName` (ADR-1239 Phase B, #1679). Install output is byte-identical for all 16 runtimes (golden-parity asserted). Two console-label inconsistencies are normalized as a side effect: `kimi` shows 'Kimi CLI' in both sites, and `cline` uninstall no longer falls through to 'Claude Code'. + + diff --git a/bin/install.js b/bin/install.js index 272439d24..33b9cf5b1 100755 --- a/bin/install.js +++ b/bin/install.js @@ -37,7 +37,7 @@ const { // installer to the runtime-name-policy leaf (ADR-1508 / #1510 Phase 1) so the // conversion module's rewrite engine can consume it without importing // bin/install.js. Re-exported below for back-compat consumers/tests. -const { getDirName } = require('../gsd-core/bin/lib/runtime-name-policy.cjs'); +const { getDirName, getRuntimeLabel } = require('../gsd-core/bin/lib/runtime-name-policy.cjs'); const { applyWorktreeBaseRef, readBaseRefFromSettings, @@ -6960,21 +6960,9 @@ function uninstall(isGlobal, runtime = 'claude') { ? targetDir.replace(os.homedir(), '~') : targetDir.replace(process.cwd(), '.'); - let runtimeLabel = 'Claude Code'; - if (runtime === 'opencode') runtimeLabel = 'OpenCode'; - if (runtime === 'gemini') runtimeLabel = 'Gemini'; - if (runtime === 'kilo') runtimeLabel = 'Kilo'; - if (runtime === 'codex') runtimeLabel = 'Codex'; - if (runtime === 'copilot') runtimeLabel = 'Copilot'; - if (runtime === 'antigravity') runtimeLabel = 'Antigravity'; - if (runtime === 'cursor') runtimeLabel = 'Cursor'; - if (runtime === 'windsurf') runtimeLabel = 'Windsurf'; - if (runtime === 'augment') runtimeLabel = 'Augment'; - if (runtime === 'trae') runtimeLabel = 'Trae'; - if (runtime === 'qwen') runtimeLabel = 'Qwen Code'; - if (runtime === 'hermes') runtimeLabel = 'Hermes Agent'; - if (runtime === 'kimi') runtimeLabel = 'Kimi CLI'; - if (runtime === 'codebuddy') runtimeLabel = 'CodeBuddy'; + // runtimeLabel is now the single-source getRuntimeLabel lookup (ADR-1239 + // Phase B / #1679) — collapses the prior 15-line assignment chain. + const runtimeLabel = getRuntimeLabel(runtime); console.log(` Uninstalling GSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`); @@ -8599,22 +8587,9 @@ function install(isGlobal, runtime = 'claude', options = {}) { homeDir, }); - let runtimeLabel = 'Claude Code'; - if (isOpencode) runtimeLabel = 'OpenCode'; - if (isGemini) runtimeLabel = 'Gemini'; - if (isKilo) runtimeLabel = 'Kilo'; - if (isCodex) runtimeLabel = 'Codex'; - if (isCopilot) runtimeLabel = 'Copilot'; - if (isAntigravity) runtimeLabel = 'Antigravity'; - if (isCursor) runtimeLabel = 'Cursor'; - if (isWindsurf) runtimeLabel = 'Windsurf'; - if (isAugment) runtimeLabel = 'Augment'; - if (isTrae) runtimeLabel = 'Trae'; - if (isQwen) runtimeLabel = 'Qwen Code'; - if (isHermes) runtimeLabel = 'Hermes Agent'; - if (isKimi) runtimeLabel = 'Kimi'; - if (isCodebuddy) runtimeLabel = 'CodeBuddy'; - if (isCline) runtimeLabel = 'Cline'; + // runtimeLabel is now the single-source getRuntimeLabel lookup (ADR-1239 + // Phase B / #1679) — collapses the prior 16-line assignment chain. + const runtimeLabel = getRuntimeLabel(runtime); console.log(` Installing for ${cyan}${runtimeLabel}${reset} to ${cyan}${locationLabel}${reset}\n`); diff --git a/src/runtime-name-policy.cts b/src/runtime-name-policy.cts index 6b20fe053..d31f89f7e 100644 --- a/src/runtime-name-policy.cts +++ b/src/runtime-name-policy.cts @@ -151,3 +151,60 @@ export function getDirName(runtime: string): string { if (typeof dir === 'string' && dir.length > 0) return dir; return '.claude'; } + +/** + * Curated short display labels for the install/uninstall console output, keyed + * by canonical runtime id. The SINGLE source of truth consumed by both + * `install()` and `uninstall()` in bin/install.js via `getRuntimeLabel`. + * + * Collapses the two duplicated `runtimeLabel` assignment chains that previously + * lived inline in bin/install.js (ADR-1239 Phase B, #1679) — the add-a-host tax: + * a new runtime meant remembering to add a label line in BOTH chains, and they + * had drifted out of sync (uninstall omitted `cline` and used a different + * `kimi` value than install). This table is the curated canonical resolution: + * - kimi: install 'Kimi' / uninstall 'Kimi CLI' → 'Kimi CLI' (majority + descriptor title) + * - cline: install 'Cline' / uninstall (omitted) → 'Cline' (majority + descriptor title) + * + * Voice: these are the SHORT UI labels, intentionally distinct from the + * descriptor `title` (the long product name — e.g. "OpenAI Codex CLI", + * "GitHub Copilot", "Gemini CLI") which serves documentation/registry display, + * not the install console. A future slice may relocate this to a + * `runtime.label` descriptor field; until then this table is the source. + * + * Lookup is RAW-ID only (no alias expansion) — callers pass an already- + * canonicalized runtime id, keeping the label surface explicit. Unknown/empty + * ids fall back to 'Claude Code' (the always-safe default, fail-closed). + * + * The drift-guard test (tests/runtime-label-policy.test.cjs) pins this table's + * id set to the capability-registry runtime id set, so adding/removing a runtime + * forces a deliberate update here. + */ +const RUNTIME_LABELS: Readonly> = { + claude: 'Claude Code', + opencode: 'OpenCode', + gemini: 'Gemini', + kilo: 'Kilo', + codex: 'Codex', + copilot: 'Copilot', + antigravity: 'Antigravity', + cursor: 'Cursor', + windsurf: 'Windsurf', + augment: 'Augment', + trae: 'Trae', + qwen: 'Qwen Code', + hermes: 'Hermes Agent', + kimi: 'Kimi CLI', + codebuddy: 'CodeBuddy', + cline: 'Cline', +}; + +/** + * Map a canonical runtime id to its short display label for the + * install/uninstall console output. Unknown/empty inputs fall back to + * 'Claude Code'. Sibling to `getDirName`; pure (no I/O). + */ +export function getRuntimeLabel(runtime: string): string { + if (!runtime) return 'Claude Code'; + const label = RUNTIME_LABELS[runtime]; + return typeof label === 'string' && label.length > 0 ? label : 'Claude Code'; +} diff --git a/tests/runtime-label-policy.test.cjs b/tests/runtime-label-policy.test.cjs new file mode 100644 index 000000000..119e02e45 --- /dev/null +++ b/tests/runtime-label-policy.test.cjs @@ -0,0 +1,103 @@ +'use strict'; +/** + * Drift-guard + collapse: getRuntimeLabel must be the SINGLE source of truth for + * the install/uninstall console display label, replacing the two duplicated + * `runtimeLabel` assignment chains that previously lived in bin/install.js + * (uninstall() and install()) — the add-a-host tax ADR-1239 Phase B (#1679) + * eliminates. + * + * Verifies: + * 1. For every known runtime id, getRuntimeLabel(id) equals the hardcoded + * golden expected map — a pinned oracle that catches BOTH table bugs AND + * unintended drift (adding/removing a runtime forces a deliberate + * golden-map update here). + * 2. getRuntimeLabel('unknown') and getRuntimeLabel('') fall back to + * 'Claude Code' (the always-safe default). + * 3. The golden id set EXACTLY equals the capability-registry runtime id set + * (adding/removing a runtime forces a golden update here). + * + * Voice: these are the SHORT UI labels used in the install/uninstall console + * output, intentionally distinct from the descriptor `title` (the long product + * name, e.g. "OpenAI Codex CLI", "GitHub Copilot") which serves + * documentation/registry display. Two prior-chain inconsistencies are resolved + * by this canonical map (both move toward the majority + descriptor value): + * - kimi: install said 'Kimi', uninstall said 'Kimi CLI' → canonical 'Kimi CLI' + * - cline: install said 'Cline', uninstall omitted it (→ 'Claude Code') → canonical 'Cline' + * + * ADR-1239 Phase B (#1679). + * Behavioral tests only: assert on returned values, no source-grep. + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const runtimeNamePolicy = require('../gsd-core/bin/lib/runtime-name-policy.cjs'); +const registry = require('../gsd-core/bin/lib/capability-registry.cjs'); + +const { getRuntimeLabel } = runtimeNamePolicy; + +// Golden oracle: hardcoded expected map of all 16 runtime ids to their short +// install/uninstall display label. A pinned expected value in a TEST is correct +// — the test IS the oracle (non-circular). Only PRODUCTION code should derive +// dynamically. If this map diverges from getRuntimeLabel output, either the +// table is wrong OR the registry changed — both require a deliberate golden-map +// update here. +const GOLDEN_LABEL_MAP = { + claude: 'Claude Code', + opencode: 'OpenCode', + gemini: 'Gemini', + kilo: 'Kilo', + codex: 'Codex', + copilot: 'Copilot', + antigravity: 'Antigravity', + cursor: 'Cursor', + windsurf: 'Windsurf', + augment: 'Augment', + trae: 'Trae', + qwen: 'Qwen Code', + hermes: 'Hermes Agent', + kimi: 'Kimi CLI', + codebuddy: 'CodeBuddy', + cline: 'Cline', +}; + +test('getRuntimeLabel: golden map matches for all 16 known runtime ids', () => { + for (const [id, expected] of Object.entries(GOLDEN_LABEL_MAP)) { + const actual = getRuntimeLabel(id); + assert.strictEqual( + actual, + expected, + `getRuntimeLabel('${id}') diverged from golden.\n` + + ` actual: ${JSON.stringify(actual)}\n` + + ` expected: ${JSON.stringify(expected)}`, + ); + } +}); + +test('drift guard: registry runtime id set EXACTLY equals the golden map (adding/removing a runtime forces a golden update)', () => { + // Without this, a newly-added runtime would pass (its label never checked) and + // removing `claude` could pass via the 'Claude Code' fallback. Pin the set + // both ways — mirroring the getDirName drift guard. + const registryIds = Object.keys(registry.runtimes).sort(); + const goldenIds = Object.keys(GOLDEN_LABEL_MAP).sort(); + assert.deepEqual(registryIds, goldenIds, + 'registry.runtimes id set must exactly match GOLDEN_LABEL_MAP — update the golden map when adding/removing a runtime'); +}); + +test('getRuntimeLabel fallback: unknown runtime returns "Claude Code"', () => { + assert.strictEqual(getRuntimeLabel('unknown'), 'Claude Code', + 'getRuntimeLabel("unknown") must return "Claude Code" (default fallback)'); +}); + +test('getRuntimeLabel fallback: empty string returns "Claude Code"', () => { + assert.strictEqual(getRuntimeLabel(''), 'Claude Code', + 'getRuntimeLabel("") must return "Claude Code" (empty-input fallback)'); +}); + +test('getRuntimeLabel fallback: alias is NOT auto-expanded (raw id match only)', () => { + // getRuntimeLabel is a raw-id lookup, not alias-aware (unlike canonicalizeRuntimeName). + // Callers pass an already-canonicalized runtime id. An alias must fall back to + // the default rather than silently matching — this keeps the label surface + // explicit and prevents a future alias from changing console output by accident. + assert.strictEqual(getRuntimeLabel('claude-code'), 'Claude Code', + 'getRuntimeLabel("claude-code") must return the default "Claude Code" (raw-id match only; aliases are not expanded)'); +});