'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 { cleanup } = require('./helpers.cjs'); const { validateCapability, validateAgainstContract, validateConsumesGlobal, validateCrossCapability, classifyCrossErrors, loadAndValidate, buildRegistry, serializeRegistry, computeRequiresClosure, topoSortSteps, normalizeLineEndings, stripGeneratedComment, validateConfigSliceEntry, VALID_CONFIG_SLICE_TYPES, SCHEMA_VERSION, // ADR-857 phase 4a deriveCapabilityClusters, deriveProfileMembership, runConsistencyGate, PROFILE_RANK, // ADR-959 validateCommandEntry, // ADR-857 phase 5e VALID_CONVERTER_NAMES, validateArtifactKindEntry, runConfigFormatParityGate, INSTALL_SURFACE_TO_CONFIG_FORMAT, // ADR-857 phase 5f: cross-field consistency gates INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES, VALID_INSTALL_SURFACES, VALID_EXTENDED_HOOK_EVENTS, VALID_PERMISSION_WRITERS, validateRuntimeCompat, validateRuntimeBody, loadCentralConfigKeys, validateHooksWired, POINT_ORDER, } = require('../scripts/gen-capability-registry.cjs'); const { STEP_WORKFLOWS, HOST_LOOP_FILES, scanWiredPoints, getWiredLoopPoints, CANONICAL_POINTS, } = require('../scripts/gen-loop-host-contract.cjs'); const { LOOP_HOST_CONTRACT } = require('../gsd-core/bin/lib/loop-host-contract.cjs'); // ADR-1244 D2: the validator was extracted to a shared runtime-callable module. // The generator must re-export it verbatim — the parity suite below proves no drift. const capValidatorModule = require('../gsd-core/bin/lib/capability-validator.cjs'); const generatorModule = require('../scripts/gen-capability-registry.cjs'); const fc = require('fast-check'); const ROOT = path.resolve(__dirname, '..'); // ─── 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); assert.deepStrictEqual( registry.capabilities.ui.runtimeCompat, { supported: ['*'], unsupported: [] }, 'feature runtime compatibility contract should be preserved in the registry', ); // 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['plan:pre'].gates contains the new ui.plan-gate (#1026) const planPreGates = registry.byLoopPoint['plan:pre'].gates; assert.ok(Array.isArray(planPreGates), 'plan:pre.gates should be an array'); const uiPlanGate = planPreGates.find( (g) => g.check && g.check.query === 'ui.plan-gate', ); assert.ok(uiPlanGate, 'plan:pre.gates should contain the ui.plan-gate (#1026)'); assert.strictEqual(uiPlanGate.capId, 'ui'); assert.strictEqual(uiPlanGate.blocking, true); assert.strictEqual(uiPlanGate.when, 'workflow.ui_safety_gate'); assert.strictEqual(uiPlanGate.onError, 'halt'); // 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' (ownership map — preserved) 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'); // configSchema index — new in phase 3b assert.ok(registry.configSchema, 'registry.configSchema should exist'); // workflow.ui_phase assert.ok(registry.configSchema['workflow.ui_phase'], 'configSchema should have workflow.ui_phase'); assert.strictEqual(registry.configSchema['workflow.ui_phase'].owner, 'ui'); assert.strictEqual(registry.configSchema['workflow.ui_phase'].type, 'boolean'); assert.strictEqual(registry.configSchema['workflow.ui_phase'].default, true); assert.strictEqual(typeof registry.configSchema['workflow.ui_phase'].description, 'string'); assert.ok(registry.configSchema['workflow.ui_phase'].description.length > 0); // workflow.ui_review assert.ok(registry.configSchema['workflow.ui_review'], 'configSchema should have workflow.ui_review'); assert.strictEqual(registry.configSchema['workflow.ui_review'].owner, 'ui'); assert.strictEqual(registry.configSchema['workflow.ui_review'].type, 'boolean'); assert.strictEqual(registry.configSchema['workflow.ui_review'].default, true); // workflow.ui_safety_gate assert.ok(registry.configSchema['workflow.ui_safety_gate'], 'configSchema should have workflow.ui_safety_gate'); assert.strictEqual(registry.configSchema['workflow.ui_safety_gate'].owner, 'ui'); assert.strictEqual(registry.configSchema['workflow.ui_safety_gate'].type, 'boolean'); assert.strictEqual(registry.configSchema['workflow.ui_safety_gate'].default, true); }); 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('feature capability without runtimeCompat is rejected', () => { const cap = { ...UI_CAP }; delete cap.runtimeCompat; const errors = validateCapability(cap, 'ui'); assert.ok( errors.some((e) => e.includes('runtimeCompat')), 'Expected missing runtimeCompat validation error, got: ' + JSON.stringify(errors), ); }); test('runtimeCompat.supported must be a non-empty array', () => { const errors = validateRuntimeCompat('ui', { supported: [], unsupported: [] }); assert.ok( errors.some((e) => e.includes('runtimeCompat.supported')), 'Expected supported-array validation error, got: ' + JSON.stringify(errors), ); }); test('runtimeCompat.supported wildcard cannot be mixed with runtime ids', () => { const errors = validateRuntimeCompat('ui', { supported: ['*', 'claude'], unsupported: [] }); assert.ok( errors.some((e) => e.includes('wildcard')), 'Expected wildcard validation error, got: ' + JSON.stringify(errors), ); }); 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), ); }); test('#1634: a valid tool-scoping matcher is accepted on a lifecycle hook', () => { const cap = { ...UI_CAP, hooks: [{ event: 'PreToolUse', script: 'hooks/genfile-guard.cjs', matcher: 'Write|Edit' }], }; const errors = validateCapability(cap, 'ui'); assert.ok( !errors.some((e) => e.includes('matcher')), 'A valid matcher must not produce a matcher error, got: ' + JSON.stringify(errors), ); }); test('#1634: an absent matcher is accepted (match-all)', () => { const cap = { ...UI_CAP, hooks: [{ event: 'PreToolUse', script: 'hooks/g.js' }] }; const errors = validateCapability(cap, 'ui'); assert.ok( !errors.some((e) => e.includes('matcher')), 'An absent matcher must not error, got: ' + JSON.stringify(errors), ); }); test('#1634: an empty-string matcher is rejected', () => { const cap = { ...UI_CAP, hooks: [{ event: 'PreToolUse', script: 'hooks/g.js', matcher: '' }] }; const errors = validateCapability(cap, 'ui'); assert.ok( errors.some((e) => e.includes('matcher') && e.includes('non-empty')), 'Expected a non-empty matcher error, got: ' + JSON.stringify(errors), ); }); test('#1634: a non-string matcher is rejected', () => { const cap = { ...UI_CAP, hooks: [{ event: 'PreToolUse', script: 'hooks/g.js', matcher: 42 }] }; const errors = validateCapability(cap, 'ui'); assert.ok( errors.some((e) => e.includes('matcher')), 'Expected a matcher type error, got: ' + JSON.stringify(errors), ); }); test('#1634: a matcher containing control characters is rejected', () => { const cap = { ...UI_CAP, hooks: [{ event: 'PreToolUse', script: 'hooks/g.js', matcher: 'Write\n|Edit' }], }; const errors = validateCapability(cap, 'ui'); assert.ok( errors.some((e) => e.includes('matcher') && e.includes('control')), 'Expected a control-character matcher error, 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('runtimeCompat explicit runtime ids must exist', () => { const cap = { ...UI_CAP, runtimeCompat: { supported: ['claude', 'future-runtime'], unsupported: [] }, }; const runtime = { id: 'claude', role: 'runtime', title: 'Claude', description: 'Runtime fixture.', tier: 'core', requires: [], runtime: { configHome: { kind: 'dot-home', name: '.claude', env: ['CLAUDE_CONFIG_DIR'] }, configFormat: 'settings-json', artifactLayout: { global: [], local: [] }, commandStyle: 'slash-hyphen', hooksSurface: 'settings-json', sandboxTier: 'none', supportTier: 1, installSurface: 'settings-json', writesSharedSettings: true, permissionWriter: null, extendedHookEvents: [], }, }; const capMap = new Map([['ui', cap], ['claude', runtime]]); const errors = validateCrossCapability(capMap, new Set()); assert.ok( errors.some((e) => e.includes('runtimeCompat.supported') && e.includes('future-runtime')), 'Expected unknown runtimeCompat runtime error, got: ' + JSON.stringify(errors), ); }); 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'); }); test('contributions at one point use produces/consumes dependency order', () => { const capMap = new Map([ ['a-consumer', { id: 'a-consumer', role: 'feature', title: 'Consumer', tier: 'full', requires: [], runtimeCompat: { supported: ['*'], unsupported: [] }, skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [{ point: 'plan:pre', into: 'planner', fragment: { inline: 'Consume produced planning note.' }, produces: [], consumes: ['PLAN-NOTE.md'], onError: 'skip', }], gates: [], }], ['b-producer', { id: 'b-producer', role: 'feature', title: 'Producer', tier: 'full', requires: [], skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [{ point: 'plan:pre', into: 'planner', fragment: { inline: 'Produce planning note.' }, produces: ['PLAN-NOTE.md'], consumes: [], onError: 'skip', }], gates: [], }], ]); const registry = buildRegistry(capMap); assert.deepEqual( registry.byLoopPoint['plan:pre'].contributions.map((c) => c.capId), ['b-producer', 'a-consumer'], ); }); test('contribution produces/consumes cycle throws a clear error', () => { const capMap = new Map([ ['cap-a', { id: 'cap-a', role: 'feature', title: 'A', tier: 'full', requires: [], skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [{ point: 'plan:pre', into: 'planner', fragment: { inline: 'A.' }, produces: ['A.md'], consumes: ['B.md'], onError: 'skip', }], gates: [], }], ['cap-b', { id: 'cap-b', role: 'feature', title: 'B', tier: 'full', requires: [], skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [{ point: 'plan:pre', into: 'planner', fragment: { inline: 'B.' }, produces: ['B.md'], consumes: ['A.md'], onError: 'skip', }], gates: [], }], ]); assert.throws( () => buildRegistry(capMap), (err) => { assert.ok(err instanceof Error); assert.match(err.message, /contributions/); assert.match(err.message, /cycle/); return true; }, ); }); }); // ─── 4. --check drift detection ────────────────────────────────────────────── // // The real --check pipeline (gen-capability-registry.cjs main()) compares // committed vs live via: // // normalizeLineEndings(stripGeneratedComment(committed)) // !== normalizeLineEndings(stripGeneratedComment(live)) // // stripGeneratedComment is private (not exported), so these tests replicate the // same filter inline and call the exported normalizeLineEndings to exercise the // ACTUAL comparison semantics rather than doing a bare string-equality tautology. // // The subprocess tests additionally prove that the real --check CLI exits 1 on a // tampered registry and exits 0 when only the auto-generated timestamp comment // changes (comment immunity). const REGISTRY_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'capability-registry.cjs'); /** * Mirror of the private stripGeneratedComment() from gen-capability-registry.cjs. * Kept here intentionally: the test validates BEHAVIOR, and the implementation is * stable (a single-line filter). If the source changes the sentinel string, this * test will correctly start failing — that is the desired red signal. * * NOTE (#1191): The equivalence test below pins the exported stripGeneratedComment * to this mirror. If the export's sentinel ever drifts from the mirror's sentinel, * the equivalence test goes RED — making the implicit "sentinel drift = red signal" * comment above into an explicit automated check. */ function applyStripGeneratedComment(content) { return content .split('\n') .filter((line) => !line.includes('generated by scripts/gen-capability-registry.cjs')) .join('\n'); } /** Apply the full --check comparison pipeline to a single content string. */ function checkPipeline(content) { return normalizeLineEndings(applyStripGeneratedComment(content)); } // ─── Seam-3 equivalence test (#1191) ───────────────────────────────────────── // // Verifies that the now-exported stripGeneratedComment from gen-capability-registry.cjs // matches the local oracle (applyStripGeneratedComment) on a representative sample. // This turns the oracle's implicit "fails if sentinel drifts" into an explicit guard. describe('exported stripGeneratedComment matches the test oracle (no sentinel drift)', () => { test('exported stripGeneratedComment matches the test oracle (no sentinel drift)', () => { const sample = [ '// generated by scripts/gen-capability-registry.cjs — DO NOT EDIT', "'use strict';", '// normal comment (not a generated-by line)', "module.exports = { version: '1' };", '// generated by scripts/gen-capability-registry.cjs (second occurrence)', ].join('\n'); assert.strictEqual( stripGeneratedComment(sample), applyStripGeneratedComment(sample), 'exported stripGeneratedComment must produce identical output to the test oracle — ' + 'a sentinel mismatch means the export and the oracle have drifted' ); }); }); describe('--check drift detection', () => { test('stale VERSION survives stripGeneratedComment+normalizeLineEndings and IS detected as drift', () => { // Build a fresh registry from the real UI cap — this is the "live" content const capDir = makeTempCapDir({ ui: UI_CAP }); const { capMap } = loadAndValidate(new Set(), capDir); const registry = buildRegistry(capMap); const liveContent = serializeRegistry(registry, capMap); // Tamper: replace the schema version field — this simulates a stale committed file // (version: '1' → version: '0-stale') const staledContent = liveContent.replace( "version: '" + SCHEMA_VERSION + "'", "version: '0-stale'", ); assert.notStrictEqual(staledContent, liveContent, 'precondition: tampered content differs before pipeline'); // The tampered version must SURVIVE both pipeline steps and still differ from live. // This is what actually matters: a raw string diff is trivial; the test must show // the comparison survives stripping + normalization — i.e. it IS real drift. assert.notStrictEqual( checkPipeline(staledContent), checkPipeline(liveContent), 'stale VERSION must be detected as drift after stripGeneratedComment + normalizeLineEndings', ); }); test('comment-only timestamp change is NOT flagged as drift after stripping', () => { // Build a fresh registry const capDir = makeTempCapDir({ ui: UI_CAP }); const { capMap } = loadAndValidate(new Set(), capDir); const registry = buildRegistry(capMap); const liveContent = serializeRegistry(registry, capMap); // Confirm the generated comment is present in the serialized output assert.ok( liveContent.includes('generated by scripts/gen-capability-registry.cjs'), 'precondition: generated comment must be present in serialized output', ); // Simulate a Windows git checkout that adds a fake timestamp annotation on the // generated-comment line — the kind of comment-only mutation that must NOT trigger drift const commentVariant = liveContent.replace( ' * capability-registry.cjs — generated by scripts/gen-capability-registry.cjs', ' * capability-registry.cjs — generated by scripts/gen-capability-registry.cjs on 2024-01-01T00:00:00Z', ); assert.notStrictEqual(commentVariant, liveContent, 'precondition: variant differs before stripping'); // After stripping the generated-comment line, both must be identical — NOT flagged as drift assert.strictEqual( checkPipeline(commentVariant), checkPipeline(liveContent), 'comment-only change must NOT be detected as drift (stripGeneratedComment must neutralize it)', ); }); test('no drift when registry is freshly generated', () => { // Determinism check: two calls to serializeRegistry must produce identical output 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'); }); test('--check comparison pipeline detects a tampered VERSION (in-memory, no file mutation)', () => { // Prove that the --check comparison pipeline (the same pipeline used by // gen-capability-registry.cjs main()) exits 1 on a stale committed registry. // // NOTE: We intentionally do NOT write to the committed REGISTRY_PATH here. // Writing to a committed file during a test is unsafe: it races with concurrent // test runners that require() the same module and leaves the worktree dirty on // SIGKILL. Instead, we exercise the comparison logic using the same exported // helpers the CLI uses, applied to in-memory strings — giving identical coverage // without touching the filesystem. const originalContent = fs.readFileSync(REGISTRY_PATH, 'utf8'); const tamperedContent = originalContent.replace( "version: '" + SCHEMA_VERSION + "'", "version: '0-stale'", ); assert.notStrictEqual(tamperedContent, originalContent, 'precondition: tamper must change the file'); // Build the "live" content the same way --check does. const capDir = makeTempCapDir({ ui: UI_CAP }); const { capMap } = loadAndValidate(new Set(), capDir); const registry = buildRegistry(capMap); const liveContent = serializeRegistry(registry, capMap); // The tampered committed content must NOT equal the live content after the // same stripGeneratedComment + normalizeLineEndings pipeline that --check uses. // If this assertion passes, --check would exit 1 (drift detected) and emit "stale". assert.notStrictEqual( checkPipeline(tamperedContent), checkPipeline(liveContent), '--check comparison pipeline must flag a tampered VERSION as drift.\n' + 'If this fails, the pipeline no longer detects stale VERSION strings.', ); // Also verify the tampered content contains the stale marker (so the above // assertion is meaningful and not vacuously true due to other diff). assert.ok( tamperedContent.includes("version: '0-stale'"), 'precondition: tampered content must contain the stale version marker', ); }); }); // ─── 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); }); }); describe('ADR-857 phase 6 planning feature capabilities', () => { const realRegistry = require('../gsd-core/bin/lib/capability-registry.cjs'); test('real registry declares research, ai-integration, and pattern-mapper capabilities', () => { for (const capId of ['research', 'ai-integration', 'pattern-mapper']) { assert.ok(realRegistry.capabilities[capId], `${capId} capability must be declared`); assert.strictEqual(realRegistry.capabilities[capId].role, 'feature'); } }); test('planning feature capabilities own their workflow config keys', () => { assert.strictEqual(realRegistry.configKeys['workflow.research'], 'research'); assert.strictEqual(realRegistry.configKeys['workflow.ai_integration_phase'], 'ai-integration'); assert.strictEqual(realRegistry.configKeys['workflow.pattern_mapper'], 'pattern-mapper'); }); test('planning feature capabilities register plan:pre hooks', () => { const hooks = [ ...realRegistry.byLoopPoint['plan:pre'].steps, ...realRegistry.byLoopPoint['plan:pre'].contributions, ...realRegistry.byLoopPoint['plan:pre'].gates, ]; for (const capId of ['research', 'ai-integration', 'pattern-mapper']) { assert.ok( hooks.some((hook) => hook.capId === capId), `${capId} must participate in plan:pre through the Capability Registry`, ); } }); }); // ─── 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('double-prefix guard: step.ref.skill must not start with "gsd-"', () => { // ref.skill is an unprefixed stem (e.g. "ui-review"). Workflow dispatch prepends // "gsd-" at runtime. A stem already starting with "gsd-" would produce "gsd-gsd-..." // at dispatch time, silently invoking a non-existent skill. test('ref.skill starting with "gsd-" is rejected', () => { const cap = { ...UI_CAP, steps: [ { point: 'verify:post', ref: { skill: 'gsd-ui-review' }, // wrong: stem must NOT have gsd- prefix produces: ['UI-REVIEW.md'], consumes: ['UI-SPEC.md'], when: 'workflow.ui_review', onError: 'skip', }, ], }; const errors = validateCapability(cap, 'ui'); assert.ok(errors.length > 0, 'Expected an error for gsd-prefixed ref.skill'); assert.ok( errors.some((e) => e.includes('gsd-') && (e.includes('double') || e.includes('unprefixed') || e.includes('must not start'))), 'Error should mention the double-prefix problem, got: ' + JSON.stringify(errors), ); }); test('ref.skill without "gsd-" prefix is accepted (stem only)', () => { const cap = { ...UI_CAP, steps: [ { point: 'verify:post', ref: { skill: 'ui-review' }, // correct: unprefixed stem produces: ['UI-REVIEW.md'], consumes: ['UI-SPEC.md'], when: 'workflow.ui_review', onError: 'skip', }, ], }; const prefixErrors = validateCapability(cap, 'ui').filter((e) => e.includes('gsd-') && e.includes('stem')); assert.deepEqual(prefixErrors, [], 'No prefix errors expected for unprefixed stem, got: ' + JSON.stringify(prefixErrors)); }); test('real UI capability.json uses unprefixed ref.skill values', () => { // Verify the live capability uses unprefixed stems and therefore passes the new guard. const errors = validateCapability(UI_CAP, 'ui'); const prefixErrors = errors.filter((e) => e.includes('must not start with')); assert.deepEqual(prefixErrors, [], 'Live UI capability.json should not trigger the double-prefix guard: ' + JSON.stringify(prefixErrors)); }); }); // ─── Fix: ref.skill/ref.agent membership in declared skills/agents ──────────── describe('ref membership check: step.ref.skill must be in cap.skills', () => { // A capability declares skills: ["ui-phase", "ui-review"]. // A step with ref.skill "typo-skill" (not in skills) must be rejected. test('step.ref.skill NOT in cap.skills is rejected', () => { const cap = { ...UI_CAP, steps: [ { point: 'plan:pre', ref: { skill: 'typo-skill' }, // not in skills: ["ui-phase", "ui-review"] 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 undeclared ref.skill'); assert.ok( errors.some((e) => e.includes('typo-skill') && e.includes('not declared')), 'Error should mention "typo-skill" and "not declared", got: ' + JSON.stringify(errors), ); }); test('step.ref.skill IN cap.skills is accepted', () => { const cap = { ...UI_CAP, steps: [ { point: 'plan:pre', ref: { skill: 'ui-phase' }, // declared in skills: ["ui-phase", "ui-review"] produces: ['UI-SPEC.md'], consumes: ['CONTEXT.md'], when: 'workflow.ui_phase', onError: 'skip', }, ], }; const errors = validateCapability(cap, 'ui'); const membershipErrors = errors.filter((e) => e.includes('not declared') && e.includes('ui-phase')); assert.deepEqual( membershipErrors, [], 'No membership errors expected for declared ref.skill, got: ' + JSON.stringify(membershipErrors), ); }); test('real UI capability passes: ui-phase and ui-review are both in skills', () => { // Regression guard: the real UI capability must not trigger the new membership check. const errors = validateCapability(UI_CAP, 'ui'); const membershipErrors = errors.filter((e) => e.includes('not declared')); assert.deepEqual( membershipErrors, [], 'Real UI capability should pass membership check for all ref.skill values, got: ' + JSON.stringify(membershipErrors), ); }); }); describe('ref membership check: step.ref.agent must be in cap.agents', () => { // A capability declares agents: ["gsd-ui-checker", "gsd-ui-auditor"]. // A step with ref.agent "gsd-unknown-agent" (not in agents) must be rejected. test('step.ref.agent NOT in cap.agents is rejected', () => { const cap = { ...UI_CAP, steps: [ { point: 'plan:pre', ref: { agent: 'gsd-unknown-agent' }, // not in agents: ["gsd-ui-checker", "gsd-ui-auditor"] 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 undeclared ref.agent'); assert.ok( errors.some((e) => e.includes('gsd-unknown-agent') && e.includes('not declared')), 'Error should mention "gsd-unknown-agent" and "not declared", got: ' + JSON.stringify(errors), ); }); test('step.ref.agent IN cap.agents is accepted', () => { const cap = { ...UI_CAP, steps: [ { point: 'plan:pre', ref: { agent: 'gsd-ui-checker' }, // declared in agents produces: ['UI-SPEC.md'], consumes: ['CONTEXT.md'], when: 'workflow.ui_phase', onError: 'skip', }, ], }; const errors = validateCapability(cap, 'ui'); const membershipErrors = errors.filter((e) => e.includes('not declared') && e.includes('gsd-ui-checker')); assert.deepEqual( membershipErrors, [], 'No membership errors expected for declared ref.agent, got: ' + JSON.stringify(membershipErrors), ); }); }); 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'))); }); test('array fragment shape is rejected', () => { const cap = { ...UI_CAP, contributions: [ { point: 'plan:pre', into: 'planner', fragment: [], when: 'workflow.ui_phase', onError: 'skip', }, ], }; const errors = validateCapability(cap, 'ui'); assert.ok(errors.some((e) => e.includes('fragment') && e.includes('object'))); }); test('non-string fragment.inline is rejected', () => { const cap = { ...UI_CAP, contributions: [ { point: 'plan:pre', into: 'planner', fragment: { inline: 42 }, when: 'workflow.ui_phase', onError: 'skip', }, ], }; const errors = validateCapability(cap, 'ui'); assert.ok(errors.some((e) => e.includes('fragment.inline') && e.includes('string'))); }); test('empty fragment.inline string is rejected', () => { const cap = { ...UI_CAP, contributions: [ { point: 'plan:pre', into: 'planner', fragment: { inline: '' }, when: 'workflow.ui_phase', onError: 'skip', }, ], }; const errors = validateCapability(cap, 'ui'); assert.ok(errors.some((e) => e.includes('fragment.inline') && e.includes('non-empty'))); }); test('non-array contribution produces is rejected', () => { const cap = { ...UI_CAP, contributions: [ { point: 'plan:pre', into: 'planner', fragment: { inline: 'Plan with UI context.' }, produces: 'PLAN-NOTE.md', consumes: [], when: 'workflow.ui_phase', onError: 'skip', }, ], }; const errors = validateCapability(cap, 'ui'); assert.ok(errors.some((e) => e.includes('produces') && e.includes('array'))); }); test('non-string contribution consumes entry is rejected', () => { const cap = { ...UI_CAP, contributions: [ { point: 'plan:pre', into: 'planner', fragment: { inline: 'Plan with UI context.' }, produces: [], consumes: [42], when: 'workflow.ui_phase', onError: 'skip', }, ], }; const errors = validateCapability(cap, 'ui'); assert.ok(errors.some((e) => e.includes('consumes entries') && e.includes('strings'))); }); test('fragment.path is materialized into inline registry content', (t) => { const capsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cap-fragment-')); t.after(() => cleanup(capsDir)); const capDir = path.join(capsDir, 'planning-advice'); fs.mkdirSync(path.join(capDir, 'fragments'), { recursive: true }); fs.writeFileSync( path.join(capDir, 'fragments', 'plan-pre.md'), 'Use the capability-owned planning fragment.\n', ); fs.writeFileSync( path.join(capDir, 'capability.json'), JSON.stringify({ id: 'planning-advice', role: 'feature', version: '1.0.0', title: 'Planning advice', description: 'Synthetic fixture for fragment path materialization.', tier: 'full', requires: [], runtimeCompat: { supported: ['*'], unsupported: [] }, skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [{ point: 'plan:pre', into: 'planner', fragment: { path: 'fragments/plan-pre.md' }, produces: [], consumes: ['CONTEXT.md'], onError: 'skip', }], gates: [], }), ); const { capMap, errors } = loadAndValidate(new Set(), capsDir); assert.deepEqual(errors, []); const registry = buildRegistry(capMap); assert.strictEqual( registry.byLoopPoint['plan:pre'].contributions[0].fragment.inline, 'Use the capability-owned planning fragment.\n', ); }); test('step fragment.path is materialized into inline registry content', (t) => { const capsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cap-step-fragment-')); t.after(() => cleanup(capsDir)); const capDir = path.join(capsDir, 'research'); fs.mkdirSync(path.join(capDir, 'fragments'), { recursive: true }); fs.writeFileSync( path.join(capDir, 'fragments', 'plan-pre.md'), 'Research prompt owned by the capability.\n', ); fs.writeFileSync( path.join(capDir, 'capability.json'), JSON.stringify({ id: 'research', role: 'feature', version: '1.0.0', title: 'Research', description: 'Synthetic fixture for step fragment materialization.', tier: 'standard', requires: [], runtimeCompat: { supported: ['*'], unsupported: [] }, skills: [], agents: ['gsd-phase-researcher'], hooks: [], config: {}, steps: [{ point: 'plan:pre', ref: { agent: 'gsd-phase-researcher' }, fragment: { path: 'fragments/plan-pre.md' }, produces: ['RESEARCH.md'], consumes: ['CONTEXT.md'], onError: 'skip', }], contributions: [], gates: [], }), ); const { capMap, errors } = loadAndValidate(new Set(), capsDir); assert.deepEqual(errors, []); const registry = buildRegistry(capMap); assert.strictEqual( registry.byLoopPoint['plan:pre'].steps[0].fragment.inline, 'Research prompt owned by the capability.\n', ); }); }); // ─── 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', () => { // ADR-1016 phase 5a: fixture updated to match tightened structured-value shapes. // configHome is now an object (Decision 1), artifactLayout is { global, local } (Decision 3), // commandStyle is closed enum (Decision 4), hooksSurface is closed enum (Decision 5). const VALID_RUNTIME_CAP = { id: 'cursor', role: 'runtime', version: '1.0.0', title: 'Cursor', description: 'Cursor IDE runtime', tier: 'standard', requires: [], runtime: { configHome: { kind: 'dot-home', name: '.cursor', env: ['CURSOR_CONFIG_DIR'] }, configFormat: 'settings-json', artifactLayout: { global: [], local: [] }, commandStyle: 'slash-hyphen', hooksSurface: 'cursor-hooks-json', hookEvents: 'claude', sandboxTier: 'none', supportTier: 2, installSurface: 'cursor-hooks-json', writesSharedSettings: false, permissionWriter: null, extendedHookEvents: [], hostIntegration: { embeddingMode: 'declarative', commandSurface: 'slash-file', dispatch: { namedDispatch: true, nested: true, maxDepth: 2, background: false, subagentToolkit: 'full', backgroundDispatch: false }, modelMode: 'passive', hookBus: 'host', stateIO: 'filesystem', transport: 'mcp', runtime: 'node', }, }, }; 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)); }); // ─── #1460 (R) HIGH: hook script path must be shell-safe ────────────────── // The hook `script` is resolved to an absolute path and written verbatim as the hook // `command` STRING in settings.json (consumed by a shell). A manifest-controlled script // name containing shell metacharacters (`;`, `|`, `$`, backtick, whitespace, …) would // inject a second command at hook-exec time. Fail closed at the validator: reject any // script path outside the conservative [A-Za-z0-9._/-] allowlist. revert-fails: without // the allowlist these all pass the non-empty-string check and validate OK. for (const [label, script] of [ ['command-injection via `;`', 'run.sh; touch /tmp/pwn'], ['embedded space', 'my hook.sh'], ['command substitution `$( )`', 'run-$(whoami).sh'], ['backtick substitution', 'run-`id`.sh'], ['pipe metacharacter', 'a.sh|b.sh'], ['newline injection', 'a.sh\ntouch /tmp/pwn'], ['ampersand background', 'a.sh & evil'], ['shell glob', 'hooks/*.sh'], ['redirect', 'a.sh > /tmp/pwn'], ['leading dash (option injection)', '-rf'], ['single quote', "a'.sh"], ['double quote', 'a".sh'], ['NUL/control char', 'a\u0000.sh'], ]) { test(`hook script with unsafe chars is rejected (${label})`, () => { const cap = { ...UI_CAP, hooks: [{ event: 'PostToolUse', script }] }; const errors = validateCapability(cap, 'ui'); const hookErrors = errors.filter((e) => e.includes('hooks[0].script')); assert.ok( hookErrors.length > 0, `Expected a hooks[0].script rejection for ${label} (script=${JSON.stringify(script)}), got: ` + JSON.stringify(errors), ); assert.ok( hookErrors.some((e) => /unsafe character/.test(e)), 'Error should mention unsafe characters, got: ' + JSON.stringify(hookErrors), ); }); } test('hook script with absolute path is rejected', () => { const cap = { ...UI_CAP, hooks: [{ event: 'PostToolUse', script: '/etc/evil.sh' }] }; const errors = validateCapability(cap, 'ui'); assert.ok(errors.some((e) => e.includes('hooks[0].script')), 'absolute script must be rejected: ' + JSON.stringify(errors)); }); test('hook script with .. traversal is rejected', () => { const cap = { ...UI_CAP, hooks: [{ event: 'PostToolUse', script: '../../etc/evil.sh' }] }; const errors = validateCapability(cap, 'ui'); assert.ok(errors.some((e) => e.includes('hooks[0].script')), '.. script must be rejected: ' + JSON.stringify(errors)); }); test('hook script with a normal nested relative path is still accepted', () => { const cap = { ...UI_CAP, hooks: [{ event: 'PostToolUse', script: 'hooks/sub-dir/format_v2.sh' }] }; const errors = validateCapability(cap, 'ui'); const hookErrors = errors.filter((e) => e.includes('hooks[0].script')); assert.deepEqual(hookErrors, [], 'Expected a normal nested relative script to be accepted, 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), ); }); // (this test follows the series above) // Updated fixture: producer-cap produces SELF.md at plan:pre; self-cap CONSUMES (not produces) // SELF.md at plan:pre — a single producer at that point satisfies the consume. // The prior fixture had BOTH caps producing SELF.md at plan:pre, which now violates the // duplicate-producer invariant added in Issue #1123. The consume-satisfiability logic being // tested here is unaffected — the key assertion remains: a step consuming an artifact that // a DIFFERENT cap produces at the SAME point is satisfied. test('a step consumes:["SELF.md"] and another capability produces SELF.md at the SAME point is accepted (different cap)', () => { 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', ref: { skill: 'producer-skill' }, produces: ['SELF.md'], consumes: [], onError: 'skip', }, ], contributions: [], gates: [], }; const consumerCap = { id: 'self-cap', role: 'feature', title: 'Self', description: 'Consume test', tier: 'standard', requires: [], skills: ['self-skill'], agents: ['gsd-self-agent'], hooks: [], config: {}, steps: [ { point: 'plan:pre', // same point — producer-cap (different cap) satisfies the consume ref: { skill: 'self-skill' }, produces: [], consumes: ['SELF.md'], onError: 'skip', }, ], contributions: [], gates: [], }; const capMap = new Map([['producer-cap', producerCap], ['self-cap', consumerCap]]); 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), ); }); }); // ─── 14. configSchema emission (ADR-857 phase 3b) ──────────────────────────── describe('configSchema emission (ADR-857 phase 3b)', () => { test('buildRegistry emits configSchema with correct shape for UI pilot', () => { const capDir = makeTempCapDir({ ui: UI_CAP }); const { capMap, errors } = loadAndValidate(new Set(), capDir); assert.deepEqual(errors, [], 'No errors expected'); const registry = buildRegistry(capMap); assert.ok(registry.configSchema, 'registry.configSchema must exist'); const uiPhase = registry.configSchema['workflow.ui_phase']; assert.ok(uiPhase, 'configSchema must have workflow.ui_phase'); assert.strictEqual(uiPhase.owner, 'ui'); assert.strictEqual(uiPhase.type, 'boolean'); assert.strictEqual(uiPhase.default, true); assert.ok(typeof uiPhase.description === 'string' && uiPhase.description.length > 0); const uiReview = registry.configSchema['workflow.ui_review']; assert.ok(uiReview, 'configSchema must have workflow.ui_review'); assert.strictEqual(uiReview.owner, 'ui'); assert.strictEqual(uiReview.type, 'boolean'); const uiSafetyGate = registry.configSchema['workflow.ui_safety_gate']; assert.ok(uiSafetyGate, 'configSchema must have workflow.ui_safety_gate'); assert.strictEqual(uiSafetyGate.type, 'boolean'); }); test('serializeRegistry emits a configSchema block in the generated .cjs', () => { const capDir = makeTempCapDir({ ui: UI_CAP }); const { capMap } = loadAndValidate(new Set(), capDir); const registry = buildRegistry(capMap); const content = serializeRegistry(registry, capMap); assert.ok(content.includes('const configSchema'), 'Generated file must contain "const configSchema"'); assert.ok(content.includes('"workflow.ui_phase"'), 'Generated file must contain "workflow.ui_phase"'); assert.ok(content.includes('"owner"'), 'Generated file must contain "owner" field'); assert.ok(content.includes('"type"'), 'Generated file must contain "type" field'); assert.ok(content.includes('"default"'), 'Generated file must contain "default" field'); assert.ok(content.includes('"description"'), 'Generated file must contain "description" field'); assert.ok(content.includes('configSchema,'), 'Generated module.exports must include configSchema'); }); test('committed capability-registry.cjs has configSchema with correct shape', () => { const registry = require('../gsd-core/bin/lib/capability-registry.cjs'); assert.ok(registry.configSchema, 'capability-registry.cjs must export configSchema'); const uiPhase = registry.configSchema['workflow.ui_phase']; assert.ok(uiPhase, 'committed registry configSchema must have workflow.ui_phase'); assert.strictEqual(uiPhase.owner, 'ui', 'owner must be "ui"'); assert.strictEqual(uiPhase.type, 'boolean', 'type must be "boolean"'); assert.strictEqual(uiPhase.default, true, 'default must be true'); assert.ok(typeof uiPhase.description === 'string' && uiPhase.description.length > 0); }); }); // ─── 15. validateConfigSliceEntry adversarial tests ─────────────────────────── describe('validateConfigSliceEntry adversarial cases (ADR-857 phase 3b)', () => { const CAP_ID = 'test-cap'; const KEY = 'test.key'; test('VALID_CONFIG_SLICE_TYPES exports expected types', () => { const types = [...VALID_CONFIG_SLICE_TYPES]; assert.ok(types.includes('boolean'), 'Must include boolean'); assert.ok(types.includes('string'), 'Must include string'); assert.ok(types.includes('number'), 'Must include number'); assert.ok(types.includes('enum'), 'Must include enum'); assert.strictEqual(types.length, 4, 'Must have exactly 4 types'); }); test('valid boolean slice passes validation', () => { const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'boolean', default: true, description: 'ok' }); assert.deepEqual(errors, [], 'Valid boolean slice should produce no errors, got: ' + JSON.stringify(errors)); }); test('valid string slice passes validation', () => { const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'string', default: 'x', description: 'ok' }); assert.deepEqual(errors, []); }); test('valid number slice passes validation', () => { const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'number', default: 5, description: 'ok' }); assert.deepEqual(errors, []); }); test('REJECTED: enum slice without values list → error (FIX 5a: values required)', () => { const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'enum', default: 'x', description: 'ok' }); assert.ok(errors.length > 0, 'Expected rejection for enum without values list, got: ' + JSON.stringify(errors)); assert.ok( errors.some((e) => e.includes('values') || e.includes('enum')), 'Error should mention values or enum, got: ' + JSON.stringify(errors), ); }); test('valid enum slice (with values list, default in values) passes', () => { const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'enum', default: 'b', values: ['a', 'b', 'c'], description: 'ok', }); assert.deepEqual(errors, []); }); test('REJECTED: bad type ("xml") → error mentioning type', () => { const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'xml', default: '', description: 'ok' }); assert.ok(errors.length > 0, 'Expected rejection for bad type'); assert.ok(errors.some((e) => e.includes('type')), 'Error should mention type, got: ' + JSON.stringify(errors)); }); test('REJECTED: missing type → error', () => { const errors = validateConfigSliceEntry(CAP_ID, KEY, { default: true, description: 'ok' }); assert.ok(errors.length > 0, 'Expected rejection for missing type'); assert.ok(errors.some((e) => e.includes('type'))); }); test('REJECTED: missing default → error mentioning default', () => { const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'boolean', description: 'ok' }); assert.ok(errors.length > 0, 'Expected rejection for missing default'); assert.ok(errors.some((e) => e.includes('default')), 'Error should mention default, got: ' + JSON.stringify(errors)); }); test('REJECTED: boolean type with string default → error', () => { const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'boolean', default: 'true', description: 'ok' }); assert.ok(errors.length > 0, 'Expected rejection for boolean type with string default'); assert.ok(errors.some((e) => e.includes('boolean') || e.includes('default'))); }); test('REJECTED: string type with boolean default → error', () => { const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'string', default: false, description: 'ok' }); assert.ok(errors.length > 0, 'Expected rejection for string type with boolean default'); }); test('REJECTED: number type with string default → error', () => { const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'number', default: 'five', description: 'ok' }); assert.ok(errors.length > 0, 'Expected rejection for number type with string default'); }); test('REJECTED: enum type with values list, default not in values → error', () => { const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'enum', default: 'z', values: ['a', 'b', 'c'], description: 'ok', }); assert.ok(errors.length > 0, 'Expected rejection for enum default not in values'); assert.ok(errors.some((e) => e.includes('enum') || e.includes('values') || e.includes('z'))); }); test('REJECTED: enum type with non-string default → error', () => { const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'enum', default: 42, description: 'ok' }); assert.ok(errors.length > 0, 'Expected rejection for enum with non-string default'); }); test('REJECTED: empty description string → error', () => { const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'boolean', default: true, description: '' }); assert.ok(errors.length > 0, 'Expected rejection for empty description'); assert.ok(errors.some((e) => e.includes('description'))); }); test('REJECTED: non-string description (number) → error', () => { const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'boolean', default: true, description: 42 }); assert.ok(errors.length > 0, 'Expected rejection for non-string description'); assert.ok(errors.some((e) => e.includes('description'))); }); test('REJECTED: missing description → error', () => { const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'boolean', default: true }); assert.ok(errors.length > 0, 'Expected rejection for missing description'); assert.ok(errors.some((e) => e.includes('description'))); }); test('REJECTED: null slice → error', () => { const errors = validateConfigSliceEntry(CAP_ID, KEY, null); assert.ok(errors.length > 0, 'Expected rejection for null slice'); }); test('REJECTED: array slice → error', () => { const errors = validateConfigSliceEntry(CAP_ID, KEY, []); assert.ok(errors.length > 0, 'Expected rejection for array slice'); }); // FIX 5a: enum-without-values and default-not-in-values test('REJECTED: enum with empty values array → error (FIX 5a)', () => { const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'enum', default: 'x', values: [], description: 'ok' }); assert.ok(errors.length > 0, 'Expected rejection for enum with empty values, got: ' + JSON.stringify(errors)); assert.ok(errors.some((e) => e.includes('values') || e.includes('enum'))); }); test('REJECTED: enum with non-string values array entries → error (FIX 5a)', () => { const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'enum', default: 'x', values: ['a', 42], description: 'ok' }); assert.ok(errors.length > 0, 'Expected rejection for enum with non-string values, got: ' + JSON.stringify(errors)); assert.ok(errors.some((e) => e.includes('values') || e.includes('string'))); }); test('REJECTED: enum default not in values → error (FIX 5a)', () => { const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'enum', default: 'z', values: ['a', 'b'], description: 'ok' }); assert.ok(errors.length > 0, 'Expected rejection for enum default not in values, got: ' + JSON.stringify(errors)); assert.ok(errors.some((e) => e.includes('z') || e.includes('values') || e.includes('default'))); }); // FIX 6a: NaN and non-finite number defaults test('REJECTED: NaN number default → error (FIX 6a)', () => { const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'number', default: NaN, description: 'ok' }); assert.ok(errors.length > 0, 'Expected rejection for NaN default, got: ' + JSON.stringify(errors)); assert.ok(errors.some((e) => e.includes('finite') || e.includes('NaN') || e.includes('number'))); }); test('REJECTED: Infinity number default → error (FIX 6a)', () => { const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'number', default: Infinity, description: 'ok' }); assert.ok(errors.length > 0, 'Expected rejection for Infinity default, got: ' + JSON.stringify(errors)); assert.ok(errors.some((e) => e.includes('finite') || e.includes('number'))); }); test('REJECTED: -Infinity number default → error (FIX 6a)', () => { const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'number', default: -Infinity, description: 'ok' }); assert.ok(errors.length > 0, 'Expected rejection for -Infinity default, got: ' + JSON.stringify(errors)); }); test('buildRegistry throws on malformed config slice in capability', () => { // A capability with a config slice that has a missing default — buildRegistry must throw const cap = { ...UI_CAP, config: { ...UI_CAP.config, 'workflow.bad_key': { type: 'boolean', description: 'missing default' }, }, }; const capMap = new Map([['ui', cap]]); assert.throws( () => buildRegistry(capMap), (err) => { assert.ok(err instanceof Error, 'Must throw an Error'); assert.ok( err.message.includes('configSchema') || err.message.includes('default') || err.message.includes('validation'), 'Error must mention configSchema validation, got: ' + err.message, ); return true; }, ); }); }); // ─── 16. ADR-857 phase 4a: capabilityClusters + profileMembership ───────────── // Minimal valid feature capability for synthetic tests function makeSyntheticCap(id, tier, skills) { return { id, role: 'feature', title: id, description: 'Synthetic cap for testing', tier, requires: [], skills: [...skills], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [], }; } describe('ADR-857 phase 4a: capabilityClusters shape', () => { test('ui capabilityClusters → [ui-phase, ui-review]', () => { const capMap = new Map([['ui', UI_CAP]]); const clusters = deriveCapabilityClusters(capMap); assert.ok(clusters.ui, 'capabilityClusters.ui should exist'); // Skills are sorted for determinism assert.deepEqual( clusters.ui, ['ui-phase', 'ui-review'], 'ui cluster should be [ui-phase, ui-review], got: ' + JSON.stringify(clusters.ui), ); }); test('capabilityClusters skips runtime capabilities (no skills)', () => { const runtimeCap = { id: 'cursor', role: 'runtime', title: 'Cursor', description: 'Cursor runtime', tier: 'standard', requires: [], runtime: { configHome: '~/.cursor', configFormat: 'settings-json', artifactLayout: [], commandStyle: 'slash', hooksSurface: 'rules', sandboxTier: 'none', supportTier: 2, }, }; const capMap = new Map([['cursor', runtimeCap]]); const clusters = deriveCapabilityClusters(capMap); assert.ok(!clusters.cursor, 'runtime cap should not appear in capabilityClusters'); }); test('capabilityClusters skills are sorted for determinism', () => { const cap = makeSyntheticCap('test-cap', 'standard', ['z-skill', 'a-skill', 'm-skill']); const capMap = new Map([['test-cap', cap]]); const clusters = deriveCapabilityClusters(capMap); assert.deepEqual( clusters['test-cap'], ['a-skill', 'm-skill', 'z-skill'], 'Skills should be sorted alphabetically, got: ' + JSON.stringify(clusters['test-cap']), ); }); test('buildRegistry includes capabilityClusters with correct ui value', () => { const capDir = makeTempCapDir({ ui: UI_CAP }); const { capMap } = loadAndValidate(new Set(), capDir); const registry = buildRegistry(capMap); assert.ok(registry.capabilityClusters, 'registry.capabilityClusters should exist'); assert.deepEqual( registry.capabilityClusters.ui, ['ui-phase', 'ui-review'], 'registry.capabilityClusters.ui should be [ui-phase, ui-review]', ); }); test('serializeRegistry emits capabilityClusters block in generated .cjs', () => { const capDir = makeTempCapDir({ ui: UI_CAP }); const { capMap } = loadAndValidate(new Set(), capDir); const registry = buildRegistry(capMap); const content = serializeRegistry(registry, capMap); assert.ok(content.includes('const capabilityClusters'), 'Generated file must contain "const capabilityClusters"'); assert.ok(content.includes('"ui-phase"'), 'Generated file must contain "ui-phase" in capabilityClusters'); assert.ok(content.includes('capabilityClusters,'), 'module.exports must include capabilityClusters'); }); test('committed capability-registry.cjs has capabilityClusters with ui=[ui-phase,ui-review]', () => { const registry = require('../gsd-core/bin/lib/capability-registry.cjs'); assert.ok(registry.capabilityClusters, 'capability-registry.cjs must export capabilityClusters'); assert.deepEqual( registry.capabilityClusters.ui, ['ui-phase', 'ui-review'], 'committed capabilityClusters.ui should be [ui-phase, ui-review]', ); }); }); describe('ADR-857 phase 4a: capabilityClusters HARD consistency gate', () => { test('synthetic cap whose capId matches a CLUSTERS name but with different skills throws', () => { // The 'ui' name exists in CLUSTERS with ['ui-phase', 'ui-review']. // A synthetic 'ui' cap with only ['ui-phase'] (missing 'ui-review') must throw. const wrongUiCap = makeSyntheticCap('ui', 'standard', ['ui-phase']); // missing ui-review const capMap = new Map([['ui', wrongUiCap]]); const clusters = deriveCapabilityClusters(capMap); const profiles = deriveProfileMembership(capMap); assert.throws( () => runConsistencyGate(clusters, profiles, capMap), (err) => { assert.ok(err instanceof Error, 'Must throw an Error'); assert.ok( err.message.includes('ui'), 'Error must name the capId, got: ' + err.message, ); assert.ok( err.message.includes('ui-review') || err.message.includes('derived set') || err.message.includes('hand-authored'), 'Error must describe the mismatch, got: ' + err.message, ); return true; }, ); }); test('cap with capId that has NO matching CLUSTERS entry is accepted (new cluster — fine)', () => { // A new capability 'payments' that has no CLUSTERS entry must NOT throw const newCap = makeSyntheticCap('payments', 'standard', ['pay-phase', 'pay-review']); const capMap = new Map([['payments', newCap]]); const clusters = deriveCapabilityClusters(capMap); const profiles = deriveProfileMembership(capMap); // Must not throw assert.doesNotThrow( () => runConsistencyGate(clusters, profiles, capMap), 'A cap with no matching CLUSTERS entry should not throw (new cluster is fine)', ); }); test('HARD gate: extra skill in derived set (more than hand-authored) also throws', () => { // 'ui' cap with an extra skill triggers the mismatch const extraUiCap = makeSyntheticCap('ui', 'standard', ['ui-phase', 'ui-review', 'ui-extra']); const capMap = new Map([['ui', extraUiCap]]); const clusters = deriveCapabilityClusters(capMap); const profiles = deriveProfileMembership(capMap); assert.throws( () => runConsistencyGate(clusters, profiles, capMap), (err) => { assert.ok(err instanceof Error); assert.ok(err.message.includes('ui'), 'Error must name the capId'); return true; }, ); }); }); describe('ADR-857 phase 4a: profileMembership derivation', () => { test('tier core → profiles [core, standard, full]', () => { const cap = makeSyntheticCap('core-cap', 'core', ['core-skill']); const capMap = new Map([['core-cap', cap]]); const profiles = deriveProfileMembership(capMap); assert.ok(profiles['core-cap'], 'profileMembership should have core-cap'); assert.strictEqual(profiles['core-cap'].tier, 'core'); assert.deepEqual( profiles['core-cap'].profiles, ['core', 'standard', 'full'], 'core tier should produce [core, standard, full], got: ' + JSON.stringify(profiles['core-cap'].profiles), ); }); test('tier standard → profiles [standard, full]', () => { const cap = makeSyntheticCap('std-cap', 'standard', ['std-skill']); const capMap = new Map([['std-cap', cap]]); const profiles = deriveProfileMembership(capMap); assert.ok(profiles['std-cap'], 'profileMembership should have std-cap'); assert.strictEqual(profiles['std-cap'].tier, 'standard'); assert.deepEqual( profiles['std-cap'].profiles, ['standard', 'full'], 'standard tier should produce [standard, full], got: ' + JSON.stringify(profiles['std-cap'].profiles), ); }); test('tier full → profiles [full]', () => { const cap = makeSyntheticCap('full-cap', 'full', ['full-skill']); const capMap = new Map([['full-cap', cap]]); const profiles = deriveProfileMembership(capMap); assert.ok(profiles['full-cap'], 'profileMembership should have full-cap'); assert.strictEqual(profiles['full-cap'].tier, 'full'); assert.deepEqual( profiles['full-cap'].profiles, ['full'], 'full tier should produce [full], got: ' + JSON.stringify(profiles['full-cap'].profiles), ); }); test('PROFILE_RANK is imported (not hardcoded): all three tiers covered', () => { // Verify PROFILE_RANK is the canonical ['core', 'standard', 'full'] from install-profiles.cjs assert.deepEqual( PROFILE_RANK, ['core', 'standard', 'full'], 'PROFILE_RANK must be [core, standard, full] from install-profiles.cjs, got: ' + JSON.stringify(PROFILE_RANK), ); }); test('ui cap (tier full after reconciliation) profileMembership is [full]', () => { // ADR-857 phase 4c: ui tier changed from standard → full const capMap = new Map([['ui', UI_CAP]]); const profiles = deriveProfileMembership(capMap); assert.deepEqual( profiles.ui.profiles, ['full'], 'ui (tier full) should have profiles [full] after reconciliation', ); }); test('buildRegistry includes profileMembership with correct ui value', () => { const capDir = makeTempCapDir({ ui: UI_CAP }); const { capMap } = loadAndValidate(new Set(), capDir); const registry = buildRegistry(capMap); assert.ok(registry.profileMembership, 'registry.profileMembership should exist'); // After ADR-857 phase 4c reconciliation: ui is tier:full → profiles: ['full'] only assert.deepEqual( registry.profileMembership.ui, { tier: 'full', profiles: ['full'] }, 'profileMembership.ui should be { tier: full, profiles: [full] } after reconciliation', ); }); test('serializeRegistry emits profileMembership block in generated .cjs', () => { const capDir = makeTempCapDir({ ui: UI_CAP }); const { capMap } = loadAndValidate(new Set(), capDir); const registry = buildRegistry(capMap); const content = serializeRegistry(registry, capMap); assert.ok(content.includes('const profileMembership'), 'Generated file must contain "const profileMembership"'); // After ADR-857 phase 4c reconciliation: ui is tier:full → profileMembership contains "full" assert.ok(content.includes('"full"'), 'Generated file must contain "full" in profileMembership'); assert.ok(content.includes('profileMembership,'), 'module.exports must include profileMembership'); }); test('committed capability-registry.cjs has profileMembership with correct ui value', () => { const registry = require('../gsd-core/bin/lib/capability-registry.cjs'); assert.ok(registry.profileMembership, 'capability-registry.cjs must export profileMembership'); // After ADR-857 phase 4c reconciliation: ui is tier:full → profiles: ['full'] only assert.deepEqual( registry.profileMembership.ui, { tier: 'full', profiles: ['full'] }, 'committed profileMembership.ui should be { tier: full, profiles: [full] } after reconciliation', ); }); }); describe('ADR-857 phase 4a: pending-reconciliation warnings (SOFT gate)', () => { test('ui (tier full) generates ZERO pending-reconciliation warnings (ADR-857 phase 4c reconciliation)', () => { // After reconciliation: ui is tier:full. The full profile is '*' (every skill). // The consistency gate must NOT fire for full-tier capabilities — their skills are // always present in the full profile by definition. const capMap = new Map([['ui', UI_CAP]]); const clusters = deriveCapabilityClusters(capMap); const profiles = deriveProfileMembership(capMap); const warnings = runConsistencyGate(clusters, profiles, capMap); const uiPhaseWarn = warnings.find((w) => w.includes('ui-phase')); const uiReviewWarn = warnings.find((w) => w.includes('ui-review')); assert.ok( !uiPhaseWarn, 'No pending-reconciliation warning expected for ui-phase (tier:full), got: ' + JSON.stringify(warnings), ); assert.ok( !uiReviewWarn, 'No pending-reconciliation warning expected for ui-review (tier:full), got: ' + JSON.stringify(warnings), ); }); test('ui reconciled: profileMembership.ui.profiles is ["full"] after tier:full reconciliation', () => { // After reconciliation: ui is tier:full → profileMembership.ui.profiles = ['full'] only. // ui-phase/ui-review are correctly absent from core/standard (they're full-only features). // No pending-reconciliation warning fires because full='*' always satisfies the gate. const capMap = new Map([['ui', UI_CAP]]); const profiles = deriveProfileMembership(capMap); assert.deepStrictEqual( profiles.ui.profiles, ['full'], 'After tier:full reconciliation, ui profileMembership should be ["full"] only', ); assert.strictEqual( profiles.ui.tier, 'full', 'ui tier should be "full" after reconciliation', ); // Confirm ui-phase is NOT in standard profile — that's expected and correct for full-tier skills. const { resolveProfile: rp } = require('../gsd-core/bin/lib/install-profiles.cjs'); const resolved = rp({ modes: ['standard'], manifest: new Map() }); assert.ok( resolved.skills !== '*', 'standard profile should not be full', ); assert.ok( !resolved.skills.has('ui-phase'), 'ui-phase should NOT be in hand-authored standard profile (correctly full-only after reconciliation)', ); assert.ok( !resolved.skills.has('ui-review'), 'ui-review should NOT be in hand-authored standard profile (correctly full-only after reconciliation)', ); }); test('SOFT gate does NOT throw — only returns warnings', () => { // Even with reconciliation gaps, runConsistencyGate must NOT throw const capMap = new Map([['ui', UI_CAP]]); const clusters = deriveCapabilityClusters(capMap); const profiles = deriveProfileMembership(capMap); let warnings; assert.doesNotThrow( () => { warnings = runConsistencyGate(clusters, profiles, capMap); }, 'SOFT gate must not throw — only collect warnings', ); assert.ok(Array.isArray(warnings), 'runConsistencyGate must return an array'); }); test('buildRegistry._reconciliationWarnings is empty for reconciled ui (tier:full)', () => { // After reconciliation: ui is tier:full → no pending-reconciliation warnings. const capDir = makeTempCapDir({ ui: UI_CAP }); const { capMap } = loadAndValidate(new Set(), capDir); const registry = buildRegistry(capMap); assert.ok( Array.isArray(registry._reconciliationWarnings), 'registry._reconciliationWarnings should be an array', ); const uiWarnings = registry._reconciliationWarnings.filter( (w) => w.includes('ui-phase') || w.includes('ui-review') ); assert.deepStrictEqual( uiWarnings, [], 'No reconciliation warnings expected for reconciled ui capability, got: ' + JSON.stringify(uiWarnings), ); }); test('reconciliation warnings are NOT in serialized registry output (determinism gate)', () => { // Warnings must appear ONLY on stderr, not in the generated .cjs file const capDir = makeTempCapDir({ ui: UI_CAP }); const { capMap } = loadAndValidate(new Set(), capDir); const registry = buildRegistry(capMap); const content = serializeRegistry(registry, capMap); assert.ok( !content.includes('pending-reconciliation'), 'Serialized registry must NOT contain "pending-reconciliation" text (warnings are stderr-only)', ); assert.ok( !content.includes('_reconciliationWarnings'), 'Serialized registry must NOT contain _reconciliationWarnings key', ); }); test('a cap whose skill IS already in the standard profile emits no reconciliation warning', () => { // 'plan-phase' IS in the hand-authored standard profile. A synthetic cap // with tier=standard and skill=plan-phase should NOT generate a warning. const cap = makeSyntheticCap('planner-cap', 'standard', ['plan-phase']); const capMap = new Map([['planner-cap', cap]]); const clusters = deriveCapabilityClusters(capMap); const profiles = deriveProfileMembership(capMap); const warnings = runConsistencyGate(clusters, profiles, capMap); const planPhaseWarnings = warnings.filter((w) => w.includes('plan-phase')); assert.deepEqual( planPhaseWarnings, [], 'No reconciliation warning expected for plan-phase (already in standard profile), got: ' + JSON.stringify(planPhaseWarnings), ); }); test('a core-tier cap with skills already in core profile emits no reconciliation warning', () => { // 'new-project' IS in the hand-authored core profile. const cap = makeSyntheticCap('np-cap', 'core', ['new-project']); const capMap = new Map([['np-cap', cap]]); const clusters = deriveCapabilityClusters(capMap); const profiles = deriveProfileMembership(capMap); const warnings = runConsistencyGate(clusters, profiles, capMap); const npWarnings = warnings.filter((w) => w.includes('new-project')); assert.deepEqual( npWarnings, [], 'No reconciliation warning expected for new-project (already in core profile), got: ' + JSON.stringify(npWarnings), ); }); }); describe('ADR-857 phase 4a: requires-closure tier-monotone (synthetic)', () => { test('tier-monotone: a required capability must be same-or-lower tier', () => { // Cap A at 'core' requiring cap B at 'standard' violates tier-monotone. // validateCrossCapability already tests this; here we verify the rule via // a profileMembership structural check: if A is core → B must have rank ≤ core. const capA = makeSyntheticCap('tier-a', 'core', ['a-skill']); capA.requires = ['tier-b']; const capB = makeSyntheticCap('tier-b', 'standard', ['b-skill']); const capMap = new Map([['tier-a', capA], ['tier-b', capB]]); // validateCrossCapability enforces the rule const { validateCrossCapability: vcc } = require('../scripts/gen-capability-registry.cjs'); const errors = vcc(capMap, new Set()); assert.ok( errors.some((e) => e.includes('tier-monotone')), 'Expected tier-monotone error, got: ' + JSON.stringify(errors), ); }); test('tier-monotone: same-tier requires is accepted', () => { const capA = makeSyntheticCap('mono-a', 'standard', ['ma-skill']); capA.requires = ['mono-b']; const capB = makeSyntheticCap('mono-b', 'standard', ['mb-skill']); const capMap = new Map([['mono-a', capA], ['mono-b', capB]]); const { validateCrossCapability: vcc } = require('../scripts/gen-capability-registry.cjs'); const errors = vcc(capMap, new Set()); const monotoneErrors = errors.filter((e) => e.includes('tier-monotone')); assert.deepEqual(monotoneErrors, [], 'Same-tier requires should be accepted, got: ' + JSON.stringify(monotoneErrors)); }); test('tier-monotone: higher-tier requiring lower-tier is accepted (full requires core)', () => { const capA = makeSyntheticCap('full-a', 'full', ['fa-skill']); capA.requires = ['core-b']; const capB = makeSyntheticCap('core-b', 'core', ['cb-skill']); const capMap = new Map([['full-a', capA], ['core-b', capB]]); const { validateCrossCapability: vcc } = require('../scripts/gen-capability-registry.cjs'); const errors = vcc(capMap, new Set()); const monotoneErrors = errors.filter((e) => e.includes('tier-monotone')); assert.deepEqual( monotoneErrors, [], 'full requiring core should be accepted (higher tier can require lower tier), got: ' + JSON.stringify(monotoneErrors), ); }); }); describe('ADR-857 phase 4a: --check determinism after --write', () => { test('serializeRegistry produces identical output for two calls (determinism)', () => { // Regression guard: --check would fail if output is non-deterministic 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, 'serializeRegistry output must be deterministic'); }); }); // ─── 17. FIX 1: SOFT gate uses real manifest for requires-closure expansion ──── describe('FIX 1: SOFT gate uses closure-resolved manifest (not empty)', () => { test('plan-phase is transitively in standard — no reconciliation warning', () => { // plan-phase is in PROFILES.standard directly; resolved (with real manifest) = in standard. // A standard-tier cap with skill=plan-phase must NOT generate a reconciliation warning. const cap = makeSyntheticCap('plan-cap', 'standard', ['plan-phase']); const capMap = new Map([['plan-cap', cap]]); const clusters = deriveCapabilityClusters(capMap); const profiles = deriveProfileMembership(capMap); const warnings = runConsistencyGate(clusters, profiles, capMap); const planWarnings = warnings.filter((w) => w.includes('plan-phase')); assert.deepEqual( planWarnings, [], 'plan-phase is in standard profile — no warning expected, got: ' + JSON.stringify(planWarnings), ); }); test('FIX 1: skill only transitively in standard (requires-closure) emits no warning', () => { // 'code-review' is brought into standard via requires-closure expansion (not in raw base). // FIX 1 ensures the real manifest is used, so no false-positive warning is emitted. const cap = makeSyntheticCap('cr-cap', 'standard', ['code-review']); const capMap = new Map([['cr-cap', cap]]); const clusters = deriveCapabilityClusters(capMap); const profiles = deriveProfileMembership(capMap); const warnings = runConsistencyGate(clusters, profiles, capMap); const crWarnings = warnings.filter((w) => w.includes('code-review')); assert.deepEqual( crWarnings, [], 'code-review is transitively in standard via requires-closure — no warning expected, got: ' + JSON.stringify(crWarnings), ); }); }); // ─── 18. FIX 2: globally-sorted capId emission ─────────────────────────────── describe('FIX 2: globally-sorted capId emission (determinism with mixed feature+runtime)', () => { test('feature cap "analytics" and feature cap "ui" are globally sorted in serialized output', () => { // "analytics" < "ui" alphabetically — must appear first in both derived views const analyticsCap = makeSyntheticCap('analytics', 'standard', ['analytics-skill']); const capDir = makeTempCapDir({ analytics: analyticsCap, ui: UI_CAP }); const { capMap } = loadAndValidate(new Set(), capDir); const registry = buildRegistry(capMap); const content = serializeRegistry(registry, capMap); // Find the positions of "analytics" and "ui" in the capabilityClusters block const clustersStart = content.indexOf('const capabilityClusters'); const clustersEnd = content.indexOf('const profileMembership'); const clustersBlock = content.slice(clustersStart, clustersEnd); const analyticsPos = clustersBlock.indexOf('"analytics"'); const uiPos = clustersBlock.indexOf('"ui"'); assert.ok( analyticsPos < uiPos, 'analytics must appear before ui in capabilityClusters (global alphabetical sort)', ); // Same check for profileMembership const profileStart = content.indexOf('const profileMembership'); const profileEnd = content.indexOf('const _requiresGraph'); const profileBlock = content.slice(profileStart, profileEnd); const analyticsProfilePos = profileBlock.indexOf('"analytics"'); const uiProfilePos = profileBlock.indexOf('"ui"'); assert.ok( analyticsProfilePos < uiProfilePos, 'analytics must appear before ui in profileMembership (global alphabetical sort)', ); }); test('serialized output is stable across two calls (determinism)', () => { const analyticsCap = makeSyntheticCap('analytics', 'standard', ['analytics-skill']); const capDir = makeTempCapDir({ analytics: analyticsCap, ui: UI_CAP }); const { capMap } = loadAndValidate(new Set(), capDir); const registry = buildRegistry(capMap); const s1 = serializeRegistry(registry, capMap); const s2 = serializeRegistry(registry, capMap); assert.strictEqual(s1, s2, 'Two serializeRegistry calls must produce identical output'); }); }); // ─── 19. FIX 3: consistent role scoping across both derived views ───────────── describe('FIX 3: consistent scope — capabilities that own skills', () => { test('feature cap with empty skills does not appear in capabilityClusters', () => { // A feature cap with an empty skills array must NOT appear in capabilityClusters const emptySkillsCap = { id: 'empty-skills', role: 'feature', title: 'Empty', description: 'No skills', tier: 'standard', requires: [], skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [], }; const capMap = new Map([['empty-skills', emptySkillsCap]]); const clusters = deriveCapabilityClusters(capMap); assert.ok( !Object.prototype.hasOwnProperty.call(clusters, 'empty-skills'), 'Cap with empty skills array must not appear in capabilityClusters', ); }); test('feature cap with empty skills does not appear in profileMembership', () => { // FIX 3: profileMembership must also exclude caps with no skills (consistent scope) const emptySkillsCap = { id: 'empty-skills', role: 'feature', title: 'Empty', description: 'No skills', tier: 'standard', requires: [], skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [], }; const capMap = new Map([['empty-skills', emptySkillsCap]]); const profiles = deriveProfileMembership(capMap); assert.ok( !Object.prototype.hasOwnProperty.call(profiles, 'empty-skills'), 'Cap with empty skills array must not appear in profileMembership (FIX 3: consistent scope)', ); }); }); // ─── 20. FIX 4: de-duplicated reconciliation warnings ──────────────────────── describe('FIX 4: de-duplicated reconciliation warnings (one per skill, not per profile)', () => { test('core-tier cap with skill missing from both core and standard emits ONE warning', () => { // ui-phase is not in the hand-authored core or standard profiles. // A core-tier cap with ui-phase must emit exactly 1 warning (listing both profiles). const cap = makeSyntheticCap('core-ui-cap', 'core', ['ui-phase']); const capMap = new Map([['core-ui-cap', cap]]); const clusters = deriveCapabilityClusters(capMap); const profiles = deriveProfileMembership(capMap); const warnings = runConsistencyGate(clusters, profiles, capMap); const uiPhaseWarnings = warnings.filter((w) => w.includes('ui-phase')); assert.strictEqual( uiPhaseWarnings.length, 1, 'Expected exactly 1 warning for ui-phase (FIX 4: one per skill, not per profile), got: ' + JSON.stringify(uiPhaseWarnings), ); // Warning must list both missing profiles assert.ok( uiPhaseWarnings[0].includes('core') && uiPhaseWarnings[0].includes('standard'), 'Warning must list both missing profiles (core and standard), got: ' + uiPhaseWarnings[0], ); }); test('standard-tier cap with skill missing from standard emits ONE warning with ', () => { const cap = makeSyntheticCap('std-ui-cap', 'standard', ['ui-phase']); const capMap = new Map([['std-ui-cap', cap]]); const clusters = deriveCapabilityClusters(capMap); const profiles = deriveProfileMembership(capMap); const warnings = runConsistencyGate(clusters, profiles, capMap); assert.strictEqual(warnings.length, 1, 'Expected exactly 1 warning, got: ' + JSON.stringify(warnings)); assert.ok( warnings[0].includes('profile(s): '), 'Warning must use "profile(s): " format, got: ' + warnings[0], ); }); }); // ─── 21. FIX 5: tierIdx -1 throws loudly ───────────────────────────────────── describe('FIX 5: tierIdx === -1 throws loudly (VALID_TIERS/PROFILE_RANK drift guard)', () => { test('normal usage (standard/core/full) does not throw in deriveProfileMembership', () => { const cap = makeSyntheticCap('drift-test', 'standard', ['s1']); const capMap = new Map([['drift-test', cap]]); assert.doesNotThrow( () => deriveProfileMembership(capMap), 'deriveProfileMembership must not throw for valid tiers', ); }); }); // ─── 22. FIX 6: UI true-negative doesNotThrow ───────────────────────────────── describe('FIX 6: runConsistencyGate does NOT throw for real UI capability (true-negative)', () => { test('buildRegistry with real UI cap does not throw (HARD gate true-negative)', () => { // The real UI cap has skills = [ui-phase, ui-review] which matches CLUSTERS.ui exactly. // The HARD gate must NOT throw. const capDir = makeTempCapDir({ ui: UI_CAP }); const { capMap } = loadAndValidate(new Set(), capDir); assert.doesNotThrow( () => buildRegistry(capMap), 'buildRegistry must not throw for the real UI capability (CLUSTERS match expected)', ); }); test('runConsistencyGate does NOT throw for real UI cap (cluster match true-negative)', () => { // Explicit doesNotThrow covering runConsistencyGate directly const capMap = new Map([['ui', UI_CAP]]); const clusters = deriveCapabilityClusters(capMap); const profiles = deriveProfileMembership(capMap); assert.doesNotThrow( () => runConsistencyGate(clusters, profiles, capMap), 'runConsistencyGate must not throw for UI cap (CLUSTERS.ui matches ui.skills)', ); }); }); // ─── 23. ADR-959: commands field + commandFamilies index ────────────────────── /** * Build a minimal feature capability for ADR-959 command tests. * skills/agents/etc. kept minimal-valid so validateCapability passes. */ function makeCommandCap(id, commands) { return { id, role: 'feature', version: '1.0.0', title: 'Test cap ' + id, description: 'Synthetic capability for ADR-959 command tests.', tier: 'full', requires: [], runtimeCompat: { supported: ['*'], unsupported: [] }, skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [], commands, }; } describe('ADR-959: validateCommandEntry — valid entry', () => { test('valid minimal entry (no subcommands) passes', () => { const errors = validateCommandEntry('my-cap', { family: 'foo', module: 'foo.cjs', router: 'routeFoo' }, 'commands[0]'); assert.deepEqual(errors, []); }); test('valid entry with subcommands passes', () => { const errors = validateCommandEntry('my-cap', { family: 'bar', module: 'bar-router.cjs', router: 'routeBar', subcommands: ['query', 'status'], }, 'commands[0]'); assert.deepEqual(errors, []); }); }); describe('ADR-959: validateCommandEntry — adversarial rejects', () => { test('missing family → error', () => { const errors = validateCommandEntry('my-cap', { module: 'foo.cjs', router: 'routeFoo' }, 'commands[0]'); assert.ok(errors.some((e) => e.includes('family')), 'Expected family error, got: ' + JSON.stringify(errors)); }); test('empty family → error', () => { const errors = validateCommandEntry('my-cap', { family: '', module: 'foo.cjs', router: 'routeFoo' }, 'commands[0]'); assert.ok(errors.some((e) => e.includes('family')), 'Expected family error, got: ' + JSON.stringify(errors)); }); test('missing module → error', () => { const errors = validateCommandEntry('my-cap', { family: 'foo', router: 'routeFoo' }, 'commands[0]'); assert.ok(errors.some((e) => e.includes('module')), 'Expected module error, got: ' + JSON.stringify(errors)); }); test('missing router → error', () => { const errors = validateCommandEntry('my-cap', { family: 'foo', module: 'foo.cjs' }, 'commands[0]'); assert.ok(errors.some((e) => e.includes('router')), 'Expected router error, got: ' + JSON.stringify(errors)); }); test('non-string router → error', () => { const errors = validateCommandEntry('my-cap', { family: 'foo', module: 'foo.cjs', router: 42 }, 'commands[0]'); assert.ok(errors.some((e) => e.includes('router')), 'Expected router error, got: ' + JSON.stringify(errors)); }); test('traversal module "../evil.cjs" → error', () => { const errors = validateCommandEntry('my-cap', { family: 'foo', module: '../evil.cjs', router: 'r' }, 'commands[0]'); assert.ok(errors.some((e) => e.includes('module')), 'Expected module traversal error, got: ' + JSON.stringify(errors)); }); test('absolute module "/abs/path.cjs" → error', () => { const errors = validateCommandEntry('my-cap', { family: 'foo', module: '/abs/path.cjs', router: 'r' }, 'commands[0]'); assert.ok(errors.some((e) => e.includes('module')), 'Expected module absolute error, got: ' + JSON.stringify(errors)); }); test('module with "/" separator "lib/foo.cjs" → error', () => { const errors = validateCommandEntry('my-cap', { family: 'foo', module: 'lib/foo.cjs', router: 'r' }, 'commands[0]'); assert.ok(errors.some((e) => e.includes('module')), 'Expected module separator error, got: ' + JSON.stringify(errors)); }); test('subcommands non-array → error', () => { const errors = validateCommandEntry('my-cap', { family: 'foo', module: 'foo.cjs', router: 'r', subcommands: 'not-array', }, 'commands[0]'); assert.ok(errors.some((e) => e.includes('subcommands')), 'Expected subcommands error, got: ' + JSON.stringify(errors)); }); test('subcommands with non-string entry → error', () => { const errors = validateCommandEntry('my-cap', { family: 'foo', module: 'foo.cjs', router: 'r', subcommands: ['ok', 42], }, 'commands[0]'); assert.ok(errors.some((e) => e.includes('subcommands')), 'Expected subcommands[1] error, got: ' + JSON.stringify(errors)); }); }); describe('ADR-959: validateCrossCapability — duplicate family ownership', () => { test('duplicate family across two capabilities → error', () => { const capA = makeCommandCap('cap-a', [{ family: 'shared', module: 'a.cjs', router: 'rA' }]); const capB = makeCommandCap('cap-b', [{ family: 'shared', module: 'b.cjs', router: 'rB' }]); const capMap = new Map([['cap-a', capA], ['cap-b', capB]]); const errors = validateCrossCapability(capMap, new Set()); assert.ok( errors.some((e) => e.includes('shared') && e.includes('cap-a') && e.includes('cap-b')), 'Expected duplicate family error mentioning both caps, got: ' + JSON.stringify(errors), ); }); test('unique families in two capabilities → no error', () => { const capA = makeCommandCap('cap-a', [{ family: 'alpha', module: 'alpha.cjs', router: 'rA' }]); const capB = makeCommandCap('cap-b', [{ family: 'beta', module: 'beta.cjs', router: 'rB' }]); const capMap = new Map([['cap-a', capA], ['cap-b', capB]]); const errors = validateCrossCapability(capMap, new Set()); assert.ok( !errors.some((e) => e.includes('owned by both')), 'Expected no duplicate-ownership error, got: ' + JSON.stringify(errors), ); }); }); describe('ADR-959: buildRegistry — commandFamilies index shape', () => { test('cap with valid commands entry produces commandFamilies entry', () => { const cap = makeCommandCap('test-cmd', [ { family: 'myfamily', module: 'myfamily.cjs', router: 'routeMyFamily' }, ]); const capMap = new Map([['test-cmd', cap]]); const registry = buildRegistry(capMap); assert.ok(registry.commandFamilies, 'commandFamilies must be present'); const entry = registry.commandFamilies['myfamily']; assert.ok(entry, 'commandFamilies["myfamily"] must exist'); assert.strictEqual(entry.capId, 'test-cmd'); assert.strictEqual(entry.module, 'myfamily.cjs'); assert.strictEqual(entry.router, 'routeMyFamily'); }); test('cap without commands → commandFamilies is empty', () => { const capDir = makeTempCapDir({ ui: UI_CAP }); const { capMap } = loadAndValidate(new Set(), capDir); const registry = buildRegistry(capMap); assert.ok(registry.commandFamilies, 'commandFamilies must be present'); assert.deepEqual(Object.keys(registry.commandFamilies), [], 'commandFamilies must be empty for real registry'); }); test('commandFamilies keys are sorted in serialized output (determinism)', () => { // Two caps with commands in z→a order; expect a→z in the commandFamilies section const capA = makeCommandCap('cap-a', [{ family: 'zebra', module: 'z.cjs', router: 'rZ' }]); const capB = makeCommandCap('cap-b', [{ family: 'alpha', module: 'a.cjs', router: 'rA' }]); const capMap = new Map([['cap-a', capA], ['cap-b', capB]]); const registry = buildRegistry(capMap); const serialized = serializeRegistry(registry, capMap); // Find the commandFamilies section specifically (not the full capabilities JSON) const cfStart = serialized.indexOf('const commandFamilies = '); assert.ok(cfStart >= 0, 'commandFamilies section must be present'); const cfEnd = serialized.indexOf('\n};', cfStart) + 3; // closing }; of const assignment const cfSection = serialized.slice(cfStart, cfEnd); const alphaIdx = cfSection.indexOf('"alpha"'); const zebraIdx = cfSection.indexOf('"zebra"'); assert.ok(alphaIdx >= 0, '"alpha" must appear in commandFamilies section'); assert.ok(zebraIdx >= 0, '"zebra" must appear in commandFamilies section'); assert.ok(alphaIdx < zebraIdx, 'commandFamilies section must list "alpha" before "zebra" (sorted)'); }); }); describe('ADR-959: validateCapability — commands field on feature role', () => { test('valid commands entry on feature cap passes validateCapability', () => { const cap = makeCommandCap('cmd-cap', [ { family: 'testfamily', module: 'testfamily.cjs', router: 'routeTestFamily' }, ]); const errors = validateCapability(cap, 'cmd-cap'); assert.deepEqual(errors, [], 'Expected no errors: ' + JSON.stringify(errors)); }); test('commands: null on feature cap → error', () => { const cap = makeCommandCap('cmd-cap', null); const errors = validateCapability(cap, 'cmd-cap'); assert.ok(errors.some((e) => e.includes('commands')), 'Expected commands error, got: ' + JSON.stringify(errors)); }); }); // ─── 24. ADR-1016 phase 5a: runtime capability descriptors ─────────────────── const { validateConfigHome, validateArtifactLayout, VALID_CONFIG_HOME_KINDS, VALID_COMMAND_STYLES, VALID_HOOKS_SURFACES, VALID_HOOK_EVENTS, VALID_SANDBOX_TIERS, VALID_ARTIFACT_KIND_NAMES, VALID_ARTIFACT_NESTINGS, } = require('../scripts/gen-capability-registry.cjs'); const RUNTIME_IDS = [ 'claude', 'codex', 'antigravity', 'gemini', 'cursor', 'opencode', 'kilo', 'copilot', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline', 'kimi', 'windsurf', ]; // Helper: build a minimal valid runtime capability object for fixture-based tests function makeRuntimeCap(overrides) { return { id: 'test-rt', role: 'runtime', version: '1.0.0', title: 'Test Runtime', description: 'A synthetic runtime capability for testing.', tier: 'core', requires: [], runtime: { configHome: { kind: 'dot-home', name: '.test-rt', env: ['TEST_RT_DIR'] }, configFormat: 'settings-json', artifactLayout: { global: [], local: [] }, commandStyle: 'slash-hyphen', hooksSurface: 'settings-json', hookEvents: 'claude', sandboxTier: 'none', supportTier: 1, installSurface: 'settings-json', writesSharedSettings: true, permissionWriter: null, extendedHookEvents: [], hostIntegration: { embeddingMode: 'imperative', commandSurface: 'slash-file', dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: false }, modelMode: 'passive', hookBus: 'host', stateIO: 'filesystem', transport: 'mcp', runtime: 'node', }, ...((overrides && overrides.runtime) ? overrides.runtime : {}), }, ...overrides, }; } // ── 24a. All 16 runtime ids appear in the runtimes index ───────────────────── describe('ADR-1016 phase 5a: all 16 runtimes in registry index', () => { let registry; test('loadAndValidate + buildRegistry produces runtimes index with 16 entries', () => { const { capMap, errors } = loadAndValidate(new Set()); const hardErrors = errors.filter((e) => !e.includes('pending-migration')); assert.deepEqual(hardErrors, [], 'Expected no hard errors: ' + JSON.stringify(hardErrors)); registry = buildRegistry(capMap); const runtimeKeys = Object.keys(registry.runtimes).sort(); assert.strictEqual(runtimeKeys.length, 16, 'Expected 16 runtime entries, got: ' + runtimeKeys.join(', ')); for (const id of RUNTIME_IDS) { assert.ok( Object.prototype.hasOwnProperty.call(registry.runtimes, id), 'runtimes index must contain "' + id + '"', ); } }); test('every runtimes entry has role: "runtime"', () => { const { capMap } = loadAndValidate(new Set()); registry = buildRegistry(capMap); for (const id of RUNTIME_IDS) { assert.strictEqual( registry.runtimes[id] && registry.runtimes[id].role, 'runtime', 'runtimes["' + id + '"].role must be "runtime"', ); } }); test('every runtimes entry has all 6 axes present in runtime object', () => { const { capMap } = loadAndValidate(new Set()); registry = buildRegistry(capMap); const requiredAxes = ['configHome', 'configFormat', 'artifactLayout', 'commandStyle', 'hooksSurface', 'sandboxTier']; for (const id of RUNTIME_IDS) { const rt = registry.runtimes[id] && registry.runtimes[id].runtime; assert.ok(rt, 'runtimes["' + id + '"].runtime must exist'); for (const axis of requiredAxes) { assert.ok( Object.prototype.hasOwnProperty.call(rt, axis), 'runtimes["' + id + '"].runtime.' + axis + ' must be present', ); } // supportTier also required assert.ok( rt.supportTier === 1 || rt.supportTier === 2, 'runtimes["' + id + '"].runtime.supportTier must be 1 or 2 (got: ' + rt.supportTier + ')', ); } }); }); // ── 24b. Sample axis value assertions ──────────────────────────────────────── describe('ADR-1016 phase 5a: sample axis value assertions', () => { test('codex: commandStyle === shell-var and sandboxTier === codex-agent-sandbox', () => { const { capMap } = loadAndValidate(new Set()); const registry = buildRegistry(capMap); const rt = registry.runtimes['codex'].runtime; assert.strictEqual(rt.commandStyle, 'shell-var', 'codex.commandStyle must be "shell-var"'); assert.strictEqual(rt.sandboxTier, 'codex-agent-sandbox', 'codex.sandboxTier must be "codex-agent-sandbox"'); }); test('codex: configHome.kind === dot-home, name === .codex', () => { const { capMap } = loadAndValidate(new Set()); const registry = buildRegistry(capMap); const rt = registry.runtimes['codex'].runtime; assert.strictEqual(rt.configHome.kind, 'dot-home', 'codex.configHome.kind must be "dot-home"'); assert.strictEqual(rt.configHome.name, '.codex', 'codex.configHome.name must be ".codex"'); assert.ok(Array.isArray(rt.configHome.env) && rt.configHome.env.includes('CODEX_HOME'), 'codex.configHome.env must include "CODEX_HOME"'); }); test('claude: commandStyle === slash-hyphen, sandboxTier === none', () => { const { capMap } = loadAndValidate(new Set()); const registry = buildRegistry(capMap); const rt = registry.runtimes['claude'].runtime; assert.strictEqual(rt.commandStyle, 'slash-hyphen', 'claude.commandStyle must be "slash-hyphen"'); assert.strictEqual(rt.sandboxTier, 'none', 'claude.sandboxTier must be "none"'); }); test('claude: configHome.kind === dot-home', () => { const { capMap } = loadAndValidate(new Set()); const registry = buildRegistry(capMap); const rt = registry.runtimes['claude'].runtime; assert.strictEqual(rt.configHome.kind, 'dot-home', 'claude.configHome.kind must be "dot-home"'); }); test('antigravity: configHome.kind === dot-home-nested with parent .gemini', () => { const { capMap } = loadAndValidate(new Set()); const registry = buildRegistry(capMap); const rt = registry.runtimes['antigravity'].runtime; assert.strictEqual(rt.configHome.kind, 'dot-home-nested', 'antigravity.configHome.kind must be "dot-home-nested"'); assert.strictEqual(rt.configHome.parent, '.gemini', 'antigravity.configHome.parent must be ".gemini"'); assert.ok(Array.isArray(rt.configHome.probe), 'antigravity.configHome.probe must be an array'); assert.ok(rt.configHome.probe.length > 0, 'antigravity.configHome.probe must be non-empty'); }); test('kilo: configHome.skillsHome is present', () => { const { capMap } = loadAndValidate(new Set()); const registry = buildRegistry(capMap); const rt = registry.runtimes['kilo'].runtime; assert.ok(rt.configHome.skillsHome, 'kilo.configHome.skillsHome must be present'); assert.ok(typeof rt.configHome.skillsHome.kind === 'string', 'kilo.configHome.skillsHome.kind must be a string'); }); test('windsurf: configHome.kind === dot-home-nested with parent .codeium', () => { const { capMap } = loadAndValidate(new Set()); const registry = buildRegistry(capMap); const rt = registry.runtimes['windsurf'].runtime; assert.strictEqual(rt.configHome.kind, 'dot-home-nested', 'windsurf.configHome.kind must be "dot-home-nested"'); assert.strictEqual(rt.configHome.parent, '.codeium', 'windsurf.configHome.parent must be ".codeium"'); }); test('kimi: configHome.kind === generic-agents-root', () => { const { capMap } = loadAndValidate(new Set()); const registry = buildRegistry(capMap); const rt = registry.runtimes['kimi'].runtime; assert.strictEqual(rt.configHome.kind, 'generic-agents-root', 'kimi.configHome.kind must be "generic-agents-root"'); }); test('antigravity: hookEvents === gemini', () => { const { capMap } = loadAndValidate(new Set()); const registry = buildRegistry(capMap); const rt = registry.runtimes['antigravity'].runtime; assert.strictEqual(rt.hookEvents, 'gemini', 'antigravity.hookEvents must be "gemini"'); }); test('opencode: hooksSurface === none, no hookEvents (registers zero lifecycle hooks)', () => { const { capMap } = loadAndValidate(new Set()); const registry = buildRegistry(capMap); const rt = registry.runtimes['opencode'].runtime; assert.strictEqual(rt.hooksSurface, 'none', 'opencode.hooksSurface must be "none" (no managed lifecycle hooks)'); assert.ok( !Object.prototype.hasOwnProperty.call(rt, 'hookEvents'), 'opencode.runtime must NOT have hookEvents (no lifecycle hook registration)', ); }); test('kilo: hooksSurface === none, no hookEvents (registers zero lifecycle hooks)', () => { const { capMap } = loadAndValidate(new Set()); const registry = buildRegistry(capMap); const rt = registry.runtimes['kilo'].runtime; assert.strictEqual(rt.hooksSurface, 'none', 'kilo.hooksSurface must be "none" (no managed lifecycle hooks)'); assert.ok( !Object.prototype.hasOwnProperty.call(rt, 'hookEvents'), 'kilo.runtime must NOT have hookEvents (no lifecycle hook registration)', ); }); test('kimi: configHome.probeExists === "skills" (probe selects first candidate with skills/ dir)', () => { const { capMap } = loadAndValidate(new Set()); const registry = buildRegistry(capMap); const rt = registry.runtimes['kimi'].runtime; assert.strictEqual( rt.configHome.probeExists, 'skills', 'kimi.configHome.probeExists must be "skills" (selects first probe candidate where /skills exists)', ); }); test('copilot/trae/kimi/windsurf/cline/opencode/kilo: hooksSurface none or copilot-inline/cline-rules, no hookEvents', () => { const { capMap } = loadAndValidate(new Set()); const registry = buildRegistry(capMap); // opencode and kilo register ZERO lifecycle hooks → hooksSurface none, no hookEvents const noEventsRuntimes = ['trae', 'kimi', 'windsurf', 'opencode', 'kilo']; for (const id of noEventsRuntimes) { const rt = registry.runtimes[id].runtime; assert.ok( !Object.prototype.hasOwnProperty.call(rt, 'hookEvents'), id + '.runtime must NOT have hookEvents (no hook events for this runtime)', ); } // cline has cline-rules surface but no hookEvents const clineRt = registry.runtimes['cline'].runtime; assert.strictEqual(clineRt.hooksSurface, 'cline-rules', 'cline.hooksSurface must be "cline-rules"'); assert.ok( !Object.prototype.hasOwnProperty.call(clineRt, 'hookEvents'), 'cline.runtime must NOT have hookEvents', ); // copilot has copilot-inline surface but no hookEvents const copilotRt = registry.runtimes['copilot'].runtime; assert.strictEqual(copilotRt.hooksSurface, 'copilot-inline', 'copilot.hooksSurface must be "copilot-inline"'); assert.ok( !Object.prototype.hasOwnProperty.call(copilotRt, 'hookEvents'), 'copilot.runtime must NOT have hookEvents', ); }); test('codex: hooksSurface === codex-hooks-json, cursor: hooksSurface === cursor-hooks-json', () => { const { capMap } = loadAndValidate(new Set()); const registry = buildRegistry(capMap); assert.strictEqual(registry.runtimes['codex'].runtime.hooksSurface, 'codex-hooks-json', 'codex.hooksSurface must be "codex-hooks-json"'); assert.strictEqual(registry.runtimes['cursor'].runtime.hooksSurface, 'cursor-hooks-json', 'cursor.hooksSurface must be "cursor-hooks-json"'); }); test('tier-1 runtimes: claude, codex, antigravity have supportTier === 1', () => { const { capMap } = loadAndValidate(new Set()); const registry = buildRegistry(capMap); for (const id of ['claude', 'codex', 'antigravity']) { assert.strictEqual( registry.runtimes[id].runtime.supportTier, 1, id + '.runtime.supportTier must be 1 (tier-1 support)', ); } }); test('tier-2 runtimes: gemini through windsurf have supportTier === 2', () => { const { capMap } = loadAndValidate(new Set()); const registry = buildRegistry(capMap); const tier2 = ['gemini', 'cursor', 'opencode', 'kilo', 'copilot', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline', 'kimi', 'windsurf']; for (const id of tier2) { assert.strictEqual( registry.runtimes[id].runtime.supportTier, 2, id + '.runtime.supportTier must be 2 (tier-2 support)', ); } }); }); // ── 24c. Closed-vocab REJECTION tests ──────────────────────────────────────── describe('ADR-1016 phase 5a: closed-vocab rejection — tightened validateRuntimeBody', () => { test('bad configHome.kind → validation error', () => { const cap = makeRuntimeCap({ id: 'test-rt', runtime: { configHome: { kind: 'custom-home', name: '.test', env: [] } } }); const errors = validateCapability(cap, 'test-rt'); assert.ok( errors.some((e) => e.includes('configHome') && e.includes('kind')), 'Expected configHome.kind error, got: ' + JSON.stringify(errors), ); }); test('configHome is a string (old shape) → validation error', () => { const cap = makeRuntimeCap({ id: 'test-rt', runtime: { configHome: '~/.test-rt' } }); const errors = validateCapability(cap, 'test-rt'); assert.ok( errors.some((e) => e.includes('configHome')), 'Expected configHome object error (string not accepted), got: ' + JSON.stringify(errors), ); }); test('dot-home-nested without parent → validation error', () => { const cap = makeRuntimeCap({ id: 'test-rt', runtime: { configHome: { kind: 'dot-home-nested', name: 'foo', env: [] } } }); const errors = validateCapability(cap, 'test-rt'); assert.ok( errors.some((e) => e.includes('parent') && e.includes('dot-home-nested')), 'Expected parent required for dot-home-nested, got: ' + JSON.stringify(errors), ); }); test('bad commandStyle → validation error', () => { const cap = makeRuntimeCap({ id: 'test-rt', runtime: { commandStyle: 'slash-colon' } }); const errors = validateCapability(cap, 'test-rt'); assert.ok( errors.some((e) => e.includes('commandStyle')), 'Expected commandStyle error, got: ' + JSON.stringify(errors), ); }); test('bad hooksSurface → validation error', () => { const cap = makeRuntimeCap({ id: 'test-rt', runtime: { hooksSurface: 'custom-hooks' } }); const errors = validateCapability(cap, 'test-rt'); assert.ok( errors.some((e) => e.includes('hooksSurface')), 'Expected hooksSurface error, got: ' + JSON.stringify(errors), ); }); test('bad hookEvents → validation error', () => { const cap = makeRuntimeCap({ id: 'test-rt', runtime: { hookEvents: 'my-dialect' } }); const errors = validateCapability(cap, 'test-rt'); assert.ok( errors.some((e) => e.includes('hookEvents')), 'Expected hookEvents error, got: ' + JSON.stringify(errors), ); }); test('bad sandboxTier → validation error', () => { const cap = makeRuntimeCap({ id: 'test-rt', runtime: { sandboxTier: 'workspace-sandbox' } }); const errors = validateCapability(cap, 'test-rt'); assert.ok( errors.some((e) => e.includes('sandboxTier')), 'Expected sandboxTier error, got: ' + JSON.stringify(errors), ); }); test('artifactLayout is an array (old shape) → validation error', () => { const cap = makeRuntimeCap({ id: 'test-rt', runtime: { artifactLayout: [] } }); const errors = validateCapability(cap, 'test-rt'); assert.ok( errors.some((e) => e.includes('artifactLayout')), 'Expected artifactLayout shape error (old array not accepted), got: ' + JSON.stringify(errors), ); }); test('bad ArtifactKind.kind in artifactLayout → validation error', () => { const cap = makeRuntimeCap({ id: 'test-rt', runtime: { artifactLayout: { global: [{ kind: 'magic-kind', destSubpath: 'x', prefix: 'g-', nesting: 'flat', recursive: false, converter: null }], local: [], }, }, }); const errors = validateCapability(cap, 'test-rt'); assert.ok( errors.some((e) => e.includes('kind')), 'Expected ArtifactKind.kind error, got: ' + JSON.stringify(errors), ); }); test('bad ArtifactKind.nesting → validation error', () => { const cap = makeRuntimeCap({ id: 'test-rt', runtime: { artifactLayout: { global: [{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', nesting: 'deep', recursive: false, converter: null }], local: [], }, }, }); const errors = validateCapability(cap, 'test-rt'); assert.ok( errors.some((e) => e.includes('nesting')), 'Expected nesting error, got: ' + JSON.stringify(errors), ); }); test('valid runtime descriptor passes validateCapability with no errors', () => { const cap = makeRuntimeCap({ id: 'test-rt' }); const errors = validateCapability(cap, 'test-rt'); assert.deepEqual(errors, [], 'Expected no errors for valid runtime cap: ' + JSON.stringify(errors)); }); }); // ── 24d. validateConfigHome + validateArtifactLayout unit tests ─────────────── describe('ADR-1016 phase 5a: validateConfigHome unit tests', () => { test('valid dot-home with env → no errors', () => { const errors = validateConfigHome('test', { kind: 'dot-home', name: '.test', env: ['TEST_DIR'] }); assert.deepEqual(errors, []); }); test('valid dot-home-nested with parent → no errors', () => { const errors = validateConfigHome('test', { kind: 'dot-home-nested', name: 'foo', parent: '.bar', env: [] }); assert.deepEqual(errors, []); }); test('valid xdg → no errors', () => { const errors = validateConfigHome('test', { kind: 'xdg', name: 'opencode', env: ['OPENCODE_CONFIG_DIR', 'XDG_CONFIG_HOME'] }); assert.deepEqual(errors, []); }); test('valid generic-agents-root with probe → no errors', () => { const errors = validateConfigHome('test', { kind: 'generic-agents-root', name: 'agents', env: ['KIMI_CONFIG_DIR'], probe: ['~/.config/agents'] }); assert.deepEqual(errors, []); }); test('null configHome → error', () => { const errors = validateConfigHome('test', null); assert.ok(errors.length > 0 && errors.some((e) => e.includes('configHome'))); }); test('string configHome → error', () => { const errors = validateConfigHome('test', '~/.test'); assert.ok(errors.length > 0 && errors.some((e) => e.includes('configHome'))); }); test('unknown kind → error mentioning the valid set', () => { const errors = validateConfigHome('test', { kind: 'unknown', name: '.x', env: [] }); assert.ok(errors.some((e) => e.includes('kind') && e.includes('dot-home')), 'Error should list valid kinds, got: ' + JSON.stringify(errors)); }); test('dot-home-nested missing parent → error', () => { const errors = validateConfigHome('test', { kind: 'dot-home-nested', name: 'x', env: [] }); assert.ok(errors.some((e) => e.includes('parent'))); }); test('skillsHome with valid kind → no errors', () => { const errors = validateConfigHome('test', { kind: 'xdg', name: 'kilo', env: [], skillsHome: { kind: 'dot-home', name: '.kilo', env: [] }, }); assert.deepEqual(errors, []); }); test('skillsHome with bad kind → error', () => { const errors = validateConfigHome('test', { kind: 'xdg', name: 'kilo', env: [], skillsHome: { kind: 'exotic', name: '.kilo', env: [] }, }); assert.ok(errors.some((e) => e.includes('skillsHome') && e.includes('kind'))); }); }); describe('ADR-1016 phase 5a: validateArtifactLayout unit tests', () => { test('valid empty global/local → no errors', () => { const errors = validateArtifactLayout('test', { global: [], local: [] }); assert.deepEqual(errors, []); }); test('valid skills entry in global → no errors', () => { const errors = validateArtifactLayout('test', { global: [{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', nesting: 'flat', recursive: false, converter: null }], local: [], }); assert.deepEqual(errors, []); }); test('array (old shape) → error', () => { const errors = validateArtifactLayout('test', []); assert.ok(errors.some((e) => e.includes('artifactLayout'))); }); test('missing global → error', () => { const errors = validateArtifactLayout('test', { local: [] }); assert.ok(errors.some((e) => e.includes('global'))); }); test('missing local → error', () => { const errors = validateArtifactLayout('test', { global: [] }); assert.ok(errors.some((e) => e.includes('local'))); }); test('bad kind in global[0] → error', () => { const errors = validateArtifactLayout('test', { global: [{ kind: 'bad-kind', destSubpath: 'x', prefix: '', nesting: 'flat', recursive: false, converter: null }], local: [], }); assert.ok(errors.some((e) => e.includes('kind'))); }); test('bad nesting in global[0] → error', () => { const errors = validateArtifactLayout('test', { global: [{ kind: 'skills', destSubpath: 'x', prefix: '', nesting: 'trilateral', recursive: false, converter: null }], local: [], }); assert.ok(errors.some((e) => e.includes('nesting'))); }); test('kimi-agents kind → no errors', () => { const errors = validateArtifactLayout('test', { global: [{ kind: 'kimi-agents', destSubpath: 'agents', prefix: 'gsd', nesting: 'flat', recursive: false, converter: null }], local: [], }); assert.deepEqual(errors, []); }); }); // ── 24d-extra. FIX 3: tightened validateRuntimeBody / validateConfigHome ────── describe('FIX 3: tightened runtime validator — configHome.env required', () => { test('configHome missing env → validation error', () => { // env is now required; omitting it must produce an error const cap = makeRuntimeCap({ id: 'test-rt', runtime: { configHome: { kind: 'dot-home', name: '.test-rt' } }, // no env }); const errors = validateCapability(cap, 'test-rt'); assert.ok( errors.some((e) => e.includes('env') && (e.includes('required') || e.includes('array'))), 'Expected configHome.env required error, got: ' + JSON.stringify(errors), ); }); test('configHome with env: [] (empty array) is accepted', () => { const cap = makeRuntimeCap({ id: 'test-rt', runtime: { configHome: { kind: 'dot-home', name: '.test-rt', env: [] } }, }); const errors = validateCapability(cap, 'test-rt'); const envErrors = errors.filter((e) => e.includes('env')); assert.deepEqual(envErrors, [], 'Empty env array should be accepted, got: ' + JSON.stringify(envErrors)); }); test('configHome with env: null → validation error (not an array)', () => { const cap = makeRuntimeCap({ id: 'test-rt', runtime: { configHome: { kind: 'dot-home', name: '.test-rt', env: null } }, }); const errors = validateCapability(cap, 'test-rt'); assert.ok( errors.some((e) => e.includes('env')), 'Expected env error for null, got: ' + JSON.stringify(errors), ); }); }); describe('FIX 3: tightened runtime validator — skillsHome recursive validation', () => { test('skillsHome with bad kind → validation error via full recursive check', () => { const cap = makeRuntimeCap({ id: 'test-rt', runtime: { configHome: { kind: 'xdg', name: 'test', env: [], skillsHome: { kind: 'exotic-kind', name: '.test', env: [] }, }, }, }); const errors = validateCapability(cap, 'test-rt'); assert.ok( errors.some((e) => e.includes('skillsHome') && e.includes('kind')), 'Expected skillsHome.kind error for exotic-kind, got: ' + JSON.stringify(errors), ); }); test('skillsHome missing env → validation error (env required in recursive check)', () => { const cap = makeRuntimeCap({ id: 'test-rt', runtime: { configHome: { kind: 'xdg', name: 'test', env: [], skillsHome: { kind: 'dot-home', name: '.test' }, // no env }, }, }); const errors = validateCapability(cap, 'test-rt'); assert.ok( errors.some((e) => e.includes('skillsHome') && e.includes('env')), 'Expected skillsHome.env required error, got: ' + JSON.stringify(errors), ); }); test('kilo descriptor: skillsHome passes full validation', () => { // kilo has skillsHome: { kind: "dot-home", name: ".kilo", env: [] } — must pass const { capMap, errors } = loadAndValidate(new Set()); const hardErrors = errors.filter((e) => !e.includes('pending-migration')); assert.deepEqual(hardErrors, [], 'No hard errors expected for kilo, got: ' + JSON.stringify(hardErrors)); const kiloRt = capMap.get('kilo'); assert.ok(kiloRt, 'kilo must be in capMap'); assert.ok(kiloRt.runtime.configHome.skillsHome, 'kilo.configHome.skillsHome must be present'); assert.strictEqual(kiloRt.runtime.configHome.skillsHome.kind, 'dot-home'); assert.strictEqual(kiloRt.runtime.configHome.skillsHome.name, '.kilo'); }); }); describe('FIX 3: tightened runtime validator — ArtifactKind field type checks', () => { test('ArtifactKind with recursive: "yes" (non-boolean) → validation error', () => { const cap = makeRuntimeCap({ id: 'test-rt', runtime: { artifactLayout: { global: [{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', nesting: 'flat', recursive: 'yes', converter: null }], local: [], }, }, }); const errors = validateCapability(cap, 'test-rt'); assert.ok( errors.some((e) => e.includes('recursive') && e.includes('boolean')), 'Expected recursive non-boolean error, got: ' + JSON.stringify(errors), ); }); test('ArtifactKind with recursive: true (boolean) → no error', () => { const cap = makeRuntimeCap({ id: 'test-rt', runtime: { artifactLayout: { global: [{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', nesting: 'flat', recursive: true, converter: null }], local: [], }, }, }); const errors = validateCapability(cap, 'test-rt'); const recursiveErrors = errors.filter((e) => e.includes('recursive')); assert.deepEqual(recursiveErrors, [], 'recursive: true must be accepted, got: ' + JSON.stringify(recursiveErrors)); }); test('ArtifactKind with prefix: 42 (non-string) → validation error', () => { const cap = makeRuntimeCap({ id: 'test-rt', runtime: { artifactLayout: { global: [{ kind: 'skills', destSubpath: 'skills', prefix: 42, nesting: 'flat', recursive: false, converter: null }], local: [], }, }, }); const errors = validateCapability(cap, 'test-rt'); assert.ok( errors.some((e) => e.includes('prefix') && e.includes('string')), 'Expected prefix non-string error, got: ' + JSON.stringify(errors), ); }); test('ArtifactKind with converter: 123 (non-string, non-null) → validation error', () => { const cap = makeRuntimeCap({ id: 'test-rt', runtime: { artifactLayout: { global: [{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', nesting: 'flat', recursive: false, converter: 123 }], local: [], }, }, }); const errors = validateCapability(cap, 'test-rt'); assert.ok( errors.some((e) => e.includes('converter') && (e.includes('string') || e.includes('null'))), 'Expected converter type error, got: ' + JSON.stringify(errors), ); }); test('ArtifactKind with converter: null → no error', () => { const cap = makeRuntimeCap({ id: 'test-rt', runtime: { artifactLayout: { global: [{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', nesting: 'flat', recursive: false, converter: null }], local: [], }, }, }); const errors = validateCapability(cap, 'test-rt'); const converterErrors = errors.filter((e) => e.includes('converter')); assert.deepEqual(converterErrors, [], 'converter: null must be accepted, got: ' + JSON.stringify(converterErrors)); }); test('ArtifactKind with converter: "convertClaudeCommandToKiloSkill" (string) → no error', () => { const cap = makeRuntimeCap({ id: 'test-rt', runtime: { artifactLayout: { global: [{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', nesting: 'flat', recursive: true, converter: 'convertClaudeCommandToKiloSkill' }], local: [], }, }, }); const errors = validateCapability(cap, 'test-rt'); const converterErrors = errors.filter((e) => e.includes('converter')); assert.deepEqual(converterErrors, [], 'converter: string must be accepted, got: ' + JSON.stringify(converterErrors)); }); }); describe('FIX 3: tightened runtime validator — probeExists optional string', () => { test('probeExists: "skills" is accepted', () => { const errors = validateConfigHome('test', { kind: 'generic-agents-root', name: 'agents', env: ['KIMI_CONFIG_DIR'], probe: ['~/.config/agents', '~/.agents'], probeExists: 'skills', }); assert.deepEqual(errors, [], 'probeExists: "skills" must be accepted, got: ' + JSON.stringify(errors)); }); test('probeExists: "" (empty string) → error', () => { const errors = validateConfigHome('test', { kind: 'generic-agents-root', name: 'agents', env: [], probeExists: '', }); assert.ok( errors.some((e) => e.includes('probeExists')), 'Expected probeExists empty string error, got: ' + JSON.stringify(errors), ); }); test('probeExists: 42 (non-string) → error', () => { const errors = validateConfigHome('test', { kind: 'generic-agents-root', name: 'agents', env: [], probeExists: 42, }); assert.ok( errors.some((e) => e.includes('probeExists')), 'Expected probeExists non-string error, got: ' + JSON.stringify(errors), ); }); test('probeExists absent → no error (optional)', () => { const errors = validateConfigHome('test', { kind: 'generic-agents-root', name: 'agents', env: ['KIMI_CONFIG_DIR'], probe: ['~/.config/agents'], }); const probeExistsErrors = errors.filter((e) => e.includes('probeExists')); assert.deepEqual(probeExistsErrors, [], 'probeExists is optional — no error when absent, got: ' + JSON.stringify(probeExistsErrors)); }); }); // ── 24e. Closed-vocab set exports are correct ───────────────────────────────── describe('ADR-1016 phase 5a: closed-vocab set exports', () => { test('VALID_CONFIG_HOME_KINDS has exactly the 4 expected values', () => { assert.ok(VALID_CONFIG_HOME_KINDS instanceof Set); for (const v of ['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root']) { assert.ok(VALID_CONFIG_HOME_KINDS.has(v), 'VALID_CONFIG_HOME_KINDS must contain "' + v + '"'); } assert.strictEqual(VALID_CONFIG_HOME_KINDS.size, 4, 'VALID_CONFIG_HOME_KINDS must have exactly 4 members'); }); test('VALID_COMMAND_STYLES has exactly 2 values', () => { assert.ok(VALID_COMMAND_STYLES instanceof Set); assert.ok(VALID_COMMAND_STYLES.has('slash-hyphen')); assert.ok(VALID_COMMAND_STYLES.has('shell-var')); assert.strictEqual(VALID_COMMAND_STYLES.size, 2); }); test('VALID_HOOKS_SURFACES has exactly 6 values', () => { assert.ok(VALID_HOOKS_SURFACES instanceof Set); for (const v of ['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'none']) { assert.ok(VALID_HOOKS_SURFACES.has(v), 'VALID_HOOKS_SURFACES must contain "' + v + '"'); } assert.strictEqual(VALID_HOOKS_SURFACES.size, 6); }); test('VALID_HOOK_EVENTS has exactly 3 values', () => { assert.ok(VALID_HOOK_EVENTS instanceof Set); for (const v of ['claude', 'gemini', 'opencode-subset']) { assert.ok(VALID_HOOK_EVENTS.has(v), 'VALID_HOOK_EVENTS must contain "' + v + '"'); } assert.strictEqual(VALID_HOOK_EVENTS.size, 3); }); test('VALID_SANDBOX_TIERS has exactly 2 values', () => { assert.ok(VALID_SANDBOX_TIERS instanceof Set); assert.ok(VALID_SANDBOX_TIERS.has('none')); assert.ok(VALID_SANDBOX_TIERS.has('codex-agent-sandbox')); assert.strictEqual(VALID_SANDBOX_TIERS.size, 2); }); test('VALID_ARTIFACT_KIND_NAMES has exactly 4 values', () => { assert.ok(VALID_ARTIFACT_KIND_NAMES instanceof Set); for (const v of ['commands', 'agents', 'skills', 'kimi-agents']) { assert.ok(VALID_ARTIFACT_KIND_NAMES.has(v), 'VALID_ARTIFACT_KIND_NAMES must contain "' + v + '"'); } assert.strictEqual(VALID_ARTIFACT_KIND_NAMES.size, 4); }); test('VALID_ARTIFACT_NESTINGS has exactly 2 values', () => { assert.ok(VALID_ARTIFACT_NESTINGS instanceof Set); assert.ok(VALID_ARTIFACT_NESTINGS.has('flat')); assert.ok(VALID_ARTIFACT_NESTINGS.has('nested')); assert.strictEqual(VALID_ARTIFACT_NESTINGS.size, 2); }); }); // ─── 25. ADR-857 phase 5e: closed ConverterName enum (Part B) ───────────────── describe('ADR-857 phase 5e: VALID_CONVERTER_NAMES closed enum', () => { test('VALID_CONVERTER_NAMES has exactly 25 entries (16 command/skill/workflow + 9 agent converters)', () => { assert.ok(VALID_CONVERTER_NAMES instanceof Set, 'VALID_CONVERTER_NAMES must be a Set'); assert.strictEqual(VALID_CONVERTER_NAMES.size, 25, 'VALID_CONVERTER_NAMES must have exactly 25 entries, got: ' + VALID_CONVERTER_NAMES.size); }); test('VALID_CONVERTER_NAMES contains all expected converter names', () => { const expected = [ // command/skill converters (pre-existing) 'convertClaudeCommandToAntigravitySkill', 'convertClaudeCommandToAugmentSkill', 'convertClaudeCommandToClineSkill', 'convertClaudeCommandToClaudeSkill', 'convertClaudeCommandToCodebuddyCommand', 'convertClaudeCommandToCodebuddySkill', 'convertClaudeCommandToCodexSkill', 'convertClaudeCommandToCopilotSkill', 'convertClaudeCommandToCursorCommand', 'convertClaudeCommandToCursorSkill', 'convertClaudeCommandToKiloSkill', 'convertClaudeCommandToKimiSkill', 'convertClaudeCommandToOpencodeSkill', 'convertClaudeCommandToTraeSkill', 'convertClaudeCommandToWindsurfSkill', 'convertClaudeCommandToWindsurfWorkflow', // agent converters (#1173 — descriptor-driven agent conversion wiring) 'convertClaudeAgentToCopilotAgent', 'convertClaudeAgentToAntigravityAgent', 'convertClaudeAgentToCursorAgent', 'convertClaudeAgentToWindsurfAgent', 'convertClaudeAgentToAugmentAgent', 'convertClaudeAgentToTraeAgent', 'convertClaudeAgentToCodebuddyAgent', 'convertClaudeAgentToClineAgent', 'convertClaudeAgentToCodexAgent', ]; for (const name of expected) { assert.ok(VALID_CONVERTER_NAMES.has(name), 'VALID_CONVERTER_NAMES must contain "' + name + '"'); } }); }); describe('ADR-857 phase 5e: validateArtifactKindEntry — ConverterName enum (FAIL-FIRST regression)', () => { // Helper to build a minimal valid ArtifactKind entry function makeArtifactEntry(overrides) { return { kind: 'skills', destSubpath: 'skills', nesting: 'flat', prefix: 'gsd-', recursive: false, converter: null, ...overrides, }; } // FAIL-FIRST: unknown converter name must be rejected test('REJECTED: converter "convertClaudeCommandToUnknownRuntime" is not a known ConverterName', () => { const entry = makeArtifactEntry({ converter: 'convertClaudeCommandToUnknownRuntime' }); const errors = validateArtifactKindEntry('test-cap', entry, 'artifactLayout.global[0]'); assert.ok(errors.length > 0, 'Expected rejection for unknown converter name, got: ' + JSON.stringify(errors)); assert.ok( errors.some((e) => e.includes('convertClaudeCommandToUnknownRuntime') && e.includes('not a known ConverterName')), 'Error must name the bad converter and say "not a known ConverterName", got: ' + JSON.stringify(errors), ); }); // Valid known name must be accepted test('ACCEPTED: converter "convertClaudeCommandToKiloSkill" is a known ConverterName', () => { const entry = makeArtifactEntry({ converter: 'convertClaudeCommandToKiloSkill' }); const errors = validateArtifactKindEntry('test-cap', entry, 'artifactLayout.global[0]'); const converterErrors = errors.filter((e) => e.includes('converter')); assert.deepEqual(converterErrors, [], 'Known converter name must be accepted, got: ' + JSON.stringify(converterErrors)); }); // null converter is always accepted (means "no conversion") test('ACCEPTED: converter: null is always accepted', () => { const entry = makeArtifactEntry({ converter: null }); const errors = validateArtifactKindEntry('test-cap', entry, 'artifactLayout.global[0]'); const converterErrors = errors.filter((e) => e.includes('converter')); assert.deepEqual(converterErrors, [], 'converter: null must always be accepted, got: ' + JSON.stringify(converterErrors)); }); // Parity: all 16 runtime descriptors must have converters in the valid set (or null) test('all 16 real runtime descriptors have converters in VALID_CONVERTER_NAMES or null', () => { const { capMap, errors } = loadAndValidate(new Set()); const hardErrors = errors.filter((e) => !e.includes('pending-migration')); assert.deepEqual(hardErrors, [], 'Expected no hard errors from real capabilities, got: ' + JSON.stringify(hardErrors)); const runtimeIds = [ 'claude', 'codex', 'antigravity', 'gemini', 'cursor', 'opencode', 'kilo', 'copilot', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline', 'kimi', 'windsurf', ]; for (const id of runtimeIds) { const cap = capMap.get(id); assert.ok(cap, 'capMap must contain "' + id + '"'); const r = cap.runtime; const allEntries = [ ...(r.artifactLayout && Array.isArray(r.artifactLayout.global) ? r.artifactLayout.global : []), ...(r.artifactLayout && Array.isArray(r.artifactLayout.local) ? r.artifactLayout.local : []), ]; for (let i = 0; i < allEntries.length; i++) { const entry = allEntries[i]; if (entry.converter !== null) { assert.ok( VALID_CONVERTER_NAMES.has(entry.converter), id + ' artifactLayout[' + i + '].converter "' + entry.converter + '" is not in VALID_CONVERTER_NAMES', ); } } } }); // validateCapability end-to-end: unknown converter propagates through the full chain test('validateCapability REJECTS a runtime cap with unknown converter in artifactLayout', () => { const cap = { id: 'test-rt', role: 'runtime', title: 'Test Runtime', description: 'Test runtime with unknown converter.', tier: 'core', requires: [], runtime: { configHome: { kind: 'dot-home', name: '.test-rt', env: [] }, configFormat: 'settings-json', artifactLayout: { global: [{ kind: 'skills', destSubpath: 'skills', nesting: 'flat', prefix: 'gsd-', recursive: false, converter: 'convertClaudeCommandToUnknownRuntime', }], local: [], }, commandStyle: 'slash-hyphen', hooksSurface: 'settings-json', sandboxTier: 'none', supportTier: 1, }, }; const errors = validateCapability(cap, 'test-rt'); assert.ok(errors.length > 0, 'Expected validation errors for unknown converter, got: ' + JSON.stringify(errors)); assert.ok( errors.some((e) => e.includes('convertClaudeCommandToUnknownRuntime') && e.includes('not a known ConverterName')), 'Error must mention the unknown converter name, got: ' + JSON.stringify(errors), ); }); }); // ─── 26. ADR-857 phase 5e: configFormat ↔ installSurface parity gate (Part A) ─ describe('ADR-857 phase 5e: configFormat ↔ installSurface parity gate', () => { // Helper: build a minimal runtime capMap for parity tests. // installSurface must be supplied for any runtime that should be checked by the gate; // omit it (undefined) to simulate a runtime with no installSurface (gate skips it). function makeRuntimeCapMap(runtimeId, configFormat, installSurface) { const runtime = { configHome: { kind: 'dot-home', name: '.' + runtimeId, env: [] }, configFormat, artifactLayout: { global: [], local: [] }, commandStyle: 'slash-hyphen', hooksSurface: 'none', sandboxTier: 'none', supportTier: 1, }; if (installSurface !== undefined) { runtime.installSurface = installSurface; } const cap = { id: runtimeId, role: 'runtime', title: 'Test ' + runtimeId, description: 'Synthetic runtime for parity gate testing.', tier: 'core', requires: [], runtime, }; return new Map([[runtimeId, cap]]); } // FAIL-FIRST: parity mismatch must throw test('THROWS: claude with wrong configFormat "toml" (installSurface=settings-json → expected settings-json)', () => { // claude has installSurface=settings-json → expected configFormat=settings-json // Giving it configFormat=toml must trigger the HARD gate const capMap = makeRuntimeCapMap('claude', 'toml', 'settings-json'); assert.throws( () => runConfigFormatParityGate(capMap), (err) => { assert.ok(err instanceof Error, 'Must throw an Error'); assert.ok( err.message.includes('claude') && err.message.includes('parity gate FAILED'), 'Error must name the runtime and say "parity gate FAILED", got: ' + err.message, ); assert.ok( err.message.includes('settings-json') && err.message.includes('toml'), 'Error must name both the expected and actual configFormat, got: ' + err.message, ); return true; }, ); }); test('THROWS: codex with wrong configFormat "settings-json" (installSurface=codex-toml → expected toml)', () => { const capMap = makeRuntimeCapMap('codex', 'settings-json', 'codex-toml'); assert.throws( () => runConfigFormatParityGate(capMap), (err) => { assert.ok(err instanceof Error); assert.ok(err.message.includes('codex') && err.message.includes('parity gate FAILED')); return true; }, ); }); // All 16 real runtime descriptors must pass the parity gate (true-negative) test('all 16 real runtime descriptors pass the configFormat parity gate (DOES NOT THROW)', () => { const { capMap, errors } = loadAndValidate(new Set()); const hardErrors = errors.filter((e) => !e.includes('pending-migration')); assert.deepEqual(hardErrors, [], 'No hard errors expected: ' + JSON.stringify(hardErrors)); // runConfigFormatParityGate must not throw for the real registry assert.doesNotThrow( () => runConfigFormatParityGate(capMap), 'runConfigFormatParityGate must not throw for the real 16 runtime descriptors', ); }); // buildRegistry must not throw for the real registry (end-to-end integration) test('buildRegistry with real 16 runtime descriptors does not throw (parity gate integrated)', () => { const { capMap } = loadAndValidate(new Set()); assert.doesNotThrow( () => buildRegistry(capMap), 'buildRegistry must not throw for the real registry (parity gate must pass)', ); }); // INSTALL_SURFACE_TO_CONFIG_FORMAT export check test('INSTALL_SURFACE_TO_CONFIG_FORMAT covers all 6 installSurface values with correct mappings', () => { assert.ok(INSTALL_SURFACE_TO_CONFIG_FORMAT instanceof Map, 'Must be a Map'); assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.size, 6, 'Must cover 6 installSurface values'); assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('settings-json'), 'settings-json'); assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('codex-toml'), 'toml'); assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('copilot-instructions'), 'markdown'); assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('cline-rules'), 'markdown-dir'); assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('cursor-hooks-json'), 'none'); assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('profile-marker-only'), 'none'); }); // Feature capabilities (role:feature) are silently ignored by the gate test('feature capabilities (role:feature) are ignored by the parity gate — does not throw', () => { // Use the real UI cap (role:feature, has no installSurface) — gate must pass silently const capMap = new Map([['ui', UI_CAP]]); assert.doesNotThrow( () => runConfigFormatParityGate(capMap), 'Feature capabilities must be ignored by the configFormat parity gate', ); }); // Runtimes with no installSurface in their descriptor are excluded from the gate. // The gate reads installSurface from cap.runtime.installSurface (the descriptor level); // if it is absent (typeof !== 'string'), the runtime is soft-skipped. // NOTE: the gate no longer uses the adapter registry — it reads purely from the descriptor. test('runtime with no installSurface in descriptor (e.g. hypothetical "grok") is excluded from parity gate — does not throw', () => { // 'grok' has no installSurface → gate must soft-skip (typeof r.installSurface !== 'string') const grokCap = { id: 'grok', role: 'runtime', title: 'Grok', description: 'Hypothetical grok runtime', tier: 'core', requires: [], runtime: { configHome: { kind: 'dot-home', name: '.grok', env: [] }, configFormat: 'settings-json', // any value — gate should not check this (no installSurface) artifactLayout: { global: [], local: [] }, commandStyle: 'slash-hyphen', hooksSurface: 'none', sandboxTier: 'none', supportTier: 2, // intentionally no installSurface — gate must skip this entry }, }; const capMap = new Map([['grok', grokCap]]); assert.doesNotThrow( () => runConfigFormatParityGate(capMap), 'Runtimes with no installSurface in their descriptor must be excluded from the parity gate', ); }); }); // ─── 27. ADR-857 phase 5f: cross-field consistency gate rejection tests ──────── describe('ADR-857 phase 5f: cross-field consistency gate rejection tests (DEFECT.GENERATIVE-FIX)', () => { // Helper: build a minimal VALID runtime cap for cross-field rejection tests. // Override any field via the overrides object. function makeValidRuntimeCap(overrides) { const base = { id: 'test-runtime', role: 'runtime', title: 'Test runtime', description: 'Synthetic runtime for cross-field gate rejection testing.', tier: 'core', requires: [], runtime: { configHome: { kind: 'dot-home', name: '.test-runtime', env: [] }, configFormat: 'settings-json', artifactLayout: { global: [], local: [] }, commandStyle: 'slash-hyphen', hooksSurface: 'settings-json', hookEvents: 'claude', sandboxTier: 'none', supportTier: 1, installSurface: 'settings-json', writesSharedSettings: true, permissionWriter: null, extendedHookEvents: [], hostIntegration: { embeddingMode: 'imperative', commandSurface: 'slash-file', dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: false }, modelMode: 'passive', hookBus: 'host', stateIO: 'filesystem', transport: 'mcp', runtime: 'node', }, }, }; if (overrides && typeof overrides === 'object') { for (const [k, v] of Object.entries(overrides)) { if (k === 'runtime' && typeof v === 'object') { Object.assign(base.runtime, v); } else { base[k] = v; } } } return base; } test('REJECTS: installSurface not in VALID_INSTALL_SURFACES → throws validation error', () => { const cap = makeValidRuntimeCap({ runtime: { installSurface: 'bogus-surface' } }); const errors = validateRuntimeBody(cap); assert.ok( errors.some((e) => e.includes('installSurface') && e.includes('bogus-surface')), 'Expected error about invalid installSurface, got: ' + JSON.stringify(errors), ); }); test('REJECTS: permissionWriter not null and not in {opencode,kilo} → throws validation error', () => { const cap = makeValidRuntimeCap({ runtime: { permissionWriter: 'notarealwriter' } }); const errors = validateRuntimeBody(cap); assert.ok( errors.some((e) => e.includes('permissionWriter') && e.includes('notarealwriter')), 'Expected error about invalid permissionWriter, got: ' + JSON.stringify(errors), ); }); test('REJECTS: extendedHookEvents containing a bogus event ("SubagentStopTypo") → throws validation error', () => { const cap = makeValidRuntimeCap({ runtime: { extendedHookEvents: ['SubagentStopTypo'] } }); const errors = validateRuntimeBody(cap); assert.ok( errors.some((e) => e.includes('extendedHookEvents') && e.includes('SubagentStopTypo')), 'Expected error about invalid extendedHookEvents entry, got: ' + JSON.stringify(errors), ); }); test('REJECTS: writesSharedSettings not a boolean → throws validation error', () => { const cap = makeValidRuntimeCap({ runtime: { writesSharedSettings: 'yes' } }); const errors = validateRuntimeBody(cap); assert.ok( errors.some((e) => e.includes('writesSharedSettings') && e.includes('"yes"')), 'Expected error about writesSharedSettings not boolean, got: ' + JSON.stringify(errors), ); }); test('GATE A REJECTS: profile-marker-only + hooksSurface="settings-json" → validation error', () => { // profile-marker-only installSurface only allows hooksSurface='none' const cap = makeValidRuntimeCap({ runtime: { installSurface: 'profile-marker-only', hooksSurface: 'settings-json', configFormat: 'none', // correct for profile-marker-only }, }); const errors = validateRuntimeBody(cap); assert.ok( errors.some((e) => e.includes('hooksSurface') && e.includes('profile-marker-only')), 'Expected GATE A error for profile-marker-only + hooksSurface=settings-json, got: ' + JSON.stringify(errors), ); }); test('GATE B REJECTS: hookEvents="claude" + extendedHookEvents=["BeforeAgent"] → validation error', () => { // BeforeAgent is a Gemini agent-event — requires hookEvents='gemini', not 'claude' const cap = makeValidRuntimeCap({ runtime: { hookEvents: 'claude', extendedHookEvents: ['BeforeAgent'], }, }); const errors = validateRuntimeBody(cap); assert.ok( errors.some((e) => e.includes('BeforeAgent') && e.includes('"gemini"')), 'Expected GATE B error for hookEvents=claude + extendedHookEvents=[BeforeAgent], got: ' + JSON.stringify(errors), ); }); // Verify the new constants are well-formed test('INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES covers all 6 installSurface values', () => { assert.ok(INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES instanceof Map, 'Must be a Map'); assert.strictEqual(INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES.size, 6, 'Must cover 6 installSurface values'); for (const installSurface of VALID_INSTALL_SURFACES) { assert.ok( INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES.has(installSurface), 'INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES must include installSurface "' + installSurface + '"', ); } }); test('VALID_EXTENDED_HOOK_EVENTS covers all 7 known extended events', () => { assert.ok(VALID_EXTENDED_HOOK_EVENTS instanceof Set, 'Must be a Set'); assert.strictEqual(VALID_EXTENDED_HOOK_EVENTS.size, 7, 'Must cover 7 extended hook events'); for (const ev of ['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'BeforeAgent', 'AfterAgent', 'BeforeModel']) { assert.ok(VALID_EXTENDED_HOOK_EVENTS.has(ev), 'Must include event "' + ev + '"'); } }); test('VALID_PERMISSION_WRITERS covers exactly {opencode, kilo}', () => { assert.ok(VALID_PERMISSION_WRITERS instanceof Set, 'Must be a Set'); assert.strictEqual(VALID_PERMISSION_WRITERS.size, 2, 'Must cover 2 permission writers'); assert.ok(VALID_PERMISSION_WRITERS.has('opencode'), 'Must include opencode'); assert.ok(VALID_PERMISSION_WRITERS.has('kilo'), 'Must include kilo'); }); // Confirm the valid base fixture does NOT produce errors (sanity) test('valid runtime fixture produces no validation errors', () => { const cap = makeValidRuntimeCap({}); const errors = validateRuntimeBody(cap); assert.deepEqual(errors, [], 'Valid fixture must produce no errors, got: ' + JSON.stringify(errors)); }); }); // ─── Change A: loadCentralConfigKeys ENOENT vs parse-error distinction ──────── describe('loadCentralConfigKeys — ENOENT vs parse-error (Issue #1124)', () => { test('ENOENT: nonexistent path returns empty Set without throwing or writing stderr', () => { const stderrWrites = []; const origWrite = process.stderr.write.bind(process.stderr); process.stderr.write = (msg, ...rest) => { stderrWrites.push(msg); return origWrite(msg, ...rest); }; let result; try { const nonexistent = path.join(os.tmpdir(), 'cfgkeys-nonexistent-' + Date.now() + '.json'); result = loadCentralConfigKeys(nonexistent); } finally { process.stderr.write = origWrite; } assert.ok(result instanceof Set, 'should return a Set'); assert.strictEqual(result.size, 0, 'Set should be empty for ENOENT'); const warnings = stderrWrites.filter((m) => typeof m === 'string' && m.length > 0); assert.deepEqual(warnings, [], 'No stderr output expected for ENOENT, got: ' + JSON.stringify(warnings)); }); test('malformed JSON: throws and writes stderr containing the file path', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfgkeys-')); const badFile = path.join(tmpDir, 'bad-schema.json'); fs.writeFileSync(badFile, '<<<<<<< HEAD\nnot json\n>>>>>>> main', 'utf8'); const stderrWrites = []; const origWrite = process.stderr.write.bind(process.stderr); process.stderr.write = (msg, ...rest) => { stderrWrites.push(msg); return origWrite(msg, ...rest); }; let thrownErr; try { loadCentralConfigKeys(badFile); } catch (err) { thrownErr = err; } finally { process.stderr.write = origWrite; } // Clean up cleanup(tmpDir); assert.ok(thrownErr !== undefined, 'Expected loadCentralConfigKeys to throw on malformed JSON'); assert.strictEqual(thrownErr.name, 'ExitError', 'thrown error must be an ExitError, got: ' + thrownErr.name); assert.strictEqual(thrownErr.code, 1, 'ExitError must have code 1, got: ' + thrownErr.code); const combined = stderrWrites.join(''); assert.ok( combined.includes(badFile), 'stderr should include the file path, got: ' + combined, ); }); test('valid file: returns Set containing the declared keys', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfgkeys-')); const goodFile = path.join(tmpDir, 'good-schema.json'); fs.writeFileSync(goodFile, JSON.stringify({ validKeys: ['a', 'b'] }), 'utf8'); let result; try { result = loadCentralConfigKeys(goodFile); } finally { cleanup(tmpDir); } assert.ok(result instanceof Set, 'should return a Set'); assert.ok(result.has('a'), "Set should contain 'a'"); assert.ok(result.has('b'), "Set should contain 'b'"); assert.strictEqual(result.size, 2, 'Set should have exactly 2 entries'); }); test('non-ENOENT read error (path is a directory) throws ExitError and warns', () => { // fs.readFileSync on a directory throws EISDIR (code !== 'ENOENT'), which must // hit the non-ENOENT read-error branch: throw ExitError(1) and write stderr. const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfgkeys-dir-')); const stderrWrites = []; const origWrite = process.stderr.write.bind(process.stderr); process.stderr.write = (msg, ...rest) => { stderrWrites.push(msg); return origWrite(msg, ...rest); }; let thrownErr; try { loadCentralConfigKeys(tmpDir); } catch (err) { thrownErr = err; } finally { process.stderr.write = origWrite; } cleanup(tmpDir); assert.ok(thrownErr !== undefined, 'Expected loadCentralConfigKeys to throw on EISDIR (directory path)'); assert.strictEqual(thrownErr.name, 'ExitError', 'thrown error must be an ExitError, got: ' + thrownErr.name); assert.strictEqual(thrownErr.code, 1, 'ExitError must have code 1, got: ' + thrownErr.code); const combined = stderrWrites.join(''); assert.ok( combined.includes(tmpDir), 'stderr should include the directory path, got: ' + combined, ); }); }); // ─── Change B: duplicate-producer invariant at gen time (Issue #1123) ───────── describe('duplicate-producer invariant — same artifact same point (Issue #1123)', () => { // Minimal base capability clone helper (deep-clone UI_CAP then override key fields) function makeFeatureCap(overrides) { return Object.assign(JSON.parse(JSON.stringify(UI_CAP)), overrides); } test('REJECTION: two caps each producing DUP.md at plan:pre → throws with artifact/point/ids in message', () => { // Note: omit 'when' and use config:{} so there are no config-key validation errors. // Caps must pass per-capability validation to enter capMap and trigger the global check. const capA = makeFeatureCap({ id: 'dup-a', skills: ['dup-a-skill'], agents: ['gsd-dup-a-agent'], config: {}, steps: [ { point: 'plan:pre', ref: { skill: 'dup-a-skill' }, produces: ['DUP.md'], consumes: ['CONTEXT.md'], onError: 'skip', }, ], gates: [], contributions: [], }); const capB = makeFeatureCap({ id: 'dup-b', skills: ['dup-b-skill'], agents: ['gsd-dup-b-agent'], config: {}, steps: [ { point: 'plan:pre', ref: { skill: 'dup-b-skill' }, produces: ['DUP.md'], consumes: ['CONTEXT.md'], onError: 'skip', }, ], gates: [], contributions: [], }); // The duplicate-producer check fires during loadAndValidate (in validateConsumesGlobal) // or during buildRegistry — test wraps the entire build flow. const capDir = makeTempCapDir({ 'dup-a': capA, 'dup-b': capB }); let threw = false; let errorMsg = ''; try { const { capMap } = loadAndValidate(new Set(), capDir); buildRegistry(capMap); } catch (err) { threw = true; errorMsg = err.message || String(err); } assert.ok(threw, 'Expected build flow to throw for duplicate producers at the same point'); assert.ok(errorMsg.includes('DUP.md'), 'Error message should mention DUP.md, got: ' + errorMsg); assert.ok(errorMsg.includes('plan:pre'), 'Error message should mention the point, got: ' + errorMsg); assert.ok( errorMsg.includes('dup-a') && errorMsg.includes('dup-b'), 'Error message should mention both cap ids, got: ' + errorMsg, ); }); test('PASSING: same artifact at DIFFERENT points does not trigger duplicate-producer error', () => { // cap-diff-a produces SAME.md at plan:pre; cap-diff-b produces SAME.md at execute:post // Different pointIdx → must not throw the duplicate-producer error. // NOTE: both points must be wired (have render-hooks call sites in host workflows) // to pass the validateHooksWired gen-time guard added in #1196. const capDiffA = makeFeatureCap({ id: 'diff-a', skills: ['diff-a-skill'], agents: ['gsd-diff-a-agent'], config: {}, steps: [ { point: 'plan:pre', ref: { skill: 'diff-a-skill' }, produces: ['SAME.md'], consumes: ['CONTEXT.md'], onError: 'skip', }, ], gates: [], contributions: [], }); const capDiffB = makeFeatureCap({ id: 'diff-b', skills: ['diff-b-skill'], agents: ['gsd-diff-b-agent'], config: {}, steps: [ { point: 'execute:post', ref: { skill: 'diff-b-skill' }, produces: ['SAME.md'], consumes: [], onError: 'skip', }, ], gates: [], contributions: [], }); const capDir = makeTempCapDir({ 'diff-a': capDiffA, 'diff-b': capDiffB }); const { capMap, errors } = loadAndValidate(new Set(), capDir); assert.deepEqual(errors, [], 'Expected no validation errors for different-point fixtures, got: ' + JSON.stringify(errors)); // buildRegistry must NOT throw — if the duplicate-producer bug regressed it would throw here assert.doesNotThrow( () => buildRegistry(capMap), 'Should NOT throw duplicate-producer error for same artifact at DIFFERENT points', ); }); }); // ─── #1196 — discuss loop wiring + wired-point guard ───────────────────────── describe('#1196 — discuss loop wiring + wired-point guard', () => { // ─── Defect 1 — discuss is now wireable ───────────────────────────────────── describe('Defect 1: discuss is a wireable loop host', () => { test('HOST_LOOP_FILES includes discuss-phase.md', () => { assert.ok( Array.isArray(HOST_LOOP_FILES), 'HOST_LOOP_FILES must be an array', ); assert.ok( HOST_LOOP_FILES.includes('gsd-core/workflows/discuss-phase.md'), `HOST_LOOP_FILES must include 'gsd-core/workflows/discuss-phase.md'. Got: ${JSON.stringify(HOST_LOOP_FILES)}`, ); }); test('getWiredLoopPoints(ROOT) contains discuss:pre', () => { const wired = getWiredLoopPoints(ROOT); assert.ok( wired instanceof Set, 'getWiredLoopPoints must return a Set', ); assert.ok( wired.has('discuss:pre'), `getWiredLoopPoints(ROOT) must contain 'discuss:pre'. Got: ${JSON.stringify([...wired])}`, ); }); test('getWiredLoopPoints(ROOT) contains discuss:post', () => { const wired = getWiredLoopPoints(ROOT); assert.ok( wired.has('discuss:post'), `getWiredLoopPoints(ROOT) must contain 'discuss:post'. Got: ${JSON.stringify([...wired])}`, ); }); }); // ─── Defect 2 — gen-time wired guard ──────────────────────────────────────── describe('Defect 2: validateHooksWired gen-time guard', () => { /** Minimal capability fixture with one hook at a given point */ function makeCapWithStep(point) { return { id: 'test-cap', role: 'feature', steps: [{ point, ref: { skill: 'my-skill' }, produces: [], consumes: [], onError: 'skip' }], contributions: [], gates: [], config: {}, }; } function makeCapWithContribution(point) { return { id: 'test-cap', role: 'feature', steps: [], contributions: [{ point, into: 'orchestrator', fragment: { inline: 'hi' }, produces: [], consumes: [] }], gates: [], config: {}, }; } function makeCapWithGate(point) { return { id: 'test-cap', role: 'feature', steps: [], contributions: [], gates: [{ point, check: { query: 'test-query' }, blocking: false, onError: 'skip' }], config: {}, }; } test('returns non-empty error array mentioning "not wired" when step point is not in wiredSet', () => { const cap = makeCapWithStep('discuss:pre'); const wiredSet = new Set(['plan:pre', 'plan:post']); // discuss:pre absent const errs = validateHooksWired(cap, wiredSet); assert.ok(Array.isArray(errs), 'must return an array'); assert.ok(errs.length > 0, 'must return errors when point is unwired'); const joined = errs.join(' '); assert.match(joined, /not wired/i, 'error must mention "not wired"'); assert.match(joined, /discuss:pre/, 'error must name the point'); assert.match(joined, /test-cap/, 'error must name the capability id'); }); test('returns non-empty error array when contribution point is not in wiredSet', () => { const cap = makeCapWithContribution('discuss:pre'); const wiredSet = new Set(['plan:pre']); // discuss:pre absent const errs = validateHooksWired(cap, wiredSet); assert.ok(errs.length > 0, 'must return errors for unwired contribution point'); assert.match(errs.join(' '), /not wired/i); }); test('returns non-empty error array when gate point is not in wiredSet', () => { const cap = makeCapWithGate('discuss:pre'); const wiredSet = new Set(['plan:pre']); // discuss:pre absent const errs = validateHooksWired(cap, wiredSet); assert.ok(errs.length > 0, 'must return errors for unwired gate point'); assert.match(errs.join(' '), /not wired/i); }); test('returns empty array when all declared points are in wiredSet', () => { const cap = makeCapWithStep('plan:pre'); const wiredSet = new Set(['plan:pre', 'plan:post', 'execute:post']); const errs = validateHooksWired(cap, wiredSet); assert.deepEqual(errs, [], 'must return empty array when all points are wired'); }); test('boundary: cap declaring discuss:pre is rejected against wiredSet lacking it', () => { const cap = makeCapWithStep('discuss:pre'); const smallSet = new Set(['plan:pre', 'plan:post']); const errs = validateHooksWired(cap, smallSet); assert.ok(errs.length > 0, 'must reject discuss:pre against a set that lacks it'); }); test('boundary: cap declaring discuss:pre is accepted against real getWiredLoopPoints(ROOT) post-fix', () => { const cap = makeCapWithStep('discuss:pre'); const realWired = getWiredLoopPoints(ROOT); const errs = validateHooksWired(cap, realWired); assert.deepEqual( errs, [], `discuss:pre must be wired after the fix. Errors: ${errs.join('; ')}`, ); }); test('boundary: cap declaring discuss:post is accepted against real getWiredLoopPoints(ROOT) post-fix', () => { const cap = makeCapWithContribution('discuss:post'); const realWired = getWiredLoopPoints(ROOT); const errs = validateHooksWired(cap, realWired); assert.deepEqual( errs, [], `discuss:post must be wired after the fix. Errors: ${errs.join('; ')}`, ); }); test('invalid points (not in VALID_LOOP_POINTS) are not flagged as "unwired" (already caught by schema validator)', () => { const cap = { id: 'test-cap', role: 'feature', steps: [{ point: 'not:a:real:point', ref: { skill: 'x' }, produces: [], consumes: [], onError: 'skip' }], contributions: [], gates: [], config: {}, }; const wiredSet = new Set(['plan:pre']); // the invalid point is not here either const errs = validateHooksWired(cap, wiredSet); // Should NOT flag it — invalid points are the schema validator's job const notWiredErrors = errs.filter((e) => /not wired/i.test(e)); assert.deepEqual( notWiredErrors, [], 'validateHooksWired must not flag invalid points as "not wired" (those are caught by schema validation)', ); }); }); // ─── Anti-pattern parity guards ────────────────────────────────────────────── describe('Anti-pattern parity: host-file set has a single source of truth', () => { test('every STEP_WORKFLOWS entry file exists on disk and contains a gsd:loop-host marker', () => { for (const { file, step } of STEP_WORKFLOWS) { const absPath = path.join(ROOT, 'gsd-core', 'workflows', file); assert.ok( fs.existsSync(absPath), `STEP_WORKFLOWS entry ${file} (step: ${step}) does not exist on disk at ${absPath}`, ); const content = fs.readFileSync(absPath, 'utf8'); assert.match( content, /