From 8951f2907ed9ad274ab33907fd718c736aa01f7f Mon Sep 17 00:00:00 2001 From: Joe <44273333+jslitzkerttcu@users.noreply.github.com> Date: Sun, 7 Jun 2026 09:53:00 -0500 Subject: [PATCH 1/5] test(#339): regression guard for gsd-sdk refs in runtime surfaces (#691) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(#339): add regression guard for gsd-sdk refs in runtime surfaces Lock in the already-clean runtime surface so a retired `gsd-sdk`/`GSD_SDK` reference cannot creep back into a shipped prompt or hook. Scans gsd-core/workflows, gsd-core/references, commands/gsd, agents, and hooks (excluding the gitignored dist/ build artifact). Complements gsd-tools-path-refs.test.cjs, which only catches the `gsd-sdk query` binary form; this catches any runtime reference. A second test guards against an empty sweep so a future dir rename can't silently turn the guard into a no-op. Intentionally does NOT touch bin/install.js (live stale-package-detection mechanics per #339 triage) or CI/lint scripts (legitimate stale detection). Refs #339 * test(#339): split file content on /\r?\n/ for Windows CRLF parity The windows-test-parity-guard lint requires test files that readFileSync + split to use /\r?\n/, not '\n', so CRLF files don't leave a trailing \r. New test files are not in the PR #3649 allowlist. * test(#339): cover .sh/.json runtime files in workflows surface Address #691 review: the gsd-core/workflows surface scanned only .md, silently skipping two deployed runtime files — _runtime-launcher.snippet.sh (synced into every hook) and discuss-phase/templates/checkpoint.json. Add .sh/.json so the guard covers all 290 deployed runtime files (was 288/290), not just the .md subset. * test(#339): cover templates/contexts surfaces + per-ext empty-sweep guard Address PR #691 review (trek-e): - Add gsd-core/templates and gsd-core/contexts to RUNTIME_SURFACES — both are deep-copied by the installer and runtime-loaded via @~/.claude/gsd-core/templates/*.md anchors, so a reintroduced gsd-sdk ref there would have slipped past the guard. (major) - Reword the bin/install.js exclusion rationale: it has zero gsd-sdk refs today (subsystem removed in #515, shim retired in #522); the real reason it is excluded is that it is installer code, not a deployed prompt/hook surface. (minor) - Make the empty-sweep guard assert coverage per configured extension, not per surface — .md files alone kept gsd-core/workflows green even if .sh/.json were dropped, silently un-covering _runtime-launcher.snippet.sh and discuss-phase/templates/*.json. (low) --------- Co-authored-by: Tom Boucher --- .../bug-3810-no-gsd-sdk-runtime-refs.test.cjs | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 tests/bug-3810-no-gsd-sdk-runtime-refs.test.cjs diff --git a/tests/bug-3810-no-gsd-sdk-runtime-refs.test.cjs b/tests/bug-3810-no-gsd-sdk-runtime-refs.test.cjs new file mode 100644 index 000000000..bd72b35e0 --- /dev/null +++ b/tests/bug-3810-no-gsd-sdk-runtime-refs.test.cjs @@ -0,0 +1,133 @@ +// allow-test-rule: source-text-is-the-product +// Runtime prompt/hook files are deployed verbatim — their text IS what the +// runtime loads and executes. Asserting that text carries no retired `gsd-sdk` +// reference tests the deployed contract, which no behavioral seam can observe +// (there is no runtime API that enumerates "did any shipped prompt name the +// removed SDK binary"). + +/** + * Regression guard: no `gsd-sdk` references in runtime-facing surfaces (#339). + * + * The `@opengsd/gsd-sdk` package and its `gsd-sdk` binary were retired (ADR 0174, + * #191). The bulk runtime cleanup is already done — this test locks it in so a + * `gsd-sdk` / `GSD_SDK` reference cannot creep back into a shipped prompt or hook + * and re-introduce drift between the documented surface and the supported + * `gsd-tools` binary. + * + * Scope: runtime surfaces only — the prompts and hooks the installer ships into + * a user's runtime config dir. Explicitly NOT covered here: + * - `bin/install.js` — installer code, not a runtime-deployed prompt/hook + * surface. (It carries zero `gsd-sdk` references today; the SDK-shim + * verification subsystem was removed in #515 and the shim retired in #522.) + * - `gsd-core/bin/` — executable library code, not deployed prompt text; it + * may legitimately reference the SDK retirement in comments. + * - `tests/`, `docs/`, `.changeset/`, CI/lint scripts — legitimately reference + * the SDK retirement as history or detect its stale artifacts. + * + * Complements `tests/gsd-tools-path-refs.test.cjs`, which only catches the + * `gsd-sdk query` binary-invocation form; this catches ANY runtime reference. + */ + +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const REPO_ROOT = path.join(__dirname, '..'); + +// Runtime surfaces the installer ships. Each entry is { dir, exts } — dir is +// repo-relative, exts is the set of file extensions whose text is deployed. +const RUNTIME_SURFACES = [ + // .md prompts plus the non-.md runtime artifacts this dir also ships: + // _runtime-launcher.snippet.sh (the canonical launcher synced into every hook + // by scripts/sync-runtime-launcher.cjs) and discuss-phase/templates/*.json + // (loaded at runtime by discuss-phase.md). Scanning only .md left these two + // deployed files uncovered. (#691 review) + { dir: path.join('gsd-core', 'workflows'), exts: ['.md', '.sh', '.json'] }, + { dir: path.join('gsd-core', 'references'), exts: ['.md'] }, + // Prompt surfaces the installer deep-copies and the runtime loads via + // `@~/.claude/gsd-core/templates/*.md` anchors in workflows/commands; the + // lone config.json under templates/ ships too. (#691 review) + { dir: path.join('gsd-core', 'templates'), exts: ['.md', '.json'] }, + { dir: path.join('gsd-core', 'contexts'), exts: ['.md'] }, + { dir: path.join('commands', 'gsd'), exts: ['.md'] }, + { dir: 'agents', exts: ['.md'] }, + // Hooks ship as executable text (.js/.cjs/.sh). `hooks/dist/` is a gitignored + // build artifact regenerated from these sources, so scanning the sources is + // sufficient and avoids asserting against generated copies. + { dir: 'hooks', exts: ['.js', '.cjs', '.sh'], skipDirs: ['dist'] }, +]; + +// Matches every casing/separator variant of the retired SDK token: +// gsd-sdk, gsd_sdk, GSD-SDK, GSD_SDK, etc. +const SDK_REF = /gsd[-_]sdk/i; + +/** + * Recursively collect files under `absDir` whose extension is in `exts`, + * skipping any directory name listed in `skipDirs`. + */ +function collectFiles(absDir, exts, skipDirs) { + if (!fs.existsSync(absDir)) return []; + const out = []; + for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (skipDirs.includes(entry.name)) continue; + out.push(...collectFiles(path.join(absDir, entry.name), exts, skipDirs)); + } else if (entry.isFile() && exts.includes(path.extname(entry.name))) { + out.push(path.join(absDir, entry.name)); + } + } + return out; +} + +function rel(file) { + return path.relative(REPO_ROOT, file).split(path.sep).join('/'); +} + +describe('#339 no gsd-sdk references in runtime surfaces', () => { + test('shipped prompts and hooks carry no retired gsd-sdk reference', () => { + const violations = []; + + for (const { dir, exts, skipDirs = [] } of RUNTIME_SURFACES) { + const files = collectFiles(path.join(REPO_ROOT, dir), exts, skipDirs); + for (const file of files) { + const lines = fs.readFileSync(file, 'utf-8').split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + if (SDK_REF.test(lines[i])) { + violations.push(`${rel(file)}:${i + 1}: ${lines[i].trim()}`); + } + } + } + } + + assert.strictEqual( + violations.length, + 0, + 'Runtime surfaces must not reference the retired gsd-sdk binary/package — ' + + 'use gsd-tools instead.\nViolations:\n' + violations.join('\n') + ); + }); + + test('at least one file per configured extension is scanned (guards against an empty sweep)', () => { + // A path typo, directory rename, or stale extension could silently make + // collectFiles() return [] for part of a surface, turning the guard above + // into a no-op that always passes. Checking per-surface isn't enough: for + // gsd-core/workflows the .md files alone keep a per-surface count > 0, so + // dropping .sh/.json would stop covering _runtime-launcher.snippet.sh and + // discuss-phase/templates/*.json while the test stayed green. Assert each + // configured extension actually resolves to scanned files. (#691 review) + for (const { dir, exts, skipDirs = [] } of RUNTIME_SURFACES) { + for (const ext of exts) { + const count = collectFiles(path.join(REPO_ROOT, dir), [ext], skipDirs).length; + assert.ok( + count > 0, + `Runtime surface "${dir}" resolved to 0 "${ext}" files — the path may ` + + 'have moved or the extension is stale; update RUNTIME_SURFACES so the ' + + 'guard keeps covering it.' + ); + } + } + }); +}); From 2f07443119c815f82b394fc72833a492a36cec38 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 7 Jun 2026 11:16:03 -0400 Subject: [PATCH 2/5] refactor(#60): make runtime config adapter registry explicit (#795) * 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 * chore(#60): add changeset for runtime config adapter registry Co-Authored-By: Claude Opus 4.8 * docs(#60): register Runtime Config Adapter Registry in CONTEXT.md glossary Per docs/contributor-standards.md, every new Module/seam must get a `### ` 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 --------- Co-authored-by: Claude Opus 4.8 --- .../60-runtime-config-adapter-registry.md | 7 + .gitignore | 1 + CONTEXT.md | 3 + bin/install.js | 31 +-- docs/INVENTORY-MANIFEST.json | 1 + docs/INVENTORY.md | 3 +- eslint.config.mjs | 1 + src/runtime-config-adapter-registry.cts | 109 ++++++++ .../runtime-config-adapter-registry.test.cjs | 245 ++++++++++++++++++ 9 files changed, 380 insertions(+), 21 deletions(-) create mode 100644 .changeset/60-runtime-config-adapter-registry.md create mode 100644 src/runtime-config-adapter-registry.cts create mode 100644 tests/runtime-config-adapter-registry.test.cjs 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); + }); +}); From 571d7b5a1c0ef340fe426a88d2b6bc3c1d462ddd Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 7 Jun 2026 11:46:48 -0400 Subject: [PATCH 3/5] feat(#764): skip cross-platform test matrix for docs-only and inert-CI PRs (#798) test.yml had no paths filter and the ci-test-scope classifier treated docs/ and every .github/workflows/* as code_changed, so documentation edits and product-irrelevant automation tweaks still spun up the full Linux/Windows/macOS matrix. Narrow the heavy matrix to changes that can actually affect the product or the test pipeline. - ci-test-scope.cjs: drop docs/ from code_changed (docs-only -> full skip; the required-tests fan-in still reports green). Add src/ to code_changed (it was missing -> a source-only PR previously skipped all tests). Add INERT_WORKFLOWS allowlist + isInertCi() + an "inert CI" rule, and a product_changed output that gates the heavy test/coverage jobs. Fail-safe: any workflow not on the inert allowlist defaults to the full matrix. A module-load assertion throws if a PROTECTED_WORKFLOWS entry (test/install-smoke/mutation/security-scan/release) is ever added to the inert set, so a weakening edit fails CI loudly. - test.yml: keep the static 3-lane matrix (so the H1 shell-policy linter can still statically verify the Windows lane), gate test/coverage on product_changed, add a lightweight ubuntu-only test-inert job, and branch the required-tests fan-in on product_changed. - docs-required.yml: run docs-parity-live-registry (gated on docs/ changes) so pure-docs PRs still catch live-registry drift without the matrix. - tests: cover docs-only, inert-only, src/, pipeline, unknown-workflow fail-safe, mixed escalation, the code_changed=false -> no-lanes invariant, and protected- workflow tamper-evidence. Closes #764 Co-authored-by: Claude Opus 4.8 --- .github/workflows/docs-required.yml | 15 ++ .github/workflows/test.yml | 83 +++++++-- scripts/ci-test-scope.cjs | 131 +++++++++++-- tests/ci-test-scope.test.cjs | 276 ++++++++++++++++++++++++++-- 4 files changed, 455 insertions(+), 50 deletions(-) diff --git a/.github/workflows/docs-required.yml b/.github/workflows/docs-required.yml index a6c8a213a..4961d90d5 100644 --- a/.github/workflows/docs-required.yml +++ b/.github/workflows/docs-required.yml @@ -30,3 +30,18 @@ jobs: env: GITHUB_BASE_REF: ${{ github.base_ref }} run: node scripts/lint-docs-required.cjs + + - name: Detect docs/ changes + id: docs-changed + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + if git diff --name-only "origin/${BASE_REF}...HEAD" | grep -q '^docs/'; then + echo "docs_changed=true" >> "$GITHUB_OUTPUT" + else + echo "docs_changed=false" >> "$GITHUB_OUTPUT" + fi + + - name: Docs parity — live registry check + if: steps.docs-changed.outputs.docs_changed == 'true' + run: node --test tests/docs-parity-live-registry.test.cjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 68c46eb4e..d186c6d65 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,6 +28,7 @@ jobs: outputs: code_changed: ${{ steps.scope.outputs.code_changed }} full_matrix: ${{ steps.scope.outputs.full_matrix }} + product_changed: ${{ steps.scope.outputs.product_changed }} targeted_tests: ${{ steps.scope.outputs.targeted_tests }} windows_tests: ${{ steps.scope.outputs.windows_tests }} steps: @@ -49,6 +50,7 @@ jobs: if [ "$EVENT_NAME" != "pull_request" ]; then { echo "code_changed=true" + echo "product_changed=true" echo "full_matrix=true" echo "targeted_tests=" echo "windows_tests=" @@ -117,7 +119,7 @@ jobs: test: name: test (${{ matrix.os }}, ${{ matrix.node-version }}) needs: changes - if: needs.changes.outputs.code_changed == 'true' + if: needs.changes.outputs.product_changed == 'true' runs-on: ${{ matrix.os }} timeout-minutes: 15 env: @@ -214,6 +216,47 @@ jobs: if: matrix.scope == 'full' && needs.changes.outputs.full_matrix == 'true' run: npm run test:slow + test-inert: + name: test (inert CI) + needs: changes + if: needs.changes.outputs.code_changed == 'true' && needs.changes.outputs.product_changed != 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + GSD_PLUGIN_ROOT: .ci-gsd-plugin-root-disabled + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: true + token: ${{ github.token }} + - name: Guard — require GitHub-hosted runner + run: node scripts/ci-guard-runner.cjs + - name: Rebase check — merge PR base branch into PR head + if: github.event_name == 'pull_request' + env: + GITHUB_TOKEN: ${{ github.token }} + run: node scripts/ci-rebase-check.cjs + - name: Set up Node.js 22 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 22 + cache: 'npm' + - name: Environment check + run: npm run check:env + - name: Install dependencies + run: npm ci + - name: Dependency integrity gate + run: node scripts/check-npm-integrity.cjs + - name: Prepare scoped test list + env: + TEST_SCOPE: targeted + TARGETED_TESTS: ${{ needs.changes.outputs.targeted_tests }} + WINDOWS_TESTS: ${{ needs.changes.outputs.windows_tests }} + run: node scripts/ci-prepare-test-scope.cjs + - name: Run scoped tests + run: node scripts/run-tests.cjs --files-from .ci-selected-tests.txt + test-full: name: full test (${{ matrix.os }}, ${{ matrix.node-version }}) needs: changes @@ -289,7 +332,7 @@ jobs: coverage: needs: changes - if: needs.changes.outputs.code_changed == 'true' + if: needs.changes.outputs.product_changed == 'true' runs-on: ubuntu-latest timeout-minutes: 15 env: @@ -336,6 +379,7 @@ jobs: - changes - lint-tests - test + - test-inert - test-full - coverage if: always() @@ -345,17 +389,21 @@ jobs: - name: Summarize required test gate env: CODE_CHANGED: ${{ needs.changes.outputs.code_changed }} + PRODUCT_CHANGED: ${{ needs.changes.outputs.product_changed }} CHANGES_RESULT: ${{ needs.changes.result }} LINT_RESULT: ${{ needs.lint-tests.result }} TEST_RESULT: ${{ needs.test.result }} + INERT_RESULT: ${{ needs.test-inert.result }} FULL_TEST_RESULT: ${{ needs.test-full.result }} COVERAGE_RESULT: ${{ needs.coverage.result }} run: | set -euo pipefail echo "code_changed=$CODE_CHANGED" + echo "product_changed=$PRODUCT_CHANGED" echo "changes=$CHANGES_RESULT" echo "lint-tests=$LINT_RESULT" echo "test=$TEST_RESULT" + echo "test-inert=$INERT_RESULT" echo "test-full=$FULL_TEST_RESULT" echo "coverage=$COVERAGE_RESULT" @@ -374,19 +422,24 @@ jobs: exit 0 fi - if [ "$TEST_RESULT" != "success" ]; then - echo "::error::test matrix did not pass" - exit 1 - fi - - if [ "$FULL_TEST_RESULT" != "success" ] && [ "$FULL_TEST_RESULT" != "skipped" ]; then - echo "::error::full parity matrix did not pass" - exit 1 - fi - - if [ "$COVERAGE_RESULT" != "success" ]; then - echo "::error::coverage did not pass" - exit 1 + if [ "$PRODUCT_CHANGED" = "true" ]; then + if [ "$TEST_RESULT" != "success" ]; then + echo "::error::test matrix did not pass" + exit 1 + fi + if [ "$FULL_TEST_RESULT" != "success" ] && [ "$FULL_TEST_RESULT" != "skipped" ]; then + echo "::error::full parity matrix did not pass" + exit 1 + fi + if [ "$COVERAGE_RESULT" != "success" ]; then + echo "::error::coverage did not pass" + exit 1 + fi + else + if [ "$INERT_RESULT" != "success" ]; then + echo "::error::inert CI lane did not pass" + exit 1 + fi fi echo "Required test gate passed." diff --git a/scripts/ci-test-scope.cjs b/scripts/ci-test-scope.cjs index 977c9d5c4..1028377a4 100644 --- a/scripts/ci-test-scope.cjs +++ b/scripts/ci-test-scope.cjs @@ -6,16 +6,84 @@ const { existsSync, readdirSync, appendFileSync } = require('fs'); const { ExitError, runMain } = require('./lib/cli-exit.cjs'); +// Workflow files that are purely administrative / policy bots. Changes to these +// files do NOT require the cross-platform test matrix — only a lightweight +// ubuntu lane running workflow-lint tests is needed. +// FAIL-SAFE: any .github/workflows/*.yml NOT listed here is treated as a +// pipeline workflow and gets the full matrix. New workflow files default to full. +const INERT_WORKFLOWS = new Set([ + 'stale.yml', + 'branch-cleanup.yml', + 'branch-naming.yml', + 'auto-label-issues.yml', + 'auto-branch.yml', + 'auto-backmerge.yml', + 'close-draft-prs.yml', + 'dismiss-unauthorized-pr-approvals.yml', + 'pr-gate.yml', + 'pr-target-validator.yml', + 'pr-template-format.yml', + 'require-issue-link.yml', + 'changeset-required.yml', + 'docs-required.yml', + 'discord-changelog.yml', +]); + +// Workflows that gate merges, ship the product, or run security/cross-platform +// suites — these must ALWAYS get the full pipeline treatment and can never be +// added to INERT_WORKFLOWS. A module-load assertion enforces this so a mistaken +// or malicious addition fails CI loudly in the `changes` job on every PR. +const PROTECTED_WORKFLOWS = new Set([ + 'test.yml', + 'install-smoke.yml', + 'mutation.yml', + 'security-scan.yml', + 'release.yml', +]); +for (const wf of PROTECTED_WORKFLOWS) { + if (INERT_WORKFLOWS.has(wf)) { + throw new Error(`ci-test-scope: protected workflow "${wf}" must not be in INERT_WORKFLOWS (it requires the full test matrix).`); + } +} + +/** + * Returns true if the path is an inert (non-pipeline) workflow file. + * Only `.github/workflows/` where is in INERT_WORKFLOWS qualifies. + */ +function isInertCi(filePath) { + if (!filePath.startsWith('.github/workflows/')) return false; + const name = filePath.slice('.github/workflows/'.length); + // Must be a direct child (no further slashes) and in the allowlist. + return !name.includes('/') && INERT_WORKFLOWS.has(name); +} + +// Tests shared by both the 'workflow automation' and 'inert CI' rules. +const WORKFLOW_LINT_TESTS = [ + 'tests/workflow-shell-pinning.test.cjs', + 'tests/pr-template-policy.test.cjs', + 'tests/lint-pr-check-project-dir.test.cjs', +]; + const RULES = [ { name: 'workflow automation', - match: path => path.startsWith('.github/workflows/') || path.startsWith('.github/rulesets/'), + // Only NON-inert .github/workflows/* and all .github/rulesets/* trigger full matrix. + // FAIL-SAFE: any .github/workflows/*.yml not in INERT_WORKFLOWS is treated as pipeline. + match: filePath => (filePath.startsWith('.github/workflows/') && !isInertCi(filePath)) || + filePath.startsWith('.github/rulesets/'), fullMatrix: true, tests: [ - 'tests/workflow-shell-pinning.test.cjs', + ...WORKFLOW_LINT_TESTS, 'tests/release-tarball-smoke-workflow.test.cjs', - 'tests/lint-pr-check-project-dir.test.cjs', - 'tests/pr-template-policy.test.cjs', + ], + }, + { + name: 'inert CI', + match: filePath => isInertCi(filePath), + fullMatrix: false, + tests: [ + ...WORKFLOW_LINT_TESTS, + 'tests/policy-lint-shallow-checkout.test.cjs', ], }, { @@ -144,14 +212,6 @@ const RULES = [ 'tests/docs-parity-live-registry.test.cjs', ], }, - { - name: 'docs content', - match: path => path.startsWith('docs/'), - fullMatrix: false, - tests: [ - 'tests/docs-parity-live-registry.test.cjs', - ], - }, { name: 'configuration', match: path => ['config', 'configuration', 'model-catalog', 'model-profile'].some(k => path.includes(k)), @@ -251,16 +311,30 @@ function classify(files) { const targeted = new Set(); const windows = new Set(); const reasons = []; - let codeChanged = false; + let productOrPipelineChanged = false; // product/pipeline code (excludes docs) + let inertCiChanged = false; // inert workflow files let fullMatrix = false; for (const file of files) { - if (['bin/', 'gsd-core/', 'agents/', 'commands/', 'docs/', 'hooks/', 'tests/', 'scripts/'].some(p => file.startsWith(p)) || + // Determine if this file is product/pipeline code. + // docs/ and root-level .md files are intentionally excluded. + if ( + ['bin/', 'src/', 'gsd-core/', 'agents/', 'commands/', 'hooks/', 'tests/', 'scripts/'].some(p => file.startsWith(p)) || file === 'package.json' || file === 'package-lock.json' || (file.startsWith('tsconfig') && file.endsWith('.json')) || - file.startsWith('.github/workflows/') || - file.startsWith('.github/rulesets/')) { - codeChanged = true; + file.startsWith('.github/rulesets/') + ) { + productOrPipelineChanged = true; + } + + // Non-inert .github/workflows/* are pipeline code → full matrix. + if (file.startsWith('.github/workflows/') && !isInertCi(file)) { + productOrPipelineChanged = true; + } + + // Inert workflow files set a lightweight signal. + if (isInertCi(file)) { + inertCiChanged = true; } if (file.startsWith('tests/') && file.endsWith('.test.cjs')) { @@ -280,6 +354,10 @@ function classify(files) { } } + // code_changed: true when product/pipeline OR inert CI changed. + // Docs-only PRs (neither flag set) get code_changed=false → full matrix skip. + const codeChanged = productOrPipelineChanged || inertCiChanged; + const targetedTests = existingTests([...targeted].sort()); // When code changed but no rule matched any changed file, fall back to the @@ -290,8 +368,25 @@ function classify(files) { const windowsTests = existingTests([...new Set([...windows, ...targetedTests.filter(isWindowsHint)])].sort()); + // Inert-CI-only: full_matrix must be false (override any RULES that fired). + if (inertCiChanged && !productOrPipelineChanged) { + fullMatrix = false; + } + + // Normalize: when code_changed is false, the output must be self-consistent. + // A docs file can coincidentally match a coarse content RULE (e.g. docs/installer-migrations.md + // matches the installer rule via path.includes('install')), leaving full_matrix=true and + // non-empty targeted_tests/windows_tests. The workflow skips correctly (gated on code_changed) + // but the output object would be self-contradictory. Force a clean "nothing to run" result. + if (!codeChanged) { + fullMatrix = false; + targetedTests.length = 0; + windowsTests.length = 0; + } + return { code_changed: codeChanged, + product_changed: productOrPipelineChanged, full_matrix: fullMatrix, targeted_tests: targetedTests, windows_tests: windowsTests, @@ -303,6 +398,7 @@ function writeOutputs(result) { if (!process.env.GITHUB_OUTPUT) return; const lines = [ `code_changed=${result.code_changed}`, + `product_changed=${result.product_changed}`, `full_matrix=${result.full_matrix}`, `targeted_tests=${result.targeted_tests.join(' ')}`, `windows_tests=${result.windows_tests.join(' ')}`, @@ -313,6 +409,7 @@ function writeOutputs(result) { function main() { try { const args = parseArgs(process.argv.slice(2)); + const files = changedFiles(args); const result = classify(files); result.changed_files = files; diff --git a/tests/ci-test-scope.test.cjs b/tests/ci-test-scope.test.cjs index 2ce8d4f1d..a242cee4e 100644 --- a/tests/ci-test-scope.test.cjs +++ b/tests/ci-test-scope.test.cjs @@ -4,9 +4,11 @@ const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const { spawnSync } = require('child_process'); const path = require('path'); +const fs = require('fs'); const ROOT = path.join(__dirname, '..'); const SCRIPT = path.join(ROOT, 'scripts', 'ci-test-scope.cjs'); +const WORKFLOWS_DIR = path.join(ROOT, '.github', 'workflows'); function scopeFor(files) { const r = spawnSync(process.execPath, [SCRIPT, '--files', files.join(' ')], { @@ -18,25 +20,124 @@ function scopeFor(files) { } describe('ci-test-scope.cjs', () => { - test('docs-only changes mark code_changed and select docs-parity (new correct contract)', () => { + test('docs-only changes: code_changed is false, product_changed false (skip matrix entirely)', () => { const result = scopeFor(['docs/usage.md']); - assert.strictEqual(result.code_changed, true); + assert.strictEqual(result.code_changed, false, + `expected code_changed=false for docs-only change, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.product_changed, false, + `expected product_changed=false for docs-only change, got: ${JSON.stringify(result)}`); assert.strictEqual(result.full_matrix, false); + // docs-parity is NOT in targeted_tests when docs-only (it runs via docs-required.yml instead) assert.ok( - result.targeted_tests.some(t => t.includes('docs-parity-live-registry')), - `expected docs-parity-live-registry in targeted_tests, got: ${JSON.stringify(result.targeted_tests)}`, + !result.targeted_tests.some(t => t.includes('docs-parity-live-registry')), + `docs-parity-live-registry must NOT be in targeted_tests for docs-only, got: ${JSON.stringify(result.targeted_tests)}`, ); }); - test('workflow changes request full matrix and workflow contract tests', () => { + test('root markdown only: code_changed is false, product_changed false', () => { + const result = scopeFor(['README.md']); + assert.strictEqual(result.code_changed, false, + `expected code_changed=false for root markdown, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.product_changed, false, + `expected product_changed=false for root markdown, got: ${JSON.stringify(result)}`); + }); + + test('pipeline workflow (test.yml) — product_changed true, full_matrix true, workflow contract tests', () => { const result = scopeFor(['.github/workflows/test.yml']); assert.strictEqual(result.code_changed, true); + assert.strictEqual(result.product_changed, true, + `expected product_changed=true for test.yml, got: ${JSON.stringify(result)}`); assert.strictEqual(result.full_matrix, true); assert.ok(result.targeted_tests.includes('tests/workflow-shell-pinning.test.cjs')); assert.ok(result.targeted_tests.includes('tests/release-tarball-smoke-workflow.test.cjs')); assert.ok(result.windows_tests.includes('tests/workflow-shell-pinning.test.cjs')); }); + test('pipeline workflow (install-smoke.yml) — product_changed true, full_matrix true', () => { + const result = scopeFor(['.github/workflows/install-smoke.yml']); + assert.strictEqual(result.code_changed, true); + assert.strictEqual(result.product_changed, true, + `expected product_changed=true for install-smoke.yml, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.full_matrix, true); + }); + + test('inert CI only (stale.yml) — code_changed true, product_changed false, full_matrix false', () => { + const result = scopeFor(['.github/workflows/stale.yml']); + assert.strictEqual(result.code_changed, true, + `expected code_changed=true for inert CI, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.product_changed, false, + `expected product_changed=false for inert CI, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.full_matrix, false, + `expected full_matrix=false for inert CI, got: ${JSON.stringify(result)}`); + assert.ok(result.targeted_tests.includes('tests/workflow-shell-pinning.test.cjs'), + `expected workflow-shell-pinning in targeted_tests, got: ${JSON.stringify(result.targeted_tests)}`); + assert.ok(result.targeted_tests.includes('tests/policy-lint-shallow-checkout.test.cjs'), + `expected policy-lint-shallow-checkout in targeted_tests for inert CI, got: ${JSON.stringify(result.targeted_tests)}`); + }); + + test('TS runtime sources (src/semver.cts) — code_changed true, product_changed true, full_matrix false, semver tests targeted', () => { + const result = scopeFor(['src/semver.cts']); + assert.strictEqual(result.code_changed, true, + `expected code_changed=true for src/ change, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.product_changed, true, + `expected product_changed=true for src/ change, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.full_matrix, false, + `expected full_matrix=false for src/-only change (TS runtime sources rule has no fullMatrix), got: ${JSON.stringify(result)}`); + assert.ok(result.targeted_tests.includes('tests/semver-compare.test.cjs'), + `expected semver-compare in targeted_tests, got: ${JSON.stringify(result.targeted_tests)}`); + }); + + test('product code (gsd-core/bin/lib/foo.cjs) — product_changed true', () => { + const result = scopeFor(['gsd-core/bin/lib/foo.cjs']); + assert.strictEqual(result.code_changed, true); + assert.strictEqual(result.product_changed, true, + `expected product_changed=true for gsd-core/ change, got: ${JSON.stringify(result)}`); + }); + + test('unknown/new workflow defaults to pipeline (fail-safe) — product_changed true', () => { + const result = scopeFor(['.github/workflows/brand-new-thing.yml']); + assert.strictEqual(result.code_changed, true); + assert.strictEqual(result.product_changed, true, + `expected product_changed=true for unknown workflow (fail-safe), got: ${JSON.stringify(result)}`); + assert.strictEqual(result.full_matrix, true, + `expected full_matrix=true for unknown workflow (fail-safe), got: ${JSON.stringify(result)}`); + }); + + test('mixed docs + code — escalates to product_changed true', () => { + // Use bin/gsd (installer rule, fullMatrix:true) to get a code file that reliably triggers full matrix. + const result = scopeFor(['docs/x.md', 'bin/gsd']); + assert.strictEqual(result.code_changed, true); + assert.strictEqual(result.product_changed, true, + `expected product_changed=true for docs+code, got: ${JSON.stringify(result)}`); + }); + + test('inert CI (docs-required.yml) — includes shallow-checkout policy test, product_changed false', () => { + const result = scopeFor(['.github/workflows/docs-required.yml']); + assert.strictEqual(result.code_changed, true, + `expected code_changed=true for docs-required.yml, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.product_changed, false, + `expected product_changed=false for docs-required.yml, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.full_matrix, false, + `expected full_matrix=false for docs-required.yml, got: ${JSON.stringify(result)}`); + assert.ok(result.targeted_tests.includes('tests/policy-lint-shallow-checkout.test.cjs'), + `expected policy-lint-shallow-checkout in targeted_tests for docs-required.yml, got: ${JSON.stringify(result.targeted_tests)}`); + }); + + test('mixed docs + inert CI — code_changed true, product_changed false (inert lane)', () => { + const result = scopeFor(['docs/x.md', '.github/workflows/stale.yml']); + assert.strictEqual(result.code_changed, true); + assert.strictEqual(result.product_changed, false, + `expected product_changed=false for docs+inert, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.full_matrix, false); + }); + + test('mixed docs + src — product_changed true', () => { + const result = scopeFor(['docs/x.md', 'src/semver.cts']); + assert.strictEqual(result.code_changed, true); + assert.strictEqual(result.product_changed, true, + `expected product_changed=true for docs+src, got: ${JSON.stringify(result)}`); + }); + test('command changes request command tests without full parity matrix', () => { const result = scopeFor(['commands/gsd/plan-phase.md']); assert.strictEqual(result.code_changed, true); @@ -54,6 +155,8 @@ describe('ci-test-scope.cjs', () => { test('installer-sensitive changes request full matrix and install tests', () => { const result = scopeFor(['bin/gsd']); assert.strictEqual(result.code_changed, true); + assert.strictEqual(result.product_changed, true, + `expected product_changed=true for bin/gsd, got: ${JSON.stringify(result)}`); assert.strictEqual(result.full_matrix, true); assert.ok(result.targeted_tests.includes('tests/install.test.cjs')); assert.ok(result.targeted_tests.includes('tests/release-tarball-smoke.install.test.cjs')); @@ -120,25 +223,27 @@ describe('ci-test-scope superset invariant (#494)', () => { `expected full_matrix=true for tests/** change, got: ${JSON.stringify(result)}`); }); - // Facet B: docs/**, commands/**, agents/** → code_changed AND docs-parity selected - test('B1: docs/adr change marks code_changed and selects docs-parity-live-registry', () => { + // Facet B: commands/**, agents/** → code_changed AND docs-parity selected + // docs/ is NO LONGER in this facet — docs-only PRs skip the matrix entirely. + test('B1: docs/adr change: code_changed is false (docs skip matrix)', () => { const result = scopeFor(['docs/adr/22-plan-drift-guard.md']); - assert.strictEqual(result.code_changed, true, - `expected code_changed=true for docs/** change, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.code_changed, false, + `expected code_changed=false for docs/** change (matrix skip), got: ${JSON.stringify(result)}`); + assert.strictEqual(result.product_changed, false, + `expected product_changed=false for docs/** change, got: ${JSON.stringify(result)}`); + // docs-parity is NOT in targeted_tests (handled by docs-required.yml) assert.ok( - result.targeted_tests.some(t => t.includes('docs-parity-live-registry')), - `expected docs-parity-live-registry in targeted_tests, got: ${JSON.stringify(result.targeted_tests)}`, + !result.targeted_tests.some(t => t.includes('docs-parity-live-registry')), + `docs-parity-live-registry must NOT be in targeted_tests for docs-only, got: ${JSON.stringify(result.targeted_tests)}`, ); }); - test('B2: docs locale dir change marks code_changed and selects docs-parity-live-registry', () => { + test('B2: docs locale dir change: code_changed is false (docs skip matrix)', () => { const result = scopeFor(['docs/ja-JP/USAGE.md']); - assert.strictEqual(result.code_changed, true, - `expected code_changed=true for docs/ja-JP/** change, got: ${JSON.stringify(result)}`); - assert.ok( - result.targeted_tests.some(t => t.includes('docs-parity-live-registry')), - `expected docs-parity-live-registry in targeted_tests, got: ${JSON.stringify(result.targeted_tests)}`, - ); + assert.strictEqual(result.code_changed, false, + `expected code_changed=false for docs/ja-JP/** change, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.product_changed, false, + `expected product_changed=false for docs/ja-JP/** change, got: ${JSON.stringify(result)}`); }); test('B3: commands/** change selects docs-parity-live-registry', () => { @@ -149,3 +254,138 @@ describe('ci-test-scope superset invariant (#494)', () => { ); }); }); + +describe('INERT_WORKFLOWS allowlist integrity guard', () => { + // Load the INERT_WORKFLOWS set from the script by spawning it and using --files + // on a sentinel path, then separately verify the set contents via the filesystem. + + // Known pipeline workflows that MUST NOT appear in INERT_WORKFLOWS. + // Must stay in sync with PROTECTED_WORKFLOWS in scripts/ci-test-scope.cjs. + const KNOWN_PIPELINE = [ + 'test.yml', + 'install-smoke.yml', + 'mutation.yml', + 'security-scan.yml', + 'release.yml', + ]; + + // Canonical inert workflow list — reused by both tests below. + const knownInert = [ + 'stale.yml', 'branch-cleanup.yml', 'branch-naming.yml', 'auto-label-issues.yml', + 'auto-branch.yml', 'auto-backmerge.yml', 'close-draft-prs.yml', + 'dismiss-unauthorized-pr-approvals.yml', 'pr-gate.yml', 'pr-target-validator.yml', + 'pr-template-format.yml', 'require-issue-link.yml', 'changeset-required.yml', + 'docs-required.yml', 'discord-changelog.yml', + ]; + + test('all entries in INERT_WORKFLOWS exist under .github/workflows/', () => { + // We derive the inert set implicitly: any .github/workflows/*.yml that produces + // full_matrix=false when passed alone is inert. We check the known inert names + // against the filesystem instead. + // The canonical list is in the script — we verify each named file exists. + for (const name of knownInert) { + const fullPath = path.join(WORKFLOWS_DIR, name); + assert.ok( + fs.existsSync(fullPath), + `INERT_WORKFLOWS entry '${name}' does not exist at ${fullPath}`, + ); + } + }); + + test('known pipeline workflows are NOT treated as inert (product_changed true, full_matrix true)', () => { + for (const name of KNOWN_PIPELINE) { + const result = scopeFor([`.github/workflows/${name}`]); + assert.strictEqual(result.product_changed, true, + `${name} must be pipeline (product_changed=true), got: ${JSON.stringify(result)}`); + assert.strictEqual(result.full_matrix, true, + `${name} must be pipeline (full_matrix=true), got: ${JSON.stringify(result)}`); + } + }); + + // Explicit per-workflow guard: each of the five protected workflows must route to + // the full matrix. This documents intent and proves that PROTECTED_WORKFLOWS + // enforcement is covered end-to-end via the spawn helper. + test('all five PROTECTED_WORKFLOWS individually route to full matrix (tamper-evidence)', () => { + const protected_ = [ + 'test.yml', + 'install-smoke.yml', + 'mutation.yml', + 'security-scan.yml', + 'release.yml', + ]; + for (const name of protected_) { + const result = scopeFor([`.github/workflows/${name}`]); + assert.strictEqual(result.product_changed, true, + `PROTECTED_WORKFLOW ${name}: expected product_changed=true, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.full_matrix, true, + `PROTECTED_WORKFLOW ${name}: expected full_matrix=true, got: ${JSON.stringify(result)}`); + } + }); + + test('every inert workflow produces code_changed=true, product_changed=false, and full_matrix=false', () => { + for (const name of knownInert) { + const result = scopeFor([`.github/workflows/${name}`]); + assert.strictEqual(result.code_changed, true, + `${name}: expected code_changed=true`); + assert.strictEqual(result.product_changed, false, + `${name}: expected product_changed=false`); + assert.strictEqual(result.full_matrix, false, + `${name}: expected full_matrix=false`); + } + }); +}); + +describe('code_changed=false implies clean output invariant', () => { + // Fix 1: when code_changed is false, full_matrix, targeted_tests, windows_tests + // must ALL be empty/false — even if a docs path coincidentally + // matches a content rule via coarse substring (e.g. path.includes('install') or + // path.includes('config')). + + test('docs-only: code_changed=false → product_changed=false, full_matrix=false, empty targeted_tests', () => { + const result = scopeFor(['docs/usage.md']); + assert.strictEqual(result.code_changed, false); + assert.strictEqual(result.product_changed, false); + assert.strictEqual(result.full_matrix, false); + assert.deepStrictEqual(result.targeted_tests, []); + }); + + // docs/installer-migrations.md contains 'install' → would match the installer rule + // via path.includes('install'). Normalization must suppress the contradictory output. + test('docs/installer-migrations.md: code_changed=false AND product_changed=false AND full_matrix=false AND empty targeted_tests', () => { + const result = scopeFor(['docs/installer-migrations.md']); + assert.strictEqual(result.code_changed, false, + `expected code_changed=false for docs/installer-migrations.md, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.product_changed, false, + `expected product_changed=false for docs/installer-migrations.md, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.full_matrix, false, + `expected full_matrix=false for docs/installer-migrations.md, got: ${JSON.stringify(result)}`); + assert.deepStrictEqual(result.targeted_tests, [], + `expected empty targeted_tests for docs/installer-migrations.md, got: ${JSON.stringify(result.targeted_tests)}`); + }); + + // docs/how-to/configure-model-profiles.md contains 'config' → matches configuration rule. + test('docs path matching config rule: code_changed=false → empty output (coarse-substring docs suppressed)', () => { + const result = scopeFor(['docs/how-to/configure-model-profiles.md']); + assert.strictEqual(result.code_changed, false, + `expected code_changed=false, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.product_changed, false); + assert.strictEqual(result.full_matrix, false); + assert.deepStrictEqual(result.targeted_tests, []); + }); + + // code_changed=true must produce >= 1 targeted_test or 'unit' fallback. + test('code_changed=true implies non-empty targeted_tests', () => { + for (const files of [ + ['src/semver.cts'], + ['bin/gsd'], + ['.github/workflows/test.yml'], + ['.github/workflows/stale.yml'], + ]) { + const result = scopeFor(files); + assert.strictEqual(result.code_changed, true, + `expected code_changed=true for ${files}, got: ${JSON.stringify(result)}`); + assert.ok(result.targeted_tests.length >= 1, + `expected >= 1 targeted_test for ${files}, got: ${JSON.stringify(result.targeted_tests)}`); + } + }); +}); From f66c4a082c7d4f5c61760b13647912f02c79978c Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 7 Jun 2026 12:02:39 -0400 Subject: [PATCH 4/5] feat(#766): distribute gsd-core as a native Claude Code plugin (#797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#766): distribute gsd-core as a native Claude Code plugin Add an additive .claude-plugin/plugin.json manifest plus hooks/hooks.json so gsd-core can be installed as a first-class Claude Code plugin (marketplace or zero-friction @skills-dir), with /gsd-core: namespaced commands and lifecycle management — alongside the unchanged npm/file-copy installer. - .claude-plugin/plugin.json: validated with 'claude plugin validate --strict' - hooks/hooks.json: mirrors the installer's always-on Claude hook wiring via ${CLAUDE_PLUGIN_ROOT} - package.json: ship .claude-plugin in the npm tarball - tests/issue-766-plugin-manifest.test.cjs: manifest + always-on-hook-contract drift guards - docs: install-on-your-runtime.md + FEATURES.md Closes #766 Co-Authored-By: Claude Opus 4.8 * docs(#766): add ADR-766 + glossary entry for Claude Code Plugin Manifest Module Record the plugin manifest as the Seam projecting gsd-core's artifact surfaces onto the Claude Code plugin contract (sibling of the Runtime Artifact Layout Module, ADR-3660), with the defined kind->field mapping and the always-on hook projection rule. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../766-native-claude-plugin-manifest.md | 5 + .claude-plugin/plugin.json | 16 + CONTEXT.md | 3 + docs/FEATURES.md | 2 + .../766-claude-code-plugin-manifest-module.md | 70 ++++ docs/adr/README.md | 1 + docs/how-to/install-on-your-runtime.md | 44 +++ hooks/hooks.json | 40 ++ package.json | 1 + tests/issue-766-plugin-manifest.test.cjs | 373 ++++++++++++++++++ 10 files changed, 555 insertions(+) create mode 100644 .changeset/766-native-claude-plugin-manifest.md create mode 100644 .claude-plugin/plugin.json create mode 100644 docs/adr/766-claude-code-plugin-manifest-module.md create mode 100644 hooks/hooks.json create mode 100644 tests/issue-766-plugin-manifest.test.cjs diff --git a/.changeset/766-native-claude-plugin-manifest.md b/.changeset/766-native-claude-plugin-manifest.md new file mode 100644 index 000000000..9e4cdf9e7 --- /dev/null +++ b/.changeset/766-native-claude-plugin-manifest.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 797 +--- +**gsd-core can now be installed as a native Claude Code plugin** — a new `.claude-plugin/plugin.json` manifest enables installing gsd-core via `claude plugin install` or the zero-friction `~/.claude/skills/` auto-load path (`gsd-core@skills-dir`), with slash commands auto-namespaced as `/gsd-core:` (e.g. `/gsd-core:plan-phase`) and lifecycle management via `claude plugin enable|disable|update`. gsd-core's always-on guard and update hooks are wired for the plugin path through `hooks/hooks.json` using `${CLAUDE_PLUGIN_ROOT}`. This is additive — the existing npm / file-copy installer is unchanged. diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 000000000..68df04eb6 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,16 @@ +{ + "name": "gsd-core", + "displayName": "GSD Core", + "version": "1.3.1-dev.0", + "description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.", + "author": { + "name": "open-gsd", + "url": "https://github.com/open-gsd" + }, + "homepage": "https://github.com/open-gsd/gsd-core", + "repository": "https://github.com/open-gsd/gsd-core", + "license": "MIT", + "keywords": ["spec-driven-development", "planning", "workflow", "context-engineering", "claude-code", "gsd"], + "commands": "./commands/gsd/", + "hooks": "./hooks/hooks.json" +} diff --git a/CONTEXT.md b/CONTEXT.md index 30acfb6c9..449508464 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -124,6 +124,9 @@ Projects a pure, typed install plan for a given runtime by composing artifact pl ### 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. +### Claude Code Plugin Manifest Module +Module owning the projection of gsd-core's artifact surfaces (`commands`, `agents`, hooks) onto the Claude Code plugin contract (`.claude-plugin/plugin.json` + `hooks/hooks.json`) — the plugin-contract sibling of the Runtime Artifact Layout Module (which projects the same surfaces onto filesystem placements). Defined mapping: `name`=`binName` (drives the `/gsd-core:` command namespace), `repository`/`homepage`=`repoUrl` (Package Identity Module), `version`/`description`/`license` from `package.json` (`version` is required for `claude plugin validate --strict`), `commands`=`./commands/gsd/`, agents via Claude Code's default `agents/` discovery (the explicit string form is schema-rejected), `hooks`=`./hooks/hooks.json`. The hook projection carries ONLY the always-on subset of the Installer Module's Claude `settings.json` wiring (check-update, context-monitor, prompt-guard, read-guard, worktree-path-guard, read-injection-scanner) via `${CLAUDE_PLUGIN_ROOT}`; config-gated opt-in hooks are excluded because a static manifest cannot honor per-project config gates, and plugin-shipped agents cannot carry hook frontmatter (so all plugin-path hook wiring lives in hooks.json). Additive — the file-copy path (Runtime Artifact Layout / Install Policy / Installer Modules) is unchanged. Conformance is validated by `claude plugin validate --strict` plus the in-repo drift-guard `tests/issue-766-plugin-manifest.test.cjs`. _Avoid_: "the plugin API", "the plugin file" (when you mean the seam). See ADR-766 and Runtime Artifact Layout 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`. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index e6e5a6db9..f879a60bd 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -1020,6 +1020,8 @@ fix(03-01): correct auth token expiry | Hook events | `PostToolUse` | N/A | `AfterTool` | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | | Config | `settings.json` | `opencode.json(c)` | `settings.json` | `kilo.json(c)` | TOML | Instructions | Config | Config | `.clinerules` | Config | Config | Config | +**Claude Code native plugin distribution:** GSD Core ships a `.claude-plugin/plugin.json` manifest, enabling installation and lifecycle management via `claude plugin install|enable|disable|update gsd-core`. Commands load under the `/gsd-core:` namespace (e.g. `/gsd-core:plan-phase`), avoiding slash-command collisions with the classic npm installer which uses `/gsd:`. Always-on guard and update hooks are wired automatically via `hooks/hooks.json`. The plugin path is additive — the npm installer (`npx @opengsd/gsd-core`) remains fully supported. + --- ### 37. Hook System diff --git a/docs/adr/766-claude-code-plugin-manifest-module.md b/docs/adr/766-claude-code-plugin-manifest-module.md new file mode 100644 index 000000000..c150f8d03 --- /dev/null +++ b/docs/adr/766-claude-code-plugin-manifest-module.md @@ -0,0 +1,70 @@ +# Claude Code Plugin Manifest Module owns the projection of gsd-core surfaces onto the Claude Code plugin contract + +- **Status:** Accepted +- **Date:** 2026-06-07 +- **Issue:** #766 +- **Implementation:** PR #797 + +## Context + +gsd-core has, until now, reached Claude Code through exactly one Adapter: the file-copy installer. The **Runtime Artifact Layout Module** (ADR-3660) projects gsd-core's artifact surfaces (`commands`, `agents`, `skills`) onto per-runtime filesystem placements, and the **Runtime Install Policy Module** (ADR-58) composes those placements with command text and config intentions into a typed install plan that adapters write to `~/.claude/` / `.claude/`. + +Claude Code now exposes a second, first-class way to receive the same surfaces: the **plugin contract** — a `.claude-plugin/plugin.json` manifest plus a `hooks/hooks.json`, consumed either by a marketplace install or by the zero-friction `@skills-dir` path. This contract is an *external interface owned by Claude Code*, not by gsd-core: it has its own schema, its own namespacing rules (`/:`), its own validation tool (`claude plugin validate`), and its own constraints (notably: plugin-shipped agents may not carry `hooks` / `permissionMode` / `mcpServers` frontmatter — Claude Code silently ignores them). + +Before this ADR, the only record of how gsd-core maps onto that external contract was the manifest files themselves. A hand-authored config file with no named Seam invites drift: the manifest's hook wiring silently diverges from what the Installer Module wires into `settings.json`; the identity fields drift from the Package Identity Module; and a future maintainer has no single place that says *which gsd-core surface maps to which manifest field, and why*. The plugin contract is exactly the kind of external interface that earns a defined, typed mapping rather than an ad-hoc file — the same reasoning that gave the file-copy path the Runtime Artifact Layout Module. + +This is the structural signal the architecture review looks for: **two Adapters at one Seam.** The file-copy layout and the plugin manifest are two projections of *the same* gsd-core artifact surfaces onto two different distribution contracts. That makes the distribution Seam real, and the plugin-side projection deserves a name. + +## Decision + +Introduce the **Claude Code Plugin Manifest Module** as the Seam that owns the projection of gsd-core's artifact surfaces onto the Claude Code plugin contract. It is the plugin-contract sibling of the Runtime Artifact Layout Module: where that Module projects surfaces onto filesystem placements, this Module projects the same surfaces onto `.claude-plugin/plugin.json` + `hooks/hooks.json`. + +The mapping is **defined, not incidental**: + +| gsd-core surface / source | Claude Code plugin field | Rule / invariant | +|---|---|---| +| Package Identity Module `binName` | `name` | `gsd-core` — drives the `/gsd-core:` command namespace; must be kebab-case (no colon/space/uppercase). | +| Package Identity Module `repoUrl` | `repository`, `homepage` | derived, never re-typed. | +| `package.json` `version` / `description` / `license` | `version` / `description` / `license` | `version` is **required** for `claude plugin validate --strict` (a missing version is a strict failure), so it is synced to `package.json` and held by a drift-guard test. | +| Command surface (`commands/gsd/*.md`) | `commands: "./commands/gsd/"` | exposed as `/gsd-core:`; namespacing replaces the file-copy path's `/gsd:` (an additive UX change, not a data-format break). | +| Agent surface (`agents/*.md`) | *(omitted — default `agents/` discovery)* | the explicit `agents: ` form is rejected by the plugin schema; relying on Claude Code's default `agents/` discovery loads them and stays self-maintaining. Agents are already plugin-safe — their `hooks`/`permissionMode` frontmatter is inert. | +| Always-on hook policy (subset of the Installer Module's `settings.json` wiring) | `hooks: "./hooks/hooks.json"` | see below. | + +The hook projection is the load-bearing part of this Module, because of the external constraint: a plugin's agents cannot carry hook frontmatter, so **all plugin-path hook wiring must live in `hooks/hooks.json`**. The Module projects *only the always-on subset* of the Installer Module's Claude hook wiring — `gsd-check-update` (SessionStart), `gsd-context-monitor` (PostToolUse), and the security guards `gsd-prompt-guard` / `gsd-read-guard` / `gsd-worktree-path-guard` / `gsd-read-injection-scanner` — preserving each event, matcher, and timeout. The installer's **config-gated opt-in** hooks (workflow-guard, validate-commit, graphify-update, session-state, phase-boundary, update-banner) are deliberately excluded: a static manifest cannot read a project's `.planning/config.json` to honor those gates, so projecting them would run them unconditionally — a behavior change the Module must not introduce. Hook commands reference bundled scripts through Claude Code's `${CLAUDE_PLUGIN_ROOT}` variable. + +The interface of this Module is therefore a **conformance contract**, validated two ways: `claude plugin validate --strict` (the external tool's view) and an in-repo drift-guard test (`tests/issue-766-plugin-manifest.test.cjs`) that locks the identity mapping, the version sync, the always-on hook contract, and the absence of opt-in hooks. Manifest component paths are resolved relative to the **plugin root** (the directory containing `.claude-plugin/`), which is the repository root. + +This is **additive**. The file-copy path — Runtime Artifact Layout Module, Runtime Install Policy Module, Installer Module — is unchanged. The plugin manifest is a parallel Adapter, the fallback for users on older Claude Code versions that predate the plugin contract. + +## What stays OUTSIDE this Module + +To keep the Seam honest about where the plugin contract ends: + +- **Runtime execution.** The Module projects the command/agent/hook *surface* and lifecycle metadata. It does not make gsd commands self-contained: their backing logic still resolves the gsd runtime CLI (`gsd-tools`) and `node` on `PATH`. The plugin delivers discoverability and lifecycle (`claude plugin enable|disable|update`); it does not replace the runtime. +- **The file-copy install.** Filesystem placement, `settings.json` merge semantics, and per-runtime config rendering remain owned by the Runtime Artifact Layout / Install Policy / Installer Modules. +- **Marketplace listing.** Publishing gsd-core to a marketplace registry is an external, out-of-repo act. +- **Manifest emission by the installer.** Having `bin/install.js` drop the manifest in-place for the npm `@skills-dir` path is a follow-up; the repo-root manifest already serves the marketplace and git-clone `@skills-dir` paths. + +## Consequences + +- gsd-core gains a one-command install/update/disable lifecycle and automatic `/gsd-core:` namespacing that prevents slash-command collisions, without disturbing the file-copy path. +- The plugin contract gains a named place in the glossary (`CONTEXT.md`) and a defined mapping, so future surface additions have an obvious projection target instead of an ad-hoc file edit. +- **Latent duplication is now named, not hidden.** The always-on hook policy is currently encoded twice — imperatively in the Installer Module's `settings.json` wiring, and declaratively in `hooks/hooks.json` — kept in agreement only by the drift-guard test. This ADR records that as the known cost of a *static* manifest. Elevating the Module from a hand-authored manifest to a **generated projection** (stamping `plugin.json` from the Package Identity Module + `package.json`, and `hooks/hooks.json` from `managed-hooks-registry.cjs` + a shared always-on-hook policy) would collapse the duplication to one source — the same generated-single-source move ADR-457 made for `.cjs` and the Runtime Install Policy Module made for install plans. Deferred; see Open questions. +- The `name` field is a stability surface: it is the published `/gsd-core:` namespace. Changing it is a user-visible break under Hyrum's law, the same way command names are. +- Rollout is incremental: this ADR + the hand-authored manifest land first (#766/PR#797); installer-emit, release-time version stamping, and the generated projection are tracked follow-ups under #766. + +## Open questions + +- Should this Module be **generated** rather than hand-authored, deriving `version` (and identity) at build/release time so a `package.json` bump cannot leave `plugin.json` stale? The release pipeline bumps via `npm version --no-git-tag-version` with no regeneration hook, so today the drift-guard test enforces the sync manually (idiomatic with the repo's other drift guards, but a release speed-bump). +- Should the always-on-hook policy be lifted into a single shared source consumed by *both* the Installer Module and this Module, retiring the dual hand-encoding? + +## References + +- ADR-3660 — Runtime Artifact Layout Module (the file-copy sibling: projects the same surfaces onto filesystem placements). +- ADR-58 — Runtime Install Policy Module (typed install-plan projection for the file-copy path). +- ADR-457 — Generated single-source (the precedent a generated manifest projection would follow). +- ADR-0008 — Installer Migration Module (adjacent installer Seam). +- Package Identity Module (`gsd-core/bin/lib/package-identity.cjs`) — source of the manifest's identity fields. +- Installer Module (`bin/install.js`) — owns the `settings.json` always-on hook wiring this Module mirrors for the plugin path. +- `CONTEXT.md` § Glossary — Domain modules and seams (where this Module is registered). +- Claude Code plugin contract: . diff --git a/docs/adr/README.md b/docs/adr/README.md index 612e4891f..e91265d49 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -55,6 +55,7 @@ See **[CONTRIBUTING.md — "Proposing an ADR or PRD"](../../CONTRIBUTING.md#prop | [457-generated-cjs-single-source.md](457-generated-cjs-single-source.md) | Collapse hand-written CJS to generated single-source | Proposed | | [660-release-from-next-head.md](660-release-from-next-head.md) | Release from the head of next; immutable release tags; @next dist-tag as the RC surface | Proposed | | [58-runtime-install-policy-module.md](58-runtime-install-policy-module.md) | Runtime Install Policy Module owns the typed install-plan projection | Accepted | +| [766-claude-code-plugin-manifest-module.md](766-claude-code-plugin-manifest-module.md) | Claude Code Plugin Manifest Module owns the projection of gsd-core surfaces onto the Claude Code plugin contract | Accepted | ## Seam map diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index 0e2a160e7..e7f3ccb1b 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -44,6 +44,50 @@ CLAUDE_CONFIG_DIR=~/.claude-alt npx @opengsd/gsd-core@latest --claude --global --- +### Claude Code — native plugin install + +GSD Core ships a `.claude-plugin/plugin.json` manifest, which enables installation and lifecycle management through the Claude Code plugin system. This path is **additive** — the npm installer above remains fully supported, and the two approaches differ in namespace and lifecycle only. + +**Install paths** + +*Option A — marketplace or git install (once listed):* + +```bash +claude plugin install gsd-core +``` + +*Option B — zero-friction skills-dir load:* Claude Code automatically discovers any directory under `~/.claude/skills/` that contains a `.claude-plugin/plugin.json` as a plugin. To use gsd-core this way, place (or symlink) the gsd-core package directory there: + +```bash +# Example: place the package under ~/.claude/skills/gsd-core/ +# Claude Code loads it as gsd-core@skills-dir on the next session start. +# No explicit install step required. +``` + +**Command namespace** + +Plugin commands are namespaced as `/gsd-core:` — for example, `/gsd-core:plan-phase`. This is distinct from the classic npm/file-copy installer, which exposes commands as `/gsd:`. Use whichever namespace corresponds to your install method. + +**Lifecycle** + +```bash +claude plugin enable gsd-core +claude plugin disable gsd-core +claude plugin update gsd-core +``` + +**Hooks** + +The plugin wires gsd-core's always-on guard and update hooks automatically via `hooks/hooks.json`. No manual hook registration is required. + +**Prerequisites** + +The `gsd-tools` binary (installed as part of the `@opengsd/gsd-core` npm package) must be available on your `PATH` for gsd commands to execute their backing logic. The plugin delivers the command, agent, and hook surface; the npm package delivers the runtime CLI. + +Node.js (`node`) must also be available on your `PATH`. The plugin's always-on guard hooks (wired in `hooks/hooks.json`) are invoked as `node "${CLAUDE_PLUGIN_ROOT}/hooks/