* fix(#1160): resolve capability surface from installed skill layouts In a global skills-runtime install (e.g. Codex at ~/.codex), gsd-tools.cjs runs from <configDir>/gsd-core/bin/ and the commands/gsd source tree is absent — only <configDir>/skills/gsd-<stem>/SKILL.md files exist. _resolveCommandsGsdDir() returned a path that does not exist there, so loadSkillsManifest returned an empty Map. resolveSurface then materialised the '*' (full) profile sentinel by enumerating that empty manifest → empty surfaced Set → every capability reported surfaced=false/enabled=false regardless of project config. As a result `loop render-hooks verify:post` returned activeHooks:[] even with workflow.security_enforcement and workflow.nyquist_validation enabled, silently disabling the security and Nyquist gates. Fix: add _loadInstalledSkillsManifest(configDir) that scans configDir/skills/ for gsd-<stem>/SKILL.md dirs and builds the same Map shape, and _resolveManifest(commandsGsdDir, configDir) that prefers the source tree when present (preserving repo-checkout behaviour) and falls back to the installed skills layout otherwise. Both resolveCapabilityRuntimeState call sites use _resolveManifest. Both helpers are exported for direct unit-testing. Tests: capability-state.test.cjs gains a faithful installed-runtime e2e block that copies gsd-core/bin + scripts + package.json into a temp install root with no reachable commands/gsd, then runs the real gsd-tools.cjs against an installed skills/ layout. It asserts capability state reports security & nyquist enabled and verify:post includes security->secure-phase and nyquist->validate-phase; a disabled-config negative confirms no over-activation. This block FAILS before the fix (activeHooks:[]) and PASSES after. Plus unit coverage for the two new helpers and the empty-surface pre-fix scenario. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(changeset): backfill PR number Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1180 lines
50 KiB
JavaScript
1180 lines
50 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* capability-state.test.cjs — behavioral tests for capability-state.cjs.
|
|
*
|
|
* ADR-857 phase 4b.
|
|
* Uses node:test + node:assert/strict.
|
|
* Pure-function tests (resolveCapabilityState) pass registry+Sets+config
|
|
* directly — no I/O. End-to-end tests use cmdCapabilityState + temp dirs.
|
|
*/
|
|
|
|
const { describe, test, before, after } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
const {
|
|
resolveCapabilityState,
|
|
_isSafePropKey,
|
|
_loadInstalledSkillsManifest,
|
|
_resolveManifest,
|
|
} = require('../gsd-core/bin/lib/capability-state.cjs');
|
|
|
|
// The real capability registry
|
|
const realRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
|
|
|
// ─── Synthetic registry fixture ───────────────────────────────────────────────
|
|
|
|
/**
|
|
* Build a minimal synthetic registry for a single capability with the given
|
|
* skills, steps, gates, contributions, and configSchema entries.
|
|
*/
|
|
function makeRegistry({
|
|
id = 'test-cap',
|
|
tier = 'standard',
|
|
skills = [],
|
|
steps = [],
|
|
gates = [],
|
|
contributions = [],
|
|
configSchema = {},
|
|
} = {}) {
|
|
return {
|
|
capabilities: {
|
|
[id]: {
|
|
id,
|
|
tier,
|
|
skills,
|
|
steps,
|
|
gates,
|
|
contributions,
|
|
config: {},
|
|
},
|
|
},
|
|
configSchema,
|
|
};
|
|
}
|
|
|
|
// ─── Temp project helpers ─────────────────────────────────────────────────────
|
|
|
|
let tmpProjectDir;
|
|
let tmpProjectDirFalse;
|
|
|
|
before(() => {
|
|
// Project with UI flags enabled
|
|
tmpProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-test-'));
|
|
const planningDir = path.join(tmpProjectDir, '.planning');
|
|
fs.mkdirSync(planningDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(planningDir, 'config.json'),
|
|
JSON.stringify({
|
|
workflow: {
|
|
ui_phase: true,
|
|
ui_review: true,
|
|
ui_safety_gate: true,
|
|
},
|
|
}),
|
|
'utf8',
|
|
);
|
|
|
|
// Project with all UI flags disabled
|
|
tmpProjectDirFalse = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-false-'));
|
|
fs.mkdirSync(path.join(tmpProjectDirFalse, '.planning'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(path.join(tmpProjectDirFalse, '.planning'), 'config.json'),
|
|
JSON.stringify({
|
|
workflow: {
|
|
ui_phase: false,
|
|
ui_review: false,
|
|
ui_safety_gate: false,
|
|
},
|
|
}),
|
|
'utf8',
|
|
);
|
|
});
|
|
|
|
after(() => {
|
|
cleanup(tmpProjectDir);
|
|
cleanup(tmpProjectDirFalse);
|
|
});
|
|
|
|
// ─── _isSafePropKey helper ────────────────────────────────────────────────────
|
|
|
|
describe('_isSafePropKey', () => {
|
|
test('allows normal keys', () => {
|
|
assert.strictEqual(_isSafePropKey('ui'), true);
|
|
assert.strictEqual(_isSafePropKey('my-cap'), true);
|
|
assert.strictEqual(_isSafePropKey('cap123'), true);
|
|
});
|
|
|
|
test('blocks __proto__', () => {
|
|
assert.strictEqual(_isSafePropKey('__proto__'), false);
|
|
});
|
|
|
|
test('blocks constructor', () => {
|
|
assert.strictEqual(_isSafePropKey('constructor'), false);
|
|
});
|
|
|
|
test('blocks prototype', () => {
|
|
assert.strictEqual(_isSafePropKey('prototype'), false);
|
|
});
|
|
|
|
test('blocks non-string', () => {
|
|
assert.strictEqual(_isSafePropKey(null), false);
|
|
assert.strictEqual(_isSafePropKey(42), false);
|
|
assert.strictEqual(_isSafePropKey(undefined), false);
|
|
});
|
|
});
|
|
|
|
// ─── resolveCapabilityState — basic shapes ────────────────────────────────────
|
|
|
|
describe('resolveCapabilityState — basic shapes', () => {
|
|
test('empty registry → {capabilities:[]}', () => {
|
|
const result = resolveCapabilityState({
|
|
registry: { capabilities: {} },
|
|
installedSkills: new Set(),
|
|
surfacedSkills: new Set(),
|
|
config: {},
|
|
});
|
|
assert.deepStrictEqual(result, { capabilities: [] });
|
|
});
|
|
|
|
test('missing capabilities key → {capabilities:[]}', () => {
|
|
const result = resolveCapabilityState({
|
|
registry: {},
|
|
installedSkills: new Set(),
|
|
surfacedSkills: new Set(),
|
|
config: {},
|
|
});
|
|
assert.deepStrictEqual(result, { capabilities: [] });
|
|
});
|
|
|
|
test('null registry → {capabilities:[]}', () => {
|
|
const result = resolveCapabilityState({
|
|
registry: null,
|
|
installedSkills: new Set(),
|
|
surfacedSkills: new Set(),
|
|
config: {},
|
|
});
|
|
assert.deepStrictEqual(result, { capabilities: [] });
|
|
});
|
|
|
|
test('array registry → {capabilities:[]}', () => {
|
|
const result = resolveCapabilityState({
|
|
registry: [],
|
|
installedSkills: new Set(),
|
|
surfacedSkills: new Set(),
|
|
config: {},
|
|
});
|
|
assert.deepStrictEqual(result, { capabilities: [] });
|
|
});
|
|
|
|
test('malformed capabilities entry is skipped gracefully', () => {
|
|
const result = resolveCapabilityState({
|
|
registry: { capabilities: { 'bad-cap': 'not-an-object' } },
|
|
installedSkills: new Set(),
|
|
surfacedSkills: new Set(),
|
|
config: {},
|
|
});
|
|
assert.deepStrictEqual(result, { capabilities: [] });
|
|
});
|
|
});
|
|
|
|
// ─── resolveCapabilityState — installed dimension ────────────────────────────
|
|
|
|
describe('resolveCapabilityState — installed dimension', () => {
|
|
test('installedSkills="*" → installed=true for all caps', () => {
|
|
const registry = makeRegistry({ skills: ['ui-phase', 'ui-review'] });
|
|
const result = resolveCapabilityState({
|
|
registry,
|
|
installedSkills: '*',
|
|
surfacedSkills: new Set(),
|
|
config: {},
|
|
});
|
|
assert.strictEqual(result.capabilities.length, 1);
|
|
assert.strictEqual(result.capabilities[0].installed, true);
|
|
});
|
|
|
|
test('all skills in installedSkills → installed=true', () => {
|
|
const registry = makeRegistry({ skills: ['ui-phase', 'ui-review'] });
|
|
const result = resolveCapabilityState({
|
|
registry,
|
|
installedSkills: new Set(['ui-phase', 'ui-review']),
|
|
surfacedSkills: new Set(),
|
|
config: {},
|
|
});
|
|
assert.strictEqual(result.capabilities[0].installed, true);
|
|
});
|
|
|
|
test('one skill missing from installedSkills → installed=false', () => {
|
|
const registry = makeRegistry({ skills: ['ui-phase', 'ui-review'] });
|
|
const result = resolveCapabilityState({
|
|
registry,
|
|
installedSkills: new Set(['ui-phase']), // missing ui-review
|
|
surfacedSkills: new Set(),
|
|
config: {},
|
|
});
|
|
assert.strictEqual(result.capabilities[0].installed, false);
|
|
});
|
|
|
|
test('empty skills array → installed=true vacuously', () => {
|
|
// A capability with zero skills has no skills to be absent, so it is
|
|
// vacuously installed and surfaced regardless of the installed/surfaced sets.
|
|
// This is intentional: capabilities that gate purely on config (no skills
|
|
// required) should report installed=true/surfaced=true when no skills are
|
|
// needed. Activation state is still governed by hook `when` keys.
|
|
const registry = makeRegistry({ skills: [] });
|
|
const result = resolveCapabilityState({
|
|
registry,
|
|
installedSkills: new Set(), // nothing installed — vacuous true still applies
|
|
surfacedSkills: new Set(),
|
|
config: {},
|
|
});
|
|
assert.strictEqual(result.capabilities[0].installed, true);
|
|
assert.strictEqual(result.capabilities[0].surfaced, true);
|
|
});
|
|
});
|
|
|
|
// ─── resolveCapabilityState — surfaced dimension ──────────────────────────────
|
|
|
|
describe('resolveCapabilityState — surfaced dimension', () => {
|
|
test('all skills in surfacedSkills → surfaced=true', () => {
|
|
const registry = makeRegistry({ skills: ['ui-phase', 'ui-review'] });
|
|
const result = resolveCapabilityState({
|
|
registry,
|
|
installedSkills: '*',
|
|
surfacedSkills: new Set(['ui-phase', 'ui-review']),
|
|
config: {},
|
|
});
|
|
assert.strictEqual(result.capabilities[0].surfaced, true);
|
|
});
|
|
|
|
test('one skill missing from surfacedSkills → surfaced=false', () => {
|
|
const registry = makeRegistry({ skills: ['ui-phase', 'ui-review'] });
|
|
const result = resolveCapabilityState({
|
|
registry,
|
|
installedSkills: '*',
|
|
surfacedSkills: new Set(['ui-phase']), // missing ui-review
|
|
config: {},
|
|
});
|
|
assert.strictEqual(result.capabilities[0].surfaced, false);
|
|
});
|
|
|
|
test('empty skills array → surfaced=true vacuously', () => {
|
|
const registry = makeRegistry({ skills: [] });
|
|
const result = resolveCapabilityState({
|
|
registry,
|
|
installedSkills: '*',
|
|
surfacedSkills: new Set(), // nothing surfaced
|
|
config: {},
|
|
});
|
|
assert.strictEqual(result.capabilities[0].surfaced, true);
|
|
});
|
|
});
|
|
|
|
// ─── resolveCapabilityState — UI capability (real registry) ──────────────────
|
|
|
|
describe('resolveCapabilityState — UI capability with real registry', () => {
|
|
test('UI cap: installed=true when ui-phase + ui-review in installedSkills', () => {
|
|
const result = resolveCapabilityState({
|
|
registry: realRegistry,
|
|
installedSkills: new Set(['ui-phase', 'ui-review']),
|
|
surfacedSkills: new Set(['ui-phase', 'ui-review']),
|
|
config: { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: true } },
|
|
cwd: tmpProjectDir,
|
|
});
|
|
const uiCap = result.capabilities.find((c) => c.id === 'ui');
|
|
assert.ok(uiCap, 'ui capability should be present');
|
|
assert.strictEqual(uiCap.installed, true);
|
|
assert.strictEqual(uiCap.surfaced, true);
|
|
assert.strictEqual(uiCap.enabled, true);
|
|
});
|
|
|
|
test('UI cap: installed=false when ui-review missing from installedSkills', () => {
|
|
const result = resolveCapabilityState({
|
|
registry: realRegistry,
|
|
installedSkills: new Set(['ui-phase']), // missing ui-review
|
|
surfacedSkills: new Set(['ui-phase', 'ui-review']),
|
|
config: { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: true } },
|
|
cwd: tmpProjectDir,
|
|
});
|
|
const uiCap = result.capabilities.find((c) => c.id === 'ui');
|
|
assert.ok(uiCap);
|
|
assert.strictEqual(uiCap.installed, false);
|
|
assert.strictEqual(uiCap.enabled, false);
|
|
});
|
|
|
|
test('UI cap: surfaced=false when ui-review missing from surfacedSkills', () => {
|
|
const result = resolveCapabilityState({
|
|
registry: realRegistry,
|
|
installedSkills: new Set(['ui-phase', 'ui-review']),
|
|
surfacedSkills: new Set(['ui-phase']), // missing ui-review
|
|
config: { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: true } },
|
|
cwd: tmpProjectDir,
|
|
});
|
|
const uiCap = result.capabilities.find((c) => c.id === 'ui');
|
|
assert.ok(uiCap);
|
|
assert.strictEqual(uiCap.surfaced, false);
|
|
assert.strictEqual(uiCap.enabled, false);
|
|
});
|
|
|
|
test('UI cap step hook: workflow.ui_phase true → active=true', () => {
|
|
const result = resolveCapabilityState({
|
|
registry: realRegistry,
|
|
installedSkills: '*',
|
|
surfacedSkills: new Set(['ui-phase', 'ui-review']),
|
|
config: { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: true } },
|
|
cwd: tmpProjectDir,
|
|
});
|
|
const uiCap = result.capabilities.find((c) => c.id === 'ui');
|
|
assert.ok(uiCap);
|
|
// Find the plan:pre step (ui-phase step)
|
|
const planPreStep = uiCap.hooks.find(
|
|
(h) => h.kind === 'step' && h.when === 'workflow.ui_phase',
|
|
);
|
|
assert.ok(planPreStep, 'should have plan:pre step with when=workflow.ui_phase');
|
|
assert.strictEqual(planPreStep.configured, true);
|
|
assert.strictEqual(planPreStep.active, true);
|
|
});
|
|
|
|
test('UI cap step hook: surfaced=false and workflow.ui_phase true → configured=true but active=false', () => {
|
|
const result = resolveCapabilityState({
|
|
registry: realRegistry,
|
|
installedSkills: '*',
|
|
surfacedSkills: new Set(),
|
|
config: { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: true } },
|
|
cwd: tmpProjectDir,
|
|
});
|
|
const uiCap = result.capabilities.find((c) => c.id === 'ui');
|
|
assert.ok(uiCap);
|
|
assert.strictEqual(uiCap.enabled, false);
|
|
const planPreStep = uiCap.hooks.find(
|
|
(h) => h.kind === 'step' && h.when === 'workflow.ui_phase',
|
|
);
|
|
assert.ok(planPreStep, 'should have plan:pre step with when=workflow.ui_phase');
|
|
assert.strictEqual(planPreStep.configured, true);
|
|
assert.strictEqual(planPreStep.active, false);
|
|
});
|
|
|
|
test('UI cap step hook: workflow.ui_phase false → active=false', () => {
|
|
const result = resolveCapabilityState({
|
|
registry: realRegistry,
|
|
installedSkills: '*',
|
|
surfacedSkills: new Set(),
|
|
config: { workflow: { ui_phase: false, ui_review: false, ui_safety_gate: false } },
|
|
cwd: tmpProjectDirFalse,
|
|
});
|
|
const uiCap = result.capabilities.find((c) => c.id === 'ui');
|
|
assert.ok(uiCap);
|
|
const planPreStep = uiCap.hooks.find(
|
|
(h) => h.kind === 'step' && h.when === 'workflow.ui_phase',
|
|
);
|
|
assert.ok(planPreStep, 'should have plan:pre step with when=workflow.ui_phase');
|
|
assert.strictEqual(planPreStep.configured, false);
|
|
assert.strictEqual(planPreStep.active, false);
|
|
});
|
|
|
|
test('UI cap gate hook: workflow.ui_safety_gate true → active=true', () => {
|
|
const result = resolveCapabilityState({
|
|
registry: realRegistry,
|
|
installedSkills: '*',
|
|
surfacedSkills: new Set(['ui-phase', 'ui-review']),
|
|
config: { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: true } },
|
|
cwd: tmpProjectDir,
|
|
});
|
|
const uiCap = result.capabilities.find((c) => c.id === 'ui');
|
|
assert.ok(uiCap);
|
|
const safetyGate = uiCap.hooks.find(
|
|
(h) => h.kind === 'gate' && h.when === 'workflow.ui_safety_gate',
|
|
);
|
|
assert.ok(safetyGate, 'should have gate with when=workflow.ui_safety_gate');
|
|
assert.strictEqual(safetyGate.configured, true);
|
|
assert.strictEqual(safetyGate.active, true);
|
|
});
|
|
|
|
test('UI cap gate hook: workflow.ui_safety_gate false → active=false', () => {
|
|
const result = resolveCapabilityState({
|
|
registry: realRegistry,
|
|
installedSkills: '*',
|
|
surfacedSkills: new Set(),
|
|
config: { workflow: { ui_phase: false, ui_review: false, ui_safety_gate: false } },
|
|
cwd: tmpProjectDirFalse,
|
|
});
|
|
const uiCap = result.capabilities.find((c) => c.id === 'ui');
|
|
assert.ok(uiCap);
|
|
const safetyGate = uiCap.hooks.find(
|
|
(h) => h.kind === 'gate' && h.when === 'workflow.ui_safety_gate',
|
|
);
|
|
assert.ok(safetyGate, 'should have gate with when=workflow.ui_safety_gate');
|
|
assert.strictEqual(safetyGate.configured, false);
|
|
assert.strictEqual(safetyGate.active, false);
|
|
});
|
|
});
|
|
|
|
// ─── resolveCapabilityState — hook activation ─────────────────────────────────
|
|
|
|
describe('resolveCapabilityState — hook activation details', () => {
|
|
test('hook with no `when` → active=true (unconditional)', () => {
|
|
const registry = makeRegistry({
|
|
steps: [{ point: 'plan:pre', ref: { skill: 'test-skill' } }], // no `when`
|
|
});
|
|
const result = resolveCapabilityState({
|
|
registry,
|
|
installedSkills: '*',
|
|
surfacedSkills: new Set(),
|
|
config: {},
|
|
});
|
|
assert.strictEqual(result.capabilities.length, 1);
|
|
const hook = result.capabilities[0].hooks.find((h) => h.kind === 'step');
|
|
assert.ok(hook, 'step hook should be present');
|
|
assert.strictEqual(hook.when, undefined);
|
|
assert.strictEqual(hook.active, true);
|
|
});
|
|
|
|
test('hook with `when` resolving truthy → active=true', () => {
|
|
const registry = makeRegistry({
|
|
steps: [{ point: 'plan:pre', when: 'workflow.my_feature' }],
|
|
});
|
|
const result = resolveCapabilityState({
|
|
registry,
|
|
installedSkills: '*',
|
|
surfacedSkills: new Set(),
|
|
config: { workflow: { my_feature: true } },
|
|
});
|
|
const hook = result.capabilities[0].hooks.find((h) => h.kind === 'step');
|
|
assert.ok(hook);
|
|
assert.strictEqual(hook.active, true);
|
|
});
|
|
|
|
test('hook with `when` resolving falsy → active=false', () => {
|
|
const registry = makeRegistry({
|
|
steps: [{ point: 'plan:pre', when: 'workflow.my_feature' }],
|
|
});
|
|
const result = resolveCapabilityState({
|
|
registry,
|
|
installedSkills: '*',
|
|
surfacedSkills: new Set(),
|
|
config: { workflow: { my_feature: false } },
|
|
});
|
|
const hook = result.capabilities[0].hooks.find((h) => h.kind === 'step');
|
|
assert.ok(hook);
|
|
assert.strictEqual(hook.active, false);
|
|
});
|
|
|
|
test('mixed hooks: some active, some not', () => {
|
|
const registry = makeRegistry({
|
|
steps: [
|
|
{ point: 'plan:pre', when: 'workflow.feat_a' },
|
|
{ point: 'plan:post' }, // no when → unconditional
|
|
],
|
|
gates: [{ point: 'execute:wave:post', when: 'workflow.feat_b' }],
|
|
// contributions must be a real array (not an object) so hook enumeration works
|
|
contributions: [
|
|
{ point: 'plan:pre', into: 'context', when: 'workflow.feat_c' },
|
|
],
|
|
});
|
|
const result = resolveCapabilityState({
|
|
registry,
|
|
installedSkills: '*',
|
|
surfacedSkills: new Set(),
|
|
config: { workflow: { feat_a: false, feat_b: true, feat_c: true } },
|
|
});
|
|
const cap = result.capabilities[0];
|
|
// feat_a step: inactive
|
|
const featAStep = cap.hooks.find((h) => h.when === 'workflow.feat_a');
|
|
assert.ok(featAStep);
|
|
assert.strictEqual(featAStep.active, false);
|
|
// unconditional step: active
|
|
const unconditional = cap.hooks.find((h) => h.kind === 'step' && !h.when);
|
|
assert.ok(unconditional);
|
|
assert.strictEqual(unconditional.active, true);
|
|
// feat_b gate: active
|
|
const featBGate = cap.hooks.find((h) => h.when === 'workflow.feat_b');
|
|
assert.ok(featBGate);
|
|
assert.strictEqual(featBGate.active, true);
|
|
// feat_c contribution: active, enumerated correctly
|
|
const featCContrib = cap.hooks.find((h) => h.kind === 'contribution' && h.when === 'workflow.feat_c');
|
|
assert.ok(featCContrib, 'contribution hook should be enumerated from array');
|
|
assert.strictEqual(featCContrib.active, true);
|
|
});
|
|
|
|
test('empty-string `when` → active=false (aligned with loop-resolver)', () => {
|
|
// loop-resolver.isActive: `when.length === 0` → false
|
|
// capability-state must behave identically
|
|
const registry = makeRegistry({
|
|
steps: [{ point: 'plan:pre', when: '' }],
|
|
});
|
|
const result = resolveCapabilityState({
|
|
registry,
|
|
installedSkills: '*',
|
|
surfacedSkills: new Set(),
|
|
config: {},
|
|
});
|
|
const hook = result.capabilities[0].hooks.find((h) => h.kind === 'step');
|
|
assert.ok(hook, 'step hook should be present');
|
|
assert.strictEqual(hook.when, '', 'original when value must be preserved');
|
|
assert.strictEqual(hook.active, false, 'empty-string when → inactive');
|
|
});
|
|
|
|
test('non-string `when` → active=false (aligned with loop-resolver)', () => {
|
|
// loop-resolver.isActive: `typeof when !== 'string'` → false
|
|
const registry = makeRegistry({
|
|
steps: [{ point: 'plan:pre', when: 42 }],
|
|
});
|
|
const result = resolveCapabilityState({
|
|
registry,
|
|
installedSkills: '*',
|
|
surfacedSkills: new Set(),
|
|
config: {},
|
|
});
|
|
const hook = result.capabilities[0].hooks.find((h) => h.kind === 'step');
|
|
assert.ok(hook, 'step hook should be present');
|
|
assert.strictEqual(hook.when, 42, 'original non-string when value must be preserved');
|
|
assert.strictEqual(hook.active, false, 'non-string when → inactive');
|
|
});
|
|
});
|
|
|
|
// ─── resolveCapabilityState — determinism ─────────────────────────────────────
|
|
|
|
describe('resolveCapabilityState — determinism', () => {
|
|
test('sorted by id — two caps returned in lexicographic order', () => {
|
|
// contributions must be an array (not an object) for the hook enumeration to work
|
|
const registry = {
|
|
capabilities: {
|
|
'zzz-cap': { id: 'zzz-cap', tier: 'standard', skills: [], steps: [], gates: [], contributions: [] },
|
|
'aaa-cap': { id: 'aaa-cap', tier: 'standard', skills: [], steps: [], gates: [], contributions: [] },
|
|
'mmm-cap': { id: 'mmm-cap', tier: 'standard', skills: [], steps: [], gates: [], contributions: [] },
|
|
},
|
|
};
|
|
const result = resolveCapabilityState({
|
|
registry,
|
|
installedSkills: '*',
|
|
surfacedSkills: new Set(),
|
|
config: {},
|
|
});
|
|
const ids = result.capabilities.map((c) => c.id);
|
|
assert.deepStrictEqual(ids, ['aaa-cap', 'mmm-cap', 'zzz-cap']);
|
|
});
|
|
|
|
test('two calls with same inputs produce identical output', () => {
|
|
const result1 = resolveCapabilityState({
|
|
registry: realRegistry,
|
|
installedSkills: new Set(['ui-phase', 'ui-review']),
|
|
surfacedSkills: new Set(['ui-phase']),
|
|
config: { workflow: { ui_phase: true, ui_review: false, ui_safety_gate: true } },
|
|
cwd: tmpProjectDir,
|
|
});
|
|
const result2 = resolveCapabilityState({
|
|
registry: realRegistry,
|
|
installedSkills: new Set(['ui-phase', 'ui-review']),
|
|
surfacedSkills: new Set(['ui-phase']),
|
|
config: { workflow: { ui_phase: true, ui_review: false, ui_safety_gate: true } },
|
|
cwd: tmpProjectDir,
|
|
});
|
|
assert.deepStrictEqual(result1, result2);
|
|
});
|
|
|
|
test('pure config-only resolution (cwd: undefined) — no I/O, deterministic', () => {
|
|
// When cwd is omitted, resolveCapabilityState does no filesystem I/O.
|
|
// Two calls with identical args must produce identical output regardless
|
|
// of any .planning/config.json files that may exist on disk.
|
|
const result1 = resolveCapabilityState({
|
|
registry: realRegistry,
|
|
installedSkills: new Set(['ui-phase', 'ui-review']),
|
|
surfacedSkills: new Set(['ui-phase', 'ui-review']),
|
|
config: { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: false } },
|
|
// no cwd
|
|
});
|
|
const result2 = resolveCapabilityState({
|
|
registry: realRegistry,
|
|
installedSkills: new Set(['ui-phase', 'ui-review']),
|
|
surfacedSkills: new Set(['ui-phase', 'ui-review']),
|
|
config: { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: false } },
|
|
// no cwd
|
|
});
|
|
assert.deepStrictEqual(result1, result2);
|
|
// Activation should come from the `config` arg only, not from disk
|
|
const uiCap = result1.capabilities.find((c) => c.id === 'ui');
|
|
assert.ok(uiCap, 'ui capability should be present');
|
|
const uiPhaseStep = uiCap.hooks.find(
|
|
(h) => h.kind === 'step' && h.when === 'workflow.ui_phase',
|
|
);
|
|
if (uiPhaseStep) {
|
|
assert.strictEqual(uiPhaseStep.active, true, 'should use config arg, not disk');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── resolveCapabilityState — prototype pollution guard ──────────────────────
|
|
|
|
describe('resolveCapabilityState — prototype pollution guard', () => {
|
|
test('prototype-pollution capId is skipped; Object.prototype unpolluted', () => {
|
|
// Use Object.create(null) + Object.defineProperty to create a capabilities
|
|
// map with a real OWN '__proto__' key (not the prototype chain).
|
|
// The `{ __proto__: ... }` object literal syntax sets the prototype, not
|
|
// an own property — so it cannot exercise the guard. Using defineProperty
|
|
// ensures the key is an enumerable own property that Object.keys() returns.
|
|
const capabilitiesMap = Object.create(null);
|
|
Object.defineProperty(capabilitiesMap, '__proto__', {
|
|
value: { id: '__proto__', tier: 'standard', skills: [], steps: [], gates: [], contributions: [] },
|
|
enumerable: true,
|
|
configurable: true,
|
|
writable: true,
|
|
});
|
|
Object.defineProperty(capabilitiesMap, 'safe-cap', {
|
|
value: { id: 'safe-cap', tier: 'standard', skills: [], steps: [], gates: [], contributions: [] },
|
|
enumerable: true,
|
|
configurable: true,
|
|
writable: true,
|
|
});
|
|
const registry = { capabilities: capabilitiesMap };
|
|
const before = Object.prototype.toString.call({});
|
|
const result = resolveCapabilityState({
|
|
registry,
|
|
installedSkills: '*',
|
|
surfacedSkills: new Set(),
|
|
config: {},
|
|
});
|
|
const after = Object.prototype.toString.call({});
|
|
// Object.prototype must be unpolluted
|
|
assert.strictEqual(before, after);
|
|
// Verify no pollution occurred — a new plain object must not have a `polluted` property
|
|
assert.strictEqual(({}).polluted, undefined);
|
|
// Only the safe cap should appear
|
|
assert.strictEqual(result.capabilities.length, 1);
|
|
assert.strictEqual(result.capabilities[0].id, 'safe-cap');
|
|
});
|
|
});
|
|
|
|
// ─── cmdCapabilityState — end-to-end via gsd-tools CLI ──────────────────────
|
|
//
|
|
// Because cmdCapabilityState destructures `output` at module load time, patching
|
|
// core.cjs after the fact is ineffective. We instead invoke gsd-tools via
|
|
// spawnSync so each test gets a fresh process with stdout captured.
|
|
|
|
const { spawnSync } = require('node:child_process');
|
|
|
|
const gsdToolsPath = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
|
|
|
function runCapabilityState(cwd, configDir) {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[gsdToolsPath, 'capability', 'state', '--config-dir', configDir, '--raw', '--cwd', cwd],
|
|
{ encoding: 'utf8', timeout: 15000 },
|
|
);
|
|
return result;
|
|
}
|
|
|
|
describe('cmdCapabilityState — end-to-end via gsd-tools CLI', () => {
|
|
let tmpConfigDir;
|
|
let tmpConfigDirCore;
|
|
let tmpConfigDirUiDisabled;
|
|
|
|
before(() => {
|
|
// Tmp runtime config dir without .gsd-profile (defaults to 'full')
|
|
tmpConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-cfg-'));
|
|
|
|
// Tmp runtime config dir with core profile marker
|
|
tmpConfigDirCore = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-cfg-core-'));
|
|
fs.writeFileSync(path.join(tmpConfigDirCore, '.gsd-profile'), 'core\n', 'utf8');
|
|
|
|
tmpConfigDirUiDisabled = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-cfg-ui-disabled-'));
|
|
fs.writeFileSync(
|
|
path.join(tmpConfigDirUiDisabled, '.gsd-surface.json'),
|
|
JSON.stringify({
|
|
baseProfile: 'full',
|
|
disabledClusters: ['ui'],
|
|
explicitAdds: [],
|
|
explicitRemoves: [],
|
|
}, null, 2),
|
|
'utf8',
|
|
);
|
|
});
|
|
|
|
after(() => {
|
|
cleanup(tmpConfigDir);
|
|
cleanup(tmpConfigDirCore);
|
|
cleanup(tmpConfigDirUiDisabled);
|
|
});
|
|
|
|
test('emits envelope with runtimeConfigDir and capabilities array', () => {
|
|
const result = runCapabilityState(tmpProjectDir, tmpConfigDir);
|
|
assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}: ${result.stderr}`);
|
|
const envelope = JSON.parse(result.stdout);
|
|
assert.ok(typeof envelope === 'object' && envelope !== null, 'envelope must be an object');
|
|
assert.ok('runtimeConfigDir' in envelope, 'envelope must have runtimeConfigDir');
|
|
assert.ok(Array.isArray(envelope.capabilities), 'envelope.capabilities must be an array');
|
|
assert.ok(envelope.capabilities.length > 0, 'should have at least one capability');
|
|
});
|
|
|
|
test('with core profile marker: capabilities present (profile resolution does not throw)', () => {
|
|
const result = runCapabilityState(tmpProjectDir, tmpConfigDirCore);
|
|
assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}: ${result.stderr}`);
|
|
const envelope = JSON.parse(result.stdout);
|
|
assert.ok(Array.isArray(envelope.capabilities));
|
|
// ui capability should appear; installed=false because core profile doesn't include ui-phase/ui-review
|
|
const uiCap = envelope.capabilities.find((c) => c.id === 'ui');
|
|
assert.ok(uiCap, 'ui capability should be present in output');
|
|
assert.strictEqual(uiCap.installed, false, 'ui-phase/ui-review not in core profile');
|
|
});
|
|
|
|
test('runtimeConfigDir is echoed in the envelope', () => {
|
|
const result = runCapabilityState(tmpProjectDir, tmpConfigDir);
|
|
assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}: ${result.stderr}`);
|
|
const envelope = JSON.parse(result.stdout);
|
|
assert.strictEqual(envelope.runtimeConfigDir, tmpConfigDir);
|
|
});
|
|
|
|
test('surface-disabled UI capability reports enabled=false and inactive hooks', () => {
|
|
const result = runCapabilityState(tmpProjectDir, tmpConfigDirUiDisabled);
|
|
assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}: ${result.stderr}`);
|
|
const envelope = JSON.parse(result.stdout);
|
|
const uiCap = envelope.capabilities.find((c) => c.id === 'ui');
|
|
assert.ok(uiCap, 'ui capability should be present in output');
|
|
assert.strictEqual(uiCap.installed, true, 'full base profile still installs UI');
|
|
assert.strictEqual(uiCap.surfaced, false, 'surface disables UI capability skills');
|
|
assert.strictEqual(uiCap.enabled, false, 'disabled surface must disable the capability');
|
|
const planPreStep = uiCap.hooks.find(
|
|
(h) => h.kind === 'step' && h.when === 'workflow.ui_phase',
|
|
);
|
|
assert.ok(planPreStep, 'ui plan step should be present in diagnostic state');
|
|
assert.strictEqual(planPreStep.configured, true, 'project config/schema still configures UI on');
|
|
assert.strictEqual(planPreStep.active, false, 'effective hook activity must match workflow dispatch');
|
|
});
|
|
});
|
|
|
|
// ─── regressions: installed-runtime capability surface (#1160) ────────────────
|
|
//
|
|
// In an installed runtime (e.g. Codex) the commands/gsd source tree is absent.
|
|
// Only <configDir>/skills/gsd-*/SKILL.md files exist. Prior to this fix,
|
|
// loadSkillsManifest returned an empty map → resolveSurface materialized '*'
|
|
// to an empty Set → security.enabled=false / nyquist.enabled=false even when
|
|
// the project config had security_enforcement=true / nyquist_validation=true.
|
|
//
|
|
// These tests directly exercise the new _loadInstalledSkillsManifest and
|
|
// _resolveManifest functions with a non-existent commandsGsdDir path so they
|
|
// FAIL before the fix and PASS after, regardless of whether commands/gsd
|
|
// happens to exist in the current checkout.
|
|
|
|
describe('regressions: installed-runtime capability surface (#1160)', () => {
|
|
// Minimal valid SKILL.md content (frontmatter only — matches what install emits)
|
|
function makeSkillMd(stem) {
|
|
return [
|
|
'---',
|
|
`name: gsd:${stem}`,
|
|
`description: ${stem} skill`,
|
|
'argument-hint: "[phase number]"',
|
|
'allowed-tools:',
|
|
' - Read',
|
|
'requires: [phase]',
|
|
'---',
|
|
'Execute end-to-end.',
|
|
].join('\n') + '\n';
|
|
}
|
|
|
|
// ── Unit tests for _loadInstalledSkillsManifest ──────────────────────────────
|
|
|
|
test('_loadInstalledSkillsManifest: returns empty map when skills dir absent', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-ism-empty-'));
|
|
try {
|
|
const manifest = _loadInstalledSkillsManifest(tmpDir);
|
|
assert.ok(manifest instanceof Map, 'should return a Map');
|
|
assert.strictEqual(manifest.size, 0, 'should be empty when no skills/ dir');
|
|
} finally {
|
|
cleanup(tmpDir);
|
|
}
|
|
});
|
|
|
|
test('_loadInstalledSkillsManifest: scans gsd-<stem>/SKILL.md dirs and produces stems', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-ism-scan-'));
|
|
try {
|
|
// Create installed skill dirs
|
|
const secureDir = path.join(tmpDir, 'skills', 'gsd-secure-phase');
|
|
const validateDir = path.join(tmpDir, 'skills', 'gsd-validate-phase');
|
|
fs.mkdirSync(secureDir, { recursive: true });
|
|
fs.mkdirSync(validateDir, { recursive: true });
|
|
fs.writeFileSync(path.join(secureDir, 'SKILL.md'), makeSkillMd('secure-phase'), 'utf8');
|
|
fs.writeFileSync(path.join(validateDir, 'SKILL.md'), makeSkillMd('validate-phase'), 'utf8');
|
|
// Add a non-gsd- dir that should be ignored
|
|
fs.mkdirSync(path.join(tmpDir, 'skills', 'user-custom'), { recursive: true });
|
|
|
|
const manifest = _loadInstalledSkillsManifest(tmpDir);
|
|
assert.ok(manifest instanceof Map);
|
|
assert.ok(manifest.has('secure-phase'), 'should have secure-phase stem');
|
|
assert.ok(manifest.has('validate-phase'), 'should have validate-phase stem');
|
|
assert.ok(!manifest.has('user-custom'), 'non-gsd- dir must not appear');
|
|
} finally {
|
|
cleanup(tmpDir);
|
|
}
|
|
});
|
|
|
|
test('_loadInstalledSkillsManifest: parses requires from SKILL.md frontmatter', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-ism-req-'));
|
|
try {
|
|
const skillDir = path.join(tmpDir, 'skills', 'gsd-my-skill');
|
|
fs.mkdirSync(skillDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(skillDir, 'SKILL.md'),
|
|
'---\nname: gsd:my-skill\nrequires: [dep-a, dep-b]\n---\nbody\n',
|
|
'utf8',
|
|
);
|
|
const manifest = _loadInstalledSkillsManifest(tmpDir);
|
|
assert.deepStrictEqual(manifest.get('my-skill'), ['dep-a', 'dep-b']);
|
|
} finally {
|
|
cleanup(tmpDir);
|
|
}
|
|
});
|
|
|
|
test('_loadInstalledSkillsManifest: directory with no SKILL.md is NOT registered (parity with loadSkillsManifest)', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-ism-nodoc-'));
|
|
try {
|
|
fs.mkdirSync(path.join(tmpDir, 'skills', 'gsd-nodoc'), { recursive: true });
|
|
// No SKILL.md written — a stale/empty skill dir must not invent a stem,
|
|
// mirroring loadSkillsManifest which only registers stems for files that exist.
|
|
const manifest = _loadInstalledSkillsManifest(tmpDir);
|
|
assert.ok(!manifest.has('nodoc'), 'stem must NOT be registered when SKILL.md is absent');
|
|
assert.ok(!manifest.has('_calls_agents_nodoc'), 'companion agents key must also be absent');
|
|
} finally {
|
|
cleanup(tmpDir);
|
|
}
|
|
});
|
|
|
|
// ── Unit tests for _resolveManifest ─────────────────────────────────────────
|
|
|
|
test('_resolveManifest: uses commandsGsdDir when it exists', () => {
|
|
const realCommandsGsdDir = path.resolve(__dirname, '..', 'commands', 'gsd');
|
|
if (!fs.existsSync(realCommandsGsdDir)) {
|
|
// Skip if not in a repo checkout
|
|
return;
|
|
}
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rm-src-'));
|
|
try {
|
|
// No skills/ dir — if _resolveManifest uses source, it returns real skills
|
|
const manifest = _resolveManifest(realCommandsGsdDir, tmpDir);
|
|
assert.ok(manifest instanceof Map);
|
|
// The real commands/gsd has many skills; manifest should be non-empty
|
|
assert.ok(manifest.size > 0, 'should load skills from real source dir');
|
|
} finally {
|
|
cleanup(tmpDir);
|
|
}
|
|
});
|
|
|
|
test('_resolveManifest: falls back to installed skills when commandsGsdDir absent', () => {
|
|
const nonExistentDir = path.join(os.tmpdir(), 'cap-rm-nonexistent-' + Date.now());
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rm-installed-'));
|
|
try {
|
|
// Create installed skill layout under tmpDir
|
|
const secureDir = path.join(tmpDir, 'skills', 'gsd-secure-phase');
|
|
const validateDir = path.join(tmpDir, 'skills', 'gsd-validate-phase');
|
|
fs.mkdirSync(secureDir, { recursive: true });
|
|
fs.mkdirSync(validateDir, { recursive: true });
|
|
fs.writeFileSync(path.join(secureDir, 'SKILL.md'), makeSkillMd('secure-phase'), 'utf8');
|
|
fs.writeFileSync(path.join(validateDir, 'SKILL.md'), makeSkillMd('validate-phase'), 'utf8');
|
|
|
|
const manifest = _resolveManifest(nonExistentDir, tmpDir);
|
|
assert.ok(manifest instanceof Map);
|
|
assert.ok(manifest.has('secure-phase'), 'must have secure-phase from installed skills');
|
|
assert.ok(manifest.has('validate-phase'), 'must have validate-phase from installed skills');
|
|
} finally {
|
|
cleanup(tmpDir);
|
|
}
|
|
});
|
|
|
|
// ── Integration tests: capability state with installed-layout config dir ────
|
|
// These tests use a config dir that has NO .gsd-source and where _resolveCommandsGsdDir
|
|
// would return the real repo path. To force the installed-path, we call
|
|
// resolveCapabilityState directly with manifests derived from _resolveManifest
|
|
// using a non-existent commandsGsdDir.
|
|
|
|
test('resolveCapabilityState: security enabled when secure-phase in installedSkills+surfacedSkills', () => {
|
|
const securitySkills = ['secure-phase'];
|
|
const result = resolveCapabilityState({
|
|
registry: realRegistry,
|
|
installedSkills: new Set(securitySkills),
|
|
surfacedSkills: new Set(securitySkills),
|
|
config: { workflow: { security_enforcement: true } },
|
|
});
|
|
const secCap = result.capabilities.find((c) => c.id === 'security');
|
|
assert.ok(secCap, 'security capability must be present');
|
|
assert.strictEqual(secCap.installed, true, 'secure-phase in installedSkills → installed');
|
|
assert.strictEqual(secCap.surfaced, true, 'secure-phase in surfacedSkills → surfaced');
|
|
assert.strictEqual(secCap.enabled, true, 'installed+surfaced → enabled');
|
|
});
|
|
|
|
test('resolveCapabilityState: security NOT enabled when surfacedSkills empty (pre-fix scenario)', () => {
|
|
// Simulates the pre-fix behavior: manifest empty → surface materializes to empty Set
|
|
const result = resolveCapabilityState({
|
|
registry: realRegistry,
|
|
installedSkills: '*', // full profile → installed=true
|
|
surfacedSkills: new Set(), // empty set (what empty manifest causes)
|
|
config: { workflow: { security_enforcement: true } },
|
|
});
|
|
const secCap = result.capabilities.find((c) => c.id === 'security');
|
|
assert.ok(secCap, 'security capability must be present');
|
|
assert.strictEqual(secCap.installed, true);
|
|
assert.strictEqual(secCap.surfaced, false, 'empty surfacedSkills → surfaced=false (pre-fix bug)');
|
|
assert.strictEqual(secCap.enabled, false, 'surfaced=false → enabled=false (pre-fix bug)');
|
|
});
|
|
|
|
// ── End-to-end CLI tests: capability state + loop render-hooks ───────────────
|
|
|
|
describe('end-to-end CLI with installed skill layout', () => {
|
|
let tmpInstalledConfigDir;
|
|
let tmpInstalledProjectDir;
|
|
|
|
before(() => {
|
|
tmpInstalledConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-installed-'));
|
|
fs.writeFileSync(path.join(tmpInstalledConfigDir, '.gsd-profile'), 'full\n', 'utf8');
|
|
const secureDir = path.join(tmpInstalledConfigDir, 'skills', 'gsd-secure-phase');
|
|
const validateDir = path.join(tmpInstalledConfigDir, 'skills', 'gsd-validate-phase');
|
|
fs.mkdirSync(secureDir, { recursive: true });
|
|
fs.mkdirSync(validateDir, { recursive: true });
|
|
fs.writeFileSync(path.join(secureDir, 'SKILL.md'), makeSkillMd('secure-phase'), 'utf8');
|
|
fs.writeFileSync(path.join(validateDir, 'SKILL.md'), makeSkillMd('validate-phase'), 'utf8');
|
|
|
|
tmpInstalledProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-installed-proj-'));
|
|
fs.mkdirSync(path.join(tmpInstalledProjectDir, '.planning'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(tmpInstalledProjectDir, '.planning', 'config.json'),
|
|
JSON.stringify({ workflow: { security_enforcement: true, nyquist_validation: true } }),
|
|
'utf8',
|
|
);
|
|
});
|
|
|
|
after(() => {
|
|
cleanup(tmpInstalledConfigDir);
|
|
cleanup(tmpInstalledProjectDir);
|
|
});
|
|
|
|
test('security capability: enabled=true in installed runtime with security_enforcement=true', () => {
|
|
const result = runCapabilityState(tmpInstalledProjectDir, tmpInstalledConfigDir);
|
|
assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
|
const envelope = JSON.parse(result.stdout);
|
|
const secCap = envelope.capabilities.find((c) => c.id === 'security');
|
|
assert.ok(secCap, 'security capability must be present in output');
|
|
assert.strictEqual(secCap.installed, true, 'security skill secure-phase is installed');
|
|
assert.strictEqual(secCap.surfaced, true, 'security skill secure-phase is surfaced (full profile)');
|
|
assert.strictEqual(secCap.enabled, true, 'security capability must be enabled');
|
|
});
|
|
|
|
test('nyquist capability: enabled=true in installed runtime with nyquist_validation=true', () => {
|
|
const result = runCapabilityState(tmpInstalledProjectDir, tmpInstalledConfigDir);
|
|
assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
|
const envelope = JSON.parse(result.stdout);
|
|
const nyqCap = envelope.capabilities.find((c) => c.id === 'nyquist');
|
|
assert.ok(nyqCap, 'nyquist capability must be present in output');
|
|
assert.strictEqual(nyqCap.installed, true, 'nyquist skill validate-phase is installed');
|
|
assert.strictEqual(nyqCap.surfaced, true, 'nyquist skill validate-phase is surfaced (full profile)');
|
|
assert.strictEqual(nyqCap.enabled, true, 'nyquist capability must be enabled');
|
|
});
|
|
|
|
test('security hook at verify:post is active in installed runtime', () => {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[
|
|
gsdToolsPath,
|
|
'loop', 'render-hooks', 'verify:post',
|
|
'--config-dir', tmpInstalledConfigDir,
|
|
'--cwd', tmpInstalledProjectDir,
|
|
],
|
|
{ encoding: 'utf8', timeout: 15000 },
|
|
);
|
|
assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
|
const envelope = JSON.parse(result.stdout.trim());
|
|
assert.strictEqual(envelope.point, 'verify:post');
|
|
assert.ok(Array.isArray(envelope.activeHooks), 'activeHooks must be an array');
|
|
const securityHook = envelope.activeHooks.find(
|
|
(h) => h.capId === 'security' && h.kind === 'step',
|
|
);
|
|
assert.ok(
|
|
securityHook,
|
|
'verify:post must include security step hook when security_enforcement=true. Got: ' +
|
|
JSON.stringify(envelope.activeHooks),
|
|
);
|
|
assert.ok(
|
|
securityHook.ref && securityHook.ref.skill === 'secure-phase',
|
|
'security hook ref.skill must be secure-phase',
|
|
);
|
|
});
|
|
|
|
test('nyquist hook at verify:post is active in installed runtime', () => {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[
|
|
gsdToolsPath,
|
|
'loop', 'render-hooks', 'verify:post',
|
|
'--config-dir', tmpInstalledConfigDir,
|
|
'--cwd', tmpInstalledProjectDir,
|
|
],
|
|
{ encoding: 'utf8', timeout: 15000 },
|
|
);
|
|
assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
|
const envelope = JSON.parse(result.stdout.trim());
|
|
const nyquistHook = envelope.activeHooks.find(
|
|
(h) => h.capId === 'nyquist' && h.kind === 'step',
|
|
);
|
|
assert.ok(
|
|
nyquistHook,
|
|
'verify:post must include nyquist step hook when nyquist_validation=true. Got: ' +
|
|
JSON.stringify(envelope.activeHooks),
|
|
);
|
|
assert.ok(
|
|
nyquistHook.ref && nyquistHook.ref.skill === 'validate-phase',
|
|
'nyquist hook ref.skill must be validate-phase',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── TRUE installed-runtime layout: gsd-tools runs where commands/gsd is unreachable ──
|
|
// The block above runs the repo's gsd-tools.cjs, where commands/gsd source IS
|
|
// reachable by walk-up, so it cannot exercise the bug. This block copies the
|
|
// runtime executable tree (gsd-core/bin + scripts + package.json) into a temp
|
|
// install root that has NO commands/ sibling — faithfully reproducing a global
|
|
// skills-runtime install (e.g. Codex at ~/.codex). There, the source manifest
|
|
// is genuinely empty, so pre-fix the '*' profile materialized to an empty
|
|
// surfaced set → enabled=false → verify:post activeHooks: []. This test FAILS
|
|
// before the fix and PASSES after.
|
|
describe('true installed layout (commands/gsd unreachable)', () => {
|
|
let installRoot;
|
|
let installedConfigDir;
|
|
let installedProjectDir;
|
|
let installedGsdTools;
|
|
|
|
before(() => {
|
|
const repoRoot = path.resolve(__dirname, '..');
|
|
// 1. Faithful install root: copy the executable runtime WITHOUT commands/.
|
|
installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-installroot-'));
|
|
fs.mkdirSync(path.join(installRoot, 'gsd-core'), { recursive: true });
|
|
fs.cpSync(
|
|
path.join(repoRoot, 'gsd-core', 'bin'),
|
|
path.join(installRoot, 'gsd-core', 'bin'),
|
|
{ recursive: true },
|
|
);
|
|
fs.cpSync(
|
|
path.join(repoRoot, 'scripts'),
|
|
path.join(installRoot, 'scripts'),
|
|
{ recursive: true },
|
|
);
|
|
fs.copyFileSync(
|
|
path.join(repoRoot, 'package.json'),
|
|
path.join(installRoot, 'package.json'),
|
|
);
|
|
installedGsdTools = path.join(installRoot, 'gsd-core', 'bin', 'gsd-tools.cjs');
|
|
|
|
// 2. Installed runtime config dir: full profile + skills/gsd-*/SKILL.md only.
|
|
installedConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-instcfg-'));
|
|
fs.writeFileSync(path.join(installedConfigDir, '.gsd-profile'), 'full\n', 'utf8');
|
|
for (const stem of ['secure-phase', 'validate-phase']) {
|
|
const skillDir = path.join(installedConfigDir, 'skills', `gsd-${stem}`);
|
|
fs.mkdirSync(skillDir, { recursive: true });
|
|
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), makeSkillMd(stem), 'utf8');
|
|
}
|
|
|
|
// 3. Project enabling both gates.
|
|
installedProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-instproj-'));
|
|
fs.mkdirSync(path.join(installedProjectDir, '.planning'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(installedProjectDir, '.planning', 'config.json'),
|
|
JSON.stringify({ workflow: { security_enforcement: true, nyquist_validation: true } }),
|
|
'utf8',
|
|
);
|
|
});
|
|
|
|
after(() => {
|
|
cleanup(installRoot);
|
|
cleanup(installedConfigDir);
|
|
cleanup(installedProjectDir);
|
|
});
|
|
|
|
test('commands/gsd is genuinely unreachable from the installed gsd-tools', () => {
|
|
// Sanity: no commands/gsd anywhere under the install root.
|
|
const probe = path.join(installRoot, 'commands', 'gsd');
|
|
assert.strictEqual(fs.existsSync(probe), false, 'install root must have no commands/gsd');
|
|
});
|
|
|
|
test('capability state: security & nyquist enabled in true installed runtime', () => {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[
|
|
installedGsdTools,
|
|
'capability', 'state',
|
|
'--config-dir', installedConfigDir,
|
|
'--cwd', installedProjectDir,
|
|
'--raw',
|
|
],
|
|
{ encoding: 'utf8', timeout: 20000 },
|
|
);
|
|
assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
|
const envelope = JSON.parse(result.stdout);
|
|
const secCap = envelope.capabilities.find((c) => c.id === 'security');
|
|
const nyqCap = envelope.capabilities.find((c) => c.id === 'nyquist');
|
|
assert.ok(secCap && nyqCap, 'security and nyquist capabilities must be present');
|
|
assert.strictEqual(secCap.surfaced, true, 'security surfaced from installed skills (pre-fix: false)');
|
|
assert.strictEqual(secCap.enabled, true, 'security enabled in installed runtime (pre-fix: false)');
|
|
assert.strictEqual(nyqCap.surfaced, true, 'nyquist surfaced from installed skills (pre-fix: false)');
|
|
assert.strictEqual(nyqCap.enabled, true, 'nyquist enabled in installed runtime (pre-fix: false)');
|
|
});
|
|
|
|
test('loop render-hooks verify:post: includes security & nyquist in true installed runtime', () => {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[
|
|
installedGsdTools,
|
|
'loop', 'render-hooks', 'verify:post',
|
|
'--config-dir', installedConfigDir,
|
|
'--cwd', installedProjectDir,
|
|
],
|
|
{ encoding: 'utf8', timeout: 20000 },
|
|
);
|
|
assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
|
const envelope = JSON.parse(result.stdout.trim());
|
|
assert.strictEqual(envelope.point, 'verify:post');
|
|
assert.ok(Array.isArray(envelope.activeHooks), 'activeHooks must be an array');
|
|
const sec = envelope.activeHooks.find((h) => h.capId === 'security' && h.kind === 'step');
|
|
const nyq = envelope.activeHooks.find((h) => h.capId === 'nyquist' && h.kind === 'step');
|
|
assert.ok(
|
|
sec && sec.ref && sec.ref.skill === 'secure-phase',
|
|
'verify:post must include security -> secure-phase (pre-fix: activeHooks was []). Got: ' + JSON.stringify(envelope.activeHooks),
|
|
);
|
|
assert.ok(
|
|
nyq && nyq.ref && nyq.ref.skill === 'validate-phase',
|
|
'verify:post must include nyquist -> validate-phase (pre-fix: activeHooks was []). Got: ' + JSON.stringify(envelope.activeHooks),
|
|
);
|
|
});
|
|
|
|
test('negative: when both gates disabled, hooks stay inactive (no over-activation)', () => {
|
|
const disabledProj = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-instproj-off-'));
|
|
try {
|
|
fs.mkdirSync(path.join(disabledProj, '.planning'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(disabledProj, '.planning', 'config.json'),
|
|
JSON.stringify({ workflow: { security_enforcement: false, nyquist_validation: false } }),
|
|
'utf8',
|
|
);
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[
|
|
installedGsdTools,
|
|
'loop', 'render-hooks', 'verify:post',
|
|
'--config-dir', installedConfigDir,
|
|
'--cwd', disabledProj,
|
|
],
|
|
{ encoding: 'utf8', timeout: 20000 },
|
|
);
|
|
assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}:\nstderr: ${result.stderr}`);
|
|
const envelope = JSON.parse(result.stdout.trim());
|
|
const sec = (envelope.activeHooks || []).find((h) => h.capId === 'security' && h.kind === 'step');
|
|
const nyq = (envelope.activeHooks || []).find((h) => h.capId === 'nyquist' && h.kind === 'step');
|
|
assert.ok(!sec, 'security step must NOT be active when security_enforcement=false');
|
|
assert.ok(!nyq, 'nyquist step must NOT be active when nyquist_validation=false');
|
|
} finally {
|
|
cleanup(disabledProj);
|
|
}
|
|
});
|
|
});
|
|
|
|
});
|