* 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>
This commit is contained in:
275
tests/capability-loader.test.cjs
Normal file
275
tests/capability-loader.test.cjs
Normal file
@@ -0,0 +1,275 @@
|
||||
'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');
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,11 @@ const {
|
||||
|
||||
const { LOOP_HOST_CONTRACT } = require('../gsd-core/bin/lib/loop-host-contract.cjs');
|
||||
|
||||
// ADR-1244 D2: the validator was extracted to a shared runtime-callable module.
|
||||
// The generator must re-export it verbatim — the parity suite below proves no drift.
|
||||
const capValidatorModule = require('../gsd-core/bin/lib/capability-validator.cjs');
|
||||
const generatorModule = require('../scripts/gen-capability-registry.cjs');
|
||||
|
||||
const fc = require('fast-check');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
@@ -5035,3 +5040,61 @@ describe('activationKey validation', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ADR-1244 D2: validator extraction generative parity ──────────────────────
|
||||
//
|
||||
// The validator now lives in gsd-core/bin/lib/capability-validator.cjs and is
|
||||
// re-exported by the generator. These assertions guarantee the build-time
|
||||
// generator and the runtime overlay share ONE validator implementation — no
|
||||
// divergent copy can drift between them, because the generator re-exports the
|
||||
// very same object references.
|
||||
describe('ADR-1244 D2: validator extraction generative parity', () => {
|
||||
const CORE = [
|
||||
'validateCapability', 'validateCrossCapability', 'validateVersionEnvelope',
|
||||
'validateConsumesGlobal', 'validateAgainstContract', 'validateConfigSliceEntry',
|
||||
'validateRuntimeBody', 'classifyCrossErrors',
|
||||
];
|
||||
|
||||
test('the runtime validator module exposes the full validator surface', () => {
|
||||
for (const sym of [...CORE, 'SEMVER_RE', 'SEMVER_RANGE_RE', 'POINT_ORDER', 'VALID_LOOP_POINTS', 'VALID_TIERS']) {
|
||||
assert.ok(sym in capValidatorModule, `validator module must export ${sym}`);
|
||||
}
|
||||
assert.strictEqual(typeof capValidatorModule.validateCapability, 'function');
|
||||
assert.ok(capValidatorModule.SEMVER_RE instanceof RegExp);
|
||||
});
|
||||
|
||||
test('every generator-re-exported validator symbol is the SAME object as the validator module (no drift)', () => {
|
||||
const shared = Object.keys(capValidatorModule).filter((k) => Object.prototype.hasOwnProperty.call(generatorModule, k));
|
||||
assert.ok(shared.length >= 20, `expected the generator to re-export the validator surface, got ${shared.length}`);
|
||||
for (const k of shared) {
|
||||
assert.strictEqual(
|
||||
generatorModule[k],
|
||||
capValidatorModule[k],
|
||||
`generator export "${k}" must be the SAME reference as the validator module's (drift detected)`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('core validators are re-exported identically by the generator', () => {
|
||||
for (const sym of CORE) {
|
||||
assert.strictEqual(
|
||||
generatorModule[sym], capValidatorModule[sym],
|
||||
`${sym} must be re-exported by the generator as the validator module's reference`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the extracted validator runs standalone (no generator/build-time deps required)', () => {
|
||||
// Proves the module is genuinely runtime-callable: a clean require + validate
|
||||
// with no install-profiles/clusters/config-schema machinery present.
|
||||
const { validateCapability } = capValidatorModule;
|
||||
const cap = {
|
||||
id: 'demo', role: 'feature', version: '1.0.0', title: 'Demo', description: 'demo',
|
||||
tier: 'standard', requires: [], runtimeCompat: { supported: ['*'], unsupported: [] },
|
||||
skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [],
|
||||
};
|
||||
assert.deepEqual(validateCapability(cap, 'demo'), []);
|
||||
const { version: _v, ...noVersion } = cap;
|
||||
assert.ok(validateCapability(noVersion, 'demo').some((e) => e.includes('version')));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1719,3 +1719,53 @@ describe('isCapabilityActive cross-runtime detection (GSD_RUNTIME → config.run
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ADR-1244 D2 overlay wiring — capability-state sees installed overlays ───
|
||||
|
||||
describe('ADR-1244 D2: overlay-aware registry wiring in capability-state', () => {
|
||||
// Verifies that resolveCapabilityRuntimeState uses loadRegistry({includeInstalled:true})
|
||||
// so a valid installed overlay capability appears in the capabilities list.
|
||||
// The overlay cap has no activationKey so it activates freely.
|
||||
const { resolveCapabilityRuntimeState } = require('../gsd-core/bin/lib/capability-state.cjs');
|
||||
|
||||
test('valid overlay capability appears in runtime state capabilities list', () => {
|
||||
const overlayHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-overlay-'));
|
||||
const prevGsdHome = process.env.GSD_HOME;
|
||||
try {
|
||||
// Write a valid overlay capability manifest
|
||||
const capDir = path.join(overlayHome, '.gsd', 'capabilities', 'my-overlay-cap');
|
||||
fs.mkdirSync(capDir, { recursive: true });
|
||||
const capManifest = {
|
||||
id: 'my-overlay-cap',
|
||||
role: 'feature',
|
||||
version: '1.0.0',
|
||||
title: 'My Overlay Cap',
|
||||
description: 'ADR-1244 D2 wiring test overlay',
|
||||
tier: 'standard',
|
||||
requires: [],
|
||||
engines: { gsd: '>=0.0.0' },
|
||||
runtimeCompat: { supported: ['*'], unsupported: [] },
|
||||
skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [],
|
||||
};
|
||||
fs.writeFileSync(path.join(capDir, 'capability.json'), JSON.stringify(capManifest), 'utf8');
|
||||
|
||||
// Point GSD_HOME to the overlay home so loadRegistry finds it
|
||||
process.env.GSD_HOME = overlayHome;
|
||||
|
||||
// Use a non-existent cwd so no project-scope overlay is scanned — pure global
|
||||
const nonExistentCwd = path.join(os.tmpdir(), 'cap-state-overlay-cwd-' + Date.now());
|
||||
const result = resolveCapabilityRuntimeState(nonExistentCwd, undefined);
|
||||
|
||||
const overlayEntry = result.capabilities.find((c) => c.id === 'my-overlay-cap');
|
||||
assert.ok(
|
||||
overlayEntry !== undefined,
|
||||
'overlay capability "my-overlay-cap" must appear in resolveCapabilityRuntimeState results ' +
|
||||
'(ADR-1244 D2: capability-state must use overlay-aware loadRegistry)',
|
||||
);
|
||||
} finally {
|
||||
if (prevGsdHome === undefined) delete process.env.GSD_HOME;
|
||||
else process.env.GSD_HOME = prevGsdHome;
|
||||
cleanup(overlayHome);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,12 +17,17 @@
|
||||
* (e) Arbitrary garbage strings return false (not throw) from isValidConfigKey
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
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 fc = require('./helpers/fast-check-setup.cjs');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
isValidConfigKey,
|
||||
isCapabilityConfigKey,
|
||||
VALID_CONFIG_KEYS,
|
||||
RUNTIME_STATE_KEYS,
|
||||
} = require('../gsd-core/bin/lib/config-schema.cjs');
|
||||
@@ -152,3 +157,72 @@ describe('config-schema: isValidConfigKey properties', () => {
|
||||
assert.equal(isValidConfigKey(NaN), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ADR-1244 D2: cwd-aware overlay config-key federation ─────────────────────
|
||||
//
|
||||
// Exercises every branch of the new _capabilityConfigSchema(cwd) path so the
|
||||
// mutation suite (this is the file Stryker runs for config-schema) KILLS the
|
||||
// added mutants: the `typeof cwd === 'string' && cwd` guard, the overlay
|
||||
// loadRegistry({includeInstalled,cwd}) call, the `schema && typeof === 'object'`
|
||||
// found-branch, the first-party fallback, and the cwd threading through
|
||||
// isValidConfigKey. Uses a real overlay fixture (no test seam).
|
||||
describe('config-schema: cwd-aware overlay federation (ADR-1244 D2)', () => {
|
||||
const OVERLAY_KEY = 'workflow.cfgschema_overlay_gate';
|
||||
// A known FIRST-PARTY capability config key (ui capability) — exercises the
|
||||
// first-party fallback branch (no cwd → frozen registry configSchema).
|
||||
const FIRST_PARTY_KEY = 'workflow.ui_phase';
|
||||
const overlayCap = {
|
||||
id: 'cfgschema-overlay', role: 'feature', version: '1.0.0', title: 'cfg overlay', description: 'x',
|
||||
tier: 'standard', requires: [], engines: { gsd: '>=1.0.0' },
|
||||
runtimeCompat: { supported: ['*'], unsupported: [] },
|
||||
skills: ['cfgschema-overlay-skill'], agents: [], hooks: [],
|
||||
config: { [OVERLAY_KEY]: { type: 'boolean', default: true, description: 'overlay-owned key' } },
|
||||
steps: [], contributions: [], gates: [],
|
||||
};
|
||||
|
||||
let withOverlay, withoutOverlay, sandboxHome, savedHome;
|
||||
before(() => {
|
||||
savedHome = process.env.GSD_HOME;
|
||||
sandboxHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cfgschema-home-'));
|
||||
process.env.GSD_HOME = sandboxHome; // empty global overlay root
|
||||
withOverlay = fs.mkdtempSync(path.join(os.tmpdir(), 'cfgschema-proj-'));
|
||||
fs.mkdirSync(path.join(withOverlay, '.planning'), { recursive: true }); // project-root marker
|
||||
const capDir = path.join(withOverlay, '.gsd', 'capabilities', 'cfgschema-overlay');
|
||||
fs.mkdirSync(capDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(capDir, 'capability.json'), JSON.stringify(overlayCap), 'utf8');
|
||||
withoutOverlay = fs.mkdtempSync(path.join(os.tmpdir(), 'cfgschema-bare-'));
|
||||
fs.mkdirSync(path.join(withoutOverlay, '.planning'), { recursive: true });
|
||||
});
|
||||
after(() => {
|
||||
if (savedHome === undefined) delete process.env.GSD_HOME; else process.env.GSD_HOME = savedHome;
|
||||
cleanup(sandboxHome); cleanup(withOverlay); cleanup(withoutOverlay);
|
||||
});
|
||||
|
||||
test('first-party fallback: a first-party capability config key is valid with no cwd', () => {
|
||||
// Kills the fallback branch (return fp ... : {}) and the no-cwd path.
|
||||
assert.equal(isCapabilityConfigKey(FIRST_PARTY_KEY), true);
|
||||
assert.equal(isValidConfigKey(FIRST_PARTY_KEY), true);
|
||||
});
|
||||
|
||||
test('overlay key is recognized only when the installing project cwd is supplied', () => {
|
||||
// cwd with the overlay → true (kills cwd-guard, loadRegistry call, found-branch, hasOwnProperty)
|
||||
assert.equal(isCapabilityConfigKey(OVERLAY_KEY, withOverlay), true);
|
||||
assert.equal(isValidConfigKey(OVERLAY_KEY, withOverlay), true);
|
||||
// no cwd → first-party only → false (kills the cwd-true→fallback distinction)
|
||||
assert.equal(isCapabilityConfigKey(OVERLAY_KEY), false);
|
||||
assert.equal(isValidConfigKey(OVERLAY_KEY), false);
|
||||
// cwd WITHOUT the overlay → loadRegistry returns base → false (cwd-correct)
|
||||
assert.equal(isCapabilityConfigKey(OVERLAY_KEY, withoutOverlay), false);
|
||||
assert.equal(isValidConfigKey(OVERLAY_KEY, withoutOverlay), false);
|
||||
});
|
||||
|
||||
test('a genuinely unknown key is invalid regardless of cwd', () => {
|
||||
assert.equal(isCapabilityConfigKey('zz.not.a.key', withOverlay), false);
|
||||
assert.equal(isValidConfigKey('zz.not.a.key', withOverlay), false);
|
||||
});
|
||||
|
||||
test('non-string keyPath returns false even with a cwd (no throw)', () => {
|
||||
assert.equal(isCapabilityConfigKey(null, withOverlay), false);
|
||||
assert.equal(isCapabilityConfigKey(42, withOverlay), false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -423,3 +423,59 @@ describe('MALFORMED registry: loadConfig does not throw', () => {
|
||||
assert.ok(Object.prototype.hasOwnProperty.call(result, 'model_profile'), 'model_profile must be present');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 5. ADR-1244 D2: overlay config-key federation is cwd-aware (REAL loader, no seam) ──
|
||||
//
|
||||
// Proves "toggable via config" for installed third-party capabilities AND that it
|
||||
// is cwd-correct: an overlay capability's config key is valid + federates ONLY in
|
||||
// the project where the overlay is installed — never globally, never for the wrong
|
||||
// project, never from a bare require (no seam used here — the real loadRegistry path).
|
||||
describe('ADR-1244 D2: overlay config-key federation (cwd-aware, real loader)', () => {
|
||||
const configSchema = require('../gsd-core/bin/lib/config-schema.cjs');
|
||||
const KEY = 'workflow.overlay_demo_gate';
|
||||
const overlayCap = {
|
||||
id: 'overlay-demo', role: 'feature', version: '1.0.0', title: 'Overlay demo', description: 'overlay',
|
||||
tier: 'standard', requires: [], engines: { gsd: '>=1.0.0' },
|
||||
runtimeCompat: { supported: ['*'], unsupported: [] },
|
||||
skills: ['overlay-demo-skill'], agents: [], hooks: [],
|
||||
config: { [KEY]: { type: 'boolean', default: true, description: 'overlay-owned federated key' } },
|
||||
steps: [], contributions: [], gates: [],
|
||||
};
|
||||
|
||||
let sandboxHome, withOverlay, withoutOverlay, savedHome;
|
||||
beforeEach(() => {
|
||||
_resetFederatedRegistryForTests(); // NO seam override — exercise the real cwd-aware path
|
||||
savedHome = process.env.GSD_HOME;
|
||||
sandboxHome = makeTempProject();
|
||||
process.env.GSD_HOME = sandboxHome; // empty global overlay root
|
||||
withOverlay = mkTemp();
|
||||
const capDir = path.join(withOverlay, '.gsd', 'capabilities', 'overlay-demo');
|
||||
fs.mkdirSync(capDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(capDir, 'capability.json'), JSON.stringify(overlayCap), 'utf-8');
|
||||
withoutOverlay = mkTemp();
|
||||
});
|
||||
afterEach(() => {
|
||||
if (savedHome === undefined) delete process.env.GSD_HOME; else process.env.GSD_HOME = savedHome;
|
||||
try { cleanup(sandboxHome); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('overlay config key is valid in its own project, unknown elsewhere and with no cwd', () => {
|
||||
assert.equal(configSchema.isValidConfigKey(KEY, withOverlay), true, 'valid in the project that installs the overlay');
|
||||
assert.equal(configSchema.isValidConfigKey(KEY, withoutOverlay), false, 'unknown in a project without the overlay (cwd-correct)');
|
||||
assert.equal(configSchema.isValidConfigKey(KEY), false, 'unknown with no cwd (first-party only)');
|
||||
});
|
||||
|
||||
test('loadConfig federates the overlay key default only for the installing project', () => {
|
||||
writeConfig(withOverlay, {});
|
||||
const cfg = loadConfig(withOverlay);
|
||||
assert.strictEqual(cfg.workflow && cfg.workflow.overlay_demo_gate, true, 'overlay default federates in its project');
|
||||
|
||||
writeConfig(withoutOverlay, {});
|
||||
const other = loadConfig(withoutOverlay);
|
||||
assert.strictEqual(
|
||||
other.workflow ? other.workflow.overlay_demo_gate : undefined,
|
||||
undefined,
|
||||
'overlay key does NOT federate into an unrelated project',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1038,3 +1038,81 @@ describe('Phase 4 regression: capabilityStatesById gates on active (not enabled)
|
||||
assert.strictEqual(result.activeHooks[0].capId, 'test-cap');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ADR-1244 D2 fail-closed gate injection ────────────────────────────────────
|
||||
|
||||
describe('ADR-1244 D2: fail-closed gate injection for skipped overlay caps with gates', () => {
|
||||
// Verifies that cmdLoopRenderHooks injects a BLOCKING synthetic gate at the
|
||||
// declared point when an overlay capability that declares a gate is skipped at
|
||||
// load time due to an incompatible engines.gsd version constraint.
|
||||
//
|
||||
// Fixture: overlay cap declares a gate at execute:wave:post with engines.gsd: ">=99.0.0"
|
||||
// → loadRegistry skips it → records it in _overlay.blockedGates
|
||||
// → cmdLoopRenderHooks injects a blocking=true, onError=halt gate at execute:wave:post
|
||||
|
||||
test('skipped gate-kind overlay cap → BLOCKING synthetic gate at its declared point', (t) => {
|
||||
const overlayHome = fs.mkdtempSync(path.join(os.tmpdir(), 'loop-fail-closed-'));
|
||||
t.after(() => cleanup(overlayHome));
|
||||
|
||||
// Write an overlay capability that:
|
||||
// - declares a gate at execute:wave:post
|
||||
// - has engines.gsd: ">=99.0.0" (incompatible → will be skipped at load)
|
||||
const capId = 'fail-closed-gate-cap';
|
||||
const capDir = path.join(overlayHome, '.gsd', 'capabilities', capId);
|
||||
fs.mkdirSync(capDir, { recursive: true });
|
||||
const capManifest = {
|
||||
id: capId,
|
||||
role: 'feature',
|
||||
version: '1.0.0',
|
||||
title: 'Fail Closed Gate Cap',
|
||||
description: 'ADR-1244 D2 fail-closed wiring test',
|
||||
tier: 'standard',
|
||||
requires: [],
|
||||
engines: { gsd: '>=99.0.0' }, // intentionally incompatible → always skipped
|
||||
runtimeCompat: { supported: ['*'], unsupported: [] },
|
||||
skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [],
|
||||
gates: [{ point: 'execute:wave:post', check: 'always-pass', blocking: true, onError: 'halt' }],
|
||||
};
|
||||
fs.writeFileSync(path.join(capDir, 'capability.json'), JSON.stringify(capManifest), 'utf8');
|
||||
|
||||
// Invoke gsd-tools via subprocess so stdout is the real fd-1 (io.cjs writes via writeSync).
|
||||
// Set GSD_HOME to the overlay home so loadRegistry picks up the incompatible cap.
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[GSD_TOOLS, 'loop', 'render-hooks', 'execute:wave:post', '--cwd', overlayHome],
|
||||
{
|
||||
cwd: ROOT,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, GSD_HOME: overlayHome },
|
||||
},
|
||||
);
|
||||
|
||||
assert.strictEqual(result.status, 0, 'Expected exit 0. stderr: ' + (result.stderr || ''));
|
||||
|
||||
let envelope;
|
||||
try {
|
||||
envelope = JSON.parse(result.stdout.trim());
|
||||
} catch {
|
||||
assert.fail('loop render-hooks output must be valid JSON; got: ' + result.stdout.slice(0, 300));
|
||||
}
|
||||
|
||||
// The synthetic blocking gate must be present in activeHooks
|
||||
const syntheticGate = Array.isArray(envelope.activeHooks)
|
||||
? envelope.activeHooks.find((h) => h.capId === capId && h.kind === 'gate')
|
||||
: undefined;
|
||||
assert.ok(
|
||||
syntheticGate !== undefined,
|
||||
`activeHooks must contain a synthetic gate attributed to ${capId} (fail-closed injection). ` +
|
||||
'Got: ' + JSON.stringify(envelope.activeHooks),
|
||||
);
|
||||
assert.strictEqual(syntheticGate.blocking, true, 'synthetic gate must be blocking=true');
|
||||
assert.strictEqual(syntheticGate.onError, 'halt', 'synthetic gate must have onError=halt');
|
||||
|
||||
// The rendered markdown must also reference the gate cap
|
||||
assert.ok(
|
||||
typeof envelope.rendered === 'string' && envelope.rendered.includes(capId),
|
||||
'rendered output must reference the fail-closed gate cap. Got: ' + envelope.rendered,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
compareSemverCore,
|
||||
isSemverNewer,
|
||||
toNumericTuple,
|
||||
semverSatisfies,
|
||||
} = require('../gsd-core/bin/lib/semver-compare.cjs');
|
||||
|
||||
describe('isSemverNewer (shared semver comparison)', () => {
|
||||
@@ -77,3 +78,102 @@ describe('isSemverNewer (shared semver comparison)', () => {
|
||||
assert.strictEqual(compareSemverCore('1.2.0', '1.2.1'), -1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('semverSatisfies (ADR-1244 engines.gsd range gate)', () => {
|
||||
const sat = (v, r, expected) =>
|
||||
assert.strictEqual(semverSatisfies(v, r), expected, `expected satisfies(${JSON.stringify(v)}, ${JSON.stringify(r)}) === ${expected}`);
|
||||
|
||||
test('>= comparator', () => {
|
||||
sat('1.6.0', '>=1.6.0', true);
|
||||
sat('1.6.1', '>=1.6.0', true);
|
||||
sat('2.0.0', '>=1.6.0', true);
|
||||
sat('1.5.9', '>=1.6.0', false);
|
||||
});
|
||||
|
||||
test('> < <= = comparators', () => {
|
||||
sat('1.6.1', '>1.6.0', true);
|
||||
sat('1.6.0', '>1.6.0', false);
|
||||
sat('1.5.0', '<1.6.0', true);
|
||||
sat('1.6.0', '<1.6.0', false);
|
||||
sat('1.6.0', '<=1.6.0', true);
|
||||
sat('1.6.1', '<=1.6.0', false);
|
||||
sat('1.6.0', '=1.6.0', true);
|
||||
sat('1.6.1', '=1.6.0', false);
|
||||
});
|
||||
|
||||
test('bare exact full version', () => {
|
||||
sat('1.6.0', '1.6.0', true);
|
||||
sat('1.6.1', '1.6.0', false);
|
||||
});
|
||||
|
||||
test('AND-composed range (whitespace)', () => {
|
||||
sat('1.6.0', '>=1.6.0 <3.0.0', true);
|
||||
sat('2.9.9', '>=1.6.0 <3.0.0', true);
|
||||
sat('3.0.0', '>=1.6.0 <3.0.0', false);
|
||||
sat('1.5.0', '>=1.6.0 <3.0.0', false);
|
||||
});
|
||||
|
||||
test('OR-composed range (||)', () => {
|
||||
sat('1.6.0', '>=1.6.0 || >=2.0.0', true);
|
||||
sat('2.0.0', '<1.0.0 || >=2.0.0', true);
|
||||
sat('1.5.0', '<1.0.0 || >=2.0.0', false);
|
||||
});
|
||||
|
||||
test('caret ranges', () => {
|
||||
sat('1.2.3', '^1.2.3', true);
|
||||
sat('1.9.0', '^1.2.3', true);
|
||||
sat('2.0.0', '^1.2.3', false);
|
||||
sat('1.2.2', '^1.2.3', false);
|
||||
sat('0.2.3', '^0.2.3', true);
|
||||
sat('0.3.0', '^0.2.3', false);
|
||||
sat('0.0.3', '^0.0.3', true);
|
||||
sat('0.0.4', '^0.0.3', false);
|
||||
});
|
||||
|
||||
test('tilde ranges', () => {
|
||||
sat('1.2.3', '~1.2.3', true);
|
||||
sat('1.2.9', '~1.2.3', true);
|
||||
sat('1.3.0', '~1.2.3', false);
|
||||
sat('1.2.0', '~1.2', true);
|
||||
sat('1.3.0', '~1.2', false);
|
||||
sat('1.9.0', '~1', true);
|
||||
sat('2.0.0', '~1', false);
|
||||
});
|
||||
|
||||
test('wildcards and partials', () => {
|
||||
sat('99.0.0', '*', true);
|
||||
sat('0.0.1', '*', true);
|
||||
sat('1.0.0', '1.x', true);
|
||||
sat('1.9.9', '1.x', true);
|
||||
sat('2.0.0', '1.x', false);
|
||||
sat('0.9.9', '1.x', false);
|
||||
sat('1.2.0', '1.2.x', true);
|
||||
sat('1.3.0', '1.2.x', false);
|
||||
sat('1.5.0', '1', true);
|
||||
sat('2.0.0', '1', false);
|
||||
});
|
||||
|
||||
test('prerelease and v-prefix normalize to numeric core', () => {
|
||||
sat('1.6.0-rc.1', '>=1.6.0', true);
|
||||
sat('1.5.1-dev.0', '>=1.6.0', false);
|
||||
sat('v1.6.0', '>=1.6.0', true);
|
||||
});
|
||||
|
||||
test('FAIL CLOSED on empty/unparseable ranges', () => {
|
||||
for (const bad of ['', ' ', 'abc', 'not a range', '>=', '>=x', 'foo.bar.baz', '1.2.3.4', '>=1.2.3 garbage']) {
|
||||
sat('1.6.0', bad, false);
|
||||
}
|
||||
});
|
||||
|
||||
test('FAIL CLOSED on malformed wildcard tokens (concrete segment after a wildcard)', () => {
|
||||
for (const bad of ['1.x.2', '>=1.x.2', '1.*.2', '1.X.0']) {
|
||||
sat('1.5.0', bad, false);
|
||||
}
|
||||
});
|
||||
|
||||
test('null/undefined inputs do not throw and fail closed', () => {
|
||||
sat('1.6.0', null, false);
|
||||
sat('1.6.0', undefined, false);
|
||||
sat(null, '>=1.6.0', false); // null version -> [0,0,0] -> not >= 1.6.0
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user