Add a read-side query composing the three toggle systems into one
per-capability view. resolveCapabilityState({registry, installedSkills,
surfacedSkills, config, cwd}) reports installed (skills ⊆ resolved install
profile), surfaced (skills ⊆ resolved surface), and per-hook active (no when →
active; non-empty-string when → resolved via _resolveActivationValue; empty/
non-string → inactive), with no forced composite verdict. cmdCapabilityState
does the I/O (resolveProfile + resolveSurface + loadConfig), resolves the
runtime config dir via the canonical getGlobalConfigDir (--config-dir override),
and surfaces resolution failures as warnings rather than a false installed='*'.
Routed as `gsd-tools capability state`.
Additive: install/surface/workflows untouched; consumed by nothing.
Closes #945
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
688
tests/capability-state.test.cjs
Normal file
688
tests/capability-state.test.cjs
Normal file
@@ -0,0 +1,688 @@
|
||||
'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,
|
||||
} = 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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test('UI cap step hook: workflow.ui_phase true → active=true', () => {
|
||||
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);
|
||||
// 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.active, true);
|
||||
});
|
||||
|
||||
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.active, false);
|
||||
});
|
||||
|
||||
test('UI cap gate hook: workflow.ui_safety_gate true → active=true', () => {
|
||||
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);
|
||||
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.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.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;
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(tmpConfigDir);
|
||||
cleanup(tmpConfigDirCore);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user