* feat(#2401): ground <automated> verify-command paths and inherit prior-phase commands Adds a deterministic resolvability probe over each PLAN.md <automated> verify command and surfaces the nearest prior phase's proven commands to the planner at every context window. - src/verify-command-grounding.cts: recognizer (not a shell interpreter) that grounds a leading cd <literal> chain and npm --prefix <literal>, and reports unresolvable rather than guessing. Never executes command text. - gsd-tools check verify-command-paths <N>: per-phase probe, wired into plan-phase.md before the plan-check pass. - init.plan-phase gains prior_verify_commands, ungated by context_window. - gsd-plan-checker: new Verify Command Path Resolvability dimension that reports the failing target and never prescribes a replacement. Also fixes first-match-wins prefix bucketing in scripts/lint-test-file-count.cjs (readdir order is not stable across platforms, so a module whose name extends another's with a hyphen bucketed differently on Linux than on macOS). Closes #2401 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2401): ground the canonical --prefix form, quoted paths, and absolute cd resets Independent review found three defects in the recognizer: - npm --prefix DIR run SCRIPT never reached the script-existence check, because the pattern required npm and run to be adjacent. That is the form the docs tell planners to prefer, so script_missing never fired for it. The prefix flag and its value are now stripped before matching. - --prefix captured with \S+, so a quoted path containing a space was truncated to a stray opening quote and reported as a missing directory - a false blocker, worse than the bug this feature fixes. The capture is now quote-aware. - A chained cd whose later segment was absolute concatenated instead of resetting, producing a nonsense path and another false blocker. The fold now resets on an absolute segment. Also replaces the bespoke phase-directory regex with the canonical phase-id helpers. Real phase directories are NN-slug, not phase-N-slug, so the prior-command harvest matched nothing outside its own fixtures and the planner-inheritance half of this feature was dead code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(#2401): source task blocks from the canonical sectionizer The module carried its own copy of the <task>-block grammar - a fourth hand-rolled mirror of the one markdown-sectionizer owns. verify.cts keeps its copy only because it needs the type= attribute the canonical helper discards; this module never reads that attribute, so it can share the owner outright instead of adding a test around a copy. extractAutomatedCommands now takes task bodies from extractTaggedBlocks and the out-of-task remainder from stripTaggedBlocks. A task-grammar parity test pins the attributed task-name set against the canonical helper across six awkward task shapes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2401): extract agent-file overflow to references and repair the property arbitrary The remote matrix run came back red with 19 failures, four root causes: - agents/gsd-plan-checker.md and agents/gsd-planner.md both blew the 49152 agent cap. Their bodies move to gsd-core/references/, leaving @-reference stubs, per the documented overflow pattern. - The new checker dimension invoked gsd_run before the canonical preamble that defines it. The call is deleted outright: plan-phase.md already runs the probe and hands the result in as {VERIFY_PATHS}, so the dimension consumes that rather than re-running anything. - fc.fullUnicodeString does not exist in fast-check 4.8.0. Replaced with fc.string({ unit: 'binary' }), which covers the same 0000-10FFFF range. - Three runtime-loaded files grew; acknowledged in the existing ack fragments that already own those bare filenames, since two ack sources may never name the same path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#2401): regenerate golden install-tree fixtures for the new references Adding two files under gsd-core/references/ changes what the installer emits into every runtime's tree, so all 19 golden install-parity fixtures went stale. Regenerated with npm run gen:install-tree; the delta is exactly the two new reference paths per runtime, no removals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#2401): backfill changeset pr number to 3678 * fix(#2401): treat ~ as a home expansion only at the start of a path Windows CI caught this on both shards; the Linux-only remote matrix cannot see it. The dynamic-path refusal rejected ~ anywhere, and a GitHub Windows runner's tmpdir is an 8.3 short name - C:\Users\RUNNER~1\AppData\Local\Temp - so a valid absolute Windows path came back unresolvable/dynamic_path. This was a production bug, not a test artifact: any Windows user whose project path carries an 8.3 short name, or any literal ~, silently lost the probe entirely - every command degrading to unresolvable with no explanation. ~ is a home expansion only at the start of a path; elsewhere it is an ordinary literal. The check is now split: $, backtick, *, ? and newline stay refused anywhere (substitution and globs, and the glob characters are illegal in Windows path components regardless), while ~ is refused only leading, tolerating one leading quote since the check runs before quote stripping. The prior tests only caught this on Windows because only Windows puts a ~ in tmpdir. Four new tests pin it on every platform via a fixture directory literally named RUNNER~1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,9 @@ const { scanPhasePlans } = planScanMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningScopeMod = require('./planning-scope.cjs');
|
||||
const { SCOPE } = planningScopeMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import verifyCommandGroundingMod = require('./verify-command-grounding.cjs');
|
||||
const { probePhaseVerifyCommands } = verifyCommandGroundingMod;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -935,6 +938,72 @@ function cmdTddReviewCheckpoint(projectDir: string, args: string[], raw: boolean
|
||||
output(result, raw, undefined);
|
||||
}
|
||||
|
||||
// ─── verify-command-paths (#2401) ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* verify-command-paths: probes every `<automated>` verify command declared in a
|
||||
* phase's `-PLAN.md` files against the filesystem WITHOUT executing anything —
|
||||
* see verify-command-grounding.cjs for the recognizer contract.
|
||||
*
|
||||
* Args: check verify-command-paths <phase>
|
||||
* Invocable as: gsd_run check verify-command-paths <phase>
|
||||
*
|
||||
* When the phase cannot be resolved to a directory, this emits a non-throwing
|
||||
* degraded JSON payload (status/commands/counts all zeroed, `readError`
|
||||
* populated) rather than calling `error()` — the plan-checker parses this
|
||||
* result and must be able to distinguish "nothing to report" from "could not
|
||||
* look", which a non-zero exit / thrown error would collapse.
|
||||
*/
|
||||
function cmdVerifyCommandPaths(projectDir: string, args: string[], raw: boolean): void {
|
||||
// args[0] = 'check', args[1] = 'verify-command-paths', args[2] = phase
|
||||
const phase = args[2] || '';
|
||||
if (!phase) {
|
||||
output(
|
||||
{
|
||||
status: 'unresolvable',
|
||||
commands: [],
|
||||
counts: { blocker: 0, warning: 0, total: 0 },
|
||||
readError: 'verify-command-paths requires a phase argument: check verify-command-paths <phase>',
|
||||
},
|
||||
raw,
|
||||
undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let phaseDir = '';
|
||||
try {
|
||||
const result = findPhaseInternal(projectDir, phase);
|
||||
if (result && typeof result === 'object') {
|
||||
// findPhaseInternal returns { directory: '<relative-posix-path>', ... }
|
||||
// directory is relative to cwd — resolve it to absolute.
|
||||
const relDir = typeof result['directory'] === 'string' ? result['directory'] : '';
|
||||
if (relDir) {
|
||||
phaseDir = path.resolve(projectDir, relDir);
|
||||
}
|
||||
} else if (typeof result === 'string') {
|
||||
phaseDir = result;
|
||||
}
|
||||
} catch { /* phase dir lookup failure → degraded payload below */ }
|
||||
|
||||
if (!phaseDir) {
|
||||
output(
|
||||
{
|
||||
status: 'unresolvable',
|
||||
commands: [],
|
||||
counts: { blocker: 0, warning: 0, total: 0 },
|
||||
readError: `could not resolve phase directory for phase ${phase}`,
|
||||
},
|
||||
raw,
|
||||
undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const probed = probePhaseVerifyCommands({ phaseDir, projectRoot: projectDir });
|
||||
output(probed, raw, undefined);
|
||||
}
|
||||
|
||||
// ─── gap-analysis-plan-post ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -1522,6 +1591,12 @@ function routeCheckCommand({ args, cwd, raw }: RouteCheckCommandOptions): void {
|
||||
cmdGapAnalysisPlanPost(cwd, args, raw);
|
||||
return;
|
||||
}
|
||||
if (subcommand === 'verify-command-paths') {
|
||||
// Deterministic filesystem probe for <automated> verify commands (#2401) —
|
||||
// never executes anything; see verify-command-grounding.cjs.
|
||||
cmdVerifyCommandPaths(cwd, args, raw);
|
||||
return;
|
||||
}
|
||||
if (subcommand === 'api-coverage-verify-pre') {
|
||||
// ai-integration capability blocking gate at verify:pre (#1562). Dot-to-
|
||||
// hyphen normalization means query "api-coverage.verify-pre" routes here.
|
||||
@@ -1568,7 +1643,7 @@ function routeCheckCommand({ args, cwd, raw }: RouteCheckCommandOptions): void {
|
||||
routeProhibitionEnforcement(args, raw);
|
||||
return;
|
||||
}
|
||||
error('Unknown check subcommand. Available: api-coverage-verify-pre, auto-mode, decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, predicate, prohibition-enforcement, tdd-review-checkpoint, ui-plan-gate, ui-safety-gate, verify-schema-drift, verify-codebase-drift', ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
||||
error('Unknown check subcommand. Available: api-coverage-verify-pre, auto-mode, decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, predicate, prohibition-enforcement, tdd-review-checkpoint, ui-plan-gate, ui-safety-gate, verify-command-paths, verify-schema-drift, verify-codebase-drift', ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
||||
}
|
||||
|
||||
export = {
|
||||
@@ -1578,6 +1653,7 @@ export = {
|
||||
computeUiPlanGate,
|
||||
computeUiSafetyGate,
|
||||
cmdGapAnalysisPlanPost,
|
||||
cmdVerifyCommandPaths,
|
||||
cmdTddReviewCheckpoint,
|
||||
cmdCheckPredicate,
|
||||
buildPredicateDeps,
|
||||
|
||||
26
src/init.cts
26
src/init.cts
@@ -77,6 +77,9 @@ const {
|
||||
hasPackageFileInternal,
|
||||
listCodebaseMapFiles,
|
||||
} = onboardProjection;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- verify-command-grounding.cjs is an export= CommonJS module
|
||||
import verifyCommandGrounding = require('./verify-command-grounding.cjs');
|
||||
const { harvestPriorVerifyCommands } = verifyCommandGrounding;
|
||||
|
||||
const { output, error, ERROR_REASON } = io;
|
||||
const { loadConfig, loadConfigResolved } = configLoader;
|
||||
@@ -1242,6 +1245,29 @@ function cmdInitPlanPhase(
|
||||
// #2992 (Phase 6.1): additive, optional field — degrades to null, never throws.
|
||||
result['section_manifest'] = buildSectionManifestField(cwd, phaseInfo, options, 'plan-phase');
|
||||
|
||||
// #2401: prior-phase verify commands, surfaced UNGATED — additive field, never
|
||||
// conditioned on context_window. Before this, the planner only inherited
|
||||
// prior-phase verify-command context when context_window >= 500000, so at
|
||||
// lower context windows it re-invented (and mis-resolved) the command. The
|
||||
// harvest already degrades to `{commands: [], readError}` rather than
|
||||
// throwing; the try/catch is defense-in-depth so init never breaks on this.
|
||||
let priorVerifyCommands: unknown[] = [];
|
||||
try {
|
||||
// #2401 review fix: harvestPriorVerifyCommands accepts a phase-id token
|
||||
// (string) directly, so a decimal phase like '2.1' is no longer silently
|
||||
// dropped by `Number('2.1')` producing a value the old `number`-only
|
||||
// parameter mishandled for lettered/decimal tokens.
|
||||
if (phaseNumberPlan !== null) {
|
||||
priorVerifyCommands = harvestPriorVerifyCommands({
|
||||
planningDir: planningPaths(cwd).phases,
|
||||
beforePhase: phaseNumberPlan,
|
||||
}).commands;
|
||||
}
|
||||
} catch {
|
||||
priorVerifyCommands = [];
|
||||
}
|
||||
result['prior_verify_commands'] = priorVerifyCommands;
|
||||
|
||||
output(withProjectRoot(cwd, result), raw);
|
||||
}
|
||||
|
||||
|
||||
754
src/verify-command-grounding.cts
Normal file
754
src/verify-command-grounding.cts
Normal file
@@ -0,0 +1,754 @@
|
||||
/**
|
||||
* 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>/;
|
||||
const AUTOMATED_BLOCK_RE = /<automated[^>]{0,200}>([\s\S]*?)<\/automated>/g;
|
||||
|
||||
/**
|
||||
* Bounds walking pathological input (e.g. hundreds of unclosed `<automated>`
|
||||
* openers). `extractTaggedBlocks`/`stripTaggedBlocks` (task-block grammar
|
||||
* owner, `./markdown-sectionizer.cjs`) already use a ReDoS-safe
|
||||
* stop-at-next-open pattern with no separate iteration cap of their own — a
|
||||
* document full of unclosed `<task>` openers never matches, so their walk is
|
||||
* a single linear scan regardless. 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;
|
||||
}
|
||||
|
||||
// ─── 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:@./-]+)/;
|
||||
const MISSING_SENTINEL_RE = /^MISSING\b/;
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user