Merge branch 'next' into kimi-runtime-support
This commit is contained in:
@@ -29,6 +29,8 @@ const {
|
||||
computeRequiresClosure,
|
||||
topoSortSteps,
|
||||
normalizeLineEndings,
|
||||
validateConfigSliceEntry,
|
||||
VALID_CONFIG_SLICE_TYPES,
|
||||
SCHEMA_VERSION,
|
||||
} = require('../scripts/gen-capability-registry.cjs');
|
||||
|
||||
@@ -101,10 +103,33 @@ describe('UI pilot capability', () => {
|
||||
assert.strictEqual(uiGate.capId, 'ui');
|
||||
assert.strictEqual(uiGate.blocking, true);
|
||||
|
||||
// configKeys maps the 3 UI keys to 'ui'
|
||||
// configKeys maps the 3 UI keys to 'ui' (ownership map — preserved)
|
||||
assert.strictEqual(registry.configKeys['workflow.ui_phase'], 'ui');
|
||||
assert.strictEqual(registry.configKeys['workflow.ui_review'], 'ui');
|
||||
assert.strictEqual(registry.configKeys['workflow.ui_safety_gate'], 'ui');
|
||||
|
||||
// configSchema index — new in phase 3b
|
||||
assert.ok(registry.configSchema, 'registry.configSchema should exist');
|
||||
|
||||
// workflow.ui_phase
|
||||
assert.ok(registry.configSchema['workflow.ui_phase'], 'configSchema should have workflow.ui_phase');
|
||||
assert.strictEqual(registry.configSchema['workflow.ui_phase'].owner, 'ui');
|
||||
assert.strictEqual(registry.configSchema['workflow.ui_phase'].type, 'boolean');
|
||||
assert.strictEqual(registry.configSchema['workflow.ui_phase'].default, true);
|
||||
assert.strictEqual(typeof registry.configSchema['workflow.ui_phase'].description, 'string');
|
||||
assert.ok(registry.configSchema['workflow.ui_phase'].description.length > 0);
|
||||
|
||||
// workflow.ui_review
|
||||
assert.ok(registry.configSchema['workflow.ui_review'], 'configSchema should have workflow.ui_review');
|
||||
assert.strictEqual(registry.configSchema['workflow.ui_review'].owner, 'ui');
|
||||
assert.strictEqual(registry.configSchema['workflow.ui_review'].type, 'boolean');
|
||||
assert.strictEqual(registry.configSchema['workflow.ui_review'].default, true);
|
||||
|
||||
// workflow.ui_safety_gate
|
||||
assert.ok(registry.configSchema['workflow.ui_safety_gate'], 'configSchema should have workflow.ui_safety_gate');
|
||||
assert.strictEqual(registry.configSchema['workflow.ui_safety_gate'].owner, 'ui');
|
||||
assert.strictEqual(registry.configSchema['workflow.ui_safety_gate'].type, 'boolean');
|
||||
assert.strictEqual(registry.configSchema['workflow.ui_safety_gate'].default, true);
|
||||
});
|
||||
|
||||
test('requiresClosure("ui") returns empty set (no requires)', () => {
|
||||
@@ -1200,6 +1225,7 @@ describe('FIX 1: self-consume rejection in validateConsumesGlobal', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// (this test follows the series above)
|
||||
test('a step produces:["SELF.md"] and consumes:["SELF.md"] and another capability produces SELF.md at the SAME point is accepted (different hook)', () => {
|
||||
const producerCap = {
|
||||
id: 'producer-cap', role: 'feature', title: 'Producer', description: 'Produces SELF.md',
|
||||
@@ -1240,3 +1266,241 @@ describe('FIX 1: self-consume rejection in validateConsumesGlobal', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 14. configSchema emission (ADR-857 phase 3b) ────────────────────────────
|
||||
|
||||
describe('configSchema emission (ADR-857 phase 3b)', () => {
|
||||
test('buildRegistry emits configSchema with correct shape for UI pilot', () => {
|
||||
const capDir = makeTempCapDir({ ui: UI_CAP });
|
||||
const { capMap, errors } = loadAndValidate(new Set(), capDir);
|
||||
assert.deepEqual(errors, [], 'No errors expected');
|
||||
|
||||
const registry = buildRegistry(capMap);
|
||||
assert.ok(registry.configSchema, 'registry.configSchema must exist');
|
||||
|
||||
const uiPhase = registry.configSchema['workflow.ui_phase'];
|
||||
assert.ok(uiPhase, 'configSchema must have workflow.ui_phase');
|
||||
assert.strictEqual(uiPhase.owner, 'ui');
|
||||
assert.strictEqual(uiPhase.type, 'boolean');
|
||||
assert.strictEqual(uiPhase.default, true);
|
||||
assert.ok(typeof uiPhase.description === 'string' && uiPhase.description.length > 0);
|
||||
|
||||
const uiReview = registry.configSchema['workflow.ui_review'];
|
||||
assert.ok(uiReview, 'configSchema must have workflow.ui_review');
|
||||
assert.strictEqual(uiReview.owner, 'ui');
|
||||
assert.strictEqual(uiReview.type, 'boolean');
|
||||
|
||||
const uiSafetyGate = registry.configSchema['workflow.ui_safety_gate'];
|
||||
assert.ok(uiSafetyGate, 'configSchema must have workflow.ui_safety_gate');
|
||||
assert.strictEqual(uiSafetyGate.type, 'boolean');
|
||||
});
|
||||
|
||||
test('serializeRegistry emits a configSchema block in the generated .cjs', () => {
|
||||
const capDir = makeTempCapDir({ ui: UI_CAP });
|
||||
const { capMap } = loadAndValidate(new Set(), capDir);
|
||||
const registry = buildRegistry(capMap);
|
||||
const content = serializeRegistry(registry, capMap);
|
||||
|
||||
assert.ok(content.includes('const configSchema'), 'Generated file must contain "const configSchema"');
|
||||
assert.ok(content.includes('"workflow.ui_phase"'), 'Generated file must contain "workflow.ui_phase"');
|
||||
assert.ok(content.includes('"owner"'), 'Generated file must contain "owner" field');
|
||||
assert.ok(content.includes('"type"'), 'Generated file must contain "type" field');
|
||||
assert.ok(content.includes('"default"'), 'Generated file must contain "default" field');
|
||||
assert.ok(content.includes('"description"'), 'Generated file must contain "description" field');
|
||||
assert.ok(content.includes('configSchema,'), 'Generated module.exports must include configSchema');
|
||||
});
|
||||
|
||||
test('committed capability-registry.cjs has configSchema with correct shape', () => {
|
||||
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
assert.ok(registry.configSchema, 'capability-registry.cjs must export configSchema');
|
||||
|
||||
const uiPhase = registry.configSchema['workflow.ui_phase'];
|
||||
assert.ok(uiPhase, 'committed registry configSchema must have workflow.ui_phase');
|
||||
assert.strictEqual(uiPhase.owner, 'ui', 'owner must be "ui"');
|
||||
assert.strictEqual(uiPhase.type, 'boolean', 'type must be "boolean"');
|
||||
assert.strictEqual(uiPhase.default, true, 'default must be true');
|
||||
assert.ok(typeof uiPhase.description === 'string' && uiPhase.description.length > 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 15. validateConfigSliceEntry adversarial tests ───────────────────────────
|
||||
|
||||
describe('validateConfigSliceEntry adversarial cases (ADR-857 phase 3b)', () => {
|
||||
const CAP_ID = 'test-cap';
|
||||
const KEY = 'test.key';
|
||||
|
||||
test('VALID_CONFIG_SLICE_TYPES exports expected types', () => {
|
||||
const types = [...VALID_CONFIG_SLICE_TYPES];
|
||||
assert.ok(types.includes('boolean'), 'Must include boolean');
|
||||
assert.ok(types.includes('string'), 'Must include string');
|
||||
assert.ok(types.includes('number'), 'Must include number');
|
||||
assert.ok(types.includes('enum'), 'Must include enum');
|
||||
assert.strictEqual(types.length, 4, 'Must have exactly 4 types');
|
||||
});
|
||||
|
||||
test('valid boolean slice passes validation', () => {
|
||||
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'boolean', default: true, description: 'ok' });
|
||||
assert.deepEqual(errors, [], 'Valid boolean slice should produce no errors, got: ' + JSON.stringify(errors));
|
||||
});
|
||||
|
||||
test('valid string slice passes validation', () => {
|
||||
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'string', default: 'x', description: 'ok' });
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
test('valid number slice passes validation', () => {
|
||||
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'number', default: 5, description: 'ok' });
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
test('REJECTED: enum slice without values list → error (FIX 5a: values required)', () => {
|
||||
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'enum', default: 'x', description: 'ok' });
|
||||
assert.ok(errors.length > 0, 'Expected rejection for enum without values list, got: ' + JSON.stringify(errors));
|
||||
assert.ok(
|
||||
errors.some((e) => e.includes('values') || e.includes('enum')),
|
||||
'Error should mention values or enum, got: ' + JSON.stringify(errors),
|
||||
);
|
||||
});
|
||||
|
||||
test('valid enum slice (with values list, default in values) passes', () => {
|
||||
const errors = validateConfigSliceEntry(CAP_ID, KEY, {
|
||||
type: 'enum', default: 'b', values: ['a', 'b', 'c'], description: 'ok',
|
||||
});
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
test('REJECTED: bad type ("xml") → error mentioning type', () => {
|
||||
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'xml', default: '<x/>', description: 'ok' });
|
||||
assert.ok(errors.length > 0, 'Expected rejection for bad type');
|
||||
assert.ok(errors.some((e) => e.includes('type')), 'Error should mention type, got: ' + JSON.stringify(errors));
|
||||
});
|
||||
|
||||
test('REJECTED: missing type → error', () => {
|
||||
const errors = validateConfigSliceEntry(CAP_ID, KEY, { default: true, description: 'ok' });
|
||||
assert.ok(errors.length > 0, 'Expected rejection for missing type');
|
||||
assert.ok(errors.some((e) => e.includes('type')));
|
||||
});
|
||||
|
||||
test('REJECTED: missing default → error mentioning default', () => {
|
||||
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'boolean', description: 'ok' });
|
||||
assert.ok(errors.length > 0, 'Expected rejection for missing default');
|
||||
assert.ok(errors.some((e) => e.includes('default')), 'Error should mention default, got: ' + JSON.stringify(errors));
|
||||
});
|
||||
|
||||
test('REJECTED: boolean type with string default → error', () => {
|
||||
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'boolean', default: 'true', description: 'ok' });
|
||||
assert.ok(errors.length > 0, 'Expected rejection for boolean type with string default');
|
||||
assert.ok(errors.some((e) => e.includes('boolean') || e.includes('default')));
|
||||
});
|
||||
|
||||
test('REJECTED: string type with boolean default → error', () => {
|
||||
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'string', default: false, description: 'ok' });
|
||||
assert.ok(errors.length > 0, 'Expected rejection for string type with boolean default');
|
||||
});
|
||||
|
||||
test('REJECTED: number type with string default → error', () => {
|
||||
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'number', default: 'five', description: 'ok' });
|
||||
assert.ok(errors.length > 0, 'Expected rejection for number type with string default');
|
||||
});
|
||||
|
||||
test('REJECTED: enum type with values list, default not in values → error', () => {
|
||||
const errors = validateConfigSliceEntry(CAP_ID, KEY, {
|
||||
type: 'enum', default: 'z', values: ['a', 'b', 'c'], description: 'ok',
|
||||
});
|
||||
assert.ok(errors.length > 0, 'Expected rejection for enum default not in values');
|
||||
assert.ok(errors.some((e) => e.includes('enum') || e.includes('values') || e.includes('z')));
|
||||
});
|
||||
|
||||
test('REJECTED: enum type with non-string default → error', () => {
|
||||
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'enum', default: 42, description: 'ok' });
|
||||
assert.ok(errors.length > 0, 'Expected rejection for enum with non-string default');
|
||||
});
|
||||
|
||||
test('REJECTED: empty description string → error', () => {
|
||||
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'boolean', default: true, description: '' });
|
||||
assert.ok(errors.length > 0, 'Expected rejection for empty description');
|
||||
assert.ok(errors.some((e) => e.includes('description')));
|
||||
});
|
||||
|
||||
test('REJECTED: non-string description (number) → error', () => {
|
||||
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'boolean', default: true, description: 42 });
|
||||
assert.ok(errors.length > 0, 'Expected rejection for non-string description');
|
||||
assert.ok(errors.some((e) => e.includes('description')));
|
||||
});
|
||||
|
||||
test('REJECTED: missing description → error', () => {
|
||||
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'boolean', default: true });
|
||||
assert.ok(errors.length > 0, 'Expected rejection for missing description');
|
||||
assert.ok(errors.some((e) => e.includes('description')));
|
||||
});
|
||||
|
||||
test('REJECTED: null slice → error', () => {
|
||||
const errors = validateConfigSliceEntry(CAP_ID, KEY, null);
|
||||
assert.ok(errors.length > 0, 'Expected rejection for null slice');
|
||||
});
|
||||
|
||||
test('REJECTED: array slice → error', () => {
|
||||
const errors = validateConfigSliceEntry(CAP_ID, KEY, []);
|
||||
assert.ok(errors.length > 0, 'Expected rejection for array slice');
|
||||
});
|
||||
|
||||
// FIX 5a: enum-without-values and default-not-in-values
|
||||
test('REJECTED: enum with empty values array → error (FIX 5a)', () => {
|
||||
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'enum', default: 'x', values: [], description: 'ok' });
|
||||
assert.ok(errors.length > 0, 'Expected rejection for enum with empty values, got: ' + JSON.stringify(errors));
|
||||
assert.ok(errors.some((e) => e.includes('values') || e.includes('enum')));
|
||||
});
|
||||
|
||||
test('REJECTED: enum with non-string values array entries → error (FIX 5a)', () => {
|
||||
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'enum', default: 'x', values: ['a', 42], description: 'ok' });
|
||||
assert.ok(errors.length > 0, 'Expected rejection for enum with non-string values, got: ' + JSON.stringify(errors));
|
||||
assert.ok(errors.some((e) => e.includes('values') || e.includes('string')));
|
||||
});
|
||||
|
||||
test('REJECTED: enum default not in values → error (FIX 5a)', () => {
|
||||
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'enum', default: 'z', values: ['a', 'b'], description: 'ok' });
|
||||
assert.ok(errors.length > 0, 'Expected rejection for enum default not in values, got: ' + JSON.stringify(errors));
|
||||
assert.ok(errors.some((e) => e.includes('z') || e.includes('values') || e.includes('default')));
|
||||
});
|
||||
|
||||
// FIX 6a: NaN and non-finite number defaults
|
||||
test('REJECTED: NaN number default → error (FIX 6a)', () => {
|
||||
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'number', default: NaN, description: 'ok' });
|
||||
assert.ok(errors.length > 0, 'Expected rejection for NaN default, got: ' + JSON.stringify(errors));
|
||||
assert.ok(errors.some((e) => e.includes('finite') || e.includes('NaN') || e.includes('number')));
|
||||
});
|
||||
|
||||
test('REJECTED: Infinity number default → error (FIX 6a)', () => {
|
||||
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'number', default: Infinity, description: 'ok' });
|
||||
assert.ok(errors.length > 0, 'Expected rejection for Infinity default, got: ' + JSON.stringify(errors));
|
||||
assert.ok(errors.some((e) => e.includes('finite') || e.includes('number')));
|
||||
});
|
||||
|
||||
test('REJECTED: -Infinity number default → error (FIX 6a)', () => {
|
||||
const errors = validateConfigSliceEntry(CAP_ID, KEY, { type: 'number', default: -Infinity, description: 'ok' });
|
||||
assert.ok(errors.length > 0, 'Expected rejection for -Infinity default, got: ' + JSON.stringify(errors));
|
||||
});
|
||||
|
||||
test('buildRegistry throws on malformed config slice in capability', () => {
|
||||
// A capability with a config slice that has a missing default — buildRegistry must throw
|
||||
const cap = {
|
||||
...UI_CAP,
|
||||
config: {
|
||||
...UI_CAP.config,
|
||||
'workflow.bad_key': { type: 'boolean', description: 'missing default' },
|
||||
},
|
||||
};
|
||||
const capMap = new Map([['ui', cap]]);
|
||||
assert.throws(
|
||||
() => buildRegistry(capMap),
|
||||
(err) => {
|
||||
assert.ok(err instanceof Error, 'Must throw an Error');
|
||||
assert.ok(
|
||||
err.message.includes('configSchema') || err.message.includes('default') || err.message.includes('validation'),
|
||||
'Error must mention configSchema validation, got: ' + err.message,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
451
tests/federated-config-loadconfig.test.cjs
Normal file
451
tests/federated-config-loadconfig.test.cjs
Normal file
@@ -0,0 +1,451 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* federated-config-loadconfig.test.cjs — Tests for the federated config overlay
|
||||
* wired into loadConfig (ADR-857 phase 3b).
|
||||
*
|
||||
* Tests:
|
||||
* 1. EQUIVALENCE/no-op: with the real registry, loadConfig output has NO unexpected
|
||||
* extra keys (the UI keys are central so the overlay is empty).
|
||||
* 2. FIXTURE federated key: inject a synthetic configSchema with a key NOT in
|
||||
* central schema → loadConfig surfaces it with its default.
|
||||
* 3. FIXTURE federated key with user override: user config sets the federated key
|
||||
* to a valid value → that value is used.
|
||||
* 4. MALFORMED registry: configSchema with bad slices → loadConfig returns a valid
|
||||
* config without throwing.
|
||||
*/
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── Module under test ────────────────────────────────────────────────────────
|
||||
|
||||
const configLoader = require('../gsd-core/bin/lib/config-loader.cjs');
|
||||
const {
|
||||
loadConfig,
|
||||
_setFederatedRegistryForTests,
|
||||
_resetFederatedRegistryForTests,
|
||||
} = configLoader;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeTempProject() {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-fed-cfg-test-'));
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
function writeConfig(tmpDir, obj) {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify(obj, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
// Keep track of temp dirs for cleanup
|
||||
let tmpDirs = [];
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDirs = [];
|
||||
_resetFederatedRegistryForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
_resetFederatedRegistryForTests();
|
||||
for (const d of tmpDirs) {
|
||||
try { cleanup(d); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
function mkTemp() {
|
||||
const d = makeTempProject();
|
||||
tmpDirs.push(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
// ─── 1. Equivalence / no-op with real registry ───────────────────────────────
|
||||
|
||||
describe('EQUIVALENCE: real registry is a no-op overlay', () => {
|
||||
test('loadConfig with an empty config.json returns base config without extra federated keys', () => {
|
||||
const tmpDir = mkTemp();
|
||||
// Write an empty config to trigger the try-branch (federated overlay path)
|
||||
writeConfig(tmpDir, {});
|
||||
const result = loadConfig(tmpDir);
|
||||
|
||||
// The result must be an object
|
||||
assert.ok(typeof result === 'object' && result !== null, 'loadConfig must return an object');
|
||||
|
||||
// Known result keys that loadConfig always provides (from the main try-branch)
|
||||
const knownKeys = [
|
||||
'model_profile', 'commit_docs', 'search_gitignored', 'branching_strategy',
|
||||
'research', 'plan_checker', 'verifier', 'parallelization', 'brave_search',
|
||||
'firecrawl', 'exa_search', 'text_mode', 'auto_advance',
|
||||
'mode', 'sub_repos', 'resolve_model_ids', 'context_window', 'phase_naming',
|
||||
'project_code', 'subagent_timeout', 'model_overrides', 'models', 'granularity',
|
||||
'granularities', 'planning', 'dynamic_routing', 'runtime', 'model_profile_overrides',
|
||||
'model_policy', 'effort', 'fast_mode', 'agent_skills', 'manager',
|
||||
];
|
||||
|
||||
for (const key of knownKeys) {
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(result, key),
|
||||
'Expected result to have key: ' + key,
|
||||
);
|
||||
}
|
||||
|
||||
// UI-capability keys must NOT appear as new top-level keys (they're still central
|
||||
// and the overlay is empty — so these keys should not be added)
|
||||
// Note: 'workflow' IS an existing top-level key concept via VALID_CONFIG_KEYS,
|
||||
// but the nested keys like 'ui_phase' must not be present.
|
||||
const workflowSection = result['workflow'];
|
||||
if (workflowSection && typeof workflowSection === 'object') {
|
||||
// workflow section may already exist from user config but should not have ui_phase
|
||||
// in the default no-config case
|
||||
assert.ok(
|
||||
!Object.prototype.hasOwnProperty.call(workflowSection, 'ui_phase'),
|
||||
'workflow.ui_phase should not be injected by the federated overlay (key is still central)',
|
||||
);
|
||||
assert.ok(
|
||||
!Object.prototype.hasOwnProperty.call(workflowSection, 'ui_review'),
|
||||
'workflow.ui_review should not be injected by the federated overlay (key is still central)',
|
||||
);
|
||||
assert.ok(
|
||||
!Object.prototype.hasOwnProperty.call(workflowSection, 'ui_safety_gate'),
|
||||
'workflow.ui_safety_gate should not be injected by the federated overlay (key is still central)',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('loadConfig with a real config.json returns expected values + no unexpected keys from overlay', () => {
|
||||
const tmpDir = mkTemp();
|
||||
writeConfig(tmpDir, { model_profile: 'balanced', research: true });
|
||||
const result = loadConfig(tmpDir);
|
||||
|
||||
assert.strictEqual(result['model_profile'], 'balanced', 'model_profile from config');
|
||||
assert.strictEqual(result['research'], true, 'research from config');
|
||||
|
||||
// The overlay must not have added any unexpected keys from the registry
|
||||
// (all UI keys are central → skipped → no additions)
|
||||
// Verify a spot-check: 'ui_phase' should not exist anywhere
|
||||
assert.strictEqual(result['ui_phase'], undefined, 'ui_phase should not appear as top-level key');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 2. Fixture federated key — value = default ──────────────────────────────
|
||||
|
||||
describe('FIXTURE federated key: key not in central schema', () => {
|
||||
test('injected configSchema with non-central key → appears in loadConfig result with default', () => {
|
||||
const tmpDir = mkTemp();
|
||||
// Write an empty config.json so loadConfig enters the try-branch (federated overlay path)
|
||||
writeConfig(tmpDir, {});
|
||||
|
||||
// Inject a synthetic registry with a key not in the central schema
|
||||
_setFederatedRegistryForTests({
|
||||
configSchema: {
|
||||
'mytool.enabled': {
|
||||
owner: 'mytool',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Enable mytool.',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = loadConfig(tmpDir);
|
||||
|
||||
// mytool is not in the central schema, so the overlay should surface it
|
||||
// The key 'mytool.enabled' is dotted → result should have result.mytool.enabled = true
|
||||
const myToolSection = result['mytool'];
|
||||
assert.ok(typeof myToolSection === 'object' && myToolSection !== null,
|
||||
'mytool section must be created for dotted federated key');
|
||||
assert.strictEqual(
|
||||
(myToolSection)['enabled'],
|
||||
true,
|
||||
'mytool.enabled must default to true from slice',
|
||||
);
|
||||
});
|
||||
|
||||
test('injected top-level (non-dotted) federated key → appears in result', () => {
|
||||
const tmpDir = mkTemp();
|
||||
// Write an empty config.json to enter the try-branch
|
||||
writeConfig(tmpDir, {});
|
||||
|
||||
_setFederatedRegistryForTests({
|
||||
configSchema: {
|
||||
'mytool_flag': {
|
||||
owner: 'mytool',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Top-level mytool flag.',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = loadConfig(tmpDir);
|
||||
// Top-level key: result['mytool_flag'] = false (the default)
|
||||
// BUT: only added if NOT already present in _baseConfig
|
||||
// 'mytool_flag' is not in the central schema, so it should be added
|
||||
assert.strictEqual(result['mytool_flag'], false, 'mytool_flag should be set to default false');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 3. Fixture federated key — user override ────────────────────────────────
|
||||
|
||||
describe('FIXTURE federated key: user config sets the key', () => {
|
||||
test('user sets a federated key to a valid value → loadConfig uses user value', () => {
|
||||
const tmpDir = mkTemp();
|
||||
|
||||
// Write a user config with a synthetic federated key
|
||||
// The user config uses flat notation (mytool_flag: false)
|
||||
writeConfig(tmpDir, { mytool_flag: true });
|
||||
|
||||
_setFederatedRegistryForTests({
|
||||
configSchema: {
|
||||
'mytool_flag': {
|
||||
owner: 'mytool',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Top-level mytool flag.',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = loadConfig(tmpDir);
|
||||
// The user set mytool_flag=true, which matches the type (boolean), so user value wins
|
||||
assert.strictEqual(result['mytool_flag'], true, 'User-supplied true should override default false');
|
||||
});
|
||||
|
||||
test('user sets a federated key to wrong type → loadConfig falls back to default', () => {
|
||||
const tmpDir = mkTemp();
|
||||
// Write a user config with the wrong type for the federated key
|
||||
writeConfig(tmpDir, { mytool_flag: 'not-a-bool' });
|
||||
|
||||
_setFederatedRegistryForTests({
|
||||
configSchema: {
|
||||
'mytool_flag': {
|
||||
owner: 'mytool',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Top-level mytool flag.',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = loadConfig(tmpDir);
|
||||
// Wrong type → fallback to default (false)
|
||||
assert.strictEqual(result['mytool_flag'], false, 'Should fall back to default on type mismatch');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FIX 1: Nested dotted-path user-override in loadConfig ───────────────────
|
||||
|
||||
describe('FIX 1: nested user config drives federated overlay in loadConfig', () => {
|
||||
test('user config { mytool: { enabled: false } } (NESTED) → loadConfig surfaces false', () => {
|
||||
const tmpDir = mkTemp();
|
||||
// Write config.json with the nested structure users actually write
|
||||
writeConfig(tmpDir, { mytool: { enabled: false } });
|
||||
|
||||
_setFederatedRegistryForTests({
|
||||
configSchema: {
|
||||
'mytool.enabled': {
|
||||
owner: 'mytool',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Enable mytool.',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = loadConfig(tmpDir);
|
||||
const myToolSection = result['mytool'];
|
||||
assert.ok(typeof myToolSection === 'object' && myToolSection !== null,
|
||||
'mytool section must be in result');
|
||||
assert.strictEqual(
|
||||
(myToolSection)['enabled'],
|
||||
false,
|
||||
'Nested user override of false should override the default of true',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FIX 2: Overlay applied on no-config path ────────────────────────────────
|
||||
|
||||
describe('FIX 2: overlay applied on the no-config path', () => {
|
||||
test('project with NO config.json → federated default is surfaced (non-central key)', () => {
|
||||
// Create a project dir WITHOUT a .planning/config.json
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-fed-noconfig-'));
|
||||
tmpDirs.push(tmpDir);
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
|
||||
// Intentionally do NOT write a config.json
|
||||
|
||||
_setFederatedRegistryForTests({
|
||||
configSchema: {
|
||||
'mytool.enabled': {
|
||||
owner: 'mytool',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Enable mytool (default false).',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = loadConfig(tmpDir);
|
||||
// The overlay must be applied on the no-config path: mytool.enabled should be false (the default)
|
||||
const myToolSection = result['mytool'];
|
||||
assert.ok(
|
||||
typeof myToolSection === 'object' && myToolSection !== null,
|
||||
'mytool section must be created by overlay even on no-config path, got: ' + JSON.stringify(result['mytool']),
|
||||
);
|
||||
assert.strictEqual(
|
||||
(myToolSection)['enabled'],
|
||||
false,
|
||||
'mytool.enabled must default to false on no-config path',
|
||||
);
|
||||
});
|
||||
|
||||
test('no-config path with REAL registry (all keys central) → output is byte-identical to defaults (no-op)', () => {
|
||||
// No config.json — use real registry which has all keys central
|
||||
_resetFederatedRegistryForTests();
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-fed-noconfig-real-'));
|
||||
tmpDirs.push(tmpDir);
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
|
||||
// No config.json
|
||||
|
||||
const result = loadConfig(tmpDir);
|
||||
assert.ok(typeof result === 'object' && result !== null, 'result must be an object');
|
||||
|
||||
// With real registry (all keys central → overlay is empty → no-op), the result
|
||||
// should be the defaults object WITHOUT any extra injected keys.
|
||||
// Spot-check: ui_phase / ui_review / ui_safety_gate must NOT be injected
|
||||
const workflowSection = result['workflow'];
|
||||
if (workflowSection && typeof workflowSection === 'object') {
|
||||
assert.ok(
|
||||
!Object.prototype.hasOwnProperty.call(workflowSection, 'ui_phase'),
|
||||
'workflow.ui_phase must not be injected on no-config path (no-op)',
|
||||
);
|
||||
}
|
||||
// model_profile must be present (it comes from defaults)
|
||||
assert.ok(Object.prototype.hasOwnProperty.call(result, 'model_profile'), 'model_profile must be present');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FIX 3: Federated key in config.json → no unknown-key warning ─────────────
|
||||
|
||||
describe('FIX 3: federated key present in config.json → no unknown-key warning', () => {
|
||||
test('synthetic federated key in config.json → no "unknown config key" warning on stderr', () => {
|
||||
const tmpDir = mkTemp();
|
||||
// Write a config.json that contains a key matching our synthetic federated key's top-level segment
|
||||
writeConfig(tmpDir, { mytool: { enabled: true } });
|
||||
|
||||
_setFederatedRegistryForTests({
|
||||
configSchema: {
|
||||
'mytool.enabled': {
|
||||
owner: 'mytool',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Enable mytool.',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Capture stderr to check for unknown-key warning
|
||||
const stderrChunks = [];
|
||||
const origWrite = process.stderr.write.bind(process.stderr);
|
||||
process.stderr.write = (chunk, ...args) => {
|
||||
stderrChunks.push(typeof chunk === 'string' ? chunk : String(chunk));
|
||||
return origWrite(chunk, ...args);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = loadConfig(tmpDir);
|
||||
// mytool.enabled is in the federated registry → KNOWN_TOP_LEVEL should include 'mytool'
|
||||
// → no "unknown config key(s)" warning for 'mytool'
|
||||
const stderrOutput = stderrChunks.join('');
|
||||
assert.ok(
|
||||
!stderrOutput.includes('unknown config key') || !stderrOutput.includes('mytool'),
|
||||
'Should NOT warn about mytool as an unknown key when it is a registered federated key. stderr: ' + stderrOutput,
|
||||
);
|
||||
// The value should be set from user config
|
||||
const myToolSection = result['mytool'];
|
||||
assert.ok(
|
||||
typeof myToolSection === 'object' && myToolSection !== null,
|
||||
'mytool section should be in result',
|
||||
);
|
||||
} finally {
|
||||
process.stderr.write = origWrite;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 4. Malformed registry — loadConfig still works ──────────────────────────
|
||||
|
||||
describe('MALFORMED registry: loadConfig does not throw', () => {
|
||||
test('configSchema with all malformed slices → loadConfig returns valid config, no throw', () => {
|
||||
const tmpDir = mkTemp();
|
||||
|
||||
_setFederatedRegistryForTests({
|
||||
configSchema: {
|
||||
'bad.key1': null,
|
||||
'bad.key2': 'just-a-string',
|
||||
'bad.key3': { type: 'xml', default: '<x/>' }, // invalid type
|
||||
'bad.key4': { type: 'boolean', description: 'ok' }, // missing default
|
||||
'bad.key5': {}, // missing both
|
||||
},
|
||||
});
|
||||
|
||||
let result;
|
||||
assert.doesNotThrow(() => {
|
||||
result = loadConfig(tmpDir);
|
||||
}, 'loadConfig must not throw even with all-malformed configSchema');
|
||||
|
||||
assert.ok(typeof result === 'object' && result !== null, 'result must be an object');
|
||||
// None of the bad keys should appear in the result
|
||||
assert.strictEqual(result['bad.key1'], undefined);
|
||||
assert.strictEqual(result['bad.key2'], undefined);
|
||||
const badSection = result['bad'];
|
||||
if (badSection && typeof badSection === 'object') {
|
||||
assert.strictEqual((badSection)['key1'], undefined, 'bad.key1 must not be set');
|
||||
}
|
||||
});
|
||||
|
||||
test('configSchema is a string (completely unexpected) → loadConfig still works', () => {
|
||||
const tmpDir = mkTemp();
|
||||
|
||||
_setFederatedRegistryForTests({
|
||||
configSchema: 'not-an-object',
|
||||
});
|
||||
|
||||
let result;
|
||||
assert.doesNotThrow(() => {
|
||||
result = loadConfig(tmpDir);
|
||||
}, 'loadConfig must not throw with non-object configSchema');
|
||||
|
||||
assert.ok(typeof result === 'object' && result !== null, 'result must be an object');
|
||||
});
|
||||
|
||||
test('registry throws during configSchema access → loadConfig still returns base config', () => {
|
||||
const tmpDir = mkTemp();
|
||||
|
||||
// Create a registry proxy that throws when configSchema is accessed
|
||||
const throwingRegistry = {
|
||||
get configSchema() { throw new Error('registry exploded'); },
|
||||
};
|
||||
|
||||
_setFederatedRegistryForTests(throwingRegistry);
|
||||
|
||||
let result;
|
||||
assert.doesNotThrow(() => {
|
||||
result = loadConfig(tmpDir);
|
||||
}, 'loadConfig must not throw even if registry access throws');
|
||||
|
||||
assert.ok(typeof result === 'object' && result !== null, 'result must still be an object');
|
||||
// The base config keys must be present
|
||||
assert.ok(Object.prototype.hasOwnProperty.call(result, 'model_profile'), 'model_profile must be present');
|
||||
});
|
||||
});
|
||||
696
tests/federated-config.test.cjs
Normal file
696
tests/federated-config.test.cjs
Normal file
@@ -0,0 +1,696 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* federated-config.test.cjs — Behavioral tests for the federated-config module.
|
||||
*
|
||||
* ADR-857 phase 3b. Tests cover:
|
||||
* - empty configSchema → empty result
|
||||
* - key still in central schema → skipped + pending-migration warning + NOT in values
|
||||
* - malformed slice (each variant) → skipped + warning, no throw
|
||||
* - valid federated key → value = default
|
||||
* - valid federated key with correct-type user override → user value used
|
||||
* - valid federated key with wrong-type user override → falls back to default + warning
|
||||
* - __proto__/constructor/prototype keys → ignored, no Object.prototype pollution
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { mergeFederatedConfig } = require('../gsd-core/bin/lib/federated-config.cjs');
|
||||
|
||||
// ─── Helper fixtures ──────────────────────────────────────────────────────────
|
||||
|
||||
/** A minimal well-formed boolean config slice entry. */
|
||||
const BOOLEAN_SLICE = {
|
||||
owner: 'test-cap',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'A test boolean key.',
|
||||
};
|
||||
|
||||
/** A minimal well-formed string config slice entry. */
|
||||
const STRING_SLICE = {
|
||||
owner: 'test-cap',
|
||||
type: 'string',
|
||||
default: 'hello',
|
||||
description: 'A test string key.',
|
||||
};
|
||||
|
||||
/** A minimal well-formed number config slice entry. */
|
||||
const NUMBER_SLICE = {
|
||||
owner: 'test-cap',
|
||||
type: 'number',
|
||||
default: 42,
|
||||
description: 'A test number key.',
|
||||
};
|
||||
|
||||
/** A minimal well-formed enum config slice entry (no values list). */
|
||||
const ENUM_SLICE_NO_VALUES = {
|
||||
owner: 'test-cap',
|
||||
type: 'enum',
|
||||
default: 'medium',
|
||||
description: 'A test enum key without values list.',
|
||||
};
|
||||
|
||||
/** A minimal well-formed enum config slice entry (with values list). */
|
||||
const ENUM_SLICE_WITH_VALUES = {
|
||||
owner: 'test-cap',
|
||||
type: 'enum',
|
||||
default: 'low',
|
||||
values: ['low', 'medium', 'high'],
|
||||
description: 'A test enum key with values list.',
|
||||
};
|
||||
|
||||
/** A never-central isCentralKey that always returns false. */
|
||||
const neverCentral = (_key) => false;
|
||||
|
||||
/** An always-central isCentralKey. */
|
||||
const alwaysCentral = (_key) => true;
|
||||
|
||||
// ─── 1. Empty configSchema ────────────────────────────────────────────────────
|
||||
|
||||
describe('empty configSchema', () => {
|
||||
test('empty object → empty result', () => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: {},
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: {},
|
||||
});
|
||||
assert.strictEqual(Object.keys(result.values).length, 0, 'values should be empty');
|
||||
assert.deepEqual(result.validKeys, [], 'validKeys should be empty');
|
||||
assert.deepEqual(result.warnings, [], 'warnings should be empty');
|
||||
});
|
||||
|
||||
test('null configSchema → empty result (defensive)', () => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: null,
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: {},
|
||||
});
|
||||
// FIX 6b: values uses null-prototype object; check it's empty
|
||||
assert.strictEqual(Object.keys(result.values).length, 0, 'values should be empty for null configSchema');
|
||||
assert.deepEqual(result.validKeys, [], 'validKeys should be empty');
|
||||
assert.deepEqual(result.warnings, [], 'warnings should be empty');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 2. Central-key skipping ──────────────────────────────────────────────────
|
||||
|
||||
describe('central-key skipping', () => {
|
||||
test('key in central schema → skipped + pending-migration warning + NOT in values', () => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: { 'workflow.ui_phase': BOOLEAN_SLICE },
|
||||
isCentralKey: alwaysCentral,
|
||||
userConfig: {},
|
||||
});
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(result.values, 'workflow.ui_phase'),
|
||||
'central key must NOT appear in values');
|
||||
assert.deepEqual(result.validKeys, [], 'validKeys must be empty for central keys');
|
||||
assert.ok(result.warnings.length >= 1, 'Must produce at least one warning');
|
||||
assert.ok(
|
||||
result.warnings.some((w) => w.includes('pending-migration') || w.includes('central config-schema')),
|
||||
'Warning must mention pending-migration or central config-schema, got: ' + JSON.stringify(result.warnings),
|
||||
);
|
||||
});
|
||||
|
||||
test('key NOT in central schema → appears in values', () => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: { 'mytool.enabled': BOOLEAN_SLICE },
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: {},
|
||||
});
|
||||
assert.ok(Object.prototype.hasOwnProperty.call(result.values, 'mytool.enabled'),
|
||||
'Non-central key must appear in values');
|
||||
assert.strictEqual(result.validKeys.length, 1);
|
||||
});
|
||||
|
||||
test('mixed central + non-central: central skipped, non-central included', () => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: {
|
||||
'central.key': BOOLEAN_SLICE,
|
||||
'mytool.enabled': BOOLEAN_SLICE,
|
||||
},
|
||||
isCentralKey: (key) => key === 'central.key',
|
||||
userConfig: {},
|
||||
});
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(result.values, 'central.key'),
|
||||
'central.key must not be in values');
|
||||
assert.ok(Object.prototype.hasOwnProperty.call(result.values, 'mytool.enabled'),
|
||||
'mytool.enabled must be in values');
|
||||
assert.strictEqual(result.validKeys.length, 1);
|
||||
assert.ok(result.warnings.some((w) => w.includes('pending-migration') || w.includes('central')));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 3. Malformed slice handling ──────────────────────────────────────────────
|
||||
|
||||
describe('malformed slice handling — no throw, warning emitted', () => {
|
||||
test('slice missing type → skipped + warning, no throw', () => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: { 'tool.key': { owner: 'x', default: true, description: 'x' } },
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: {},
|
||||
});
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(result.values, 'tool.key'), 'malformed key must not be in values');
|
||||
assert.ok(result.warnings.length >= 1, 'Must warn about malformed slice');
|
||||
assert.ok(result.warnings.some((w) => w.includes('malformed') || w.includes('tool.key')),
|
||||
'Warning must mention the key, got: ' + JSON.stringify(result.warnings));
|
||||
});
|
||||
|
||||
test('slice with invalid type ("xml") → skipped + warning, no throw', () => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: { 'tool.key': { owner: 'x', type: 'xml', default: '<x/>', description: 'xml key' } },
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: {},
|
||||
});
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(result.values, 'tool.key'));
|
||||
assert.ok(result.warnings.length >= 1);
|
||||
});
|
||||
|
||||
test('slice missing default → skipped + warning, no throw', () => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: { 'tool.key': { owner: 'x', type: 'boolean', description: 'x' } },
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: {},
|
||||
});
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(result.values, 'tool.key'));
|
||||
assert.ok(result.warnings.length >= 1);
|
||||
});
|
||||
|
||||
test('slice is null → skipped + warning, no throw', () => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: { 'tool.key': null },
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: {},
|
||||
});
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(result.values, 'tool.key'));
|
||||
assert.ok(result.warnings.length >= 1);
|
||||
});
|
||||
|
||||
test('slice is a string scalar → skipped + warning, no throw', () => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: { 'tool.key': 'just-a-string' },
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: {},
|
||||
});
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(result.values, 'tool.key'));
|
||||
assert.ok(result.warnings.length >= 1);
|
||||
});
|
||||
|
||||
test('slice is a number → skipped + warning, no throw', () => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: { 'tool.key': 42 },
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: {},
|
||||
});
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(result.values, 'tool.key'));
|
||||
assert.ok(result.warnings.length >= 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 4. Valid federated key — default resolution ──────────────────────────────
|
||||
|
||||
describe('valid federated key — default resolution', () => {
|
||||
test('boolean key absent from userConfig → value = default (true)', () => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: { 'mytool.enabled': BOOLEAN_SLICE },
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: {},
|
||||
});
|
||||
assert.strictEqual(result.values['mytool.enabled'], true, 'Should use slice default (true)');
|
||||
assert.ok(result.validKeys.includes('mytool.enabled'));
|
||||
assert.deepEqual(result.warnings, []);
|
||||
});
|
||||
|
||||
test('string key absent from userConfig → value = default ("hello")', () => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: { 'mytool.name': STRING_SLICE },
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: {},
|
||||
});
|
||||
assert.strictEqual(result.values['mytool.name'], 'hello');
|
||||
assert.ok(result.validKeys.includes('mytool.name'));
|
||||
});
|
||||
|
||||
test('number key absent from userConfig → value = default (42)', () => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: { 'mytool.timeout': NUMBER_SLICE },
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: {},
|
||||
});
|
||||
assert.strictEqual(result.values['mytool.timeout'], 42);
|
||||
});
|
||||
|
||||
test('enum key absent from userConfig → value = default ("medium")', () => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: { 'mytool.level': ENUM_SLICE_NO_VALUES },
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: {},
|
||||
});
|
||||
assert.strictEqual(result.values['mytool.level'], 'medium');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 5. Valid federated key — correct-type user override ─────────────────────
|
||||
|
||||
describe('valid federated key — user override with correct type', () => {
|
||||
test('boolean key with boolean user override (nested) → user value used', () => {
|
||||
// FIX 1: users write nested objects, not flat dotted keys
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: { 'mytool.enabled': BOOLEAN_SLICE },
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: { mytool: { enabled: false } },
|
||||
});
|
||||
assert.strictEqual(result.values['mytool.enabled'], false, 'Should use user-supplied false');
|
||||
assert.deepEqual(result.warnings, []);
|
||||
});
|
||||
|
||||
test('string key with string user override (nested) → user value used', () => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: { 'mytool.name': STRING_SLICE },
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: { mytool: { name: 'custom' } },
|
||||
});
|
||||
assert.strictEqual(result.values['mytool.name'], 'custom');
|
||||
assert.deepEqual(result.warnings, []);
|
||||
});
|
||||
|
||||
test('number key with number user override (nested) → user value used', () => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: { 'mytool.timeout': NUMBER_SLICE },
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: { mytool: { timeout: 99 } },
|
||||
});
|
||||
assert.strictEqual(result.values['mytool.timeout'], 99);
|
||||
assert.deepEqual(result.warnings, []);
|
||||
});
|
||||
|
||||
test('enum key with in-values string user override (nested) → user value used', () => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: { 'mytool.level': ENUM_SLICE_WITH_VALUES },
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: { mytool: { level: 'high' } },
|
||||
});
|
||||
assert.strictEqual(result.values['mytool.level'], 'high');
|
||||
assert.deepEqual(result.warnings, []);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 6. Valid federated key — wrong-type user override ───────────────────────
|
||||
|
||||
describe('valid federated key — wrong-type user override', () => {
|
||||
test('boolean key with string user override (nested) → falls back to default + warning', () => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: { 'mytool.enabled': BOOLEAN_SLICE },
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: { mytool: { enabled: 'not-a-bool' } },
|
||||
});
|
||||
// Key IS in validKeys (degraded resolution — default used)
|
||||
assert.ok(Object.prototype.hasOwnProperty.call(result.values, 'mytool.enabled'),
|
||||
'Key should still appear in values (degraded)');
|
||||
assert.strictEqual(result.values['mytool.enabled'], true, 'Should fall back to default (true)');
|
||||
assert.ok(result.warnings.length >= 1, 'Should warn about type mismatch');
|
||||
assert.ok(
|
||||
result.warnings.some((w) => w.includes('wrong type') || w.includes('type')),
|
||||
'Warning should mention type mismatch, got: ' + JSON.stringify(result.warnings),
|
||||
);
|
||||
});
|
||||
|
||||
test('string key with boolean user override (nested) → falls back to default + warning', () => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: { 'mytool.name': STRING_SLICE },
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: { mytool: { name: true } },
|
||||
});
|
||||
assert.strictEqual(result.values['mytool.name'], 'hello', 'Should fall back to default');
|
||||
assert.ok(result.warnings.length >= 1);
|
||||
});
|
||||
|
||||
test('number key with string user override (nested) → falls back to default + warning', () => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: { 'mytool.timeout': NUMBER_SLICE },
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: { mytool: { timeout: 'fast' } },
|
||||
});
|
||||
assert.strictEqual(result.values['mytool.timeout'], 42, 'Should fall back to default');
|
||||
assert.ok(result.warnings.length >= 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 7. Prototype pollution guard ────────────────────────────────────────────
|
||||
|
||||
describe('prototype pollution guard', () => {
|
||||
test('__proto__ key in configSchema → ignored, no Object.prototype pollution', () => {
|
||||
// We can't pass __proto__ as an own-enumerable property via object literal,
|
||||
// so we use Object.create + defineProperty to simulate what a capability registry
|
||||
// might hand us if prototype pollution had been attempted upstream.
|
||||
const poisonedSchema = Object.create(null);
|
||||
Object.defineProperty(poisonedSchema, '__proto__', {
|
||||
value: { polluted: true },
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
// Note: 'constructor' and 'prototype' CAN be passed via plain object literals
|
||||
const schemaWithReservedKeys = {
|
||||
'constructor': BOOLEAN_SLICE,
|
||||
'prototype': STRING_SLICE,
|
||||
};
|
||||
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: schemaWithReservedKeys,
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: {},
|
||||
});
|
||||
|
||||
// Reserved keys must not appear in values
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(result.values, 'constructor'), 'constructor must not be in values');
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(result.values, 'prototype'), 'prototype must not be in values');
|
||||
assert.ok(!result.validKeys.includes('constructor'), 'constructor must not be in validKeys');
|
||||
assert.ok(!result.validKeys.includes('prototype'), 'prototype must not be in validKeys');
|
||||
|
||||
// Object.prototype must not be polluted
|
||||
assert.strictEqual(({}).polluted, undefined, 'Object.prototype must not be polluted');
|
||||
assert.strictEqual(({}).constructor, Object, 'Object.prototype.constructor must be Object (not overwritten)');
|
||||
});
|
||||
|
||||
test('buildRegistry with poisoned keys does not pollute Object.prototype', () => {
|
||||
// Even with isCentralKey always returning false, reserved keys are guarded
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: { 'legitimate.key': BOOLEAN_SLICE },
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: {},
|
||||
});
|
||||
assert.strictEqual(({}).polluted, undefined, 'Object.prototype.polluted must be undefined after merge');
|
||||
assert.ok(Object.prototype.hasOwnProperty.call(result.values, 'legitimate.key'), 'legitimate key must be in values');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FIX 1: Nested dotted-path user-override lookup ──────────────────────────
|
||||
|
||||
describe('FIX 1: nested dotted-path user-override lookup', () => {
|
||||
test('user sets mytool.enabled via NESTED object → user value used', () => {
|
||||
// Nested config: { mytool: { enabled: false } } — NOT flat {"mytool.enabled": false}
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: {
|
||||
'mytool.enabled': {
|
||||
owner: 'mytool',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Enable mytool.',
|
||||
},
|
||||
},
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: { mytool: { enabled: false } }, // NESTED
|
||||
});
|
||||
assert.strictEqual(result.values['mytool.enabled'], false, 'Nested user override should be used (false overrides true)');
|
||||
assert.deepEqual(result.warnings, [], 'No warnings for valid nested override');
|
||||
assert.ok(result.validKeys.includes('mytool.enabled'));
|
||||
});
|
||||
|
||||
test('flat {"mytool.enabled": false} does NOT match nested path lookup', () => {
|
||||
// Flat key string lookup is intentionally NOT supported per FIX 1 spec
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: {
|
||||
'mytool.enabled': {
|
||||
owner: 'mytool',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Enable mytool.',
|
||||
},
|
||||
},
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: { 'mytool.enabled': false }, // FLAT — not found via nested traversal
|
||||
});
|
||||
// Flat key is not found by nested traversal, so default is used
|
||||
assert.strictEqual(result.values['mytool.enabled'], true, 'Flat key not found by nested traversal → default used');
|
||||
});
|
||||
|
||||
test('user sets nested 3-segment key correctly', () => {
|
||||
// Key: "a.b.c", user config: { a: { b: { c: 'override' } } }
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: {
|
||||
'a.b.c': {
|
||||
owner: 'test',
|
||||
type: 'string',
|
||||
default: 'default-val',
|
||||
description: 'Three-segment key.',
|
||||
},
|
||||
},
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: { a: { b: { c: 'override' } } },
|
||||
});
|
||||
assert.strictEqual(result.values['a.b.c'], 'override');
|
||||
assert.deepEqual(result.warnings, []);
|
||||
});
|
||||
|
||||
test('partial nested path (a.b exists but a.b.c missing) → uses default', () => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: {
|
||||
'a.b.c': {
|
||||
owner: 'test',
|
||||
type: 'string',
|
||||
default: 'default-val',
|
||||
description: 'Three-segment key.',
|
||||
},
|
||||
},
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: { a: { b: {} } }, // c is missing
|
||||
});
|
||||
assert.strictEqual(result.values['a.b.c'], 'default-val', 'Missing leaf should use default');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FIX 4: null/undefined/non-object input guards ───────────────────────────
|
||||
|
||||
describe('FIX 4: null/undefined/non-object input guards', () => {
|
||||
test('null input → no throw, empty result', () => {
|
||||
assert.doesNotThrow(() => {
|
||||
const result = mergeFederatedConfig(null);
|
||||
assert.ok(result.validKeys.length === 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('undefined input → no throw, empty result', () => {
|
||||
assert.doesNotThrow(() => {
|
||||
const result = mergeFederatedConfig(undefined);
|
||||
assert.ok(result.validKeys.length === 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('non-object input (string) → no throw, empty result', () => {
|
||||
assert.doesNotThrow(() => {
|
||||
const result = mergeFederatedConfig('not-an-object');
|
||||
assert.ok(result.validKeys.length === 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('null userConfig → treated as {} (no overrides), no throw', () => {
|
||||
assert.doesNotThrow(() => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: { 'mytool.enabled': BOOLEAN_SLICE },
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: null,
|
||||
});
|
||||
// Should use the default since userConfig is null
|
||||
assert.strictEqual(result.values['mytool.enabled'], true, 'Should use default when userConfig is null');
|
||||
assert.deepEqual(result.warnings, []);
|
||||
});
|
||||
});
|
||||
|
||||
test('undefined userConfig → treated as {} (no overrides), no throw', () => {
|
||||
assert.doesNotThrow(() => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: { 'mytool.enabled': BOOLEAN_SLICE },
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: undefined,
|
||||
});
|
||||
assert.strictEqual(result.values['mytool.enabled'], true, 'Should use default when userConfig is undefined');
|
||||
});
|
||||
});
|
||||
|
||||
test('non-object userConfig → treated as {} (no overrides), no throw', () => {
|
||||
assert.doesNotThrow(() => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: { 'mytool.enabled': BOOLEAN_SLICE },
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: 42,
|
||||
});
|
||||
assert.strictEqual(result.values['mytool.enabled'], true, 'Should use default when userConfig is non-object');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FIX 5b: enum user override validation against values list ────────────────
|
||||
|
||||
describe('FIX 5b: enum out-of-values user override → falls back to default', () => {
|
||||
test('enum user override IN values list → accepted', () => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: { 'mytool.level': ENUM_SLICE_WITH_VALUES },
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: { mytool: { level: 'high' } },
|
||||
});
|
||||
assert.strictEqual(result.values['mytool.level'], 'high', 'In-values override should be accepted');
|
||||
assert.deepEqual(result.warnings, []);
|
||||
});
|
||||
|
||||
test('enum user override OUT OF values list → falls back to default + warning', () => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: { 'mytool.level': ENUM_SLICE_WITH_VALUES },
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: { mytool: { level: 'extreme' } }, // 'extreme' not in ['low', 'medium', 'high']
|
||||
});
|
||||
assert.strictEqual(result.values['mytool.level'], 'low', 'Out-of-values override should fall back to default');
|
||||
assert.ok(result.warnings.length >= 1, 'Should warn about out-of-values override');
|
||||
assert.ok(
|
||||
result.warnings.some((w) => w.includes('type') || w.includes('enum') || w.includes('invalid')),
|
||||
'Warning should mention type/enum issue, got: ' + JSON.stringify(result.warnings),
|
||||
);
|
||||
});
|
||||
|
||||
test('enum user override is non-string → falls back to default + warning', () => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: { 'mytool.level': ENUM_SLICE_WITH_VALUES },
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: { mytool: { level: 42 } }, // number, not string
|
||||
});
|
||||
assert.strictEqual(result.values['mytool.level'], 'low');
|
||||
assert.ok(result.warnings.length >= 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FIX 6b: null-proto consistency in early-return paths ────────────────────
|
||||
|
||||
describe('FIX 6b: null-proto consistency on early-return paths', () => {
|
||||
test('null configSchema → values uses Object.create(null) (no __proto__ chain)', () => {
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: null,
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: {},
|
||||
});
|
||||
// Object.create(null) has no __proto__ — prototype is null
|
||||
assert.strictEqual(Object.getPrototypeOf(result.values), null, 'values must use null prototype on null configSchema path');
|
||||
});
|
||||
|
||||
test('null input → values uses Object.create(null)', () => {
|
||||
const result = mergeFederatedConfig(null);
|
||||
assert.strictEqual(Object.getPrototypeOf(result.values), null, 'values must use null prototype on null input path');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FIX 6c: N-level nested write + prototype pollution via dotted keys ──────
|
||||
|
||||
describe('FIX 6c: N-level nested write and prototype-pollution via dotted keys', () => {
|
||||
test('3-segment federated key is correctly nested in loadConfig overlay', () => {
|
||||
// This test verifies _getNestedValue works correctly for 3-segment keys.
|
||||
// The values object should store the key as "a.b.c" → value mapping.
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: {
|
||||
'tool.section.flag': {
|
||||
owner: 'test',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Three-segment boolean key.',
|
||||
},
|
||||
},
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: { tool: { section: { flag: false } } },
|
||||
});
|
||||
assert.strictEqual(result.values['tool.section.flag'], false, '3-segment override should be picked up');
|
||||
assert.ok(result.validKeys.includes('tool.section.flag'));
|
||||
assert.deepEqual(result.warnings, []);
|
||||
});
|
||||
|
||||
test('__proto__ segment in dotted key does NOT pollute Object.prototype', () => {
|
||||
const schema = Object.create(null);
|
||||
// Create a key with __proto__ in the path via defineProperty
|
||||
Object.defineProperty(schema, '__proto__.x', {
|
||||
value: { owner: 'test', type: 'boolean', default: true, description: 'bad key' },
|
||||
enumerable: true, configurable: true, writable: true,
|
||||
});
|
||||
assert.doesNotThrow(() => {
|
||||
mergeFederatedConfig({
|
||||
configSchema: schema,
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: {},
|
||||
});
|
||||
});
|
||||
// Object.prototype must not be polluted
|
||||
assert.strictEqual(({}).x, undefined, 'Object.prototype.x must not be polluted via __proto__ key');
|
||||
});
|
||||
|
||||
test('a.__proto__.b segment in dotted key does NOT pollute Object.prototype', () => {
|
||||
// The key "a.__proto__.b" should be skipped at the __proto__ segment
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: {
|
||||
// We can't define 'a.__proto__.b' as an OWN property normally; skip test via defensive path
|
||||
'a.constructor.b': {
|
||||
owner: 'test',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'constructor key',
|
||||
},
|
||||
},
|
||||
isCentralKey: neverCentral,
|
||||
userConfig: {},
|
||||
});
|
||||
// 'a.constructor.b' contains 'constructor' segment — must be skipped
|
||||
assert.ok(!result.validKeys.includes('a.constructor.b'), 'Key with constructor segment must be skipped');
|
||||
// Object.prototype.constructor must still be Object
|
||||
assert.strictEqual(({}).constructor, Object, 'Object.prototype.constructor must not be modified');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 8. Real registry — all UI keys are central (no-op guarantee) ────────────
|
||||
|
||||
describe('real registry: all UI keys are central → no-op channel', () => {
|
||||
test('with real capability-registry, all configSchema keys are skipped (pending-migration)', () => {
|
||||
const capRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
const configSchemaFromRegistry = capRegistry.configSchema;
|
||||
|
||||
// Import the real isValidConfigKey
|
||||
const configSchemaModule = require('../gsd-core/bin/lib/config-schema.cjs');
|
||||
const { isValidConfigKey } = configSchemaModule;
|
||||
|
||||
if (!configSchemaFromRegistry || Object.keys(configSchemaFromRegistry).length === 0) {
|
||||
// Registry has no configSchema keys — no-op by definition
|
||||
return;
|
||||
}
|
||||
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: configSchemaFromRegistry,
|
||||
isCentralKey: isValidConfigKey,
|
||||
userConfig: {},
|
||||
});
|
||||
|
||||
// Every key should be skipped (pending-migration) because UI keys are still in central schema
|
||||
assert.strictEqual(Object.keys(result.values).length, 0, 'values must be empty — all keys are central (pending-migration)');
|
||||
assert.deepEqual(result.validKeys, [], 'validKeys must be empty');
|
||||
assert.ok(result.warnings.length > 0, 'Should have pending-migration warnings');
|
||||
|
||||
// Confirm each UI key specifically
|
||||
const uiKeys = ['workflow.ui_phase', 'workflow.ui_review', 'workflow.ui_safety_gate'];
|
||||
for (const key of uiKeys) {
|
||||
assert.ok(
|
||||
result.warnings.some((w) => w.includes(key)),
|
||||
'Should have a warning for ' + key + ', got: ' + JSON.stringify(result.warnings),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 9. isCentralKey throwing defensively ────────────────────────────────────
|
||||
|
||||
describe('isCentralKey defensive behavior', () => {
|
||||
test('isCentralKey that throws → key is skipped with warning, no throw from mergeFederatedConfig', () => {
|
||||
const throwingCentralKey = () => { throw new Error('internal error'); };
|
||||
const result = mergeFederatedConfig({
|
||||
configSchema: { 'mytool.key': BOOLEAN_SLICE },
|
||||
isCentralKey: throwingCentralKey,
|
||||
userConfig: {},
|
||||
});
|
||||
// Key skipped due to isCentralKey throwing
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(result.values, 'mytool.key'), 'Key must be skipped when isCentralKey throws');
|
||||
assert.ok(result.warnings.length >= 1, 'Must produce a warning when isCentralKey throws');
|
||||
});
|
||||
});
|
||||
777
tests/loop-render-hooks.test.cjs
Normal file
777
tests/loop-render-hooks.test.cjs
Normal file
@@ -0,0 +1,777 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* loop-render-hooks.test.cjs — behavioral tests for loop-resolver.cjs.
|
||||
*
|
||||
* ADR-857 phase 3c.
|
||||
* Uses node:test + node:assert/strict.
|
||||
* Pure-function tests (resolveLoopHooks, renderLoopHooks) pass registry+config
|
||||
* directly — no I/O. End-to-end tests use cmdLoopRenderHooks + a temp project.
|
||||
*/
|
||||
|
||||
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 {
|
||||
resolveLoopHooks,
|
||||
renderLoopHooks,
|
||||
_getNestedConfigValue,
|
||||
_resolveActivationValue,
|
||||
_readRawConfigKey,
|
||||
CANONICAL_POINTS_FALLBACK,
|
||||
CANONICAL_POINTS,
|
||||
} = require('../gsd-core/bin/lib/loop-resolver.cjs');
|
||||
|
||||
// The real registry for integration tests
|
||||
const realRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
|
||||
// ─── Synthetic registry fixtures ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a minimal synthetic registry with a single step hook at a given point.
|
||||
* Optionally include a configSchema for testing default-based activation.
|
||||
*/
|
||||
function makeRegistry({ point = 'plan:pre', steps = [], contributions = [], gates = {}, configSchema = {} } = {}) {
|
||||
const byLoopPoint = {};
|
||||
for (const p of CANONICAL_POINTS_FALLBACK) {
|
||||
byLoopPoint[p] = { steps: [], contributions: [], gates: [] };
|
||||
}
|
||||
if (steps.length) byLoopPoint[point].steps = steps;
|
||||
if (contributions.length) byLoopPoint[point].contributions = contributions;
|
||||
if (gates[point]) byLoopPoint[point].gates = gates[point];
|
||||
return { byLoopPoint, configSchema };
|
||||
}
|
||||
|
||||
// ─── Temp project helpers ─────────────────────────────────────────────────────
|
||||
|
||||
let tmpProjectDir;
|
||||
// A project with NO .planning/config.json — relies on schema defaults
|
||||
let tmpEmptyProjectDir;
|
||||
// A project where ui_phase is explicitly false in root config
|
||||
let tmpFalseConfigProjectDir;
|
||||
|
||||
before(() => {
|
||||
tmpProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loop-resolver-test-'));
|
||||
const planningDir = path.join(tmpProjectDir, '.planning');
|
||||
fs.mkdirSync(planningDir, { recursive: true });
|
||||
// Write minimal config.json with all UI flags enabled
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'config.json'),
|
||||
JSON.stringify({ workflow: { ui_phase: true, ui_review: true, ui_safety_gate: true } }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// Empty project — no config.json: schema defaults drive activation
|
||||
tmpEmptyProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loop-resolver-empty-'));
|
||||
fs.mkdirSync(path.join(tmpEmptyProjectDir, '.planning'), { recursive: true });
|
||||
|
||||
// False config project — ui_phase explicitly false in root config
|
||||
tmpFalseConfigProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loop-resolver-false-'));
|
||||
const falseConfigPlanningDir = path.join(tmpFalseConfigProjectDir, '.planning');
|
||||
fs.mkdirSync(falseConfigPlanningDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(falseConfigPlanningDir, 'config.json'),
|
||||
JSON.stringify({ workflow: { ui_phase: false, ui_review: false, ui_safety_gate: false } }),
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (tmpProjectDir) cleanup(tmpProjectDir);
|
||||
if (tmpEmptyProjectDir) cleanup(tmpEmptyProjectDir);
|
||||
if (tmpFalseConfigProjectDir) cleanup(tmpFalseConfigProjectDir);
|
||||
});
|
||||
|
||||
// ─── 1. Canonical-point validation ───────────────────────────────────────────
|
||||
|
||||
describe('canonical point validation', () => {
|
||||
test('all 12 canonical points are accepted by resolveLoopHooks with empty registry', () => {
|
||||
const emptyRegistry = makeRegistry();
|
||||
const config = {};
|
||||
for (const p of CANONICAL_POINTS_FALLBACK) {
|
||||
const result = resolveLoopHooks({ point: p, registry: emptyRegistry, config });
|
||||
assert.strictEqual(result.point, p);
|
||||
assert.deepEqual(result.activeHooks, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('12 canonical points total', () => {
|
||||
assert.strictEqual(CANONICAL_POINTS_FALLBACK.length, 12);
|
||||
});
|
||||
|
||||
test('invalid point throws with a clear message', () => {
|
||||
const emptyRegistry = makeRegistry();
|
||||
assert.throws(
|
||||
() => resolveLoopHooks({ point: 'plan:mid', registry: emptyRegistry, config: {} }),
|
||||
(err) => {
|
||||
assert.ok(err instanceof Error);
|
||||
assert.match(err.message, /Invalid loop point/);
|
||||
assert.match(err.message, /plan:mid/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('empty string point throws', () => {
|
||||
const emptyRegistry = makeRegistry();
|
||||
assert.throws(
|
||||
() => resolveLoopHooks({ point: '', registry: emptyRegistry, config: {} }),
|
||||
/Invalid loop point/,
|
||||
);
|
||||
});
|
||||
|
||||
test('close typo throws', () => {
|
||||
const emptyRegistry = makeRegistry();
|
||||
assert.throws(
|
||||
() => resolveLoopHooks({ point: 'plan:pre ', registry: emptyRegistry, config: {} }),
|
||||
/Invalid loop point/,
|
||||
);
|
||||
});
|
||||
|
||||
// FIX 2: non-canonical point rejected even if the registry has it as a byLoopPoint key
|
||||
test('non-canonical point in registry byLoopPoint is still rejected', () => {
|
||||
// Craft a registry that has a synthetic non-canonical key in byLoopPoint
|
||||
const registry = {
|
||||
byLoopPoint: {
|
||||
// All canonical points (needed so the registry is well-formed)
|
||||
...Object.fromEntries(CANONICAL_POINTS_FALLBACK.map(p => [p, { steps: [], contributions: [], gates: [] }])),
|
||||
// A non-canonical key that a malformed registry might inject
|
||||
'inject:arbitrary': { steps: [{ capId: 'evil', ref: { skill: 'bad' } }], contributions: [], gates: [] },
|
||||
},
|
||||
};
|
||||
assert.throws(
|
||||
() => resolveLoopHooks({ point: 'inject:arbitrary', registry, config: {} }),
|
||||
/Invalid loop point/,
|
||||
);
|
||||
});
|
||||
|
||||
// FIX 2: all 12 canonical points are listed in the error message
|
||||
test('invalid point error lists the canonical 12', () => {
|
||||
const emptyRegistry = makeRegistry();
|
||||
assert.throws(
|
||||
() => resolveLoopHooks({ point: 'not:real', registry: emptyRegistry, config: {} }),
|
||||
(err) => {
|
||||
assert.ok(err instanceof Error);
|
||||
for (const p of CANONICAL_POINTS_FALLBACK) {
|
||||
assert.ok(err.message.includes(p), `Expected "${p}" in error message: ${err.message}`);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// CANONICAL_POINTS is derived from LOOP_HOST_CONTRACT, not from registry keys
|
||||
test('CANONICAL_POINTS and CANONICAL_POINTS_FALLBACK are the same 12 points', () => {
|
||||
assert.deepEqual(CANONICAL_POINTS, CANONICAL_POINTS_FALLBACK);
|
||||
assert.strictEqual(CANONICAL_POINTS.length, 12);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 2. Activation tests ─────────────────────────────────────────────────────
|
||||
|
||||
describe('activation filter', () => {
|
||||
test('hook with no "when" is always active', () => {
|
||||
const registry = makeRegistry({
|
||||
steps: [{ capId: 'test-cap', point: 'plan:pre', ref: { skill: 'my-skill' } }],
|
||||
});
|
||||
const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} });
|
||||
assert.strictEqual(result.activeHooks.length, 1);
|
||||
assert.strictEqual(result.activeHooks[0].capId, 'test-cap');
|
||||
});
|
||||
|
||||
test('hook with when="mytool.on", config{mytool:{on:true}} → active', () => {
|
||||
const registry = makeRegistry({
|
||||
steps: [{ capId: 'test-cap', point: 'plan:pre', ref: { skill: 'my-skill' }, when: 'mytool.on' }],
|
||||
});
|
||||
const config = { mytool: { on: true } };
|
||||
const result = resolveLoopHooks({ point: 'plan:pre', registry, config });
|
||||
assert.strictEqual(result.activeHooks.length, 1);
|
||||
assert.strictEqual(result.activeHooks[0].kind, 'step');
|
||||
});
|
||||
|
||||
test('hook with when="mytool.on", config{mytool:{on:false}} → filtered', () => {
|
||||
const registry = makeRegistry({
|
||||
steps: [{ capId: 'test-cap', point: 'plan:pre', ref: { skill: 'my-skill' }, when: 'mytool.on' }],
|
||||
});
|
||||
const config = { mytool: { on: false } };
|
||||
const result = resolveLoopHooks({ point: 'plan:pre', registry, config });
|
||||
assert.strictEqual(result.activeHooks.length, 0);
|
||||
});
|
||||
|
||||
test('hook with when="mytool.on", config{} (absent key) → filtered', () => {
|
||||
const registry = makeRegistry({
|
||||
steps: [{ capId: 'test-cap', point: 'plan:pre', ref: { skill: 'my-skill' }, when: 'mytool.on' }],
|
||||
});
|
||||
const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} });
|
||||
assert.strictEqual(result.activeHooks.length, 0);
|
||||
});
|
||||
|
||||
test('hook with when="mytool.on", config{mytool:{}} → filtered (key absent)', () => {
|
||||
const registry = makeRegistry({
|
||||
steps: [{ capId: 'test-cap', point: 'plan:pre', ref: { skill: 'my-skill' }, when: 'mytool.on' }],
|
||||
});
|
||||
const config = { mytool: {} };
|
||||
const result = resolveLoopHooks({ point: 'plan:pre', registry, config });
|
||||
assert.strictEqual(result.activeHooks.length, 0);
|
||||
});
|
||||
|
||||
// FIX 3: non-string `when` → INACTIVE (not always-active)
|
||||
test('hook with when=true (boolean) → inactive (FIX 3: malformed non-string when)', () => {
|
||||
const registry = makeRegistry({
|
||||
steps: [{ capId: 'test-cap', point: 'plan:pre', ref: { skill: 'my-skill' }, when: true }],
|
||||
});
|
||||
const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} });
|
||||
assert.strictEqual(result.activeHooks.length, 0, 'non-string when=true must be treated as inactive');
|
||||
});
|
||||
|
||||
test('hook with when=42 (number) → inactive (FIX 3)', () => {
|
||||
const registry = makeRegistry({
|
||||
steps: [{ capId: 'test-cap', point: 'plan:pre', ref: { skill: 'my-skill' }, when: 42 }],
|
||||
});
|
||||
const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} });
|
||||
assert.strictEqual(result.activeHooks.length, 0, 'non-string when=42 must be inactive');
|
||||
});
|
||||
|
||||
test('hook with when={} (object) → inactive (FIX 3)', () => {
|
||||
const registry = makeRegistry({
|
||||
steps: [{ capId: 'test-cap', point: 'plan:pre', ref: { skill: 'my-skill' }, when: {} }],
|
||||
});
|
||||
const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} });
|
||||
assert.strictEqual(result.activeHooks.length, 0, 'non-string when={} must be inactive');
|
||||
});
|
||||
|
||||
// FIX 4: configSchema default=true → active with absent config (no cwd → level 4 applies)
|
||||
test('configSchema default=true + absent config → active', () => {
|
||||
const registry = makeRegistry({
|
||||
steps: [{ capId: 'test-cap', point: 'plan:pre', ref: { skill: 'my-skill' }, when: 'mytool.on' }],
|
||||
configSchema: {
|
||||
'mytool.on': { type: 'boolean', default: true, description: 'Enable mytool.' },
|
||||
},
|
||||
});
|
||||
const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} });
|
||||
assert.strictEqual(result.activeHooks.length, 1, 'schema default=true should activate the hook');
|
||||
assert.strictEqual(result.activeHooks[0].capId, 'test-cap');
|
||||
});
|
||||
|
||||
// FIX 4: configSchema default=false → inactive with absent config
|
||||
test('configSchema default=false + absent config → inactive', () => {
|
||||
const registry = makeRegistry({
|
||||
steps: [{ capId: 'test-cap', point: 'plan:pre', ref: { skill: 'my-skill' }, when: 'mytool.on' }],
|
||||
configSchema: {
|
||||
'mytool.on': { type: 'boolean', default: false, description: 'Disabled by default.' },
|
||||
},
|
||||
});
|
||||
const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} });
|
||||
assert.strictEqual(result.activeHooks.length, 0, 'schema default=false should keep hook inactive');
|
||||
});
|
||||
|
||||
// FIX 4: configSchema default=true but explicit config override=false → inactive (config wins)
|
||||
test('configSchema default=true but config override false → inactive (config wins)', () => {
|
||||
const registry = makeRegistry({
|
||||
steps: [{ capId: 'test-cap', point: 'plan:pre', ref: { skill: 'my-skill' }, when: 'mytool.on' }],
|
||||
configSchema: {
|
||||
'mytool.on': { type: 'boolean', default: true, description: 'Enabled by default.' },
|
||||
},
|
||||
});
|
||||
const config = { mytool: { on: false } };
|
||||
const result = resolveLoopHooks({ point: 'plan:pre', registry, config });
|
||||
assert.strictEqual(result.activeHooks.length, 0, 'explicit config=false overrides schema default=true');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 3. UI pilot integration tests ───────────────────────────────────────────
|
||||
|
||||
describe('UI pilot integration', () => {
|
||||
test('plan:pre with workflow.ui_phase=true → ui-phase step active', () => {
|
||||
const config = { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: true } };
|
||||
const result = resolveLoopHooks({ point: 'plan:pre', registry: realRegistry, config });
|
||||
const uiStep = result.activeHooks.find(h => h.capId === 'ui' && h.kind === 'step');
|
||||
assert.ok(uiStep, 'Expected ui step at plan:pre');
|
||||
assert.deepEqual(uiStep.ref, { skill: 'ui-phase' });
|
||||
assert.ok(Array.isArray(uiStep.produces));
|
||||
assert.ok(uiStep.produces.includes('UI-SPEC.md'));
|
||||
});
|
||||
|
||||
test('plan:pre with workflow.ui_phase=false → ui-phase step filtered', () => {
|
||||
const config = { workflow: { ui_phase: false, ui_review: true, ui_safety_gate: true } };
|
||||
const result = resolveLoopHooks({ point: 'plan:pre', registry: realRegistry, config });
|
||||
const uiStep = result.activeHooks.find(h => h.capId === 'ui' && h.kind === 'step');
|
||||
assert.strictEqual(uiStep, undefined, 'Expected ui step to be filtered');
|
||||
});
|
||||
|
||||
// FIX 4 INVERSION: empty config + real registry → ui-phase IS active (schema default=true)
|
||||
test('plan:pre with empty config + real registry → ui-phase step active by default (FIX 4)', () => {
|
||||
// realRegistry has configSchema['workflow.ui_phase'].default === true
|
||||
// So with no config and no cwd, the schema default kicks in → active
|
||||
const result = resolveLoopHooks({ point: 'plan:pre', registry: realRegistry, config: {} });
|
||||
const uiStep = result.activeHooks.find(h => h.capId === 'ui' && h.kind === 'step');
|
||||
assert.ok(
|
||||
uiStep,
|
||||
'Expected ui step to be active by default (configSchema.default=true). Got: ' +
|
||||
JSON.stringify(result.activeHooks),
|
||||
);
|
||||
assert.strictEqual(uiStep.when, 'workflow.ui_phase');
|
||||
});
|
||||
|
||||
test('execute:wave:post with workflow.ui_safety_gate=true → ui gate active', () => {
|
||||
const config = { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: true } };
|
||||
const result = resolveLoopHooks({ point: 'execute:wave:post', registry: realRegistry, config });
|
||||
const uiGate = result.activeHooks.find(h => h.capId === 'ui' && h.kind === 'gate');
|
||||
assert.ok(uiGate, 'Expected ui gate at execute:wave:post');
|
||||
assert.strictEqual(uiGate.blocking, true);
|
||||
assert.strictEqual(uiGate.onError, 'halt');
|
||||
});
|
||||
|
||||
test('execute:wave:post with workflow.ui_safety_gate=false → ui gate filtered', () => {
|
||||
const config = { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: false } };
|
||||
const result = resolveLoopHooks({ point: 'execute:wave:post', registry: realRegistry, config });
|
||||
const uiGate = result.activeHooks.find(h => h.capId === 'ui' && h.kind === 'gate');
|
||||
assert.strictEqual(uiGate, undefined, 'Expected ui gate to be filtered');
|
||||
});
|
||||
|
||||
// FIX 4: execute:wave:post with empty config → ui gate active by schema default
|
||||
test('execute:wave:post with empty config → ui gate active by schema default', () => {
|
||||
const result = resolveLoopHooks({ point: 'execute:wave:post', registry: realRegistry, config: {} });
|
||||
const uiGate = result.activeHooks.find(h => h.capId === 'ui' && h.kind === 'gate');
|
||||
assert.ok(uiGate, 'Expected ui gate active by default (configSchema.default=true)');
|
||||
assert.strictEqual(uiGate.blocking, true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 4. Ordering tests ────────────────────────────────────────────────────────
|
||||
|
||||
describe('hook ordering', () => {
|
||||
test('steps appear before contributions before gates', () => {
|
||||
const registry = makeRegistry({
|
||||
point: 'plan:pre',
|
||||
steps: [{ capId: 'c1', point: 'plan:pre', ref: { skill: 'sk1' } }],
|
||||
contributions: [{ capId: 'c2', point: 'plan:pre', into: 'planner' }],
|
||||
gates: { 'plan:pre': [{ capId: 'c3', point: 'plan:pre', check: { query: 'some-gate' }, blocking: false }] },
|
||||
});
|
||||
const config = {};
|
||||
const result = resolveLoopHooks({ point: 'plan:pre', registry, config });
|
||||
assert.strictEqual(result.activeHooks.length, 3);
|
||||
assert.strictEqual(result.activeHooks[0].kind, 'step');
|
||||
assert.strictEqual(result.activeHooks[1].kind, 'contribution');
|
||||
assert.strictEqual(result.activeHooks[2].kind, 'gate');
|
||||
});
|
||||
|
||||
test('within steps, registry order is preserved', () => {
|
||||
const registry = makeRegistry({
|
||||
point: 'plan:pre',
|
||||
steps: [
|
||||
{ capId: 'cap-a', point: 'plan:pre', ref: { skill: 'a' } },
|
||||
{ capId: 'cap-b', point: 'plan:pre', ref: { skill: 'b' } },
|
||||
{ capId: 'cap-c', point: 'plan:pre', ref: { skill: 'c' } },
|
||||
],
|
||||
});
|
||||
const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} });
|
||||
assert.deepEqual(result.activeHooks.map(h => h.capId), ['cap-a', 'cap-b', 'cap-c']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 5. Envelope shape ────────────────────────────────────────────────────────
|
||||
|
||||
describe('envelope shape', () => {
|
||||
test('envelope has point, activeHooks, rendered from renderLoopHooks', () => {
|
||||
const registry = makeRegistry({
|
||||
steps: [{ capId: 'cap-a', point: 'plan:pre', ref: { skill: 'my-skill' }, produces: ['A.md'], consumes: ['B.md'] }],
|
||||
});
|
||||
const resolved = resolveLoopHooks({ point: 'plan:pre', registry, config: {} });
|
||||
const rendered = renderLoopHooks(resolved);
|
||||
assert.strictEqual(resolved.point, 'plan:pre');
|
||||
assert.ok(Array.isArray(resolved.activeHooks));
|
||||
assert.strictEqual(typeof rendered, 'string');
|
||||
});
|
||||
|
||||
test('empty activeHooks → rendered is non-empty placeholder string', () => {
|
||||
const registry = makeRegistry(); // all empty
|
||||
const resolved = resolveLoopHooks({ point: 'plan:pre', registry, config: {} });
|
||||
const rendered = renderLoopHooks(resolved);
|
||||
assert.strictEqual(resolved.activeHooks.length, 0);
|
||||
assert.ok(rendered.length > 0, 'rendered should be a non-empty placeholder');
|
||||
assert.match(rendered, /plan:pre/);
|
||||
});
|
||||
|
||||
test('rendered contains hook content when hooks are active', () => {
|
||||
const registry = makeRegistry({
|
||||
steps: [{ capId: 'ui', point: 'plan:pre', ref: { skill: 'ui-phase' }, produces: ['UI-SPEC.md'], consumes: ['CONTEXT.md'], when: 'workflow.ui_phase', onError: 'skip' }],
|
||||
});
|
||||
const config = { workflow: { ui_phase: true } };
|
||||
const resolved = resolveLoopHooks({ point: 'plan:pre', registry, config });
|
||||
const rendered = renderLoopHooks(resolved);
|
||||
assert.match(rendered, /ui-phase/);
|
||||
assert.match(rendered, /ui/);
|
||||
assert.match(rendered, /UI-SPEC\.md/);
|
||||
});
|
||||
|
||||
test('rendered for UI pilot at plan:pre with all flags on', () => {
|
||||
const config = { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: true } };
|
||||
const resolved = resolveLoopHooks({ point: 'plan:pre', registry: realRegistry, config });
|
||||
const rendered = renderLoopHooks(resolved);
|
||||
assert.match(rendered, /ui-phase/);
|
||||
assert.match(rendered, /UI-SPEC\.md/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 6. Malformed registry resilience ────────────────────────────────────────
|
||||
|
||||
describe('malformed registry resilience', () => {
|
||||
test('missing byLoopPoint → no throw, empty activeHooks', () => {
|
||||
const badRegistry = {}; // no byLoopPoint
|
||||
// No throw — but point validation falls back to CANONICAL_POINTS_FALLBACK
|
||||
const result = resolveLoopHooks({ point: 'plan:pre', registry: badRegistry, config: {} });
|
||||
assert.strictEqual(result.activeHooks.length, 0);
|
||||
});
|
||||
|
||||
test('null hook in steps array → skipped', () => {
|
||||
const registry = makeRegistry({
|
||||
steps: [null, { capId: 'ok', point: 'plan:pre', ref: { skill: 'ok-skill' } }, undefined],
|
||||
});
|
||||
const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} });
|
||||
assert.strictEqual(result.activeHooks.length, 1);
|
||||
assert.strictEqual(result.activeHooks[0].capId, 'ok');
|
||||
});
|
||||
|
||||
test('byLoopPoint[point] missing arrays → no throw, empty result', () => {
|
||||
const registry = { byLoopPoint: { 'plan:pre': {} } }; // no steps/contributions/gates keys
|
||||
const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} });
|
||||
assert.strictEqual(result.activeHooks.length, 0);
|
||||
});
|
||||
|
||||
test('byLoopPoint[point] has non-array steps → treated as empty', () => {
|
||||
const registry = { byLoopPoint: { 'plan:pre': { steps: 'bad', contributions: [], gates: [] } } };
|
||||
const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} });
|
||||
assert.strictEqual(result.activeHooks.length, 0);
|
||||
});
|
||||
|
||||
test('byLoopPoint[point] is null → no throw, empty result', () => {
|
||||
const registry = { byLoopPoint: { 'plan:pre': null } };
|
||||
const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} });
|
||||
assert.strictEqual(result.activeHooks.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 7. Prototype-pollution guard ────────────────────────────────────────────
|
||||
|
||||
describe('prototype-pollution guard', () => {
|
||||
test('when="__proto__.x" does not pollute Object.prototype', () => {
|
||||
const registry = makeRegistry({
|
||||
steps: [{ capId: 'attacker', point: 'plan:pre', ref: { skill: 'evil' }, when: '__proto__.x' }],
|
||||
});
|
||||
const config = { x: 'injected' };
|
||||
// Should not throw and should not activate (guard returns found:false)
|
||||
const result = resolveLoopHooks({ point: 'plan:pre', registry, config });
|
||||
assert.strictEqual(result.activeHooks.length, 0);
|
||||
// Object.prototype must not be polluted
|
||||
assert.strictEqual(({}).x, undefined);
|
||||
});
|
||||
|
||||
test('when="constructor.x" does not pollute', () => {
|
||||
const registry = makeRegistry({
|
||||
steps: [{ capId: 'attacker', point: 'plan:pre', ref: { skill: 'evil' }, when: 'constructor.x' }],
|
||||
});
|
||||
const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} });
|
||||
assert.strictEqual(result.activeHooks.length, 0);
|
||||
});
|
||||
|
||||
test('when="prototype.x" does not pollute', () => {
|
||||
const registry = makeRegistry({
|
||||
steps: [{ capId: 'attacker', point: 'plan:pre', ref: { skill: 'evil' }, when: 'prototype.x' }],
|
||||
});
|
||||
const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {} });
|
||||
assert.strictEqual(result.activeHooks.length, 0);
|
||||
});
|
||||
|
||||
test('_getNestedConfigValue: __proto__ segment returns found:false', () => {
|
||||
const r = _getNestedConfigValue({}, '__proto__.x');
|
||||
assert.strictEqual(r.found, false);
|
||||
});
|
||||
|
||||
test('_getNestedConfigValue: constructor segment returns found:false', () => {
|
||||
const r = _getNestedConfigValue({}, 'constructor.toString');
|
||||
assert.strictEqual(r.found, false);
|
||||
});
|
||||
|
||||
test('_getNestedConfigValue: normal dotted key traversal works', () => {
|
||||
const config = { workflow: { ui_phase: true } };
|
||||
const r = _getNestedConfigValue(config, 'workflow.ui_phase');
|
||||
assert.strictEqual(r.found, true);
|
||||
assert.strictEqual(r.value, true);
|
||||
});
|
||||
|
||||
// FIX 4: raw config.json with __proto__ key does not pollute via _readRawConfigKey
|
||||
test('raw config.json with "__proto__" key does not pollute Object.prototype', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loop-resolver-proto-'));
|
||||
try {
|
||||
// Write a raw config.json containing __proto__ at top level and nested
|
||||
// (JSON.parse of {"__proto__":{"x":"polluted"}} does NOT set prototype in modern Node,
|
||||
// but we verify our guarded traversal returns found:false for such keys)
|
||||
const maliciousConfig = '{"__proto__":{"x":"polluted"},"workflow":{"ui_phase":true}}';
|
||||
fs.writeFileSync(path.join(tmpDir, 'config.json'), maliciousConfig, 'utf8');
|
||||
// _readRawConfigKey with '__proto__.x' should return found:false (guard)
|
||||
const r1 = _readRawConfigKey(path.join(tmpDir, 'config.json'), '__proto__.x');
|
||||
assert.strictEqual(r1.found, false, '__proto__ lookup must be guarded');
|
||||
// Normal key should work
|
||||
const r2 = _readRawConfigKey(path.join(tmpDir, 'config.json'), 'workflow.ui_phase');
|
||||
assert.strictEqual(r2.found, true);
|
||||
assert.strictEqual(r2.value, true);
|
||||
// Object.prototype must not be polluted
|
||||
assert.strictEqual(({}).x, undefined);
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
// FIX 4: _resolveActivationValue with cwd pointing to project with __proto__ config key
|
||||
test('_resolveActivationValue: raw config with __proto__ key does not pollute', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loop-resolver-proto2-'));
|
||||
try {
|
||||
const planningDir = path.join(tmpDir, '.planning');
|
||||
fs.mkdirSync(planningDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'config.json'),
|
||||
'{"__proto__":{"y":"polluted2"}}',
|
||||
'utf8',
|
||||
);
|
||||
const registry = makeRegistry({
|
||||
steps: [{ capId: 'test', point: 'plan:pre', ref: { skill: 'sk' }, when: '__proto__.y' }],
|
||||
});
|
||||
const result = resolveLoopHooks({ point: 'plan:pre', registry, config: {}, cwd: tmpDir });
|
||||
assert.strictEqual(result.activeHooks.length, 0, '__proto__ when must be inactive');
|
||||
assert.strictEqual(({}).y, undefined, 'Object.prototype.y must not be polluted');
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 7b. Raw config.json override paths (FIX 4) ──────────────────────────────
|
||||
|
||||
describe('raw config.json override paths (FIX 4)', () => {
|
||||
// FIX 4: user sets workflow.ui_phase=false in root config.json → hook filtered
|
||||
test('root config.json with ui_phase=false overrides schema default=true → inactive', () => {
|
||||
// tmpFalseConfigProjectDir has .planning/config.json { workflow: { ui_phase: false } }
|
||||
const result = resolveLoopHooks({
|
||||
point: 'plan:pre',
|
||||
registry: realRegistry,
|
||||
config: {}, // empty loadConfig result (simulating pre-cutover)
|
||||
cwd: tmpFalseConfigProjectDir,
|
||||
});
|
||||
const uiStep = result.activeHooks.find(h => h.capId === 'ui' && h.kind === 'step');
|
||||
assert.strictEqual(
|
||||
uiStep,
|
||||
undefined,
|
||||
'root config.json override false must beat schema default=true',
|
||||
);
|
||||
});
|
||||
|
||||
// FIX 4: root config.json with ui_phase=true (explicit) → hook active
|
||||
test('root config.json with ui_phase=true → active (raw config read path)', () => {
|
||||
// tmpProjectDir has .planning/config.json { workflow: { ui_phase: true } }
|
||||
const result = resolveLoopHooks({
|
||||
point: 'plan:pre',
|
||||
registry: realRegistry,
|
||||
config: {}, // empty loadConfig result (simulating pre-cutover)
|
||||
cwd: tmpProjectDir,
|
||||
});
|
||||
const uiStep = result.activeHooks.find(h => h.capId === 'ui' && h.kind === 'step');
|
||||
assert.ok(uiStep, 'root config.json ui_phase=true should activate hook');
|
||||
});
|
||||
|
||||
// FIX 4: no config.json at all → falls through to schema default=true → active
|
||||
test('no config.json → schema default=true → hook active', () => {
|
||||
// tmpEmptyProjectDir has .planning/ directory but no config.json
|
||||
const result = resolveLoopHooks({
|
||||
point: 'plan:pre',
|
||||
registry: realRegistry,
|
||||
config: {}, // empty loadConfig result
|
||||
cwd: tmpEmptyProjectDir,
|
||||
});
|
||||
const uiStep = result.activeHooks.find(h => h.capId === 'ui' && h.kind === 'step');
|
||||
assert.ok(uiStep, 'no config.json → schema default=true → hook should be active');
|
||||
});
|
||||
|
||||
// FIX 4: _readRawConfigKey returns found:false for missing file (ENOENT — silent)
|
||||
test('_readRawConfigKey: missing file → found:false, no throw', () => {
|
||||
const result = _readRawConfigKey('/nonexistent/path/config.json', 'workflow.ui_phase');
|
||||
assert.strictEqual(result.found, false);
|
||||
});
|
||||
|
||||
// FIX 4: _readRawConfigKey returns found:false for malformed JSON, warns once
|
||||
test('_readRawConfigKey: malformed JSON → found:false, no throw', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loop-resolver-malformed-'));
|
||||
try {
|
||||
const malformedPath = path.join(tmpDir, 'config.json');
|
||||
fs.writeFileSync(malformedPath, '{ invalid json }', 'utf8');
|
||||
const result = _readRawConfigKey(malformedPath, 'workflow.ui_phase');
|
||||
assert.strictEqual(result.found, false, 'malformed JSON should return found:false');
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 8. Renderer tests ────────────────────────────────────────────────────────
|
||||
|
||||
describe('renderLoopHooks', () => {
|
||||
test('step hook renders skill ref, capId, produces, consumes', () => {
|
||||
const resolved = {
|
||||
point: 'plan:pre',
|
||||
activeHooks: [{
|
||||
capId: 'ui',
|
||||
kind: 'step',
|
||||
ref: { skill: 'ui-phase' },
|
||||
when: 'workflow.ui_phase',
|
||||
produces: ['UI-SPEC.md'],
|
||||
consumes: ['CONTEXT.md'],
|
||||
onError: 'skip',
|
||||
}],
|
||||
};
|
||||
const rendered = renderLoopHooks(resolved);
|
||||
assert.match(rendered, /Step 1/);
|
||||
assert.match(rendered, /skill:ui-phase/);
|
||||
assert.match(rendered, /\(ui\)/);
|
||||
assert.match(rendered, /UI-SPEC\.md/);
|
||||
assert.match(rendered, /CONTEXT\.md/);
|
||||
assert.match(rendered, /workflow\.ui_phase/);
|
||||
assert.match(rendered, /skip/);
|
||||
});
|
||||
|
||||
test('contribution hook renders into role', () => {
|
||||
const resolved = {
|
||||
point: 'plan:pre',
|
||||
activeHooks: [{
|
||||
capId: 'contrib-cap',
|
||||
kind: 'contribution',
|
||||
into: 'planner',
|
||||
}],
|
||||
};
|
||||
const rendered = renderLoopHooks(resolved);
|
||||
assert.match(rendered, /contribution/);
|
||||
assert.match(rendered, /contrib-cap/);
|
||||
assert.match(rendered, /planner/);
|
||||
});
|
||||
|
||||
test('gate hook renders check, blocking, onError', () => {
|
||||
const resolved = {
|
||||
point: 'execute:wave:post',
|
||||
activeHooks: [{
|
||||
capId: 'ui',
|
||||
kind: 'gate',
|
||||
check: { query: 'ui.safety-gate' },
|
||||
blocking: true,
|
||||
onError: 'halt',
|
||||
}],
|
||||
};
|
||||
const rendered = renderLoopHooks(resolved);
|
||||
assert.match(rendered, /Gate/);
|
||||
assert.match(rendered, /ui/);
|
||||
assert.match(rendered, /blocking=true/);
|
||||
assert.match(rendered, /halt/);
|
||||
});
|
||||
|
||||
test('multiple hooks in order render with correct ordinals', () => {
|
||||
const resolved = {
|
||||
point: 'plan:pre',
|
||||
activeHooks: [
|
||||
{ capId: 'cap-a', kind: 'step', ref: { skill: 'a' }, produces: ['A.md'], consumes: [] },
|
||||
{ capId: 'cap-b', kind: 'step', ref: { skill: 'b' }, produces: ['B.md'], consumes: ['A.md'] },
|
||||
],
|
||||
};
|
||||
const rendered = renderLoopHooks(resolved);
|
||||
assert.match(rendered, /Step 1/);
|
||||
assert.match(rendered, /Step 2/);
|
||||
const idx1 = rendered.indexOf('Step 1');
|
||||
const idx2 = rendered.indexOf('Step 2');
|
||||
assert.ok(idx1 < idx2, 'Step 1 should appear before Step 2');
|
||||
});
|
||||
|
||||
test('empty hooks returns placeholder containing the point name', () => {
|
||||
const rendered = renderLoopHooks({ point: 'ship:post', activeHooks: [] });
|
||||
assert.match(rendered, /ship:post/);
|
||||
assert.ok(rendered.length > 0);
|
||||
});
|
||||
|
||||
test('rendered is deterministic (same input → same output)', () => {
|
||||
const config = { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: true } };
|
||||
const resolved = resolveLoopHooks({ point: 'plan:pre', registry: realRegistry, config });
|
||||
const r1 = renderLoopHooks(resolved);
|
||||
const r2 = renderLoopHooks(resolved);
|
||||
assert.strictEqual(r1, r2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 9. End-to-end cmdLoopRenderHooks (via gsd-tools subprocess) ─────────────
|
||||
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const GSD_TOOLS = path.join(ROOT, 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
describe('cmdLoopRenderHooks end-to-end (via gsd-tools)', () => {
|
||||
test('loop render-hooks plan:pre returns JSON envelope with ui-phase step active', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[GSD_TOOLS, 'loop', 'render-hooks', 'plan:pre', '--cwd', tmpProjectDir],
|
||||
{ cwd: ROOT, encoding: 'utf8' },
|
||||
);
|
||||
assert.strictEqual(result.status, 0, 'Expected exit 0. stderr: ' + (result.stderr || ''));
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
assert.strictEqual(envelope.point, 'plan:pre');
|
||||
assert.ok(Array.isArray(envelope.activeHooks));
|
||||
assert.strictEqual(typeof envelope.rendered, 'string');
|
||||
// With ui_phase=true in tmpProjectDir config, ui-phase step should be active
|
||||
const uiStep = envelope.activeHooks.find(h => h.capId === 'ui' && h.kind === 'step');
|
||||
assert.ok(uiStep, 'Expected ui step in activeHooks. Got: ' + JSON.stringify(envelope.activeHooks));
|
||||
assert.match(envelope.rendered, /ui-phase/);
|
||||
});
|
||||
|
||||
// FIX 4: schema-default activation — no config.json in project → ui-phase step active by default
|
||||
test('loop render-hooks plan:pre with no config.json → ui-phase step active by schema default', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[GSD_TOOLS, 'loop', 'render-hooks', 'plan:pre', '--cwd', tmpEmptyProjectDir],
|
||||
{ cwd: ROOT, encoding: 'utf8' },
|
||||
);
|
||||
assert.strictEqual(result.status, 0, 'Expected exit 0. stderr: ' + (result.stderr || ''));
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
const uiStep = envelope.activeHooks.find(h => h.capId === 'ui' && h.kind === 'step');
|
||||
assert.ok(
|
||||
uiStep,
|
||||
'Expected ui step active by default. Got: ' + JSON.stringify(envelope.activeHooks),
|
||||
);
|
||||
assert.match(envelope.rendered, /ui-phase/);
|
||||
});
|
||||
|
||||
// FIX 4: explicit false in config.json overrides schema default
|
||||
test('loop render-hooks plan:pre with ui_phase=false in config.json → ui-phase step absent', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[GSD_TOOLS, 'loop', 'render-hooks', 'plan:pre', '--cwd', tmpFalseConfigProjectDir],
|
||||
{ cwd: ROOT, encoding: 'utf8' },
|
||||
);
|
||||
assert.strictEqual(result.status, 0, 'Expected exit 0. stderr: ' + (result.stderr || ''));
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
const uiStep = envelope.activeHooks.find(h => h.capId === 'ui' && h.kind === 'step');
|
||||
assert.strictEqual(
|
||||
uiStep,
|
||||
undefined,
|
||||
'ui-phase step should be absent when config.json sets ui_phase=false',
|
||||
);
|
||||
});
|
||||
|
||||
test('loop render-hooks invalid-point exits non-zero', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[GSD_TOOLS, 'loop', 'render-hooks', 'plan:mid', '--cwd', tmpProjectDir],
|
||||
{ cwd: ROOT, encoding: 'utf8' },
|
||||
);
|
||||
assert.notStrictEqual(result.status, 0, 'Expected non-zero exit for invalid point');
|
||||
assert.match(result.stderr, /plan:mid|Invalid loop point/);
|
||||
});
|
||||
});
|
||||
@@ -3364,6 +3364,55 @@ describe('bug #1962: normalizePhaseName preserves letter suffix case', () => {
|
||||
// (consolidated from tests/bug-1998-phase-complete-checkbox.test.cjs)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run `gsd-tools phase complete <phase>` for the phase-complete regression
|
||||
* suites and return its stdout.
|
||||
*
|
||||
* `phase complete` writes ROADMAP.md as its LAST step, after a read-heavy
|
||||
* parse/lock sequence (ROADMAP read, two extractCurrentMilestone STATE.md
|
||||
* parses, REQUIREMENTS read, phase-dir scan, STATE read — all before the single
|
||||
* writePlanningFileSet flush). Under the high-concurrency docker run (~672 test
|
||||
* files in parallel), a tight 10s timeout could fire mid-parse and SIGTERM the
|
||||
* subprocess BEFORE that write landed, leaving ROADMAP.md untouched. Call sites
|
||||
* that used a bare `catch {}` then silently proceeded to assert on the pristine
|
||||
* file — an intermittent "checkbox not checked" failure (bug #1998 flake).
|
||||
*
|
||||
* Two-part fix, no retry loop:
|
||||
* 1. A generous timeout so the test's own timer never kills the subprocess
|
||||
* under load (10s cold-node startup × 672-way CPU/IO contention was the
|
||||
* real culprit — all I/O is scoped to tmpDir, so there is no cross-process
|
||||
* race to blame).
|
||||
* 2. Never silently swallow a signal/timeout kill: it means the process was
|
||||
* terminated before completing its writes, so we surface it loudly with
|
||||
* context instead of letting it masquerade as an assertion failure. A
|
||||
* *clean* non-zero exit is still tolerated when `tolerateExit` is set,
|
||||
* because the ROADMAP write has already landed before any post-write step
|
||||
* that may exit non-zero in these minimal fixtures.
|
||||
*/
|
||||
function runPhaseComplete(tmpDir, { phase = '1', tolerateExit = false } = {}) {
|
||||
try {
|
||||
return execFileSync('node', [GSD_TOOLS_BIN, 'phase', 'complete', phase], {
|
||||
cwd: tmpDir,
|
||||
timeout: 60000,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
} catch (err) {
|
||||
// A signal/timeout kill terminated the process before it finished writing —
|
||||
// never tolerate it; surface it with whatever output was captured.
|
||||
if (err.killed || err.signal != null || err.code === 'ETIMEDOUT') {
|
||||
throw new Error(
|
||||
`gsd-tools phase complete ${phase} was killed before completion ` +
|
||||
`(signal=${err.signal}, code=${err.code}). ` +
|
||||
`stdout=${err.stdout || ''} stderr=${err.stderr || ''}`
|
||||
);
|
||||
}
|
||||
if (tolerateExit) {
|
||||
return `${err.stdout || ''}${err.stderr || ''}`;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
describe('bug #1998: phase complete updates overview checkbox', () => {
|
||||
let tmpDir;
|
||||
let planningDir;
|
||||
@@ -3419,11 +3468,7 @@ describe('bug #1998: phase complete updates overview checkbox', () => {
|
||||
'| 2. Features | 0/1 | Pending | - |',
|
||||
].join('\n'));
|
||||
|
||||
try {
|
||||
execFileSync('node', [GSD_TOOLS_BIN, 'phase', 'complete', '1'], { cwd: tmpDir, timeout: 10000 });
|
||||
} catch {
|
||||
// Command may exit non-zero if STATE.md update fails, but ROADMAP.md update happens first
|
||||
}
|
||||
runPhaseComplete(tmpDir, { tolerateExit: true });
|
||||
|
||||
const result = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
assert.match(result, /- \[x\] \*\*Phase 1: Foundation\*\*/, 'overview checkbox should be checked');
|
||||
@@ -3468,11 +3513,7 @@ describe('bug #1998: phase complete updates overview checkbox', () => {
|
||||
'</details>',
|
||||
].join('\n'));
|
||||
|
||||
try {
|
||||
execFileSync('node', [GSD_TOOLS_BIN, 'phase', 'complete', '1'], { cwd: tmpDir, timeout: 10000 });
|
||||
} catch {
|
||||
// May exit non-zero
|
||||
}
|
||||
runPhaseComplete(tmpDir, { tolerateExit: true });
|
||||
|
||||
const result = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
assert.match(result, /- \[x\] \*\*Phase 1: Setup\*\*/, 'current milestone checkbox should be checked');
|
||||
@@ -3559,11 +3600,7 @@ describe('bug #2005: phase complete updates plan count when milestone is inside
|
||||
'</details>',
|
||||
].join('\n'));
|
||||
|
||||
try {
|
||||
execFileSync('node', [GSD_TOOLS_BIN, 'phase', 'complete', '1'], { cwd: tmpDir, timeout: 10000 });
|
||||
} catch {
|
||||
// May exit non-zero if STATE.md update fails, but ROADMAP.md update is the target
|
||||
}
|
||||
runPhaseComplete(tmpDir, { tolerateExit: true });
|
||||
|
||||
const result = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
|
||||
@@ -3619,9 +3656,7 @@ describe('bug #2005: phase complete updates plan count when milestone is inside
|
||||
'</details>',
|
||||
].join('\n'));
|
||||
|
||||
try {
|
||||
execFileSync('node', [GSD_TOOLS_BIN, 'phase', 'complete', '1'], { cwd: tmpDir, timeout: 10000 });
|
||||
} catch {}
|
||||
runPhaseComplete(tmpDir, { tolerateExit: true });
|
||||
|
||||
const result = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
|
||||
@@ -3709,22 +3744,7 @@ describe('bug #2526: phase complete warns about unregistered REQ-IDs', () => {
|
||||
'| REQ-001 | 1 | Pending |',
|
||||
].join('\n'));
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
try {
|
||||
const result = execFileSync('node', [GSD_TOOLS_BIN, 'phase', 'complete', '1'], {
|
||||
cwd: tmpDir,
|
||||
timeout: 10000,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
stdout = result;
|
||||
} catch (err) {
|
||||
stdout = err.stdout || '';
|
||||
stderr = err.stderr || '';
|
||||
throw err;
|
||||
}
|
||||
|
||||
const combined = stdout + stderr;
|
||||
const combined = runPhaseComplete(tmpDir);
|
||||
assert.match(combined, /REQ-002/, 'output should mention REQ-002 as missing from Traceability table');
|
||||
assert.match(combined, /REQ-003/, 'output should mention REQ-003 as missing from Traceability table');
|
||||
});
|
||||
@@ -3776,22 +3796,7 @@ describe('bug #2526: phase complete warns about unregistered REQ-IDs', () => {
|
||||
'| REQ-002 | 1 | Pending |',
|
||||
].join('\n'));
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
try {
|
||||
const result = execFileSync('node', [GSD_TOOLS_BIN, 'phase', 'complete', '1'], {
|
||||
cwd: tmpDir,
|
||||
timeout: 10000,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
stdout = result;
|
||||
} catch (err) {
|
||||
stdout = err.stdout || '';
|
||||
stderr = err.stderr || '';
|
||||
throw err;
|
||||
}
|
||||
|
||||
const combined = stdout + stderr;
|
||||
const combined = runPhaseComplete(tmpDir);
|
||||
assert.doesNotMatch(
|
||||
combined,
|
||||
/unregistered|missing.*traceability|not in.*traceability/i,
|
||||
@@ -3845,22 +3850,7 @@ describe('bug #2526: phase complete warns about unregistered REQ-IDs', () => {
|
||||
'| REQ-001 | 1 | Pending |',
|
||||
].join('\n'));
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
try {
|
||||
const result = execFileSync('node', [GSD_TOOLS_BIN, 'phase', 'complete', '1'], {
|
||||
cwd: tmpDir,
|
||||
timeout: 10000,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
stdout = result;
|
||||
} catch (err) {
|
||||
stdout = err.stdout || '';
|
||||
stderr = err.stderr || '';
|
||||
throw err;
|
||||
}
|
||||
|
||||
const combined = stdout + stderr;
|
||||
const combined = runPhaseComplete(tmpDir);
|
||||
assert.match(combined, /REQ-002/, 'should warn about REQ-002');
|
||||
assert.match(combined, /REQ-003/, 'should warn about REQ-003');
|
||||
assert.match(combined, /REQ-004/, 'should warn about REQ-004');
|
||||
|
||||
@@ -135,3 +135,61 @@ describe('plan-phase workflow: Artifacts this phase produces section (#22)', ()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── (C) Top-level spawn guard (#913) ────────────────────────────────────────
|
||||
|
||||
describe('plan-phase workflow: top-level spawn guard (#913)', () => {
|
||||
// Extract the runtime_compatibility block for targeted assertions
|
||||
const rtBlock = (() => {
|
||||
const m = workflow.match(/<runtime_compatibility>([\s\S]*?)<\/runtime_compatibility>/);
|
||||
return m ? m[1] : '';
|
||||
})();
|
||||
|
||||
test('workflow has a runtime_compatibility block asserting Agent is available at top-level', () => {
|
||||
assert.ok(
|
||||
rtBlock.length > 0,
|
||||
'plan-phase must have a <runtime_compatibility> block — prevents role-collapse regression (#913)'
|
||||
);
|
||||
assert.ok(
|
||||
rtBlock.includes('Agent tool IS available') || rtBlock.includes('Agent IS available'),
|
||||
'plan-phase runtime_compatibility must assert that the Agent tool IS available at top-level Claude Code (#913)'
|
||||
);
|
||||
assert.ok(
|
||||
rtBlock.toLowerCase().includes('top-level'),
|
||||
'plan-phase runtime_compatibility must scope the IS-available assertion to top-level Claude Code (#913)'
|
||||
);
|
||||
assert.ok(
|
||||
rtBlock.includes('Always spawn') || rtBlock.includes('always spawn'),
|
||||
'plan-phase runtime_compatibility must state that plan roles must always be spawned (#913)'
|
||||
);
|
||||
assert.ok(
|
||||
rtBlock.includes('Never absorb') || rtBlock.includes('never absorb'),
|
||||
'plan-phase runtime_compatibility must state that roles must never be absorbed inline (#913)'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow states --chain/--auto suppress prompts only, not spawns', () => {
|
||||
assert.ok(
|
||||
rtBlock.includes('suppress') &&
|
||||
(rtBlock.includes('prompts only') || rtBlock.includes('interactive prompts only')),
|
||||
'plan-phase runtime_compatibility must document that --chain/--auto suppress prompts only, not spawns (#913)'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow does not contain unscoped CODEX RUNTIME orchestrator rule labels', () => {
|
||||
// All "wait for subagent" rules must apply to ALL RUNTIMES, not just Codex
|
||||
assert.ok(
|
||||
!workflow.includes('ORCHESTRATOR RULE — CODEX RUNTIME'),
|
||||
'plan-phase must not label orchestrator wait rules as "CODEX RUNTIME" — they apply to all runtimes including top-level Claude Code (#913)'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow contains ALL RUNTIMES orchestrator rule labels (count preserved)', () => {
|
||||
// Must have all 7 agent-spawn wait rules still present (none dropped during rename)
|
||||
const allRuntimesCount = (workflow.match(/ORCHESTRATOR RULE — ALL RUNTIMES/g) || []).length;
|
||||
assert.ok(
|
||||
allRuntimesCount >= 7,
|
||||
`plan-phase must have at least 7 "ORCHESTRATOR RULE — ALL RUNTIMES" labels (one per agent spawn site); found ${allRuntimesCount} (#913)`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,8 +81,8 @@ const GRACE = 3000;
|
||||
// current high-water mark within GRACE (#597 tighten-only ratchet).
|
||||
// XL high-water mark is execute-phase.md — note that under LINES it was
|
||||
// plan-phase; bytes genuinely re-rank the tier, which is the point of #717.
|
||||
// actualMax=91161 (execute-phase, #891 launcher shim expansion — added 17 runtime home arms);
|
||||
// slack=1839 ≤ GRACE. plan-phase.md=88120, new-project.md=58110; both well under ceiling.
|
||||
// actualMax=92525 (execute-phase, #913 inline-fallback scope clarification);
|
||||
// slack=475 ≤ GRACE. plan-phase.md=90501 (#913 runtime_compatibility block + label rename), new-project.md=58110.
|
||||
const XL_BUDGET = 93000;
|
||||
// LARGE high-water mark is docs-update.md. actualMax=54410 (#891 launcher shim expansion);
|
||||
// slack=1590 ≤ GRACE. quick.md=45710, autonomous.md=38030.
|
||||
@@ -95,8 +95,8 @@ const DEFAULT_BUDGET = 40000;
|
||||
// Grandfathered at current sizes — see PR #2551 for the progressive-disclosure
|
||||
// pattern that future shrinks should follow. Byte counts noted for reference.
|
||||
const XL_WORKFLOWS = new Set([
|
||||
'execute-phase', // 91161 bytes (tier high-water mark; grew in #891 launcher shim expansion)
|
||||
'plan-phase', // 85068 bytes
|
||||
'execute-phase', // 92525 bytes (tier high-water mark; grew in #913 inline-fallback scope clarification)
|
||||
'plan-phase', // 90501 bytes (grew in #913 runtime_compatibility block + label rename)
|
||||
'new-project', // 55850 bytes
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user