From 353f63d170347cf2c464ad959b7a2ab0b2c572b8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 18 Jun 2026 14:41:20 -0400 Subject: [PATCH] feat(#1431): runtime capability registry overlay (ADR-1244 Phase 2) (#1440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#1431): runtime capability registry overlay (ADR-1244 Phase 2) Promote the registry from a frozen data file to loadRegistry({includeInstalled}), composing the first-party registry with a validated installed overlay (ADR-1244 D2): - Extract the conformance validator to a shared runtime-callable module (gsd-core/bin/lib/capability-validator.cjs); the generator re-exports it verbatim, guarded by a generative-parity test (no build-time/runtime drift). - capability-loader.cts: loadRegistry({includeInstalled}) composes first-party ∪ validated overlay from $GSD_HOME/.gsd/capabilities (global) and /.gsd/capabilities (project) via the canonical buildRegistry. First-party always wins (id/skill/agent/config/command-family + reserved gsd-/anthropic- prefixes); full merged-set cross-capability validation; engines.gsd load-time re-gate (skip-with-warning); gate-kind capabilities FAIL CLOSED; fragment-path escapes rejected. - semverSatisfies (hand-written, no dep) for the engines.gsd gate, fail-closed. - Wire surface/state + loop to the overlay; loop injects a blocking gate for each skipped gate-kind overlay (fail-closed). - cwd-aware overlay config-key federation: config-loader _federatedConfigSchema(cwd) + config-schema isValidConfigKey(key, cwd) compose the overlay per loadConfig/ config-set call (never eager at module load, never wrong-cwd); first-party path unchanged with no cwd. - run-tests.cjs sandboxes GSD_HOME (idempotent — nested spawns reuse it) for test hermeticity; capability-loader.cjs git+eslint-ignored (tsc artifact); capability-validator.cjs stays linted (#551 migration coverage). Closes #1431 Co-Authored-By: Claude Opus 4.8 * docs(#1431): add changeset for runtime capability registry overlay Co-Authored-By: Claude Opus 4.8 * test(#1431): kill config-schema cwd-aware federation mutants (Stryker ≥52) The cwd-aware overlay config-key federation added to config-schema.cts (_capabilityConfigSchema(cwd) + isCapabilityConfigKey/isValidConfigKey cwd threading) introduced mutable surface uncovered by config-schema's mutation test set, dropping its score to 39.58% (below the 52 break threshold). Add a real-overlay-fixture describe block exercising every branch (cwd guard, overlay loadRegistry, found-branch, first-party fallback, cwd threading); local Stryker score 39.58% -> 77.08%. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .changeset/humble-sloths-click.md | 5 + .gitignore | 1 + CONTEXT.md | 6 + docs/ARCHITECTURE.md | 2 + docs/CONFIGURATION.md | 35 + docs/INVENTORY-MANIFEST.json | 2 + docs/INVENTORY.md | 2 + eslint.config.mjs | 1 + gsd-core/bin/lib/capability-validator.cjs | 1995 ++++++++++++++++++++ scripts/gen-capability-registry.cjs | 1959 +------------------ scripts/run-tests.cjs | 14 + src/capability-loader.cts | 367 ++++ src/capability-state.cts | 12 +- src/config-loader.cts | 38 +- src/config-schema.cts | 29 +- src/config.cts | 2 +- src/loop-resolver.cts | 27 +- src/semver-compare.cts | 123 ++ tests/capability-loader.test.cjs | 275 +++ tests/capability-registry.test.cjs | 63 + tests/capability-state.test.cjs | 50 + tests/config-schema.property.test.cjs | 76 +- tests/federated-config-loadconfig.test.cjs | 56 + tests/loop-render-hooks.test.cjs | 78 + tests/semver-compare.test.cjs | 100 + 25 files changed, 3389 insertions(+), 1929 deletions(-) create mode 100644 .changeset/humble-sloths-click.md create mode 100644 gsd-core/bin/lib/capability-validator.cjs create mode 100644 src/capability-loader.cts create mode 100644 tests/capability-loader.test.cjs diff --git a/.changeset/humble-sloths-click.md b/.changeset/humble-sloths-click.md new file mode 100644 index 000000000..c1339c406 --- /dev/null +++ b/.changeset/humble-sloths-click.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 1440 +--- +**Runtime capability registry overlay** — installed third-party capabilities (under `~/.gsd/capabilities/` or a project's `.gsd/capabilities/`) are now composed into the registry at runtime via `loadRegistry({ includeInstalled })`: validated against the same conformance invariants as first-party, first-party-wins on any collision, skipped-with-a-warning when incompatible with the running GSD version (`engines.gsd`), with gate-kind capabilities failing closed. Installed overlays are toggable via surface and federate their config keys (cwd-aware) exactly like first-party. Foundation (ADR-1244 Phase 2) for capability install/upgrade/remove. diff --git a/.gitignore b/.gitignore index dde551686..3d0cbaff1 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,7 @@ build/ # by `npm run build:lib`). Source of truth is src/; these are emitted, never edited. # Published via prepublishOnly; built before test via pretest. Grows as modules migrate. /tsconfig.build.tsbuildinfo +/gsd-core/bin/lib/capability-loader.cjs /gsd-core/bin/lib/markdown-sectionizer.cjs /gsd-core/bin/lib/resolution.cjs /gsd-core/bin/lib/research-store.cjs diff --git a/CONTEXT.md b/CONTEXT.md index 212fbb7b7..27b72f2d1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -175,6 +175,12 @@ Generated central manifest projecting all co-located Capability declarations int ### Federated Config ADR-857 phase 3b seam that merges capability-declared config slices into the `loadConfig` return value. Implemented in `src/federated-config.cts` → `gsd-core/bin/lib/federated-config.cjs`. Exports `mergeFederatedConfig({ configSchema, isCentralKey, userConfig }) → { values, validKeys, warnings }`. Rules: central-schema keys are skipped with a `pending-migration` warning; malformed slices are skipped with a warning (never throws); valid federated keys (absent from the central schema) resolve to the user-supplied value (if type-matches) or the slice default. Object writes are guarded against prototype pollution with inline literal `__proto__`/`constructor`/`prototype` key checks. ADR-857 phase 6 made the channel live for migrated Capability keys: `config-schema.cjs` exposes `isCentralConfigKey()` for central ownership and `isValidConfigKey()` accepts central + runtime + dynamic + Capability-owned registry keys. `loadConfig` exposes `_setFederatedRegistryForTests`/`_resetFederatedRegistryForTests` seams for injecting a synthetic registry in tests. +### Capability Registry Overlay +Runtime seam (`gsd-core/bin/lib/capability-loader.cjs`, ADR-1244 D2) that composes the frozen first-party Capability Registry (`capability-registry.cjs`) with a validated installed overlay of third-party capability manifests discovered at load time. Install roots are global (`$GSD_HOME/.gsd/capabilities//capability.json`, where `GSD_HOME` defaults to `~`) and project (`/.gsd/capabilities//capability.json`). Primary interface: `loadRegistry({ includeInstalled }) → registry` — when `includeInstalled` is true the overlay is merged via the canonical `buildRegistry` so all derived views (bySkill, byAgent, byLoopPoint, configKeys) cover first-party and overlay entries identically. First-party always wins: any overlay entry whose id, owned skill/agent stem, or federated config key collides with first-party, or whose id uses a reserved `gsd-`/`gsd-core-`/`anthropic-` prefix, is rejected at load time. Load-time re-gate: an overlay failing schema validation or whose `engines.gsd` semver range does not satisfy the running GSD version is skipped with a warning and never crashes the load loop. Per-hook-kind policy: a skipped capability that declared a `gate`-kind hook fails CLOSED (the loop resolver injects a blocking gate); skipped `step` or `contribution` capabilities skip open. Consumers wired to the overlay-aware registry: `config-loader.cjs`, `config-schema.cjs`, `capability-state.cjs`, `loop-resolver.cjs`. + +### Capability Validator +Shared conformance validator (`gsd-core/bin/lib/capability-validator.cjs`, ADR-1244 D2) extracted from `scripts/gen-capability-registry.cjs` so the build-time generator and the runtime overlay loader share one validator implementation. Exports the same `validateCapability(manifest)` surface consumed by both the generator (build-time) and `capability-loader.cjs` (runtime). Generative-parity is CI-guarded: a drift between the generator's validation logic and the extracted module is a hard failure. Callers that previously inlined validation against the generator's internal helpers are migrated to import this module directly. Source of truth: `gsd-core/bin/lib/capability-validator.cjs`. + ### Loop Extension Point A named, stable site on a host loop step (per-step `pre`/`post` plus per-wave in Execute; 12 total) where Capabilities register hooks. Three hook kinds: `step` (runs as its own sequenced unit), `contribution` (injects into the core step's prompt/context), and `gate` (checks and optionally blocks via a declared `blocking` flag). Each hook declares the artifacts it produces and consumes; hook order is derived by topological sort of that produces/consumes graph (capability-id tiebreak), which also defines data flow — file-artifact based, surviving `/clear` and fresh executor contexts. Hooks are surfaced by runtime resolution with concrete projection: the workflow calls a query that resolves the active hooks and returns fully-rendered, ordered markdown for the executor. Failure is default-resilient — a non-gate hook that errors is skipped with a warning; a hook may opt into `onError: halt`. Part of the Capability system. ADR-857 phase 3c ships the registry-consuming query layer: `gsd-core/bin/lib/loop-resolver.cjs` exposes `resolveLoopHooks({ point, registry, config })` (pure, no I/O), `renderLoopHooks(resolved)` (pure markdown renderer), and `cmdLoopRenderHooks(cwd, point, raw, opts)` (I/O entry point); activated via `gsd-tools loop render-hooks ` which emits `{ point, activeHooks[], rendered }`. Activation is driven by `when` (dotted config key resolved against `loadConfig`), with inline literal `__proto__`/`constructor`/`prototype` prototype-pollution guard. The first phase-6 cutovers wiring workflows to this query have landed — ui-phase at `plan:pre` and ui-review at `verify:post` (in `plan-phase.md`/`autonomous.md`); further per-feature cutovers are ongoing. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8282310de..9f2b62611 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -373,9 +373,11 @@ Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core | `profile-pipeline.cjs` | User behavioral profiling data pipeline, session file scanning | | `profile-output.cjs` | Profile rendering, USER-PROFILE.md and dev-preferences.md generation | | `loop-host-contract.cjs` | Generated Loop Host Contract — 12 loop points, per-step agent roles, and core artifacts; emitted by `scripts/gen-loop-host-contract.cjs` from workflow markers (ADR-894 §3); consumed by `gen-capability-registry.cjs` | +| `capability-loader.cjs` | Runtime registry overlay loader (ADR-1244 D2) — `loadRegistry({ includeInstalled })` composes the frozen first-party registry with a validated installed overlay of third-party capability manifests read from global `$GSD_HOME/.gsd/capabilities/` and project `/.gsd/capabilities/`; first-party always wins; load-time `engines.gsd` re-gate skips incompatible overlays with a warning; gate-kind hooks on skipped capabilities fail CLOSED | | `capability-registry.cjs` | Generated central Capability Registry — role-partitioned index of all co-located capability declarations; emitted by `scripts/gen-capability-registry.cjs` (ADR-894 §5) | | `loop-resolver.cjs` | Loop Extension Point resolver — ADR-857 phase 3c registry-consuming query; consumes resolved Capability State, filters `byLoopPoint` by capability enablement plus config activation, renders active hooks as markdown, emits `{ point, activeHooks, rendered }` envelope; `gsd-tools loop render-hooks [--config-dir ]` | | `capability-state.cjs` | Unified capability-state resolver — ADR-857 phase 4b/6; composes install profile, runtime surface, and config activation into one per-capability view consumed by workflow hook rendering; pure `resolveCapabilityState`, reusable `resolveCapabilityRuntimeState`, I/O `cmdCapabilityState`, and convenience predicate `isCapabilityActive(capId, cwd)`; `gsd-tools capability state [--config-dir ]` emits `{ runtimeConfigDir, capabilities[] }` where each entry carries `enabled` (installed && surfaced) and `active` (enabled && configActivation via the capability's `activationKey`; absent key → active===enabled) | +| `capability-validator.cjs` | Shared capability conformance validator (ADR-1244 D2) — extracted from `scripts/gen-capability-registry.cjs` so the build-time generator and the runtime overlay loader share one `validateCapability(manifest)` implementation; generative-parity is CI-guarded | | `graphify-command-router.cjs` | ADR-959 capability command router — first real capability command cutover (phase 4d-impl-2); extracted from the `case 'graphify':` arm in `gsd-tools.cjs`; dispatches build/query/status/diff subcommands; discovered via `commandFamilies` in the capability registry | | `audit-command-router.cjs` | ADR-959 capability command router (phase 4d-impl-3); extracted from the `case 'audit-uat':` and `case 'audit-open':` arms in `gsd-tools.cjs`; `routeAuditUat` → `uat.cjs:cmdAuditUat`, `routeAuditOpen` → `audit.cjs:{auditOpenArtifacts,formatAuditReport}`; discovered via `commandFamilies` in the capability registry | | `intel-command-router.cjs` | ADR-959 capability command router (phase 4d-impl-4, last first-party cutover); extracted from the `case 'intel':` arm in `gsd-tools.cjs`; `routeIntelCommand` → all 9 intel subcommands via lazy `require('./intel.cjs')`; preserves non-raw `timeAgo` transform on `status.files[*].updated_at`; discovered via `commandFamilies` in the capability registry | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 780824afa..2f28bd410 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -657,6 +657,41 @@ The `features.*` namespace is a dynamic key pattern — new feature flags can be --- +## Capability Overlay (installed third-party capabilities) + +GSD supports an **installed overlay** of third-party capability manifests that are composed with the frozen first-party registry at runtime via `loadRegistry({ includeInstalled: true })` (ADR-1244; see [`docs/reference/capability-manifest.md`](reference/capability-manifest.md) and [`docs/how-to/import-a-capability-from-a-url.md`](how-to/import-a-capability-from-a-url.md)). + +### Install roots + +Capability manifests (`capability.json`) are discovered from two scoped roots: + +| Scope | Path | +|-------|------| +| Global | `$GSD_HOME/.gsd/capabilities//capability.json` | +| Project | `/.gsd/capabilities//capability.json` | + +`GSD_HOME` defaults to your home directory (`~`) when unset. Both roots are scanned on every `loadRegistry` call; neither requires config changes to activate. + +### Composition and first-party-wins invariant + +Installed overlay capabilities are merged via the same `buildRegistry` pipeline as first-party capabilities, so all derived views (`bySkill`, `byAgent`, `byLoopPoint`, `configKeys`) cover first-party and overlay entries identically. **First-party always wins**: an overlay entry is rejected at load time if its `id`, any owned skill or agent stem, or any federated config key collides with a first-party entry, or if its `id` uses a reserved prefix (`gsd-`, `gsd-core-`, `anthropic-`). Rejected entries emit a warning and are skipped; they never crash the load loop. + +### Load-time `engines.gsd` compatibility gate + +Each overlay manifest may declare an `engines.gsd` semver range. At load time GSD evaluates this range against the running GSD version. An overlay that does not satisfy the range is **skipped with a warning** — it is never loaded and never crashes the loop. Manifests without an `engines.gsd` field are accepted unconditionally. + +### Gate-kind fail-closed policy + +If a skipped overlay capability declared a `gate`-kind loop hook, the loop resolver **injects a blocking gate** at that hook point (fail CLOSED). Skipped capabilities whose hooks are `step` or `contribution` kind skip open — the loop proceeds without them. + +### Overlay config federation + +Config keys declared in an overlay capability's `.config` slice federate into the `loadConfig` return value via the same Federated Config channel as first-party capability keys. They appear as valid keys in `config-schema.cjs` (`isValidConfigKey`) and in the runtime config schema, so overlay capabilities can declare project-local config toggles without editing the central config schema. + +> **See also:** [`docs/reference/capability-manifest.md`](reference/capability-manifest.md) for the full `capability.json` schema, [`docs/how-to/import-a-capability-from-a-url.md`](how-to/import-a-capability-from-a-url.md) for installation steps, and [ADR-1244](adr/1244-runtime-capability-registry-overlay.md) for the design record. + +--- + ## Parallelization Settings | Setting | Type | Default | Description | diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index e52c36baf..8e537b452 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -279,8 +279,10 @@ "audit-command-router.cjs", "audit.cjs", "capability-activation.cjs", + "capability-loader.cjs", "capability-registry.cjs", "capability-state.cjs", + "capability-validator.cjs", "capability-writer.cjs", "check-command-router.cjs", "cjs-command-router-adapter.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index ca0d7d847..639c77498 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -390,8 +390,10 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `audit-command-router.cjs` | ADR-959 capability command router for `gsd-tools audit-uat` and `gsd-tools audit-open` — extracted from hardcoded cases in `gsd-tools.cjs`; dispatches to `uat.cjs:cmdAuditUat` and `audit.cjs:{auditOpenArtifacts,formatAuditReport}`; phase 4d-impl-3 | | `audit.cjs` | Audit dispatch, audit open sessions, audit storage helpers | | `capability-activation.cjs` | Capability activation resolver shared by config validation and capability-state consumers — resolves registry-owned config keys from raw runtime config without re-centralizing migrated settings | +| `capability-loader.cjs` | Runtime Capability Registry overlay (ADR-1244 D2) — `loadRegistry({ includeInstalled })` composes the frozen first-party registry with a validated installed overlay read from `$GSD_HOME/.gsd/capabilities//` (global) and `/.gsd/capabilities//` (project); first-party-wins on id/skill/agent/config collisions, reserved-namespace rejection, load-time `engines.gsd` re-gate (skip-with-warning), and gate-kind fail-closed via `_overlay.blockedGates`; composes through the canonical `buildRegistry` so derived views never drift | | `capability-registry.cjs` | Generated central Capability Registry — role-partitioned index of all co-located capability declarations (`capabilities//capability.json`); emitted by `scripts/gen-capability-registry.cjs --write` (ADR-894 §5) | | `capability-state.cjs` | Unified capability-state resolver (ADR-857 phase 4b/6) — composes install profile, runtime surface, and config activation into one per-capability view consumed by workflow hook rendering; exports pure `resolveCapabilityState`, reusable `resolveCapabilityRuntimeState`, and I/O handler `cmdCapabilityState`; command surface: `gsd-tools capability state [--config-dir ]` emitting `{ runtimeConfigDir, capabilities[] }` | +| `capability-validator.cjs` | Shared runtime-callable capability validator (ADR-1244 D2) — extracted from `scripts/gen-capability-registry.cjs` so the build-time generator and the runtime overlay loader share ONE validation implementation (generative-parity guarded); exports `validateCapability`/`validateCrossCapability`/`validateVersionEnvelope`/`validateConsumesGlobal`/… plus the closed-vocabulary sets and `SEMVER_RE` | | `capability-writer.cjs` | Capability State Writer (ADR-1213) — write-side inverse of the resolver; projects desired per-capability enabled/gates onto surface + config substrates, then re-resolves (assert-and-report); exports `setCapabilityState` and I/O handler `cmdCapabilitySet`; command surface: `gsd-tools capability set [--on\|--off] [--gate =]` | | `check-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools check` | | `cli-exit.cjs` | `ExitError` class and `runMain()` helper — CLI entrypoints throw `ExitError` instead of calling `process.exit()`; `runMain()` translates the outcome into `process.exitCode` so output flushes cleanly | diff --git a/eslint.config.mjs b/eslint.config.mjs index dbf90b8d0..98f24c76d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -39,6 +39,7 @@ export default tseslint.config( '**/*.generated.cjs', // ADR-457: tsc-generated runtime artifact — lint the src/*.cts source, not the emitted .cjs. 'gsd-core/bin/lib/semver-compare.cjs', + 'gsd-core/bin/lib/capability-loader.cjs', 'gsd-core/bin/lib/resolution.cjs', 'gsd-core/bin/lib/plan-drift-guard.cjs', 'gsd-core/bin/lib/cli-exit.cjs', diff --git a/gsd-core/bin/lib/capability-validator.cjs b/gsd-core/bin/lib/capability-validator.cjs new file mode 100644 index 000000000..950391f38 --- /dev/null +++ b/gsd-core/bin/lib/capability-validator.cjs @@ -0,0 +1,1995 @@ +'use strict'; + +/** + * capability-validator.cjs — shared, runtime-callable capability validator. + * + * Extracted from scripts/gen-capability-registry.cjs per ADR-1244 D2 so that + * both the build-time generator and the runtime overlay loader can require the + * validator WITHOUT pulling in the generator's build-time-only machinery + * (ExitError, config-schema.manifest.json, install-profiles.cjs, clusters.cjs, + * gen-loop-host-contract.cjs, etc.). + * + * This is a COMMITTED plain .cjs (not built from .cts) so it is available on a + * fresh worktree before `npm run build:lib` has run. + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const { LOOP_HOST_CONTRACT } = require('./loop-host-contract.cjs'); + +// ─── Shared schema-version constant ────────────────────────────────────────── + +const SCHEMA_VERSION = '1'; + +// ─── Loop Host Contract ─────────────────────────────────────────────────────── + +// Canonical point order — explicit constant (do NOT rely on Set insertion order). +// Used for point-ordering semantics in consumes-satisfiability validation and topo-sort. +const POINT_ORDER = [ + 'discuss:pre', + 'discuss:post', + 'plan:pre', + 'plan:post', + 'execute:pre', + 'execute:wave:pre', + 'execute:wave:post', + 'execute:post', + 'verify:pre', + 'verify:post', + 'ship:pre', + 'ship:post', +]; + +// C1: Artifact availability — host-produced artifacts become available at their step's :post +// point. Build a map: artifact → earliest POINT_ORDER index at which it is available. +// (discuss produces CONTEXT.md → discuss:post = index 1; +// plan produces PLAN.md → plan:post = index 3; +// execute produces SUMMARY.md → execute:post = index 7; +// verify produces UAT.md → verify:post = index 9) +// +// NOTE: this map covers ONLY host artifacts. Hook-produced artifacts are handled per-run +// during consumes-satisfiability validation (C2 global pass). +const HOST_ARTIFACT_EARLIEST_POINT_IDX = (() => { + const m = Object.create(null); + for (const entry of LOOP_HOST_CONTRACT) { + // The :post point is the last point in each step's points array. + const postPoint = entry.points[entry.points.length - 1]; + const postIdx = POINT_ORDER.indexOf(postPoint); + for (const artifact of entry.coreArtifacts.produces) { + // Only record the earliest (should be unique, but take min to be safe). + if (m[artifact] === undefined || postIdx < m[artifact]) { + m[artifact] = postIdx; + } + } + } + return m; +})(); + +// Flatten all valid loop points into a Set for O(1) validation +const VALID_LOOP_POINTS = new Set(POINT_ORDER); + +// Map point → step contract (agentRoles + coreArtifacts) +const POINT_TO_CONTRACT = new Map(); +for (const entry of LOOP_HOST_CONTRACT) { + for (const point of entry.points) { + POINT_TO_CONTRACT.set(point, entry); + } +} + +// ─── Config-slice validation ────────────────────────────────────────────────── + +const VALID_CONFIG_SLICE_TYPES = new Set(['boolean', 'string', 'number', 'enum']); + +/** + * Validate a single config-slice entry (one key's { type, default, description }). + * Returns an array of error strings. Empty = valid. + * + * @param {string} capId Capability id (for error messages) + * @param {string} key Config key (for error messages) + * @param {object} slice The slice object from cap.config[key] + * @returns {string[]} + */ +function validateConfigSliceEntry(capId, key, slice) { + const errors = []; + + if (typeof slice !== 'object' || slice === null || Array.isArray(slice)) { + errors.push('capability "' + capId + '" config["' + key + '"]: slice must be a non-null object'); + return errors; + } + + // type must be one of the allowed set + if (!VALID_CONFIG_SLICE_TYPES.has(slice.type)) { + errors.push( + 'capability "' + capId + '" config["' + key + '"]: type must be one of ' + + [...VALID_CONFIG_SLICE_TYPES].join(', ') + ' (got: ' + JSON.stringify(slice.type) + ')', + ); + } + + // default must be present + if (!Object.prototype.hasOwnProperty.call(slice, 'default')) { + errors.push( + 'capability "' + capId + '" config["' + key + '"]: default is required', + ); + } else { + // type-consistency check + const def = slice.default; + if (slice.type === 'boolean') { + if (typeof def !== 'boolean') { + errors.push( + 'capability "' + capId + '" config["' + key + '"]: default must be a boolean for type:"boolean" (got: ' + typeof def + ')', + ); + } + } else if (slice.type === 'string') { + if (typeof def !== 'string') { + errors.push( + 'capability "' + capId + '" config["' + key + '"]: default must be a string for type:"string" (got: ' + typeof def + ')', + ); + } + } else if (slice.type === 'number') { + if (typeof def !== 'number') { + errors.push( + 'capability "' + capId + '" config["' + key + '"]: default must be a number for type:"number" (got: ' + typeof def + ')', + ); + } else if (!Number.isFinite(def)) { + // FIX 6a: Reject NaN and non-finite number defaults + errors.push( + 'capability "' + capId + '" config["' + key + '"]: default for type:"number" must be a finite number (got: ' + String(def) + ')', + ); + } + } else if (slice.type === 'enum') { + // FIX 5a: enum REQUIRES a non-empty values array (all strings), and default must be in it + if (!Array.isArray(slice.values) || slice.values.length === 0) { + errors.push( + 'capability "' + capId + '" config["' + key + '"]: type:"enum" requires a non-empty "values" array of strings', + ); + } else if (!slice.values.every((v) => typeof v === 'string')) { + errors.push( + 'capability "' + capId + '" config["' + key + '"]: type:"enum" values array must contain only strings', + ); + } + if (typeof def !== 'string') { + errors.push( + 'capability "' + capId + '" config["' + key + '"]: default must be a string for type:"enum" (got: ' + typeof def + ')', + ); + } else if (Array.isArray(slice.values) && slice.values.length > 0 && !slice.values.includes(def)) { + errors.push( + 'capability "' + capId + '" config["' + key + '"]: default "' + def + + '" is not one of the declared enum values [' + slice.values.join(', ') + ']', + ); + } + } + } + + // description must be a non-empty string + if (typeof slice.description !== 'string' || slice.description.length === 0) { + errors.push( + 'capability "' + capId + '" config["' + key + '"]: description must be a non-empty string (got: ' + JSON.stringify(slice.description) + ')', + ); + } + + return errors; +} + +// ─── Per-capability validation ──────────────────────────────────────────────── + +const KEBAB_RE = /^[a-z][a-z0-9-]*$/; +const VALID_ROLES = new Set(['feature', 'runtime']); +const VALID_TIERS = new Set(['core', 'standard', 'full']); +const VALID_ON_ERROR = new Set(['skip', 'halt']); +const RUNTIME_COMPAT_WILDCARD = '*'; + +// ── ADR-1244 D1: versioned-manifest envelope ───────────────────────────────── +// Official strict SemVer 2.0.0 grammar (https://semver.org). Rejects partials +// ("1.0"), prefixes ("v1.0.0"), leading-zero segments ("01.2.3"), numeric +// prerelease identifiers with leading zeros ("1.2.3-01"), empty identifiers +// ("1.2.3-..") and — critically — prerelease/build identifiers containing +// anything outside [0-9A-Za-z-] (so a version can never smuggle shell +// metacharacters, spaces or unicode into a downstream `git tag v` or +// path). Accepts "1.2.3-dev.0", "1.2.3-rc.1", "1.2.3+build.5". +const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; +// Permissive *shape* check for a semver range (engines.gsd / compatVersions +// values). Range SATISFACTION is enforced by the runtime overlay (ADR-1244 D2); +// here we only reject empty/garbage and shell metacharacters. +const SEMVER_RANGE_RE = /^[0-9A-Za-z.\-+ |<>=~^*()]+$/; +// Subresource-integrity hash: "sha512-" + base64 of a 64-byte digest (86 base64 +// chars + "==" padding). Exact length so malformed pins ("sha512-abc") fail. +const SHA512_INTEGRITY_RE = /^sha512-[A-Za-z0-9+/]{86}==$/; + +// A syntactically plausible semver range (shape-only — see SEMVER_RANGE_RE). +// Requires a digit or a bare wildcard so pure-alpha garbage ("abcx", "()x") is +// rejected; full range satisfaction is the runtime overlay's job (ADR-1244 D2). +function isPlausibleRange(s) { + if (typeof s !== 'string') return false; + const t = s.trim(); + if (t.length === 0 || !SEMVER_RANGE_RE.test(s)) return false; + return /\d/.test(t) || t === '*' || t === 'x' || t === 'X'; +} + +/** + * ADR-1244 D1: validate the versioned-manifest envelope. + * - version REQUIRED semver string (the registry rejects a manifest + * without one). + * - engines optional object; engines.gsd optional semver-range string. + * - compatVersions optional object mapping a capability version (semver) to a + * gsd version range. + * - integrity optional "sha512-" string. + * - provenance optional { sourceRepo, commit } strings. + * + * Shape only — range satisfaction and integrity verification are enforced by + * the source resolver / runtime overlay (ADR-1244 D2/D3). + * + * @param {object} cap The parsed JSON object. + * @returns {string[]} Array of error strings; empty = valid. + */ +function validateVersionEnvelope(cap) { + const errors = []; + + if (typeof cap.version !== 'string' || !SEMVER_RE.test(cap.version)) { + errors.push('version must be a semver string (e.g. "1.2.3"); got: ' + JSON.stringify(cap.version)); + } + + if (cap.engines !== undefined) { + if (typeof cap.engines !== 'object' || cap.engines === null || Array.isArray(cap.engines)) { + errors.push('engines must be an object (e.g. { "gsd": ">=1.6.0" })'); + } else if (cap.engines.gsd !== undefined && !isPlausibleRange(cap.engines.gsd)) { + errors.push('engines.gsd must be a semver range string; got: ' + JSON.stringify(cap.engines.gsd)); + } + } + + if (cap.compatVersions !== undefined) { + if (typeof cap.compatVersions !== 'object' || cap.compatVersions === null || Array.isArray(cap.compatVersions)) { + errors.push('compatVersions must be an object mapping capability versions to gsd version ranges'); + } else { + for (const [k, v] of Object.entries(cap.compatVersions)) { + if (!SEMVER_RE.test(k)) errors.push('compatVersions key "' + k + '" must be a semver string'); + if (!isPlausibleRange(v)) errors.push('compatVersions["' + k + '"] must be a semver range string'); + } + } + } + + if (cap.integrity !== undefined && (typeof cap.integrity !== 'string' || !SHA512_INTEGRITY_RE.test(cap.integrity))) { + errors.push('integrity must be a "sha512-" string'); + } + + if (cap.provenance !== undefined) { + const p = cap.provenance; + if (typeof p !== 'object' || p === null || Array.isArray(p)) { + errors.push('provenance must be an object { sourceRepo, commit }'); + } else { + if (typeof p.sourceRepo !== 'string' || p.sourceRepo.length === 0) { + errors.push('provenance.sourceRepo must be a non-empty string'); + } + if (typeof p.commit !== 'string' || p.commit.length === 0) { + errors.push('provenance.commit must be a non-empty string'); + } + } + } + + return errors; +} + +/** + * Validate a single capability declaration. + * + * @param {object} cap The parsed JSON object. + * @param {string} folderId The folder name (must equal cap.id). + * @returns {string[]} Array of error strings; empty = valid. + */ +function validateCapability(cap, folderId) { + const errors = []; + + if (typeof cap !== 'object' || cap === null || Array.isArray(cap)) { + return ['capability must be a JSON object']; + } + + // ── Common envelope ──────────────────────────────────────────────────────── + + if (typeof cap.id !== 'string' || !KEBAB_RE.test(cap.id)) { + errors.push('id must be a kebab-case string'); + } else if (cap.id !== folderId) { + errors.push('id "' + cap.id + '" must equal the folder name "' + folderId + '"'); + } + + if (!VALID_ROLES.has(cap.role)) { + errors.push('role must be one of: feature, runtime (got: ' + cap.role + ')'); + } + + if (typeof cap.title !== 'string' || cap.title.length === 0) { + errors.push('title must be a non-empty string'); + } + + // C4: description is required + if (typeof cap.description !== 'string' || cap.description.length === 0) { + errors.push('description must be a non-empty string'); + } + + if (!VALID_TIERS.has(cap.tier)) { + errors.push('tier must be one of: core, standard, full (got: ' + cap.tier + ')'); + } + + if (!Array.isArray(cap.requires)) { + errors.push('requires must be an array of capability ids'); + } else { + for (const req of cap.requires) { + if (typeof req !== 'string') { + errors.push('requires entries must be strings (got: ' + JSON.stringify(req) + ')'); + } + } + } + + // ── Versioned-manifest envelope (ADR-1244 D1) ────────────────────────────── + errors.push(...validateVersionEnvelope(cap)); + + // ── Role-specific body ──────────────────────────────────────────────────── + + if (cap.role === 'feature') { + errors.push(...validateFeatureBody(cap)); + } else if (cap.role === 'runtime') { + errors.push(...validateRuntimeBody(cap)); + } + + return errors; +} + +/** + * ADR-959: Validate a single commands[] entry on a feature-role capability. + * { family: string, module: string, router: string, subcommands?: string[] } + * + * - family: non-empty string, no reserved names + * - module: non-empty string, no path traversal, no absolute paths, no "/" + * segments other than a bare basename (expected form: "foo.cjs") + * - router: non-empty string + * - subcommands: optional array of strings (doc/introspection only) + * + * @param {string} capId Capability id (for error messages) + * @param {*} entry The entry to validate + * @param {string} prefix Path prefix (e.g. "commands[0]") + * @returns {string[]} Array of error strings; empty = valid. + */ +function validateCommandEntry(capId, entry, prefix) { + const errors = []; + const ctx = 'capability "' + capId + '" ' + prefix; + + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { + errors.push(ctx + ' must be an object with family, module, and router'); + return errors; + } + + // family: non-empty string, no reserved names + if (typeof entry.family !== 'string' || entry.family.length === 0) { + errors.push(ctx + '.family must be a non-empty string'); + } else if (entry.family === '__proto__' || entry.family === 'constructor' || entry.family === 'prototype') { + // S2a: inline literal reserved-name guard (CodeQL barrier) + errors.push(ctx + '.family "' + entry.family + '" is a reserved name'); + } + + // module: must be a safe bare basename matching /^[A-Za-z0-9._-]+\.cjs$/ — + // no path separators, no "..", no NUL bytes, no absolute paths, ends in .cjs. + // This conservative pattern subsumes all earlier traversal/absolute/separator checks. + if (typeof entry.module !== 'string' || entry.module.length === 0) { + errors.push(ctx + '.module must be a non-empty string'); + } else { + const mod = entry.module; + const SAFE_BASENAME = /^[A-Za-z0-9._-]+\.cjs$/; + if (!SAFE_BASENAME.test(mod)) { + errors.push( + ctx + '.module must be a safe bare basename (pattern: /^[A-Za-z0-9._-]+\\.cjs$/, no path separators, no "..", no NUL bytes, must end in ".cjs"); got: ' + + JSON.stringify(mod), + ); + } + } + + // router: non-empty string + if (typeof entry.router !== 'string' || entry.router.length === 0) { + errors.push(ctx + '.router must be a non-empty string'); + } + + // subcommands: optional array of non-empty strings (doc/introspection only) + if (entry.subcommands !== undefined) { + if (!Array.isArray(entry.subcommands)) { + errors.push(ctx + '.subcommands must be an array of strings if present'); + } else { + for (let i = 0; i < entry.subcommands.length; i++) { + if (typeof entry.subcommands[i] !== 'string') { + errors.push(ctx + '.subcommands[' + i + '] must be a string'); + } else if (entry.subcommands[i].length === 0) { + errors.push(ctx + '.subcommands[' + i + '] must be a non-empty string'); + } + } + } + } + + return errors; +} + +function validateRuntimeCompat(capId, runtimeCompat) { + const errors = []; + const ctx = 'capability "' + capId + '" runtimeCompat'; + + if (typeof runtimeCompat !== 'object' || runtimeCompat === null || Array.isArray(runtimeCompat)) { + errors.push(ctx + ' must be an object with supported and unsupported arrays'); + return errors; + } + + const validateRuntimeArray = (field, { allowWildcard }) => { + const value = runtimeCompat[field]; + if (!Array.isArray(value)) { + errors.push(ctx + '.' + field + ' must be an array of runtime ids' + (allowWildcard ? ' or ["*"]' : '')); + return; + } + if (field === 'supported' && value.length === 0) { + errors.push(ctx + '.supported must be a non-empty array'); + } + let hasWildcard = false; + for (let i = 0; i < value.length; i++) { + const entry = value[i]; + if (typeof entry !== 'string' || entry.length === 0) { + errors.push(ctx + '.' + field + '[' + i + '] must be a non-empty string'); + continue; + } + if (entry === '__proto__' || entry === 'constructor' || entry === 'prototype') { + errors.push(ctx + '.' + field + '[' + i + '] "' + entry + '" is a reserved name'); + } + if (entry === RUNTIME_COMPAT_WILDCARD) { + if (!allowWildcard) { + errors.push(ctx + '.' + field + ' must not include wildcard "*"'); + } + hasWildcard = true; + } else if (!KEBAB_RE.test(entry)) { + errors.push(ctx + '.' + field + '[' + i + '] must be a kebab-case runtime id or "*"'); + } + } + if (hasWildcard && value.length > 1) { + errors.push(ctx + '.' + field + ' wildcard "*" cannot be mixed with runtime ids'); + } + }; + + validateRuntimeArray('supported', { allowWildcard: true }); + validateRuntimeArray('unsupported', { allowWildcard: false }); + + if (runtimeCompat.notes !== undefined) { + if (typeof runtimeCompat.notes !== 'object' || runtimeCompat.notes === null || Array.isArray(runtimeCompat.notes)) { + errors.push(ctx + '.notes must be an object of runtime id to string if present'); + } else { + for (const [key, value] of Object.entries(runtimeCompat.notes)) { + if (key !== RUNTIME_COMPAT_WILDCARD && !KEBAB_RE.test(key)) { + errors.push(ctx + '.notes key "' + key + '" must be a kebab-case runtime id or "*"'); + } + if (typeof value !== 'string' || value.length === 0) { + errors.push(ctx + '.notes["' + key + '"] must be a non-empty string'); + } + } + } + } + + return errors; +} + +function validateFeatureBody(cap) { + const errors = []; + + errors.push(...validateRuntimeCompat(cap.id || '(unknown)', cap.runtimeCompat)); + + if (!Array.isArray(cap.skills)) { + errors.push('skills must be an array of strings'); + } else { + for (const s of cap.skills) { + if (typeof s !== 'string') { + errors.push('skills entries must be strings'); + } else if (s === '__proto__' || s === 'constructor' || s === 'prototype') { + // S2a: inline literal reserved-name guard (CodeQL barrier) + errors.push('skills entry "' + s + '" is a reserved name'); + } + } + } + + // ADR-959: optional commands array + if (cap.commands !== undefined) { + if (!Array.isArray(cap.commands)) { + errors.push('commands must be an array of {family, module, router} objects'); + } else { + for (let i = 0; i < cap.commands.length; i++) { + errors.push(...validateCommandEntry(cap.id || cap.role, cap.commands[i], 'commands[' + i + ']')); + } + } + } + + if (!Array.isArray(cap.agents)) { + errors.push('agents must be an array of strings'); + } else { + for (const a of cap.agents) { + if (typeof a !== 'string') { + errors.push('agents entries must be strings'); + } else if (a === '__proto__' || a === 'constructor' || a === 'prototype') { + // S2a: inline literal reserved-name guard (CodeQL barrier) + errors.push('agents entry "' + a + '" is a reserved name'); + } + } + } + + if (typeof cap.config !== 'object' || cap.config === null || Array.isArray(cap.config)) { + errors.push('config must be an object'); + } else { + // C5: validate config key names and value shapes + for (const key of Object.keys(cap.config)) { + if (key === '' ) { + errors.push('config keys must be non-empty strings'); + } else if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + // S2a: inline literal reserved-name guard (CodeQL barrier) + errors.push('config key "' + key + '" is a reserved name'); + } + const val = cap.config[key]; + if (val === null || typeof val !== 'object' || Array.isArray(val)) { + errors.push('config["' + key + '"] must be an object (got: ' + (val === null ? 'null' : typeof val) + ')'); + } else if (typeof val.type !== 'string' || val.type.length === 0) { + errors.push('config["' + key + '"] must have a string "type" field (e.g. "boolean", "string", "number", "enum")'); + } + } + } + + // C4: hooks, when present, must be an array of {event: string, script: string} + if (cap.hooks !== undefined) { + if (!Array.isArray(cap.hooks)) { + errors.push('hooks must be an array of {event, script} objects'); + } else { + for (let i = 0; i < cap.hooks.length; i++) { + const h = cap.hooks[i]; + if (typeof h !== 'object' || h === null || Array.isArray(h)) { + errors.push('hooks[' + i + '] must be an object with event and script keys'); + } else { + if (typeof h.event !== 'string' || h.event.length === 0) { + errors.push('hooks[' + i + '].event must be a non-empty string'); + } + if (typeof h.script !== 'string' || h.script.length === 0) { + errors.push('hooks[' + i + '].script must be a non-empty string'); + } + } + } + } + } + + // Build the declared skill/agent sets for ref membership checks (used in validateStep). + // Only build these if the arrays are valid (already validated above). + const declaredSkills = Array.isArray(cap.skills) ? new Set(cap.skills.filter((s) => typeof s === 'string')) : null; + const declaredAgents = Array.isArray(cap.agents) ? new Set(cap.agents.filter((a) => typeof a === 'string')) : null; + + if (!Array.isArray(cap.steps)) { + errors.push('steps must be an array'); + } else { + for (let i = 0; i < cap.steps.length; i++) { + errors.push(...validateStep(cap.steps[i], 'steps[' + i + ']', declaredSkills, declaredAgents)); + } + } + + if (!Array.isArray(cap.contributions)) { + errors.push('contributions must be an array'); + } else { + for (let i = 0; i < cap.contributions.length; i++) { + errors.push(...validateContribution(cap.contributions[i], 'contributions[' + i + ']')); + } + } + + if (!Array.isArray(cap.gates)) { + errors.push('gates must be an array'); + } else { + for (let i = 0; i < cap.gates.length; i++) { + errors.push(...validateGate(cap.gates[i], 'gates[' + i + ']')); + } + } + + // activationKey: optional string naming the dotted config key that gates this capability. + // If present: must be a non-empty string that is declared in this capability's own config slice. + if (cap.activationKey !== undefined) { + if (typeof cap.activationKey !== 'string' || cap.activationKey.length === 0) { + errors.push( + 'capability "' + (cap.id || '(unknown)') + '" activationKey must be a non-empty string (got: ' + + JSON.stringify(cap.activationKey) + ')', + ); + } else if (cap.activationKey === '__proto__' || cap.activationKey === 'constructor' || cap.activationKey === 'prototype') { + // Prototype-pollution guard (inline literal, CodeQL barrier) + errors.push( + 'capability "' + (cap.id || '(unknown)') + '" activationKey "' + cap.activationKey + + '" is a reserved JavaScript property name and cannot be used as an activationKey', + ); + } else if ( + typeof cap.config !== 'object' || + cap.config === null || + !Object.prototype.hasOwnProperty.call(cap.config, cap.activationKey) + ) { + errors.push( + 'capability "' + (cap.id || '(unknown)') + '" activationKey "' + cap.activationKey + + '" is not declared in this capability\'s config slice — add it to the "config" object or use a key that is declared there', + ); + } + } + + return errors; +} + +// ADR-857 phase 5e: Closed ConverterName enum — complete set used across 16 runtime descriptors, +// all exported by bin/install.js (commands/skills) and src/runtime-artifact-conversion.cts (agents). +// Any ArtifactKind with a non-null converter must use one of these. +const VALID_CONVERTER_NAMES = new Set([ + // commands / skills converters (pre-existing) + 'convertClaudeCommandToAntigravitySkill', + 'convertClaudeCommandToAugmentSkill', + 'convertClaudeCommandToClineSkill', + 'convertClaudeCommandToClaudeSkill', + 'convertClaudeCommandToCodebuddyCommand', + 'convertClaudeCommandToCodebuddySkill', + 'convertClaudeCommandToCodexSkill', + 'convertClaudeCommandToCopilotSkill', + 'convertClaudeCommandToCursorCommand', + 'convertClaudeCommandToCursorSkill', + 'convertClaudeCommandToKiloSkill', + 'convertClaudeCommandToKimiSkill', + 'convertClaudeCommandToOpencodeSkill', + 'convertClaudeCommandToTraeSkill', + 'convertClaudeCommandToWindsurfSkill', + // agent converters (#1173 — descriptor-driven agent conversion wiring) + 'convertClaudeAgentToCopilotAgent', + 'convertClaudeAgentToAntigravityAgent', + 'convertClaudeAgentToCursorAgent', + 'convertClaudeAgentToWindsurfAgent', + 'convertClaudeAgentToAugmentAgent', + 'convertClaudeAgentToTraeAgent', + 'convertClaudeAgentToCodebuddyAgent', + 'convertClaudeAgentToClineAgent', + 'convertClaudeAgentToCodexAgent', +]); + +// C3: Validate role:runtime body +const VALID_CONFIG_FORMATS = new Set(['settings-json', 'toml', 'markdown', 'markdown-dir', 'none']); +const VALID_CONFIG_HOME_KINDS = new Set(['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root']); +const VALID_COMMAND_STYLES = new Set(['slash-hyphen', 'shell-var']); +const VALID_HOOKS_SURFACES = new Set(['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'none']); +const VALID_HOOK_EVENTS = new Set(['claude', 'gemini', 'opencode-subset']); +const VALID_SANDBOX_TIERS = new Set(['none', 'codex-agent-sandbox']); +const VALID_ARTIFACT_KIND_NAMES = new Set(['commands', 'agents', 'skills', 'kimi-agents']); +const VALID_ARTIFACT_NESTINGS = new Set(['flat', 'nested']); +const FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME = ['skills', 'agents', 'steps', 'contributions', 'gates', 'hooks', 'activationKey']; +const VALID_INSTALL_SURFACES = new Set(['settings-json', 'codex-toml', 'copilot-instructions', 'cline-rules', 'cursor-hooks-json', 'profile-marker-only']); +const VALID_PERMISSION_WRITERS = new Set(['opencode', 'kilo']); +const VALID_EXTENDED_HOOK_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'BeforeAgent', 'AfterAgent', 'BeforeModel']); + +// GATE A: installSurface → allowed hooksSurface values (DEFECT.GENERATIVE-FIX: parity invariant) +// Derived from the actual pairings in the 16 real runtime descriptors. +const INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES = new Map([ + ['settings-json', new Set(['settings-json', 'none'])], + ['codex-toml', new Set(['codex-hooks-json'])], + ['copilot-instructions', new Set(['copilot-inline'])], + ['cline-rules', new Set(['cline-rules'])], + ['cursor-hooks-json', new Set(['cursor-hooks-json'])], + ['profile-marker-only', new Set(['none'])], +]); + +// GATE B: extended hook event families → required hookEvents value +// Gemini agent-events require hookEvents='gemini'; Claude-family events require hookEvents='claude'. +const GEMINI_AGENT_EVENTS = new Set(['BeforeAgent', 'AfterAgent', 'BeforeModel']); +const CLAUDE_FAMILY_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged']); + +/** + * Validate a runtime.configHome object per ADR-1016 Decision 1. + * Returns an array of error strings. + * + * @param {string} capId Capability id (for error messages) + * @param {*} ch The configHome value + * @returns {string[]} + */ +function validateConfigHome(capId, ch) { + const errors = []; + const ctx = 'capability "' + capId + '" runtime.configHome'; + + if (typeof ch !== 'object' || ch === null || Array.isArray(ch)) { + errors.push(ctx + ' must be an object (got: ' + (ch === null ? 'null' : typeof ch) + ')'); + return errors; + } + + // kind — must be in closed vocab; inline literal guard (CodeQL barrier) + if (ch.kind === '__proto__' || ch.kind === 'constructor' || ch.kind === 'prototype') { + errors.push(ctx + '.kind "' + ch.kind + '" is a reserved name'); + } else if (!VALID_CONFIG_HOME_KINDS.has(ch.kind)) { + errors.push( + ctx + '.kind must be one of: ' + [...VALID_CONFIG_HOME_KINDS].join(', ') + + ' (got: ' + JSON.stringify(ch.kind) + ')', + ); + } + + // name — required string + if (typeof ch.name !== 'string' || ch.name.length === 0) { + errors.push(ctx + '.name must be a non-empty string'); + } + + // parent — required when kind == dot-home-nested + if (ch.kind === 'dot-home-nested') { + if (typeof ch.parent !== 'string' || ch.parent.length === 0) { + errors.push(ctx + '.parent must be a non-empty string when kind is "dot-home-nested"'); + } + } + + // env — required; must be an array of strings (every runtime has ≥0 env overrides) + if (!Array.isArray(ch.env)) { + errors.push(ctx + '.env is required and must be an array of strings (got: ' + JSON.stringify(ch.env) + ')'); + } else { + for (let i = 0; i < ch.env.length; i++) { + if (typeof ch.env[i] !== 'string') { + errors.push(ctx + '.env[' + i + '] must be a string'); + } + } + } + + // probe — optional; if present must be an array of strings + if (ch.probe !== undefined) { + if (!Array.isArray(ch.probe)) { + errors.push(ctx + '.probe must be an array of strings if present'); + } else { + for (let i = 0; i < ch.probe.length; i++) { + if (typeof ch.probe[i] !== 'string') { + errors.push(ctx + '.probe[' + i + '] must be a string'); + } + } + } + } + + // probeExists — optional; if present must be a non-empty string (sub-path existence check for probe) + if (ch.probeExists !== undefined) { + if (typeof ch.probeExists !== 'string' || ch.probeExists.length === 0) { + errors.push(ctx + '.probeExists must be a non-empty string if present (got: ' + JSON.stringify(ch.probeExists) + ')'); + } + } + + // skillsHome — optional; if present must be a full valid configHome object (recursive validation) + if (ch.skillsHome !== undefined) { + // Recursive call: validate skillsHome as a nested configHome. + // Use a synthetic capId to surface the sub-path in error messages. + const skillsHomeErrors = validateConfigHome(capId + '.skillsHome', ch.skillsHome); + // Rewrite the inner ctx prefix so errors read as "...runtime.configHome.skillsHome..." + for (const e of skillsHomeErrors) { + errors.push(e.replace( + 'capability "' + capId + '.skillsHome" runtime.configHome', + ctx + '.skillsHome', + )); + } + } + + return errors; +} + +/** + * Validate a single ArtifactKind entry per ADR-1016 Decision 3. + * Returns an array of error strings. + * + * @param {string} capId Capability id (for error messages) + * @param {*} entry The ArtifactKind object + * @param {string} prefix Path prefix for error messages (e.g. "artifactLayout.global[0]") + * @returns {string[]} + */ +function validateArtifactKindEntry(capId, entry, prefix) { + const errors = []; + const ctx = 'capability "' + capId + '" runtime.' + prefix; + + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { + errors.push(ctx + ' must be an object'); + return errors; + } + + // kind — must be in closed vocab; inline literal guard (CodeQL barrier) + if (entry.kind === '__proto__' || entry.kind === 'constructor' || entry.kind === 'prototype') { + errors.push(ctx + '.kind "' + entry.kind + '" is a reserved name'); + } else if (!VALID_ARTIFACT_KIND_NAMES.has(entry.kind)) { + errors.push( + ctx + '.kind must be one of: ' + [...VALID_ARTIFACT_KIND_NAMES].join(', ') + + ' (got: ' + JSON.stringify(entry.kind) + ')', + ); + } + + // destSubpath — required non-empty string + if (typeof entry.destSubpath !== 'string' || entry.destSubpath.length === 0) { + errors.push(ctx + '.destSubpath must be a non-empty string'); + } + + // nesting — required; must be in closed vocab (ADR-857 §5d: now drives install) + if (entry.nesting === undefined || entry.nesting === null) { + errors.push(ctx + '.nesting is required and must be one of: ' + [...VALID_ARTIFACT_NESTINGS].join(', ')); + } else if (!VALID_ARTIFACT_NESTINGS.has(entry.nesting)) { + errors.push( + ctx + '.nesting must be one of: ' + [...VALID_ARTIFACT_NESTINGS].join(', ') + + ' (got: ' + JSON.stringify(entry.nesting) + ')', + ); + } + + // prefix — required; must be a string (may be empty string '') + if (entry.prefix === undefined || entry.prefix === null) { + errors.push(ctx + '.prefix is required (must be a string, may be empty)'); + } else if (typeof entry.prefix !== 'string') { + errors.push(ctx + '.prefix must be a string (got: ' + typeof entry.prefix + ')'); + } + + // recursive — optional; if present must be a boolean + if (entry.recursive !== undefined) { + if (typeof entry.recursive !== 'boolean') { + errors.push(ctx + '.recursive must be a boolean if present (got: ' + typeof entry.recursive + ')'); + } + } + + // converter — required; must be a string or null (closed ConverterName enum — now enforced in phase 5e) + if (!Object.prototype.hasOwnProperty.call(entry, 'converter')) { + errors.push(ctx + '.converter is required (must be a string or null)'); + } else if (entry.converter !== null && typeof entry.converter !== 'string') { + errors.push(ctx + '.converter must be a string or null (got: ' + typeof entry.converter + ')'); + } else if (entry.converter !== null && typeof entry.converter === 'string' && + !VALID_CONVERTER_NAMES.has(entry.converter)) { + // Closed ConverterName enum (ADR-857 phase 5e): reject unknown converter names + errors.push(ctx + '.converter "' + entry.converter + '" is not a known ConverterName'); + } + + return errors; +} + +/** + * Validate runtime.artifactLayout per ADR-1016 Decision 3. + * Accepts the structured { global, local } shape. + * Returns an array of error strings. + * + * @param {string} capId Capability id (for error messages) + * @param {*} layout The artifactLayout value + * @returns {string[]} + */ +function validateArtifactLayout(capId, layout) { + const errors = []; + const ctx = 'capability "' + capId + '" runtime.artifactLayout'; + + if (typeof layout !== 'object' || layout === null || Array.isArray(layout)) { + errors.push(ctx + ' must be an object with "global" and "local" arrays'); + return errors; + } + + for (const scope of ['global', 'local']) { + const arr = layout[scope]; + if (!Array.isArray(arr)) { + errors.push(ctx + '.' + scope + ' must be an array'); + } else { + for (let i = 0; i < arr.length; i++) { + errors.push(...validateArtifactKindEntry(capId, arr[i], 'artifactLayout.' + scope + '[' + i + ']')); + } + } + } + + return errors; +} + +function validateRuntimeBody(cap) { + const errors = []; + + // C3: feature-only fields must NOT appear on a runtime cap + for (const field of FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME) { + if (cap[field] !== undefined) { + errors.push('role:runtime capability must not have "' + field + '" (feature-only field)'); + } + } + + // C3: require a runtime object + if (typeof cap.runtime !== 'object' || cap.runtime === null || Array.isArray(cap.runtime)) { + errors.push('role:runtime capability must have a "runtime" object'); + return errors; // can't validate further without the object + } + + const r = cap.runtime; + + // configHome — must be a structured object (ADR-1016 Decision 1) + errors.push(...validateConfigHome(cap.id || '(unknown)', r.configHome)); + + // configFormat — closed 5-enum (unchanged) + if (!VALID_CONFIG_FORMATS.has(r.configFormat)) { + errors.push('runtime.configFormat must be one of: ' + [...VALID_CONFIG_FORMATS].join(', ') + ' (got: ' + r.configFormat + ')'); + } + + // artifactLayout — structured { global, local } per ADR-1016 Decision 3 + errors.push(...validateArtifactLayout(cap.id || '(unknown)', r.artifactLayout)); + + // commandStyle — closed 2-enum (ADR-1016 Decision 4); inline literal guard (CodeQL barrier) + if (r.commandStyle === '__proto__' || r.commandStyle === 'constructor' || r.commandStyle === 'prototype') { + errors.push('runtime.commandStyle "' + r.commandStyle + '" is a reserved name'); + } else if (!VALID_COMMAND_STYLES.has(r.commandStyle)) { + errors.push( + 'runtime.commandStyle must be one of: ' + [...VALID_COMMAND_STYLES].join(', ') + + ' (got: ' + JSON.stringify(r.commandStyle) + ')', + ); + } + + // hooksSurface — closed 6-enum (ADR-1016 Decision 5); inline literal guard (CodeQL barrier) + if (r.hooksSurface === '__proto__' || r.hooksSurface === 'constructor' || r.hooksSurface === 'prototype') { + errors.push('runtime.hooksSurface "' + r.hooksSurface + '" is a reserved name'); + } else if (!VALID_HOOKS_SURFACES.has(r.hooksSurface)) { + errors.push( + 'runtime.hooksSurface must be one of: ' + [...VALID_HOOKS_SURFACES].join(', ') + + ' (got: ' + JSON.stringify(r.hooksSurface) + ')', + ); + } + + // hookEvents — optional; if present must be in closed 3-enum (ADR-1016 Decision 5) + if (r.hookEvents !== undefined) { + if (r.hookEvents === '__proto__' || r.hookEvents === 'constructor' || r.hookEvents === 'prototype') { + errors.push('runtime.hookEvents "' + r.hookEvents + '" is a reserved name'); + } else if (!VALID_HOOK_EVENTS.has(r.hookEvents)) { + errors.push( + 'runtime.hookEvents must be one of: ' + [...VALID_HOOK_EVENTS].join(', ') + + ' (got: ' + JSON.stringify(r.hookEvents) + ')', + ); + } + } + + // sandboxTier — closed 2-enum (ADR-1016 Decision 6); inline literal guard (CodeQL barrier) + if (r.sandboxTier === '__proto__' || r.sandboxTier === 'constructor' || r.sandboxTier === 'prototype') { + errors.push('runtime.sandboxTier "' + r.sandboxTier + '" is a reserved name'); + } else if (!VALID_SANDBOX_TIERS.has(r.sandboxTier)) { + errors.push( + 'runtime.sandboxTier must be one of: ' + [...VALID_SANDBOX_TIERS].join(', ') + + ' (got: ' + JSON.stringify(r.sandboxTier) + ')', + ); + } + + // supportTier — 1 or 2 (unchanged) + if (r.supportTier !== 1 && r.supportTier !== 2) { + errors.push('runtime.supportTier must be 1 or 2 (got: ' + r.supportTier + ')'); + } + + // installSurface — required string in closed enum + if (!VALID_INSTALL_SURFACES.has(r.installSurface)) { + errors.push( + 'runtime.installSurface must be one of: ' + [...VALID_INSTALL_SURFACES].join(', ') + + ' (got: ' + JSON.stringify(r.installSurface) + ')', + ); + } + + // writesSharedSettings — required boolean + if (typeof r.writesSharedSettings !== 'boolean') { + errors.push( + 'runtime.writesSharedSettings must be a boolean (got: ' + JSON.stringify(r.writesSharedSettings) + ')', + ); + } + + // permissionWriter — required key; value must be null or a string in VALID_PERMISSION_WRITERS + if (!Object.prototype.hasOwnProperty.call(r, 'permissionWriter')) { + errors.push('runtime.permissionWriter is required (must be null or one of: ' + [...VALID_PERMISSION_WRITERS].join(', ') + ')'); + } else if (r.permissionWriter !== null && !VALID_PERMISSION_WRITERS.has(r.permissionWriter)) { + errors.push( + 'runtime.permissionWriter must be null or one of: ' + [...VALID_PERMISSION_WRITERS].join(', ') + + ' (got: ' + JSON.stringify(r.permissionWriter) + ')', + ); + } + + // extendedHookEvents — required array; every element must be in closed enum + if (!Array.isArray(r.extendedHookEvents)) { + errors.push( + 'runtime.extendedHookEvents must be an array (got: ' + JSON.stringify(r.extendedHookEvents) + ')', + ); + } else { + for (let i = 0; i < r.extendedHookEvents.length; i++) { + const ev = r.extendedHookEvents[i]; + if (typeof ev !== 'string' || !VALID_EXTENDED_HOOK_EVENTS.has(ev)) { + errors.push( + 'runtime.extendedHookEvents[' + i + '] must be one of: ' + [...VALID_EXTENDED_HOOK_EVENTS].join(', ') + + ' (got: ' + JSON.stringify(ev) + ')', + ); + } + } + } + + // GATE A: installSurface ↔ hooksSurface consistency (DEFECT.GENERATIVE-FIX) + // Only check if both fields are valid strings (individual field validators above report type errors). + if (typeof r.installSurface === 'string' && typeof r.hooksSurface === 'string') { + const allowedHooksSurfaces = INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES.get(r.installSurface); + if (allowedHooksSurfaces !== undefined && !allowedHooksSurfaces.has(r.hooksSurface)) { + errors.push( + 'runtime.hooksSurface "' + r.hooksSurface + '" is not valid for installSurface "' + r.installSurface + '"' + + ' — allowed: ' + [...allowedHooksSurfaces].join(', ') + + ' (src: INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES in scripts/gen-capability-registry.cjs)', + ); + } + } + + // GATE B: extendedHookEvents ↔ hookEvents consistency (DEFECT.GENERATIVE-FIX) + // If extendedHookEvents contains Gemini agent-events, hookEvents must be 'gemini'. + // If it contains Claude-family events, hookEvents must be 'claude'. + // Empty extendedHookEvents imposes no constraint. + if (Array.isArray(r.extendedHookEvents) && r.extendedHookEvents.length > 0) { + const hasGeminiEvents = r.extendedHookEvents.some((ev) => GEMINI_AGENT_EVENTS.has(ev)); + const hasClaudeEvents = r.extendedHookEvents.some((ev) => CLAUDE_FAMILY_EVENTS.has(ev)); + if (hasGeminiEvents && r.hookEvents !== 'gemini') { + errors.push( + 'runtime.extendedHookEvents contains Gemini agent-events (' + + r.extendedHookEvents.filter((ev) => GEMINI_AGENT_EVENTS.has(ev)).join(', ') + + ') but runtime.hookEvents is "' + r.hookEvents + '" — must be "gemini"', + ); + } + if (hasClaudeEvents && r.hookEvents !== 'claude') { + errors.push( + 'runtime.extendedHookEvents contains Claude-family events (' + + r.extendedHookEvents.filter((ev) => CLAUDE_FAMILY_EVENTS.has(ev)).join(', ') + + ') but runtime.hookEvents is "' + r.hookEvents + '" — must be "claude"', + ); + } + } + + return errors; +} + +function materializeHookFragments(cap, capDir) { + const errors = []; + const hookGroups = [ + ['steps', Array.isArray(cap.steps) ? cap.steps : []], + ['contributions', Array.isArray(cap.contributions) ? cap.contributions : []], + ]; + + for (const [groupName, hooks] of hookGroups) { + for (let i = 0; i < hooks.length; i++) { + const hook = hooks[i]; + if (!hook || typeof hook !== 'object' || Array.isArray(hook)) continue; + const fragment = hook.fragment; + if (!fragment || typeof fragment !== 'object' || Array.isArray(fragment)) continue; + if (typeof fragment.inline === 'string') continue; + if (typeof fragment.path !== 'string') continue; + + const abs = path.resolve(capDir, fragment.path); + const capRoot = path.resolve(capDir); + if (abs !== capRoot && !abs.startsWith(capRoot + path.sep)) { + errors.push( + cap.id + '/' + groupName + '[' + i + '].fragment.path escapes capability directory: ' + + fragment.path, + ); + continue; + } + + try { + fragment.inline = fs.readFileSync(abs, 'utf8'); + } catch (err) { + errors.push( + cap.id + '/' + groupName + '[' + i + '].fragment.path could not be read: ' + + fragment.path + ' (' + err.message + ')', + ); + } + } + } + + return errors; +} + +function validateFragment(fragment, prefix) { + const errors = []; + + if (typeof fragment !== 'object' || fragment === null || Array.isArray(fragment)) { + errors.push(prefix + ' must be an object with path or inline key'); + return errors; + } + + const hasPath = Object.prototype.hasOwnProperty.call(fragment, 'path'); + const hasInline = Object.prototype.hasOwnProperty.call(fragment, 'inline'); + if (!hasPath && !hasInline) { + errors.push(prefix + ' must have a "path" or "inline" key'); + } + if (hasInline) { + const inline = fragment.inline; + if (typeof inline !== 'string') { + errors.push(prefix + '.inline must be a string'); + } else if (inline === '') { + errors.push(prefix + '.inline must be a non-empty string'); + } + } + // S1: fragment.path traversal guard — must be a relative path with no ".." segments + if (hasPath) { + const p = fragment.path; + if (typeof p !== 'string' || p === '' || path.isAbsolute(p) || p.split(/[\\/]/).includes('..')) { + errors.push(prefix + '.path must be a relative path with no ".." segments'); + } + } + + return errors; +} + +/** + * Validate a single step entry. + * + * @param {object} step The step to validate. + * @param {string} prefix Path prefix for error messages (e.g. "steps[0]"). + * @param {Set|null} declaredSkills Set of skill stems declared in this capability's skills array, + * or null if the skills array was not valid (skip membership check). + * @param {Set|null} declaredAgents Set of agent names declared in this capability's agents array, + * or null if the agents array was not valid (skip membership check). + * @returns {string[]} + */ +function validateStep(step, prefix, declaredSkills, declaredAgents) { + const errors = []; + + if (!VALID_LOOP_POINTS.has(step.point)) { + errors.push(prefix + '.point "' + step.point + '" is not a valid loop point'); + } + + if (typeof step.ref !== 'object' || step.ref === null) { + errors.push(prefix + '.ref must be an object with skill, agent, or command key'); + } else { + const hasSkill = Object.prototype.hasOwnProperty.call(step.ref, 'skill'); + const hasAgent = Object.prototype.hasOwnProperty.call(step.ref, 'agent'); + const hasCommand = Object.prototype.hasOwnProperty.call(step.ref, 'command'); + const dispatchCount = [hasSkill, hasAgent, hasCommand].filter(Boolean).length; + if (dispatchCount === 0) { + errors.push(prefix + '.ref must have a "skill", "agent", or "command" key'); + } else if (dispatchCount > 1) { + // ref must be exclusive: skill XOR agent XOR command + errors.push(prefix + '.ref must have exactly one of "skill", "agent", or "command", not multiple'); + } + if (hasSkill && typeof step.ref.skill !== 'string') { + errors.push(prefix + '.ref.skill must be a string'); + } else if (hasSkill && typeof step.ref.skill === 'string' && step.ref.skill.startsWith('gsd-')) { + // Double-prefix guard: ref.skill is an unprefixed stem (e.g. "ui-review"). + // Workflow dispatch prepends "gsd-" at runtime → "gsd-ui-review". + // A stem that already starts with "gsd-" would produce "gsd-gsd-..." at dispatch. + errors.push( + prefix + '.ref.skill "' + step.ref.skill + '" must not start with "gsd-" ' + + '(it is an unprefixed stem; the workflow prepends "gsd-" at dispatch — ' + + 'starting with "gsd-" would produce "gsd-' + step.ref.skill + '")', + ); + } else if (hasSkill && typeof step.ref.skill === 'string' && declaredSkills !== null && !declaredSkills.has(step.ref.skill)) { + // Membership check: ref.skill must be declared in this capability's skills array. + // This catches typos and ensures every dispatched skill is owned by this capability. + errors.push( + prefix + '.ref.skill "' + step.ref.skill + '" is not declared in this capability\'s skills: [' + + [...declaredSkills].join(', ') + ']', + ); + } + if (hasAgent && typeof step.ref.agent !== 'string') { + errors.push(prefix + '.ref.agent must be a string'); + } else if (hasAgent && typeof step.ref.agent === 'string' && declaredAgents !== null && !declaredAgents.has(step.ref.agent)) { + // Membership check: ref.agent must be declared in this capability's agents array. + errors.push( + prefix + '.ref.agent "' + step.ref.agent + '" is not declared in this capability\'s agents: [' + + [...declaredAgents].join(', ') + ']', + ); + } + if (hasCommand && typeof step.ref.command !== 'string') { + errors.push(prefix + '.ref.command must be a string'); + } + } + + if (!Array.isArray(step.produces)) { + errors.push(prefix + '.produces must be an array'); + } else { + for (const p of step.produces) { + if (typeof p !== 'string') errors.push(prefix + '.produces entries must be strings'); + } + } + + if (!Array.isArray(step.consumes)) { + errors.push(prefix + '.consumes must be an array'); + } else { + for (const c of step.consumes) { + if (typeof c !== 'string') errors.push(prefix + '.consumes entries must be strings'); + } + } + + if (step.when !== undefined && typeof step.when !== 'string') { + errors.push(prefix + '.when must be a string if present'); + } + + if (step.fragment !== undefined) { + errors.push(...validateFragment(step.fragment, prefix + '.fragment')); + } + + if (!VALID_ON_ERROR.has(step.onError)) { + errors.push(prefix + '.onError must be "skip" or "halt" (got: ' + step.onError + ')'); + } + + return errors; +} + +function validateContribution(contrib, prefix) { + const errors = []; + + if (!VALID_LOOP_POINTS.has(contrib.point)) { + errors.push(prefix + '.point "' + contrib.point + '" is not a valid loop point'); + } + + if (typeof contrib.into !== 'string') { + errors.push(prefix + '.into must be a string (agent role name)'); + } + + if (!Array.isArray(contrib.produces)) { + errors.push(prefix + '.produces must be an array'); + } else { + for (const p of contrib.produces) { + if (typeof p !== 'string') errors.push(prefix + '.produces entries must be strings'); + } + } + + if (!Array.isArray(contrib.consumes)) { + errors.push(prefix + '.consumes must be an array'); + } else { + for (const c of contrib.consumes) { + if (typeof c !== 'string') errors.push(prefix + '.consumes entries must be strings'); + } + } + + errors.push(...validateFragment(contrib.fragment, prefix + '.fragment')); + + if (contrib.when !== undefined && typeof contrib.when !== 'string') { + errors.push(prefix + '.when must be a string if present'); + } + + if (contrib.onError !== undefined && !VALID_ON_ERROR.has(contrib.onError)) { + errors.push(prefix + '.onError must be "skip" or "halt" if present'); + } + + return errors; +} + +function validateGate(gate, prefix) { + const errors = []; + + if (!VALID_LOOP_POINTS.has(gate.point)) { + errors.push(prefix + '.point "' + gate.point + '" is not a valid loop point'); + } + + if (typeof gate.check !== 'object' || gate.check === null) { + errors.push(prefix + '.check must be an object'); + } else { + const hasQuery = Object.prototype.hasOwnProperty.call(gate.check, 'query'); + const hasPredicate = Object.prototype.hasOwnProperty.call(gate.check, 'predicate'); + const hasAgentVerdict = Object.prototype.hasOwnProperty.call(gate.check, 'agentVerdict'); + const count = [hasQuery, hasPredicate, hasAgentVerdict].filter(Boolean).length; + if (count !== 1) { + errors.push(prefix + '.check must have exactly one of: query, predicate, agentVerdict'); + } + // agentVerdict forces blocking: false (advisory only) + if (hasAgentVerdict && gate.blocking === true) { + errors.push( + prefix + '.check.agentVerdict forces blocking: false (non-deterministic checks may not halt the loop)', + ); + } + } + + if (gate.when !== undefined && typeof gate.when !== 'string') { + errors.push(prefix + '.when must be a string if present'); + } + + if (typeof gate.blocking !== 'boolean') { + errors.push(prefix + '.blocking must be a boolean'); + } + + if (!VALID_ON_ERROR.has(gate.onError)) { + errors.push(prefix + '.onError must be "skip" or "halt" (got: ' + gate.onError + ')'); + } + + return errors; +} + +// ─── Contract validation ────────────────────────────────────────────────────── + +/** + * Validate per-capability contract constraints against the Loop Host Contract. + * This covers: + * - contribution.into ∈ step's agentRoles + * - when references a config key in cap.config + * + * NOTE: step.consumes satisfiability is NOT checked here — it requires the full + * set of validated capabilities (cross-capability produces). It runs in + * validateConsumesGlobal() after loadAndValidate builds capMap. + * + * @param {object} cap Validated capability object + * @param {string} capId Capability id (for error messages) + */ +function validateAgainstContract(cap, capId) { + if (cap.role !== 'feature') return []; + const errors = []; + const prefix = 'capability "' + capId + '"'; + + // contribution.into must be in the step's agentRoles + for (const contrib of cap.contributions) { + if (!VALID_LOOP_POINTS.has(contrib.point)) continue; // already reported + const contract = POINT_TO_CONTRACT.get(contrib.point); + if (contract && !contract.agentRoles.includes(contrib.into)) { + errors.push( + prefix + ' contribution.into "' + contrib.into + '" at point "' + contrib.point + + '" is not in the step\'s agentRoles [' + contract.agentRoles.join(', ') + ']', + ); + } + } + + // when references a plausibly-valid config key (string — we require it's in cap.config) + for (const step of cap.steps) { + if (step.when !== undefined) { + if (typeof step.when !== 'string') continue; // already reported above + if ( + typeof cap.config === 'object' && + cap.config !== null && + !Object.prototype.hasOwnProperty.call(cap.config, step.when) + ) { + errors.push( + prefix + ' step.when "' + step.when + '" is not defined in capability config keys', + ); + } + } + } + + for (const contrib of cap.contributions) { + if (contrib.when !== undefined) { + if (typeof contrib.when !== 'string') continue; + if ( + typeof cap.config === 'object' && + cap.config !== null && + !Object.prototype.hasOwnProperty.call(cap.config, contrib.when) + ) { + errors.push( + prefix + ' contribution.when "' + contrib.when + '" is not defined in capability config keys', + ); + } + } + } + + for (const gate of cap.gates) { + if (gate.when !== undefined) { + if (typeof gate.when !== 'string') continue; + if ( + typeof cap.config === 'object' && + cap.config !== null && + !Object.prototype.hasOwnProperty.call(cap.config, gate.when) + ) { + errors.push( + prefix + ' gate.when "' + gate.when + '" is not defined in capability config keys', + ); + } + } + } + + return errors; +} + +/** + * C1+C2: Global consumes-satisfiability validation. + * + * A hook at point P consuming artifact A is satisfiable iff: + * - A is a host-produced artifact available from its step's :post point (C1), and + * that :post point's POINT_ORDER index ≤ P's index; OR + * - A is produced by any capability hook step at a point whose POINT_ORDER index ≤ P's index + * (same-point is OK — topoSortSteps enforces intra-point order); OR + * - A is never produced anywhere → rejected. + * + * Runs after capMap is fully built so cross-capability produces are visible. + * + * @param {Map} capMap Fully-validated capability map. + * @returns {string[]} Array of error strings. + */ +function validateConsumesGlobal(capMap) { + const errors = []; + + // Build producedAtPoint: artifact → earliest POINT_ORDER index at which it is produced. + // Seed with host artifacts (C1: available from their step's :post point). + // Host-artifact entries are tagged {pointIdx, isHost:true} so they are never excluded by + // the self-consume check. + const producedAtPoint = Object.create(null); + for (const [artifact, postIdx] of Object.entries(HOST_ARTIFACT_EARLIEST_POINT_IDX)) { + if (artifact === '__proto__' || artifact === 'constructor' || artifact === 'prototype') continue; + producedAtPoint[artifact] = postIdx; + } + + // Build a richer per-artifact producer list for the self-consume check. + // Each entry: { pointIdx, capId, stepIdx } — identifies which cap+step produced the artifact. + // Host artifacts are seeded separately (no capId) and always satisfy the consume check. + // capHookProducers[artifact] = [{pointIdx, capId, stepIdx}, ...] + const capHookProducers = Object.create(null); + + // Add hook-produced artifacts from all capabilities. + for (const [capId, cap] of capMap) { + if (cap.role !== 'feature') continue; + for (let si = 0; si < (cap.steps || []).length; si++) { + const step = cap.steps[si]; + if (!VALID_LOOP_POINTS.has(step.point)) continue; + const pointIdx = POINT_ORDER.indexOf(step.point); + for (const artifact of (step.produces || [])) { + if (typeof artifact !== 'string') continue; + if (artifact === '__proto__' || artifact === 'constructor' || artifact === 'prototype') continue; + if (producedAtPoint[artifact] === undefined || pointIdx < producedAtPoint[artifact]) { + producedAtPoint[artifact] = pointIdx; + } + if (!capHookProducers[artifact]) capHookProducers[artifact] = []; + capHookProducers[artifact].push({ pointIdx, capId, stepIdx: si }); + } + } + } + + // Duplicate-producer invariant: two capability steps may not produce the same artifact + // at the same Loop Extension Point. Same-point dual production makes data-flow resolution + // ambiguous and is rejected at gen time (Decision #6). + for (const artifact of Object.keys(capHookProducers)) { + if (artifact === '__proto__' || artifact === 'constructor' || artifact === 'prototype') continue; + const producers = capHookProducers[artifact]; + // Group by pointIdx + const byPoint = Object.create(null); + for (const entry of producers) { + if (!byPoint[entry.pointIdx]) byPoint[entry.pointIdx] = []; + byPoint[entry.pointIdx].push(entry); + } + for (const pointIdxStr of Object.keys(byPoint)) { + const group = byPoint[pointIdxStr]; + // Count distinct (capId, stepIdx) producer steps — a single step listing the same + // artifact twice in its produces array pushes duplicate entries but represents only + // ONE producer step and must not false-positive the cross-step gate. + const distinctProducers = new Set(group.map((e) => e.capId + ' ' + e.stepIdx)); + if (distinctProducers.size >= 2) { + const pointIdx = Number(pointIdxStr); + const pointName = POINT_ORDER[pointIdx]; + const capIds = [...new Set(group.map((e) => e.capId))].sort().join(', '); + throw new Error( + 'duplicate-producer invariant violated: artifact "' + artifact + '" is produced by ' + + 'two or more capability steps at the same Loop Extension Point "' + pointName + '" ' + + '(capabilities: ' + capIds + '). ' + + 'Two capability steps producing the same artifact at the same Loop Extension Point ' + + 'makes data-flow resolution ambiguous and is rejected at gen time.', + ); + } + } + } + + // Now check every hook step's consumes. + // Self-consume rule: a step H cannot satisfy its own consumes[A] from its own produces[A]. + // A is satisfiable for H iff: + // (a) A is a host artifact with pointIdx <= stepPointIdx, OR + // (b) A is produced by a DIFFERENT cap/step at pointIdx <= stepPointIdx. + // "Different" means capId != H.capId OR stepIdx != H.stepIdx. + for (const [capId, cap] of capMap) { + if (cap.role !== 'feature') continue; + const prefix = 'capability "' + capId + '"'; + for (let si = 0; si < (cap.steps || []).length; si++) { + const step = cap.steps[si]; + if (!VALID_LOOP_POINTS.has(step.point)) continue; + const stepPointIdx = POINT_ORDER.indexOf(step.point); + for (const artifact of (step.consumes || [])) { + if (typeof artifact !== 'string') continue; + + // Check host-artifact satisfaction first (never excluded by self-consume). + const hostIdx = HOST_ARTIFACT_EARLIEST_POINT_IDX[artifact]; + const hostSatisfied = hostIdx !== undefined && hostIdx <= stepPointIdx; + if (hostSatisfied) continue; // fast-path: host artifact is available + + // Check cap-hook producers, excluding this step itself. + const producers = capHookProducers[artifact]; + if (!producers || producers.length === 0) { + // Not a host artifact and never produced by any hook. + errors.push( + prefix + ' step at point "' + step.point + '" consumes "' + artifact + + '" which is never produced by any host artifact or capability hook', + ); + continue; + } + + // Find any non-self producer at pointIdx <= stepPointIdx. + const otherEarliestIdx = producers.reduce((best, p) => { + const isSelf = p.capId === capId && p.stepIdx === si; + if (isSelf) return best; + return (best === undefined || p.pointIdx < best) ? p.pointIdx : best; + }, undefined); + + if (otherEarliestIdx === undefined) { + // Only producer is this step itself — self-consume violation. + errors.push( + prefix + ' step at point "' + step.point + '" consumes "' + artifact + + '" which is only produced by this step itself (a step cannot consume its own output)', + ); + } else if (otherEarliestIdx > stepPointIdx) { + errors.push( + prefix + ' step at point "' + step.point + '" consumes "' + artifact + + '" which is only produced after this point (earliest available at POINT_ORDER index ' + + otherEarliestIdx + ' = "' + POINT_ORDER[otherEarliestIdx] + '")', + ); + } + // else: satisfied by another cap/step at an earlier-or-same point — OK. + } + } + } + + return errors; +} + +// ─── Cross-capability invariants ────────────────────────────────────────────── + +const TIER_RANK = { core: 0, standard: 1, full: 2 }; + +/** + * Enforce cross-capability invariants. + * + * @param {Map} capMap id → validated capability object + * @param {Set} centralKeys Set of keys in the central config-schema + * @returns {string[]} Array of error strings; empty = all pass. + */ +function validateCrossCapability(capMap, centralKeys) { + const errors = []; + + // Ownership: one owner per skill stem + agent name + const skillOwner = new Map(); // skill → capId + const agentOwner = new Map(); // agent → capId + const familyOwner = new Map(); // command family → capId (ADR-959) + for (const [capId, cap] of capMap) { + if (cap.role !== 'feature') continue; + for (const skill of cap.skills) { + if (skillOwner.has(skill)) { + errors.push( + 'skill "' + skill + '" is owned by both "' + skillOwner.get(skill) + '" and "' + capId + '"', + ); + } else { + skillOwner.set(skill, capId); + } + } + for (const agent of cap.agents) { + if (agentOwner.has(agent)) { + errors.push( + 'agent "' + agent + '" is owned by both "' + agentOwner.get(agent) + '" and "' + capId + '"', + ); + } else { + agentOwner.set(agent, capId); + } + } + // ADR-959: single family ownership across the whole registry + if (Array.isArray(cap.commands)) { + for (const cmd of cap.commands) { + if (typeof cmd.family !== 'string' || cmd.family.length === 0) continue; // already reported + if (cmd.family === '__proto__' || cmd.family === 'constructor' || cmd.family === 'prototype') continue; + if (familyOwner.has(cmd.family)) { + errors.push( + 'command family "' + cmd.family + '" is owned by both "' + familyOwner.get(cmd.family) + '" and "' + capId + '"', + ); + } else { + familyOwner.set(cmd.family, capId); + } + } + } + } + + // Config key ownership: exclusive AND absent from central schema + const configKeyOwner = new Map(); // key → capId + for (const [capId, cap] of capMap) { + if (cap.role !== 'feature' || typeof cap.config !== 'object' || cap.config === null) continue; + for (const key of Object.keys(cap.config)) { + if (configKeyOwner.has(key)) { + errors.push( + 'config key "' + key + '" is owned by both "' + configKeyOwner.get(key) + '" and "' + capId + '"', + ); + } else { + configKeyOwner.set(key, capId); + } + if (centralKeys.has(key)) { + errors.push( + 'config key "' + key + '" is declared in capability "' + capId + + '" AND exists in the central config-schema — migration mid-flight: ' + + 'remove from central config-schema before adding to the capability', + ); + } + } + } + + // requires: all ids exist + for (const [capId, cap] of capMap) { + if (!Array.isArray(cap.requires)) continue; + for (const req of cap.requires) { + if (!capMap.has(req)) { + errors.push( + 'capability "' + capId + '" requires "' + req + '" which does not exist', + ); + } + } + } + + // runtimeCompat: explicit runtime ids must reference runtime capabilities. + // The wildcard "*" means descriptor-backed runtimes are supported by default. + const runtimeIds = new Set(); + for (const [id, cap] of capMap) { + if (cap.role === 'runtime') runtimeIds.add(id); + } + for (const [capId, cap] of capMap) { + if (cap.role !== 'feature' || typeof cap.runtimeCompat !== 'object' || cap.runtimeCompat === null) continue; + for (const field of ['supported', 'unsupported']) { + const entries = Array.isArray(cap.runtimeCompat[field]) ? cap.runtimeCompat[field] : []; + for (const runtimeId of entries) { + if (runtimeId === RUNTIME_COMPAT_WILDCARD) continue; + if (typeof runtimeId !== 'string' || runtimeId.length === 0) continue; + if (!runtimeIds.has(runtimeId)) { + errors.push( + 'capability "' + capId + '" runtimeCompat.' + field + + ' references unknown runtime "' + runtimeId + '"', + ); + } + } + } + if (cap.runtimeCompat.notes && typeof cap.runtimeCompat.notes === 'object') { + for (const runtimeId of Object.keys(cap.runtimeCompat.notes)) { + if (runtimeId === RUNTIME_COMPAT_WILDCARD) continue; + if (!runtimeIds.has(runtimeId)) { + errors.push( + 'capability "' + capId + '" runtimeCompat.notes references unknown runtime "' + runtimeId + '"', + ); + } + } + } + } + + // requires: acyclic + const cycleErrors = detectRequiresCycles(capMap); + errors.push(...cycleErrors); + + // requires: tier-monotone (core may not require standard/full; standard may not require full) + for (const [capId, cap] of capMap) { + if (!Array.isArray(cap.requires) || !VALID_TIERS.has(cap.tier)) continue; + const myRank = TIER_RANK[cap.tier]; + for (const req of cap.requires) { + const reqCap = capMap.get(req); + if (!reqCap || !VALID_TIERS.has(reqCap.tier)) continue; + const reqRank = TIER_RANK[reqCap.tier]; + if (reqRank > myRank) { + errors.push( + 'tier-monotone violation: capability "' + capId + '" (tier: ' + cap.tier + + ') requires "' + req + '" (tier: ' + reqCap.tier + + ') — a capability may not require a higher-tier capability', + ); + } + } + } + + return errors; +} + +/** + * Detect cycles in the requires graph using DFS. + */ +function detectRequiresCycles(capMap) { + const errors = []; + const WHITE = 0, GRAY = 1, BLACK = 2; + const color = new Map([...capMap.keys()].map((k) => [k, WHITE])); + + function dfs(id, stack) { + if (color.get(id) === GRAY) { + const cycleStr = [...stack, id].join(' → '); + errors.push('requires cycle detected: ' + cycleStr); + return; + } + if (color.get(id) === BLACK) return; + color.set(id, GRAY); + stack.push(id); + const cap = capMap.get(id); + if (cap && Array.isArray(cap.requires)) { + for (const req of cap.requires) { + if (capMap.has(req)) dfs(req, stack); + } + } + stack.pop(); + color.set(id, BLACK); + } + + for (const id of capMap.keys()) { + if (color.get(id) === WHITE) dfs(id, []); + } + + return errors; +} + +// ─── requiresClosure ───────────────────────────────────────────────────────── + +/** + * Compute the transitive requires closure for a capability id. + * Returns a Set of all transitively required capability ids. + * + * @param {string} id + * @param {Map} capMap + */ +function computeRequiresClosure(id, capMap) { + const visited = new Set(); + const queue = [id]; + while (queue.length > 0) { + const current = queue.shift(); + const cap = capMap.get(current); + if (!cap || !Array.isArray(cap.requires)) continue; + for (const req of cap.requires) { + if (!visited.has(req)) { + visited.add(req); + queue.push(req); + } + } + } + return visited; +} + +// ─── Topological ordering ───────────────────────────────────────────────────── + +function topoSortHookEntries(entries, hookKey, hookKind) { + if (entries.length <= 1) return entries; + + // Build adjacency: entry A must come before entry B if B consumes something A produces + const n = entries.length; + const inDegree = new Array(n).fill(0); + const adj = Array.from({ length: n }, () => []); + + for (let i = 0; i < n; i++) { + const producesI = new Set(entries[i][hookKey].produces || []); + for (let j = 0; j < n; j++) { + if (i === j) continue; + const consumesJ = entries[j][hookKey].consumes || []; + for (const artifact of consumesJ) { + if (producesI.has(artifact)) { + adj[i].push(j); + inDegree[j]++; + break; + } + } + } + } + + // Kahn's algorithm with stable tiebreak on capId + const queue = []; + for (let i = 0; i < n; i++) { + if (inDegree[i] === 0) queue.push(i); + } + // Sort queue by capId for determinism + queue.sort((a, b) => entries[a].capId.localeCompare(entries[b].capId)); + + const result = []; + while (queue.length > 0) { + // Take the first (sorted) ready node + const idx = queue.shift(); + result.push(entries[idx]); + const newReady = []; + for (const neighbor of adj[idx]) { + inDegree[neighbor]--; + if (inDegree[neighbor] === 0) newReady.push(neighbor); + } + newReady.sort((a, b) => entries[a].capId.localeCompare(entries[b].capId)); + queue.push(...newReady); + } + + // Fix #2: if result.length < n, Kahn's could not complete — there is a produces/consumes + // cycle. Do NOT silently fall back to declaration order; throw a clear error. + if (result.length < n) { + const sortedIds = entries.map((e) => e.capId).join(', '); + throw new Error( + 'produces/consumes cycle detected in ' + hookKind + ' at point "' + + (entries[0] && entries[0][hookKey] ? entries[0][hookKey].point : '?') + + '" among capabilities [' + sortedIds + ']: ' + + 'a cycle in hook produces/consumes prevents deterministic ordering', + ); + } + return result; +} + +/** + * Topologically sort steps at a given point by produces/consumes. + * Capability-id tiebreak for determinism. + * + * @param {{ capId: string, step: object }[]} entries + * @returns {{ capId: string, step: object }[]} + */ +function topoSortSteps(entries) { + return topoSortHookEntries(entries, 'step', 'steps'); +} + +function topoSortContributions(entries) { + return topoSortHookEntries(entries, 'contrib', 'contributions'); +} + +// ─── Gen-time wired guard ───────────────────────────────────────────────────── + +/** + * Validate that every hook point declared by a capability has a corresponding + * `loop render-hooks ` call site in one of the host-loop workflow files. + * + * Only valid loop points (in VALID_LOOP_POINTS) are checked here. Invalid points + * are already caught by validateStep/validateContribution/validateGate — do not + * double-report. + * + * @param {object} cap Validated capability object. + * @param {Set} wiredSet Set of points that have call sites in host workflows. + * @returns {string[]} Array of error strings; empty means all points are wired. + */ +function validateHooksWired(cap, wiredSet) { + const errors = []; + const capId = cap.id || '(unknown)'; + + function checkPoint(point, groupName, idx) { + // Only flag valid points that are unwired — invalid points are schema-validator's job. + if (!VALID_LOOP_POINTS.has(point)) return; + if (!wiredSet.has(point)) { + errors.push( + 'capability "' + capId + '" ' + groupName + '[' + idx + '].point "' + point + + '" is declared but not wired in any host-loop workflow ' + + '(no `loop render-hooks ' + point + '` call site). ' + + 'Wire the call site in the host workflow ' + + '(see scripts/gen-loop-host-contract.cjs STEP_WORKFLOWS) or remove the hook.', + ); + } + } + + for (let i = 0; i < (cap.steps || []).length; i++) { + const hook = cap.steps[i]; + if (hook.point !== undefined) checkPoint(hook.point, 'steps', i); + } + for (let i = 0; i < (cap.contributions || []).length; i++) { + const hook = cap.contributions[i]; + if (hook.point !== undefined) checkPoint(hook.point, 'contributions', i); + } + for (let i = 0; i < (cap.gates || []).length; i++) { + const hook = cap.gates[i]; + if (hook.point !== undefined) checkPoint(hook.point, 'gates', i); + } + + return errors; +} + +// ─── classifyCrossErrors ────────────────────────────────────────────────────── + +/** + * Fix #3: Emit pending-migration WARNINGs for config keys that collide with the central + * config-schema. Per ADR-894 staged cutover, a collision during the registry-only phase is + * NOT a hard error — the capability pipeline is being established before the atomic cutover + * PR for each feature. The registry still generates; the warning tells the maintainer which + * keys need to be moved out of the central schema at cutover time. + * + * A NEW unexpected collision (a key that shouldn't be in both) is also surfaced — the + * maintainer sees it in build output rather than it being silently swallowed. + * + * Reference: ADR-894 §4 "config-key ownership exclusive AND complete — presence in both = + * collision = a mid-flight migration; finish the move." + * + * @param {string[]} crossErrors Errors from validateCrossCapability (may include collision msgs) + * @returns {{ hardErrors: string[], pendingMigrationWarnings: string[] }} + */ +function classifyCrossErrors(crossErrors) { + const hardErrors = []; + const pendingMigrationWarnings = []; + const collisionRe = /config key "([^"]+)" is declared in capability "([^"]+)" AND exists in the central config-schema/; + + for (const e of crossErrors) { + const m = collisionRe.exec(e); + if (m) { + // Collision = pending-migration warning, not a hard error during 3a-impl staged cutover + pendingMigrationWarnings.push( + '⚠ pending-migration: capability \'' + m[2] + '\' declares config key \'' + m[1] + + '\' still present in central config-schema; finish the move at cutover', + ); + } else { + hardErrors.push(e); + } + } + return { hardErrors, pendingMigrationWarnings }; +} + +// ─── ADR-857 phase 5e: configFormat ↔ installSurface parity gate ───────────── + +// Map: installSurface → expected configFormat +// Derived from the pairing of capability.json descriptors (installSurface) +// and capability.json descriptors (configFormat). DEFECT.GENERATIVE-FIX: this map +// is the single parity contract between the two generated surfaces. +// NOTE: both values come from the descriptor bodies in capMap — no dependency on +// runtime-config-adapter-registry.cjs, which now requires capability-registry.cjs +// (the file this gen-script produces), and thus must not be required here. +const INSTALL_SURFACE_TO_CONFIG_FORMAT = new Map([ + ['settings-json', 'settings-json'], + ['codex-toml', 'toml'], + ['copilot-instructions', 'markdown'], + ['cline-rules', 'markdown-dir'], + ['cursor-hooks-json', 'none'], + ['profile-marker-only', 'none'], +]); + +/** + * ADR-857 phase 5e: configFormat ↔ installSurface parity gate. + * + * For each runtime capability that has an installSurface in its descriptor, + * assert that its configFormat matches the expected value derived from its + * installSurface. Both values are read directly from the capMap descriptor + * bodies — no dependency on runtime-config-adapter-registry.cjs. + * + * HARD gate — throws on mismatch (DEFECT.GENERATIVE-FIX: this invariant is + * derived from two parallel generated surfaces and must fail loudly). + * + * @param {Map} capMap Fully-validated capability map. + * @returns {void} Throws on mismatch; returns normally on success. + */ +function runConfigFormatParityGate(capMap) { + // Read installSurface directly from the descriptor bodies already loaded into + // capMap — eliminates the require cycle introduced when adapter-registry was + // changed to require capability-registry.cjs (ADR-857 phase 5g drive 2). + for (const [capId, cap] of capMap) { + if (cap.role !== 'runtime') continue; + + const r = cap.runtime; + if (!r || typeof r.configFormat !== 'string') continue; // already validated above + + // Only check runtimes that have an installSurface (i.e. are config-adapter runtimes) + if (typeof r.installSurface !== 'string') continue; // grok etc. excluded — no installSurface + + const installSurface = r.installSurface; + const expectedConfigFormat = INSTALL_SURFACE_TO_CONFIG_FORMAT.get(installSurface); + + if (expectedConfigFormat === undefined) { + // Unknown installSurface — the mapping needs to be updated + throw new Error( + 'configFormat parity gate: runtime "' + capId + '" has installSurface "' + installSurface + + '" which is not in the INSTALL_SURFACE_TO_CONFIG_FORMAT mapping — ' + + 'update the mapping in scripts/gen-capability-registry.cjs', + ); + } + + if (r.configFormat !== expectedConfigFormat) { + throw new Error( + 'configFormat parity gate FAILED for runtime "' + capId + '":\n' + + ' installSurface: ' + installSurface + '\n' + + ' expected configFormat: ' + expectedConfigFormat + '\n' + + ' actual configFormat: ' + r.configFormat + '\n' + + 'The capability.json configFormat must match the value derived from installSurface ' + + '(src: scripts/gen-capability-registry.cjs INSTALL_SURFACE_TO_CONFIG_FORMAT)', + ); + } + } +} + +// ─── Exports ────────────────────────────────────────────────────────────────── + +module.exports = { + // Constants + SCHEMA_VERSION, + POINT_ORDER, + HOST_ARTIFACT_EARLIEST_POINT_IDX, + VALID_LOOP_POINTS, + POINT_TO_CONTRACT, + VALID_CONFIG_SLICE_TYPES, + KEBAB_RE, + VALID_ROLES, + VALID_TIERS, + VALID_ON_ERROR, + RUNTIME_COMPAT_WILDCARD, + SEMVER_RE, + SEMVER_RANGE_RE, + SHA512_INTEGRITY_RE, + VALID_CONVERTER_NAMES, + VALID_CONFIG_FORMATS, + VALID_CONFIG_HOME_KINDS, + VALID_COMMAND_STYLES, + VALID_HOOKS_SURFACES, + VALID_HOOK_EVENTS, + VALID_SANDBOX_TIERS, + VALID_ARTIFACT_KIND_NAMES, + VALID_ARTIFACT_NESTINGS, + FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME, + VALID_INSTALL_SURFACES, + VALID_PERMISSION_WRITERS, + VALID_EXTENDED_HOOK_EVENTS, + INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES, + GEMINI_AGENT_EVENTS, + CLAUDE_FAMILY_EVENTS, + TIER_RANK, + INSTALL_SURFACE_TO_CONFIG_FORMAT, + // Functions + isPlausibleRange, + validateVersionEnvelope, + validateCapability, + validateCommandEntry, + validateRuntimeCompat, + validateFeatureBody, + validateConfigHome, + validateArtifactKindEntry, + validateArtifactLayout, + validateRuntimeBody, + materializeHookFragments, + validateFragment, + validateStep, + validateContribution, + validateGate, + validateAgainstContract, + validateConsumesGlobal, + validateCrossCapability, + detectRequiresCycles, + computeRequiresClosure, + topoSortHookEntries, + topoSortSteps, + topoSortContributions, + validateHooksWired, + validateConfigSliceEntry, + classifyCrossErrors, + runConfigFormatParityGate, +}; diff --git a/scripts/gen-capability-registry.cjs b/scripts/gen-capability-registry.cjs index b277a9d32..d16805058 100644 --- a/scripts/gen-capability-registry.cjs +++ b/scripts/gen-capability-registry.cjs @@ -25,8 +25,6 @@ const CAPABILITIES_DIR = path.join(ROOT, 'capabilities'); const REGISTRY_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'capability-registry.cjs'); const CONFIG_SCHEMA_PATH = path.join(ROOT, 'gsd-core', 'bin', 'shared', 'config-schema.manifest.json'); -const SCHEMA_VERSION = '1'; - // ─── Loop Host Contract ─────────────────────────────────────────────────────── // // Generated from workflow markers by scripts/gen-loop-host-contract.cjs (ADR-894 §3). @@ -37,58 +35,54 @@ const { LOOP_HOST_CONTRACT } = require('../gsd-core/bin/lib/loop-host-contract.c // Wired-points helper — tells us which points actually have render-hooks call sites. const { getWiredLoopPoints } = require('./gen-loop-host-contract.cjs'); -// Canonical point order — explicit constant (do NOT rely on Set insertion order). -// Used for point-ordering semantics in consumes-satisfiability validation and topo-sort. -const POINT_ORDER = [ - 'discuss:pre', - 'discuss:post', - 'plan:pre', - 'plan:post', - 'execute:pre', - 'execute:wave:pre', - 'execute:wave:post', - 'execute:post', - 'verify:pre', - 'verify:post', - 'ship:pre', - 'ship:post', -]; - -// C1: Artifact availability — host-produced artifacts become available at their step's :post -// point. Build a map: artifact → earliest POINT_ORDER index at which it is available. -// (discuss produces CONTEXT.md → discuss:post = index 1; -// plan produces PLAN.md → plan:post = index 3; -// execute produces SUMMARY.md → execute:post = index 7; -// verify produces UAT.md → verify:post = index 9) -// -// NOTE: this map covers ONLY host artifacts. Hook-produced artifacts are handled per-run -// during consumes-satisfiability validation (C2 global pass). -const HOST_ARTIFACT_EARLIEST_POINT_IDX = (() => { - const m = Object.create(null); - for (const entry of LOOP_HOST_CONTRACT) { - // The :post point is the last point in each step's points array. - const postPoint = entry.points[entry.points.length - 1]; - const postIdx = POINT_ORDER.indexOf(postPoint); - for (const artifact of entry.coreArtifacts.produces) { - // Only record the earliest (should be unique, but take min to be safe). - if (m[artifact] === undefined || postIdx < m[artifact]) { - m[artifact] = postIdx; - } - } - } - return m; -})(); - -// Flatten all valid loop points into a Set for O(1) validation -const VALID_LOOP_POINTS = new Set(POINT_ORDER); - -// Map point → step contract (agentRoles + coreArtifacts) -const POINT_TO_CONTRACT = new Map(); -for (const entry of LOOP_HOST_CONTRACT) { - for (const point of entry.points) { - POINT_TO_CONTRACT.set(point, entry); - } -} +// Capability validator — shared runtime-callable module extracted per ADR-1244 D2. +const capValidator = require('../gsd-core/bin/lib/capability-validator.cjs'); +// Destructure only what the generator's own function bodies reference directly. +// Everything else is re-exported from capValidator in module.exports below. +const { + POINT_ORDER, + HOST_ARTIFACT_EARLIEST_POINT_IDX, + VALID_LOOP_POINTS, + POINT_TO_CONTRACT, + VALID_CONFIG_SLICE_TYPES, + VALID_TIERS, + SEMVER_RE, + SEMVER_RANGE_RE, + SHA512_INTEGRITY_RE, + VALID_CONVERTER_NAMES, + VALID_CONFIG_HOME_KINDS, + VALID_COMMAND_STYLES, + VALID_HOOKS_SURFACES, + VALID_HOOK_EVENTS, + VALID_SANDBOX_TIERS, + VALID_ARTIFACT_KIND_NAMES, + VALID_ARTIFACT_NESTINGS, + VALID_INSTALL_SURFACES, + VALID_PERMISSION_WRITERS, + VALID_EXTENDED_HOOK_EVENTS, + INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES, + INSTALL_SURFACE_TO_CONFIG_FORMAT, + SCHEMA_VERSION, + validateVersionEnvelope, + validateCapability, + validateCommandEntry, + validateRuntimeCompat, + validateConfigHome, + validateArtifactKindEntry, + validateArtifactLayout, + validateRuntimeBody, + materializeHookFragments, + validateAgainstContract, + validateConsumesGlobal, + validateCrossCapability, + computeRequiresClosure, + topoSortSteps, + topoSortContributions, + validateHooksWired, + validateConfigSliceEntry, + classifyCrossErrors, + runConfigFormatParityGate, +} = capValidator; // ─── Central config-schema loader ──────────────────────────────────────────── @@ -134,1706 +128,12 @@ function loadCentralConfigKeys(schemaPath = CONFIG_SCHEMA_PATH) { return new Set(Array.isArray(manifest.validKeys) ? manifest.validKeys : []); } -// ─── Config-slice validation ────────────────────────────────────────────────── - -const VALID_CONFIG_SLICE_TYPES = new Set(['boolean', 'string', 'number', 'enum']); - -/** - * Validate a single config-slice entry (one key's { type, default, description }). - * Returns an array of error strings. Empty = valid. - * - * @param {string} capId Capability id (for error messages) - * @param {string} key Config key (for error messages) - * @param {object} slice The slice object from cap.config[key] - * @returns {string[]} - */ -function validateConfigSliceEntry(capId, key, slice) { - const errors = []; - - if (typeof slice !== 'object' || slice === null || Array.isArray(slice)) { - errors.push('capability "' + capId + '" config["' + key + '"]: slice must be a non-null object'); - return errors; - } - - // type must be one of the allowed set - if (!VALID_CONFIG_SLICE_TYPES.has(slice.type)) { - errors.push( - 'capability "' + capId + '" config["' + key + '"]: type must be one of ' + - [...VALID_CONFIG_SLICE_TYPES].join(', ') + ' (got: ' + JSON.stringify(slice.type) + ')', - ); - } - - // default must be present - if (!Object.prototype.hasOwnProperty.call(slice, 'default')) { - errors.push( - 'capability "' + capId + '" config["' + key + '"]: default is required', - ); - } else { - // type-consistency check - const def = slice.default; - if (slice.type === 'boolean') { - if (typeof def !== 'boolean') { - errors.push( - 'capability "' + capId + '" config["' + key + '"]: default must be a boolean for type:"boolean" (got: ' + typeof def + ')', - ); - } - } else if (slice.type === 'string') { - if (typeof def !== 'string') { - errors.push( - 'capability "' + capId + '" config["' + key + '"]: default must be a string for type:"string" (got: ' + typeof def + ')', - ); - } - } else if (slice.type === 'number') { - if (typeof def !== 'number') { - errors.push( - 'capability "' + capId + '" config["' + key + '"]: default must be a number for type:"number" (got: ' + typeof def + ')', - ); - } else if (!Number.isFinite(def)) { - // FIX 6a: Reject NaN and non-finite number defaults - errors.push( - 'capability "' + capId + '" config["' + key + '"]: default for type:"number" must be a finite number (got: ' + String(def) + ')', - ); - } - } else if (slice.type === 'enum') { - // FIX 5a: enum REQUIRES a non-empty values array (all strings), and default must be in it - if (!Array.isArray(slice.values) || slice.values.length === 0) { - errors.push( - 'capability "' + capId + '" config["' + key + '"]: type:"enum" requires a non-empty "values" array of strings', - ); - } else if (!slice.values.every((v) => typeof v === 'string')) { - errors.push( - 'capability "' + capId + '" config["' + key + '"]: type:"enum" values array must contain only strings', - ); - } - if (typeof def !== 'string') { - errors.push( - 'capability "' + capId + '" config["' + key + '"]: default must be a string for type:"enum" (got: ' + typeof def + ')', - ); - } else if (Array.isArray(slice.values) && slice.values.length > 0 && !slice.values.includes(def)) { - errors.push( - 'capability "' + capId + '" config["' + key + '"]: default "' + def + - '" is not one of the declared enum values [' + slice.values.join(', ') + ']', - ); - } - } - } - - // description must be a non-empty string - if (typeof slice.description !== 'string' || slice.description.length === 0) { - errors.push( - 'capability "' + capId + '" config["' + key + '"]: description must be a non-empty string (got: ' + JSON.stringify(slice.description) + ')', - ); - } - - return errors; -} - -// ─── Per-capability validation ──────────────────────────────────────────────── - -const KEBAB_RE = /^[a-z][a-z0-9-]*$/; -const VALID_ROLES = new Set(['feature', 'runtime']); -const VALID_TIERS = new Set(['core', 'standard', 'full']); -const VALID_ON_ERROR = new Set(['skip', 'halt']); -const RUNTIME_COMPAT_WILDCARD = '*'; - -// ── ADR-1244 D1: versioned-manifest envelope ───────────────────────────────── -// Official strict SemVer 2.0.0 grammar (https://semver.org). Rejects partials -// ("1.0"), prefixes ("v1.0.0"), leading-zero segments ("01.2.3"), numeric -// prerelease identifiers with leading zeros ("1.2.3-01"), empty identifiers -// ("1.2.3-..") and — critically — prerelease/build identifiers containing -// anything outside [0-9A-Za-z-] (so a version can never smuggle shell -// metacharacters, spaces or unicode into a downstream `git tag v` or -// path). Accepts "1.2.3-dev.0", "1.2.3-rc.1", "1.2.3+build.5". -const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; -// Permissive *shape* check for a semver range (engines.gsd / compatVersions -// values). Range SATISFACTION is enforced by the runtime overlay (ADR-1244 D2); -// here we only reject empty/garbage and shell metacharacters. -const SEMVER_RANGE_RE = /^[0-9A-Za-z.\-+ |<>=~^*()]+$/; -// Subresource-integrity hash: "sha512-" + base64 of a 64-byte digest (86 base64 -// chars + "==" padding). Exact length so malformed pins ("sha512-abc") fail. -const SHA512_INTEGRITY_RE = /^sha512-[A-Za-z0-9+/]{86}==$/; - -// A syntactically plausible semver range (shape-only — see SEMVER_RANGE_RE). -// Requires a digit or a bare wildcard so pure-alpha garbage ("abcx", "()x") is -// rejected; full range satisfaction is the runtime overlay's job (ADR-1244 D2). -function isPlausibleRange(s) { - if (typeof s !== 'string') return false; - const t = s.trim(); - if (t.length === 0 || !SEMVER_RANGE_RE.test(s)) return false; - return /\d/.test(t) || t === '*' || t === 'x' || t === 'X'; -} - -/** - * ADR-1244 D1: validate the versioned-manifest envelope. - * - version REQUIRED semver string (the registry rejects a manifest - * without one). - * - engines optional object; engines.gsd optional semver-range string. - * - compatVersions optional object mapping a capability version (semver) to a - * gsd version range. - * - integrity optional "sha512-" string. - * - provenance optional { sourceRepo, commit } strings. - * - * Shape only — range satisfaction and integrity verification are enforced by - * the source resolver / runtime overlay (ADR-1244 D2/D3). - * - * @param {object} cap The parsed JSON object. - * @returns {string[]} Array of error strings; empty = valid. - */ -function validateVersionEnvelope(cap) { - const errors = []; - - if (typeof cap.version !== 'string' || !SEMVER_RE.test(cap.version)) { - errors.push('version must be a semver string (e.g. "1.2.3"); got: ' + JSON.stringify(cap.version)); - } - - if (cap.engines !== undefined) { - if (typeof cap.engines !== 'object' || cap.engines === null || Array.isArray(cap.engines)) { - errors.push('engines must be an object (e.g. { "gsd": ">=1.6.0" })'); - } else if (cap.engines.gsd !== undefined && !isPlausibleRange(cap.engines.gsd)) { - errors.push('engines.gsd must be a semver range string; got: ' + JSON.stringify(cap.engines.gsd)); - } - } - - if (cap.compatVersions !== undefined) { - if (typeof cap.compatVersions !== 'object' || cap.compatVersions === null || Array.isArray(cap.compatVersions)) { - errors.push('compatVersions must be an object mapping capability versions to gsd version ranges'); - } else { - for (const [k, v] of Object.entries(cap.compatVersions)) { - if (!SEMVER_RE.test(k)) errors.push('compatVersions key "' + k + '" must be a semver string'); - if (!isPlausibleRange(v)) errors.push('compatVersions["' + k + '"] must be a semver range string'); - } - } - } - - if (cap.integrity !== undefined && (typeof cap.integrity !== 'string' || !SHA512_INTEGRITY_RE.test(cap.integrity))) { - errors.push('integrity must be a "sha512-" string'); - } - - if (cap.provenance !== undefined) { - const p = cap.provenance; - if (typeof p !== 'object' || p === null || Array.isArray(p)) { - errors.push('provenance must be an object { sourceRepo, commit }'); - } else { - if (typeof p.sourceRepo !== 'string' || p.sourceRepo.length === 0) { - errors.push('provenance.sourceRepo must be a non-empty string'); - } - if (typeof p.commit !== 'string' || p.commit.length === 0) { - errors.push('provenance.commit must be a non-empty string'); - } - } - } - - return errors; -} - -/** - * Validate a single capability declaration. - * - * @param {object} cap The parsed JSON object. - * @param {string} folderId The folder name (must equal cap.id). - * @returns {string[]} Array of error strings; empty = valid. - */ -function validateCapability(cap, folderId) { - const errors = []; - - if (typeof cap !== 'object' || cap === null || Array.isArray(cap)) { - return ['capability must be a JSON object']; - } - - // ── Common envelope ──────────────────────────────────────────────────────── - - if (typeof cap.id !== 'string' || !KEBAB_RE.test(cap.id)) { - errors.push('id must be a kebab-case string'); - } else if (cap.id !== folderId) { - errors.push('id "' + cap.id + '" must equal the folder name "' + folderId + '"'); - } - - if (!VALID_ROLES.has(cap.role)) { - errors.push('role must be one of: feature, runtime (got: ' + cap.role + ')'); - } - - if (typeof cap.title !== 'string' || cap.title.length === 0) { - errors.push('title must be a non-empty string'); - } - - // C4: description is required - if (typeof cap.description !== 'string' || cap.description.length === 0) { - errors.push('description must be a non-empty string'); - } - - if (!VALID_TIERS.has(cap.tier)) { - errors.push('tier must be one of: core, standard, full (got: ' + cap.tier + ')'); - } - - if (!Array.isArray(cap.requires)) { - errors.push('requires must be an array of capability ids'); - } else { - for (const req of cap.requires) { - if (typeof req !== 'string') { - errors.push('requires entries must be strings (got: ' + JSON.stringify(req) + ')'); - } - } - } - - // ── Versioned-manifest envelope (ADR-1244 D1) ────────────────────────────── - errors.push(...validateVersionEnvelope(cap)); - - // ── Role-specific body ──────────────────────────────────────────────────── - - if (cap.role === 'feature') { - errors.push(...validateFeatureBody(cap)); - } else if (cap.role === 'runtime') { - errors.push(...validateRuntimeBody(cap)); - } - - return errors; -} - -/** - * ADR-959: Validate a single commands[] entry on a feature-role capability. - * { family: string, module: string, router: string, subcommands?: string[] } - * - * - family: non-empty string, no reserved names - * - module: non-empty string, no path traversal, no absolute paths, no "/" - * segments other than a bare basename (expected form: "foo.cjs") - * - router: non-empty string - * - subcommands: optional array of strings (doc/introspection only) - * - * @param {string} capId Capability id (for error messages) - * @param {*} entry The entry to validate - * @param {string} prefix Path prefix (e.g. "commands[0]") - * @returns {string[]} Array of error strings; empty = valid. - */ -function validateCommandEntry(capId, entry, prefix) { - const errors = []; - const ctx = 'capability "' + capId + '" ' + prefix; - - if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { - errors.push(ctx + ' must be an object with family, module, and router'); - return errors; - } - - // family: non-empty string, no reserved names - if (typeof entry.family !== 'string' || entry.family.length === 0) { - errors.push(ctx + '.family must be a non-empty string'); - } else if (entry.family === '__proto__' || entry.family === 'constructor' || entry.family === 'prototype') { - // S2a: inline literal reserved-name guard (CodeQL barrier) - errors.push(ctx + '.family "' + entry.family + '" is a reserved name'); - } - - // module: must be a safe bare basename matching /^[A-Za-z0-9._-]+\.cjs$/ — - // no path separators, no "..", no NUL bytes, no absolute paths, ends in .cjs. - // This conservative pattern subsumes all earlier traversal/absolute/separator checks. - if (typeof entry.module !== 'string' || entry.module.length === 0) { - errors.push(ctx + '.module must be a non-empty string'); - } else { - const mod = entry.module; - const SAFE_BASENAME = /^[A-Za-z0-9._-]+\.cjs$/; - if (!SAFE_BASENAME.test(mod)) { - errors.push( - ctx + '.module must be a safe bare basename (pattern: /^[A-Za-z0-9._-]+\\.cjs$/, no path separators, no "..", no NUL bytes, must end in ".cjs"); got: ' + - JSON.stringify(mod), - ); - } - } - - // router: non-empty string - if (typeof entry.router !== 'string' || entry.router.length === 0) { - errors.push(ctx + '.router must be a non-empty string'); - } - - // subcommands: optional array of non-empty strings (doc/introspection only) - if (entry.subcommands !== undefined) { - if (!Array.isArray(entry.subcommands)) { - errors.push(ctx + '.subcommands must be an array of strings if present'); - } else { - for (let i = 0; i < entry.subcommands.length; i++) { - if (typeof entry.subcommands[i] !== 'string') { - errors.push(ctx + '.subcommands[' + i + '] must be a string'); - } else if (entry.subcommands[i].length === 0) { - errors.push(ctx + '.subcommands[' + i + '] must be a non-empty string'); - } - } - } - } - - return errors; -} - -function validateRuntimeCompat(capId, runtimeCompat) { - const errors = []; - const ctx = 'capability "' + capId + '" runtimeCompat'; - - if (typeof runtimeCompat !== 'object' || runtimeCompat === null || Array.isArray(runtimeCompat)) { - errors.push(ctx + ' must be an object with supported and unsupported arrays'); - return errors; - } - - const validateRuntimeArray = (field, { allowWildcard }) => { - const value = runtimeCompat[field]; - if (!Array.isArray(value)) { - errors.push(ctx + '.' + field + ' must be an array of runtime ids' + (allowWildcard ? ' or ["*"]' : '')); - return; - } - if (field === 'supported' && value.length === 0) { - errors.push(ctx + '.supported must be a non-empty array'); - } - let hasWildcard = false; - for (let i = 0; i < value.length; i++) { - const entry = value[i]; - if (typeof entry !== 'string' || entry.length === 0) { - errors.push(ctx + '.' + field + '[' + i + '] must be a non-empty string'); - continue; - } - if (entry === '__proto__' || entry === 'constructor' || entry === 'prototype') { - errors.push(ctx + '.' + field + '[' + i + '] "' + entry + '" is a reserved name'); - } - if (entry === RUNTIME_COMPAT_WILDCARD) { - if (!allowWildcard) { - errors.push(ctx + '.' + field + ' must not include wildcard "*"'); - } - hasWildcard = true; - } else if (!KEBAB_RE.test(entry)) { - errors.push(ctx + '.' + field + '[' + i + '] must be a kebab-case runtime id or "*"'); - } - } - if (hasWildcard && value.length > 1) { - errors.push(ctx + '.' + field + ' wildcard "*" cannot be mixed with runtime ids'); - } - }; - - validateRuntimeArray('supported', { allowWildcard: true }); - validateRuntimeArray('unsupported', { allowWildcard: false }); - - if (runtimeCompat.notes !== undefined) { - if (typeof runtimeCompat.notes !== 'object' || runtimeCompat.notes === null || Array.isArray(runtimeCompat.notes)) { - errors.push(ctx + '.notes must be an object of runtime id to string if present'); - } else { - for (const [key, value] of Object.entries(runtimeCompat.notes)) { - if (key !== RUNTIME_COMPAT_WILDCARD && !KEBAB_RE.test(key)) { - errors.push(ctx + '.notes key "' + key + '" must be a kebab-case runtime id or "*"'); - } - if (typeof value !== 'string' || value.length === 0) { - errors.push(ctx + '.notes["' + key + '"] must be a non-empty string'); - } - } - } - } - - return errors; -} - -function validateFeatureBody(cap) { - const errors = []; - - errors.push(...validateRuntimeCompat(cap.id || '(unknown)', cap.runtimeCompat)); - - if (!Array.isArray(cap.skills)) { - errors.push('skills must be an array of strings'); - } else { - for (const s of cap.skills) { - if (typeof s !== 'string') { - errors.push('skills entries must be strings'); - } else if (s === '__proto__' || s === 'constructor' || s === 'prototype') { - // S2a: inline literal reserved-name guard (CodeQL barrier) - errors.push('skills entry "' + s + '" is a reserved name'); - } - } - } - - // ADR-959: optional commands array - if (cap.commands !== undefined) { - if (!Array.isArray(cap.commands)) { - errors.push('commands must be an array of {family, module, router} objects'); - } else { - for (let i = 0; i < cap.commands.length; i++) { - errors.push(...validateCommandEntry(cap.id || cap.role, cap.commands[i], 'commands[' + i + ']')); - } - } - } - - if (!Array.isArray(cap.agents)) { - errors.push('agents must be an array of strings'); - } else { - for (const a of cap.agents) { - if (typeof a !== 'string') { - errors.push('agents entries must be strings'); - } else if (a === '__proto__' || a === 'constructor' || a === 'prototype') { - // S2a: inline literal reserved-name guard (CodeQL barrier) - errors.push('agents entry "' + a + '" is a reserved name'); - } - } - } - - if (typeof cap.config !== 'object' || cap.config === null || Array.isArray(cap.config)) { - errors.push('config must be an object'); - } else { - // C5: validate config key names and value shapes - for (const key of Object.keys(cap.config)) { - if (key === '' ) { - errors.push('config keys must be non-empty strings'); - } else if (key === '__proto__' || key === 'constructor' || key === 'prototype') { - // S2a: inline literal reserved-name guard (CodeQL barrier) - errors.push('config key "' + key + '" is a reserved name'); - } - const val = cap.config[key]; - if (val === null || typeof val !== 'object' || Array.isArray(val)) { - errors.push('config["' + key + '"] must be an object (got: ' + (val === null ? 'null' : typeof val) + ')'); - } else if (typeof val.type !== 'string' || val.type.length === 0) { - errors.push('config["' + key + '"] must have a string "type" field (e.g. "boolean", "string", "number", "enum")'); - } - } - } - - // C4: hooks, when present, must be an array of {event: string, script: string} - if (cap.hooks !== undefined) { - if (!Array.isArray(cap.hooks)) { - errors.push('hooks must be an array of {event, script} objects'); - } else { - for (let i = 0; i < cap.hooks.length; i++) { - const h = cap.hooks[i]; - if (typeof h !== 'object' || h === null || Array.isArray(h)) { - errors.push('hooks[' + i + '] must be an object with event and script keys'); - } else { - if (typeof h.event !== 'string' || h.event.length === 0) { - errors.push('hooks[' + i + '].event must be a non-empty string'); - } - if (typeof h.script !== 'string' || h.script.length === 0) { - errors.push('hooks[' + i + '].script must be a non-empty string'); - } - } - } - } - } - - // Build the declared skill/agent sets for ref membership checks (used in validateStep). - // Only build these if the arrays are valid (already validated above). - const declaredSkills = Array.isArray(cap.skills) ? new Set(cap.skills.filter((s) => typeof s === 'string')) : null; - const declaredAgents = Array.isArray(cap.agents) ? new Set(cap.agents.filter((a) => typeof a === 'string')) : null; - - if (!Array.isArray(cap.steps)) { - errors.push('steps must be an array'); - } else { - for (let i = 0; i < cap.steps.length; i++) { - errors.push(...validateStep(cap.steps[i], 'steps[' + i + ']', declaredSkills, declaredAgents)); - } - } - - if (!Array.isArray(cap.contributions)) { - errors.push('contributions must be an array'); - } else { - for (let i = 0; i < cap.contributions.length; i++) { - errors.push(...validateContribution(cap.contributions[i], 'contributions[' + i + ']')); - } - } - - if (!Array.isArray(cap.gates)) { - errors.push('gates must be an array'); - } else { - for (let i = 0; i < cap.gates.length; i++) { - errors.push(...validateGate(cap.gates[i], 'gates[' + i + ']')); - } - } - - // activationKey: optional string naming the dotted config key that gates this capability. - // If present: must be a non-empty string that is declared in this capability's own config slice. - if (cap.activationKey !== undefined) { - if (typeof cap.activationKey !== 'string' || cap.activationKey.length === 0) { - errors.push( - 'capability "' + (cap.id || '(unknown)') + '" activationKey must be a non-empty string (got: ' + - JSON.stringify(cap.activationKey) + ')', - ); - } else if (cap.activationKey === '__proto__' || cap.activationKey === 'constructor' || cap.activationKey === 'prototype') { - // Prototype-pollution guard (inline literal, CodeQL barrier) - errors.push( - 'capability "' + (cap.id || '(unknown)') + '" activationKey "' + cap.activationKey + - '" is a reserved JavaScript property name and cannot be used as an activationKey', - ); - } else if ( - typeof cap.config !== 'object' || - cap.config === null || - !Object.prototype.hasOwnProperty.call(cap.config, cap.activationKey) - ) { - errors.push( - 'capability "' + (cap.id || '(unknown)') + '" activationKey "' + cap.activationKey + - '" is not declared in this capability\'s config slice — add it to the "config" object or use a key that is declared there', - ); - } - } - - return errors; -} - -// ADR-857 phase 5e: Closed ConverterName enum — complete set used across 16 runtime descriptors, -// all exported by bin/install.js (commands/skills) and src/runtime-artifact-conversion.cts (agents). -// Any ArtifactKind with a non-null converter must use one of these. -const VALID_CONVERTER_NAMES = new Set([ - // commands / skills converters (pre-existing) - 'convertClaudeCommandToAntigravitySkill', - 'convertClaudeCommandToAugmentSkill', - 'convertClaudeCommandToClineSkill', - 'convertClaudeCommandToClaudeSkill', - 'convertClaudeCommandToCodebuddyCommand', - 'convertClaudeCommandToCodebuddySkill', - 'convertClaudeCommandToCodexSkill', - 'convertClaudeCommandToCopilotSkill', - 'convertClaudeCommandToCursorCommand', - 'convertClaudeCommandToCursorSkill', - 'convertClaudeCommandToKiloSkill', - 'convertClaudeCommandToKimiSkill', - 'convertClaudeCommandToOpencodeSkill', - 'convertClaudeCommandToTraeSkill', - 'convertClaudeCommandToWindsurfSkill', - // agent converters (#1173 — descriptor-driven agent conversion wiring) - 'convertClaudeAgentToCopilotAgent', - 'convertClaudeAgentToAntigravityAgent', - 'convertClaudeAgentToCursorAgent', - 'convertClaudeAgentToWindsurfAgent', - 'convertClaudeAgentToAugmentAgent', - 'convertClaudeAgentToTraeAgent', - 'convertClaudeAgentToCodebuddyAgent', - 'convertClaudeAgentToClineAgent', - 'convertClaudeAgentToCodexAgent', -]); - -// C3: Validate role:runtime body -const VALID_CONFIG_FORMATS = new Set(['settings-json', 'toml', 'markdown', 'markdown-dir', 'none']); -const VALID_CONFIG_HOME_KINDS = new Set(['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root']); -const VALID_COMMAND_STYLES = new Set(['slash-hyphen', 'shell-var']); -const VALID_HOOKS_SURFACES = new Set(['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'none']); -const VALID_HOOK_EVENTS = new Set(['claude', 'gemini', 'opencode-subset']); -const VALID_SANDBOX_TIERS = new Set(['none', 'codex-agent-sandbox']); -const VALID_ARTIFACT_KIND_NAMES = new Set(['commands', 'agents', 'skills', 'kimi-agents']); -const VALID_ARTIFACT_NESTINGS = new Set(['flat', 'nested']); -const FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME = ['skills', 'agents', 'steps', 'contributions', 'gates', 'hooks', 'activationKey']; -const VALID_INSTALL_SURFACES = new Set(['settings-json', 'codex-toml', 'copilot-instructions', 'cline-rules', 'cursor-hooks-json', 'profile-marker-only']); -const VALID_PERMISSION_WRITERS = new Set(['opencode', 'kilo']); -const VALID_EXTENDED_HOOK_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'BeforeAgent', 'AfterAgent', 'BeforeModel']); - -// GATE A: installSurface → allowed hooksSurface values (DEFECT.GENERATIVE-FIX: parity invariant) -// Derived from the actual pairings in the 16 real runtime descriptors. -const INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES = new Map([ - ['settings-json', new Set(['settings-json', 'none'])], - ['codex-toml', new Set(['codex-hooks-json'])], - ['copilot-instructions', new Set(['copilot-inline'])], - ['cline-rules', new Set(['cline-rules'])], - ['cursor-hooks-json', new Set(['cursor-hooks-json'])], - ['profile-marker-only', new Set(['none'])], -]); - -// GATE B: extended hook event families → required hookEvents value -// Gemini agent-events require hookEvents='gemini'; Claude-family events require hookEvents='claude'. -const GEMINI_AGENT_EVENTS = new Set(['BeforeAgent', 'AfterAgent', 'BeforeModel']); -const CLAUDE_FAMILY_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged']); - -/** - * Validate a runtime.configHome object per ADR-1016 Decision 1. - * Returns an array of error strings. - * - * @param {string} capId Capability id (for error messages) - * @param {*} ch The configHome value - * @returns {string[]} - */ -function validateConfigHome(capId, ch) { - const errors = []; - const ctx = 'capability "' + capId + '" runtime.configHome'; - - if (typeof ch !== 'object' || ch === null || Array.isArray(ch)) { - errors.push(ctx + ' must be an object (got: ' + (ch === null ? 'null' : typeof ch) + ')'); - return errors; - } - - // kind — must be in closed vocab; inline literal guard (CodeQL barrier) - if (ch.kind === '__proto__' || ch.kind === 'constructor' || ch.kind === 'prototype') { - errors.push(ctx + '.kind "' + ch.kind + '" is a reserved name'); - } else if (!VALID_CONFIG_HOME_KINDS.has(ch.kind)) { - errors.push( - ctx + '.kind must be one of: ' + [...VALID_CONFIG_HOME_KINDS].join(', ') + - ' (got: ' + JSON.stringify(ch.kind) + ')', - ); - } - - // name — required string - if (typeof ch.name !== 'string' || ch.name.length === 0) { - errors.push(ctx + '.name must be a non-empty string'); - } - - // parent — required when kind == dot-home-nested - if (ch.kind === 'dot-home-nested') { - if (typeof ch.parent !== 'string' || ch.parent.length === 0) { - errors.push(ctx + '.parent must be a non-empty string when kind is "dot-home-nested"'); - } - } - - // env — required; must be an array of strings (every runtime has ≥0 env overrides) - if (!Array.isArray(ch.env)) { - errors.push(ctx + '.env is required and must be an array of strings (got: ' + JSON.stringify(ch.env) + ')'); - } else { - for (let i = 0; i < ch.env.length; i++) { - if (typeof ch.env[i] !== 'string') { - errors.push(ctx + '.env[' + i + '] must be a string'); - } - } - } - - // probe — optional; if present must be an array of strings - if (ch.probe !== undefined) { - if (!Array.isArray(ch.probe)) { - errors.push(ctx + '.probe must be an array of strings if present'); - } else { - for (let i = 0; i < ch.probe.length; i++) { - if (typeof ch.probe[i] !== 'string') { - errors.push(ctx + '.probe[' + i + '] must be a string'); - } - } - } - } - - // probeExists — optional; if present must be a non-empty string (sub-path existence check for probe) - if (ch.probeExists !== undefined) { - if (typeof ch.probeExists !== 'string' || ch.probeExists.length === 0) { - errors.push(ctx + '.probeExists must be a non-empty string if present (got: ' + JSON.stringify(ch.probeExists) + ')'); - } - } - - // skillsHome — optional; if present must be a full valid configHome object (recursive validation) - if (ch.skillsHome !== undefined) { - // Recursive call: validate skillsHome as a nested configHome. - // Use a synthetic capId to surface the sub-path in error messages. - const skillsHomeErrors = validateConfigHome(capId + '.skillsHome', ch.skillsHome); - // Rewrite the inner ctx prefix so errors read as "...runtime.configHome.skillsHome..." - for (const e of skillsHomeErrors) { - errors.push(e.replace( - 'capability "' + capId + '.skillsHome" runtime.configHome', - ctx + '.skillsHome', - )); - } - } - - return errors; -} - -/** - * Validate a single ArtifactKind entry per ADR-1016 Decision 3. - * Returns an array of error strings. - * - * @param {string} capId Capability id (for error messages) - * @param {*} entry The ArtifactKind object - * @param {string} prefix Path prefix for error messages (e.g. "artifactLayout.global[0]") - * @returns {string[]} - */ -function validateArtifactKindEntry(capId, entry, prefix) { - const errors = []; - const ctx = 'capability "' + capId + '" runtime.' + prefix; - - if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { - errors.push(ctx + ' must be an object'); - return errors; - } - - // kind — must be in closed vocab; inline literal guard (CodeQL barrier) - if (entry.kind === '__proto__' || entry.kind === 'constructor' || entry.kind === 'prototype') { - errors.push(ctx + '.kind "' + entry.kind + '" is a reserved name'); - } else if (!VALID_ARTIFACT_KIND_NAMES.has(entry.kind)) { - errors.push( - ctx + '.kind must be one of: ' + [...VALID_ARTIFACT_KIND_NAMES].join(', ') + - ' (got: ' + JSON.stringify(entry.kind) + ')', - ); - } - - // destSubpath — required non-empty string - if (typeof entry.destSubpath !== 'string' || entry.destSubpath.length === 0) { - errors.push(ctx + '.destSubpath must be a non-empty string'); - } - - // nesting — required; must be in closed vocab (ADR-857 §5d: now drives install) - if (entry.nesting === undefined || entry.nesting === null) { - errors.push(ctx + '.nesting is required and must be one of: ' + [...VALID_ARTIFACT_NESTINGS].join(', ')); - } else if (!VALID_ARTIFACT_NESTINGS.has(entry.nesting)) { - errors.push( - ctx + '.nesting must be one of: ' + [...VALID_ARTIFACT_NESTINGS].join(', ') + - ' (got: ' + JSON.stringify(entry.nesting) + ')', - ); - } - - // prefix — required; must be a string (may be empty string '') - if (entry.prefix === undefined || entry.prefix === null) { - errors.push(ctx + '.prefix is required (must be a string, may be empty)'); - } else if (typeof entry.prefix !== 'string') { - errors.push(ctx + '.prefix must be a string (got: ' + typeof entry.prefix + ')'); - } - - // recursive — optional; if present must be a boolean - if (entry.recursive !== undefined) { - if (typeof entry.recursive !== 'boolean') { - errors.push(ctx + '.recursive must be a boolean if present (got: ' + typeof entry.recursive + ')'); - } - } - - // converter — required; must be a string or null (closed ConverterName enum — now enforced in phase 5e) - if (!Object.prototype.hasOwnProperty.call(entry, 'converter')) { - errors.push(ctx + '.converter is required (must be a string or null)'); - } else if (entry.converter !== null && typeof entry.converter !== 'string') { - errors.push(ctx + '.converter must be a string or null (got: ' + typeof entry.converter + ')'); - } else if (entry.converter !== null && typeof entry.converter === 'string' && - !VALID_CONVERTER_NAMES.has(entry.converter)) { - // Closed ConverterName enum (ADR-857 phase 5e): reject unknown converter names - errors.push(ctx + '.converter "' + entry.converter + '" is not a known ConverterName'); - } - - return errors; -} - -/** - * Validate runtime.artifactLayout per ADR-1016 Decision 3. - * Accepts the structured { global, local } shape. - * Returns an array of error strings. - * - * @param {string} capId Capability id (for error messages) - * @param {*} layout The artifactLayout value - * @returns {string[]} - */ -function validateArtifactLayout(capId, layout) { - const errors = []; - const ctx = 'capability "' + capId + '" runtime.artifactLayout'; - - if (typeof layout !== 'object' || layout === null || Array.isArray(layout)) { - errors.push(ctx + ' must be an object with "global" and "local" arrays'); - return errors; - } - - for (const scope of ['global', 'local']) { - const arr = layout[scope]; - if (!Array.isArray(arr)) { - errors.push(ctx + '.' + scope + ' must be an array'); - } else { - for (let i = 0; i < arr.length; i++) { - errors.push(...validateArtifactKindEntry(capId, arr[i], 'artifactLayout.' + scope + '[' + i + ']')); - } - } - } - - return errors; -} - -function validateRuntimeBody(cap) { - const errors = []; - - // C3: feature-only fields must NOT appear on a runtime cap - for (const field of FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME) { - if (cap[field] !== undefined) { - errors.push('role:runtime capability must not have "' + field + '" (feature-only field)'); - } - } - - // C3: require a runtime object - if (typeof cap.runtime !== 'object' || cap.runtime === null || Array.isArray(cap.runtime)) { - errors.push('role:runtime capability must have a "runtime" object'); - return errors; // can't validate further without the object - } - - const r = cap.runtime; - - // configHome — must be a structured object (ADR-1016 Decision 1) - errors.push(...validateConfigHome(cap.id || '(unknown)', r.configHome)); - - // configFormat — closed 5-enum (unchanged) - if (!VALID_CONFIG_FORMATS.has(r.configFormat)) { - errors.push('runtime.configFormat must be one of: ' + [...VALID_CONFIG_FORMATS].join(', ') + ' (got: ' + r.configFormat + ')'); - } - - // artifactLayout — structured { global, local } per ADR-1016 Decision 3 - errors.push(...validateArtifactLayout(cap.id || '(unknown)', r.artifactLayout)); - - // commandStyle — closed 2-enum (ADR-1016 Decision 4); inline literal guard (CodeQL barrier) - if (r.commandStyle === '__proto__' || r.commandStyle === 'constructor' || r.commandStyle === 'prototype') { - errors.push('runtime.commandStyle "' + r.commandStyle + '" is a reserved name'); - } else if (!VALID_COMMAND_STYLES.has(r.commandStyle)) { - errors.push( - 'runtime.commandStyle must be one of: ' + [...VALID_COMMAND_STYLES].join(', ') + - ' (got: ' + JSON.stringify(r.commandStyle) + ')', - ); - } - - // hooksSurface — closed 6-enum (ADR-1016 Decision 5); inline literal guard (CodeQL barrier) - if (r.hooksSurface === '__proto__' || r.hooksSurface === 'constructor' || r.hooksSurface === 'prototype') { - errors.push('runtime.hooksSurface "' + r.hooksSurface + '" is a reserved name'); - } else if (!VALID_HOOKS_SURFACES.has(r.hooksSurface)) { - errors.push( - 'runtime.hooksSurface must be one of: ' + [...VALID_HOOKS_SURFACES].join(', ') + - ' (got: ' + JSON.stringify(r.hooksSurface) + ')', - ); - } - - // hookEvents — optional; if present must be in closed 3-enum (ADR-1016 Decision 5) - if (r.hookEvents !== undefined) { - if (r.hookEvents === '__proto__' || r.hookEvents === 'constructor' || r.hookEvents === 'prototype') { - errors.push('runtime.hookEvents "' + r.hookEvents + '" is a reserved name'); - } else if (!VALID_HOOK_EVENTS.has(r.hookEvents)) { - errors.push( - 'runtime.hookEvents must be one of: ' + [...VALID_HOOK_EVENTS].join(', ') + - ' (got: ' + JSON.stringify(r.hookEvents) + ')', - ); - } - } - - // sandboxTier — closed 2-enum (ADR-1016 Decision 6); inline literal guard (CodeQL barrier) - if (r.sandboxTier === '__proto__' || r.sandboxTier === 'constructor' || r.sandboxTier === 'prototype') { - errors.push('runtime.sandboxTier "' + r.sandboxTier + '" is a reserved name'); - } else if (!VALID_SANDBOX_TIERS.has(r.sandboxTier)) { - errors.push( - 'runtime.sandboxTier must be one of: ' + [...VALID_SANDBOX_TIERS].join(', ') + - ' (got: ' + JSON.stringify(r.sandboxTier) + ')', - ); - } - - // supportTier — 1 or 2 (unchanged) - if (r.supportTier !== 1 && r.supportTier !== 2) { - errors.push('runtime.supportTier must be 1 or 2 (got: ' + r.supportTier + ')'); - } - - // installSurface — required string in closed enum - if (!VALID_INSTALL_SURFACES.has(r.installSurface)) { - errors.push( - 'runtime.installSurface must be one of: ' + [...VALID_INSTALL_SURFACES].join(', ') + - ' (got: ' + JSON.stringify(r.installSurface) + ')', - ); - } - - // writesSharedSettings — required boolean - if (typeof r.writesSharedSettings !== 'boolean') { - errors.push( - 'runtime.writesSharedSettings must be a boolean (got: ' + JSON.stringify(r.writesSharedSettings) + ')', - ); - } - - // permissionWriter — required key; value must be null or a string in VALID_PERMISSION_WRITERS - if (!Object.prototype.hasOwnProperty.call(r, 'permissionWriter')) { - errors.push('runtime.permissionWriter is required (must be null or one of: ' + [...VALID_PERMISSION_WRITERS].join(', ') + ')'); - } else if (r.permissionWriter !== null && !VALID_PERMISSION_WRITERS.has(r.permissionWriter)) { - errors.push( - 'runtime.permissionWriter must be null or one of: ' + [...VALID_PERMISSION_WRITERS].join(', ') + - ' (got: ' + JSON.stringify(r.permissionWriter) + ')', - ); - } - - // extendedHookEvents — required array; every element must be in closed enum - if (!Array.isArray(r.extendedHookEvents)) { - errors.push( - 'runtime.extendedHookEvents must be an array (got: ' + JSON.stringify(r.extendedHookEvents) + ')', - ); - } else { - for (let i = 0; i < r.extendedHookEvents.length; i++) { - const ev = r.extendedHookEvents[i]; - if (typeof ev !== 'string' || !VALID_EXTENDED_HOOK_EVENTS.has(ev)) { - errors.push( - 'runtime.extendedHookEvents[' + i + '] must be one of: ' + [...VALID_EXTENDED_HOOK_EVENTS].join(', ') + - ' (got: ' + JSON.stringify(ev) + ')', - ); - } - } - } - - // GATE A: installSurface ↔ hooksSurface consistency (DEFECT.GENERATIVE-FIX) - // Only check if both fields are valid strings (individual field validators above report type errors). - if (typeof r.installSurface === 'string' && typeof r.hooksSurface === 'string') { - const allowedHooksSurfaces = INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES.get(r.installSurface); - if (allowedHooksSurfaces !== undefined && !allowedHooksSurfaces.has(r.hooksSurface)) { - errors.push( - 'runtime.hooksSurface "' + r.hooksSurface + '" is not valid for installSurface "' + r.installSurface + '"' + - ' — allowed: ' + [...allowedHooksSurfaces].join(', ') + - ' (src: INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES in scripts/gen-capability-registry.cjs)', - ); - } - } - - // GATE B: extendedHookEvents ↔ hookEvents consistency (DEFECT.GENERATIVE-FIX) - // If extendedHookEvents contains Gemini agent-events, hookEvents must be 'gemini'. - // If it contains Claude-family events, hookEvents must be 'claude'. - // Empty extendedHookEvents imposes no constraint. - if (Array.isArray(r.extendedHookEvents) && r.extendedHookEvents.length > 0) { - const hasGeminiEvents = r.extendedHookEvents.some((ev) => GEMINI_AGENT_EVENTS.has(ev)); - const hasClaudeEvents = r.extendedHookEvents.some((ev) => CLAUDE_FAMILY_EVENTS.has(ev)); - if (hasGeminiEvents && r.hookEvents !== 'gemini') { - errors.push( - 'runtime.extendedHookEvents contains Gemini agent-events (' + - r.extendedHookEvents.filter((ev) => GEMINI_AGENT_EVENTS.has(ev)).join(', ') + - ') but runtime.hookEvents is "' + r.hookEvents + '" — must be "gemini"', - ); - } - if (hasClaudeEvents && r.hookEvents !== 'claude') { - errors.push( - 'runtime.extendedHookEvents contains Claude-family events (' + - r.extendedHookEvents.filter((ev) => CLAUDE_FAMILY_EVENTS.has(ev)).join(', ') + - ') but runtime.hookEvents is "' + r.hookEvents + '" — must be "claude"', - ); - } - } - - return errors; -} - -function materializeHookFragments(cap, capDir) { - const errors = []; - const hookGroups = [ - ['steps', Array.isArray(cap.steps) ? cap.steps : []], - ['contributions', Array.isArray(cap.contributions) ? cap.contributions : []], - ]; - - for (const [groupName, hooks] of hookGroups) { - for (let i = 0; i < hooks.length; i++) { - const hook = hooks[i]; - if (!hook || typeof hook !== 'object' || Array.isArray(hook)) continue; - const fragment = hook.fragment; - if (!fragment || typeof fragment !== 'object' || Array.isArray(fragment)) continue; - if (typeof fragment.inline === 'string') continue; - if (typeof fragment.path !== 'string') continue; - - const abs = path.resolve(capDir, fragment.path); - const capRoot = path.resolve(capDir); - if (abs !== capRoot && !abs.startsWith(capRoot + path.sep)) { - errors.push( - cap.id + '/' + groupName + '[' + i + '].fragment.path escapes capability directory: ' + - fragment.path, - ); - continue; - } - - try { - fragment.inline = fs.readFileSync(abs, 'utf8'); - } catch (err) { - errors.push( - cap.id + '/' + groupName + '[' + i + '].fragment.path could not be read: ' + - fragment.path + ' (' + err.message + ')', - ); - } - } - } - - return errors; -} - -function validateFragment(fragment, prefix) { - const errors = []; - - if (typeof fragment !== 'object' || fragment === null || Array.isArray(fragment)) { - errors.push(prefix + ' must be an object with path or inline key'); - return errors; - } - - const hasPath = Object.prototype.hasOwnProperty.call(fragment, 'path'); - const hasInline = Object.prototype.hasOwnProperty.call(fragment, 'inline'); - if (!hasPath && !hasInline) { - errors.push(prefix + ' must have a "path" or "inline" key'); - } - if (hasInline) { - const inline = fragment.inline; - if (typeof inline !== 'string') { - errors.push(prefix + '.inline must be a string'); - } else if (inline === '') { - errors.push(prefix + '.inline must be a non-empty string'); - } - } - // S1: fragment.path traversal guard — must be a relative path with no ".." segments - if (hasPath) { - const p = fragment.path; - if (typeof p !== 'string' || p === '' || path.isAbsolute(p) || p.split(/[\\/]/).includes('..')) { - errors.push(prefix + '.path must be a relative path with no ".." segments'); - } - } - - return errors; -} - -/** - * Validate a single step entry. - * - * @param {object} step The step to validate. - * @param {string} prefix Path prefix for error messages (e.g. "steps[0]"). - * @param {Set|null} declaredSkills Set of skill stems declared in this capability's skills array, - * or null if the skills array was not valid (skip membership check). - * @param {Set|null} declaredAgents Set of agent names declared in this capability's agents array, - * or null if the agents array was not valid (skip membership check). - * @returns {string[]} - */ -function validateStep(step, prefix, declaredSkills, declaredAgents) { - const errors = []; - - if (!VALID_LOOP_POINTS.has(step.point)) { - errors.push(prefix + '.point "' + step.point + '" is not a valid loop point'); - } - - if (typeof step.ref !== 'object' || step.ref === null) { - errors.push(prefix + '.ref must be an object with skill, agent, or command key'); - } else { - const hasSkill = Object.prototype.hasOwnProperty.call(step.ref, 'skill'); - const hasAgent = Object.prototype.hasOwnProperty.call(step.ref, 'agent'); - const hasCommand = Object.prototype.hasOwnProperty.call(step.ref, 'command'); - const dispatchCount = [hasSkill, hasAgent, hasCommand].filter(Boolean).length; - if (dispatchCount === 0) { - errors.push(prefix + '.ref must have a "skill", "agent", or "command" key'); - } else if (dispatchCount > 1) { - // ref must be exclusive: skill XOR agent XOR command - errors.push(prefix + '.ref must have exactly one of "skill", "agent", or "command", not multiple'); - } - if (hasSkill && typeof step.ref.skill !== 'string') { - errors.push(prefix + '.ref.skill must be a string'); - } else if (hasSkill && typeof step.ref.skill === 'string' && step.ref.skill.startsWith('gsd-')) { - // Double-prefix guard: ref.skill is an unprefixed stem (e.g. "ui-review"). - // Workflow dispatch prepends "gsd-" at runtime → "gsd-ui-review". - // A stem that already starts with "gsd-" would produce "gsd-gsd-..." at dispatch. - errors.push( - prefix + '.ref.skill "' + step.ref.skill + '" must not start with "gsd-" ' + - '(it is an unprefixed stem; the workflow prepends "gsd-" at dispatch — ' + - 'starting with "gsd-" would produce "gsd-' + step.ref.skill + '")', - ); - } else if (hasSkill && typeof step.ref.skill === 'string' && declaredSkills !== null && !declaredSkills.has(step.ref.skill)) { - // Membership check: ref.skill must be declared in this capability's skills array. - // This catches typos and ensures every dispatched skill is owned by this capability. - errors.push( - prefix + '.ref.skill "' + step.ref.skill + '" is not declared in this capability\'s skills: [' + - [...declaredSkills].join(', ') + ']', - ); - } - if (hasAgent && typeof step.ref.agent !== 'string') { - errors.push(prefix + '.ref.agent must be a string'); - } else if (hasAgent && typeof step.ref.agent === 'string' && declaredAgents !== null && !declaredAgents.has(step.ref.agent)) { - // Membership check: ref.agent must be declared in this capability's agents array. - errors.push( - prefix + '.ref.agent "' + step.ref.agent + '" is not declared in this capability\'s agents: [' + - [...declaredAgents].join(', ') + ']', - ); - } - if (hasCommand && typeof step.ref.command !== 'string') { - errors.push(prefix + '.ref.command must be a string'); - } - } - - if (!Array.isArray(step.produces)) { - errors.push(prefix + '.produces must be an array'); - } else { - for (const p of step.produces) { - if (typeof p !== 'string') errors.push(prefix + '.produces entries must be strings'); - } - } - - if (!Array.isArray(step.consumes)) { - errors.push(prefix + '.consumes must be an array'); - } else { - for (const c of step.consumes) { - if (typeof c !== 'string') errors.push(prefix + '.consumes entries must be strings'); - } - } - - if (step.when !== undefined && typeof step.when !== 'string') { - errors.push(prefix + '.when must be a string if present'); - } - - if (step.fragment !== undefined) { - errors.push(...validateFragment(step.fragment, prefix + '.fragment')); - } - - if (!VALID_ON_ERROR.has(step.onError)) { - errors.push(prefix + '.onError must be "skip" or "halt" (got: ' + step.onError + ')'); - } - - return errors; -} - -function validateContribution(contrib, prefix) { - const errors = []; - - if (!VALID_LOOP_POINTS.has(contrib.point)) { - errors.push(prefix + '.point "' + contrib.point + '" is not a valid loop point'); - } - - if (typeof contrib.into !== 'string') { - errors.push(prefix + '.into must be a string (agent role name)'); - } - - if (!Array.isArray(contrib.produces)) { - errors.push(prefix + '.produces must be an array'); - } else { - for (const p of contrib.produces) { - if (typeof p !== 'string') errors.push(prefix + '.produces entries must be strings'); - } - } - - if (!Array.isArray(contrib.consumes)) { - errors.push(prefix + '.consumes must be an array'); - } else { - for (const c of contrib.consumes) { - if (typeof c !== 'string') errors.push(prefix + '.consumes entries must be strings'); - } - } - - errors.push(...validateFragment(contrib.fragment, prefix + '.fragment')); - - if (contrib.when !== undefined && typeof contrib.when !== 'string') { - errors.push(prefix + '.when must be a string if present'); - } - - if (contrib.onError !== undefined && !VALID_ON_ERROR.has(contrib.onError)) { - errors.push(prefix + '.onError must be "skip" or "halt" if present'); - } - - return errors; -} - -function validateGate(gate, prefix) { - const errors = []; - - if (!VALID_LOOP_POINTS.has(gate.point)) { - errors.push(prefix + '.point "' + gate.point + '" is not a valid loop point'); - } - - if (typeof gate.check !== 'object' || gate.check === null) { - errors.push(prefix + '.check must be an object'); - } else { - const hasQuery = Object.prototype.hasOwnProperty.call(gate.check, 'query'); - const hasPredicate = Object.prototype.hasOwnProperty.call(gate.check, 'predicate'); - const hasAgentVerdict = Object.prototype.hasOwnProperty.call(gate.check, 'agentVerdict'); - const count = [hasQuery, hasPredicate, hasAgentVerdict].filter(Boolean).length; - if (count !== 1) { - errors.push(prefix + '.check must have exactly one of: query, predicate, agentVerdict'); - } - // agentVerdict forces blocking: false (advisory only) - if (hasAgentVerdict && gate.blocking === true) { - errors.push( - prefix + '.check.agentVerdict forces blocking: false (non-deterministic checks may not halt the loop)', - ); - } - } - - if (gate.when !== undefined && typeof gate.when !== 'string') { - errors.push(prefix + '.when must be a string if present'); - } - - if (typeof gate.blocking !== 'boolean') { - errors.push(prefix + '.blocking must be a boolean'); - } - - if (!VALID_ON_ERROR.has(gate.onError)) { - errors.push(prefix + '.onError must be "skip" or "halt" (got: ' + gate.onError + ')'); - } - - return errors; -} - -// ─── Contract validation ────────────────────────────────────────────────────── - -/** - * Validate per-capability contract constraints against the Loop Host Contract. - * This covers: - * - contribution.into ∈ step's agentRoles - * - when references a config key in cap.config - * - * NOTE: step.consumes satisfiability is NOT checked here — it requires the full - * set of validated capabilities (cross-capability produces). It runs in - * validateConsumesGlobal() after loadAndValidate builds capMap. - * - * @param {object} cap Validated capability object - * @param {string} capId Capability id (for error messages) - */ -function validateAgainstContract(cap, capId) { - if (cap.role !== 'feature') return []; - const errors = []; - const prefix = 'capability "' + capId + '"'; - - // contribution.into must be in the step's agentRoles - for (const contrib of cap.contributions) { - if (!VALID_LOOP_POINTS.has(contrib.point)) continue; // already reported - const contract = POINT_TO_CONTRACT.get(contrib.point); - if (contract && !contract.agentRoles.includes(contrib.into)) { - errors.push( - prefix + ' contribution.into "' + contrib.into + '" at point "' + contrib.point + - '" is not in the step\'s agentRoles [' + contract.agentRoles.join(', ') + ']', - ); - } - } - - // when references a plausibly-valid config key (string — we require it's in cap.config) - for (const step of cap.steps) { - if (step.when !== undefined) { - if (typeof step.when !== 'string') continue; // already reported above - if ( - typeof cap.config === 'object' && - cap.config !== null && - !Object.prototype.hasOwnProperty.call(cap.config, step.when) - ) { - errors.push( - prefix + ' step.when "' + step.when + '" is not defined in capability config keys', - ); - } - } - } - - for (const contrib of cap.contributions) { - if (contrib.when !== undefined) { - if (typeof contrib.when !== 'string') continue; - if ( - typeof cap.config === 'object' && - cap.config !== null && - !Object.prototype.hasOwnProperty.call(cap.config, contrib.when) - ) { - errors.push( - prefix + ' contribution.when "' + contrib.when + '" is not defined in capability config keys', - ); - } - } - } - - for (const gate of cap.gates) { - if (gate.when !== undefined) { - if (typeof gate.when !== 'string') continue; - if ( - typeof cap.config === 'object' && - cap.config !== null && - !Object.prototype.hasOwnProperty.call(cap.config, gate.when) - ) { - errors.push( - prefix + ' gate.when "' + gate.when + '" is not defined in capability config keys', - ); - } - } - } - - return errors; -} - -/** - * C1+C2: Global consumes-satisfiability validation. - * - * A hook at point P consuming artifact A is satisfiable iff: - * - A is a host-produced artifact available from its step's :post point (C1), and - * that :post point's POINT_ORDER index ≤ P's index; OR - * - A is produced by any capability hook step at a point whose POINT_ORDER index ≤ P's index - * (same-point is OK — topoSortSteps enforces intra-point order); OR - * - A is never produced anywhere → rejected. - * - * Runs after capMap is fully built so cross-capability produces are visible. - * - * @param {Map} capMap Fully-validated capability map. - * @returns {string[]} Array of error strings. - */ -function validateConsumesGlobal(capMap) { - const errors = []; - - // Build producedAtPoint: artifact → earliest POINT_ORDER index at which it is produced. - // Seed with host artifacts (C1: available from their step's :post point). - // Host-artifact entries are tagged {pointIdx, isHost:true} so they are never excluded by - // the self-consume check. - const producedAtPoint = Object.create(null); - for (const [artifact, postIdx] of Object.entries(HOST_ARTIFACT_EARLIEST_POINT_IDX)) { - if (artifact === '__proto__' || artifact === 'constructor' || artifact === 'prototype') continue; - producedAtPoint[artifact] = postIdx; - } - - // Build a richer per-artifact producer list for the self-consume check. - // Each entry: { pointIdx, capId, stepIdx } — identifies which cap+step produced the artifact. - // Host artifacts are seeded separately (no capId) and always satisfy the consume check. - // capHookProducers[artifact] = [{pointIdx, capId, stepIdx}, ...] - const capHookProducers = Object.create(null); - - // Add hook-produced artifacts from all capabilities. - for (const [capId, cap] of capMap) { - if (cap.role !== 'feature') continue; - for (let si = 0; si < (cap.steps || []).length; si++) { - const step = cap.steps[si]; - if (!VALID_LOOP_POINTS.has(step.point)) continue; - const pointIdx = POINT_ORDER.indexOf(step.point); - for (const artifact of (step.produces || [])) { - if (typeof artifact !== 'string') continue; - if (artifact === '__proto__' || artifact === 'constructor' || artifact === 'prototype') continue; - if (producedAtPoint[artifact] === undefined || pointIdx < producedAtPoint[artifact]) { - producedAtPoint[artifact] = pointIdx; - } - if (!capHookProducers[artifact]) capHookProducers[artifact] = []; - capHookProducers[artifact].push({ pointIdx, capId, stepIdx: si }); - } - } - } - - // Duplicate-producer invariant: two capability steps may not produce the same artifact - // at the same Loop Extension Point. Same-point dual production makes data-flow resolution - // ambiguous and is rejected at gen time (Decision #6). - for (const artifact of Object.keys(capHookProducers)) { - if (artifact === '__proto__' || artifact === 'constructor' || artifact === 'prototype') continue; - const producers = capHookProducers[artifact]; - // Group by pointIdx - const byPoint = Object.create(null); - for (const entry of producers) { - if (!byPoint[entry.pointIdx]) byPoint[entry.pointIdx] = []; - byPoint[entry.pointIdx].push(entry); - } - for (const pointIdxStr of Object.keys(byPoint)) { - const group = byPoint[pointIdxStr]; - // Count distinct (capId, stepIdx) producer steps — a single step listing the same - // artifact twice in its produces array pushes duplicate entries but represents only - // ONE producer step and must not false-positive the cross-step gate. - const distinctProducers = new Set(group.map((e) => e.capId + ' ' + e.stepIdx)); - if (distinctProducers.size >= 2) { - const pointIdx = Number(pointIdxStr); - const pointName = POINT_ORDER[pointIdx]; - const capIds = [...new Set(group.map((e) => e.capId))].sort().join(', '); - throw new Error( - 'duplicate-producer invariant violated: artifact "' + artifact + '" is produced by ' + - 'two or more capability steps at the same Loop Extension Point "' + pointName + '" ' + - '(capabilities: ' + capIds + '). ' + - 'Two capability steps producing the same artifact at the same Loop Extension Point ' + - 'makes data-flow resolution ambiguous and is rejected at gen time.', - ); - } - } - } - - // Now check every hook step's consumes. - // Self-consume rule: a step H cannot satisfy its own consumes[A] from its own produces[A]. - // A is satisfiable for H iff: - // (a) A is a host artifact with pointIdx <= stepPointIdx, OR - // (b) A is produced by a DIFFERENT cap/step at pointIdx <= stepPointIdx. - // "Different" means capId != H.capId OR stepIdx != H.stepIdx. - for (const [capId, cap] of capMap) { - if (cap.role !== 'feature') continue; - const prefix = 'capability "' + capId + '"'; - for (let si = 0; si < (cap.steps || []).length; si++) { - const step = cap.steps[si]; - if (!VALID_LOOP_POINTS.has(step.point)) continue; - const stepPointIdx = POINT_ORDER.indexOf(step.point); - for (const artifact of (step.consumes || [])) { - if (typeof artifact !== 'string') continue; - - // Check host-artifact satisfaction first (never excluded by self-consume). - const hostIdx = HOST_ARTIFACT_EARLIEST_POINT_IDX[artifact]; - const hostSatisfied = hostIdx !== undefined && hostIdx <= stepPointIdx; - if (hostSatisfied) continue; // fast-path: host artifact is available - - // Check cap-hook producers, excluding this step itself. - const producers = capHookProducers[artifact]; - if (!producers || producers.length === 0) { - // Not a host artifact and never produced by any hook. - errors.push( - prefix + ' step at point "' + step.point + '" consumes "' + artifact + - '" which is never produced by any host artifact or capability hook', - ); - continue; - } - - // Find any non-self producer at pointIdx <= stepPointIdx. - const otherEarliestIdx = producers.reduce((best, p) => { - const isSelf = p.capId === capId && p.stepIdx === si; - if (isSelf) return best; - return (best === undefined || p.pointIdx < best) ? p.pointIdx : best; - }, undefined); - - if (otherEarliestIdx === undefined) { - // Only producer is this step itself — self-consume violation. - errors.push( - prefix + ' step at point "' + step.point + '" consumes "' + artifact + - '" which is only produced by this step itself (a step cannot consume its own output)', - ); - } else if (otherEarliestIdx > stepPointIdx) { - errors.push( - prefix + ' step at point "' + step.point + '" consumes "' + artifact + - '" which is only produced after this point (earliest available at POINT_ORDER index ' + - otherEarliestIdx + ' = "' + POINT_ORDER[otherEarliestIdx] + '")', - ); - } - // else: satisfied by another cap/step at an earlier-or-same point — OK. - } - } - } - - return errors; -} - -// ─── Cross-capability invariants ────────────────────────────────────────────── - -const TIER_RANK = { core: 0, standard: 1, full: 2 }; - -/** - * Enforce cross-capability invariants. - * - * @param {Map} capMap id → validated capability object - * @param {Set} centralKeys Set of keys in the central config-schema - * @returns {string[]} Array of error strings; empty = all pass. - */ -function validateCrossCapability(capMap, centralKeys) { - const errors = []; - - // Ownership: one owner per skill stem + agent name - const skillOwner = new Map(); // skill → capId - const agentOwner = new Map(); // agent → capId - const familyOwner = new Map(); // command family → capId (ADR-959) - for (const [capId, cap] of capMap) { - if (cap.role !== 'feature') continue; - for (const skill of cap.skills) { - if (skillOwner.has(skill)) { - errors.push( - 'skill "' + skill + '" is owned by both "' + skillOwner.get(skill) + '" and "' + capId + '"', - ); - } else { - skillOwner.set(skill, capId); - } - } - for (const agent of cap.agents) { - if (agentOwner.has(agent)) { - errors.push( - 'agent "' + agent + '" is owned by both "' + agentOwner.get(agent) + '" and "' + capId + '"', - ); - } else { - agentOwner.set(agent, capId); - } - } - // ADR-959: single family ownership across the whole registry - if (Array.isArray(cap.commands)) { - for (const cmd of cap.commands) { - if (typeof cmd.family !== 'string' || cmd.family.length === 0) continue; // already reported - if (cmd.family === '__proto__' || cmd.family === 'constructor' || cmd.family === 'prototype') continue; - if (familyOwner.has(cmd.family)) { - errors.push( - 'command family "' + cmd.family + '" is owned by both "' + familyOwner.get(cmd.family) + '" and "' + capId + '"', - ); - } else { - familyOwner.set(cmd.family, capId); - } - } - } - } - - // Config key ownership: exclusive AND absent from central schema - const configKeyOwner = new Map(); // key → capId - for (const [capId, cap] of capMap) { - if (cap.role !== 'feature' || typeof cap.config !== 'object' || cap.config === null) continue; - for (const key of Object.keys(cap.config)) { - if (configKeyOwner.has(key)) { - errors.push( - 'config key "' + key + '" is owned by both "' + configKeyOwner.get(key) + '" and "' + capId + '"', - ); - } else { - configKeyOwner.set(key, capId); - } - if (centralKeys.has(key)) { - errors.push( - 'config key "' + key + '" is declared in capability "' + capId + - '" AND exists in the central config-schema — migration mid-flight: ' + - 'remove from central config-schema before adding to the capability', - ); - } - } - } - - // requires: all ids exist - for (const [capId, cap] of capMap) { - if (!Array.isArray(cap.requires)) continue; - for (const req of cap.requires) { - if (!capMap.has(req)) { - errors.push( - 'capability "' + capId + '" requires "' + req + '" which does not exist', - ); - } - } - } - - // runtimeCompat: explicit runtime ids must reference runtime capabilities. - // The wildcard "*" means descriptor-backed runtimes are supported by default. - const runtimeIds = new Set(); - for (const [id, cap] of capMap) { - if (cap.role === 'runtime') runtimeIds.add(id); - } - for (const [capId, cap] of capMap) { - if (cap.role !== 'feature' || typeof cap.runtimeCompat !== 'object' || cap.runtimeCompat === null) continue; - for (const field of ['supported', 'unsupported']) { - const entries = Array.isArray(cap.runtimeCompat[field]) ? cap.runtimeCompat[field] : []; - for (const runtimeId of entries) { - if (runtimeId === RUNTIME_COMPAT_WILDCARD) continue; - if (typeof runtimeId !== 'string' || runtimeId.length === 0) continue; - if (!runtimeIds.has(runtimeId)) { - errors.push( - 'capability "' + capId + '" runtimeCompat.' + field + - ' references unknown runtime "' + runtimeId + '"', - ); - } - } - } - if (cap.runtimeCompat.notes && typeof cap.runtimeCompat.notes === 'object') { - for (const runtimeId of Object.keys(cap.runtimeCompat.notes)) { - if (runtimeId === RUNTIME_COMPAT_WILDCARD) continue; - if (!runtimeIds.has(runtimeId)) { - errors.push( - 'capability "' + capId + '" runtimeCompat.notes references unknown runtime "' + runtimeId + '"', - ); - } - } - } - } - - // requires: acyclic - const cycleErrors = detectRequiresCycles(capMap); - errors.push(...cycleErrors); - - // requires: tier-monotone (core may not require standard/full; standard may not require full) - for (const [capId, cap] of capMap) { - if (!Array.isArray(cap.requires) || !VALID_TIERS.has(cap.tier)) continue; - const myRank = TIER_RANK[cap.tier]; - for (const req of cap.requires) { - const reqCap = capMap.get(req); - if (!reqCap || !VALID_TIERS.has(reqCap.tier)) continue; - const reqRank = TIER_RANK[reqCap.tier]; - if (reqRank > myRank) { - errors.push( - 'tier-monotone violation: capability "' + capId + '" (tier: ' + cap.tier + - ') requires "' + req + '" (tier: ' + reqCap.tier + - ') — a capability may not require a higher-tier capability', - ); - } - } - } - - return errors; -} - -/** - * Detect cycles in the requires graph using DFS. - */ -function detectRequiresCycles(capMap) { - const errors = []; - const WHITE = 0, GRAY = 1, BLACK = 2; - const color = new Map([...capMap.keys()].map((k) => [k, WHITE])); - - function dfs(id, stack) { - if (color.get(id) === GRAY) { - const cycleStr = [...stack, id].join(' → '); - errors.push('requires cycle detected: ' + cycleStr); - return; - } - if (color.get(id) === BLACK) return; - color.set(id, GRAY); - stack.push(id); - const cap = capMap.get(id); - if (cap && Array.isArray(cap.requires)) { - for (const req of cap.requires) { - if (capMap.has(req)) dfs(req, stack); - } - } - stack.pop(); - color.set(id, BLACK); - } - - for (const id of capMap.keys()) { - if (color.get(id) === WHITE) dfs(id, []); - } - - return errors; -} - -// ─── requiresClosure ───────────────────────────────────────────────────────── - -/** - * Compute the transitive requires closure for a capability id. - * Returns a Set of all transitively required capability ids. - * - * @param {string} id - * @param {Map} capMap - */ -function computeRequiresClosure(id, capMap) { - const visited = new Set(); - const queue = [id]; - while (queue.length > 0) { - const current = queue.shift(); - const cap = capMap.get(current); - if (!cap || !Array.isArray(cap.requires)) continue; - for (const req of cap.requires) { - if (!visited.has(req)) { - visited.add(req); - queue.push(req); - } - } - } - return visited; -} - -// ─── Topological ordering ───────────────────────────────────────────────────── - -function topoSortHookEntries(entries, hookKey, hookKind) { - if (entries.length <= 1) return entries; - - // Build adjacency: entry A must come before entry B if B consumes something A produces - const n = entries.length; - const inDegree = new Array(n).fill(0); - const adj = Array.from({ length: n }, () => []); - - for (let i = 0; i < n; i++) { - const producesI = new Set(entries[i][hookKey].produces || []); - for (let j = 0; j < n; j++) { - if (i === j) continue; - const consumesJ = entries[j][hookKey].consumes || []; - for (const artifact of consumesJ) { - if (producesI.has(artifact)) { - adj[i].push(j); - inDegree[j]++; - break; - } - } - } - } - - // Kahn's algorithm with stable tiebreak on capId - const queue = []; - for (let i = 0; i < n; i++) { - if (inDegree[i] === 0) queue.push(i); - } - // Sort queue by capId for determinism - queue.sort((a, b) => entries[a].capId.localeCompare(entries[b].capId)); - - const result = []; - while (queue.length > 0) { - // Take the first (sorted) ready node - const idx = queue.shift(); - result.push(entries[idx]); - const newReady = []; - for (const neighbor of adj[idx]) { - inDegree[neighbor]--; - if (inDegree[neighbor] === 0) newReady.push(neighbor); - } - newReady.sort((a, b) => entries[a].capId.localeCompare(entries[b].capId)); - queue.push(...newReady); - } - - // Fix #2: if result.length < n, Kahn's could not complete — there is a produces/consumes - // cycle. Do NOT silently fall back to declaration order; throw a clear error. - if (result.length < n) { - const sortedIds = entries.map((e) => e.capId).join(', '); - throw new Error( - 'produces/consumes cycle detected in ' + hookKind + ' at point "' + - (entries[0] && entries[0][hookKey] ? entries[0][hookKey].point : '?') + - '" among capabilities [' + sortedIds + ']: ' + - 'a cycle in hook produces/consumes prevents deterministic ordering', - ); - } - return result; -} - -/** - * Topologically sort steps at a given point by produces/consumes. - * Capability-id tiebreak for determinism. - * - * @param {{ capId: string, step: object }[]} entries - * @returns {{ capId: string, step: object }[]} - */ -function topoSortSteps(entries) { - return topoSortHookEntries(entries, 'step', 'steps'); -} - -function topoSortContributions(entries) { - return topoSortHookEntries(entries, 'contrib', 'contributions'); -} - // ─── ADR-857 Phase 4a: Derived views ───────────────────────────────────────── -// FIX 5 (lazy requires): paths are declared at top level but the actual require() -// calls are deferred into lazy accessor functions so importing this generator for -// its other exports does NOT fail at module-load time on a fresh/unbuilt worktree. +// (Config-slice validation, per-capability validators, contract validators, +// cross-capability validators, topo-sort helpers, and classifyCrossErrors have +// been moved to gsd-core/bin/lib/capability-validator.cjs per ADR-1244 D2.) + const INSTALL_PROFILES_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-profiles.cjs'); const CLUSTERS_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'clusters.cjs'); @@ -2025,125 +325,6 @@ function runConsistencyGate(capabilityClusters, profileMembership, capMap) { return warnings; } -// ─── ADR-857 phase 5e: configFormat ↔ installSurface parity gate ───────────── - -// Map: installSurface → expected configFormat -// Derived from the pairing of capability.json descriptors (installSurface) -// and capability.json descriptors (configFormat). DEFECT.GENERATIVE-FIX: this map -// is the single parity contract between the two generated surfaces. -// NOTE: both values come from the descriptor bodies in capMap — no dependency on -// runtime-config-adapter-registry.cjs, which now requires capability-registry.cjs -// (the file this gen-script produces), and thus must not be required here. -const INSTALL_SURFACE_TO_CONFIG_FORMAT = new Map([ - ['settings-json', 'settings-json'], - ['codex-toml', 'toml'], - ['copilot-instructions', 'markdown'], - ['cline-rules', 'markdown-dir'], - ['cursor-hooks-json', 'none'], - ['profile-marker-only', 'none'], -]); - -/** - * ADR-857 phase 5e: configFormat ↔ installSurface parity gate. - * - * For each runtime capability that has an installSurface in its descriptor, - * assert that its configFormat matches the expected value derived from its - * installSurface. Both values are read directly from the capMap descriptor - * bodies — no dependency on runtime-config-adapter-registry.cjs. - * - * HARD gate — throws on mismatch (DEFECT.GENERATIVE-FIX: this invariant is - * derived from two parallel generated surfaces and must fail loudly). - * - * @param {Map} capMap Fully-validated capability map. - * @returns {void} Throws on mismatch; returns normally on success. - */ -function runConfigFormatParityGate(capMap) { - // Read installSurface directly from the descriptor bodies already loaded into - // capMap — eliminates the require cycle introduced when adapter-registry was - // changed to require capability-registry.cjs (ADR-857 phase 5g drive 2). - for (const [capId, cap] of capMap) { - if (cap.role !== 'runtime') continue; - - const r = cap.runtime; - if (!r || typeof r.configFormat !== 'string') continue; // already validated above - - // Only check runtimes that have an installSurface (i.e. are config-adapter runtimes) - if (typeof r.installSurface !== 'string') continue; // grok etc. excluded — no installSurface - - const installSurface = r.installSurface; - const expectedConfigFormat = INSTALL_SURFACE_TO_CONFIG_FORMAT.get(installSurface); - - if (expectedConfigFormat === undefined) { - // Unknown installSurface — the mapping needs to be updated - throw new Error( - 'configFormat parity gate: runtime "' + capId + '" has installSurface "' + installSurface + - '" which is not in the INSTALL_SURFACE_TO_CONFIG_FORMAT mapping — ' + - 'update the mapping in scripts/gen-capability-registry.cjs', - ); - } - - if (r.configFormat !== expectedConfigFormat) { - throw new Error( - 'configFormat parity gate FAILED for runtime "' + capId + '":\n' + - ' installSurface: ' + installSurface + '\n' + - ' expected configFormat: ' + expectedConfigFormat + '\n' + - ' actual configFormat: ' + r.configFormat + '\n' + - 'The capability.json configFormat must match the value derived from installSurface ' + - '(src: scripts/gen-capability-registry.cjs INSTALL_SURFACE_TO_CONFIG_FORMAT)', - ); - } - } -} - -// ─── Gen-time wired guard ───────────────────────────────────────────────────── - -/** - * Validate that every hook point declared by a capability has a corresponding - * `loop render-hooks ` call site in one of the host-loop workflow files. - * - * Only valid loop points (in VALID_LOOP_POINTS) are checked here. Invalid points - * are already caught by validateStep/validateContribution/validateGate — do not - * double-report. - * - * @param {object} cap Validated capability object. - * @param {Set} wiredSet Set of points that have call sites in host workflows. - * @returns {string[]} Array of error strings; empty means all points are wired. - */ -function validateHooksWired(cap, wiredSet) { - const errors = []; - const capId = cap.id || '(unknown)'; - - function checkPoint(point, groupName, idx) { - // Only flag valid points that are unwired — invalid points are schema-validator's job. - if (!VALID_LOOP_POINTS.has(point)) return; - if (!wiredSet.has(point)) { - errors.push( - 'capability "' + capId + '" ' + groupName + '[' + idx + '].point "' + point + - '" is declared but not wired in any host-loop workflow ' + - '(no `loop render-hooks ' + point + '` call site). ' + - 'Wire the call site in the host workflow ' + - '(see scripts/gen-loop-host-contract.cjs STEP_WORKFLOWS) or remove the hook.', - ); - } - } - - for (let i = 0; i < (cap.steps || []).length; i++) { - const hook = cap.steps[i]; - if (hook.point !== undefined) checkPoint(hook.point, 'steps', i); - } - for (let i = 0; i < (cap.contributions || []).length; i++) { - const hook = cap.contributions[i]; - if (hook.point !== undefined) checkPoint(hook.point, 'contributions', i); - } - for (let i = 0; i < (cap.gates || []).length; i++) { - const hook = cap.gates[i]; - if (hook.point !== undefined) checkPoint(hook.point, 'gates', i); - } - - return errors; -} - -// ─── Registry builder ───────────────────────────────────────────────────────── /** * Read + validate all capabilities//capability.json files. @@ -2553,42 +734,6 @@ function normalizeLineEndings(content) { // ─── Main ───────────────────────────────────────────────────────────────────── -/** - * Fix #3: Emit pending-migration WARNINGs for config keys that collide with the central - * config-schema. Per ADR-894 staged cutover, a collision during the registry-only phase is - * NOT a hard error — the capability pipeline is being established before the atomic cutover - * PR for each feature. The registry still generates; the warning tells the maintainer which - * keys need to be moved out of the central schema at cutover time. - * - * A NEW unexpected collision (a key that shouldn't be in both) is also surfaced — the - * maintainer sees it in build output rather than it being silently swallowed. - * - * Reference: ADR-894 §4 "config-key ownership exclusive AND complete — presence in both = - * collision = a mid-flight migration; finish the move." - * - * @param {string[]} crossErrors Errors from validateCrossCapability (may include collision msgs) - * @param {Map} capMap - * @returns {{ hardErrors: string[], pendingMigrationWarnings: string[] }} - */ -function classifyCrossErrors(crossErrors) { - const hardErrors = []; - const pendingMigrationWarnings = []; - const collisionRe = /config key "([^"]+)" is declared in capability "([^"]+)" AND exists in the central config-schema/; - - for (const e of crossErrors) { - const m = collisionRe.exec(e); - if (m) { - // Collision = pending-migration warning, not a hard error during 3a-impl staged cutover - pendingMigrationWarnings.push( - '⚠ pending-migration: capability \'' + m[2] + '\' declares config key \'' + m[1] + - '\' still present in central config-schema; finish the move at cutover', - ); - } else { - hardErrors.push(e); - } - } - return { hardErrors, pendingMigrationWarnings }; -} function main() { const flag = process.argv[2]; diff --git a/scripts/run-tests.cjs b/scripts/run-tests.cjs index d6b5d4958..bc6933cdb 100644 --- a/scripts/run-tests.cjs +++ b/scripts/run-tests.cjs @@ -462,6 +462,20 @@ function main() { delete process.env.GSD_PROJECT; delete process.env.GSD_WORKSTREAM; delete process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS; + // Sandbox the overlay home so the loader's global scan ($GSD_HOME/.gsd/capabilities) + // cannot read a developer's real installed capabilities during tests (ADR-1244 D2). + // IDEMPOTENT: a nested run-tests spawn (e.g. tests/run-tests-harness.test.cjs) + // inherits this sandbox via env — it must REUSE it, never mkdtemp a fresh dir per + // invocation (that churned ~20+ temp dirs per harness run and amplified Docker load). + { + const { mkdtempSync } = require('fs'); + const { join: _join, basename: _basename } = require('path'); + const { tmpdir } = require('os'); + const _gh = process.env.GSD_HOME; + if (!_gh || !_basename(_gh).startsWith('gsd-test-home-')) { + process.env.GSD_HOME = mkdtempSync(_join(tmpdir(), 'gsd-test-home-')); + } + } // Log selected files to stderr for CI / harness-test visibility. // node:test default reporter doesn't echo filenames, so this gives diff --git a/src/capability-loader.cts b/src/capability-loader.cts new file mode 100644 index 000000000..9729ec2f9 --- /dev/null +++ b/src/capability-loader.cts @@ -0,0 +1,367 @@ +/** + * capability-loader.cts — runtime Capability Registry overlay (ADR-1244 D2). + * + * Promotes the registry from a frozen data file to a module with an interface: + * + * loadRegistry({ includeInstalled }) -> composed registry + * + * It composes the **first-party frozen registry** (the committed, generated + * `capability-registry.cjs`) with a **validated installed overlay** — third-party + * capability manifests read at runtime from per-scope install roots: + * - global: $GSD_HOME/.gsd/capabilities//capability.json (GSD_HOME defaults to ~) + * - project: /.gsd/capabilities//capability.json + * + * Invariants enforced over the merged set (first-party ∪ overlay): + * - First-party always wins: an overlay whose `id`, owned skill/agent stem, or + * federated config key collides with first-party (or uses a reserved `gsd-` / + * `gsd-core-` / `anthropic-` id prefix) is rejected. + * - Load-time re-gate (default-resilient): an overlay that fails validation or + * whose `engines.gsd` does not satisfy the running GSD version is SKIPPED + * with a warning — it never crashes the loop. EXCEPTION (per-hook-kind + * policy): a skipped capability that declares a `gate` is recorded in + * `_overlay.incompatibleGateCapIds` so the loop resolver can fail CLOSED for + * that gate rather than silently proceeding as if it had passed. + * + * The merged registry is materialized by the canonical `buildRegistry` + * (re-exported from the generator, which ships) over a cap-map reconstructed + * from the frozen registry's capability objects plus the accepted overlay + * capabilities — so every derived view (bySkill, byLoopPoint, configSchema, + * capabilityClusters, profileMembership, …) is computed by exactly one builder + * and cannot drift from the first-party path. + * + * Install never executes capability code here (staging/exec belongs to ADR-1244 + * D3/D5); this module only READS and VALIDATES declarations. + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +type Registry = Record; + +interface CapManifest { + id: string; + role?: string; + version?: string; + skills?: string[]; + agents?: string[]; + commands?: Array>; + config?: Record; + gates?: unknown[]; + engines?: { gsd?: string }; +} + +interface ValidatorModule { + validateCapability: (cap: unknown, id: string) => string[]; + /** Returns an error array (e.g. fragment path escapes the capability dir) — NOT a throw. */ + materializeHookFragments: (cap: unknown, capDir: string) => string[]; + validateAgainstContract: (cap: unknown, capId: string) => string[]; + validateConsumesGlobal: (capMap: Map) => string[]; + validateCrossCapability: (capMap: Map, centralKeys: Set) => string[]; +} +interface SemverModule { + semverSatisfies: (version: unknown, range: unknown) => boolean; +} +interface ProjectRootModule { + findProjectRoot: (startDir: string) => string | null; +} +interface GeneratorModule { + buildRegistry: (capMap: Map) => Registry; + loadCentralConfigKeys: () => Set; +} + +export interface LoadRegistryOptions { + /** When true, compose the validated installed overlay on top of first-party. */ + includeInstalled?: boolean; + /** Working directory used to locate the project-scoped overlay root. */ + cwd?: string; + /** Override the global overlay home (defaults to GSD_HOME env or os.homedir()). */ + gsdHome?: string; + /** Override the running GSD version used for engines.gsd satisfaction. */ + hostVersion?: string; +} + +export interface OverlaySkip { + id: string; + scope: 'global' | 'project'; + reason: string; +} + +export interface BlockedGate { + /** Loop extension point the skipped capability declared a gate at. */ + point: string; + /** The skipped capability's id. */ + capId: string; + /** Why the capability was skipped. */ + reason: string; +} + +export interface OverlayMeta { + /** Capabilities skipped at load, with the reason (surfaced to the user). */ + warnings: OverlaySkip[]; + /** Skipped capabilities that declared a gate — the loop must fail CLOSED for these. */ + incompatibleGateCapIds: string[]; + /** + * Per-point fail-closed records: for each gate a skipped capability declared at + * a known loop point, the loop resolver must inject a blocking gate at that + * point rather than proceeding as if the gate had passed. + */ + blockedGates: BlockedGate[]; +} + +const RESERVED_ID_PREFIX = /^(gsd-|gsd-core-|anthropic-)/; +const GSD_HOME_DIRNAME = '.gsd'; + +function errMessage(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +/** Resolve the running GSD version; fail-closed to '0.0.0' if it cannot be read. */ +function readHostVersion(): string { + try { + // gsd-core/bin/lib/ -> repo/package root is three levels up. + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment + const pkg: { version?: string } = require('../../../package.json'); + return typeof pkg.version === 'string' && pkg.version ? pkg.version : '0.0.0'; + } catch { + return '0.0.0'; + } +} + +/** + * The ordered overlay install roots (global first, then project), deduped by + * resolved absolute path so a single directory is never scanned twice (which + * would otherwise self-report a spurious id collision when the project lives + * under the GSD home, or in tests where both resolve to the same fixture). + */ +function overlayRoots(cwd: string, gsdHome?: string): Array<{ dir: string; scope: 'global' | 'project' }> { + const roots: Array<{ dir: string; scope: 'global' | 'project' }> = []; + const seen = new Set(); + const add = (dir: string, scope: 'global' | 'project'): void => { + const resolved = path.resolve(dir); + if (seen.has(resolved)) return; + seen.add(resolved); + roots.push({ dir: resolved, scope }); + }; + const home = gsdHome || process.env['GSD_HOME'] || os.homedir(); + add(path.join(home, GSD_HOME_DIRNAME, 'capabilities'), 'global'); + let projectRoot: string | null = null; + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment + const projectRootMod: ProjectRootModule = require('./project-root.cjs'); + projectRoot = projectRootMod.findProjectRoot(cwd); + } catch { + projectRoot = null; + } + if (projectRoot) { + add(path.join(projectRoot, GSD_HOME_DIRNAME, 'capabilities'), 'project'); + } + return roots; +} + +/** Shallow-attach overlay diagnostics WITHOUT mutating the frozen registry module. */ +function withOverlayMeta(reg: Registry, meta: OverlayMeta): Registry { + return Object.assign({}, reg, { _overlay: meta }); +} + +/** + * Load the capability registry, optionally composing the installed overlay. + * + * @returns the registry object (same shape as `capability-registry.cjs`). When + * overlays are considered, an `_overlay` field carries skip warnings and the + * fail-closed gate list. With `includeInstalled` falsy, the frozen first-party + * registry is returned unchanged (identity-stable). + */ +export function loadRegistry(options: LoadRegistryOptions = {}): Registry { + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment + const base: Registry = require('./capability-registry.cjs'); + if (!options.includeInstalled) return base; + + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment + const validator: ValidatorModule = require('./capability-validator.cjs'); + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment + const semver: SemverModule = require('./semver-compare.cjs'); + + const cwd = options.cwd || process.cwd(); + const hostVersion = options.hostVersion || readHostVersion(); + + const warnings: OverlaySkip[] = []; + const incompatibleGateCapIds: string[] = []; + const blockedGates: BlockedGate[] = []; + const overlayCaps: CapManifest[] = []; + + // First-party reservations — first-party always wins. + const fpCaps = (base.capabilities ?? {}) as Record; + const fpBySkill = (base.bySkill ?? {}) as Record; + const fpByAgent = (base.byAgent ?? {}) as Record; + const fpConfigKeys = (base.configKeys ?? {}) as Record; + const fpConfigSchema = (base.configSchema ?? {}) as Record; + + const fpFamilies = (base.commandFamilies ?? {}) as Record; + const fpIds = new Set(Object.keys(fpCaps)); + const claimedSkills = new Set(Object.keys(fpBySkill)); + const claimedAgents = new Set(Object.keys(fpByAgent)); + const claimedConfig = new Set([...Object.keys(fpConfigKeys), ...Object.keys(fpConfigSchema)]); + const claimedFamilies = new Set(Object.keys(fpFamilies)); + const acceptedIds = new Set(); + + // Running merged cap-map (first-party ∪ accepted overlays). A candidate is + // accepted only if the FULL cross-capability suite stays clean after adding it + // (first-party alone is clean, so any new error is the candidate's fault) — the + // overlay can never violate the same invariants the build-time generator enforces. + const acceptedMap = new Map(Object.entries(fpCaps)); + + // Generator (buildRegistry + central config keys) loaded lazily — only when at + // least one overlay candidate exists, so the no-overlay fast path stays cheap. + let generatorMod: GeneratorModule | null = null; + const getGenerator = (): GeneratorModule => { + if (generatorMod) return generatorMod; + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment + const mod: GeneratorModule = require('../../../scripts/gen-capability-registry.cjs'); + generatorMod = mod; + return mod; + }; + let centralKeys: Set | null = null; + const getCentralKeys = (): Set => { + if (!centralKeys) { + try { + centralKeys = getGenerator().loadCentralConfigKeys(); + } catch { + centralKeys = new Set(); + } + } + return centralKeys; + }; + + for (const root of overlayRoots(cwd, options.gsdHome)) { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(root.dir, { withFileTypes: true }); + } catch { + continue; // no overlay dir at this scope — normal + } + for (const ent of entries) { + if (!ent.isDirectory()) continue; + const id = ent.name; + const capDir = path.join(root.dir, id); + const manifestPath = path.join(capDir, 'capability.json'); + + let cap: CapManifest; + try { + cap = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as CapManifest; + } catch (e) { + warnings.push({ id, scope: root.scope, reason: 'unreadable or invalid capability.json: ' + errMessage(e) }); + continue; + } + + // Points at which this capability declares a gate — used to fail CLOSED if + // the capability is skipped (a skipped deploy gate must block, not pass). + const gatePoints: string[] = Array.isArray(cap.gates) + ? (cap.gates as Array>) + .map((g) => (g && typeof g === 'object' && typeof g.point === 'string' ? g.point : null)) + .filter((p): p is string => typeof p === 'string') + : []; + const declaresGate = gatePoints.length > 0; + const skip = (reason: string): void => { + warnings.push({ id, scope: root.scope, reason }); + if (declaresGate) { + incompatibleGateCapIds.push(id); + for (const point of gatePoints) blockedGates.push({ point, capId: id, reason }); + } + }; + + // 1. Reserved namespace — third-party may not impersonate first-party. + if (RESERVED_ID_PREFIX.test(id)) { + skip('id uses a reserved first-party prefix (gsd-/gsd-core-/anthropic-)'); + continue; + } + // 2. Per-capability structural + version-envelope validation. + const errs = validator.validateCapability(cap, id); + if (errs.length) { + skip('failed validation: ' + errs.join('; ')); + continue; + } + // 3. First-party wins + overlay/overlay de-dup on id, skill, agent, config key. + if (fpIds.has(id) || acceptedIds.has(id)) { + skip('id collides with an already-registered capability'); + continue; + } + const skills: string[] = Array.isArray(cap.skills) ? cap.skills : []; + const agents: string[] = Array.isArray(cap.agents) ? cap.agents : []; + const cfgKeys: string[] = cap.config && typeof cap.config === 'object' && !Array.isArray(cap.config) + ? Object.keys(cap.config) : []; + const skillClash = skills.find((s) => claimedSkills.has(s)); + if (skillClash) { skip('owns skill "' + skillClash + '" already owned by another capability'); continue; } + const agentClash = agents.find((a) => claimedAgents.has(a)); + if (agentClash) { skip('owns agent "' + agentClash + '" already owned by another capability'); continue; } + const cfgClash = cfgKeys.find((k) => claimedConfig.has(k)); + if (cfgClash) { skip('owns config key "' + cfgClash + '" already owned by another capability'); continue; } + const families: string[] = Array.isArray(cap.commands) + ? cap.commands + .map((c) => (c && typeof c === 'object' && typeof c.family === 'string' ? c.family : null)) + .filter((f): f is string => typeof f === 'string') + : []; + const familyClash = families.find((f) => claimedFamilies.has(f)); + if (familyClash) { skip('owns command family "' + familyClash + '" already owned by another capability'); continue; } + // 4. Load-time engines.gsd re-gate. + const range = cap.engines?.gsd; + if (typeof range === 'string' && range && !semver.semverSatisfies(hostVersion, range)) { + skip('incompatible with GSD ' + hostVersion + ' (requires engines.gsd "' + range + '")'); + continue; + } + // 5. Materialize path-based hook fragments (resolved against the overlay dir). + // materializeHookFragments RETURNS errors (e.g. a fragment path escaping the + // capability dir) — capture them; an un-materializable fragment is a skip. + let fragErrs: string[]; + try { + fragErrs = validator.materializeHookFragments(cap, capDir) || []; + } catch (e) { + skip('hook fragment could not be materialized: ' + errMessage(e)); + continue; + } + if (fragErrs.length) { + skip('invalid hook fragment: ' + fragErrs.join('; ')); + continue; + } + // 6. Full cross-capability validation over the merged set (the same invariants + // the build-time generator enforces): contract roles, consumes-satisfiability, + // owner-uniqueness, config-key exclusivity vs central schema, requires acyclicity + // + tier-monotone. Incremental: add the candidate, validate, drop on any error. + acceptedMap.set(id, cap); + const crossErrs = [ + ...validator.validateAgainstContract(cap, id), + ...validator.validateConsumesGlobal(acceptedMap), + ...validator.validateCrossCapability(acceptedMap, getCentralKeys()), + ]; + if (crossErrs.length) { + acceptedMap.delete(id); + skip('cross-capability validation failed: ' + crossErrs.slice(0, 3).join('; ')); + continue; + } + + // Accepted. + overlayCaps.push(cap); + acceptedIds.add(id); + for (const s of skills) claimedSkills.add(s); + for (const a of agents) claimedAgents.add(a); + for (const k of cfgKeys) claimedConfig.add(k); + for (const f of families) claimedFamilies.add(f); + } + } + + const meta: OverlayMeta = { warnings, incompatibleGateCapIds, blockedGates }; + + if (overlayCaps.length === 0) { + // Nothing to compose. Return the frozen registry unchanged when there is + // also nothing to report (identity-stable); otherwise attach diagnostics. + if (warnings.length === 0) return base; + return withOverlayMeta(base, meta); + } + + // Compose via the canonical builder so every derived view matches first-party. + // acceptedMap already holds first-party ∪ accepted overlays (validated above). + const merged = getGenerator().buildRegistry(acceptedMap); + return withOverlayMeta(merged, meta); +} + +module.exports = { loadRegistry }; diff --git a/src/capability-state.cts b/src/capability-state.cts index 5a12b33af..389b85d8b 100644 --- a/src/capability-state.cts +++ b/src/capability-state.cts @@ -477,13 +477,13 @@ function resolveCapabilityRuntimeState( } } - // ── Load registry (ADR-857 phase 4c) ──────────────────────────────────────── - // Load BEFORE resolveProfile and resolveSurface so both calls receive the - // registry and capability-contributed skills are reflected in installed/surfaced. - // No-op today (UI capability is tier:full → only adds to 'full', which returns - // '*' regardless) but cutover-ready for future tier:core/standard capabilities. + // ── Load registry (ADR-1244 D2 wiring) ────────────────────────────────────── + // Load overlay-aware registry BEFORE resolveProfile and resolveSurface so both + // calls receive the composed registry and installed third-party capabilities are + // reflected in installed/surfaced state exactly like first-party capabilities. // eslint-disable-next-line @typescript-eslint/no-require-imports - const registry = require('./capability-registry.cjs') as Record; + const { loadRegistry } = require('./capability-loader.cjs') as { loadRegistry: (opts?: Record) => Record }; + const registry = loadRegistry({ includeInstalled: true, cwd }); // ── Resolve installed skills (from install profile) ────────────────────────── // Distinguish "no profile marker → default full" (legitimate) from a thrown diff --git a/src/config-loader.cts b/src/config-loader.cts index 098911333..feb38be31 100644 --- a/src/config-loader.cts +++ b/src/config-loader.cts @@ -42,6 +42,10 @@ import federatedConfigModule = require('./federated-config.cjs'); const { mergeFederatedConfig } = federatedConfigModule; // The capability-registry.cjs is generated and lives in the same gsd-core/bin/lib/ output dir. // Both config-loader.cjs and capability-registry.cjs land in gsd-core/bin/lib/ at build time. +// This is the FROZEN first-party registry — used as the test-seam default and the +// fallback. Overlay (installed third-party) config-key federation is cwd-dependent +// and composed PER loadConfig CALL by _federatedConfigSchema(cwd) below (ADR-1244 D2), +// never eagerly at module load. // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment const _capabilityRegistryReal: { configSchema?: Record } = require('./capability-registry.cjs'); @@ -349,11 +353,33 @@ function _applyFederatedValues( * When validKeys is non-empty, applies values into a shallow clone to avoid * mutating shared CONFIG_DEFAULTS/module constants. */ +// Resolve the federated capability config-schema for a project (ADR-1244 D2). +// A test override (via _setFederatedRegistryForTests) wins; otherwise, when a +// project cwd is available, compose the installed overlay for THAT project — +// LAZILY (never at module load, so a bare require never scans the filesystem and +// the result is never cached for the wrong cwd) — falling back to the frozen +// first-party schema when there is no cwd or the loader is unavailable. +function _federatedConfigSchema(cwd?: string): Record | undefined { + if (_capabilityRegistry !== _capabilityRegistryReal) { + return _capabilityRegistry.configSchema; // explicit test override + } + if (typeof cwd === 'string' && cwd) { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment + const loaderMod: { loadRegistry: (o?: Record) => { configSchema?: Record } } = require('./capability-loader.cjs'); + const schema = loaderMod.loadRegistry({ includeInstalled: true, cwd }).configSchema; + if (schema && typeof schema === 'object') return schema; + } catch { /* fall back to first-party */ } + } + return _capabilityRegistryReal.configSchema; +} + function _applyFederatedOverlay( baseConfig: Record, userConfig: Record, + cwd?: string, ): Record { - const _fedRegistrySchema = _capabilityRegistry.configSchema; + const _fedRegistrySchema = _federatedConfigSchema(cwd); if (!_fedRegistrySchema || typeof _fedRegistrySchema !== 'object') return baseConfig; const _fedOverlay = mergeFederatedConfig({ configSchema: _fedRegistrySchema, @@ -508,7 +534,7 @@ function loadConfigResolved(cwd: string, options: Record = {}): let _preWarningFedValidKeys: string[] = []; try { - const _fedRegistrySchemaEarly = _capabilityRegistry.configSchema; + const _fedRegistrySchemaEarly = _federatedConfigSchema(cwd); if (_fedRegistrySchemaEarly && typeof _fedRegistrySchemaEarly === 'object') { const _earlyOverlay = mergeFederatedConfig({ configSchema: _fedRegistrySchemaEarly, @@ -613,7 +639,7 @@ function loadConfigResolved(cwd: string, options: Record = {}): // ADR-857 phase 3b: federated config overlay try { if (_preWarningFedValidKeys.length > 0) { - const _fedRegistrySchema = _capabilityRegistry.configSchema; + const _fedRegistrySchema = _federatedConfigSchema(cwd); if (_fedRegistrySchema && typeof _fedRegistrySchema === 'object') { const _fedOverlay = mergeFederatedConfig({ configSchema: _fedRegistrySchema, @@ -651,7 +677,7 @@ function loadConfigResolved(cwd: string, options: Record = {}): } // Branch C: .planning/ exists but no config.json and no root config — federated/builtin defaults try { - return { config: _applyFederatedOverlay(defaults, {}), source: 'builtin-defaults', degraded: false }; + return { config: _applyFederatedOverlay(defaults, {}, cwd), source: 'builtin-defaults', degraded: false }; } catch { return { config: defaults, source: 'builtin-defaults', degraded: false }; } @@ -692,14 +718,14 @@ function loadConfigResolved(cwd: string, options: Record = {}): }; // Branch D: global-defaults try { - return { config: _applyFederatedOverlay(_globalBaseCfg, globalDefaults), source: 'global-defaults', degraded: false }; + return { config: _applyFederatedOverlay(_globalBaseCfg, globalDefaults, cwd), source: 'global-defaults', degraded: false }; } catch { return { config: _globalBaseCfg, source: 'global-defaults', degraded: false }; } } catch { // Branch E: no global defaults try { - return { config: _applyFederatedOverlay(defaults, {}), source: 'builtin-defaults', degraded: false }; + return { config: _applyFederatedOverlay(defaults, {}, cwd), source: 'builtin-defaults', degraded: false }; } catch { return { config: defaults, source: 'builtin-defaults', degraded: false }; } diff --git a/src/config-schema.cts b/src/config-schema.cts index 50f028d83..903c1af9f 100644 --- a/src/config-schema.cts +++ b/src/config-schema.cts @@ -21,16 +21,33 @@ import { DYNAMIC_KEY_PATTERNS, } from './configuration.cjs'; +// Frozen first-party capability config-schema — the fallback when no project cwd +// is available (cwd-agnostic call sites). // eslint-disable-next-line @typescript-eslint/no-require-imports const capabilityRegistry = require('./capability-registry.cjs') as { configSchema?: Record; }; -function isCapabilityConfigKey(keyPath: string): boolean { +// Resolve the capability config-schema for a project (ADR-1244 D2). When a cwd is +// supplied, compose installed overlay capabilities for THAT project — LAZILY (never +// at module load: a bare require of this module never scans the filesystem) — +// falling back to the frozen first-party schema. Without a cwd, first-party only. +function _capabilityConfigSchema(cwd?: string): Record { + if (typeof cwd === 'string' && cwd) { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment + const loaderMod: { loadRegistry: (o?: Record) => { configSchema?: Record } } = require('./capability-loader.cjs'); + const schema = loaderMod.loadRegistry({ includeInstalled: true, cwd }).configSchema; + if (schema && typeof schema === 'object') return schema; + } catch { /* fall back to first-party */ } + } + const fp = capabilityRegistry.configSchema; + return fp && typeof fp === 'object' ? fp : {}; +} + +function isCapabilityConfigKey(keyPath: string, cwd?: string): boolean { if (typeof keyPath !== 'string') return false; - const schema = capabilityRegistry.configSchema; - if (!schema || typeof schema !== 'object') return false; - return Object.prototype.hasOwnProperty.call(schema, keyPath); + return Object.prototype.hasOwnProperty.call(_capabilityConfigSchema(cwd), keyPath); } /** @@ -48,9 +65,9 @@ function isCentralConfigKey(keyPath: string): boolean { * Returns true if keyPath is a valid central, runtime-state, dynamic, or * federated Capability config key. */ -function isValidConfigKey(keyPath: string): boolean { +function isValidConfigKey(keyPath: string, cwd?: string): boolean { if (isCentralConfigKey(keyPath)) return true; - return isCapabilityConfigKey(keyPath); + return isCapabilityConfigKey(keyPath, cwd); } export = { diff --git a/src/config.cts b/src/config.cts index 35a3afed5..652f1df60 100644 --- a/src/config.cts +++ b/src/config.cts @@ -560,7 +560,7 @@ function cmdConfigSet(cwd: string, keyPath: string | undefined, value: string | validateKnownConfigKeyPath(kp); - if (!isValidConfigKey(kp)) { + if (!isValidConfigKey(kp, cwd)) { error(`Unknown config key: "${kp}". Valid keys: ${[...VALID_CONFIG_KEYS].sort().join(', ')}, agent_skills., features.`, ERROR_REASON.CONFIG_INVALID_KEY); } diff --git a/src/loop-resolver.cts b/src/loop-resolver.cts index fe07faeb4..5b5b8e856 100644 --- a/src/loop-resolver.cts +++ b/src/loop-resolver.cts @@ -492,9 +492,11 @@ function cmdLoopRenderHooks( warnings?: string[]; capabilities: Array<{ id: string; enabled?: boolean; active: boolean }>; }; - // Registry is the static generated module — same object capability-state uses internally. + // Load overlay-aware registry (ADR-1244 D2 wiring) so installed third-party + // capabilities are visible to loop rendering exactly like first-party ones. // eslint-disable-next-line @typescript-eslint/no-require-imports - const registry = require('./capability-registry.cjs') as Record; + const { loadRegistry } = require('./capability-loader.cjs') as { loadRegistry: (opts?: Record) => Record }; + const registry = loadRegistry({ includeInstalled: true, cwd }); const capabilityStatesById = new Map(); for (const cap of state.capabilities || []) { capabilityStatesById.set(cap.id, cap); @@ -509,6 +511,27 @@ function cmdLoopRenderHooks( return; } + // ── ADR-1244 D2 fail-closed gate injection ──────────────────────────────────── + // For every skipped overlay capability that declared a gate at this point, + // inject a synthetic BLOCKING gate into the resolved output so the loop HALTS + // rather than silently proceeding as if the gate had passed. step/contribution + // overlays that were skipped are left open (skip-open is correct for them). + const overlayMeta = (registry as { _overlay?: { blockedGates?: Array<{ point: string; capId: string; reason: string }> } })['_overlay']; + if (overlayMeta && Array.isArray(overlayMeta.blockedGates)) { + for (const blocked of overlayMeta.blockedGates) { + if (blocked.point === point) { + const syntheticGate: ActiveHook = { + capId: blocked.capId, + kind: 'gate', + blocking: true, + onError: 'halt', + check: `capability "${blocked.capId}" was skipped at load (${blocked.reason}); its gate at ${point} cannot be evaluated — failing closed`, + }; + resolved.activeHooks.push(syntheticGate); + } + } + } + // --active-cap mode: print exactly 'true' or 'false' with no envelope if (activeCapId !== undefined) { const isActive = resolved.activeHooks.some((h) => h.capId === activeCapId); diff --git a/src/semver-compare.cts b/src/semver-compare.cts index 465e9e1df..98c0e9e2a 100644 --- a/src/semver-compare.cts +++ b/src/semver-compare.cts @@ -49,3 +49,126 @@ export function isSemverNewer(a: VersionInput, b: VersionInput): boolean { export function isStableTripletSemver(v: VersionInput): boolean { return /^\d+\.\d+\.\d+$/.test(String(v || '').replace(/^v/, '')); } + +// ─── Range satisfaction (ADR-1244 D2 — engines.gsd load-time gate) ──────────── +// +// A minimal, hand-written `semverSatisfies(version, range)` — deliberately NOT +// the `semver` npm package (no new dependency / supply-chain surface in core, +// consistent with this module's hand-written heritage). It supports the operator +// subset capability `engines.gsd` ranges actually use: `>= <= > < =` (exact), +// caret `^`, tilde `~`, OR via `||`, AND via whitespace, partials (`1`, `1.2`) +// and wildcards (`*`, `1.x`). Satisfaction is computed on the numeric +// major.minor.patch core (prerelease-insensitive), matching this module's +// existing `toNumericTuple` policy. CRITICAL: any comparator it cannot parse +// makes the whole check FAIL CLOSED (returns false) — an unparseable engines +// range must never silently pass the load-time gate. + +type RangeOp = '>=' | '<=' | '>' | '<' | '='; +interface Primitive { op: RangeOp; t: SemverTuple; } + +function compareTuples(a: SemverTuple, b: SemverTuple): CompareResult { + if (a[0] !== b[0]) return a[0] > b[0] ? 1 : -1; + if (a[1] !== b[1]) return a[1] > b[1] ? 1 : -1; + if (a[2] !== b[2]) return a[2] > b[2] ? 1 : -1; + return 0; +} + +// Parse a version-ish token into a tuple + how many leading numeric parts were +// specified (0 = bare wildcard "*"/"x", 1 = "1", 2 = "1.2", 3 = "1.2.3"). +// Returns null if the token is not a parseable partial/full version. +function parseVersionToken(token: string): { tuple: SemverTuple; specified: 0 | 1 | 2 | 3 } | null { + const clean = token.trim().replace(/^v/, '').replace(/[-+].*$/, ''); + if (clean === '' || clean === '*' || clean === 'x' || clean === 'X') return { tuple: [0, 0, 0], specified: 0 }; + const parts = clean.split('.'); + if (parts.length > 3) return null; + const nums: number[] = []; + let sawWildcard = false; + for (const p of parts) { + if (p === 'x' || p === 'X' || p === '*') { sawWildcard = true; continue; } + // A concrete segment after a wildcard ("1.x.2", "1.*.2") is malformed → fail closed. + if (sawWildcard) return null; + if (!/^\d+$/.test(p)) return null; + nums.push(Number.parseInt(p, 10)); + } + if (nums.length === 0) return { tuple: [0, 0, 0], specified: 0 }; + return { tuple: [nums[0] || 0, nums[1] || 0, nums[2] || 0], specified: nums.length as 1 | 2 | 3 }; +} + +// Expand a single comparator into primitive (op, tuple) constraints, or null if +// unparseable (→ fail closed). +function expandComparator(c: string): Primitive[] | null { + const trimmed = c.trim(); + if (trimmed === '' || trimmed === '*' || trimmed === 'x' || trimmed === 'X') return [{ op: '>=', t: [0, 0, 0] }]; + const m = /^(>=|<=|>|<|=|\^|~)?\s*(.+)$/.exec(trimmed); + if (!m) return null; + const op = m[1] || ''; + const pv = parseVersionToken(m[2]); + if (!pv) return null; + const { tuple, specified } = pv; + const [maj, min, pat] = tuple; + + if (op === '^') { + let upper: SemverTuple; + if (maj > 0) upper = [maj + 1, 0, 0]; + else if (min > 0) upper = [0, min + 1, 0]; + else upper = [0, 0, pat + 1]; + return [{ op: '>=', t: tuple }, { op: '<', t: upper }]; + } + if (op === '~') { + const upper: SemverTuple = specified >= 2 ? [maj, min + 1, 0] : [maj + 1, 0, 0]; + return [{ op: '>=', t: tuple }, { op: '<', t: upper }]; + } + if (op === '' || op === '=') { + if (specified === 0) return [{ op: '>=', t: [0, 0, 0] }]; // "*" → any + if (specified === 3) return [{ op: '=', t: tuple }]; + const upper: SemverTuple = specified === 1 ? [maj + 1, 0, 0] : [maj, min + 1, 0]; + return [{ op: '>=', t: tuple }, { op: '<', t: upper }]; + } + // >= <= > < with an explicit version + if (specified === 0) return null; // e.g. ">=*" is meaningless → fail closed + return [{ op: op as RangeOp, t: tuple }]; +} + +function satisfiesPrimitive(v: SemverTuple, prim: Primitive): boolean { + const cmp = compareTuples(v, prim.t); + switch (prim.op) { + case '>=': return cmp >= 0; + case '<=': return cmp <= 0; + case '>': return cmp > 0; + case '<': return cmp < 0; + case '=': return cmp === 0; + default: return false; + } +} + +// One whitespace-separated comparator set (ANDed). Fail closed if any comparator +// is unparseable. +function satisfiesSet(v: SemverTuple, set: string): boolean { + const trimmed = set.trim(); + if (trimmed === '') return false; + const comparators = trimmed.split(/\s+/).filter(Boolean); + if (comparators.length === 0) return false; + for (const c of comparators) { + const prims = expandComparator(c); + if (prims === null) return false; // unparseable → fail closed + for (const prim of prims) { + if (!satisfiesPrimitive(v, prim)) return false; + } + } + return true; +} + +/** + * Does `version` satisfy the semver `range`? OR-composed across `||`, AND-composed + * across whitespace. Fail-closed: an empty range, or any comparator this minimal + * implementation cannot parse, returns false. Comparison is on the numeric + * major.minor.patch core (prerelease tags are stripped, per `toNumericTuple`). + */ +export function semverSatisfies(version: VersionInput, range: VersionInput): boolean { + const r = String(range == null ? '' : range).trim(); + if (r === '') return false; + const v = toNumericTuple(version); + const orSets = r.split('||').map((s) => s.trim()).filter((s) => s.length > 0); + if (orSets.length === 0) return false; + return orSets.some((set) => satisfiesSet(v, set)); +} diff --git a/tests/capability-loader.test.cjs b/tests/capability-loader.test.cjs new file mode 100644 index 000000000..03001c75c --- /dev/null +++ b/tests/capability-loader.test.cjs @@ -0,0 +1,275 @@ +'use strict'; + +/** + * capability-loader.test.cjs — ADR-1244 D2 runtime registry overlay. + * + * Behavioral tests for loadRegistry({ includeInstalled }): first-party ∪ + * validated overlay composition, first-party-wins collisions, reserved + * namespace, engines.gsd load-time re-gate (skip-with-warning), gate-kind + * fail-closed tracking, and parity with the canonical builder. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { cleanup } = require('./helpers.cjs'); +const { loadRegistry } = require('../gsd-core/bin/lib/capability-loader.cjs'); +const baseRegistry = require('../gsd-core/bin/lib/capability-registry.cjs'); +const { buildRegistry } = require('../scripts/gen-capability-registry.cjs'); + +const HOST = '1.6.0'; + +function featureCap(id, extra) { + return { + id, role: 'feature', version: '1.0.0', title: id, description: 'overlay cap', + tier: 'standard', requires: [], engines: { gsd: '>=1.0.0' }, + runtimeCompat: { supported: ['*'], unsupported: [] }, + skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [], + ...extra, + }; +} + +// Build a temp GSD home containing .gsd/capabilities//capability.json for each cap. +function makeOverlayHome(caps) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-overlay-')); + for (const cap of caps) { + const dir = path.join(home, '.gsd', 'capabilities', cap.id); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'capability.json'), JSON.stringify(cap), 'utf8'); + } + return home; +} + +// Always pass cwd === home so the project-root probe cannot wander into the +// real repo; root-dedup makes the project scope a no-op there. +function load(home, opts) { + return loadRegistry({ includeInstalled: true, gsdHome: home, cwd: home, hostVersion: HOST, ...opts }); +} + +describe('loadRegistry — base behavior', () => { + test('without includeInstalled returns the frozen registry (identity-stable)', () => { + assert.strictEqual(loadRegistry(), baseRegistry); + assert.strictEqual(loadRegistry({ includeInstalled: false }), baseRegistry); + }); + + test('includeInstalled with no overlay directory returns the frozen registry unchanged', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-empty-')); + try { + assert.strictEqual(load(home), baseRegistry); + } finally { + cleanup(home); + } + }); +}); + +describe('loadRegistry — accepting valid overlays', () => { + test('a valid overlay capability appears in every derived view (toggable + federated)', (t) => { + const home = makeOverlayHome([ + featureCap('deploy-gate', { + skills: ['deploy-review'], + agents: ['gsd-deploy-checker'], + config: { 'workflow.deploy_gate': { type: 'boolean', default: true, description: 'Enable the deploy gate.' } }, + steps: [{ point: 'execute:wave:post', ref: { skill: 'deploy-review' }, produces: ['DEPLOY.md'], consumes: [], when: 'workflow.deploy_gate', onError: 'skip' }], + }), + ]); + t.after(() => cleanup(home)); + const reg = load(home); + + assert.ok(reg.capabilities['deploy-gate'], 'overlay in capabilities'); + assert.equal(reg.bySkill['deploy-review'], 'deploy-gate', 'overlay skill indexed (surface)'); + assert.equal(reg.byAgent['gsd-deploy-checker'], 'deploy-gate', 'overlay agent indexed'); + assert.ok(reg.configSchema['workflow.deploy_gate'], 'overlay config federated'); + assert.equal(reg.configKeys['workflow.deploy_gate'], 'deploy-gate', 'overlay config key owned'); + assert.ok(reg.capabilityClusters['deploy-gate'], 'overlay in capabilityClusters (surface toggle)'); + assert.ok(reg.profileMembership['deploy-gate'], 'overlay in profileMembership (surface toggle)'); + const wavePost = reg.byLoopPoint['execute:wave:post']; + assert.ok(wavePost && Array.isArray(wavePost.steps) && + wavePost.steps.some((h) => h.capId === 'deploy-gate'), 'overlay step wired into the loop'); + + // First-party is preserved. + assert.equal(reg.capabilities['ui'].title, 'UI design contracts'); + assert.equal(reg._overlay.warnings.length, 0, 'no warnings when all overlays accepted'); + assert.deepEqual(reg._overlay.incompatibleGateCapIds, []); + }); + + test('composed registry equals buildRegistry over the same merged cap-map (no drift / no dropped caps)', (t) => { + const overlay = featureCap('extra-cap', { skills: ['extra-skill'] }); + const home = makeOverlayHome([overlay]); + t.after(() => cleanup(home)); + const reg = load(home); + + const mergedMap = new Map(Object.entries(baseRegistry.capabilities)); + mergedMap.set('extra-cap', overlay); + const expected = buildRegistry(mergedMap); + + assert.deepEqual(Object.keys(reg.capabilities).sort(), Object.keys(expected.capabilities).sort()); + assert.deepEqual(reg.bySkill, expected.bySkill); + assert.deepEqual(Object.keys(reg.configSchema).sort(), Object.keys(expected.configSchema).sort()); + assert.deepEqual(reg.capabilityClusters['extra-cap'], expected.capabilityClusters['extra-cap']); + }); +}); + +describe('loadRegistry — first-party always wins', () => { + test('overlay whose id collides with a first-party id is rejected; first-party preserved', (t) => { + const home = makeOverlayHome([featureCap('ui', { skills: ['hijacked'] })]); + t.after(() => cleanup(home)); + const reg = load(home); + assert.equal(reg.capabilities['ui'].title, 'UI design contracts', 'first-party ui untouched'); + assert.ok(reg._overlay.warnings.some((w) => w.id === 'ui' && /collide/i.test(w.reason))); + }); + + test('overlay claiming a first-party skill stem is rejected', (t) => { + const home = makeOverlayHome([featureCap('skill-thief', { skills: ['ui-phase'] })]); + t.after(() => cleanup(home)); + const reg = load(home); + assert.ok(!reg.capabilities['skill-thief']); + assert.ok(reg._overlay.warnings.some((w) => w.id === 'skill-thief' && /skill/i.test(w.reason))); + }); + + test('reserved id prefix (gsd-/gsd-core-/anthropic-) is rejected', (t) => { + const home = makeOverlayHome([ + featureCap('gsd-impostor'), + featureCap('anthropic-impostor'), + ]); + t.after(() => cleanup(home)); + const reg = load(home); + assert.ok(!reg.capabilities['gsd-impostor']); + assert.ok(!reg.capabilities['anthropic-impostor']); + assert.equal(reg._overlay.warnings.filter((w) => /reserved/i.test(w.reason)).length, 2); + }); +}); + +describe('loadRegistry — load-time re-gate (engines.gsd) + fail-closed gates', () => { + test('incompatible engines.gsd is skipped with a warning', (t) => { + const home = makeOverlayHome([featureCap('future-cap', { engines: { gsd: '>=99.0.0' } })]); + t.after(() => cleanup(home)); + const reg = load(home); + assert.ok(!reg.capabilities['future-cap']); + assert.ok(reg._overlay.warnings.some((w) => w.id === 'future-cap' && /incompatible/i.test(w.reason))); + assert.deepEqual(reg._overlay.incompatibleGateCapIds, [], 'no gate declared → not a fail-closed blocker'); + }); + + test('a skipped capability that DECLARES a gate is recorded for fail-closed handling', (t) => { + const home = makeOverlayHome([ + featureCap('incompat-gate', { + engines: { gsd: '>=99.0.0' }, + gates: [{ point: 'execute:wave:post', check: { query: 'x.deploy' }, blocking: true, onError: 'halt' }], + }), + ]); + t.after(() => cleanup(home)); + const reg = load(home); + assert.ok(!reg.capabilities['incompat-gate'], 'incompatible cap not loaded'); + assert.ok(reg._overlay.incompatibleGateCapIds.includes('incompat-gate'), 'gate-kind tracked as fail-closed'); + assert.ok( + reg._overlay.blockedGates.some((g) => g.point === 'execute:wave:post' && g.capId === 'incompat-gate'), + 'declared gate point recorded for per-point fail-closed injection', + ); + }); + + test('compatible engines.gsd is accepted', (t) => { + const home = makeOverlayHome([featureCap('compat-cap', { engines: { gsd: '>=1.6.0 <3.0.0' } })]); + t.after(() => cleanup(home)); + const reg = load(home); + assert.ok(reg.capabilities['compat-cap']); + }); +}); + +describe('loadRegistry — malformed overlays are skipped, never crash', () => { + test('manifest failing validation is skipped with a warning', (t) => { + const home = makeOverlayHome([ + // missing required version → validateCapability error + (() => { const c = featureCap('no-version'); delete c.version; return c; })(), + ]); + t.after(() => cleanup(home)); + const reg = load(home); + assert.ok(!reg.capabilities['no-version']); + assert.ok(reg._overlay.warnings.some((w) => w.id === 'no-version' && /version/i.test(w.reason))); + }); + + test('unreadable / invalid JSON is skipped with a warning (no throw)', (t) => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-badjson-')); + t.after(() => cleanup(home)); + const dir = path.join(home, '.gsd', 'capabilities', 'broken'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'capability.json'), '{ not valid json', 'utf8'); + let reg; + assert.doesNotThrow(() => { reg = load(home); }); + assert.ok(!reg.capabilities['broken']); + assert.ok(reg._overlay.warnings.some((w) => w.id === 'broken')); + }); + + test('the loop never crashes — first-party registry remains fully intact alongside bad overlays', (t) => { + const home = makeOverlayHome([ + featureCap('gsd-reserved'), + (() => { const c = featureCap('bad'); c.role = 'nonsense'; return c; })(), + featureCap('good', { skills: ['good-only-skill'] }), + ]); + t.after(() => cleanup(home)); + const reg = load(home); + assert.equal(Object.keys(baseRegistry.capabilities).length + 1, Object.keys(reg.capabilities).length, + 'exactly the one good overlay is added; first-party count preserved'); + assert.ok(reg.capabilities['good']); + }); +}); + +describe('loadRegistry — full merged-set cross-capability validation', () => { + test('overlay claiming a first-party command family is rejected (first-party wins)', (t) => { + const firstPartyFamily = Object.keys(baseRegistry.commandFamilies || {})[0]; + assert.ok(firstPartyFamily, 'precondition: first-party owns at least one command family'); + const home = makeOverlayHome([ + featureCap('cmd-thief', { commands: [{ family: firstPartyFamily, module: 'thief.cjs', router: 'route' }] }), + ]); + t.after(() => cleanup(home)); + const reg = load(home); + assert.ok(!reg.capabilities['cmd-thief'], 'overlay hijacking a first-party command family is not loaded'); + assert.ok(reg._overlay.warnings.some((w) => w.id === 'cmd-thief' && /command family/i.test(w.reason))); + assert.ok(reg.commandFamilies[firstPartyFamily], 'first-party command family preserved'); + }); + + test('overlay with an unsatisfiable consumes is rejected by cross-capability validation', (t) => { + const home = makeOverlayHome([ + featureCap('bad-consumes', { + skills: ['bad-consumes-skill'], + config: { 'workflow.bad_consumes': { type: 'boolean', default: true, description: 'x' } }, + steps: [{ point: 'plan:pre', ref: { skill: 'bad-consumes-skill' }, produces: [], consumes: ['NONEXISTENT-ARTIFACT.md'], when: 'workflow.bad_consumes', onError: 'skip' }], + }), + ]); + t.after(() => cleanup(home)); + const reg = load(home); + assert.ok(!reg.capabilities['bad-consumes'], 'overlay failing consumes-satisfiability is not loaded'); + assert.ok(reg._overlay.warnings.some((w) => w.id === 'bad-consumes' && /cross-capability/i.test(w.reason))); + }); + + test('an invalid hook fragment path (escaping the capability dir) is rejected', (t) => { + const home = makeOverlayHome([ + featureCap('frag-escape', { + contributions: [{ point: 'plan:pre', into: 'planner', fragment: { path: '../../../etc/passwd' }, when: 'workflow.frag', onError: 'skip' }], + config: { 'workflow.frag': { type: 'boolean', default: true, description: 'x' } }, + }), + ]); + t.after(() => cleanup(home)); + const reg = load(home); + assert.ok(!reg.capabilities['frag-escape'], 'overlay with an escaping fragment path is not loaded'); + assert.ok(reg._overlay.warnings.some((w) => w.id === 'frag-escape' && /fragment/i.test(w.reason))); + }); +}); + +describe('loadRegistry — project-scoped overlay root', () => { + test('reads an overlay from /.gsd/capabilities when cwd is inside a project', (t) => { + const proj = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-proj-')); + t.after(() => cleanup(proj)); + fs.mkdirSync(path.join(proj, '.planning'), { recursive: true }); // project-root marker + const dir = path.join(proj, '.gsd', 'capabilities', 'proj-cap'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'capability.json'), JSON.stringify(featureCap('proj-cap', { skills: ['proj-skill'] })), 'utf8'); + + // Point the global home elsewhere (empty) so only the project scope contributes. + const emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-emptyhome-')); + t.after(() => cleanup(emptyHome)); + const reg = loadRegistry({ includeInstalled: true, gsdHome: emptyHome, cwd: proj, hostVersion: HOST }); + assert.ok(reg.capabilities['proj-cap'], 'project-scoped overlay loaded'); + }); +}); diff --git a/tests/capability-registry.test.cjs b/tests/capability-registry.test.cjs index 830cee607..973cf5f55 100644 --- a/tests/capability-registry.test.cjs +++ b/tests/capability-registry.test.cjs @@ -69,6 +69,11 @@ const { const { LOOP_HOST_CONTRACT } = require('../gsd-core/bin/lib/loop-host-contract.cjs'); +// ADR-1244 D2: the validator was extracted to a shared runtime-callable module. +// The generator must re-export it verbatim — the parity suite below proves no drift. +const capValidatorModule = require('../gsd-core/bin/lib/capability-validator.cjs'); +const generatorModule = require('../scripts/gen-capability-registry.cjs'); + const fc = require('fast-check'); const ROOT = path.resolve(__dirname, '..'); @@ -5035,3 +5040,61 @@ describe('activationKey validation', () => { ); }); }); + +// ─── ADR-1244 D2: validator extraction generative parity ────────────────────── +// +// The validator now lives in gsd-core/bin/lib/capability-validator.cjs and is +// re-exported by the generator. These assertions guarantee the build-time +// generator and the runtime overlay share ONE validator implementation — no +// divergent copy can drift between them, because the generator re-exports the +// very same object references. +describe('ADR-1244 D2: validator extraction generative parity', () => { + const CORE = [ + 'validateCapability', 'validateCrossCapability', 'validateVersionEnvelope', + 'validateConsumesGlobal', 'validateAgainstContract', 'validateConfigSliceEntry', + 'validateRuntimeBody', 'classifyCrossErrors', + ]; + + test('the runtime validator module exposes the full validator surface', () => { + for (const sym of [...CORE, 'SEMVER_RE', 'SEMVER_RANGE_RE', 'POINT_ORDER', 'VALID_LOOP_POINTS', 'VALID_TIERS']) { + assert.ok(sym in capValidatorModule, `validator module must export ${sym}`); + } + assert.strictEqual(typeof capValidatorModule.validateCapability, 'function'); + assert.ok(capValidatorModule.SEMVER_RE instanceof RegExp); + }); + + test('every generator-re-exported validator symbol is the SAME object as the validator module (no drift)', () => { + const shared = Object.keys(capValidatorModule).filter((k) => Object.prototype.hasOwnProperty.call(generatorModule, k)); + assert.ok(shared.length >= 20, `expected the generator to re-export the validator surface, got ${shared.length}`); + for (const k of shared) { + assert.strictEqual( + generatorModule[k], + capValidatorModule[k], + `generator export "${k}" must be the SAME reference as the validator module's (drift detected)`, + ); + } + }); + + test('core validators are re-exported identically by the generator', () => { + for (const sym of CORE) { + assert.strictEqual( + generatorModule[sym], capValidatorModule[sym], + `${sym} must be re-exported by the generator as the validator module's reference`, + ); + } + }); + + test('the extracted validator runs standalone (no generator/build-time deps required)', () => { + // Proves the module is genuinely runtime-callable: a clean require + validate + // with no install-profiles/clusters/config-schema machinery present. + const { validateCapability } = capValidatorModule; + const cap = { + id: 'demo', role: 'feature', version: '1.0.0', title: 'Demo', description: 'demo', + tier: 'standard', requires: [], runtimeCompat: { supported: ['*'], unsupported: [] }, + skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [], + }; + assert.deepEqual(validateCapability(cap, 'demo'), []); + const { version: _v, ...noVersion } = cap; + assert.ok(validateCapability(noVersion, 'demo').some((e) => e.includes('version'))); + }); +}); diff --git a/tests/capability-state.test.cjs b/tests/capability-state.test.cjs index 2656819db..9b669218c 100644 --- a/tests/capability-state.test.cjs +++ b/tests/capability-state.test.cjs @@ -1719,3 +1719,53 @@ describe('isCapabilityActive cross-runtime detection (GSD_RUNTIME → config.run } }); }); + +// ─── ADR-1244 D2 overlay wiring — capability-state sees installed overlays ─── + +describe('ADR-1244 D2: overlay-aware registry wiring in capability-state', () => { + // Verifies that resolveCapabilityRuntimeState uses loadRegistry({includeInstalled:true}) + // so a valid installed overlay capability appears in the capabilities list. + // The overlay cap has no activationKey so it activates freely. + const { resolveCapabilityRuntimeState } = require('../gsd-core/bin/lib/capability-state.cjs'); + + test('valid overlay capability appears in runtime state capabilities list', () => { + const overlayHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-overlay-')); + const prevGsdHome = process.env.GSD_HOME; + try { + // Write a valid overlay capability manifest + const capDir = path.join(overlayHome, '.gsd', 'capabilities', 'my-overlay-cap'); + fs.mkdirSync(capDir, { recursive: true }); + const capManifest = { + id: 'my-overlay-cap', + role: 'feature', + version: '1.0.0', + title: 'My Overlay Cap', + description: 'ADR-1244 D2 wiring test overlay', + tier: 'standard', + requires: [], + engines: { gsd: '>=0.0.0' }, + runtimeCompat: { supported: ['*'], unsupported: [] }, + skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [], + }; + fs.writeFileSync(path.join(capDir, 'capability.json'), JSON.stringify(capManifest), 'utf8'); + + // Point GSD_HOME to the overlay home so loadRegistry finds it + process.env.GSD_HOME = overlayHome; + + // Use a non-existent cwd so no project-scope overlay is scanned — pure global + const nonExistentCwd = path.join(os.tmpdir(), 'cap-state-overlay-cwd-' + Date.now()); + const result = resolveCapabilityRuntimeState(nonExistentCwd, undefined); + + const overlayEntry = result.capabilities.find((c) => c.id === 'my-overlay-cap'); + assert.ok( + overlayEntry !== undefined, + 'overlay capability "my-overlay-cap" must appear in resolveCapabilityRuntimeState results ' + + '(ADR-1244 D2: capability-state must use overlay-aware loadRegistry)', + ); + } finally { + if (prevGsdHome === undefined) delete process.env.GSD_HOME; + else process.env.GSD_HOME = prevGsdHome; + cleanup(overlayHome); + } + }); +}); diff --git a/tests/config-schema.property.test.cjs b/tests/config-schema.property.test.cjs index ce6a34077..322bf75f3 100644 --- a/tests/config-schema.property.test.cjs +++ b/tests/config-schema.property.test.cjs @@ -17,12 +17,17 @@ * (e) Arbitrary garbage strings return false (not throw) from isValidConfigKey */ -const { describe, test } = require('node:test'); +const { describe, test, before, after } = require('node:test'); const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); const fc = require('./helpers/fast-check-setup.cjs'); +const { cleanup } = require('./helpers.cjs'); const { isValidConfigKey, + isCapabilityConfigKey, VALID_CONFIG_KEYS, RUNTIME_STATE_KEYS, } = require('../gsd-core/bin/lib/config-schema.cjs'); @@ -152,3 +157,72 @@ describe('config-schema: isValidConfigKey properties', () => { assert.equal(isValidConfigKey(NaN), false); }); }); + +// ─── ADR-1244 D2: cwd-aware overlay config-key federation ───────────────────── +// +// Exercises every branch of the new _capabilityConfigSchema(cwd) path so the +// mutation suite (this is the file Stryker runs for config-schema) KILLS the +// added mutants: the `typeof cwd === 'string' && cwd` guard, the overlay +// loadRegistry({includeInstalled,cwd}) call, the `schema && typeof === 'object'` +// found-branch, the first-party fallback, and the cwd threading through +// isValidConfigKey. Uses a real overlay fixture (no test seam). +describe('config-schema: cwd-aware overlay federation (ADR-1244 D2)', () => { + const OVERLAY_KEY = 'workflow.cfgschema_overlay_gate'; + // A known FIRST-PARTY capability config key (ui capability) — exercises the + // first-party fallback branch (no cwd → frozen registry configSchema). + const FIRST_PARTY_KEY = 'workflow.ui_phase'; + const overlayCap = { + id: 'cfgschema-overlay', role: 'feature', version: '1.0.0', title: 'cfg overlay', description: 'x', + tier: 'standard', requires: [], engines: { gsd: '>=1.0.0' }, + runtimeCompat: { supported: ['*'], unsupported: [] }, + skills: ['cfgschema-overlay-skill'], agents: [], hooks: [], + config: { [OVERLAY_KEY]: { type: 'boolean', default: true, description: 'overlay-owned key' } }, + steps: [], contributions: [], gates: [], + }; + + let withOverlay, withoutOverlay, sandboxHome, savedHome; + before(() => { + savedHome = process.env.GSD_HOME; + sandboxHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cfgschema-home-')); + process.env.GSD_HOME = sandboxHome; // empty global overlay root + withOverlay = fs.mkdtempSync(path.join(os.tmpdir(), 'cfgschema-proj-')); + fs.mkdirSync(path.join(withOverlay, '.planning'), { recursive: true }); // project-root marker + const capDir = path.join(withOverlay, '.gsd', 'capabilities', 'cfgschema-overlay'); + fs.mkdirSync(capDir, { recursive: true }); + fs.writeFileSync(path.join(capDir, 'capability.json'), JSON.stringify(overlayCap), 'utf8'); + withoutOverlay = fs.mkdtempSync(path.join(os.tmpdir(), 'cfgschema-bare-')); + fs.mkdirSync(path.join(withoutOverlay, '.planning'), { recursive: true }); + }); + after(() => { + if (savedHome === undefined) delete process.env.GSD_HOME; else process.env.GSD_HOME = savedHome; + cleanup(sandboxHome); cleanup(withOverlay); cleanup(withoutOverlay); + }); + + test('first-party fallback: a first-party capability config key is valid with no cwd', () => { + // Kills the fallback branch (return fp ... : {}) and the no-cwd path. + assert.equal(isCapabilityConfigKey(FIRST_PARTY_KEY), true); + assert.equal(isValidConfigKey(FIRST_PARTY_KEY), true); + }); + + test('overlay key is recognized only when the installing project cwd is supplied', () => { + // cwd with the overlay → true (kills cwd-guard, loadRegistry call, found-branch, hasOwnProperty) + assert.equal(isCapabilityConfigKey(OVERLAY_KEY, withOverlay), true); + assert.equal(isValidConfigKey(OVERLAY_KEY, withOverlay), true); + // no cwd → first-party only → false (kills the cwd-true→fallback distinction) + assert.equal(isCapabilityConfigKey(OVERLAY_KEY), false); + assert.equal(isValidConfigKey(OVERLAY_KEY), false); + // cwd WITHOUT the overlay → loadRegistry returns base → false (cwd-correct) + assert.equal(isCapabilityConfigKey(OVERLAY_KEY, withoutOverlay), false); + assert.equal(isValidConfigKey(OVERLAY_KEY, withoutOverlay), false); + }); + + test('a genuinely unknown key is invalid regardless of cwd', () => { + assert.equal(isCapabilityConfigKey('zz.not.a.key', withOverlay), false); + assert.equal(isValidConfigKey('zz.not.a.key', withOverlay), false); + }); + + test('non-string keyPath returns false even with a cwd (no throw)', () => { + assert.equal(isCapabilityConfigKey(null, withOverlay), false); + assert.equal(isCapabilityConfigKey(42, withOverlay), false); + }); +}); diff --git a/tests/federated-config-loadconfig.test.cjs b/tests/federated-config-loadconfig.test.cjs index d3d5489f7..788da60ea 100644 --- a/tests/federated-config-loadconfig.test.cjs +++ b/tests/federated-config-loadconfig.test.cjs @@ -423,3 +423,59 @@ describe('MALFORMED registry: loadConfig does not throw', () => { assert.ok(Object.prototype.hasOwnProperty.call(result, 'model_profile'), 'model_profile must be present'); }); }); + +// ─── 5. ADR-1244 D2: overlay config-key federation is cwd-aware (REAL loader, no seam) ── +// +// Proves "toggable via config" for installed third-party capabilities AND that it +// is cwd-correct: an overlay capability's config key is valid + federates ONLY in +// the project where the overlay is installed — never globally, never for the wrong +// project, never from a bare require (no seam used here — the real loadRegistry path). +describe('ADR-1244 D2: overlay config-key federation (cwd-aware, real loader)', () => { + const configSchema = require('../gsd-core/bin/lib/config-schema.cjs'); + const KEY = 'workflow.overlay_demo_gate'; + const overlayCap = { + id: 'overlay-demo', role: 'feature', version: '1.0.0', title: 'Overlay demo', description: 'overlay', + tier: 'standard', requires: [], engines: { gsd: '>=1.0.0' }, + runtimeCompat: { supported: ['*'], unsupported: [] }, + skills: ['overlay-demo-skill'], agents: [], hooks: [], + config: { [KEY]: { type: 'boolean', default: true, description: 'overlay-owned federated key' } }, + steps: [], contributions: [], gates: [], + }; + + let sandboxHome, withOverlay, withoutOverlay, savedHome; + beforeEach(() => { + _resetFederatedRegistryForTests(); // NO seam override — exercise the real cwd-aware path + savedHome = process.env.GSD_HOME; + sandboxHome = makeTempProject(); + process.env.GSD_HOME = sandboxHome; // empty global overlay root + withOverlay = mkTemp(); + const capDir = path.join(withOverlay, '.gsd', 'capabilities', 'overlay-demo'); + fs.mkdirSync(capDir, { recursive: true }); + fs.writeFileSync(path.join(capDir, 'capability.json'), JSON.stringify(overlayCap), 'utf-8'); + withoutOverlay = mkTemp(); + }); + afterEach(() => { + if (savedHome === undefined) delete process.env.GSD_HOME; else process.env.GSD_HOME = savedHome; + try { cleanup(sandboxHome); } catch { /* ignore */ } + }); + + test('overlay config key is valid in its own project, unknown elsewhere and with no cwd', () => { + assert.equal(configSchema.isValidConfigKey(KEY, withOverlay), true, 'valid in the project that installs the overlay'); + assert.equal(configSchema.isValidConfigKey(KEY, withoutOverlay), false, 'unknown in a project without the overlay (cwd-correct)'); + assert.equal(configSchema.isValidConfigKey(KEY), false, 'unknown with no cwd (first-party only)'); + }); + + test('loadConfig federates the overlay key default only for the installing project', () => { + writeConfig(withOverlay, {}); + const cfg = loadConfig(withOverlay); + assert.strictEqual(cfg.workflow && cfg.workflow.overlay_demo_gate, true, 'overlay default federates in its project'); + + writeConfig(withoutOverlay, {}); + const other = loadConfig(withoutOverlay); + assert.strictEqual( + other.workflow ? other.workflow.overlay_demo_gate : undefined, + undefined, + 'overlay key does NOT federate into an unrelated project', + ); + }); +}); diff --git a/tests/loop-render-hooks.test.cjs b/tests/loop-render-hooks.test.cjs index 782949868..dc82b982a 100644 --- a/tests/loop-render-hooks.test.cjs +++ b/tests/loop-render-hooks.test.cjs @@ -1038,3 +1038,81 @@ describe('Phase 4 regression: capabilityStatesById gates on active (not enabled) assert.strictEqual(result.activeHooks[0].capId, 'test-cap'); }); }); + +// ─── ADR-1244 D2 fail-closed gate injection ──────────────────────────────────── + +describe('ADR-1244 D2: fail-closed gate injection for skipped overlay caps with gates', () => { + // Verifies that cmdLoopRenderHooks injects a BLOCKING synthetic gate at the + // declared point when an overlay capability that declares a gate is skipped at + // load time due to an incompatible engines.gsd version constraint. + // + // Fixture: overlay cap declares a gate at execute:wave:post with engines.gsd: ">=99.0.0" + // → loadRegistry skips it → records it in _overlay.blockedGates + // → cmdLoopRenderHooks injects a blocking=true, onError=halt gate at execute:wave:post + + test('skipped gate-kind overlay cap → BLOCKING synthetic gate at its declared point', (t) => { + const overlayHome = fs.mkdtempSync(path.join(os.tmpdir(), 'loop-fail-closed-')); + t.after(() => cleanup(overlayHome)); + + // Write an overlay capability that: + // - declares a gate at execute:wave:post + // - has engines.gsd: ">=99.0.0" (incompatible → will be skipped at load) + const capId = 'fail-closed-gate-cap'; + const capDir = path.join(overlayHome, '.gsd', 'capabilities', capId); + fs.mkdirSync(capDir, { recursive: true }); + const capManifest = { + id: capId, + role: 'feature', + version: '1.0.0', + title: 'Fail Closed Gate Cap', + description: 'ADR-1244 D2 fail-closed wiring test', + tier: 'standard', + requires: [], + engines: { gsd: '>=99.0.0' }, // intentionally incompatible → always skipped + runtimeCompat: { supported: ['*'], unsupported: [] }, + skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], + gates: [{ point: 'execute:wave:post', check: 'always-pass', blocking: true, onError: 'halt' }], + }; + fs.writeFileSync(path.join(capDir, 'capability.json'), JSON.stringify(capManifest), 'utf8'); + + // Invoke gsd-tools via subprocess so stdout is the real fd-1 (io.cjs writes via writeSync). + // Set GSD_HOME to the overlay home so loadRegistry picks up the incompatible cap. + const result = spawnSync( + process.execPath, + [GSD_TOOLS, 'loop', 'render-hooks', 'execute:wave:post', '--cwd', overlayHome], + { + cwd: ROOT, + encoding: 'utf8', + env: { ...process.env, GSD_HOME: overlayHome }, + }, + ); + + assert.strictEqual(result.status, 0, 'Expected exit 0. stderr: ' + (result.stderr || '')); + + let envelope; + try { + envelope = JSON.parse(result.stdout.trim()); + } catch { + assert.fail('loop render-hooks output must be valid JSON; got: ' + result.stdout.slice(0, 300)); + } + + // The synthetic blocking gate must be present in activeHooks + const syntheticGate = Array.isArray(envelope.activeHooks) + ? envelope.activeHooks.find((h) => h.capId === capId && h.kind === 'gate') + : undefined; + assert.ok( + syntheticGate !== undefined, + `activeHooks must contain a synthetic gate attributed to ${capId} (fail-closed injection). ` + + 'Got: ' + JSON.stringify(envelope.activeHooks), + ); + assert.strictEqual(syntheticGate.blocking, true, 'synthetic gate must be blocking=true'); + assert.strictEqual(syntheticGate.onError, 'halt', 'synthetic gate must have onError=halt'); + + // The rendered markdown must also reference the gate cap + assert.ok( + typeof envelope.rendered === 'string' && envelope.rendered.includes(capId), + 'rendered output must reference the fail-closed gate cap. Got: ' + envelope.rendered, + ); + }); +}); + diff --git a/tests/semver-compare.test.cjs b/tests/semver-compare.test.cjs index 0946152f1..fb63d4076 100644 --- a/tests/semver-compare.test.cjs +++ b/tests/semver-compare.test.cjs @@ -10,6 +10,7 @@ const { compareSemverCore, isSemverNewer, toNumericTuple, + semverSatisfies, } = require('../gsd-core/bin/lib/semver-compare.cjs'); describe('isSemverNewer (shared semver comparison)', () => { @@ -77,3 +78,102 @@ describe('isSemverNewer (shared semver comparison)', () => { assert.strictEqual(compareSemverCore('1.2.0', '1.2.1'), -1); }); }); + +describe('semverSatisfies (ADR-1244 engines.gsd range gate)', () => { + const sat = (v, r, expected) => + assert.strictEqual(semverSatisfies(v, r), expected, `expected satisfies(${JSON.stringify(v)}, ${JSON.stringify(r)}) === ${expected}`); + + test('>= comparator', () => { + sat('1.6.0', '>=1.6.0', true); + sat('1.6.1', '>=1.6.0', true); + sat('2.0.0', '>=1.6.0', true); + sat('1.5.9', '>=1.6.0', false); + }); + + test('> < <= = comparators', () => { + sat('1.6.1', '>1.6.0', true); + sat('1.6.0', '>1.6.0', false); + sat('1.5.0', '<1.6.0', true); + sat('1.6.0', '<1.6.0', false); + sat('1.6.0', '<=1.6.0', true); + sat('1.6.1', '<=1.6.0', false); + sat('1.6.0', '=1.6.0', true); + sat('1.6.1', '=1.6.0', false); + }); + + test('bare exact full version', () => { + sat('1.6.0', '1.6.0', true); + sat('1.6.1', '1.6.0', false); + }); + + test('AND-composed range (whitespace)', () => { + sat('1.6.0', '>=1.6.0 <3.0.0', true); + sat('2.9.9', '>=1.6.0 <3.0.0', true); + sat('3.0.0', '>=1.6.0 <3.0.0', false); + sat('1.5.0', '>=1.6.0 <3.0.0', false); + }); + + test('OR-composed range (||)', () => { + sat('1.6.0', '>=1.6.0 || >=2.0.0', true); + sat('2.0.0', '<1.0.0 || >=2.0.0', true); + sat('1.5.0', '<1.0.0 || >=2.0.0', false); + }); + + test('caret ranges', () => { + sat('1.2.3', '^1.2.3', true); + sat('1.9.0', '^1.2.3', true); + sat('2.0.0', '^1.2.3', false); + sat('1.2.2', '^1.2.3', false); + sat('0.2.3', '^0.2.3', true); + sat('0.3.0', '^0.2.3', false); + sat('0.0.3', '^0.0.3', true); + sat('0.0.4', '^0.0.3', false); + }); + + test('tilde ranges', () => { + sat('1.2.3', '~1.2.3', true); + sat('1.2.9', '~1.2.3', true); + sat('1.3.0', '~1.2.3', false); + sat('1.2.0', '~1.2', true); + sat('1.3.0', '~1.2', false); + sat('1.9.0', '~1', true); + sat('2.0.0', '~1', false); + }); + + test('wildcards and partials', () => { + sat('99.0.0', '*', true); + sat('0.0.1', '*', true); + sat('1.0.0', '1.x', true); + sat('1.9.9', '1.x', true); + sat('2.0.0', '1.x', false); + sat('0.9.9', '1.x', false); + sat('1.2.0', '1.2.x', true); + sat('1.3.0', '1.2.x', false); + sat('1.5.0', '1', true); + sat('2.0.0', '1', false); + }); + + test('prerelease and v-prefix normalize to numeric core', () => { + sat('1.6.0-rc.1', '>=1.6.0', true); + sat('1.5.1-dev.0', '>=1.6.0', false); + sat('v1.6.0', '>=1.6.0', true); + }); + + test('FAIL CLOSED on empty/unparseable ranges', () => { + for (const bad of ['', ' ', 'abc', 'not a range', '>=', '>=x', 'foo.bar.baz', '1.2.3.4', '>=1.2.3 garbage']) { + sat('1.6.0', bad, false); + } + }); + + test('FAIL CLOSED on malformed wildcard tokens (concrete segment after a wildcard)', () => { + for (const bad of ['1.x.2', '>=1.x.2', '1.*.2', '1.X.0']) { + sat('1.5.0', bad, false); + } + }); + + test('null/undefined inputs do not throw and fail closed', () => { + sat('1.6.0', null, false); + sat('1.6.0', undefined, false); + sat(null, '>=1.6.0', false); // null version -> [0,0,0] -> not >= 1.6.0 + }); +});