* refactor(#60): make runtime config adapter registry explicit Replace scattered inline `runtime === '...'` config-mutation branching in bin/install.js with an explicit, typed adapter registry. The new src/runtime-config-adapter-registry.cts maps each of the 15 supported runtimes to a config intent { installSurface, writesSharedSettings, finishPermissionWriter }; install()/finishInstall() dispatch by resolved intent instead of runtime-name checks (cursor/windsurf/trae collapse to one profile-marker-only branch). Behavior-preserving: the same config files are written for the same runtimes (opencode still writes both settings.json and its permissions; kilo writes only its permissions; codex minimal-mode and opencode GSD_TEST_MODE guards unchanged). Unknown runtimes fail loudly via TypeError, with an Object.hasOwn barrier so prototype-chain keys (__proto__/constructor) also throw rather than returning a bogus intent. Leads the installer-refactor chain (#58 -> #60 -> #56), building on ADR-58. Closes #60 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#60): add changeset for runtime config adapter registry Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(#60): register Runtime Config Adapter Registry in CONTEXT.md glossary Per docs/contributor-standards.md, every new Module/seam must get a `### <Name>` entry under the domain glossary. Adds the entry for the runtime-config-adapter-registry seam introduced in this PR (interface, policy boundary, source file, ADR-58 / #60 cross-references). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/60-runtime-config-adapter-registry.md
Normal file
7
.changeset/60-runtime-config-adapter-registry.md
Normal file
@@ -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)
|
||||||
|
|
||||||
|
<!-- docs-exempt: internal behavior-preserving refactor of bin/install.js config dispatch into a dedicated registry module; no user-facing behavior change -->
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -124,6 +124,7 @@ build/
|
|||||||
/gsd-core/bin/lib/worktree-safety.cjs
|
/gsd-core/bin/lib/worktree-safety.cjs
|
||||||
/gsd-core/bin/lib/planning-workspace.cjs
|
/gsd-core/bin/lib/planning-workspace.cjs
|
||||||
/gsd-core/bin/lib/runtime-artifact-layout.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/command-routing-hub.cjs
|
||||||
/gsd-core/bin/lib/core.cjs
|
/gsd-core/bin/lib/core.cjs
|
||||||
/gsd-core/bin/lib/drift.cjs
|
/gsd-core/bin/lib/drift.cjs
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ Module owning the per-runtime mapping from artifact kind to filesystem placement
|
|||||||
### Runtime Install Policy Module
|
### 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.
|
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
|
### 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`.
|
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`.
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const {
|
|||||||
applyWorktreeBaseRef,
|
applyWorktreeBaseRef,
|
||||||
readBaseRefFromSettings,
|
readBaseRefFromSettings,
|
||||||
} = require('../gsd-core/bin/lib/worktree-base-ref.cjs');
|
} = 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
|
* 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 isHermes = runtime === 'hermes';
|
||||||
const isCodebuddy = runtime === 'codebuddy';
|
const isCodebuddy = runtime === 'codebuddy';
|
||||||
const isCline = runtime === 'cline';
|
const isCline = runtime === 'cline';
|
||||||
|
const configIntent = resolveRuntimeConfigIntent(runtime);
|
||||||
const dirName = getDirName(runtime);
|
const dirName = getDirName(runtime);
|
||||||
const src = path.join(__dirname, '..');
|
const src = path.join(__dirname, '..');
|
||||||
|
|
||||||
@@ -9214,7 +9216,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|||||||
throw _earlyInstallErr;
|
throw _earlyInstallErr;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCodex && !isMinimalMode(_effectiveInstallMode)) {
|
if (configIntent.installSurface === 'codex-toml' && !isMinimalMode(_effectiveInstallMode)) {
|
||||||
// Capture pre-install snapshots before ANY GSD mutation
|
// Capture pre-install snapshots before ANY GSD mutation
|
||||||
// (#2760 fix 3). On post-write schema-validation failure OR any throw
|
// (#2760 fix 3). On post-write schema-validation failure OR any throw
|
||||||
// during the mutation sequence (write failure, merge throw, etc.) we
|
// 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 };
|
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCopilot) {
|
if (configIntent.installSurface === 'copilot-instructions') {
|
||||||
// Generate copilot-instructions.md
|
// Generate copilot-instructions.md
|
||||||
const templatePath = path.join(targetDir, 'gsd-core', 'templates', 'copilot-instructions.md');
|
const templatePath = path.join(targetDir, 'gsd-core', 'templates', 'copilot-instructions.md');
|
||||||
const instructionsPath = path.join(targetDir, '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 };
|
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCursor) {
|
if (configIntent.installSurface === 'profile-marker-only') {
|
||||||
// Cursor uses skills — no config.toml, no settings.json hooks needed
|
// Cursor/Windsurf/Trae use skills — no config.toml, no settings.json hooks needed
|
||||||
persistActiveProfileMarker();
|
persistActiveProfileMarker();
|
||||||
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isWindsurf) {
|
if (configIntent.installSurface === 'cline-rules') {
|
||||||
// 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) {
|
|
||||||
// Cline uses .clinerules — generate a rules file with GSD system instructions
|
// Cline uses .clinerules — generate a rules file with GSD system instructions
|
||||||
const clinerulesDest = path.join(targetDir, '.clinerules');
|
const clinerulesDest = path.join(targetDir, '.clinerules');
|
||||||
const clinerules = [
|
const clinerules = [
|
||||||
@@ -10210,6 +10200,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
|||||||
const isWindsurf = runtime === 'windsurf';
|
const isWindsurf = runtime === 'windsurf';
|
||||||
const isTrae = runtime === 'trae';
|
const isTrae = runtime === 'trae';
|
||||||
const isCline = runtime === 'cline';
|
const isCline = runtime === 'cline';
|
||||||
|
const configIntent = resolveRuntimeConfigIntent(runtime);
|
||||||
|
|
||||||
if (shouldInstallStatusline && !isOpencode && !isKilo && !isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae) {
|
if (shouldInstallStatusline && !isOpencode && !isKilo && !isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae) {
|
||||||
if (!isGlobal && !forceStatusline) {
|
if (!isGlobal && !forceStatusline) {
|
||||||
@@ -10269,17 +10260,17 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
|||||||
// {type: 'command', command: null} items that the runtime hook schema
|
// {type: 'command', command: null} items that the runtime hook schema
|
||||||
// rejects at parse time. validateHookFields filters those out so the file
|
// rejects at parse time. validateHookFields filters those out so the file
|
||||||
// we write is always schema-valid.
|
// we write is always schema-valid.
|
||||||
if (!isCodex && !isCopilot && !isKilo && !isCursor && !isWindsurf && !isTrae && !isCline) {
|
if (configIntent.writesSharedSettings) {
|
||||||
writeSettings(settingsPath, validateHookFields(settings));
|
writeSettings(settingsPath, validateHookFields(settings));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure OpenCode permissions
|
// Configure OpenCode permissions
|
||||||
if (isOpencode && !process.env.GSD_TEST_MODE) {
|
if (configIntent.finishPermissionWriter === 'opencode' && !process.env.GSD_TEST_MODE) {
|
||||||
configureOpencodePermissions(isGlobal, configDir);
|
configureOpencodePermissions(isGlobal, configDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure Kilo permissions
|
// Configure Kilo permissions
|
||||||
if (isKilo) {
|
if (configIntent.finishPermissionWriter === 'kilo') {
|
||||||
configureKiloPermissions(isGlobal, configDir);
|
configureKiloPermissions(isGlobal, configDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -325,6 +325,7 @@
|
|||||||
"roadmap-upgrade.cjs",
|
"roadmap-upgrade.cjs",
|
||||||
"roadmap.cjs",
|
"roadmap.cjs",
|
||||||
"runtime-artifact-layout.cjs",
|
"runtime-artifact-layout.cjs",
|
||||||
|
"runtime-config-adapter-registry.cjs",
|
||||||
"runtime-homes.cjs",
|
"runtime-homes.cjs",
|
||||||
"runtime-name-policy.cjs",
|
"runtime-name-policy.cjs",
|
||||||
"runtime-slash.cjs",
|
"runtime-slash.cjs",
|
||||||
|
|||||||
@@ -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`.
|
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-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 |
|
| `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-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-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-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-<cmd>` (skills-based runtimes) and `$gsd-<cmd>` (codex) in user-facing output and persisted artifacts (#3584) |
|
| `runtime-slash.cjs` | Runtime-aware slash-command formatter — single source of truth for emitting `/gsd-<cmd>` (skills-based runtimes) and `$gsd-<cmd>` (codex) in user-facing output and persisted artifacts (#3584) |
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export default tseslint.config(
|
|||||||
'gsd-core/bin/lib/worktree-base-ref.cjs',
|
'gsd-core/bin/lib/worktree-base-ref.cjs',
|
||||||
'gsd-core/bin/lib/planning-workspace.cjs',
|
'gsd-core/bin/lib/planning-workspace.cjs',
|
||||||
'gsd-core/bin/lib/runtime-artifact-layout.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/command-routing-hub.cjs',
|
||||||
'gsd-core/bin/lib/core.cjs',
|
'gsd-core/bin/lib/core.cjs',
|
||||||
'gsd-core/bin/lib/drift.cjs',
|
'gsd-core/bin/lib/drift.cjs',
|
||||||
|
|||||||
109
src/runtime-config-adapter-registry.cts
Normal file
109
src/runtime-config-adapter-registry.cts
Normal file
@@ -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<string, Readonly<RegistryEntry>> = 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<string> = new Set(Object.keys(REGISTRY));
|
||||||
|
|
||||||
|
/** All valid installSurface values. */
|
||||||
|
const INSTALL_SURFACES: ReadonlyArray<ConfigInstallSurface> = 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 };
|
||||||
245
tests/runtime-config-adapter-registry.test.cjs
Normal file
245
tests/runtime-config-adapter-registry.test.cjs
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user