Merge branch 'next' into feat/1279-fail-first-prover

This commit is contained in:
Tom Boucher
2026-06-16 11:32:57 -04:00
committed by GitHub
86 changed files with 2607 additions and 1686 deletions

View File

@@ -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,
};

View File

@@ -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,
};
}

View File

@@ -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']

View File

@@ -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,
};

View File

@@ -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;
}

View File

@@ -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,