* 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:
5
.changeset/bold-pandas-snooze.md
Normal file
5
.changeset/bold-pandas-snooze.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 3678
|
||||
---
|
||||
**Verify-command path grounding for phase planning** — a plan's `<automated>` verify command whose target directory does not exist (or holds no `package.json`) is now caught deterministically before execution instead of being hand-reasoned by the plan checker, which previously prescribed wrong replacement paths. The planner also inherits the nearest prior phase's proven verify commands at every context window, not only above 500k. (#2401)
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -251,6 +251,7 @@ build/
|
||||
/gsd-core/bin/lib/gate-predicate-evaluator.cjs
|
||||
/gsd-core/bin/lib/docs.cjs
|
||||
/gsd-core/bin/lib/check-command-router.cjs
|
||||
/gsd-core/bin/lib/verify-command-grounding.cjs
|
||||
/gsd-core/bin/lib/frontmatter.cjs
|
||||
/gsd-core/bin/lib/learnings.cjs
|
||||
/gsd-core/bin/lib/gsd2-import.cjs
|
||||
|
||||
@@ -26,6 +26,9 @@ Module owning phase-effort estimation and its calibration against measured reali
|
||||
### Verification Module
|
||||
Module owning the canonical phase-verification status projection shared by phase transition, progress, manager, autonomous, and closeout readiness paths. `readVerificationStatus(phaseDir, opts?)` reads the first `*-VERIFICATION.md` frontmatter `status`, maps it through `VERIFICATION_ROUTING_TABLE`, and fail-closes — only `{passed}` satisfies the canonical gate; `missing`/`unknown`/`gaps_found`/`human_needed`/`stale` all route away from "complete" (#1522). `findStaleVerificationSummary` flags a SUMMARY newer than the VERIFICATION file (status `stale`). Both honor a no-throw, degrade-to-safe contract (any FS error → `missing` / not-stale) and an injectable `opts.fs` seam. `isPhaseComplete(phaseDir, deps?)` is the single canonical owner of "is phase P complete?" (ADR-3180 §7.4, issue #3186, disk-strict per #2957): it wraps `readVerificationStatus`, calling it UNCONDITIONALLY — plan count is never a precondition, so a zero-plan phase with a passing `*-VERIFICATION.md` is complete (#3168) — and returns `{ value: { complete, verification }, scope }`; `complete` is exactly `verification.status === 'passed'`. A ROADMAP checkbox carries no machine authority and is never consulted. `cmdPhaseComplete`, `buildPhaseCompletionProjection`, and `buildStateFrontmatter` all route through it. Source of truth: `gsd-core/bin/lib/verification.cjs` (generated from `src/verification.cts`).
|
||||
|
||||
### Verify Command Grounding Module
|
||||
Module owning the deterministic resolvability probe over PLAN.md `<automated>` verify commands (#2401), plus the prior-phase command harvest that feeds the planner. `extractAutomatedCommands(planText)` pulls every `<automated>…</automated>` body with its owning `<task><name>`, in document order, via a ReDoS-safe stop-at-next-open task pattern (shape mirrors `PLAN_TASK_BLOCK_RE` in `verify.cjs`) and a monotonic span pointer; non-string input yields `[]`. `resolveVerifyCommandTarget(command, {projectRoot, declaredPaths})` is a **RECOGNIZER, not a shell interpreter** (deliberate, per Greenspun): it grounds exactly two forms — a folded leading `cd <literal>` chain and `npm --prefix <literal>` — and any path carrying `$`, a backtick, `*`, `?`, `~`, or a newline returns `unresolvable`/`dynamic_path` at WARNING severity, never BLOCKER. Status is a closed 5-atom enum (`ok`/`broken`/`unresolvable`/`not_applicable`/`pending_creation`) and severity a closed 3-atom enum (`blocker`/`warning`/`none`); `broken` is only ever `missing_dir` or `no_manifest`, while `script_missing`/`manifest_unreadable`/`outside_root` stay advisory on an `ok` status. A target an earlier task in the same phase declares (`<files>` or the `## Artifacts this phase produces` section) is `pending_creation`, never a blocker — without that, every greenfield phase would red. A bare ancestor climb (`cd ../..`, every segment `..`) short-circuits to `outside_root` without touching the filesystem, because the checker's root and a parallel executor worktree's root differ; a climb naming a concrete sibling (`cd ../../frontend` — the exact #2401 shape) still names something checkable and is probed normally. **The module never executes command text** (`fs.statSync`/`existsSync`/`readFileSync`/`readdirSync` only — PLAN.md is model-authored untrusted input) and deliberately exposes **no `suggestion` field**: prescribing a replacement path is the failure being fixed, not the fix. `probePhaseVerifyCommands({phaseDir, projectRoot})` backs `gsd-tools check verify-command-paths <N>` (routed in `check-command-router.cjs`), degrading to a populated `readError` rather than throwing — an empty `commands` with a non-empty `readError` means *could not look*, not *nothing to report*. `harvestPriorVerifyCommands({planningDir, beforePhase, limit=20, lookback=3})` walks descending phase dirs for the nearest prior phase with any command, deduped first-seen and capped, and is emitted as `init.plan-phase`'s `prior_verify_commands` **ungated by `context_window`** — the `>= 500000` enrichment gate is exactly what starved the planner at 200k. Source of truth: `gsd-core/bin/lib/verify-command-grounding.cjs` (generated from `src/verify-command-grounding.cts`).
|
||||
|
||||
### Phase Locator Module
|
||||
Module owning phase-directory search and location: active-phase discovery against the `.planning/phases/` tree (`searchPhaseInDir`, `findPhaseInternal`) and archived-phase-dir enumeration (`getArchivedPhaseDirs`), matching phase ids/tokens against the filesystem. Depends only on leaf modules (`phase-id` for token/name matching, `core-utils` for fs-scan/path helpers, `planning-workspace` for `planningDir`) — no `loadConfig`, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2d (#881); the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/phase-locator.cjs` (generated from `src/phase-locator.cts`). Since #2830, `searchPhaseInDir` also parses each plan's `depends_on` and each completed plan's SUMMARY `status` and calls Plan Dependency Graph Module's `computeHaltPropagation` to populate `halted_plans`/`blocked_by`/`runnable_plans` — additive fields; `incomplete_plans` keeps its pre-#2830 meaning unchanged. Since #3185 (ADR-3180 Decision 1, Phase 3), the module also owns `listMilestonePhaseDirs(phasesDir, { cwd, ws, versionOverride, phaseIdConvention })`, the single canonical owner of milestone-scoped phase-directory enumeration: it applies the current milestone's `ROADMAP.md` window (via `getMilestonePhaseFilter`) and then the canonical `isSentinelPhaseId` sentinel filter, in that order, over the raw `phasesDir` directory listing. It returns `{ value: string[], scope }`, where `scope` is the `SCOPE` enum from `src/planning-scope.cts` (`complete`/`truncated`/`unscoped`/`unreadable`), so a caller can distinguish a genuinely empty milestone from an enumeration that could not be scoped. Consumed by `query progress`, `stats`, and the bare `phases list`, all of which need "which phases belong to this milestone." `phases list --phase` and `--include-archived` (lookup/archive questions) read the unscoped physical directory set and do not call this owner. `phases clear` and `milestone complete`'s phase-archival move call `isSentinelPhaseId` directly instead — they must sweep every non-sentinel phase directory regardless of milestone window, so they take the sentinel filter without this owner's window scoping.
|
||||
|
||||
|
||||
@@ -715,6 +715,11 @@ issue:
|
||||
2. For each `<automated>` block containing `2>/dev/null || echo` where the result feeds a `[ "$VAR" = ... ]` comparison: BLOCKER.
|
||||
3. For each `<automated>` block asserting a specific numeric count not cited as measured in this plan: WARNING.
|
||||
|
||||
## Dimension: Verify Command Path Resolvability (#2401)
|
||||
|
||||
**Question:** Does each `<automated>` command's target resolve? Consume the supplied
|
||||
`{VERIFY_PATHS}` probe, never re-run/hand-reason it: @gsd-core/references/verify-command-path-resolvability.md
|
||||
|
||||
## Dimension: Numeric/Factual Claim Authority (#1480)
|
||||
|
||||
**Rule:** RESEARCH.md is produced at research time and may be stale. Numeric claims (test counts, file counts, version numbers) and factual state claims ("feature X is implemented") in RESEARCH.md may not reflect the current codebase. The plan may be more current. RESEARCH.md is authoritative for architectural decisions and constraints — not for measurements.
|
||||
|
||||
@@ -190,6 +190,8 @@ Every task has four required fields:
|
||||
|
||||
**Nyquist Rule:** Every `<verify>` includes `<automated>`. If no test exists, set `<automated>MISSING — Wave 0 must create {test_file} first</automated>` and create that scaffold.
|
||||
|
||||
**Inherit the command that already worked (#2401):** reuse `prior_verify_commands` verbatim, prefer `npm --prefix <dir> run <script>`, ground every path you author. @gsd-core/references/planner-verify-command-grounding.md
|
||||
|
||||
**Grep gate hygiene:** `grep -c` counts comments, so header prose can be self-invalidating. Use `grep -v '^#' | grep -c token`. Bare `== 0` gates on unfiltered files are forbidden.
|
||||
|
||||
<comment_text_discipline>
|
||||
|
||||
@@ -1172,6 +1172,64 @@ Extract reusable patterns, anti-patterns, and architectural decisions from compl
|
||||
|
||||
---
|
||||
|
||||
### `gsd-tools check verify-command-paths`
|
||||
|
||||
Deterministic resolvability probe over a phase's `<automated>` verify commands (#2401). Run
|
||||
automatically by `/gsd-plan-phase` before the plan-check pass and handed to `gsd-plan-checker`;
|
||||
runnable by hand to see what the checker saw.
|
||||
|
||||
| Argument | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `N` | **Yes** | Phase number whose `-PLAN.md` files are probed |
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--raw` | Emit the JSON payload with no surrounding prose |
|
||||
|
||||
**Prerequisites:** none — an unresolvable phase degrades to a JSON payload with `readError` set
|
||||
rather than failing.
|
||||
**Produces:** JSON on stdout. Nothing is written to disk.
|
||||
|
||||
**It never executes command text.** PLAN.md is LLM-authored, so the probe only resolves paths
|
||||
and stats directories; a `package.json` it finds is read for script *names* only.
|
||||
|
||||
It grounds exactly two forms — a leading `cd <literal>` chain and `npm --prefix <literal>` —
|
||||
and refuses to guess at anything else. `pushd`, `make -C`, `yarn --cwd`, `pnpm -C`, and
|
||||
`cargo --manifest-path` are not recognized today and report `unresolvable`.
|
||||
|
||||
Each row of `commands` carries `command`, `plan`, `task`, `status`, `severity`, `reason`,
|
||||
`form`, `rawTarget`, `target`, `manifest`, `script`, `sentinel`, and `base`. There is
|
||||
deliberately **no** `suggestion` field — the probe reports what failed to resolve and leaves
|
||||
the replacement to the planner.
|
||||
|
||||
| `status` | Meaning |
|
||||
|---|---|
|
||||
| `ok` | Target resolved (a `reason` may still carry an advisory — see below) |
|
||||
| `broken` | Target does not resolve, or holds no required manifest — **blocker** |
|
||||
| `unresolvable` | The path could not be grounded (variable, glob, substitution, `~`) — warning |
|
||||
| `pending_creation` | An earlier task in this phase creates the target — not a finding |
|
||||
| `not_applicable` | No `cd`/`--prefix` to resolve, or a Nyquist `MISSING …` sentinel |
|
||||
|
||||
| `reason` | `severity` | What it means |
|
||||
|---|---|---|
|
||||
| `missing_dir` | `blocker` | The resolved directory does not exist, or is not a directory |
|
||||
| `no_manifest` | `blocker` | The directory exists but holds no `package.json` / `Makefile` the command needs |
|
||||
| `dynamic_path` | `warning` | The path contains `$`, a backtick, `*`, `?`, or `~` — refused, not guessed |
|
||||
| `outside_root` | `warning` | A bare ancestor climb (`cd ../..`); the base differs under worktree execution |
|
||||
| `script_missing` | `warning` | `npm run <script>` names a script the manifest does not define — this phase may add it |
|
||||
| `manifest_unreadable` | `warning` | `package.json` is oversized, unparseable, or not a JSON object |
|
||||
| `null` | `none` | Nothing to report |
|
||||
|
||||
A non-empty `readError` means the probe **could not look** — distinct from finding nothing.
|
||||
|
||||
```bash
|
||||
gsd-tools check verify-command-paths 3 --raw # probe phase 3's verify commands
|
||||
```
|
||||
|
||||
See [Resolve verify-command path findings](how-to/resolve-verify-command-path-findings.md).
|
||||
|
||||
---
|
||||
|
||||
## Workstream Management
|
||||
|
||||
### `/gsd-workstreams`
|
||||
|
||||
@@ -3414,3 +3414,29 @@ The load-bearing wire is the `plan-phase` lift into `must_haves.prohibitions`, s
|
||||
- A `STATE.md` with no `### Quick Tasks Completed` section at all is a normal, silent no-op for the reset step — the section is created lazily by `/gsd-quick`, not present in the project template.
|
||||
|
||||
See [Archiving quick tasks](how-to/handle-quick-and-fast-tasks.md#archiving-quick-tasks) for the full walkthrough.
|
||||
|
||||
---
|
||||
|
||||
### 161. Verify-Command Path Grounding
|
||||
|
||||
**Command:** `/gsd-plan-phase` (automatic), `gsd-tools check verify-command-paths <N>` (#2401)
|
||||
|
||||
**Behavior:** A planner authoring a per-task `<automated>` verify command has no line of sight to whether the path it just wrote actually resolves, and `gsd-plan-checker` had no deterministic way to check — so it hand-reasoned the filesystem and, in the motivating case, prescribed two successively-wrong replacement paths (the second citing a `package.json` that did not exist). Two changes close that:
|
||||
|
||||
1. **Prior-command inheritance.** The nearest prior phase's `<automated>` commands are surfaced to the planner as `prior_verify_commands`, **at every context window**. Cross-phase enrichment was previously gated on `context_window >= 500000`; at 200k the planner re-invented the command and got it wrong. This payload is a handful of one-liners, so it is never gated.
|
||||
2. **A deterministic probe.** `gsd-tools check verify-command-paths <N>` resolves each `<automated>` command's target directory and reports whether it exists and holds the manifest the command needs. `/gsd-plan-phase` runs it before the plan-check pass and hands the JSON to the checker, which acts on `severity` instead of guessing.
|
||||
|
||||
**It never executes command text.** PLAN.md is model-authored, so running it from the checker would be arbitrary code execution — and would trigger the real lint/build as a side effect. The probe only resolves paths and stats directories; a `package.json` it finds is read for script names only.
|
||||
|
||||
**Why a recognizer, not a shell parser.** Interpreting shell would mean maintaining a bad shell. Exactly two forms are grounded — a leading `cd <literal>` chain and `npm --prefix <literal>` — and any path carrying a variable, glob, substitution, or `~` returns `unresolvable`, which is a warning and never a blocker. The parser's incompleteness is the specification: it degrades to "cannot prove" rather than growing features. Refusing to guess is the fix, not a limitation of it.
|
||||
|
||||
**It reports, it never prescribes.** The payload carries the target that failed and what was missing; there is deliberately no `suggestion` field. Choosing the replacement is the planner's job — and the planner now has the prior phase's proven command to reach for.
|
||||
|
||||
**Not findings:** a target an earlier task in this phase creates (`pending_creation`), a command with no `cd`/`--prefix` at all, and the Nyquist `MISSING — Wave 0 …` sentinel, which Dimension 8 owns.
|
||||
|
||||
**Known limits:**
|
||||
- Only `cd <literal>` and `npm --prefix <literal>` are recognized. `pushd`, `make -C`, `yarn --cwd`, `pnpm -C`, and `cargo --manifest-path` report `unresolvable`.
|
||||
- Verdicts are relative to the *checker's* project root. Under parallel worktree execution the executor's root differs, so a bare ancestor climb (`cd ../..`) is reported `outside_root` as a warning rather than asserted about.
|
||||
- `script_missing` is advisory only — this phase may be adding the script — so a genuinely mistyped npm script still reaches the executor.
|
||||
|
||||
See [Resolve verify-command path findings](how-to/resolve-verify-command-path-findings.md) and [`gsd-tools check verify-command-paths`](COMMANDS.md#gsd-tools-check-verify-command-paths).
|
||||
|
||||
@@ -262,6 +262,7 @@
|
||||
"planner-reviews.md",
|
||||
"planner-revision.md",
|
||||
"planner-source-audit.md",
|
||||
"planner-verify-command-grounding.md",
|
||||
"planning-config.md",
|
||||
"prohibition-probe.md",
|
||||
"project-skills-discovery.md",
|
||||
@@ -298,6 +299,7 @@
|
||||
"verification-patterns.md",
|
||||
"verifier-phase-gates.md",
|
||||
"verifier-wiring-patterns.md",
|
||||
"verify-command-path-resolvability.md",
|
||||
"verify-mvp-mode.md",
|
||||
"workstream-flag.md",
|
||||
"worktree-branch-check.md",
|
||||
@@ -516,6 +518,7 @@
|
||||
"vendor/re2js.cjs",
|
||||
"verification-command-router.cjs",
|
||||
"verification.cjs",
|
||||
"verify-command-grounding.cjs",
|
||||
"verify-command-router.cjs",
|
||||
"verify.cjs",
|
||||
"workflow-fragments.cjs",
|
||||
|
||||
@@ -328,6 +328,7 @@ Full roster at `gsd-core/references/*.md`. References are shared knowledge docum
|
||||
| `research-documentation-lookup.md` | Shared documentation-lookup protocol (Context7 MCP + guarded CLI fallback) injected into all researcher agents. |
|
||||
| `research-philosophy.md` | Shared research philosophy (training-as-hypothesis, honest reporting, investigation-not-confirmation) injected into researcher agents. |
|
||||
| `research-verification-protocol.md` | Shared research verification protocol (4 pitfalls + pre-submission checklist) injected into researcher agents. |
|
||||
| `verify-command-path-resolvability.md` | Verify Command Path Resolvability dimension (#2401) loaded by `gsd-plan-checker`: how to consume the `{VERIFY_PATHS}` probe result (never re-run or hand-reason the filesystem), the severity/reason table, and report-never-prescribe rules. |
|
||||
|
||||
### Workflow References
|
||||
|
||||
@@ -416,6 +417,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
|
||||
| `planner-graphify-auto-update.md` | How `load_graph_context` surfaces `.last-build-status.json` auto-update state (running / failed / stale head) alongside the existing staleness annotation. Opt-in via `graphify.auto_update` (#3347). |
|
||||
| `planner-interface-context.md` | Interface context rules for executors — how to extract key interfaces/types/exports from existing code and document new interfaces that downstream plans will consume. |
|
||||
| `planner-load-graph-context.md` | Planner's load_graph_context step: knowledge-graph freshness + dependency-context query via the gsd_run launcher (extracted from gsd-planner.md). |
|
||||
| `planner-verify-command-grounding.md` | Verify Command Grounding rules (#2401): inherit `prior_verify_commands` verbatim when the story repeats, prefer `npm --prefix <dir> run <script>` over `cd <dir> && npm run <script>`, and ground every authored path. |
|
||||
| `skeleton-template.md` | SKELETON.md template emitted for new-project Walking Skeleton (Phase 1 + `--mvp`). |
|
||||
| `user-story-template.md` | User story format for MVP planning — "As a / I want to / So that" structured fields. |
|
||||
| `specless-probe-fallback.md` | Spec-less probe fallback protocol — gate (toggle + per-section absence via the shared `spec-section` helper), the deterministic edge probe (mirrors spec-phase 5.5), the in-planner prohibition recall, and the `must_haves` authoring lift; consumed by plan-phase step 7.95 when a phase SPEC omits `## Edge Coverage` / `## Prohibitions` (ADR-857 Phase 6). |
|
||||
@@ -616,6 +618,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
|
||||
| `validate.cjs` | Pure phase variant normalization helpers (`phaseVariants`, `buildRoadmapPhaseVariants`, `buildNotStartedPhaseVariants`) used by `verify.cjs` for W006/W007 checks; no I/O, no async |
|
||||
| `verification-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools verification` |
|
||||
| `verification.cjs` | Verification-status routing — consolidates pass/gaps_found/human_needed status from phase verifier-emitted VERIFICATION.md frontmatter (#651) |
|
||||
| `verify-command-grounding.cjs` | Verify-command path-resolvability probe (#2401) — pure `extractAutomatedCommands` (`<automated>` blocks + owning task, ReDoS-safe), `resolveVerifyCommandTarget` (grounds a leading `cd <literal>` chain or `npm --prefix <literal>` against the project root; three-state `ok`/`broken`/`unresolvable` plus `not_applicable`/`pending_creation`), `probePhaseVerifyCommands` (per-phase report backing `gsd-tools check verify-command-paths`), and `harvestPriorVerifyCommands` (nearest prior phase's commands, surfaced to the planner ungated by `context_window`). Never executes command text — PLAN.md is model-authored — and never prescribes a replacement path. Compiled from `src/verify-command-grounding.cts` |
|
||||
| `verify-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools verify` |
|
||||
| `verify.cjs` | Plan structure, phase completeness, reference, commit validation |
|
||||
| `workflow-fragments.cjs` | In-file `<!-- gsd:section id= when= -->` marker parser/composer for GSD workflow markdown (ADR-1671, #2930) — `parseWorkflowSections` (fence/HTML-comment-aware document partition into explicit/gap sections, fail-closed on malformed/unclosed/nested/duplicate markers or an unknown `when=`), `toFragments` (maps sections to `context-composer.cjs` `verbatim` fragments — non-lossy by construction), and `renderFragments`/`composeWorkflow` (compose-within-budget then join, run BEFORE per-runtime converters so a marker attribute never reaches a path-rewrite regex). `WHEN_VOCABULARY` is a frozen 4-atom applicability set (`always`, `flag:--wave`, `state:gap-closure-phase`, `state:has-prior-phases`); widening it is an ADR amendment, not an organic edit. Compiled from `src/workflow-fragments.cts` |
|
||||
|
||||
@@ -24,6 +24,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md)
|
||||
- [Resolve edge-coverage findings](how-to/resolve-edge-coverage-findings.md) — turn the spec phase's surfaced domain-boundary edges into covered, dismissed, or backstopped spec decisions
|
||||
- [Resolve prohibition findings](how-to/resolve-prohibition-findings.md) — turn the spec phase's surfaced must-NOT constraints into resolved, dismissed, or deferred spec decisions
|
||||
- [Resolve an unreachable-workflow finding](how-to/resolve-unreachable-workflow-findings.md) — wire or fully sweep a shipped workflow that no command, agent, or skill references
|
||||
- [Resolve verify-command path findings](how-to/resolve-verify-command-path-findings.md) — fix an `<automated>` verify command whose target directory does not resolve from the executor's cwd
|
||||
- [Resolve a contract-drift finding](how-to/resolve-contract-drift-findings.md) — bring an agent's completion contract, read-tag gate, or deleted-file test reference back into agreement with the registry
|
||||
- [Resolve unreachable-guard findings](how-to/resolve-unreachable-guard-findings.md) — fix shell guards whose fallback arm cannot run, and tell "nothing to report" apart from "could not look"
|
||||
- [Resolve an ESLint glob-coverage finding](how-to/resolve-eslint-coverage-findings.md) — bring a source file that matches no lint rule under coverage, or record a reasoned exemption
|
||||
|
||||
105
docs/how-to/resolve-verify-command-path-findings.md
Normal file
105
docs/how-to/resolve-verify-command-path-findings.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Resolve verify-command path findings
|
||||
|
||||
`/gsd-plan-phase` runs a deterministic probe over every `<automated>` verify command in a
|
||||
phase's plans before the plan-check pass. When a command's target directory does not resolve,
|
||||
the plan checker reports it and planning does not pass. This page is what to do with that
|
||||
report.
|
||||
|
||||
The probe answers one narrow question — *can this command's target directory be grounded from
|
||||
the executor's cwd?* — and it answers it without ever running the command.
|
||||
|
||||
## When you will see this
|
||||
|
||||
`/gsd-plan-phase N` returns `## ISSUES FOUND` with a blocker like:
|
||||
|
||||
```
|
||||
Verify Command Path Resolvability — BLOCKER
|
||||
Plan 02-PLAN.md, task "lint and build the frontend"
|
||||
Command: cd ../../frontend && npm run lint && npm run build
|
||||
rawTarget: ../../frontend
|
||||
target: /Users/you/code/frontend
|
||||
reason: missing_dir
|
||||
```
|
||||
|
||||
## Fix it in three steps
|
||||
|
||||
### 1. Read the prior phase's proven command first
|
||||
|
||||
The planner is handed `prior_verify_commands` — the `<automated>` commands from the nearest
|
||||
prior phase that had any — at **every** context window. If a previous phase already lints or
|
||||
builds the same tree, that command resolved in a real run. Reuse it verbatim.
|
||||
|
||||
```bash
|
||||
gsd-tools query init.plan-phase N --pick prior_verify_commands
|
||||
```
|
||||
|
||||
If that returns commands, the fix is usually a copy-paste, not a new path.
|
||||
|
||||
### 2. Ground the path yourself if there is nothing to inherit
|
||||
|
||||
Check the target the probe reported:
|
||||
|
||||
```bash
|
||||
ls -d <target>
|
||||
ls <target>/package.json
|
||||
```
|
||||
|
||||
Two forms are recognized. Prefer the second:
|
||||
|
||||
| Form | When it breaks |
|
||||
|---|---|
|
||||
| `cd <dir> && npm run <script>` | Depends on the executor's cwd; a relative climb resolves differently inside a worktree |
|
||||
| `npm --prefix <dir> run <script>` | Independent of cwd — **prefer this** |
|
||||
|
||||
Rewrite the plan's `<automated>` block, then re-run `/gsd-plan-phase N`.
|
||||
|
||||
### 3. Re-run the probe by hand to confirm
|
||||
|
||||
```bash
|
||||
gsd-tools check verify-command-paths N --raw
|
||||
```
|
||||
|
||||
Every row should be `severity: none`, or carry a warning you have consciously accepted.
|
||||
|
||||
## Reading the report
|
||||
|
||||
Act on `severity`, not on `status` — a row can be `status: ok` and still carry an advisory.
|
||||
|
||||
| `reason` | `severity` | What to do |
|
||||
|---|---|---|
|
||||
| `missing_dir` | blocker | The directory is not there. Fix the path, or have an earlier task create it. |
|
||||
| `no_manifest` | blocker | The directory exists but has no `package.json` / `Makefile`. You are almost certainly one level off — this is the #2401 case. |
|
||||
| `script_missing` | warning | The manifest has no such script. Fine if this phase adds it; otherwise a typo. |
|
||||
| `dynamic_path` | warning | The path uses a variable, glob, substitution, or `~`. The probe refuses to guess. Replace it with a literal if you can. |
|
||||
| `outside_root` | warning | A bare ancestor climb (`cd ../..`). Under parallel worktree execution the base differs, so this cannot be checked. Anchor it instead. |
|
||||
| `manifest_unreadable` | warning | `package.json` is unparseable, not a JSON object, or over 512 KB. Fix the manifest. |
|
||||
|
||||
## When the report says nothing
|
||||
|
||||
**An empty report is not automatically a clean bill of health.** Tell the two apart:
|
||||
|
||||
| What you see | What it means |
|
||||
|---|---|
|
||||
| `commands: []`, `readError: null` | The phase's plans contain no `<automated>` blocks to probe. |
|
||||
| `commands: [...]`, every `severity: none` | Every command's target resolved. This is the clean case. |
|
||||
| `readError` is a non-empty string | The probe **could not look** — the phase directory or a plan file was unreadable. Not a pass. |
|
||||
| `status: not_applicable` rows | Those commands have no `cd` or `--prefix` to resolve; they run at the project root. |
|
||||
| `status: pending_creation` rows | An earlier task in this phase creates that directory. Correct and expected on a greenfield phase. |
|
||||
|
||||
## What the probe deliberately will not do
|
||||
|
||||
- **It will not run your command.** PLAN.md is model-authored text; executing it from the
|
||||
checker would be arbitrary code execution, and would trigger the real lint/build as a side
|
||||
effect.
|
||||
- **It will not suggest a replacement path.** Prescribing one is exactly the failure that
|
||||
motivated this check — the checker previously guessed twice and was wrong both times, the
|
||||
second time citing a `package.json` that did not exist.
|
||||
- **It does not recognize every launcher.** `pushd`, `make -C`, `yarn --cwd`, `pnpm -C`, and
|
||||
`cargo --manifest-path` report `unresolvable` rather than being half-parsed. Refusing to
|
||||
guess is the design.
|
||||
|
||||
## Related
|
||||
|
||||
- [Verify Command Path Resolvability](../COMMANDS.md#gsd-tools-check-verify-command-paths) — the command reference
|
||||
- [Resolve edge-coverage findings](resolve-edge-coverage-findings.md)
|
||||
- [Resolve prohibition findings](resolve-prohibition-findings.md)
|
||||
@@ -99,6 +99,8 @@ export default tseslint.config(
|
||||
'gsd-core/bin/lib/resolution.cjs',
|
||||
'gsd-core/bin/lib/unusable-input.cjs',
|
||||
'gsd-core/bin/lib/plan-drift-guard.cjs',
|
||||
// #2401: tsc-generated runtime artifact — lint the src/verify-command-grounding.cts source.
|
||||
'gsd-core/bin/lib/verify-command-grounding.cjs',
|
||||
'gsd-core/bin/lib/cli-exit.cjs',
|
||||
'gsd-core/bin/lib/external-job.cjs',
|
||||
'gsd-core/bin/lib/edge-probe.cjs',
|
||||
|
||||
17
gsd-core/references/planner-verify-command-grounding.md
Normal file
17
gsd-core/references/planner-verify-command-grounding.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Verify Command Grounding (#2401)
|
||||
|
||||
> Reference file for gsd-planner agent. Loaded on-demand via `@` reference.
|
||||
|
||||
**Inherit the command that already worked.** The planning context carries
|
||||
`prior_verify_commands` — the `<automated>` commands from the most recent prior phase that had
|
||||
any, surfaced **at every context window**, not only on 1M-class models. When this phase's build
|
||||
or test story is the same one a prior phase already proved, **reuse that command verbatim**
|
||||
rather than re-deriving a path. Re-invention is what produced `cd ../../frontend && npm run
|
||||
lint` against a directory that holds no `package.json`, and cost two revision cycles.
|
||||
|
||||
Ground every path you do author: a command's `cd` target or `npm --prefix` target must be a
|
||||
directory that exists (or that an earlier task in this phase creates) and, for an npm/make
|
||||
command, must hold the matching `package.json`/`Makefile`. `npm --prefix <dir> run <script>` is
|
||||
preferred over `cd <dir> && npm run <script>` — it does not depend on the executor's cwd. If
|
||||
`prior_verify_commands` is empty and you cannot ground a path, say so in the plan instead of
|
||||
guessing one.
|
||||
42
gsd-core/references/verify-command-path-resolvability.md
Normal file
42
gsd-core/references/verify-command-path-resolvability.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Verify Command Path Resolvability (#2401)
|
||||
|
||||
> Reference file for gsd-plan-checker agent. Loaded on-demand via `@` reference.
|
||||
|
||||
**Question:** Does each `<automated>` command's target directory actually resolve from the
|
||||
executor's cwd (the project root)? Format sanity above asks whether the *pattern* can match;
|
||||
this asks whether the command can *run at all*.
|
||||
|
||||
**Do not hand-reason the filesystem.** #2401 is precisely the failure of doing so: this
|
||||
checker flagged a bad `cd ../../frontend` (correct), then prescribed two successively-wrong
|
||||
replacement paths — the second citing a `package.json` that did not exist. Consume the
|
||||
deterministic probe result, never re-derive it yourself.
|
||||
|
||||
`gsd-core/workflows/plan-phase.md` already runs the probe **before** spawning this checker and
|
||||
interpolates the result into the verification prompt as `{VERIFY_PATHS}`, inside a
|
||||
`<verify_command_path_probe>` block. This dimension reads that already-supplied JSON — it never
|
||||
invokes `gsd_run check verify-command-paths` itself. If `{VERIFY_PATHS}` is absent from the
|
||||
prompt, treat this dimension as silent (nothing to check) rather than trying to run the probe.
|
||||
|
||||
The probe never executes command text (PLAN.md is untrusted, LLM-authored). It recognizes two
|
||||
grounded forms — a leading `cd <literal>` chain and `npm --prefix <literal>` — and refuses to
|
||||
guess at anything else.
|
||||
|
||||
**Process:** for each row in `.commands`, act on `severity` only:
|
||||
|
||||
| `severity` | `reason` | Action |
|
||||
|---|---|---|
|
||||
| `blocker` | `missing_dir` / `no_manifest` | **BLOCKER** — quote `rawTarget` and `target` verbatim |
|
||||
| `warning` | `dynamic_path` / `outside_root` / `script_missing` / `manifest_unreadable` | **WARNING** |
|
||||
| `none` | — | silent |
|
||||
|
||||
Rules:
|
||||
- **Report, never prescribe.** State the target that failed to resolve and what was missing.
|
||||
Choosing the replacement is the planner's job — it now receives the prior phase's proven
|
||||
commands (see `prior_verify_commands` in the planning context).
|
||||
- `status: pending_creation` means an earlier task in this phase creates that directory. **Not
|
||||
a finding.** Say nothing.
|
||||
- `unresolvable` means the probe could not ground the path (a variable, glob, substitution, or
|
||||
`~`). That is a WARNING, never a BLOCKER — and never a licence to guess the literal path.
|
||||
- A non-empty `readError` means the probe **could not look**. Report that as a WARNING in its
|
||||
own words; it is not a clean bill of health.
|
||||
- `MISSING …` sentinels are Dimension 8's business — this dimension stays silent on them.
|
||||
@@ -94,7 +94,9 @@ When the tdd capability's `workflow.tdd_mode` is active (resolved via the plan:p
|
||||
|
||||
When `CONTEXT_WINDOW >= 500000`, the planner prompt includes the 3 most recent prior-phase CONTEXT.md/SUMMARY.md files plus any phases in the current phase's `Depends on:` field (explicit deps load regardless of recency).
|
||||
|
||||
Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_enabled`, `plan_checker_enabled`, `nyquist_validation_enabled`, `commit_docs`, `text_mode`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `has_research`, `has_context`, `has_reviews`, `has_plans`, `plan_count`, `phase_status` (#3569), `planning_exists`, `roadmap_exists`, `phase_req_ids`, `response_language`, `granularity`.
|
||||
**#2401 — `prior_verify_commands` is NOT part of that enrichment and is never gated on `CONTEXT_WINDOW`.** It is a handful of one-line `<automated>` commands harvested from the nearest prior phase that had any; the payload is tiny and its absence at 200k is exactly what made the planner re-invent a verify command and author an unrunnable path. Surface it at every context window.
|
||||
|
||||
Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_enabled`, `plan_checker_enabled`, `nyquist_validation_enabled`, `commit_docs`, `text_mode`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `has_research`, `has_context`, `has_reviews`, `has_plans`, `plan_count`, `phase_status` (#3569), `planning_exists`, `roadmap_exists`, `phase_req_ids`, `response_language`, `granularity`, `prior_verify_commands` (#2401 — array of `{phase, plan, task, command}`, possibly empty; emitted at every context window).
|
||||
|
||||
**#2517:** omit the `model=` param from an `Agent()` call when its `researcher`/`planner`/`checker`_model is `"inherit"` or empty — passing `model=""` 404s on non-Claude runtimes; omitting inherits the orchestrator model (mirrors execute-phase).
|
||||
|
||||
@@ -725,6 +727,17 @@ ${CONTEXT_WINDOW >= 500000 ? `
|
||||
- Skip all other prior phases to stay within context budget
|
||||
` : ''}
|
||||
</required_reading>
|
||||
${prior_verify_commands.length > 0 ? `
|
||||
<proven_verify_commands>
|
||||
**Verify commands the previous phase actually ran (#2401) — reuse before re-deriving.** These
|
||||
are the `<automated>` commands from the nearest prior phase that had any. They resolved from
|
||||
the executor's cwd in a real run, so a path here is grounded evidence, not a guess. When this
|
||||
phase's build/test story is the same, **copy the command verbatim**; do not re-derive a
|
||||
directory. Surfaced at every context window — not part of the 1M enrichment above.
|
||||
|
||||
{For each entry in \`prior_verify_commands\`: \`- Phase {phase} · {task}: \\\`{command}\\\`\`}
|
||||
</proven_verify_commands>
|
||||
` : ''}
|
||||
${API_SURFACE_PATH ? `
|
||||
<intel_surface_hint>
|
||||
**API Surface (HINT — may be incomplete):** When \`intel.enabled\` is true, \`${API_SURFACE_PATH}\` lists symbols extracted from the codebase by regex/JS analysis. Prefer symbols listed there when referencing existing code. This surface is regex/JS-derived and MAY BE INCOMPLETE — a symbol's absence means *unknown*, not *nonexistent*. Never treat the surface as exhaustive. If you reference a symbol that is not in the surface and this phase creates it, list it under "Artifacts this phase produces".
|
||||
@@ -971,6 +984,15 @@ Display banner:
|
||||
◆ Spawning plan checker... (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze)
|
||||
```
|
||||
|
||||
**Verify-command path probe (#2401).** Before spawning, run the deterministic resolvability
|
||||
probe and hand its JSON to the checker. It never executes command text and never prescribes a
|
||||
replacement path — it reports which `<automated>` targets resolve, which do not, and which it
|
||||
refused to guess at. Handing it over is what stops the checker hand-reasoning the filesystem.
|
||||
|
||||
```bash
|
||||
VERIFY_PATHS=$(gsd_run check verify-command-paths "${PHASE}" --raw)
|
||||
```
|
||||
|
||||
Checker prompt:
|
||||
|
||||
```markdown
|
||||
@@ -990,6 +1012,19 @@ Checker prompt:
|
||||
|
||||
${AGENT_SKILLS_CHECKER}
|
||||
|
||||
<verify_command_path_probe>
|
||||
**Deterministic verify-command path probe (#2401)** — already run; do NOT re-derive these
|
||||
verdicts by reading the filesystem yourself. Act on `severity` per the "Verify Command Path
|
||||
Resolvability" dimension: `blocker` → BLOCKER, `warning` → WARNING, `none` → silent.
|
||||
`status: pending_creation` is not a finding. A non-empty `readError` means the probe could not
|
||||
look — a WARNING, not a pass. Report the failing target verbatim; never prescribe a
|
||||
replacement path.
|
||||
|
||||
```json
|
||||
{VERIFY_PATHS}
|
||||
```
|
||||
</verify_command_path_probe>
|
||||
|
||||
<review_incorporation_verification>
|
||||
**If Mode is reviews:** Read REVIEWS.md and verify each current actionable review finding is visible in executable PLAN.md content or explicitly deferred/rejected in the relevant PLAN.md. A finding remains actionable if it requires a concrete plan task, `<action>`, `<acceptance_criteria>`, `<verify>`, `must_haves`, threat-model item, stale-path correction, or execution contract change before /gsd:execute-phase runs.
|
||||
|
||||
|
||||
@@ -109,12 +109,25 @@ function buildTestMap(prodPrefixes, allTestFiles) {
|
||||
const map = new Map([...prodPrefixes.keys()].map(p => [p, []]));
|
||||
for (const tf of allTestFiles) {
|
||||
const ep = testEffectivePrefix(path.basename(tf));
|
||||
// fs.readdirSync order (and therefore prodPrefixes' Map insertion order,
|
||||
// which is built from it in collectProdPrefixes) is NOT stable across
|
||||
// platforms/filesystems — e.g. ext4 hash order on Linux CI differs from
|
||||
// HFS+/APFS on macOS. When one module's name is a hyphen-extension of
|
||||
// another's (e.g. `verify` and `verify-command-grounding`), first-match-
|
||||
// wins bucketing is therefore platform-dependent. Scan every candidate
|
||||
// and keep the longest (most specific) matching prefix, so the bucket a
|
||||
// test file lands in is decided by specificity, never iteration order.
|
||||
let bestPrefix = null;
|
||||
for (const prefix of prodPrefixes.keys()) {
|
||||
if (ep === prefix || ep.startsWith(prefix + '-')) {
|
||||
map.get(prefix).push(tf);
|
||||
break;
|
||||
if (bestPrefix === null || prefix.length > bestPrefix.length) {
|
||||
bestPrefix = prefix;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestPrefix !== null) {
|
||||
map.get(bestPrefix).push(tf);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"paths": {
|
||||
"gsd-plan-checker.md": "#1954: Dimension 3 gains sub-dimension 3b (undeclared / temporal coupling), plus one success-criteria line. Growth is the new sub-dimension only — no existing text was rewritten. The addition is deliberately dense rather than extracted: ADR-1610 Decision 4 names eager `@`-import relocation as proxy-gaming (it shrinks the measured file while leaving loaded context unchanged or larger), legitimate extraction is Read-at-step lazy, and this agent has no lazy-read seam; issue #1954's approved scope is also explicitly 'No new files'. After the change the file sits at 48810 bytes against the LARGE tier hard cap of 49152 (tests/agent-size-budget.test.cjs), i.e. 342 bytes of headroom. That is deliberate and disclosed: the cap is not crossed and is not raised, but the next contributor who needs room in this agent must do a lazy extraction rather than add prose. Content justification: Dimension 3 proves declared dependency edges resolve and are acyclic, and execute-phase's intra-wave guard proves same-wave plans do not overlap in files_modified — neither axis sees an undeclared edge, so two same-wave plans coupled through a shared config key, table, migration, env var, singleton or cache (or through one plan's produced state) pass plan-check and fail intermittently under parallel execution. 3b is advisory only (WARNING, never blocker, per the issue's rejected-alternatives list) and reuses the existing dependency_correctness finding key so no consumer sees a new dimension."
|
||||
"gsd-plan-checker.md": "#1954: Dimension 3 gains sub-dimension 3b (undeclared / temporal coupling), plus one success-criteria line. Growth is the new sub-dimension only — no existing text was rewritten. The addition is deliberately dense rather than extracted: ADR-1610 Decision 4 names eager `@`-import relocation as proxy-gaming (it shrinks the measured file while leaving loaded context unchanged or larger), legitimate extraction is Read-at-step lazy, and this agent has no lazy-read seam; issue #1954's approved scope is also explicitly 'No new files'. After the change the file sits at 48810 bytes against the LARGE tier hard cap of 49152 (tests/agent-size-budget.test.cjs), i.e. 342 bytes of headroom. That is deliberate and disclosed: the cap is not crossed and is not raised, but the next contributor who needs room in this agent must do a lazy extraction rather than add prose. Content justification: Dimension 3 proves declared dependency edges resolve and are acyclic, and execute-phase's intra-wave guard proves same-wave plans do not overlap in files_modified — neither axis sees an undeclared edge, so two same-wave plans coupled through a shared config key, table, migration, env var, singleton or cache (or through one plan's produced state) pass plan-check and fail intermittently under parallel execution. 3b is advisory only (WARNING, never blocker, per the issue's rejected-alternatives list) and reuses the existing dependency_correctness finding key so no consumer sees a new dimension. — #2401 append (merged into this fragment because two ack sources may never name the same path): the Verify Command Path Resolvability dimension is now a short stub pointing at the extracted gsd-core/references/verify-command-path-resolvability.md (the full dimension body — including the {VERIFY_PATHS} probe-consumption rules and severity/reason table — moved out to stay under the LARGE cap). The file is now 49064 bytes, 88 bytes of headroom under the same 49152 LARGE cap."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"version": 1,
|
||||
"paths": {
|
||||
"gsd-planner.md": {
|
||||
"reason": "#2775: the STRIDE supply-chain row for npm/pip/cargo installs was rewritten from 'slopcheck + blocking human checkpoint for [ASSUMED]/[SUS]' to 'package-legitimacy gate + blocking human checkpoint for [ASSUMED]/[SUS]', matching ADR-0656 (registry-API verdicts are the gate; slopcheck is an optional escalate-only adapter no shipped configuration wires). The +14 bytes is the corrected mitigation description agents read at plan time, not incidental prose growth. #3565 appends the fenced `## Return Markers` section (~1.2 KB) enumerating the six exact stall-watch dispatch markers plan-phase.md matches on — the new registry lint requires every declared marker to be emitted in-fence by its producer, and the planner previously documented none of them anywhere."
|
||||
"reason": "#2775: the STRIDE supply-chain row for npm/pip/cargo installs was rewritten from 'slopcheck + blocking human checkpoint for [ASSUMED]/[SUS]' to 'package-legitimacy gate + blocking human checkpoint for [ASSUMED]/[SUS]', matching ADR-0656 (registry-API verdicts are the gate; slopcheck is an optional escalate-only adapter no shipped configuration wires). The +14 bytes is the corrected mitigation description agents read at plan time, not incidental prose growth. #3565 appends the fenced `## Return Markers` section (~1.2 KB) enumerating the six exact stall-watch dispatch markers plan-phase.md matches on — the new registry lint requires every declared marker to be emitted in-fence by its producer, and the planner previously documented none of them anywhere. #2401 append (merged into this fragment because two ack sources may never name the same path): the 'Inherit the command that already worked' paragraph is now a short pointer to the extracted gsd-core/references/planner-verify-command-grounding.md (prior_verify_commands inheritance, npm --prefix grounding rules, and the guessing prohibition all moved to the reference). The file is now 49274 bytes, well under the XL tier's 57344-byte cap, and 49077 chars (LF-normalized) under the separate 49152-char cap asserted by tests/planner-decomposition.test.cjs, tests/precondition-element.test.cjs, tests/reversibility-tagging.test.cjs, and tests/security.test.cjs."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"gsd-verifier.md": "#3409: same nullglob-hang fix as gsd-phase-researcher.md, applied to `cat \"$PHASE_DIR\"/*-VERIFICATION.md` in Step 0 — an absent VERIFICATION.md previously left a zero-operand `cat` blocking on stdin instead of falling through to first-verification mode. Growth is the array-guard idiom (+49 bytes). — #3206 append (merged into this fragment because two ack sources may never name the same path): +52 bytes, 49098 -> 49150 (2 under the LARGE cap). The growth is the literal fix for the term 5b used undefined: the compressed explicit-evidence definition inlined at 5b (+34 net on the rewritten line — the trailing honest-verifier cite there is dropped as superseded by the inline definition; honest-verifier.md stays cited at 5c) plus gsd-core/ path-prefix repairs on the two 404ing bare references/ cites at 5c (honest-verifier.md) and the MVP-mode section (verify-mvp-mode.md) (+9 each). Lazy extraction remains untakeable in this change: the large extractable blocks are content-pinned by tests that read the agent file directly (tests/verifier-behavior-unverified.test.cjs, tests/verification-overrides.test.cjs), so extraction is its own coordinated change.",
|
||||
"complete-milestone.md": "#3409: guarded `cat .planning/phases/*-*/*-SUMMARY.md` — with `shopt -s nullglob` active in this block's preamble (#2962), zero matching phase summaries collapses the glob to nothing and a bare `cat` blocks reading stdin rather than producing empty output, wedging the milestone-completion review. Growth is the array-existence-check idiom (+73 bytes, two glob segments makes this longer than the single-glob sites). — #2142 append (merged into this fragment because two ack sources may never name the same path): +1605 bytes, 40498 -> 42103. The `archive_milestone` step now documents the opt-in `--archive-quick` quick-task archival flag (default OFF, deliberately NOT symmetrical with phase archival's default-ON posture), folds the AskUserQuestion decision for it into the SAME `milestone.complete` invocation (avoiding a redundant second call), and states the known bucket-all provenance limit.",
|
||||
"discuss-phase-assumptions.md": "#3409: replaced the unreachable `AUTO_MODE=$(gsd_run query check auto-mode --pick active 2>/dev/null || echo \"false\")` — `||` never fires because the query exits 0 with empty stdout when the field is absent, not a failure, so AUTO_MODE silently ended up empty rather than \"false\" — with a two-line capture-then-default (`AUTO_MODE=\"${AUTO_MODE:-false}\"`) that actually reaches the fallback. Growth is the extra default-assignment line (+19 bytes).",
|
||||
"plan-phase.md": "#3409: three sites. `AUTO_CHAIN` and `PHASE_REQ_IDS` get the same unreachable-`||`-fallback fix as discuss-phase-assumptions.md (empty-but-successful `gsd_run query` output never triggered `|| echo`, now uses `${VAR:-default}`); `PRIOR_SUMMARIES` additionally swapped `gsd_run query phases.list --pick summaries_total` for `--type summaries --pick count` since the old pick key produced the same unreachable-fallback failure mode for the walking-skeleton check. Net growth across the three sites is +39 bytes. — #3576 append: bare `references/<name>.md` cites repaired to the canonical `gsd-core/references/<name>.md` form (+36 bytes, 4 cite(s) × 9). Dead-pointer fix; no content change. #3559: the generic gate-dispatch arm gained the in-context validation contract for manifest-supplied check values. gates[].check is not one of the four executable surfaces the install consent prompt discloses, so a capability consented to as declarative-only could still reach a shell through an unvalidated check.query interpolated into a command substitution. The reference (references/loop-hook-dispatch.md) stated this requirement for step -> ref.command and omitted it for gate; that omission is the root cause and is now closed at the reference plus all four dispatch sites. Growth is one validation paragraph per site. 90516 -> 90627 bytes (+111).",
|
||||
"plan-phase.md": "#3409: three sites. `AUTO_CHAIN` and `PHASE_REQ_IDS` get the same unreachable-`||`-fallback fix as discuss-phase-assumptions.md (empty-but-successful `gsd_run query` output never triggered `|| echo`, now uses `${VAR:-default}`); `PRIOR_SUMMARIES` additionally swapped `gsd_run query phases.list --pick summaries_total` for `--type summaries --pick count` since the old pick key produced the same unreachable-fallback failure mode for the walking-skeleton check. Net growth across the three sites is +39 bytes. — #3576 append: bare `references/<name>.md` cites repaired to the canonical `gsd-core/references/<name>.md` form (+36 bytes, 4 cite(s) × 9). Dead-pointer fix; no content change. #3559: the generic gate-dispatch arm gained the in-context validation contract for manifest-supplied check values. gates[].check is not one of the four executable surfaces the install consent prompt discloses, so a capability consented to as declarative-only could still reach a shell through an unvalidated check.query interpolated into a command substitution. The reference (references/loop-hook-dispatch.md) stated this requirement for step -> ref.command and omitted it for gate; that omission is the root cause and is now closed at the reference plus all four dispatch sites. Growth is one validation paragraph per site. 90516 -> 90627 bytes (+111). — #2401 append (merged into this fragment because two ack sources may never name the same path): plan-phase.md now dispatches the deterministic `gsd_run check verify-command-paths` probe before spawning the plan-check pass and interpolates its result into the verification prompt as {VERIFY_PATHS} inside a new <verify_command_path_probe> block, plus a <proven_verify_commands> block carrying prior_verify_commands into planning context so the planner can reuse a proven path instead of re-deriving one. 90627 -> 92807 bytes (+2180). Deliberate runtime-loaded workflow text for the new feature, not converter drift.",
|
||||
"session-report.md": "#3409: guarded `ls -la .planning/reports/SESSION_REPORT*.md 2>/dev/null || echo \"No previous reports\"` — with nullglob active, zero prior reports collapses the pattern to nothing and `ls -la` with no operands lists the current directory (a successful exit, wrong output) instead of failing into the `|| echo` fallback, so the report-existence check silently printed a directory listing. Replaced with an array-existence check that only lists when a real report file is present. Growth is the guard idiom (+58 bytes).",
|
||||
"transition.md": "#3409: guarded `cat .planning/phases/XX-current/*-SUMMARY.md` — same nullglob-hang defect as complete-milestone.md's phase-summary read: zero summaries left a bare `cat` blocking on stdin instead of proceeding with no summary content during PROJECT.md evolution. Growth is the array-existence-check idiom (+73 bytes)."
|
||||
}
|
||||
|
||||
2
tests/fixtures/install-tree/antigravity.json
vendored
2
tests/fixtures/install-tree/antigravity.json
vendored
@@ -125,6 +125,7 @@
|
||||
"gsd-core/references/planner-reviews.md",
|
||||
"gsd-core/references/planner-revision.md",
|
||||
"gsd-core/references/planner-source-audit.md",
|
||||
"gsd-core/references/planner-verify-command-grounding.md",
|
||||
"gsd-core/references/planning-config.md",
|
||||
"gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json",
|
||||
"gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json",
|
||||
@@ -164,6 +165,7 @@
|
||||
"gsd-core/references/verification-patterns.md",
|
||||
"gsd-core/references/verifier-phase-gates.md",
|
||||
"gsd-core/references/verifier-wiring-patterns.md",
|
||||
"gsd-core/references/verify-command-path-resolvability.md",
|
||||
"gsd-core/references/verify-mvp-mode.md",
|
||||
"gsd-core/references/workstream-flag.md",
|
||||
"gsd-core/references/worktree-branch-check.md",
|
||||
|
||||
2
tests/fixtures/install-tree/augment.json
vendored
2
tests/fixtures/install-tree/augment.json
vendored
@@ -196,6 +196,7 @@
|
||||
"gsd-core/references/planner-reviews.md",
|
||||
"gsd-core/references/planner-revision.md",
|
||||
"gsd-core/references/planner-source-audit.md",
|
||||
"gsd-core/references/planner-verify-command-grounding.md",
|
||||
"gsd-core/references/planning-config.md",
|
||||
"gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json",
|
||||
"gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json",
|
||||
@@ -235,6 +236,7 @@
|
||||
"gsd-core/references/verification-patterns.md",
|
||||
"gsd-core/references/verifier-phase-gates.md",
|
||||
"gsd-core/references/verifier-wiring-patterns.md",
|
||||
"gsd-core/references/verify-command-path-resolvability.md",
|
||||
"gsd-core/references/verify-mvp-mode.md",
|
||||
"gsd-core/references/workstream-flag.md",
|
||||
"gsd-core/references/worktree-branch-check.md",
|
||||
|
||||
@@ -196,6 +196,7 @@
|
||||
"gsd-core/references/planner-reviews.md",
|
||||
"gsd-core/references/planner-revision.md",
|
||||
"gsd-core/references/planner-source-audit.md",
|
||||
"gsd-core/references/planner-verify-command-grounding.md",
|
||||
"gsd-core/references/planning-config.md",
|
||||
"gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json",
|
||||
"gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json",
|
||||
@@ -235,6 +236,7 @@
|
||||
"gsd-core/references/verification-patterns.md",
|
||||
"gsd-core/references/verifier-phase-gates.md",
|
||||
"gsd-core/references/verifier-wiring-patterns.md",
|
||||
"gsd-core/references/verify-command-path-resolvability.md",
|
||||
"gsd-core/references/verify-mvp-mode.md",
|
||||
"gsd-core/references/workstream-flag.md",
|
||||
"gsd-core/references/worktree-branch-check.md",
|
||||
|
||||
2
tests/fixtures/install-tree/claude.json
vendored
2
tests/fixtures/install-tree/claude.json
vendored
@@ -125,6 +125,7 @@
|
||||
"gsd-core/references/planner-reviews.md",
|
||||
"gsd-core/references/planner-revision.md",
|
||||
"gsd-core/references/planner-source-audit.md",
|
||||
"gsd-core/references/planner-verify-command-grounding.md",
|
||||
"gsd-core/references/planning-config.md",
|
||||
"gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json",
|
||||
"gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json",
|
||||
@@ -164,6 +165,7 @@
|
||||
"gsd-core/references/verification-patterns.md",
|
||||
"gsd-core/references/verifier-phase-gates.md",
|
||||
"gsd-core/references/verifier-wiring-patterns.md",
|
||||
"gsd-core/references/verify-command-path-resolvability.md",
|
||||
"gsd-core/references/verify-mvp-mode.md",
|
||||
"gsd-core/references/workstream-flag.md",
|
||||
"gsd-core/references/worktree-branch-check.md",
|
||||
|
||||
2
tests/fixtures/install-tree/cline.json
vendored
2
tests/fixtures/install-tree/cline.json
vendored
@@ -127,6 +127,7 @@
|
||||
"gsd-core/references/planner-reviews.md",
|
||||
"gsd-core/references/planner-revision.md",
|
||||
"gsd-core/references/planner-source-audit.md",
|
||||
"gsd-core/references/planner-verify-command-grounding.md",
|
||||
"gsd-core/references/planning-config.md",
|
||||
"gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json",
|
||||
"gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json",
|
||||
@@ -166,6 +167,7 @@
|
||||
"gsd-core/references/verification-patterns.md",
|
||||
"gsd-core/references/verifier-phase-gates.md",
|
||||
"gsd-core/references/verifier-wiring-patterns.md",
|
||||
"gsd-core/references/verify-command-path-resolvability.md",
|
||||
"gsd-core/references/verify-mvp-mode.md",
|
||||
"gsd-core/references/workstream-flag.md",
|
||||
"gsd-core/references/worktree-branch-check.md",
|
||||
|
||||
2
tests/fixtures/install-tree/codebuddy.json
vendored
2
tests/fixtures/install-tree/codebuddy.json
vendored
@@ -196,6 +196,7 @@
|
||||
"gsd-core/references/planner-reviews.md",
|
||||
"gsd-core/references/planner-revision.md",
|
||||
"gsd-core/references/planner-source-audit.md",
|
||||
"gsd-core/references/planner-verify-command-grounding.md",
|
||||
"gsd-core/references/planning-config.md",
|
||||
"gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json",
|
||||
"gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json",
|
||||
@@ -235,6 +236,7 @@
|
||||
"gsd-core/references/verification-patterns.md",
|
||||
"gsd-core/references/verifier-phase-gates.md",
|
||||
"gsd-core/references/verifier-wiring-patterns.md",
|
||||
"gsd-core/references/verify-command-path-resolvability.md",
|
||||
"gsd-core/references/verify-mvp-mode.md",
|
||||
"gsd-core/references/workstream-flag.md",
|
||||
"gsd-core/references/worktree-branch-check.md",
|
||||
|
||||
2
tests/fixtures/install-tree/codex.json
vendored
2
tests/fixtures/install-tree/codex.json
vendored
@@ -160,6 +160,7 @@
|
||||
"gsd-core/references/planner-reviews.md",
|
||||
"gsd-core/references/planner-revision.md",
|
||||
"gsd-core/references/planner-source-audit.md",
|
||||
"gsd-core/references/planner-verify-command-grounding.md",
|
||||
"gsd-core/references/planning-config.md",
|
||||
"gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json",
|
||||
"gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json",
|
||||
@@ -199,6 +200,7 @@
|
||||
"gsd-core/references/verification-patterns.md",
|
||||
"gsd-core/references/verifier-phase-gates.md",
|
||||
"gsd-core/references/verifier-wiring-patterns.md",
|
||||
"gsd-core/references/verify-command-path-resolvability.md",
|
||||
"gsd-core/references/verify-mvp-mode.md",
|
||||
"gsd-core/references/workstream-flag.md",
|
||||
"gsd-core/references/worktree-branch-check.md",
|
||||
|
||||
2
tests/fixtures/install-tree/copilot.json
vendored
2
tests/fixtures/install-tree/copilot.json
vendored
@@ -126,6 +126,7 @@
|
||||
"gsd-core/references/planner-reviews.md",
|
||||
"gsd-core/references/planner-revision.md",
|
||||
"gsd-core/references/planner-source-audit.md",
|
||||
"gsd-core/references/planner-verify-command-grounding.md",
|
||||
"gsd-core/references/planning-config.md",
|
||||
"gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json",
|
||||
"gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json",
|
||||
@@ -165,6 +166,7 @@
|
||||
"gsd-core/references/verification-patterns.md",
|
||||
"gsd-core/references/verifier-phase-gates.md",
|
||||
"gsd-core/references/verifier-wiring-patterns.md",
|
||||
"gsd-core/references/verify-command-path-resolvability.md",
|
||||
"gsd-core/references/verify-mvp-mode.md",
|
||||
"gsd-core/references/workstream-flag.md",
|
||||
"gsd-core/references/worktree-branch-check.md",
|
||||
|
||||
2
tests/fixtures/install-tree/cursor.json
vendored
2
tests/fixtures/install-tree/cursor.json
vendored
@@ -125,6 +125,7 @@
|
||||
"gsd-core/references/planner-reviews.md",
|
||||
"gsd-core/references/planner-revision.md",
|
||||
"gsd-core/references/planner-source-audit.md",
|
||||
"gsd-core/references/planner-verify-command-grounding.md",
|
||||
"gsd-core/references/planning-config.md",
|
||||
"gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json",
|
||||
"gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json",
|
||||
@@ -164,6 +165,7 @@
|
||||
"gsd-core/references/verification-patterns.md",
|
||||
"gsd-core/references/verifier-phase-gates.md",
|
||||
"gsd-core/references/verifier-wiring-patterns.md",
|
||||
"gsd-core/references/verify-command-path-resolvability.md",
|
||||
"gsd-core/references/verify-mvp-mode.md",
|
||||
"gsd-core/references/workstream-flag.md",
|
||||
"gsd-core/references/worktree-branch-check.md",
|
||||
|
||||
2
tests/fixtures/install-tree/hermes.json
vendored
2
tests/fixtures/install-tree/hermes.json
vendored
@@ -125,6 +125,7 @@
|
||||
"gsd-core/references/planner-reviews.md",
|
||||
"gsd-core/references/planner-revision.md",
|
||||
"gsd-core/references/planner-source-audit.md",
|
||||
"gsd-core/references/planner-verify-command-grounding.md",
|
||||
"gsd-core/references/planning-config.md",
|
||||
"gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json",
|
||||
"gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json",
|
||||
@@ -164,6 +165,7 @@
|
||||
"gsd-core/references/verification-patterns.md",
|
||||
"gsd-core/references/verifier-phase-gates.md",
|
||||
"gsd-core/references/verifier-wiring-patterns.md",
|
||||
"gsd-core/references/verify-command-path-resolvability.md",
|
||||
"gsd-core/references/verify-mvp-mode.md",
|
||||
"gsd-core/references/workstream-flag.md",
|
||||
"gsd-core/references/worktree-branch-check.md",
|
||||
|
||||
2
tests/fixtures/install-tree/kilo.json
vendored
2
tests/fixtures/install-tree/kilo.json
vendored
@@ -196,6 +196,7 @@
|
||||
"gsd-core/references/planner-reviews.md",
|
||||
"gsd-core/references/planner-revision.md",
|
||||
"gsd-core/references/planner-source-audit.md",
|
||||
"gsd-core/references/planner-verify-command-grounding.md",
|
||||
"gsd-core/references/planning-config.md",
|
||||
"gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json",
|
||||
"gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json",
|
||||
@@ -235,6 +236,7 @@
|
||||
"gsd-core/references/verification-patterns.md",
|
||||
"gsd-core/references/verifier-phase-gates.md",
|
||||
"gsd-core/references/verifier-wiring-patterns.md",
|
||||
"gsd-core/references/verify-command-path-resolvability.md",
|
||||
"gsd-core/references/verify-mvp-mode.md",
|
||||
"gsd-core/references/workstream-flag.md",
|
||||
"gsd-core/references/worktree-branch-check.md",
|
||||
|
||||
2
tests/fixtures/install-tree/kimi-code.json
vendored
2
tests/fixtures/install-tree/kimi-code.json
vendored
@@ -126,6 +126,7 @@
|
||||
"gsd-core/references/planner-reviews.md",
|
||||
"gsd-core/references/planner-revision.md",
|
||||
"gsd-core/references/planner-source-audit.md",
|
||||
"gsd-core/references/planner-verify-command-grounding.md",
|
||||
"gsd-core/references/planning-config.md",
|
||||
"gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json",
|
||||
"gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json",
|
||||
@@ -165,6 +166,7 @@
|
||||
"gsd-core/references/verification-patterns.md",
|
||||
"gsd-core/references/verifier-phase-gates.md",
|
||||
"gsd-core/references/verifier-wiring-patterns.md",
|
||||
"gsd-core/references/verify-command-path-resolvability.md",
|
||||
"gsd-core/references/verify-mvp-mode.md",
|
||||
"gsd-core/references/workstream-flag.md",
|
||||
"gsd-core/references/worktree-branch-check.md",
|
||||
|
||||
2
tests/fixtures/install-tree/kimi.json
vendored
2
tests/fixtures/install-tree/kimi.json
vendored
@@ -161,6 +161,7 @@
|
||||
"gsd-core/references/planner-reviews.md",
|
||||
"gsd-core/references/planner-revision.md",
|
||||
"gsd-core/references/planner-source-audit.md",
|
||||
"gsd-core/references/planner-verify-command-grounding.md",
|
||||
"gsd-core/references/planning-config.md",
|
||||
"gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json",
|
||||
"gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json",
|
||||
@@ -200,6 +201,7 @@
|
||||
"gsd-core/references/verification-patterns.md",
|
||||
"gsd-core/references/verifier-phase-gates.md",
|
||||
"gsd-core/references/verifier-wiring-patterns.md",
|
||||
"gsd-core/references/verify-command-path-resolvability.md",
|
||||
"gsd-core/references/verify-mvp-mode.md",
|
||||
"gsd-core/references/workstream-flag.md",
|
||||
"gsd-core/references/worktree-branch-check.md",
|
||||
|
||||
2
tests/fixtures/install-tree/opencode.json
vendored
2
tests/fixtures/install-tree/opencode.json
vendored
@@ -196,6 +196,7 @@
|
||||
"gsd-core/references/planner-reviews.md",
|
||||
"gsd-core/references/planner-revision.md",
|
||||
"gsd-core/references/planner-source-audit.md",
|
||||
"gsd-core/references/planner-verify-command-grounding.md",
|
||||
"gsd-core/references/planning-config.md",
|
||||
"gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json",
|
||||
"gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json",
|
||||
@@ -235,6 +236,7 @@
|
||||
"gsd-core/references/verification-patterns.md",
|
||||
"gsd-core/references/verifier-phase-gates.md",
|
||||
"gsd-core/references/verifier-wiring-patterns.md",
|
||||
"gsd-core/references/verify-command-path-resolvability.md",
|
||||
"gsd-core/references/verify-mvp-mode.md",
|
||||
"gsd-core/references/workstream-flag.md",
|
||||
"gsd-core/references/worktree-branch-check.md",
|
||||
|
||||
2
tests/fixtures/install-tree/pi.json
vendored
2
tests/fixtures/install-tree/pi.json
vendored
@@ -93,6 +93,7 @@
|
||||
"gsd-core/references/planner-reviews.md",
|
||||
"gsd-core/references/planner-revision.md",
|
||||
"gsd-core/references/planner-source-audit.md",
|
||||
"gsd-core/references/planner-verify-command-grounding.md",
|
||||
"gsd-core/references/planning-config.md",
|
||||
"gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json",
|
||||
"gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json",
|
||||
@@ -132,6 +133,7 @@
|
||||
"gsd-core/references/verification-patterns.md",
|
||||
"gsd-core/references/verifier-phase-gates.md",
|
||||
"gsd-core/references/verifier-wiring-patterns.md",
|
||||
"gsd-core/references/verify-command-path-resolvability.md",
|
||||
"gsd-core/references/verify-mvp-mode.md",
|
||||
"gsd-core/references/workstream-flag.md",
|
||||
"gsd-core/references/worktree-branch-check.md",
|
||||
|
||||
2
tests/fixtures/install-tree/qwen.json
vendored
2
tests/fixtures/install-tree/qwen.json
vendored
@@ -125,6 +125,7 @@
|
||||
"gsd-core/references/planner-reviews.md",
|
||||
"gsd-core/references/planner-revision.md",
|
||||
"gsd-core/references/planner-source-audit.md",
|
||||
"gsd-core/references/planner-verify-command-grounding.md",
|
||||
"gsd-core/references/planning-config.md",
|
||||
"gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json",
|
||||
"gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json",
|
||||
@@ -164,6 +165,7 @@
|
||||
"gsd-core/references/verification-patterns.md",
|
||||
"gsd-core/references/verifier-phase-gates.md",
|
||||
"gsd-core/references/verifier-wiring-patterns.md",
|
||||
"gsd-core/references/verify-command-path-resolvability.md",
|
||||
"gsd-core/references/verify-mvp-mode.md",
|
||||
"gsd-core/references/workstream-flag.md",
|
||||
"gsd-core/references/worktree-branch-check.md",
|
||||
|
||||
2
tests/fixtures/install-tree/trae.json
vendored
2
tests/fixtures/install-tree/trae.json
vendored
@@ -125,6 +125,7 @@
|
||||
"gsd-core/references/planner-reviews.md",
|
||||
"gsd-core/references/planner-revision.md",
|
||||
"gsd-core/references/planner-source-audit.md",
|
||||
"gsd-core/references/planner-verify-command-grounding.md",
|
||||
"gsd-core/references/planning-config.md",
|
||||
"gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json",
|
||||
"gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json",
|
||||
@@ -164,6 +165,7 @@
|
||||
"gsd-core/references/verification-patterns.md",
|
||||
"gsd-core/references/verifier-phase-gates.md",
|
||||
"gsd-core/references/verifier-wiring-patterns.md",
|
||||
"gsd-core/references/verify-command-path-resolvability.md",
|
||||
"gsd-core/references/verify-mvp-mode.md",
|
||||
"gsd-core/references/workstream-flag.md",
|
||||
"gsd-core/references/worktree-branch-check.md",
|
||||
|
||||
2
tests/fixtures/install-tree/windsurf.json
vendored
2
tests/fixtures/install-tree/windsurf.json
vendored
@@ -125,6 +125,7 @@
|
||||
"gsd-core/references/planner-reviews.md",
|
||||
"gsd-core/references/planner-revision.md",
|
||||
"gsd-core/references/planner-source-audit.md",
|
||||
"gsd-core/references/planner-verify-command-grounding.md",
|
||||
"gsd-core/references/planning-config.md",
|
||||
"gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json",
|
||||
"gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json",
|
||||
@@ -164,6 +165,7 @@
|
||||
"gsd-core/references/verification-patterns.md",
|
||||
"gsd-core/references/verifier-phase-gates.md",
|
||||
"gsd-core/references/verifier-wiring-patterns.md",
|
||||
"gsd-core/references/verify-command-path-resolvability.md",
|
||||
"gsd-core/references/verify-mvp-mode.md",
|
||||
"gsd-core/references/workstream-flag.md",
|
||||
"gsd-core/references/worktree-branch-check.md",
|
||||
|
||||
2
tests/fixtures/install-tree/zcode.json
vendored
2
tests/fixtures/install-tree/zcode.json
vendored
@@ -196,6 +196,7 @@
|
||||
"gsd-core/references/planner-reviews.md",
|
||||
"gsd-core/references/planner-revision.md",
|
||||
"gsd-core/references/planner-source-audit.md",
|
||||
"gsd-core/references/planner-verify-command-grounding.md",
|
||||
"gsd-core/references/planning-config.md",
|
||||
"gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json",
|
||||
"gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json",
|
||||
@@ -235,6 +236,7 @@
|
||||
"gsd-core/references/verification-patterns.md",
|
||||
"gsd-core/references/verifier-phase-gates.md",
|
||||
"gsd-core/references/verifier-wiring-patterns.md",
|
||||
"gsd-core/references/verify-command-path-resolvability.md",
|
||||
"gsd-core/references/verify-mvp-mode.md",
|
||||
"gsd-core/references/workstream-flag.md",
|
||||
"gsd-core/references/worktree-branch-check.md",
|
||||
|
||||
@@ -20,6 +20,7 @@ const {
|
||||
Verdict,
|
||||
evaluateLint,
|
||||
testEffectivePrefix,
|
||||
_buildTestMap,
|
||||
} = require(LINT_SCRIPT);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -262,6 +263,42 @@ describe('evaluateLint — allowlist behaviour (identity-based)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// _buildTestMap — longest-prefix bucketing (order-independence)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('_buildTestMap — longest-prefix bucketing', () => {
|
||||
// Regression for readdirSync-order-dependent bucketing: `verify.cjs` and
|
||||
// `verify-command-grounding.cjs` are production modules where one name is a
|
||||
// hyphen-extension of the other. fs.readdirSync order is not stable across
|
||||
// platforms (e.g. Linux ext4 hash order vs macOS HFS+/APFS), so
|
||||
// `prodPrefixes` (a Map built from readdirSync) can iterate in either
|
||||
// order. A test file matching the longer, more specific prefix must always
|
||||
// bucket there — never fall through to the shorter prefix — regardless of
|
||||
// which key the Map visits first.
|
||||
const testFile = makeFiles('verify-command-grounding', ['verify-command-grounding.test.cjs'])[0];
|
||||
|
||||
test('buckets to the longer prefix when the short prefix is visited first', () => {
|
||||
const prodPrefixes = new Map([
|
||||
['verify', '/fake/src/verify.cjs'],
|
||||
['verify-command-grounding', '/fake/src/verify-command-grounding.cjs'],
|
||||
]);
|
||||
const map = _buildTestMap(prodPrefixes, [testFile]);
|
||||
assert.deepStrictEqual(map.get('verify-command-grounding'), [testFile]);
|
||||
assert.deepStrictEqual(map.get('verify'), []);
|
||||
});
|
||||
|
||||
test('buckets to the longer prefix when the long prefix is visited first', () => {
|
||||
const prodPrefixes = new Map([
|
||||
['verify-command-grounding', '/fake/src/verify-command-grounding.cjs'],
|
||||
['verify', '/fake/src/verify.cjs'],
|
||||
]);
|
||||
const map = _buildTestMap(prodPrefixes, [testFile]);
|
||||
assert.deepStrictEqual(map.get('verify-command-grounding'), [testFile]);
|
||||
assert.deepStrictEqual(map.get('verify'), []);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// testEffectivePrefix — issue-stamp stripping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
972
tests/verify-command-grounding.test.cjs
Normal file
972
tests/verify-command-grounding.test.cjs
Normal file
@@ -0,0 +1,972 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Verify-command grounding (#2401).
|
||||
*
|
||||
* Module: gsd-core/bin/lib/verify-command-grounding.cjs
|
||||
* Exported: extractAutomatedCommands, resolveVerifyCommandTarget,
|
||||
* probePhaseVerifyCommands, harvestPriorVerifyCommands
|
||||
*
|
||||
* #2401: a planner authored `<automated>cd ../../frontend && npm run lint</automated>`
|
||||
* whose target does 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 command's target directory be grounded?" WITHOUT
|
||||
* executing the command. It is a RECOGNIZER, not a shell interpreter: exactly two
|
||||
* forms are grounded (`cd <literal>` and `npm --prefix <literal>`), and anything
|
||||
* carrying a variable, glob, substitution, or a LEADING `~` (home-expansion)
|
||||
* returns `unresolvable` — a warning, never a blocker. A `~` anywhere else in
|
||||
* the path (e.g. a Windows 8.3 short name like `RUNNER~1`) is an ordinary
|
||||
* literal character and must resolve normally (#2401 CI regression). Refusing
|
||||
* to guess is the whole point; guessing is the defect being fixed.
|
||||
*
|
||||
* Row numbers below map to `.gsd/phase/feat-2401-verify-command-grounding/50-test-matrix.md`.
|
||||
*
|
||||
* IO-failure rows monkeypatch the `fs` method and restore in `finally` — never
|
||||
* `chmod 0o000`, which root bypasses in Docker/CI so the test would pass with
|
||||
* zero coverage.
|
||||
*
|
||||
* The final `describe('property-based invariants', …)` block below carries the
|
||||
* fast-check property tests for this module. Properties covered:
|
||||
* (a) resolveVerifyCommandTarget never throws and always returns a known
|
||||
* status/severity/base shape, for arbitrary string input.
|
||||
* (b) a 'blocker' severity is only ever reported for a grounded form: form
|
||||
* is non-null and target is a non-empty string.
|
||||
* (c) extractAutomatedCommands recovers every generated <automated> command,
|
||||
* in order, from synthesized plan text.
|
||||
* (d) CRLF invariance: the same plan rendered with \r\n line joins produces
|
||||
* an identical command list.
|
||||
* (e) extractAutomatedCommands never throws on arbitrary input, including
|
||||
* non-string values, and returns [] for non-strings.
|
||||
*/
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const fc = require('./helpers/fast-check-setup.cjs');
|
||||
const { cleanup, runGsdTools } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
extractAutomatedCommands,
|
||||
resolveVerifyCommandTarget,
|
||||
probePhaseVerifyCommands,
|
||||
harvestPriorVerifyCommands,
|
||||
} = require('../gsd-core/bin/lib/verify-command-grounding.cjs');
|
||||
const { extractTaggedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs');
|
||||
|
||||
const KNOWN_STATUSES = new Set([
|
||||
'ok',
|
||||
'broken',
|
||||
'unresolvable',
|
||||
'not_applicable',
|
||||
'pending_creation',
|
||||
]);
|
||||
|
||||
/** Root for every fixture built by this file; removed in `after`. */
|
||||
let ROOT = '';
|
||||
|
||||
function fixtureRoot(name) {
|
||||
const dir = path.join(ROOT, name);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function writePackageJson(dir, scripts) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'fx', scripts }), 'utf8');
|
||||
}
|
||||
|
||||
/** Build a minimal PLAN.md body with one `<automated>` per supplied command. */
|
||||
function planWith(commands, { taskFiles = [], artifacts = [], eol = '\n' } = {}) {
|
||||
const tasks = commands
|
||||
.map(
|
||||
(cmd, i) =>
|
||||
[
|
||||
'<task type="auto">',
|
||||
` <name>task-${i}</name>`,
|
||||
` <files>${taskFiles[i] ?? ''}</files>`,
|
||||
' <action>do the thing</action>',
|
||||
` <verify><automated>${cmd}</automated></verify>`,
|
||||
' <acceptance_criteria>it works</acceptance_criteria>',
|
||||
' <done>committed</done>',
|
||||
'</task>',
|
||||
].join(eol),
|
||||
)
|
||||
.join(eol);
|
||||
const artifactSection = artifacts.length
|
||||
? [eol, '## Artifacts this phase produces', ...artifacts.map((a) => `- ${a}`)].join(eol)
|
||||
: '';
|
||||
return ['# Plan', '', tasks, artifactSection].join(eol);
|
||||
}
|
||||
|
||||
function writePhase(root, phaseName, plans) {
|
||||
const planningDir = path.join(root, '.planning');
|
||||
const phaseDir = path.join(planningDir, phaseName);
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
for (const [file, body] of Object.entries(plans)) {
|
||||
fs.writeFileSync(path.join(phaseDir, file), body, 'utf8');
|
||||
}
|
||||
return phaseDir;
|
||||
}
|
||||
|
||||
before(() => {
|
||||
ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2401-'));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (ROOT) cleanup(ROOT);
|
||||
});
|
||||
|
||||
describe('extractAutomatedCommands', () => {
|
||||
test('row 29 — non-string plan text yields empty list', () => {
|
||||
for (const bad of [0, '', [], null, undefined, true, {}]) {
|
||||
assert.deepEqual(extractAutomatedCommands(bad), [], `input ${JSON.stringify(bad)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('row 30 — empty automated block is skipped', () => {
|
||||
const out = extractAutomatedCommands(planWith(['', ' ', 'npm test']));
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].command, 'npm test');
|
||||
});
|
||||
|
||||
test('row 31 — attribute-bearing automated tag is extracted', () => {
|
||||
const md = '<task type="auto"><name>t</name><verify><automated tier="fast">npm test</automated></verify></task>';
|
||||
const out = extractAutomatedCommands(md);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].command, 'npm test');
|
||||
});
|
||||
|
||||
test('row 32 — unclosed automated openers terminate', () => {
|
||||
const md = '<automated>'.repeat(200);
|
||||
const out = extractAutomatedCommands(md);
|
||||
assert.ok(Array.isArray(out));
|
||||
});
|
||||
|
||||
test('row 27 — repeated command reports both occurrences', () => {
|
||||
const out = extractAutomatedCommands(planWith(['npm test', 'npm test']));
|
||||
assert.equal(out.length, 2);
|
||||
});
|
||||
|
||||
test('carries the owning task name', () => {
|
||||
const out = extractAutomatedCommands(planWith(['npm test']));
|
||||
assert.equal(out[0].task, 'task-0');
|
||||
});
|
||||
|
||||
test('row 25 — CRLF plan yields identical extraction to LF', () => {
|
||||
const lf = extractAutomatedCommands(planWith(['cd web && npm test', 'npm run build']));
|
||||
const crlf = extractAutomatedCommands(planWith(['cd web && npm test', 'npm run build'], { eol: '\r\n' }));
|
||||
assert.deepEqual(
|
||||
crlf.map((c) => c.command),
|
||||
lf.map((c) => c.command),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('task-grammar parity', () => {
|
||||
test('extractAutomatedCommands attributes the same task names as the canonical sectionizer', () => {
|
||||
const longAttr = 'x'.repeat(400);
|
||||
const fixture = [
|
||||
'<task><name>bare</name><verify><automated>echo bare</automated></verify></task>',
|
||||
'<task type="auto"><name>typed</name><verify><automated>echo typed</automated></verify></task>',
|
||||
`<task type="auto" data-note="${longAttr}"><name>longattr</name><verify><automated>echo longattr</automated></verify></task>`,
|
||||
'<task><name>adjacent-a</name><verify><automated>echo a</automated></verify></task>',
|
||||
'<task><name>adjacent-b</name><verify><automated>echo b</automated></verify></task>',
|
||||
'<task><name>lt</name><verify><automated>echo "a < b" && echo x < y</automated></verify></task>',
|
||||
].join('\n');
|
||||
|
||||
const attributed = new Set(extractAutomatedCommands(fixture).map((c) => c.task));
|
||||
const canonicalNames = new Set(
|
||||
extractTaggedBlocks(fixture, 'task', true).map((body) => {
|
||||
const m = /<name>([\s\S]*?)<\/name>/.exec(body);
|
||||
return m ? m[1].trim() : '';
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(attributed, canonicalNames);
|
||||
assert.deepEqual(
|
||||
[...attributed].sort(),
|
||||
['adjacent-a', 'adjacent-b', 'bare', 'longattr', 'lt', 'typed'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveVerifyCommandTarget — grounded forms', () => {
|
||||
test('row 1 — no cd or prefix is not applicable', () => {
|
||||
const root = fixtureRoot('row1');
|
||||
const r = resolveVerifyCommandTarget('npm test -- --filter=x', { projectRoot: root });
|
||||
assert.equal(r.status, 'not_applicable');
|
||||
assert.equal(r.target, null);
|
||||
assert.equal(r.severity, 'none');
|
||||
});
|
||||
|
||||
test('row 2 — resolves a cd target that exists', () => {
|
||||
const root = fixtureRoot('row2');
|
||||
writePackageJson(path.join(root, 'frontend'), { lint: 'eslint .' });
|
||||
const r = resolveVerifyCommandTarget('cd frontend && npm run lint', { projectRoot: root });
|
||||
assert.equal(r.status, 'ok');
|
||||
assert.equal(r.form, 'cd');
|
||||
assert.equal(r.target, path.join(root, 'frontend'));
|
||||
assert.equal(r.manifest, 'package.json');
|
||||
assert.equal(r.severity, 'none');
|
||||
});
|
||||
|
||||
test('row 3 — flags the reported unresolvable cd target', () => {
|
||||
// Nested two levels deep so `cd ../../frontend` resolves to
|
||||
// `<row3-root>/frontend` — inside the suite's own temp root, which it
|
||||
// controls and does not create — rather than escaping into the shared OS
|
||||
// temp directory where a stray `frontend/` could silently flip the
|
||||
// assertion.
|
||||
const root = fixtureRoot('row3/a/b');
|
||||
const r = resolveVerifyCommandTarget('cd ../../frontend && npm run lint && npm run build', {
|
||||
projectRoot: root,
|
||||
});
|
||||
assert.equal(r.status, 'broken');
|
||||
assert.equal(r.reason, 'missing_dir');
|
||||
assert.equal(r.severity, 'blocker');
|
||||
// The probe reports what it resolved; it never prescribes a replacement.
|
||||
assert.equal(r.rawTarget, '../../frontend');
|
||||
assert.ok(!('suggestion' in r), 'probe must not prescribe a replacement path');
|
||||
});
|
||||
|
||||
test('row 4 — dir without a manifest is broken for an npm command', () => {
|
||||
const root = fixtureRoot('row4');
|
||||
fs.mkdirSync(path.join(root, 'docs-only'), { recursive: true });
|
||||
const r = resolveVerifyCommandTarget('cd docs-only && npm run lint', { projectRoot: root });
|
||||
assert.equal(r.status, 'broken');
|
||||
assert.equal(r.reason, 'no_manifest');
|
||||
assert.equal(r.severity, 'blocker');
|
||||
});
|
||||
|
||||
test('row 5 — non-npm command needs no manifest', () => {
|
||||
const root = fixtureRoot('row5');
|
||||
fs.mkdirSync(path.join(root, 'docs-only'), { recursive: true });
|
||||
const r = resolveVerifyCommandTarget('cd docs-only && grep -q x README.md', { projectRoot: root });
|
||||
assert.equal(r.status, 'ok');
|
||||
assert.equal(r.severity, 'none');
|
||||
});
|
||||
|
||||
test('row 6 — resolves npm --prefix', () => {
|
||||
const root = fixtureRoot('row6');
|
||||
writePackageJson(path.join(root, 'web'), { build: 'vite build' });
|
||||
const r = resolveVerifyCommandTarget('npm --prefix ./web run build', { projectRoot: root });
|
||||
assert.equal(r.status, 'ok');
|
||||
assert.equal(r.form, 'prefix');
|
||||
assert.equal(r.target, path.join(root, 'web'));
|
||||
});
|
||||
|
||||
test('row 7 — resolves trailing --prefix', () => {
|
||||
const root = fixtureRoot('row7');
|
||||
writePackageJson(path.join(root, 'web'), { build: 'vite build' });
|
||||
const r = resolveVerifyCommandTarget('npm run build --prefix ./web', { projectRoot: root });
|
||||
assert.equal(r.status, 'ok');
|
||||
assert.equal(r.target, path.join(root, 'web'));
|
||||
});
|
||||
|
||||
test('row 8 — absolute prefix is not joined to root', () => {
|
||||
const root = fixtureRoot('row8');
|
||||
const abs = path.join(ROOT, 'row8-abs');
|
||||
writePackageJson(abs, { lint: 'eslint .' });
|
||||
const r = resolveVerifyCommandTarget(`npm --prefix ${abs} run lint`, { projectRoot: root });
|
||||
assert.equal(r.status, 'ok');
|
||||
assert.equal(r.target, abs);
|
||||
});
|
||||
|
||||
test('row 9 — folds chained cd segments', () => {
|
||||
const root = fixtureRoot('row9');
|
||||
writePackageJson(path.join(root, 'a', 'b'), { test: 'node --version' });
|
||||
const r = resolveVerifyCommandTarget('cd a && cd b && npm test', { projectRoot: root });
|
||||
assert.equal(r.status, 'ok');
|
||||
assert.equal(r.target, path.join(root, 'a', 'b'));
|
||||
});
|
||||
|
||||
test('row 22 — cd dot resolves to project root', () => {
|
||||
const root = fixtureRoot('row22');
|
||||
writePackageJson(root, { test: 'node --version' });
|
||||
const r = resolveVerifyCommandTarget('cd . && npm test', { projectRoot: root });
|
||||
assert.equal(r.status, 'ok');
|
||||
assert.equal(r.target, root);
|
||||
});
|
||||
|
||||
test('row 20 — normalizes windows separators', () => {
|
||||
const root = fixtureRoot('row20');
|
||||
const back = resolveVerifyCommandTarget('cd ..\\..\\frontend && npm test', { projectRoot: root });
|
||||
const fwd = resolveVerifyCommandTarget('cd ../../frontend && npm test', { projectRoot: root });
|
||||
assert.equal(back.status, fwd.status);
|
||||
assert.equal(back.target, fwd.target);
|
||||
});
|
||||
|
||||
test('row 35 — file at target path is not a directory', () => {
|
||||
const root = fixtureRoot('row35');
|
||||
fs.writeFileSync(path.join(root, 'frontend'), 'not a dir', 'utf8');
|
||||
const r = resolveVerifyCommandTarget('cd frontend && npm test', { projectRoot: root });
|
||||
assert.equal(r.status, 'broken');
|
||||
assert.equal(r.reason, 'missing_dir');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2401 review Finding 1 — script check runs for --prefix form regardless of flag order', () => {
|
||||
test('npm --prefix ./web run <missing-script> is script_missing/warning', () => {
|
||||
const root = fixtureRoot('finding1-prefix-first-missing');
|
||||
writePackageJson(path.join(root, 'web'), { build: 'vite build' });
|
||||
const r = resolveVerifyCommandTarget('npm --prefix ./web run nope', { projectRoot: root });
|
||||
assert.equal(r.reason, 'script_missing');
|
||||
assert.equal(r.severity, 'warning');
|
||||
});
|
||||
|
||||
test('npm run <missing-script> --prefix ./web is script_missing/warning', () => {
|
||||
const root = fixtureRoot('finding1-prefix-trailing-missing');
|
||||
writePackageJson(path.join(root, 'web'), { build: 'vite build' });
|
||||
const r = resolveVerifyCommandTarget('npm run nope --prefix ./web', { projectRoot: root });
|
||||
assert.equal(r.reason, 'script_missing');
|
||||
assert.equal(r.severity, 'warning');
|
||||
});
|
||||
|
||||
test('npm --prefix ./web run <existing-script> is reason:null/severity:none', () => {
|
||||
const root = fixtureRoot('finding1-prefix-first-present');
|
||||
writePackageJson(path.join(root, 'web'), { build: 'vite build' });
|
||||
const r = resolveVerifyCommandTarget('npm --prefix ./web run build', { projectRoot: root });
|
||||
assert.equal(r.reason, null);
|
||||
assert.equal(r.severity, 'none');
|
||||
});
|
||||
|
||||
test('npm run <existing-script> --prefix ./web is reason:null/severity:none', () => {
|
||||
const root = fixtureRoot('finding1-prefix-trailing-present');
|
||||
writePackageJson(path.join(root, 'web'), { build: 'vite build' });
|
||||
const r = resolveVerifyCommandTarget('npm run build --prefix ./web', { projectRoot: root });
|
||||
assert.equal(r.reason, null);
|
||||
assert.equal(r.severity, 'none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2401 review Finding 2 — quoted --prefix paths containing spaces', () => {
|
||||
for (const [label, quote] of [['double-quoted', '"'], ['single-quoted', "'"]]) {
|
||||
test(`npm --prefix ${quote}my dir${quote} run test resolves the ${label} directory`, () => {
|
||||
const root = fixtureRoot(`finding2-present-${label}`);
|
||||
writePackageJson(path.join(root, 'my dir'), { test: 'node --version' });
|
||||
const r = resolveVerifyCommandTarget(`npm --prefix ${quote}my dir${quote} run test`, { projectRoot: root });
|
||||
assert.equal(r.status, 'ok');
|
||||
assert.equal(r.severity, 'none');
|
||||
assert.equal(r.target, path.join(root, 'my dir'));
|
||||
});
|
||||
|
||||
test(`npm --prefix ${quote}my dir${quote} run test is broken/missing_dir when the ${label} dir does not exist`, () => {
|
||||
const root = fixtureRoot(`finding2-absent-${label}`);
|
||||
const r = resolveVerifyCommandTarget(`npm --prefix ${quote}my dir${quote} run test`, { projectRoot: root });
|
||||
assert.equal(r.status, 'broken');
|
||||
assert.equal(r.reason, 'missing_dir');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('#2401 review Finding 3 — an absolute segment in a chained cd resets, not concatenates', () => {
|
||||
test('cd sub && cd <abs-existing-dir> && npm test resolves to the absolute dir', () => {
|
||||
const root = fixtureRoot('finding3-abs-reset');
|
||||
fs.mkdirSync(path.join(root, 'sub'), { recursive: true });
|
||||
const abs = path.join(ROOT, 'finding3-abs-target');
|
||||
writePackageJson(abs, { test: 'node --version' });
|
||||
const r = resolveVerifyCommandTarget(`cd sub && cd ${abs} && npm test`, { projectRoot: root });
|
||||
assert.equal(r.status, 'ok');
|
||||
assert.equal(r.severity, 'none');
|
||||
assert.equal(r.target, abs);
|
||||
assert.equal(r.rawTarget, abs);
|
||||
});
|
||||
|
||||
test('cd a && cd b && npm test (both relative) still resolves to <root>/a/b', () => {
|
||||
const root = fixtureRoot('finding3-relative-chain');
|
||||
writePackageJson(path.join(root, 'a', 'b'), { test: 'node --version' });
|
||||
const r = resolveVerifyCommandTarget('cd a && cd b && npm test', { projectRoot: root });
|
||||
assert.equal(r.status, 'ok');
|
||||
assert.equal(r.target, path.join(root, 'a', 'b'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveVerifyCommandTarget — refusals and negative space', () => {
|
||||
const hostile = [
|
||||
['row 10 — refuses a variable path', 'cd "$FRONTEND" && npm test'],
|
||||
['row 11 — refuses a glob path', 'cd frontend-* && npm test'],
|
||||
['row 12 — refuses command substitution', 'cd $(git rev-parse --show-toplevel)/web && npm test'],
|
||||
['row 13 — refuses backtick substitution', 'cd `pwd`/web && npm test'],
|
||||
['row 14 — refuses tilde expansion', 'cd ~/web && npm test'],
|
||||
];
|
||||
|
||||
for (const [name, command] of hostile) {
|
||||
test(name, () => {
|
||||
const root = fixtureRoot('hostile');
|
||||
const r = resolveVerifyCommandTarget(command, { projectRoot: root });
|
||||
assert.equal(r.status, 'unresolvable', command);
|
||||
assert.equal(r.reason, 'dynamic_path');
|
||||
assert.equal(r.severity, 'warning', 'an ungroundable path must never block');
|
||||
});
|
||||
}
|
||||
|
||||
test('row 15 — Nyquist MISSING sentinel is not applicable', () => {
|
||||
const root = fixtureRoot('row15');
|
||||
const r = resolveVerifyCommandTarget('MISSING — Wave 0 must create tests/x.py first', {
|
||||
projectRoot: root,
|
||||
});
|
||||
assert.equal(r.status, 'not_applicable');
|
||||
assert.equal(r.sentinel, true);
|
||||
assert.equal(r.severity, 'none');
|
||||
});
|
||||
|
||||
test('row 16 — pseudo-shell assertion is not applicable', () => {
|
||||
const root = fixtureRoot('row16');
|
||||
const r = resolveVerifyCommandTarget("grep -c '?from=' src/x.tsx == 0", { projectRoot: root });
|
||||
assert.equal(r.status, 'not_applicable');
|
||||
});
|
||||
|
||||
test('row 17 — dir created by an earlier task is pending, not broken', () => {
|
||||
const root = fixtureRoot('row17');
|
||||
const r = resolveVerifyCommandTarget('cd frontend && npm test', {
|
||||
projectRoot: root,
|
||||
declaredPaths: ['frontend/package.json'],
|
||||
});
|
||||
assert.equal(r.status, 'pending_creation');
|
||||
assert.notEqual(r.severity, 'blocker');
|
||||
});
|
||||
|
||||
test('row 21 — target above project root warns', () => {
|
||||
const root = fixtureRoot('row21');
|
||||
const r = resolveVerifyCommandTarget('cd ../.. && npm test', { projectRoot: root });
|
||||
assert.equal(r.reason, 'outside_root');
|
||||
assert.equal(r.severity, 'warning');
|
||||
assert.equal(r.base, root, 'the resolved base must be reported');
|
||||
});
|
||||
|
||||
test('row 19 — missing npm script warns, never blocks', () => {
|
||||
const root = fixtureRoot('row19');
|
||||
writePackageJson(path.join(root, 'frontend'), { lint: 'eslint .' });
|
||||
const r = resolveVerifyCommandTarget('cd frontend && npm run nope', { projectRoot: root });
|
||||
assert.equal(r.status, 'ok');
|
||||
assert.equal(r.reason, 'script_missing');
|
||||
assert.equal(r.severity, 'warning');
|
||||
assert.equal(r.script, 'nope');
|
||||
});
|
||||
|
||||
test('row 33 — invalid manifest json degrades to warning', () => {
|
||||
const root = fixtureRoot('row33');
|
||||
const dir = path.join(root, 'frontend');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), '{ not json', 'utf8');
|
||||
const r = resolveVerifyCommandTarget('cd frontend && npm run lint', { projectRoot: root });
|
||||
assert.equal(r.status, 'ok');
|
||||
assert.equal(r.reason, 'manifest_unreadable');
|
||||
assert.equal(r.severity, 'warning');
|
||||
});
|
||||
|
||||
test('row 34 — non-object manifest json does not crash', () => {
|
||||
for (const [i, body] of ['0', '"s"', '[]', 'null', 'true'].entries()) {
|
||||
const root = fixtureRoot(`row34-${i}`);
|
||||
const dir = path.join(root, 'frontend');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), body, 'utf8');
|
||||
const r = resolveVerifyCommandTarget('cd frontend && npm run lint', { projectRoot: root });
|
||||
assert.ok(KNOWN_STATUSES.has(r.status), `body ${body} → ${r.status}`);
|
||||
assert.notEqual(r.severity, 'blocker', `body ${body} must not block`);
|
||||
}
|
||||
});
|
||||
|
||||
test('row 28 — bare call shape does not throw', () => {
|
||||
const r = resolveVerifyCommandTarget('cd frontend && npm test');
|
||||
assert.ok(KNOWN_STATUSES.has(r.status));
|
||||
assert.equal(typeof r.base, 'string');
|
||||
});
|
||||
|
||||
test('row 46 — probe never executes the command', () => {
|
||||
const root = fixtureRoot('row46');
|
||||
const canary = path.join(root, 'pwned');
|
||||
resolveVerifyCommandTarget(`cd . && touch ${canary}`, { projectRoot: root });
|
||||
resolveVerifyCommandTarget(`npm --prefix . run x; touch ${canary}`, { projectRoot: root });
|
||||
assert.equal(fs.existsSync(canary), false, 'the probe must never execute command text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2401 CI regression — mid-string ~ (Windows 8.3 short names) is a literal, not home-expansion', () => {
|
||||
// Fixture dir literally named `RUNNER~1`, mirroring the 8.3 short-name shape
|
||||
// GitHub's Windows runners put in os.tmpdir() (e.g. `C:\Users\RUNNER~1\...`).
|
||||
// A `~` anywhere but the START of a path is an ordinary literal character;
|
||||
// only a LEADING `~` is shell home-expansion.
|
||||
function shortNameFixture(name) {
|
||||
const root = fixtureRoot(name);
|
||||
const app = path.join(root, 'RUNNER~1', 'app');
|
||||
writePackageJson(app, { lint: 'eslint .', test: 'node --version' });
|
||||
return { root, app };
|
||||
}
|
||||
|
||||
test('mid-string ~ in an absolute --prefix path resolves', () => {
|
||||
const { app } = shortNameFixture('tilde-abs-prefix');
|
||||
const r = resolveVerifyCommandTarget(`npm --prefix ${app} run lint`, { projectRoot: app });
|
||||
assert.equal(r.status, 'ok');
|
||||
assert.equal(r.severity, 'none');
|
||||
assert.equal(r.target, app);
|
||||
});
|
||||
|
||||
test('mid-string ~ in a relative cd target resolves', () => {
|
||||
const { root } = shortNameFixture('tilde-relative-cd');
|
||||
const r = resolveVerifyCommandTarget('cd RUNNER~1/app && npm test', { projectRoot: root });
|
||||
assert.equal(r.status, 'ok');
|
||||
assert.equal(r.severity, 'none');
|
||||
assert.equal(r.target, path.join(root, 'RUNNER~1', 'app'));
|
||||
});
|
||||
|
||||
test('leading ~ is still refused (row 14 must not be weakened)', () => {
|
||||
const root = fixtureRoot('tilde-leading-still-refused');
|
||||
const r = resolveVerifyCommandTarget('cd ~/web && npm test', { projectRoot: root });
|
||||
assert.equal(r.status, 'unresolvable');
|
||||
assert.equal(r.reason, 'dynamic_path');
|
||||
assert.equal(r.severity, 'warning');
|
||||
});
|
||||
|
||||
test('quoted leading ~ is still refused', () => {
|
||||
const root = fixtureRoot('tilde-leading-quoted-refused');
|
||||
const r = resolveVerifyCommandTarget('cd "~/web" && npm test', { projectRoot: root });
|
||||
assert.equal(r.status, 'unresolvable');
|
||||
assert.equal(r.reason, 'dynamic_path');
|
||||
assert.equal(r.severity, 'warning');
|
||||
});
|
||||
});
|
||||
|
||||
describe('probePhaseVerifyCommands', () => {
|
||||
test('row 23 — plan with no automated blocks is ok', () => {
|
||||
const root = fixtureRoot('row23');
|
||||
const phaseDir = writePhase(root, 'phase-1', { '01-PLAN.md': '# Plan\n\nno tasks here\n' });
|
||||
const r = probePhaseVerifyCommands({ phaseDir, projectRoot: root });
|
||||
assert.deepEqual(r.commands, []);
|
||||
assert.equal(r.status, 'ok');
|
||||
});
|
||||
|
||||
test('row 26 — overall status is the worst present', () => {
|
||||
const root = fixtureRoot('row26');
|
||||
writePackageJson(path.join(root, 'good'), { test: 'node --version' });
|
||||
const phaseDir = writePhase(root, 'phase-1', {
|
||||
'01-PLAN.md': planWith(['cd good && npm test']),
|
||||
'02-PLAN.md': planWith(['cd nowhere && npm test']),
|
||||
});
|
||||
const r = probePhaseVerifyCommands({ phaseDir, projectRoot: root });
|
||||
assert.equal(r.commands.length, 2);
|
||||
assert.equal(r.status, 'broken');
|
||||
assert.equal(r.counts.blocker, 1);
|
||||
});
|
||||
|
||||
test('row 18 — artifact-section path is pending, not broken', () => {
|
||||
const root = fixtureRoot('row18');
|
||||
const phaseDir = writePhase(root, 'phase-1', {
|
||||
'01-PLAN.md': planWith(['cd frontend && npm test'], { artifacts: ['frontend/package.json'] }),
|
||||
});
|
||||
const r = probePhaseVerifyCommands({ phaseDir, projectRoot: root });
|
||||
assert.equal(r.commands[0].status, 'pending_creation');
|
||||
assert.equal(r.counts.blocker, 0);
|
||||
});
|
||||
|
||||
test('row 17b — a path declared in an earlier task <files> is pending', () => {
|
||||
const root = fixtureRoot('row17b');
|
||||
const phaseDir = writePhase(root, 'phase-1', {
|
||||
'01-PLAN.md': planWith(['cd frontend && npm test'], { taskFiles: ['frontend/package.json'] }),
|
||||
});
|
||||
const r = probePhaseVerifyCommands({ phaseDir, projectRoot: root });
|
||||
assert.equal(r.commands[0].status, 'pending_creation');
|
||||
});
|
||||
|
||||
test('row 25b — CRLF plan yields identical verdicts', () => {
|
||||
const root = fixtureRoot('row25b');
|
||||
writePackageJson(path.join(root, 'web'), { test: 'node --version' });
|
||||
const lfDir = writePhase(root, 'phase-lf', {
|
||||
'01-PLAN.md': planWith(['cd web && npm test', 'cd nowhere && npm test']),
|
||||
});
|
||||
const crlfDir = writePhase(root, 'phase-crlf', {
|
||||
'01-PLAN.md': planWith(['cd web && npm test', 'cd nowhere && npm test'], { eol: '\r\n' }),
|
||||
});
|
||||
const lf = probePhaseVerifyCommands({ phaseDir: lfDir, projectRoot: root });
|
||||
const crlf = probePhaseVerifyCommands({ phaseDir: crlfDir, projectRoot: root });
|
||||
assert.deepEqual(
|
||||
crlf.commands.map((c) => [c.command, c.status, c.reason]),
|
||||
lf.commands.map((c) => [c.command, c.status, c.reason]),
|
||||
);
|
||||
});
|
||||
|
||||
test('row 24 — unreadable phase degrades, never throws', () => {
|
||||
const root = fixtureRoot('row24');
|
||||
const phaseDir = writePhase(root, 'phase-1', { '01-PLAN.md': planWith(['npm test']) });
|
||||
const realReadFile = fs.readFileSync;
|
||||
try {
|
||||
fs.readFileSync = (p, ...rest) => {
|
||||
if (String(p).endsWith('01-PLAN.md')) {
|
||||
const err = new Error('EIO: simulated read failure');
|
||||
err.code = 'EIO';
|
||||
throw err;
|
||||
}
|
||||
return realReadFile.call(fs, p, ...rest);
|
||||
};
|
||||
const r = probePhaseVerifyCommands({ phaseDir, projectRoot: root });
|
||||
assert.ok(r.readError, 'a read failure must surface as readError');
|
||||
assert.deepEqual(r.commands, []);
|
||||
} finally {
|
||||
fs.readFileSync = realReadFile;
|
||||
}
|
||||
});
|
||||
|
||||
test('row 24b — absent phase dir degrades, never throws', () => {
|
||||
const root = fixtureRoot('row24b');
|
||||
const r = probePhaseVerifyCommands({
|
||||
phaseDir: path.join(root, 'no-such-phase'),
|
||||
projectRoot: root,
|
||||
});
|
||||
assert.ok(r.readError);
|
||||
assert.deepEqual(r.commands, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('harvestPriorVerifyCommands', () => {
|
||||
function planningWithPhases(name, phases) {
|
||||
const root = fixtureRoot(name);
|
||||
for (const [phaseName, plans] of Object.entries(phases)) writePhase(root, phaseName, plans);
|
||||
return { root, planningDir: path.join(root, '.planning') };
|
||||
}
|
||||
|
||||
test('row 36 — harvests prior phase commands', () => {
|
||||
const { planningDir } = planningWithPhases('row36', {
|
||||
'01-alpha': { '01-PLAN.md': planWith(['npm --prefix ./web run lint']) },
|
||||
'02-beta': { '01-PLAN.md': planWith(['cd nowhere && npm test']) },
|
||||
});
|
||||
const r = harvestPriorVerifyCommands({ planningDir, beforePhase: 2 });
|
||||
assert.equal(r.commands.length, 1);
|
||||
assert.equal(r.commands[0].command, 'npm --prefix ./web run lint');
|
||||
assert.equal(r.commands[0].phase, '01');
|
||||
assert.ok(r.commands[0].plan.endsWith('01-PLAN.md'));
|
||||
});
|
||||
|
||||
test('row 37 — no prior phase harvests empty', () => {
|
||||
const { planningDir } = planningWithPhases('row37', {
|
||||
'01-alpha': { '01-PLAN.md': planWith(['npm test']) },
|
||||
});
|
||||
assert.deepEqual(harvestPriorVerifyCommands({ planningDir, beforePhase: 1 }).commands, []);
|
||||
});
|
||||
|
||||
test('row 38 — walks back to the nearest phase with commands', () => {
|
||||
const { planningDir } = planningWithPhases('row38', {
|
||||
'01-alpha': { '01-PLAN.md': planWith(['npm --prefix ./web run build']) },
|
||||
'02-beta': { '01-PLAN.md': '# Plan\n\nno automated blocks\n' },
|
||||
'03-gamma': { '01-PLAN.md': planWith(['cd x && npm test']) },
|
||||
});
|
||||
const r = harvestPriorVerifyCommands({ planningDir, beforePhase: 3 });
|
||||
assert.equal(r.commands.length, 1);
|
||||
assert.equal(r.commands[0].phase, '01');
|
||||
});
|
||||
|
||||
test('row 39 — walkback stops at three phases', () => {
|
||||
const { planningDir } = planningWithPhases('row39', {
|
||||
'01-alpha': { '01-PLAN.md': planWith(['npm --prefix ./web run build']) },
|
||||
'02-beta': { '01-PLAN.md': '# Plan\n' },
|
||||
'03-gamma': { '01-PLAN.md': '# Plan\n' },
|
||||
'04-delta': { '01-PLAN.md': '# Plan\n' },
|
||||
'05-epsilon': { '01-PLAN.md': planWith(['cd x && npm test']) },
|
||||
});
|
||||
assert.deepEqual(harvestPriorVerifyCommands({ planningDir, beforePhase: 5 }).commands, []);
|
||||
});
|
||||
|
||||
test('row 40 — harvest caps at twenty commands (19 / 20 / 21)', () => {
|
||||
for (const [n, expected] of [
|
||||
[19, 19],
|
||||
[20, 20],
|
||||
[21, 20],
|
||||
]) {
|
||||
const cmds = Array.from({ length: n }, (_, i) => `npm --prefix ./p${i} run build`);
|
||||
const { planningDir } = planningWithPhases(`row40-${n}`, {
|
||||
'01-alpha': { '01-PLAN.md': planWith(cmds) },
|
||||
'02-beta': { '01-PLAN.md': '# Plan\n' },
|
||||
});
|
||||
const r = harvestPriorVerifyCommands({ planningDir, beforePhase: 2 });
|
||||
assert.equal(r.commands.length, expected, `${n} distinct commands → ${expected}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('row 40b — duplicate commands are deduped before the cap', () => {
|
||||
const { planningDir } = planningWithPhases('row40b', {
|
||||
'01-alpha': { '01-PLAN.md': planWith(['npm test', 'npm test', 'npm run lint']) },
|
||||
'02-beta': { '01-PLAN.md': '# Plan\n' },
|
||||
});
|
||||
const r = harvestPriorVerifyCommands({ planningDir, beforePhase: 2 });
|
||||
assert.deepEqual(
|
||||
r.commands.map((c) => c.command),
|
||||
['npm test', 'npm run lint'],
|
||||
);
|
||||
});
|
||||
|
||||
test('row 41 — harvest degrades on unreadable planning dir', () => {
|
||||
const { planningDir } = planningWithPhases('row41', {
|
||||
'01-alpha': { '01-PLAN.md': planWith(['npm test']) },
|
||||
'02-beta': { '01-PLAN.md': '# Plan\n' },
|
||||
});
|
||||
const realReaddir = fs.readdirSync;
|
||||
try {
|
||||
fs.readdirSync = () => {
|
||||
const err = new Error('EACCES: simulated');
|
||||
err.code = 'EACCES';
|
||||
throw err;
|
||||
};
|
||||
const r = harvestPriorVerifyCommands({ planningDir, beforePhase: 2 });
|
||||
assert.deepEqual(r.commands, []);
|
||||
assert.ok(r.readError);
|
||||
} finally {
|
||||
fs.readdirSync = realReaddir;
|
||||
}
|
||||
});
|
||||
|
||||
test('harvest never throws on a missing planning dir', () => {
|
||||
const root = fixtureRoot('harvest-missing');
|
||||
const r = harvestPriorVerifyCommands({
|
||||
planningDir: path.join(root, 'nope'),
|
||||
beforePhase: 3,
|
||||
});
|
||||
assert.deepEqual(r.commands, []);
|
||||
});
|
||||
|
||||
// #2401 review fix: production phase directories are NOT named `phase-N-slug`
|
||||
// — they are `01-foundation`, `3-thing`, `2.1-thing`, `12A-thing`, and
|
||||
// optionally project-code-prefixed (`CK-01-name`). The bespoke
|
||||
// `/^phase-(\d+(?:\.\d+)?)/` regex this module previously used matched none
|
||||
// of these, so `harvestPriorVerifyCommands` always returned `[]` in
|
||||
// production. These rows pin every real directory-naming form via the
|
||||
// canonical `phase-id.cjs` grammar.
|
||||
|
||||
test('#2401 — zero-padded phase dir (01-foundation)', () => {
|
||||
const { planningDir } = planningWithPhases('naming-zero-padded', {
|
||||
'01-foundation': { '01-PLAN.md': planWith(['npm --prefix ./web run lint']) },
|
||||
'02-next': { '01-PLAN.md': '# Plan\n' },
|
||||
});
|
||||
const r = harvestPriorVerifyCommands({ planningDir, beforePhase: 2 });
|
||||
assert.equal(r.commands.length, 1);
|
||||
assert.equal(r.commands[0].command, 'npm --prefix ./web run lint');
|
||||
assert.equal(r.commands[0].phase, '01');
|
||||
});
|
||||
|
||||
test('#2401 — unpadded phase dir (3-thing)', () => {
|
||||
const { planningDir } = planningWithPhases('naming-unpadded', {
|
||||
'3-thing': { '01-PLAN.md': planWith(['npm test']) },
|
||||
'4-next': { '01-PLAN.md': '# Plan\n' },
|
||||
});
|
||||
const r = harvestPriorVerifyCommands({ planningDir, beforePhase: 4 });
|
||||
assert.equal(r.commands.length, 1);
|
||||
assert.equal(r.commands[0].command, 'npm test');
|
||||
assert.equal(r.commands[0].phase, '3');
|
||||
});
|
||||
|
||||
test('#2401 — decimal sub-phase (2.1-thing) orders between 2-thing and 3-thing', () => {
|
||||
const { planningDir } = planningWithPhases('naming-decimal', {
|
||||
'2-thing': { '01-PLAN.md': planWith(['npm run build:base']) },
|
||||
'2.1-thing': { '01-PLAN.md': planWith(['npm run build:subphase']) },
|
||||
'3-thing': { '01-PLAN.md': '# Plan\n' },
|
||||
});
|
||||
// beforePhase 3 walks back descending: 3-thing (empty) → 2.1-thing (has
|
||||
// commands, stop) — 2.1 must sort BETWEEN 2 and 3, not after 3 or before 2.
|
||||
const r = harvestPriorVerifyCommands({ planningDir, beforePhase: 3 });
|
||||
assert.equal(r.commands.length, 1);
|
||||
assert.equal(r.commands[0].command, 'npm run build:subphase');
|
||||
assert.equal(r.commands[0].phase, '2.1');
|
||||
});
|
||||
|
||||
test('#2401 — variant-suffixed phase dir (12A-thing)', () => {
|
||||
const { planningDir } = planningWithPhases('naming-variant-suffix', {
|
||||
'12A-thing': { '01-PLAN.md': planWith(['npm test']) },
|
||||
'13-next': { '01-PLAN.md': '# Plan\n' },
|
||||
});
|
||||
const r = harvestPriorVerifyCommands({ planningDir, beforePhase: 13 });
|
||||
assert.equal(r.commands.length, 1);
|
||||
assert.equal(r.commands[0].command, 'npm test');
|
||||
assert.equal(r.commands[0].phase, '12A');
|
||||
});
|
||||
|
||||
test('#2401 — project-code-prefixed phase dir (CK-01-name)', () => {
|
||||
const { planningDir } = planningWithPhases('naming-project-code', {
|
||||
'CK-01-name': { '01-PLAN.md': planWith(['npm --prefix ./web run lint']) },
|
||||
'CK-02-next': { '01-PLAN.md': '# Plan\n' },
|
||||
});
|
||||
const r = harvestPriorVerifyCommands({ planningDir, beforePhase: 2 });
|
||||
assert.equal(r.commands.length, 1);
|
||||
assert.equal(r.commands[0].command, 'npm --prefix ./web run lint');
|
||||
});
|
||||
|
||||
test('#2401 — non-phase directories (notes, archive) are ignored, not thrown on', () => {
|
||||
const { planningDir } = planningWithPhases('naming-non-phase-dirs', {
|
||||
'01-alpha': { '01-PLAN.md': planWith(['npm test']) },
|
||||
notes: { 'README.md': '# not a plan\n' },
|
||||
archive: { 'old-PLAN.md': planWith(['npm run stale']) },
|
||||
});
|
||||
const r = harvestPriorVerifyCommands({ planningDir, beforePhase: 2 });
|
||||
assert.equal(r.commands.length, 1);
|
||||
assert.equal(r.commands[0].command, 'npm test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('check verify-command-paths verb', () => {
|
||||
/** Fixture matching the real `.planning/phases/<dir>/` layout findPhaseInternal resolves against. */
|
||||
function writeCliPhase(root, dirName, planBody) {
|
||||
const phaseDir = path.join(root, '.planning', 'phases', dirName);
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '01-PLAN.md'), planBody, 'utf8');
|
||||
return phaseDir;
|
||||
}
|
||||
|
||||
test('row 44 — check verb emits documented json', () => {
|
||||
const root = fixtureRoot('row44');
|
||||
writeCliPhase(root, '01-test-phase', planWith(['npm test']));
|
||||
|
||||
const result = runGsdTools(['check', 'verify-command-paths', '1', '--raw'], root);
|
||||
assert.ok(result.success, `check verify-command-paths should succeed. stderr: ${result.error}`);
|
||||
|
||||
const payload = JSON.parse(result.output);
|
||||
assert.equal(typeof payload.status, 'string');
|
||||
assert.ok(Array.isArray(payload.commands));
|
||||
assert.equal(typeof payload.counts, 'object');
|
||||
assert.equal(typeof payload.counts.blocker, 'number');
|
||||
assert.equal(typeof payload.counts.warning, 'number');
|
||||
assert.equal(typeof payload.counts.total, 'number');
|
||||
assert.ok('readError' in payload);
|
||||
});
|
||||
|
||||
test('row 45 — check verb degrades on unknown phase', () => {
|
||||
const root = fixtureRoot('row45');
|
||||
fs.mkdirSync(path.join(root, '.planning', 'phases'), { recursive: true });
|
||||
|
||||
// Capture the result rather than asserting on exit code: an unknown phase
|
||||
// is a degraded-but-valid JSON payload, not necessarily a clean exit.
|
||||
const result = runGsdTools(['check', 'verify-command-paths', '999', '--raw'], root);
|
||||
const payload = JSON.parse(result.output);
|
||||
assert.deepEqual(payload.commands, []);
|
||||
assert.equal(typeof payload.readError, 'string');
|
||||
assert.ok(payload.readError.length > 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('property-based invariants', () => {
|
||||
const KNOWN_STATUSES_PROP = new Set([
|
||||
'ok',
|
||||
'broken',
|
||||
'unresolvable',
|
||||
'not_applicable',
|
||||
'pending_creation',
|
||||
]);
|
||||
const KNOWN_SEVERITIES = new Set(['blocker', 'warning', 'none']);
|
||||
|
||||
// Safe alphabet for synthesized <automated> command bodies: excludes '<', '>'
|
||||
// and '&' so the generated body cannot forge XML markup inside the rendered
|
||||
// plan text, and is non-empty after trimming.
|
||||
const commandArb = fc
|
||||
.stringMatching(/^[A-Za-z0-9_./ :=-]+$/)
|
||||
.filter(s => s.trim().length > 0);
|
||||
|
||||
describe('#2401 resolveVerifyCommandTarget — grounding properties', () => {
|
||||
test('(a) never throws; always returns a known status/severity/base shape', () => {
|
||||
fc.assert(fc.property(fc.string(), (s) => {
|
||||
let result;
|
||||
assert.doesNotThrow(() => {
|
||||
result = resolveVerifyCommandTarget(s, { projectRoot: os.tmpdir() });
|
||||
});
|
||||
assert.ok(result && typeof result === 'object', 'must return an object');
|
||||
assert.ok(KNOWN_STATUSES_PROP.has(result.status), `unknown status ${result.status}`);
|
||||
assert.ok(KNOWN_SEVERITIES.has(result.severity), `unknown severity ${result.severity}`);
|
||||
assert.strictEqual(typeof result.base, 'string');
|
||||
}));
|
||||
});
|
||||
|
||||
test('(a) never throws for full-unicode input; always returns a known status/severity/base shape', () => {
|
||||
fc.assert(fc.property(fc.string({ unit: 'binary' }), (s) => {
|
||||
let result;
|
||||
assert.doesNotThrow(() => {
|
||||
result = resolveVerifyCommandTarget(s, { projectRoot: os.tmpdir() });
|
||||
});
|
||||
assert.ok(result && typeof result === 'object', 'must return an object');
|
||||
assert.ok(KNOWN_STATUSES_PROP.has(result.status), `unknown status ${result.status}`);
|
||||
assert.ok(KNOWN_SEVERITIES.has(result.severity), `unknown severity ${result.severity}`);
|
||||
assert.strictEqual(typeof result.base, 'string');
|
||||
}));
|
||||
});
|
||||
|
||||
test('(b) a blocker severity is only ever reported for a grounded form', () => {
|
||||
fc.assert(fc.property(fc.string(), (s) => {
|
||||
const result = resolveVerifyCommandTarget(s, { projectRoot: os.tmpdir() });
|
||||
if (result.severity === 'blocker') {
|
||||
assert.ok(result.form !== null && result.form !== undefined,
|
||||
`blocker for ${JSON.stringify(s)} must have a non-null form`);
|
||||
assert.strictEqual(typeof result.target, 'string',
|
||||
`blocker for ${JSON.stringify(s)} must have a string target`);
|
||||
assert.ok(result.target.length > 0,
|
||||
`blocker for ${JSON.stringify(s)} must have a non-empty target`);
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
test('(b) a blocker severity is only ever reported for a grounded form (full-unicode)', () => {
|
||||
fc.assert(fc.property(fc.string({ unit: 'binary' }), (s) => {
|
||||
const result = resolveVerifyCommandTarget(s, { projectRoot: os.tmpdir() });
|
||||
if (result.severity === 'blocker') {
|
||||
assert.ok(result.form !== null && result.form !== undefined,
|
||||
`blocker for ${JSON.stringify(s)} must have a non-null form`);
|
||||
assert.strictEqual(typeof result.target, 'string',
|
||||
`blocker for ${JSON.stringify(s)} must have a string target`);
|
||||
assert.ok(result.target.length > 0,
|
||||
`blocker for ${JSON.stringify(s)} must have a non-empty target`);
|
||||
}
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2401 extractAutomatedCommands — recovery properties', () => {
|
||||
test('(c) recovers every generated command, in order, from synthesized plan text', () => {
|
||||
fc.assert(fc.property(fc.array(commandArb, { minLength: 0, maxLength: 8 }), (commands) => {
|
||||
const planText = commands
|
||||
.map((cmd, i) => `<task type="auto"><name>t${i}</name><verify><automated>${cmd}</automated></verify></task>`)
|
||||
.join('\n');
|
||||
|
||||
const extracted = extractAutomatedCommands(planText);
|
||||
assert.deepStrictEqual(
|
||||
extracted.map(c => c.command),
|
||||
commands.map(c => c.trim()),
|
||||
);
|
||||
extracted.forEach((row, i) => {
|
||||
assert.strictEqual(row.task, `t${i}`);
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
test('(d) CRLF invariance: \\r\\n-joined plan text yields an identical command list', () => {
|
||||
fc.assert(fc.property(fc.array(commandArb, { minLength: 0, maxLength: 8 }), (commands) => {
|
||||
const lfPlanText = commands
|
||||
.map((cmd, i) => `<task type="auto"><name>t${i}</name><verify><automated>${cmd}</automated></verify></task>`)
|
||||
.join('\n');
|
||||
const crlfPlanText = commands
|
||||
.map((cmd, i) => `<task type="auto"><name>t${i}</name><verify><automated>${cmd}</automated></verify></task>`)
|
||||
.join('\r\n');
|
||||
|
||||
const lfExtracted = extractAutomatedCommands(lfPlanText);
|
||||
const crlfExtracted = extractAutomatedCommands(crlfPlanText);
|
||||
assert.deepStrictEqual(crlfExtracted, lfExtracted);
|
||||
}));
|
||||
});
|
||||
|
||||
test('(e) never throws on arbitrary string input', () => {
|
||||
fc.assert(fc.property(fc.string(), (s) => {
|
||||
let result;
|
||||
assert.doesNotThrow(() => {
|
||||
result = extractAutomatedCommands(s);
|
||||
});
|
||||
assert.ok(Array.isArray(result), 'must return an array');
|
||||
}));
|
||||
});
|
||||
|
||||
test('(e) never throws on non-string input and returns [] for each', () => {
|
||||
const nonStrings = [0, [], null, undefined, true, {}];
|
||||
for (const v of nonStrings) {
|
||||
let result;
|
||||
assert.doesNotThrow(() => {
|
||||
result = extractAutomatedCommands(v);
|
||||
}, `must not throw for ${JSON.stringify(v)}`);
|
||||
assert.deepStrictEqual(result, [], `must return [] for ${JSON.stringify(v)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user