diff --git a/.changeset/60-runtime-config-adapter-registry.md b/.changeset/60-runtime-config-adapter-registry.md new file mode 100644 index 000000000..802af38a6 --- /dev/null +++ b/.changeset/60-runtime-config-adapter-registry.md @@ -0,0 +1,7 @@ +--- +type: Changed +pr: 795 +--- +Make per-runtime config-mutation dispatch in the installer explicit: a new runtime config adapter registry maps each supported runtime to a typed config intent (install surface, shared-settings gate, finish-phase permission writer), and `install()`/`finishInstall()` dispatch by resolved intent instead of inline `runtime === '...'` branching. Behavior-preserving; unknown runtimes now fail loudly. (#60) + + diff --git a/.gitignore b/.gitignore index 49990d26c..bbc12e1b1 100644 --- a/.gitignore +++ b/.gitignore @@ -124,6 +124,7 @@ build/ /gsd-core/bin/lib/worktree-safety.cjs /gsd-core/bin/lib/planning-workspace.cjs /gsd-core/bin/lib/runtime-artifact-layout.cjs +/gsd-core/bin/lib/runtime-config-adapter-registry.cjs /gsd-core/bin/lib/command-routing-hub.cjs /gsd-core/bin/lib/core.cjs /gsd-core/bin/lib/drift.cjs diff --git a/CONTEXT.md b/CONTEXT.md index 0a15b14ff..30acfb6c9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -121,6 +121,9 @@ Module owning the per-runtime mapping from artifact kind to filesystem placement ### Runtime Install Policy Module Projects a pure, typed install plan for a given runtime by composing artifact placements (Runtime Artifact Layout Module), command text (Shell Command Projection Module), and per-runtime config intentions — with no filesystem IO or format-specific serialization. Runtime-specific adapters consume the plan and execute concrete file mutations and config rendering. See ADR-58. +### Runtime Config Adapter Registry +Module owning the explicit per-runtime config-mutation dispatch table for the installer. `resolveRuntimeConfigIntent(runtime)` projects a typed config intent — `installSurface` (`settings-json` | `codex-toml` | `copilot-instructions` | `cline-rules` | `profile-marker-only`), `writesSharedSettings` (the `finishInstall` shared-settings write gate), and `finishPermissionWriter` (`opencode` | `kilo` | none) — that `bin/install.js` dispatches on instead of inline `runtime === '...'` branching. Owns adapter selection only: it performs no filesystem IO and does not execute config mutations (the install/finishInstall handlers and the per-runtime writers do that). Unknown runtimes fail loudly with a `TypeError`, guarded by an `Object.hasOwn` own-property check so prototype-chain keys (`__proto__`, `constructor`) also throw. Realizes the adapter-selection half of the Runtime Install Policy Module boundary. Source: `gsd-core/bin/lib/runtime-config-adapter-registry.cjs`. See ADR-58, #60. + ### Knowledge Graph Module Module owning the graphify integration: config gate (`isGraphifyEnabled`), disabled response (`disabledResponse`), subprocess helper (`execGraphify`, typed `GRAPHIFY_REASON` enum), presence detection (`checkGraphifyInstalled`), version checking (`checkGraphifyVersion`), query surface (`graphifyQuery` — BFS seed-expand + budget trim), status surface (`graphifyStatus` — node/edge counts, mtime staleness, commit-staleness tri-state via `built_at_commit`/`commits_behind`/`commit_stale`), diff surface (`graphifyDiff` — added/removed/changed nodes+edges), build pre-flight (`graphifyBuild`), snapshot management (`writeSnapshot`). Reads `.planning/config.json:graphify.enabled` as config gate; writes to `.planning/graphs/`. Auto-update hook (`hooks/gsd-graphify-update.sh`) triggers a detached background rebuild after HEAD-advancing git operations on the default branch when `graphify.auto_update=true`. Status file `.planning/graphs/.last-build-status.json` carries `{ ts, status, exit_code, duration_ms, head_at_build, graphify_version }`. Graph IR uses `nodes[]`, `edges[]` (or `links[]` for graphify ≥0.7 compat), `hyperedges[]`, `built_at_commit`. `commit_stale` is tri-state: `false` (known fresh), `true` (stale), `null` (unknown — no git or pre-v0.7 graph). Source: `gsd-core/bin/lib/graphify.cjs`. Skill: `commands/gsd/graphify.md`. diff --git a/bin/install.js b/bin/install.js index 5c2b3e9df..4f67b71e0 100755 --- a/bin/install.js +++ b/bin/install.js @@ -35,6 +35,7 @@ const { applyWorktreeBaseRef, readBaseRefFromSettings, } = require('../gsd-core/bin/lib/worktree-base-ref.cjs'); +const { resolveRuntimeConfigIntent } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs'); /** * Runtimes that register hyphen-form `name:` per #2808 AND copy agent bodies @@ -8254,6 +8255,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { const isHermes = runtime === 'hermes'; const isCodebuddy = runtime === 'codebuddy'; const isCline = runtime === 'cline'; + const configIntent = resolveRuntimeConfigIntent(runtime); const dirName = getDirName(runtime); const src = path.join(__dirname, '..'); @@ -9214,7 +9216,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { throw _earlyInstallErr; } - if (isCodex && !isMinimalMode(_effectiveInstallMode)) { + if (configIntent.installSurface === 'codex-toml' && !isMinimalMode(_effectiveInstallMode)) { // Capture pre-install snapshots before ANY GSD mutation // (#2760 fix 3). On post-write schema-validation failure OR any throw // during the mutation sequence (write failure, merge throw, etc.) we @@ -9560,7 +9562,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } - if (isCopilot) { + if (configIntent.installSurface === 'copilot-instructions') { // Generate copilot-instructions.md const templatePath = path.join(targetDir, 'gsd-core', 'templates', 'copilot-instructions.md'); const instructionsPath = path.join(targetDir, 'copilot-instructions.md'); @@ -9574,25 +9576,13 @@ function install(isGlobal, runtime = 'claude', options = {}) { return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } - if (isCursor) { - // Cursor uses skills — no config.toml, no settings.json hooks needed + if (configIntent.installSurface === 'profile-marker-only') { + // Cursor/Windsurf/Trae use skills — no config.toml, no settings.json hooks needed persistActiveProfileMarker(); return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } - if (isWindsurf) { - // Windsurf uses skills — no config.toml, no settings.json hooks needed - persistActiveProfileMarker(); - return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; - } - - if (isTrae) { - // Trae uses skills — no settings.json hooks needed - persistActiveProfileMarker(); - return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; - } - - if (isCline) { + if (configIntent.installSurface === 'cline-rules') { // Cline uses .clinerules — generate a rules file with GSD system instructions const clinerulesDest = path.join(targetDir, '.clinerules'); const clinerules = [ @@ -10210,6 +10200,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS const isWindsurf = runtime === 'windsurf'; const isTrae = runtime === 'trae'; const isCline = runtime === 'cline'; + const configIntent = resolveRuntimeConfigIntent(runtime); if (shouldInstallStatusline && !isOpencode && !isKilo && !isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae) { if (!isGlobal && !forceStatusline) { @@ -10269,17 +10260,17 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS // {type: 'command', command: null} items that the runtime hook schema // rejects at parse time. validateHookFields filters those out so the file // we write is always schema-valid. - if (!isCodex && !isCopilot && !isKilo && !isCursor && !isWindsurf && !isTrae && !isCline) { + if (configIntent.writesSharedSettings) { writeSettings(settingsPath, validateHookFields(settings)); } // Configure OpenCode permissions - if (isOpencode && !process.env.GSD_TEST_MODE) { + if (configIntent.finishPermissionWriter === 'opencode' && !process.env.GSD_TEST_MODE) { configureOpencodePermissions(isGlobal, configDir); } // Configure Kilo permissions - if (isKilo) { + if (configIntent.finishPermissionWriter === 'kilo') { configureKiloPermissions(isGlobal, configDir); } diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index fedcb8ab2..046b487c2 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -325,6 +325,7 @@ "roadmap-upgrade.cjs", "roadmap.cjs", "runtime-artifact-layout.cjs", + "runtime-config-adapter-registry.cjs", "runtime-homes.cjs", "runtime-name-policy.cjs", "runtime-slash.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 2f458c0b0..bb422cd18 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -370,7 +370,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (89 shipped) +## CLI Modules (90 shipped) Full listing: `gsd-core/bin/lib/*.cjs`. @@ -436,6 +436,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `roadmap-upgrade.cjs` | Migration tool for converting legacy `Phase N` entries to milestone-prefixed `Phase M-NN` convention; `computeMigrationPlan` + `applyMigration` with dry-run default and atomic rollback | | `roadmap.cjs` | ROADMAP.md parsing, phase extraction, plan progress | | `runtime-artifact-layout.cjs` | Runtime artifact layout module — resolves the artifact directory shapes (commands, agents, skills) for each supported runtime; single source of truth for per-runtime artifact placement (#3663) | +| `runtime-config-adapter-registry.cjs` | Explicit runtime config adapter registry — resolves per-runtime config-mutation install intent (install surface, shared-settings gate, finish-phase permission writer); see ADR-58. | | `runtime-name-policy.cjs` | Runtime name normalization policy — canonical token sanitization for runtime identifiers used in path construction and display | | `runtime-homes.cjs` | Canonical runtime → global config/skills directory mapping; first-class support for all 15 runtimes including Hermes nested layout and Cline rules-based exclusion (#3126) | | `runtime-slash.cjs` | Runtime-aware slash-command formatter — single source of truth for emitting `/gsd-` (skills-based runtimes) and `$gsd-` (codex) in user-facing output and persisted artifacts (#3584) | diff --git a/eslint.config.mjs b/eslint.config.mjs index 6ea65d3bb..d88a62b24 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -86,6 +86,7 @@ export default tseslint.config( 'gsd-core/bin/lib/worktree-base-ref.cjs', 'gsd-core/bin/lib/planning-workspace.cjs', 'gsd-core/bin/lib/runtime-artifact-layout.cjs', + 'gsd-core/bin/lib/runtime-config-adapter-registry.cjs', 'gsd-core/bin/lib/command-routing-hub.cjs', 'gsd-core/bin/lib/core.cjs', 'gsd-core/bin/lib/drift.cjs', diff --git a/src/runtime-config-adapter-registry.cts b/src/runtime-config-adapter-registry.cts new file mode 100644 index 000000000..3bb690471 --- /dev/null +++ b/src/runtime-config-adapter-registry.cts @@ -0,0 +1,109 @@ +'use strict'; + +/** + * Runtime config adapter registry — explicit dispatch table for install-phase + * config mutations (issue #60), replacing inline `runtime === '...'` branching + * in bin/install.js. + * + * Design notes: + * - `installSurface` selects which config handler install() runs: + * 'settings-json' → fall through to the shared settings.json accumulation. + * 'codex-toml' → early-return after writing codex.toml. + * 'copilot-instructions' → early-return after writing .github/copilot-instructions.md. + * 'cline-rules' → early-return after writing .clinerules. + * 'profile-marker-only' → early-return after writing only the profile marker. + * - `writesSharedSettings` is the finishInstall writeSettings gate: + * false for codex / copilot / kilo / cursor / windsurf / trae / cline (legacy exclusion list). + * true for all other runtimes. + * - `finishPermissionWriter` names the finishInstall-phase dedicated config writer: + * 'opencode' → writes BOTH shared settings AND its own permissions file. + * 'kilo' → writes only its own permissions file. + * null → no dedicated permission writer. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type ConfigInstallSurface = + | 'settings-json' + | 'codex-toml' + | 'copilot-instructions' + | 'cline-rules' + | 'profile-marker-only'; + +type FinishPermissionWriter = 'opencode' | 'kilo' | null; + +interface RuntimeConfigIntent { + runtime: string; + installSurface: ConfigInstallSurface; + writesSharedSettings: boolean; + finishPermissionWriter: FinishPermissionWriter; +} + +interface RegistryEntry { + installSurface: ConfigInstallSurface; + writesSharedSettings: boolean; + finishPermissionWriter: FinishPermissionWriter; +} + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +const REGISTRY: Record> = Object.freeze({ + claude: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const), + gemini: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const), + antigravity: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const), + augment: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const), + qwen: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const), + hermes: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const), + codebuddy: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const), + opencode: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: 'opencode' } as const), + kilo: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: false, finishPermissionWriter: 'kilo' } as const), + codex: Object.freeze({ installSurface: 'codex-toml', writesSharedSettings: false, finishPermissionWriter: null } as const), + copilot: Object.freeze({ installSurface: 'copilot-instructions', writesSharedSettings: false, finishPermissionWriter: null } as const), + cline: Object.freeze({ installSurface: 'cline-rules', writesSharedSettings: false, finishPermissionWriter: null } as const), + cursor: Object.freeze({ installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null } as const), + windsurf: Object.freeze({ installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null } as const), + trae: Object.freeze({ installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null } as const), +}); + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +/** The complete set of 15 supported runtimes for config-adapter dispatch. */ +const ALLOWED_CONFIG_RUNTIMES: ReadonlySet = new Set(Object.keys(REGISTRY)); + +/** All valid installSurface values. */ +const INSTALL_SURFACES: ReadonlyArray = Object.freeze([ + 'settings-json', + 'codex-toml', + 'copilot-instructions', + 'cline-rules', + 'profile-marker-only', +]); + +/** + * Resolve the config adapter intent for a given runtime. + * + * Returns a fresh object each call so callers cannot poison the registry by + * mutating the returned value. + * + * @throws {TypeError} if runtime is not a known supported runtime. + */ +function resolveRuntimeConfigIntent(runtime: string): RuntimeConfigIntent { + if (!Object.hasOwn(REGISTRY, runtime)) { + throw new TypeError(`Unknown runtime for config adapter: ${runtime}`); + } + const entry = REGISTRY[runtime]; + return { + runtime, + installSurface: entry.installSurface, + writesSharedSettings: entry.writesSharedSettings, + finishPermissionWriter: entry.finishPermissionWriter, + }; +} + +export = { resolveRuntimeConfigIntent, ALLOWED_CONFIG_RUNTIMES, INSTALL_SURFACES }; diff --git a/tests/runtime-config-adapter-registry.test.cjs b/tests/runtime-config-adapter-registry.test.cjs new file mode 100644 index 000000000..a288a6ccf --- /dev/null +++ b/tests/runtime-config-adapter-registry.test.cjs @@ -0,0 +1,245 @@ +'use strict'; + +// Tests for runtime-config-adapter-registry.cjs (issue #60). +// TDD: this file is written BEFORE the implementation to establish the red state. + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const { + resolveRuntimeConfigIntent, + ALLOWED_CONFIG_RUNTIMES, + INSTALL_SURFACES, +} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-config-adapter-registry.cjs')); + +// --------------------------------------------------------------------------- +// Source-of-truth table (mirrors the intent table in the brief exactly) +// --------------------------------------------------------------------------- + +const EXPECTED_TABLE = [ + { runtime: 'claude', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'gemini', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'antigravity', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'augment', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'qwen', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'hermes', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'codebuddy', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'opencode', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: 'opencode' }, + { runtime: 'kilo', installSurface: 'settings-json', writesSharedSettings: false, finishPermissionWriter: 'kilo' }, + { runtime: 'codex', installSurface: 'codex-toml', writesSharedSettings: false, finishPermissionWriter: null }, + { runtime: 'copilot', installSurface: 'copilot-instructions', writesSharedSettings: false, finishPermissionWriter: null }, + { runtime: 'cline', installSurface: 'cline-rules', writesSharedSettings: false, finishPermissionWriter: null }, + { runtime: 'cursor', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null }, + { runtime: 'windsurf', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null }, + { runtime: 'trae', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null }, +]; + +// --------------------------------------------------------------------------- +// Test 1: Table-lock — every row in EXPECTED_TABLE must match exactly +// --------------------------------------------------------------------------- + +describe('resolveRuntimeConfigIntent — table-lock', () => { + for (const row of EXPECTED_TABLE) { + test(`${row.runtime} resolves to expected intent`, () => { + const intent = resolveRuntimeConfigIntent(row.runtime); + assert.deepStrictEqual(intent, { + runtime: row.runtime, + installSurface: row.installSurface, + writesSharedSettings: row.writesSharedSettings, + finishPermissionWriter: row.finishPermissionWriter, + }); + }); + } +}); + +// --------------------------------------------------------------------------- +// Test 2: Unknown runtime fails loudly (AC#2) +// --------------------------------------------------------------------------- + +describe('resolveRuntimeConfigIntent — unknown runtime throws TypeError', () => { + test('throws TypeError for unknown string "grok"', () => { + assert.throws(() => resolveRuntimeConfigIntent('grok'), TypeError); + }); + + test('throws TypeError for unknown string "xyzunknown"', () => { + assert.throws(() => resolveRuntimeConfigIntent('xyzunknown'), TypeError); + }); + + test('throws TypeError for empty string ""', () => { + assert.throws(() => resolveRuntimeConfigIntent(''), TypeError); + }); + + test('throws TypeError for undefined', () => { + assert.throws(() => resolveRuntimeConfigIntent(undefined), TypeError); + }); + + test('throws TypeError for "__proto__" (prototype-chain key)', () => { + assert.throws(() => resolveRuntimeConfigIntent('__proto__'), TypeError); + }); + + test('throws TypeError for "constructor" (prototype-chain key)', () => { + assert.throws(() => resolveRuntimeConfigIntent('constructor'), TypeError); + }); + + test('throws TypeError for "hasOwnProperty" (prototype-chain key)', () => { + assert.throws(() => resolveRuntimeConfigIntent('hasOwnProperty'), TypeError); + }); + + test('throws TypeError for "toString" (prototype-chain key)', () => { + assert.throws(() => resolveRuntimeConfigIntent('toString'), TypeError); + }); +}); + +// --------------------------------------------------------------------------- +// Test 3: writesSharedSettings exclusion equivalence +// --------------------------------------------------------------------------- + +describe('writesSharedSettings exclusion equivalence', () => { + const EXPECTED_FALSE_SET = new Set(['codex', 'copilot', 'kilo', 'cursor', 'windsurf', 'trae', 'cline']); + + test('runtimes with writesSharedSettings===false are exactly the exclusion set', () => { + const falseRuntimes = EXPECTED_TABLE + .filter(r => r.writesSharedSettings === false) + .map(r => r.runtime); + assert.deepStrictEqual(new Set(falseRuntimes), EXPECTED_FALSE_SET); + }); + + test('all other supported runtimes have writesSharedSettings===true', () => { + const trueRuntimes = EXPECTED_TABLE + .filter(r => r.writesSharedSettings === true) + .map(r => r.runtime); + for (const runtime of trueRuntimes) { + assert.ok(!EXPECTED_FALSE_SET.has(runtime), `${runtime} should have writesSharedSettings true`); + } + }); +}); + +// --------------------------------------------------------------------------- +// Test 4: finishPermissionWriter correctness +// --------------------------------------------------------------------------- + +describe('finishPermissionWriter', () => { + test('opencode -> "opencode"', () => { + assert.strictEqual(resolveRuntimeConfigIntent('opencode').finishPermissionWriter, 'opencode'); + }); + + test('kilo -> "kilo"', () => { + assert.strictEqual(resolveRuntimeConfigIntent('kilo').finishPermissionWriter, 'kilo'); + }); + + test('every other supported runtime -> null', () => { + const nullExpected = EXPECTED_TABLE + .filter(r => r.finishPermissionWriter === null) + .map(r => r.runtime); + for (const runtime of nullExpected) { + assert.strictEqual( + resolveRuntimeConfigIntent(runtime).finishPermissionWriter, + null, + `${runtime} should have finishPermissionWriter null`, + ); + } + }); +}); + +// --------------------------------------------------------------------------- +// Test 5: Distinct dedicated surfaces +// --------------------------------------------------------------------------- + +describe('installSurface correctness', () => { + test('codex -> "codex-toml"', () => { + assert.strictEqual(resolveRuntimeConfigIntent('codex').installSurface, 'codex-toml'); + }); + + test('copilot -> "copilot-instructions"', () => { + assert.strictEqual(resolveRuntimeConfigIntent('copilot').installSurface, 'copilot-instructions'); + }); + + test('cline -> "cline-rules"', () => { + assert.strictEqual(resolveRuntimeConfigIntent('cline').installSurface, 'cline-rules'); + }); + + test('cursor -> "profile-marker-only"', () => { + assert.strictEqual(resolveRuntimeConfigIntent('cursor').installSurface, 'profile-marker-only'); + }); + + test('windsurf -> "profile-marker-only"', () => { + assert.strictEqual(resolveRuntimeConfigIntent('windsurf').installSurface, 'profile-marker-only'); + }); + + test('trae -> "profile-marker-only"', () => { + assert.strictEqual(resolveRuntimeConfigIntent('trae').installSurface, 'profile-marker-only'); + }); + + test('the 7 passthroughs + opencode + kilo -> "settings-json"', () => { + const settingsJsonRuntimes = ['claude', 'gemini', 'antigravity', 'augment', 'qwen', 'hermes', 'codebuddy', 'opencode', 'kilo']; + for (const runtime of settingsJsonRuntimes) { + assert.strictEqual( + resolveRuntimeConfigIntent(runtime).installSurface, + 'settings-json', + `${runtime} should have installSurface "settings-json"`, + ); + } + }); +}); + +// --------------------------------------------------------------------------- +// Test 6: Returned intent is a fresh object (no shared reference mutation) +// --------------------------------------------------------------------------- + +describe('resolveRuntimeConfigIntent — fresh object each call', () => { + test('mutating the returned object does not affect a subsequent resolve', () => { + const first = resolveRuntimeConfigIntent('claude'); + first.installSurface = 'MUTATED'; + first.writesSharedSettings = false; + + const second = resolveRuntimeConfigIntent('claude'); + assert.strictEqual(second.installSurface, 'settings-json'); + assert.strictEqual(second.writesSharedSettings, true); + }); +}); + +// --------------------------------------------------------------------------- +// Test 7: Completeness (AC#4 table-driven) — ALLOWED_CONFIG_RUNTIMES +// --------------------------------------------------------------------------- + +describe('ALLOWED_CONFIG_RUNTIMES completeness', () => { + const EXPECTED_15 = new Set([ + 'claude', 'gemini', 'antigravity', 'augment', 'qwen', 'hermes', 'codebuddy', + 'opencode', 'kilo', 'codex', 'copilot', 'cline', 'cursor', 'windsurf', 'trae', + ]); + + test('ALLOWED_CONFIG_RUNTIMES contains exactly the 15 expected runtimes', () => { + const runtimeSet = new Set(ALLOWED_CONFIG_RUNTIMES); + assert.deepStrictEqual(runtimeSet, EXPECTED_15); + }); + + test('every member of ALLOWED_CONFIG_RUNTIMES resolves without throwing', () => { + for (const runtime of ALLOWED_CONFIG_RUNTIMES) { + assert.doesNotThrow(() => resolveRuntimeConfigIntent(runtime), `${runtime} should resolve without throwing`); + } + }); + + test('ALLOWED_CONFIG_RUNTIMES has exactly 15 entries', () => { + assert.strictEqual([...ALLOWED_CONFIG_RUNTIMES].length, 15); + }); +}); + +// --------------------------------------------------------------------------- +// Test 8: INSTALL_SURFACES export +// --------------------------------------------------------------------------- + +describe('INSTALL_SURFACES export', () => { + const EXPECTED_SURFACES = new Set([ + 'settings-json', + 'codex-toml', + 'copilot-instructions', + 'cline-rules', + 'profile-marker-only', + ]); + + test('INSTALL_SURFACES contains exactly the 5 surface strings', () => { + assert.deepStrictEqual(new Set(INSTALL_SURFACES), EXPECTED_SURFACES); + }); +});