Files
msd-core/src/capability-activation.cts
Tom Boucher 3a53255244 refactor(#1308): consolidate config-key precedence engine into single owner (#1322)
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>
2026-06-16 02:38:47 -04:00

133 lines
4.5 KiB
TypeScript

/**
* Capability activation helpers.
*
* Shared by the Capability State Resolver and Loop Resolver so config-key
* activation uses one precedence chain and one prototype-pollution guard.
*/
import fs from 'node:fs';
import path from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspaceMod = require('./planning-workspace.cjs');
const { planningDir, planningRoot } = planningWorkspaceMod;
function _getNestedConfigValue(
config: Record<string, unknown>,
dotKey: string,
): { found: boolean; value: unknown } {
const segments = dotKey.split('.');
let current: unknown = config;
for (const seg of segments) {
if (seg === '__proto__' || seg === 'constructor' || seg === 'prototype') {
return { found: false, value: undefined };
}
if (typeof current !== 'object' || current === null) {
return { found: false, value: undefined };
}
const cur = current as Record<string, unknown>;
if (!Object.prototype.hasOwnProperty.call(cur, seg)) {
return { found: false, value: undefined };
}
current = cur[seg];
}
return { found: true, value: current };
}
const _warnedRawConfigPaths = new Set<string>();
function _readRawConfigKey(
filePath: string,
dotKey: string,
): { found: boolean; value: unknown } {
try {
const raw = fs.readFileSync(filePath, 'utf8');
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(raw) as Record<string, unknown>;
} catch {
if (!_warnedRawConfigPaths.has(filePath)) {
_warnedRawConfigPaths.add(filePath);
try {
process.stderr.write(
`gsd-tools: warning: failed to parse ${filePath} as JSON — skipping for activation resolution\n`,
);
} catch { /* stderr might be closed */ }
}
return { found: false, value: undefined };
}
return _getNestedConfigValue(parsed, dotKey);
} catch {
return { found: false, value: undefined };
}
}
/**
* Resolve the raw value for a dotted config key using the four-level precedence
* walk. Returns { found, value } with the RAW value (not coerced to boolean),
* so callers can decide how to interpret the value (boolean gate vs. raw config
* value for numeric/string settings like security_asvs_level).
*
* Precedence (mirrors _resolveActivationValue):
* 1. loadConfig result (config arg) — guarded nested-lookup.
* 2. Workstream config.json at planningDir(cwd)/config.json.
* 3. Root config.json at planningRoot(cwd)/config.json (only if path differs).
* 4. registry.configSchema[dotKey].default — schema default.
* 5. Absent → { found: false, value: undefined }.
*/
function resolveConfigKey(
dotKey: string,
opts: { config: Record<string, unknown>; cwd: string | undefined; registry: Record<string, unknown> },
): { found: boolean; value: unknown } {
const { config, cwd, registry } = opts;
// Level 1: loadConfig result
const fromConfig = _getNestedConfigValue(config, dotKey);
if (fromConfig.found) return { found: true, value: fromConfig.value };
// Level 2 + 3: raw config.json files (only when cwd is available)
if (cwd) {
const wsConfigPath = path.join(planningDir(cwd), 'config.json');
const rootConfigPath = path.join(planningRoot(cwd), 'config.json');
const fromWs = _readRawConfigKey(wsConfigPath, dotKey);
if (fromWs.found) return { found: true, value: fromWs.value };
if (wsConfigPath !== rootConfigPath) {
const fromRoot = _readRawConfigKey(rootConfigPath, dotKey);
if (fromRoot.found) return { found: true, value: fromRoot.value };
}
}
// Level 4: registry configSchema default
const schemaMap = registry['configSchema'];
if (schemaMap && typeof schemaMap === 'object' && !Array.isArray(schemaMap)
&& Object.prototype.hasOwnProperty.call(schemaMap, dotKey)) {
const schemaEntry = (schemaMap as Record<string, unknown>)[dotKey];
if (schemaEntry && typeof schemaEntry === 'object' && schemaEntry !== null) {
const def = (schemaEntry as Record<string, unknown>)['default'];
if (def !== undefined) return { found: true, value: def };
}
}
// Level 5: absent
return { found: false, value: undefined };
}
function _resolveActivationValue(
dotKey: string,
config: Record<string, unknown>,
cwd: string | undefined,
registry: Record<string, unknown>,
): boolean {
const r = resolveConfigKey(dotKey, { config, cwd, registry });
return r.found ? Boolean(r.value) : false;
}
export = {
_getNestedConfigValue,
_readRawConfigKey,
_resolveActivationValue,
resolveConfigKey,
};