* test(#3172): failing-first suite for the stated failing-direction probe Pins the <fails_when> pairing walk, placeholder denylist, MISSING sentinel exemption, degraded-read contract, CLI arm and the plan-authoring contract text. RED by construction: the module exports it requires do not exist yet. Executed on the remote runner. * feat(#3172): require a stated failing direction for every automated acceptance command Every runnable <automated> command now carries a <fails_when> sibling naming what output constitutes failure. A command with no expressible failure mode is not an acceptance test: it reads as rigour and is not falsifiable. - verify-command-grounding gains a failing-direction probe sharing the existing <automated> grammar, MISSING sentinel and walk guard rather than copying them - gsd-tools check verify-failure-directions <N> backs it; plan-phase dispatches it and hands the JSON to gsd-plan-checker check 8f - Dimension 8 detail extracted to references to stay under the agent size cap Verified on the remote runner. * fix(#3172): close four review findings in the failing-direction probe - MISSING_SENTINEL_RE matched an env-var assignment prefix (MISSING=1 cmd), so a real command was exempted from the new blocking gate. Tightened the SHARED constant rather than adding a second copy. - Both token regexes scanned to EOF on unclosed openers (O(n^2), 1562ms at 40k). Bodies are now non-crossing; 1ms, byte-identical on well-formed input. The pre-existing AUTOMATED_BLOCK_RE carried the same defect and is fixed here too. - probePhaseFailingDirections reported status 'ok' when one plan was unreadable, conflating 'could not look' with 'nothing to report'. - Extracted the phase-resolution block both check arms had copied verbatim. Also corrects a docs/AGENTS.md dimension list stale since #2401. Verified on the remote runner. * fix(#3172): project the planner rule onto the spawn contract, settle emitted bookkeeping The remote runner refuted the planner-side edit. agents/gsd-planner.md is frozen under a 49152-LF-char cap asserted by four suites and sat at 49,146 — six chars of headroom — so the +537 of authoring rule blew it. #3297/#3645 already settled where such a rule goes: the planner spawn contract in plan-phase.md, beside <tracked_source_paths>. The agent file is reverted to origin/next verbatim. - plan-phase.md gains <failing_direction_contract>; tests row 30 now asserts the contract there and row 30b guards the freeze in both directions - plan-phase.md growth acknowledged by APPENDING to the 3409 fragment, per the precedent that two ack sources may never name the same path - install-tree fixtures regenerated for the three new reference files Verified on the remote runner. * chore(#3172): backfill PR number into the changeset fragment pr:0 -> pr:3825 now that the PR exists. --------- Co-authored-by: sim <sim@local>
1027 lines
36 KiB
TypeScript
1027 lines
36 KiB
TypeScript
/**
|
|
* Verify-command grounding probe (#2401).
|
|
*
|
|
* #2401: a planner authored `<automated>cd ../../frontend && npm run lint</automated>`
|
|
* whose target did not resolve from the executor's cwd, and the plan-checker —
|
|
* lacking a deterministic probe — hand-reasoned the filesystem and prescribed two
|
|
* successively-wrong replacement paths.
|
|
*
|
|
* This module answers "can this `<automated>` verify command's target directory be
|
|
* grounded?" WITHOUT ever executing the command. PLAN.md is LLM-authored untrusted
|
|
* text, so this module never `exec`s, `spawn`s, or otherwise shells out — it reads
|
|
* only via `fs.statSync`, `fs.existsSync`, `fs.readFileSync`, `fs.readdirSync`.
|
|
*
|
|
* It is a RECOGNIZER, not a shell interpreter (deliberate, per Greenspun's Tenth
|
|
* Rule — the fix for #2401 is refusing to guess, not writing a bigger shell
|
|
* parser): exactly two forms are grounded (`cd <literal>` and
|
|
* `npm --prefix <literal>`), and anything this probe cannot ground returns
|
|
* `unresolvable`, which is a warning and never a blocker.
|
|
*
|
|
* A bare ancestor climb (`cd ../..`, no trailing named segment) is genuinely
|
|
* ambiguous under parallel-worktree execution — the checker's root and the
|
|
* executor's root differ, so "my grandparent directory" cannot be asserted
|
|
* about, and it is reported `outside_root` without touching the filesystem.
|
|
* A climb that names a concrete sibling (`cd ../../frontend`, the exact #2401
|
|
* shape) still names something checkable, so it is resolved and probed like any
|
|
* other target.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { extractTaggedBlocks, stripTaggedBlocks } from './markdown-sectionizer.cjs';
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
|
|
import phaseIdMod = require('./phase-id.cjs');
|
|
const { stripProjectCodePrefix, extractPhaseToken, comparePhaseNum } = phaseIdMod;
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
type VerifyCommandStatus = 'ok' | 'broken' | 'unresolvable' | 'not_applicable' | 'pending_creation';
|
|
type VerifyCommandSeverity = 'blocker' | 'warning' | 'none';
|
|
type VerifyCommandReason =
|
|
| 'missing_dir'
|
|
| 'no_manifest'
|
|
| 'script_missing'
|
|
| 'dynamic_path'
|
|
| 'outside_root'
|
|
| 'manifest_unreadable'
|
|
| null;
|
|
type VerifyCommandForm = 'cd' | 'prefix' | null;
|
|
|
|
interface AutomatedCommand {
|
|
plan: string;
|
|
task: string;
|
|
command: string;
|
|
}
|
|
|
|
interface ResolveOptions {
|
|
projectRoot?: string;
|
|
declaredPaths?: string[];
|
|
}
|
|
|
|
interface ResolvedVerifyCommand {
|
|
command: string;
|
|
status: VerifyCommandStatus;
|
|
severity: VerifyCommandSeverity;
|
|
reason: VerifyCommandReason;
|
|
form: VerifyCommandForm;
|
|
rawTarget: string | null;
|
|
target: string | null;
|
|
manifest: string | null;
|
|
script: string | null;
|
|
sentinel: boolean;
|
|
base: string;
|
|
}
|
|
|
|
interface ProbedVerifyCommand extends ResolvedVerifyCommand {
|
|
plan: string;
|
|
task: string;
|
|
}
|
|
|
|
interface ProbeCounts {
|
|
blocker: number;
|
|
warning: number;
|
|
total: number;
|
|
}
|
|
|
|
interface ProbePhaseResult {
|
|
status: VerifyCommandStatus;
|
|
commands: ProbedVerifyCommand[];
|
|
counts: ProbeCounts;
|
|
readError: string | null;
|
|
}
|
|
|
|
interface ProbePhaseOptions {
|
|
phaseDir: string;
|
|
projectRoot: string;
|
|
}
|
|
|
|
interface HarvestedCommand {
|
|
phase: string;
|
|
plan: string;
|
|
task: string;
|
|
command: string;
|
|
}
|
|
|
|
interface HarvestResult {
|
|
commands: HarvestedCommand[];
|
|
readError: string | null;
|
|
}
|
|
|
|
interface HarvestOptions {
|
|
planningDir: string;
|
|
/**
|
|
* A phase-id token (`phase-id.cjs`'s grammar) or a plain number. Accepting
|
|
* either lets callers pass a decimal/lettered token (`'2.1'`, `'12A'`)
|
|
* without lossy `Number()` coercion; a plain number is stringified before
|
|
* comparison via `comparePhaseNum`.
|
|
*/
|
|
beforePhase: number | string;
|
|
limit?: number;
|
|
lookback?: number;
|
|
}
|
|
|
|
// ─── Extraction ───────────────────────────────────────────────────────────────
|
|
|
|
const TASK_NAME_RE = /<name>([\s\S]*?)<\/name>/;
|
|
/**
|
|
* The body cannot cross a subsequent `<automated>` opener (negative lookahead
|
|
* `(?!<automated\b)` gates each consumed char), mirroring the stop-at-next-open
|
|
* shape `extractTaggedBlocks`/`stripTaggedBlocks` already use. This is what
|
|
* makes a scan over an unclosed opener LINEAR instead of quadratic: the lazy
|
|
* `[\s\S]*?` body used to scan all the way to EOF hunting a closing tag that
|
|
* never comes (measured: k=40000 unclosed openers, ~1.5s). `MAX_BLOCK_WALK`
|
|
* below bounds matched-block COUNT, not scan cost — it does nothing for a scan
|
|
* that matches nothing, which is exactly the case this shape fixes.
|
|
*/
|
|
const AUTOMATED_BLOCK_RE = /<automated[^>]{0,200}>((?:(?!<automated\b)[\s\S])*?)<\/automated>/g;
|
|
|
|
/**
|
|
* Bounds the NUMBER of matched `<automated>` blocks walked (e.g. hundreds of
|
|
* legitimately closed blocks in one document), not the cost of scanning
|
|
* pathological unclosed input — that cost is now bounded by the non-crossing
|
|
* body in `AUTOMATED_BLOCK_RE`/`VERIFY_TOKEN_RE` themselves (a failed scan is
|
|
* linear, so no separate iteration cap is needed to keep it cheap).
|
|
* `extractTaggedBlocks`/`stripTaggedBlocks` (task-block grammar owner,
|
|
* `./markdown-sectionizer.cjs`) already use the same non-crossing shape with
|
|
* no iteration cap of their own. This guard only bounds the `<automated>`
|
|
* scan this module still runs directly, matching the pre-existing behavior.
|
|
*/
|
|
const MAX_BLOCK_WALK = 20000;
|
|
|
|
/**
|
|
* Run `AUTOMATED_BLOCK_RE` over `text`, pushing `{plan: '', task, command}`
|
|
* for each non-empty trimmed block onto `out`, in order. Shares `guard`
|
|
* across every caller in one `extractAutomatedCommands` invocation so the
|
|
* MAX_BLOCK_WALK bound applies to the WHOLE document's `<automated>` count,
|
|
* not per task body. Returns `false` when the bound was hit (caller stops
|
|
* walking further task bodies immediately); `true` otherwise.
|
|
*/
|
|
function extractAutomatedFromText(
|
|
text: string,
|
|
task: string,
|
|
out: AutomatedCommand[],
|
|
guard: { n: number },
|
|
): boolean {
|
|
AUTOMATED_BLOCK_RE.lastIndex = 0;
|
|
let aMatch: RegExpExecArray | null;
|
|
while ((aMatch = AUTOMATED_BLOCK_RE.exec(text)) !== null) {
|
|
const raw = (aMatch[1] ?? '').trim();
|
|
if (raw !== '') out.push({ plan: '', task, command: raw });
|
|
if (aMatch.index === AUTOMATED_BLOCK_RE.lastIndex) AUTOMATED_BLOCK_RE.lastIndex += 1;
|
|
guard.n += 1;
|
|
if (guard.n > MAX_BLOCK_WALK) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Extract every `<automated>…</automated>` command from PLAN.md text,
|
|
* attaching the owning `<task><name>` when the block sits inside a
|
|
* `<task>…</task>`. Never throws; a non-string or blank plan yields `[]`.
|
|
* `plan` is left `''` — callers (`probePhaseVerifyCommands`,
|
|
* `harvestPriorVerifyCommands`) set it from the filename they read.
|
|
*
|
|
* Task-block bodies are obtained from the canonical sectionizer
|
|
* (`extractTaggedBlocks`/`stripTaggedBlocks`, `./markdown-sectionizer.cjs`)
|
|
* rather than a fourth hand-rolled `<task>` grammar copy (review finding,
|
|
* generative fix divergence class). This module only needs each task's
|
|
* `<name>` and its `<automated>` blocks — never the opening tag's `type=`
|
|
* attribute — so, unlike `src/verify.cts`'s `PLAN_TASK_BLOCK_RE` (which keeps
|
|
* its own copy specifically to read `type=`), it can share the owner
|
|
* outright. Ordering: every in-task command is emitted first, task by task
|
|
* in document order (`extractTaggedBlocks` returns bodies in document
|
|
* order); every command sitting OUTSIDE any `<task>` is emitted after, in
|
|
* the order it appears in the task-stripped remainder. No existing caller or
|
|
* test depends on interleaving an outside-task block between two in-task
|
|
* blocks that surround it in the raw document.
|
|
*/
|
|
function extractAutomatedCommands(planText: unknown): AutomatedCommand[] {
|
|
if (typeof planText !== 'string' || planText.length === 0) return [];
|
|
|
|
const out: AutomatedCommand[] = [];
|
|
const guard = { n: 0 };
|
|
|
|
const taskBodies = extractTaggedBlocks(planText, 'task', true);
|
|
for (const body of taskBodies) {
|
|
const nameMatch = TASK_NAME_RE.exec(body);
|
|
const task = nameMatch ? nameMatch[1].trim() : '';
|
|
if (!extractAutomatedFromText(body, task, out, guard)) return out;
|
|
}
|
|
|
|
const remainder = stripTaggedBlocks(planText, 'task', true);
|
|
extractAutomatedFromText(remainder, '', out, guard);
|
|
|
|
return out;
|
|
}
|
|
|
|
// ─── Failing direction (#3172) ─────────────────────────────────────────────────
|
|
|
|
type FailingDirectionStatus = 'ok' | 'missing' | 'empty' | 'placeholder' | 'sentinel' | 'orphan';
|
|
type FailingDirectionSeverity = 'blocker' | 'warning' | 'none';
|
|
|
|
interface ResolvedFailingDirection {
|
|
command: string;
|
|
statement: string | null;
|
|
status: FailingDirectionStatus;
|
|
severity: FailingDirectionSeverity;
|
|
}
|
|
|
|
interface FailingDirectionEntry extends ResolvedFailingDirection {
|
|
plan: string;
|
|
task: string;
|
|
}
|
|
|
|
interface FailingDirectionCounts {
|
|
blocker: number;
|
|
warning: number;
|
|
total: number;
|
|
}
|
|
|
|
interface ProbePhaseFailingResult {
|
|
status: 'ok' | 'blocked' | 'unresolvable';
|
|
commands: FailingDirectionEntry[];
|
|
counts: FailingDirectionCounts;
|
|
readError: string | null;
|
|
}
|
|
|
|
interface ProbePhaseFailingOptions {
|
|
phaseDir: string;
|
|
}
|
|
|
|
/**
|
|
* Ordered alternation with a backreference so `<automated>` and `<fails_when>`
|
|
* are recovered in a SINGLE document-order pass — the pairing walk needs
|
|
* their relative positions, which two independent scans would discard.
|
|
*
|
|
* The body cannot cross a subsequent `<automated>` or `<fails_when>` opener
|
|
* (negative lookahead `(?!<(?:automated|fails_when)\b)`), the same
|
|
* non-crossing shape as `AUTOMATED_BLOCK_RE` above. This is what keeps a scan
|
|
* over an unclosed opener LINEAR (verified: k=10000/20000/40000 unclosed
|
|
* openers all complete in ~0ms); `MAX_BLOCK_WALK` bounds matched-block COUNT,
|
|
* not scan cost.
|
|
*/
|
|
const VERIFY_TOKEN_RE = /<(automated|fails_when)[^>]{0,200}>((?:(?!<(?:automated|fails_when)\b)[\s\S])*?)<\/\1>/g;
|
|
const PLACEHOLDER_STATEMENTS = new Set(['tbd', 'todo', 'n/a', 'na', 'none', 'unknown', 'tba', '?', '-']);
|
|
|
|
/**
|
|
* Judge one `<automated>` command's stated failing direction. Pure, total,
|
|
* and never throws for any input shape — order of checks is load-bearing
|
|
* (#3172 review): the sentinel exemption (step 3) must run BEFORE the
|
|
* statement-presence check (step 4), because the Nyquist Wave-0 sentinel is
|
|
* not a runnable command and so has no failure mode to state, even when a
|
|
* statement happens to follow it in the plan text.
|
|
*/
|
|
function resolveFailingDirection(command: unknown, statement: unknown): ResolvedFailingDirection {
|
|
const cmd = typeof command === 'string' ? command.trim() : '';
|
|
|
|
if (cmd === '') {
|
|
return {
|
|
command: '',
|
|
statement: typeof statement === 'string' ? statement : null,
|
|
status: 'orphan',
|
|
severity: 'warning',
|
|
};
|
|
}
|
|
|
|
if (MISSING_SENTINEL_RE.test(cmd)) {
|
|
return {
|
|
command: cmd,
|
|
statement: typeof statement === 'string' ? statement.trim() : null,
|
|
status: 'sentinel',
|
|
severity: 'none',
|
|
};
|
|
}
|
|
|
|
if (typeof statement !== 'string') {
|
|
return { command: cmd, statement: null, status: 'missing', severity: 'blocker' };
|
|
}
|
|
|
|
const trimmed = statement.trim();
|
|
if (trimmed === '') {
|
|
return { command: cmd, statement, status: 'empty', severity: 'blocker' };
|
|
}
|
|
|
|
// Whole-value match only — never a substring test. "TBD in the harness
|
|
// output" is real prose and must pass (#3172 review Finding).
|
|
if (PLACEHOLDER_STATEMENTS.has(trimmed.toLowerCase())) {
|
|
return { command: cmd, statement: trimmed, status: 'placeholder', severity: 'blocker' };
|
|
}
|
|
|
|
return { command: cmd, statement: trimmed, status: 'ok', severity: 'none' };
|
|
}
|
|
|
|
/**
|
|
* Scan one text unit's `<automated>`/`<fails_when>` tokens in document order,
|
|
* binding each `<fails_when>` to the nearest PRECEDING `<automated>` (first
|
|
* statement wins; a redundant trailing statement adds no row) and emitting an
|
|
* `orphan` row for a `<fails_when>` with no pending command. Shares `guard`
|
|
* across every text unit in one `extractFailingDirections` call, mirroring
|
|
* `extractAutomatedFromText`'s MAX_BLOCK_WALK bound and zero-length-match
|
|
* advance. Returns `false` when the bound was hit; `true` otherwise.
|
|
*/
|
|
function extractFailingFromText(
|
|
text: string,
|
|
task: string,
|
|
out: FailingDirectionEntry[],
|
|
guard: { n: number },
|
|
): boolean {
|
|
VERIFY_TOKEN_RE.lastIndex = 0;
|
|
let pending: FailingDirectionEntry | null = null;
|
|
let bound = false;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = VERIFY_TOKEN_RE.exec(text)) !== null) {
|
|
const kind = match[1];
|
|
const body = match[2] ?? '';
|
|
|
|
if (kind === 'automated') {
|
|
const trimmedCommand = body.trim();
|
|
if (trimmedCommand !== '') {
|
|
const entry: FailingDirectionEntry = { ...resolveFailingDirection(trimmedCommand, undefined), plan: '', task };
|
|
out.push(entry);
|
|
pending = entry;
|
|
bound = false;
|
|
}
|
|
} else {
|
|
// kind === 'fails_when'
|
|
if (pending === null) {
|
|
const entry: FailingDirectionEntry = { ...resolveFailingDirection('', body), plan: '', task };
|
|
out.push(entry);
|
|
} else if (!bound) {
|
|
const resolved = resolveFailingDirection(pending.command, body);
|
|
pending.statement = resolved.statement;
|
|
pending.status = resolved.status;
|
|
pending.severity = resolved.severity;
|
|
bound = true;
|
|
}
|
|
// already bound → redundant trailing statement, ignored (first-wins).
|
|
}
|
|
|
|
if (match.index === VERIFY_TOKEN_RE.lastIndex) VERIFY_TOKEN_RE.lastIndex += 1;
|
|
guard.n += 1;
|
|
if (guard.n > MAX_BLOCK_WALK) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Extract every `<automated>`/`<fails_when>` pairing verdict from PLAN.md
|
|
* text. Never throws; a non-string or blank plan yields `[]`. `plan` is left
|
|
* `''`, mirroring `extractAutomatedCommands` — callers set it from the
|
|
* filename they read.
|
|
*
|
|
* Walks the SAME text units, in the SAME order, as `extractAutomatedCommands`
|
|
* (every `<task>` body first in document order, then the task-stripped
|
|
* remainder as a final unit with task `''`) so pairing never crosses a task
|
|
* boundary, and shares ONE `{n:0}` guard across every unit so
|
|
* `MAX_BLOCK_WALK` bounds the whole document.
|
|
*/
|
|
function extractFailingDirections(planText: unknown): FailingDirectionEntry[] {
|
|
if (typeof planText !== 'string' || planText.length === 0) return [];
|
|
|
|
const out: FailingDirectionEntry[] = [];
|
|
const guard = { n: 0 };
|
|
|
|
const taskBodies = extractTaggedBlocks(planText, 'task', true);
|
|
for (const body of taskBodies) {
|
|
const nameMatch = TASK_NAME_RE.exec(body);
|
|
const task = nameMatch ? nameMatch[1].trim() : '';
|
|
if (!extractFailingFromText(body, task, out, guard)) return out;
|
|
}
|
|
|
|
const remainder = stripTaggedBlocks(planText, 'task', true);
|
|
extractFailingFromText(remainder, '', out, guard);
|
|
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Probe every `<automated>` command's stated failing direction across a
|
|
* phase directory's `-PLAN.md` files. Never throws — a read failure degrades
|
|
* to `readError` with the offending file skipped, and an empty result with a
|
|
* populated `readError` means "could not look", which must never render as
|
|
* "nothing to report".
|
|
*/
|
|
function probePhaseFailingDirections(options: ProbePhaseFailingOptions): ProbePhaseFailingResult {
|
|
const { phaseDir } = options;
|
|
|
|
let entries: string[];
|
|
try {
|
|
entries = fs.readdirSync(phaseDir);
|
|
} catch (err) {
|
|
return {
|
|
status: 'unresolvable',
|
|
commands: [],
|
|
counts: { blocker: 0, warning: 0, total: 0 },
|
|
readError: toMessage(err),
|
|
};
|
|
}
|
|
|
|
const planFiles = entries.filter(f => PLAN_FILE_RE.test(f)).sort();
|
|
const commands: FailingDirectionEntry[] = [];
|
|
const readErrors: string[] = [];
|
|
|
|
for (const file of planFiles) {
|
|
let text: string;
|
|
try {
|
|
text = fs.readFileSync(path.join(phaseDir, file), 'utf-8');
|
|
} catch (err) {
|
|
readErrors.push(toMessage(err));
|
|
continue;
|
|
}
|
|
|
|
for (const entry of extractFailingDirections(text)) {
|
|
commands.push({ ...entry, plan: file });
|
|
}
|
|
}
|
|
|
|
const counts: FailingDirectionCounts = { blocker: 0, warning: 0, total: commands.length };
|
|
for (const c of commands) {
|
|
if (c.severity === 'blocker') counts.blocker += 1;
|
|
else if (c.severity === 'warning') counts.warning += 1;
|
|
}
|
|
|
|
const readError = readErrors.length > 0 ? readErrors.join('; ') : null;
|
|
|
|
// A populated readError can never yield 'ok' — "could not look" must never
|
|
// render as "nothing to report" (#3172 review self-finding: with the old
|
|
// `commands.length === 0 && readError !== null` precedence, a phase where
|
|
// one plan read fine with zero blockers and a SECOND plan failed to read
|
|
// fell through to 'ok' because commands.length > 0). 'blocked' keeps
|
|
// precedence over 'unresolvable' because a proven blocker is the stronger
|
|
// signal.
|
|
let status: 'ok' | 'blocked' | 'unresolvable';
|
|
if (counts.blocker > 0) {
|
|
status = 'blocked';
|
|
} else if (readError !== null) {
|
|
status = 'unresolvable';
|
|
} else {
|
|
status = 'ok';
|
|
}
|
|
|
|
return { status, commands, counts, readError };
|
|
}
|
|
|
|
// ─── Resolution ───────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* `$`, backtick, `*`, `?`, or a newline — a path this recognizer refuses to
|
|
* guess at anywhere in the string. `$`/backtick are substitution, `*`/`?` are
|
|
* shell globs (and illegal in Windows path components regardless), and a
|
|
* newline is never a valid single path.
|
|
*/
|
|
const DYNAMIC_PATH_ANYWHERE_RE = /[$`*?\n]/;
|
|
/**
|
|
* A LEADING `~` is shell home-expansion (`~/web`, `~user/web`) and is refused
|
|
* as dynamic; the dynamic check runs BEFORE quote stripping (so `cd
|
|
* "$FRONTEND"` is still caught), so this tolerates one leading quote char
|
|
* before the `~`. A `~` anywhere else in a path is an ordinary literal
|
|
* character — e.g. Windows 8.3 short names like `RUNNER~1` — and must resolve
|
|
* normally rather than being refused (#2401 CI regression).
|
|
*/
|
|
const DYNAMIC_PATH_LEADING_TILDE_RE = /^["']?~/;
|
|
const CD_SEGMENT_RE = /^cd\s+(.+)$/;
|
|
/**
|
|
* Quote-aware `--prefix` value capture (#2401 review Finding 2): a bare
|
|
* `\S+` capture truncates a quoted path containing a space (`--prefix "my
|
|
* dir"` → `"my`). Alternation order is double-quoted, single-quoted,
|
|
* unquoted — the surrounding quote pair (if any) is removed downstream by
|
|
* the existing `stripQuotes`.
|
|
*/
|
|
const PREFIX_FLAG_RE = /(?:^|\s)--prefix(?:=|\s+)("[^"]*"|'[^']*'|\S+)/;
|
|
/** Same value grammar as `PREFIX_FLAG_RE`, `g`-flagged for stripping (Finding 1). */
|
|
const PREFIX_FLAG_STRIP_RE = /(?:^|\s)--prefix(?:=|\s+)(?:"[^"]*"|'[^']*'|\S+)/g;
|
|
const NEEDS_NPM_RE = /^(npm|npx|pnpm|yarn|bun)\b/;
|
|
const NEEDS_MAKE_RE = /^make\b/;
|
|
const NPM_RUN_SCRIPT_RE = /\bnpm\s+run\s+([\w:@./-]+)/;
|
|
/**
|
|
* The `\s|$` anchor (not `\b`) is load-bearing: `\b` also matches an env-var
|
|
* assignment prefix such as `MISSING=1 cmd` (`=` is a non-word char, so `\b`
|
|
* sits happily before it) — and that IS a real runnable command, not the
|
|
* Nyquist Wave-0 sentinel. #3172 wires this constant into a BLOCKING gate
|
|
* (`resolveFailingDirection`), so a benign #2401 false-positive on the `\b`
|
|
* form becomes a real gate bypass: `MISSING=true npm test` would be reported
|
|
* `status:'sentinel', severity:'none'` instead of `missing`/`blocker`
|
|
* (#3172 review).
|
|
*/
|
|
const MISSING_SENTINEL_RE = /^MISSING(?:\s|$)/;
|
|
const MAX_MANIFEST_BYTES = 512 * 1024;
|
|
|
|
function emptyResult(base: string): ResolvedVerifyCommand {
|
|
return {
|
|
command: '',
|
|
status: 'not_applicable',
|
|
severity: 'none',
|
|
reason: null,
|
|
form: null,
|
|
rawTarget: null,
|
|
target: null,
|
|
manifest: null,
|
|
script: null,
|
|
sentinel: false,
|
|
base,
|
|
};
|
|
}
|
|
|
|
/** Split on `&&`, `||`, `;`, and newlines; trim each segment; drop empties. No quote-awareness. */
|
|
function splitSegments(cmd: string): string[] {
|
|
return cmd
|
|
.split(/&&|\|\||;|\n/)
|
|
.map(s => s.trim())
|
|
.filter(s => s.length > 0);
|
|
}
|
|
|
|
/** Strip a single matching pair of surrounding quotes, if present. */
|
|
function stripQuotes(s: string): string {
|
|
if (s.length >= 2) {
|
|
const first = s[0];
|
|
const last = s[s.length - 1];
|
|
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
return s.slice(1, -1);
|
|
}
|
|
}
|
|
return s;
|
|
}
|
|
|
|
/** Backslash → forward-slash, applied unconditionally (backslash paths arrive on Linux too). */
|
|
function toSlash(s: string): string {
|
|
return s.replace(/\\/g, '/');
|
|
}
|
|
|
|
/**
|
|
* `NPM_RUN_SCRIPT_RE` requires `npm` and `run` adjacent, so `npm --prefix
|
|
* ./web run lint` (the form this feature's own docs prefer) never matches
|
|
* (#2401 review Finding 1). Strip the `--prefix <value>` / `--prefix=<value>`
|
|
* flag (either ordering, quote-aware) before running the script-name match.
|
|
*/
|
|
function stripPrefixFlag(s: string): string {
|
|
return s.replace(PREFIX_FLAG_STRIP_RE, ' ');
|
|
}
|
|
|
|
/** Is `raw` (after the same quote-stripping/slash-normalization used elsewhere) an absolute path? */
|
|
function isAbsoluteCdSegment(raw: string): boolean {
|
|
return path.isAbsolute(toSlash(stripQuotes(raw)));
|
|
}
|
|
|
|
/**
|
|
* Fold chained `cd` segments left-to-right with absolute-reset semantics
|
|
* (#2401 review Finding 3): a relative segment appends onto the
|
|
* accumulator; an absolute segment discards everything accumulated so far
|
|
* and becomes the new accumulator (matching real shell `cd` semantics —
|
|
* `cd sub && cd /abs/path` ends up at `/abs/path`, not `sub//abs/path`).
|
|
* A single segment (the overwhelmingly common case) always returns that
|
|
* segment verbatim, byte-identical to the pre-fix `cdArgs[0]` behavior.
|
|
*/
|
|
function foldCdArgs(args: string[]): string {
|
|
let acc = '';
|
|
for (const raw of args) {
|
|
if (acc === '' || isAbsoluteCdSegment(raw)) {
|
|
acc = raw;
|
|
} else {
|
|
acc = `${acc}/${raw}`;
|
|
}
|
|
}
|
|
return acc;
|
|
}
|
|
|
|
function stripLeadingDotSlash(s: string): string {
|
|
return s.replace(/^\.\//, '');
|
|
}
|
|
|
|
/**
|
|
* A relative escape whose every segment is `..` (a bare ancestor climb, e.g.
|
|
* `cd ../..`) is inherently ambiguous across worktrees — flagged `outside_root`
|
|
* without touching the filesystem. A climb that names a concrete sibling (e.g.
|
|
* `cd ../../frontend`, the exact #2401 shape) still names something checkable
|
|
* and falls through to the normal filesystem probe below.
|
|
*/
|
|
function isPureAncestorClimb(rel: string): boolean {
|
|
if (rel.length === 0) return false;
|
|
const segs = rel.split(/[\\/]/).filter(s => s.length > 0);
|
|
return segs.length > 0 && segs.every(s => s === '..');
|
|
}
|
|
|
|
function declaredPathCovers(declaredPaths: string[] | undefined, norm: string): boolean {
|
|
if (!Array.isArray(declaredPaths)) return false;
|
|
const target = stripLeadingDotSlash(norm);
|
|
return declaredPaths.some(p => {
|
|
if (typeof p !== 'string') return false;
|
|
const dp = stripLeadingDotSlash(toSlash(p));
|
|
return dp === target || dp.startsWith(target + '/');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Read a package.json manifest bounded by size and guarded end-to-end; never
|
|
* throws. Returns `null` when the manifest cannot be read/parsed/shaped, or
|
|
* the parsed value when it is usable.
|
|
*/
|
|
function readManifestObject(manifestPath: string): Record<string, unknown> | null {
|
|
let size = 0;
|
|
try {
|
|
size = fs.statSync(manifestPath).size;
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (size > MAX_MANIFEST_BYTES) return null;
|
|
|
|
let raw: string;
|
|
try {
|
|
raw = fs.readFileSync(manifestPath, 'utf-8');
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
return parsed as Record<string, unknown>;
|
|
}
|
|
|
|
/**
|
|
* Resolve a single `<automated>` command's target directory WITHOUT ever
|
|
* executing it. Every `fs` call is individually guarded, so this function
|
|
* never throws for any input — including a bare call with no options.
|
|
*/
|
|
function resolveVerifyCommandTarget(command: unknown, options?: ResolveOptions): ResolvedVerifyCommand {
|
|
const opts = options && typeof options === 'object' ? options : {};
|
|
const base = typeof opts.projectRoot === 'string' && opts.projectRoot !== '' ? opts.projectRoot : process.cwd();
|
|
const result = emptyResult(base);
|
|
|
|
if (typeof command !== 'string') return result;
|
|
result.command = command;
|
|
|
|
const trimmed = command.trim();
|
|
if (trimmed === '') return result;
|
|
|
|
// Nyquist "MISSING — Wave 0 must create …" sentinel; Dimension 8 owns it.
|
|
if (MISSING_SENTINEL_RE.test(trimmed)) {
|
|
result.sentinel = true;
|
|
return result;
|
|
}
|
|
|
|
const segments = splitSegments(trimmed);
|
|
|
|
let form: VerifyCommandForm = null;
|
|
let rawTarget: string | null = null;
|
|
let rest: string;
|
|
|
|
const cdArgs: string[] = [];
|
|
let i = 0;
|
|
while (i < segments.length) {
|
|
const m = CD_SEGMENT_RE.exec(segments[i]);
|
|
if (!m) break;
|
|
cdArgs.push(m[1].trim());
|
|
i += 1;
|
|
}
|
|
|
|
if (cdArgs.length > 0) {
|
|
form = 'cd';
|
|
rawTarget = foldCdArgs(cdArgs);
|
|
rest = segments.slice(i).join(' && ');
|
|
} else {
|
|
const prefixMatch = PREFIX_FLAG_RE.exec(trimmed);
|
|
if (!prefixMatch) return result; // not_applicable — neither form matched
|
|
form = 'prefix';
|
|
rawTarget = prefixMatch[1];
|
|
rest = trimmed;
|
|
}
|
|
|
|
// Dynamic-path refusal, tested against rawTarget BEFORE any unquoting.
|
|
if (DYNAMIC_PATH_ANYWHERE_RE.test(rawTarget) || DYNAMIC_PATH_LEADING_TILDE_RE.test(rawTarget)) {
|
|
result.status = 'unresolvable';
|
|
result.reason = 'dynamic_path';
|
|
result.severity = 'warning';
|
|
result.form = form;
|
|
result.rawTarget = rawTarget;
|
|
return result;
|
|
}
|
|
|
|
const norm = toSlash(stripQuotes(rawTarget));
|
|
const isAbs = path.isAbsolute(norm);
|
|
const target = isAbs ? path.normalize(norm) : path.resolve(base, norm);
|
|
|
|
result.form = form;
|
|
result.rawTarget = rawTarget;
|
|
result.target = target;
|
|
|
|
if (!isAbs) {
|
|
const rel = path.relative(base, target);
|
|
if (isPureAncestorClimb(rel)) {
|
|
result.status = 'ok';
|
|
result.severity = 'warning';
|
|
result.reason = 'outside_root';
|
|
return result;
|
|
}
|
|
}
|
|
|
|
let stat: fs.Stats | undefined;
|
|
try {
|
|
stat = fs.statSync(target, { throwIfNoEntry: false });
|
|
} catch {
|
|
stat = undefined;
|
|
}
|
|
|
|
if (!stat || !stat.isDirectory()) {
|
|
if (declaredPathCovers(opts.declaredPaths, norm)) {
|
|
result.status = 'pending_creation';
|
|
result.severity = 'none';
|
|
result.reason = null;
|
|
} else {
|
|
result.status = 'broken';
|
|
result.reason = 'missing_dir';
|
|
result.severity = 'blocker';
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const restTrimmed = rest.trim();
|
|
let neededManifest: 'package.json' | 'Makefile' | null = null;
|
|
if (NEEDS_NPM_RE.test(restTrimmed)) neededManifest = 'package.json';
|
|
else if (NEEDS_MAKE_RE.test(restTrimmed)) neededManifest = 'Makefile';
|
|
|
|
if (!neededManifest) {
|
|
result.status = 'ok';
|
|
result.severity = 'none';
|
|
return result;
|
|
}
|
|
|
|
const manifestPath = path.join(target, neededManifest);
|
|
let manifestExists = false;
|
|
try {
|
|
manifestExists = fs.existsSync(manifestPath);
|
|
} catch {
|
|
manifestExists = false;
|
|
}
|
|
|
|
if (!manifestExists) {
|
|
result.status = 'broken';
|
|
result.reason = 'no_manifest';
|
|
result.severity = 'blocker';
|
|
return result;
|
|
}
|
|
|
|
result.manifest = neededManifest;
|
|
|
|
if (neededManifest === 'Makefile') {
|
|
result.status = 'ok';
|
|
result.severity = 'none';
|
|
return result;
|
|
}
|
|
|
|
const parsed = readManifestObject(manifestPath);
|
|
if (!parsed) {
|
|
result.status = 'ok';
|
|
result.reason = 'manifest_unreadable';
|
|
result.severity = 'warning';
|
|
return result;
|
|
}
|
|
|
|
const scriptMatch = NPM_RUN_SCRIPT_RE.exec(stripPrefixFlag(restTrimmed));
|
|
if (scriptMatch) {
|
|
const scriptName = scriptMatch[1];
|
|
result.script = scriptName;
|
|
const scripts = parsed['scripts'];
|
|
const hasScript =
|
|
scripts !== null &&
|
|
typeof scripts === 'object' &&
|
|
!Array.isArray(scripts) &&
|
|
Object.prototype.hasOwnProperty.call(scripts, scriptName);
|
|
if (!hasScript) {
|
|
result.status = 'ok';
|
|
result.reason = 'script_missing';
|
|
result.severity = 'warning';
|
|
return result;
|
|
}
|
|
}
|
|
|
|
result.status = 'ok';
|
|
result.severity = 'none';
|
|
return result;
|
|
}
|
|
|
|
// ─── Phase probing ────────────────────────────────────────────────────────────
|
|
|
|
const PLAN_FILE_RE = /-PLAN\.md$/i;
|
|
const ARTIFACTS_HEADING_RE = /^##[ \t]+Artifacts this phase produces\s*$/m;
|
|
const NEXT_HEADING_RE = /^##[ \t]+/m;
|
|
const FILES_BLOCK_RE = /<files>([\s\S]*?)<\/files>/g;
|
|
const ARTIFACT_BULLET_RE = /^-[ \t]+(.+)$/gm;
|
|
|
|
function toMessage(err: unknown): string {
|
|
return err instanceof Error ? err.message : String(err);
|
|
}
|
|
|
|
/**
|
|
* Every `<files>…</files>` body (split on commas/whitespace/newlines) plus
|
|
* every `- ` bullet under a `## Artifacts this phase produces` heading (up to
|
|
* the next `## ` heading), scanned across the WHOLE plan text.
|
|
*/
|
|
function extractDeclaredPaths(text: string): string[] {
|
|
const out: string[] = [];
|
|
|
|
FILES_BLOCK_RE.lastIndex = 0;
|
|
let fMatch: RegExpExecArray | null;
|
|
while ((fMatch = FILES_BLOCK_RE.exec(text)) !== null) {
|
|
const body = fMatch[1] ?? '';
|
|
for (const tok of body.split(/[,\s]+/)) {
|
|
const t = tok.trim();
|
|
if (t) out.push(t);
|
|
}
|
|
}
|
|
|
|
const headingMatch = ARTIFACTS_HEADING_RE.exec(text);
|
|
if (headingMatch) {
|
|
const after = text.slice(headingMatch.index + headingMatch[0].length);
|
|
const nextIdx = after.search(NEXT_HEADING_RE);
|
|
const section = nextIdx === -1 ? after : after.slice(0, nextIdx);
|
|
ARTIFACT_BULLET_RE.lastIndex = 0;
|
|
let bMatch: RegExpExecArray | null;
|
|
while ((bMatch = ARTIFACT_BULLET_RE.exec(section)) !== null) {
|
|
const t = (bMatch[1] ?? '').trim();
|
|
if (t) out.push(t);
|
|
}
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
const STATUS_PRIORITY: VerifyCommandStatus[] = ['broken', 'unresolvable', 'pending_creation', 'ok', 'not_applicable'];
|
|
|
|
/**
|
|
* Probe every `<automated>` command in a phase directory's `-PLAN.md` files
|
|
* against the filesystem. Never throws — a read failure degrades to
|
|
* `readError` with the offending file skipped.
|
|
*/
|
|
function probePhaseVerifyCommands(options: ProbePhaseOptions): ProbePhaseResult {
|
|
const { phaseDir, projectRoot } = options;
|
|
|
|
let entries: string[];
|
|
try {
|
|
entries = fs.readdirSync(phaseDir);
|
|
} catch (err) {
|
|
return {
|
|
status: 'unresolvable',
|
|
commands: [],
|
|
counts: { blocker: 0, warning: 0, total: 0 },
|
|
readError: toMessage(err),
|
|
};
|
|
}
|
|
|
|
const planFiles = entries.filter(f => PLAN_FILE_RE.test(f)).sort();
|
|
const commands: ProbedVerifyCommand[] = [];
|
|
const readErrors: string[] = [];
|
|
|
|
for (const file of planFiles) {
|
|
let text: string;
|
|
try {
|
|
text = fs.readFileSync(path.join(phaseDir, file), 'utf-8');
|
|
} catch (err) {
|
|
readErrors.push(toMessage(err));
|
|
continue;
|
|
}
|
|
|
|
const declaredPaths = extractDeclaredPaths(text);
|
|
const extracted = extractAutomatedCommands(text);
|
|
for (const cmd of extracted) {
|
|
const resolved = resolveVerifyCommandTarget(cmd.command, { projectRoot, declaredPaths });
|
|
commands.push({ ...resolved, plan: file, task: cmd.task });
|
|
}
|
|
}
|
|
|
|
const counts: ProbeCounts = { blocker: 0, warning: 0, total: commands.length };
|
|
for (const c of commands) {
|
|
if (c.severity === 'blocker') counts.blocker += 1;
|
|
else if (c.severity === 'warning') counts.warning += 1;
|
|
}
|
|
|
|
let status: VerifyCommandStatus = 'ok';
|
|
if (commands.length > 0) {
|
|
const present = new Set(commands.map(c => c.status));
|
|
status = STATUS_PRIORITY.find(s => present.has(s)) ?? 'ok';
|
|
}
|
|
|
|
return {
|
|
status,
|
|
commands,
|
|
counts,
|
|
readError: readErrors.length > 0 ? readErrors.join('; ') : null,
|
|
};
|
|
}
|
|
|
|
// ─── Prior-phase harvesting ───────────────────────────────────────────────────
|
|
|
|
const DEFAULT_LIMIT = 20;
|
|
const DEFAULT_LOOKBACK = 3;
|
|
|
|
/**
|
|
* Walk backward from `beforePhase` (descending, at most `lookback` phase
|
|
* directories) looking for the nearest prior phase whose plans carry any
|
|
* `<automated>` command. Returns that phase's commands, deduped by command
|
|
* text (first-seen order) and capped at `limit`. Never throws.
|
|
*
|
|
* Phase directories are enumerated and ordered via the canonical grammar in
|
|
* `phase-id.cjs` (#2401 review fix) rather than a bespoke `phase-N-slug`
|
|
* regex — real GSD phase directories are `01-foundation`, `3-thing`,
|
|
* `2.1-thing`, `12A-thing`, or project-code-prefixed (`CK-01-name`), never
|
|
* `phase-N-slug`. A directory name is treated as a phase directory only when
|
|
* it (after stripping an optional project-code prefix) starts with a digit;
|
|
* `notes`, `archive`, etc. are ignored. `extractPhaseToken` reads each
|
|
* directory's phase token and `comparePhaseNum` both filters (`< beforePhase`)
|
|
* and orders (descending) so decimal/lettered/sentinel tokens (`2.1`, `12A`,
|
|
* `999.1`) compare correctly instead of via lossy `Number()` parsing.
|
|
*/
|
|
function harvestPriorVerifyCommands(options: HarvestOptions): HarvestResult {
|
|
const { planningDir, beforePhase, limit = DEFAULT_LIMIT, lookback = DEFAULT_LOOKBACK } = options;
|
|
|
|
let entries: fs.Dirent[];
|
|
try {
|
|
entries = fs.readdirSync(planningDir, { withFileTypes: true });
|
|
} catch (err) {
|
|
return { commands: [], readError: toMessage(err) };
|
|
}
|
|
|
|
const beforePhaseStr = String(beforePhase);
|
|
const candidates: Array<{ token: string; dir: string }> = [];
|
|
for (const ent of entries) {
|
|
let isDir = false;
|
|
try {
|
|
isDir = ent.isDirectory();
|
|
} catch {
|
|
isDir = false;
|
|
}
|
|
if (!isDir) continue;
|
|
// Not a phase directory at all (e.g. 'notes', 'archive') — no reliable
|
|
// phase token can be read from a name that doesn't start with a digit
|
|
// once any project-code prefix ('CK-', 'PROJ-') is stripped.
|
|
if (!/^\d/.test(stripProjectCodePrefix(ent.name))) continue;
|
|
const token = extractPhaseToken(ent.name);
|
|
if (comparePhaseNum(token, beforePhaseStr) < 0) {
|
|
candidates.push({ token, dir: ent.name });
|
|
}
|
|
}
|
|
candidates.sort((a, b) => comparePhaseNum(b.token, a.token));
|
|
|
|
const readErrors: string[] = [];
|
|
let examined = 0;
|
|
|
|
for (const candidate of candidates) {
|
|
if (examined >= lookback) break;
|
|
examined += 1;
|
|
|
|
const phaseDirPath = path.join(planningDir, candidate.dir);
|
|
let planFiles: string[];
|
|
try {
|
|
planFiles = fs.readdirSync(phaseDirPath).filter(f => PLAN_FILE_RE.test(f)).sort();
|
|
} catch (err) {
|
|
readErrors.push(toMessage(err));
|
|
continue;
|
|
}
|
|
|
|
const seen = new Set<string>();
|
|
const found: HarvestedCommand[] = [];
|
|
for (const file of planFiles) {
|
|
let text: string;
|
|
try {
|
|
text = fs.readFileSync(path.join(phaseDirPath, file), 'utf-8');
|
|
} catch (err) {
|
|
readErrors.push(toMessage(err));
|
|
continue;
|
|
}
|
|
for (const cmd of extractAutomatedCommands(text)) {
|
|
if (seen.has(cmd.command)) continue;
|
|
seen.add(cmd.command);
|
|
found.push({
|
|
phase: candidate.token,
|
|
plan: path.join(phaseDirPath, file),
|
|
task: cmd.task,
|
|
command: cmd.command,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (found.length > 0) {
|
|
return {
|
|
commands: found.slice(0, limit),
|
|
readError: readErrors.length > 0 ? readErrors.join('; ') : null,
|
|
};
|
|
}
|
|
}
|
|
|
|
return { commands: [], readError: readErrors.length > 0 ? readErrors.join('; ') : null };
|
|
}
|
|
|
|
export = {
|
|
extractAutomatedCommands,
|
|
resolveVerifyCommandTarget,
|
|
probePhaseVerifyCommands,
|
|
harvestPriorVerifyCommands,
|
|
resolveFailingDirection,
|
|
extractFailingDirections,
|
|
probePhaseFailingDirections,
|
|
};
|