diff --git a/CONTEXT.md b/CONTEXT.md index 031d5fe48..15efaabd2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -145,8 +145,8 @@ Projects a pure, typed install plan for a given runtime by composing artifact pl ### Capability [Planned] A bundle delivering one optional GSD feature, toggled as a unit at install or after install. Owns its skills, agents, hooks, federated config-key schema (keys + defaults + validation), and loop extension-point registrations, plus a `requires` list of other Capabilities. Declared co-located in the Capability's own folder and compiled into a generated central Capability Registry at build time. The five-step loop (Discuss → Plan → Execute → Verify → Ship) and shared-infrastructure skills (phase, config, help, update, surface, progress) are the privileged host, not Capabilities, in v1 — but host extension points are data so a loop step can become a Capability under a future uniform kernel. Supersedes the implicit feature-scattering across clusters, install-profiles, and config-schema. Generalizes the Skill Surface Budget Module and Runtime Install Policy Module. -### Capability Registry [Planned] -Generated central manifest projecting all co-located Capability declarations into one validated artifact for runtime resolution and for the install, surface, config, and loop-extension adapters. Mirrors the research-profiles / package-identity generation pattern (co-located source → generated central file). +### Capability Registry +Generated central manifest projecting all co-located Capability declarations into one validated artifact for runtime resolution and for the install, surface, config, and loop-extension adapters. Mirrors the research-profiles / package-identity generation pattern (co-located source → generated central file). Generated by `scripts/gen-capability-registry.cjs` → `gsd-core/bin/lib/capability-registry.cjs` (ADR-894 §5 phase 3a-impl). Role-partitioned indexes: `bySkill`, `byAgent`, `byLoopPoint` (hook ordering materialized), `configKeys`, `runtimes`, `requiresClosure(id)`. Validated against the inline Loop Host Contract (12 points; `gen-loop-host-contract.cjs` to replace the inline constant in phase 3a-impl-2). Run `node scripts/gen-capability-registry.cjs --write` after editing any `capabilities//capability.json`. ### Loop Extension Point [Planned] 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 (extending the `init.*` resolution seam) 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. diff --git a/capabilities/ui/capability.json b/capabilities/ui/capability.json new file mode 100644 index 000000000..1d014923c --- /dev/null +++ b/capabilities/ui/capability.json @@ -0,0 +1,21 @@ +{ + "id": "ui", "role": "feature", "title": "UI design contracts", + "description": "UI-SPEC design contract + retrospective UI audit for frontend phases.", + "tier": "standard", "requires": [], + "skills": ["ui-phase", "ui-review"], + "agents": ["gsd-ui-checker", "gsd-ui-auditor"], + "hooks": [], + "config": { + "workflow.ui_phase": { "type": "boolean", "default": true, "description": "Enable the UI design-contract gate during planning." }, + "workflow.ui_review": { "type": "boolean", "default": true, "description": "Enable the retrospective UI audit." }, + "workflow.ui_safety_gate": { "type": "boolean", "default": true, "description": "Block execution on unmet UI-SPEC contracts." } + }, + "steps": [ + { "point": "plan:pre", "ref": { "skill": "ui-phase" }, "produces": ["UI-SPEC.md"], "consumes": ["CONTEXT.md"], "when": "workflow.ui_phase", "onError": "skip" }, + { "point": "verify:post", "ref": { "skill": "ui-review" }, "produces": ["UI-REVIEW.md"], "consumes": ["UI-SPEC.md"], "when": "workflow.ui_review", "onError": "skip" } + ], + "contributions": [], + "gates": [ + { "point": "execute:wave:post", "check": { "query": "ui.safety-gate" }, "when": "workflow.ui_safety_gate", "blocking": true, "onError": "halt" } + ] +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ceadf1f58..e5db74298 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -368,7 +368,8 @@ Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core | `workstream.cjs` | Workstream CRUD, migration, session-scoped active pointer | | `schema-detect.cjs` | Schema-drift detection for ORM patterns (Prisma, Drizzle, etc.) | | `profile-pipeline.cjs` | User behavioral profiling data pipeline, session file scanning | -| `profile-output.cjs` | Profile rendering, USER-PROFILE.md and dev-preferences.md generation | +| `profile-output.cjs` | Profile rendering, USER-PROFILE.md and dev-preferences.md generation | +| `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) | --- diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index f695d5e33..31b557add 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -270,6 +270,7 @@ "agent-command-router.cjs", "artifacts.cjs", "audit.cjs", + "capability-registry.cjs", "check-command-router.cjs", "cjs-command-router-adapter.cjs", "cli-exit.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index dfdb2cd83..e4e1042e3 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -370,7 +370,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (97 shipped) +## CLI Modules (98 shipped) Full listing: `gsd-core/bin/lib/*.cjs`. @@ -381,6 +381,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `agent-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools agent` | | `artifacts.cjs` | Canonical artifact registry — known `.planning/` root file names; used by `gsd-health` W019 lint | | `audit.cjs` | Audit dispatch, audit open sessions, audit storage helpers | +| `capability-registry.cjs` | Generated central Capability Registry — role-partitioned index of all co-located capability declarations (`capabilities//capability.json`); emitted by `scripts/gen-capability-registry.cjs --write` (ADR-894 §5) | | `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 | | `cjs-command-router-adapter.cjs` | Shared compatibility adapter for manifest-backed CJS command-family routers | diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs new file mode 100644 index 000000000..a8ebff59f --- /dev/null +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -0,0 +1,241 @@ +'use strict'; + +/** + * capability-registry.cjs — generated by scripts/gen-capability-registry.cjs + * DO NOT EDIT BY HAND. Run: node scripts/gen-capability-registry.cjs --write + * ADR-894 §5 — role-partitioned Capability Registry. + */ + +const capabilities = { + "ui": { + "id": "ui", + "role": "feature", + "title": "UI design contracts", + "description": "UI-SPEC design contract + retrospective UI audit for frontend phases.", + "tier": "standard", + "requires": [], + "skills": [ + "ui-phase", + "ui-review" + ], + "agents": [ + "gsd-ui-checker", + "gsd-ui-auditor" + ], + "hooks": [], + "config": { + "workflow.ui_phase": { + "type": "boolean", + "default": true, + "description": "Enable the UI design-contract gate during planning." + }, + "workflow.ui_review": { + "type": "boolean", + "default": true, + "description": "Enable the retrospective UI audit." + }, + "workflow.ui_safety_gate": { + "type": "boolean", + "default": true, + "description": "Block execution on unmet UI-SPEC contracts." + } + }, + "steps": [ + { + "point": "plan:pre", + "ref": { + "skill": "ui-phase" + }, + "produces": [ + "UI-SPEC.md" + ], + "consumes": [ + "CONTEXT.md" + ], + "when": "workflow.ui_phase", + "onError": "skip" + }, + { + "point": "verify:post", + "ref": { + "skill": "ui-review" + }, + "produces": [ + "UI-REVIEW.md" + ], + "consumes": [ + "UI-SPEC.md" + ], + "when": "workflow.ui_review", + "onError": "skip" + } + ], + "contributions": [], + "gates": [ + { + "point": "execute:wave:post", + "check": { + "query": "ui.safety-gate" + }, + "when": "workflow.ui_safety_gate", + "blocking": true, + "onError": "halt" + } + ] + } +}; + +const bySkill = { + "ui-phase": "ui", + "ui-review": "ui" +}; + +const byAgent = { + "gsd-ui-checker": "ui", + "gsd-ui-auditor": "ui" +}; + +const byLoopPoint = { + "discuss:pre": { + "steps": [], + "contributions": [], + "gates": [] + }, + "discuss:post": { + "steps": [], + "contributions": [], + "gates": [] + }, + "plan:pre": { + "steps": [ + { + "capId": "ui", + "point": "plan:pre", + "ref": { + "skill": "ui-phase" + }, + "produces": [ + "UI-SPEC.md" + ], + "consumes": [ + "CONTEXT.md" + ], + "when": "workflow.ui_phase", + "onError": "skip" + } + ], + "contributions": [], + "gates": [] + }, + "plan:post": { + "steps": [], + "contributions": [], + "gates": [] + }, + "execute:pre": { + "steps": [], + "contributions": [], + "gates": [] + }, + "execute:wave:pre": { + "steps": [], + "contributions": [], + "gates": [] + }, + "execute:wave:post": { + "steps": [], + "contributions": [], + "gates": [ + { + "capId": "ui", + "point": "execute:wave:post", + "check": { + "query": "ui.safety-gate" + }, + "when": "workflow.ui_safety_gate", + "blocking": true, + "onError": "halt" + } + ] + }, + "execute:post": { + "steps": [], + "contributions": [], + "gates": [] + }, + "verify:pre": { + "steps": [], + "contributions": [], + "gates": [] + }, + "verify:post": { + "steps": [ + { + "capId": "ui", + "point": "verify:post", + "ref": { + "skill": "ui-review" + }, + "produces": [ + "UI-REVIEW.md" + ], + "consumes": [ + "UI-SPEC.md" + ], + "when": "workflow.ui_review", + "onError": "skip" + } + ], + "contributions": [], + "gates": [] + }, + "ship:pre": { + "steps": [], + "contributions": [], + "gates": [] + }, + "ship:post": { + "steps": [], + "contributions": [], + "gates": [] + } +}; + +const configKeys = { + "workflow.ui_phase": "ui", + "workflow.ui_review": "ui", + "workflow.ui_safety_gate": "ui" +}; + +const runtimes = {}; + +const _requiresGraph = { + "ui": [] +}; + +function requiresClosure(id) { + const visited = new Set(); + const queue = [id]; + while (queue.length > 0) { + const current = queue.shift(); + const reqs = _requiresGraph[current] || []; + for (const req of reqs) { + if (!visited.has(req)) { + visited.add(req); + queue.push(req); + } + } + } + return visited; +} + +module.exports = { + version: '1', + capabilities, + bySkill, + byAgent, + byLoopPoint, + configKeys, + runtimes, + requiresClosure, +}; diff --git a/package.json b/package.json index 64e82d54d..d753bbcb8 100644 --- a/package.json +++ b/package.json @@ -77,10 +77,11 @@ "check:alias-drift": "node scripts/check-alias-drift.cjs", "check:identity-drift": "node scripts/lint-package-identity-drift.cjs", "check:integrity": "node scripts/check-npm-integrity.cjs", - "build": "npm run generate:identity && npm run build:lib && npm run build:hooks", + "build": "npm run generate:identity && npm run build:lib && npm run gen:capability-registry && npm run build:hooks", "build:hooks": "node scripts/build-hooks.js", "build:lib": "tsc -p tsconfig.build.json", "generate:identity": "node scripts/generate-package-identity.cjs", + "gen:capability-registry": "node scripts/gen-capability-registry.cjs --write", "prepack": "npm run build:lib", "prepare": "npm run build:lib", "version": "node scripts/sync-manifest-versions.cjs --stage", diff --git a/scripts/gen-capability-registry.cjs b/scripts/gen-capability-registry.cjs new file mode 100644 index 000000000..1a1bd57d9 --- /dev/null +++ b/scripts/gen-capability-registry.cjs @@ -0,0 +1,1346 @@ +#!/usr/bin/env node +'use strict'; + +/** + * gen-capability-registry.cjs — generates gsd-core/bin/lib/capability-registry.cjs + * from every capabilities//capability.json declaration. + * + * Usage: + * node scripts/gen-capability-registry.cjs # print to stdout + * node scripts/gen-capability-registry.cjs --write # write capability-registry.cjs + * node scripts/gen-capability-registry.cjs --check # exit 1 if committed registry is stale + * + * ADR-894 phase 3a-impl. Validates each capability against the schema, enforces + * cross-capability invariants, materializes hook ordering, and emits a role- + * partitioned CommonJS registry module. + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const CAPABILITIES_DIR = path.join(ROOT, 'capabilities'); +const REGISTRY_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'capability-registry.cjs'); +const CONFIG_SCHEMA_PATH = path.join(ROOT, 'gsd-core', 'bin', 'shared', 'config-schema.manifest.json'); + +const SCHEMA_VERSION = '1'; + +// ─── Loop Host Contract ─────────────────────────────────────────────────────── +// +// Inline constant — hardcoded from ADR-894 §3 (12 points + per-step agentRoles + +// coreArtifacts). This represents the host contract that will be generated from +// workflow markers once the workflow-marker infrastructure is in place. +// +// TODO 3a-impl-2: replace this constant with the generated-from-workflows host +// contract (ADR-894 §3). The workflow markers (, , +// ) must be authored in each of the five step workflows; the +// gen-loop-host-contract.cjs generator will parse them and produce this object. +const LOOP_HOST_CONTRACT = [ + { + step: 'discuss', + points: ['discuss:pre', 'discuss:post'], + agentRoles: ['orchestrator'], + coreArtifacts: { + produces: ['CONTEXT.md'], + consumes: [], + }, + }, + { + step: 'plan', + points: ['plan:pre', 'plan:post'], + agentRoles: ['researcher', 'planner', 'checker'], + coreArtifacts: { + produces: ['PLAN.md'], + consumes: ['CONTEXT.md'], + }, + }, + { + step: 'execute', + points: ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'], + agentRoles: ['executor', 'verifier'], + coreArtifacts: { + produces: ['SUMMARY.md'], + consumes: ['PLAN.md'], + }, + }, + { + step: 'verify', + points: ['verify:pre', 'verify:post'], + agentRoles: ['orchestrator'], + coreArtifacts: { + produces: ['UAT.md'], + consumes: ['SUMMARY.md'], + }, + }, + { + step: 'ship', + points: ['ship:pre', 'ship:post'], + agentRoles: ['orchestrator'], + coreArtifacts: { + produces: [], + consumes: ['UAT.md'], + }, + }, +]; + +// Canonical point order — explicit constant (do NOT rely on Set insertion order). +// Used for point-ordering semantics in consumes-satisfiability validation and topo-sort. +const POINT_ORDER = [ + 'discuss:pre', + 'discuss:post', + 'plan:pre', + 'plan:post', + 'execute:pre', + 'execute:wave:pre', + 'execute:wave:post', + 'execute:post', + 'verify:pre', + 'verify:post', + 'ship:pre', + 'ship:post', +]; + +// C1: Artifact availability — host-produced artifacts become available at their step's :post +// point. Build a map: artifact → earliest POINT_ORDER index at which it is available. +// (discuss produces CONTEXT.md → discuss:post = index 1; +// plan produces PLAN.md → plan:post = index 3; +// execute produces SUMMARY.md → execute:post = index 7; +// verify produces UAT.md → verify:post = index 9) +// +// NOTE: this map covers ONLY host artifacts. Hook-produced artifacts are handled per-run +// during consumes-satisfiability validation (C2 global pass). +const HOST_ARTIFACT_EARLIEST_POINT_IDX = (() => { + const m = Object.create(null); + for (const entry of LOOP_HOST_CONTRACT) { + // The :post point is the last point in each step's points array. + const postPoint = entry.points[entry.points.length - 1]; + const postIdx = POINT_ORDER.indexOf(postPoint); + for (const artifact of entry.coreArtifacts.produces) { + // Only record the earliest (should be unique, but take min to be safe). + if (m[artifact] === undefined || postIdx < m[artifact]) { + m[artifact] = postIdx; + } + } + } + return m; +})(); + +// Flatten all valid loop points into a Set for O(1) validation +const VALID_LOOP_POINTS = new Set(POINT_ORDER); + +// Map point → step contract (agentRoles + coreArtifacts) +const POINT_TO_CONTRACT = new Map(); +for (const entry of LOOP_HOST_CONTRACT) { + for (const point of entry.points) { + POINT_TO_CONTRACT.set(point, entry); + } +} + +// ─── Central config-schema loader ──────────────────────────────────────────── + +/** + * Loads the set of keys from the central config-schema manifest. + * Returns a Set. Used for collision detection. + * + * TODO: distinguish file-not-found (ok, return empty Set) from JSON-parse-error + * (should warn — a parse error means the schema is broken, not just absent). + */ +function loadCentralConfigKeys() { + try { + const manifest = JSON.parse(fs.readFileSync(CONFIG_SCHEMA_PATH, 'utf8')); + return new Set(Array.isArray(manifest.validKeys) ? manifest.validKeys : []); + } catch (_) { + return new Set(); + } +} + +// ─── Per-capability validation ──────────────────────────────────────────────── + +const KEBAB_RE = /^[a-z][a-z0-9-]*$/; +const VALID_ROLES = new Set(['feature', 'runtime']); +const VALID_TIERS = new Set(['core', 'standard', 'full']); +const VALID_ON_ERROR = new Set(['skip', 'halt']); + +/** + * Validate a single capability declaration. + * + * @param {object} cap The parsed JSON object. + * @param {string} folderId The folder name (must equal cap.id). + * @returns {string[]} Array of error strings; empty = valid. + */ +function validateCapability(cap, folderId) { + const errors = []; + + if (typeof cap !== 'object' || cap === null || Array.isArray(cap)) { + return ['capability must be a JSON object']; + } + + // ── Common envelope ──────────────────────────────────────────────────────── + + if (typeof cap.id !== 'string' || !KEBAB_RE.test(cap.id)) { + errors.push('id must be a kebab-case string'); + } else if (cap.id !== folderId) { + errors.push('id "' + cap.id + '" must equal the folder name "' + folderId + '"'); + } + + if (!VALID_ROLES.has(cap.role)) { + errors.push('role must be one of: feature, runtime (got: ' + cap.role + ')'); + } + + if (typeof cap.title !== 'string' || cap.title.length === 0) { + errors.push('title must be a non-empty string'); + } + + // C4: description is required + if (typeof cap.description !== 'string' || cap.description.length === 0) { + errors.push('description must be a non-empty string'); + } + + if (!VALID_TIERS.has(cap.tier)) { + errors.push('tier must be one of: core, standard, full (got: ' + cap.tier + ')'); + } + + if (!Array.isArray(cap.requires)) { + errors.push('requires must be an array of capability ids'); + } else { + for (const req of cap.requires) { + if (typeof req !== 'string') { + errors.push('requires entries must be strings (got: ' + JSON.stringify(req) + ')'); + } + } + } + + // ── Role-specific body ──────────────────────────────────────────────────── + + if (cap.role === 'feature') { + errors.push(...validateFeatureBody(cap)); + } else if (cap.role === 'runtime') { + errors.push(...validateRuntimeBody(cap)); + } + + return errors; +} + +function validateFeatureBody(cap) { + const errors = []; + + if (!Array.isArray(cap.skills)) { + errors.push('skills must be an array of strings'); + } else { + for (const s of cap.skills) { + if (typeof s !== 'string') { + errors.push('skills entries must be strings'); + } else if (s === '__proto__' || s === 'constructor' || s === 'prototype') { + // S2a: inline literal reserved-name guard (CodeQL barrier) + errors.push('skills entry "' + s + '" is a reserved name'); + } + } + } + + if (!Array.isArray(cap.agents)) { + errors.push('agents must be an array of strings'); + } else { + for (const a of cap.agents) { + if (typeof a !== 'string') { + errors.push('agents entries must be strings'); + } else if (a === '__proto__' || a === 'constructor' || a === 'prototype') { + // S2a: inline literal reserved-name guard (CodeQL barrier) + errors.push('agents entry "' + a + '" is a reserved name'); + } + } + } + + if (typeof cap.config !== 'object' || cap.config === null || Array.isArray(cap.config)) { + errors.push('config must be an object'); + } else { + // C5: validate config key names and value shapes + for (const key of Object.keys(cap.config)) { + if (key === '' ) { + errors.push('config keys must be non-empty strings'); + } else if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + // S2a: inline literal reserved-name guard (CodeQL barrier) + errors.push('config key "' + key + '" is a reserved name'); + } + const val = cap.config[key]; + if (val === null || typeof val !== 'object' || Array.isArray(val)) { + errors.push('config["' + key + '"] must be an object (got: ' + (val === null ? 'null' : typeof val) + ')'); + } else if (typeof val.type !== 'string' || val.type.length === 0) { + errors.push('config["' + key + '"] must have a string "type" field (e.g. "boolean", "string", "number", "enum")'); + } + } + } + + // C4: hooks, when present, must be an array of {event: string, script: string} + if (cap.hooks !== undefined) { + if (!Array.isArray(cap.hooks)) { + errors.push('hooks must be an array of {event, script} objects'); + } else { + for (let i = 0; i < cap.hooks.length; i++) { + const h = cap.hooks[i]; + if (typeof h !== 'object' || h === null || Array.isArray(h)) { + errors.push('hooks[' + i + '] must be an object with event and script keys'); + } else { + if (typeof h.event !== 'string' || h.event.length === 0) { + errors.push('hooks[' + i + '].event must be a non-empty string'); + } + if (typeof h.script !== 'string' || h.script.length === 0) { + errors.push('hooks[' + i + '].script must be a non-empty string'); + } + } + } + } + } + + if (!Array.isArray(cap.steps)) { + errors.push('steps must be an array'); + } else { + for (let i = 0; i < cap.steps.length; i++) { + errors.push(...validateStep(cap.steps[i], 'steps[' + i + ']')); + } + } + + if (!Array.isArray(cap.contributions)) { + errors.push('contributions must be an array'); + } else { + for (let i = 0; i < cap.contributions.length; i++) { + errors.push(...validateContribution(cap.contributions[i], 'contributions[' + i + ']')); + } + } + + if (!Array.isArray(cap.gates)) { + errors.push('gates must be an array'); + } else { + for (let i = 0; i < cap.gates.length; i++) { + errors.push(...validateGate(cap.gates[i], 'gates[' + i + ']')); + } + } + + return errors; +} + +// C3: Validate role:runtime body +const VALID_CONFIG_FORMATS = new Set(['settings-json', 'toml', 'markdown', 'markdown-dir', 'none']); +const FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME = ['skills', 'agents', 'steps', 'contributions', 'gates', 'hooks']; + +function validateRuntimeBody(cap) { + const errors = []; + + // C3: feature-only fields must NOT appear on a runtime cap + for (const field of FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME) { + if (cap[field] !== undefined) { + errors.push('role:runtime capability must not have "' + field + '" (feature-only field)'); + } + } + + // C3: require a runtime object + if (typeof cap.runtime !== 'object' || cap.runtime === null || Array.isArray(cap.runtime)) { + errors.push('role:runtime capability must have a "runtime" object'); + return errors; // can't validate further without the object + } + + const r = cap.runtime; + if (typeof r.configHome !== 'string' || r.configHome.length === 0) { + errors.push('runtime.configHome must be a non-empty string'); + } + if (!VALID_CONFIG_FORMATS.has(r.configFormat)) { + errors.push('runtime.configFormat must be one of: ' + [...VALID_CONFIG_FORMATS].join(', ') + ' (got: ' + r.configFormat + ')'); + } + if (!Array.isArray(r.artifactLayout)) { + errors.push('runtime.artifactLayout must be an array'); + } + if (typeof r.commandStyle !== 'string' || r.commandStyle.length === 0) { + errors.push('runtime.commandStyle must be a non-empty string'); + } + if (typeof r.hooksSurface !== 'string' || r.hooksSurface.length === 0) { + errors.push('runtime.hooksSurface must be a non-empty string'); + } + if (typeof r.sandboxTier !== 'string' || r.sandboxTier.length === 0) { + errors.push('runtime.sandboxTier must be a non-empty string'); + } + if (r.supportTier !== 1 && r.supportTier !== 2) { + errors.push('runtime.supportTier must be 1 or 2 (got: ' + r.supportTier + ')'); + } + + return errors; +} + +function validateStep(step, prefix) { + const errors = []; + + if (!VALID_LOOP_POINTS.has(step.point)) { + errors.push(prefix + '.point "' + step.point + '" is not a valid loop point'); + } + + if (typeof step.ref !== 'object' || step.ref === null) { + errors.push(prefix + '.ref must be an object with skill or agent key'); + } else { + const hasSkill = Object.prototype.hasOwnProperty.call(step.ref, 'skill'); + const hasAgent = Object.prototype.hasOwnProperty.call(step.ref, 'agent'); + if (!hasSkill && !hasAgent) { + errors.push(prefix + '.ref must have a "skill" or "agent" key'); + } else if (hasSkill && hasAgent) { + // Fix #4: ref must be exclusive {skill} XOR {agent} + errors.push(prefix + '.ref must have exactly one of "skill" or "agent", not both'); + } + if (hasSkill && typeof step.ref.skill !== 'string') { + errors.push(prefix + '.ref.skill must be a string'); + } + if (hasAgent && typeof step.ref.agent !== 'string') { + errors.push(prefix + '.ref.agent must be a string'); + } + } + + if (!Array.isArray(step.produces)) { + errors.push(prefix + '.produces must be an array'); + } else { + for (const p of step.produces) { + if (typeof p !== 'string') errors.push(prefix + '.produces entries must be strings'); + } + } + + if (!Array.isArray(step.consumes)) { + errors.push(prefix + '.consumes must be an array'); + } else { + for (const c of step.consumes) { + if (typeof c !== 'string') errors.push(prefix + '.consumes entries must be strings'); + } + } + + if (step.when !== undefined && typeof step.when !== 'string') { + errors.push(prefix + '.when must be a string if present'); + } + + if (!VALID_ON_ERROR.has(step.onError)) { + errors.push(prefix + '.onError must be "skip" or "halt" (got: ' + step.onError + ')'); + } + + return errors; +} + +function validateContribution(contrib, prefix) { + const errors = []; + + if (!VALID_LOOP_POINTS.has(contrib.point)) { + errors.push(prefix + '.point "' + contrib.point + '" is not a valid loop point'); + } + + if (typeof contrib.into !== 'string') { + errors.push(prefix + '.into must be a string (agent role name)'); + } + + if (typeof contrib.fragment !== 'object' || contrib.fragment === null) { + errors.push(prefix + '.fragment must be an object with path or inline key'); + } else { + const hasPath = Object.prototype.hasOwnProperty.call(contrib.fragment, 'path'); + const hasInline = Object.prototype.hasOwnProperty.call(contrib.fragment, 'inline'); + if (!hasPath && !hasInline) { + errors.push(prefix + '.fragment must have a "path" or "inline" key'); + } + // S1: fragment.path traversal guard — must be a relative path with no ".." segments + if (hasPath) { + const p = contrib.fragment.path; + if (typeof p !== 'string' || p === '' || path.isAbsolute(p) || p.split(/[\\/]/).includes('..')) { + errors.push(prefix + '.fragment.path must be a relative path with no ".." segments'); + } + } + } + + if (contrib.when !== undefined && typeof contrib.when !== 'string') { + errors.push(prefix + '.when must be a string if present'); + } + + if (contrib.onError !== undefined && !VALID_ON_ERROR.has(contrib.onError)) { + errors.push(prefix + '.onError must be "skip" or "halt" if present'); + } + + return errors; +} + +function validateGate(gate, prefix) { + const errors = []; + + if (!VALID_LOOP_POINTS.has(gate.point)) { + errors.push(prefix + '.point "' + gate.point + '" is not a valid loop point'); + } + + if (typeof gate.check !== 'object' || gate.check === null) { + errors.push(prefix + '.check must be an object'); + } else { + const hasQuery = Object.prototype.hasOwnProperty.call(gate.check, 'query'); + const hasPredicate = Object.prototype.hasOwnProperty.call(gate.check, 'predicate'); + const hasAgentVerdict = Object.prototype.hasOwnProperty.call(gate.check, 'agentVerdict'); + const count = [hasQuery, hasPredicate, hasAgentVerdict].filter(Boolean).length; + if (count !== 1) { + errors.push(prefix + '.check must have exactly one of: query, predicate, agentVerdict'); + } + // agentVerdict forces blocking: false (advisory only) + if (hasAgentVerdict && gate.blocking === true) { + errors.push( + prefix + '.check.agentVerdict forces blocking: false (non-deterministic checks may not halt the loop)', + ); + } + } + + if (gate.when !== undefined && typeof gate.when !== 'string') { + errors.push(prefix + '.when must be a string if present'); + } + + if (typeof gate.blocking !== 'boolean') { + errors.push(prefix + '.blocking must be a boolean'); + } + + if (!VALID_ON_ERROR.has(gate.onError)) { + errors.push(prefix + '.onError must be "skip" or "halt" (got: ' + gate.onError + ')'); + } + + return errors; +} + +// ─── Contract validation ────────────────────────────────────────────────────── + +/** + * Validate per-capability contract constraints against the Loop Host Contract. + * This covers: + * - contribution.into ∈ step's agentRoles + * - when references a config key in cap.config + * + * NOTE: step.consumes satisfiability is NOT checked here — it requires the full + * set of validated capabilities (cross-capability produces). It runs in + * validateConsumesGlobal() after loadAndValidate builds capMap. + * + * @param {object} cap Validated capability object + * @param {string} capId Capability id (for error messages) + */ +function validateAgainstContract(cap, capId) { + if (cap.role !== 'feature') return []; + const errors = []; + const prefix = 'capability "' + capId + '"'; + + // contribution.into must be in the step's agentRoles + for (const contrib of cap.contributions) { + if (!VALID_LOOP_POINTS.has(contrib.point)) continue; // already reported + const contract = POINT_TO_CONTRACT.get(contrib.point); + if (contract && !contract.agentRoles.includes(contrib.into)) { + errors.push( + prefix + ' contribution.into "' + contrib.into + '" at point "' + contrib.point + + '" is not in the step\'s agentRoles [' + contract.agentRoles.join(', ') + ']', + ); + } + } + + // when references a plausibly-valid config key (string — we require it's in cap.config) + for (const step of cap.steps) { + if (step.when !== undefined) { + if (typeof step.when !== 'string') continue; // already reported above + if ( + typeof cap.config === 'object' && + cap.config !== null && + !Object.prototype.hasOwnProperty.call(cap.config, step.when) + ) { + errors.push( + prefix + ' step.when "' + step.when + '" is not defined in capability config keys', + ); + } + } + } + + for (const contrib of cap.contributions) { + if (contrib.when !== undefined) { + if (typeof contrib.when !== 'string') continue; + if ( + typeof cap.config === 'object' && + cap.config !== null && + !Object.prototype.hasOwnProperty.call(cap.config, contrib.when) + ) { + errors.push( + prefix + ' contribution.when "' + contrib.when + '" is not defined in capability config keys', + ); + } + } + } + + for (const gate of cap.gates) { + if (gate.when !== undefined) { + if (typeof gate.when !== 'string') continue; + if ( + typeof cap.config === 'object' && + cap.config !== null && + !Object.prototype.hasOwnProperty.call(cap.config, gate.when) + ) { + errors.push( + prefix + ' gate.when "' + gate.when + '" is not defined in capability config keys', + ); + } + } + } + + return errors; +} + +/** + * C1+C2: Global consumes-satisfiability validation. + * + * A hook at point P consuming artifact A is satisfiable iff: + * - A is a host-produced artifact available from its step's :post point (C1), and + * that :post point's POINT_ORDER index ≤ P's index; OR + * - A is produced by any capability hook step at a point whose POINT_ORDER index ≤ P's index + * (same-point is OK — topoSortSteps enforces intra-point order); OR + * - A is never produced anywhere → rejected. + * + * Runs after capMap is fully built so cross-capability produces are visible. + * + * @param {Map} capMap Fully-validated capability map. + * @returns {string[]} Array of error strings. + */ +function validateConsumesGlobal(capMap) { + const errors = []; + + // Build producedAtPoint: artifact → earliest POINT_ORDER index at which it is produced. + // Seed with host artifacts (C1: available from their step's :post point). + // Host-artifact entries are tagged {pointIdx, isHost:true} so they are never excluded by + // the self-consume check. + const producedAtPoint = Object.create(null); + for (const [artifact, postIdx] of Object.entries(HOST_ARTIFACT_EARLIEST_POINT_IDX)) { + if (artifact === '__proto__' || artifact === 'constructor' || artifact === 'prototype') continue; + producedAtPoint[artifact] = postIdx; + } + + // Build a richer per-artifact producer list for the self-consume check. + // Each entry: { pointIdx, capId, stepIdx } — identifies which cap+step produced the artifact. + // Host artifacts are seeded separately (no capId) and always satisfy the consume check. + // capHookProducers[artifact] = [{pointIdx, capId, stepIdx}, ...] + const capHookProducers = Object.create(null); + + // Add hook-produced artifacts from all capabilities. + for (const [capId, cap] of capMap) { + if (cap.role !== 'feature') continue; + for (let si = 0; si < (cap.steps || []).length; si++) { + const step = cap.steps[si]; + if (!VALID_LOOP_POINTS.has(step.point)) continue; + const pointIdx = POINT_ORDER.indexOf(step.point); + for (const artifact of (step.produces || [])) { + if (typeof artifact !== 'string') continue; + if (artifact === '__proto__' || artifact === 'constructor' || artifact === 'prototype') continue; + if (producedAtPoint[artifact] === undefined || pointIdx < producedAtPoint[artifact]) { + producedAtPoint[artifact] = pointIdx; + } + if (!capHookProducers[artifact]) capHookProducers[artifact] = []; + capHookProducers[artifact].push({ pointIdx, capId, stepIdx: si }); + } + } + } + + // TODO: duplicate-producer invariant — if two capability steps produce the same artifact + // at the same point, that's ambiguous. Detect and reject as a follow-up. + + // Now check every hook step's consumes. + // Self-consume rule: a step H cannot satisfy its own consumes[A] from its own produces[A]. + // A is satisfiable for H iff: + // (a) A is a host artifact with pointIdx <= stepPointIdx, OR + // (b) A is produced by a DIFFERENT cap/step at pointIdx <= stepPointIdx. + // "Different" means capId != H.capId OR stepIdx != H.stepIdx. + for (const [capId, cap] of capMap) { + if (cap.role !== 'feature') continue; + const prefix = 'capability "' + capId + '"'; + for (let si = 0; si < (cap.steps || []).length; si++) { + const step = cap.steps[si]; + if (!VALID_LOOP_POINTS.has(step.point)) continue; + const stepPointIdx = POINT_ORDER.indexOf(step.point); + for (const artifact of (step.consumes || [])) { + if (typeof artifact !== 'string') continue; + + // Check host-artifact satisfaction first (never excluded by self-consume). + const hostIdx = HOST_ARTIFACT_EARLIEST_POINT_IDX[artifact]; + const hostSatisfied = hostIdx !== undefined && hostIdx <= stepPointIdx; + if (hostSatisfied) continue; // fast-path: host artifact is available + + // Check cap-hook producers, excluding this step itself. + const producers = capHookProducers[artifact]; + if (!producers || producers.length === 0) { + // Not a host artifact and never produced by any hook. + errors.push( + prefix + ' step at point "' + step.point + '" consumes "' + artifact + + '" which is never produced by any host artifact or capability hook', + ); + continue; + } + + // Find any non-self producer at pointIdx <= stepPointIdx. + const otherEarliestIdx = producers.reduce((best, p) => { + const isSelf = p.capId === capId && p.stepIdx === si; + if (isSelf) return best; + return (best === undefined || p.pointIdx < best) ? p.pointIdx : best; + }, undefined); + + if (otherEarliestIdx === undefined) { + // Only producer is this step itself — self-consume violation. + errors.push( + prefix + ' step at point "' + step.point + '" consumes "' + artifact + + '" which is only produced by this step itself (a step cannot consume its own output)', + ); + } else if (otherEarliestIdx > stepPointIdx) { + errors.push( + prefix + ' step at point "' + step.point + '" consumes "' + artifact + + '" which is only produced after this point (earliest available at POINT_ORDER index ' + + otherEarliestIdx + ' = "' + POINT_ORDER[otherEarliestIdx] + '")', + ); + } + // else: satisfied by another cap/step at an earlier-or-same point — OK. + } + } + } + + return errors; +} + +// ─── Cross-capability invariants ────────────────────────────────────────────── + +const TIER_RANK = { core: 0, standard: 1, full: 2 }; + +/** + * Enforce cross-capability invariants. + * + * @param {Map} capMap id → validated capability object + * @param {Set} centralKeys Set of keys in the central config-schema + * @returns {string[]} Array of error strings; empty = all pass. + */ +function validateCrossCapability(capMap, centralKeys) { + const errors = []; + + // Ownership: one owner per skill stem + agent name + const skillOwner = new Map(); // skill → capId + const agentOwner = new Map(); // agent → capId + for (const [capId, cap] of capMap) { + if (cap.role !== 'feature') continue; + for (const skill of cap.skills) { + if (skillOwner.has(skill)) { + errors.push( + 'skill "' + skill + '" is owned by both "' + skillOwner.get(skill) + '" and "' + capId + '"', + ); + } else { + skillOwner.set(skill, capId); + } + } + for (const agent of cap.agents) { + if (agentOwner.has(agent)) { + errors.push( + 'agent "' + agent + '" is owned by both "' + agentOwner.get(agent) + '" and "' + capId + '"', + ); + } else { + agentOwner.set(agent, capId); + } + } + } + + // Config key ownership: exclusive AND absent from central schema + const configKeyOwner = new Map(); // key → capId + for (const [capId, cap] of capMap) { + if (cap.role !== 'feature' || typeof cap.config !== 'object' || cap.config === null) continue; + for (const key of Object.keys(cap.config)) { + if (configKeyOwner.has(key)) { + errors.push( + 'config key "' + key + '" is owned by both "' + configKeyOwner.get(key) + '" and "' + capId + '"', + ); + } else { + configKeyOwner.set(key, capId); + } + if (centralKeys.has(key)) { + errors.push( + 'config key "' + key + '" is declared in capability "' + capId + + '" AND exists in the central config-schema — migration mid-flight: ' + + 'remove from central config-schema before adding to the capability', + ); + } + } + } + + // requires: all ids exist + for (const [capId, cap] of capMap) { + if (!Array.isArray(cap.requires)) continue; + for (const req of cap.requires) { + if (!capMap.has(req)) { + errors.push( + 'capability "' + capId + '" requires "' + req + '" which does not exist', + ); + } + } + } + + // requires: acyclic + const cycleErrors = detectRequiresCycles(capMap); + errors.push(...cycleErrors); + + // requires: tier-monotone (core may not require standard/full; standard may not require full) + for (const [capId, cap] of capMap) { + if (!Array.isArray(cap.requires) || !VALID_TIERS.has(cap.tier)) continue; + const myRank = TIER_RANK[cap.tier]; + for (const req of cap.requires) { + const reqCap = capMap.get(req); + if (!reqCap || !VALID_TIERS.has(reqCap.tier)) continue; + const reqRank = TIER_RANK[reqCap.tier]; + if (reqRank > myRank) { + errors.push( + 'tier-monotone violation: capability "' + capId + '" (tier: ' + cap.tier + + ') requires "' + req + '" (tier: ' + reqCap.tier + + ') — a capability may not require a higher-tier capability', + ); + } + } + } + + return errors; +} + +/** + * Detect cycles in the requires graph using DFS. + */ +function detectRequiresCycles(capMap) { + const errors = []; + const WHITE = 0, GRAY = 1, BLACK = 2; + const color = new Map([...capMap.keys()].map((k) => [k, WHITE])); + + function dfs(id, stack) { + if (color.get(id) === GRAY) { + const cycleStr = [...stack, id].join(' → '); + errors.push('requires cycle detected: ' + cycleStr); + return; + } + if (color.get(id) === BLACK) return; + color.set(id, GRAY); + stack.push(id); + const cap = capMap.get(id); + if (cap && Array.isArray(cap.requires)) { + for (const req of cap.requires) { + if (capMap.has(req)) dfs(req, stack); + } + } + stack.pop(); + color.set(id, BLACK); + } + + for (const id of capMap.keys()) { + if (color.get(id) === WHITE) dfs(id, []); + } + + return errors; +} + +// ─── requiresClosure ───────────────────────────────────────────────────────── + +/** + * Compute the transitive requires closure for a capability id. + * Returns a Set of all transitively required capability ids. + * + * @param {string} id + * @param {Map} capMap + */ +function computeRequiresClosure(id, capMap) { + const visited = new Set(); + const queue = [id]; + while (queue.length > 0) { + const current = queue.shift(); + const cap = capMap.get(current); + if (!cap || !Array.isArray(cap.requires)) continue; + for (const req of cap.requires) { + if (!visited.has(req)) { + visited.add(req); + queue.push(req); + } + } + } + return visited; +} + +// ─── Topological ordering ───────────────────────────────────────────────────── + +/** + * Topologically sort steps at a given point by produces/consumes. + * Capability-id tiebreak for determinism. + * + * @param {{ capId: string, step: object }[]} entries + * @returns {{ capId: string, step: object }[]} + */ +function topoSortSteps(entries) { + if (entries.length <= 1) return entries; + + // Build adjacency: entry A must come before entry B if B consumes something A produces + const n = entries.length; + const inDegree = new Array(n).fill(0); + const adj = Array.from({ length: n }, () => []); + + for (let i = 0; i < n; i++) { + const producesI = new Set(entries[i].step.produces || []); + for (let j = 0; j < n; j++) { + if (i === j) continue; + const consumesJ = entries[j].step.consumes || []; + for (const artifact of consumesJ) { + if (producesI.has(artifact)) { + adj[i].push(j); + inDegree[j]++; + break; + } + } + } + } + + // Kahn's algorithm with stable tiebreak on capId + const queue = []; + for (let i = 0; i < n; i++) { + if (inDegree[i] === 0) queue.push(i); + } + // Sort queue by capId for determinism + queue.sort((a, b) => entries[a].capId.localeCompare(entries[b].capId)); + + const result = []; + while (queue.length > 0) { + // Take the first (sorted) ready node + const idx = queue.shift(); + result.push(entries[idx]); + const newReady = []; + for (const neighbor of adj[idx]) { + inDegree[neighbor]--; + if (inDegree[neighbor] === 0) newReady.push(neighbor); + } + newReady.sort((a, b) => entries[a].capId.localeCompare(entries[b].capId)); + queue.push(...newReady); + } + + // Fix #2: if result.length < n, Kahn's could not complete — there is a produces/consumes + // cycle. Do NOT silently fall back to declaration order; throw a clear error. + if (result.length < n) { + const sortedIds = entries.map((e) => e.capId).join(', '); + throw new Error( + 'produces/consumes cycle detected in steps at point "' + + (entries[0] && entries[0].step ? entries[0].step.point : '?') + + '" among capabilities [' + sortedIds + ']: ' + + 'a cycle in hook produces/consumes prevents deterministic ordering', + ); + } + return result; +} + +// ─── Registry builder ───────────────────────────────────────────────────────── + +/** + * Read + validate all capabilities//capability.json files. + * Returns { capMap, errors } where capMap is Map. + * + * @param {Set} [centralKeys] Keys in central config-schema for collision detection. + * If omitted, reads from disk. Pass new Set() to skip central-collision checks + * (used during 3a-impl while migration is in-progress). + * @param {string} [capabilitiesDir] Override capabilities dir (for testing with fixtures). + */ +function loadAndValidate(centralKeys, capabilitiesDir) { + const resolvedCentralKeys = centralKeys !== undefined ? centralKeys : loadCentralConfigKeys(); + const resolvedCapDir = capabilitiesDir !== undefined ? capabilitiesDir : CAPABILITIES_DIR; + const errors = []; + const capMap = new Map(); + + if (!fs.existsSync(resolvedCapDir)) { + return { capMap, errors }; + } + + const folderEntries = fs.readdirSync(resolvedCapDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .sort(); + + for (const folderId of folderEntries) { + const capPath = path.join(resolvedCapDir, folderId, 'capability.json'); + if (!fs.existsSync(capPath)) continue; + + let cap; + try { + cap = JSON.parse(fs.readFileSync(capPath, 'utf8')); + } catch (err) { + errors.push(folderId + '/capability.json: JSON parse error: ' + String(err.message)); + continue; + } + + const capErrors = validateCapability(cap, folderId); + if (capErrors.length > 0) { + for (const e of capErrors) errors.push(folderId + '/capability.json: ' + e); + continue; // skip cross-validation if basic schema fails + } + + const contractErrors = validateAgainstContract(cap, cap.id); + if (contractErrors.length > 0) { + for (const e of contractErrors) errors.push(folderId + '/capability.json: ' + e); + // Fix #6: do NOT add contract-invalid caps to capMap — validateCrossCapability should + // only see fully-valid capabilities so its invariants are meaningful. + continue; + } + + capMap.set(cap.id, cap); + } + + // Cross-capability invariants — capMap contains only fully-valid capabilities at this point. + const crossErrors = validateCrossCapability(capMap, resolvedCentralKeys); + errors.push(...crossErrors); + + // C2: Global consumes-satisfiability — runs after capMap is fully built so cross-capability + // produces are visible. A capability with consumes errors is kept in capMap (it passed per-cap + // validation) but the errors are surfaced so the build fails. + const consumesErrors = validateConsumesGlobal(capMap); + errors.push(...consumesErrors); + + return { capMap, errors }; +} + +/** + * Build the registry object from a validated capMap. + * + * @param {Map} capMap + */ +function buildRegistry(capMap) { + // S2b: Use Object.create(null) for all accumulator maps so prototype-pollution + // can't touch Object.prototype even if a reserved name slips through validation. + const capabilities = Object.create(null); + const bySkill = Object.create(null); + const byAgent = Object.create(null); + const byLoopPoint = Object.create(null); + const configKeys = Object.create(null); + const runtimes = Object.create(null); + + // Initialize byLoopPoint for all valid points + for (const point of VALID_LOOP_POINTS) { + byLoopPoint[point] = { steps: [], contributions: [], gates: [] }; + } + + // Phase 1: collect per-point entries grouped by point + const pointSteps = new Map(); // point → [{ capId, step }] + const pointContribs = new Map(); // point → [{ capId, contrib }] + const pointGates = new Map(); // point → [{ capId, gate }] + + for (const point of VALID_LOOP_POINTS) { + pointSteps.set(point, []); + pointContribs.set(point, []); + pointGates.set(point, []); + } + + for (const [capId, cap] of capMap) { + // S2b: inline literal guard at each write site (CodeQL barrier) + if (capId === '__proto__' || capId === 'constructor' || capId === 'prototype') continue; + capabilities[capId] = cap; + + if (cap.role === 'feature') { + for (const skill of (cap.skills || [])) { + // S2b: inline literal guard at each write site (CodeQL barrier) + if (skill === '__proto__' || skill === 'constructor' || skill === 'prototype') continue; + bySkill[skill] = capId; + } + for (const agent of (cap.agents || [])) { + // S2b: inline literal guard at each write site (CodeQL barrier) + if (agent === '__proto__' || agent === 'constructor' || agent === 'prototype') continue; + byAgent[agent] = capId; + } + for (const key of Object.keys(cap.config || {})) { + // S2b: inline literal guard at each write site (CodeQL barrier) + if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue; + configKeys[key] = capId; + } + + for (const step of (cap.steps || [])) { + if (VALID_LOOP_POINTS.has(step.point)) { + pointSteps.get(step.point).push({ capId, step }); + } + } + for (const contrib of (cap.contributions || [])) { + if (VALID_LOOP_POINTS.has(contrib.point)) { + // Group contributions by into, then cap-id order + pointContribs.get(contrib.point).push({ capId, contrib }); + } + } + for (const gate of (cap.gates || [])) { + if (VALID_LOOP_POINTS.has(gate.point)) { + pointGates.get(gate.point).push({ capId, gate }); + } + } + } else if (cap.role === 'runtime') { + // S2b: inline literal guard at each write site (CodeQL barrier) — capId already guarded above + runtimes[capId] = cap; + } + } + + // Phase 2: materialize ordering + for (const point of VALID_LOOP_POINTS) { + // Steps: topological sort by produces/consumes, cap-id tiebreak + const sortedSteps = topoSortSteps(pointSteps.get(point)); + byLoopPoint[point].steps = sortedSteps.map((e) => ({ + capId: e.capId, + ...e.step, + })); + + // Contributions: group by into, then capability-id order within group + const contribs = pointContribs.get(point); + contribs.sort((a, b) => { + const intoCompare = a.contrib.into.localeCompare(b.contrib.into); + if (intoCompare !== 0) return intoCompare; + return a.capId.localeCompare(b.capId); + }); + byLoopPoint[point].contributions = contribs.map((e) => ({ + capId: e.capId, + ...e.contrib, + })); + + // Gates: as declared (stable by capId order) + const gates = pointGates.get(point); + gates.sort((a, b) => a.capId.localeCompare(b.capId)); + byLoopPoint[point].gates = gates.map((e) => ({ + capId: e.capId, + ...e.gate, + })); + } + + return { + version: SCHEMA_VERSION, + capabilities, + bySkill, + byAgent, + byLoopPoint, + configKeys, + runtimes, + }; +} + +// ─── Registry serialization ─────────────────────────────────────────────────── + +/** + * Serialize the registry to a CommonJS module string. + * + * @param {object} registry The registry object from buildRegistry() + * @param {Map} capMap Used for requiresClosure() + */ +function serializeRegistry(registry, capMap) { + const lines = []; + + lines.push("'use strict';"); + lines.push(''); + lines.push('/**'); + lines.push(' * capability-registry.cjs — generated by scripts/gen-capability-registry.cjs'); + lines.push(' * DO NOT EDIT BY HAND. Run: node scripts/gen-capability-registry.cjs --write'); + lines.push(' * ADR-894 §5 — role-partitioned Capability Registry.'); + lines.push(' */'); + lines.push(''); + + // Serialize each section as a variable to keep the file readable + lines.push('const capabilities = ' + JSON.stringify(registry.capabilities, null, 2) + ';'); + lines.push(''); + lines.push('const bySkill = ' + JSON.stringify(registry.bySkill, null, 2) + ';'); + lines.push(''); + lines.push('const byAgent = ' + JSON.stringify(registry.byAgent, null, 2) + ';'); + lines.push(''); + lines.push('const byLoopPoint = ' + JSON.stringify(registry.byLoopPoint, null, 2) + ';'); + lines.push(''); + lines.push('const configKeys = ' + JSON.stringify(registry.configKeys, null, 2) + ';'); + lines.push(''); + lines.push('const runtimes = ' + JSON.stringify(registry.runtimes, null, 2) + ';'); + lines.push(''); + + // Inline the requires graph so requiresClosure() works without re-reading files + const requiresGraph = {}; + for (const [id, cap] of capMap) { + requiresGraph[id] = Array.isArray(cap.requires) ? cap.requires : []; + } + lines.push('const _requiresGraph = ' + JSON.stringify(requiresGraph, null, 2) + ';'); + lines.push(''); + + // requiresClosure function + lines.push('function requiresClosure(id) {'); + lines.push(' const visited = new Set();'); + lines.push(' const queue = [id];'); + lines.push(' while (queue.length > 0) {'); + lines.push(' const current = queue.shift();'); + lines.push(' const reqs = _requiresGraph[current] || [];'); + lines.push(' for (const req of reqs) {'); + lines.push(' if (!visited.has(req)) {'); + lines.push(' visited.add(req);'); + lines.push(' queue.push(req);'); + lines.push(' }'); + lines.push(' }'); + lines.push(' }'); + lines.push(' return visited;'); + lines.push('}'); + lines.push(''); + + lines.push('module.exports = {'); + lines.push(" version: '" + registry.version + "',"); + lines.push(' capabilities,'); + lines.push(' bySkill,'); + lines.push(' byAgent,'); + lines.push(' byLoopPoint,'); + lines.push(' configKeys,'); + lines.push(' runtimes,'); + lines.push(' requiresClosure,'); + lines.push('};'); + lines.push(''); + + return lines.join('\n'); +} + +// ─── --check diff helper ────────────────────────────────────────────────────── + +/** + * Compare committed registry with live registry (for --check). + * Strips the generated comment line for comparison. + */ +function stripGeneratedComment(content) { + return content + .split('\n') + .filter((line) => !line.includes('generated by scripts/gen-capability-registry.cjs')) + .join('\n'); +} + +/** + * Normalize line endings to LF. + * The generator always writes LF, but Windows git (autocrlf) checks out committed files with + * CRLF. The --check comparison must be line-ending-agnostic so it only fails on REAL content + * differences, not on checkout-introduced whitespace differences. + * + * @param {string} content + * @returns {string} + */ +function normalizeLineEndings(content) { + return content.replace(/\r/g, ''); +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +/** + * Fix #3: Emit pending-migration WARNINGs for config keys that collide with the central + * config-schema. Per ADR-894 staged cutover, a collision during the registry-only phase is + * NOT a hard error — the capability pipeline is being established before the atomic cutover + * PR for each feature. The registry still generates; the warning tells the maintainer which + * keys need to be moved out of the central schema at cutover time. + * + * A NEW unexpected collision (a key that shouldn't be in both) is also surfaced — the + * maintainer sees it in build output rather than it being silently swallowed. + * + * Reference: ADR-894 §4 "config-key ownership exclusive AND complete — presence in both = + * collision = a mid-flight migration; finish the move." + * + * @param {string[]} crossErrors Errors from validateCrossCapability (may include collision msgs) + * @param {Map} capMap + * @returns {{ hardErrors: string[], pendingMigrationWarnings: string[] }} + */ +function classifyCrossErrors(crossErrors) { + const hardErrors = []; + const pendingMigrationWarnings = []; + const collisionRe = /config key "([^"]+)" is declared in capability "([^"]+)" AND exists in the central config-schema/; + + for (const e of crossErrors) { + const m = collisionRe.exec(e); + if (m) { + // Collision = pending-migration warning, not a hard error during 3a-impl staged cutover + pendingMigrationWarnings.push( + '⚠ pending-migration: capability \'' + m[2] + '\' declares config key \'' + m[1] + + '\' still present in central config-schema; finish the move at cutover', + ); + } else { + hardErrors.push(e); + } + } + return { hardErrors, pendingMigrationWarnings }; +} + +function main() { + const flag = process.argv[2]; + + if (flag === '--check') { + // Fix #3: read the REAL central config keys so collision detection fires and is visible. + const centralKeys = loadCentralConfigKeys(); + const { capMap, errors } = loadAndValidate(centralKeys); + + // Separate pending-migration warnings from hard errors + const { hardErrors, pendingMigrationWarnings } = classifyCrossErrors(errors); + for (const w of pendingMigrationWarnings) process.stderr.write(w + '\n'); + if (hardErrors.length > 0) { + for (const e of hardErrors) process.stderr.write(' ERROR ' + e + '\n'); + throw new ExitError(1, 'capability validation failed (' + hardErrors.length + ' error(s))'); + } + + const registry = buildRegistry(capMap); + const live = serializeRegistry(registry, capMap); + + if (!fs.existsSync(REGISTRY_PATH)) { + process.stderr.write( + 'gsd-core/bin/lib/capability-registry.cjs does not exist. Run:\n' + + ' node scripts/gen-capability-registry.cjs --write\n', + ); + throw new ExitError(1); + } + + const committed = fs.readFileSync(REGISTRY_PATH, 'utf8'); + if (normalizeLineEndings(stripGeneratedComment(committed)) !== normalizeLineEndings(stripGeneratedComment(live))) { + process.stderr.write( + 'gsd-core/bin/lib/capability-registry.cjs is stale. Run:\n' + + ' node scripts/gen-capability-registry.cjs --write\n', + ); + throw new ExitError(1); + } + + process.stdout.write('gsd-core/bin/lib/capability-registry.cjs is up to date.\n'); + } else if (flag === '--write') { + // Fix #3: read the REAL central config keys so collision detection fires and is visible. + const centralKeys = loadCentralConfigKeys(); + const { capMap, errors } = loadAndValidate(centralKeys); + + // Separate pending-migration warnings from hard errors + const { hardErrors, pendingMigrationWarnings } = classifyCrossErrors(errors); + for (const w of pendingMigrationWarnings) process.stderr.write(w + '\n'); + if (hardErrors.length > 0) { + for (const e of hardErrors) process.stderr.write(' ERROR ' + e + '\n'); + throw new ExitError(1, 'capability validation failed — registry not written'); + } + + const registry = buildRegistry(capMap); + const content = serializeRegistry(registry, capMap); + // Fix #5: mkdir-p before writing so --write doesn't ENOENT in a fresh worktree. + fs.mkdirSync(path.dirname(REGISTRY_PATH), { recursive: true }); + fs.writeFileSync(REGISTRY_PATH, content, 'utf8'); + process.stdout.write('Wrote ' + REGISTRY_PATH + '\n'); + } else { + // Default: print to stdout — use real central keys for visibility + const centralKeys = loadCentralConfigKeys(); + const { capMap, errors } = loadAndValidate(centralKeys); + + const { hardErrors, pendingMigrationWarnings } = classifyCrossErrors(errors); + for (const w of pendingMigrationWarnings) process.stderr.write(w + '\n'); + if (hardErrors.length > 0) { + for (const e of hardErrors) process.stderr.write(' ERROR ' + e + '\n'); + throw new ExitError(1, 'capability validation failed'); + } + const registry = buildRegistry(capMap); + process.stdout.write(serializeRegistry(registry, capMap) + '\n'); + } +} + +// ─── Exports (for tests) ────────────────────────────────────────────────────── + +module.exports = { + validateCapability, + validateAgainstContract, + validateConsumesGlobal, + validateCrossCapability, + classifyCrossErrors, + loadAndValidate, + buildRegistry, + serializeRegistry, + computeRequiresClosure, + topoSortSteps, + normalizeLineEndings, + LOOP_HOST_CONTRACT, + VALID_LOOP_POINTS, + POINT_ORDER, + POINT_TO_CONTRACT, + HOST_ARTIFACT_EARLIEST_POINT_IDX, + SCHEMA_VERSION, +}; + +// ─── CLI entry point ────────────────────────────────────────────────────────── + +if (require.main === module) { + runMain(main); +} diff --git a/tests/capability-registry.test.cjs b/tests/capability-registry.test.cjs new file mode 100644 index 000000000..7c2994d5f --- /dev/null +++ b/tests/capability-registry.test.cjs @@ -0,0 +1,1242 @@ +'use strict'; + +/** + * capability-registry.test.cjs — behavioral tests for the capability registry generator. + * + * ADR-894 phase 3a-impl. + * Uses node:test + node:assert/strict. + * Tests use in-memory fixtures (not real files) for adversarial cases. + * The UI pilot test loads from the real capabilities/ui/ directory. + */ + +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 { spawnSync } = require('node:child_process'); + +const { + validateCapability, + validateAgainstContract, + validateConsumesGlobal, + validateCrossCapability, + classifyCrossErrors, + loadAndValidate, + buildRegistry, + serializeRegistry, + computeRequiresClosure, + topoSortSteps, + normalizeLineEndings, + SCHEMA_VERSION, +} = require('../scripts/gen-capability-registry.cjs'); + +const ROOT = path.resolve(__dirname, '..'); + +// ─── UI pilot fixture (from capabilities/ui/capability.json) ───────────────── + +const UI_CAP_PATH = path.join(ROOT, 'capabilities', 'ui', 'capability.json'); +const UI_CAP = JSON.parse(fs.readFileSync(UI_CAP_PATH, 'utf8')); + +// ─── Helper: write temporary capability dir ─────────────────────────────────── + +function makeTempCapDir(capabilities) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-test-')); + for (const [id, cap] of Object.entries(capabilities)) { + const subDir = path.join(tmpDir, id); + fs.mkdirSync(subDir, { recursive: true }); + fs.writeFileSync(path.join(subDir, 'capability.json'), JSON.stringify(cap), 'utf8'); + } + return tmpDir; +} + +// ─── 1. Valid UI pilot ──────────────────────────────────────────────────────── + +describe('UI pilot capability', () => { + test('UI capability.json passes per-file validation', () => { + const errors = validateCapability(UI_CAP, 'ui'); + assert.deepEqual(errors, [], 'Expected no validation errors: ' + JSON.stringify(errors)); + }); + + test('UI capability passes contract validation', () => { + const errors = validateAgainstContract(UI_CAP, 'ui'); + assert.deepEqual(errors, [], 'Expected no contract errors: ' + JSON.stringify(errors)); + }); + + test('UI pilot generates a registry with correct shape', () => { + // Pass empty central keys so the pre-migration config keys do not cause collision errors + const capDir = makeTempCapDir({ ui: UI_CAP }); + const { capMap, errors } = loadAndValidate(new Set(), capDir); + assert.deepEqual(errors, [], 'Expected no errors: ' + JSON.stringify(errors)); + + const registry = buildRegistry(capMap); + + // capabilities.ui exists + assert.ok(registry.capabilities.ui, 'registry.capabilities.ui should exist'); + assert.strictEqual(registry.version, SCHEMA_VERSION); + + // bySkill maps ui-phase and ui-review to 'ui' + assert.strictEqual(registry.bySkill['ui-phase'], 'ui'); + assert.strictEqual(registry.bySkill['ui-review'], 'ui'); + + // byAgent maps gsd-ui-checker and gsd-ui-auditor to 'ui' + assert.strictEqual(registry.byAgent['gsd-ui-checker'], 'ui'); + assert.strictEqual(registry.byAgent['gsd-ui-auditor'], 'ui'); + + // byLoopPoint['plan:pre'].steps contains the ui-phase step + const planPreSteps = registry.byLoopPoint['plan:pre'].steps; + assert.ok(Array.isArray(planPreSteps), 'plan:pre.steps should be an array'); + const uiPhaseStep = planPreSteps.find((s) => s.ref && s.ref.skill === 'ui-phase'); + assert.ok(uiPhaseStep, 'plan:pre.steps should contain the ui-phase step'); + assert.strictEqual(uiPhaseStep.capId, 'ui'); + + // byLoopPoint['execute:wave:post'].gates contains the UI safety gate + const execWavePostGates = registry.byLoopPoint['execute:wave:post'].gates; + assert.ok(Array.isArray(execWavePostGates), 'execute:wave:post.gates should be an array'); + const uiGate = execWavePostGates.find( + (g) => g.check && g.check.query === 'ui.safety-gate', + ); + assert.ok(uiGate, 'execute:wave:post.gates should contain the ui safety gate'); + assert.strictEqual(uiGate.capId, 'ui'); + assert.strictEqual(uiGate.blocking, true); + + // configKeys maps the 3 UI keys to 'ui' + assert.strictEqual(registry.configKeys['workflow.ui_phase'], 'ui'); + assert.strictEqual(registry.configKeys['workflow.ui_review'], 'ui'); + assert.strictEqual(registry.configKeys['workflow.ui_safety_gate'], 'ui'); + }); + + test('requiresClosure("ui") returns empty set (no requires)', () => { + const capMap = new Map([['ui', UI_CAP]]); + const closure = computeRequiresClosure('ui', capMap); + assert.deepEqual([...closure], []); + }); +}); + +// ─── 2. Adversarial invalid declarations ───────────────────────────────────── + +describe('validateCapability adversarial cases', () => { + test('missing id rejected', () => { + const cap = { ...UI_CAP }; + delete cap.id; + const errors = validateCapability(cap, 'ui'); + assert.ok(errors.length > 0, 'Expected errors for missing id'); + assert.ok( + errors.some((e) => e.includes('id')), + 'Error should mention id, got: ' + JSON.stringify(errors), + ); + }); + + test('id not equal to folder name rejected', () => { + const cap = { ...UI_CAP, id: 'not-ui' }; + const errors = validateCapability(cap, 'ui'); + assert.ok(errors.length > 0); + assert.ok(errors.some((e) => e.includes('folder'))); + }); + + test('bad role rejected', () => { + const cap = { ...UI_CAP, role: 'plugin' }; + const errors = validateCapability(cap, 'ui'); + assert.ok(errors.length > 0); + assert.ok(errors.some((e) => e.includes('role'))); + }); + + test('bad tier enum rejected', () => { + const cap = { ...UI_CAP, tier: 'premium' }; + const errors = validateCapability(cap, 'ui'); + assert.ok(errors.length > 0); + assert.ok(errors.some((e) => e.includes('tier'))); + }); + + test('step with invalid point rejected', () => { + const cap = { + ...UI_CAP, + steps: [ + { ...UI_CAP.steps[0], point: 'notapoint:pre' }, + ], + }; + const errors = validateCapability(cap, 'ui'); + assert.ok(errors.length > 0); + assert.ok(errors.some((e) => e.includes('notapoint:pre'))); + }); + + test('gate with agentVerdict and blocking:true rejected', () => { + const cap = { + ...UI_CAP, + gates: [ + { + point: 'execute:wave:post', + check: { agentVerdict: { ref: 'gsd-ui-checker', prompt: 'check' } }, + blocking: true, + onError: 'halt', + }, + ], + }; + const errors = validateCapability(cap, 'ui'); + assert.ok(errors.length > 0); + assert.ok( + errors.some((e) => e.includes('agentVerdict') && e.includes('blocking')), + 'Expected error about agentVerdict forcing blocking:false, got: ' + JSON.stringify(errors), + ); + }); +}); + +describe('validateAgainstContract adversarial cases', () => { + test('contribution.into not in step agentRoles rejected', () => { + const cap = { + ...UI_CAP, + contributions: [ + { + point: 'plan:pre', + into: 'notarole', + fragment: { inline: 'test' }, + when: 'workflow.ui_phase', + onError: 'skip', + }, + ], + }; + const errors = validateAgainstContract(cap, 'ui'); + assert.ok(errors.length > 0); + assert.ok(errors.some((e) => e.includes('notarole'))); + }); +}); + +describe('validateCrossCapability adversarial cases', () => { + test('duplicate skill ownership across two capabilities rejected', () => { + const cap1 = { ...UI_CAP }; + const cap2 = { + ...UI_CAP, + id: 'ui2', + skills: ['ui-phase'], // duplicate + agents: ['gsd-other-agent'], + config: {}, + }; + const capMap = new Map([['ui', cap1], ['ui2', cap2]]); + const errors = validateCrossCapability(capMap, new Set()); + assert.ok(errors.length > 0); + assert.ok(errors.some((e) => e.includes('ui-phase'))); + }); + + test('requires referencing nonexistent id rejected', () => { + const cap = { ...UI_CAP, requires: ['nonexistent-cap'] }; + const capMap = new Map([['ui', cap]]); + const errors = validateCrossCapability(capMap, new Set()); + assert.ok(errors.length > 0); + assert.ok(errors.some((e) => e.includes('nonexistent-cap'))); + }); + + test('requires cycle rejected', () => { + const capA = { ...UI_CAP, id: 'cap-a', tier: 'standard', requires: ['cap-b'] }; + const capB = { + id: 'cap-b', role: 'feature', title: 'B', tier: 'standard', requires: ['cap-a'], + skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [], + }; + const capMap = new Map([['cap-a', capA], ['cap-b', capB]]); + const errors = validateCrossCapability(capMap, new Set()); + assert.ok(errors.length > 0); + assert.ok(errors.some((e) => e.includes('cycle'))); + }); + + test('tier-monotone violation: core requires full rejected', () => { + const coreCap = { + id: 'core-cap', role: 'feature', title: 'Core', tier: 'core', requires: ['full-cap'], + skills: ['core-skill'], agents: ['gsd-core-agent'], hooks: [], config: {}, + steps: [], contributions: [], gates: [], + }; + const fullCap = { + id: 'full-cap', role: 'feature', title: 'Full', tier: 'full', requires: [], + skills: ['full-skill'], agents: ['gsd-full-agent'], hooks: [], config: {}, + steps: [], contributions: [], gates: [], + }; + const capMap = new Map([['core-cap', coreCap], ['full-cap', fullCap]]); + const errors = validateCrossCapability(capMap, new Set()); + assert.ok(errors.length > 0); + assert.ok(errors.some((e) => e.includes('tier-monotone'))); + }); + + test('config key colliding with central config-schema rejected', () => { + const cap = { ...UI_CAP }; + const centralKeys = new Set(['workflow.ui_phase']); // simulate key present in both + const capMap = new Map([['ui', cap]]); + const errors = validateCrossCapability(capMap, centralKeys); + assert.ok(errors.length > 0); + assert.ok( + errors.some((e) => e.includes('workflow.ui_phase') && e.includes('central config-schema')), + 'Expected central config-schema collision error, got: ' + JSON.stringify(errors), + ); + }); + + test('config key owned by two capabilities rejected', () => { + const cap1 = { ...UI_CAP }; + const cap2 = { + id: 'ui2', role: 'feature', title: 'UI2', tier: 'standard', requires: [], + skills: ['other-skill'], agents: ['gsd-other-agent'], hooks: [], + config: { 'workflow.ui_phase': { type: 'boolean', default: true, description: 'dup' } }, + steps: [], contributions: [], gates: [], + }; + const capMap = new Map([['ui', cap1], ['ui2', cap2]]); + const errors = validateCrossCapability(capMap, new Set()); + assert.ok(errors.length > 0); + assert.ok(errors.some((e) => e.includes('workflow.ui_phase'))); + }); +}); + +// ─── 3. Materialized ordering ───────────────────────────────────────────────── + +describe('topological step ordering', () => { + test('two steps at one point with produces/consumes dependency order correctly', () => { + // Step B consumes what step A produces → A must come before B + const stepA = { + capId: 'cap-a', + step: { point: 'plan:pre', ref: { skill: 'a-skill' }, produces: ['A-OUTPUT.md'], consumes: [], when: undefined, onError: 'skip' }, + }; + const stepB = { + capId: 'cap-b', + step: { point: 'plan:pre', ref: { skill: 'b-skill' }, produces: ['B-OUTPUT.md'], consumes: ['A-OUTPUT.md'], when: undefined, onError: 'skip' }, + }; + + // Pass in reverse order to verify sort happens + const sorted = topoSortSteps([stepB, stepA]); + assert.strictEqual(sorted[0].capId, 'cap-a', 'cap-a (producer) should come first'); + assert.strictEqual(sorted[1].capId, 'cap-b', 'cap-b (consumer) should come second'); + }); + + test('steps with no dependency order by capId tiebreak', () => { + const stepZ = { + capId: 'z-cap', + step: { point: 'plan:pre', ref: { skill: 'z' }, produces: ['Z.md'], consumes: [], when: undefined, onError: 'skip' }, + }; + const stepA = { + capId: 'a-cap', + step: { point: 'plan:pre', ref: { skill: 'a' }, produces: ['A.md'], consumes: [], when: undefined, onError: 'skip' }, + }; + const sorted = topoSortSteps([stepZ, stepA]); + assert.strictEqual(sorted[0].capId, 'a-cap', 'a-cap should come first (alphabetical tiebreak)'); + assert.strictEqual(sorted[1].capId, 'z-cap'); + }); +}); + +// ─── 4. --check drift detection ────────────────────────────────────────────── + +describe('--check drift detection', () => { + test('returns drift when on-disk registry differs from live', () => { + // Build a registry from the real UI cap + const capDir = makeTempCapDir({ ui: UI_CAP }); + const { capMap } = loadAndValidate(new Set(), capDir); + const registry = buildRegistry(capMap); + const liveContent = serializeRegistry(registry, capMap); + + // Modify it slightly to simulate drift — replace the version string constant at top level + const driftedContent = liveContent.replace( + "version: '" + SCHEMA_VERSION + "'", + "version: '0-stale'", + ); + + // Confirm the replacement actually changed something + assert.notStrictEqual(driftedContent, liveContent, 'driftedContent should differ from liveContent after replacement'); + + // Write to a temp file + const tmpFile = path.join(os.tmpdir(), 'cap-registry-drift-test.cjs'); + fs.writeFileSync(tmpFile, driftedContent, 'utf8'); + + // Compare: live vs drifted (simulating what --check does) + const committed = fs.readFileSync(tmpFile, 'utf8'); + assert.notStrictEqual(committed, liveContent, 'Drifted content should differ from live'); + + // Cleanup + fs.unlinkSync(tmpFile); + }); + + test('no drift when registry is freshly generated', () => { + const capDir = makeTempCapDir({ ui: UI_CAP }); + const { capMap } = loadAndValidate(new Set(), capDir); + const registry = buildRegistry(capMap); + const content1 = serializeRegistry(registry, capMap); + const content2 = serializeRegistry(registry, capMap); + assert.strictEqual(content1, content2, 'Two calls to serializeRegistry should be identical'); + }); +}); + +// ─── 4b. normalizeLineEndings — Windows CRLF regression guard ──────────────── + +describe('normalizeLineEndings', () => { + test('strips \\r so LF and CRLF content compare as equal', () => { + const lf = 'line1\nline2\nline3\n'; + const crlf = 'line1\r\nline2\r\nline3\r\n'; + assert.strictEqual( + normalizeLineEndings(lf), + normalizeLineEndings(crlf), + 'LF and CRLF variants should normalize to the same string', + ); + }); + + test('standalone \\r (old Mac line endings) is also stripped', () => { + const cr = 'line1\rline2\r'; + const lf = 'line1\nline2\n'; + assert.notStrictEqual(normalizeLineEndings(cr), normalizeLineEndings(lf), + 'standalone CR collapses differently from LF — only \\r is stripped, not newlines added'); + // The key property: \\r is gone + assert.ok(!normalizeLineEndings(cr).includes('\r'), 'result must not contain \\r'); + }); + + test('real registry content: CRLF variant compares equal to LF variant after normalization', () => { + const capDir = makeTempCapDir({ ui: UI_CAP }); + const { capMap } = loadAndValidate(new Set(), capDir); + const registry = buildRegistry(capMap); + const lfContent = serializeRegistry(registry, capMap); + + // Simulate Windows git checkout by converting LF -> CRLF + const crlfContent = lfContent.replace(/\n/g, '\r\n'); + + assert.notStrictEqual(lfContent, crlfContent, 'CRLF and LF versions are byte-different'); + assert.strictEqual( + normalizeLineEndings(lfContent), + normalizeLineEndings(crlfContent), + '--check must treat CRLF-checked-out registry as up to date (Windows autocrlf regression guard)', + ); + }); +}); + +describe('committed gsd-core/bin/lib/capability-registry.cjs is not stale', () => { + test('gen-capability-registry.cjs --check exits 0 (committed registry is up to date)', () => { + const result = spawnSync( + process.execPath, + [require('node:path').join(ROOT, 'scripts', 'gen-capability-registry.cjs'), '--check'], + { cwd: ROOT, encoding: 'utf8' }, + ); + assert.strictEqual( + result.status, + 0, + 'gen-capability-registry.cjs --check failed — committed capability-registry.cjs is stale.\n' + + 'Run: node scripts/gen-capability-registry.cjs --write\n' + + 'stderr: ' + (result.stderr || ''), + ); + }); +}); + +// ─── 5. Registry shape from multiple capabilities ──────────────────────────── + +describe('registry structure', () => { + test('byLoopPoint contains all 12 valid points', () => { + const capDir = makeTempCapDir({ ui: UI_CAP }); + const { capMap } = loadAndValidate(new Set(), capDir); + const registry = buildRegistry(capMap); + + const expectedPoints = [ + 'discuss:pre', 'discuss:post', + 'plan:pre', 'plan:post', + 'execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post', + 'verify:pre', 'verify:post', + 'ship:pre', 'ship:post', + ]; + for (const point of expectedPoints) { + assert.ok( + Object.prototype.hasOwnProperty.call(registry.byLoopPoint, point), + 'byLoopPoint should contain point: ' + point, + ); + } + }); + + test('requiresClosure works for a cap with transitive requires', () => { + const capA = { + id: 'cap-a', role: 'feature', title: 'A', tier: 'standard', requires: ['cap-b'], + skills: ['a-skill'], agents: ['gsd-a-agent'], hooks: [], config: {}, + steps: [], contributions: [], gates: [], + }; + const capB = { + id: 'cap-b', role: 'feature', title: 'B', tier: 'standard', requires: ['cap-c'], + skills: ['b-skill'], agents: ['gsd-b-agent'], hooks: [], config: {}, + steps: [], contributions: [], gates: [], + }; + const capC = { + id: 'cap-c', role: 'feature', title: 'C', tier: 'standard', requires: [], + skills: ['c-skill'], agents: ['gsd-c-agent'], hooks: [], config: {}, + steps: [], contributions: [], gates: [], + }; + const capMap = new Map([['cap-a', capA], ['cap-b', capB], ['cap-c', capC]]); + const closure = computeRequiresClosure('cap-a', capMap); + assert.ok(closure.has('cap-b'), 'closure should include cap-b'); + assert.ok(closure.has('cap-c'), 'closure should include cap-c (transitive)'); + assert.strictEqual(closure.size, 2); + }); +}); + +// ─── 6. Fix regression guards ──────────────────────────────────────────────── + +describe('Fix #1: consumes-satisfiability is point-order-aware', () => { + test('plan:pre step consuming UAT.md (produced only at verify:post) is rejected', () => { + // UAT.md is produced by the host at verify:post (C1: :post availability rule). + // A plan:pre step consuming it must fail — the host hasn't produced it yet at that point. + const cap = { + ...UI_CAP, + steps: [ + { + point: 'plan:pre', + ref: { skill: 'ui-phase' }, + produces: [], + consumes: ['UAT.md'], // UAT.md not available until verify:post + when: 'workflow.ui_phase', + onError: 'skip', + }, + ], + }; + // C2: consumes validation is now global + const capMap = new Map([['ui', cap]]); + const errors = validateConsumesGlobal(capMap); + assert.ok(errors.length > 0, 'Expected a satisfiability error for early consumption of UAT.md'); + assert.ok( + errors.some((e) => e.includes('UAT.md')), + 'Error should mention UAT.md, got: ' + JSON.stringify(errors), + ); + assert.ok( + errors.some((e) => e.includes('plan:pre')), + 'Error should mention plan:pre, got: ' + JSON.stringify(errors), + ); + }); + + test('verify:post step consuming UAT.md (produced at verify:post by host) is accepted', () => { + // C1: UAT.md becomes available from verify:post onward (produced by the verify host step). + // verify:post index (9) <= verify:post index (9) → accepted. + const cap = { + ...UI_CAP, + steps: [ + { + point: 'verify:post', + ref: { skill: 'ui-review' }, + produces: ['UI-REVIEW.md'], + consumes: ['UAT.md'], + when: 'workflow.ui_review', + onError: 'skip', + }, + ], + }; + // C2: consumes validation is now global + const capMap = new Map([['ui', cap]]); + const errors = validateConsumesGlobal(capMap); + // Should have zero satisfiability errors for UAT.md at verify:post + const satErrors = errors.filter((e) => e.includes('UAT.md')); + assert.deepEqual(satErrors, [], 'Expected no satisfiability errors for UAT.md at verify:post, got: ' + JSON.stringify(satErrors)); + }); + + // C1 regression: PLAN.md is produced at plan:post, NOT plan:pre + test('plan:pre step consuming PLAN.md is rejected (PLAN.md only available from plan:post)', () => { + const cap = { + ...UI_CAP, + steps: [ + { + point: 'plan:pre', + ref: { skill: 'ui-phase' }, + produces: [], + consumes: ['PLAN.md'], // PLAN.md produced at plan:post, not available at plan:pre + when: 'workflow.ui_phase', + onError: 'skip', + }, + ], + }; + const capMap = new Map([['ui', cap]]); + const errors = validateConsumesGlobal(capMap); + assert.ok(errors.length > 0, 'Expected rejection: PLAN.md not available at plan:pre'); + assert.ok(errors.some((e) => e.includes('PLAN.md')), 'Error should mention PLAN.md'); + }); + + // C1: execute:pre consuming PLAN.md → PLAN.md available at plan:post (index 3), execute:pre is index 4 → accepted + test('execute:pre step consuming PLAN.md (produced at plan:post) is accepted', () => { + const cap = { + ...UI_CAP, + steps: [ + { + point: 'execute:pre', + ref: { skill: 'ui-phase' }, + produces: [], + consumes: ['PLAN.md'], // PLAN.md available from plan:post onward + when: 'workflow.ui_phase', + onError: 'skip', + }, + ], + }; + const capMap = new Map([['ui', cap]]); + const errors = validateConsumesGlobal(capMap); + const satErrors = errors.filter((e) => e.includes('PLAN.md')); + assert.deepEqual(satErrors, [], 'Expected PLAN.md to be available at execute:pre, got: ' + JSON.stringify(satErrors)); + }); +}); + +describe('Fix #2: topoSortSteps errors on a produces/consumes cycle', () => { + test('two-step cycle at the same point throws an error', () => { + // Step A produces X and consumes Y; step B produces Y and consumes X — mutual dependency + const stepA = { + capId: 'cap-a', + step: { + point: 'plan:pre', + ref: { skill: 'a-skill' }, + produces: ['X.md'], + consumes: ['Y.md'], + onError: 'skip', + }, + }; + const stepB = { + capId: 'cap-b', + step: { + point: 'plan:pre', + ref: { skill: 'b-skill' }, + produces: ['Y.md'], + consumes: ['X.md'], + onError: 'skip', + }, + }; + assert.throws( + () => topoSortSteps([stepA, stepB]), + (err) => { + assert.ok(err instanceof Error, 'Should throw an Error'); + assert.ok( + err.message.includes('cycle') || err.message.includes('cycle'), + 'Error message should mention cycle, got: ' + err.message, + ); + return true; + }, + ); + }); +}); + +describe('Fix #3: config-collision emits pending-migration warning, not hard error', () => { + test('validateCrossCapability still detects and reports the collision', () => { + // The underlying collision detection must still fire (regression guard for existing test) + const cap = { ...UI_CAP }; + const centralKeys = new Set(['workflow.ui_phase']); + const capMap = new Map([['ui', cap]]); + const errors = validateCrossCapability(capMap, centralKeys); + assert.ok(errors.length > 0, 'Expected collision errors from validateCrossCapability'); + assert.ok( + errors.some((e) => e.includes('workflow.ui_phase') && e.includes('central config-schema')), + 'Expected central config-schema collision error, got: ' + JSON.stringify(errors), + ); + }); + + test('classifyCrossErrors separates collision errors into pending-migration warnings', () => { + const cap = { ...UI_CAP }; + const centralKeys = new Set(['workflow.ui_phase', 'workflow.ui_review', 'workflow.ui_safety_gate']); + const capMap = new Map([['ui', cap]]); + const allErrors = validateCrossCapability(capMap, centralKeys); + const { hardErrors, pendingMigrationWarnings } = classifyCrossErrors(allErrors); + // All three collision errors should become warnings, not hard errors + assert.strictEqual( + hardErrors.length, 0, + 'No hard errors expected for collision-only cross errors, got: ' + JSON.stringify(hardErrors), + ); + assert.ok( + pendingMigrationWarnings.length >= 1, + 'Expected at least one pending-migration warning', + ); + assert.ok( + pendingMigrationWarnings.some((w) => w.includes('pending-migration') && w.includes('workflow.ui_phase')), + 'Warning should mention pending-migration and workflow.ui_phase, got: ' + JSON.stringify(pendingMigrationWarnings), + ); + }); +}); + +describe('Fix #4: step.ref must be exclusive skill XOR agent', () => { + test('step.ref with both skill and agent is rejected', () => { + const cap = { + ...UI_CAP, + steps: [ + { + point: 'plan:pre', + ref: { skill: 'ui-phase', agent: 'gsd-ui-checker' }, // BOTH keys — invalid + produces: ['UI-SPEC.md'], + consumes: ['CONTEXT.md'], + when: 'workflow.ui_phase', + onError: 'skip', + }, + ], + }; + const errors = validateCapability(cap, 'ui'); + assert.ok(errors.length > 0, 'Expected errors for step.ref with both skill and agent'); + assert.ok( + errors.some((e) => e.includes('exactly one') || e.includes('not both') || e.includes('skill') && e.includes('agent')), + 'Error should mention exclusive skill/agent constraint, got: ' + JSON.stringify(errors), + ); + }); + + test('step.ref with only skill is accepted', () => { + const cap = { + ...UI_CAP, + steps: [ + { + point: 'plan:pre', + ref: { skill: 'ui-phase' }, + produces: ['UI-SPEC.md'], + consumes: ['CONTEXT.md'], + when: 'workflow.ui_phase', + onError: 'skip', + }, + ], + }; + const refErrors = validateCapability(cap, 'ui').filter((e) => e.includes('ref')); + assert.deepEqual(refErrors, [], 'No ref errors expected for skill-only ref, got: ' + JSON.stringify(refErrors)); + }); + + test('step.ref with only agent is accepted', () => { + const cap = { + ...UI_CAP, + steps: [ + { + point: 'plan:pre', + ref: { agent: 'gsd-ui-checker' }, + produces: ['UI-SPEC.md'], + consumes: ['CONTEXT.md'], + when: 'workflow.ui_phase', + onError: 'skip', + }, + ], + }; + const refErrors = validateCapability(cap, 'ui').filter((e) => e.includes('ref')); + assert.deepEqual(refErrors, [], 'No ref errors expected for agent-only ref, got: ' + JSON.stringify(refErrors)); + }); +}); + +describe('Fix: 3-node requires cycle (A→B→C→A) is detected', () => { + test('three-node requires cycle is reported as an error', () => { + const capA = { + id: 'cyc-a', role: 'feature', title: 'CycA', tier: 'standard', requires: ['cyc-b'], + skills: ['cyc-a-skill'], agents: ['gsd-cyc-a'], hooks: [], config: {}, + steps: [], contributions: [], gates: [], + }; + const capB = { + id: 'cyc-b', role: 'feature', title: 'CycB', tier: 'standard', requires: ['cyc-c'], + skills: ['cyc-b-skill'], agents: ['gsd-cyc-b'], hooks: [], config: {}, + steps: [], contributions: [], gates: [], + }; + const capC = { + id: 'cyc-c', role: 'feature', title: 'CycC', tier: 'standard', requires: ['cyc-a'], + skills: ['cyc-c-skill'], agents: ['gsd-cyc-c'], hooks: [], config: {}, + steps: [], contributions: [], gates: [], + }; + const capMap = new Map([['cyc-a', capA], ['cyc-b', capB], ['cyc-c', capC]]); + const errors = validateCrossCapability(capMap, new Set()); + assert.ok(errors.length > 0, 'Expected cycle errors for A→B→C→A'); + assert.ok( + errors.some((e) => e.toLowerCase().includes('cycle')), + 'Error should mention cycle, got: ' + JSON.stringify(errors), + ); + }); +}); + +describe('Fix: agentVerdict gate with blocking:false is accepted', () => { + test('agentVerdict gate with blocking:false generates zero errors', () => { + // Complement of the existing blocking:true rejection test + const cap = { + ...UI_CAP, + gates: [ + { + point: 'execute:wave:post', + check: { agentVerdict: { ref: 'gsd-ui-checker', prompt: 'check ui' } }, + blocking: false, // advisory — valid + onError: 'skip', + }, + ], + }; + const errors = validateCapability(cap, 'ui'); + const gateErrors = errors.filter((e) => e.includes('agentVerdict')); + assert.deepEqual( + gateErrors, [], + 'Expected no agentVerdict errors for blocking:false, got: ' + JSON.stringify(gateErrors), + ); + }); +}); + +// ─── 7. Security: fragment.path traversal (S1) ─────────────────────────────── + +describe('S1: fragment.path traversal guard', () => { + const makeCapWithContribPath = (fragPath) => ({ + ...UI_CAP, + contributions: [ + { + point: 'plan:pre', + into: 'planner', + fragment: { path: fragPath }, + when: 'workflow.ui_phase', + onError: 'skip', + }, + ], + }); + + test('fragment.path with ".." segments is rejected', () => { + const errors = validateCapability(makeCapWithContribPath('../../etc/passwd'), 'ui'); + assert.ok(errors.length > 0, 'Expected rejection for path traversal'); + assert.ok( + errors.some((e) => e.includes('fragment.path') && e.includes('..')), + 'Error should mention fragment.path traversal, got: ' + JSON.stringify(errors), + ); + }); + + test('absolute fragment.path is rejected', () => { + const errors = validateCapability(makeCapWithContribPath('/etc/passwd'), 'ui'); + assert.ok(errors.length > 0, 'Expected rejection for absolute path'); + assert.ok( + errors.some((e) => e.includes('fragment.path')), + 'Error should mention fragment.path, got: ' + JSON.stringify(errors), + ); + }); + + test('clean relative fragment.path is accepted', () => { + const errors = validateCapability(makeCapWithContribPath('loop/threat-model.md'), 'ui'); + const pathErrors = errors.filter((e) => e.includes('fragment.path')); + assert.deepEqual(pathErrors, [], 'Expected no path errors for clean relative path, got: ' + JSON.stringify(pathErrors)); + }); + + test('empty fragment.path string is rejected', () => { + const errors = validateCapability(makeCapWithContribPath(''), 'ui'); + assert.ok(errors.length > 0, 'Expected rejection for empty path'); + assert.ok(errors.some((e) => e.includes('fragment.path'))); + }); +}); + +// ─── 8. Security: prototype pollution (S2) ──────────────────────────────────── + +describe('S2: prototype pollution guards', () => { + test('skill named "__proto__" is rejected', () => { + const cap = { ...UI_CAP, skills: ['__proto__'] }; + const errors = validateCapability(cap, 'ui'); + assert.ok(errors.length > 0, 'Expected rejection for __proto__ skill'); + assert.ok( + errors.some((e) => e.includes('__proto__') && e.includes('reserved')), + 'Error should mention reserved name, got: ' + JSON.stringify(errors), + ); + }); + + test('skill named "constructor" is rejected', () => { + const cap = { ...UI_CAP, skills: ['constructor'] }; + const errors = validateCapability(cap, 'ui'); + assert.ok(errors.length > 0); + assert.ok(errors.some((e) => e.includes('constructor') && e.includes('reserved'))); + }); + + test('agent named "__proto__" is rejected', () => { + const cap = { ...UI_CAP, agents: ['__proto__'] }; + const errors = validateCapability(cap, 'ui'); + assert.ok(errors.length > 0); + assert.ok(errors.some((e) => e.includes('__proto__') && e.includes('reserved'))); + }); + + test('config key named "prototype" is rejected', () => { + const configWithReserved = { + ...UI_CAP.config, + 'prototype': { type: 'boolean', default: false, description: 'bad key' }, + }; + const cap = { ...UI_CAP, config: configWithReserved }; + const errors = validateCapability(cap, 'ui'); + assert.ok(errors.length > 0); + assert.ok(errors.some((e) => e.includes('prototype') && e.includes('reserved'))); + }); + + test('building registry with prototype-polluting names does not pollute Object.prototype', () => { + // Even if somehow a reserved name got through, buildRegistry must not pollute. + // We test this by checking that Object.prototype is clean after a normal build. + const capDir = makeTempCapDir({ ui: UI_CAP }); + const { capMap } = loadAndValidate(new Set(), capDir); + buildRegistry(capMap); + // After registry build, Object.prototype must not have been polluted. + assert.strictEqual(({}).polluted, undefined, 'Object.prototype should not be polluted'); + assert.strictEqual(({}).ui, undefined, 'Object.prototype.ui should not exist'); + }); +}); + +// ─── 9. C2: Cross-capability consumes satisfiability ───────────────────────── + +describe('C2: cross-capability consumes satisfiability (global pass)', () => { + test('cap B step consuming artifact produced by cap A at earlier point is accepted', () => { + const capA = { + id: 'cap-a', role: 'feature', title: 'A', description: 'A', tier: 'standard', requires: [], + skills: ['a-skill'], agents: ['gsd-a-agent'], hooks: [], config: {}, + steps: [ + { + point: 'plan:pre', + ref: { skill: 'a-skill' }, + produces: ['A-OUTPUT.md'], + consumes: ['CONTEXT.md'], + onError: 'skip', + }, + ], + contributions: [], gates: [], + }; + const capB = { + id: 'cap-b', role: 'feature', title: 'B', description: 'B', tier: 'standard', requires: [], + skills: ['b-skill'], agents: ['gsd-b-agent'], hooks: [], config: {}, + steps: [ + { + point: 'execute:pre', // after plan:pre — A-OUTPUT.md is available + ref: { skill: 'b-skill' }, + produces: [], + consumes: ['A-OUTPUT.md'], + onError: 'skip', + }, + ], + contributions: [], gates: [], + }; + const capMap = new Map([['cap-a', capA], ['cap-b', capB]]); + const errors = validateConsumesGlobal(capMap); + const aOutputErrors = errors.filter((e) => e.includes('A-OUTPUT.md')); + assert.deepEqual(aOutputErrors, [], 'Cap B consuming A-OUTPUT at execute:pre should be accepted, got: ' + JSON.stringify(aOutputErrors)); + }); + + test('consuming an artifact that is never produced is rejected', () => { + const cap = { + id: 'cap-a', role: 'feature', title: 'A', description: 'A', tier: 'standard', requires: [], + skills: ['a-skill'], agents: ['gsd-a-agent'], hooks: [], config: {}, + steps: [ + { + point: 'plan:pre', + ref: { skill: 'a-skill' }, + produces: [], + consumes: ['NONEXISTENT-ARTIFACT.md'], + onError: 'skip', + }, + ], + contributions: [], gates: [], + }; + const capMap = new Map([['cap-a', cap]]); + const errors = validateConsumesGlobal(capMap); + assert.ok(errors.length > 0, 'Expected rejection: NONEXISTENT-ARTIFACT.md is never produced'); + assert.ok(errors.some((e) => e.includes('NONEXISTENT-ARTIFACT.md'))); + assert.ok(errors.some((e) => e.includes('never produced'))); + }); + + test('same-point consumer of cross-cap artifact is accepted (topo handles intra-point order)', () => { + // Cap B at plan:pre consumes A-OUTPUT.md produced by cap A also at plan:pre. + // Same-point is OK — topoSortSteps will ensure A runs before B. + const capA = { + id: 'cap-a', role: 'feature', title: 'A', description: 'A', tier: 'standard', requires: [], + skills: ['a-skill'], agents: ['gsd-a-agent'], hooks: [], config: {}, + steps: [ + { + point: 'plan:pre', + ref: { skill: 'a-skill' }, + produces: ['A-PLAN-OUTPUT.md'], + consumes: [], + onError: 'skip', + }, + ], + contributions: [], gates: [], + }; + const capB = { + id: 'cap-b', role: 'feature', title: 'B', description: 'B', tier: 'standard', requires: [], + skills: ['b-skill'], agents: ['gsd-b-agent'], hooks: [], config: {}, + steps: [ + { + point: 'plan:pre', // same point — OK for global check; topo handles ordering + ref: { skill: 'b-skill' }, + produces: [], + consumes: ['A-PLAN-OUTPUT.md'], + onError: 'skip', + }, + ], + contributions: [], gates: [], + }; + const capMap = new Map([['cap-a', capA], ['cap-b', capB]]); + const errors = validateConsumesGlobal(capMap); + const outputErrors = errors.filter((e) => e.includes('A-PLAN-OUTPUT.md')); + assert.deepEqual(outputErrors, [], 'Same-point cross-cap consume should be accepted by global check, got: ' + JSON.stringify(outputErrors)); + }); +}); + +// ─── 10. C3: role:runtime validation ───────────────────────────────────────── + +describe('C3: role:runtime body validation', () => { + const VALID_RUNTIME_CAP = { + id: 'cursor', role: 'runtime', title: 'Cursor', description: 'Cursor IDE runtime', + tier: 'standard', requires: [], + runtime: { + configHome: '~/.cursor', + configFormat: 'settings-json', + artifactLayout: [], + commandStyle: 'slash', + hooksSurface: 'rules', + sandboxTier: 'none', + supportTier: 2, + }, + }; + + test('valid runtime descriptor passes validation', () => { + const errors = validateCapability(VALID_RUNTIME_CAP, 'cursor'); + assert.deepEqual(errors, [], 'Expected no validation errors for valid runtime cap, got: ' + JSON.stringify(errors)); + }); + + test('runtime cap with skills present is rejected', () => { + const cap = { ...VALID_RUNTIME_CAP, skills: ['some-skill'] }; + const errors = validateCapability(cap, 'cursor'); + assert.ok(errors.length > 0); + assert.ok(errors.some((e) => e.includes('skills') && e.includes('feature-only'))); + }); + + test('runtime cap with steps present is rejected', () => { + const cap = { ...VALID_RUNTIME_CAP, steps: [] }; + const errors = validateCapability(cap, 'cursor'); + assert.ok(errors.length > 0); + assert.ok(errors.some((e) => e.includes('steps') && e.includes('feature-only'))); + }); + + test('runtime cap with contributions present is rejected', () => { + const cap = { ...VALID_RUNTIME_CAP, contributions: [] }; + const errors = validateCapability(cap, 'cursor'); + assert.ok(errors.length > 0); + assert.ok(errors.some((e) => e.includes('contributions') && e.includes('feature-only'))); + }); + + test('runtime cap missing the runtime object is rejected', () => { + const { runtime: _r, ...capWithoutRuntime } = VALID_RUNTIME_CAP; + const errors = validateCapability(capWithoutRuntime, 'cursor'); + assert.ok(errors.length > 0); + assert.ok(errors.some((e) => e.includes('runtime') && e.includes('object'))); + }); + + test('runtime cap with invalid configFormat is rejected', () => { + const cap = { ...VALID_RUNTIME_CAP, runtime: { ...VALID_RUNTIME_CAP.runtime, configFormat: 'xml' } }; + const errors = validateCapability(cap, 'cursor'); + assert.ok(errors.length > 0); + assert.ok(errors.some((e) => e.includes('configFormat'))); + }); + + test('runtime cap with supportTier 3 is rejected', () => { + const cap = { ...VALID_RUNTIME_CAP, runtime: { ...VALID_RUNTIME_CAP.runtime, supportTier: 3 } }; + const errors = validateCapability(cap, 'cursor'); + assert.ok(errors.length > 0); + assert.ok(errors.some((e) => e.includes('supportTier'))); + }); + + test('runtime cap with supportTier 1 is accepted', () => { + const cap = { ...VALID_RUNTIME_CAP, runtime: { ...VALID_RUNTIME_CAP.runtime, supportTier: 1 } }; + const errors = validateCapability(cap, 'cursor'); + assert.deepEqual(errors, [], 'Expected no errors for supportTier:1, got: ' + JSON.stringify(errors)); + }); +}); + +// ─── 11. C4: description and hooks validation ───────────────────────────────── + +describe('C4: description and hooks validation', () => { + test('missing description is rejected', () => { + const { description: _d, ...capWithoutDesc } = UI_CAP; + const errors = validateCapability(capWithoutDesc, 'ui'); + assert.ok(errors.length > 0, 'Expected rejection for missing description'); + assert.ok(errors.some((e) => e.includes('description'))); + }); + + test('hooks = 42 (non-array) is rejected', () => { + const cap = { ...UI_CAP, hooks: 42 }; + const errors = validateCapability(cap, 'ui'); + assert.ok(errors.length > 0, 'Expected rejection for hooks = 42'); + assert.ok(errors.some((e) => e.includes('hooks') && e.includes('array'))); + }); + + test('hooks with malformed entry (missing event) is rejected', () => { + const cap = { ...UI_CAP, hooks: [{ script: 'some.sh' }] }; + const errors = validateCapability(cap, 'ui'); + assert.ok(errors.length > 0, 'Expected rejection for hook missing event'); + assert.ok(errors.some((e) => e.includes('hooks[0].event'))); + }); + + test('hooks with malformed entry (missing script) is rejected', () => { + const cap = { ...UI_CAP, hooks: [{ event: 'FileChanged' }] }; + const errors = validateCapability(cap, 'ui'); + assert.ok(errors.length > 0, 'Expected rejection for hook missing script'); + assert.ok(errors.some((e) => e.includes('hooks[0].script'))); + }); + + test('valid hooks array with well-formed entry is accepted', () => { + const cap = { ...UI_CAP, hooks: [{ event: 'FileChanged', script: 'hooks/file-changed.sh' }] }; + const errors = validateCapability(cap, 'ui'); + const hookErrors = errors.filter((e) => e.includes('hooks[')); + assert.deepEqual(hookErrors, [], 'Expected no hook errors for valid hooks entry, got: ' + JSON.stringify(hookErrors)); + }); + + test('description present in UI_CAP passes validation', () => { + const errors = validateCapability(UI_CAP, 'ui'); + const descErrors = errors.filter((e) => e.includes('description')); + assert.deepEqual(descErrors, [], 'UI_CAP should have valid description, got: ' + JSON.stringify(descErrors)); + }); +}); + +// ─── 12. C5: config value shape validation ──────────────────────────────────── + +describe('C5: config value shape validation', () => { + test('config value that is null is rejected', () => { + const config = { ...UI_CAP.config, 'workflow.ui_null_test': null }; + const cap = { ...UI_CAP, config }; + const errors = validateCapability(cap, 'ui'); + assert.ok(errors.length > 0, 'Expected rejection for null config value'); + assert.ok( + errors.some((e) => e.includes('workflow.ui_null_test') && e.includes('null')), + 'Error should mention the key and null, got: ' + JSON.stringify(errors), + ); + }); + + test('config value that is a string scalar is rejected', () => { + const config = { ...UI_CAP.config, 'workflow.ui_bad': 'just-a-string' }; + const cap = { ...UI_CAP, config }; + const errors = validateCapability(cap, 'ui'); + assert.ok(errors.length > 0, 'Expected rejection for scalar string config value'); + assert.ok(errors.some((e) => e.includes('workflow.ui_bad') && e.includes('object'))); + }); + + test('config value that is a number is rejected', () => { + const config = { ...UI_CAP.config, 'workflow.ui_num': 42 }; + const cap = { ...UI_CAP, config }; + const errors = validateCapability(cap, 'ui'); + assert.ok(errors.length > 0); + assert.ok(errors.some((e) => e.includes('workflow.ui_num') && e.includes('object'))); + }); + + test('config value that is a proper object is accepted', () => { + // UI_CAP config values are all valid objects — validate it + const errors = validateCapability(UI_CAP, 'ui'); + const configErrors = errors.filter((e) => e.includes('config[')); + assert.deepEqual(configErrors, [], 'UI_CAP config values should all be valid objects, got: ' + JSON.stringify(configErrors)); + }); + + test('config value {} (empty object, missing type) is rejected', () => { + const config = { ...UI_CAP.config, 'workflow.ui_no_type': {} }; + const cap = { ...UI_CAP, config }; + const errors = validateCapability(cap, 'ui'); + assert.ok(errors.length > 0, 'Expected rejection for config value with no type field'); + assert.ok( + errors.some((e) => e.includes('workflow.ui_no_type') && e.includes('type')), + 'Error should mention the key and "type", got: ' + JSON.stringify(errors), + ); + }); + + test('config value { type: "boolean", default: true } is accepted', () => { + const config = { ...UI_CAP.config, 'workflow.ui_good': { type: 'boolean', default: true } }; + const cap = { ...UI_CAP, config }; + const errors = validateCapability(cap, 'ui'); + const configErrors = errors.filter((e) => e.includes('workflow.ui_good')); + assert.deepEqual(configErrors, [], 'config value with type:"boolean" and default should be accepted, got: ' + JSON.stringify(configErrors)); + }); + + test('UI pilot config values all have type:"boolean" and pass FIX 2 validation', () => { + // Regression guard: UI_CAP config keys (workflow.ui_phase etc.) all have type:"boolean" + const errors = validateCapability(UI_CAP, 'ui'); + const configErrors = errors.filter((e) => e.includes('config[')); + assert.deepEqual( + configErrors, [], + 'UI pilot config values should all pass type-field validation, got: ' + JSON.stringify(configErrors), + ); + // Directly confirm each key has type:"boolean" + for (const [key, val] of Object.entries(UI_CAP.config)) { + assert.strictEqual(typeof val.type, 'string', 'config["' + key + '"].type should be a string'); + assert.strictEqual(val.type, 'boolean', 'config["' + key + '"].type should be "boolean"'); + } + }); +}); + +// ─── 13. FIX 1: self-consume rejection ─────────────────────────────────────── + +describe('FIX 1: self-consume rejection in validateConsumesGlobal', () => { + test('a step produces:["SELF.md"] and consumes:["SELF.md"] with no other producer is rejected', () => { + const cap = { + id: 'self-cap', role: 'feature', title: 'Self', description: 'Self consume test', + tier: 'standard', requires: [], + skills: ['self-skill'], agents: ['gsd-self-agent'], hooks: [], config: {}, + steps: [ + { + point: 'plan:pre', + ref: { skill: 'self-skill' }, + produces: ['SELF.md'], + consumes: ['SELF.md'], + onError: 'skip', + }, + ], + contributions: [], gates: [], + }; + const capMap = new Map([['self-cap', cap]]); + const errors = validateConsumesGlobal(capMap); + assert.ok(errors.length > 0, 'Expected rejection: step cannot consume its own output'); + assert.ok( + errors.some((e) => e.includes('SELF.md')), + 'Error should mention SELF.md, got: ' + JSON.stringify(errors), + ); + assert.ok( + errors.some((e) => e.includes('self') || e.includes('itself') || e.includes('own output')), + 'Error should indicate self-consume violation, got: ' + JSON.stringify(errors), + ); + }); + + test('a step produces:["SELF.md"] and consumes:["SELF.md"] but another capability produces SELF.md at an earlier point is accepted', () => { + const producerCap = { + id: 'producer-cap', role: 'feature', title: 'Producer', description: 'Produces SELF.md', + tier: 'standard', requires: [], + skills: ['producer-skill'], agents: ['gsd-producer-agent'], hooks: [], config: {}, + steps: [ + { + point: 'plan:pre', // same point, but different cap — satisfies self-cap's consume + ref: { skill: 'producer-skill' }, + produces: ['SELF.md'], + consumes: [], + onError: 'skip', + }, + ], + contributions: [], gates: [], + }; + const selfCap = { + id: 'self-cap', role: 'feature', title: 'Self', description: 'Self consume test', + tier: 'standard', requires: [], + skills: ['self-skill'], agents: ['gsd-self-agent'], hooks: [], config: {}, + steps: [ + { + point: 'execute:pre', // later point than plan:pre — producer-cap satisfies it + ref: { skill: 'self-skill' }, + produces: ['SELF.md'], + consumes: ['SELF.md'], + onError: 'skip', + }, + ], + contributions: [], gates: [], + }; + const capMap = new Map([['producer-cap', producerCap], ['self-cap', selfCap]]); + const errors = validateConsumesGlobal(capMap); + const selfErrors = errors.filter((e) => e.includes('SELF.md') && e.includes('self-cap')); + assert.deepEqual( + selfErrors, [], + 'Expected self-cap consume of SELF.md to be accepted when producer-cap produces it at an earlier point, got: ' + JSON.stringify(selfErrors), + ); + }); + + test('a step produces:["SELF.md"] and consumes:["SELF.md"] and another capability produces SELF.md at the SAME point is accepted (different hook)', () => { + const producerCap = { + id: 'producer-cap', role: 'feature', title: 'Producer', description: 'Produces SELF.md', + tier: 'standard', requires: [], + skills: ['producer-skill'], agents: ['gsd-producer-agent'], hooks: [], config: {}, + steps: [ + { + point: 'plan:pre', // same point as self-cap + ref: { skill: 'producer-skill' }, + produces: ['SELF.md'], + consumes: [], + onError: 'skip', + }, + ], + contributions: [], gates: [], + }; + const selfCap = { + id: 'self-cap', role: 'feature', title: 'Self', description: 'Self consume test', + tier: 'standard', requires: [], + skills: ['self-skill'], agents: ['gsd-self-agent'], hooks: [], config: {}, + steps: [ + { + point: 'plan:pre', // same point — different hook (producer-cap) satisfies it + ref: { skill: 'self-skill' }, + produces: ['SELF.md'], + consumes: ['SELF.md'], + onError: 'skip', + }, + ], + contributions: [], gates: [], + }; + const capMap = new Map([['producer-cap', producerCap], ['self-cap', selfCap]]); + const errors = validateConsumesGlobal(capMap); + const selfErrors = errors.filter((e) => e.includes('SELF.md') && e.includes('self-cap')); + assert.deepEqual( + selfErrors, [], + 'Expected self-cap consume of SELF.md to be accepted when a DIFFERENT cap produces it at the same point, got: ' + JSON.stringify(selfErrors), + ); + }); +});