Merge branch 'next' into feat/1279-fail-first-prover
This commit is contained in:
@@ -62,39 +62,71 @@ function _readRawConfigKey(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 fromConfig = _getNestedConfigValue(config, dotKey);
|
||||
if (fromConfig.found) return Boolean(fromConfig.value);
|
||||
|
||||
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 Boolean(fromWs.value);
|
||||
|
||||
if (wsConfigPath !== rootConfigPath) {
|
||||
const fromRoot = _readRawConfigKey(rootConfigPath, dotKey);
|
||||
if (fromRoot.found) return Boolean(fromRoot.value);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return false;
|
||||
const r = resolveConfigKey(dotKey, { config, cwd, registry });
|
||||
return r.found ? Boolean(r.value) : false;
|
||||
}
|
||||
|
||||
export = {
|
||||
_getNestedConfigValue,
|
||||
_readRawConfigKey,
|
||||
_resolveActivationValue,
|
||||
resolveConfigKey,
|
||||
};
|
||||
|
||||
@@ -128,8 +128,6 @@ interface ResolveCapabilityStateResult {
|
||||
interface ResolveCapabilityRuntimeStateResult {
|
||||
runtimeConfigDir: string;
|
||||
warnings: string[];
|
||||
registry: Record<string, unknown>;
|
||||
config: Record<string, unknown>;
|
||||
capabilities: CapabilityStateEntry[];
|
||||
}
|
||||
|
||||
@@ -430,6 +428,7 @@ function _resolveManifest(commandsGsdDir: string, configDir: string): Map<string
|
||||
function resolveCapabilityRuntimeState(
|
||||
cwd: string,
|
||||
runtimeConfigDir: string | undefined | null,
|
||||
configOverride?: Record<string, unknown>,
|
||||
): ResolveCapabilityRuntimeStateResult {
|
||||
const warnings: string[] = [];
|
||||
|
||||
@@ -520,11 +519,18 @@ function resolveCapabilityRuntimeState(
|
||||
}
|
||||
|
||||
// ── Load config ───────────────────────────────────────────────────────────────
|
||||
// When the caller already holds a loadConfig snapshot (e.g. cmdLoopRenderHooks),
|
||||
// accept it via configOverride so capability `active` and hook resolution
|
||||
// share the SAME config object — single snapshot, no TOCTOU window.
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
config = loadConfig(cwd);
|
||||
} catch {
|
||||
config = {};
|
||||
if (configOverride !== undefined) {
|
||||
config = configOverride;
|
||||
} else {
|
||||
try {
|
||||
config = loadConfig(cwd);
|
||||
} catch {
|
||||
config = {};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resolve state ────────────────────────────────────────────────────────────
|
||||
@@ -540,8 +546,6 @@ function resolveCapabilityRuntimeState(
|
||||
return {
|
||||
runtimeConfigDir: resolvedConfigDir,
|
||||
warnings,
|
||||
registry,
|
||||
config,
|
||||
capabilities: result.capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -129,7 +129,8 @@ function setCapabilityState(
|
||||
const resolvedConfigDir = before.runtimeConfigDir;
|
||||
|
||||
// ── Load registry ─────────────────────────────────────────────────────────
|
||||
const registry = before.registry;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const registry = require('./capability-registry.cjs') as Record<string, unknown>;
|
||||
const capabilitiesMap = (
|
||||
registry['capabilities'] && typeof registry['capabilities'] === 'object' && !Array.isArray(registry['capabilities'])
|
||||
? registry['capabilities']
|
||||
|
||||
@@ -20,17 +20,14 @@
|
||||
* registry/config arguments so they are trivially testable without I/O.
|
||||
*
|
||||
* Dependencies (leaf modules only — no 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)
|
||||
* - ./io.cjs (output, error)
|
||||
* - ./capability-activation.cjs (resolveConfigKey, _resolveActivationValue, _getNestedConfigValue, _readRawConfigKey)
|
||||
* - loop-host-contract.cjs (CANONICAL_POINTS via LOOP_HOST_CONTRACT)
|
||||
* - capability-registry.cjs (byLoopPoint, consumed at call time)
|
||||
* - capability-state.cjs (resolveCapabilityRuntimeState — for capabilities list)
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import ioMod = require('./io.cjs');
|
||||
const { output: coreOutput, error: coreError } = ioMod;
|
||||
@@ -43,9 +40,10 @@ const { loadConfig } = configLoaderModule;
|
||||
import capabilityStateModule = require('./capability-state.cjs');
|
||||
const { resolveCapabilityRuntimeState } = capabilityStateModule;
|
||||
|
||||
// ─── Capability-activation engine (single owner for config-key precedence) ────
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningWorkspaceMod = require('./planning-workspace.cjs');
|
||||
const { planningDir, planningRoot } = planningWorkspaceMod;
|
||||
import capabilityActivationModule = require('./capability-activation.cjs');
|
||||
const { _getNestedConfigValue, _readRawConfigKey, _resolveActivationValue, resolveConfigKey } = capabilityActivationModule;
|
||||
|
||||
// ─── Canonical points (derived from LOOP_HOST_CONTRACT — authoritative 12) ───
|
||||
|
||||
@@ -95,133 +93,7 @@ function _getCanonicalPoints(_registry: Record<string, unknown>): ReadonlyArray<
|
||||
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;
|
||||
}
|
||||
// ─── (Precedence engine imported from capability-activation.cjs above) ────────
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -383,27 +255,8 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
|
||||
// Prototype-pollution guard (inline literal, CodeQL barrier)
|
||||
if (alias === '__proto__' || alias === 'constructor' || alias === 'prototype') continue;
|
||||
if (typeof dotKey !== 'string') continue;
|
||||
// Level 1: loadConfig result
|
||||
const fromConfig = _getNestedConfigValue(config, dotKey);
|
||||
if (fromConfig.found) { resolved[alias] = fromConfig.value; continue; }
|
||||
// Level 2 + 3: raw config.json files
|
||||
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) { resolved[alias] = fromWs.value; continue; }
|
||||
if (wsConfigPath !== rootConfigPath) {
|
||||
const fromRoot = _readRawConfigKey(rootConfigPath, dotKey);
|
||||
if (fromRoot.found) { resolved[alias] = fromRoot.value; continue; }
|
||||
}
|
||||
}
|
||||
// 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) { resolved[alias] = def; continue; }
|
||||
}
|
||||
// Level 5: absent → undefined (omit from resolved map)
|
||||
const r = resolveConfigKey(dotKey, { config, cwd, registry });
|
||||
if (r.found) resolved[alias] = r.value;
|
||||
}
|
||||
return Object.keys(resolved).length > 0 ? resolved : undefined;
|
||||
}
|
||||
@@ -622,14 +475,26 @@ function cmdLoopRenderHooks(
|
||||
const runtimeConfigDir = typeof options['configDir'] === 'string'
|
||||
? options['configDir']
|
||||
: undefined;
|
||||
const state = resolveCapabilityRuntimeState(cwd, runtimeConfigDir) as {
|
||||
// Load the config snapshot ONCE and share it with both the capability-state
|
||||
// resolver (via configOverride) and loop-hook resolution, so federated keys
|
||||
// present in loadConfig resolve identically for `active` and for hook when/
|
||||
// configValues — eliminating the previous double loadConfig() call. Note: keys
|
||||
// absent from loadConfig still fall through to raw .planning/config.json reads
|
||||
// (precedence levels 2-3) in each pass; that residual re-read window is
|
||||
// pre-existing (unchanged by this consolidation), not introduced here.
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
config = loadConfig(cwd);
|
||||
} catch {
|
||||
config = {};
|
||||
}
|
||||
const state = resolveCapabilityRuntimeState(cwd, runtimeConfigDir, config) as {
|
||||
warnings?: string[];
|
||||
registry: Record<string, unknown>;
|
||||
config: Record<string, unknown>;
|
||||
capabilities: Array<{ id: string; enabled?: boolean; active: boolean }>;
|
||||
};
|
||||
const registry = state.registry;
|
||||
const config = state.config || loadConfig(cwd);
|
||||
// Registry is the static generated module — same object capability-state uses internally.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const registry = require('./capability-registry.cjs') as Record<string, unknown>;
|
||||
const capabilityStatesById = new Map<string, { enabled?: boolean; active: boolean }>();
|
||||
for (const cap of state.capabilities || []) {
|
||||
capabilityStatesById.set(cap.id, cap);
|
||||
@@ -677,6 +542,9 @@ export = {
|
||||
_getNestedConfigValue,
|
||||
_resolveActivationValue,
|
||||
_readRawConfigKey,
|
||||
// Re-exported for identity parity guard (FIX 2: resolveConfigValues in this module
|
||||
// calls resolveConfigKey; exporting it here makes the single-owner contract testable).
|
||||
resolveConfigKey,
|
||||
CANONICAL_POINTS_FALLBACK,
|
||||
CANONICAL_POINTS,
|
||||
};
|
||||
|
||||
@@ -216,6 +216,33 @@ interface RoadmapPhaseResult {
|
||||
section: string;
|
||||
}
|
||||
|
||||
function findRoadmapPhaseInContent(content: string, phaseNum: unknown): RoadmapPhaseResult | null {
|
||||
const phasePattern = new RegExp(
|
||||
`#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`,
|
||||
'i'
|
||||
);
|
||||
const headerMatch = content.match(phasePattern);
|
||||
if (!headerMatch) return null;
|
||||
|
||||
const phaseName = headerMatch[1].trim();
|
||||
const headerIndex = headerMatch.index!;
|
||||
const restOfContent = content.slice(headerIndex);
|
||||
const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i);
|
||||
const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index! : content.length;
|
||||
const section = content.slice(headerIndex, sectionEnd).trim();
|
||||
|
||||
const goalMatch = section.match(/\*\*Goal(?:\*\*:|\*?\*?:\*\*)\s*([^\n]+)/i);
|
||||
const goal = goalMatch ? goalMatch[1].trim() : null;
|
||||
|
||||
return {
|
||||
found: true,
|
||||
phase_number: String(phaseNum),
|
||||
phase_name: phaseName,
|
||||
goal,
|
||||
section,
|
||||
};
|
||||
}
|
||||
|
||||
function getRoadmapPhaseInternal(cwd: string, phaseNum: unknown): RoadmapPhaseResult | null {
|
||||
if (!phaseNum) return null;
|
||||
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
||||
@@ -225,31 +252,10 @@ function getRoadmapPhaseInternal(cwd: string, phaseNum: unknown): RoadmapPhaseRe
|
||||
const roadmapRaw = platformReadSync(roadmapPath);
|
||||
if (roadmapRaw === null) throw new Error('missing');
|
||||
const content = extractCurrentMilestone(roadmapRaw, cwd);
|
||||
const phasePattern = new RegExp(
|
||||
`#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`,
|
||||
'i'
|
||||
);
|
||||
const headerMatch = content.match(phasePattern);
|
||||
if (!headerMatch) return null;
|
||||
const scopedResult = findRoadmapPhaseInContent(content, phaseNum);
|
||||
if (scopedResult) return scopedResult;
|
||||
|
||||
const phaseName = headerMatch[1].trim();
|
||||
const headerIndex = headerMatch.index!;
|
||||
const restOfContent = content.slice(headerIndex);
|
||||
const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i);
|
||||
const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index! : content.length;
|
||||
const section = content.slice(headerIndex, sectionEnd).trim();
|
||||
|
||||
const goalMatch = section.match(/\*\*Goal(?:\*\*:|\*?\*?:\*\*)\s*([^\n]+)/i);
|
||||
const goal = goalMatch ? goalMatch[1].trim() : null;
|
||||
|
||||
return {
|
||||
found: true,
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
phase_number: String(phaseNum),
|
||||
phase_name: phaseName,
|
||||
goal,
|
||||
section,
|
||||
};
|
||||
return findRoadmapPhaseInContent(stripShippedMilestones(roadmapRaw), phaseNum);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
308
src/verify.cts
308
src/verify.cts
@@ -253,6 +253,310 @@ function scanNegativeGrepCommentEcho(content: string): { errors: string[]; warni
|
||||
return { errors, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue #968 — file-wide negative-grep sibling conflict detector.
|
||||
* A file-wide negative grep gate (! grep -Eq 'PAT' FILE or grep -c 'PAT' FILE == 0)
|
||||
* bans a construct across the WHOLE file. When a sibling task in the same plan
|
||||
* legitimately requires the same construct in the same file, the two gates are
|
||||
* mutually unsatisfiable. This is a WARN-only check (never changes valid:false).
|
||||
*/
|
||||
function scanFileWideNegativeGateConflict(content: string): { warnings: string[]; valid: true } {
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Normalize newlines; join backslash line-continuations (same as #429).
|
||||
const text = (content || '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.replace(/\\\n/g, ' ');
|
||||
|
||||
// Allowlisted patterns: <!-- planner-region-allow: PAT -->
|
||||
const allow = new Set<string>();
|
||||
const allowRe = /<!--\s*planner-region-allow:\s*(.+?)\s*-->/g;
|
||||
let am: RegExpExecArray | null;
|
||||
while ((am = allowRe.exec(text)) !== null) allow.add(am[1]);
|
||||
|
||||
// Helper predicates (reused from #429 style).
|
||||
// Zero-equality comparison: spaced == 0 or -eq 0.
|
||||
const zeroCmp = (s: string): boolean =>
|
||||
/\s==?\s*0\b/.test(s) || /-eq\s+0\b/.test(s) || /\bequals\s+0\b/.test(s);
|
||||
|
||||
// grep options include -c / --count
|
||||
const optsHaveCount = (opts: string): boolean =>
|
||||
/(?:^|\s)-[A-Za-z]*c[A-Za-z]*(?=\s|$)/.test(opts) || /--count\b/.test(opts);
|
||||
|
||||
// grep options include -v / --invert-match (inverted count is NOT a negative gate)
|
||||
const optsHaveInvert = (opts: string): boolean =>
|
||||
/(?:^|\s)-[A-Za-z]*v[A-Za-z]*(?=\s|$)/.test(opts) || /--invert-match\b/.test(opts);
|
||||
|
||||
// A bareword that is a plausible grep pattern (not a stray flag/number).
|
||||
const plausibleBare = (s: string): boolean => /[A-Za-z0-9_]/.test(s) && !/^[-=!<>0-9]+$/.test(s);
|
||||
|
||||
// Regex to extract grep arguments: opts run then PAT (quoted or bare).
|
||||
const grepArgRe =
|
||||
/grep((?:\s+-{1,2}[A-Za-z][A-Za-z-]*)+)\s+(?:'([^']*)'|"([^"]*)"|([^\s'"|>&;$()\[\]]+))/g;
|
||||
|
||||
// FIX 1 (ReDoS): Linear-time "does reqText satisfy the grep pattern" — no RegExp execution.
|
||||
// Never calls new RegExp, so no catastrophic backtracking is possible.
|
||||
//
|
||||
// Handles literal patterns and `.`/`.*/`.+`/`\s`-style wildcard gaps and `^`/`$` anchors.
|
||||
// Patterns using character classes (`[…]`), alternation (`a|b`), or other regex constructs
|
||||
// fall back to a conservative literal-substring check, so the detector may NOT warn on those
|
||||
// (false-negative is the safe direction for a warn-only advisory).
|
||||
const patternRequiredIn = (pat: string, reqText: string): boolean => {
|
||||
const hay = (reqText || '').slice(0, 8000); // bound the haystack
|
||||
if (!pat) return false;
|
||||
// Strip ERE anchors — position constraints don't change whether the construct is required.
|
||||
pat = pat.replace(/^\^/, '').replace(/\$$/, '');
|
||||
if (!pat) return false;
|
||||
// Pure literal (no regex metacharacters): direct substring.
|
||||
if (!/[.*+?^${}()|[\]\\]/.test(pat)) return hay.includes(pat);
|
||||
const SENT = ' ';
|
||||
// Replace simple wildcard gaps (\s* \w+ .* .+ .? bare .) with a sentinel.
|
||||
let work = pat
|
||||
.replace(/\\[sSwWdD][*+?]?/g, SENT)
|
||||
.replace(/\.[*+?]/g, SENT)
|
||||
.replace(/\./g, SENT);
|
||||
work = work.replace(/\\(.)/g, '$1'); // de-escape \( \. etc → literal char
|
||||
const joined = work.split(SENT).join('');
|
||||
// Unhandled regex constructs remain → safe literal-substring fallback on the raw pattern.
|
||||
if (/[*+?^${}()|[\]]/.test(joined)) return hay.includes(pat);
|
||||
const frags = work.split(SENT).filter(Boolean);
|
||||
if (!frags.length) return false; // all-wildcard pattern → no meaningful requirement
|
||||
let pos = 0;
|
||||
for (const f of frags) {
|
||||
const idx = hay.indexOf(f, pos);
|
||||
if (idx === -1) return false;
|
||||
pos = idx + f.length;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// FIX 2 (file basename over-match): exact normalized match; basename fallback ONLY for
|
||||
// unqualified gate files (no path separator).
|
||||
const normPath = (p: string): string => p.replace(/^\.\//, '').trim();
|
||||
|
||||
// File-wide discriminator: a token AFTER PAT that looks like a path.
|
||||
// Paths have /, a file extension, or match a known task <files> entry.
|
||||
// Globs (containing *) are excluded (unresolvable — no warn).
|
||||
const looksLikePath = (token: string): boolean =>
|
||||
!token.includes('*') &&
|
||||
(token.includes('/') || /\.[a-zA-Z]{1,6}$/.test(token));
|
||||
|
||||
// FIX 5 (hasLeadingNot): collapse to one command-boundary-anchored regex.
|
||||
// Negation at a command boundary: start of segment, or after ; & | ( newline / then / do.
|
||||
|
||||
// FIX 4 (isRegionScoped tightened): return true ONLY when grep is downstream of a
|
||||
// sed line-range or awk range producer. Other pipe sources (cat, tac, etc.) are file-wide.
|
||||
const isRegionScoped = (seg: string): boolean => {
|
||||
if (!seg.includes('|')) return false;
|
||||
const before = seg.slice(0, seg.lastIndexOf('|'));
|
||||
// sed -n line/range extraction, e.g. sed -n '12,40p' FILE or sed -n '/a/,/b/p' FILE
|
||||
if (/\bsed\s+-n\b/.test(before)) return true;
|
||||
// awk range pattern, e.g. awk '/start/,/end/' FILE
|
||||
if (/\bawk\b[^|]*\/[^/]*\/\s*,\s*\/[^/]*\//.test(before)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
// Parse all <task> blocks.
|
||||
interface TaskInfo {
|
||||
name: string;
|
||||
files: string[]; // entries from <files>
|
||||
gateText: string; // <verify>+<automated>+<acceptance_criteria> text
|
||||
reqText: string; // <action>+<acceptance_criteria> text (requirement side)
|
||||
}
|
||||
const taskRe = /<task[^>]*>([\s\S]*?)<\/task>/g;
|
||||
const tasks: TaskInfo[] = [];
|
||||
let tm: RegExpExecArray | null;
|
||||
while ((tm = taskRe.exec(text)) !== null) {
|
||||
const tc = tm[1];
|
||||
// Extract task name.
|
||||
const namem = tc.match(/<name>([\s\S]*?)<\/name>/);
|
||||
const name = namem ? namem[1].trim() : 'unnamed';
|
||||
// Extract <files> entries.
|
||||
const filesm = tc.match(/<files>([\s\S]*?)<\/files>/);
|
||||
const filesText = filesm ? filesm[1] : '';
|
||||
const files = filesText.split(/[,\s]+/).map(s => s.trim()).filter(Boolean);
|
||||
// Gate text: <verify>/<automated>/<acceptance_criteria>.
|
||||
const gateFragments: string[] = [];
|
||||
for (const tag of ['verify', 'automated', 'acceptance_criteria']) {
|
||||
const re = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'g');
|
||||
let mm: RegExpExecArray | null;
|
||||
while ((mm = re.exec(tc)) !== null) gateFragments.push(mm[1]);
|
||||
}
|
||||
// Requirement text: <action>/<acceptance_criteria>.
|
||||
const reqFragments: string[] = [];
|
||||
for (const tag of ['action', 'acceptance_criteria']) {
|
||||
const re = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'g');
|
||||
let mm: RegExpExecArray | null;
|
||||
while ((mm = re.exec(tc)) !== null) reqFragments.push(mm[1]);
|
||||
}
|
||||
// Strip XML tags from gate text so segments containing embedded
|
||||
// XML closing tags (e.g. <automated>cmd</automated> nested inside <verify>)
|
||||
// don't bleed into the file-path token extraction.
|
||||
const rawGateText = gateFragments.join('\n');
|
||||
const gateText = rawGateText.replace(/<[^>]+>/g, ' ');
|
||||
tasks.push({
|
||||
name,
|
||||
files,
|
||||
gateText,
|
||||
reqText: reqFragments.join('\n'),
|
||||
});
|
||||
}
|
||||
|
||||
if (tasks.length < 2) return { warnings, valid: true };
|
||||
|
||||
// FIX 3 (extensionless known files): build a normalized set of ALL tasks' <files> entries
|
||||
// so that extensionless filenames like Dockerfile are also recognized as valid file tokens.
|
||||
const knownFiles = new Set<string>();
|
||||
for (const t of tasks) {
|
||||
for (const f of t.files) knownFiles.add(normPath(f));
|
||||
}
|
||||
|
||||
// Extended looksLikePath: accepts known <files> entries even without an extension.
|
||||
const isFileLike = (token: string): boolean => {
|
||||
if (token.includes('*')) return false; // exclude globs
|
||||
if (looksLikePath(token)) return true;
|
||||
return knownFiles.has(normPath(token));
|
||||
};
|
||||
|
||||
// Dedup key: (taskAIdx, taskBIdx, pat, file)
|
||||
const seen = new Set<string>();
|
||||
|
||||
// For each task A, scan gate text for file-wide negative grep bans.
|
||||
for (let ai = 0; ai < tasks.length; ai++) {
|
||||
const taskA = tasks[ai];
|
||||
|
||||
// Split gate text into shell segments (split on && / || within lines).
|
||||
const segments = taskA.gateText.split('\n').flatMap(line =>
|
||||
line.split(/\s*(?:&&|\|\|)\s*/),
|
||||
);
|
||||
|
||||
for (const seg of segments) {
|
||||
if (!/grep/.test(seg)) continue;
|
||||
|
||||
// FIX 5: Negation at a command boundary: start of segment, or after ; & | ( newline / then / do.
|
||||
// Also handles ! negating an entire pipeline (e.g. ! cat FILE | grep ...).
|
||||
const hasLeadingNot =
|
||||
// Direct ! grep: negation immediately before grep keyword
|
||||
/(?:^|[\n;&|(]|\bthen\b|\bdo\b)\s*!\s*grep/.test(seg) ||
|
||||
// Pipeline negation: ! at command boundary, grep appears in pipeline after |
|
||||
(/(?:^|[\n;&|(]|\bthen\b|\bdo\b)\s*!\s*\w/.test(seg) && /\|\s*grep\b/.test(seg));
|
||||
|
||||
const hasCountZero = zeroCmp(seg);
|
||||
|
||||
// Extract grep invocation and check for count.
|
||||
grepArgRe.lastIndex = 0;
|
||||
let pat: string | null = null;
|
||||
let file: string | null = null;
|
||||
let isBan = false;
|
||||
|
||||
// FIX 4 helper: given a segment and the grep match end position, find the
|
||||
// file argument. First try the token immediately after PAT; if none qualifies,
|
||||
// try a cat/tac producer or < FILE redirect from the full segment.
|
||||
const resolveFileArg = (segment: string, afterPatStr: string): string | null => {
|
||||
// Primary: token immediately after PAT in the grep command
|
||||
const fileM = afterPatStr.match(/^\s+([^\s'"|>&;$()\[\]]+)/);
|
||||
const rawFile = fileM ? fileM[1] : null;
|
||||
if (rawFile && isFileLike(rawFile)) return rawFile;
|
||||
// FIX 4: For NON-region segments, also look for cat/tac producer or < FILE redirect
|
||||
const catM = segment.match(/\b(?:cat|tac)\s+([^\s'"|>&;()]+)/);
|
||||
if (catM && isFileLike(catM[1])) return catM[1];
|
||||
const redirM = segment.match(/<\s*([^\s'"|>&;()]+)/);
|
||||
if (redirM && isFileLike(redirM[1])) return redirM[1];
|
||||
return null;
|
||||
};
|
||||
|
||||
// If leading !, it might be a count or a direct !grep
|
||||
if (hasLeadingNot && !hasCountZero) {
|
||||
// Direct ! grep PAT FILE form: grep opts PAT FILE
|
||||
// Extract PAT and FILE from the grep invocation
|
||||
grepArgRe.lastIndex = 0;
|
||||
let gm: RegExpExecArray | null;
|
||||
while ((gm = grepArgRe.exec(seg)) !== null) {
|
||||
const opts = gm[1];
|
||||
if (optsHaveInvert(opts)) continue; // -v form: not a ban
|
||||
// PAT
|
||||
const rawPat = gm[2] !== undefined ? gm[2] :
|
||||
gm[3] !== undefined ? gm[3] :
|
||||
gm[4] !== undefined && plausibleBare(gm[4]) ? gm[4] : null;
|
||||
if (!rawPat) continue;
|
||||
// FILE: next non-option token after PAT (or cat/tac/redirect in segment)
|
||||
const afterPat = seg.slice((gm.index || 0) + gm[0].length);
|
||||
const rawFile = resolveFileArg(seg, afterPat);
|
||||
if (rawFile) {
|
||||
pat = rawPat;
|
||||
file = rawFile;
|
||||
isBan = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isBan && hasCountZero) {
|
||||
// count grep form: grep -c PAT FILE == 0 or [ $(grep -c PAT FILE) -eq 0 ]
|
||||
grepArgRe.lastIndex = 0;
|
||||
let gm: RegExpExecArray | null;
|
||||
while ((gm = grepArgRe.exec(seg)) !== null) {
|
||||
const opts = gm[1];
|
||||
if (!optsHaveCount(opts) || optsHaveInvert(opts)) continue;
|
||||
const rawPat = gm[2] !== undefined ? gm[2] :
|
||||
gm[3] !== undefined ? gm[3] :
|
||||
gm[4] !== undefined && plausibleBare(gm[4]) ? gm[4] : null;
|
||||
if (!rawPat) continue;
|
||||
const afterPat = seg.slice((gm.index || 0) + gm[0].length);
|
||||
const rawFile = resolveFileArg(seg, afterPat);
|
||||
if (rawFile) {
|
||||
pat = rawPat;
|
||||
file = rawFile;
|
||||
isBan = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isBan || !pat || !file) continue;
|
||||
if (allow.has(pat)) continue;
|
||||
// Skip if region-scoped (grep downstream of a sed/awk pipe — region extracted)
|
||||
if (isRegionScoped(seg)) continue;
|
||||
|
||||
// For each other task B: check if B's <files> includes FILE AND B's reqText contains PAT
|
||||
for (let bi = 0; bi < tasks.length; bi++) {
|
||||
if (bi === ai) continue;
|
||||
const taskB = tasks[bi];
|
||||
|
||||
// FIX 2: Exact normalized match; basename fallback ONLY for unqualified gate files.
|
||||
const gateFile = normPath(file);
|
||||
const bMatchesFile = taskB.files.some((bf) => {
|
||||
const nbf = normPath(bf);
|
||||
if (nbf === gateFile) return true;
|
||||
// basename fallback only when the gate file is an unqualified bare filename (no dir separator)
|
||||
if (!gateFile.includes('/') && path.basename(nbf) === gateFile) return true;
|
||||
return false;
|
||||
});
|
||||
if (!bMatchesFile) continue;
|
||||
|
||||
// FIX 1: Use linear-time patternRequiredIn instead of new RegExp (ReDoS-safe).
|
||||
const bRequiresPat = patternRequiredIn(pat, taskB.reqText);
|
||||
if (!bRequiresPat) continue;
|
||||
|
||||
const dedupeKey = `${ai}:${bi}:${pat}:${file}`;
|
||||
if (seen.has(dedupeKey)) continue;
|
||||
seen.add(dedupeKey);
|
||||
|
||||
warnings.push(
|
||||
`Region-scope conflict (#968): task "${taskA.name}" negative-greps "${pat}" file-wide on ${file}, ` +
|
||||
`but sibling task "${taskB.name}" requires it in the same file. ` +
|
||||
`A file-wide ban is unsatisfiable when a sibling needs the construct elsewhere — ` +
|
||||
`region-scope task "${taskA.name}"'s gate (sed -n/awk range then grep) or use an AST/test check. ` +
|
||||
`See planner-antipatterns.md "Region-Scoped Negative Gates", or add ` +
|
||||
`<!-- planner-region-allow: ${pat} --> if intentional.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This detector is warn-only: it never sets valid=false.
|
||||
return { warnings, valid: true as const };
|
||||
}
|
||||
|
||||
function cmdVerifyPlanStructure(cwd: string, filePath: string, raw: boolean): void {
|
||||
if (!filePath) {
|
||||
error('file path required');
|
||||
@@ -315,6 +619,9 @@ function cmdVerifyPlanStructure(cwd: string, filePath: string, raw: boolean): vo
|
||||
errors.push(...echoScan.errors);
|
||||
warnings.push(...echoScan.warnings);
|
||||
|
||||
const conflictScan = scanFileWideNegativeGateConflict(content);
|
||||
warnings.push(...conflictScan.warnings);
|
||||
|
||||
output(
|
||||
{
|
||||
valid: errors.length === 0,
|
||||
@@ -1935,6 +2242,7 @@ function cmdVerifyCodebaseDrift(cwd: string, raw: boolean): void {
|
||||
|
||||
export = {
|
||||
scanNegativeGrepCommentEcho,
|
||||
scanFileWideNegativeGateConflict,
|
||||
cmdVerifySummary,
|
||||
cmdVerifyPlanStructure,
|
||||
cmdVerifyPhaseCompleteness,
|
||||
|
||||
Reference in New Issue
Block a user