* feat(#1431): runtime capability registry overlay (ADR-1244 Phase 2) Promote the registry from a frozen data file to loadRegistry({includeInstalled}), composing the first-party registry with a validated installed overlay (ADR-1244 D2): - Extract the conformance validator to a shared runtime-callable module (gsd-core/bin/lib/capability-validator.cjs); the generator re-exports it verbatim, guarded by a generative-parity test (no build-time/runtime drift). - capability-loader.cts: loadRegistry({includeInstalled}) composes first-party ∪ validated overlay from $GSD_HOME/.gsd/capabilities (global) and <root>/.gsd/capabilities (project) via the canonical buildRegistry. First-party always wins (id/skill/agent/config/command-family + reserved gsd-/anthropic- prefixes); full merged-set cross-capability validation; engines.gsd load-time re-gate (skip-with-warning); gate-kind capabilities FAIL CLOSED; fragment-path escapes rejected. - semverSatisfies (hand-written, no dep) for the engines.gsd gate, fail-closed. - Wire surface/state + loop to the overlay; loop injects a blocking gate for each skipped gate-kind overlay (fail-closed). - cwd-aware overlay config-key federation: config-loader _federatedConfigSchema(cwd) + config-schema isValidConfigKey(key, cwd) compose the overlay per loadConfig/ config-set call (never eager at module load, never wrong-cwd); first-party path unchanged with no cwd. - run-tests.cjs sandboxes GSD_HOME (idempotent — nested spawns reuse it) for test hermeticity; capability-loader.cjs git+eslint-ignored (tsc artifact); capability-validator.cjs stays linted (#551 migration coverage). Closes #1431 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(#1431): add changeset for runtime capability registry overlay Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#1431): kill config-schema cwd-aware federation mutants (Stryker ≥52) The cwd-aware overlay config-key federation added to config-schema.cts (_capabilityConfigSchema(cwd) + isCapabilityConfigKey/isValidConfigKey cwd threading) introduced mutable surface uncovered by config-schema's mutation test set, dropping its score to 39.58% (below the 52 break threshold). Add a real-overlay-fixture describe block exercising every branch (cwd guard, overlay loadRegistry, found-branch, first-party fallback, cwd threading); local Stryker score 39.58% -> 77.08%. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
276 lines
13 KiB
JavaScript
276 lines
13 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* capability-loader.test.cjs — ADR-1244 D2 runtime registry overlay.
|
||
*
|
||
* Behavioral tests for loadRegistry({ includeInstalled }): first-party ∪
|
||
* validated overlay composition, first-party-wins collisions, reserved
|
||
* namespace, engines.gsd load-time re-gate (skip-with-warning), gate-kind
|
||
* fail-closed tracking, and parity with the canonical builder.
|
||
*/
|
||
|
||
const { describe, test } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const os = require('node:os');
|
||
const path = require('node:path');
|
||
|
||
const { cleanup } = require('./helpers.cjs');
|
||
const { loadRegistry } = require('../gsd-core/bin/lib/capability-loader.cjs');
|
||
const baseRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||
const { buildRegistry } = require('../scripts/gen-capability-registry.cjs');
|
||
|
||
const HOST = '1.6.0';
|
||
|
||
function featureCap(id, extra) {
|
||
return {
|
||
id, role: 'feature', version: '1.0.0', title: id, description: 'overlay cap',
|
||
tier: 'standard', requires: [], engines: { gsd: '>=1.0.0' },
|
||
runtimeCompat: { supported: ['*'], unsupported: [] },
|
||
skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [],
|
||
...extra,
|
||
};
|
||
}
|
||
|
||
// Build a temp GSD home containing .gsd/capabilities/<id>/capability.json for each cap.
|
||
function makeOverlayHome(caps) {
|
||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-overlay-'));
|
||
for (const cap of caps) {
|
||
const dir = path.join(home, '.gsd', 'capabilities', cap.id);
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
fs.writeFileSync(path.join(dir, 'capability.json'), JSON.stringify(cap), 'utf8');
|
||
}
|
||
return home;
|
||
}
|
||
|
||
// Always pass cwd === home so the project-root probe cannot wander into the
|
||
// real repo; root-dedup makes the project scope a no-op there.
|
||
function load(home, opts) {
|
||
return loadRegistry({ includeInstalled: true, gsdHome: home, cwd: home, hostVersion: HOST, ...opts });
|
||
}
|
||
|
||
describe('loadRegistry — base behavior', () => {
|
||
test('without includeInstalled returns the frozen registry (identity-stable)', () => {
|
||
assert.strictEqual(loadRegistry(), baseRegistry);
|
||
assert.strictEqual(loadRegistry({ includeInstalled: false }), baseRegistry);
|
||
});
|
||
|
||
test('includeInstalled with no overlay directory returns the frozen registry unchanged', () => {
|
||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-empty-'));
|
||
try {
|
||
assert.strictEqual(load(home), baseRegistry);
|
||
} finally {
|
||
cleanup(home);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('loadRegistry — accepting valid overlays', () => {
|
||
test('a valid overlay capability appears in every derived view (toggable + federated)', (t) => {
|
||
const home = makeOverlayHome([
|
||
featureCap('deploy-gate', {
|
||
skills: ['deploy-review'],
|
||
agents: ['gsd-deploy-checker'],
|
||
config: { 'workflow.deploy_gate': { type: 'boolean', default: true, description: 'Enable the deploy gate.' } },
|
||
steps: [{ point: 'execute:wave:post', ref: { skill: 'deploy-review' }, produces: ['DEPLOY.md'], consumes: [], when: 'workflow.deploy_gate', onError: 'skip' }],
|
||
}),
|
||
]);
|
||
t.after(() => cleanup(home));
|
||
const reg = load(home);
|
||
|
||
assert.ok(reg.capabilities['deploy-gate'], 'overlay in capabilities');
|
||
assert.equal(reg.bySkill['deploy-review'], 'deploy-gate', 'overlay skill indexed (surface)');
|
||
assert.equal(reg.byAgent['gsd-deploy-checker'], 'deploy-gate', 'overlay agent indexed');
|
||
assert.ok(reg.configSchema['workflow.deploy_gate'], 'overlay config federated');
|
||
assert.equal(reg.configKeys['workflow.deploy_gate'], 'deploy-gate', 'overlay config key owned');
|
||
assert.ok(reg.capabilityClusters['deploy-gate'], 'overlay in capabilityClusters (surface toggle)');
|
||
assert.ok(reg.profileMembership['deploy-gate'], 'overlay in profileMembership (surface toggle)');
|
||
const wavePost = reg.byLoopPoint['execute:wave:post'];
|
||
assert.ok(wavePost && Array.isArray(wavePost.steps) &&
|
||
wavePost.steps.some((h) => h.capId === 'deploy-gate'), 'overlay step wired into the loop');
|
||
|
||
// First-party is preserved.
|
||
assert.equal(reg.capabilities['ui'].title, 'UI design contracts');
|
||
assert.equal(reg._overlay.warnings.length, 0, 'no warnings when all overlays accepted');
|
||
assert.deepEqual(reg._overlay.incompatibleGateCapIds, []);
|
||
});
|
||
|
||
test('composed registry equals buildRegistry over the same merged cap-map (no drift / no dropped caps)', (t) => {
|
||
const overlay = featureCap('extra-cap', { skills: ['extra-skill'] });
|
||
const home = makeOverlayHome([overlay]);
|
||
t.after(() => cleanup(home));
|
||
const reg = load(home);
|
||
|
||
const mergedMap = new Map(Object.entries(baseRegistry.capabilities));
|
||
mergedMap.set('extra-cap', overlay);
|
||
const expected = buildRegistry(mergedMap);
|
||
|
||
assert.deepEqual(Object.keys(reg.capabilities).sort(), Object.keys(expected.capabilities).sort());
|
||
assert.deepEqual(reg.bySkill, expected.bySkill);
|
||
assert.deepEqual(Object.keys(reg.configSchema).sort(), Object.keys(expected.configSchema).sort());
|
||
assert.deepEqual(reg.capabilityClusters['extra-cap'], expected.capabilityClusters['extra-cap']);
|
||
});
|
||
});
|
||
|
||
describe('loadRegistry — first-party always wins', () => {
|
||
test('overlay whose id collides with a first-party id is rejected; first-party preserved', (t) => {
|
||
const home = makeOverlayHome([featureCap('ui', { skills: ['hijacked'] })]);
|
||
t.after(() => cleanup(home));
|
||
const reg = load(home);
|
||
assert.equal(reg.capabilities['ui'].title, 'UI design contracts', 'first-party ui untouched');
|
||
assert.ok(reg._overlay.warnings.some((w) => w.id === 'ui' && /collide/i.test(w.reason)));
|
||
});
|
||
|
||
test('overlay claiming a first-party skill stem is rejected', (t) => {
|
||
const home = makeOverlayHome([featureCap('skill-thief', { skills: ['ui-phase'] })]);
|
||
t.after(() => cleanup(home));
|
||
const reg = load(home);
|
||
assert.ok(!reg.capabilities['skill-thief']);
|
||
assert.ok(reg._overlay.warnings.some((w) => w.id === 'skill-thief' && /skill/i.test(w.reason)));
|
||
});
|
||
|
||
test('reserved id prefix (gsd-/gsd-core-/anthropic-) is rejected', (t) => {
|
||
const home = makeOverlayHome([
|
||
featureCap('gsd-impostor'),
|
||
featureCap('anthropic-impostor'),
|
||
]);
|
||
t.after(() => cleanup(home));
|
||
const reg = load(home);
|
||
assert.ok(!reg.capabilities['gsd-impostor']);
|
||
assert.ok(!reg.capabilities['anthropic-impostor']);
|
||
assert.equal(reg._overlay.warnings.filter((w) => /reserved/i.test(w.reason)).length, 2);
|
||
});
|
||
});
|
||
|
||
describe('loadRegistry — load-time re-gate (engines.gsd) + fail-closed gates', () => {
|
||
test('incompatible engines.gsd is skipped with a warning', (t) => {
|
||
const home = makeOverlayHome([featureCap('future-cap', { engines: { gsd: '>=99.0.0' } })]);
|
||
t.after(() => cleanup(home));
|
||
const reg = load(home);
|
||
assert.ok(!reg.capabilities['future-cap']);
|
||
assert.ok(reg._overlay.warnings.some((w) => w.id === 'future-cap' && /incompatible/i.test(w.reason)));
|
||
assert.deepEqual(reg._overlay.incompatibleGateCapIds, [], 'no gate declared → not a fail-closed blocker');
|
||
});
|
||
|
||
test('a skipped capability that DECLARES a gate is recorded for fail-closed handling', (t) => {
|
||
const home = makeOverlayHome([
|
||
featureCap('incompat-gate', {
|
||
engines: { gsd: '>=99.0.0' },
|
||
gates: [{ point: 'execute:wave:post', check: { query: 'x.deploy' }, blocking: true, onError: 'halt' }],
|
||
}),
|
||
]);
|
||
t.after(() => cleanup(home));
|
||
const reg = load(home);
|
||
assert.ok(!reg.capabilities['incompat-gate'], 'incompatible cap not loaded');
|
||
assert.ok(reg._overlay.incompatibleGateCapIds.includes('incompat-gate'), 'gate-kind tracked as fail-closed');
|
||
assert.ok(
|
||
reg._overlay.blockedGates.some((g) => g.point === 'execute:wave:post' && g.capId === 'incompat-gate'),
|
||
'declared gate point recorded for per-point fail-closed injection',
|
||
);
|
||
});
|
||
|
||
test('compatible engines.gsd is accepted', (t) => {
|
||
const home = makeOverlayHome([featureCap('compat-cap', { engines: { gsd: '>=1.6.0 <3.0.0' } })]);
|
||
t.after(() => cleanup(home));
|
||
const reg = load(home);
|
||
assert.ok(reg.capabilities['compat-cap']);
|
||
});
|
||
});
|
||
|
||
describe('loadRegistry — malformed overlays are skipped, never crash', () => {
|
||
test('manifest failing validation is skipped with a warning', (t) => {
|
||
const home = makeOverlayHome([
|
||
// missing required version → validateCapability error
|
||
(() => { const c = featureCap('no-version'); delete c.version; return c; })(),
|
||
]);
|
||
t.after(() => cleanup(home));
|
||
const reg = load(home);
|
||
assert.ok(!reg.capabilities['no-version']);
|
||
assert.ok(reg._overlay.warnings.some((w) => w.id === 'no-version' && /version/i.test(w.reason)));
|
||
});
|
||
|
||
test('unreadable / invalid JSON is skipped with a warning (no throw)', (t) => {
|
||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-badjson-'));
|
||
t.after(() => cleanup(home));
|
||
const dir = path.join(home, '.gsd', 'capabilities', 'broken');
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
fs.writeFileSync(path.join(dir, 'capability.json'), '{ not valid json', 'utf8');
|
||
let reg;
|
||
assert.doesNotThrow(() => { reg = load(home); });
|
||
assert.ok(!reg.capabilities['broken']);
|
||
assert.ok(reg._overlay.warnings.some((w) => w.id === 'broken'));
|
||
});
|
||
|
||
test('the loop never crashes — first-party registry remains fully intact alongside bad overlays', (t) => {
|
||
const home = makeOverlayHome([
|
||
featureCap('gsd-reserved'),
|
||
(() => { const c = featureCap('bad'); c.role = 'nonsense'; return c; })(),
|
||
featureCap('good', { skills: ['good-only-skill'] }),
|
||
]);
|
||
t.after(() => cleanup(home));
|
||
const reg = load(home);
|
||
assert.equal(Object.keys(baseRegistry.capabilities).length + 1, Object.keys(reg.capabilities).length,
|
||
'exactly the one good overlay is added; first-party count preserved');
|
||
assert.ok(reg.capabilities['good']);
|
||
});
|
||
});
|
||
|
||
describe('loadRegistry — full merged-set cross-capability validation', () => {
|
||
test('overlay claiming a first-party command family is rejected (first-party wins)', (t) => {
|
||
const firstPartyFamily = Object.keys(baseRegistry.commandFamilies || {})[0];
|
||
assert.ok(firstPartyFamily, 'precondition: first-party owns at least one command family');
|
||
const home = makeOverlayHome([
|
||
featureCap('cmd-thief', { commands: [{ family: firstPartyFamily, module: 'thief.cjs', router: 'route' }] }),
|
||
]);
|
||
t.after(() => cleanup(home));
|
||
const reg = load(home);
|
||
assert.ok(!reg.capabilities['cmd-thief'], 'overlay hijacking a first-party command family is not loaded');
|
||
assert.ok(reg._overlay.warnings.some((w) => w.id === 'cmd-thief' && /command family/i.test(w.reason)));
|
||
assert.ok(reg.commandFamilies[firstPartyFamily], 'first-party command family preserved');
|
||
});
|
||
|
||
test('overlay with an unsatisfiable consumes is rejected by cross-capability validation', (t) => {
|
||
const home = makeOverlayHome([
|
||
featureCap('bad-consumes', {
|
||
skills: ['bad-consumes-skill'],
|
||
config: { 'workflow.bad_consumes': { type: 'boolean', default: true, description: 'x' } },
|
||
steps: [{ point: 'plan:pre', ref: { skill: 'bad-consumes-skill' }, produces: [], consumes: ['NONEXISTENT-ARTIFACT.md'], when: 'workflow.bad_consumes', onError: 'skip' }],
|
||
}),
|
||
]);
|
||
t.after(() => cleanup(home));
|
||
const reg = load(home);
|
||
assert.ok(!reg.capabilities['bad-consumes'], 'overlay failing consumes-satisfiability is not loaded');
|
||
assert.ok(reg._overlay.warnings.some((w) => w.id === 'bad-consumes' && /cross-capability/i.test(w.reason)));
|
||
});
|
||
|
||
test('an invalid hook fragment path (escaping the capability dir) is rejected', (t) => {
|
||
const home = makeOverlayHome([
|
||
featureCap('frag-escape', {
|
||
contributions: [{ point: 'plan:pre', into: 'planner', fragment: { path: '../../../etc/passwd' }, when: 'workflow.frag', onError: 'skip' }],
|
||
config: { 'workflow.frag': { type: 'boolean', default: true, description: 'x' } },
|
||
}),
|
||
]);
|
||
t.after(() => cleanup(home));
|
||
const reg = load(home);
|
||
assert.ok(!reg.capabilities['frag-escape'], 'overlay with an escaping fragment path is not loaded');
|
||
assert.ok(reg._overlay.warnings.some((w) => w.id === 'frag-escape' && /fragment/i.test(w.reason)));
|
||
});
|
||
});
|
||
|
||
describe('loadRegistry — project-scoped overlay root', () => {
|
||
test('reads an overlay from <projectRoot>/.gsd/capabilities when cwd is inside a project', (t) => {
|
||
const proj = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-proj-'));
|
||
t.after(() => cleanup(proj));
|
||
fs.mkdirSync(path.join(proj, '.planning'), { recursive: true }); // project-root marker
|
||
const dir = path.join(proj, '.gsd', 'capabilities', 'proj-cap');
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
fs.writeFileSync(path.join(dir, 'capability.json'), JSON.stringify(featureCap('proj-cap', { skills: ['proj-skill'] })), 'utf8');
|
||
|
||
// Point the global home elsewhere (empty) so only the project scope contributes.
|
||
const emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-emptyhome-'));
|
||
t.after(() => cleanup(emptyHome));
|
||
const reg = loadRegistry({ includeInstalled: true, gsdHome: emptyHome, cwd: proj, hostVersion: HOST });
|
||
assert.ok(reg.capabilities['proj-cap'], 'project-scoped overlay loaded');
|
||
});
|
||
});
|