From 8da2dd3ad2bdd781e0529d3dd6f52fd13a9d0a1c Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 20 Aug 2026 13:42:43 -0400 Subject: [PATCH] feat(#2790): add read-only planning.inspect schema-v1 snapshot query (#3708) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#2790): add read-only planning.inspect schema-v1 snapshot query Adds a read-only query emitting a schema-versioned JSON projection of .planning/ so downstream harness UIs can consume planning state without parsing GSD's Markdown a second time. Composed strictly from the ADR-3180 section 7 owners plus parsePlanDocument, parseRequirements and parseUatItems; markdown structure is read through the Markdown Sectionizer and Markdown Table Model seams. It declares its own flat external schema rather than serializing PlanningSnapshot, which is the diagnostic-rule subject and still growing. Extracts plan-document parsing out of cmdPhasePlanIndex into a shared leaf module so phase.plan-index and planning.inspect cannot drift, including the plan-id derivation both surfaces report. Also fixes parseRequirements dropping the separator delimiter used by the shipped requirements template, surfaced while wiring the requirement rows. * fix(#2790): close spec gaps and a raw-text test assertion found in review Review findings from the standards, spec and security passes: - phases[] rows carry goal and dependencies, the two per-phase elements the issue Summary names that had no corresponding field. Goal is bounded to the section's leading prose so the Depends-on line, the Plans checklist and the wave annotations are not duplicated into it. - requirement rows carry their own diagnostic codes, so a consumer no longer has to string-parse the global diagnostics subject to correlate. - roadmap_acceptance.checkbox is looked up through the phase-id key owners. It was compared raw against the on-disk directory name, so it read null for every real-world slugged phase directory and the evidence channel was inert. - the hostile-input test asserts the structured payload instead of matching the raw stdout string. The absence proof over raw stdout is kept deliberately. * fix(#2790): register planning in the runtime usage list and repair fixtures Remote runner reported 9 failures on 9b3f9aa. Two root causes, both fixed: - gsd-tools.cjs registered the planning family in HOST_COMMAND_ROUTERS but never added it to TOP_LEVEL_USAGE's Commands list. Those are two surfaces a parity test guards, and the top-of-file block comment is not the runtime help string. A real wiring gap that every local gate and three review passes missed. - the new suite's fixtures could not produce a resolvable phase set. STATE.md frontmatter omitted the milestone field, which ADR-3180 7.2 rule 1 makes the primary milestone selector, so the phase set scoped unscoped and every percentage was correctly withheld. Separately declarePhase returned a path without creating the directory, so a phase declared but never written to left phases empty. Both reproduced against the built module before fixing. No assertion was weakened. The withholding path is still exercised and still returns null when the roadmap is absent. * chore(#2790): backfill changeset pr number * test(#2790): cover every enumerated matrix row and contain a symlink escape Reverses a silent deferral. An earlier revision left 23 of the 78 enumerated matrix rows unimplemented and 7 more as one-off manual checks, with a paragraph in the artifact and the PR body describing the gap. CLAUDE.md is explicit that such a note is not a fix and is not surfacing. The rows are implemented instead and the manual-evidence bucket is gone: 49 test cases become 88, covering all 78. Writing the symlink row proved a real leak: a *-PLAN.md symlinked outside .planning/ had its content emitted into the payload, confirmed via a direct call and the spawned CLI. readDocument now resolves target and planning root with realpathSync and rejects an escape, returning the ordinary unreadable-document shape. Tested both ways, because a containment check that over-rejects is its own defect: an escaping symlink leaks nothing and degrades that plan alone, while a legitimately relocated .planning/ symlink stays fully readable. The three new modules are registered in the mutation COVERED registry, which had been reporting has_work false and skipping the Stryker gate entirely. Provisional non-binding floors so the shards run and report; raised to the measured value before merge, since the registry forbids calibrating from a local run. * fix(#2790): satisfy the mutation ratchet contract and scope the 1MB test Remote runner reported 16 failures on 8c451ed. Two causes. The COVERED registry has a paired contract the earlier commit violated: every module needs a matching RATCHET_BASELINE entry, and minScore must be between 50 and 100 with minScore === baseline. The provisional floor of 1 was illegal on both counts. All three modules now sit at 50 — the registry's own enforced minimum — with matching baselines. The score cannot be measured locally: the shard runs node --test, which this repo hard-blocks, so CI is the only source. Floors are raised to the measured value once this PR's shards report; a shard below 50 means the tests need strengthening, since the floor cannot go lower. The 1MB test was measuring the test harness rather than the product. The command handles the oversized payload correctly by spilling to a tmpfile and resolving it back, but the resolved stdout then exceeds runGsdTools' maxBuffer and the helper reports ENOBUFS. It now uses --pick so stdout stays one byte while the full 1MB document is still read and parsed end to end. * fix(#2790): wire containment across every document read this command drives An isolated security review of the containment control found the boundary logic sound but not comprehensively wired: two content reads reached the filesystem without it. An escaped phase DIRECTORY could enumerate external filenames into the file fields and diagnostic subjects. Both enumeration sites now containment-check the directory before reading. Worth recording that the leak was already prevented one layer earlier than the review claimed: Dirent#isDirectory() reports false for a directory symlink, so such a directory never becomes a phase row at all. The guard is defense-in-depth for a direct caller and for platforms where a reparse point reports as a directory. A *-VERIFICATION.md symlinked outside the root leaked one frontmatter value verbatim, because readVerificationStatus does its own read and copies an unrecognized status into the payload's next_action. Closed from the consumer side through that function's existing fs injection seam, so src/verification.cts keeps its signature and its other callers are untouched. The reviewer additionally rated a forged status: passed as an integrity bypass. It is not: anyone able to plant the symlink can plant a real VERIFICATION.md saying the same thing. The incremental risk is confidentiality, which is what these fixes close. src/plan-scan.cts is deliberately unchanged: isPlanSuperseded reads symlink-followed content but yields only a derived boolean, no document text. * test(#2790): give the mutation shards an in-process surface Two Stryker shards were CANCELLED at the 15-minute cap, not failed on score. CI log: 640 mutants instrumented, and the dry run reported 'Ran 1 tests in 20 seconds' because the shards pointed at the integration suite, where nearly every case spawns a gsd-tools subprocess and Stryker's command runner treats the whole test-runner invocation as a single test. 640 x 20s cannot finish in 15 minutes; at the kill it was 27/640 with an ETA over an hour. Every other COVERED module points at a property or unit file, and the workflow's own paths filter lists exactly those two patterns. In-process is the intended mutation surface; the shards were pointed at the wrong shape of test. Adds tests/planning-inspect.unit.test.cjs — 39 cases in 10 describes that spawn nothing and call the built modules directly. plan-document and the router need no filesystem at all, one being a pure content-to-object parser and the other taking an injected mock. The three shards now point here. The 91-case integration suite is untouched and still runs in the normal test job. * chore(#2790): ratchet mutation floors to the measured CI scores CI run 32392791843 measured all three shards, which is the only source the registry accepts — local runs count timeouts as kills and inflate badly. planning-command-router 95.65 -> floor 94 plan-document 76.58 -> floor 75 planning-inspect 57.03 -> floor 56 Applied the registry's own rule, floor(score) - 1, and updated RATCHET_BASELINE to match, since the ratchet test enforces equality. planning-inspect sits well below the file's target of 80 and is the obvious ratchet candidate as its tests improve. planning-command-router already exceeds the target. The placeholder comment about floors pending measurement is removed rather than left standing as a false statement. --------- Co-authored-by: sim --- .changeset/jolly-rams-march.md | 5 + .gitignore | 3 + CONTEXT.md | 6 + docs/CLI-TOOLS.md | 96 + docs/COMMANDS.md | 25 + docs/FEATURES.md | 23 + docs/INVENTORY-MANIFEST.json | 3 + docs/INVENTORY.md | 3 + docs/README.md | 1 + docs/how-to/consume-the-planning-snapshot.md | 198 ++ eslint.config.mjs | 6 + gsd-core/bin/gsd-tools.cjs | 16 +- scripts/mutation-matrix.cjs | 61 + src/gap-checker.cts | 14 +- src/phase.cts | 71 +- src/plan-document.cts | 322 +++ src/planning-command-router.cts | 86 + src/planning-inspect.cts | 1286 +++++++++ src/uat.cts | 17 +- tests/mutation-matrix-ratchet.test.cjs | 3 + tests/planning-inspect.test.cjs | 2440 +++++++++++++++++ tests/planning-inspect.unit.test.cjs | 778 ++++++ ...refactor-1390-t3-characterization.test.cjs | 8 + 23 files changed, 5413 insertions(+), 58 deletions(-) create mode 100644 .changeset/jolly-rams-march.md create mode 100644 docs/how-to/consume-the-planning-snapshot.md create mode 100644 src/plan-document.cts create mode 100644 src/planning-command-router.cts create mode 100644 src/planning-inspect.cts create mode 100644 tests/planning-inspect.test.cjs create mode 100644 tests/planning-inspect.unit.test.cjs diff --git a/.changeset/jolly-rams-march.md b/.changeset/jolly-rams-march.md new file mode 100644 index 000000000..8ae4bbf92 --- /dev/null +++ b/.changeset/jolly-rams-march.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 3708 +--- +**New `planning inspect` query emits a schema-v1 snapshot of the whole planning state** — downstream harness UIs and dashboards can now read milestone identity, active position, per-phase verification/roadmap-acceptance/UAT evidence, requirement traceability, plan and task rows, and progress fractions from one read-only JSON document instead of parsing GSD's Markdown a second time. Unknown or conflicting evidence is reported as `unknown` with a coded diagnostic rather than inferred. (#2790) diff --git a/.gitignore b/.gitignore index 0f2b1eef4..cca65beee 100644 --- a/.gitignore +++ b/.gitignore @@ -201,6 +201,9 @@ build/ /gsd-core/bin/lib/planning-workspace.cjs /gsd-core/bin/lib/planning-scope.cjs /gsd-core/bin/lib/planning-snapshot.cjs +/gsd-core/bin/lib/planning-inspect.cjs +/gsd-core/bin/lib/planning-command-router.cjs +/gsd-core/bin/lib/plan-document.cjs /gsd-core/bin/lib/pattern.cjs /gsd-core/bin/lib/text-lines.cjs /gsd-core/bin/lib/token-scanner.cjs diff --git a/CONTEXT.md b/CONTEXT.md index 4f92aea2a..acd1d512c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -118,6 +118,12 @@ Leaf module generalizing the proven `hooks/lib/git-cmd.js` token-walk (#3129) in ### Planning Snapshot Module Module owning the parsed projection of `.planning/` that a diagnostic rule may read, per ADR-3180 §8.1 (Decision 8, Phase 10, #3308). `buildPlanningSnapshot(cwd) → PlanningSnapshot` is composed EXCLUSIVELY from the already-consolidated §7 owners — `getMilestoneInfo` (Roadmap Parser Module), `listMilestonePhaseDirs` (Phase Locator Module), `isPhaseComplete` (Verification Module), `scanPhasePlans` (Plan Scan Module), `stateFieldValue`/`stateCurrentPositionSlice` (STATE.md Document Module), `planningPaths` (Planning Workspace Module) — and introduces no new semantic derivation of its own. `PlanningSnapshot` exposes `milestone`/`phaseDirs`/`phases`/`currentPhaseLabel`, each a `{value, scope}` pair per the Planning Scope Module's frozen `SCOPE` enum; `phases` additionally carries a `PhaseSnapshot[]` (`dir`, `complete`, `verificationStatus`, `planCount`, `summaryCount`, `scope`). The one new piece of logic this module adds is `worstScope(...scopes) → Scope`, a pure severity-ordered combinator (`UNREADABLE` > `UNSCOPED` > `TRUNCATED` > `COMPLETE`) that folds several independently-scoped owner answers about the same phase directory into one composite signal — NOT a re-derivation of any owner (each owner's own algorithm is untouched; only their already-computed `scope` verdicts are combined), but new coordination logic no single owner has the visibility to express. Every exposed field carries PARSED values only, never raw document text — this is structural, not advisory: a diagnostic rule given only the parsed value cannot re-derive a field's location the way `#3162`'s three inert `Current Phase` literal-search predicates did. Read failures on STATE.md (exists-but-unreadable, distinct from absent) are reported via the Unusable Input Diagnostic Module's `warnUnusableInput(UNUSABLE_REASON.STATE_UNREADABLE)`. Guarded by `scripts/lint-planning-snapshot-bypass-drift.cjs` (ratcheted per Decision 4(e), scoped to `DIAGNOSTIC_RULE_FUNCTIONS` — currently `cmdValidateHealth` in `src/verify.cts` only, acknowledging its existing raw `.planning/` reads as debt owned by Phase 11, #3309, which migrates it onto this snapshot). Source of truth: `gsd-core/bin/lib/planning-snapshot.cjs` (generated from `src/planning-snapshot.cts`). Design: `.gsd/phase/refactor-3308-planning-snapshot-parsed-projection/40-design.md`. +### Plan Document Module +Leaf module owning the parse of a `*-PLAN.md` document BODY: `` extraction, the `` block grammar (with the legacy `## Task N` heading fallback), per-task `` / `` / ``, and the frontmatter-derived scheduling metadata (`wave`, `depends_on`, `autonomous`, `agent_hint`, `files_modified`). `parsePlanDocument(content, planPath?) → PlanDocument`; `TASK_KIND` is a frozen `{AUTO, CHECKPOINT}` enum so a `` block — which carries an entirely different element set (``/``, no ``/``) — is reported as its own kind rather than as a malformed auto task. Extracted from `cmdPhasePlanIndex`'s inline pass-1 loop (#2790) because two commands in two families now need it (`phase.plan-index` and `planning.inspect`); leaving it in `phase.cts` would have forced a `planning` → `phase` dependency, and copying it is the `DEFECT.GENERATIVE-FIX` shape. **NOT an ADR-3180 §7 derivation** — §6 puts the document-parsing layer (#2143) explicitly out of that epic's scope; this module answers "what does this plan document say", never "how many plans are outstanding" (`scanPhasePlans`, §7.5) or "is this phase complete" (`isPhaseComplete`, §7.4). Behaviour is byte-for-behaviour identical to the prior inline code, INCLUDING the invariant `taskCount === tasks.length === (xmlTaskCount || mdTaskCount)` and its known fence-blindness (a `## Task 1` inside a fenced block still counts) — characterised, not endorsed: changing it would silently alter `phase.plan-index`'s output for existing projects. Source of truth: `gsd-core/bin/lib/plan-document.cjs` (generated from `src/plan-document.cts`). + +### Planning Inspect Module +Module owning the **schema-v1 canonical planning snapshot** emitted by the read-only `planning inspect` query (#2790), for downstream harness UIs that need truthful `.planning/` state without parsing ROADMAP/REQUIREMENTS/PLAN/SUMMARY Markdown a second time. `buildPlanningInspect(cwd) → payload`; `cmdPlanningInspect(cwd, raw)` emits it through `output()` (so the existing >50 KB `@file:` spill seam applies unchanged). `PLANNING_INSPECT_SCHEMA_VERSION = 1` is the wire contract — a consumer MUST reject any other value rather than best-effort-parse an unknown shape. Composes, never re-derives: milestone identity/windowing and phase enumeration via `buildPlanningSnapshot` (Planning Snapshot Module), completion via `isPhaseComplete` (§7.4, disk-strict), live-plan counting via `scanPhasePlans` (§7.5), percent via `clampPercent` (§7.6), STATE fields via `stateFieldValue`/`stateCurrentPositionSlice` (§7.7), plan bodies via `parsePlanDocument`, requirement IDs via `parseRequirements`, UAT items via `parseUatItems`/`selectPhaseUatFiles`. **It deliberately does NOT serialize `PlanningSnapshot`**: that shape is the §8.1 diagnostic-rule subject and is explicitly additive/growing (4 fields at Phase 10, 20+ by Phase 12), so handing it to external consumers would freeze an internal contract by accident (Hyrum's Law) — this module declares its own flat schema and maps into it, and a field added to `PlanningSnapshot` must never change schema-v1 output. Three frozen enums carry every non-answer — `INSPECT_DIAGNOSTIC`, `TASK_STATUS` (`done|pending|unknown`), `PROVENANCE` (`task_scoped|plan_scoped|absent`) and `AGREEMENT` (`agreed|conflicting|unknown`) — because unknown or conflicting evidence serializes as `unknown` plus a diagnostic and is **never inferred, reconciled, or defaulted**; keys are always present, `null` is the explicit non-answer. Roadmap acceptance, verification and UAT are reported **side by side and never folded into one verdict**, and a ROADMAP checkbox is emitted with `authoritative: false` per §7.4. **Not a diagnostic rule**, and deliberately NOT registered in `scripts/lint-planning-snapshot-bypass-drift.cjs`, which is `DIAGNOSTIC_RULE_FUNCTIONS`-scoped and must remain prunable to zero when #3309 lands. Dispatched by the Planning Command Router (`src/planning-command-router.cts`, family `planning`, subcommand `inspect`, no arguments in v1 — a stray positional or unknown flag is a fail-loud `ERROR_REASON.USAGE`). Source of truth: `gsd-core/bin/lib/planning-inspect.cjs` (generated from `src/planning-inspect.cts`). Design: `.gsd/phase/feat-2790-planning-inspect/40-design.md`. + ### Health Diagnostic Types Module Leaf module owning the `SEVERITY`/`REMEDY_ACTION`/`REMEDY_RISK` enums and `Diagnostic`/`Remedy`/`Rule` types shared between the Health Diagnostic Module (the evaluator) and the Health Diagnostic Rule Groups (the eight rule-group files it concatenates). Split out of `src/health-diagnostic.cts` (Phase 11, #3309, ADR-3180 §8.2/§8.3/§8.5) to break a CJS circular dependency: the evaluator must `require()` every rule-group file to populate `RULES`, and every rule-group file needs these enums/types — if the rule-group files required the evaluator back, the require cycle would resolve `module.exports` before it is assigned. This leaf has no runtime dependency on either side of that cycle. Source of truth: `gsd-core/bin/lib/health-diagnostic-types.cjs` (generated from `src/health-diagnostic-types.cts`). diff --git a/docs/CLI-TOOLS.md b/docs/CLI-TOOLS.md index 96850a256..30b9cbcd0 100644 --- a/docs/CLI-TOOLS.md +++ b/docs/CLI-TOOLS.md @@ -578,6 +578,102 @@ Pass `--json` to receive the typed IR directly (useful in scripts and test asser --- +## Planning Snapshot Commands + +### `planning inspect` + +Emits a read-only, schema-versioned snapshot of everything `.planning/` knows, +as one JSON document. It exists so a downstream tool — a harness UI, a +mission-control view, a dashboard — can consume planning state without parsing +`ROADMAP.md` / `REQUIREMENTS.md` / `*-PLAN.md` / `*-SUMMARY.md` a second time +and drifting from gsd-core's own answers. + +```bash +gsd-tools query planning inspect +gsd-tools query planning.inspect # dotted canonical form — identical output +``` + +**Takes no arguments.** A stray positional or an unrecognized flag is a +fail-loud usage error, not a silently-ignored one: a caller who believed +`--phase 3` was scoping the query would otherwise receive a whole-project +snapshot presented as a scoped one. + +`planning inspect` writes nothing, anywhere. It is safe to run against a +project mid-workflow. + +#### The schema contract + +```json +{ "schema_version": 1, "...": "..." } +``` + +`schema_version` is the contract. **A consumer must reject any value other than +the one it was written against** rather than best-effort-parsing a shape it does +not know. Every top-level key is always present; a key is never omitted to +signal absence, because omission is itself something callers come to depend on. + +| Key | What it carries | +|-----|-----------------| +| `schema_version` | Always `1` today | +| `generated_from` | Resolved `cwd` and `.planning/` root (`null` when there is no planning root) | +| `milestone` | `version`, `name`, and the `scope` of that answer | +| `active` | `phase`, `plan`, and `status` — three distinct STATE.md facts, each scoped separately | +| `phases[]` | Per phase: completion, verification, roadmap acceptance, UAT, plan and task rows | +| `orphan_phase_dirs[]` | Directories under `phases/` that the current milestone window does not declare | +| `requirements[]` | Requirement rows with mapped-phase traceability | +| `progress` | `accepted_phases` and `completed_plans`, as independent fractions | +| `diagnostics[]` | Coded reasons for every non-answer above | + +#### Three kinds of evidence, never folded together + +Each phase reports `verification`, `roadmap_acceptance`, and `uat` **side by +side**. They are not combined into a single verdict, because they answer +different questions and can legitimately disagree — a phase can pass +verification while UAT items remain open. + +`roadmap_acceptance.checkbox` is reported with `authoritative: false`. A ticked +ROADMAP checkbox is a human annotation with no machine authority: completion is +derived from disk state (a passing `*-VERIFICATION.md`), and a stale tick never +overrides it. See [Milestone window scope](#milestone-window-scope-roadmap-analyze). + +#### Unknown is a real answer; nothing is inferred + +Where the evidence is absent, or where two sources disagree, the value is `null` +or `"unknown"` and a coded entry in `diagnostics[]` says why. It is never +reconciled, guessed, or filled from a plausible default. + +The most common case is task-scoped file provenance. A `` block declares +the files it plans to touch, but `SUMMARY.md`'s `## Files Created/Modified` +section describes the **whole plan**, not an individual task. Spreading that +plan-level list across the plan's tasks would be inference, so instead: + +| `provenance` | Meaning | +|---|---| +| `task_scoped` | The summary attributed files to this specific task (via a deviation block naming `Found during: Task N`) | +| `plan_scoped` | A summary exists, but only carries a plan-level file list — this task's changed files are unknown | +| `absent` | No summary exists yet | + +When a task's planned and changed file sets both exist and disagree, +`agreement` is `"conflicting"` and **both lists are emitted verbatim**. + +#### Percentages are withheld rather than guessed + +`progress.accepted_phases` and `progress.completed_plans` are independent +fractions, each `{completed, total, percent, scope}`. `percent` is `null` +whenever `scope` is anything other than `complete` — the same rule the roadmap +and progress surfaces follow, for the same reason. See +[A non-`COMPLETE` scope withholds the percentage entirely](#a-non-complete-scope-withholds-the-percentage-entirely-3217). + +`0` is a real answer under a `complete` scope and is never withheld. + +#### Large payloads + +Output over ~50 KB is written to a temp file and returned as +`@file:`, which `gsd-tools` resolves transparently before writing to +stdout — the same channel `init` uses. Callers see JSON either way. + +--- + ## Template Commands Template selection and filling. diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 18b1f5b01..2b25570d3 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -652,6 +652,31 @@ node gsd-tools.cjs phase uat-passed 3 --raw # Machine-readable --- +### `planning inspect` + +Emit a read-only, schema-versioned JSON snapshot of the whole planning state — +milestone identity, active phase/plan/status, per-phase verification, roadmap +acceptance and UAT evidence (kept separate), requirement rows with mapped-phase +traceability, plan and task rows with planned/changed file provenance, and +independent `accepted_phases` / `completed_plans` fractions. + +For downstream tools that need planning state without re-parsing GSD's Markdown. +Mutates nothing. Takes no arguments — a stray positional or unknown flag is a +fail-loud usage error rather than a silently-ignored one. + +```bash +node gsd-tools.cjs query planning inspect # schema-v1 snapshot +node gsd-tools.cjs query planning.inspect # dotted canonical form, identical +node gsd-tools.cjs query planning inspect --cwd /path/to/project +``` + +Check `schema_version` before reading any other field, and branch on each value's +`scope` — `complete` with an empty value is a real answer, `unreadable` is not. +Full field reference: [CLI Tools](CLI-TOOLS.md#planning-inspect). Integration +walkthrough: [Consume the planning snapshot](how-to/consume-the-planning-snapshot.md). + +--- + ## Navigation Commands ### `/gsd-next` diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 0e66ce31a..d1257cfdc 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -3473,3 +3473,26 @@ See [Resolve verify-command path findings](how-to/resolve-verify-command-path-fi **A proxy, never a drift measurement.** The count includes commits that touched nothing `STATE.md` describes, and the stamp restamps on every state write — so a low count means "something wrote STATE recently", not "STATE is accurate". Rendered with a `~`; never gate on it. **Reference:** [Configuration](CONFIGURATION.md) · [Read the statusline freshness marker](how-to/read-the-statusline-freshness-marker.md) · [ADR-2164](adr/2164-statusline-scope-boundary.md) + +### 163. Read-Only Planning Snapshot (`planning inspect`) + +**Command:** `gsd-tools query planning inspect` + +**Purpose:** Give downstream consumers — harness UIs, mission-control surfaces, dashboards, bots — one schema-versioned JSON document describing everything `.planning/` knows, so nothing outside gsd-core has to parse `ROADMAP.md` / `REQUIREMENTS.md` / `*-PLAN.md` / `*-SUMMARY.md` a second time. gsd-core is the single source of `.planning/` truth; a second parser is a second answer. + +**Requirements:** +- REQ-INSP-01: `PLANNING_INSPECT_SCHEMA_VERSION = 1` is emitted as `schema_version`. Consumers MUST reject any other value rather than best-effort-parse an unknown shape. +- REQ-INSP-02: Read-only. The command mutates no planning state, and mutates nothing on disk, under any input. +- REQ-INSP-03: Unknown or conflicting evidence serializes as `null` / `"unknown"` with a coded entry in `diagnostics[]` — never inferred, reconciled, or defaulted. Every key is always present; a key is never omitted to signal absence. +- REQ-INSP-04: Argument errors fail loud (non-zero exit, typed `ERROR_REASON`); data gaps do not. v1 takes no arguments, and a stray positional or unknown flag is a usage error rather than a silently-ignored one. +- REQ-INSP-05: Roadmap acceptance, verification status, and UAT items are reported side by side per phase and are never folded into a single verdict. A ROADMAP checkbox carries `authoritative: false` — completion is derived from disk state. +- REQ-INSP-06: `accepted_phases` and `completed_plans` are independent fractions. `percent` is `null` whenever the scope is not `complete`, per the same rule the roadmap and progress surfaces follow. +- REQ-INSP-07: Payloads over ~50 KB use the existing `@file:` spill channel, resolved transparently before stdout. + +**Why it does not simply serialize the internal snapshot.** `PlanningSnapshot` (the diagnostic-rule subject introduced by ADR-3180 §8.1) is deliberately additive and still growing — four fields at Phase 10, twenty-plus by Phase 12. Handing that shape to external consumers would freeze an internal contract by accident. `planning inspect` declares its own flat schema and maps into it, so a field added to `PlanningSnapshot` never changes what this command emits. + +**Composed, never re-derived.** Milestone identity and phase enumeration arrive via `buildPlanningSnapshot`; completion from `isPhaseComplete` (disk-strict); live-plan counting from `scanPhasePlans`; the percentage arithmetic from `clampPercent`; STATE fields from `stateFieldValue`; plan bodies from the Plan Document Module; requirement IDs from `parseRequirements`; UAT items from `parseUatItems`. Markdown structure is read through the Markdown Sectionizer and Markdown Table Model seams, so the Traceability table is resolved by column name against its registered schema rather than by a position-anchored regex. + +**Known limit — task-scoped file provenance.** A `` declares the files it plans to touch, but `SUMMARY.md`'s `## Files Created/Modified` describes the whole plan. Spreading that list across a plan's tasks would be inference, so a task's `changed_files` is populated only where the summary attributes files to that specific task; otherwise it is `null` with `provenance: "plan_scoped"`. Closing this needs a change to the SUMMARY format, not to the reader. + +**Reference:** [CLI Tools](CLI-TOOLS.md#planning-inspect) · [Consume the planning snapshot](how-to/consume-the-planning-snapshot.md) diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index edecccc76..26909bce0 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -453,8 +453,11 @@ "phase.cjs", "phases-command-router.cjs", "plan-dependency-graph.cjs", + "plan-document.cjs", "plan-drift-guard.cjs", "plan-scan.cjs", + "planning-command-router.cjs", + "planning-inspect.cjs", "planning-scope.cjs", "planning-snapshot.cjs", "planning-workspace.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 19c3ddc3c..fd6fa25fd 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -556,7 +556,10 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `phase.cjs` | Phase directory operations, decimal numbering, plan indexing | | `phases-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phases` | | `plan-dependency-graph.cjs` | Shared halt-propagation over a plan's `depends_on` DAG — the single topological-order + halt-propagation engine used by both `phase.cjs`'s wave-grouping and `phase-locator.cjs`'s phase-location primitive, so the two can never diverge on which plans a halted plan blocks (#2830) | +| `plan-document.cjs` | Canonical parser for a `*-PLAN.md` document BODY (compiled from `src/plan-document.cts`, gitignored; #2790) — `parsePlanDocument(content, planPath?)` returns ``, the `` block rows (with the legacy `## Task N` heading fallback), per-task ``/``/``, and the frontmatter scheduling metadata (`wave`, `depends_on`, `autonomous`, `agent_hint`, `files_modified`); frozen `TASK_KIND` (`AUTO`/`CHECKPOINT`) so a `checkpoint:*` block is reported as its own kind rather than a malformed auto task. Extracted from `cmdPhasePlanIndex`'s inline loop so `phase.plan-index` and `planning.inspect` cannot drift; the invariant `taskCount === tasks.length === (xmlTaskCount \|\| mdTaskCount)` is preserved byte-for-behaviour | | `plan-scan.cjs` | Canonical phase-plan scanner for detecting plan and summary files in flat and nested layouts (k014) | +| `planning-command-router.cjs` | Thin CJS subcommand router for `gsd-tools planning` (compiled from `src/planning-command-router.cts`, gitignored; #2790) — one subcommand, `inspect`; v1 accepts no arguments, so a stray positional or unknown flag is a fail-loud `ERROR_REASON.USAGE` rather than a silently-ignored one | +| `planning-inspect.cjs` | Read-only schema-v1 canonical planning snapshot (compiled from `src/planning-inspect.cts`, gitignored; #2790) — `buildPlanningInspect(cwd)` / `cmdPlanningInspect`; `PLANNING_INSPECT_SCHEMA_VERSION = 1` is the wire contract consumers must reject other values of. Composed from the ADR-3180 §7 owners plus `parsePlanDocument`/`parseRequirements`/`parseUatItems`, and read through the Markdown Sectionizer + Markdown Table Model seams; deliberately declares its own flat external schema rather than serializing the still-growing `PlanningSnapshot`. Frozen `INSPECT_DIAGNOSTIC`/`TASK_STATUS`/`PROVENANCE`/`AGREEMENT` enums carry every non-answer — unknown or conflicting evidence is reported with a coded diagnostic, never inferred | | `planning-scope.cjs` | Frozen `SCOPE` discriminator (`COMPLETE`/`TRUNCATED`/`UNSCOPED`/`UNREADABLE`) distinguishing a genuinely-empty derivation from one computed over a truncated or unscoped input, so callers can branch on the difference instead of reading a plausible zero (ADR-3180) | | `planning-snapshot.cjs` | Parsed projection of `.planning/` composed exclusively from the ADR-3180 §7 owners (milestone identity, phase enumeration, phase completion, plan/summary counting, STATE.md current-phase) — exposes only scope-carrying parsed values, never raw document text, so a diagnostic rule cannot re-derive a field's location (ADR-3180 §8.1) | | `planning-workspace.cjs` | Planning path/workstream seam (`planningDir`, `planningPaths`, active-workstream routing, `.planning/.lock` orchestration) | diff --git a/docs/README.md b/docs/README.md index 3dba62bf1..aa2e326d7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -30,6 +30,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md) - [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 - [Read the statusline freshness marker](how-to/read-the-statusline-freshness-marker.md) — turn on `state ~N commits back`, and tell "STATE.md is fresh" apart from "freshness could not be established" +- [Consume the planning snapshot](how-to/consume-the-planning-snapshot.md) — read `planning inspect` from a dashboard or harness, and tell "nothing to report" apart from "could not look" - [Keep planning docs out of a shared repo](how-to/keep-planning-docs-private.md) — make `.planning/` local-only, including untracking files git already tracks (the step `.gitignore` alone cannot do) - [Plan a phase](how-to/plan-a-phase.md) — run research, decompose work, and verify plan quality - [Execute a phase](how-to/execute-a-phase.md) — run plans in parallel waves with fresh-context subagents diff --git a/docs/how-to/consume-the-planning-snapshot.md b/docs/how-to/consume-the-planning-snapshot.md new file mode 100644 index 000000000..edaf63bf2 --- /dev/null +++ b/docs/how-to/consume-the-planning-snapshot.md @@ -0,0 +1,198 @@ +# Consume the planning snapshot + +You are building something that needs to know where a GSD project stands — +a dashboard, a status page, a harness UI, a bot that comments on a pull request. +`planning inspect` gives you that as one JSON document, so you never have to +parse `ROADMAP.md`, `REQUIREMENTS.md`, `*-PLAN.md`, or `*-SUMMARY.md` yourself. + +This guide covers the whole path from *off* to *reading a value you can trust*, +including the part most integrations get wrong: telling **"nothing to report"** +apart from **"could not look."** + +## Before you start + +You need `gsd-tools` on the machine, and a project directory containing +`.planning/`. Nothing has to be enabled or configured — the command is read-only +and always available. + +## 1. Get a snapshot + +```bash +gsd-tools query planning inspect +``` + +The dotted form is identical, if that reads better in your code: + +```bash +gsd-tools query planning.inspect +``` + +The command takes no arguments. If you pass one, it fails loudly rather than +ignoring it — see [Troubleshooting](#troubleshooting). + +To inspect a project other than your current directory, use the global `--cwd` +flag, which every `gsd-tools` command accepts: + +```bash +gsd-tools query planning inspect --cwd /path/to/project +``` + +## 2. Check the schema version first + +```json +{ "schema_version": 1 } +``` + +**Reject any version you were not written against.** Do this before you touch +any other field: + +```javascript +const snapshot = JSON.parse(stdout); +if (snapshot.schema_version !== 1) { + throw new Error(`Unsupported planning snapshot schema: ${snapshot.schema_version}`); +} +``` + +Best-effort-parsing an unknown shape is how an integration starts silently +reporting wrong numbers after an upgrade. A hard failure is the kinder outcome. + +## 3. Read a value — and check its scope + +Most answers arrive alongside a `scope`. It tells you whether the value is a +real answer or a placeholder for one that could not be produced: + +| `scope` | Meaning | What to render | +|---|---|---| +| `complete` | The read succeeded. The value is real — **including when it is `0` or `[]`** | The value | +| `truncated` | Part of the source could not be read | The value, marked partial | +| `unscoped` | The source exists but nothing could be located in it | "Unknown" | +| `unreadable` | The source could not be read at all | "Unknown" | + +The distinction that matters: **`complete` with an empty value is a real answer.** +A milestone with zero phases genuinely has zero phases. `unreadable` with an +empty value means nobody looked. Rendering both as "0 phases" is the bug this +field exists to prevent. + +```javascript +const phases = snapshot.progress.accepted_phases; +if (phases.scope !== 'complete') { + render('Progress unavailable'); // could not look +} else { + render(`${phases.completed} / ${phases.total}`); // real, even if 0 / 0 +} +``` + +## 4. Handle a withheld percentage + +`progress.accepted_phases` and `progress.completed_plans` each carry +`{completed, total, percent, scope}`. **`percent` is `null` whenever `scope` is +not `complete`.** + +That is deliberate. A percentage computed from a partial phase set is a +confidently wrong number, and a consumer cannot tell it apart from a real one. +Do not substitute `0`, and do not compute your own from `completed / total` — +those counts are partial too. + +```javascript +const { percent } = snapshot.progress.completed_plans; +render(percent === null ? '—' : `${percent}%`); +``` + +`percent: 0` under a `complete` scope is a real 0 and should be rendered. + +## 5. Read the three kinds of phase evidence separately + +Each entry in `phases[]` reports three independent signals. **They are not +combined into one verdict, and you should not combine them either** — they +answer different questions and can legitimately disagree. + +| Field | Question it answers | +|---|---| +| `verification` | Did the verifier pass this phase? This is what `complete` is derived from | +| `roadmap_acceptance` | Is the ROADMAP checkbox ticked? | +| `uat` | Are there unresolved user-acceptance items? | + +`roadmap_acceptance` carries `authoritative: false`, and it means it. A ticked +checkbox is a human annotation with no machine authority — completion comes from +disk state. If you show the checkbox, label it as an annotation, not as status. + +A phase can be `complete: true` with open UAT items. That is a real state, not a +contradiction. + +## 6. Handle `unknown` rather than guessing + +Where evidence is absent or two sources disagree, the value is `null` or +`"unknown"` and `diagnostics[]` says why. Nothing is inferred. + +The case you will hit most often is task-scoped file provenance: + +| `provenance` | What it means | What to show | +|---|---|---| +| `task_scoped` | The summary attributed files to this exact task | The file list | +| `plan_scoped` | A summary exists, but only lists files for the whole plan | "Not attributed" — **not** the plan's list | +| `absent` | No summary yet | "Not started" | + +`plan_scoped` is the common case and is not an error. Attributing a plan's file +list to one of its tasks would be a guess, so the snapshot declines to make it. +The plan-level list is still available at `plans[].changed_files`, where it is +accurate. + +When a task's planned and changed files both exist and disagree, `agreement` is +`"conflicting"` and both lists are present, unreconciled. Show both; do not pick. + +## 7. Read the diagnostics + +Every non-answer above has a matching entry in `diagnostics[]`: + +```json +{ "code": "requirement_unmapped", "subject": "AUTH-03", "detail": "..." } +``` + +`code` is a stable identifier from a frozen vocabulary — match on it, never on +`detail`, whose wording may change. Common codes: + +| Code | Meaning | +|---|---| +| `planning_root_absent` | No `.planning/` directory — every section below is a non-answer | +| `roadmap_unscoped` | No milestone version could be resolved; none was invented | +| `requirements_absent` | No `REQUIREMENTS.md` | +| `requirement_unmapped` | A requirement no Traceability row maps to a phase | +| `requirement_phase_unknown` | A requirement mapped to a phase that is not on disk | +| `orphan_phase_dir` | A phase directory the current milestone does not declare | +| `task_changed_files_plan_scoped` | Task-level file attribution unavailable (see step 6) | +| `task_changed_files_conflicting` | Planned and changed files disagree | +| `percent_withheld` | A percentage was suppressed because its scope was not `complete` | + +An empty `diagnostics[]` means every value in the snapshot is a real answer. + +## 8. Handle a large payload + +On a big project the JSON can exceed the ~50 KB console limit. `gsd-tools` +handles this for you: it writes to a temp file and resolves the reference before +writing to stdout, so you always receive JSON. If you are invoking `gsd-tools` +through a shell wrapper that captures stdout directly, no special handling is +needed. + +## Troubleshooting + +**`Unknown planning subcommand. Available: inspect`** +You typed a subcommand that does not exist. `inspect` is the only one. + +**`planning inspect takes no arguments; got flag: --phase`** +v1 always returns the whole project. It refuses scoping arguments rather than +ignoring them — silently returning an unscoped snapshot to a caller who asked +for a scoped one would be worse. Filter the `phases[]` array on your side. + +**Everything is `unknown` and `diagnostics[0].code` is `planning_root_absent`** +You are not in a GSD project directory. Use `--cwd`, or `cd` first. + +**A phase you expect is missing from `phases[]`** +Check `orphan_phase_dirs[]`. `phases[]` is scoped to the phases the current +milestone's ROADMAP window declares; a directory on disk that the roadmap never +mentions is reported there instead, so that a genuinely orphaned directory +cannot masquerade as a planned phase. + +## Related + +- [`planning inspect` reference](../CLI-TOOLS.md#planning-inspect) — every field, with exact semantics +- [Resolve unreachable-guard findings](resolve-unreachable-guard-findings.md) — the same "nothing to report vs. could not look" distinction, one layer down diff --git a/eslint.config.mjs b/eslint.config.mjs index 7f9a17a4f..34e385892 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -157,6 +157,12 @@ export default tseslint.config( 'gsd-core/bin/lib/configuration.cjs', 'gsd-core/bin/lib/state-document.cjs', 'gsd-core/bin/lib/planning-snapshot.cjs', + // #2790: tsc-generated runtime artifacts — lint the src/*.cts sources + // (src/planning-inspect.cts, src/planning-command-router.cts, + // src/plan-document.cts), not these emitted .cjs files. + 'gsd-core/bin/lib/planning-inspect.cjs', + 'gsd-core/bin/lib/planning-command-router.cjs', + 'gsd-core/bin/lib/plan-document.cjs', 'gsd-core/bin/lib/pattern.cjs', 'gsd-core/bin/lib/text-lines.cjs', 'gsd-core/bin/lib/token-scanner.cjs', diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index 35bde8533..f4c8b25b1 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -98,6 +98,15 @@ * validate health [--repair] Check .planning/ integrity, optionally repair * validate agents Check GSD agent installation status * + * Planning Snapshot: + * planning inspect Read-only schema-v1 canonical planning snapshot + * (milestone identity, active phase, per-phase + * verification/roadmap-acceptance/UAT evidence kept + * separate, requirement rows with mapped-phase + * traceability, plan/task rows with planned+changed + * file provenance, and independent accepted_phases / + * completed_plans fractions). Takes no arguments. + * * Progress: * progress [json|table|bar] Render progress in various formats * @@ -297,6 +306,7 @@ const { routeVerifyCommand } = require('./lib/verify-command-router.cjs'); const { routeEvalCommand } = require('./lib/eval-command-router.cjs'); const evalMod = require('./lib/eval.cjs'); const { routeVerificationCommand } = require('./lib/verification-command-router.cjs'); +const { routePlanningCommand } = require('./lib/planning-command-router.cjs'); const verification = require('./lib/verification.cjs'); const { routeInitCommand } = require('./lib/init-command-router.cjs'); // Stale-bake guard (#1688): warns once when model config changed since agents @@ -3728,6 +3738,10 @@ const HOST_COMMAND_ROUTERS = { 'skill-manifest': routeSkillManifest, 'history-digest': routeHistoryDigest, 'phases': routePhases, + // #2790: read-only schema-v1 planning snapshot. The router imports its own + // io/planning-inspect deps, so it needs no module injection — it receives + // { args, cwd, raw, error } and ignores the rest of the dispatch context. + 'planning': routePlanningCommand, 'assumption-delta': routeAssumptionDelta, 'requirements': routeRequirements, 'gap-analysis': routeGapAnalysis, @@ -3974,7 +3988,7 @@ const TOP_LEVEL_USAGE = 'Usage: gsd-tools [args] [--raw] [--pick ` invocation as ONE test costing + // whatever the slowest case costs (measured ~20s), and re-runs that entire + // file once per mutant — 640 mutants x 20s cannot finish in 15 minutes. + // tests/planning-inspect.unit.test.cjs is the dedicated, spawn-free, + // in-process mutation surface for exactly these three modules (measured + // locally: the whole file runs in well under a second) — the same shape + // every other entry in this registry already uses (*.property.test.cjs / + // *.unit.test.cjs). The integration suite is UNAFFECTED by this change: it + // keeps running in full in the normal (non-mutation) test job, and remains + // the source of truth for spawn-boundary/CLI-dispatch/read-only-proof + // behaviour that an in-process unit file cannot exercise. + // + // Measured CI scores (GitHub Actions run 32392791843, all three shards + // PASSED — not a local run; mutation shards run `node --test`, hard-blocked + // in this repo's local environment): + // planning-command-router 95.65% → floor 94 (already exceeds TARGET_MUTATION_SCORE (80)) + // plan-document 76.58% → floor 75 + // planning-inspect 57.03% → floor 56 (well below TARGET (80) — ratchet + // candidate; comfortably clears its own floor but has real room to grow. + // Raise as its tests improve, never lower it.) + // + // All three shards point at tests/planning-inspect.unit.test.cjs (in-process, + // spawn-free, ~0.3s dry run), not tests/planning-inspect.test.cjs — that is + // what made measurement possible at all. The integration file spawns a + // subprocess per case via runGsdTools; Stryker's command runner treats the + // whole `node --test ` invocation as one test costing whatever the + // slowest case costs (measured ~20s), and re-runs that entire file once per + // mutant, so 640 mutants x 20s could not finish inside the 15-minute shard + // cap. The integration suite is unaffected by this change: it keeps running + // in full in the normal (non-mutation) test job. + 'planning-inspect': { + cjs: 'gsd-core/bin/lib/planning-inspect.cjs', + tests: [ + 'tests/planning-inspect.unit.test.cjs', + ], + minScore: 56, + }, + 'plan-document': { + cjs: 'gsd-core/bin/lib/plan-document.cjs', + tests: [ + 'tests/planning-inspect.unit.test.cjs', + ], + minScore: 75, + }, + 'planning-command-router': { + cjs: 'gsd-core/bin/lib/planning-command-router.cjs', + tests: [ + 'tests/planning-inspect.unit.test.cjs', + ], + minScore: 94, + }, }; // ── Files that, when changed, invalidate ALL modules ───────────────────────── diff --git a/src/gap-checker.cts b/src/gap-checker.cts index 56be6dba2..d54980d56 100644 --- a/src/gap-checker.cts +++ b/src/gap-checker.cts @@ -92,6 +92,17 @@ function parseRequirements(reqMd: unknown): ReqItem[] { // The **ID** is extracted from the bullet text caller-side — the seam provides // the raw text; we parse the bold-ID prefix from it here. const boldIdRe = new RegExp(`^\\*\\*(${ID_PATTERN})\\*\\*\\s*(.*)$`); + // The shipped template (gsd-core/templates/requirements.md) writes + // `- [ ] **AUTH-01**: User can sign up` — a single separator delimiter + // between the bold ID and the description. Strip AT MOST ONE leading + // delimiter (plus its surrounding whitespace) before the final `.trim()`, + // mirroring roadmap-parser.cts's `stripLeadingDelimiter` delimiter set + // (em dash, en dash, colon, hyphen) — that helper is not exported, and its + // own `+`-quantified strip removes an entire delimiter RUN, which would + // also eat a second, meaningful marker (`**X-01**: -- weird` must keep the + // `--`), so the set is mirrored here with a single-occurrence match instead + // of reused verbatim. + const ONE_LEADING_DELIMITER_RE = /^\s*[—–:-]\s*/; for (const bullet of iterateBullets(reqMd)) { if (bullet.marker !== 'checkbox-unchecked' && bullet.marker !== 'checkbox-checked') continue; const m = boldIdRe.exec(bullet.text); @@ -100,7 +111,8 @@ function parseRequirements(reqMd: unknown): ReqItem[] { if (!idRe.test(id)) continue; if (!seen.has(id)) { seen.add(id); - out.push({ id, text: (m[2] || '').trim() }); + const rawText = m[2] || ''; + out.push({ id, text: rawText.replace(ONE_LEADING_DELIMITER_RE, '').trim() }); } } diff --git a/src/phase.cts b/src/phase.cts index f7b349946..080c28265 100644 --- a/src/phase.cts +++ b/src/phase.cts @@ -88,6 +88,9 @@ const { } = planningWorkspace; // eslint-disable-next-line @typescript-eslint/no-require-imports -- milestone-lock.cjs is an export= CommonJS module import milestoneLockMod = require('./milestone-lock.cjs'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +import planDocumentMod = require('./plan-document.cjs'); +const { parsePlanDocument, planIdFromFile } = planDocumentMod; const { extractFrontmatter } = frontmatterMod; const { readModifyWriteStateMd, @@ -583,11 +586,6 @@ function cmdFindPhase(cwd: string, phase: string, raw: boolean): void { output(notFound, raw, ''); } -function extractObjective(content: string): string | null { - const m = content.match(/\s*\n?\s*(.+)/); - return m ? m[1].trim() : null; -} - interface RawPlan { id: string; declaredWave: number | null; @@ -800,51 +798,14 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void { const rawPlans: RawPlan[] = []; for (const planFile of planFiles) { - const planId = planFile.replace('-PLAN.md', '').replace('PLAN.md', ''); + const planId = planIdFromFile(planFile); const planPath = path.join(phaseDir, planFile); const content = fs.readFileSync(planPath, 'utf-8'); - // Pass planPath so a truncated PLAN.md names the file in the #1882 diagnostic. - const fm = extractFrontmatter(content, planPath); - - const xmlTasks = content.match(/]/gi) || []; - const mdTasks = content.match(/##\s*Task\s*\d+/gi) || []; - const taskCount = xmlTasks.length || mdTasks.length; - - const parsedWave = parseInt(fm['wave'] as string, 10); - const declaredWave = Number.isNaN(parsedWave) ? null : parsedWave; - - let dependsOn: string[] = []; - const fmDeps = fm['depends_on']; - if (Array.isArray(fmDeps)) { - dependsOn = fmDeps.map(String); - } else if (typeof fmDeps === 'string' && fmDeps.trim() !== '') { - dependsOn = [fmDeps]; - } - - let autonomous = true; - if (fm['autonomous'] !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue comparison - autonomous = fm['autonomous'] === 'true' || String(fm['autonomous']) === 'true'; - } - - let filesModified: string[] = []; - const fmFiles = fm['files_modified'] || fm['files-modified']; - if (fmFiles) { - // eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue scalar-to-string - filesModified = Array.isArray(fmFiles) ? fmFiles.map(String) : [String(fmFiles)]; - } - - // #1689: optional per-plan specialist executor hint. Read verbatim here; the - // orchestrator resolves it against the active runtime's agent dir at dispatch - // time (execute-phase.md -> `gsd_run query resolve-agent`), falling back to - // gsd-executor when the field is unset or the named agent does not resolve. - let agentHint: string | null = null; - const fmAgentHint = fm['agent_hint']; - if (fmAgentHint !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue scalar-to-string - const hintStr = String(fmAgentHint).trim(); - agentHint = hintStr !== '' ? hintStr : null; - } + // #2790: plan-body parsing is owned by the shared Plan Document Module, so + // this command and the read-only `planning.inspect` query cannot drift on + // what a plan document says. planPath is still passed so a truncated + // PLAN.md names the file in the #1882 diagnostic. + const planDoc = parsePlanDocument(content, planPath); const hasSummary = !unsummarizedPlanFiles.has(planFile); @@ -860,13 +821,13 @@ function cmdPhasePlanIndex(cwd: string, phase: string, raw: boolean): void { rawPlans.push({ id: planId, - declaredWave, - dependsOn, - autonomous, - objective: extractObjective(content) || (fm['objective'] as string | null) || null, - filesModified, - agentHint, - taskCount, + declaredWave: planDoc.declaredWave, + dependsOn: planDoc.dependsOn, + autonomous: planDoc.autonomous, + objective: planDoc.objective, + filesModified: planDoc.filesModified, + agentHint: planDoc.agentHint, + taskCount: planDoc.taskCount, hasSummary, halted, }); diff --git a/src/plan-document.cts b/src/plan-document.cts new file mode 100644 index 000000000..0f5db9416 --- /dev/null +++ b/src/plan-document.cts @@ -0,0 +1,322 @@ +/** + * Plan Document Module — the single parser for a `*-PLAN.md` document BODY. + * + * Owns: objective extraction, the task-block grammar (`` elements, with + * the legacy `## Task N` heading fallback), planned-file extraction, and the + * frontmatter-derived scheduling metadata (`wave`, `depends_on`, `autonomous`, + * `agent_hint`, `files_modified`). + * + * WHY THIS IS A LEAF MODULE. This logic was written inline inside + * `cmdPhasePlanIndex` (`src/phase.cts`). Two commands in two different families + * now need it — `phase.plan-index` and `planning.inspect` (#2790) — so leaving + * it in `phase.cts` would force `planning` to depend on `phase`, and copying it + * would be the *Generative Fix Divergence* class `CLAUDE.md` names. A leaf owned + * by neither family is the seam that matches the actual usage (Conway's Law). + * + * NOT an ADR-3180 §7 derivation. §6 puts the document-parsing layer explicitly + * out of that epic's scope (#2143); this module answers "what does this plan + * document say", never "how many plans are outstanding" (that is + * `scanPhasePlans`, §7.5) or "is this phase complete" (`isPhaseComplete`, §7.4). + * + * BEHAVIOUR IS PRESERVED BYTE-FOR-BEHAVIOUR from the prior inline code. In + * particular `tasks.length` is exactly the legacy `taskCount` + * (`xmlTasks.length || mdTasks.length`), including its known fence-blindness — + * a `## Task 1` inside a fenced code block still counts, exactly as it does + * today. That is a characterised limit, not an endorsement: changing it would + * silently change `phase.plan-index`'s output for existing projects, which is a + * Hyrum's-Law break that belongs in its own issue rather than riding along on a + * read-only query addition. + * + * ADR-457 build-at-publish: source in src/plan-document.cts, compiled to + * gsd-core/bin/lib/plan-document.cjs (gitignored). + */ + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import frontmatterMod = require('./frontmatter.cjs'); +const { extractFrontmatter } = frontmatterMod; + +// ─── Frozen vocabularies ────────────────────────────────────────────────────── + +/** + * How a task row was expressed in the document. `auto` is the ordinary + * executable task; `checkpoint` is a `` block, which + * carries an entirely different element set (``/``, no + * ``/``/``). Distinguishing them is what + * stops a checkpoint from being reported as a malformed auto task. + */ +const TASK_KIND = Object.freeze({ + AUTO: 'auto', + CHECKPOINT: 'checkpoint', +}); + +type TaskKind = (typeof TASK_KIND)[keyof typeof TASK_KIND]; + +// ─── Shapes ─────────────────────────────────────────────────────────────────── + +interface PlanTask { + /** 1-based position in document order. */ + index: number; + kind: TaskKind; + /** The verbatim `type` attribute when present (e.g. `auto`, `checkpoint:decision`). */ + type: string | null; + /** `` text, or null — always null for a checkpoint block. */ + name: string | null; + /** `` split on commas, trimmed, empties dropped. Never null; `[]` means "none declared". */ + plannedFiles: string[]; + /** `` bullet lines, in document order. */ + acceptanceCriteria: string[]; + /** `` text, or null. */ + done: string | null; +} + +interface PlanDocument { + /** `` body, else frontmatter `objective`, else null. */ + objective: string | null; + /** Frontmatter `wave` as an integer, or null when absent/unparseable. */ + declaredWave: number | null; + dependsOn: string[]; + autonomous: boolean; + agentHint: string | null; + /** Frontmatter `files_modified` / `files-modified`, normalised to an array. */ + filesModified: string[]; + tasks: PlanTask[]; + /** + * Legacy count. Invariant: `taskCount === tasks.length`, always. Exposed as + * its own field so the invariant is assertable rather than assumed. + */ + taskCount: number; +} + +// ─── Task grammar ───────────────────────────────────────────────────────────── + +// The legacy counting rule, preserved verbatim from cmdPhasePlanIndex. `g` is +// required (we count every occurrence) and these are rebuilt per call rather +// than hoisted to module scope: a global regex carries mutable `lastIndex` +// state, and a shared instance is a cross-call contamination bug. +function xmlTaskOpenings(content: string): RegExpMatchArray[] { + return [...content.matchAll(/])[^>]*>/gi)]; +} + +function markdownTaskHeadings(content: string): RegExpMatchArray[] { + return [...content.matchAll(/##\s*Task\s*\d+[^\n]*/gi)]; +} + +/** Extract the value of one attribute from a `` opening tag. */ +function tagAttribute(openTag: string, attr: string): string | null { + const re = new RegExp(`\\b${attr}\\s*=\\s*"([^"]*)"|\\b${attr}\\s*=\\s*'([^']*)'`, 'i'); + const m = re.exec(openTag); + if (!m) return null; + const value = (m[1] ?? m[2] ?? '').trim(); + return value.length > 0 ? value : null; +} + +/** + * Body of the first `…` inside `block`, or null. Non-greedy and + * case-insensitive; a tag that is opened but never closed yields null rather + * than swallowing the rest of the document. + */ +function elementBody(block: string, tag: string): string | null { + const re = new RegExp(`<${tag}\\s*>([\\s\\S]*?)`, 'i'); + const m = re.exec(block); + return m ? m[1] : null; +} + +/** + * Split a `` body into paths. Comma-separated per the shipped + * `templates/phase-prompt.md` grammar; newline-separated forms are tolerated + * too (Postel — liberal in what we accept), and the caller records nothing + * special for them because a path list is a path list either way. + */ +function splitFileList(body: string | null): string[] { + if (body === null) return []; + return body + .split(/[,\n]/) + .map((part) => part.trim()) + .filter((part) => part.length > 0); +} + +/** `` carries `- ` bullets, one criterion per line. */ +function splitCriteria(body: string | null): string[] { + if (body === null) return []; + return body + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => line.replace(/^[-*]\s*/, '')) + .filter((line) => line.length > 0); +} + +function collapseWhitespace(value: string | null): string | null { + if (value === null) return null; + const trimmed = value.replace(/\s+/g, ' ').trim(); + return trimmed.length > 0 ? trimmed : null; +} + +/** + * Parse the `` blocks. Each opening tag found by `xmlTaskOpenings` yields + * exactly one row — the block runs from that tag to its ``, or to the + * next opening tag, or to end-of-document. Bounding on the NEXT OPENING rather + * than only on `` is what keeps an unclosed block from consuming its + * siblings, so the row count still equals the opening count. + */ +function parseXmlTasks(content: string): PlanTask[] { + const openings = xmlTaskOpenings(content); + return openings.map((match, i) => { + const start = match.index ?? 0; + const openTag = match[0]; + const nextStart = i + 1 < openings.length ? (openings[i + 1].index ?? content.length) : content.length; + const window = content.slice(start, nextStart); + const closeIdx = window.search(/<\/task\s*>/i); + const block = closeIdx === -1 ? window : window.slice(0, closeIdx); + + const type = tagAttribute(openTag, 'type'); + const kind: TaskKind = type !== null && type.toLowerCase().startsWith('checkpoint') + ? TASK_KIND.CHECKPOINT + : TASK_KIND.AUTO; + + // A checkpoint block has no /// in + // the shipped grammar. Reading them anyway would be harmless but dishonest: + // the caller must be able to tell "this element is absent because this kind + // of task has no such element" from "this element is missing and should not + // be". + if (kind === TASK_KIND.CHECKPOINT) { + return { + index: i + 1, + kind, + type, + name: null, + plannedFiles: [], + acceptanceCriteria: [], + done: null, + }; + } + + return { + index: i + 1, + kind, + type, + name: collapseWhitespace(elementBody(block, 'name')), + plannedFiles: splitFileList(elementBody(block, 'files')), + acceptanceCriteria: splitCriteria(elementBody(block, 'acceptance_criteria')), + done: collapseWhitespace(elementBody(block, 'done')), + }; + }); +} + +/** + * Legacy fallback: `## Task N` headings, used ONLY when the document carries no + * `` blocks at all. Deliberately fence-blind, matching the counting rule + * `cmdPhasePlanIndex` has always used — see this module's header comment. + */ +function parseMarkdownTasks(content: string): PlanTask[] { + return markdownTaskHeadings(content).map((match, i) => ({ + index: i + 1, + kind: TASK_KIND.AUTO, + type: null, + name: collapseWhitespace(match[0].replace(/^##\s*/, '')), + plannedFiles: [], + acceptanceCriteria: [], + done: null, + })); +} + +// ─── Objective ──────────────────────────────────────────────────────────────── + +/** + * Preserved verbatim from `cmdPhasePlanIndex`: the first line following an + * `` tag. Deliberately NOT widened to the full element body — that + * would change `phase.plan-index`'s existing output for any multi-line + * objective. + */ +function extractObjective(content: string): string | null { + const m = content.match(/\s*\n?\s*(.+)/); + return m ? m[1].trim() : null; +} + +// ─── Entry point ────────────────────────────────────────────────────────────── + +/** + * The plan id for a plan FILE ENTRY, exactly as `scanPhasePlans` stores it + * (root entries bare, nested entries `plans/`-prefixed). + * + * This is the established derivation from `cmdPhasePlanIndex`, moved here + * VERBATIM (#2790) so `phase.plan-index` and `planning.inspect` cannot report + * different ids for the same plan — a consumer correlating the two surfaces + * needs them to join. Deliberately NOT "improved": it is a display/lookup key + * with existing callers, and changing what it returns would be a Hyrum's-Law + * break on `phase-plan-index`. + */ +function planIdFromFile(planFile: string): string { + return planFile.replace('-PLAN.md', '').replace('PLAN.md', ''); +} + +/** + * Parse one plan document. + * + * @param content Raw `*-PLAN.md` text. + * @param planPath Optional path, used only to name the file in `extractFrontmatter`'s + * truncated-frontmatter diagnostic (#1882). Callers that do not have + * one omit it — this default IS the shape production uses from the + * read-only query path. + */ +function parsePlanDocument(content: string, planPath = ''): PlanDocument { + const fm = extractFrontmatter(content, planPath); + + const xmlTasks = parseXmlTasks(content); + const tasks = xmlTasks.length > 0 ? xmlTasks : parseMarkdownTasks(content); + + const parsedWave = parseInt(fm['wave'] as string, 10); + const declaredWave = Number.isNaN(parsedWave) ? null : parsedWave; + + let dependsOn: string[] = []; + const fmDeps = fm['depends_on']; + if (Array.isArray(fmDeps)) { + dependsOn = fmDeps.map(String); + } else if (typeof fmDeps === 'string' && fmDeps.trim() !== '') { + dependsOn = [fmDeps]; + } + + let autonomous = true; + if (fm['autonomous'] !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue comparison + autonomous = fm['autonomous'] === 'true' || String(fm['autonomous']) === 'true'; + } + + let filesModified: string[] = []; + const fmFiles = fm['files_modified'] || fm['files-modified']; + if (fmFiles) { + // eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue scalar-to-string + filesModified = Array.isArray(fmFiles) ? fmFiles.map(String) : [String(fmFiles)]; + } + + let agentHint: string | null = null; + const fmAgentHint = fm['agent_hint']; + if (fmAgentHint !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue scalar-to-string + const hintStr = String(fmAgentHint).trim(); + agentHint = hintStr !== '' ? hintStr : null; + } + + return { + objective: extractObjective(content) || (fm['objective'] as string | null) || null, + declaredWave, + dependsOn, + autonomous, + agentHint, + filesModified, + tasks, + taskCount: tasks.length, + }; +} + +const planDocument = { TASK_KIND, parsePlanDocument, planIdFromFile }; + +// Required to merge the compile-time-only types onto the `export =` runtime +// value; there is no ES-module-syntax way to export a type alongside a CJS +// `export =`. +// eslint-disable-next-line @typescript-eslint/no-namespace +declare namespace planDocument { + export { PlanDocument, PlanTask, TaskKind }; +} + +export = planDocument; diff --git a/src/planning-command-router.cts b/src/planning-command-router.cts new file mode 100644 index 000000000..db49975b9 --- /dev/null +++ b/src/planning-command-router.cts @@ -0,0 +1,86 @@ +/** + * Planning command router — CLI subcommand dispatcher for `gsd-tools planning`. + * + * Routes `planning inspect` (#2790) to `planningInspect.cmdPlanningInspect`. + * Both the spaced form (`query planning inspect`) and the dotted canonical form + * (`query planning.inspect`) reach here identically: `gsd-tools` splits a dotted + * command on its FIRST dot before dispatch, so the router never sees the dot. + * + * v1 accepts NO arguments beyond the subcommand. `--raw`, `--cwd`, `--pick`, + * `--default` and `--json-errors` are global and are spliced out of argv by + * `gsd-tools`' own `main()` before any router runs, so anything still present at + * `args[2]` or beyond is genuinely unrecognised and is a fail-loud USAGE error. + * That strictness is deliberate: silently ignoring an argument a caller believed + * was scoping the query would return a full-project snapshot presented as a + * scoped one — a confidently wrong answer. + * + * Router signature `{ args, cwd, raw, error }` — identical to the other host + * routers. Test seam: pass `_planningInspect` to inject a recording mock; the + * `_` prefix follows this repo's established seam convention. + * + * ADR-457 build-at-publish: source in src/planning-command-router.cts, compiled + * to gsd-core/bin/lib/planning-command-router.cjs (gitignored). + */ + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import planningInspect = require('./planning-inspect.cjs'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +import cjsCommandRouterAdapter = require('./cjs-command-router-adapter.cjs'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +import io = require('./io.cjs'); + +const { routeCjsCommandFamily } = cjsCommandRouterAdapter; +const { ERROR_REASON } = io; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface PlanningInspectModule { + cmdPlanningInspect(cwd: string, raw: boolean): void; +} + +interface RoutePlanningCommandOptions { + args: string[]; + cwd: string; + raw: boolean; + error: (message: string, reason?: string) => void; + /** Test seam: inject a mock planning-inspect module. Defaults to the real one. */ + _planningInspect?: PlanningInspectModule; +} + +// ─── Implementation ─────────────────────────────────────────────────────────── + +const PLANNING_SUBCOMMANDS = ['inspect']; + +function routePlanningCommand({ args, cwd, raw, error, _planningInspect }: RoutePlanningCommandOptions): void { + const mod: PlanningInspectModule = _planningInspect ?? planningInspect; + + routeCjsCommandFamily({ + args, + subcommands: PLANNING_SUBCOMMANDS, + unsupported: {}, + error, + unknownMessage: (_subcommand: string, available: string[]) => + `Unknown planning subcommand. Available: ${available.join(', ')}`, + handlers: { + inspect: () => { + const extra = args.slice(2); + if (extra.length > 0) { + const offender = extra[0]; + const shape = offender.startsWith('-') ? 'flag' : 'positional argument'; + error( + `planning inspect takes no arguments; got ${shape}: ${offender}. ` + + 'Usage: gsd-tools query planning inspect', + ERROR_REASON.USAGE, + ); + return; + } + mod.cmdPlanningInspect(cwd, raw); + }, + }, + }); +} + +export = { + routePlanningCommand, + PLANNING_SUBCOMMANDS, +}; diff --git a/src/planning-inspect.cts b/src/planning-inspect.cts new file mode 100644 index 000000000..a790556d6 --- /dev/null +++ b/src/planning-inspect.cts @@ -0,0 +1,1286 @@ +/** + * Planning Inspect Module — the schema-v1 canonical planning snapshot (#2790). + * + * `planning.inspect` is READ-ONLY: it opens `.planning/` documents and writes + * nothing, anywhere, ever. Downstream harness UIs (gsd-code Phase 12, plan + * mission-control) consume it instead of parsing ROADMAP/REQUIREMENTS/PLAN/ + * SUMMARY markdown a second time — gsd-core is the single source of `.planning/` + * truth. + * + * COMPOSED, NOT RE-DERIVED. Every ADR-3180 §7 derivation arrives from its + * declared owner: milestone identity and windowing from `getMilestoneInfo` + * (§7.2) and phase enumeration from `listMilestonePhaseDirs` (§7.3), both via + * `buildPlanningSnapshot`; phase completion from `isPhaseComplete` (§7.4, + * disk-strict); live-plan counting from `scanPhasePlans` (§7.5); the + * fraction→percent arithmetic from `clampPercent` (§7.6). Plan bodies come from + * `parsePlanDocument` (`src/plan-document.cts`), requirement IDs from + * `parseRequirements` (`src/gap-checker.cts`), UAT items from `parseUatItems` + * (`src/uat.cts`). This module introduces no second answer to any of those + * questions. + * + * WHY THIS DOES NOT SERIALIZE `PlanningSnapshot` DIRECTLY. `PlanningSnapshot` + * is the §8.1 *diagnostic-rule subject* — explicitly additive and still growing + * (4 fields at Phase 10, 20+ by Phase 12). schema-v1 is a frozen EXTERNAL + * contract. Handing an internal, churning shape to external consumers is a + * Hyrum's-Law break waiting to happen, so this module declares its own flat + * schema and maps into it. Adding a field to `PlanningSnapshot` must never + * change what `planning.inspect` emits. + * + * NEVER INFERS. Where evidence is absent or two sources disagree, the value is + * `null` / `unknown` and a diagnostic names why. It is never reconciled, never + * guessed, and never filled from a plausible default. Keys are ALWAYS present — + * omitting a key on a non-answer is itself an observable a consumer would bind + * to. + * + * NOT a diagnostic rule, and deliberately NOT registered in + * `scripts/lint-planning-snapshot-bypass-drift.cjs`: that guard is + * `DIAGNOSTIC_RULE_FUNCTIONS`-scoped and must be prunable to zero when #3309 + * lands. This is a query command. + * + * ADR-457 build-at-publish: source in src/planning-inspect.cts, compiled to + * gsd-core/bin/lib/planning-inspect.cjs (gitignored). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import planningSnapshotMod = require('./planning-snapshot.cjs'); +const { buildPlanningSnapshot, worstScope } = planningSnapshotMod; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import planningWorkspaceMod = require('./planning-workspace.cjs'); +const { planningPaths } = planningWorkspaceMod; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import planScan = require('./plan-scan.cjs'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +import planDocumentMod = require('./plan-document.cjs'); +const { parsePlanDocument, TASK_KIND, planIdFromFile } = planDocumentMod; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import gapCheckerMod = require('./gap-checker.cjs'); +const { parseRequirements } = gapCheckerMod; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import uatMod = require('./uat.cjs'); +const { parseUatItems, selectPhaseUatFiles } = uatMod; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import phaseLifecycleMod = require('./phase-lifecycle.cjs'); +const { clampPercent } = phaseLifecycleMod; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import planningScopeMod = require('./planning-scope.cjs'); +const { SCOPE } = planningScopeMod; +type Scope = planningScopeMod.Scope; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import verificationMod = require('./verification.cjs'); +const { readVerificationStatus } = verificationMod; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import phaseIdMod = require('./phase-id.cjs'); +const { phaseKeyFromDir, phaseKeyFromToken, phaseMarkdownRegexSource } = phaseIdMod; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import roadmapParserMod = require('./roadmap-parser.cjs'); +const { extractCurrentMilestone } = roadmapParserMod; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import io = require('./io.cjs'); +const { output } = io; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import frontmatterMod = require('./frontmatter.cjs'); +const { extractFrontmatter, stripFrontmatter } = frontmatterMod; +import { stateFieldValue, stateCurrentPositionSlice } from './state-document.cjs'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import markdownSectionizer = require('./markdown-sectionizer.cjs'); +const { collectSection, iterateBullets } = markdownSectionizer; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import markdownTable = require('./markdown-table.cjs'); +const { parseMarkdownTable, matchTableSchema } = markdownTable; + +/** + * The wire schema version. A consumer MUST reject any value other than this + * one rather than best-effort-parsing an unknown shape. + */ +const PLANNING_INSPECT_SCHEMA_VERSION = 1; + +/** + * Frozen diagnostic vocabulary. Adding a member is the repo's standard three + * coordinated changes: this enum, the emitting site, and the test that locks + * `Object.keys(INSPECT_DIAGNOSTIC).sort()`. + */ +const INSPECT_DIAGNOSTIC = Object.freeze({ + PLANNING_ROOT_ABSENT: 'planning_root_absent', + ROADMAP_UNSCOPED: 'roadmap_unscoped', + REQUIREMENTS_ABSENT: 'requirements_absent', + REQUIREMENTS_UNREADABLE: 'requirements_unreadable', + REQUIREMENT_DUPLICATE: 'requirement_duplicate', + REQUIREMENT_UNMAPPED: 'requirement_unmapped', + REQUIREMENT_PHASE_UNKNOWN: 'requirement_phase_unknown', + REQUIREMENT_COMPLETION_UNKNOWN: 'requirement_completion_unknown', + ORPHAN_PHASE_DIR: 'orphan_phase_dir', + PHASE_SCOPE_DEGRADED: 'phase_scope_degraded', + PLAN_UNREADABLE: 'plan_unreadable', + SUMMARY_UNREADABLE: 'summary_unreadable', + TASK_SHAPE_CHECKPOINT: 'task_shape_checkpoint', + TASK_CHANGED_FILES_PLAN_SCOPED: 'task_changed_files_plan_scoped', + TASK_CHANGED_FILES_CONFLICTING: 'task_changed_files_conflicting', + UAT_ABSENT: 'uat_absent', + UAT_UNREADABLE: 'uat_unreadable', + PERCENT_WITHHELD: 'percent_withheld', +}); + +type InspectDiagnosticCode = (typeof INSPECT_DIAGNOSTIC)[keyof typeof INSPECT_DIAGNOSTIC]; + +/** Whether a task has a completion record. `unknown` is a first-class answer. */ +const TASK_STATUS = Object.freeze({ + DONE: 'done', + PENDING: 'pending', + UNKNOWN: 'unknown', +}); + +type TaskStatus = (typeof TASK_STATUS)[keyof typeof TASK_STATUS]; + +/** + * How precisely a changed-file list is attributed. `plan_scoped` is the common + * case and is NOT an error — SUMMARY.md's `## Files Created/Modified` is a + * plan-level section, so spreading it across a plan's tasks would be inference. + */ +const PROVENANCE = Object.freeze({ + TASK_SCOPED: 'task_scoped', + PLAN_SCOPED: 'plan_scoped', + ABSENT: 'absent', +}); + +type Provenance = (typeof PROVENANCE)[keyof typeof PROVENANCE]; + +/** Whether planned and changed file sets agree. Never reconciled. */ +const AGREEMENT = Object.freeze({ + AGREED: 'agreed', + CONFLICTING: 'conflicting', + UNKNOWN: 'unknown', +}); + +type Agreement = (typeof AGREEMENT)[keyof typeof AGREEMENT]; + +interface Diagnostic { + code: InspectDiagnosticCode; + subject: string; + detail: string; +} + +interface Fraction { + completed: number; + total: number; + /** `null` whenever `scope !== complete` — ADR-3180 §7.6 rule 4. */ + percent: number | null; + scope: Scope; +} + +// ─── Small shared helpers ───────────────────────────────────────────────────── + +/** + * Path separators are normalised UNCONDITIONALLY, never via `path.sep` — a + * backslash-bearing path can arrive on Linux too, so a platform-gated + * normaliser leaves the non-Windows case untested and broken. + */ +function toPosix(value: string): string { + return value.replace(/\\/g, '/'); +} + +/** + * Read a UTF-8 file, distinguishing absent from unreadable. + * + * `root` is a containment boundary, not a convenience default: every + * document this module reads arrives as UNTRUSTED input — a clone, a PR + * branch, a teammate's working tree — and the assembled payload is handed + * to downstream tooling verbatim (see module doc). A `*-PLAN.md` (or any + * other document under `.planning/`) that is a SYMLINK resolving outside + * `root` is therefore an exfiltration path, not a convenience: reading it + * would let a planted symlink smuggle arbitrary readable file content into + * the emitted payload. Both `filePath` and `root` are resolved with + * `fs.realpathSync` — never a raw string prefix check on the unresolved + * path — so a project that legitimately symlinks its whole `.planning/` + * directory, or a single phase directory, elsewhere on disk keeps working; + * only a resolved target that ends up OUTSIDE the resolved root is + * rejected. An escape is reported as unreadable (same shape as any other + * unreadable document) so existing per-document degradation and + * diagnostics apply unchanged. + */ +/** + * Path-boundary-safe comparison of two ALREADY-RESOLVED absolute paths — the + * ONE containment comparison every containment check in this module shares + * (`readDocument`'s file-level guard, `isPathContained` below for + * directories and the verification-status `fs` seam), so none of them can + * drift apart. `target` must equal `root`, or begin with `root` plus a path + * separator; a bare `startsWith(root)` would wrongly accept a sibling + * directory like `.planning-evil/` that merely shares a string prefix. Pure + * string comparison, no I/O — callers own their own `fs.realpathSync` call + * (and its own not-found/broken-symlink handling). + */ +function isWithinRoot(resolvedTarget: string, resolvedRoot: string): boolean { + return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(resolvedRoot + path.sep); +} + +/** + * Containment check for a path (file OR directory) that resolves its own + * `fs.realpathSync`, then delegates the actual boundary comparison to + * `isWithinRoot`. Used where the caller does not need to distinguish "target + * vanished / broken symlink" from "target resolved but escapes root" — both + * degrade the same way at every call site that uses this (an escaped or + * unresolvable phase directory is treated identically to an unreadable one). + * `readDocument` below needs that distinction for its own exists/readable + * tri-state, so it keeps its own inline `realpathSync` calls and calls + * `isWithinRoot` directly instead of this wrapper. + */ +function isPathContained(target: string, root: string): boolean { + let realTarget: string; + let realRoot: string; + try { + realTarget = fs.realpathSync(target); + realRoot = fs.realpathSync(root); + } catch { + return false; + } + return isWithinRoot(realTarget, realRoot); +} + +function readDocument(filePath: string, root: string): { text: string | null; exists: boolean; readable: boolean } { + let stat: fs.Stats; + try { + stat = fs.statSync(filePath); + } catch { + return { text: null, exists: false, readable: false }; + } + // A directory, socket, or symlink resolving to a device in a document + // position is not a document. Reject before any open (cf. #2378/#2383). + if (!stat.isFile()) return { text: null, exists: true, readable: false }; + + let realTarget: string; + let realRoot: string; + try { + realTarget = fs.realpathSync(filePath); + realRoot = fs.realpathSync(root); + } catch { + // A broken symlink, or the path vanished between the stat above and + // here — the same non-answer `readDocument` already gives "not exists". + return { text: null, exists: false, readable: false }; + } + if (!isWithinRoot(realTarget, realRoot)) { + return { text: null, exists: true, readable: false }; + } + + try { + return { text: fs.readFileSync(filePath, 'utf-8'), exists: true, readable: true }; + } catch { + return { text: null, exists: true, readable: false }; + } +} + +/** + * Containment-enforcing `fs` seam for `readVerificationStatus` + * (`src/verification.cts`), passed through that function's existing + * `opts.fs` injection point — GAP 2 of the #2790 follow-up security review. + * + * `readVerificationStatus` is a SHARED owner consumed by many commands + * (init, roadmap, phase, ship, …), so it must not gain a containment + * parameter of its own; the boundary is enforced HERE, at this consumer's + * call site, instead. Each method below delegates to the real `node:fs` + * ONLY after the given path passes `isPathContained` against + * `planningRoot` — otherwise it throws. `readVerificationStatus` already + * treats every one of these calls' failures as its no-throw "missing" + * degradation (see its own try/catch around `readdirSync`/`readFileSync`, + * and `findStaleVerificationSummary`'s around `readdirSync`/`statSync`), so + * a `*-VERIFICATION.md` symlinked outside the planning root — or a phase + * directory that is itself such a symlink — now degrades to the ordinary + * 'missing' status instead of leaking frontmatter content (or the escaped + * directory's filenames) into the payload. + */ +function containmentEnforcingVerificationFs(planningRoot: string): { + readdirSync(dir: string): string[]; + readFileSync(filePath: string, encoding: 'utf-8'): string; + statSync(filePath: string): { mtimeMs: number }; +} { + function assertContained(target: string): void { + if (!isPathContained(target, planningRoot)) { + throw new Error(`planning-inspect: path escapes planning root: ${toPosix(target)}`); + } + } + return { + readdirSync(dir: string): string[] { + assertContained(dir); + return fs.readdirSync(dir); + }, + readFileSync(filePath: string, encoding: 'utf-8'): string { + assertContained(filePath); + return fs.readFileSync(filePath, encoding); + }, + statSync(filePath: string): { mtimeMs: number } { + assertContained(filePath); + return fs.statSync(filePath); + }, + }; +} + +function sortedUnique(values: string[]): string[] { + return [...new Set(values)].sort(); +} + +// ─── Requirements ───────────────────────────────────────────────────────────── + +interface RequirementRow { + id: string; + text: string | null; + /** `true` / `false` from the checkbox, or `unknown` when no checkbox exists. */ + complete: boolean | 'unknown'; + /** Phase tokens the Traceability table maps this requirement to. */ + mappedPhases: string[]; + /** + * Correlation convenience: the CODES (never prose) of the diagnostics this + * module raised for THIS requirement. The global `diagnostics[]` array + * remains the sole authoritative record — this is a lookup shortcut so a + * consumer does not have to string-parse `diagnostics[].subject` (e.g. + * `"AUTH-02->9"`) to correlate a diagnostic back to a row. + */ + diagnostics: InspectDiagnosticCode[]; + scope: Scope; +} + +/** + * Prefix-agnostic requirement-ID format shared with `parseRequirements` + * (`src/gap-checker.cts`) — REQ-01, TST-01, BACK-07, INSP-04, etc. Bold + * markers (`**ID**`) are optional decoration, matching how `gap-checker.cts` + * pulls the ID out of a checkbox bullet's text. + */ +const ID_PATTERN = '[A-Z][A-Z0-9]*-[A-Za-z0-9_-]+'; + +/** + * A Traceability table's `Requirement` cell holds ONLY the ID (mod optional + * bold + surrounding whitespace) — full-match, mirroring the pipe-bounded + * anchoring the prior hand-rolled `ID_CELL` regex enforced. + */ +const CELL_ID_RE = new RegExp(`^\\*{0,2}(${ID_PATTERN})\\*{0,2}$`); + +/** + * A checkbox bullet's leading `**ID**` — prefix match only (no end anchor): + * trailing prose (`: description`, ` some text`) is not required to match, + * mirroring the prior hand-rolled `BULLET` regex. + */ +const BULLET_ID_RE = new RegExp(`^\\*{0,2}(${ID_PATTERN})\\*{0,2}`); + +/** + * Parse the `## Traceability` table's `Requirement | Phase | Status` rows via + * the canonical `markdown-table` seam (ADR-2143) — never a hand-rolled + * table/row regex. A missing/malformed section or table, or a header that + * doesn't match the `RequirementsTraceability` schema, yields an EMPTY map: a + * malformed table is a non-answer, and every requirement then falls through + * to the caller's own `REQUIREMENT_UNMAPPED` diagnostic. + */ +function parseTraceability(reqMd: string): Map { + const byId = new Map(); + const section = collectSection(reqMd, (h) => /^traceability$/i.test(h.text.trim())); + if (!section) return byId; + + const parsed = parseMarkdownTable(section.body); + if (!parsed.ok) return byId; + + const schema = matchTableSchema(parsed.value.columns); + if (!schema || schema.id !== 'RequirementsTraceability') return byId; + + for (const row of parsed.value.rows) { + const idMatch = CELL_ID_RE.exec((row.Requirement ?? '').trim()); + if (!idMatch) continue; + const id = idMatch[1]; + // `Phase 1`, `1`, `Phase 1, Phase 2` — the token is what a consumer can + // match against a phase id; the surrounding word is decoration. This is + // value-level parsing of ONE already-addressed cell, not markdown parsing. + const tokens = [...(row.Phase ?? '').matchAll(/\d+(?:\.\d+)*/g)].map((t) => t[0]); + const existing = byId.get(id); + if (existing) existing.push(...tokens); + else byId.set(id, tokens); + } + return byId; +} + +/** + * Checkbox completion state per requirement ID, from the `- [x] **ID**` + * bullets — driven by the canonical `iterateBullets` seam, same bold-ID + * extraction style as `parseRequirements` (`src/gap-checker.cts`). A + * requirement with no bullet has no checkbox answer — reported as `unknown`, + * never defaulted to `false`. + */ +function parseCheckboxStates(reqMd: string): Map { + const states = new Map(); + for (const bullet of iterateBullets(reqMd)) { + if (bullet.marker !== 'checkbox-checked' && bullet.marker !== 'checkbox-unchecked') continue; + const m = BULLET_ID_RE.exec(bullet.text); + if (!m) continue; + // First occurrence wins, matching parseRequirements' own `seen` set. + if (!states.has(m[1])) states.set(m[1], bullet.marker === 'checkbox-checked'); + } + return states; +} + +/** IDs appearing more than once in the checkbox bullets, in document order. */ +function findDuplicateIds(reqMd: string): string[] { + const seen = new Set(); + const dupes: string[] = []; + for (const bullet of iterateBullets(reqMd)) { + if (bullet.marker !== 'checkbox-checked' && bullet.marker !== 'checkbox-unchecked') continue; + const m = BULLET_ID_RE.exec(bullet.text); + if (!m) continue; + if (seen.has(m[1])) dupes.push(m[1]); + else seen.add(m[1]); + } + return dupes; +} + +function buildRequirements( + requirementsPath: string, + knownPhaseKeys: Set, + diagnostics: Diagnostic[], + planningRoot: string, +): { rows: RequirementRow[]; scope: Scope } { + const doc = readDocument(requirementsPath, planningRoot); + if (!doc.exists) { + diagnostics.push({ + code: INSPECT_DIAGNOSTIC.REQUIREMENTS_ABSENT, + subject: toPosix(requirementsPath), + detail: 'REQUIREMENTS.md does not exist; no requirement rows are available.', + }); + return { rows: [], scope: SCOPE.UNSCOPED }; + } + if (!doc.readable || doc.text === null) { + diagnostics.push({ + code: INSPECT_DIAGNOSTIC.REQUIREMENTS_UNREADABLE, + subject: toPosix(requirementsPath), + detail: 'REQUIREMENTS.md exists but could not be read; zero rows is not a reliable answer.', + }); + return { rows: [], scope: SCOPE.UNREADABLE }; + } + + const reqMd = doc.text; + const items = parseRequirements(reqMd); + const traceability = parseTraceability(reqMd); + const checkboxes = parseCheckboxStates(reqMd); + + const dupeIds = findDuplicateIds(reqMd); + for (const dupe of dupeIds) { + diagnostics.push({ + code: INSPECT_DIAGNOSTIC.REQUIREMENT_DUPLICATE, + subject: dupe, + detail: 'Requirement ID appears more than once; the first occurrence is authoritative.', + }); + } + const duplicateIdSet = new Set(dupeIds); + + const rows: RequirementRow[] = items.map((item: { id: string; text: string }) => { + const mappedPhases = sortedUnique(traceability.get(item.id) ?? []); + const hasCheckbox = checkboxes.has(item.id); + const complete: boolean | 'unknown' = hasCheckbox ? (checkboxes.get(item.id) as boolean) : 'unknown'; + const rowDiagnostics: InspectDiagnosticCode[] = []; + + if (duplicateIdSet.has(item.id)) { + rowDiagnostics.push(INSPECT_DIAGNOSTIC.REQUIREMENT_DUPLICATE); + } + if (mappedPhases.length === 0) { + diagnostics.push({ + code: INSPECT_DIAGNOSTIC.REQUIREMENT_UNMAPPED, + subject: item.id, + detail: 'No Traceability row maps this requirement to a phase.', + }); + rowDiagnostics.push(INSPECT_DIAGNOSTIC.REQUIREMENT_UNMAPPED); + } + for (const token of mappedPhases) { + // `phaseKeyFromDir` and `phaseKeyFromToken` are the canonical pair for + // phase-IDENTITY equality: both map a directory name and a document + // token onto the same canonical zero-padded key, so `01-auth` / + // `1-auth` / `Phase 1` / `Phase 01` are recognized as one phase, and + // decimal phases (`1.1`) compare correctly. A raw string compare gets + // the padding case wrong; `phaseTokenMatches` is the wrong primitive + // here too — it is a FILE-MEMBERSHIP predicate (#3511) for aggregate + // `*-UAT.md` / `*-VERIFICATION.md` scans, not a phase-identity equality + // test, and is unreliable for decimal phases. + if (!knownPhaseKeys.has(phaseKeyFromToken(token))) { + diagnostics.push({ + code: INSPECT_DIAGNOSTIC.REQUIREMENT_PHASE_UNKNOWN, + subject: `${item.id}->${token}`, + detail: 'Traceability maps this requirement to a phase that is not present on disk.', + }); + rowDiagnostics.push(INSPECT_DIAGNOSTIC.REQUIREMENT_PHASE_UNKNOWN); + } + } + if (complete === 'unknown') { + diagnostics.push({ + code: INSPECT_DIAGNOSTIC.REQUIREMENT_COMPLETION_UNKNOWN, + subject: item.id, + detail: 'Requirement has no checkbox bullet; completion is unknown, not incomplete.', + }); + rowDiagnostics.push(INSPECT_DIAGNOSTIC.REQUIREMENT_COMPLETION_UNKNOWN); + } + + return { + id: item.id, + text: item.text && item.text.length > 0 ? item.text : null, + complete, + mappedPhases, + diagnostics: rowDiagnostics, + scope: SCOPE.COMPLETE, + }; + }); + + return { rows, scope: SCOPE.COMPLETE }; +} + +// ─── Summary provenance ─────────────────────────────────────────────────────── + +interface SummaryProvenance { + /** `## Files Created/Modified` — a PLAN-level list. Never attributed to a task. */ + planFiles: string[]; + /** Task index -> files, from a deviation block naming `Found during: Task N`. */ + byTask: Map; +} + +/** + * Parse a SUMMARY.md body for file provenance. + * + * Two DIFFERENT scopes live in this document and conflating them is the whole + * hazard: `## Files Created/Modified` describes the PLAN, while a deviation + * block's `**Files modified:**` is attributed to the task its `**Found during:** + * Task N` line names. Only the latter is task-scoped. + */ +function parseSummaryProvenance(content: string): SummaryProvenance { + const planFiles: string[] = []; + const byTask = new Map(); + + // `## Files Created/Modified` — a PLAN-level bullet list. Bounded to the + // section body via collectSection + iterateBullets; absent is not an error. + const filesSection = collectSection(content, (h) => /^files\s+created\/modified\s*$/i.test(h.text.trim())); + if (filesSection) { + for (const bullet of iterateBullets(filesSection.body)) { + // `- \`path/to/file.ts\` - What it does` + const m = /^`([^`]+)`/.exec(bullet.text); + if (m) planFiles.push(m[1].trim()); + } + } + + // `## Deviations from Plan` (also matches the `(Auto-fixed)` variant) — the + // `**Found during:** Task N` / `**Files modified:**` scan is a regex + // CONFINED to this already-collected section body (ADR-2143 §4 sanctioned + // pattern), never a document-wide heading walk. + const deviationsSection = collectSection(content, (h) => /^deviations\s+from\s+plan/i.test(h.text.trim())); + if (deviationsSection) { + let currentTask: number | null = null; + for (const line of deviationsSection.body.split(/\r?\n/)) { + const foundDuring = /\*\*Found during:\*\*\s*Task\s*(\d+)/i.exec(line); + if (foundDuring) { + currentTask = parseInt(foundDuring[1], 10); + continue; + } + + const filesModified = /\*\*Files modified:\*\*\s*(.+)$/i.exec(line); + if (filesModified && currentTask !== null) { + const files = filesModified[1] + .split(',') + .map((f) => f.trim().replace(/^`|`$/g, '')) + .filter((f) => f.length > 0); + const existing = byTask.get(currentTask); + if (existing) existing.push(...files); + else byTask.set(currentTask, files); + } + } + } + + return { planFiles: sortedUnique(planFiles), byTask }; +} + +// ─── Plans and tasks ────────────────────────────────────────────────────────── + +interface TaskRow { + index: number; + kind: string; + type: string | null; + name: string | null; + plannedFiles: string[]; + acceptanceCriteria: string[]; + done: string | null; + /** `null` unless the SUMMARY attributes files to THIS task. */ + changedFiles: string[] | null; + provenance: Provenance; + agreement: Agreement; + status: TaskStatus; +} + +interface PlanRow { + id: string; + file: string; + superseded: boolean; + objective: string | null; + wave: number | null; + dependsOn: string[]; + autonomous: boolean; + agentHint: string | null; + /** From the plan's own frontmatter `files_modified`. */ + plannedFiles: string[]; + /** Plan-scoped `## Files Created/Modified`. `null` when no SUMMARY exists. */ + changedFiles: string[] | null; + hasSummary: boolean; + tasks: TaskRow[]; + scope: Scope; +} + +/** Pair a plan file with its SUMMARY by the canonical id embedded in the name. */ +function summaryForPlan(planFile: string, summaryFiles: string[]): string | null { + const base = path.basename(planFile); + const key = base.replace(/-?PLAN/i, '').replace(/\.md$/i, ''); + const dir = planFile.includes('/') ? planFile.slice(0, planFile.lastIndexOf('/') + 1) : ''; + for (const candidate of summaryFiles) { + if (!candidate.startsWith(dir)) continue; + const candidateKey = path.basename(candidate).replace(/-?SUMMARY/i, '').replace(/\.md$/i, ''); + if (candidateKey === key) return candidate; + } + return null; +} + +function buildTaskRows( + planFile: string, + parsed: ReturnType, + provenance: SummaryProvenance | null, + diagnostics: Diagnostic[], +): TaskRow[] { + return parsed.tasks.map((task) => { + if (task.kind === TASK_KIND.CHECKPOINT) { + diagnostics.push({ + code: INSPECT_DIAGNOSTIC.TASK_SHAPE_CHECKPOINT, + subject: `${toPosix(planFile)}#${task.index}`, + detail: 'Checkpoint task: the grammar carries no name/files/acceptance elements.', + }); + } + + if (provenance === null) { + return { + index: task.index, + kind: task.kind, + type: task.type, + name: task.name, + plannedFiles: task.plannedFiles, + acceptanceCriteria: task.acceptanceCriteria, + done: task.done, + changedFiles: null, + provenance: PROVENANCE.ABSENT, + agreement: AGREEMENT.UNKNOWN, + status: TASK_STATUS.PENDING, + }; + } + + const attributed = provenance.byTask.get(task.index); + if (attributed === undefined) { + // A SUMMARY exists but says nothing about THIS task. The plan-level file + // list is not evidence about a task — attributing it would be inference. + diagnostics.push({ + code: INSPECT_DIAGNOSTIC.TASK_CHANGED_FILES_PLAN_SCOPED, + subject: `${toPosix(planFile)}#${task.index}`, + detail: 'SUMMARY carries only a plan-level file list; task-scoped changed files are unknown.', + }); + return { + index: task.index, + kind: task.kind, + type: task.type, + name: task.name, + plannedFiles: task.plannedFiles, + acceptanceCriteria: task.acceptanceCriteria, + done: task.done, + changedFiles: null, + provenance: PROVENANCE.PLAN_SCOPED, + agreement: AGREEMENT.UNKNOWN, + status: TASK_STATUS.UNKNOWN, + }; + } + + const changed = sortedUnique(attributed); + const planned = sortedUnique(task.plannedFiles); + let agreement: Agreement = AGREEMENT.UNKNOWN; + if (planned.length > 0) { + const same = planned.length === changed.length && planned.every((f, i) => f === changed[i]); + agreement = same ? AGREEMENT.AGREED : AGREEMENT.CONFLICTING; + if (!same) { + diagnostics.push({ + code: INSPECT_DIAGNOSTIC.TASK_CHANGED_FILES_CONFLICTING, + subject: `${toPosix(planFile)}#${task.index}`, + detail: 'Planned and changed file sets disagree; both are reported verbatim, unreconciled.', + }); + } + } + + return { + index: task.index, + kind: task.kind, + type: task.type, + name: task.name, + plannedFiles: task.plannedFiles, + acceptanceCriteria: task.acceptanceCriteria, + done: task.done, + changedFiles: changed, + provenance: PROVENANCE.TASK_SCOPED, + agreement, + status: TASK_STATUS.DONE, + }; + }); +} + +function buildPlanRows(phaseDir: string, diagnostics: Diagnostic[], planningRoot: string): { rows: PlanRow[]; scope: Scope } { + // GAP 1 (#2790 follow-up security review): `scanPhasePlans` (`plan-scan.cjs`) + // does its own `readdirSync(phaseDir)`, which FOLLOWS a directory symlink — + // so a phase directory that is itself a symlink escaping `planningRoot` + // would have its EXTERNAL filenames enumerated and surfaced via `file: + // toPosix(planFile)` below, even though the per-file `readDocument` guard + // already rejects the CONTENT. Contained before any enumeration happens, so + // an escaped phase directory contributes zero rows and zero filenames — the + // same degraded shape (`scope: unreadable`, empty `rows`) `scanPhasePlans` + // already returns when the directory cannot be listed at all, via the same + // `isPathContained` comparison `readDocument` uses for files (never a + // second, hand-rolled boundary check). + if (!isPathContained(phaseDir, planningRoot)) { + return { rows: [], scope: SCOPE.UNREADABLE }; + } + const scan = planScan(phaseDir); + const supersededSet = new Set( + scan.allPlanFiles.filter((f: string) => !scan.planFiles.includes(f)), + ); + + const rows: PlanRow[] = scan.allPlanFiles.map((planFile: string) => { + const doc = readDocument(path.join(phaseDir, planFile), planningRoot); + if (doc.text === null) { + diagnostics.push({ + code: INSPECT_DIAGNOSTIC.PLAN_UNREADABLE, + subject: toPosix(planFile), + detail: 'Plan file could not be read; its body is unknown. Sibling plans are unaffected.', + }); + return { + id: planIdFromFile(planFile), + file: toPosix(planFile), + superseded: supersededSet.has(planFile), + objective: null, + wave: null, + dependsOn: [], + autonomous: true, + agentHint: null, + plannedFiles: [], + changedFiles: null, + hasSummary: false, + tasks: [], + scope: SCOPE.UNREADABLE, + }; + } + + const parsed = parsePlanDocument(doc.text); + const summaryFile = summaryForPlan(planFile, scan.summaryFiles); + let provenance: SummaryProvenance | null = null; + if (summaryFile !== null) { + const summaryDoc = readDocument(path.join(phaseDir, summaryFile), planningRoot); + if (summaryDoc.text === null) { + diagnostics.push({ + code: INSPECT_DIAGNOSTIC.SUMMARY_UNREADABLE, + subject: toPosix(summaryFile), + detail: 'Summary file could not be read; file provenance for this plan is unknown.', + }); + } else { + provenance = parseSummaryProvenance(summaryDoc.text); + } + } + + return { + id: planIdFromFile(planFile), + file: toPosix(planFile), + superseded: supersededSet.has(planFile), + objective: parsed.objective, + wave: parsed.declaredWave, + dependsOn: parsed.dependsOn, + autonomous: parsed.autonomous, + agentHint: parsed.agentHint, + plannedFiles: parsed.filesModified, + changedFiles: provenance === null ? null : provenance.planFiles, + hasSummary: summaryFile !== null, + tasks: buildTaskRows(planFile, parsed, provenance, diagnostics), + scope: SCOPE.COMPLETE, + }; + }); + + return { rows, scope: scan.scope }; +} + +// ─── UAT ────────────────────────────────────────────────────────────────────── + +function buildUatRows( + phasesDir: string, + phaseDirName: string, + diagnostics: Diagnostic[], + planningRoot: string, +): { items: unknown[]; scope: Scope } { + const phaseDir = path.join(phasesDir, phaseDirName); + // GAP 1 (#2790 follow-up security review): same rationale as + // `buildPlanRows` above — `readdirSync` below FOLLOWS a directory symlink, + // so an escaped phase directory must be rejected before enumeration, not + // after. Reuses the existing `UAT_UNREADABLE` diagnostic and degraded + // shape (the pre-existing "directory could not be listed" path below), + // rather than inventing a new diagnostic code for what is, from a + // consumer's perspective, the same non-answer. + if (!isPathContained(phaseDir, planningRoot)) { + diagnostics.push({ + code: INSPECT_DIAGNOSTIC.UAT_UNREADABLE, + subject: phaseDirName, + detail: 'Phase directory could not be listed; UAT presence is unknown.', + }); + return { items: [], scope: SCOPE.UNREADABLE }; + } + let entries: string[]; + try { + entries = fs.readdirSync(phaseDir); + } catch { + diagnostics.push({ + code: INSPECT_DIAGNOSTIC.UAT_UNREADABLE, + subject: phaseDirName, + detail: 'Phase directory could not be listed; UAT presence is unknown.', + }); + return { items: [], scope: SCOPE.UNREADABLE }; + } + + const uatFiles = selectPhaseUatFiles(entries, phaseDirName); + if (uatFiles.length === 0) { + diagnostics.push({ + code: INSPECT_DIAGNOSTIC.UAT_ABSENT, + subject: phaseDirName, + detail: 'No UAT document for this phase. This does not affect phase acceptance.', + }); + return { items: [], scope: SCOPE.COMPLETE }; + } + + const items: unknown[] = []; + let scope: Scope = SCOPE.COMPLETE; + for (const file of uatFiles) { + const doc = readDocument(path.join(phaseDir, file), planningRoot); + if (doc.text === null) { + diagnostics.push({ + code: INSPECT_DIAGNOSTIC.UAT_UNREADABLE, + subject: `${phaseDirName}/${file}`, + detail: 'UAT document exists but could not be read.', + }); + scope = SCOPE.TRUNCATED; + continue; + } + items.push(...parseUatItems(doc.text)); + } + return { items, scope }; +} + +// ─── Progress ───────────────────────────────────────────────────────────────── + +/** + * ADR-3180 §7.6: the arithmetic is `clampPercent`'s alone (rule 1), a + * non-positive denominator is 0 not 100 (rule 2), numerator and denominator + * come from one scoped set (rule 3), and a scope other than COMPLETE renders NO + * percentage at all (rule 4). + */ +function makeFraction(completed: number, total: number, scope: Scope, subject: string, diagnostics: Diagnostic[]): Fraction { + if (scope !== SCOPE.COMPLETE) { + diagnostics.push({ + code: INSPECT_DIAGNOSTIC.PERCENT_WITHHELD, + subject, + detail: `Scope is "${scope}"; a percentage derived from an incomplete read would be a confident wrong answer.`, + }); + return { completed, total, percent: null, scope }; + } + return { completed, total, percent: clampPercent(completed, total), scope }; +} + +/** + * The active plan position, from STATE.md's `## Current Position` block. + * + * ADR-3180 §7.7: `stateFieldValue` owns the #1760 frontmatter-then-body + * fallback ladder — this composes it, it does not re-derive it. The section + * slice is load-bearing: `Plan:` canonically lives under `## Current Position`, + * and a whole-document search would match a historical `Plan:` line in an + * archive section instead (#2956). A missing section is therefore reported as + * UNSCOPED rather than silently widened to the whole body. + */ +function buildActivePlan(statePath: string, planningRoot: string): { value: string | null; scope: Scope } { + const doc = readDocument(statePath, planningRoot); + if (!doc.exists) return { value: null, scope: SCOPE.UNSCOPED }; + if (!doc.readable || doc.text === null) return { value: null, scope: SCOPE.UNREADABLE }; + const fm = extractFrontmatter(doc.text, statePath) as Record; + const body = stripFrontmatter(doc.text); + const slice = stateCurrentPositionSlice(body); + return stateFieldValue(fm, slice ?? body, 'plan', 'Plan', { + scope: slice === null ? SCOPE.UNSCOPED : SCOPE.COMPLETE, + }); +} + +// ─── Phase ROADMAP section (goal / dependencies) ────────────────────────────── + +interface ScopedText { + value: string | null; + scope: Scope; +} + +interface ScopedTextList { + value: string[]; + scope: Scope; +} + +/** + * The same `**Depends on:**` line `src/phase.cts`'s phase-insert path writes + * (`\n**Depends on:** Phase ${afterPhase}`) and `init.cts`'s own + * phase-listing scan reads (`init.cts:2359`) — mirrored verbatim here rather + * than re-derived. + */ +const DEPENDS_ON_LINE_RE = /\*\*Depends on(?::\*\*|\*\*:)\s*([^\n]+)/i; + +/** + * A bold-annotation line — `**Label:** …` (colon inside the bold run) or + * `**Label**: …` (colon immediately after it). Matches `**Depends on:**`, + * `**Plans**:`, `**Goal:**`, and `**Cross-cutting constraints:**` alike. + */ +const BOLD_ANNOTATION_LINE_RE = /^\*\*(?:[^*\n]*:[^*\n]*\*\*|[^*\n]*\*\*:)/; + +/** The bare `Plans:` checklist header `cmdRoadmapAnnotateDependencies` emits. */ +const PLANS_CHECKLIST_HEADER_RE = /^plans:$/i; + +/** + * The prose immediately under a `### Phase N: Name` heading, stopping at the + * first line that is METADATA rather than prose — issue #2790's own + * definition of per-phase "goal". The boundary matters because this payload + * already surfaces every one of those metadata lines in its own typed field + * (`**Depends on:**` -> `dependencies`, `**Plans**:` / `Plans:` + `- [ ]` + * rows -> `plans`, wave headers and `**Cross-cutting constraints:**` -> the + * per-plan rows) — folding them into `goal` too would both duplicate the + * data and hand a consumer raw Markdown to render. A sub-heading boundary is + * belt-and-braces: `collectSection` already bounds the body at the next + * heading. + * + * `null` when the section carries no such prose before hitting metadata (a + * real "no goal", not a failure). + */ +function extractGoalProse(sectionBody: string): string | null { + const proseLines: string[] = []; + for (const line of sectionBody.split(/\r?\n/)) { + const trimmed = line.trim(); + if (/^\s{0,3}#{1,6}\s/.test(line)) break; + if (/^\s*(?:[-*+]|\d+[.)])\s/.test(line)) break; + if (BOLD_ANNOTATION_LINE_RE.test(trimmed)) break; + if (PLANS_CHECKLIST_HEADER_RE.test(trimmed)) break; + proseLines.push(line); + } + const text = proseLines.join('\n').trim(); + return text.length > 0 ? text : null; +} + +/** + * Phase tokens off a `**Depends on:**` line — value-level token extraction + * of ONE already-addressed line (mirrors `parseTraceability`'s Phase-cell + * token scan above), never a document-wide scan. `[]` when the line is + * absent. + */ +function extractDependencyTokens(sectionBody: string): string[] { + const m = DEPENDS_ON_LINE_RE.exec(sectionBody); + if (!m) return []; + return sortedUnique([...m[1].matchAll(/\d+(?:\.\d+)*/g)].map((t) => t[0])); +} + +/** + * This phase's own ROADMAP.md section body — milestone-scoped via the SAME + * `extractCurrentMilestone` seam `planning-snapshot.cts`'s own ROADMAP + * consumers use (`planning-snapshot.cts:918`), never a document-wide walk. + * `collectSection` (ADR-2143) owns the heading walk; the predicate is built + * from the canonical `phaseMarkdownRegexSource` (`phase-id.cts`) anchor — + * the same start-of-heading anchor `roadmap-parser.cts`'s own phase-section + * lookups (`withPhaseSection`, `findRoadmapPhaseInContent`) use, so a + * sibling phase whose TITLE merely mentions this phase's number is never + * hijacked. + */ +function findPhaseRoadmapSection(cwd: string, roadmapText: string, phaseId: string): string | null { + const scoped = extractCurrentMilestone(roadmapText, cwd); + const headingRe = new RegExp( + `^(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${phaseMarkdownRegexSource(phaseId)}(?=[\\s:(]|$)`, + 'i', + ); + const section = collectSection(scoped, (h) => headingRe.test(h.text)); + return section ? section.body : null; +} + +/** + * Issue #2790's Summary names "per-phase goal/dependency" as spec elements + * distinct from the STATUS evidence (`verification` / `roadmap_acceptance` / + * `uat`) phase rows already carry. Both fields fold the SAME three-way scope + * every other value in this module carries: `complete` when the section was + * found and parsed, `unscoped` when ROADMAP.md has no section for this + * phase, `unreadable` when ROADMAP.md itself could not be read. Never + * inferred — an absent `**Depends on:**` line is `[]`, not a degraded scope. + * + * `getRoadmapPhaseWithFallback` (`roadmap.cts`) and `findRoadmapPhaseInContent` + * (`roadmap-parser.cts`) were considered first (per this module's own + * "COMPOSED, NOT RE-DERIVED" rule) but both perform their OWN internal file + * read, bypassing this module's `readDocument` tri-state (exists/readable/ + * text) — the exact distinction `unreadable` vs `unscoped` needs here, and + * the one every other section of this module gets via the same seam. Their + * `goal` extraction also targets an explicit `**Goal:**` bold line, not the + * free prose immediately under the heading the issue's own fixture expects. + * `collectSection` + `extractCurrentMilestone`, fed by this module's own + * `readDocument(paths.roadmap)` read, keeps both the read path and the + * "found vs missing vs unreadable" distinction singular. + */ +function buildPhaseGoalAndDependencies( + cwd: string, + roadmapDoc: { text: string | null; readable: boolean }, + phaseId: string | null, + phaseDirLabel: string, + diagnostics: Diagnostic[], +): { goal: ScopedText; dependencies: ScopedTextList } { + if (!roadmapDoc.readable || roadmapDoc.text === null) { + diagnostics.push({ + code: INSPECT_DIAGNOSTIC.ROADMAP_UNSCOPED, + subject: phaseDirLabel, + detail: 'ROADMAP.md could not be read; phase goal and dependencies are unknown, not empty.', + }); + return { + goal: { value: null, scope: SCOPE.UNREADABLE }, + dependencies: { value: [], scope: SCOPE.UNREADABLE }, + }; + } + if (phaseId === null) { + diagnostics.push({ + code: INSPECT_DIAGNOSTIC.ROADMAP_UNSCOPED, + subject: phaseDirLabel, + detail: 'Phase directory name carries no recognizable phase number; ROADMAP section cannot be located.', + }); + return { + goal: { value: null, scope: SCOPE.UNSCOPED }, + dependencies: { value: [], scope: SCOPE.UNSCOPED }, + }; + } + const sectionBody = findPhaseRoadmapSection(cwd, roadmapDoc.text, phaseId); + if (sectionBody === null) { + diagnostics.push({ + code: INSPECT_DIAGNOSTIC.ROADMAP_UNSCOPED, + subject: phaseDirLabel, + detail: 'ROADMAP.md has no section for this phase; goal and dependencies are non-answers, not empty.', + }); + return { + goal: { value: null, scope: SCOPE.UNSCOPED }, + dependencies: { value: [], scope: SCOPE.UNSCOPED }, + }; + } + return { + goal: { value: extractGoalProse(sectionBody), scope: SCOPE.COMPLETE }, + dependencies: { value: extractDependencyTokens(sectionBody), scope: SCOPE.COMPLETE }, + }; +} + +// ─── Entry points ───────────────────────────────────────────────────────────── + +function buildPlanningInspect(cwd: string): Record { + const diagnostics: Diagnostic[] = []; + const paths = planningPaths(cwd); + const planningExists = fs.existsSync(paths.planning); + if (!planningExists) { + diagnostics.push({ + code: INSPECT_DIAGNOSTIC.PLANNING_ROOT_ABSENT, + subject: toPosix(paths.planning), + detail: 'No .planning/ directory; every section below is an empty non-answer, not an empty project.', + }); + } + + const snapshot = buildPlanningSnapshot(cwd); + + if (snapshot.milestone.scope !== SCOPE.COMPLETE) { + diagnostics.push({ + code: INSPECT_DIAGNOSTIC.ROADMAP_UNSCOPED, + subject: toPosix(paths.roadmap), + detail: `Milestone identity scope is "${snapshot.milestone.scope}"; no version is invented to stand in for it.`, + }); + } + + const milestoneValue = snapshot.milestone.value as { version?: string; name?: string | null } | null; + + // Phase rows come from the WINDOWED set (this milestone's phases). A dir on + // disk that the roadmap never declares is an orphan, reported separately — + // it is not silently promoted into `phases`, and it is not silently dropped. + const windowed: string[] = snapshot.phaseDirs.value; + const windowedSet = new Set(windowed); + const orphans = snapshot.allPhaseDirNames.value + .filter((dir) => !windowedSet.has(dir)) + .sort(); + for (const orphan of orphans) { + diagnostics.push({ + code: INSPECT_DIAGNOSTIC.ORPHAN_PHASE_DIR, + subject: orphan, + detail: 'Phase directory exists on disk but is not declared in the current milestone window.', + }); + } + + const checkboxes = snapshot.roadmapPhaseCheckboxes.value; + // ROADMAP checkboxes arrive keyed by the BARE phase token the ROADMAP prose + // carries ("1"), while phase rows are keyed by on-disk directory name + // ("01-auth"). Comparing them raw makes this field null for every + // real-world directory — an evidence channel that silently never fires. + // Both sides go through the phase-id owners so "1", "01" and "01-auth" are + // one phase. + const checkboxByPhaseKey = new Map(); + for (const [token, ticked] of Object.entries(checkboxes)) { + checkboxByPhaseKey.set(phaseKeyFromToken(token), ticked); + } + const phaseSnapshots = snapshot.phases.value as { + dir: string; + complete: boolean; + verificationStatus: string; + planCount: number; + summaryCount: number; + scope: Scope; + }[]; + + // Canonical per-directory identity keys (`phase-id.cts`'s + // `phaseKeyFromDir`), not a raw regex scrape — this is the set + // `buildRequirements` below matches Traceability tokens against via + // `phaseKeyFromToken`. + const knownPhaseKeys: Set = new Set(windowed.map((dir) => phaseKeyFromDir(dir))); + + // Read once, shared across every phase row's goal/dependencies lookup — + // the same `readDocument` seam every other document read in this module + // uses, never a second file-reading path. + const roadmapDoc = readDocument(paths.roadmap, paths.planning); + + const phaseRows = phaseSnapshots.map((phase) => { + const phaseDir = path.join(paths.phases, phase.dir); + const plans = buildPlanRows(phaseDir, diagnostics, paths.planning); + const uat = buildUatRows(paths.phases, phase.dir, diagnostics, paths.planning); + // GAP 2 (#2790 follow-up security review): `readVerificationStatus` + // (`src/verification.cts`) is a shared owner with its own unguarded + // `readFileSync` — a `*-VERIFICATION.md` symlinked outside the planning + // root would leak an unrecognized `status:` value verbatim via its + // "Unexpected verification status ''" `next_action` string. Fixed + // from THIS consumer's side via the injectable `opts.fs` seam that + // function already exposes, never by touching its signature — see + // `containmentEnforcingVerificationFs`'s doc comment. This same seam's + // `readdirSync(phaseDir)` guard also independently covers the + // escaped-phase-DIRECTORY case for this call site (GAP 1 above only + // gates `buildPlanRows`/`buildUatRows`, not this one). + // + // `src/plan-scan.cts`'s `isPlanSuperseded` similarly reads + // symlink-followed content with no containment of its own, but it is + // reached only via `scanPhasePlans(phaseDir)` inside `buildPlanRows` + // above (never touched here), leaks only a derived boolean + // (`superseded`) rather than document text, and GAP 1's directory + // containment check already covers the escaped-DIRECTORY case for it — + // so it needs no fix of its own. + const verification = readVerificationStatus(phaseDir, { + fs: containmentEnforcingVerificationFs(paths.planning), + }); + + const token = /^(\d+(?:\.\d+)*)/.exec(phase.dir); + const phaseId = token ? token[1] : null; + const { goal, dependencies } = buildPhaseGoalAndDependencies(cwd, roadmapDoc, phaseId, phase.dir, diagnostics); + + const folded = worstScope(phase.scope, plans.scope, uat.scope, goal.scope, dependencies.scope); + if (folded !== SCOPE.COMPLETE) { + diagnostics.push({ + code: INSPECT_DIAGNOSTIC.PHASE_SCOPE_DEGRADED, + subject: phase.dir, + detail: `Phase evidence is incomplete (scope "${folded}").`, + }); + } + + return { + dir: phase.dir, + phase_id: phaseId, + complete: phase.complete, + goal, + dependencies, + // The three evidence sources are reported SIDE BY SIDE and never folded + // into one verdict — folding them is precisely the confidently-wrong + // composite ADR-3180 exists to remove. + verification: { + status: verification.status, + next_action: verification.next_action ?? null, + }, + roadmap_acceptance: { + checkbox: checkboxByPhaseKey.has(phaseKeyFromDir(phase.dir)) + ? (checkboxByPhaseKey.get(phaseKeyFromDir(phase.dir)) as boolean) + : null, + // ADR-3180 §7.4: a ticked checkbox is a human annotation with no + // machine authority. Stated in the payload so a consumer cannot + // mistake it for a completion signal. + authoritative: false, + }, + uat: { unresolved: uat.items, scope: uat.scope }, + plan_count: phase.planCount, + summary_count: phase.summaryCount, + plans: plans.rows, + scope: folded, + }; + }); + + const requirements = buildRequirements(paths.requirements, knownPhaseKeys, diagnostics, paths.planning); + + const phaseScope = worstScope( + snapshot.phaseDirs.scope, + snapshot.phases.scope, + ...phaseRows.map((p) => p.scope), + ); + const acceptedPhases = makeFraction( + phaseRows.filter((p) => p.complete).length, + phaseRows.length, + phaseScope, + 'progress.accepted_phases', + diagnostics, + ); + const completedPlans = makeFraction( + phaseRows.reduce((sum, p) => sum + p.summary_count, 0), + phaseRows.reduce((sum, p) => sum + p.plan_count, 0), + phaseScope, + 'progress.completed_plans', + diagnostics, + ); + + return { + schema_version: PLANNING_INSPECT_SCHEMA_VERSION, + generated_from: { + cwd: toPosix(cwd), + planning_root: planningExists ? toPosix(paths.planning) : null, + }, + milestone: { + version: milestoneValue && milestoneValue.version ? milestoneValue.version : null, + name: milestoneValue && milestoneValue.name ? milestoneValue.name : null, + scope: snapshot.milestone.scope, + }, + // Three DISTINCT STATE.md facts, each from its own source. Collapsing any + // two of them is the confidently-wrong composite this schema exists to + // avoid: `Status:` is a lifecycle label, `Plan:` is a position. + active: { + phase: { value: snapshot.currentPhaseLabel.value, scope: snapshot.currentPhaseLabel.scope }, + plan: buildActivePlan(paths.state, paths.planning), + status: { value: snapshot.stateStatus.value, scope: snapshot.stateStatus.scope }, + }, + phases: phaseRows, + orphan_phase_dirs: orphans, + requirements: requirements.rows, + progress: { + accepted_phases: acceptedPhases, + completed_plans: completedPlans, + }, + diagnostics, + }; +} + +/** + * `planning inspect` — emit the schema-v1 snapshot. + * + * `output()` is the spill seam: a payload over 50 KB is written to a tmpfile and + * returned as `@file:`, which `gsd-tools`' `resolveAtFileOutput` resolves + * transparently on stdout. Bypassing `output()` would lose that for free. + */ +function cmdPlanningInspect(cwd: string, raw: boolean): void { + output(buildPlanningInspect(cwd), raw); +} + +const planningInspect = { + PLANNING_INSPECT_SCHEMA_VERSION, + INSPECT_DIAGNOSTIC, + TASK_STATUS, + PROVENANCE, + AGREEMENT, + buildPlanningInspect, + cmdPlanningInspect, +}; + +export = planningInspect; diff --git a/src/uat.cts b/src/uat.cts index 6d1657cb7..1c9f6c977 100644 --- a/src/uat.cts +++ b/src/uat.cts @@ -81,6 +81,19 @@ interface CurrentTest { // ─── cmdAuditUat ───────────────────────────────────────────────────────────── +/** + * Select the UAT documents belonging to ONE phase directory. + * + * Extracted (#2790) so `cmdAuditUat` and the read-only `planning.inspect` query + * cannot drift on which files count as this phase's UAT. `scopeToPhase` has no + * unfiltered fallback on purpose: a phase whose own UAT file is genuinely absent + * scopes to empty and contributes nothing, rather than picking up a stray + * cross-phase file (#3511). + */ +function selectPhaseUatFiles(files: string[], phaseDirName: string): string[] { + return scopeToPhase(files.filter((f) => f.includes('-UAT') && f.endsWith('.md')), phaseDirName); +} + function cmdAuditUat(cwd: string, raw: boolean): void { const phasesDir = path.join(planningDir(cwd), 'phases'); const hasActivePhases = fs.existsSync(phasesDir); @@ -139,7 +152,7 @@ function cmdAuditUat(cwd: string, raw: boolean): void { // under this phase's audit-uat entry. A phase whose own UAT file is // genuinely absent scopes to empty and contributes nothing — correct, and // the reason scopeToPhase has no unfiltered fallback. - for (const file of scopeToPhase(files.filter(f => f.includes('-UAT') && f.endsWith('.md')), dir)) { + for (const file of selectPhaseUatFiles(files, dir)) { const uatFilePath = path.join(phaseDir, file); const content = fs.readFileSync(uatFilePath, 'utf-8'); const items = parseUatItems(content); @@ -1657,6 +1670,8 @@ export = { cmdAuditUat, cmdRenderCheckpoint, parseCurrentTest, + parseUatItems, + selectPhaseUatFiles, buildCheckpoint, CHECKPOINT_FRAMES, CHECKPOINT_LANGUAGE_ALIASES, diff --git a/tests/mutation-matrix-ratchet.test.cjs b/tests/mutation-matrix-ratchet.test.cjs index 79c1be2e5..497cb3041 100644 --- a/tests/mutation-matrix-ratchet.test.cjs +++ b/tests/mutation-matrix-ratchet.test.cjs @@ -199,6 +199,9 @@ const RATCHET_BASELINE = { 'config-schema': 52, // CI 54.55% 2026-06-14; was 68 (timeout-inflated local) 'active-workstream-store': 80, 'core-utils': 75, + 'planning-inspect': 56, // CI run 32392791843: 57.03% (unit shard); ratchet candidate vs TARGET 80 + 'plan-document': 75, // CI run 32392791843: 76.58% (unit shard) + 'planning-command-router': 94, // CI run 32392791843: 95.65% (unit shard); already exceeds TARGET 80 }; describe('mutation-matrix ratchet: floor equality enforcement', () => { diff --git a/tests/planning-inspect.test.cjs b/tests/planning-inspect.test.cjs new file mode 100644 index 000000000..933a162e6 --- /dev/null +++ b/tests/planning-inspect.test.cjs @@ -0,0 +1,2440 @@ +'use strict'; + +/** + * Tests for `src/planning-inspect.cts` — the schema-v1 canonical planning + * snapshot (`query planning inspect` / `query planning.inspect`, #2790). + * + * Design: .gsd/phase/feat-2790-planning-inspect/40-design.md + * Test matrix: .gsd/phase/feat-2790-planning-inspect/50-test-matrix.md + * + * Fixture provenance (CONTRIBUTING.md "Fixture provenance (#2371)"): every + * `.planning/` document shape written by this file's fixture builders is + * derived from the SHIPPED templates the product author wrote — + * `gsd-core/templates/requirements.md` (checkbox bullets, `## Traceability` + * table), `gsd-core/templates/phase-prompt.md` (the `` XML grammar and + * `## Task N` legacy heading fallback), `gsd-core/templates/summary.md` + * (`## Files Created/Modified`, `## Deviations from Plan` / `**Found + * during:**` / `**Files modified:**`), `gsd-core/templates/state.md` + * (`## Current Position`), and `gsd-core/templates/roadmap.md` (`## Phases` + * checkbox list, `### Phase N: Name` headings) — never from + * `planning-inspect.cts`'s own parsing model. `planning.inspect` has no + * writer of its own (it is read-only), so the property-test generator in + * this file (`propertySchemaIsTotalOverDocumentShapedInputs`) is + * document-shaped from those same templates, not seeded from the module + * under test. + * + * `runGsdTools(['query', 'planning', 'inspect'], tmpDir)` is the invocation + * shape used throughout — array form, shell-bypassed, safe for hostile + * fixture values (CONTRIBUTING "CLI and command routing"). + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const fc = require('fast-check'); + +const { createTempProject, createTempDir, cleanup, runGsdTools, toPosixPath } = require('./helpers.cjs'); + +// ─── Fixture helpers ────────────────────────────────────────────────────────── + +function planningDirOf(cwd) { + return path.join(cwd, '.planning'); +} + +function phasesDirOf(cwd) { + return path.join(planningDirOf(cwd), 'phases'); +} + +function phaseDirOf(cwd, token) { + return path.join(phasesDirOf(cwd), token); +} + +function writeAbs(fullPath, content) { + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content); +} + +function writeFile(cwd, relPath, content) { + writeAbs(path.join(cwd, relPath), content); +} + +function frontmatterDoc(frontmatterLines, bodyLines, eol) { + return ['---', ...frontmatterLines, '---', '', ...bodyLines].join(eol); +} + +function writeRoadmap(cwd, lines, eol = '\n') { + writeFile(cwd, '.planning/ROADMAP.md', lines.join(eol)); +} + +function writeState(cwd, frontmatterLines, bodyLines = [], eol = '\n') { + writeFile(cwd, '.planning/STATE.md', frontmatterDoc(frontmatterLines, bodyLines, eol)); +} + +function writeRequirements(cwd, content) { + writeFile(cwd, '.planning/REQUIREMENTS.md', content); +} + +function writePlanDoc(phaseDir, fileName, frontmatterLines, bodyLines, eol = '\n') { + writeAbs(path.join(phaseDir, fileName), frontmatterDoc(frontmatterLines, bodyLines, eol)); +} + +function writeSummaryDoc(phaseDir, fileName, frontmatterLines, bodyLines, eol = '\n') { + writeAbs(path.join(phaseDir, fileName), frontmatterDoc(frontmatterLines, bodyLines, eol)); +} + +function writeVerification(phaseDir, phaseToken, status, eol = '\n') { + writeAbs(path.join(phaseDir, `${phaseToken}-VERIFICATION.md`), ['---', `status: ${status}`, '---', ''].join(eol)); +} + +function writeUatDoc(phaseDir, phaseToken, bodyLines, eol = '\n') { + writeAbs(path.join(phaseDir, `${phaseToken}-UAT.md`), bodyLines.join(eol)); +} + +/** Slugify a phase name the same way `getPhaseDirFromPhaseId` (`src/phase-id.cts`) does. */ +function slugify(name) { + return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); +} + +/** + * The on-disk directory token for phase `token`/`name` — zero-padded numeric + * prefix + slug (`"01-auth"`), matching `tests/planning-snapshot.test.cjs`'s + * fixtures and every real project (phases are never named by a bare `"1"`). + * Deliberately DIFFERENT from the bare numeric token the ROADMAP prose itself + * carries (`"Phase 1"`) — that mismatch (directory `phaseKeyFromDir`-keyed, + * ROADMAP `phaseKeyFromToken`-keyed) is exactly what a real project looks + * like, and exactly what `planning-inspect.cts`'s roadmap-checkbox lookup + * must reconcile through the phase-id owners rather than raw string equality. + */ +function slugPhaseDirName(token, name) { + return `${String(token).padStart(2, '0')}-${slugify(name)}`; +} + +/** + * Declare one phase in STATE.md + ROADMAP.md (milestone window), matching + * `gsd-core/templates/roadmap.md`'s `### Phase N: Name` heading shape and + * `gsd-core/templates/state.md`'s frontmatter shape. The ROADMAP prose + * (heading + checkbox bullet) carries the BARE numeric token, exactly as real + * ROADMAP.md documents do — `buildRoadmapPhaseCheckboxesField` and the + * requirement-traceability parser both capture this bare form. The returned + * directory is the SLUGGED convention (`slugPhaseDirName`), matching real + * projects and `tests/planning-snapshot.test.cjs`'s own fixtures. + */ +function declarePhase(cwd, token, name, { checkedInPhaseList = false } = {}) { + writeState(cwd, ["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0']); + const phaseListLine = checkedInPhaseList + ? `- [x] **Phase ${token}: ${name}** - stub` + : `- [ ] **Phase ${token}: ${name}** - stub`; + writeRoadmap(cwd, [ + '## v1.0 Current 🚧', + '', + '## Phases', + '', + phaseListLine, + '', + `### Phase ${token}: ${name}`, + '', + ]); + // phaseDirOf only computes a path; only writeAbs creates directories, as a + // side effect of writing a file. A declared-but-absent directory silently + // produces an empty `phases[]`, so create it explicitly here. + const phaseDir = phaseDirOf(cwd, slugPhaseDirName(token, name)); + fs.mkdirSync(phaseDir, { recursive: true }); + return phaseDir; +} + +/** A healthy two-phase project: both phases complete, requirements mapped. */ +function buildHealthyFixture(cwd, eol = '\n') { + writeState(cwd, ["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0'], [], eol); + writeRoadmap(cwd, [ + '## v1.0 Current 🚧', + '', + '### Phase 1: Foo', + '', + '### Phase 2: Bar', + '', + ], eol); + writeRequirements(cwd, [ + '# Requirements: Test', + '', + '## v1 Requirements', + '', + '- [x] **AUTH-01**: User can sign up', + '- [ ] **AUTH-02**: User can log in', + '', + '## Traceability', + '', + '| Requirement | Phase | Status |', + '|-------------|-------|--------|', + '| AUTH-01 | Phase 1 | Complete |', + '| AUTH-02 | Phase 2 | Pending |', + '', + ].join(eol)); + + for (const [token, name] of [['1', 'foo'], ['2', 'bar']]) { + const phaseDir = phaseDirOf(cwd, slugPhaseDirName(token, name)); + writePlanDoc(phaseDir, `${token}-01-PLAN.md`, ['wave: 1'], [ + '', + `Ship ${name}`, + '', + '', + '', + '', + '', + ` Task 1: Build ${name}`, + ` src/${name}.ts`, + ' Build it', + ' Done', + '', + '', + '', + ], eol); + writeSummaryDoc(phaseDir, `${token}-01-SUMMARY.md`, ['status: complete'], [ + '# Summary', + '', + '## Files Created/Modified', + `- \`src/${name}.ts\` - ${name}`, + ], eol); + writeVerification(phaseDir, token, 'passed', eol); + } +} + +/** Recursive {relPath -> {size, mtimeMs}} snapshot of `.planning/`, for read-only proof. */ +function snapshotPlanningTree(cwd) { + const root = planningDirOf(cwd); + const snap = {}; + function walk(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + const rel = path.relative(root, full); + if (entry.isDirectory()) { + walk(full); + continue; + } + let stat; + try { + stat = fs.statSync(full); + } catch { + continue; + } + snap[rel] = { size: stat.size, mtimeMs: stat.mtimeMs }; + } + } + if (fs.existsSync(root)) walk(root); + return snap; +} + +/** JSON round-trip with the fixture's own absolute cwd replaced by a stable placeholder. */ +function stripCwd(payload, cwd) { + const cwdPosix = toPosixPath(cwd); + const json = JSON.stringify(payload).split(cwdPosix).join('').split(cwd).join(''); + return JSON.parse(json); +} + +const EXPECTED_TOP_LEVEL_KEYS = [ + 'active', 'diagnostics', 'generated_from', 'milestone', 'orphan_phase_dirs', + 'phases', 'progress', 'requirements', 'schema_version', +].sort(); + +const EXPECTED_PHASE_ROW_KEYS = [ + 'complete', 'dependencies', 'dir', 'goal', 'phase_id', 'plan_count', 'plans', + 'roadmap_acceptance', 'scope', 'summary_count', 'uat', 'verification', +].sort(); + +function sortedKeys(obj) { + return Object.keys(obj).sort(); +} + +function runInspect(tmpDir, extraArgs = []) { + return runGsdTools(['query', 'planning', 'inspect', ...extraArgs], tmpDir); +} + +function parseInspect(tmpDir, extraArgs = []) { + const result = runInspect(tmpDir, extraArgs); + assert.strictEqual(result.success, true, `planning inspect should succeed: ${result.error}`); + return JSON.parse(result.output); +} + +function runInspectJsonError(tmpDir, extraArgs) { + const result = runGsdTools(['query', 'planning', 'inspect', ...extraArgs, '--json-errors'], tmpDir); + assert.strictEqual(result.success, false, `expected failure for args: ${extraArgs.join(' ')}`); + let parsed; + try { + parsed = JSON.parse(result.error); + } catch (e) { + throw new Error(`--json-errors must emit valid JSON on stderr; got: ${result.error}\nparse error: ${e.message}`); + } + assert.strictEqual(parsed.ok, false); + return parsed; +} + +// ─── 1. Schema contract ───────────────────────────────────────────────────────── + +describe('planning inspect — schema contract', () => { + test('emitsSchemaV1SnapshotForPopulatedProject', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + buildHealthyFixture(tmpDir); + + const result = runInspect(tmpDir); + assert.strictEqual(result.success, true, `expected success: ${result.error}`); + const payload = JSON.parse(result.output); + assert.strictEqual(payload.schema_version, 1); + }); + + test('locksTopLevelSchemaKeySet', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + buildHealthyFixture(tmpDir); + + const payload = parseInspect(tmpDir); + assert.deepStrictEqual(sortedKeys(payload), EXPECTED_TOP_LEVEL_KEYS); + }); + + test('schemaKeySetIsDecoupledFromPlanningSnapshotShape', (t) => { + // Row 4: the top-level key set is a FROZEN mapping this module owns, not + // a reflection of `PlanningSnapshot`'s own (additive, still-growing) + // shape. Proven by locking the map itself — EXPECTED_TOP_LEVEL_KEYS is a + // hand-authored constant in this file, not derived from the snapshot + // module — so a field added to `PlanningSnapshot` cannot silently widen + // what `planning.inspect` emits without this test also being edited. + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + buildHealthyFixture(tmpDir); + + const planningSnapshotLib = require('../gsd-core/bin/lib/planning-snapshot.cjs'); + const snapshot = planningSnapshotLib.buildPlanningSnapshot(tmpDir); + // PlanningSnapshot's own key set is whatever it is (additive, churning) — + // asserted only to prove this test is exercising the real module, not a + // stub. + assert.ok(Object.keys(snapshot).length > 0); + + const payload = parseInspect(tmpDir); + assert.deepStrictEqual(sortedKeys(payload), EXPECTED_TOP_LEVEL_KEYS); + }); + + test('locksSchemaVersionConstantAgainstTheExportedModule', () => { + // Loading a module's exported runtime value, not source-grepping text. + const planningInspectLib = require('../gsd-core/bin/lib/planning-inspect.cjs'); + assert.strictEqual(planningInspectLib.PLANNING_INSPECT_SCHEMA_VERSION, 1); + }); + + test('locksDiagnosticTaskStatusProvenanceAndAgreementEnums', () => { + const planningInspectLib = require('../gsd-core/bin/lib/planning-inspect.cjs'); + + const expectedDiagnosticKeys = [ + 'PLANNING_ROOT_ABSENT', 'ROADMAP_UNSCOPED', 'REQUIREMENTS_ABSENT', 'REQUIREMENTS_UNREADABLE', + 'REQUIREMENT_DUPLICATE', 'REQUIREMENT_UNMAPPED', 'REQUIREMENT_PHASE_UNKNOWN', + 'REQUIREMENT_COMPLETION_UNKNOWN', 'ORPHAN_PHASE_DIR', 'PHASE_SCOPE_DEGRADED', + 'PLAN_UNREADABLE', 'SUMMARY_UNREADABLE', 'TASK_SHAPE_CHECKPOINT', + 'TASK_CHANGED_FILES_PLAN_SCOPED', 'TASK_CHANGED_FILES_CONFLICTING', + 'UAT_ABSENT', 'UAT_UNREADABLE', 'PERCENT_WITHHELD', + ].sort(); + assert.deepStrictEqual(sortedKeys(planningInspectLib.INSPECT_DIAGNOSTIC), expectedDiagnosticKeys); + + assert.deepStrictEqual(sortedKeys(planningInspectLib.TASK_STATUS), ['DONE', 'PENDING', 'UNKNOWN'].sort()); + assert.deepStrictEqual(sortedKeys(planningInspectLib.PROVENANCE), ['ABSENT', 'PLAN_SCOPED', 'TASK_SCOPED'].sort()); + assert.deepStrictEqual(sortedKeys(planningInspectLib.AGREEMENT), ['AGREED', 'CONFLICTING', 'UNKNOWN'].sort()); + }); + + test('dottedAndSpacedInvocationsAgree', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + buildHealthyFixture(tmpDir); + + const spaced = parseInspect(tmpDir); + const dottedResult = runGsdTools(['query', 'planning.inspect'], tmpDir); + assert.strictEqual(dottedResult.success, true, `expected success: ${dottedResult.error}`); + const dotted = JSON.parse(dottedResult.output); + + assert.deepStrictEqual(dotted, spaced); + }); +}); + +// ─── 2. Dispatch / usage ────────────────────────────────────────────────────── + +describe('planning inspect — dispatch and usage', () => { + test('rejectsPlanningFamilyWithNoSubcommand', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['query', 'planning'], tmpDir); + assert.strictEqual(result.success, false); + }); + + test('rejectsUnknownPlanningSubcommand', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runGsdTools(['query', 'planning', 'bogus'], tmpDir); + assert.strictEqual(result.success, false); + }); + + test('rejectsStrayPositionalArgumentWithUsageReason', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const parsed = runInspectJsonError(tmpDir, ['extra']); + assert.strictEqual(parsed.reason, 'usage'); + }); + + test('rejectsUnknownFlagWithUsageReason', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const parsed = runInspectJsonError(tmpDir, ['--nope']); + assert.strictEqual(parsed.reason, 'usage'); + }); + + test('rejectsScopingFlagsNotSupportedInV1WithUsageReason', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const parsed = runInspectJsonError(tmpDir, ['--phase', '3']); + assert.strictEqual(parsed.reason, 'usage'); + }); + + test('neverPrintsStackTraceOnUsageFailure', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const result = runInspect(tmpDir, ['extra']); + assert.strictEqual(result.success, false); + // Structural "did our own error envelope leak a raw stack" proof — the + // established repo pattern (tests/commands.test.cjs, tests/config-get-default.test.cjs). + assert.strictEqual(/\n\s*at\s/.test(result.error), false, `stderr must not carry a stack trace: ${result.error}`); + }); + + test('rejectsEmptyAndValuelessFlagForms', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + // Row 12: `--phase ""` and `--phase` (no value at all) both land on the + // same "planning inspect takes no arguments" usage rejection as any other + // unrecognized flag — v1 takes no flags at all, so neither form is + // special-cased into a different failure shape. + const emptyValue = runInspectJsonError(tmpDir, ['--phase', '']); + assert.strictEqual(emptyValue.reason, 'usage'); + const noValue = runInspectJsonError(tmpDir, ['--phase']); + assert.strictEqual(noValue.reason, 'usage'); + }); + + test('toleratesDuplicateGlobalFlag', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + buildHealthyFixture(tmpDir); + // Row 13: `--raw --raw` — v1 takes no flags, so `--raw` (recognized by + // other query commands) is itself rejected by this command's own usage + // check. The row's contract is "no crash; deterministic", which this + // proves by running twice and asserting the two failures are identical. + const first = runInspect(tmpDir, ['--raw', '--raw']); + const second = runInspect(tmpDir, ['--raw', '--raw']); + assert.strictEqual(first.success, false); + assert.strictEqual(second.success, false); + assert.strictEqual(first.error, second.error); + assert.strictEqual(/\n\s*at\s/.test(first.error), false, `stderr must not carry a stack trace: ${first.error}`); + }); + + test('rejectsFlagShapedValueWithoutStackTrace', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + // Row 14: a value that itself looks like a flag (`--pick --weird`) must + // still fail as a plain usage error, never crash with a raw stack trace. + const result = runInspect(tmpDir, ['--pick', '--weird']); + assert.strictEqual(result.success, false); + assert.strictEqual(/\n\s*at\s/.test(result.error), false, `stderr must not carry a stack trace: ${result.error}`); + }); + + test('emitsTypedReasonCodesUnderJsonErrors', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + // Row 15: every usage/dispatch failure in rows 7-11 must carry a TYPED + // `reason` under `--json-errors` — never require a caller to regex the + // human `message` prose. + const noSubcommand = runGsdTools(['query', 'planning', '--json-errors'], tmpDir); + assert.strictEqual(noSubcommand.success, false); + assert.strictEqual(JSON.parse(noSubcommand.error).reason, 'sdk_unknown_command'); + + const unknownSubcommand = runGsdTools(['query', 'planning', 'bogus', '--json-errors'], tmpDir); + assert.strictEqual(unknownSubcommand.success, false); + assert.strictEqual(JSON.parse(unknownSubcommand.error).reason, 'sdk_unknown_command'); + + assert.strictEqual(runInspectJsonError(tmpDir, ['extra']).reason, 'usage'); + assert.strictEqual(runInspectJsonError(tmpDir, ['--nope']).reason, 'usage'); + assert.strictEqual(runInspectJsonError(tmpDir, ['--phase', '3']).reason, 'usage'); + }); +}); + +// ─── 3. Read-only proof ─────────────────────────────────────────────────────── + +describe('planning inspect — read-only proof', () => { + test('mutatesNothingUnderPlanningDirOnASuccessfulRun', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + buildHealthyFixture(tmpDir); + + const before = snapshotPlanningTree(tmpDir); + const result = runInspect(tmpDir); + assert.strictEqual(result.success, true, `expected success: ${result.error}`); + const after = snapshotPlanningTree(tmpDir); + assert.deepStrictEqual(after, before); + }); + + test('mutatesNothingEvenWhenAPlanDocumentIsUnreadable', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const phaseDir = declarePhase(tmpDir, '1', 'Foo'); + // Directory-in-file-position (no chmod; root-proof, cross-platform): + // readDocument() sees `!stat.isFile()` and reports unreadable. + fs.mkdirSync(path.join(phaseDir, '1-01-PLAN.md'), { recursive: true }); + + const before = snapshotPlanningTree(tmpDir); + const result = runInspect(tmpDir); + assert.strictEqual(result.success, true, `expected success: ${result.error}`); + const after = snapshotPlanningTree(tmpDir); + assert.deepStrictEqual(after, before); + }); +}); + +// ─── 4. Degradation and scope ───────────────────────────────────────────────── + +describe('planning inspect — degradation and scope', () => { + test('degradesCleanlyWithNoPlanningDirAtAll', (t) => { + const tmpDir = createTempDir(); + t.after(() => cleanup(tmpDir)); + + const payload = parseInspect(tmpDir); + assert.deepStrictEqual(sortedKeys(payload), EXPECTED_TOP_LEVEL_KEYS); + assert.ok(payload.diagnostics.some((d) => d.code === 'planning_root_absent')); + }); + + test('distinguishesAbsentRequirementsFileFromEmpty', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + declarePhase(tmpDir, '1', 'Foo'); + // REQUIREMENTS.md deliberately not written. + + const payload = parseInspect(tmpDir); + assert.deepStrictEqual(payload.requirements, []); + assert.ok(payload.diagnostics.some((d) => d.code === 'requirements_absent')); + }); + + test('treatsAnEmptyRequirementsFileAsARealEmptyAnswerNotAnAbsentOne', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + declarePhase(tmpDir, '1', 'Foo'); + writeRequirements(tmpDir, ''); + + const payload = parseInspect(tmpDir); + assert.deepStrictEqual(payload.requirements, []); + assert.ok(!payload.diagnostics.some((d) => d.code === 'requirements_absent')); + }); + + test('flagsCheckboxOnlyRequirementWithNoTraceabilityRowAsUnmapped', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + declarePhase(tmpDir, '1', 'Foo'); + writeRequirements(tmpDir, [ + '# Requirements', + '', + '## v1 Requirements', + '', + '- [ ] **REQ-01**: Something to build', + '', + ].join('\n')); + + const payload = parseInspect(tmpDir); + const row = payload.requirements.find((r) => r.id === 'REQ-01'); + assert.ok(row, 'REQ-01 row must be present'); + assert.deepStrictEqual(row.mappedPhases, []); + assert.ok(payload.diagnostics.some((d) => d.code === 'requirement_unmapped' && d.subject === 'REQ-01')); + }); + + test('carriesTheRequirementUnmappedCodeOnTheRowItselfForCorrelation', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + declarePhase(tmpDir, '1', 'Foo'); + writeRequirements(tmpDir, [ + '# Requirements', + '', + '## v1 Requirements', + '', + '- [ ] **REQ-04**: Something to build', + '', + ].join('\n')); + + const payload = parseInspect(tmpDir); + const row = payload.requirements.find((r) => r.id === 'REQ-04'); + assert.ok(row, 'REQ-04 row must be present'); + // Per-row diagnostics is a correlation convenience over the SAME global + // diagnostics array — never a second, independent answer. + assert.ok(row.diagnostics.includes('requirement_unmapped')); + assert.ok(payload.diagnostics.some((d) => d.code === 'requirement_unmapped' && d.subject === 'REQ-04')); + }); + + test('flagsRequirementMappedToAPhaseNotPresentOnDisk', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + declarePhase(tmpDir, '1', 'Foo'); + writeRequirements(tmpDir, [ + '# Requirements', + '', + '## v1 Requirements', + '', + '- [ ] **REQ-02**: Something', + '', + '## Traceability', + '', + '| Requirement | Phase | Status |', + '|-------------|-------|--------|', + '| REQ-02 | Phase 9 | Pending |', + '', + ].join('\n')); + + const payload = parseInspect(tmpDir); + const row = payload.requirements.find((r) => r.id === 'REQ-02'); + assert.ok(row, 'REQ-02 row must be present'); + assert.deepStrictEqual(row.mappedPhases, ['9']); + assert.ok(payload.diagnostics.some((d) => d.code === 'requirement_phase_unknown' && d.subject === 'REQ-02->9')); + }); + + test('flagsDuplicateRequirementIdNamingTheDuplicatedId', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + declarePhase(tmpDir, '1', 'Foo'); + writeRequirements(tmpDir, [ + '# Requirements', + '', + '## v1 Requirements', + '', + '- [ ] **REQ-03**: First occurrence', + '- [ ] **REQ-03**: Second occurrence (duplicate)', + '', + ].join('\n')); + + const payload = parseInspect(tmpDir); + assert.ok(payload.diagnostics.some((d) => d.code === 'requirement_duplicate' && d.subject === 'REQ-03')); + // Row 30: the SECOND occurrence, specifically — the first wins (its text + // survives), there is exactly one row (never two), and the row itself + // carries the correlation diagnostic naming the collision. + const matching = payload.requirements.filter((r) => r.id === 'REQ-03'); + assert.strictEqual(matching.length, 1, 'a duplicate ID must produce exactly one row, not two'); + assert.strictEqual(matching[0].text, 'First occurrence'); + assert.ok(matching[0].diagnostics.includes('requirement_duplicate')); + }); + + test('reportsUndeclaredPhaseDirAsOrphanNotAsAPhase', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + declarePhase(tmpDir, '1', 'Foo'); + fs.mkdirSync(phaseDirOf(tmpDir, '99-stray'), { recursive: true }); + + const payload = parseInspect(tmpDir); + assert.deepStrictEqual(payload.orphan_phase_dirs, ['99-stray']); + assert.ok(!payload.phases.some((p) => p.dir === '99-stray')); + assert.ok(payload.diagnostics.some((d) => d.code === 'orphan_phase_dir' && d.subject === '99-stray')); + }); + + test('withholdsPercentWhenRoadmapAbsent', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + // Row 20: ROADMAP.md deliberately not written (STATE.md alone is not + // enough to scope a milestone). getMilestoneInfo's own absent-file path + // reports SCOPE.UNREADABLE, not COMPLETE. + writeState(tmpDir, ["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0']); + + const payload = parseInspect(tmpDir); + assert.notStrictEqual(payload.milestone.scope, 'complete'); + assert.strictEqual(payload.progress.accepted_phases.percent, null); + }); + + test('doesNotInventAMilestoneVersionForFreeFormRoadmap', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + writeState(tmpDir, ["gsd_state_version: '1.0'", 'status: planning']); + writeRoadmap(tmpDir, [ + '# Project Roadmap', + '', + 'Some free-form notes with no version token anywhere in this document.', + '', + '### Phase 1: Foo', + '', + ]); + fs.mkdirSync(phaseDirOf(tmpDir, slugPhaseDirName('1', 'Foo')), { recursive: true }); + + const payload = parseInspect(tmpDir); + assert.strictEqual(payload.milestone.scope, 'unscoped'); + assert.strictEqual(payload.milestone.version, null); + // The absence of any version evidence must never be filled with a + // plausible-looking default such as "v1.0". + assert.notStrictEqual(payload.milestone.version, 'v1.0'); + }); + + test('reportsTruncatedIdentityForProseOnlyVersionToken', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + writeState(tmpDir, ["gsd_state_version: '1.0'", 'status: planning']); + writeRoadmap(tmpDir, [ + '# Project Roadmap', + '', + 'We are targeting v1.4 sometime this quarter.', + '', + '### Phase 1: Foo', + '', + ]); + fs.mkdirSync(phaseDirOf(tmpDir, slugPhaseDirName('1', 'Foo')), { recursive: true }); + + const payload = parseInspect(tmpDir); + assert.strictEqual(payload.milestone.scope, 'truncated'); + assert.strictEqual(payload.milestone.version, 'v1.4'); + assert.strictEqual(payload.milestone.name, null); + }); + + test('derivesNameFromTheHeadingsOwnVersionToken', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + // Row 23: STATE.md declares "v2.0"; ROADMAP's heading is "v2.0.1 — + // Portability" — the milestone name must derive from the heading's OWN + // version token ("Portability"), never the raw remainder text after a + // naive prefix strip (".1 — Portability"). + writeState(tmpDir, ["gsd_state_version: '1.0'", 'status: planning', 'milestone: v2.0']); + writeRoadmap(tmpDir, [ + '## v2.0.1 — Portability', + '', + '### Phase 1: Foo', + '', + ]); + fs.mkdirSync(phaseDirOf(tmpDir, slugPhaseDirName('1', 'Foo')), { recursive: true }); + + const payload = parseInspect(tmpDir); + assert.strictEqual(payload.milestone.name, 'Portability'); + assert.ok(!payload.milestone.name.includes('.1')); + }); + + test('treatsWhitespaceOnlyRequirementsAsEmpty', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + declarePhase(tmpDir, '1', 'Foo'); + writeRequirements(tmpDir, ' \n\n\t\n'); + + const payload = parseInspect(tmpDir); + assert.deepStrictEqual(payload.requirements, []); + assert.ok(!payload.diagnostics.some((d) => d.code === 'requirements_absent')); + }); + + test('emitsUnknownForRequirementWithNoCheckbox', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + declarePhase(tmpDir, '1', 'Foo'); + // Row 28: a Traceability-table-only requirement — present as a table row + // (`parseRequirements`'s pipe-table path), with NO checkbox bullet + // anywhere. `text` is a real non-answer (null, not ''); `complete` is + // `unknown`, never inferred `false`. + writeRequirements(tmpDir, [ + '# Requirements', + '', + '## Traceability', + '', + '| Requirement | Phase | Status |', + '|-------------|-------|--------|', + '| REQ-05 | Phase 1 | Pending |', + '', + ].join('\n')); + + const payload = parseInspect(tmpDir); + const row = payload.requirements.find((r) => r.id === 'REQ-05'); + assert.ok(row, 'REQ-05 row must be present from the Traceability row alone'); + assert.strictEqual(row.text, null); + assert.strictEqual(row.complete, 'unknown'); + assert.ok(payload.diagnostics.some((d) => d.code === 'requirement_completion_unknown' && d.subject === 'REQ-05')); + }); + + test('acceptsPrefixAgnosticRequirementIds', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + declarePhase(tmpDir, '1', 'Foo'); + // Row 31: the ID format is prefix-agnostic — AUTH-01 and INSP-04 are + // accepted exactly like REQ-01, sharing the SAME `[A-Z][A-Z0-9]*-...` + // pattern `parseRequirements` (gap-checker.cts) uses. + writeRequirements(tmpDir, [ + '# Requirements', + '', + '## v1 Requirements', + '', + '- [x] **AUTH-01**: User can sign up', + '- [ ] **INSP-04**: Inspection works', + '', + '## Traceability', + '', + '| Requirement | Phase | Status |', + '|-------------|-------|--------|', + '| AUTH-01 | Phase 1 | Complete |', + '| INSP-04 | Phase 1 | Pending |', + '', + ].join('\n')); + + const payload = parseInspect(tmpDir); + const ids = payload.requirements.map((r) => r.id).sort(); + assert.deepStrictEqual(ids, ['AUTH-01', 'INSP-04']); + const auth = payload.requirements.find((r) => r.id === 'AUTH-01'); + assert.strictEqual(auth.complete, true); + const insp = payload.requirements.find((r) => r.id === 'INSP-04'); + assert.strictEqual(insp.complete, false); + }); + + test('doesNotTreatTableSeparatorAsARequirementRow', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + declarePhase(tmpDir, '1', 'Foo'); + writeRequirements(tmpDir, [ + '# Requirements', + '', + '## Traceability', + '', + '| Requirement | Phase | Status |', + '|-------------|-------|--------|', + '| REQ-06 | Phase 1 | Pending |', + '', + ].join('\n')); + + const payload = parseInspect(tmpDir); + // The header row and the `|---|---|` separator row must never themselves + // be parsed as requirement rows — exactly one requirement, REQ-06. + assert.strictEqual(payload.requirements.length, 1); + assert.strictEqual(payload.requirements[0].id, 'REQ-06'); + }); +}); + +// ─── 4b. Phase completion and plan liveness (§7.4/§7.5) ─────────────────────── + +describe('planning inspect — phase completion and plan liveness', () => { + test('treatsZeroPlanPhaseWithPassingVerificationAsComplete', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const phaseDir = declarePhase(tmpDir, '1', 'Foo'); + // Row 34: no plans at all, but a passing VERIFICATION.md — §7.4 gates + // completion on verification alone; plan count is not a precondition. + writeVerification(phaseDir, '01', 'passed'); + + const payload = parseInspect(tmpDir); + assert.strictEqual(payload.phases[0].complete, true); + assert.strictEqual(payload.phases[0].plan_count, 0); + }); + + test('treatsAbsentVerificationAsNotComplete', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const phaseDir = declarePhase(tmpDir, '1', 'Foo'); + // Row 35: plans exist and are summarized, but no *-VERIFICATION.md was + // ever written — completion still requires the verification record. + writePlanDoc(phaseDir, '1-01-PLAN.md', ['wave: 1'], [ + '', 'Ship it', '', '', + '', + ]); + writeSummaryDoc(phaseDir, '1-01-SUMMARY.md', [], [ + '# Summary', '', '## Files Created/Modified', + ]); + + const payload = parseInspect(tmpDir); + assert.strictEqual(payload.phases[0].complete, false); + assert.strictEqual(payload.phases[0].verification.status, 'missing'); + }); + + test('excludesSupersededPlanFromLiveCounts', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const phaseDir = declarePhase(tmpDir, '1', 'Foo'); + writePlanDoc(phaseDir, '1-01-PLAN.md', ['wave: 1'], [ + '', 'live one', '', '', + ]); + writeSummaryDoc(phaseDir, '1-01-SUMMARY.md', [], [ + '# Summary', '', '## Files Created/Modified', '- `src/a.ts` - a', + ]); + // Row 37: a plan whose frontmatter declares `status: superseded` — still + // LISTED in `plans`, but excluded from the live plan_count/summary_count. + writePlanDoc(phaseDir, '1-02-PLAN.md', ['status: superseded'], [ + '', 'old one', '', '', + ]); + + const payload = parseInspect(tmpDir); + const phase = payload.phases[0]; + assert.strictEqual(phase.plan_count, 1, 'superseded plan must not inflate the live plan count'); + const supersededRow = phase.plans.find((p) => p.id === '1-02'); + assert.ok(supersededRow, 'the superseded plan is still listed'); + assert.strictEqual(supersededRow.superseded, true); + const liveRow = phase.plans.find((p) => p.id === '1-01'); + assert.strictEqual(liveRow.superseded, false); + }); + + test('countsProseRetiredPlanAsLive', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const phaseDir = declarePhase(tmpDir, '1', 'Foo'); + // Row 38 (§7.5 GAP, deliberately characterized): a plan whose PROSE says + // "RETIRED" but carries no `status:` frontmatter key is still counted + // LIVE — the parser never compensates for a prose-only retirement claim. + writePlanDoc(phaseDir, '1-01-PLAN.md', [], [ + '', + 'RETIRED — this plan was abandoned, see prose below.', + '', + '', + ]); + + const payload = parseInspect(tmpDir); + const phase = payload.phases[0]; + assert.strictEqual(phase.plan_count, 1, 'a prose-only retirement claim must not remove the plan from the live count'); + assert.strictEqual(phase.plans[0].superseded, false); + }); + + test('blockedSummaryIsNotACompletionRecord', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const phaseDir = declarePhase(tmpDir, '1', 'Foo'); + writePlanDoc(phaseDir, '1-01-PLAN.md', ['wave: 1'], [ + '', 'Ship it', '', '', + '', + 'Task 1src/a.tsadone', + '', + ]); + // Row 39: SUMMARY declares `status: blocked` — a failure record, not a + // completion record. The plan/SUMMARY filename pairing still resolves + // (hasSummary true, provenance still parsed), but the phase-level + // summary_count must NOT count it. + writeSummaryDoc(phaseDir, '1-01-SUMMARY.md', ['status: blocked'], [ + '# Summary', '', '## Files Created/Modified', '- `src/a.ts` - a', + ]); + + const payload = parseInspect(tmpDir); + const phase = payload.phases[0]; + assert.strictEqual(phase.summary_count, 0, 'a blocked SUMMARY must not count as a completion record'); + assert.strictEqual(phase.plans[0].hasSummary, true, 'filename pairing itself is unaffected by status'); + }); + + test('haltedSummaryIsStillACompletionRecord', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const phaseDir = declarePhase(tmpDir, '1', 'Foo'); + writePlanDoc(phaseDir, '1-01-PLAN.md', ['wave: 1'], [ + '', 'Ship it', '', '', + '', + 'Task 1src/a.tsadone', + '', + ]); + // Row 40: `status: halted` (#2830) — a DESIGNED stop still writes a + // completion record, unlike `status: blocked` above. + writeSummaryDoc(phaseDir, '1-01-SUMMARY.md', ['status: halted'], [ + '# Summary', '', '## Files Created/Modified', '- `src/a.ts` - a', + ]); + + const payload = parseInspect(tmpDir); + const phase = payload.phases[0]; + assert.strictEqual(phase.plan_count, 1); + assert.strictEqual(phase.summary_count, 1, 'a halted SUMMARY still counts as a completion record'); + }); +}); + +// ─── 5. Never-infer boundary ────────────────────────────────────────────────── + +describe('planning inspect — never infers task-level file provenance', () => { + test('reportsAbsentProvenanceWhenATaskHasFilesButNoSummaryExists', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const phaseDir = declarePhase(tmpDir, '1', 'Foo'); + writePlanDoc(phaseDir, '1-01-PLAN.md', ['wave: 1'], [ + '', 'Ship it', '', '', + '', '', + '', + ' Task 1: Do it', + ' src/a.ts', + ' Do it', + ' Done', + '', + '', + '', + ]); + // No SUMMARY.md written. + + const payload = parseInspect(tmpDir); + const task = payload.phases[0].plans[0].tasks[0]; + assert.strictEqual(task.changedFiles, null); + assert.strictEqual(task.provenance, 'absent'); + }); + + test('neverAttributesPlanScopedSummaryFilesToAnIndividualTask', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const phaseDir = declarePhase(tmpDir, '1', 'Foo'); + writePlanDoc(phaseDir, '1-01-PLAN.md', ['wave: 1'], [ + '', 'Ship it', '', '', + '', '', + '', + ' Task 1: Build', + ' src/a.ts', + ' Build it', + ' Done', + '', + '', + '', + ]); + writeSummaryDoc(phaseDir, '1-01-SUMMARY.md', ['status: complete'], [ + '# Summary', + '', + '## Files Created/Modified', + '- `src/a.ts` - built', + ]); + + const payload = parseInspect(tmpDir); + const plan = payload.phases[0].plans[0]; + const task = plan.tasks[0]; + // Task-level half of the contract. + assert.strictEqual(task.changedFiles, null); + assert.strictEqual(task.provenance, 'plan_scoped'); + assert.ok(payload.diagnostics.some((d) => d.code === 'task_changed_files_plan_scoped')); + // Plan-level half of the contract — the plan-scoped list still surfaces, + // just never spread across tasks. + assert.deepStrictEqual(plan.changedFiles, ['src/a.ts']); + }); + + test('attributesOnlyTheTaskTheSummaryDeviationBlockNames', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const phaseDir = declarePhase(tmpDir, '1', 'Foo'); + writePlanDoc(phaseDir, '1-01-PLAN.md', ['wave: 1'], [ + '', 'Ship it', '', '', + '', '', + '', + ' Task 1: Build', + ' src/a.ts', + ' Build it', + ' Done', + '', + '', + '', + ' Task 2: Fix', + ' src/b.ts, src/c.ts', + ' Fix it', + ' Done', + '', + '', + '', + ]); + writeSummaryDoc(phaseDir, '1-01-SUMMARY.md', ['status: complete'], [ + '# Summary', + '', + '## Files Created/Modified', + '- `src/a.ts` - built', + '', + '## Deviations from Plan', + '', + '### Auto-fixed Issues', + '', + '**1. Some fix**', + '- **Found during:** Task 2 (Fix)', + '- **Issue:** something', + '- **Files modified:** src/b.ts, src/c.ts', + '- **Verification:** tests pass', + ]); + + const payload = parseInspect(tmpDir); + const tasks = payload.phases[0].plans[0].tasks; + assert.deepStrictEqual(tasks[1].changedFiles, ['src/b.ts', 'src/c.ts']); + assert.strictEqual(tasks[1].provenance, 'task_scoped'); + // Task 1 is untouched by the deviation block naming Task 2. + assert.strictEqual(tasks[0].changedFiles, null); + assert.strictEqual(tasks[0].provenance, 'plan_scoped'); + }); + + test('emitsConflictingProvenanceWithoutReconcilingPlannedAndChangedFiles', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const phaseDir = declarePhase(tmpDir, '1', 'Foo'); + writePlanDoc(phaseDir, '1-01-PLAN.md', ['wave: 1'], [ + '', 'Ship it', '', '', + '', '', + '', + ' Task 1: Build', + ' src/a.ts', + ' Build it', + ' Done', + '', + '', + '', + ]); + writeSummaryDoc(phaseDir, '1-01-SUMMARY.md', ['status: complete'], [ + '# Summary', + '', + '## Deviations from Plan', + '', + '### Auto-fixed Issues', + '', + '**1. Scope change**', + '- **Found during:** Task 1 (Build)', + '- **Issue:** plan undershot', + '- **Files modified:** src/b.ts', + '- **Verification:** tests pass', + ]); + + const payload = parseInspect(tmpDir); + const task = payload.phases[0].plans[0].tasks[0]; + assert.strictEqual(task.agreement, 'conflicting'); + assert.deepStrictEqual(task.plannedFiles, ['src/a.ts']); + assert.deepStrictEqual(task.changedFiles, ['src/b.ts']); + assert.ok(payload.diagnostics.some((d) => d.code === 'task_changed_files_conflicting')); + }); +}); + +// ─── 6. Evidence kept separate ───────────────────────────────────────────────── + +describe('planning inspect — evidence kept separate, never folded', () => { + test('uatAbsenceDoesNotAffectAcceptedPhases', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + // Row 50: no UAT.md at all — `uat: []` plus a `uat_absent` diagnostic, + // and — the load-bearing half of the row — phase acceptance is entirely + // unaffected by UAT's absence (UAT never gates `accepted_phases`). + const phaseDir = declarePhase(tmpDir, '1', 'Foo'); + writeVerification(phaseDir, '01', 'passed'); + + const payload = parseInspect(tmpDir); + const phase = payload.phases[0]; + assert.deepStrictEqual(phase.uat.unresolved, []); + assert.ok(payload.diagnostics.some((d) => d.code === 'uat_absent')); + assert.strictEqual(phase.complete, true); + assert.strictEqual(payload.progress.accepted_phases.percent, 100); + }); + + test('keepsUnresolvedUatAndPassingVerificationSeparateWithNoCombinedVerdict', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const phaseDir = declarePhase(tmpDir, '1', 'Foo'); + writeVerification(phaseDir, '1', 'passed'); + writeUatDoc(phaseDir, '1', [ + '### 1. Check something', + 'expected: it works', + 'result: pending', + '', + ]); + + const payload = parseInspect(tmpDir); + const phase = payload.phases[0]; + assert.strictEqual(phase.complete, true); + assert.ok(phase.uat.unresolved.length > 0); + // No combined verdict field exists alongside the raw evidence sources. + assert.deepStrictEqual(sortedKeys(phase), EXPECTED_PHASE_ROW_KEYS); + }); + + test('roadmapAcceptanceIsNeverAuthoritativeOnAnyPhaseRow', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + buildHealthyFixture(tmpDir); + + const payload = parseInspect(tmpDir); + assert.ok(payload.phases.length > 0); + for (const phase of payload.phases) { + assert.strictEqual(phase.roadmap_acceptance.authoritative, false); + } + }); + + test('roadmapCheckboxNeverOverridesDiskCompletion', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + // Checkbox ticked in ROADMAP, but no passing VERIFICATION on disk. + declarePhase(tmpDir, '1', 'Foo', { checkedInPhaseList: true }); + + const payload = parseInspect(tmpDir); + const phase = payload.phases[0]; + assert.strictEqual(phase.complete, false); + assert.strictEqual(phase.roadmap_acceptance.checkbox, true); + }); + + test('reports a ticked ROADMAP checkbox for a slugged phase directory', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + // ROADMAP prose carries the BARE numeric token ("Phase 1"/"Phase 2"/ + // "Phase 3"), while the phase directories are the SLUGGED on-disk + // convention ("01-auth" etc) — the real-world mismatch that made + // `roadmap_acceptance.checkbox` always null: comparing + // `checkboxes["1"]` against `phase.dir === "01-auth"` by raw string + // equality never matches. Three phases distinguish all three checkbox + // states so none of them collapses into another: ticked (true), unticked + // (false), and no checkbox bullet at all (null, NOT false). + writeState(tmpDir, ["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0']); + writeRoadmap(tmpDir, [ + '## v1.0 Current 🚧', + '', + '## Phases', + '', + '- [x] **Phase 1: Auth** - stub', + '- [ ] **Phase 2: Billing** - stub', + '', + '### Phase 1: Auth', + '', + '### Phase 2: Billing', + '', + '### Phase 3: Reports', + '', + ]); + fs.mkdirSync(phaseDirOf(tmpDir, slugPhaseDirName('1', 'Auth')), { recursive: true }); + fs.mkdirSync(phaseDirOf(tmpDir, slugPhaseDirName('2', 'Billing')), { recursive: true }); + fs.mkdirSync(phaseDirOf(tmpDir, slugPhaseDirName('3', 'Reports')), { recursive: true }); + + const payload = parseInspect(tmpDir); + const auth = payload.phases.find((p) => p.dir === '01-auth'); + const billing = payload.phases.find((p) => p.dir === '02-billing'); + const reports = payload.phases.find((p) => p.dir === '03-reports'); + assert.ok(auth, 'slugged phase directory 01-auth must be present as a phase row'); + assert.ok(billing, 'slugged phase directory 02-billing must be present as a phase row'); + assert.ok(reports, 'slugged phase directory 03-reports must be present as a phase row'); + + assert.strictEqual(auth.roadmap_acceptance.checkbox, true); + assert.strictEqual(auth.roadmap_acceptance.authoritative, false); + assert.strictEqual(billing.roadmap_acceptance.checkbox, false); + assert.strictEqual(billing.roadmap_acceptance.authoritative, false); + // Phase 3 has no checkbox bullet under `## Phases` at all — `null` (no + // evidence), never collapsed into `false` (unticked evidence). + assert.strictEqual(reports.roadmap_acceptance.checkbox, null); + assert.strictEqual(reports.roadmap_acceptance.authoritative, false); + }); +}); + +// ─── 6b. Per-phase goal / dependency evidence (#2790) ───────────────────────── + +describe('planning inspect — per-phase goal and dependency evidence', () => { + test('reportsThePhaseHeadingProseAsGoalWithCompleteScope', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + writeState(tmpDir, ["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0']); + writeRoadmap(tmpDir, [ + '## v1.0 Current 🚧', + '', + '## Phases', + '', + '- [ ] **Phase 1: Auth** - stub', + '', + '### Phase 1: Auth', + '', + 'Build authentication.', + '', + ]); + fs.mkdirSync(phaseDirOf(tmpDir, slugPhaseDirName('1', 'Auth')), { recursive: true }); + + const payload = parseInspect(tmpDir); + const phase = payload.phases[0]; + assert.strictEqual(phase.goal.value, 'Build authentication.'); + assert.strictEqual(phase.goal.scope, 'complete'); + }); + + test('reportsDependsOnPhaseTokensAsAStringArray', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + writeState(tmpDir, ["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0']); + writeRoadmap(tmpDir, [ + '## v1.0 Current 🚧', + '', + '## Phases', + '', + '- [ ] **Phase 1: Auth** - stub', + '- [ ] **Phase 2: Billing** - stub', + '', + '### Phase 1: Auth', + '', + '### Phase 2: Billing', + '', + 'Charge the customer.', + '', + '**Depends on:** Phase 1', + '', + ]); + fs.mkdirSync(phaseDirOf(tmpDir, slugPhaseDirName('1', 'Auth')), { recursive: true }); + fs.mkdirSync(phaseDirOf(tmpDir, slugPhaseDirName('2', 'Billing')), { recursive: true }); + + const payload = parseInspect(tmpDir); + const billing = payload.phases.find((p) => p.dir === '02-billing'); + assert.ok(billing, '02-billing phase row must be present'); + assert.ok(billing.dependencies.value.includes('1')); + assert.strictEqual(billing.dependencies.scope, 'complete'); + }); + + test('reportsNoDependsOnLineAsAnEmptyArrayNotADegradedScope', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + declarePhase(tmpDir, '1', 'Foo'); + + const payload = parseInspect(tmpDir); + const phase = payload.phases[0]; + // Absent is a real answer, not a failure — same scope as a found section + // that simply carries no dependency line. + assert.deepStrictEqual(phase.dependencies.value, []); + assert.strictEqual(phase.dependencies.scope, 'complete'); + }); + + test('excludesTheDependsOnAnnotationFromGoalEvenThoughItIsSurfacedSeparately', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + writeState(tmpDir, ["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0']); + writeRoadmap(tmpDir, [ + '## v1.0 Current 🚧', + '', + '## Phases', + '', + '- [ ] **Phase 1: Auth** - stub', + '', + '### Phase 1: Auth', + '', + 'Build authentication.', + '', + '**Depends on:** Phase 1', + '', + ]); + fs.mkdirSync(phaseDirOf(tmpDir, slugPhaseDirName('1', 'Auth')), { recursive: true }); + + const payload = parseInspect(tmpDir); + const phase = payload.phases[0]; + // The annotation is data this payload already surfaces via + // `dependencies` — asserted together so it appears in exactly one place. + assert.strictEqual(phase.goal.value, 'Build authentication.'); + assert.ok(!phase.goal.value.includes('Depends on')); + assert.ok(phase.dependencies.value.includes('1')); + }); + + test('excludesThePlansChecklistFromGoalProse', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + writeState(tmpDir, ["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0']); + writeRoadmap(tmpDir, [ + '## v1.0 Current 🚧', + '', + '## Phases', + '', + '- [ ] **Phase 1: Auth** - stub', + '', + '### Phase 1: Auth', + '', + 'Build authentication.', + '', + 'Plans:', + '- [ ] 01-PLAN.md', + '', + ]); + fs.mkdirSync(phaseDirOf(tmpDir, slugPhaseDirName('1', 'Auth')), { recursive: true }); + + const payload = parseInspect(tmpDir); + const phase = payload.phases[0]; + assert.strictEqual(phase.goal.value, 'Build authentication.'); + assert.ok(!phase.goal.value.includes('Plans:')); + assert.ok(!phase.goal.value.includes('- [ ]')); + }); + + test('reportsNullGoalWithCompleteScopeWhenTheSectionIsPureMetadata', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + writeState(tmpDir, ["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0']); + writeRoadmap(tmpDir, [ + '## v1.0 Current 🚧', + '', + '## Phases', + '', + '- [ ] **Phase 1: Auth** - stub', + '', + '### Phase 1: Auth', + '', + '**Depends on:** Phase 1', + '', + ]); + fs.mkdirSync(phaseDirOf(tmpDir, slugPhaseDirName('1', 'Auth')), { recursive: true }); + + const payload = parseInspect(tmpDir); + const phase = payload.phases[0]; + // A section that is pure metadata genuinely has no goal prose — a real + // answer, not a failed read: `complete` still means "the section was + // found", never "prose was found". + assert.strictEqual(phase.goal.value, null); + assert.strictEqual(phase.goal.scope, 'complete'); + }); +}); + +// ─── 7. Percent withholding ─────────────────────────────────────────────────── + +describe('planning inspect — percent withholding', () => { + test('percentIsAnIntegerZeroToHundredForAHealthyProject', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + buildHealthyFixture(tmpDir); + + const payload = parseInspect(tmpDir); + const percent = payload.progress.accepted_phases.percent; + assert.ok(Number.isInteger(percent) && percent >= 0 && percent <= 100, `got ${percent}`); + }); + + test('emitsZeroPercentNotNullNotHundredForAZeroPhaseButReadableProject', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + writeState(tmpDir, ["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0']); + writeRoadmap(tmpDir, ['## v1.0 Current 🚧', '']); + // No phase directories at all. + + const payload = parseInspect(tmpDir); + assert.strictEqual(payload.progress.accepted_phases.percent, 0); + }); + + test('withholdsPercentAndFlagsItWhenThePhasesDirectoryIsUnreadable', (t) => { + // Fault-injection level (CONTRIBUTING QA matrix "Integration + mock.method"): + // mock.method cannot reach a spawned CLI subprocess, so this row calls the + // built module directly — the same pattern tests/planning-snapshot.test.cjs + // uses for its own readdirSync fault-injection rows. + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + buildHealthyFixture(tmpDir); + + const planningInspectLib = require('../gsd-core/bin/lib/planning-inspect.cjs'); + const phasesDir = phasesDirOf(tmpDir); + const originalReaddirSync = fs.readdirSync; + + t.mock.method(fs, 'readdirSync', function mockedReaddirSync(target, ...rest) { + if (target === phasesDir) { + const err = new Error(`EACCES: permission denied, scandir '${phasesDir}'`); + err.code = 'EACCES'; + throw err; + } + return originalReaddirSync.call(this, target, ...rest); + }); + + const payload = planningInspectLib.buildPlanningInspect(tmpDir); + assert.strictEqual(payload.progress.accepted_phases.percent, null); + assert.ok(payload.diagnostics.some((d) => d.code === 'percent_withheld')); + }); +}); + +// ─── 7b. Per-document/per-phase fault isolation (D33/D34) and combinations ─── + +describe('planning inspect — fault isolation and combinations', () => { + test('reportsTruncatedScopeForUnreadableNestedPlansDir', (t) => { + // Row 53: the phase directory itself is readable, but its NESTED + // `plans/` subdirectory is not — scanPhasePlans (plan-scan.cts) reports + // this as TRUNCATED, never COMPLETE-with-zero, and that folds into the + // phase row's own `scope`. + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const phaseDir = declarePhase(tmpDir, '1', 'Foo'); + const nestedDir = path.join(phaseDir, 'plans'); + fs.mkdirSync(nestedDir, { recursive: true }); + writeAbs(path.join(nestedDir, 'PLAN-01-x.md'), ['', 'a', '', ''].join('\n')); + + const planningInspectLib = require('../gsd-core/bin/lib/planning-inspect.cjs'); + const originalReaddirSync = fs.readdirSync; + t.mock.method(fs, 'readdirSync', function mockedReaddirSync(target, ...rest) { + if (target === nestedDir) { + const err = new Error(`EACCES: permission denied, scandir '${nestedDir}'`); + err.code = 'EACCES'; + throw err; + } + return originalReaddirSync.call(this, target, ...rest); + }); + + const payload = planningInspectLib.buildPlanningInspect(tmpDir); + assert.strictEqual(payload.phases[0].scope, 'truncated'); + assert.ok(payload.diagnostics.some((d) => d.code === 'phase_scope_degraded')); + }); + + test('isolatesAnUnreadablePlanFromItsSiblings', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const phaseDir = declarePhase(tmpDir, '1', 'Foo'); + writePlanDoc(phaseDir, '1-01-PLAN.md', [], ['', 'good one', '', '']); + // Row 54: a SECOND plan that cannot be read (directory-in-file-position — + // no chmod, root-proof). Its own row must degrade; the sibling plan's + // row must be entirely unaffected. + fs.mkdirSync(path.join(phaseDir, '1-02-PLAN.md'), { recursive: true }); + + const payload = parseInspect(tmpDir); + const rows = payload.phases[0].plans; + const good = rows.find((p) => p.id === '1-01'); + const bad = rows.find((p) => p.id === '1-02'); + assert.strictEqual(good.scope, 'complete'); + assert.strictEqual(good.objective, 'good one'); + assert.strictEqual(bad.scope, 'unreadable'); + assert.strictEqual(bad.objective, null); + assert.ok(payload.diagnostics.some((d) => d.code === 'plan_unreadable' && d.subject === '1-02-PLAN.md')); + }); + + test('treatsDirectoryInPlanPositionAsUnreadable', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const phaseDir = declarePhase(tmpDir, '1', 'Foo'); + writePlanDoc(phaseDir, '1-01-PLAN.md', [], ['', 'good one', '', '']); + // Row 55: the SAME technique as row 54, named explicitly for the + // "PLAN.md is a directory" input class — a directory sitting exactly + // where a plan file is expected. `readDocument`'s `statSync().isFile()` + // guard rejects it before any `readFileSync` on THIS path, so it degrades + // to `unreadable` rather than crashing on EISDIR. + fs.mkdirSync(path.join(phaseDir, '2-01-PLAN.md'), { recursive: true }); + + const result = runInspect(tmpDir); + assert.strictEqual(result.success, true, `expected exit 0 even with a directory in plan position: ${result.error}`); + const payload = JSON.parse(result.output); + const bad = payload.phases[0].plans.find((p) => p.id === '2-01'); + assert.ok(bad, 'the directory-shaped plan entry must still be listed as a row'); + assert.strictEqual(bad.scope, 'unreadable'); + assert.ok(payload.diagnostics.some((d) => d.code === 'plan_unreadable' && d.subject === '2-01-PLAN.md')); + }); + + test('rejectsPlanSymlinkEscapingThePlanningRoot', (t) => { + // Row 56 — SECURITY FIX (#2790 follow-up). Previously CHARACTERIZED as + // leaking: `readDocument` (planning-inspect.cts) opened a document via + // `fs.statSync`/`fs.readFileSync`, both of which FOLLOW symlinks, so a + // `*-PLAN.md` that was actually a symlink pointing outside `.planning/` + // had its target's content surfaced into the schema-v1 payload — an + // exfiltration path, since these documents are UNTRUSTED input (a clone, + // a PR branch, a teammate's working tree) and the payload is handed to + // downstream tooling verbatim. `readDocument` now resolves both the + // document and the planning root via `fs.realpathSync` and refuses to + // read a target that resolves outside the root; the escaping plan must + // degrade exactly like any other unreadable plan, and a sibling readable + // plan in the SAME phase must be entirely unaffected. + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const secretDir = createTempDir('gsd-2790-secret-'); + t.after(() => cleanup(secretDir)); + const secretFile = path.join(secretDir, 'secret.txt'); + const sentinel = 'TOP_SECRET_OBJECTIVE_VALUE_9f3c1a'; + fs.writeFileSync(secretFile, ['', sentinel, '', ''].join('\n')); + + const phaseDir = declarePhase(tmpDir, '1', 'Foo'); + // A sibling, legitimately-readable plan in the SAME phase — proves the + // escaping symlink degrades ALONE and does not drag its sibling down. + writePlanDoc(phaseDir, '1-01-PLAN.md', [], ['', 'good one', '', '']); + try { + fs.symlinkSync(secretFile, path.join(phaseDir, '1-02-PLAN.md')); + } catch (_symlinkErr) { + t.skip('symlink creation unsupported on this platform/privilege'); + return; + } + + const result = runInspect(tmpDir); + assert.strictEqual(result.success, true, `expected exit 0 even with an escaping symlink: ${result.error}`); + // Raw-string ABSENCE proof is the sanctioned exception to the no-raw-text + // rule: the entire point of this assertion is that the sentinel is NEVER + // emitted anywhere in stdout, so only a substring-absence check — not a + // value-level assertion — can express that. + assert.ok(!result.output.includes(sentinel), 'the secret sentinel must never appear anywhere in the payload'); + + const payload = JSON.parse(result.output); + const good = payload.phases[0].plans.find((p) => p.id === '1-01'); + const escaped = payload.phases[0].plans.find((p) => p.id === '1-02'); + assert.strictEqual(good.scope, 'complete'); + assert.strictEqual(good.objective, 'good one'); + assert.strictEqual(escaped.scope, 'unreadable'); + assert.strictEqual(escaped.objective, null); + assert.ok(payload.diagnostics.some((d) => d.code === 'plan_unreadable' && d.subject === '1-02-PLAN.md')); + }); + + test('stillReadsPlansWhenTheWholePlanningRootIsALegitimateSymlinkElsewhere', (t) => { + // Companion to `rejectsPlanSymlinkEscapingThePlanningRoot`: containment + // must not over-reject the case it exists to preserve — "so a legitimately + // symlinked `.planning/` directory ... still works" (per brief). Both + // `filePath` and `root` are resolved with `fs.realpathSync` before the + // boundary check, so a project that relocates its ENTIRE `.planning/` + // directory to elsewhere on disk (a synced folder, a monorepo shared + // location, etc.) and symlinks it back in keeps reading normally — the + // resolved plan target still nests under the resolved root, it just does + // so via the relocated location rather than the literal `cwd/.planning` + // path. Without this test, the fix above could silently regress into + // rejecting every document in such a project. + const bareDir = createTempDir('gsd-2790-bare-'); + t.after(() => cleanup(bareDir)); + const realPlanningDir = createTempDir('gsd-2790-real-planning-'); + t.after(() => cleanup(realPlanningDir)); + try { + fs.symlinkSync(realPlanningDir, path.join(bareDir, '.planning')); + } catch (_symlinkErr) { + t.skip('symlink creation unsupported on this platform/privilege'); + return; + } + + const phaseDir = declarePhase(bareDir, '1', 'Foo'); + writePlanDoc(phaseDir, '1-01-PLAN.md', [], ['', 'relocated plan', '', '']); + + const payload = parseInspect(bareDir); + const plan = payload.phases[0].plans.find((p) => p.id === '1-01'); + assert.ok(plan, 'plan row must exist through the symlinked planning root'); + assert.strictEqual(plan.scope, 'complete'); + assert.strictEqual(plan.objective, 'relocated plan'); + }); + + test('oneDocumentsFaultDoesNotDowngradeAnother', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + // Row 57: REQUIREMENTS.md is unreadable (directory-in-file-position); + // ROADMAP.md is clean. Requirements degrades ALONE — milestone identity + // and phase completion must stay unaffected. + const phaseDir = declarePhase(tmpDir, '1', 'Foo'); + writeVerification(phaseDir, '01', 'passed'); + fs.mkdirSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), { recursive: true }); + + const payload = parseInspect(tmpDir); + assert.strictEqual(payload.milestone.scope, 'complete'); + assert.strictEqual(payload.phases[0].complete, true); + assert.deepStrictEqual(payload.requirements, []); + assert.ok(payload.diagnostics.some((d) => d.code === 'requirements_unreadable')); + }); + + test('foldsWorstScopeAcrossPhasesAndWithholdsPercent', (t) => { + // Row 58: phase A is unreadable (its own directory listing fails), phase + // B is clean and complete. The top-level `accepted_phases.percent` must + // withhold (fold via worstScope) rather than silently computing a + // fraction from only the phases that happened to be readable. + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + writeState(tmpDir, ["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0']); + writeRoadmap(tmpDir, [ + '## v1.0 Current 🚧', '', '### Phase 1: A', '', '### Phase 2: B', '', + ]); + const phaseADir = phaseDirOf(tmpDir, slugPhaseDirName('1', 'A')); + const phaseBDir = phaseDirOf(tmpDir, slugPhaseDirName('2', 'B')); + fs.mkdirSync(phaseADir, { recursive: true }); + writePlanDoc(phaseBDir, '2-01-PLAN.md', [], ['', 'b', '', '']); + writeSummaryDoc(phaseBDir, '2-01-SUMMARY.md', [], ['# s', '', '## Files Created/Modified']); + writeVerification(phaseBDir, '02', 'passed'); + + const planningInspectLib = require('../gsd-core/bin/lib/planning-inspect.cjs'); + const originalReaddirSync = fs.readdirSync; + t.mock.method(fs, 'readdirSync', function mockedReaddirSync(target, ...rest) { + if (target === phaseADir) { + const err = new Error(`EACCES: permission denied, scandir '${phaseADir}'`); + err.code = 'EACCES'; + throw err; + } + return originalReaddirSync.call(this, target, ...rest); + }); + + const payload = planningInspectLib.buildPlanningInspect(tmpDir); + const a = payload.phases.find((p) => p.dir === '01-a'); + const b = payload.phases.find((p) => p.dir === '02-b'); + assert.strictEqual(a.scope, 'unreadable'); + assert.strictEqual(a.complete, false); + assert.strictEqual(b.scope, 'complete'); + assert.strictEqual(b.complete, true); + assert.strictEqual(payload.progress.accepted_phases.percent, null); + }); +}); + +// ─── 7c. The 50 KB @file: spill boundary (io.cjs's own `> 50000` predicate) ── + +const ioLib = require('../gsd-core/bin/lib/io.cjs'); + +/** + * Build a JSON payload whose `serializeForOutput` byte length is EXACTLY + * `targetLen` — calibrated once against the real overhead of `{"padding": + * "..."}` at 2-space indent, then padded with ASCII (`'a'`, no JSON escaping) + * so one added character is exactly one added byte. Asserts the calibration + * landed exactly, so a future change to `serializeForOutput`'s formatting + * fails this helper loudly rather than silently testing the wrong boundary. + */ +function paddedPayloadOfSerializedLength(targetLen) { + const overhead = ioLib.serializeForOutput({ padding: '' }).length; + const padLen = Math.max(targetLen - overhead, 0); + const payload = { padding: 'a'.repeat(padLen) }; + const actualLen = ioLib.serializeForOutput(payload).length; + assert.strictEqual(actualLen, targetLen, `padding calibration failed: expected length ${targetLen}, got ${actualLen}`); + return payload; +} + +describe('planning inspect — the 50 KB @file: spill boundary', () => { + test('emitsInlineJsonJustBelowTheSpillThreshold', () => { + // Row 60 (limit-1): io.cjs's own predicate is `json.length > 50000` — + // 49999 bytes must NOT spill. + const payload = paddedPayloadOfSerializedLength(49999); + assert.strictEqual(ioLib.serializeForOutput(payload).length > 50000, false); + }); + + test('matchesIoSpillThresholdAtTheBoundary', () => { + // Row 61 (limit): exactly 50000 bytes — the predicate is strictly + // GREATER THAN, so the boundary value itself does NOT spill. + const payload = paddedPayloadOfSerializedLength(50000); + assert.strictEqual(ioLib.serializeForOutput(payload).length > 50000, false); + }); + + test('spillsOverThresholdAndResolvesBackToJsonOnStdout', (t) => { + // Row 62 (limit+1), part A: the exact boundary+1 byte value against the + // serializer's own predicate. + const payload = paddedPayloadOfSerializedLength(50001); + assert.strictEqual(ioLib.serializeForOutput(payload).length > 50000, true); + + // Row 62, part B: a genuinely oversized END-TO-END fixture through the + // real CLI — proving `resolveAtFileOutput` (gsd-tools.cjs) transparently + // resolves the `@file:` spill back into full JSON on stdout, so the + // caller never has to know the spill happened. + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + declarePhase(tmpDir, '1', 'Foo'); + const lines = ['# Requirements', '', '## v1 Requirements', '']; + const padding = 'x'.repeat(400); + for (let i = 1; i <= 300; i += 1) { + lines.push(`- [ ] **REQ-${String(i).padStart(3, '0')}**: padded description ${padding}`); + } + writeRequirements(tmpDir, lines.join('\n')); + + const result = runInspect(tmpDir); + assert.strictEqual(result.success, true, `expected exit 0 for an oversized payload: ${result.error}`); + assert.ok(result.output.length > 50000, 'the raw fixture text alone should already exceed the spill threshold'); + const parsedPayload = JSON.parse(result.output); + assert.deepStrictEqual(sortedKeys(parsedPayload), EXPECTED_TOP_LEVEL_KEYS); + assert.strictEqual(parsedPayload.requirements.length, 300); + }); +}); + +// ─── 8. Task grammar ────────────────────────────────────────────────────────── + +describe('planning inspect — task grammar', () => { + test('emitsOneRowPerXmlTaskBlockInDocumentOrder', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const phaseDir = declarePhase(tmpDir, '1', 'Foo'); + writePlanDoc(phaseDir, '1-01-PLAN.md', ['wave: 1'], [ + '', 'Ship it', '', '', + '', '', + 'Task Asrc/a.tsadone', + 'Task Bsrc/b.tsbdone', + '', + ]); + + const payload = parseInspect(tmpDir); + const tasks = payload.phases[0].plans[0].tasks; + assert.strictEqual(tasks.length, 2); + assert.strictEqual(tasks[0].name, 'Task A'); + assert.strictEqual(tasks[1].name, 'Task B'); + assert.deepStrictEqual(tasks.map((task) => task.index), [1, 2]); + }); + + test('fallsBackToMarkdownTaskHeadingsWhenNoXmlTaskBlocksExist', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const phaseDir = declarePhase(tmpDir, '1', 'Foo'); + writePlanDoc(phaseDir, '1-01-PLAN.md', ['wave: 1'], [ + '', 'Ship it', '', '', + '## Task 1: First', + '', + '## Task 2: Second', + '', + ]); + + const payload = parseInspect(tmpDir); + const tasks = payload.phases[0].plans[0].tasks; + assert.strictEqual(tasks.length, 2); + assert.strictEqual(tasks[0].kind, 'auto'); + assert.strictEqual(tasks[0].name, 'Task 1: First'); + assert.strictEqual(tasks[1].name, 'Task 2: Second'); + }); + + test('xmlTaskBlocksWinOverMarkdownHeadingsWhenBothArePresent', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const phaseDir = declarePhase(tmpDir, '1', 'Foo'); + writePlanDoc(phaseDir, '1-01-PLAN.md', ['wave: 1'], [ + '', 'Ship it', '', '', + '', + 'Only XML tasksrc/a.tsadone', + '', + '', + '## Task 1: Legacy heading one', + '## Task 2: Legacy heading two', + '## Task 3: Legacy heading three', + ]); + + const payload = parseInspect(tmpDir); + const tasks = payload.phases[0].plans[0].tasks; + assert.strictEqual(tasks.length, 1); + assert.strictEqual(tasks[0].name, 'Only XML task'); + }); + + test('emitsCheckpointTaskAsItsOwnKindNotAsMalformed', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const phaseDir = declarePhase(tmpDir, '1', 'Foo'); + writePlanDoc(phaseDir, '1-01-PLAN.md', ['wave: 1'], [ + '', 'Ship it', '', '', + '', + '', + ' Pick one', + ' Because reasons', + ' ', + ' ', + ' ', + ' Select: a', + '', + '', + ]); + + const payload = parseInspect(tmpDir); + const task = payload.phases[0].plans[0].tasks[0]; + assert.strictEqual(task.kind, 'checkpoint'); + assert.strictEqual(task.name, null); + assert.ok(payload.diagnostics.some((d) => d.code === 'task_shape_checkpoint')); + }); + + test('characterizesFenceBlindMarkdownTaskFallback', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const phaseDir = declarePhase(tmpDir, '1', 'Foo'); + // Row 44 (deliberately characterized, not endorsed — see + // plan-document.cts's own header comment): a `## Task N` heading inside a + // FENCED code block still counts under the markdown fallback, exactly as + // `cmdPhasePlanIndex` has always counted it. This plan has no `` + // blocks at all, so the fence-blind markdown fallback is what runs. + writePlanDoc(phaseDir, '1-01-PLAN.md', ['wave: 1'], [ + '', 'ship', '', '', + '```markdown', + '## Task 1: fenced, still counted by the legacy fallback', + '```', + '', + ]); + + const payload = parseInspect(tmpDir); + const tasks = payload.phases[0].plans[0].tasks; + assert.strictEqual(tasks.length, 1); + assert.strictEqual(tasks[0].name, 'Task 1: fenced, still counted by the legacy fallback'); + }); +}); + +// ─── 9. Hostile input ───────────────────────────────────────────────────────── + +describe('planning inspect — hostile input', () => { + test('neverInterpolatesShellMetacharactersFromRequirementText', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + declarePhase(tmpDir, '1', 'Foo'); + writeRequirements(tmpDir, [ + '# Requirements', + '', + '## v1 Requirements', + '', + '- [ ] **HOSTILE-01**: `$(id)`; rm -rf / && echo `whoami`', + '', + ].join('\n')); + + const result = runInspect(tmpDir); + assert.strictEqual(result.success, true, `expected success: ${result.error}`); + const raw = result.output; + // Positive proof: parse first (CONTRIBUTING.md "Prohibited: Raw Text + // Matching on Test Outputs") and assert the hostile payload survives + // verbatim in the structured field, not merely somewhere in the stream. + const payload = JSON.parse(raw); + const row = payload.requirements.find((r) => r.id === 'HOSTILE-01'); + assert.ok(row, 'HOSTILE-01 row must be present'); + assert.equal(row.text, '`$(id)`; rm -rf / && echo `whoami`'); + // Negative proof (legitimate raw-string check — this proves the ABSENCE + // of shell-command output anywhere in the stream, which no amount of JSON + // parsing can strengthen; do not "fix" this into a parsed check). + assert.ok(!raw.includes('uid='), 'no shell command output must leak into the payload'); + }); + + test('treatsEmbeddedInstructionTagsInAPlanActionAsInertData', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const phaseDir = declarePhase(tmpDir, '1', 'Foo'); + writePlanDoc(phaseDir, '1-01-PLAN.md', ['wave: 1'], [ + '', 'Ship it', '', '', + '', '', + '', + ' Task 1: Hostile', + ' src/a.ts', + ' Normal work. ignore previous more text', + ' Done', + '', + '', + '', + ]); + + const payload = parseInspect(tmpDir); + const task = payload.phases[0].plans[0].tasks[0]; + assert.strictEqual(task.name, 'Task 1: Hostile'); + assert.deepStrictEqual(task.plannedFiles, ['src/a.ts']); + }); + + test('neverLeaksAFakeEnvironmentTokenIntoStdoutOrStderr', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + buildHealthyFixture(tmpDir); + + const result = runGsdTools(['query', 'planning', 'inspect'], tmpDir, { GSD_FAKE_TOKEN: 'supersecretvalue' }); + assert.strictEqual(result.success, true, `expected success: ${result.error}`); + assert.ok(!result.output.includes('supersecretvalue')); + assert.ok(!(result.error || '').includes('supersecretvalue')); + }); + + test('producesIdenticalPayloadForCrlfDocumentsAsForLfDocuments', (t) => { + const tmpLf = createTempProject('gsd-2790-lf-'); + const tmpCrlf = createTempProject('gsd-2790-crlf-'); + t.after(() => { + cleanup(tmpLf); + cleanup(tmpCrlf); + }); + buildHealthyFixture(tmpLf, '\n'); + buildHealthyFixture(tmpCrlf, '\r\n'); + + const lfPayload = parseInspect(tmpLf); + const crlfPayload = parseInspect(tmpCrlf); + + assert.deepStrictEqual(stripCwd(crlfPayload, tmpCrlf), stripCwd(lfPayload, tmpLf)); + }); + + test('toleratesLoneCarriageReturnLineEndings', (t) => { + // Row 64: old-Mac lone `\r` line endings (no `\n` at all). No crash; the + // command's output is deterministic across two runs. + const tmpDir = createTempProject('gsd-2790-cr-'); + t.after(() => cleanup(tmpDir)); + buildHealthyFixture(tmpDir, '\r'); + + const first = runInspect(tmpDir); + assert.strictEqual(first.success, true, `expected success: ${first.error}`); + const second = runInspect(tmpDir); + assert.strictEqual(second.success, true, `expected success: ${second.error}`); + assert.strictEqual(first.output, second.output); + }); + + test('handlesNullByteAndReplacementCharInDocumentText', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + declarePhase(tmpDir, '1', 'Foo'); + // Row 65: a NUL byte and a U+FFFD replacement character embedded in a + // requirement description. Both are valid UTF-8 payload bytes/codepoints + // — the value must be carried verbatim, never silently truncated at the + // NUL. + const nul = String.fromCharCode(0); + const replacementChar = '�'; + const description = `has a${nul}nul and a ${replacementChar} replacement char inline`; + writeRequirements(tmpDir, [ + '# Requirements', + '', + '## v1 Requirements', + '', + `- [ ] **REQ-10**: ${description}`, + '', + ].join('\n')); + + const result = runInspect(tmpDir); + assert.strictEqual(result.success, true, `expected success: ${result.error}`); + const payload = JSON.parse(result.output); + const row = payload.requirements.find((r) => r.id === 'REQ-10'); + assert.ok(row, 'REQ-10 row must be present'); + assert.ok(row.text.includes(nul), 'the NUL byte must be carried verbatim, not silently dropped'); + assert.ok(row.text.includes(replacementChar), 'U+FFFD must be carried verbatim'); + }); + + test('neverResolvesTraversalShapedPhaseTokenToAPath', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + declarePhase(tmpDir, '1', 'Foo'); + // Row 66: a `../../x`-shaped token in a traceability row's Phase cell. + // The phase-token extractor only pulls DIGIT tokens out of that cell + // (`parseTraceability`'s `\d+(?:\.\d+)*` scan) — a traversal shape simply + // yields no numeric token, so it is never resolved to a filesystem path. + writeRequirements(tmpDir, [ + '# Requirements', + '', + '## v1 Requirements', + '', + '- [ ] **REQ-09**: something', + '', + '## Traceability', + '', + '| Requirement | Phase | Status |', + '|-------------|-------|--------|', + '| REQ-09 | ../../../etc | Pending |', + '', + ].join('\n')); + + const result = runInspect(tmpDir); + assert.strictEqual(result.success, true, `expected success: ${result.error}`); + const raw = result.output; + const payload = JSON.parse(raw); + const row = payload.requirements.find((r) => r.id === 'REQ-09'); + assert.ok(row, 'REQ-09 row must be present'); + assert.deepStrictEqual(row.mappedPhases, []); + // Negative proof: no path-resolution artifact (a resolved root path, or + // the traversal string itself surviving into a filesystem-shaped field) + // leaks anywhere in the stream. + assert.ok(!raw.includes('../../../etc')); + }); + + test('boundsAPathologicallyLongDocumentValue', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + declarePhase(tmpDir, '1', 'Foo'); + // Row 70: a single requirement description ~1 MB long. The command must + // still complete and exit 0 — no unbounded read, no hang. + const longValue = 'x'.repeat(1024 * 1024); + writeRequirements(tmpDir, [ + '# Requirements', + '', + '## v1 Requirements', + '', + `- [ ] **REQ-11**: ${longValue}`, + '', + ].join('\n')); + + // Assert via `--pick schema_version` rather than the full JSON payload. + // gsd-tools handles this correctly end-to-end: output() spills the >50KB + // JSON to a tmpfile and resolves the @file: reference back to stdout, so + // the full 1 MB document IS read and parsed — but resolved stdout is then + // well over runGsdTools's subprocess maxBuffer, which makes the HELPER + // report BUFFER_OVERFLOW/ENOBUFS. That failure is the test harness's + // buffer ceiling, not the product. `--pick` makes gsd-tools extract a + // single small field and print only that, so stdout stays tiny while the + // full 1 MB document is still read and parsed end to end — proving the + // command completes successfully on a pathologically long value without + // measuring runGsdTools's maxBuffer instead of the product. + const result = runInspect(tmpDir, ['--pick', 'schema_version']); + assert.strictEqual(result.success, true, `expected success for a 1MB value: ${result.error}`); + assert.strictEqual(result.output, '1', 'schema_version must round-trip as 1 through --pick'); + }); + + test('preservesUnicodeAndRtlDocumentText', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + // Row 71: Unicode + RTL headings and names must round-trip byte-identical + // into the payload — no width/parse corruption. + const rtlName = 'المصادقة'; // Arabic: "Authentication" + writeState(tmpDir, ["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0']); + writeRoadmap(tmpDir, [ + '## v1.0 Current 🚧', + '', + '## Phases', + '', + `- [ ] **Phase 1: ${rtlName}** - stub`, + '', + `### Phase 1: ${rtlName}`, + '', + rtlName, + '', + ]); + fs.mkdirSync(phaseDirOf(tmpDir, slugPhaseDirName('1', rtlName)), { recursive: true }); + + const result = runInspect(tmpDir); + assert.strictEqual(result.success, true, `expected success: ${result.error}`); + const payload = JSON.parse(result.output); + const phase = payload.phases[0]; + assert.strictEqual(phase.goal.value, rtlName); + }); +}); + +// ─── 10. Cross-consumer parity ──────────────────────────────────────────────── + +describe('planning inspect — cross-consumer parity with phase-plan-index', () => { + test('phasePlanIndexAndPlanningInspectAgreeOnPlanObjectiveAndTaskCount', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const phaseDir = declarePhase(tmpDir, '1', 'Parity'); + writePlanDoc(phaseDir, '1-01-PLAN.md', ['wave: 1'], [ + '', + 'Ship the parity check', + '', + '', + '', + 'Task 1: Asrc/a.tsadone', + 'Task 2: Bsrc/b.tsbdone', + '', + ]); + + const inspectPayload = parseInspect(tmpDir); + const planIndexResult = runGsdTools(['phase-plan-index', '1', '--raw'], tmpDir); + assert.strictEqual(planIndexResult.success, true, `phase-plan-index should succeed: ${planIndexResult.error}`); + const planIndexPayload = JSON.parse(planIndexResult.output); + + const inspectPlan = inspectPayload.phases[0].plans[0]; + const indexPlan = planIndexPayload.plans.find((p) => p.id === inspectPlan.id); + assert.ok(indexPlan, 'phase-plan-index must report the same plan id'); + + assert.strictEqual(inspectPlan.objective, indexPlan.objective); + assert.strictEqual(inspectPlan.tasks.length, indexPlan.task_count); + }); + + test('phasePlanIndexBehaviorUnchangedByPlanDocumentExtraction', (t) => { + // Row 73: `phase-plan-index`'s wave/depends_on/incomplete/runnable shape + // — the live regression surface named by the matrix's own risk section — + // exercised directly, independent of `planning.inspect`, to prove the + // `plan-document.cts` extraction did not change this command's output. + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const phaseDir = declarePhase(tmpDir, '1', 'Regress'); + writePlanDoc(phaseDir, '1-01-PLAN.md', ['wave: 1'], [ + '', 'First', '', '', + '', + 'Task 1src/a.tsadone', + '', + ]); + writeSummaryDoc(phaseDir, '1-01-SUMMARY.md', ['status: complete'], [ + '# Summary', '', '## Files Created/Modified', '- `src/a.ts` - a', + ]); + writePlanDoc(phaseDir, '1-02-PLAN.md', ['wave: 2', 'depends_on: 1-01'], [ + '', 'Second', '', '', + '', + 'Task 1src/b.tsbdone', + '', + ]); + // No SUMMARY for 1-02 — it stays incomplete/runnable. + + const result = runGsdTools(['phase-plan-index', '1', '--raw'], tmpDir); + assert.strictEqual(result.success, true, `expected success: ${result.error}`); + const payload = JSON.parse(result.output); + + const first = payload.plans.find((p) => p.id === '1-01'); + const second = payload.plans.find((p) => p.id === '1-02'); + assert.ok(first, '1-01 must be present'); + assert.ok(second, '1-02 must be present'); + assert.strictEqual(first.wave, 1); + assert.strictEqual(second.wave, 2); + assert.deepStrictEqual(second.depends_on, ['1-01']); + assert.deepStrictEqual(payload.incomplete, ['1-02']); + assert.deepStrictEqual(payload.runnable, ['1-02']); + assert.ok(Array.isArray(payload.waves['1'])); + assert.ok(payload.waves['1'].includes('1-01')); + assert.ok(Array.isArray(payload.waves['2'])); + assert.ok(payload.waves['2'].includes('1-02')); + }); + + test('producesDeterministicOutputAcrossRuns', (t) => { + // Row 75: two runs over the same fixture must produce byte-identical + // stdout — array ordering (task rows, plan rows, diagnostics) is + // deterministic, not incidentally stable. + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + buildHealthyFixture(tmpDir); + + const first = runInspect(tmpDir); + const second = runInspect(tmpDir); + assert.strictEqual(first.success, true, `expected success: ${first.error}`); + assert.strictEqual(second.success, true, `expected success: ${second.error}`); + assert.strictEqual(first.output, second.output); + }); +}); + +// ─── 11. Property tests ─────────────────────────────────────────────────────── + +const EOL_ARB = fc.constantFrom('\n', '\r\n'); + +/** + * Document-shaped: presence/absence of each document, 0..3 phases, 0..3 + * requirements, CRLF vs LF, empty vs populated — NOT seeded from + * `planning-inspect.cts`'s own parsing model (see file-header provenance note). + */ +const PLANNING_PROJECT_SHAPE_ARB = fc.record({ + hasState: fc.boolean(), + hasRoadmap: fc.boolean(), + hasRequirements: fc.boolean(), + requirementsEmpty: fc.boolean(), + phaseCount: fc.integer({ min: 0, max: 3 }), + requirementCount: fc.integer({ min: 0, max: 3 }), + eol: EOL_ARB, +}); + +function buildDocumentShapedProject(cwd, cfg) { + if (cfg.hasState) { + writeState(cwd, ["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0'], [], cfg.eol); + } + if (cfg.hasRoadmap) { + const lines = ['## v1.0 Current 🚧', '']; + for (let i = 1; i <= cfg.phaseCount; i += 1) { + lines.push(`### Phase ${i}: Phase${i}`, ''); + } + writeRoadmap(cwd, lines, cfg.eol); + } + for (let i = 1; i <= cfg.phaseCount; i += 1) { + const token = String(i); + const phaseDir = phaseDirOf(cwd, slugPhaseDirName(token, `Phase${i}`)); + writePlanDoc(phaseDir, `${token}-01-PLAN.md`, ['wave: 1'], [ + '', `Ship phase ${i}`, '', '', + '', + 'Task 1src/x.tsdo itdone', + '', + ], cfg.eol); + writeSummaryDoc(phaseDir, `${token}-01-SUMMARY.md`, ['status: complete'], [ + '# Summary', '', '## Files Created/Modified', '- `src/x.ts` - x', + ], cfg.eol); + writeVerification(phaseDir, token, 'passed', cfg.eol); + } + if (cfg.hasRequirements) { + if (cfg.requirementsEmpty) { + writeRequirements(cwd, ''); + } else { + const lines = ['# Requirements', '', '## v1 Requirements', '']; + for (let i = 1; i <= cfg.requirementCount; i += 1) { + lines.push(`- [ ] **REQ-0${i}**: Requirement ${i}`); + } + writeRequirements(cwd, lines.join(cfg.eol)); + } + } +} + +describe('planning inspect — property tests', () => { + test('propertySchemaIsTotalOverDocumentShapedInputs', (t) => { + const createdDirs = []; + t.after(() => { + for (const d of createdDirs) cleanup(d); + }); + + fc.assert( + fc.property(PLANNING_PROJECT_SHAPE_ARB, (cfg) => { + const tmpDir = createTempProject('gsd-2790-prop-'); + createdDirs.push(tmpDir); + buildDocumentShapedProject(tmpDir, cfg); + + const result = runInspect(tmpDir); + assert.strictEqual(result.success, true, `command must exit 0 for cfg=${JSON.stringify(cfg)}: ${result.error}`); + const payload = JSON.parse(result.output); + assert.deepStrictEqual(sortedKeys(payload), EXPECTED_TOP_LEVEL_KEYS); + + const percent = payload.progress.accepted_phases.percent; + assert.ok( + percent === null || (Number.isInteger(percent) && percent >= 0 && percent <= 100), + `percent must be null or an integer in [0,100], got ${percent} for cfg=${JSON.stringify(cfg)}`, + ); + }), + { numRuns: 20, seed: 279040, verbose: true }, + ); + }); +}); + +// ─── 12. plan-document task-count parity (property, direct require) ────────── + +const { parsePlanDocument } = require('../gsd-core/bin/lib/plan-document.cjs'); + +const TASK_COUNT_ARB = fc.record({ + xmlCount: fc.integer({ min: 0, max: 5 }), + mdCount: fc.integer({ min: 0, max: 5 }), +}); + +describe('plan-document — task count parity (property)', () => { + test('parsesWithTheArgumentShapeProductionActuallyPasses', () => { + // Row 74: `planning-inspect.cjs`'s OWN call site (`buildPlanRows`) is + // `parsePlanDocument(doc.text)` — `planPath` OMITTED. Proven directly + // against that exact shape, not a hand-passed `(content, path)` pair. + const content = [ + '---', + 'wave: 2', + 'depends_on: 1-01', + 'files_modified: src/a.ts, src/b.ts', + '---', + '', + '', + 'Ship the omitted-argument case', + '', + '', + '', + 'Task 1src/a.tsdo itdone', + '', + ].join('\n'); + + const parsed = parsePlanDocument(content); + assert.strictEqual(parsed.objective, 'Ship the omitted-argument case'); + assert.strictEqual(parsed.declaredWave, 2); + assert.deepStrictEqual(parsed.dependsOn, ['1-01']); + // A single scalar frontmatter value is NOT comma-split — only an actual + // YAML array is mapped element-wise (see parsePlanDocument's `fmFiles` + // handling). One frontmatter line yields one array element verbatim. + assert.deepStrictEqual(parsed.filesModified, ['src/a.ts, src/b.ts']); + assert.strictEqual(parsed.tasks.length, 1); + assert.strictEqual(parsed.taskCount, 1); + }); + + test('propertyTaskCountMatchesLegacyFallbackRule', () => { + fc.assert( + fc.property(TASK_COUNT_ARB, ({ xmlCount, mdCount }) => { + const xmlBlocks = []; + for (let i = 0; i < xmlCount; i += 1) { + xmlBlocks.push(`Task ${i + 1}`); + } + const mdBlocks = []; + for (let i = 0; i < mdCount; i += 1) { + mdBlocks.push(`## Task ${i + 1}`); + } + const content = [ + '', + 'Objective', + '', + '', + ...xmlBlocks, + '', + ...mdBlocks, + ].join('\n'); + + // Default-argument caller shape — `parsePlanDocument(content)` with + // `planPath` omitted, matching how `planning-inspect.cts` calls it. + const parsed = parsePlanDocument(content); + const expected = xmlCount > 0 ? xmlCount : mdCount; + assert.strictEqual(parsed.tasks.length, expected); + }), + { numRuns: 40, seed: 279041, verbose: true }, + ); + }); +}); + +// ─── 9. Containment: escaped phase directories / verification files (#2790 follow-up) ─ + +describe('planning inspect — phase-directory and verification-file containment', () => { + test('escapedPhaseDirectorySymlinkLeaksNoFilenamesOrContent', (t) => { + // GAP 1: `readdirSync` follows a directory symlink, so a phase directory + // that is itself a symlink escaping the planning root must contribute NO + // filenames and NO content. Empirically, a symlinked entry directly under + // `.planning/phases/` never even reaches `buildPlanRows`/`buildUatRows` + // in the first place: `listMilestonePhaseDirs`/`buildAllPhaseDirNamesField` + // (`src/phase-locator.cts` / `src/planning-snapshot.cts` — both OUT OF + // SCOPE for this fix, and unrelated to it) enumerate phase directories via + // `readdirSync(..., { withFileTypes: true }).filter((e) => e.isDirectory())`, + // and `Dirent#isDirectory()` reports a directory SYMLINK as `false` (it is + // typed from the directory entry itself, never `stat`-resolved) — so the + // escaped entry is excluded from BOTH the windowed `phases` array and + // `orphan_phase_dirs` before `planning-inspect.cts` ever sees it. The + // `isPathContained` guard added to `buildPlanRows`/`buildUatRows` is + // still correct defense-in-depth (a direct call, a future refactor of the + // upstream filter, or a platform where a directory reparse point reports + // as a directory could all reach it) — this test proves the OUTCOME the + // security review actually cares about: end to end, nothing about the + // escaped directory or its contents is ever observable, and a healthy + // sibling phase is entirely unaffected. + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const externalDir = createTempDir('gsd-2790-external-phase-'); + t.after(() => cleanup(externalDir)); + const sentinelFileName = 'TOP-SECRET-FILENAME-8f21ac.md'; + const sentinelContent = 'TOP_SECRET_PHASE_CONTENT_4d81af'; + fs.writeFileSync(path.join(externalDir, sentinelFileName), sentinelContent); + + writeState(tmpDir, ["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0']); + writeRoadmap(tmpDir, [ + '## v1.0 Current 🚧', + '', + '### Phase 1: Foo', + '', + '### Phase 2: Bar', + '', + ]); + const phase1Dir = phaseDirOf(tmpDir, slugPhaseDirName('1', 'Foo')); + fs.mkdirSync(path.dirname(phase1Dir), { recursive: true }); + try { + fs.symlinkSync(externalDir, phase1Dir); + } catch (_symlinkErr) { + t.skip('symlink creation unsupported on this platform/privilege'); + return; + } + const phase2Dir = phaseDirOf(tmpDir, slugPhaseDirName('2', 'Bar')); + fs.mkdirSync(phase2Dir, { recursive: true }); + writeVerification(phase2Dir, '02', 'passed'); + + const result = runInspect(tmpDir); + assert.strictEqual(result.success, true, `expected exit 0 with an escaping phase directory: ${result.error}`); + // Raw-string ABSENCE proof — the sanctioned exception to the no-raw-text + // rule (see `rejectsPlanSymlinkEscapingThePlanningRoot` above). + assert.ok(!result.output.includes(sentinelFileName), 'the external filename must never appear anywhere in the payload'); + assert.ok(!result.output.includes(sentinelContent), 'the external content must never appear anywhere in the payload'); + + const payload = JSON.parse(result.output); + const escaped = payload.phases.find((p) => p.dir === slugPhaseDirName('1', 'Foo')); + const healthy = payload.phases.find((p) => p.dir === slugPhaseDirName('2', 'Bar')); + // The escaped directory is invisible end to end — neither a phase row nor + // an orphan entry names it (see the upstream Dirent-filtering note above). + assert.strictEqual(escaped, undefined, 'the escaped phase directory must not surface as a phase row'); + assert.ok(!payload.orphan_phase_dirs.includes(slugPhaseDirName('1', 'Foo'))); + assert.ok(healthy, 'the sibling phase must be entirely unaffected by the escape'); + assert.strictEqual(healthy.scope, 'complete'); + assert.strictEqual(healthy.complete, true); + }); + + test('escapedVerificationFileSymlinkLeaksNoFrontmatterValue', (t) => { + // GAP 2: `readVerificationStatus` (src/verification.cts) is a shared + // owner with its own unguarded `readFileSync` — an unrecognized `status:` + // value is copied verbatim into `next_action`. A `*-VERIFICATION.md` + // symlinked outside the planning root must never surface that value. + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + const externalDir = createTempDir('gsd-2790-external-verification-'); + t.after(() => cleanup(externalDir)); + const sentinel = 'TOP_SECRET_STATUS_VALUE_c93af1'; + const secretFile = path.join(externalDir, 'secret-status.md'); + fs.writeFileSync(secretFile, ['---', `status: ${sentinel}`, '---', ''].join('\n')); + + const phaseDir = declarePhase(tmpDir, '1', 'Foo'); + try { + fs.symlinkSync(secretFile, path.join(phaseDir, '01-VERIFICATION.md')); + } catch (_symlinkErr) { + t.skip('symlink creation unsupported on this platform/privilege'); + return; + } + + const result = runInspect(tmpDir); + assert.strictEqual(result.success, true, `expected exit 0 with an escaping verification symlink: ${result.error}`); + assert.ok(!result.output.includes(sentinel), 'the sentinel status value must never appear anywhere in the payload'); + + const payload = JSON.parse(result.output); + const phase = payload.phases[0]; + // Degrades exactly like an unreadable/absent verification report already + // does — never like a recognized-but-unknown status carrying the raw value. + assert.strictEqual(phase.verification.status, 'missing'); + assert.ok(!phase.verification.next_action.includes(sentinel), 'next_action specifically must never carry the sentinel'); + }); + + test('negativeControlRelocatedPhasesDirAndPlainVerificationFileBothStillWork', (t) => { + // Companion to both tests above: containment must not over-reject the + // cases it exists to preserve. Per the Dirent-filtering note in + // `escapedPhaseDirectorySymlinkLeaksNoFilenamesOrContent` above, symlinking + // an INDIVIDUAL phase directory is never recognized as a phase by the + // upstream enumerator regardless of where it points — that is a pre-existing, + // out-of-scope limitation of `listMilestonePhaseDirs`, not a containment + // question. The reachable, meaningful "contained relocation" case is + // instead the whole `.planning/phases/` PARENT being a symlink to another + // directory INSIDE the planning root, with ORDINARY (non-symlink) phase + // subdirectories nested inside it — `readdirSync` resolves the symlinked + // parent path once and then lists genuinely-typed directory entries + // within it, so those phase rows DO reach `buildPlanRows`/`buildUatRows`/ + // the verification `fs` seam with a `phaseDir` whose resolved realpath + // sits under the relocated-but-contained real target. This is the same + // "legitimately relocated planning tree" shape + // `stillReadsPlansWhenTheWholePlanningRootIsALegitimateSymlinkElsewhere` + // proves for the WHOLE `.planning/` root, one level down at `phases/`. + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + writeState(tmpDir, ["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0']); + writeRoadmap(tmpDir, [ + '## v1.0 Current 🚧', + '', + '### Phase 1: Foo', + '', + '### Phase 2: Bar', + '', + ]); + + const realPhasesDir = path.join(planningDirOf(tmpDir), '_actual-phases'); + const phase1RealDir = path.join(realPhasesDir, slugPhaseDirName('1', 'Foo')); + fs.mkdirSync(phase1RealDir, { recursive: true }); + writePlanDoc(phase1RealDir, '1-01-PLAN.md', [], [ + '', 'relocated-parent plan', '', '', + ]); + // (b) Phase 2 gets a plain, non-symlink verification report, proving the + // common case is unaffected by this fix. + const phase2RealDir = path.join(realPhasesDir, slugPhaseDirName('2', 'Bar')); + fs.mkdirSync(phase2RealDir, { recursive: true }); + writeVerification(phase2RealDir, '02', 'passed'); + + // `createTempProject` already creates an empty `.planning/phases/` — it + // must be removed before a symlink can take its place. + fs.rmdirSync(phasesDirOf(tmpDir)); + try { + fs.symlinkSync(realPhasesDir, phasesDirOf(tmpDir)); + } catch (_symlinkErr) { + t.skip('symlink creation unsupported on this platform/privilege'); + return; + } + + const payload = parseInspect(tmpDir); + + const phase1 = payload.phases.find((p) => p.dir === slugPhaseDirName('1', 'Foo')); + assert.ok(phase1, 'a phase reached through a relocated-but-contained phases/ parent must still be listed'); + assert.strictEqual(phase1.scope, 'complete'); + const plan = phase1.plans.find((p) => p.id === '1-01'); + assert.ok(plan, 'a plan inside the relocated-but-contained directory must still be read'); + assert.strictEqual(plan.objective, 'relocated-parent plan'); + + const phase2 = payload.phases.find((p) => p.dir === slugPhaseDirName('2', 'Bar')); + assert.ok(phase2, 'the sibling phase reached through the same relocated parent must still be listed'); + assert.strictEqual(phase2.verification.status, 'passed', 'a normal verification file must still surface its status'); + }); +}); diff --git a/tests/planning-inspect.unit.test.cjs b/tests/planning-inspect.unit.test.cjs new file mode 100644 index 000000000..817008201 --- /dev/null +++ b/tests/planning-inspect.unit.test.cjs @@ -0,0 +1,778 @@ +'use strict'; + +/** + * FAST, IN-PROCESS mutation-testing surface for `planning-inspect.cjs`, + * `plan-document.cjs`, and `planning-command-router.cjs` (#2790). + * + * Root cause this file exists to fix: `tests/planning-inspect.test.cjs` is + * INTEGRATION-shaped — it spawns a `gsd-tools` child process per case via + * `runGsdTools`. Stryker's command runner treats a `node --test ` + * invocation as ONE test costing whatever the slowest case costs (measured in + * CI: ~20s), and re-runs that whole file once per mutant. 640 mutants x 20s + * cannot finish inside a 15-minute shard cap — CI evidence: two shards were + * CANCELLED at 4% (27/640) after ~3 elapsed minutes. This file is the + * dedicated, spawn-free mutation surface `scripts/mutation-matrix.cjs` + * repoints those three modules' shards at; the integration suite keeps + * running unmodified in the normal (non-mutation) test job. + * + * NEVER spawn a child process here — no `runGsdTools`, `spawnSync`, + * `execFileSync`, or CLI invocation of any kind. Every case below requires + * the BUILT `.cjs` artifacts directly and calls their exports in-process. + * `plan-document.cjs` needs no filesystem at all (pure `(content) -> object` + * parser); `planning-command-router.cjs` is driven with a recording mock and + * needs no filesystem; `planning-inspect.cjs` needs small `.planning/` + * fixtures on disk (cheap disk I/O, not the cost this file exists to avoid) + * under `os.tmpdir()`. + * + * Every fixture shape and every asserted value below was verified by + * requiring the built libs directly and inspecting the real returned object + * — never guessed from reading the source alone (CLAUDE.md "verify + * assertions by executing, not retyping"). + */ + +const { test, describe } = 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 { cleanup } = require('./helpers.cjs'); + +const { + parsePlanDocument, + planIdFromFile, + TASK_KIND, +} = require('../gsd-core/bin/lib/plan-document.cjs'); + +const { + routePlanningCommand, + PLANNING_SUBCOMMANDS, +} = require('../gsd-core/bin/lib/planning-command-router.cjs'); + +const planningInspectLib = require('../gsd-core/bin/lib/planning-inspect.cjs'); +const { + buildPlanningInspect, + INSPECT_DIAGNOSTIC, + TASK_STATUS, + PROVENANCE, + AGREEMENT, +} = planningInspectLib; + +// ─── Shared fs fixture helpers (planning-inspect only) ──────────────────────── + +function writeAbs(fullPath, content) { + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content); +} + +function writeFile(cwd, relPath, content) { + writeAbs(path.join(cwd, relPath), content); +} + +function mkCwd() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'planning-inspect-unit-')); +} + +function frontmatterDoc(fmLines, bodyLines) { + return ['---', ...fmLines, '---', '', ...bodyLines].join('\n'); +} + +function phaseDirOf(cwd, token) { + return path.join(cwd, '.planning', 'phases', token); +} + +function diagnosticCodes(payload) { + return payload.diagnostics.map((d) => d.code); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// plan-document.cjs — pure, in-memory parser +// ═══════════════════════════════════════════════════════════════════════════ + +describe('plan-document — objective extraction', () => { + test('extracts the first line after an tag', () => { + const parsed = parsePlanDocument(['', 'Ship the thing', ''].join('\n')); + assert.strictEqual(parsed.objective, 'Ship the thing'); + }); + + test('falls back to frontmatter objective when no tag is present', () => { + const parsed = parsePlanDocument(frontmatterDoc(['objective: From frontmatter'], ['no objective tag here'])); + assert.strictEqual(parsed.objective, 'From frontmatter'); + }); + + test('is null when neither the tag nor frontmatter carries an objective', () => { + const parsed = parsePlanDocument('no objective anywhere'); + assert.strictEqual(parsed.objective, null); + }); + + test('prefers the tag over frontmatter when both are present', () => { + const parsed = parsePlanDocument(frontmatterDoc(['objective: From frontmatter'], ['', 'From tag', ''])); + assert.strictEqual(parsed.objective, 'From tag'); + }); +}); + +describe('plan-document — task grammar', () => { + test('parses one block with name/files/acceptance/done', () => { + const parsed = parsePlanDocument([ + '', + '', + ' Task One ', + ' a.ts, b.ts', + ' ', + '- criterion one', + '* criterion two', + ' ', + ' All done ', + '', + '', + ].join('\n')); + assert.strictEqual(parsed.tasks.length, 1); + const [task] = parsed.tasks; + assert.strictEqual(task.index, 1); + assert.strictEqual(task.kind, TASK_KIND.AUTO); + assert.strictEqual(task.type, 'auto'); + assert.strictEqual(task.name, 'Task One'); + assert.deepStrictEqual(task.plannedFiles, ['a.ts', 'b.ts']); + assert.deepStrictEqual(task.acceptanceCriteria, ['criterion one', 'criterion two']); + assert.strictEqual(task.done, 'All done'); + assert.strictEqual(parsed.taskCount, parsed.tasks.length); + }); + + test('splits on newlines as well as commas', () => { + const parsed = parsePlanDocument([ + '', + ' ', + 'a.ts', + 'b.ts', + ' ', + '', + ].join('\n')); + assert.deepStrictEqual(parsed.tasks[0].plannedFiles, ['a.ts', 'b.ts']); + }); + + test('falls back to ## Task N headings when no blocks exist', () => { + const parsed = parsePlanDocument([ + '## Task 1: Do the thing', + 'some body text', + '## Task 2: Do another thing', + ].join('\n')); + assert.strictEqual(parsed.tasks.length, 2); + assert.strictEqual(parsed.taskCount, 2); + assert.strictEqual(parsed.tasks[0].name, 'Task 1: Do the thing'); + assert.strictEqual(parsed.tasks[0].type, null); + assert.deepStrictEqual(parsed.tasks[0].plannedFiles, []); + assert.strictEqual(parsed.tasks[1].index, 2); + assert.strictEqual(parsed.tasks[1].name, 'Task 2: Do another thing'); + }); + + test('prefers blocks over ## Task N headings when both are present', () => { + const parsed = parsePlanDocument([ + '## Task 1: Legacy heading', + '', + ' Real task', + '', + ].join('\n')); + assert.strictEqual(parsed.tasks.length, 1); + assert.strictEqual(parsed.tasks[0].name, 'Real task'); + }); + + test('a checkpoint task carries no name/files/acceptance/done, even if present in the tag', () => { + const parsed = parsePlanDocument([ + '', + ' Ship it?', + ' Should be ignored', + '', + ].join('\n')); + const [task] = parsed.tasks; + assert.strictEqual(task.kind, TASK_KIND.CHECKPOINT); + assert.strictEqual(task.type, 'checkpoint:manual'); + assert.strictEqual(task.name, null); + assert.deepStrictEqual(task.plannedFiles, []); + assert.deepStrictEqual(task.acceptanceCriteria, []); + assert.strictEqual(task.done, null); + }); + + test('checkpoint type detection is case-insensitive and prefix-only', () => { + const parsed = parsePlanDocument(''); + assert.strictEqual(parsed.tasks[0].kind, TASK_KIND.CHECKPOINT); + }); + + test('an unclosed block is bounded by the next opening tag, never swallowing siblings', () => { + const parsed = parsePlanDocument([ + '', + ' First (unclosed)', + '', + ' Second', + '', + ].join('\n')); + assert.strictEqual(parsed.tasks.length, 2); + assert.strictEqual(parsed.taskCount, 2); + assert.strictEqual(parsed.tasks[0].name, 'First (unclosed)'); + assert.strictEqual(parsed.tasks[1].name, 'Second'); + }); + + test('an unclosed final block runs to end of document', () => { + const parsed = parsePlanDocument(['', ' Only task'].join('\n')); + assert.strictEqual(parsed.tasks.length, 1); + assert.strictEqual(parsed.tasks[0].name, 'Only task'); + }); + + test('taskCount always equals tasks.length', () => { + const noTasks = parsePlanDocument('no tasks here at all'); + assert.strictEqual(noTasks.taskCount, 0); + assert.deepStrictEqual(noTasks.tasks, []); + }); +}); + +describe('plan-document — frontmatter scheduling metadata', () => { + test('invalid wave, string depends_on, autonomous false, agent_hint set, scalar files_modified', () => { + const parsed = parsePlanDocument(frontmatterDoc([ + 'wave: not-a-number', + 'depends_on: 1-01-PLAN.md', + 'autonomous: false', + 'agent_hint: backend-specialist', + 'files_modified: src/single.ts', + ], ['body'])); + assert.strictEqual(parsed.declaredWave, null); + assert.deepStrictEqual(parsed.dependsOn, ['1-01-PLAN.md']); + assert.strictEqual(parsed.autonomous, false); + assert.strictEqual(parsed.agentHint, 'backend-specialist'); + assert.deepStrictEqual(parsed.filesModified, ['src/single.ts']); + }); + + test('valid wave, array depends_on, empty agent_hint, files-modified (hyphen) array', () => { + const parsed = parsePlanDocument(frontmatterDoc([ + 'wave: 3', + 'depends_on: [1-01-PLAN.md, 1-02-PLAN.md]', + 'agent_hint: ""', + 'files-modified: [a.ts, b.ts]', + ], ['body'])); + assert.strictEqual(parsed.declaredWave, 3); + assert.deepStrictEqual(parsed.dependsOn, ['1-01-PLAN.md', '1-02-PLAN.md']); + assert.strictEqual(parsed.agentHint, null); + assert.deepStrictEqual(parsed.filesModified, ['a.ts', 'b.ts']); + }); + + test('no frontmatter at all defaults wave/dependsOn/agentHint/filesModified and autonomous true', () => { + const parsed = parsePlanDocument('plain body, no frontmatter'); + assert.strictEqual(parsed.declaredWave, null); + assert.deepStrictEqual(parsed.dependsOn, []); + assert.strictEqual(parsed.autonomous, true); + assert.strictEqual(parsed.agentHint, null); + assert.deepStrictEqual(parsed.filesModified, []); + }); + + test('empty depends_on string is dropped, not turned into a single blank entry', () => { + const parsed = parsePlanDocument(frontmatterDoc(['depends_on: ""'], ['body'])); + assert.deepStrictEqual(parsed.dependsOn, []); + }); + + test('autonomous absent defaults to true', () => { + const parsed = parsePlanDocument(frontmatterDoc(['wave: 1'], ['body'])); + assert.strictEqual(parsed.autonomous, true); + }); +}); + +describe('plan-document — planIdFromFile / TASK_KIND', () => { + test('strips the -PLAN.md suffix from a root-form plan file', () => { + assert.strictEqual(planIdFromFile('1-01-PLAN.md'), '1-01'); + }); + + test('strips a bare PLAN.md to an empty id', () => { + assert.strictEqual(planIdFromFile('PLAN.md'), ''); + }); + + test('a nested numbered plan file (plans/PLAN-01-foo.md) is left unchanged', () => { + // Neither the `-PLAN.md` nor bare `PLAN.md` suffix matches this shape — + // characterised, byte-for-behaviour-preserved limit (see module doc). + assert.strictEqual(planIdFromFile('plans/PLAN-01-foo.md'), 'plans/PLAN-01-foo.md'); + }); + + test('a nested bare plan file (plans/PLAN.md) strips to its directory prefix', () => { + assert.strictEqual(planIdFromFile('plans/PLAN.md'), 'plans/'); + }); + + test('TASK_KIND is the frozen two-member vocabulary', () => { + assert.deepStrictEqual(TASK_KIND, { AUTO: 'auto', CHECKPOINT: 'checkpoint' }); + assert.ok(Object.isFrozen(TASK_KIND)); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// planning-command-router.cjs — pure dispatch, recording mocks, no fs +// ═══════════════════════════════════════════════════════════════════════════ + +describe('planning-command-router', () => { + function mockError() { + const calls = []; + const fn = (message, reason) => calls.push({ message, reason }); + fn.calls = calls; + return fn; + } + + function mockInspect() { + const calls = []; + return { + calls, + cmdPlanningInspect(cwd, raw) { + calls.push({ cwd, raw }); + }, + }; + } + + test('PLANNING_SUBCOMMANDS is exactly ["inspect"]', () => { + assert.deepStrictEqual(PLANNING_SUBCOMMANDS, ['inspect']); + }); + + test('dispatches "planning inspect" and forwards cwd/raw verbatim', () => { + const error = mockError(); + const mod = mockInspect(); + routePlanningCommand({ args: ['planning', 'inspect'], cwd: '/some/cwd', raw: true, error, _planningInspect: mod }); + assert.deepStrictEqual(error.calls, []); + assert.deepStrictEqual(mod.calls, [{ cwd: '/some/cwd', raw: true }]); + }); + + test('forwards a falsy raw and a different cwd verbatim (not defaulted)', () => { + const error = mockError(); + const mod = mockInspect(); + routePlanningCommand({ args: ['planning', 'inspect'], cwd: '/other', raw: false, error, _planningInspect: mod }); + assert.deepStrictEqual(mod.calls, [{ cwd: '/other', raw: false }]); + }); + + test('a missing subcommand yields sdk_unknown_command and never calls the mock', () => { + const error = mockError(); + const mod = mockInspect(); + routePlanningCommand({ args: ['planning'], cwd: '/x', raw: false, error, _planningInspect: mod }); + assert.strictEqual(error.calls.length, 1); + assert.strictEqual(error.calls[0].reason, 'sdk_unknown_command'); + assert.strictEqual(error.calls[0].message, 'Unknown planning subcommand. Available: inspect'); + assert.deepStrictEqual(mod.calls, []); + }); + + test('an unknown subcommand yields sdk_unknown_command and never calls the mock', () => { + const error = mockError(); + const mod = mockInspect(); + routePlanningCommand({ args: ['planning', 'bogus'], cwd: '/x', raw: false, error, _planningInspect: mod }); + assert.strictEqual(error.calls.length, 1); + assert.strictEqual(error.calls[0].reason, 'sdk_unknown_command'); + assert.deepStrictEqual(mod.calls, []); + }); + + test('a stray positional argument is a usage error naming the offender, never dispatched', () => { + const error = mockError(); + const mod = mockInspect(); + routePlanningCommand({ args: ['planning', 'inspect', 'extra'], cwd: '/x', raw: false, error, _planningInspect: mod }); + assert.strictEqual(error.calls.length, 1); + assert.strictEqual(error.calls[0].reason, 'usage'); + assert.strictEqual( + error.calls[0].message, + 'planning inspect takes no arguments; got positional argument: extra. Usage: gsd-tools query planning inspect', + ); + assert.deepStrictEqual(mod.calls, []); + }); + + test('an unknown flag is a usage error naming it as a flag, never dispatched', () => { + const error = mockError(); + const mod = mockInspect(); + routePlanningCommand({ args: ['planning', 'inspect', '--nope'], cwd: '/x', raw: false, error, _planningInspect: mod }); + assert.strictEqual(error.calls.length, 1); + assert.strictEqual(error.calls[0].reason, 'usage'); + assert.strictEqual( + error.calls[0].message, + 'planning inspect takes no arguments; got flag: --nope. Usage: gsd-tools query planning inspect', + ); + assert.deepStrictEqual(mod.calls, []); + }); + + test('defaults to the real planning-inspect module when no mock is injected', (t) => { + // No fixtures — buildPlanningInspect degrades gracefully on an absent + // .planning/ dir, so this proves the `mod ?? planningInspect` fallback + // wiring without spawning anything. + const cwd = mkCwd(); + t.after(() => cleanup(cwd)); + const error = mockError(); + routePlanningCommand({ args: ['planning', 'inspect'], cwd, raw: true, error }); + assert.deepStrictEqual(error.calls, []); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// planning-inspect.cjs — small on-disk fixtures, in-process buildPlanningInspect +// ═══════════════════════════════════════════════════════════════════════════ + +describe('planning-inspect — planning root absent', () => { + test('degrades every section to a non-answer with PLANNING_ROOT_ABSENT', (t) => { + const cwd = mkCwd(); + t.after(() => cleanup(cwd)); + + const result = buildPlanningInspect(cwd); + + assert.strictEqual(result.schema_version, 1); + assert.strictEqual(result.generated_from.planning_root, null); + assert.deepStrictEqual(result.phases, []); + assert.deepStrictEqual(result.orphan_phase_dirs, []); + assert.deepStrictEqual(result.requirements, []); + assert.deepStrictEqual(result.progress.accepted_phases, { completed: 0, total: 0, percent: null, scope: 'unreadable' }); + assert.deepStrictEqual(result.progress.completed_plans, { completed: 0, total: 0, percent: null, scope: 'unreadable' }); + assert.strictEqual(result.milestone.scope, 'unreadable'); + assert.deepStrictEqual(diagnosticCodes(result), [ + INSPECT_DIAGNOSTIC.PLANNING_ROOT_ABSENT, + INSPECT_DIAGNOSTIC.ROADMAP_UNSCOPED, + INSPECT_DIAGNOSTIC.REQUIREMENTS_ABSENT, + INSPECT_DIAGNOSTIC.PERCENT_WITHHELD, + INSPECT_DIAGNOSTIC.PERCENT_WITHHELD, + ]); + }); +}); + +describe('planning-inspect — healthy two-phase project', () => { + function buildHealthy(cwd) { + writeFile(cwd, '.planning/STATE.md', frontmatterDoc( + ["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0'], + ['## Current Position', '', 'Plan: 1-01-PLAN.md', ''], + )); + writeFile(cwd, '.planning/ROADMAP.md', [ + '## v1.0 Current 🚧', '', '## Phases', '', + '- [x] **Phase 1: Foo** - stub', + '- [ ] **Phase 2: Bar** - stub', + '', + '### Phase 1: Foo', '', 'Ship the foo module end to end.', '', + '**Depends on:** Phase 0', '', + '### Phase 2: Bar', '', 'Ship the bar module.', '', + ].join('\n')); + writeFile(cwd, '.planning/REQUIREMENTS.md', [ + '# Requirements: Test', '', '## v1 Requirements', '', + '- [x] **AUTH-01**: User can sign up', + '- [ ] **AUTH-02**: User can log in', + '', + '## Traceability', '', + '| Requirement | Phase | Status |', + '|-------------|-------|--------|', + '| AUTH-01 | Phase 1 | Complete |', + '| AUTH-02 | Phase 2 | Pending |', + '', + ].join('\n')); + for (const [token, name] of [['1', 'foo'], ['2', 'bar']]) { + const phaseDir = phaseDirOf(cwd, `0${token}-${name}`); + writeAbs(path.join(phaseDir, `${token}-01-PLAN.md`), frontmatterDoc(['wave: 1'], [ + '', `Ship ${name}`, '', '', + '', '', + '', + ` Task 1: Build ${name}`, + ` src/${name}.ts`, + ' Done', + '', '', + '', + ])); + writeAbs(path.join(phaseDir, `${token}-01-SUMMARY.md`), frontmatterDoc(['status: complete'], [ + '# Summary', '', '## Files Created/Modified', `- \`src/${name}.ts\` - ${name}`, + ])); + writeAbs(path.join(phaseDir, `${token}-VERIFICATION.md`), ['---', 'status: passed', '---', ''].join('\n')); + } + } + + test('reports exact scope, percent, requirement, plan-metadata and phase-goal values', (t) => { + const cwd = mkCwd(); + t.after(() => cleanup(cwd)); + buildHealthy(cwd); + + const result = buildPlanningInspect(cwd); + + assert.strictEqual(result.phases.length, 2); + assert.strictEqual(result.milestone.version, 'v1.0'); + + const [foo, bar] = result.phases; + assert.strictEqual(foo.dir, '01-foo'); + assert.strictEqual(foo.phase_id, '01'); + assert.strictEqual(foo.complete, true); + assert.strictEqual(foo.scope, 'complete'); + assert.deepStrictEqual(foo.goal, { value: 'Ship the foo module end to end.', scope: 'complete' }); + assert.deepStrictEqual(foo.dependencies, { value: ['0'], scope: 'complete' }); + assert.deepStrictEqual(foo.verification, { status: 'passed', next_action: 'Verification passed — continue.' }); + assert.deepStrictEqual(foo.roadmap_acceptance, { checkbox: true, authoritative: false }); + + assert.strictEqual(bar.dir, '02-bar'); + assert.deepStrictEqual(bar.dependencies, { value: [], scope: 'complete' }); + assert.deepStrictEqual(bar.roadmap_acceptance, { checkbox: false, authoritative: false }); + + const [plan] = foo.plans; + assert.strictEqual(plan.id, '1-01'); + assert.strictEqual(plan.wave, 1); + assert.deepStrictEqual(plan.dependsOn, []); + assert.strictEqual(plan.hasSummary, true); + assert.deepStrictEqual(plan.changedFiles, ['src/foo.ts']); + + // The SUMMARY carries only `## Files Created/Modified` (plan-level), with + // no `## Deviations from Plan` block naming a task — so provenance is + // PLAN_SCOPED, not TASK_SCOPED, and status/agreement are UNKNOWN. + const [task] = plan.tasks; + assert.strictEqual(task.provenance, PROVENANCE.PLAN_SCOPED); + assert.strictEqual(task.agreement, AGREEMENT.UNKNOWN); + assert.strictEqual(task.status, TASK_STATUS.UNKNOWN); + assert.strictEqual(task.changedFiles, null); + + assert.deepStrictEqual(result.requirements.map((r) => [r.id, r.complete, r.mappedPhases]), [ + ['AUTH-01', true, ['1']], + ['AUTH-02', false, ['2']], + ]); + + assert.deepStrictEqual(result.progress.accepted_phases, { completed: 2, total: 2, percent: 100, scope: 'complete' }); + assert.deepStrictEqual(result.progress.completed_plans, { completed: 2, total: 2, percent: 100, scope: 'complete' }); + assert.strictEqual(diagnosticCodes(result).includes(INSPECT_DIAGNOSTIC.PERCENT_WITHHELD), false); + }); +}); + +describe('planning-inspect — task provenance/agreement variety, checkpoint, orphan dirs, requirement diagnostics', () => { + function build(cwd) { + writeFile(cwd, '.planning/STATE.md', frontmatterDoc(["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0'], [])); + writeFile(cwd, '.planning/ROADMAP.md', [ + '## v1.0 Current 🚧', '', '## Phases', '', + '- [x] **Phase 1: Foo** - stub', '', + '### Phase 1: Foo', '', 'Ship the foo module.', '', + ].join('\n')); + writeFile(cwd, '.planning/REQUIREMENTS.md', [ + '# Requirements: Test', '', '## v1 Requirements', '', + '- [x] **AUTH-01**: User can sign up', + '- [x] **AUTH-01**: Duplicate row', + '- [ ] **AUTH-02**: Unmapped requirement', + '- [ ] **AUTH-03**: Maps to missing phase', + '', '## Traceability', '', + '| Requirement | Phase | Status |', + '|-------------|-------|--------|', + '| AUTH-01 | Phase 1 | Complete |', + '| AUTH-03 | Phase 9 | Pending |', + '', + ].join('\n')); + const p1 = phaseDirOf(cwd, '01-foo'); + writeAbs(path.join(p1, '1-01-PLAN.md'), frontmatterDoc(['wave: 1'], [ + '', 'Ship foo', '', '', + '', '', + '', + ' Task 1: Agreed task', + ' src/a.ts', + ' Done', + '', '', + '', + ' Task 2: Conflicting task', + ' src/b.ts', + ' Done', + '', '', + '', + ' Ship it?', + '', '', + '', + ])); + writeAbs(path.join(p1, '1-01-SUMMARY.md'), frontmatterDoc(['status: complete'], [ + '# Summary', '', '## Files Created/Modified', '- `src/a.ts` - a', '', + '## Deviations from Plan', '', + '**Found during:** Task 1', + '**Files modified:** `src/a.ts`', '', + '**Found during:** Task 2', + '**Files modified:** `src/other.ts`', + ])); + writeAbs(path.join(p1, '1-VERIFICATION.md'), ['---', 'status: passed', '---', ''].join('\n')); + fs.mkdirSync(phaseDirOf(cwd, '99-orphan'), { recursive: true }); + } + + test('agreed vs conflicting task provenance, checkpoint shape, orphan dir, and every requirement diagnostic code', (t) => { + const cwd = mkCwd(); + t.after(() => cleanup(cwd)); + build(cwd); + + const result = buildPlanningInspect(cwd); + + assert.deepStrictEqual(result.orphan_phase_dirs, ['99-orphan']); + assert.strictEqual(result.phases.length, 1); + + const [agreedTask, conflictingTask, checkpointTask] = result.phases[0].plans[0].tasks; + assert.strictEqual(agreedTask.provenance, PROVENANCE.TASK_SCOPED); + assert.strictEqual(agreedTask.agreement, AGREEMENT.AGREED); + assert.strictEqual(agreedTask.status, TASK_STATUS.DONE); + assert.deepStrictEqual(agreedTask.changedFiles, ['src/a.ts']); + + assert.strictEqual(conflictingTask.provenance, PROVENANCE.TASK_SCOPED); + assert.strictEqual(conflictingTask.agreement, AGREEMENT.CONFLICTING); + assert.deepStrictEqual(conflictingTask.changedFiles, ['src/other.ts']); + assert.deepStrictEqual(conflictingTask.plannedFiles, ['src/b.ts']); + + assert.strictEqual(checkpointTask.kind, TASK_KIND.CHECKPOINT); + assert.strictEqual(checkpointTask.provenance, PROVENANCE.PLAN_SCOPED); + assert.strictEqual(checkpointTask.agreement, AGREEMENT.UNKNOWN); + + assert.deepStrictEqual( + result.requirements.map((r) => ({ id: r.id, complete: r.complete, mappedPhases: r.mappedPhases, diagnostics: r.diagnostics })), + [ + { id: 'AUTH-01', complete: true, mappedPhases: ['1'], diagnostics: [INSPECT_DIAGNOSTIC.REQUIREMENT_DUPLICATE] }, + { id: 'AUTH-02', complete: false, mappedPhases: [], diagnostics: [INSPECT_DIAGNOSTIC.REQUIREMENT_UNMAPPED] }, + { id: 'AUTH-03', complete: false, mappedPhases: ['9'], diagnostics: [INSPECT_DIAGNOSTIC.REQUIREMENT_PHASE_UNKNOWN] }, + ], + ); + + const codes = diagnosticCodes(result); + assert.ok(codes.includes(INSPECT_DIAGNOSTIC.ORPHAN_PHASE_DIR)); + assert.ok(codes.includes(INSPECT_DIAGNOSTIC.TASK_CHANGED_FILES_CONFLICTING)); + assert.ok(codes.includes(INSPECT_DIAGNOSTIC.TASK_SHAPE_CHECKPOINT)); + assert.ok(codes.includes(INSPECT_DIAGNOSTIC.TASK_CHANGED_FILES_PLAN_SCOPED)); + assert.ok(codes.includes(INSPECT_DIAGNOSTIC.REQUIREMENT_DUPLICATE)); + assert.ok(codes.includes(INSPECT_DIAGNOSTIC.REQUIREMENT_UNMAPPED)); + assert.ok(codes.includes(INSPECT_DIAGNOSTIC.REQUIREMENT_PHASE_UNKNOWN)); + assert.strictEqual(codes.includes(INSPECT_DIAGNOSTIC.PERCENT_WITHHELD), false); + }); +}); + +describe('planning-inspect — percent withheld, unreadable plan/summary, completion-unknown requirement', () => { + function build(cwd) { + writeFile(cwd, '.planning/STATE.md', frontmatterDoc(["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0'], [])); + writeFile(cwd, '.planning/ROADMAP.md', [ + '## v1.0 Current 🚧', '', '## Phases', '', + '- [x] **Phase 1: Foo** - stub', + '- [ ] **Phase 2: Bar** - stub', + '', + '### Phase 1: Foo', '', 'Ship the foo module.', '', + // Deliberately NO section for Phase 2 -> ROADMAP_UNSCOPED for it. + ].join('\n')); + writeFile(cwd, '.planning/REQUIREMENTS.md', [ + '# Requirements: Test', '', '## v1 Requirements', '', + '- [x] **AUTH-01**: User can sign up', + '', + '## Other', '', + '| AUTH-05 | some description |', + '', + '## Traceability', '', + '| Requirement | Phase | Status |', + '|-------------|-------|--------|', + '| AUTH-01 | Phase 1 | Complete |', + '| AUTH-05 | Phase 1 | Pending |', + '', + ].join('\n')); + const p1 = phaseDirOf(cwd, '01-foo'); + writeAbs(path.join(p1, '1-01-PLAN.md'), frontmatterDoc(['wave: 1'], [ + '', 'Ship foo', '', '', + '', '', '', ' Task 1', ' src/a.ts', ' Done', '', '', '', + ])); + // Directory-in-file-position (cross-platform, no chmod): readDocument sees + // !stat.isFile() and reports unreadable, never a permissions hack. + fs.mkdirSync(path.join(p1, '1-01-SUMMARY.md'), { recursive: true }); + const p2 = phaseDirOf(cwd, '02-bar'); + fs.mkdirSync(p2, { recursive: true }); + fs.mkdirSync(path.join(p2, '2-01-PLAN.md'), { recursive: true }); + } + + test('withholds percent when a windowed phase has no ROADMAP section, and reports unreadable plan/summary + completion-unknown', (t) => { + const cwd = mkCwd(); + t.after(() => cleanup(cwd)); + build(cwd); + + const result = buildPlanningInspect(cwd); + + const [foo, bar] = result.phases; + assert.deepStrictEqual(foo.goal, { value: 'Ship the foo module.', scope: 'complete' }); + assert.deepStrictEqual(bar.goal, { value: null, scope: 'unscoped' }); + assert.deepStrictEqual(bar.dependencies, { value: [], scope: 'unscoped' }); + assert.strictEqual(bar.scope, 'unscoped'); + assert.strictEqual(bar.plans[0].scope, 'unreadable'); + assert.strictEqual(bar.plans[0].tasks.length, 0); + + assert.deepStrictEqual(result.progress.accepted_phases, { completed: 0, total: 2, percent: null, scope: 'unscoped' }); + assert.strictEqual(result.progress.completed_plans.percent, null); + + const auth05 = result.requirements.find((r) => r.id === 'AUTH-05'); + assert.strictEqual(auth05.complete, 'unknown'); + assert.deepStrictEqual(auth05.mappedPhases, ['1']); + assert.deepStrictEqual(auth05.diagnostics, [INSPECT_DIAGNOSTIC.REQUIREMENT_COMPLETION_UNKNOWN]); + + const codes = diagnosticCodes(result); + assert.ok(codes.includes(INSPECT_DIAGNOSTIC.SUMMARY_UNREADABLE)); + assert.ok(codes.includes(INSPECT_DIAGNOSTIC.PLAN_UNREADABLE)); + assert.ok(codes.includes(INSPECT_DIAGNOSTIC.ROADMAP_UNSCOPED)); + assert.ok(codes.includes(INSPECT_DIAGNOSTIC.PHASE_SCOPE_DEGRADED)); + assert.ok(codes.includes(INSPECT_DIAGNOSTIC.REQUIREMENT_COMPLETION_UNKNOWN)); + assert.strictEqual(codes.filter((c) => c === INSPECT_DIAGNOSTIC.PERCENT_WITHHELD).length, 2); + }); +}); + +describe('planning-inspect — UAT unreadable, UAT items, requirements unreadable, containment escape', () => { + test('a directory-in-file-position UAT document is UAT_UNREADABLE and degrades phase scope', (t) => { + const cwd = mkCwd(); + t.after(() => cleanup(cwd)); + writeFile(cwd, '.planning/STATE.md', frontmatterDoc(["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0'], [])); + writeFile(cwd, '.planning/ROADMAP.md', [ + '## v1.0 Current 🚧', '', '## Phases', '', '- [ ] **Phase 1: Foo** - stub', '', + '### Phase 1: Foo', '', 'Ship the foo module.', '', + ].join('\n')); + const p1 = phaseDirOf(cwd, '01-foo'); + fs.mkdirSync(path.join(p1, '1-UAT.md'), { recursive: true }); + + const result = buildPlanningInspect(cwd); + assert.deepStrictEqual(result.phases[0].uat, { unresolved: [], scope: 'truncated' }); + assert.strictEqual(result.phases[0].scope, 'truncated'); + const codes = diagnosticCodes(result); + assert.ok(codes.includes(INSPECT_DIAGNOSTIC.UAT_UNREADABLE)); + assert.ok(codes.includes(INSPECT_DIAGNOSTIC.PHASE_SCOPE_DEGRADED)); + }); + + test('a pending UAT test item is surfaced verbatim in phases[].uat.unresolved', (t) => { + const cwd = mkCwd(); + t.after(() => cleanup(cwd)); + writeFile(cwd, '.planning/STATE.md', frontmatterDoc(["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0'], [])); + writeFile(cwd, '.planning/ROADMAP.md', [ + '## v1.0 Current 🚧', '', '## Phases', '', '- [ ] **Phase 1: Foo** - stub', '', + '### Phase 1: Foo', '', 'Ship the foo module.', '', + ].join('\n')); + const p1 = phaseDirOf(cwd, '01-foo'); + fs.mkdirSync(p1, { recursive: true }); + writeAbs(path.join(p1, '1-UAT.md'), [ + '# UAT: Phase 1', '', + '## Current Test', '[testing complete]', '', + '## Tests', '', + '### 1. Sign up flow', + 'expected: user can sign up', + 'result: pending', + '', + ].join('\n')); + + const result = buildPlanningInspect(cwd); + assert.deepStrictEqual(result.phases[0].uat, { + scope: 'complete', + unresolved: [{ test: 1, name: 'Sign up flow', expected: 'user can sign up', result: 'pending', category: 'pending' }], + }); + }); + + test('REQUIREMENTS.md as a directory-in-file-position is unreadable, not absent, and yields zero rows', (t) => { + const cwd = mkCwd(); + t.after(() => cleanup(cwd)); + writeFile(cwd, '.planning/STATE.md', frontmatterDoc(["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0'], [])); + writeFile(cwd, '.planning/ROADMAP.md', ['## v1.0 Current 🚧', '', '## Phases', ''].join('\n')); + fs.mkdirSync(path.join(cwd, '.planning/REQUIREMENTS.md'), { recursive: true }); + + const result = buildPlanningInspect(cwd); + assert.deepStrictEqual(result.requirements, []); + assert.ok(diagnosticCodes(result).includes(INSPECT_DIAGNOSTIC.REQUIREMENTS_UNREADABLE)); + assert.strictEqual(diagnosticCodes(result).includes(INSPECT_DIAGNOSTIC.REQUIREMENTS_ABSENT), false); + }); + + test('a plan file symlinked outside the planning root degrades to unreadable, never leaking the escaped content', (t) => { + const cwd = mkCwd(); + t.after(() => cleanup(cwd)); + writeFile(cwd, '.planning/STATE.md', frontmatterDoc(["gsd_state_version: '1.0'", 'status: planning', 'milestone: v1.0'], [])); + writeFile(cwd, '.planning/ROADMAP.md', [ + '## v1.0 Current 🚧', '', '## Phases', '', '- [ ] **Phase 1: Foo** - stub', '', + '### Phase 1: Foo', '', 'Ship the foo module.', '', + ].join('\n')); + const p1 = phaseDirOf(cwd, '01-foo'); + fs.mkdirSync(p1, { recursive: true }); + const outside = path.join(os.tmpdir(), `planning-inspect-unit-outside-${process.pid}.md`); + fs.writeFileSync(outside, 'SECRET CONTENT'); + t.after(() => cleanup(outside)); + fs.symlinkSync(outside, path.join(p1, '1-01-PLAN.md')); + + const result = buildPlanningInspect(cwd); + const [plan] = result.phases[0].plans; + assert.strictEqual(plan.scope, 'unreadable'); + assert.strictEqual(plan.objective, null); + assert.deepStrictEqual(plan.tasks, []); + const json = JSON.stringify(result); + assert.strictEqual(json.includes('SECRET CONTENT'), false); + assert.ok(diagnosticCodes(result).includes(INSPECT_DIAGNOSTIC.PLAN_UNREADABLE)); + }); +}); diff --git a/tests/refactor-1390-t3-characterization.test.cjs b/tests/refactor-1390-t3-characterization.test.cjs index 7d1707c73..d7a13bb31 100644 --- a/tests/refactor-1390-t3-characterization.test.cjs +++ b/tests/refactor-1390-t3-characterization.test.cjs @@ -297,6 +297,14 @@ describe('parseRequirements — checkbox-bullet characterization (T3 pre-migrati assert.strictEqual(items[0].text, 'Checked requirement'); }); + test('strips the separator colon used by the shipped requirements template', () => { + const md = '- [ ] **AUTH-01**: User can sign up\n'; + const items = parseRequirements(md); + assert.strictEqual(items.length, 1); + assert.strictEqual(items[0].id, 'AUTH-01'); + assert.strictEqual(items[0].text, 'User can sign up'); + }); + test('parses multiple checkbox bullets', () => { const md = [ '- [ ] **REQ-01** First',