Merge branch 'next' into kimi-runtime-support
This commit is contained in:
@@ -35,8 +35,30 @@ const { detectSubRepos } = coreUtilsModule;
|
||||
import { CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS, normalizeLegacyKeys } from './configuration.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import configSchema = require('./config-schema.cjs');
|
||||
const { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS } = configSchema;
|
||||
const { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS, isValidConfigKey: _isValidConfigKeyFn } = configSchema;
|
||||
import { KNOWN_RUNTIMES, KNOWN_PROVIDERS } from './model-catalog.cjs';
|
||||
// ─── Federated Config (ADR-857 phase 3b) ─────────────────────────────────────
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import federatedConfigModule = require('./federated-config.cjs');
|
||||
const { mergeFederatedConfig } = federatedConfigModule;
|
||||
// The capability-registry.cjs is generated and lives in the same gsd-core/bin/lib/ output dir.
|
||||
// Both config-loader.cjs and capability-registry.cjs land in gsd-core/bin/lib/ at build time.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
||||
const _capabilityRegistryReal: { configSchema?: Record<string, unknown> } = require('./capability-registry.cjs');
|
||||
|
||||
// Module-level registry reference. Defaults to the real generated registry.
|
||||
// Overridable for tests via _setFederatedRegistryForTests.
|
||||
let _capabilityRegistry: { configSchema?: Record<string, unknown> } = _capabilityRegistryReal;
|
||||
|
||||
/** Test-only seam: inject a synthetic registry. Call _resetFederatedRegistryForTests() to restore. */
|
||||
function _setFederatedRegistryForTests(reg: { configSchema?: Record<string, unknown> }): void {
|
||||
_capabilityRegistry = reg;
|
||||
}
|
||||
|
||||
/** Test-only seam: restore the real generated registry. */
|
||||
function _resetFederatedRegistryForTests(): void {
|
||||
_capabilityRegistry = _capabilityRegistryReal;
|
||||
}
|
||||
|
||||
// ─── File & Config utilities ──────────────────────────────────────────────────
|
||||
|
||||
@@ -272,6 +294,81 @@ function _resetRuntimeWarningCacheForTests(): void {
|
||||
_warnedConfigKeys.clear();
|
||||
}
|
||||
|
||||
// ─── FIX 2: Federated overlay helpers ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Apply federated key values into a mutable config object.
|
||||
* Handles N-level dotted keys (e.g. "a.b.c" → obj.a.b.c).
|
||||
* Only adds keys that are not already present (does not clobber).
|
||||
* Inline prototype-pollution guards at every segment.
|
||||
*/
|
||||
function _applyFederatedValues(
|
||||
obj: Record<string, unknown>,
|
||||
values: Record<string, unknown>,
|
||||
validKeys: string[],
|
||||
): void {
|
||||
for (const dottedKey of validKeys) {
|
||||
// S2: inline literal guard on full key
|
||||
if (dottedKey === '__proto__' || dottedKey === 'constructor' || dottedKey === 'prototype') continue;
|
||||
const parts = dottedKey.split('.');
|
||||
if (parts.length === 1) {
|
||||
const topKey = parts[0];
|
||||
if (topKey !== '__proto__' && topKey !== 'constructor' && topKey !== 'prototype') {
|
||||
if (!Object.prototype.hasOwnProperty.call(obj, topKey)) {
|
||||
obj[topKey] = values[dottedKey];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// N-level nested key: traverse/create intermediate objects
|
||||
let cur: Record<string, unknown> = obj;
|
||||
let ok = true;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const seg = parts[i];
|
||||
// S2: inline literal guard on each segment
|
||||
if (seg === '__proto__' || seg === 'constructor' || seg === 'prototype') { ok = false; break; }
|
||||
if (!Object.prototype.hasOwnProperty.call(cur, seg) || cur[seg] === null) {
|
||||
cur[seg] = {};
|
||||
}
|
||||
if (typeof cur[seg] !== 'object' || Array.isArray(cur[seg])) { ok = false; break; }
|
||||
cur = cur[seg] as Record<string, unknown>;
|
||||
}
|
||||
if (!ok) continue;
|
||||
const leafKey = parts[parts.length - 1];
|
||||
// S2: inline literal guard on leaf
|
||||
if (leafKey === '__proto__' || leafKey === 'constructor' || leafKey === 'prototype') continue;
|
||||
if (!Object.prototype.hasOwnProperty.call(cur, leafKey)) {
|
||||
cur[leafKey] = values[dottedKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FIX 2: Apply the federated overlay to a base config object.
|
||||
* When validKeys is empty (current registry — all keys are central),
|
||||
* returns the baseConfig UNCHANGED (true no-op, preserves reference identity).
|
||||
* When validKeys is non-empty, applies values into a shallow clone to avoid
|
||||
* mutating shared CONFIG_DEFAULTS/module constants.
|
||||
*/
|
||||
function _applyFederatedOverlay(
|
||||
baseConfig: Record<string, unknown>,
|
||||
userConfig: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const _fedRegistrySchema = _capabilityRegistry.configSchema;
|
||||
if (!_fedRegistrySchema || typeof _fedRegistrySchema !== 'object') return baseConfig;
|
||||
const _fedOverlay = mergeFederatedConfig({
|
||||
configSchema: _fedRegistrySchema,
|
||||
isCentralKey: (key: string) => _isValidConfigKeyFn(key),
|
||||
userConfig,
|
||||
});
|
||||
// True no-op: if no federated keys, return UNCHANGED (byte-identical, no clone)
|
||||
if (_fedOverlay.validKeys.length === 0) return baseConfig;
|
||||
// Clone shallowly to avoid mutating shared constants, then apply nested values
|
||||
const cloned: Record<string, unknown> = { ...baseConfig };
|
||||
_applyFederatedValues(cloned, _fedOverlay.values, _fedOverlay.validKeys);
|
||||
return cloned;
|
||||
}
|
||||
|
||||
function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
const activeWorkstream = Object.prototype.hasOwnProperty.call(options, 'workstream')
|
||||
? options['workstream']
|
||||
@@ -397,6 +494,31 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
|
||||
// Deprecated keys (still accepted for migration, not in config-set)
|
||||
'depth', 'multiRepo', 'branching_strategy',
|
||||
]);
|
||||
|
||||
// FIX 3: Compute federated overlay BEFORE the unknown-key warning, so that
|
||||
// federated top-level keys are added to KNOWN_TOP_LEVEL before the check runs.
|
||||
// This is hoisted out of the try-catch below so validKeys are available here.
|
||||
let _preWarningFedValidKeys: string[] = [];
|
||||
try {
|
||||
const _fedRegistrySchemaEarly = _capabilityRegistry.configSchema;
|
||||
if (_fedRegistrySchemaEarly && typeof _fedRegistrySchemaEarly === 'object') {
|
||||
const _earlyOverlay = mergeFederatedConfig({
|
||||
configSchema: _fedRegistrySchemaEarly,
|
||||
isCentralKey: (key: string) => _isValidConfigKeyFn(key),
|
||||
userConfig: parsed,
|
||||
});
|
||||
_preWarningFedValidKeys = _earlyOverlay.validKeys;
|
||||
for (const dottedKey of _preWarningFedValidKeys) {
|
||||
const topKey = dottedKey.split('.')[0];
|
||||
if (topKey !== '__proto__' && topKey !== 'constructor' && topKey !== 'prototype') {
|
||||
KNOWN_TOP_LEVEL.add(topKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Defensive: if registry access fails here, proceed without pre-warning keys
|
||||
}
|
||||
|
||||
const unknownKeys = Object.keys(parsed).filter(k => !KNOWN_TOP_LEVEL.has(k));
|
||||
if (unknownKeys.length > 0) {
|
||||
const warnKey = unknownKeys.join(',');
|
||||
@@ -429,7 +551,7 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
|
||||
return defaults.parallelization;
|
||||
})();
|
||||
|
||||
return {
|
||||
const _baseConfig: Record<string, unknown> = {
|
||||
model_profile: get('model_profile') ?? defaults.model_profile,
|
||||
commit_docs: (() => {
|
||||
const explicit = get('commit_docs', { section: 'planning', field: 'commit_docs' });
|
||||
@@ -492,14 +614,54 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
|
||||
claude_md_path: get('claude_md_path') || null,
|
||||
claude_md_assembly: (parsed['claude_md_assembly']) || null,
|
||||
};
|
||||
|
||||
// ─── ADR-857 phase 3b: federated config overlay ───────────────────────────
|
||||
// FIX 2: Use the pre-computed _preWarningFedValidKeys (from the FIX 3 block above)
|
||||
// plus a fresh overlay call to get values. The KNOWN_TOP_LEVEL was already updated.
|
||||
// TODAY: every UI key is still in the central config-schema, so isCentralKey()
|
||||
// returns true for all of them → validKeys is empty → _baseConfig is returned UNCHANGED
|
||||
// (true no-op: no clone, no reorder, byte-identical output).
|
||||
// This becomes a live channel once a key is atomically removed from the central schema.
|
||||
try {
|
||||
if (_preWarningFedValidKeys.length > 0) {
|
||||
// There are actual federated values — re-use the already-computed overlay
|
||||
// (we run mergeFederatedConfig again here to get the values map; the validKeys
|
||||
// are guaranteed identical since it's the same inputs).
|
||||
const _fedRegistrySchema = _capabilityRegistry.configSchema;
|
||||
if (_fedRegistrySchema && typeof _fedRegistrySchema === 'object') {
|
||||
const _fedOverlay = mergeFederatedConfig({
|
||||
configSchema: _fedRegistrySchema,
|
||||
isCentralKey: (key: string) => _isValidConfigKeyFn(key),
|
||||
userConfig: parsed,
|
||||
});
|
||||
// Apply dotted-path values (e.g. "workflow.ui_phase" → _baseConfig.workflow.ui_phase)
|
||||
// WITHOUT clobbering existing keys. N-level nesting supported.
|
||||
_applyFederatedValues(_baseConfig, _fedOverlay.values, _fedOverlay.validKeys);
|
||||
}
|
||||
}
|
||||
// Pending-migration warnings are suppressed at load time to avoid noisy output on
|
||||
// every loadConfig call. They are surfaced at registry-generation time (--check/--write).
|
||||
} catch {
|
||||
// Defensive: if the federated overlay throws for any reason, return the base config unchanged.
|
||||
// This keeps loadConfig's no-throw contract intact regardless of capability registry state.
|
||||
}
|
||||
return _baseConfig;
|
||||
} catch {
|
||||
// Fall back to ~/.gsd/defaults.json only for truly pre-project contexts (#1683)
|
||||
if (fs.existsSync(planningDir(cwd, ws))) {
|
||||
if (rootParsed) {
|
||||
// Workstream has no config.json: re-parse using root config as the sole source.
|
||||
// (FIX 2: overlay is applied recursively in the re-entrant loadConfig call)
|
||||
return loadConfig(cwd, { workstream: null });
|
||||
}
|
||||
return defaults;
|
||||
// FIX 2: Apply the federated overlay on the no-config path.
|
||||
// With the current registry (all keys central), _applyFederatedOverlay returns
|
||||
// `defaults` UNCHANGED (true no-op, preserves byte-identical output).
|
||||
try {
|
||||
return _applyFederatedOverlay(defaults, {});
|
||||
} catch {
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const home = process.env['GSD_HOME'] || os.homedir();
|
||||
@@ -507,7 +669,7 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
|
||||
const raw = platformReadSync(globalDefaultsPath);
|
||||
if (raw === null) throw new Error('missing');
|
||||
const globalDefaults = JSON.parse(raw) as Record<string, unknown>;
|
||||
return {
|
||||
const _globalBaseCfg: Record<string, unknown> = {
|
||||
...defaults,
|
||||
model_profile: (globalDefaults['model_profile']) ?? defaults.model_profile,
|
||||
commit_docs: (globalDefaults['commit_docs']) ?? defaults.commit_docs,
|
||||
@@ -534,8 +696,21 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
|
||||
agent_skills: (globalDefaults['agent_skills']) || {},
|
||||
response_language: (globalDefaults['response_language']) || null,
|
||||
};
|
||||
// FIX 2: Apply federated overlay on global-defaults path.
|
||||
// With the current registry this is a true no-op (returns _globalBaseCfg unchanged).
|
||||
try {
|
||||
return _applyFederatedOverlay(_globalBaseCfg, globalDefaults);
|
||||
} catch {
|
||||
return _globalBaseCfg;
|
||||
}
|
||||
} catch {
|
||||
return defaults;
|
||||
// FIX 2: Apply federated overlay on the final fallback path.
|
||||
// With the current registry this is a true no-op (returns `defaults` unchanged).
|
||||
try {
|
||||
return _applyFederatedOverlay(defaults, {});
|
||||
} catch {
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -553,4 +728,6 @@ export = {
|
||||
_warnedConfigKeys,
|
||||
_gitIgnoredCache,
|
||||
RUNTIME_OVERRIDE_TIERS,
|
||||
_setFederatedRegistryForTests,
|
||||
_resetFederatedRegistryForTests,
|
||||
};
|
||||
|
||||
230
src/federated-config.cts
Normal file
230
src/federated-config.cts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Federated Config — Defensive merge of capability-declared config keys
|
||||
*
|
||||
* ADR-857 phase 3b: wires the Capability Registry's configSchema into
|
||||
* loadConfig as a provably-empty no-op channel until capability keys are
|
||||
* migrated out of the central config-schema.
|
||||
*
|
||||
* Exported function:
|
||||
* mergeFederatedConfig({ configSchema, isCentralKey, userConfig })
|
||||
* → { values, validKeys, warnings }
|
||||
*
|
||||
* Design:
|
||||
* - For each key in configSchema:
|
||||
* If isCentralKey(key) → SKIP; push a pending-migration warning.
|
||||
* Else if slice is malformed → SKIP; push a warning. Never throw.
|
||||
* Else (valid federated key absent from central):
|
||||
* resolvedValue = nested userConfig lookup if present & type-matches; else slice.default.
|
||||
* Add key→resolvedValue to values; add key to validKeys.
|
||||
* - Guard all object writes with inline literal __proto__/constructor/prototype checks.
|
||||
* - Zero external dependencies; no ajv; hand-rolled type checks only.
|
||||
*
|
||||
* ADR-857 no-op guarantee:
|
||||
* With the current registry, every UI key is still present in the central
|
||||
* config-schema, so isCentralKey() returns true for all of them and values
|
||||
* is always empty. The channel is live but carries no traffic until a key
|
||||
* is atomically removed from the central schema (the cutover step).
|
||||
*
|
||||
* Dependencies: none (zero-dep module).
|
||||
*/
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Shape of one entry in the capability registry's configSchema index. */
|
||||
interface ConfigSliceEntry {
|
||||
owner: string;
|
||||
type: string;
|
||||
default: unknown;
|
||||
description: string;
|
||||
values?: string[]; // required for enum type
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface MergeFederatedConfigInput {
|
||||
/** configSchema index from the capability registry: { [key]: ConfigSliceEntry } */
|
||||
configSchema: Record<string, unknown>;
|
||||
/** Returns true if the given key is owned by the central config-schema. */
|
||||
isCentralKey: (key: string) => boolean;
|
||||
/** The raw/merged user config object already loaded in loadConfig. */
|
||||
userConfig: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface MergeFederatedConfigResult {
|
||||
/** Resolved values for federated (non-central) keys: { key → resolvedValue } */
|
||||
values: Record<string, unknown>;
|
||||
/** Array of keys that are now valid federated keys (i.e. were added to values). */
|
||||
validKeys: string[];
|
||||
/** Human-readable diagnostic strings (pending-migration, malformed-slice, type-mismatch). */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
// ─── Allowed slice types (mirrors gen-capability-registry.cjs VALID_CONFIG_SLICE_TYPES) ──
|
||||
|
||||
const VALID_SLICE_TYPES = new Set<string>(['boolean', 'string', 'number', 'enum']);
|
||||
|
||||
// ─── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns true if `slice` has a non-empty type, a `default` property, and a
|
||||
* non-empty string description. Does NOT throw.
|
||||
*/
|
||||
function _isWellFormedSlice(slice: unknown): slice is ConfigSliceEntry {
|
||||
if (typeof slice !== 'object' || slice === null || Array.isArray(slice)) return false;
|
||||
const s = slice as Record<string, unknown>;
|
||||
if (typeof s['type'] !== 'string' || s['type'].length === 0) return false;
|
||||
if (!VALID_SLICE_TYPES.has(s['type'])) return false;
|
||||
if (!Object.prototype.hasOwnProperty.call(s, 'default')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if `value` matches the declared type in the slice.
|
||||
* For enum, also validates against slice.values if present.
|
||||
*/
|
||||
function _typeMatches(value: unknown, slice: ConfigSliceEntry): boolean {
|
||||
switch (slice.type) {
|
||||
case 'boolean': return typeof value === 'boolean';
|
||||
case 'string': return typeof value === 'string';
|
||||
case 'number': return typeof value === 'number';
|
||||
case 'enum':
|
||||
// Must be a string AND, if values list is present, must be in it
|
||||
if (typeof value !== 'string') return false;
|
||||
if (Array.isArray(slice.values) && slice.values.length > 0) {
|
||||
return slice.values.includes(value);
|
||||
}
|
||||
return true;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverse a dotted key path through a nested config object.
|
||||
* E.g. key="workflow.ui_phase", obj={workflow:{ui_phase:false}} → {found:true, value:false}
|
||||
* Returns {found:false} if any segment is missing or not an own property.
|
||||
* Handles 1, 2, or N segments generically.
|
||||
*/
|
||||
function _getNestedValue(obj: Record<string, unknown>, key: string): { found: boolean; value: unknown } {
|
||||
const segments = key.split('.');
|
||||
let current: unknown = obj;
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const seg = segments[i];
|
||||
// Inline literal prototype-pollution guard
|
||||
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 };
|
||||
}
|
||||
|
||||
// ─── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Defensive merge of capability-declared config slices into the loadConfig
|
||||
* return value.
|
||||
*
|
||||
* DEFENSIVE contract (never throws, even on bad capability data):
|
||||
* - Null/undefined/non-object input → returns empty result.
|
||||
* - Null/undefined/non-object userConfig → treated as {} (no overrides).
|
||||
* - Central keys are skipped with a pending-migration warning.
|
||||
* - Malformed slices are skipped with a warning.
|
||||
* - User-supplied values with wrong types (or out-of-enum values) fall back
|
||||
* to the slice default (a type-mismatch warning is pushed but the key is
|
||||
* still federated with its default; this is best-effort degraded operation).
|
||||
*/
|
||||
function mergeFederatedConfig(input: MergeFederatedConfigInput): MergeFederatedConfigResult {
|
||||
// FIX 4: Guard null/undefined/non-object input
|
||||
if (input === null || input === undefined || typeof input !== 'object') {
|
||||
return { values: Object.create(null) as Record<string, unknown>, validKeys: [], warnings: [] };
|
||||
}
|
||||
|
||||
const { configSchema, isCentralKey } = input;
|
||||
|
||||
// FIX 4: Guard null/undefined/non-object userConfig — treat as {}
|
||||
const userConfig: Record<string, unknown> =
|
||||
(input.userConfig !== null && input.userConfig !== undefined && typeof input.userConfig === 'object' && !Array.isArray(input.userConfig))
|
||||
? input.userConfig
|
||||
: {};
|
||||
|
||||
// FIX 6b: Use null-prototype object for all return paths
|
||||
const values: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
||||
const validKeys: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (typeof configSchema !== 'object' || configSchema === null) {
|
||||
return { values: Object.create(null) as Record<string, unknown>, validKeys: [], warnings: [] };
|
||||
}
|
||||
|
||||
for (const key of Object.keys(configSchema)) {
|
||||
// S2: inline literal prototype-pollution guard (CodeQL barrier)
|
||||
// Guard both the full key AND all dotted-path segments
|
||||
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
|
||||
const _keySegments = key.split('.');
|
||||
if (_keySegments.some((s) => s === '__proto__' || s === 'constructor' || s === 'prototype')) continue;
|
||||
|
||||
const slice = configSchema[key];
|
||||
|
||||
// If this key is still in the central schema → pending migration, skip
|
||||
try {
|
||||
if (isCentralKey(key)) {
|
||||
warnings.push(
|
||||
'federated-config: key "' + key + '" is still in the central config-schema (pending-migration); ' +
|
||||
'skipping federated resolution until the central schema entry is removed',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// isCentralKey threw — treat as unknown, skip defensively
|
||||
warnings.push('federated-config: isCentralKey("' + key + '") threw; skipping key');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate slice shape — skip malformed entries
|
||||
if (!_isWellFormedSlice(slice)) {
|
||||
warnings.push(
|
||||
'federated-config: config slice for key "' + key + '" is malformed (missing or invalid type/default); skipping',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const sliceEntry = slice;
|
||||
|
||||
// FIX 1: Resolve value using NESTED dotted-path lookup through userConfig
|
||||
let resolvedValue: unknown = sliceEntry.default;
|
||||
const { found: userHasKey, value: userValue } = _getNestedValue(userConfig, key);
|
||||
if (userHasKey && userValue !== undefined) {
|
||||
// FIX 5b: For enum, validate against slice.values if present; otherwise check type
|
||||
if (_typeMatches(userValue, sliceEntry)) {
|
||||
resolvedValue = userValue;
|
||||
} else {
|
||||
const typeDesc = sliceEntry.type === 'enum' && Array.isArray(sliceEntry.values)
|
||||
? 'enum(' + sliceEntry.values.join('|') + ')'
|
||||
: sliceEntry.type;
|
||||
warnings.push(
|
||||
'federated-config: user-supplied value for "' + key + '" has wrong type or invalid enum value ' +
|
||||
'(expected ' + typeDesc + ', got ' + typeof userValue +
|
||||
(typeof userValue === 'string' ? ' "' + String(userValue) + '"' : '') +
|
||||
'); falling back to slice default',
|
||||
);
|
||||
// resolvedValue stays as slice default
|
||||
}
|
||||
}
|
||||
|
||||
// S2: inline literal guard before writing to values
|
||||
if (key !== '__proto__' && key !== 'constructor' && key !== 'prototype') {
|
||||
values[key] = resolvedValue;
|
||||
validKeys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
return { values, validKeys, warnings };
|
||||
}
|
||||
|
||||
export = { mergeFederatedConfig };
|
||||
533
src/loop-resolver.cts
Normal file
533
src/loop-resolver.cts
Normal file
@@ -0,0 +1,533 @@
|
||||
/**
|
||||
* Loop Resolver — ADR-857 phase 3c registry-consuming query
|
||||
*
|
||||
* Given a loop point (one of the 12 canonical points from loop-host-contract.cjs),
|
||||
* filters the materialized Capability Registry by config activation and returns
|
||||
* the active hooks as a JSON envelope with a rendered-markdown field.
|
||||
*
|
||||
* REGISTRY-ONLY: no workflow calls this yet (phase-6 cutover is out of scope).
|
||||
*
|
||||
* Command surface: gsd-tools loop render-hooks <point>
|
||||
*
|
||||
* Exports (three things):
|
||||
* resolveLoopHooks({ point, registry, config }) → { point, activeHooks }
|
||||
* renderLoopHooks(resolved) → markdown string
|
||||
* cmdLoopRenderHooks(cwd, point, raw, options) — I/O entry point
|
||||
*
|
||||
* Both pure functions (resolveLoopHooks, renderLoopHooks) take explicit
|
||||
* registry/config arguments so they are trivially testable without I/O.
|
||||
*
|
||||
* Dependencies (leaf modules only — no core.cjs circular risk):
|
||||
* - node:fs / node:path (raw config.json read for capability-key activation)
|
||||
* - ./config-loader.cjs (loadConfig)
|
||||
* - ./planning-workspace.cjs (planningDir — to locate config.json)
|
||||
* - ./core.cjs (output, error)
|
||||
* - loop-host-contract.cjs (CANONICAL_POINTS via LOOP_HOST_CONTRACT)
|
||||
* - capability-registry.cjs (byLoopPoint, consumed at call time)
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import core = require('./core.cjs');
|
||||
const { output: coreOutput, error: coreError } = core;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import configLoaderModule = require('./config-loader.cjs');
|
||||
const { loadConfig } = configLoaderModule;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningWorkspaceMod = require('./planning-workspace.cjs');
|
||||
const { planningDir, planningRoot } = planningWorkspaceMod;
|
||||
|
||||
// ─── Canonical points (derived from LOOP_HOST_CONTRACT — authoritative 12) ───
|
||||
|
||||
// FIX 2: Derive the authoritative canonical set from LOOP_HOST_CONTRACT so it
|
||||
// cannot drift from the host contract. CANONICAL_POINTS_FALLBACK is kept as an
|
||||
// alias for backward compatibility in tests and exports.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const _loopHostContract = require('./loop-host-contract.cjs') as { LOOP_HOST_CONTRACT: Array<{ points: string[] }> };
|
||||
const CANONICAL_POINTS: ReadonlyArray<string> = (() => {
|
||||
try {
|
||||
const contract = _loopHostContract.LOOP_HOST_CONTRACT;
|
||||
if (Array.isArray(contract)) {
|
||||
const pts: string[] = [];
|
||||
for (const step of contract) {
|
||||
if (step && Array.isArray(step.points)) {
|
||||
for (const p of step.points) {
|
||||
if (typeof p === 'string') pts.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pts.length > 0) return pts;
|
||||
}
|
||||
} catch { /* fall through to hardcoded fallback */ }
|
||||
return [
|
||||
'discuss:pre',
|
||||
'discuss:post',
|
||||
'plan:pre',
|
||||
'plan:post',
|
||||
'execute:pre',
|
||||
'execute:wave:pre',
|
||||
'execute:wave:post',
|
||||
'execute:post',
|
||||
'verify:pre',
|
||||
'verify:post',
|
||||
'ship:pre',
|
||||
'ship:post',
|
||||
];
|
||||
})();
|
||||
|
||||
// Alias for backward compatibility (tests import this name)
|
||||
const CANONICAL_POINTS_FALLBACK: ReadonlyArray<string> = CANONICAL_POINTS;
|
||||
|
||||
// FIX 2: _getCanonicalPoints now returns the authoritative CANONICAL_POINTS set
|
||||
// derived from LOOP_HOST_CONTRACT — not the registry's byLoopPoint keys.
|
||||
// The registry's byLoopPoint is only used to READ hooks, not to define valid points.
|
||||
function _getCanonicalPoints(_registry: Record<string, unknown>): ReadonlyArray<string> {
|
||||
return CANONICAL_POINTS;
|
||||
}
|
||||
|
||||
// ─── Prototype-pollution guard (inline literal, CodeQL barrier) ───────────────
|
||||
|
||||
/**
|
||||
* Traverse a dotted config key through a nested config object.
|
||||
* E.g. "workflow.ui_phase" in { workflow: { ui_phase: true } } → { found: true, value: true }
|
||||
* Returns { found: false } if any segment is a forbidden key or not an own property.
|
||||
*/
|
||||
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) {
|
||||
// Inline literal prototype-pollution guard (CodeQL barrier)
|
||||
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 };
|
||||
}
|
||||
|
||||
// ─── Single-key activation resolver (FIX 1) ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* Warn-once set for raw config.json parse errors.
|
||||
* Avoids noisy per-call stderr from a single malformed file.
|
||||
*/
|
||||
const _warnedRawConfigPaths = new Set<string>();
|
||||
|
||||
/**
|
||||
* Read a raw config.json file and perform a guarded nested-lookup of a single
|
||||
* dotted key. Returns { found: false } if the file is missing (ENOENT) or if
|
||||
* the key is absent/forbidden. On a genuine JSON parse error: warns once to
|
||||
* stderr and returns { found: false } — never throws.
|
||||
*/
|
||||
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 {
|
||||
// ENOENT (missing file) is expected → skip silently. All other errors → also skip (defensive).
|
||||
return { found: false, value: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FIX 1: Resolve the effective value for a hook's `when` key using the
|
||||
* four-level precedence:
|
||||
*
|
||||
* 1. loadConfig result (`config` arg) — guarded nested-lookup of the dotted key.
|
||||
* This is the post-cutover federated path (covers keys that loadConfig now exposes).
|
||||
* 2. Raw workstream `.planning/.../config.json` — guarded single-key lookup.
|
||||
* Workstream wins over root (mirrors loadConfig inheritance).
|
||||
* 3. Raw root `.planning/config.json` — guarded single-key lookup.
|
||||
* 4. `registry.configSchema[when]?.default` — schema default.
|
||||
* A `default: true` hook is active out-of-the-box without any config.
|
||||
* 5. Absent → inactive (return false).
|
||||
*
|
||||
* Never constructs a merged object from raw JSON keys — only reads the single
|
||||
* leaf value at the guarded dotted path. Prototype-pollution sink is eliminated.
|
||||
*/
|
||||
function _resolveActivationValue(
|
||||
dotKey: string,
|
||||
config: Record<string, unknown>,
|
||||
cwd: string | undefined,
|
||||
registry: Record<string, unknown>,
|
||||
): boolean {
|
||||
// Level 1: loadConfig result
|
||||
const fromConfig = _getNestedConfigValue(config, dotKey);
|
||||
if (fromConfig.found) return Boolean(fromConfig.value);
|
||||
|
||||
// Level 2 + 3: raw config.json files (only when cwd is available)
|
||||
if (cwd) {
|
||||
// Level 2: workstream config (planningDir respects GSD_WORKSTREAM env)
|
||||
const wsConfigPath = path.join(planningDir(cwd), 'config.json');
|
||||
// Level 3: root config (planningRoot = cwd/.planning always)
|
||||
const rootConfigPath = path.join(planningRoot(cwd), 'config.json');
|
||||
|
||||
// Workstream wins over root (mirroring loadConfig root→workstream precedence:
|
||||
// workstream overlays root, so workstream value takes precedence).
|
||||
const fromWs = _readRawConfigKey(wsConfigPath, dotKey);
|
||||
if (fromWs.found) return Boolean(fromWs.value);
|
||||
|
||||
// Only read root if it differs from the workstream path (avoids double-read
|
||||
// when no workstream is active and both paths resolve to the same file).
|
||||
if (wsConfigPath !== rootConfigPath) {
|
||||
const fromRoot = _readRawConfigKey(rootConfigPath, dotKey);
|
||||
if (fromRoot.found) return Boolean(fromRoot.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Level 4: registry configSchema default
|
||||
const schemaEntry = (registry['configSchema'] as Record<string, unknown> | undefined)?.[dotKey];
|
||||
if (schemaEntry && typeof schemaEntry === 'object' && schemaEntry !== null) {
|
||||
const def = (schemaEntry as Record<string, unknown>)['default'];
|
||||
if (def !== undefined) return Boolean(def);
|
||||
}
|
||||
|
||||
// Level 5: absent → inactive
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface HookRef {
|
||||
skill?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface RawHook {
|
||||
capId?: unknown;
|
||||
point?: unknown;
|
||||
ref?: unknown;
|
||||
into?: unknown;
|
||||
produces?: unknown;
|
||||
consumes?: unknown;
|
||||
when?: unknown;
|
||||
onError?: unknown;
|
||||
blocking?: unknown;
|
||||
check?: unknown;
|
||||
}
|
||||
|
||||
type HookKind = 'step' | 'contribution' | 'gate';
|
||||
|
||||
interface ActiveHook {
|
||||
capId: string;
|
||||
kind: HookKind;
|
||||
ref?: HookRef;
|
||||
into?: string;
|
||||
when?: string;
|
||||
produces?: string[];
|
||||
consumes?: string[];
|
||||
blocking?: boolean;
|
||||
check?: unknown;
|
||||
onError?: string;
|
||||
}
|
||||
|
||||
interface ResolveLoopHooksInput {
|
||||
point: string;
|
||||
registry: Record<string, unknown>;
|
||||
config: Record<string, unknown>;
|
||||
/** Optional cwd — enables raw config.json fallback reads (FIX 1 precedence level 2). */
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
interface ResolveLoopHooksResult {
|
||||
point: string;
|
||||
activeHooks: ActiveHook[];
|
||||
}
|
||||
|
||||
// ─── Pure resolver ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pure resolver: given a point, registry, and config, returns the active hooks.
|
||||
*
|
||||
* Throws if `point` is not one of the 12 canonical points (caller converts to
|
||||
* core.error). Never throws for malformed registry/hook entries — skips and
|
||||
* continues.
|
||||
*
|
||||
* Ordering: steps first, then contributions, then gates. Within each array,
|
||||
* the materialized registry order is preserved.
|
||||
*
|
||||
* Activation: a hook with no `when` is always active. With `when` (dotted key),
|
||||
* resolved against `config`; active iff truthy. Inactive hooks are filtered out.
|
||||
*/
|
||||
function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult {
|
||||
const { point, registry, config, cwd } = input;
|
||||
|
||||
// Validate point
|
||||
const canonicalPoints = _getCanonicalPoints(registry);
|
||||
if (!canonicalPoints.includes(point)) {
|
||||
throw new Error(
|
||||
`Invalid loop point: "${point}". Valid points: ${canonicalPoints.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Guard: registry missing byLoopPoint
|
||||
const byLoopPoint = registry['byLoopPoint'];
|
||||
if (!byLoopPoint || typeof byLoopPoint !== 'object' || Array.isArray(byLoopPoint)) {
|
||||
return { point, activeHooks: [] };
|
||||
}
|
||||
const byLoopPointMap = byLoopPoint as Record<string, unknown>;
|
||||
|
||||
// Guard: point missing in registry
|
||||
const entry = byLoopPointMap[point];
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
return { point, activeHooks: [] };
|
||||
}
|
||||
const entryMap = entry as Record<string, unknown>;
|
||||
|
||||
const activeHooks: ActiveHook[] = [];
|
||||
|
||||
// Helper: check activation using single-key precedence resolver (FIX 1 + FIX 3)
|
||||
function isActive(hook: RawHook): boolean {
|
||||
const when = hook['when'];
|
||||
// No `when` → unconditional hook, always active
|
||||
if (when === undefined || when === null) return true;
|
||||
// FIX 3: `when` present but not a non-empty string → malformed registry data → INACTIVE
|
||||
if (typeof when !== 'string' || when.length === 0) return false;
|
||||
return _resolveActivationValue(when, config, cwd, registry);
|
||||
}
|
||||
|
||||
// Helper: safe string array
|
||||
function toStringArray(v: unknown): string[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.filter((x): x is string => typeof x === 'string');
|
||||
}
|
||||
|
||||
// Process steps
|
||||
const stepsRaw = entryMap['steps'];
|
||||
const steps: RawHook[] = Array.isArray(stepsRaw) ? (stepsRaw as RawHook[]) : [];
|
||||
for (const hook of steps) {
|
||||
if (!hook || typeof hook !== 'object') continue;
|
||||
if (!isActive(hook)) continue;
|
||||
const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
|
||||
const ref = (typeof hook['ref'] === 'object' && hook['ref'] !== null)
|
||||
? (hook['ref'] as HookRef)
|
||||
: undefined;
|
||||
const when = typeof hook['when'] === 'string' ? hook['when'] : undefined;
|
||||
const produces = toStringArray(hook['produces']);
|
||||
const consumes = toStringArray(hook['consumes']);
|
||||
const onError = typeof hook['onError'] === 'string' ? hook['onError'] : undefined;
|
||||
const active: ActiveHook = { capId, kind: 'step' };
|
||||
if (ref !== undefined) active.ref = ref;
|
||||
if (when !== undefined) active.when = when;
|
||||
if (produces.length > 0) active.produces = produces;
|
||||
if (consumes.length > 0) active.consumes = consumes;
|
||||
if (onError !== undefined) active.onError = onError;
|
||||
activeHooks.push(active);
|
||||
}
|
||||
|
||||
// Process contributions
|
||||
const contributionsRaw = entryMap['contributions'];
|
||||
const contributions: RawHook[] = Array.isArray(contributionsRaw) ? (contributionsRaw as RawHook[]) : [];
|
||||
for (const hook of contributions) {
|
||||
if (!hook || typeof hook !== 'object') continue;
|
||||
if (!isActive(hook)) continue;
|
||||
const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
|
||||
const into = typeof hook['into'] === 'string' ? hook['into'] : undefined;
|
||||
const when = typeof hook['when'] === 'string' ? hook['when'] : undefined;
|
||||
const produces = toStringArray(hook['produces']);
|
||||
const consumes = toStringArray(hook['consumes']);
|
||||
const onError = typeof hook['onError'] === 'string' ? hook['onError'] : undefined;
|
||||
const active: ActiveHook = { capId, kind: 'contribution' };
|
||||
if (into !== undefined) active.into = into;
|
||||
if (when !== undefined) active.when = when;
|
||||
if (produces.length > 0) active.produces = produces;
|
||||
if (consumes.length > 0) active.consumes = consumes;
|
||||
if (onError !== undefined) active.onError = onError;
|
||||
activeHooks.push(active);
|
||||
}
|
||||
|
||||
// Process gates
|
||||
const gatesRaw = entryMap['gates'];
|
||||
const gates: RawHook[] = Array.isArray(gatesRaw) ? (gatesRaw as RawHook[]) : [];
|
||||
for (const hook of gates) {
|
||||
if (!hook || typeof hook !== 'object') continue;
|
||||
if (!isActive(hook)) continue;
|
||||
const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
|
||||
const when = typeof hook['when'] === 'string' ? hook['when'] : undefined;
|
||||
const check = hook['check'] !== undefined ? hook['check'] : undefined;
|
||||
const blocking = typeof hook['blocking'] === 'boolean' ? hook['blocking'] : undefined;
|
||||
const onError = typeof hook['onError'] === 'string' ? hook['onError'] : undefined;
|
||||
const active: ActiveHook = { capId, kind: 'gate' };
|
||||
if (when !== undefined) active.when = when;
|
||||
if (check !== undefined) active.check = check;
|
||||
if (blocking !== undefined) active.blocking = blocking;
|
||||
if (onError !== undefined) active.onError = onError;
|
||||
activeHooks.push(active);
|
||||
}
|
||||
|
||||
return { point, activeHooks };
|
||||
}
|
||||
|
||||
// ─── Pure renderer ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pure renderer: given a resolved result, returns a deterministic markdown string.
|
||||
*
|
||||
* Empty active set → returns a "no active hooks" placeholder line.
|
||||
* Steps: heading with ordinal + skill ref + capId, produces/consumes lines.
|
||||
* Contributions: labeled block.
|
||||
* Gates: check name, blocking flag, onError.
|
||||
*/
|
||||
function renderLoopHooks(resolved: ResolveLoopHooksResult): string {
|
||||
const { point, activeHooks } = resolved;
|
||||
|
||||
if (activeHooks.length === 0) {
|
||||
return `_No active hooks at ${point}._`;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
let stepOrdinal = 0;
|
||||
|
||||
for (const hook of activeHooks) {
|
||||
if (hook.kind === 'step') {
|
||||
stepOrdinal += 1;
|
||||
const refStr = hook.ref?.skill
|
||||
? `skill:${hook.ref.skill}`
|
||||
: JSON.stringify(hook.ref ?? {});
|
||||
lines.push(`### Step ${stepOrdinal}: ${refStr} (${hook.capId})`);
|
||||
if (hook.produces && hook.produces.length > 0) {
|
||||
lines.push(`- produces: ${hook.produces.join(', ')}`);
|
||||
}
|
||||
if (hook.consumes && hook.consumes.length > 0) {
|
||||
lines.push(`- consumes: ${hook.consumes.join(', ')}`);
|
||||
}
|
||||
if (hook.when) {
|
||||
lines.push(`- when: \`${hook.when}\``);
|
||||
}
|
||||
if (hook.onError) {
|
||||
lines.push(`- onError: ${hook.onError}`);
|
||||
}
|
||||
lines.push('');
|
||||
} else if (hook.kind === 'contribution') {
|
||||
lines.push(`<contribution from="${hook.capId}" into="${hook.into ?? '(unset)'}"/>`);
|
||||
if (hook.produces && hook.produces.length > 0) {
|
||||
lines.push(`- produces: ${hook.produces.join(', ')}`);
|
||||
}
|
||||
if (hook.consumes && hook.consumes.length > 0) {
|
||||
lines.push(`- consumes: ${hook.consumes.join(', ')}`);
|
||||
}
|
||||
if (hook.when) {
|
||||
lines.push(`- when: \`${hook.when}\``);
|
||||
}
|
||||
lines.push('');
|
||||
} else if (hook.kind === 'gate') {
|
||||
let checkStr = '(none)';
|
||||
if (hook.check !== undefined && hook.check !== null) {
|
||||
checkStr = typeof hook.check === 'object'
|
||||
? JSON.stringify(hook.check)
|
||||
: typeof hook.check === 'string' || typeof hook.check === 'number' || typeof hook.check === 'boolean'
|
||||
? String(hook.check)
|
||||
: '(complex)';
|
||||
}
|
||||
lines.push(`**Gate** (${hook.capId}): check=${checkStr}, blocking=${String(hook.blocking ?? false)}, onError=${hook.onError ?? 'skip'}`);
|
||||
if (hook.when) {
|
||||
lines.push(`- when: \`${hook.when}\``);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// Trim trailing blank line
|
||||
while (lines.length > 0 && lines[lines.length - 1] === '') {
|
||||
lines.pop();
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ─── I/O command handler ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Command entry point: load registry + config, resolve + render, emit envelope.
|
||||
*
|
||||
* Envelope: { point, activeHooks, rendered }
|
||||
* On invalid point, emits core.error instead of throwing.
|
||||
*
|
||||
* Config note: FIX 1 replaced _loadMergedConfig (whole-config deep-merge) with a
|
||||
* per-hook single-key activation resolver (_resolveActivationValue). The resolver
|
||||
* checks loadConfig result first, then raw config.json files directly (workstream
|
||||
* then root), then the registry's configSchema default. This eliminates the
|
||||
* merged-object-from-untrusted-keys security concern and correctly handles
|
||||
* pre-cutover keys like `workflow.ui_phase` that live in config.json but are not
|
||||
* yet exposed through loadConfig's whitelist.
|
||||
*/
|
||||
function cmdLoopRenderHooks(
|
||||
cwd: string,
|
||||
point: string,
|
||||
raw: boolean,
|
||||
_options: Record<string, unknown> = {},
|
||||
): void {
|
||||
if (!point) {
|
||||
coreError('loop render-hooks requires a <point> argument. Valid points: ' + CANONICAL_POINTS.join(', '));
|
||||
return;
|
||||
}
|
||||
|
||||
// Load registry at call time (generated file, not at module load time)
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const registry = require('./capability-registry.cjs') as Record<string, unknown>;
|
||||
// FIX 1: Pass loadConfig result as `config` (level 1 of precedence);
|
||||
// raw config.json reads (levels 2+3) happen per-hook inside _resolveActivationValue
|
||||
// via the `cwd` argument passed to resolveLoopHooks.
|
||||
const config = loadConfig(cwd);
|
||||
|
||||
let resolved: ResolveLoopHooksResult;
|
||||
try {
|
||||
resolved = resolveLoopHooks({ point, registry, config, cwd });
|
||||
} catch (err: unknown) {
|
||||
const msg = (err instanceof Error) ? err.message : String(err);
|
||||
coreError(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
const rendered = renderLoopHooks(resolved);
|
||||
const envelope = {
|
||||
point: resolved.point,
|
||||
activeHooks: resolved.activeHooks,
|
||||
rendered,
|
||||
};
|
||||
|
||||
coreOutput(envelope, raw);
|
||||
}
|
||||
|
||||
export = {
|
||||
resolveLoopHooks,
|
||||
renderLoopHooks,
|
||||
cmdLoopRenderHooks,
|
||||
// Exported for tests
|
||||
_getNestedConfigValue,
|
||||
_resolveActivationValue,
|
||||
_readRawConfigKey,
|
||||
CANONICAL_POINTS_FALLBACK,
|
||||
CANONICAL_POINTS,
|
||||
};
|
||||
Reference in New Issue
Block a user