Make src/capability-activation.cts the sole owner of the four-level config-key
precedence walk via a new raw-value primitive resolveConfigKey(dotKey,{config,
cwd,registry}); the boolean wrapper _resolveActivationValue and loop-resolver's
resolveConfigValues are both rebuilt on it. loop-resolver deletes its
byte-identical copy and inline re-walk and imports the engine.
resolveCapabilityRuntimeState no longer returns registry/config (leaked internal
detail); the caller loads one fail-closed config snapshot and threads it in via a
new optional configOverride param, so capability `active` and hook when/configValues
resolve against the same object. capability-writer requires the registry module
directly.
Adds a DEFECT.GENERATIVE-FIX parity gate (identity + behavioral matrix +
end-to-end resolveLoopHooks configValues) that fails if the two precedence
surfaces ever diverge. Pure internal refactor, no user-facing change.
Closes #1308
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
597 lines
27 KiB
JavaScript
597 lines
27 KiB
JavaScript
'use strict';
|
||
/**
|
||
* capability-precedence-parity.test.cjs — DEFECT.GENERATIVE-FIX parity gate
|
||
*
|
||
* Proves that the config-key four-level precedence walk has a single owner
|
||
* (capability-activation.cjs) and that loop-resolver.cjs re-exports the same
|
||
* functions rather than maintaining local copies.
|
||
*
|
||
* Two contracts enforced:
|
||
* (a) Identity: loop-resolver's _resolveActivationValue, _getNestedConfigValue,
|
||
* and _readRawConfigKey are the SAME function objects as
|
||
* capability-activation's exports. Fails the instant a local copy is
|
||
* re-introduced.
|
||
* (b) Behavioral matrix: across a fixture matrix hitting each precedence level
|
||
* (loadConfig result, workstream config.json, root config.json, registry
|
||
* default, absent, falsy values, prototype-pollution key), asserts that
|
||
* _resolveActivationValue(key,config,cwd,registry) ===
|
||
* (resolveConfigKey(key,{config,cwd,registry}).found ?
|
||
* Boolean(resolveConfigKey(key,{config,cwd,registry}).value) : false).
|
||
* Level-2/3 cases use real tmpdir .planning/config.json fixtures.
|
||
*
|
||
* RULESET: RULESET.TESTS.no-source-grep — no readFileSync + .includes() on source.
|
||
* RULESET: RULESET.TESTS.boundary-coverage — covers limit-1/limit/limit+1 (falsy boundary).
|
||
*/
|
||
|
||
process.env.GSD_TEST_MODE = '1';
|
||
|
||
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');
|
||
|
||
// ── Module paths ──────────────────────────────────────────────────────────────
|
||
|
||
const LIB = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib');
|
||
|
||
const capabilityActivation = require(path.join(LIB, 'capability-activation.cjs'));
|
||
const loopResolver = require(path.join(LIB, 'loop-resolver.cjs'));
|
||
|
||
// ─── (a) Identity guard ───────────────────────────────────────────────────────
|
||
|
||
describe('capability-precedence-parity: identity guard — loop-resolver re-exports same function objects', () => {
|
||
test('_resolveActivationValue is the same function in both modules', () => {
|
||
assert.strictEqual(
|
||
loopResolver._resolveActivationValue,
|
||
capabilityActivation._resolveActivationValue,
|
||
'_resolveActivationValue: loop-resolver must re-export the capability-activation.cjs function, not a local copy',
|
||
);
|
||
});
|
||
|
||
test('_getNestedConfigValue is the same function in both modules', () => {
|
||
assert.strictEqual(
|
||
loopResolver._getNestedConfigValue,
|
||
capabilityActivation._getNestedConfigValue,
|
||
'_getNestedConfigValue: loop-resolver must re-export the capability-activation.cjs function, not a local copy',
|
||
);
|
||
});
|
||
|
||
test('_readRawConfigKey is the same function in both modules', () => {
|
||
assert.strictEqual(
|
||
loopResolver._readRawConfigKey,
|
||
capabilityActivation._readRawConfigKey,
|
||
'_readRawConfigKey: loop-resolver must re-export the capability-activation.cjs function, not a local copy',
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─── (b) Behavioral matrix ────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Build a minimal registry with a configSchema entry for a dotted key.
|
||
* def=undefined means no default (absent). def=<value> provides a default.
|
||
*/
|
||
function makeRegistry(dotKey, def) {
|
||
const registry = {};
|
||
if (def !== undefined) {
|
||
registry.configSchema = {
|
||
[dotKey]: { default: def },
|
||
};
|
||
} else {
|
||
registry.configSchema = {};
|
||
}
|
||
return registry;
|
||
}
|
||
|
||
/**
|
||
* Helper: call resolveConfigKey and assert it equals
|
||
* _resolveActivationValue(key,config,cwd,registry) on the boolean coercion.
|
||
*/
|
||
function assertParity(key, config, cwd, registry, label) {
|
||
const { _resolveActivationValue, resolveConfigKey } = capabilityActivation;
|
||
const boolResult = _resolveActivationValue(key, config, cwd, registry);
|
||
const rawResult = resolveConfigKey(key, { config, cwd, registry });
|
||
const expectedBool = rawResult.found ? Boolean(rawResult.value) : false;
|
||
assert.strictEqual(
|
||
boolResult,
|
||
expectedBool,
|
||
`parity check failed for ${label}: _resolveActivationValue=${boolResult} but resolveConfigKey gives found=${rawResult.found} value=${JSON.stringify(rawResult.value)} → expectedBool=${expectedBool}`,
|
||
);
|
||
return { boolResult, rawResult };
|
||
}
|
||
|
||
describe('capability-precedence-parity: behavioral matrix', () => {
|
||
let tmpDir;
|
||
|
||
before(() => {
|
||
// Create a hermetic tmpdir for file-based fixtures (levels 2+3).
|
||
// Clear env vars that redirect planningDir/planningRoot.
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'parity-test-'));
|
||
});
|
||
|
||
after(() => {
|
||
// Remove the tmpdir (helpers.cleanup carries the Windows-EBUSY retry budget).
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
// Env vars that planningDir/planningRoot read — save/restore around each test.
|
||
function withCleanEnv(fn) {
|
||
const saved = {
|
||
GSD_WORKSTREAM: process.env.GSD_WORKSTREAM,
|
||
GSD_PROJECT: process.env.GSD_PROJECT,
|
||
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
|
||
};
|
||
delete process.env.GSD_WORKSTREAM;
|
||
delete process.env.GSD_PROJECT;
|
||
delete process.env.CLAUDE_CONFIG_DIR;
|
||
try {
|
||
return fn();
|
||
} finally {
|
||
if (saved.GSD_WORKSTREAM !== undefined) process.env.GSD_WORKSTREAM = saved.GSD_WORKSTREAM;
|
||
else delete process.env.GSD_WORKSTREAM;
|
||
if (saved.GSD_PROJECT !== undefined) process.env.GSD_PROJECT = saved.GSD_PROJECT;
|
||
else delete process.env.GSD_PROJECT;
|
||
if (saved.CLAUDE_CONFIG_DIR !== undefined) process.env.CLAUDE_CONFIG_DIR = saved.CLAUDE_CONFIG_DIR;
|
||
else delete process.env.CLAUDE_CONFIG_DIR;
|
||
}
|
||
}
|
||
|
||
// ── Level 1: loadConfig result (config arg has the key) ─────────────────────
|
||
|
||
test('level-1 truthy: config arg has key=true → resolved true, parity holds', () => {
|
||
const key = 'feature.enabled';
|
||
const config = { feature: { enabled: true } };
|
||
const registry = makeRegistry(key, undefined);
|
||
const { boolResult, rawResult } = assertParity(key, config, undefined, registry, 'level-1 truthy');
|
||
assert.strictEqual(boolResult, true);
|
||
assert.strictEqual(rawResult.found, true);
|
||
assert.strictEqual(rawResult.value, true);
|
||
});
|
||
|
||
test('level-1 falsy (false): config arg has key=false → resolved false, parity holds', () => {
|
||
// BVA: falsy value false — boundary case
|
||
const key = 'feature.enabled';
|
||
const config = { feature: { enabled: false } };
|
||
const registry = makeRegistry(key, true); // default true but level-1 wins
|
||
const { boolResult, rawResult } = assertParity(key, config, undefined, registry, 'level-1 falsy=false');
|
||
assert.strictEqual(boolResult, false);
|
||
assert.strictEqual(rawResult.found, true);
|
||
assert.strictEqual(rawResult.value, false); // raw value preserved
|
||
});
|
||
|
||
test('level-1 falsy (0): config arg has key=0 → resolved false, parity holds', () => {
|
||
// BVA: falsy value 0 — boundary case
|
||
const key = 'feature.level';
|
||
const config = { feature: { level: 0 } };
|
||
const registry = makeRegistry(key, 2); // default 2 but level-1 wins
|
||
const { boolResult, rawResult } = assertParity(key, config, undefined, registry, 'level-1 falsy=0');
|
||
assert.strictEqual(boolResult, false);
|
||
assert.strictEqual(rawResult.found, true);
|
||
assert.strictEqual(rawResult.value, 0); // raw value 0 preserved
|
||
});
|
||
|
||
test('level-1 truthy (nonzero): config arg has key=2 → resolved true, parity holds', () => {
|
||
// BVA: truthy numeric — boundary-adjacent to 0
|
||
const key = 'feature.level';
|
||
const config = { feature: { level: 2 } };
|
||
const registry = makeRegistry(key, undefined);
|
||
const { boolResult, rawResult } = assertParity(key, config, undefined, registry, 'level-1 truthy=2');
|
||
assert.strictEqual(boolResult, true);
|
||
assert.strictEqual(rawResult.found, true);
|
||
assert.strictEqual(rawResult.value, 2);
|
||
});
|
||
|
||
// ── Level 2: workstream config.json ─────────────────────────────────────────
|
||
|
||
test('level-2: workstream config.json has key → resolved, level-1 empty, parity holds', () => {
|
||
withCleanEnv(() => {
|
||
// Set up a .planning/config.json in tmpDir
|
||
const planningDir = path.join(tmpDir, 'ws-l2', '.planning');
|
||
fs.mkdirSync(planningDir, { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'config.json'),
|
||
JSON.stringify({ graphify: { enabled: true } }),
|
||
'utf8',
|
||
);
|
||
const key = 'graphify.enabled';
|
||
const config = {}; // level-1 miss
|
||
const registry = makeRegistry(key, undefined);
|
||
const cwd = path.join(tmpDir, 'ws-l2');
|
||
const { boolResult, rawResult } = assertParity(key, config, cwd, registry, 'level-2 workstream');
|
||
assert.strictEqual(boolResult, true);
|
||
assert.strictEqual(rawResult.found, true);
|
||
assert.strictEqual(rawResult.value, true);
|
||
});
|
||
});
|
||
|
||
test('level-2 falsy: workstream config.json has key=false → false, parity holds', () => {
|
||
withCleanEnv(() => {
|
||
const planningDir = path.join(tmpDir, 'ws-l2-false', '.planning');
|
||
fs.mkdirSync(planningDir, { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'config.json'),
|
||
JSON.stringify({ graphify: { enabled: false } }),
|
||
'utf8',
|
||
);
|
||
const key = 'graphify.enabled';
|
||
const config = {};
|
||
const registry = makeRegistry(key, true); // default true, but level-2 wins
|
||
const cwd = path.join(tmpDir, 'ws-l2-false');
|
||
const { boolResult, rawResult } = assertParity(key, config, cwd, registry, 'level-2 falsy');
|
||
assert.strictEqual(boolResult, false);
|
||
assert.strictEqual(rawResult.found, true);
|
||
assert.strictEqual(rawResult.value, false);
|
||
});
|
||
});
|
||
|
||
// ── Level 3: root config.json (only when paths differ) ──────────────────────
|
||
|
||
// Without GSD_WORKSTREAM, planningDir === planningRoot, so levels 2 and 3 point
|
||
// to the same file. Level-3 distinction is only exercisable by setting GSD_WORKSTREAM.
|
||
// We test the "paths same → no double-read" path (covered by level-2 test above)
|
||
// and the "paths differ" path via GSD_WORKSTREAM.
|
||
test('level-3: root config.json read when workstream path differs, parity holds', () => {
|
||
// Use GSD_WORKSTREAM to force planningDir to differ from planningRoot.
|
||
withCleanEnv(() => {
|
||
const projectRoot = path.join(tmpDir, 'ws-l3-project');
|
||
// Root planning dir = projectRoot/.planning
|
||
const rootPlanningDir = path.join(projectRoot, '.planning');
|
||
// Workstream planning dir = projectRoot/.planning/workstreams/mystream
|
||
const wsName = 'mystream';
|
||
const wsPlanningDir = path.join(rootPlanningDir, 'workstreams', wsName);
|
||
fs.mkdirSync(rootPlanningDir, { recursive: true });
|
||
fs.mkdirSync(wsPlanningDir, { recursive: true });
|
||
|
||
// Write ONLY the root config.json (no ws config.json)
|
||
fs.writeFileSync(
|
||
path.join(rootPlanningDir, 'config.json'),
|
||
JSON.stringify({ intel: { enabled: true } }),
|
||
'utf8',
|
||
);
|
||
|
||
// Activate workstream so planningDir → wsPlanningDir, planningRoot → rootPlanningDir
|
||
process.env.GSD_WORKSTREAM = wsName;
|
||
|
||
const key = 'intel.enabled';
|
||
const config = {};
|
||
const registry = makeRegistry(key, undefined);
|
||
const { boolResult, rawResult } = assertParity(key, config, projectRoot, registry, 'level-3 root config');
|
||
// ws config.json absent → level-2 miss; root config.json present → level-3 hit
|
||
assert.strictEqual(boolResult, true);
|
||
assert.strictEqual(rawResult.found, true);
|
||
assert.strictEqual(rawResult.value, true);
|
||
});
|
||
});
|
||
|
||
// ── Level 4: registry configSchema default ───────────────────────────────────
|
||
|
||
test('level-4: registry default=true, no config or file → true, parity holds', () => {
|
||
withCleanEnv(() => {
|
||
const key = 'workflow.some_feature';
|
||
const config = {};
|
||
const registry = makeRegistry(key, true);
|
||
// cwd points to an empty dir — no .planning/config.json files
|
||
const cwd = path.join(tmpDir, 'l4-default-true');
|
||
fs.mkdirSync(cwd, { recursive: true });
|
||
const { boolResult, rawResult } = assertParity(key, config, cwd, registry, 'level-4 default=true');
|
||
assert.strictEqual(boolResult, true);
|
||
assert.strictEqual(rawResult.found, true);
|
||
assert.strictEqual(rawResult.value, true);
|
||
});
|
||
});
|
||
|
||
test('level-4: registry default=false → false (BVA: falsy default), parity holds', () => {
|
||
withCleanEnv(() => {
|
||
const key = 'workflow.some_feature';
|
||
const config = {};
|
||
const registry = makeRegistry(key, false);
|
||
const cwd = path.join(tmpDir, 'l4-default-false');
|
||
fs.mkdirSync(cwd, { recursive: true });
|
||
const { boolResult, rawResult } = assertParity(key, config, cwd, registry, 'level-4 default=false');
|
||
assert.strictEqual(boolResult, false);
|
||
assert.strictEqual(rawResult.found, true);
|
||
assert.strictEqual(rawResult.value, false); // raw false preserved
|
||
});
|
||
});
|
||
|
||
test('level-4: registry default=0 → false (BVA: numeric zero default), parity holds', () => {
|
||
withCleanEnv(() => {
|
||
const key = 'feature.level';
|
||
const config = {};
|
||
const registry = makeRegistry(key, 0);
|
||
const cwd = path.join(tmpDir, 'l4-default-0');
|
||
fs.mkdirSync(cwd, { recursive: true });
|
||
const { boolResult, rawResult } = assertParity(key, config, cwd, registry, 'level-4 default=0');
|
||
assert.strictEqual(boolResult, false);
|
||
assert.strictEqual(rawResult.found, true);
|
||
assert.strictEqual(rawResult.value, 0); // raw 0 preserved
|
||
});
|
||
});
|
||
|
||
// ── Level 5: absent ──────────────────────────────────────────────────────────
|
||
|
||
test('level-5: key absent everywhere → false, found=false, parity holds', () => {
|
||
withCleanEnv(() => {
|
||
const key = 'nonexistent.key';
|
||
const config = {};
|
||
const registry = makeRegistry(key, undefined); // no default
|
||
const cwd = path.join(tmpDir, 'l5-absent');
|
||
fs.mkdirSync(cwd, { recursive: true });
|
||
const { boolResult, rawResult } = assertParity(key, config, cwd, registry, 'level-5 absent');
|
||
assert.strictEqual(boolResult, false);
|
||
assert.strictEqual(rawResult.found, false);
|
||
assert.strictEqual(rawResult.value, undefined);
|
||
});
|
||
});
|
||
|
||
test('level-5: cwd=undefined, absent key → false, found=false, parity holds', () => {
|
||
const key = 'nonexistent.key';
|
||
const config = {};
|
||
const registry = makeRegistry(key, undefined);
|
||
const { boolResult, rawResult } = assertParity(key, config, undefined, registry, 'level-5 absent cwd=undefined');
|
||
assert.strictEqual(boolResult, false);
|
||
assert.strictEqual(rawResult.found, false);
|
||
});
|
||
|
||
// ── Prototype-pollution guard (nested-traversal sink, levels 1–3) ──────────────
|
||
//
|
||
// The guard protects the NESTED config-object traversal (_getNestedConfigValue) —
|
||
// the only prototype-pollution sink. A `__proto__`/`constructor`/`prototype`
|
||
// SEGMENT encountered mid-path is rejected → found=false. Level 4 is a flat
|
||
// single-key lookup of the whole dotted string against registry.configSchema (no
|
||
// nested traversal, not a pollution sink), so we deliberately seed NO level-4
|
||
// default here — otherwise that flat lookup would legitimately match the literal
|
||
// dotted key and the segment guard would not be the thing under test.
|
||
|
||
test('prototype-pollution: __proto__ mid-path segment → found=false, boolResult=false, parity holds', () => {
|
||
const key = 'a.__proto__.polluted';
|
||
const config = { a: { real: 1 } }; // 'a' resolves, then the '__proto__' segment must be rejected
|
||
const registry = makeRegistry(key, undefined); // no level-4 default
|
||
const { boolResult, rawResult } = assertParity(key, config, undefined, registry, 'proto-pollution __proto__ mid-path');
|
||
assert.strictEqual(rawResult.found, false, '__proto__ path segment must be rejected by the nested-traversal guard');
|
||
assert.strictEqual(boolResult, false);
|
||
});
|
||
|
||
test('prototype-pollution: constructor mid-path segment → found=false, boolResult=false, parity holds', () => {
|
||
const key = 'a.constructor.polluted';
|
||
const config = { a: { real: 1 } };
|
||
const registry = makeRegistry(key, undefined); // no level-4 default
|
||
const { boolResult, rawResult } = assertParity(key, config, undefined, registry, 'proto-pollution constructor mid-path');
|
||
assert.strictEqual(rawResult.found, false, 'constructor path segment must be rejected by the nested-traversal guard');
|
||
assert.strictEqual(boolResult, false);
|
||
});
|
||
|
||
// ── Level-1 precedence over level-4 ─────────────────────────────────────────
|
||
|
||
test('level-1 wins over level-4 default: config=false beats registry default=true', () => {
|
||
// BVA: this is the critical boundary — level-1 falsy beats level-4 truthy
|
||
const key = 'graphify.enabled';
|
||
const config = { graphify: { enabled: false } };
|
||
const registry = makeRegistry(key, true);
|
||
const { boolResult, rawResult } = assertParity(key, config, undefined, registry, 'level-1 false beats level-4 true default');
|
||
assert.strictEqual(boolResult, false, 'config value false must win over registry default true');
|
||
assert.strictEqual(rawResult.found, true);
|
||
assert.strictEqual(rawResult.value, false);
|
||
});
|
||
});
|
||
|
||
// ─── (c) resolveConfigKey identity guard (FIX 2 — loop-resolver re-exports resolveConfigKey) ────
|
||
//
|
||
// resolveLoopHooks.resolveConfigValues uses resolveConfigKey internally to build
|
||
// hook configValues. Exporting resolveConfigKey from loop-resolver and asserting
|
||
// identity here ensures that if someone ever introduces a local copy, this test
|
||
// will catch it immediately (same mechanism as the _resolveActivationValue guard
|
||
// above, applied to the raw-value consumer).
|
||
|
||
describe('capability-precedence-parity: identity guard — loop-resolver.resolveConfigKey is same fn as capability-activation.resolveConfigKey', () => {
|
||
test('resolveConfigKey is the same function in both modules', () => {
|
||
assert.ok(
|
||
typeof loopResolver.resolveConfigKey === 'function',
|
||
'loop-resolver must export resolveConfigKey',
|
||
);
|
||
assert.strictEqual(
|
||
loopResolver.resolveConfigKey,
|
||
capabilityActivation.resolveConfigKey,
|
||
'resolveConfigKey: loop-resolver must re-export the capability-activation.cjs function, not a local copy',
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─── (d) Behavioral: loop-resolver resolveConfigValues uses the shared engine ────────────────────
|
||
//
|
||
// Proves that resolveLoopHooks's resolveConfigValues closure (the RAW-value consumer)
|
||
// produces the SAME result as calling capabilityActivation.resolveConfigKey directly
|
||
// for the same (dotKey, config, cwd, registry) arguments.
|
||
//
|
||
// This test FAILS if resolveConfigValues is ever replaced with a divergent copy that
|
||
// does not delegate to resolveConfigKey — fulfilling the DEFECT.GENERATIVE-FIX mandate.
|
||
//
|
||
// Coverage: three cases required:
|
||
// (1) Level-1 config hit — key present in config arg
|
||
// (2) Level-4 registry default — key absent from config, present in schema default
|
||
// (3) Absent key — key absent everywhere (omitted from resolved output)
|
||
|
||
describe('capability-precedence-parity: behavioral — loop-resolver resolveConfigValues uses shared resolveConfigKey engine', () => {
|
||
const { resolveLoopHooks } = loopResolver;
|
||
const { resolveConfigKey } = capabilityActivation;
|
||
const { CANONICAL_POINTS_FALLBACK } = loopResolver;
|
||
|
||
/**
|
||
* Build a minimal registry that has:
|
||
* - one contribution hook at the given point with configValues map
|
||
* - one capability that owns that hook (capId = 'test-cap', active=true implied)
|
||
* - configSchema entries for level-4 defaults
|
||
*/
|
||
function makeLoopRegistry({ point, configValues, schemaDefaults = {} }) {
|
||
const byLoopPoint = {};
|
||
for (const p of CANONICAL_POINTS_FALLBACK) {
|
||
byLoopPoint[p] = { steps: [], contributions: [], gates: [] };
|
||
}
|
||
byLoopPoint[point].contributions = [
|
||
{
|
||
capId: 'test-cap',
|
||
into: 'test-section',
|
||
fragment: { inline: 'test fragment' },
|
||
configValues,
|
||
},
|
||
];
|
||
const configSchema = {};
|
||
for (const [dotKey, def] of Object.entries(schemaDefaults)) {
|
||
configSchema[dotKey] = { default: def };
|
||
}
|
||
return { byLoopPoint, configSchema };
|
||
}
|
||
|
||
/** capabilityStatesById with test-cap active=true */
|
||
function activeCapMap() {
|
||
return new Map([['test-cap', { enabled: true, active: true }]]);
|
||
}
|
||
|
||
test('(1) level-1 config hit: resolveConfigValues matches resolveConfigKey for present key', () => {
|
||
// Hook declares configValues: { secLevel: 'security.asvs_level' }
|
||
// Config has security.asvs_level=2 → level-1 hit
|
||
const dotKey = 'security.asvs_level';
|
||
const alias = 'secLevel';
|
||
const config = { security: { asvs_level: 2 } };
|
||
const registry = makeLoopRegistry({
|
||
point: 'plan:pre',
|
||
configValues: { [alias]: dotKey },
|
||
});
|
||
|
||
const resolved = resolveLoopHooks({
|
||
point: 'plan:pre',
|
||
registry,
|
||
config,
|
||
cwd: undefined,
|
||
capabilityStatesById: activeCapMap(),
|
||
});
|
||
|
||
assert.strictEqual(resolved.activeHooks.length, 1, 'One active contribution hook expected');
|
||
const hook = resolved.activeHooks[0];
|
||
assert.ok(hook.configValues, 'configValues must be present on the resolved hook');
|
||
|
||
// resolveConfigKey direct result for the same args
|
||
const directResult = resolveConfigKey(dotKey, { config, cwd: undefined, registry });
|
||
assert.strictEqual(directResult.found, true, 'direct resolveConfigKey must find the level-1 key');
|
||
assert.strictEqual(
|
||
hook.configValues[alias],
|
||
directResult.value,
|
||
`resolved hook configValues[${alias}] must equal resolveConfigKey(...).value for level-1 hit (both=${JSON.stringify(directResult.value)})`,
|
||
);
|
||
assert.strictEqual(hook.configValues[alias], 2, 'level-1 numeric value 2 must be preserved (not coerced to boolean)');
|
||
});
|
||
|
||
test('(2) level-4 registry default hit: resolveConfigValues matches resolveConfigKey for schema default', () => {
|
||
// Hook declares configValues: { blockOn: 'security.block_on' }
|
||
// Config is empty; schema default = 'medium'
|
||
const dotKey = 'security.block_on';
|
||
const alias = 'blockOn';
|
||
const schemaDefault = 'medium';
|
||
const config = {};
|
||
const registry = makeLoopRegistry({
|
||
point: 'execute:pre',
|
||
configValues: { [alias]: dotKey },
|
||
schemaDefaults: { [dotKey]: schemaDefault },
|
||
});
|
||
|
||
const resolved = resolveLoopHooks({
|
||
point: 'execute:pre',
|
||
registry,
|
||
config,
|
||
cwd: undefined,
|
||
capabilityStatesById: activeCapMap(),
|
||
});
|
||
|
||
assert.strictEqual(resolved.activeHooks.length, 1, 'One active contribution hook expected');
|
||
const hook = resolved.activeHooks[0];
|
||
assert.ok(hook.configValues, 'configValues must be present on the resolved hook (schema default found)');
|
||
|
||
const directResult = resolveConfigKey(dotKey, { config, cwd: undefined, registry });
|
||
assert.strictEqual(directResult.found, true, 'direct resolveConfigKey must find the level-4 schema default');
|
||
assert.strictEqual(
|
||
hook.configValues[alias],
|
||
directResult.value,
|
||
`resolved hook configValues[${alias}] must equal resolveConfigKey(...).value for level-4 hit (both=${JSON.stringify(directResult.value)})`,
|
||
);
|
||
assert.strictEqual(hook.configValues[alias], 'medium', 'level-4 string default must be preserved as raw string');
|
||
});
|
||
|
||
test('(3a) mixed hook: one resolvable alias + one absent alias → configValues present, contains only the resolvable alias', () => {
|
||
// Hook declares configValues: { present: 'feature.on', absent: 'nope.missing' }
|
||
// Config has feature.on=true (level-1 hit); no registry default for nope.missing.
|
||
// Expected: resolved hook.configValues is defined, has 'present'=true, does NOT have 'absent'.
|
||
const presentDotKey = 'feature.on';
|
||
const absentDotKey = 'nope.missing';
|
||
const config = { feature: { on: true } };
|
||
const registry = makeLoopRegistry({
|
||
point: 'verify:post',
|
||
configValues: { present: presentDotKey, absent: absentDotKey },
|
||
// No schema default for absentDotKey — it must be absent everywhere
|
||
});
|
||
|
||
const resolved = resolveLoopHooks({
|
||
point: 'verify:post',
|
||
registry,
|
||
config,
|
||
cwd: undefined,
|
||
capabilityStatesById: activeCapMap(),
|
||
});
|
||
|
||
assert.strictEqual(resolved.activeHooks.length, 1, 'One active contribution hook expected');
|
||
const hook = resolved.activeHooks[0];
|
||
|
||
// The 'present' alias must resolve via level-1 config hit
|
||
assert.ok(hook.configValues !== undefined, 'configValues must be defined (at least one alias resolved)');
|
||
assert.strictEqual(
|
||
hook.configValues['present'],
|
||
true,
|
||
"hook.configValues['present'] must equal true (level-1 hit for feature.on)",
|
||
);
|
||
|
||
// The 'absent' alias must NOT appear in configValues at all (omit-when-absent contract)
|
||
assert.ok(
|
||
!Object.prototype.hasOwnProperty.call(hook.configValues, 'absent'),
|
||
"Absent key 'nope.missing' must not appear in hook.configValues (found=false → omit, not include as undefined)",
|
||
);
|
||
});
|
||
|
||
test('(3b) all-absent hook: all configValues aliases absent everywhere → hook.configValues === undefined', () => {
|
||
// Hook declares configValues: { missingAlias: 'does.not.exist' }
|
||
// Key absent from config, no schema default, no config files.
|
||
// resolveConfigValues must return undefined (empty resolved map → omitted entirely).
|
||
const dotKey = 'does.not.exist';
|
||
const alias = 'missingAlias';
|
||
const config = {};
|
||
const registry = makeLoopRegistry({
|
||
point: 'ship:pre',
|
||
configValues: { [alias]: dotKey },
|
||
// No schema default for this key
|
||
});
|
||
|
||
const resolved = resolveLoopHooks({
|
||
point: 'ship:pre',
|
||
registry,
|
||
config,
|
||
cwd: undefined,
|
||
capabilityStatesById: activeCapMap(),
|
||
});
|
||
|
||
assert.strictEqual(resolved.activeHooks.length, 1, 'One active contribution hook expected');
|
||
const hook = resolved.activeHooks[0];
|
||
|
||
// resolveConfigKey direct result must confirm absent
|
||
const directResult = resolveConfigKey(dotKey, { config, cwd: undefined, registry });
|
||
assert.strictEqual(directResult.found, false, 'resolveConfigKey must return found=false for absent key');
|
||
assert.strictEqual(directResult.value, undefined);
|
||
|
||
// When ALL aliases are absent, resolveConfigValues returns undefined → hook.configValues must be undefined
|
||
assert.strictEqual(
|
||
hook.configValues,
|
||
undefined,
|
||
'hook.configValues must be undefined when all aliases are absent (omit-when-empty contract)',
|
||
);
|
||
});
|
||
});
|