* 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 <root>/.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 <noreply@anthropic.com> * docs(#1431): add changeset for runtime capability registry overlay Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/humble-sloths-click.md
Normal file
5
.changeset/humble-sloths-click.md
Normal file
@@ -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.
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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/<id>/capability.json`, where `GSD_HOME` defaults to `~`) and project (`<projectRoot>/.gsd/capabilities/<id>/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 <point>` 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.
|
||||
|
||||
|
||||
@@ -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 `<projectRoot>/.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 <point> [--config-dir <path>]` |
|
||||
| `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 <path>]` 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 |
|
||||
|
||||
@@ -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/<id>/capability.json` |
|
||||
| Project | `<projectRoot>/.gsd/capabilities/<id>/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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/<id>/` (global) and `<projectRoot>/.gsd/capabilities/<id>/` (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/<id>/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 <path>]` 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 <id> [--on\|--off] [--gate <key>=<true\|false>]` |
|
||||
| `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 |
|
||||
|
||||
@@ -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',
|
||||
|
||||
1995
gsd-core/bin/lib/capability-validator.cjs
Normal file
1995
gsd-core/bin/lib/capability-validator.cjs
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
367
src/capability-loader.cts
Normal file
367
src/capability-loader.cts
Normal file
@@ -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/<id>/capability.json (GSD_HOME defaults to ~)
|
||||
* - project: <projectRoot>/.gsd/capabilities/<id>/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<string, unknown>;
|
||||
|
||||
interface CapManifest {
|
||||
id: string;
|
||||
role?: string;
|
||||
version?: string;
|
||||
skills?: string[];
|
||||
agents?: string[];
|
||||
commands?: Array<Record<string, unknown>>;
|
||||
config?: Record<string, unknown>;
|
||||
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, unknown>) => string[];
|
||||
validateCrossCapability: (capMap: Map<string, unknown>, centralKeys: Set<string>) => string[];
|
||||
}
|
||||
interface SemverModule {
|
||||
semverSatisfies: (version: unknown, range: unknown) => boolean;
|
||||
}
|
||||
interface ProjectRootModule {
|
||||
findProjectRoot: (startDir: string) => string | null;
|
||||
}
|
||||
interface GeneratorModule {
|
||||
buildRegistry: (capMap: Map<string, unknown>) => Registry;
|
||||
loadCentralConfigKeys: () => Set<string>;
|
||||
}
|
||||
|
||||
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<string>();
|
||||
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<string, unknown>;
|
||||
const fpBySkill = (base.bySkill ?? {}) as Record<string, unknown>;
|
||||
const fpByAgent = (base.byAgent ?? {}) as Record<string, unknown>;
|
||||
const fpConfigKeys = (base.configKeys ?? {}) as Record<string, unknown>;
|
||||
const fpConfigSchema = (base.configSchema ?? {}) as Record<string, unknown>;
|
||||
|
||||
const fpFamilies = (base.commandFamilies ?? {}) as Record<string, unknown>;
|
||||
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<string>();
|
||||
|
||||
// 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<string, unknown>(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<string> | null = null;
|
||||
const getCentralKeys = (): Set<string> => {
|
||||
if (!centralKeys) {
|
||||
try {
|
||||
centralKeys = getGenerator().loadCentralConfigKeys();
|
||||
} catch {
|
||||
centralKeys = new Set<string>();
|
||||
}
|
||||
}
|
||||
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<Record<string, unknown>>)
|
||||
.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 };
|
||||
@@ -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<string, unknown>;
|
||||
const { loadRegistry } = require('./capability-loader.cjs') as { loadRegistry: (opts?: Record<string, unknown>) => Record<string, unknown> };
|
||||
const registry = loadRegistry({ includeInstalled: true, cwd });
|
||||
|
||||
// ── Resolve installed skills (from install profile) ──────────────────────────
|
||||
// Distinguish "no profile marker → default full" (legitimate) from a thrown
|
||||
|
||||
@@ -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<string, unknown> } = 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<string, unknown> | 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<string, unknown>) => { configSchema?: Record<string, unknown> } } = 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<string, unknown>,
|
||||
userConfig: Record<string, unknown>,
|
||||
cwd?: string,
|
||||
): Record<string, unknown> {
|
||||
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<string, unknown> = {}):
|
||||
|
||||
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<string, unknown> = {}):
|
||||
// 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<string, unknown> = {}):
|
||||
}
|
||||
// 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<string, unknown> = {}):
|
||||
};
|
||||
// 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 };
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
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<string, unknown> {
|
||||
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<string, unknown>) => { configSchema?: Record<string, unknown> } } = 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 = {
|
||||
|
||||
@@ -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.<agent-type>, features.<feature_name>`, ERROR_REASON.CONFIG_INVALID_KEY);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
const { loadRegistry } = require('./capability-loader.cjs') as { loadRegistry: (opts?: Record<string, unknown>) => Record<string, unknown> };
|
||||
const registry = loadRegistry({ includeInstalled: true, cwd });
|
||||
const capabilityStatesById = new Map<string, { enabled?: boolean; active: boolean }>();
|
||||
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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
275
tests/capability-loader.test.cjs
Normal file
275
tests/capability-loader.test.cjs
Normal file
@@ -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/<id>/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 <projectRoot>/.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');
|
||||
});
|
||||
});
|
||||
@@ -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')));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user