diff --git a/.changeset/2761-bracket-read-tolerance.md b/.changeset/2761-bracket-read-tolerance.md new file mode 100644 index 000000000..14f55e0d6 --- /dev/null +++ b/.changeset/2761-bracket-read-tolerance.md @@ -0,0 +1,22 @@ +--- +type: Added +pr: 2867 +--- +**Bracket-style phase IDs (`[GSD.02] 05: Name`) are now recognized on the read path** — `roadmap`, `validate` and `state` previously matched only the `Phase N:` spelling and the `NN-name` directory shape, so on a project with `phase_id_convention: "bracket"` every phase was invisible: counts fell back to the on-disk directory listing, `get-phase` reported not-found, every `GSD.02-05-slug` directory was reported malformed, and a completed milestone was warned to have unstarted phases. What changes: + +- Milestone scoping recognizes the ADR-canonical `## [GSD.02] Foundation` heading, including the version-less form (no `vN.N`, no status emoji), at any heading level through `###`, and across a milestone split over two headings in either order. A sibling milestone's phases and directories are excluded either way. +- Phase directories resolve, so each bracket phase reports its real `disk_status`, `plan_count` and `summary_count` instead of `no_directory` and zeros, and `completed_phases`, `total_plans` and the progress percent count the whole milestone. `state sync` scopes its own disk scan the same way, so the percent it writes to STATE.md agrees with the read path. +- Bracket-sentinel milestones (`[GSD.999]` icebox and `[GSD.00]` pre-milestone) and the reserved `999` phase token are excluded from phase counts, while retired phases leave the denominator. `validate consistency` and `validate health` now agree on bracket icebox entries instead of one flagging what the other excludes. The deliberately asymmetric phase-0 behavior is detailed below. +- `missing_phase_details` classifies each checklist entry on its own bracket. Two entries sharing a phase token across different brackets previously shared one verdict, decided by which was written first, so a real phase listed under an icebox entry's token was silently dropped from the report. +- `phase_id_convention` resolves against the workstream being read. A workstream that declares its own convention is no longer overridden by the root config, and `--workstream foo` now agrees with `GSD_WORKSTREAM=foo`; workstream progress rollups resolve the convention instead of assuming legacy, so a bracket workstream's phase count comes from its ROADMAP rather than falling back to its directory count. +- `validate health` gains an advisory W021 for opted-in projects: one sub-check flags a phase whose bracket milestone disagrees with its enclosing section, the other flags a phase heading not yet migrated to bracket form. +- `state validate` resolves bracket phase directories, so its drift scan actually runs on a bracket project instead of reporting `no phase directory matches` — and `valid: false` — for a directory that is plainly on disk. +- The `roadmap milestone-scope` probe and the `phase add` / `add-batch` / `insert` milestone-scope guard both read bracket headings. Blind, the probe reported an empty phase set on a bracket ROADMAP before *and* after a write, so the edit-phase rollback check could never fire; and the guard accepted a description embedding `## [GSD.09] Name` — a heading that carries none of the legacy milestone markers yet terminates the window on an opted-in project, silently dropping every later phase out of the milestone scope. A project that has not opted in is unaffected in both cases. + +Every widened read engages only when the resolved `phase_id_convention` is `bracket`; a project that has not opted in compiles the same patterns it did before. `"bracket"` is a read-path opt-in until the migrator and write path land — valid values are documented in `docs/CONFIGURATION.md`. + +Two consequences of landing on top of #3185 are worth stating. First, #3185 moved the legacy heading counter onto the canonical sentinel predicate, which drops a `### Phase 0:` or `### Phase 0.5:` heading from `total_phases`; combined with this PR's mid-migration guard, that legacy-spelled heading is counted on a bracket project and not counted on one that has not opted in. A bracket-spelled bare `0` or `0.x` remains excluded. The bracket counter keeps the narrower `^0\b` rule deliberately, because the canonical predicate also swallows decimal phase IDs such as `00.1`, which is a real phase rather than milestone 0. Second, the shared phase-directory enumerator keeps its `phaseIdConvention = null` destructure default (`phase-locator.cts:375`), and `null` means "resolved, and not bracket" — the lazy resolve-from-config fires only on `undefined` (`roadmap-parser.cts:941`, `:1928`, whose own comment records that "explicit null still means 'resolved and non-bracket'"). Only four of its seventeen call sites thread a resolved convention: `milestone complete` (`milestone.cts:782`) and `state`'s three (`state.cts:979`, `:2303`, `:4672`). The remaining thirteen omit it and therefore still enumerate on the legacy reading — including `progress` (`roadmap.cts:745`), `stats` (`commands.cts:1947`, `:2302`), `phase list` and the init manager view (`init.cts`), and `state sync`'s own scope probe (`state.cts:4795`). On a bracket project those surfaces receive an unscoped or legacy-scoped directory set, not a bracket-scoped one, and their per-entry rendering is likewise unconverted (`cmdProgressRender`'s directory regex, `cmdStats`'s convention-less `extractPhaseToken(dir)` call, the init manager view's `Phase`-literal heading pattern). Widening those call sites is display- and command-surface work deferred to the epic's later PRs; this slice does not claim them. + +The archival and milestone-completion paths DO reach the widened enumerator, and deliberately so: `milestone complete` (`cmdMilestoneComplete`) and `state update-progress` (`cmdStateUpdateProgress`) both call the same shared `listMilestonePhaseDirs` this PR widens, and both now resolve and thread `phase_id_convention` explicitly at their call sites rather than relying on the enumerator's own lazy resolve-from-config default. On a project that has opted into `"bracket"`, `milestone complete` archives the milestone's real bracket-declared phase directories — the same set the read path already reports — instead of failing to recognize them; a `null` / `milestone-prefixed` project's archived set is unchanged. `state update-progress`'s reported and written percent was already correctly scoped (it derives from `buildStateFrontmatter`, which threads its own resolved convention independently); the explicit thread at its own enumerator call is single-derivation hygiene, not a behavior change, and is documented as such in-line (mutation-tested: reverting only this thread leaves every existing assertion on this command green, because the enumerator's own lazy resolve-from-config default answers the same question the explicit thread does). `cmdMilestoneComplete`'s enumerated set is pinned by a test (`tests/adr-612-bracket-phase-counting.test.cjs`, the round-11 BLOCKER block) so a future regression to the pass-all-degrade legacy reading cannot silently move what a bracket project's `milestone complete` archives without failing a test. `state update-progress`'s own call site is pinned differently, matching what it actually gates: not the reported percent, but the #3233 zero-plans no-op — a bracket milestone whose declared phases carry no plans on disk stays a no-op only when a directory that plainly does not belong to the milestone window is correctly excluded from this call site's enumerated set; swept in by a pass-all degrade, the no-op stops firing. (#2761) + +One more disk-side fix lands alongside the above. `listMilestonePhaseDirs`'s sentinel filter (`isSentinelPhaseId`) treated a bare, untagged phase directory under `phase_id_convention: "bracket"` (`0-bootstrap`, no `{CODE}.{MM}-` prefix) as sentinel milestone 0 by falling through to the legacy leading-int rule — silently dropping a real, on-disk, milestone-declared phase directory from `completed_phases`. This mirrors, on the disk side, the exact defect class the heading-side counters (`countRoadmapPhaseHeadings`, `scanMilestonePhaseIds`) already guard against for the identical bare/untagged shape: under bracket convention, milestone 0 is expressed only via an explicit bracket tag, so an untagged leading `0` is a real phase token, not a sentinel. The `999`/icebox reading stays universal. Legacy and milestone-prefixed projects are unaffected (this call site's `convention` argument is only ever `'bracket'` or unset). diff --git a/CONTEXT.md b/CONTEXT.md index 9eb66d28c..56d52e9f8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -15,7 +15,7 @@ Module owning `milestone complete` (archive roadmap/requirements/phases, build M Module that composes Dispatch Policy Module, Query Execution Policy Module, and per-stage handlers (input-validation, plan, execution, result-builder, formatting, error-mapping, observability) into the end-to-end pipeline that produces a `QueryDispatchResult`. The SDK-era pipeline collapsed onto the Command Routing Hub per ADR-0174; current dispatch seam: `gsd-core/bin/lib/command-routing-hub.cjs` (see Command Routing Hub below). ### Phase Id Module -Module owning the pure phase-id parsing and matching helpers: phase-name normalization, phase-token extraction/matching, canonical phase-directory selection, milestone- and phase-dir id parsing, phase-markdown regex builders, and the ADR-612 bracket phase-id round-trip grammar (`escapeRegex`, `normalizePhaseName`, `comparePhaseNum`, `extractPhaseToken`, `phaseTokenMatches`, `matchPhaseDirs`, `phaseNumberForMatch`, `phaseMarkdownRegexSource`/`phaseMarkdownRegexSourceExact`, `getMilestoneFromPhaseId`, `getPhaseDirFromPhaseId`, `parsePhaseId`/`renderPhaseId`/`toDir` over the `PhaseId` type, `isSentinelPhaseId`/`SENTINEL_RANGES`, and the `BRACKET_PHASE_TOKEN_SOURCE`/`PHASE_HEADING_PREFIX_SRC` grammar sources). Also owns the canonical phase KEY surface (#2562) — `phaseKeyFromToken`/`phaseKeyFromDir`/`phaseKeyFromProse`/`parentPhaseKey` — the padding-, case- and project-code-insensitive identity used whenever two independently-derived phase references (a ROADMAP table cell and a phase directory, say) are compared; deriving one side of such a comparison with a bespoke regex is what silently zeroed a rollup in #2562. Pure string/regex — no I/O, no config, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2a (#865) as the cycle-free leaf that unblocks the roadmap-parser and phase-locator extractions; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/phase-id.cjs` (generated from `src/phase-id.cts`). +Module owning the pure phase-id parsing and matching helpers: phase-name normalization, phase-token extraction/matching, canonical phase-directory selection, milestone- and phase-dir id parsing, phase-markdown regex builders, and the ADR-612 bracket phase-id round-trip grammar (`escapeRegex`, `normalizePhaseName`, `comparePhaseNum`, `extractPhaseToken`, `phaseTokenMatches`, `matchPhaseDirs`, `phaseNumberForMatch`, `phaseMarkdownRegexSource`/`phaseMarkdownRegexSourceExact`, `getMilestoneFromPhaseId`, `getPhaseDirFromPhaseId`, `parsePhaseId`/`renderPhaseId`/`toDir` over the `PhaseId` type, `isSentinelPhaseId`/`SENTINEL_RANGES`, and the `BRACKET_PHASE_TOKEN_SOURCE`/`PHASE_HEADING_PREFIX_SRC` grammar sources, plus the #612 read-path surface: the one bracket identity grammar `BRACKET_ID_SRC`/`BRACKET_MILESTONE_NUMERIC_SRC`/`BRACKET_DIR_PREFIX_SRC`, the case-folding `foldBracketId`, `bracketQualifiedKey`, and the convention-gated heading-intro selector `phaseHeadingPrefixSrcFor` over `PHASE_HEADING_BASELINE` / `BASE_ANY_BRACKET_HEADING_PREFIX_SRC` / `BASE_PHASE_LABEL_PREFIX_SRC`). Also owns the canonical phase KEY surface (#2562) — `phaseKeyFromToken`/`phaseKeyFromDir`/`phaseKeyFromProse`/`parentPhaseKey` — the padding-, case- and project-code-insensitive identity used whenever two independently-derived phase references (a ROADMAP table cell and a phase directory, say) are compared; deriving one side of such a comparison with a bespoke regex is what silently zeroed a rollup in #2562. Pure string/regex — no I/O, no config, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2a (#865) as the cycle-free leaf that unblocks the roadmap-parser and phase-locator extractions; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/phase-id.cjs` (generated from `src/phase-id.cts`). ### Phase Lifecycle Module Module owning phase create, rename, complete, remove, list, and plan-index operations, plus phase-dir prefix validation, STATE.md staleness detection, and auto-prune behaviour. Entry point: `gsd-core/bin/lib/phase.cjs` (CJS surface). Typed phase events: `GSDPhaseStartEvent`, `GSDPhaseStepStartEvent`, `GSDPhaseStepCompleteEvent`, `GSDPhaseCompleteEvent`. (The SDK native-query surface, the `types.ts` event definitions, `phase-runner.ts`, and `phase-prompt.ts` were retired with the SDK package per ADR-0174.) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a18cf91b0..a6e0b1a11 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -187,7 +187,7 @@ project one is reported, since that is the file you are most likely able to fix. | `dynamic_routing.max_escalations` | integer | `0`, `1`, `2`, … | `1` | Hard cap on retries per agent invocation. Beyond the cap the resolver returns the cap-tier model. Also caps `provider_escalation`. Added in v1.40 | | `dynamic_routing.provider_escalation` | string[] | ordered model IDs | (none) | Opt-in fallback providers tried when a run dies on a quota / rate limit — see [provider escalation](#provider-escalation-on-quota-exceeded--added-in-v143). Added in v1.43 ([#2296](https://github.com/open-gsd/gsd-core/issues/2296)) | | `project_code` | string | any short string | (none) | Prefix for phase directory names (e.g., `"ABC"` produces `ABC-01-setup/`). Added in v1.31 | -| `phase_id_convention` | enum | `"milestone-prefixed"`, `null` | `null` | Phase ID naming convention. `null` = legacy numeric IDs (`Phase 1`, `Phase 2`). `"milestone-prefixed"` = globally unique IDs that encode the enclosing milestone (`Phase 1-01`, `Phase 1-02`). Run `gsd-tools roadmap upgrade --convention milestone-prefixed` to migrate an existing ROADMAP.md. | +| `phase_id_convention` | enum | `"milestone-prefixed"`, `"bracket"`, `null` | `null` | Phase ID naming convention. `null` = legacy numeric IDs (`Phase 1`, `Phase 2`). `"milestone-prefixed"` = globally unique IDs that encode the enclosing milestone (`Phase 1-01`, `Phase 1-02`). Run `gsd-tools roadmap upgrade --convention milestone-prefixed` to migrate an existing ROADMAP.md. `"bracket"` = IDs that carry the milestone in a bracket ahead of the phase number — heading `### [GSD.02] 05: Name`, directory `GSD.02-05-name` — per [ADR-612](adr/612-bracket-phase-id-convention.md). **`"bracket"` currently affects the READ path only:** `roadmap analyze` / `roadmap get-phase`, the W005/W006/W007 phase checks, `validate health` (including an advisory W021 — a bracket phase's milestone disagreeing with its enclosing section, or a phase heading still spelled in legacy form that has not yet been migrated to bracket form), and both `total_phases` derivations recognise the bracket spelling once it is set. There is no bracket migrator and no bracket emit yet, so set it only on a project whose ROADMAP.md already uses that spelling; a project on any other value compiles the same patterns it did before and is unaffected. **What opting in costs:** on a bracket repo a heading whose bracket is followed directly by a digit is read as a phase heading, so shapes that are legal prose headings on any other convention — `### [RFC.2119] 5:`, `### [v1.0] 2024:`, `### [ADR.612] 3:` — are claimed as phases and will move `phase_count`, `total_phases` and W006. A bracket repo cedes that heading shape; that is the trade the opt-in buys, and it is why the widened read is selected at construction time from this value rather than applied everywhere ([#2761](https://github.com/open-gsd/gsd-core/issues/2761)). | | `response_language` | string | language code | (none) | Language for agent responses (e.g., `"pt"`, `"ko"`, `"ja"`). Propagates to all spawned agents for cross-phase language consistency. Added in v1.32. UAT checkpoint frames (`/gsd-verify-work`) render a localized banner/instruction for English, Spanish, French, German, Portuguese, Japanese, Chinese, Korean, Italian, Dutch, Polish, Russian, Ukrainian, Turkish, Hindi, Arabic, Vietnamese, and Indonesian (endonyms and ISO codes also accepted); any other value falls back to the English frame. One deliberate exception: the `spec-phase` edge-completeness probe is fed an English translation of each requirement's text, because its shape cues are English-only — the SPEC itself stays in this language. See [Spec-Phase Edge-Completeness Probe](FEATURES.md#144-spec-phase-edge-completeness-probe). | | `context_window` | number | any integer | `200000` | Context window size in tokens. Set `1000000` for 1M-context models (e.g., `claude-fable-5`). Values `>= 500000` enable adaptive context enrichment (full-body reads of prior SUMMARY.md, deeper anti-pattern reads). Configured via `/gsd-config --advanced`. | | `context_profile` | string | `dev`, `research`, `review` | (none) | Execution context preset that applies a pre-configured bundle of mode, model, and workflow settings for the current type of work. Added in v1.34 | diff --git a/docs/adr/612-bracket-phase-id-convention.md b/docs/adr/612-bracket-phase-id-convention.md index 04d947c96..20ddf0680 100644 --- a/docs/adr/612-bracket-phase-id-convention.md +++ b/docs/adr/612-bracket-phase-id-convention.md @@ -168,25 +168,35 @@ Each phase is an independently-green PR. Tolerant reads (PR-2) ship before emit |---|---|---| | **PR-0** | This ADR + plan-dimension spec + concrete collision characterization test. No behavior change. | `docs/adr/612-*.md`, `tests/adr-612-collision-characterization.test.cjs` | | **PR-1** | Core grammar: `PhaseId` type, `parsePhaseId`/`renderPhaseId`/`toDir` with fast-check round-trip properties, READING-B `getMilestoneFromPhaseId` (gated), bracket `extractPhaseToken` branch, comparator, sentinel + slug guards — all inside the single-owner leaf. | `phase-id.cts` | -| **PR-2** | Read path: bracket heading/dir/checklist tolerance + bracket-coherence checks. | `roadmap.cts`, `roadmap-parser.cts`, `validate.cts`, `verify.cts` | +| **PR-2** | Read path: bracket heading/dir/checklist tolerance + bracket-coherence checks; ROADMAP phase **counting** (both `total_phases` derivations) and the #1514 retirement filter; the write-adjacent enumerator call sites that consume that same counting (`milestone complete`, `state update-progress`) now thread the resolved convention explicitly instead of inheriting a lazy default — see the amendment note below the table. | `health-diagnostic-rules/phase-structure.cts`, `health-diagnostic-rules/roadmap-disk-consistency.cts`, `health-diagnostic-rules/state-consistency.cts`, `milestone.cts`, `phase-id.cts`, `phase.cts`, `planning-snapshot.cts`, `planning-workspace.cts`, `roadmap-parser.cts`, `roadmap.cts`, `state.cts`, `validate.cts`, `workstream-inventory.cts`, `scripts/lint-phase-id-drift.cjs`, `scripts/lint-phase-enumeration-drift.cjs` | | **PR-3** | Migrator: legacy + M-NN → bracket; dry-run / dirty-guard / surgical rollback preserved; HARD-REFUSE on absent `project_code`; real-layout fixture corpus (two invariants). | `roadmap-upgrade.cts`, `roadmap-command-router.cts` | -| **PR-4** | Write path: bracket emit gated on convention; new-project default; STATE.md `milestone:` frontmatter; `VALID_PHASE_ID_CONVENTIONS` enum. | `phase.cts`, `state.cts`, `config.cts` | +| **PR-4** | Write path: bracket emit gated on convention; new-project default; STATE.md `milestone:` frontmatter; `VALID_PHASE_ID_CONVENTIONS` enum. | `phase.cts`, `state.cts` (emit + `milestone:` frontmatter only — counting stays in PR-2, above), `config.cts` | | **PR-5** | Display + card: progress render + stats `display_id` route through the pure pair; statusline; convention card single-source. | `commands.cts`, `hooks/gsd-statusline.js` | | **PR-6** | Generated injection: single-sourced convention block across agents / phase-emitting workflows / templates + verify parity check; canonical reference doc; grep-evidence gate. | `gsd-core/references/`, `agents/*.md`, `gsd-core/workflows/**/*.md`, `gsd-core/templates/` | +**AMENDED 2026-08-18 — APPROVED AND RATIFIED by maintainer.** The PR-2 / PR-4 rows above are amended on three points, scoped to what actually ships in PR-2 (#2761) after the completion-seam work below was split out of it. The boundary between PR-2 and PR-4 is **concern**, not **file**: PR-2 owns everything that *derives a number or a directory set from the ROADMAP*; PR-4 owns everything that *emits* a bracket identifier. + +1. **`state.cts` read-tolerance is in PR-2, not PR-4.** Both `total_phases` derivations (`buildStateFrontmatter`, `cmdStateSync`) and the #1514 retirement filter are a parse-and-count of the ROADMAP, the same read-path concern PR-2 already owns in `roadmap.cts` and `validate.cts`. PR-4 retains only `state.cts`'s write concerns: bracket emit, the `milestone:` frontmatter field, and `VALID_PHASE_ID_CONVENTIONS`. + +2. **The shared phase-directory enumerator (`listMilestonePhaseDirs`) defaults its `phaseIdConvention` argument to "not yet resolved — resolve from config" rather than "resolved, and not bracket."** That default reaches every call site that does not pass the parameter explicitly, including two commands that write: `cmdMilestoneComplete` (`milestone.cts`) archives whatever directory set the enumerator returns, and `cmdStateUpdateProgress` (`state.cts`) gates and counts off it. Both are now explicit about it: each resolves `phase_id_convention` once (via `resolvePhaseIdConvention`) and threads it into its own enumerator call, rather than relying on the lazy default to resolve it the same way. On a project that has opted into `'bracket'`, this means `milestone complete` archives the milestone's real bracket-declared phase directories instead of failing to recognize them — the same set the read path already reports. `state update-progress`'s reported and written percent was already independently correct (it comes from `buildStateFrontmatter`, which resolves and threads its own convention); the explicit thread at its own enumerator call closes a second, silent, separately-resolved answer to the same question, not a defect in the reported percent — mutation-tested: reverting only this thread leaves every existing percent/completed/total assertion on this command green, because the enumerator's own lazy resolve-from-config default answers the identical question the explicit thread does. `cmdMilestoneComplete`'s enumerated set is pinned by a test (`tests/adr-612-bracket-phase-counting.test.cjs`, round-11 BLOCKER block) so a future regression to the pass-all-degrade legacy reading cannot silently move what `milestone complete` archives without failing a test. `state update-progress`'s own call site is pinned differently, matching what it actually gates: not the reported percent, but the #3233 zero-plans no-op — a bracket milestone whose declared phases carry no plans on disk stays a no-op only when a directory that plainly does not belong to the milestone window is excluded from this call site's enumerated set; swept in by a pass-all degrade, the no-op stops firing (also `tests/adr-612-bracket-phase-counting.test.cjs`, round-11 BLOCKER block, round-12 addition). A `null` / `milestone-prefixed` project's behavior at both call sites is unchanged either way — its resolved convention is the same value the lazy default would have produced. + +3. **Two modules the table omitted, and one explicitly excluded.** `phase-id.cts` is not PR-1-only: PR-2 consumes and extends the owner-sanctioned read sources (`BRACKET_PHASE_TOKEN_SOURCE`, `PHASE_HEADING_PREFIX_SRC`) inside the ADR-2121 canonical owner, and the phase-key derivation (`phaseKeyFromDir`) via an optional `convention` parameter forwarded to `extractPhaseToken` (additive, defaulted-absent, byte-identical at every existing call site). `planning-workspace.cts` had no row at all; it hosts the `resolvePhaseIdConvention` resolver every gated read (and, per point 2, both write-adjacent call sites above) consults. Both are PR-2 foundations, not scope creep. Explicitly **excluded** from PR-2: the completion-seam threading of `convention` through `isPhaseArtifact`/`scopeToPhase` and the rest of the phase-completion chain (`phase-id.cts`, ~424 lines) — round-11 review directed this split into a separate, stacked follow-up PR under epic #612, never reviewed as part of PR-2, and it ships independently. + ## Consequences - **Positive:** every token is uniquely parseable; one pure round-trippable model under the single-owner regime; milestone-detection correct under READING-B; legacy (`null` / M-NN) repos byte-untouched; migration is opt-in and reversible; the convention count drops from a transient three to a terminal two. - **Negative:** a migration window in which reads must tolerate three forms; a second milestone authority under bracket (bracket integer vs STATE.md `milestone:`) whose coherence-check teeth are advisory (W021, message-disambiguated per the folded W021 disposition below); the `getMilestoneFromPhaseId` return-form under bracket is ratified as `vN.0` (Decision 3 below), with the archive-dir glob coupled to that form. +- **Negative (added 2026-08-18):** because the tolerant reads land before emit, a repo that opts into `'bracket'` between PR-2 and PR-4 sees corrected counters, corrected milestone scoping, and corrected archival in commands that write (`state sync`, `state update-progress`, `milestone complete`) while `gsd phase add` still emits legacy ids. That asymmetry is the deliberate cost of read-before-write sequencing; it closes at PR-4. Repos on `null` / `milestone-prefixed` remain byte-identical throughout. The completion-seam threading (point 3 above) is a separate, later cost: until its follow-up PR lands, `isPhaseArtifact`/`scopeToPhase` and the phase-completion chain do not yet honor a resolved bracket convention, independent of anything PR-2 changes. ## Decisions to ratify -Resolved items are folded into the body above. These remain open and gate their named PR: +Resolved items are normally folded into the body above; ratified items retained below preserve review history. Only items explicitly marked open still gate their named PR: 1. **Bare `02-04` resolution** (gates PR-1 `normalizePhaseName` / PR-2 resolvers). Options: (i) throw a disambiguation error; (ii) keep the current M-NN reading and let only the bracket path reject it; (iii) a `surface: 'phase' | 'plan'` context hint. The throw is a *new* behavior — `normalizePhaseName('02-04')` returns `'02-04'` today, it does not truncate — so it is not merely a fix. Not committed here. 2. **`phase_naming` vs `phase_id_convention` axis relationship** (gates PR-4 build-default). Confirm these are two independent axes before wiring the new-project `'bracket'` default; the build-default object sets `phase_naming` but omits `phase_id_convention`. 3. **`getMilestoneFromPhaseId` return-form under bracket + archive-dir naming** (gated PR-1/PR-2) — **RATIFIED 2026-07-24: `vN.0`** (STATE.md parity, lowest churn), chosen over a bare integer; the archive-dir glob is value-coupled to this form and follows `vN.0`. PR-1 (#2258) ships this return-form. The separate milestone-identity normalization arc is unaffected. 4. **Convention-card single-source location** (gates PR-5/PR-6). Pin the single module/path so the ASCII grammar card renders identically at installer completion, migrator start (dry-run and apply), and in docs. +5. **PR-2 / PR-4 phase boundary, the write-observability of gated reads, and the completion-seam split** (PR-2, i.e. #2761) — **RATIFIED by maintainer.** The amendment note beneath the Phases table, dated 2026-08-18, is approved. It (a) moves `state.cts` ROADMAP-counting and retirement filtering into PR-2 while leaving emit and `milestone:` frontmatter in PR-4, (b) states that `milestone complete` and `state update-progress` now explicitly thread the resolved convention into the shared enumerator rather than inheriting its lazy default, pinned by tests, and (c) confirms the completion-seam threading (`isPhaseArtifact`/`scopeToPhase`) ships in a separate, stacked follow-up PR under epic #612, not in PR-2. Raised by the #2867 review (PR-2, round-11) and **approved by the maintainer in #2867 review `5012940978` (2026-08-24)**, which withdrew the ratification blocker in these terms: "Maintainer has approved the amended PR-2 scope... There is no separate ratification process in this repo; maintainer approval is the standard." That review is the receipt for this item; maintainer approval is the repository's ratification standard, so no separate ratification step remains. **Folded (resolved):** M-NN deprecation stance → terminal by consolidation (Decision 5). W021 renumber → **void**: W021 is pinned by `tests/milestone-prefixed-convention.test.cjs` and is kept, message-disambiguated (the earlier plan to renumber it is dropped). READING-B milestone source → Decision 6. diff --git a/scripts/lint-docs-guard-registration.exempt-baseline.cjs b/scripts/lint-docs-guard-registration.exempt-baseline.cjs index 7b7dee9f0..fb641a3c2 100644 --- a/scripts/lint-docs-guard-registration.exempt-baseline.cjs +++ b/scripts/lint-docs-guard-registration.exempt-baseline.cjs @@ -31,6 +31,7 @@ * `docs-guard-exempt:` marker for its specific reason. */ const DOCS_GUARD_EXEMPT_BASELINE = [ + 'adr-612-bracket-phase-counting.test.cjs', 'adr-parser.test.cjs', 'adr-parser.unit.test.cjs', 'agent-marker-documentation-guard.test.cjs', @@ -103,6 +104,10 @@ const DOCS_GUARD_EXEMPT_BASELINE = [ * exact same scan. */ const DOCS_GUARD_EXEMPT_DOCS_PATHS = { + // #2761: cites docs/adr/612-bracket-phase-id-convention.md in an explanatory + // comment describing ADR-612's Decision 1; the file never reads that (or any) + // docs/ file — every read call it makes targets a tmpdir .planning fixture. + 'adr-612-bracket-phase-counting.test.cjs': ['docs/adr/612-bracket-phase-id-convention.md'], 'adr-parser.test.cjs': ['docs/adr/0001.md', 'docs/adr/0002.md', 'docs/adr/0010.md', 'docs/adr/NNNN.md'], 'adr-parser.unit.test.cjs': ['docs/adr/0001.md', 'docs/adr/0099.md', 'docs/adr/NNNN.md'], 'agent-marker-documentation-guard.test.cjs': ['docs/reference', 'docs/reference/workflow-fragments.md'], diff --git a/scripts/lint-phase-enumeration-drift.cjs b/scripts/lint-phase-enumeration-drift.cjs index c3e17995f..a21b3e3de 100644 --- a/scripts/lint-phase-enumeration-drift.cjs +++ b/scripts/lint-phase-enumeration-drift.cjs @@ -128,8 +128,10 @@ * `--include-archived` merge are phase LOCATION and archive * enumeration, not current-milestone enumeration; both legitimately * read the physical set. Its ENUMERATION path routes through the owner. - * - `src/roadmap-parser.cts` `getMilestonePhaseFilter` and its #3262-extracted - * set-building owner `scanMilestonePhaseIds` (the same two heading/ + * - `src/roadmap-parser.cts` `getMilestonePhaseFilter` and its shared + * set-building implementation `scanMilestonePhaseIdSets` (the public + * `scanMilestonePhaseIds` wrapper remains a directly iterable Set; the + * same two heading/ * #3577 `collectTablePhaseRows` — the table-scan sibling feeding the same * membership set; its local 999-only exclusion mirrors the owner's * deliberate NOT-isSentinelPhaseId choice (a leading 0 is a real decimal @@ -142,6 +144,18 @@ * ("00.1" is a real phase, not milestone 0). This scan asks a narrower * question — "which phase ids does this milestone's window declare" — * where only the 999 icebox range is excluded. + * - `src/state.cts` `countRoadmapPhaseHeadings`: its bracket branch composes + * bracket-milestone sentinel detection through `isSentinelPhaseId`, but + * retains a separate bare-token `999` rule. That rule intentionally does + * NOT use the convention-blind canonical predicate: the latter also reads + * leading `0`/`00.1` as milestone-0 sentinels, while a legacy-spelled bare + * zero token inside an opted-in bracket project is a real mid-migration + * phase. This is the state-counter twin of the roadmap-parser exemption. + * The guard has no per-detector exemption scope: it ORs the enumeration + * and sentinel detectors before consulting this function-only map. The + * whole-function exemption is therefore accepted; it is bounded because + * this counter performs no phases-directory `readdirSync` and needs the + * exemption only for its intentional bare-token `999` sentinel literal. * - `src/state.cts` `cmdStateValidate` ("Gate 1: Validate STATE.md against * filesystem"): resolves ONE directory — the disk match for STATE.md's * own `Current Phase` field — by prefix, a single-phase LOOKUP, not an @@ -300,10 +314,10 @@ const FUNCTION_SCOPED_EXEMPTIONS = new Map([ [path.join('src', 'phase.cts'), new Set(['cmdPhasesList', 'cmdPhaseNextDecimal', 'cmdPhasePlanIndex', 'cmdPhaseInsert', 'renameDecimalPhases', 'renameIntegerPhases', 'collectSiblingWorktreePhaseNums'])], [path.join('src', 'audit.cts'), new Set(['listAuditPhaseTargets'])], [path.join('src', 'commands.cts'), new Set(['cmdHistoryDigest'])], - [path.join('src', 'state.cts'), new Set(['cmdStateValidate', 'cmdStateSync', 'cmdStateRebuild'])], + [path.join('src', 'state.cts'), new Set(['cmdStateValidate', 'cmdStateSync', 'cmdStateRebuild', 'countRoadmapPhaseHeadings'])], [path.join('src', 'roadmap-upgrade.cts'), new Set(['computeMigrationPlan'])], [path.join('src', 'smart-entry.cts'), new Set(['detectVerifyFailed'])], - [path.join('src', 'roadmap-parser.cts'), new Set(['getMilestonePhaseFilter', 'scanMilestonePhaseIds', 'collectTablePhaseRows'])], + [path.join('src', 'roadmap-parser.cts'), new Set(['getMilestonePhaseFilter', 'scanMilestonePhaseIdSets', 'collectTablePhaseRows'])], [path.join('src', 'planning-snapshot.cts'), new Set(['buildAllPhaseDirNamesField'])], ]); diff --git a/scripts/lint-phase-id-drift.cjs b/scripts/lint-phase-id-drift.cjs index ff04bfa72..4737b62b9 100644 --- a/scripts/lint-phase-id-drift.cjs +++ b/scripts/lint-phase-id-drift.cjs @@ -53,6 +53,35 @@ const TOKEN_DRIFT_RE = /(?:\\{1,2}d|\[0-9\])\+\[A-Z(?:a-z)?\]\??\(\?:(?:\\{1,2}\ const OWNER_RE = /^\s*\/\/.*phase-id-owner:/; const CANON_REF = 'PHASE_NUMBER_TOKEN_SOURCE'; +// #2761 M3 (trek-e review): the SECOND grammar this seam owns — the BRACKET +// project-code class of `[CODE.MM]`, spelled `[A-Z][A-Z0-9_]*` (with its +// case-widened `[A-Za-z]`/`[A-Za-z0-9_]` variant tolerated so a trivial rewrite +// does not evade the rule). The token guard above only ever knew the phase- +// NUMBER grammar, so three files re-typed this class verbatim — roadmap-parser's +// bracket-fallback selector, state's `isMilestoneBounded`, verify's +// `checkBracketCoherence` — and `check:phase-id-drift` reported clean the whole +// time. That is the blind spot which let #2761's own "no token literal outside +// src/phase-id.cts" gate pass while being violated. Build from +// `BRACKET_PROJECT_CODE_SRC`, `BRACKET_ID_SRC`, `bracketMilestoneIntroSrcFor` +// or `BRACKET_MILESTONE_INTRO_CAPTURING_SRC` instead. +const BRACKET_CODE_DRIFT_RE = /\[A-Z(?:a-z)?\]\[A-Z(?:a-z)?0-9_\]\*/; + +// This rule has NO counterpart to the token rule's `line.includes(CANON_REF)` +// escape, and that omission is the point. +// +// That escape is LINE-level: a line naming the canonical source anywhere on it +// is taken as built-from-the-owner. verify.cts's copy read +// +// new RegExp(`^\\[[A-Z][A-Z0-9_]*\\.(${BRACKET_MILESTONE_NUMERIC_SRC})\\]`, 'i') +// +// — an owner reference for the MILESTONE field sharing a line with a re-typed +// PROJECT-CODE class. A line-level escape waves that through, so a bracket rule +// that copied it would have kept reporting clean on the very site under review. +// Partial ownership IS the drift. Only a `// phase-id-owner:` sanction +// suppresses this rule, and a sanction has to state which half is deliberate. +const BRACKET_OWNER_HINT = + 'BRACKET_PROJECT_CODE_SRC / BRACKET_ID_SRC / bracketMilestoneIntroSrcFor / BRACKET_MILESTONE_INTRO_CAPTURING_SRC'; + /** * Pure: find every literal re-derivation of the canonical phase-number token in * `text` that is NOT sanctioned. A site is sanctioned when the nearest preceding @@ -79,6 +108,28 @@ function findPhaseIdRegexDrift(text) { return out; } +/** + * Pure: find every literal re-derivation of the BRACKET project-code grammar in + * `text` that is NOT sanctioned. Same sanction mechanism as the token rule — a + * dedicated `// phase-id-owner:` comment on the nearest preceding non-blank + * line — but deliberately WITHOUT its line-level owner-reference escape, so a + * site that references the owner for one field while re-typing the other is + * still reported (see BRACKET_CODE_DRIFT_RE's note). Returns [{ line, found }]. + */ +function findBracketGrammarDrift(text) { + const out = []; + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + const m = BRACKET_CODE_DRIFT_RE.exec(lines[i]); + if (!m) continue; + let j = i - 1; + while (j >= 0 && lines[j].trim() === '') j--; // nearest preceding non-blank line + if (j >= 0 && OWNER_RE.test(lines[j])) continue; + out.push({ line: i + 1, found: m[0] }); + } + return out; +} + // Authored TypeScript source only (the generated bin/lib/*.cjs mirror it). const SCAN_DIRS = ['src']; const SCAN_EXT = new Set(['.cts', '.ts', '.mts']); @@ -104,6 +155,67 @@ function walk(dir, acc) { return acc; } +// ─── #2761 M4: the heading-baseline selector census ──────────────────────── +// +// `phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.)` is the other half +// of this seam: it decides which intro grammar a call site compiles, and the +// MODE argument is a fact about that site's history that no behavioural test +// can recover — flipping verify's milestone-complete site from LABEL_ONLY to +// ANY_BRACKET grants a tolerance it has never had, and every behavioural test +// still passes. Pinning it therefore requires reading the authored source. +// +// That reading lives HERE, not in the test suite. `tests/**` runs +// `local/no-source-grep` at ERROR, and its documented exemption +// (CONTEXT.md: RULESET.TESTS.no-source-grep.exemption) is reserved for tests +// whose subject is a runtime CONTRACT FILE — STATE.md, config.toml, +// hooks.json, agent .md — which `src/*.cts` is not. The suite had claimed that +// exemption anyway. Scripts are the sanctioned home for source scanning (the +// rule runs at `warn` in `scripts/**`, and this file already scans src/ for the +// grammar rules above), so the scan is exported as structured data and the test +// asserts on the returned census instead of on file text. +const SELECTOR_CALL_RE = /phaseHeadingPrefixSrcFor\(/g; +const SELECTOR_BASELINE_RE = /phaseHeadingPrefixSrcFor\(\s*PHASE_HEADING_BASELINE\.(ANY_BRACKET|LABEL_ONLY)/g; + +/** + * Pure: census the heading-baseline selector calls in `text`. + * + * `total` counts EVERY invocation, so a call that does not name a + * `PHASE_HEADING_BASELINE` member shows up as `total > ANY_BRACKET + + * LABEL_ONLY` — a hole in the pin rather than a silently uncounted site. + * Returns { ANY_BRACKET, LABEL_ONLY, total }. + */ +function countSelectorBaselines(text) { + const out = { ANY_BRACKET: 0, LABEL_ONLY: 0, total: 0 }; + for (const m of text.matchAll(SELECTOR_BASELINE_RE)) out[m[1]] += 1; + out.total = (text.match(SELECTOR_CALL_RE) || []).length; + return out; +} + +/** + * Scan the authored source tree and return the selector census keyed by + * repo-relative path, for every file that consumes the selector at least once. + * `phase-id.cts` is excluded: it DEFINES the selector, so its own occurrences + * are the declaration, not a consumer's choice of baseline. + */ +function scanSelectorBaselines(root) { + const census = {}; + for (const dir of SCAN_DIRS) { + for (const file of walk(path.join(root, dir), [])) { + const rel = path.relative(root, file); + if (EXEMPT.has(rel)) continue; + let text; + try { + text = fs.readFileSync(file, 'utf8'); + } catch { + continue; + } + const counts = countSelectorBaselines(text); + if (counts.total > 0) census[path.basename(file)] = counts; + } + } + return census; +} + /** * Scan the authored source tree and return every unsanctioned phase-token * re-derivation, each annotated with the repo-relative file path. @@ -121,7 +233,11 @@ function scanRepo(root) { continue; } for (const d of findPhaseIdRegexDrift(text)) { - violations.push({ file: rel, ...d }); + violations.push({ file: rel, kind: 'token', ...d }); + } + // #2761 M3: the bracket grammar is the second thing this seam owns. + for (const d of findBracketGrammarDrift(text)) { + violations.push({ file: rel, kind: 'bracket', ...d }); } } } @@ -132,19 +248,28 @@ function main() { const root = path.join(__dirname, '..'); const violations = scanRepo(root); if (violations.length === 0) { - process.stdout.write('ok phase-id-drift: no unsanctioned phase-token re-derivations outside phase-id.cts\n'); + process.stdout.write('ok phase-id-drift: no unsanctioned phase-token or bracket-grammar re-derivations outside phase-id.cts\n'); return; } - process.stderr.write('phase-id-drift: literal re-derivation(s) of the canonical phase-number token found.\n'); - process.stderr.write('Build the regex from phase-id.cjs `PHASE_NUMBER_TOKEN_SOURCE` (or phaseMarkdownRegexSource for a\n'); - process.stderr.write('known number), or sanction the site with a dedicated `// phase-id-owner: `\n'); - process.stderr.write('comment on the line directly above the regex:\n'); + process.stderr.write('phase-id-drift: literal re-derivation(s) of a canonical grammar found.\n'); + process.stderr.write(`Build the regex from phase-id.cjs \`${CANON_REF}\` (or phaseMarkdownRegexSource for a\n`); + process.stderr.write(`known number) for the phase-number token, or from ${BRACKET_OWNER_HINT}\n`); + process.stderr.write('for the bracket grammar — or sanction the site with a dedicated\n'); + process.stderr.write('`// phase-id-owner: ` comment on the line directly above the regex:\n'); for (const d of violations) { - process.stderr.write(` ${d.file}:${d.line} ${d.found}\n`); + process.stderr.write(` [${d.kind}] ${d.file}:${d.line} ${d.found}\n`); } process.exitCode = 1; } if (require.main === module) main(); -module.exports = { findPhaseIdRegexDrift, scanRepo, TOKEN_DRIFT_RE }; +module.exports = { + findPhaseIdRegexDrift, + findBracketGrammarDrift, + scanRepo, + countSelectorBaselines, + scanSelectorBaselines, + TOKEN_DRIFT_RE, + BRACKET_CODE_DRIFT_RE, +}; diff --git a/src/health-diagnostic-rules/phase-structure.cts b/src/health-diagnostic-rules/phase-structure.cts index e9d4274ce..9519458ef 100644 --- a/src/health-diagnostic-rules/phase-structure.cts +++ b/src/health-diagnostic-rules/phase-structure.cts @@ -64,7 +64,13 @@ type Rule = healthDiagnosticMod.Rule; // eslint-disable-next-line @typescript-eslint/no-require-imports import validateMod = require('../validate.cjs'); -const { phaseDirNameRe } = validateMod; +// #612: `isPhaseDirName` is the convention-SELECTED shape test wrapping +// `phaseDirNameRe`. Handed no convention it delegates to that very regex, so a +// legacy repo's W005 reading is byte-identical; handed 'bracket' it also +// recognizes `{CODE}.{MM}-{PP}-slug`, which `phaseDirNameRe` rejects outright — +// left un-threaded, W005 fires on EVERY phase directory of a repo that opted +// into the convention, i.e. the check inverts on exactly the repos PR-2 widens. +const { isPhaseDirName } = validateMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseIdMod = require('../phase-id.cjs'); @@ -75,7 +81,7 @@ const { extractPhaseToken, normalizePhaseName, comparePhaseNum } = phaseIdMod; function checkW005(snapshot: PlanningSnapshot): Diagnostic[] { const diagnostics: Diagnostic[] = []; for (const name of snapshot.phaseDirs.value) { - if (!name.match(phaseDirNameRe)) { + if (!isPhaseDirName(name, snapshot.phaseIdConvention)) { diagnostics.push({ code: 'W005', severity: SEVERITY.WARNING, diff --git a/src/health-diagnostic-rules/roadmap-disk-consistency.cts b/src/health-diagnostic-rules/roadmap-disk-consistency.cts index e109bd6c3..72cf19142 100644 --- a/src/health-diagnostic-rules/roadmap-disk-consistency.cts +++ b/src/health-diagnostic-rules/roadmap-disk-consistency.cts @@ -128,8 +128,16 @@ const { phaseVariants } = validateMod; * does the roadmap claim, for W007) call this — one matcher, reused, per the * file-level comment. */ -function dirsForPhase(dirs: string[], phaseId: string): string[] { - const canonical = matchPhaseDirs(dirs, normalizePhaseName(phaseId)).matches; +function dirsForPhase(dirs: string[], phaseId: string, convention: string | null): string[] { + // #612: `convention` reaches BOTH operands of this pairing or it pairs + // nothing. The phases come from a bracket-aware heading scan and the dirs + // from a bracket-aware disk scan, so a convention-less selector here leaves + // W006's existence check and W007's `claimedDirs` empty on exactly the repos + // both scans just widened — every bracket phase reads as "no directory on + // disk" AND every bracket directory reads as unclaimed. `matchPhaseDirs` + // forwards it to its primary `phaseTokenMatches`; handed nothing it is + // byte-identical, so no legacy repo's pairing changes. + const canonical = matchPhaseDirs(dirs, normalizePhaseName(phaseId), convention).matches; if (canonical.length > 0) return canonical; // Bug 2 (#3309 W006/W007 migration cluster, found while fixing the @@ -151,7 +159,12 @@ function dirsForPhase(dirs: string[], phaseId: string): string[] { // alone misses without re-deriving a second matcher. const variants = phaseVariants(phaseId); return dirs.filter((d) => { - const token = extractPhaseToken(d).toUpperCase(); + // #612: the same convention the primary matcher above got. This fallback + // compares a DIRECTORY-derived token against phase-id variants, so left + // convention-less it reads `GSD.02-05-slug` with the legacy grammar and can + // never agree with a bracket phase id — the fallback would then be dead on + // precisely the convention it is reached for. + const token = extractPhaseToken(d, convention).toUpperCase(); for (const variant of variants) { if (token === variant.toUpperCase()) return true; } @@ -187,15 +200,40 @@ function checkW006(snapshot: PlanningSnapshot): Diagnostic[] { if (snapshot.roadmapDeclaredPhases.scope !== SCOPE.COMPLETE) return []; const dirs = snapshot.allPhaseDirNames.value; + const convention = snapshot.phaseIdConvention; const archivedTokens = new Set(snapshot.archivedPhaseTokens.value); + // No separate scope guard: `roadmapSentinelPhaseTokens` and + // `roadmapDeclaredPhases` are produced by ONE builder call + // (`buildRoadmapDeclaredPhasesField`) from ONE `buildRoadmapPhaseVariants` + // result, so their scopes are yoked by construction — the COMPLETE check + // above covers both. Stated rather than left implicit, because reading the + // sentinel set unconditionally would be a latent bug the day the two fields + // acquire independent builders: an UNREADABLE sentinel set silently degrades + // to "nothing is a sentinel," which ADDS W006 warnings rather than dropping + // them. + const sentinelTokens = new Set(snapshot.roadmapSentinelPhaseTokens.value); const checkboxes = snapshot.roadmapPhaseCheckboxes.value; const diagnostics: Diagnostic[] = []; for (const { phaseId } of snapshot.roadmapDeclaredPhases.value) { - // #3225: sentinel phase ids (999.x/0.x) are never-on-roadmap by - // convention; a sentinel heading shouldn't demand a directory. - if (isSentinelPhaseId(phaseId)) continue; - if (dirsForPhase(dirs, phaseId).length > 0) continue; + // A backlog/icebox phase legitimately has no directory. The two guards are + // DISJOINT, which is why both are needed and neither subsumes the other: + // + // #612 `roadmapSentinelPhaseTokens` — bracket sentinels. Under the + // bracket convention the sentinel-ness lives in the MILESTONE, not + // the token: the heading `### [GSD.999] 07:` yields phase id `07`, + // so a bare-token test cannot see it. The snapshot's + // `buildRoadmapPhaseVariants` call carries the bracket id alongside + // and reports the token here. Empty unless the bracket convention + // is active, so no legacy repo's reading changes. + // #3225 `isSentinelPhaseId` — the legacy/bare leading-int rule (999.x/0.x), + // which sees `### Phase 999:` and is blind to `[GSD.999] 07`. + // + // Upstream's call is kept one-arg verbatim: every `phaseId` here is a bare + // token, never a `{CODE}.{MM}`-qualified id, so a convention argument could + // not change its result. + if (sentinelTokens.has(phaseId) || isSentinelPhaseId(phaseId)) continue; + if (dirsForPhase(dirs, phaseId, convention).length > 0) continue; // Bug 1 (#3309 W006/W007 migration cluster): a phase whose ONLY // directory lives under a milestone archive // (`.planning/milestones/vX.Y-phases//`, shipped OR the current @@ -225,10 +263,11 @@ function checkW006(snapshot: PlanningSnapshot): Diagnostic[] { function computeClaimedDirs( dirs: string[], declaredPhases: { phaseId: string; milestone: string | null }[], + convention: string | null, ): Set { const claimed = new Set(); for (const { phaseId } of declaredPhases) { - for (const dir of dirsForPhase(dirs, phaseId)) claimed.add(dir); + for (const dir of dirsForPhase(dirs, phaseId, convention)) claimed.add(dir); } return claimed; } @@ -238,7 +277,8 @@ function checkW007(snapshot: PlanningSnapshot): Diagnostic[] { if (snapshot.roadmapDeclaredPhases.scope !== SCOPE.COMPLETE) return []; const dirs = snapshot.allPhaseDirNames.value; - const claimedDirs = computeClaimedDirs(dirs, snapshot.roadmapDeclaredPhases.value); + const convention = snapshot.phaseIdConvention; + const claimedDirs = computeClaimedDirs(dirs, snapshot.roadmapDeclaredPhases.value, convention); const diagnostics: Diagnostic[] = []; for (const dirName of dirs) { @@ -246,7 +286,11 @@ function checkW007(snapshot: PlanningSnapshot): Diagnostic[] { // (`src/validate.cts:73-76`) is documented to match exactly // (verify.cts's original `p` key from `collectDiskPhaseEntries`, // `verify.cts:1373-1397`) — same token, relocated read, not reinvented. - const token = extractPhaseToken(dirName); + // #612: `collectDiskPhaseEntries` keyed that map through the + // convention-aware extractor, so the token this message names must be + // derived the same way — otherwise a bracket repo's unclaimed + // `GSD.02-05-slug` is reported as phase `GSD.02` rather than `05`. + const token = extractPhaseToken(dirName, convention); // #3225: a sentinel dir on disk (999-interim, 0-drafts) is defined as // never-on-roadmap; it must not trigger W007. #3639: judged on the DIR // NAME via the dir-aware recognizer — the extracted token is diff --git a/src/health-diagnostic-rules/state-consistency.cts b/src/health-diagnostic-rules/state-consistency.cts index a141f35e4..c7ac8630c 100644 --- a/src/health-diagnostic-rules/state-consistency.cts +++ b/src/health-diagnostic-rules/state-consistency.cts @@ -125,7 +125,13 @@ const RULE_W024: Rule = { function buildValidPhaseSet(snapshot: PlanningSnapshot): Set { const valid = new Set(); for (const dir of snapshot.phaseDirs.value) { - const token = extractPhaseToken(dir); + // #612: the pre-migration source of this third was + // `collectDiskPhases(planBase, convention)`, whose keys came from the + // convention-aware extractor. Left convention-less here, a bracket repo's + // `GSD.02-05-slug` contributes `GSD.02` instead of `05`, so every STATE.md + // reference to a real phase falls outside the valid set and W002 fires on + // all of them. + const token = extractPhaseToken(dir, snapshot.phaseIdConvention); if (token) valid.add(token); } for (const entry of snapshot.roadmapDeclaredPhases.value) { @@ -253,30 +259,77 @@ const RULE_W021: Rule = { "Phase's integer prefix implies a different milestone than its ROADMAP section (phase_id_convention: milestone-prefixed)", repairable: false, check: (snapshot: PlanningSnapshot): Diagnostic[] => { - const convention = snapshot.config.value?.['phase_id_convention']; - if (convention !== 'milestone-prefixed') return []; - const diagnostics: Diagnostic[] = []; - for (const entry of snapshot.roadmapDeclaredPhases.value) { - // `entry.milestone === null` means the builder never found this phase - // heading inside any versioned (`v\d+\.\d+`) section — the original - // `checkMilestonePrefixMismatches` only ever iterates phases found - // WITHIN a section, so a phase outside any section is equivalently - // never checked here. - if (entry.milestone === null) continue; - const expectedMilestone = getMilestoneFromPhaseId(entry.phaseId); - if (expectedMilestone === null || expectedMilestone === entry.milestone) continue; - diagnostics.push({ - code: 'W021', - severity: SEVERITY.WARNING, - message: `Phase ${entry.phaseId}: integer prefix implies ${expectedMilestone} but listed under ${entry.milestone}`, - remedy: adviseRemedy('gsd-tools roadmap upgrade --convention milestone-prefixed'), - }); + + // Two INDEPENDENT gates, evaluated in the order the pre-migration + // `cmdValidateHealth` block emitted them (milestone-prefixed first, then + // bracket). They are deliberately NOT written as if/else: the two read + // DIFFERENT config bases and can both be true at once. + // + // milestone-prefixed — `snapshot.config` (ROOT config only), exactly as + // this gate has always read it. Federating THIS one silently moved a + // legacy convention's answer in BOTH directions on workstream repos: a + // W021 that fires at base vanishing, and one silent at base firing. + // bracket — `snapshot.phaseIdConvention` (federated workstream -> root), + // because the bracket reads this half reports on are themselves + // federated; gating it on the root answer would split the check against + // the reads it describes. + // + // A workstream that overrides a `milestone-prefixed` ROOT to `bracket` is + // the case that makes the distinction observable, and it is the reason an + // early `return` here would be a bug: the root-configured M-NN gate must + // still fire, or the repo looks healthier than it is. + const rootConvention = snapshot.config.value?.['phase_id_convention']; + if (rootConvention === 'milestone-prefixed') { + diagnostics.push(...checkMilestonePrefixW021(snapshot)); } + + // #612: an ADDITIVE sibling branch — the milestone-prefixed gate above is + // untouched. Gated strictly on the ACTIVE convention, never on + // project_code presence: reads are selected per convention, and a CHECK + // that can fail a repo runs only for the convention that repo opted into. + if (snapshot.phaseIdConvention === 'bracket') { + for (const mm of snapshot.roadmapBracketIncoherences.value) { + diagnostics.push({ + code: 'W021', + severity: SEVERITY.WARNING, + message: + mm.kind === 'missing-bracket' + ? `Phase ${mm.phaseId}: heading is not in bracket form under the 'bracket' convention (expected \`[CODE.${mm.sectionMilestone}] ${mm.phaseId}:\`)` + : `Phase ${mm.phaseId}: bracket milestone ${mm.phaseMilestone} does not match its section milestone ${mm.sectionMilestone}`, + remedy: adviseRemedy( + 'Bracket migration lands with the #612 migrator (PR-3); until then, manually align the bracket milestone in this heading to match the enclosing section.', + ), + }); + } + } + return diagnostics; }, }; +/** The milestone-prefixed half of W021, unchanged by #612. */ +function checkMilestonePrefixW021(snapshot: PlanningSnapshot): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + for (const entry of snapshot.roadmapDeclaredPhases.value) { + // `entry.milestone === null` means the builder never found this phase + // heading inside any versioned (`v\d+\.\d+`) section — the original + // `checkMilestonePrefixMismatches` only ever iterates phases found + // WITHIN a section, so a phase outside any section is equivalently + // never checked here. + if (entry.milestone === null) continue; + const expectedMilestone = getMilestoneFromPhaseId(entry.phaseId); + if (expectedMilestone === null || expectedMilestone === entry.milestone) continue; + diagnostics.push({ + code: 'W021', + severity: SEVERITY.WARNING, + message: `Phase ${entry.phaseId}: integer prefix implies ${expectedMilestone} but listed under ${entry.milestone}`, + remedy: adviseRemedy('gsd-tools roadmap upgrade --convention milestone-prefixed'), + }); + } + return diagnostics; +} + // ─── W026 — STATE says milestone complete but ROADMAP lists unstarted phase ─ const RULE_W026: Rule = { @@ -302,7 +355,15 @@ const RULE_W026: Rule = { // unwindowed `phaseDirNames2` (`verify.cts:2372-2382`, a direct // `readdirSync` of the phases dir), not the current-milestone-windowed // `phaseDirs`. - const hasDirectory = matchPhaseDirs(snapshot.allPhaseDirNames.value, normalized).matches.length > 0; + // #612: the directory read widens with the heading read that produced + // `currentMilestoneRoadmapPhaseIds`, or every bracket phase resolves to + // nothing, reads as unstarted, and this UNGATED warning false-fires on + // any bracket repo whose STATE says the milestone is complete. #2528 + // moved the read onto the shared selector; the convention rides along, + // since the selector's primary match IS the `phaseTokenMatches` call this + // line used to make directly. + const hasDirectory = + matchPhaseDirs(snapshot.allPhaseDirNames.value, normalized, snapshot.phaseIdConvention).matches.length > 0; if (!hasDirectory) unstarted.push(phaseId); } if (unstarted.length === 0) return []; diff --git a/src/milestone.cts b/src/milestone.cts index 685111744..ce78418a4 100644 --- a/src/milestone.cts +++ b/src/milestone.cts @@ -58,7 +58,7 @@ const { scanPhasePlans } = planScanMod; // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module import phaseLocatorMod = require('./phase-locator.cjs'); const { listMilestonePhaseDirs } = phaseLocatorMod; -const { planningPaths } = planningWorkspace; +const { planningPaths, resolvePhaseIdConvention } = planningWorkspace; const { extractFrontmatter } = frontmatterMod; // ADR-3408 §8.3 / #3469: `writeStateMd` gets sync and NO preservation — the // same #3374-shaped exposure the milestone-complete write used to carry (a @@ -791,6 +791,23 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo let totalTasks = 0; const accomplishments: string[] = []; + // #2761 (round-11 BLOCKER): resolved ONCE, ambiently (no explicit `ws` — + // this function has none of its own and already resolves everything else + // off `cwd` via planningPaths(cwd), matching the ambient-workstream + // contract `resolvePhaseIdConvention`/`planningDir` share), and threaded + // into the single `listMilestonePhaseDirs` call directly below. Left + // unthreaded, `phaseIdConvention` stays `undefined` and + // `getMilestonePhaseFilter` silently resolves it FROM CONFIG itself — + // which is exactly the behavior the changeset originally (and wrongly) + // claimed this PR does not reach: on a bracket-convention project that + // lazily-resolved convention changes `milestonePhaseDirs`/ + // `milestonePhaseScope` below, and this function goes on to ARCHIVE + // (rename/move) whatever that set names. Resolving explicitly here removes + // the silent-inherit path without changing behavior for `null` / + // `milestone-prefixed` projects, whose resolved convention is the same + // value `undefined` would have lazily produced anyway. + const phaseConvention = resolvePhaseIdConvention(cwd); + // #3597 (ADR-3180 Decision 2): SINGLE resolution of "which phase // directories belong to the current milestone" AND the SCOPE discriminator // that resolution came from — shared verbatim by the read-only stats loop @@ -804,7 +821,11 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo // disk, same as before #3597. Only the destructive archive pass (and its // --dry-run preview) refuses to act on a non-COMPLETE (non-answer) scope; // see the guard built from `milestonePhaseScope` further down. - const { value: milestonePhaseDirs, scope: milestonePhaseScope } = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: version }); + const { value: milestonePhaseDirs, scope: milestonePhaseScope } = listMilestonePhaseDirs(phasesDir, { + cwd, + versionOverride: version, + phaseIdConvention: phaseConvention, + }); try { for (const dir of milestonePhaseDirs) { diff --git a/src/phase-id.cts b/src/phase-id.cts index 3240ed43e..4335e5bfd 100644 --- a/src/phase-id.cts +++ b/src/phase-id.cts @@ -170,8 +170,171 @@ const BRACKET_PHASE_TOKEN_SOURCE = // followed by a `Phase ` label) or a bare `Phase ` label; a bare number is NOT // a phase-heading intro. The `[^\]]{1,200}` bound mirrors the existing // roadmap-parser heading regexes (ReDoS-safe: a header is one short line). +// +// Retained as PR-1 shipped it. PR-2 does not consume it — see the gated +// selector below, which supersedes it — and it is left byte-identical so an +// already-merged epic export does not change its accepted language. const PHASE_HEADING_PREFIX_SRC = '(?:\\[[^\\]]{1,200}\\]\\s*(?:Phase\\s+)?|Phase\\s+)'; +// ── #612 PR-2: the ONE bracket identity grammar ───────────────────────────── +// A bracket phase-ID prefix is `{CODE}.{MM}`. Before this, three spellings of +// that shape lived in this file and disagreed: extractPhaseToken's bracket +// branch (`\d+`, case-sensitive), bracketQualifiedKey (`\d+`, mixed-case), and +// the dir prefix (`\d{2,}`). `GSD.2-05-feature` was simultaneously "not a phase +// directory" and "phase 05", depending on which one a caller reached. They are +// now one source. +// +// The milestone width mirrors the EMIT grammar rather than accepting any digit +// run: pad2() emits at least two digits, so two digits — or three-plus with no +// leading zero — is what toDir can produce. A bare `0` is NOT admitted: the +// padded `00` is the backlog sentinel's canonical identity and `\d{2}` already +// covers it, so nothing needs the unpadded spelling. (An earlier revision of +// this comment claimed the opposite; the constant below has always rejected it +// — #2867 review, Minor 2.) `[GSD.2] 05:` is therefore NOT a bracket id — +// which is the point: it is the shape that made the three spellings disagree. +// Reconciled with BRACKET_CANONICAL_NUMERIC_SOURCE above — the width toDir +// actually emits (pad2: 2 digits, or 3+ with no leading zero). The earlier +// `(?:\d{2,}|0)` diverged from it in both directions: it admitted `002`, which +// the emit validator rejects, and a bare `0` that pad2 never produces. Every +// bracket-milestone recognizer now derives from this one constant — the section +// recognizers previously spelled `0*N` or `\d+` and accepted `[GSD.2]`, which +// SCOPED a milestone no phase heading could then resolve into, recreating the +// on-disk-count fallback this PR exists to remove. +// +// An unpadded bracket is therefore MALFORMED, uniformly: it scopes nothing, +// bounds nothing, sections nothing, and is not a phase id. W005 on its +// directories is the signal that surfaces it. +const BRACKET_MILESTONE_NUMERIC_SRC = BRACKET_CANONICAL_NUMERIC_SOURCE; + +// #2761 M3 (trek-e review): the bracket PROJECT-CODE class, as a named source +// rather than a class this file (and three readers outside it) each re-typed. +// The re-typed copies were the exact drift #2761's own gate forbids — "no token +// literal outside src/phase-id.cts" — and `check:phase-id-drift` did not see +// them, because its detector only knew the phase-NUMBER token grammar. The +// guard now carries a bracket rule too (scripts/lint-phase-id-drift.cjs). +const BRACKET_PROJECT_CODE_SRC = '[A-Z][A-Z0-9_]*'; +const BRACKET_ID_SRC = `${BRACKET_PROJECT_CODE_SRC}\\.${BRACKET_MILESTONE_NUMERIC_SRC}`; + +// The bracket MILESTONE INTRO — `[CODE.MM]` — in the two shapes its readers +// need. Both were re-typed verbatim outside this module before #2761 M3: +// +// * PINNED, to one already-resolved milestone integer. This owns the pad2 +// spelling rule as well as the grammar, so "canonical spelling only, not +// `0*N`" (the rule that keeps `[GSD.2]` from scoping a milestone no phase +// heading can resolve into) lives in ONE place instead of being restated +// beside every regex. `roadmap-parser`'s bracket-fallback selector and +// `state`'s `isMilestoneBounded` both consume this. +// +// * CAPTURING, over any milestone, putting the milestone digits in a group. +// `validate`'s `checkBracketCoherence` consumes this. +// +// The milestone argument is expected to be a safe integer — every caller +// resolves it through `Number.isSafeInteger` first. A non-integer yields a +// regex source that is still well-formed and simply matches nothing, which is +// the same safe degrade the callers' own guards produce. +function bracketMilestoneIntroSrcFor(milestone: number): string { + return `\\[${BRACKET_PROJECT_CODE_SRC}\\.${String(milestone).padStart(2, '0')}\\]`; +} +const BRACKET_MILESTONE_INTRO_CAPTURING_SRC = + `\\[${BRACKET_PROJECT_CODE_SRC}\\.(${BRACKET_MILESTONE_NUMERIC_SRC})\\]`; + +// Recognition is case-INSENSITIVE (every reader compiles `/i`), but the identity +// helpers this file owns — isSentinelPhaseId, getMilestoneFromPhaseId, +// bracketQualifiedKey — match `[A-Z]` case-SENSITIVELY. A lowercase bracket id +// captured by a reader and handed straight to them silently fails every identity +// test, which is how `### [gsd.999] 07:` leaked into phase counts as a real +// phase. Fold before any identity operation; never fold for display. +function foldBracketId(bracketId: unknown): string { + return String(bracketId).toUpperCase(); +} + +// The identity recognizers every bracket helper in this file shares. +// `BRACKET_ID_PREFIX_RE` is applied to an ALREADY-FOLDED string, so it needs no +// `/i`; the other two see raw dir names and carry it. +// The milestone field must END at the phase separator. Without the boundary the +// width alternation matched a PREFIX of a malformed run — `GSD.002-01` matched +// its leading `00` and read as a sentinel. +const BRACKET_ID_PREFIX_RE = new RegExp(`^${BRACKET_PROJECT_CODE_SRC}\\.(${BRACKET_MILESTONE_NUMERIC_SRC})(?=-|$)`); +const BRACKET_DIR_PREFIX_SRC = `${BRACKET_ID_SRC}-`; +// The trailing `(?=-|$)` is what makes the recognizer and the resolver agree on +// REJECTED input, not just accepted input. Without it `GSD.02-12A-hotfix` +// resolves to token `12` here while the directory recognizer calls the name +// malformed — so W005 reports it malformed in the same run that the +// milestone-complete check treats it as a real phase directory. +const BRACKET_DIR_TOKEN_RE = new RegExp(`^${BRACKET_DIR_PREFIX_SRC}(\\d+(?:\\.\\d+)?)(?=-|$)`, 'i'); +// Same width rule, same `(?=-|$)` boundary and same single-sub-phase shape as +// BRACKET_DIR_TOKEN_RE. Without them a qualified query `GSD.02-12` matched the +// directory `GSD.02-12A-hotfix` — which isPhaseDirName calls malformed — and +// phaseTokenMatches returns UNCONDITIONALLY on a qualified hit, so that +// disagreement would have been final rather than a fall-through. +const BRACKET_QUALIFIED_KEY_RE = new RegExp( + `^(${BRACKET_PROJECT_CODE_SRC})\\.(${BRACKET_MILESTONE_NUMERIC_SRC})-(\\d+(?:\\.\\d+)?)(?=-|$)`, 'i', +); + +// ── #612 PR-2: gated heading-intro selection ──────────────────────────────── +// The two intro spellings that exist upstream TODAY, transcribed verbatim from +// the call sites. A repo that has not opted into the bracket convention +// compiles exactly these — not a superset of them, THEM — so its reads are +// structurally identical to the base build rather than argued equivalent. +// tests/adr-612-bracket-heading-selection.test.cjs asserts that byte-equality +// against its own independently transcribed copies of the call-site literals. +const BASE_ANY_BRACKET_HEADING_PREFIX_SRC = '(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+'; +const BASE_PHASE_LABEL_PREFIX_SRC = 'Phase\\s+'; + +// Which of those two a site spells at base. Passed explicitly rather than +// inferred, because the choice is a fact about the call site's history that no +// amount of looking at the widened pattern can recover. +const PHASE_HEADING_BASELINE = Object.freeze({ + /** Site already tolerates `[anything] Phase N` — roadmap headings, validate's heading scanner. */ + ANY_BRACKET: 'any-bracket', + /** Site spells a bare `Phase N` with no bracket tolerance — checklist bullets, the counters. */ + LABEL_ONLY: 'label-only', +}); + +/** + * The heading-intro source a site should compile, given the resolved + * `phase_id_convention`. + * + * NON-bracket conventions (null, undefined, 'milestone-prefixed', or any + * unrecognized value) return the site's BASE spelling unchanged. This is the + * whole design: PR-2 originally widened these reads ungated and argued the new + * shape "cannot occur in a legacy ROADMAP", which is false — `### [RFC.2119] 5:`, + * `### [v1.0] 2024:` and `### [ADR.612] 3:` are all legal legacy headings that + * the widened form claims as phases, moving phase_count, total_phases and W006 + * on repos that never opted in. Selection at construction time removes the + * argument entirely: there is nothing to reason about, because a non-bracket + * repo compiles the same source string it compiled before. + * + * `capturing` adds EXACTLY ONE group, at position 1, holding the bracket id — + * `undefined` whenever a non-bracket alternative matched. Sites that filter + * sentinels need it: READING-B puts the sentinel milestone in the bracket, so + * testing the phase token alone is blind to `### [GSD.999] 01:`. + * + * Pure: takes the resolved convention, never reads config. + */ +function phaseHeadingPrefixSrcFor( + baseline: string, + convention?: string | null, + capturing = false, +): string { + const base = baseline === PHASE_HEADING_BASELINE.ANY_BRACKET + ? BASE_ANY_BRACKET_HEADING_PREFIX_SRC + : BASE_PHASE_LABEL_PREFIX_SRC; + if (convention !== 'bracket') return base; + const id = capturing ? `(${BRACKET_ID_SRC})` : BRACKET_ID_SRC; + // `[ \t]*` not `\s*`: `\s` spans newlines, so a bracket-terminated heading + // followed by a blank line and a digit-leading prose line read as one phase. + // BOTH bracket forms are admitted at both baselines, and both CAPTURE. The + // any-bracket base already matches `[GSD.999] Phase 07:` on its own — but + // through the base alternative, which captures nothing, so the reader saw + // `bracketId === undefined`, fell back to the legacy leading-integer rule, and + // counted a labeled icebox heading as a real phase while the label-less form + // beside it was excluded. Two derivations of one ROADMAP disagreed. The + // bracket alternative is tried FIRST so it wins the capture. + const bracketAlt = `\\[${id}\\][ \\t]*(?:Phase\\s+|(?=\\d))`; + return `(?:${bracketAlt}|${base})`; +} + function stripProjectCodePrefix(value: unknown, caseInsensitive = true): string { const input = String(value); const re = caseInsensitive ? PROJECT_CODE_PREFIX_STRIP_RE_I : PROJECT_CODE_PREFIX_STRIP_RE; @@ -216,9 +379,9 @@ function getMilestoneFromPhaseId(phaseId: unknown, convention?: string): string // pure (no config read) and backward-compatible: every existing single-arg // caller resolves to the unchanged READING-A body. if (convention === 'bracket') { - const b = String(phaseId).match(/^([A-Z][A-Z0-9_]*)\.(\d+)/); + const b = foldBracketId(phaseId).match(BRACKET_ID_PREFIX_RE); if (!b) return null; - const mm = parseInt(b[2], 10); + const mm = parseInt(b[1], 10); if (SENTINEL_RANGES.includes(mm)) return null; // sentinel milestones have no real milestone return `v${mm}.0`; } @@ -438,8 +601,35 @@ function isSentinelPhaseId(phaseId: unknown, convention?: string): boolean { // convention-less caller uses the legacy/bare leading-int rule below, so no // existing reader gains a false positive; the bracket reading is opt-in. if (convention === 'bracket') { - const bracket = s.match(/^[A-Z][A-Z0-9_]*\.(\d+)/); // bracket: milestone in the prefix + // #612 PR-2: fold before matching. Readers recognize headings under `/i`, so + // a lowercase `[gsd.999]` arrives verbatim; the case-sensitive class below + // then failed to match and an icebox item counted as a real phase. + const bracket = foldBracketId(s).match(BRACKET_ID_PREFIX_RE); if (bracket) return SENTINEL_RANGES.includes(parseInt(bracket[1], 10)); + // #2761 round-12: NO bracket tag matched — a bare/untagged id under + // bracket convention (`0-bootstrap`, a directory with no `{CODE}.{MM}-` + // prefix, or a legacy-spelled `### Phase 0:` heading routed here without + // its own bracketId guard). Falling through to the LEGACY leading-int rule + // below would read this bare `0` as sentinel milestone 0 — but under + // bracket convention milestone 0 is expressed ONLY via an explicit + // bracket tag, so an untagged leading `0` is a real phase token, not a + // sentinel. This mirrors the two HEADING-side counters that already carry + // this exact carve-out: state.cts's `countRoadmapPhaseHeadings` guards its + // bare-0 exclusion with `bracketId &&` (so an untagged `Phase 0:` heading + // is counted), and roadmap-parser.cts's `scanMilestonePhaseIds` composes + // the bare-token rule as 999-only, deliberately NOT this predicate, for + // the identical reason (#3185: the leading-int rule also swallows the + // #2554 decimal ids — `0.5-bootstrap` is a real phase, not milestone 0). + // The 999/icebox reading stays universal either way — an untagged `999` + // is still backlog under every convention — so only that half of + // SENTINEL_RANGES applies here. + // + // `isSentinelPhaseDir` remains deliberately convention-blind for its + // warning-only disk guards and may conservatively classify a bare `0` + // directory as sentinel. This branch has an explicit resolved convention + // and feeds counts/archives, so it must retain the more precise reading. + const bare = stripProjectCodePrefix(s).match(/^0*(\d+)/); + return bare !== null && parseInt(bare[1], 10) === 999; } const legacy = stripProjectCodePrefix(s).match(/^0*(\d+)/); // legacy/bare: leading int if (!legacy) return false; @@ -639,7 +829,7 @@ function derivePhaseTokenSegments( /** * Extract the phase token from a directory name. */ -function extractPhaseToken(dirName: string, convention?: string): string { +function extractPhaseToken(dirName: string, convention?: string | null): string { // #612 bracket dir form `{CODE}.{MM}-{PP}[.{SS}]-slug` → phase token `PP[.SS]`. // GATED on convention === 'bracket' (mirrors getMilestoneFromPhaseId's READING-B // decision above). A bracket dir `{CODE}.{MM}-{PP}` is string-INDISTINGUISHABLE @@ -654,7 +844,10 @@ function extractPhaseToken(dirName: string, convention?: string): string { // config read). The captured token is dot-only (`PP[.SS]`); the milestone↔phase // hyphen and any trailing plan/slug are excluded. if (convention === 'bracket') { - const bracketDir = dirName.match(/^[A-Z][A-Z0-9_]*\.\d+-(\d+(?:\.\d+)?)/); + // #612 PR-2: built from the ONE bracket identity grammar, not a private + // spelling. Case-insensitive to match how the readers recognize headings and + // directories; the milestone width is the emit grammar's. + const bracketDir = dirName.match(BRACKET_DIR_TOKEN_RE); if (bracketDir) return bracketDir[1]; } @@ -932,9 +1125,46 @@ function scopeToPhase(fileNames: string[], phaseDirName: string): string[] { } /** - * Check if a directory name's phase token matches the normalized phase exactly. + * Canonical comparable key for a milestone-qualified bracket id or dir name. + * Lifts the milestone out of the `{CODE}.{MM}-` prefix so a flat multi-milestone + * layout disambiguates: `CK.03-02` resolves to its OWN milestone's directory, + * never the first same-numbered directory of another milestone. + * + * Returns null for UNQUALIFIED ids (`02`, `HQ-11`, `11.01`) so callers fall back + * to bare-token matching unchanged, and GATED on convention === 'bracket' for + * the same reason as extractPhaseToken: the qualified key is padding- + * INSENSITIVE where the legacy token path is padding-SENSITIVE, so ungated it + * silently widens matching on legacy repos. */ -function phaseTokenMatches(dirName: string, normalized: string): boolean { +function bracketQualifiedKey(s: string, convention?: string | null): string | null { + if (convention !== 'bracket') return null; + const m = String(s).match(BRACKET_QUALIFIED_KEY_RE); + if (!m) return null; + const milestone = parseInt(m[2], 10); + // A milestone integer past Number's exact range collapses to Infinity, and + // every such id would then share one key. Refuse rather than collide. + if (!Number.isSafeInteger(milestone)) return null; + const phase = m[3].split('.').map(n => parseInt(n, 10)); + if (phase.some(n => !Number.isSafeInteger(n))) return null; + return `${foldBracketId(m[1])}.${milestone}-${phase.join('.')}`; +} + +/** + * Check if a directory name's phase token matches the normalized phase exactly. + * + * The optional `convention` is the ADR-2121 additive shape: every existing + * two-argument call site resolves to the unchanged legacy body. + */ +function phaseTokenMatches(dirName: string, normalized: string, convention?: string | null): boolean { + if (convention === 'bracket') { + // A milestone-qualified query compares on the full qualified key, and + // returns unconditionally: falling through on a miss would re-admit the + // cross-milestone match the qualification exists to prevent. + const qKey = bracketQualifiedKey(normalized, convention); + if (qKey) return bracketQualifiedKey(dirName, convention) === qKey; + const bracketToken = extractPhaseToken(dirName, convention); + if (bracketToken.toUpperCase() === normalized.toUpperCase()) return true; + } const token = extractPhaseToken(dirName); if (token.toUpperCase() === normalized.toUpperCase()) return true; const stripped = stripProjectCodePrefix(dirName); @@ -1032,8 +1262,8 @@ const unpad = (digits: string): string => digits.replace(/^0+(?=\d)/, ''); * the directory's leading digit run instead of `extractPhaseToken` (whose * token for these dirs is the mis-absorbed multi-segment form). */ -function matchPhaseDirs(dirs: string[], normalized: string): { matches: string[]; usedBareFallback: boolean } { - const primary = dirs.filter(d => phaseTokenMatches(d, normalized)); +function matchPhaseDirs(dirs: string[], normalized: string, convention?: string | null): { matches: string[]; usedBareFallback: boolean } { + const primary = dirs.filter(d => phaseTokenMatches(d, normalized, convention)); if (primary.length > 0) return { matches: primary, usedBareFallback: false }; const bare = String(normalized); @@ -1096,9 +1326,18 @@ function phaseKeyFromToken(token: unknown): string { /** * Canonical key for a phase DIRECTORY name (`"05-schedule-8"` → `"05"`, * `"PROJ-5-x"` → `"05"`, `"30.1-follow-up"` → `"30.1"`). + * + * #612: `convention` is forwarded to `extractPhaseToken`, which needs that signal + * to read a bracket directory (`"GSD.02-05-delta"` → `"05"`) — a bracket dir is + * string-indistinguishable from the legacy letter-prefixed-decimal family, so the + * extractor refuses to guess. Optional and defaulted-absent, so every pre-#612 + * call site resolves byte-identically to prior behaviour. Without it a bracket dir + * yields its whole name as the key and never matches the ROADMAP entry it names — + * #2562's own defect class reached from the other side: both sides of a comparison + * must be derived not merely by the same function but under the same convention. */ -function phaseKeyFromDir(dirName: string): string { - return phaseKeyFromToken(extractPhaseToken(dirName)); +function phaseKeyFromDir(dirName: string, convention?: string | null): string { + return phaseKeyFromToken(extractPhaseToken(dirName, convention)); } /** @@ -1254,6 +1493,18 @@ export = { isPhaseContinuationSegment, BRACKET_PHASE_TOKEN_SOURCE, PHASE_HEADING_PREFIX_SRC, + BRACKET_ID_SRC, + BRACKET_PROJECT_CODE_SRC, + bracketMilestoneIntroSrcFor, + BRACKET_MILESTONE_INTRO_CAPTURING_SRC, + BRACKET_MILESTONE_NUMERIC_SRC, + BRACKET_DIR_PREFIX_SRC, + BASE_ANY_BRACKET_HEADING_PREFIX_SRC, + BASE_PHASE_LABEL_PREFIX_SRC, + PHASE_HEADING_BASELINE, + phaseHeadingPrefixSrcFor, + foldBracketId, + bracketQualifiedKey, stripProjectCodePrefix, normalizePhaseName, getMilestoneFromPhaseId, diff --git a/src/phase.cts b/src/phase.cts index e2a0658fd..734b9da2f 100644 --- a/src/phase.cts +++ b/src/phase.cts @@ -86,9 +86,12 @@ const { readVerificationStatus } = verificationMod; import planDependencyGraphMod = require('./plan-dependency-graph.cjs'); const { computeHaltPropagation, buildSummaryFileIndex, isSummaryFileHalted, isSummaryFileBlocked } = planDependencyGraphMod; +// #612: `resolvePhaseIdConvention` selects the write-time milestone-scope +// guard's terminator vocabulary (see assertDescriptionPreservesMilestoneScope). const { planningDir, withPlanningLock, listAvailableWorkstreams, peekActiveWorkstream, diagnoseUnresolvedActiveWorkstream, describeUnresolvedWorkstreamReason, + resolvePhaseIdConvention, } = planningWorkspace; // eslint-disable-next-line @typescript-eslint/no-require-imports -- milestone-lock.cjs is an export= CommonJS module import milestoneLockMod = require('./milestone-lock.cjs'); @@ -1129,16 +1132,35 @@ function phaseEntryInsertOffset(rawContent: string, cwd: string): number { * the edit-phase workflow's depends_on gate. The predicate itself * (`findMilestoneScopeHeadingLines`) is fence-aware and Phase-heading-exempt, * so ordinary descriptions and the phase's own numbered heading never trip it. + * + * #612: the predicate is convention-SELECTED, because the terminator + * vocabulary it mirrors is. On an opted-in bracket repo the ADR-canonical + * `## [GSD.09] Hidden` carries none of the markers listed above and yet + * terminates the window, so the blind call accepted the exact description the + * guard exists to reject — measured at this CLI seam, two `phase add` calls, + * the second phase silently outside the milestone phase set. Resolved through + * the same tolerant shape the read path uses (`planningDir` throws on a + * poisoned `GSD_PROJECT`/`GSD_WORKSTREAM` segment, and this guard runs BEFORE + * `loadConfig` and the ROADMAP existence check — an unresolvable convention + * must degrade to the pre-existing legacy vocabulary, never turn a rejection + * into a crash). */ -function assertDescriptionPreservesMilestoneScope(description: string, command: string): void { - const offending = findMilestoneScopeHeadingLines(description); +function assertDescriptionPreservesMilestoneScope(cwd: string, description: string, command: string): void { + let convention: string | null = null; + try { + convention = resolvePhaseIdConvention(cwd); + } catch { /* unresolvable convention → treat as not-configured (base behaviour) */ } + const offending = findMilestoneScopeHeadingLines(description, convention); if (offending.length === 0) return; + const markerList = convention === 'bracket' + ? `(a vN.N version token, a ✅/📋/🚧/🔄 marker, the word "Milestone", or — under the bracket convention — a "[CODE.NN] Name" milestone heading)` + : `(a vN.N version token, a ✅/📋/🚧/🔄 marker, or the word "Milestone")`; error( `${command}: description contains a milestone-scoping heading line — writing it to ROADMAP.md would terminate ` + `the current milestone window and silently drop later phases out of the milestone scope. ` + `Offending line(s): ${offending.map((line) => JSON.stringify(line)).join(', ')}. ` + `Rewrite the line so it is not a level 1-3 "#" heading carrying a milestone marker ` + - `(a vN.N version token, a ✅/📋/🚧/🔄 marker, or the word "Milestone").` + markerList + `.` ); } @@ -1208,7 +1230,7 @@ function cmdPhaseAdd(cwd: string, description: string, raw: boolean, customId?: if (!description) { error('description required for phase add'); } - assertDescriptionPreservesMilestoneScope(description, 'phase add'); + assertDescriptionPreservesMilestoneScope(cwd, description, 'phase add'); const config = loadConfig(cwd); const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md'); @@ -1343,7 +1365,7 @@ function cmdPhaseAddBatch(cwd: string, descriptions: string[], raw: boolean): vo // all-or-nothing, so one offending description must reject the whole batch // with no ROADMAP write and no phase directories created. for (const description of descriptions) { - assertDescriptionPreservesMilestoneScope(description, 'phase add-batch'); + assertDescriptionPreservesMilestoneScope(cwd, description, 'phase add-batch'); } const config = loadConfig(cwd); const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md'); @@ -1446,7 +1468,7 @@ function cmdPhaseInsert(cwd: string, afterPhase: string, description: string, ra if (!afterPhase || !description) { error('after-phase and description required for phase insert'); } - assertDescriptionPreservesMilestoneScope(description, 'phase insert'); + assertDescriptionPreservesMilestoneScope(cwd, description, 'phase insert'); const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md'); if (!fs.existsSync(roadmapPath)) { diff --git a/src/planning-snapshot.cts b/src/planning-snapshot.cts index 1de9c8155..167bd8f33 100644 --- a/src/planning-snapshot.cts +++ b/src/planning-snapshot.cts @@ -34,7 +34,11 @@ const { isPhaseComplete } = verificationMod; import scanPhasePlans = require('./plan-scan.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports import planningWorkspace = require('./planning-workspace.cjs'); -const { planningPaths, planningRoot } = planningWorkspace; +// #612: `resolvePhaseIdConvention` is the federated (workstream -> root) +// `phase_id_convention` reader, from the same §7 owner module `planningPaths` +// comes from. Resolved once in `buildPlanningSnapshot` — see the +// `phaseIdConvention` field's comment for why one resolution point matters. +const { planningPaths, planningRoot, resolvePhaseIdConvention } = planningWorkspace; import { platformReadSync, execGit } from './shell-command-projection.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import frontmatterMod = require('./frontmatter.cjs'); @@ -62,8 +66,30 @@ import configLoaderMod = require('./config-loader.cjs'); const { isGitIgnored } = configLoaderMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseIdMod = require('./phase-id.cjs'); -const { PHASE_NUMBER_TOKEN_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, stripProjectCodePrefix, scopeToPhase } = phaseIdMod; -import { buildRoadmapPhaseVariants, PHASE_TOKEN_FROM_DIR_RE, MILESTONE_ARCHIVE_DIR_RE } from './validate.cjs'; +// #612: `phaseHeadingPrefixSrcFor`/`PHASE_HEADING_BASELINE` SELECT a heading +// intro by convention (a convention-less call compiles the byte-identical base +// source the literal it replaced spelled); `isSentinelPhaseId` gets its bracket +// reading only when handed the convention explicitly. +const { + PHASE_NUMBER_TOKEN_SOURCE, + OPTIONAL_PHASE_TAG_SOURCE, + stripProjectCodePrefix, + phaseHeadingPrefixSrcFor, + PHASE_HEADING_BASELINE, + isSentinelPhaseId, + scopeToPhase, +} = phaseIdMod; +// #612: `phaseTokenFromDir` is the convention-SELECTED counterpart of +// `PHASE_TOKEN_FROM_DIR_RE` — handed no convention it delegates to that very +// regex, so a legacy repo's tokenization is unchanged. `checkBracketCoherence` +// re-homed into `validate.cts` when #3309 deleted its `verify.cts` neighbours. +import { + buildRoadmapPhaseVariants, + phaseTokenFromDir, + checkBracketCoherence, + MILESTONE_ARCHIVE_DIR_RE, +} from './validate.cjs'; +import type { BracketIncoherence } from './validate.cjs'; // ─── worstScope — the one new piece of coordination logic ─────────────────── @@ -218,6 +244,40 @@ interface PlanningSnapshot { perPhasePlanNumbering: { value: { phaseDir: string; planNums: number[] }[]; scope: Scope }; perPhaseOrphanSummaries: { value: { phaseDir: string; orphanSummary: string }[]; scope: Scope }; perPhaseWaveMissingPlans: { value: { phaseDir: string; plan: string }[]; scope: Scope }; + // ─── #612 / PR-2 (bracket read tolerance) additions ──────────────────────── + // `phaseIdConvention` is the repo's federated (workstream -> root) answer to + // `phase_id_convention`, resolved EXACTLY ONCE per snapshot via + // `resolvePhaseIdConvention` (`planning-workspace.cjs` — the same ADR-3180 §7 + // owner module `planningPaths` already comes from, so this is an in-doctrine + // source, not a new derivation). Every ROADMAP/disk read below and every rule + // that selects a heading or directory grammar takes its answer from here. + // + // ONE resolution point is load-bearing, not a micro-optimisation: PR-2's + // original `cmdValidateConsistency` defect was two readers inside ONE command + // resolving the convention from two different bases, so the ROADMAP read + // widened while the directory read did not and every bracket phase reported + // missing from disk. A rule resolving it for itself would re-open that seam + // once per rule — and `Rule.check(snapshot)` may not perform ambient I/O + // (§8.1 rule 1) in any case, so the resolution belongs in this layer. `null` + // on every repo that has not opted in, which is the value each widened read + // compiles its BASE (byte-identical) grammar from. + phaseIdConvention: string | null; + // Tokens borne ONLY by sentinel-bracket ROADMAP headings (`### [GSD.999] 07:` + // yields token `07`). Produced by the same `buildRoadmapPhaseVariants` call + // that produces `roadmapDeclaredPhases`, so the two can never disagree about + // which headings they saw. EMPTY unless the bracket convention is active. + // + // Its own field because bracket sentinel-ness lives in the MILESTONE, not the + // token: a bare-token test (`isSentinelPhaseId`, #3225) cannot see it once the + // heading is reduced to `07`. The two guards are DISJOINT — #3225 reads legacy + // `### Phase 999:`, this reads `### [GSD.999] 07:` — and `checkW006` needs both. + roadmapSentinelPhaseTokens: { value: string[]; scope: Scope }; + // Bracket-coherence findings (`checkBracketCoherence`, `validate.cjs`) — the + // bracket half of W021. Computed ONLY under the bracket convention; a legacy + // repo gets a `COMPLETE`-scoped empty list without the ROADMAP being parsed + // for it at all, so a check that can fail a repo runs only for the convention + // that repo opted into. + roadmapBracketIncoherences: { value: BracketIncoherence[]; scope: Scope }; } /** @@ -615,18 +675,35 @@ function buildProjectSectionsField(cwd: string): { value: string[] | null; scope */ function buildRoadmapDeclaredPhasesField( roadmapPath: string, -): { value: { phaseId: string; milestone: string | null }[]; scope: Scope } { + convention: string | null, +): { + declared: { value: { phaseId: string; milestone: string | null }[]; scope: Scope }; + sentinelTokens: { value: string[]; scope: Scope }; +} { if (!fs.existsSync(roadmapPath)) { - return { value: [], scope: SCOPE.UNREADABLE }; + return { + declared: { value: [], scope: SCOPE.UNREADABLE }, + sentinelTokens: { value: [], scope: SCOPE.UNREADABLE }, + }; } let content: string; try { content = fs.readFileSync(roadmapPath, 'utf-8'); } catch { - return { value: [], scope: SCOPE.UNREADABLE }; + return { + declared: { value: [], scope: SCOPE.UNREADABLE }, + sentinelTokens: { value: [], scope: SCOPE.UNREADABLE }, + }; } - const { roadmapPhases } = buildRoadmapPhaseVariants(content); + // #612: the declared-phase scan is SELECTED by the resolved convention — a + // non-bracket repo compiles the byte-identical pattern sources this call + // compiled before, so its declared set is unchanged. `sentinelPhases` is the + // same call's third output (empty off the bracket convention) and is surfaced + // rather than filtered in place: `roadmapPhases` feeds both a membership check + // (W002's valid-phase set) and a missing-directory warning (W006), and only + // the latter should ignore an icebox item. + const { roadmapPhases, sentinelPhases } = buildRoadmapPhaseVariants(content, convention); const milestoneByPhase = new Map(); const sectionRx = /^#{1,3}\s+(?:\[[^\]]{1,200}\]\s*)?.*v(\d+\.\d+)/gim; @@ -650,7 +727,10 @@ function buildRoadmapDeclaredPhasesField( phaseId, milestone: milestoneByPhase.get(phaseId) ?? null, })); - return { value, scope: SCOPE.COMPLETE }; + return { + declared: { value, scope: SCOPE.COMPLETE }, + sentinelTokens: { value: [...sentinelPhases], scope: SCOPE.COMPLETE }, + }; } /** @@ -674,6 +754,7 @@ function buildRoadmapDeclaredPhasesField( */ function buildRoadmapPhaseCheckboxesField( roadmapPath: string, + convention: string | null, ): { value: Record; scope: Scope } { if (!fs.existsSync(roadmapPath)) { return { value: {}, scope: SCOPE.UNREADABLE }; @@ -685,8 +766,16 @@ function buildRoadmapPhaseCheckboxesField( return { value: {}, scope: SCOPE.UNREADABLE }; } + // #612: the `Phase\s+` label intro is SELECTED, exactly as + // `buildNotStartedPhaseVariants` (`validate.cts`) selects it for the same + // ROADMAP checklist shape — this field is what W006's not-started exclusion + // now reads instead of that helper, so the two must recognize the same + // checklist lines or a bracket repo's `- [ ] **[GSD.02] 05: Name**` entries + // vanish from the exclusion set and every unstarted bracket phase gains a + // W006. NON-capturing (`capturing` defaults false), so the phase token stays + // group 2 and the legacy repo compiles a byte-identical source. const checkboxRe = new RegExp( - `-\\s*\\[([xX ])\\].*?Phase\\s+0*(${PHASE_NUMBER_TOKEN_SOURCE})${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, + `-\\s*\\[([xX ])\\].*?${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention)}0*(${PHASE_NUMBER_TOKEN_SOURCE})${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'gi', ); const value: Record = {}; @@ -697,6 +786,43 @@ function buildRoadmapPhaseCheckboxesField( return { value, scope: SCOPE.COMPLETE }; } +/** + * Resolve `roadmapBracketIncoherences` — W021's bracket half (#612). Delegates + * wholly to `checkBracketCoherence` (`validate.cjs`), which is pure and owns + * both sub-checks; this builder only supplies the ROADMAP text and the + * convention gate. + * + * GATED, not merely filtered downstream: off the bracket convention the ROADMAP + * is never parsed for this at all and the field is a `COMPLETE`-scoped empty + * list. Inferring 'bracket' from the SHAPE of a matched heading would run a + * repo-failing check against a repo that never opted in — a legacy ROADMAP + * containing `### [RFC.2119] 5:` is legal legacy content, and that is the exact + * regression PR-2's round 1 killed the original ungated design over. + */ +function buildRoadmapBracketIncoherencesField( + roadmapPath: string, + convention: string | null, +): { value: BracketIncoherence[]; scope: Scope } { + // File-readability is decided FIRST, so `scope` means the same thing on every + // repo: UNREADABLE iff ROADMAP.md could not be read, never "this convention + // was skipped." Ordering the convention gate first would have made an absent + // ROADMAP.md report COMPLETE on a legacy repo and UNREADABLE on a bracket one + // — the same "empty, nothing to say" state wearing two different scopes, which + // is precisely the non-answer/answer distinction ADR-3180 §8.1 gives `scope` + // to carry. + if (!fs.existsSync(roadmapPath)) return { value: [], scope: SCOPE.UNREADABLE }; + // A non-bracket repo has no bracket incoherences BY DEFINITION — a real, + // COMPLETE answer, not a skipped read. + if (convention !== 'bracket') return { value: [], scope: SCOPE.COMPLETE }; + let content: string; + try { + content = fs.readFileSync(roadmapPath, 'utf-8'); + } catch { + return { value: [], scope: SCOPE.UNREADABLE }; + } + return { value: checkBracketCoherence(content), scope: SCOPE.COMPLETE }; +} + /** * Resolve `researchValidationStatus` — per phase directory, whether its * `*-RESEARCH.md` contains the literal heading `## Validation Architecture`, @@ -872,7 +998,10 @@ function buildAllPhaseDirNamesField(phasesDir: string): { value: string[]; scope * present-but-unreadable per-archive-dir entry is silently skipped, mirroring * `forEachArchivedPhaseToken`'s own per-directory `catch { /* absent/unreadable *\/ }`. */ -function buildArchivedPhaseTokensField(planBase: string): { value: string[]; scope: Scope } { +function buildArchivedPhaseTokensField( + planBase: string, + convention: string | null, +): { value: string[]; scope: Scope } { const milestonesDir = path.join(planBase, 'milestones'); let archiveDirs: string[]; try { @@ -891,8 +1020,18 @@ function buildArchivedPhaseTokensField(planBase: string): { value: string[]; sco const entries = fs.readdirSync(archiveDir, { withFileTypes: true }); for (const e of entries) { if (!e.isDirectory()) continue; - const m = e.name.match(PHASE_TOKEN_FROM_DIR_RE); - if (m) value.push(stripProjectCodePrefix(m[1])); + // #612: composed, not chosen. The convention-aware extractor decides + // WHICH directory shapes are recognized (so an archived + // `{CODE}.{MM}-{PP}-slug` is seen at all — `PHASE_TOKEN_FROM_DIR_RE` + // rejects it outright, which is why every archived bracket phase used + // to still draw a W006/W002); `stripProjectCodePrefix` then normalizes + // the token it returns. The strip is a no-op on every bracket token + // (`01`, `01.02` — the `{CODE}.{MM}` prefix is not part of the token) + // and does the #2528 work on legacy ones (`MEM-05` -> `05`), so neither + // side loses its case. Handed no convention, `phaseTokenFromDir` + // delegates to `PHASE_TOKEN_FROM_DIR_RE` itself — legacy is unchanged. + const token = phaseTokenFromDir(e.name, convention); + if (token) value.push(stripProjectCodePrefix(token)); } } catch { /* archive dir absent/unreadable — mirrors forEachArchivedPhaseToken */ @@ -913,6 +1052,7 @@ function buildArchivedPhaseTokensField(planBase: string): { value: string[]; sco function buildCurrentMilestoneRoadmapPhaseIdsField( cwd: string, roadmapPath: string, + convention: string | null, ): { value: string[]; scope: Scope } { if (!fs.existsSync(roadmapPath)) return { value: [], scope: SCOPE.UNREADABLE }; let content: string; @@ -924,11 +1064,34 @@ function buildCurrentMilestoneRoadmapPhaseIdsField( const scoped = extractCurrentMilestone(content, cwd); // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal // mirror of OPTIONAL_PHASE_TAG_SOURCE) — verbatim from `verify.cts:2366`. + // + // #612: this scan is convention-AGNOSTIC in POSTURE — W026 is ungated, and + // bug-557 pins it with an empty config so it fires on every repo — but its + // heading grammar is still SELECTED, never widened. Under bracket the intro + // CAPTURES, so the phase token moves to group 2 and `bracketGroup` carries + // that offset; off bracket the source is byte-identical to the literal above + // and `bracketGroup` is 0. Inferring the convention from a matched bracket's + // shape would run a repo-failing check against a repo that never opted in. + const bracketGroup = convention === 'bracket' ? 1 : 0; const phasePattern = new RegExp( - `#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, + `#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, Boolean(bracketGroup))}(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'gi', ); - const value = [...scoped.matchAll(phasePattern)].map((m) => m[1]); + const value: string[] = []; + for (const m of scoped.matchAll(phasePattern)) { + const bracketId = bracketGroup ? m[1] : undefined; + const phaseNum = m[1 + bracketGroup]; + // A bracket sentinel is an ICEBOX item, not an unstarted phase — it + // legitimately has no directory, so leaving it in this list makes W026 + // ("STATE says milestone complete but ROADMAP lists an unstarted phase") + // fire on every bracket repo that keeps an icebox. Filtered here rather + // than in `RULE_W026` because the bracket id is only visible at the match: + // the emitted token is `07`, and sentinel-ness lives in the `[GSD.999]` + // milestone this scan just discarded. This field's only consumer is W026 + // (see its own doc comment above). + if (bracketId && isSentinelPhaseId(`${bracketId}-${phaseNum}`, 'bracket')) continue; + value.push(phaseNum); + } return { value, scope: SCOPE.COMPLETE }; } @@ -1040,12 +1203,30 @@ function buildPerPhasePlanScanFields( */ function buildPlanningSnapshot(cwd: string): PlanningSnapshot { const paths = planningPaths(cwd); + // #612: ONE federated (workstream -> root) resolution for the whole snapshot. + // See the `phaseIdConvention` field's comment for why one resolution point is + // load-bearing rather than a micro-optimisation. + const phaseIdConvention = resolvePhaseIdConvention(cwd) ?? null; const milestone = getMilestoneInfo(cwd); + // #612: deliberately LEFT to `listMilestonePhaseDirs`'s own lazy resolve — + // this call is byte-identical to upstream's. + // + // Passing `phaseIdConvention` here would NOT be a no-op, which is exactly why + // it is not passed. The lazy path resolves `resolvePhaseIdConvention(cwd, ws)` + // with this call's `ws`, which defaults to `null` — the PROJECT-only reading, + // with no root fallback. The field above is resolved with `ws` undefined, + // i.e. the FEDERATED workstream -> root reading. On a workstream repo whose + // root opts into bracket while the workstream config does not, the two answers + // genuinely differ, and substituting one for the other would silently re-scope + // `phaseDirs` — a change this PR does not need and no test covers. The + // federation guarantee PR-2 exists to deliver is delivered where it is + // observable: in the rules that read `snapshot.phaseIdConvention`. const phaseDirs = listMilestonePhaseDirs(paths.phases, { cwd }); const phasesValue = phaseDirs.value.map((dir) => buildPhaseSnapshot(paths.phases, dir)); const stateFields = buildStateFields(paths.state); const allPhaseDirNames = buildAllPhaseDirNamesField(paths.phases); + const roadmapDeclared = buildRoadmapDeclaredPhasesField(paths.roadmap, phaseIdConvention); const perPhasePlanScanFields = buildPerPhasePlanScanFields( paths.phases, allPhaseDirNames.value, @@ -1068,17 +1249,20 @@ function buildPlanningSnapshot(cwd: string): PlanningSnapshot { projectSections: buildProjectSectionsField(cwd), statePhaseTokens: stateFields.statePhaseTokens, stateStatus: stateFields.stateStatus, - roadmapDeclaredPhases: buildRoadmapDeclaredPhasesField(paths.roadmap), - roadmapPhaseCheckboxes: buildRoadmapPhaseCheckboxesField(paths.roadmap), + roadmapDeclaredPhases: roadmapDeclared.declared, + roadmapPhaseCheckboxes: buildRoadmapPhaseCheckboxesField(paths.roadmap, phaseIdConvention), researchValidationStatus: buildResearchValidationStatusField(paths.phases, phaseDirs.value, phaseDirs.scope), milestoneArchiveStatus: buildMilestoneArchiveStatusField(cwd), planningRootFiles: buildPlanningRootFilesField(cwd), allPhaseDirNames, - archivedPhaseTokens: buildArchivedPhaseTokensField(paths.planning), - currentMilestoneRoadmapPhaseIds: buildCurrentMilestoneRoadmapPhaseIdsField(cwd, paths.roadmap), + archivedPhaseTokens: buildArchivedPhaseTokensField(paths.planning, phaseIdConvention), + currentMilestoneRoadmapPhaseIds: buildCurrentMilestoneRoadmapPhaseIdsField(cwd, paths.roadmap, phaseIdConvention), perPhasePlanNumbering: perPhasePlanScanFields.perPhasePlanNumbering, perPhaseOrphanSummaries: perPhasePlanScanFields.perPhaseOrphanSummaries, perPhaseWaveMissingPlans: perPhasePlanScanFields.perPhaseWaveMissingPlans, + phaseIdConvention, + roadmapSentinelPhaseTokens: roadmapDeclared.sentinelTokens, + roadmapBracketIncoherences: buildRoadmapBracketIncoherencesField(paths.roadmap, phaseIdConvention), }; } diff --git a/src/planning-workspace.cts b/src/planning-workspace.cts index 1f559ee00..b2a0149f9 100644 --- a/src/planning-workspace.cts +++ b/src/planning-workspace.cts @@ -196,6 +196,84 @@ function worktreesOptedOutUnguarded(cwd: string): boolean { return false; } +/** + * #612: resolve `phase_id_convention` with the SAME workstream->root federation + * config-loader uses (config-loader.cts:618/:649) — the workstream config wins, + * the root config is the fallback. + * + * Why this exists rather than `loadConfig(cwd)['phase_id_convention']`: as of + * #2997 (aa7697fe, in `next`), loadConfig surfaces `phase_id_convention` in + * its resolved `_baseConfig` — the "loadConfig drops keys it does not know" + * rationale this comment used to give is stale. The surviving reasons for the + * direct read are (1) the workstream->root federation below, a standalone + * resolution this function needs to run against a GIVEN cwd rather than + * whatever base a `loadConfig(cwd)` call elsewhere would federate from, and + * (2) convention-ENUM validation, which is still #612 PR-4 work — this + * function returns the raw string unvalidated, same as the now-surfaced + * resolved key would. #2997 surfacing the key makes consuming it from + * resolved config (instead of re-reading config.json here) a natural PR-4 + * consolidation, not this PR's scope. Cycles were never the obstacle. + * + * Why federation matters here specifically: the phase-id readers were splitting + * on this value from two different bases — one resolving from the workstream + * directory, one from the root — so a workstream repo got the widened ROADMAP + * read with the narrow directory read, or the reverse, and reported every phase + * either missing from disk or malformed on disk. One resolver, one answer. + * + * The workstream is `planningDir`'s own `ws` parameter, forwarded, so this + * shares the canonical resolution (and its GSD_PROJECT/GSD_WORKSTREAM + * handling). Root is consulted as a fallback only when a workstream is active, + * matching config-loader; a project-scoped directory stands alone. Returns null + * when unset, absent, or unreadable — every caller treats null as "not the + * bracket convention". + * + * #2761 B1 (trek-e review): `ws` is a PARAMETER, not read from the environment + * here. It was omitted at first on the reasoning that "the active workstream is + * whatever planningDir resolves" — true only for the env-driven caller. A + * caller that iterates workstreams passes the name as an ARGUMENT (it cannot + * set `GSD_WORKSTREAM` per iteration), and `planningDir` falls back to the env + * only when `ws` is `undefined`, so an argument-driven call resolved this + * convention from the ROOT config while reading that workstream's ROADMAP. Two + * consequences, both reproduced: a workstream that explicitly declares its OWN + * convention had it ignored — the root's value decided how the workstream's + * roadmap was parsed, so flipping ONLY the root config changed which milestone + * a workstream extracted; and `--workstream foo` disagreed with + * `GSD_WORKSTREAM=foo` on the same repo. + * + * `undefined` (the default) keeps `planningDir`'s env fallback, so every + * pre-#2761 call site is byte-identical; `null` means "explicitly no + * workstream". Same discriminator `planningDir` and `getMilestonePhaseFilter` + * already carry. + * + * SCOPE: this governs the #612 bracket-selection reads ONLY. The shipped + * milestone-prefixed W021 gate keeps its own root-only read — re-basing a + * legacy convention's gate onto a different config is a behaviour change to a + * shipped check, in both directions, and is not part of read tolerance. + */ +function resolvePhaseIdConvention(cwd: string, ws?: string | null): string | null { + const readFrom = (dir: string): string | null => { + const configPath = path.join(dir, 'config.json'); + if (!fs.existsSync(configPath)) return null; + try { + const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record; + const value = parsed['phase_id_convention']; + return typeof value === 'string' && value !== '' ? value : null; + } catch { + return null; + } + }; + const scoped = planningDir(cwd, ws); + const root = planningRoot(cwd); + if (scoped === root) return readFrom(root); + // Root is a fallback only when a WORKSTREAM is active — config-loader falls + // back to the root config under `if (ws)` and not otherwise, so a + // project-scoped directory stands alone. Detected by suppressing the + // workstream segment rather than re-reading the environment. + const projectOnly = planningDir(cwd, null); + if (scoped === projectOnly) return readFrom(scoped); + return readFrom(scoped) ?? readFrom(root); +} + // Sorted list of workstream directory names under `/.planning/workstreams`, // or `[]` when the project is flat (no workstreams dir). Single source of truth // for the "workstream mode" detection shared by the #1912/#2028 fail-safe guards @@ -528,6 +606,7 @@ export = { createMemoryPointerAdapter, planningDir, planningRoot, + resolvePhaseIdConvention, listAvailableWorkstreams, planningPaths, quickDirFrom, diff --git a/src/roadmap-parser.cts b/src/roadmap-parser.cts index 58db761a4..8f71eae5d 100644 --- a/src/roadmap-parser.cts +++ b/src/roadmap-parser.cts @@ -36,10 +36,27 @@ const { // see BRACKET_PHASE_ENTRY_HEADING_RE below. PHASE_HEADING_PREFIX_SRC, PHASE_NUMBER_TOKEN_SOURCE, + phaseHeadingPrefixSrcFor, + PHASE_HEADING_BASELINE, + // #612: the disk-side milestone filter resolves bracket directories through + // the owner's gated helpers rather than spelling the grammar a second time. + phaseTokenMatches, + // #2761 B1: the version-less bracket milestone boundary (computeSectionEnd / + // preambleCutoff, below) is built from this single-owner source rather than a + // re-typed bracket-id literal. + BRACKET_ID_SRC, + // #2761 M3: the PINNED bracket milestone intro, owning the pad2 spelling rule + // as well as the grammar — consumed by the bracket-fallback selector below, + // which re-typed the project-code class and restated the padding rule. + bracketMilestoneIntroSrcFor, + // #2761 B1 (round-2 fix): fold-before-identity for the SAME-MILESTONE + // continuation check in isBracketMilestoneBoundary, below — the branch's own + // convention (matches bracketQualifiedKey/isSentinelPhaseId). + foldBracketId, } = phaseIdModule; // eslint-disable-next-line @typescript-eslint/no-require-imports import planningWorkspace = require('./planning-workspace.cjs'); -const { planningDir } = planningWorkspace; +const { planningDir, resolvePhaseIdConvention } = planningWorkspace; import { platformReadSync } from './shell-command-projection.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import unusableInputMod = require('./unusable-input.cjs'); @@ -110,6 +127,129 @@ function isMilestoneShippedInRoadmap(content: string, version: string): boolean return false; } +// #2761 B1: matches a bracket MILESTONE heading's intro (`[GSD.02]`) at the +// START of a heading's text, capturing the bracket id in group 1. Shared by +// isBracketMilestoneBoundary below — the ONE recognizer for "is this heading +// bracket-shaped", so computeSectionEnd and the preambleCutoff scan cannot +// independently drift on what counts as bracket-shaped (the drift that +// produced Blocker 3 in the round-2 review). +const BRACKET_HEADING_INTRO_RE = new RegExp(`^\\[(${BRACKET_ID_SRC})\\]`, 'i'); + +// #2761 B2 (round-2 fix, Blocker 2): ADR-612 Decision 1's own discriminator +// (docs/adr/612-bracket-phase-id-convention.md:56) — "a phase heading is a +// bracket followed by a digit-then-colon (`[GSD.02] 05:`); a milestone +// heading is a bracket followed by a name" — is CONTENT, not heading level. +// The prior `h.level <= 2` level cap broke on a level-3 bracket milestone +// heading (`### [GSD.02] Foundation`): its own level-3 phase children +// (`#### [GSD.02] 01: One`) never reached this check at all (excluded +// upstream by the `h.level > level` sibling-depth filter), but a level-3 +// SIBLING milestone heading (`### [GSD.03] Later`) was ALSO excluded by the +// level cap, so the section ran to EOF instead of stopping there — trek-e's +// original #612 defect, reopened on any milestone heading below level 2. +// +// Built by interpolating phase-id.cts's single-owner +// phaseHeadingPrefixSrcFor (the SAME intro grammar getMilestonePhaseFilter's +// heading counter and extractRetiredPhaseNumbers already compile) plus the +// digit + optional-tag + colon tail every phase-heading counter in this file +// already spells (mirrors the `([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:` +// shape at :nnn below) — not a re-typed grammar. Covers the dotted sub-phase +// heading form too (`[GSD.02] 05.03:`) via the same `[\w][\w.-]*` token, +// which admits an embedded `.`. +const BRACKET_PHASE_TAIL_RE = new RegExp( + `^${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, 'bracket', false)}[\\w][\\w.-]*(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, + 'i', +); + +/** + * #2761 B1/B2 (round-2 fixes): is `headingText` (hashes STRIPPED — the + * `tokenizeHeadings` `HeadingToken.text` shape, and the shape the + * preambleCutoff scan below is made to match) a BRACKET MILESTONE boundary — + * as opposed to (a) a bracket PHASE heading (at any level, including the + * dotted sub-phase form), which must never terminate a milestone's own + * section, or (b) a heading that names the SAME milestone already selected, + * which is a CONTINUATION of the current milestone's own section (a + * version-less split like `## [GSD.02] Foundation (Phase Details)`), not the + * boundary to a DIFFERENT one? + * + * `level` is the CANDIDATE heading's own depth (`h.level`), capped at 3 — a + * depth-SANITY ceiling, not a phase/milestone discriminator (that job is + * BRACKET_PHASE_TAIL_RE, below). The cap mirrors the bracket-fallback + * selector's own `#{1,3}` ceiling (this file's SELECTION branch above) and + * `isMilestoneBounded`'s (`state.cts`) — a bracket-shaped heading deeper than + * either of those will ever select as a CURRENT milestone is outside the + * shape this function needs to discriminate at all. + * + * `selectedBracketId` is the SELECTED milestone's own bracket id, already + * case-folded by the caller — `null` when scoping is not bracket-gated, the + * selected heading is not itself bracket-shaped, or (preambleCutoff) the + * same-milestone check does not apply at this call site (see its own comment + * there) — in which case the same-milestone check below simply never fires. + */ +function isBracketMilestoneBoundary(headingText: string, level: number, selectedBracketId: string | null): boolean { + if (level > 3) return false; + const introMatch = BRACKET_HEADING_INTRO_RE.exec(headingText); + if (!introMatch) return false; + // #2761 B2: a bracket PHASE heading (`[GSD.02] 05:`, or the dotted + // sub-phase form `[GSD.02] 05.03:`) is never a milestone boundary, + // regardless of level. + if (BRACKET_PHASE_TAIL_RE.test(headingText)) return false; + // #2761 B1: fold-before-identity — this branch's own convention + // (bracketQualifiedKey / isSentinelPhaseId apply the same rule). + if (selectedBracketId && foldBracketId(introMatch[1]) === selectedBracketId) return false; + return true; +} + +/** + * #2761 B1 (round-3 fix, Blocker 1 case D; hardened post-round-3; round-4 + * fix: requires a same-id PHASE child, not merely a same-id child): does + * `headings[index]`'s SUBTREE — every heading strictly deeper than it, up to + * (not including) the next heading at or above its own level — contain a + * bracket-shaped, PHASE-TAIL-shaped heading with the SAME id as + * `headings[index]`'s own? + * + * Used ONLY at the preambleCutoff scan below, to distinguish a genuine prior + * or later sibling milestone — whose subtree contains a real phase heading + * carrying its bracket id (`## [GSD.01] Setup` / `### [GSD.01] 01: …`) + * — from an unrelated bracket-shaped PROSE heading sitting above the current + * milestone's own content. Same-id-ness alone is insufficient: round-4 F1's + * `[ADR.612] Heading convention` owns `[ADR.612] Examples`, but that child is + * milestone-shaped, not a digit-colon phase. Reuse `BRACKET_PHASE_TAIL_RE` + * for the phase-vs-milestone distinction instead of re-deriving it. + * + * Scan the whole subtree, not just the immediate child: a genuine milestone + * may open with `### Notes` before its first matching-id phase. Stopping at + * that first non-match leaked the prior milestone's qualified phase into the + * current filter (3/2/67 instead of 2/1/50 in rv2-amend1). + * + * A subtree that closes with no same-id phase hit — including a childless + * heading or one with only milestone-shaped same-id children — degrades to + * not-a-boundary. That is deliberately over-inclusive: the unmatched text + * remains in the preamble rather than silently discarding current content. + * + * `headings` is the same fence-aware token list the caller iterates; `index` + * is the candidate's own position in it. + */ +function bracketHeadingHasMatchingChild(headings: readonly HeadingToken[], index: number): boolean { + const candidate = headings[index]; + const ownMatch = BRACKET_HEADING_INTRO_RE.exec(candidate.text); + if (!ownMatch) return false; + const ownId = foldBracketId(ownMatch[1]); + for (let i = index + 1; i < headings.length; i++) { + const next = headings[i]; + if (next.level <= candidate.level) return false; + const childMatch = BRACKET_HEADING_INTRO_RE.exec(next.text); + // #2761 round-4: a same-id child is not enough — it must also be a PHASE + // (BRACKET_PHASE_TAIL_RE), or an unrelated PROSE heading whose own + // sub-heading merely happens to share its bracket id (F1: [ADR.612] + // Heading convention / [ADR.612] Examples) satisfies this rule. + if (childMatch && foldBracketId(childMatch[1]) === ownId && BRACKET_PHASE_TAIL_RE.test(next.text)) return true; + // Not a same-id PHASE match — keep scanning DEEPER into the subtree + // instead of giving up on this one heading; only a same-or-shallower + // heading (above) actually closes the subtree. + } + return false; +} + /** * #3184 (epic #3180 Phase 2): the sole owner of "where does this milestone * heading's section end". Lifted from `currentMilestoneRawRanges`'s prior @@ -119,20 +259,26 @@ function isMilestoneShippedInRoadmap(content: string, version: string): boolean * `getMilestonePhaseFilter`'s versionOverride branch all call this instead of * re-deriving it. */ -function computeMilestoneSectionEnd(content: string, headingText: string, headingStart: number): number { +function computeMilestoneSectionEnd( + content: string, + headingText: string, + headingStart: number, + additionalBoundary?: (heading: HeadingToken) => boolean, + headingTokens?: readonly HeadingToken[], +): number { const level = (headingText.match(/^(#{1,3})\s/) ?? ['', '#'])[1].length; const afterHeading = headingStart + headingText.length; // Use tokenizeHeadings (fence-aware, offsets into original content) to find // the next stop boundary without re-implementing fence detection. T4 seam migration. - const headings = tokenizeHeadings(content); + const headings = headingTokens ?? tokenizeHeadings(content); for (const h of headings) { if (h.offset <= headingStart) continue; if (h.offset < afterHeading) continue; if (h.level > level) continue; // Mirrors old stopPattern: level-bounded, not a Phase heading, milestone marker if (/^Phase\s+\S/i.test(h.text)) continue; - if (!/v\d+\.\d+|✅|📋|🚧/i.test(h.text)) continue; - return h.offset; + if (/v\d+\.\d+|✅|📋|🚧/i.test(h.text)) return h.offset; + if (additionalBoundary?.(h)) return h.offset; } return content.length; } @@ -540,16 +686,36 @@ function collectTablePhaseRows(window: string): Array<{ id: string; name: string * narrower question — "which phase ids does this window declare" — where only * the 999 icebox range is excluded. */ -function scanMilestonePhaseIds(window: string): Set { +function scanMilestonePhaseIdSets( + window: string, + convention: string | null | undefined, +): { ids: Set; qualifiedIds: Set } { const ids = new Set(); + const qualifiedIds = new Set(); // Use tokenizeHeadings (fence-aware) instead of stripFencedLines + regex. // T4 seam migration: phase headings inside fences are excluded automatically. // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phaseHeadingPattern = /^(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/i; + // #612: select the heading grammar from the resolved convention and retain + // the bracket id so the disk-side filter can distinguish equal phase tokens + // belonging to different milestones. + const capturing = convention === 'bracket'; + const bracketGroups = capturing ? 1 : 0; + const phaseHeadingPattern = new RegExp( + `^${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention, capturing)}([\\w][\\w.-]*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, + 'i', + ); for (const h of tokenizeHeadings(window)) { if (h.level < 2 || h.level > 4) continue; const pm = phaseHeadingPattern.exec(h.text); - if (pm && !/^999\b/.test(pm[1])) ids.add(pm[1]); + if (!pm) continue; + const bracketId = bracketGroups ? pm[1] : undefined; + const token = pm[1 + bracketGroups]; + if (bracketId && isSentinelPhaseId(`${bracketId}-${token}`, 'bracket')) continue; + // This scan's legacy contract excludes only the 999 icebox range. Keep the + // narrower rule so real decimal phase 00.1 is not swallowed as milestone 0. + if (/^999\b/.test(token)) continue; + ids.add(token); + if (bracketId && !token.includes('-')) qualifiedIds.add(`${bracketId}-${token}`); } // #2199: also count bullet/checkbox phase entries (`- [ ] **Phase N — name**`) // so a bullet-house-style ROADMAP populates the milestone phase set instead of @@ -563,7 +729,20 @@ function scanMilestonePhaseIds(window: string): Set { // #3577: table-declared ids join the same membership set — the milestone // filter must not collapse a table-house-style window to zero-count. for (const tr of collectTablePhaseRows(window)) ids.add(tr.id); - return ids; + return { ids, qualifiedIds }; +} + +/** + * Public #3262 owner contract: the declared-id Set remains directly iterable. + * #612's qualified bracket ids are an internal companion used only by the + * milestone directory filter, so extending that internal read must not break + * existing consumers of this exported Set (including #3577's table scan). + */ +function scanMilestonePhaseIds( + window: string, + convention?: string | null, +): Set { + return scanMilestonePhaseIdSets(window, convention).ids; } /** @@ -595,14 +774,48 @@ function scanMilestonePhaseIds(window: string): Set { * Fence-aware via `tokenizeHeadings`: a FENCED example of a milestone heading * inside a field value does not terminate the real window, so it must not be * a violation either — the parser and this guard must agree on fences. + * + * #612: `convention` is the resolved `phase_id_convention`. This predicate is + * defined as a MIRROR of the parser's terminator vocabulary, and on this + * branch that vocabulary is convention-SELECTED: `computeBracketSectionEnd` + * adds `isBracketMilestoneBoundary` as a terminator arm, so the ADR-canonical + * `## [GSD.09] Hidden` — no version token, no status emoji, not the word + * "Milestone" — terminates the window on an opted-in bracket repo while + * matching NONE of the signals above. Left unmirrored, the guard accepts + * exactly the description that narrows the window on the one convention this + * branch teaches the parser to read, which is the failure the guard exists to + * prevent. A non-bracket (or unresolvable) value takes the pre-existing path + * byte-identically. + * + * REQUIRED, not optional — the same tripwire `scanMilestonePhaseIds` carries, + * and for the sharper reason: a blind call here fails OPEN (the guard quietly + * ACCEPTS a window-narrowing description) rather than merely miscounting, so a + * future call site must fail to COMPILE. The census today is one caller, + * `assertDescriptionPreservesMilestoneScope`, which pays nothing for it. */ -function findMilestoneScopeHeadingLines(text: string): string[] { +function findMilestoneScopeHeadingLines(text: string, convention: string | null | undefined): string[] { const out: string[] = []; for (const h of tokenizeHeadings(text)) { if (h.level > 3) continue; if (/^Phase\s+\S/i.test(h.text)) continue; if (MILESTONE_HEADING_SIGNAL_PATTERN.test(h.text) || /🔄/.test(h.text)) { out.push(h.text.trim()); + continue; + } + // #612: the bracket arm, routed through the SAME single-owner + // phase-vs-milestone discriminator `computeBracketSectionEnd` consults — + // never a second bracket-heading grammar here. + // + // `selectedBracketId` is deliberately `null`, so the same-milestone + // CONTINUATION exemption never fires and a value naming the ACTIVE + // milestone (`## [GSD.02] Foundation (Phase Details)`) is flagged even + // though the real parser would treat it as a continuation. That is the + // third instance of this function's stated conservatism and rests on the + // same argument as the other two: WHICH milestone is active is a property + // of the document at write time, not of the text being validated, and + // over-rejecting is one-directional (reject more, never less). + if (convention === 'bracket' && isBracketMilestoneBoundary(h.text, h.level, null)) { + out.push(h.text.trim()); } } return out; @@ -715,7 +928,42 @@ function extractCurrentMilestoneScoped(content: string, cwd?: string, ws?: strin `]*>([^<]*${escapeRegex(version)}[^<]*)<\\/summary>`, 'i' ); - const headingMatches = locateMilestoneHeadings(content, version); + // #3184 keeps the version-heading locator single-owned. #612 adds a gated + // bracket candidate source only when that owner finds no version-bearing + // heading; it never replaces or re-derives the legacy lookup. + let headingMatches = locateMilestoneHeadings(content, version); + + // #2761 B3: resolve the boundary convention independently of whether the + // version-heading owner already selected a match. Preserve an explicitly + // threaded convention; otherwise resolve from the same workstream whose + // STATE/ROADMAP this call reads (#2761 B1). + let bracketScopeConvention: string | null = phaseIdConvention ?? null; + if (phaseIdConvention === undefined) { + try { + bracketScopeConvention = resolvePhaseIdConvention(cwd, ws); + } catch { /* unresolvable convention → preserve the legacy fallback */ } + } + if (headingMatches.length === 0 && bracketScopeConvention === 'bracket') { + const vMatch = version.match(/^v(\d+)/i); + const milestoneInt = vMatch ? parseInt(vMatch[1], 10) : NaN; + if (Number.isSafeInteger(milestoneInt)) { + // #2761 M3: the phase-id owner supplies both the bracket intro grammar + // and canonical pad2 spelling; `[CODE.2]` therefore cannot bound a + // section whose phase headings the bracket grammar rejects. + // #612 round-5: HeadingToken.offset is the line start, so require the + // `#` there to preserve the old line-start anchor's indentation parity. + // For every survivor, rebuilding [fullLine, fullLine] plus `.index` + // retains the match shape and first-match order expected downstream. + const bracketMilestoneHeadingRe = new RegExp(`^${bracketMilestoneIntroSrcFor(milestoneInt)}`, 'i'); + headingMatches = tokenizeHeadings(content) + .filter((h) => h.level <= 3 && content[h.offset] === '#' && bracketMilestoneHeadingRe.test(h.text)) + .map((h) => { + const lineEnd = content.indexOf('\n', h.offset); + const fullLine = content.slice(h.offset, lineEnd === -1 ? content.length : lineEnd); + return Object.assign([fullLine, fullLine], { index: h.offset }) as RegExpExecArray; + }); + } + } if (headingMatches.length === 0) { const summaryMatch = content.match(summaryPattern); @@ -771,16 +1019,162 @@ function extractCurrentMilestoneScoped(content: string, cwd?: string, ws?: strin // #3184: selection collapses to the sole owner; `allMatches` is still needed // below (offsets, detailsMatch search), so only the selection itself routes // through `selectMilestoneHeading` rather than the whole block. - const selected = selectMilestoneHeading(content, version)!; + // Preserve the canonical legacy selector, then fall back to the gated + // bracket candidates above when a name-only bracket milestone carries no + // version token for that selector to find. + const selected = selectMilestoneHeading(content, version) + ?? allMatches.find((m) => !isClosed(m[1])) + ?? firstMatch; const sectionStart = selected.index; - const sectionEnd = computeMilestoneSectionEnd(content, selected[0], sectionStart); + // #2761 B1: a bracket heading bearing the selected milestone's own id is + // a continuation, not a boundary. Derive the selected id once and compose + // the shared discriminator with upstream's centralized section-end walk. + const bracketBoundaryActive = bracketScopeConvention === 'bracket'; + const selectedBracketMatch = bracketBoundaryActive + ? selected[0].match(new RegExp(`^#{1,3}\\s+\\[(${BRACKET_ID_SRC})\\]`, 'i')) + : null; + const selectedBracketId = selectedBracketMatch ? foldBracketId(selectedBracketMatch[1]) : null; + // #2761 B3: tokenize once for both the centralized section-end owner and + // the bracket preamble scan below. This keeps both boundary decisions + // fence-aware without restoring the local section walker retired by #3184. + const currentMilestoneHeadings = tokenizeHeadings(content); + const bracketBoundary = bracketBoundaryActive + ? (heading: HeadingToken): boolean => + isBracketMilestoneBoundary(heading.text, heading.level, selectedBracketId) + : undefined; + const sectionEnd = computeMilestoneSectionEnd( + content, + selected[0], + sectionStart, + bracketBoundary, + currentMilestoneHeadings, + ); const anyMilestonePattern = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧)/im; - const firstMilestoneMatch = content.match(anyMilestonePattern); - const preambleCutoff = firstMilestoneMatch - ? firstMilestoneMatch.index! + let earliestMilestoneIndex: number | null; + if (!bracketBoundaryActive) { + // #2761 B3: the LEGACY (non-bracket-shaped) path stays a raw + // `content.match` — byte-identical to before this fix, including its + // fence-blindness. That hazard is real (a fenced `## Milestone v9.0` + // example in the preamble reads identically wrong at base, round-1, and + // HEAD — repro12's LEGACY control) but is PRE-EXISTING and shared with + // the ORIGINAL (pre-#612) code path, not introduced by this branch — + // fixing it is explicitly out of scope (round-2 review's own + // minimal-fix note). + const versionMilestoneMatch = content.match(anyMilestonePattern); + earliestMilestoneIndex = versionMilestoneMatch ? versionMilestoneMatch.index! : null; + } else { + // #2761 Major 1 (round-3 fix): on the BRACKET branch, derive the + // version/emoji half of "earliest milestone-shaped heading" from the + // SAME fence-aware `currentMilestoneHeadings` token list too, instead of + // the raw `content.match` above. Round-2 (ff6bf0a8) fixed the BRACKET + // half's fence-blindness but left THIS half a raw regex even on this + // branch: a fenced VERSION-BEARING example heading in a bracket repo's + // preamble (`` ```markdown\n## Milestone v9.0: Example\n``` ``, ADR-612's + // own docs illustrate the LEGACY heading shape exactly this way) was + // still textually the earliest match for the raw regex, winning the old + // min() and un-suppressing a wrong persisted 75% that base correctly + // suppressed (rv-attack3c fixture C1) — B3 fixed only the half of the + // asymmetry it introduced, not this pre-existing half once it also + // started reaching the bracket branch. The predicate below (`h.text` + // against the same `/^Phase\s+\S/i` / `/v\d+\.\d+|✅|📋|🚧/i` pair + // `computeSectionEnd` already uses) never sees a fenced heading at all, + // because tokenizeHeadings never produces a token for one. + // + // Hardened post-round-3: the raw `content.match(anyMilestonePattern)` + // this replaced was anchored `^#{1,3}\s+…` — a level cap the token loop + // dropped entirely. A level-4+ version-bearing heading in the preamble + // (`#### v2.0 notes`) would win this scan where the raw pattern on the + // legacy path ignores it outright, cutting the preamble at a heading + // neither the selector nor `isMilestoneBounded` would ever treat as a + // milestone marker. Mirrors the depth-sanity cap + // `isBracketMilestoneBoundary` already applies to the bracket half. + earliestMilestoneIndex = null; + for (const h of currentMilestoneHeadings) { + if (h.level > 3) continue; + if (/^Phase\s+\S/i.test(h.text)) continue; + if (/v\d+\.\d+|✅|📋|🚧/i.test(h.text)) { earliestMilestoneIndex = h.offset; break; } + } + } + if (bracketBoundaryActive) { + // #2761 B3: scans the SAME fence-aware `currentMilestoneHeadings` token + // list computeSectionEnd consumes, instead of a raw `content.matchAll` — + // closes the asymmetry between the two halves of one boundary semantic. + // Before this fix, a fenced markdown example containing a bracket + // heading (ADR-612's own docs do exactly this) was textually the + // earliest `#{1,3} [CODE.MM]` match, so `preambleCutoff` landed INSIDE + // the fence, `preamble` ended with an unclosed opener, and + // `getMilestonePhaseFilter`'s tokenizeHeadings(scope) call then saw an + // unbalanced fence and swallowed every real heading, degrading to a + // pass-all filter (repro11). tokenizeHeadings already strips fenced + // lines before a heading candidate is ever produced, so a heading INSIDE + // a fence is never a candidate here at all. + // + // `h.text` is ALREADY hash-stripped and trimmed (HeadingToken's own + // shape) — isBracketMilestoneBoundary is built to consume exactly that, + // so no `^#{1,3}\s+` re-derivation is needed (that spelling would not + // match `h.text` — it still carries the hashes in a raw regex match). + for (let i = 0; i < currentMilestoneHeadings.length; i++) { + const h = currentMilestoneHeadings[i]; + let isBoundary: boolean; + if (h.offset === sectionStart) { + // #2761 B1 (round-3 fix): the SELECTED heading's own occurrence is + // ALWAYS a correct earliest answer to "where does milestone content + // begin" — bypass BOTH the same-milestone check inside + // isBracketMilestoneBoundary (which would otherwise reject this + // heading against ITSELF, since `selectedBracketId` is its own id) + // and the same-id-child rule below (which would reject a genuinely + // childless CURRENT milestone, e.g. one with no phases populated + // yet). Without this, a same-milestone heading EARLIER than the + // selected one (a version-less checklist/overview split, or the + // version-bearing heading landing on the LATER half of such a + // split — Blocker 1 round-3 cases A/B) would incorrectly win via the + // OLD `null`-everywhere behaviour, or (with the same-milestone + // check alone reinstated) the selected heading would incorrectly + // reject itself and fall through to a stray, unrelated LATER + // heading. + isBoundary = true; + } else if (isBracketMilestoneBoundary(h.text, h.level, selectedBracketId)) { + // #2761 B1 (round-3 fix, Blocker 1 case D): bracket-shaped, not + // phase-tail-shaped, and not the SAME id as the selected milestone + // is not enough — an unrelated bracket-shaped PROSE heading + // (`## [ADR.612] Heading convention used by this roadmap`) reads as + // a genuine boundary by those rules alone. Require its own next + // DEEPER heading to carry ITS bracket id too — the property every + // genuine sibling MILESTONE has (its own phase children) and no + // unrelated prose heading does. + // + // #2761 round-4 Minor 1 (docstring correction — no code change): a + // heading rejected here (no same-id PHASE child — see + // bracketHeadingHasMatchingChild's own comment) leaves its WHOLE + // SUBTREE in the preamble, not merely its own inert heading text. + // That subtree can still contain a DIFFERENT-id bracket PHASE + // heading, which DOES form a qualified key and CAN admit a foreign + // directory (F7: `## [GSD.01] Setup` / `### [GSD.07] 01: Foreign` — + // no same-id child, so `[GSD.01] Setup` is not a boundary, and + // `GSD.07-01-foreign`'s directory is admitted into the CURRENT + // milestone's filter, reading 3/2/67%). This is NOT a regression — + // base, round-1 and HEAD all read 3/2/67% on F7 (base via its own + // pass-all degrade) — and it remains the declared OVER-inclusive, + // never under-inclusive, safe direction; it is a narrower and more + // honest claim than "contributes nothing to any phase count", which + // is true of the candidate's OWN text but not of what its subtree + // can carry. + isBoundary = bracketHeadingHasMatchingChild(currentMilestoneHeadings, i); + } else { + isBoundary = false; + } + if (!isBoundary) continue; + if (earliestMilestoneIndex === null || h.offset < earliestMilestoneIndex) { + earliestMilestoneIndex = h.offset; + } + break; + } + } + const preambleCutoff = earliestMilestoneIndex !== null + ? earliestMilestoneIndex : firstMatch.index; const beforeMilestones = content.slice(0, preambleCutoff); const currentSection = content.slice(sectionStart, sectionEnd); @@ -813,7 +1207,7 @@ function extractCurrentMilestoneScoped(content: string, cwd?: string, ws?: strin const detailsStart = detailsMatch.index ?? 0; detailsSection = content.slice( detailsStart, - computeMilestoneSectionEnd(content, detailsMatch[0], detailsStart), + computeMilestoneSectionEnd(content, detailsMatch[0], detailsStart, bracketBoundary, currentMilestoneHeadings), ); } @@ -1436,6 +1830,23 @@ type MilestonePhaseFilter = ((dirName: string) => boolean) & { */ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, phaseIdConvention?: string | null, ws?: string | null): MilestonePhaseFilter { const milestonePhaseNums = new Set(); + // #612: the milestone-QUALIFIED form (`{CODE}.{MM}-{PP}`) of each in-scope + // bracket heading, kept in its OWN set — deliberately not in + // milestonePhaseNums. A qualified id ALWAYS contains a hyphen, so putting one + // there would flip `roadmapUsesHyphenedIds` below on EVERY bracket repo, which + // swaps `numericRe` to the continuation-segment variant and silently moves the + // LEGACY dir path. Separate set; `phaseCount` is unchanged. + // + // Stated exactly, because the narrower claim is the true one: this keeps + // QUALIFIED IDS out of that flag's input, not hyphens in general. A heading + // whose TOKEN carries its own hyphen (`### [GSD.02] Phase 02-01:`) still flips + // it through milestonePhaseNums — as it also does at base, which matches that + // spelling through the un-widened intro. See the token-hyphen guard below. + const milestoneQualifiedIds = new Set(); + // Hoisted out of the try so the DIR side can select the same grammar the + // HEADING side selected. The two halves of this one filter reading different + // conventions is exactly the defect the bracket branch below closes. + let headingConvention: string | null | undefined; let missingExplicitVersion = false; let versionScoped = false; let versionSectionFound = false; @@ -1510,12 +1921,19 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p }); } - // #3262: the set-building scan now lives in its own named owner - // (`scanMilestonePhaseIds`) so the new `roadmap milestone-scope` probe - // reads the SAME derivation this filter does — never a second copy. - for (const id of scanMilestonePhaseIds(roadmap)) { - milestonePhaseNums.add(id); - } + // Resolve once, then thread the same answer through the shared scan and + // directory matcher. Resolve an omitted value from this call's `ws`; + // explicit null still means "resolved and non-bracket." A split convention + // would widen the ROADMAP side while leaving bracket directories unmatched. + headingConvention = phaseIdConvention === undefined + ? resolvePhaseIdConvention(cwd, ws) + : phaseIdConvention; + // #3262 remains the single owner of the phase-set scan. #612 extends its + // return with qualified bracket ids rather than restoring the superseded + // inline duplicate this commit was originally written against. + const scanned = scanMilestonePhaseIdSets(roadmap, headingConvention); + for (const id of scanned.ids) milestonePhaseNums.add(id); + for (const qualified of scanned.qualifiedIds) milestoneQualifiedIds.add(qualified); } catch { /* best-effort (#2245 audit): the real throw source is platformReadSync * at the top of this try (re-throws for a non-ENOENT read failure). On @@ -1574,6 +1992,27 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p : /^0*(\d+[A-Za-z]?(?:\.\d+)*)/; function isDirInMilestone(dirName: string): boolean { + // #612: the DIR side of this filter, selected by the same convention the + // heading side selected. Without it the heading scan's new bracket reach was + // half a fix: `milestonePhaseNums` became non-empty, so the pass-all degrade + // stopped firing, but no bracket directory could satisfy the three legacy + // checks below (numericRe fails on `GSD.02-05-five`, the custom-id match + // captures the project code `GSD`, and stripProjectCodePrefix does not strip + // a dotted prefix) — so EVERY bracket directory was rejected and + // completed_phases / total_plans / completed_plans / percent collapsed to 0 + // while `state sync` went on writing a percent off the unfiltered disk. + // + // Matching is delegated to phaseTokenMatches against the milestone-QUALIFIED + // id, not the bare token: READING-B puts the milestone in the bracket, so + // `GSD.01-01-old-one` and `GSD.02-01-one` share the token `01` and only the + // qualified key separates them. That is the scoping this filter exists to do. + // ADDITIVE: on a miss we fall through to the three legacy checks, so a + // bracket repo carrying legacy-shaped directories reads exactly as before. + if (headingConvention === 'bracket') { + for (const qualified of milestoneQualifiedIds) { + if (phaseTokenMatches(dirName, qualified, 'bracket')) return true; + } + } const m2 = dirName.match(numericRe); if (m2 && normalized.has(normalizePhaseIdSegments(m2[1]).toLowerCase())) return true; // #3213: segment-boundary membership test, scoped to LETTER-LEADING (custom- @@ -1638,6 +2077,24 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p * `computeMilestoneSectionEnd` owner (#3184), so there is no separate copy to * keep in sync. Returns null when there is no versioned active milestone; * callers then fall back to whole-content mutation (the prior behaviour). + * + * #2761 (round-2 review, Minor 3 — latent, currently harmless): this function + * consumes #3184's shared owners, but it does not pass the bracket-specific + * B1/B2 boundary predicate and still has no bracket-fallback SELECTION branch: + * it returns null when the version-string owner finds nothing, unlike + * `extractCurrentMilestoneScoped`. The read owner can therefore scope a + * bracket ROADMAP this write-range consumer still calls unscoped. Probed and + * confirmed harmless TODAY: this + * function's single consumer (`mutateMilestonePhase`, src/phase.cts) falls + * back to whole-content mutation when it returns null, and every mutation + * inside that caller is still `Phase`-labelled-only (not bracket-widened) per + * the changeset's own "READ-path opt-in until the migrator and write path + * land" — so a bracket ROADMAP's checkbox/heading patterns never match inside + * that fallback and nothing is mutated cross-milestone. The moment the write + * path is widened (PR-3+), this divergence becomes live: the whole-content + * fallback would become a cross-milestone writer, which is exactly what this + * note exists to prevent. Bracket-widen this function in lockstep with the + * write path landing, not before. */ function currentMilestoneRawRanges( content: string, diff --git a/src/roadmap.cts b/src/roadmap.cts index b354ad09b..66481b417 100644 --- a/src/roadmap.cts +++ b/src/roadmap.cts @@ -16,7 +16,7 @@ import ioMod = require('./io.cjs'); const { output, error, formatDiagnosticToken } = ioMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseIdMod = require('./phase-id.cjs'); -const { normalizePhaseName, phaseMarkdownRegexSource, matchPhaseDirs, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE, roadmapPhaseLookupSources, isSentinelPhaseId, scopeToPhase } = phaseIdMod; +const { normalizePhaseName, phaseMarkdownRegexSource, matchPhaseDirs, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE, roadmapPhaseLookupSources, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, isSentinelPhaseId, scopeToPhase, bracketQualifiedKey, foldBracketId } = phaseIdMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseLocatorMod = require('./phase-locator.cjs'); const { findPhaseInternal, listMilestonePhaseDirs, listAllPhaseDirs } = phaseLocatorMod; @@ -33,7 +33,7 @@ import { clampPercent } from './phase-lifecycle.cjs'; import { platformWriteSync } from './shell-command-projection.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import planningWorkspace = require('./planning-workspace.cjs'); -const { planningPaths, withPlanningLock, findContextMdIn } = planningWorkspace; +const { planningPaths, withPlanningLock, findContextMdIn, resolvePhaseIdConvention } = planningWorkspace; // #3641: milestone-scope's convention resolution reads the project config // (no cycle — config-loader does not import this module). // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -166,9 +166,9 @@ function countPhasePlansAndSummaries(phaseDir: string): PhasePlansAndSummaries { * exact production pattern instead of hand-duplicating it. * #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag. */ -function buildPhaseHeadingRegex(escapedPhase: string): RegExp { +function buildPhaseHeadingRegex(escapedPhase: string, convention?: string | null): RegExp { return new RegExp( - `^(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, + `^${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention)}${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, 'i' ); } @@ -178,16 +178,18 @@ function buildPhaseHeadingRegex(escapedPhase: string): RegExp { * Returns a result object if found (either a full match or a malformed_roadmap * checklist-only match), or null if the phase is not present at all. */ -function searchPhaseInContent(content: string, escapedPhase: string, phaseNum: string): PhaseSearchResult | null { - const headingPattern = buildPhaseHeadingRegex(escapedPhase); +function searchPhaseInContent(content: string, escapedPhase: string, phaseNum: string, convention?: string | null): PhaseSearchResult | null { + const headingPattern = buildPhaseHeadingRegex(escapedPhase, convention); const headings = tokenizeHeadings(content); const headingIndex = headings.findIndex((heading) => headingPattern.test(heading.text)); const headerMatch = headingIndex === -1 ? null : headings[headingIndex].text.match(headingPattern); if (!headerMatch) { // Fallback: check if phase exists in summary list but missing detail section + // A BARE `Phase\s+` at base — takes the label-only baseline, so a bracket + // repo gains the bracket-ID form and nothing else. const checklistPattern = new RegExp( - `-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*([^*]+)\\*\\*`, + `-\\s*\\[[ x]\\]\\s*\\*\\*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention)}${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*([^*]+)\\*\\*`, 'i' ); const checklistMatch = content.match(checklistPattern); @@ -285,10 +287,11 @@ function getRoadmapPhaseWithFallback(cwd: string, phaseNum: string): string | nu // #2121/#2114: iterate the shared lookup-source list (exact → numeric → // prefix-tolerant) so this resolver matches getRoadmapPhaseInternal and a // bare-number query resolves a drifted project-code-prefixed heading. + const convention = resolvePhaseIdConvention(cwd); for (const source of roadmapPhaseLookupSources(phaseNum)) { - const milestoneResult = searchPhaseInContent(milestoneContent, source, phaseNum); + const milestoneResult = searchPhaseInContent(milestoneContent, source, phaseNum, convention); if (milestoneResult && !milestoneResult.error) return milestoneResult.section ?? null; - const fullResult = searchPhaseInContent(fullContent, source, phaseNum); + const fullResult = searchPhaseInContent(fullContent, source, phaseNum, convention); if (fullResult && !fullResult.error) return fullResult.section ?? null; } @@ -315,6 +318,7 @@ function cmdRoadmapGetPhase(cwd: string, phaseNum: string, raw: boolean): void { const milestoneContent = extractCurrentMilestone(rawContent, cwd); const fullContent = stripShippedMilestones(rawContent); + const convention = resolvePhaseIdConvention(cwd); // #2121/#2114: iterate the shared lookup-source list (exact → numeric → // prefix-tolerant) so all three roadmap resolvers share one contract and a @@ -326,12 +330,12 @@ function cmdRoadmapGetPhase(cwd: string, phaseNum: string, raw: boolean): void { // heading — so a milestone checklist never blocks a full-roadmap header. let malformed: PhaseSearchResult | null = null; for (const source of roadmapPhaseLookupSources(phaseNum)) { - const milestoneResult = searchPhaseInContent(milestoneContent, source, phaseNum); + const milestoneResult = searchPhaseInContent(milestoneContent, source, phaseNum, convention); if (milestoneResult && !milestoneResult.error) { output(milestoneResult, raw, milestoneResult.section); return; } - const fullResult = searchPhaseInContent(fullContent, source, phaseNum); + const fullResult = searchPhaseInContent(fullContent, source, phaseNum, convention); if (fullResult && !fullResult.error) { output(fullResult, raw, fullResult.section); return; @@ -391,6 +395,35 @@ type AnalyzePhase = { context_read_error: string | null; }; +type AnalyzePhaseCollection = { + phases: AnalyzePhase[]; + detailKeys: Set; +}; + +// #612 composes the convention-qualified sentinel reading with upstream's +// canonical legacy sentinel owner. A reserved bracket milestone OR a reserved +// phase token excludes the occurrence. +const isSentinelPhase = (num: string, bracketId?: string): boolean => { + if (bracketId && isSentinelPhaseId(`${bracketId}-${num}`, 'bracket')) return true; + return isSentinelPhaseId(num); +}; + +// #2761 M1: missing-detail identity is milestone-qualified under bracket. +// Prefer the canonical qualified-key owner, which case-folds accepted ids, so +// `[gsd.02] 01` and `[GSD.02] 01` are one occurrence. It is intentionally not +// padding-tolerant: the milestone grammar has one canonical spelling (pad2 +// below 100, no leading zero above), so `[GSD.2]` is malformed rather than an +// alternate spelling of `[GSD.02]`. Hyphenated tokens and other shapes the +// qualified-key owner refuses retain a folded composite, keeping distinct +// bracket/token pairs from collapsing onto one missing-detail verdict. +const occurrenceKey = (num: string, bracketId?: string): string => { + if (!bracketId) return num; + const qualified = num.includes('-') + ? null + : bracketQualifiedKey(`${bracketId}-${num}`, 'bracket'); + return qualified ?? `${foldBracketId(bracketId)}|${num}`; +}; + /** * #3165: scan `content` for phase-detail headings (`##/###/#### Phase N: Name`) * and enrich each with its on-disk plan/summary/completion status and ROADMAP @@ -400,21 +433,38 @@ type AnalyzePhase = { * `cmdRoadmapAnalyze`'s former inline loop so the fallback re-runs the EXACT * same enrichment, not a second derivation. */ -function collectAnalyzePhases(content: string, phasesDir: string, phaseDirNames: string[]): AnalyzePhase[] { +function collectAnalyzePhases( + content: string, + phasesDir: string, + phaseDirNames: string[], + convention?: string | null, +): AnalyzePhaseCollection { // Extract all phase headings: ## Phase N: Name or ### Phase N: Name // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + // #612: CAPTURING intro under the bracket convention — group 1 is the + // `[CODE.MM]` bracket id (undefined otherwise), group 2 the token, group 3 the + // name. The bracket id is what the sentinel filter needs: READING-B puts the + // sentinel milestone in the bracket, not in the token. // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches. // #3036: widen the id capture to accept non-numeric-leading ids (e.g. B7, P0.3-2) // that get-phase/execute-phase already resolve. An optional leading letter prefix // ([A-Za-z]?) covers letter-prefixed ids without breaking numeric-leading ones. // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches. - const phasePattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([A-Za-z]?\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi; + const phasePattern = new RegExp(`#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention, true)}([A-Za-z]?\\d+[A-Z]?(?:[.-]\\d+)*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi'); + // The capturing intro inserts the bracket id at group 1 only under the + // bracket convention; the token and name shift by the same offset. + const G = convention === 'bracket' ? 1 : 0; const phases: AnalyzePhase[] = []; let match: RegExpExecArray | null; + // The caller needs the exact occurrence identities from the same scan that + // built `phases`; returning them together also keeps fallback rescans atomic. + const detailKeys = new Set(); while ((match = phasePattern.exec(content)) !== null) { - const phaseNum = match[1]; - if (isSentinelPhaseId(phaseNum)) continue; - const phaseName = match[2].replace(/\(INSERTED\)/i, '').trim(); + const bracketId = G ? match[1] : undefined; + const phaseNum = match[1 + G]; + if (isSentinelPhase(phaseNum, bracketId)) continue; + detailKeys.add(occurrenceKey(phaseNum, bracketId)); + const phaseName = match[2 + G].replace(/\(INSERTED\)/i, '').trim(); // Extract goal from the section const sectionStart = match.index; @@ -422,7 +472,7 @@ function collectAnalyzePhases(content: string, phasesDir: string, phaseDirNames: // #3691: `\d` → `\d[\d.]*` so decimal phase headings (e.g. `### Phase 02.3:`) are // recognised as section boundaries. #3036: `[A-Za-z]?\d` so non-numeric-leading ids // (e.g. B7) are also recognised. - const nextHeader = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]{1,200}\]\s*)?Phase\s+[A-Za-z]?\d[\d.-]*/i); + const nextHeader = restOfContent.match(new RegExp(`\\n#{2,4}\\s+${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention)}[A-Za-z]?\\d[\\d.-]*`, 'i')); const sectionEnd = nextHeader ? sectionStart + nextHeader.index! : content.length; const section = content.slice(sectionStart, sectionEnd); @@ -453,7 +503,23 @@ function collectAnalyzePhases(content: string, phasesDir: string, phaseDirNames: // readdirSync is self-guarded, and it delegates to scanPhasePlans, which // never throws) — nothing in this block can throw, so the try/catch could // never be triggered. - const dirMatch = matchPhaseDirs(phaseDirNames, normalized).matches[0]; + // #612: the DIRECTORY read is selected by the same `convention` the four + // heading/checklist patterns above already thread. Left two-argument, this + // one call reported EVERY canonical `{CODE}.{MM}-{PP}-slug` directory as + // `disk_status: "no_directory"` with `plan_count`/`summary_count` 0 — + // `extractPhaseToken('GSD.02-01-one')` with no convention returns the whole + // dir name — while the same build resolved those same directories correctly + // in three other places on the same repo (W006/W007 through their shared + // directory matcher, `state json` via the milestone filter, and the W026 + // milestone-complete read through the same convention-aware owner). It + // failed ONLY for the directory shape the convention exists to name: a + // mid-migration bracket repo carrying legacy `01-one` dirs resolved fine. + // That is verbatim the asymmetry the note above the W026 rule says this PR + // closed — the directory read widens with the heading read, or every bracket + // phase resolves to nothing. + // Upstream centralized this choice in `matchPhaseDirs`; thread the same + // convention into that owner rather than reviving the primitive `.find()`. + const dirMatch = matchPhaseDirs(phaseDirNames, normalized, convention).matches[0]; if (dirMatch) { const counts = countPhasePlansAndSummaries(path.join(phasesDir, dirMatch)); @@ -492,7 +558,7 @@ function collectAnalyzePhases(content: string, phasesDir: string, phaseDirNames: // checkbox — no passing `*-VERIFICATION.md`, plans outstanding — now // reports incomplete; this is the deliberate Tier-2 break (ADR-3180 §7.4 // Decision 3). - const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+${phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i'); + const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention)}${phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i'); const checkboxMatch = content.match(checkboxPattern); const roadmapComplete = checkboxMatch ? checkboxMatch[1] === 'x' : false; @@ -519,6 +585,9 @@ function collectAnalyzePhases(content: string, phasesDir: string, phaseDirNames: const stripPadA = (s: string) => s.replace(/^0+(?=.)/, ''); const seen = new Set(phases.map((ph) => stripPadA(ph.number))); for (const tr of collectTablePhaseRows(content)) { + // #3577 table declarations were part of the pre-existing detail set. + // Preserve that behavior while heading occurrences gain bracket identity. + detailKeys.add(occurrenceKey(tr.id)); if (seen.has(stripPadA(tr.id))) continue; const dirMatchA = matchPhaseDirs(phaseDirNames, normalizePhaseName(tr.id)).matches[0]; let tPlanCount = 0; @@ -553,7 +622,7 @@ function collectAnalyzePhases(content: string, phasesDir: string, phaseDirNames: context_read_error: tContextReadError, }); } - return phases; + return { phases, detailKeys }; } function cmdRoadmapAnalyze(cwd: string, raw: boolean): void { @@ -570,6 +639,10 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void { // indistinguishable from a genuinely empty milestone. const { value: content, scope } = extractCurrentMilestoneScoped(rawContent, cwd); const phasesDir = planningPaths(cwd).phases; + // #612: resolve once per command and thread the same reading through both + // the scoped scan and any fallback scan. + const convention = resolvePhaseIdConvention(cwd); + const G = convention === 'bracket' ? 1 : 0; // Build phase directory lookup once (O(1) readdir instead of O(N) per phase) // #3185 exemption reason (ADR-3180 Decision 4a): this is a heading->directory @@ -587,7 +660,9 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void { // Scan the scoped milestone window for phase-detail headings and enrich each // with its on-disk status. Extracted into `collectAnalyzePhases` (#3165) so // the SAME enrichment re-runs on the fallback below — not a second copy. - let phases = collectAnalyzePhases(content, phasesDir, _phaseDirNames); + let collected = collectAnalyzePhases(content, phasesDir, _phaseDirNames, convention); + let phases = collected.phases; + let detailKeys = collected.detailKeys; // `effectiveContent` is what the downstream checklist scan (missing_details) // iterates. Defaults to the scoped window; switched to the fallback document // when the recovery path below fires, so a phase found via fallback is not @@ -610,9 +685,11 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void { // populated, flagged result. if (phases.length === 0 && scope !== SCOPE.COMPLETE && _phaseDirNames.length > 0) { const fallbackContent = stripShippedMilestones(rawContent); - const fallbackPhases = collectAnalyzePhases(fallbackContent, phasesDir, _phaseDirNames); - if (fallbackPhases.length > 0) { - phases = fallbackPhases; + const fallbackCollection = collectAnalyzePhases(fallbackContent, phasesDir, _phaseDirNames, convention); + if (fallbackCollection.phases.length > 0) { + collected = fallbackCollection; + phases = collected.phases; + detailKeys = collected.detailKeys; effectiveContent = fallbackContent; } } @@ -639,17 +716,42 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void { // The char class must allow `-` (not just `.`) so dash-separated milestone-prefixed // IDs (e.g. `1-01`) match the detail-heading scanner above; otherwise they truncate // at the dash (`1-01` -> `1`) and every such phase reports a phantom missing detail. + // #612: CAPTURING label-only intro — the bracket id rides along so the + // sentinel filter below is not blind to `- [ ] **[GSD.999] 01: Icebox**`. // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches. // #3036: widen to accept non-numeric-leading ids (same widening as the detail-heading pattern above). // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches. - const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+([A-Za-z]?\d+[A-Z]?(?:[.-]\d+)*)/gi; - const checklistPhases = new Set(); + const checklistPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*\\*\\*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, true)}([A-Za-z]?\\d+[A-Z]?(?:[.-]\\d+)*)`, 'gi'); + // #2761 M1: an OCCURRENCE list keyed by `occurrenceKey`, not a token->bracket + // map. The map was first-wins on the bare token, so of two checklist entries + // sharing a token across brackets the FIRST one's bracket id classified BOTH: + // `- [ ] **[GSD.999] 01: Icebox**` written above `- [ ] **[GSD.02] 01: …**` + // made the real phase inherit the icebox's sentinel verdict and vanish from + // `missing_phase_details`; written below it, the same document reported it. + // Dedupe still happens — it is now per PHASE rather than per token, which is + // what makes the classification order-independent. + const checklistOccurrences: Array<{ token: string; bracketId?: string }> = []; + const seenChecklistKeys = new Set(); let checklistMatch: RegExpExecArray | null; while ((checklistMatch = checklistPattern.exec(effectiveContent)) !== null) { - checklistPhases.add(checklistMatch[1]); + const token = checklistMatch[1 + G]; + const bracketId = G ? checklistMatch[1] : undefined; + const key = occurrenceKey(token, bracketId); + if (seenChecklistKeys.has(key)) continue; + seenChecklistKeys.add(key); + checklistOccurrences.push({ token, bracketId }); } - const detailPhases = new Set(phases.map(p => p.number)); - const missingDetails = [...checklistPhases].filter(p => !detailPhases.has(p) && !isSentinelPhaseId(p)); + // The EMITTED value stays the bare token, unchanged: `phases[].number` is a + // token under every convention, and `missing_phase_details` is read against + // it. Only the classification moved to the qualified key — so two different + // brackets' `01` both missing report `01` once, rather than one of them + // silently covering for the other. + const missingDetails = [...new Set( + checklistOccurrences + .filter(o => !detailKeys.has(occurrenceKey(o.token, o.bracketId)) + && !isSentinelPhase(o.token, o.bracketId)) + .map(o => o.token), + )]; // #3217 (ADR-3180 §7.6 rules 3-4): `progress_percent` used to accumulate // `totalPlans`/`totalSummaries` above — a heading-matched enumeration @@ -779,7 +881,7 @@ function cmdRoadmapMilestoneScope(cwd: string, raw: boolean): void { } const { value: window, scope } = extractCurrentMilestoneScoped(rawContent, cwd, undefined, phaseIdConvention); // Document order (Set insertion order) — deterministic for a given document. - const phases = [...scanMilestonePhaseIds(window)]; + const phases = [...scanMilestonePhaseIds(window, phaseIdConvention)]; output({ scope, phases, phase_count: phases.length }, raw, undefined); } diff --git a/src/state.cts b/src/state.cts index 7c4d5d796..a8e17ca72 100644 --- a/src/state.cts +++ b/src/state.cts @@ -28,8 +28,12 @@ const { PHASE_NUMBER_TOKEN_SOURCE, phaseKeyFromToken, phaseKeyFromDir, + phaseHeadingPrefixSrcFor, + PHASE_HEADING_BASELINE, isSentinelPhaseId, scopeToPhase, + // #2761 M3: owns the bracket milestone intro and canonical pad2 spelling. + bracketMilestoneIntroSrcFor, } = phaseIdMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import roadmapParserMod = require('./roadmap-parser.cjs'); @@ -38,7 +42,7 @@ const { getMilestoneInfo, extractCurrentMilestone, isMilestoneBoundedInRoadmap, import { platformWriteSync, platformReadSync, platformEnsureDir, retryRenameSync, toPosixPath, execGit } from './shell-command-projection.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import planningWorkspace = require('./planning-workspace.cjs'); -const { planningDir, planningPaths } = planningWorkspace; +const { planningDir, planningPaths, resolvePhaseIdConvention } = planningWorkspace; import { realClock } from './clock.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import frontmatter = require('./frontmatter.cjs'); @@ -116,7 +120,7 @@ import { stateReplaceFieldIfTemplate, stateCurrentPositionSlice, } from './state-document.cjs'; -import { tokenizeHeadings, collectSection, replaceSection } from './markdown-sectionizer.cjs'; +import { tokenizeHeadings, collectSection, replaceSection, stripFencedCode } from './markdown-sectionizer.cjs'; import type { HeadingToken } from './markdown-sectionizer.cjs'; import { parseMarkdownTable, updateTableCell, deleteTableRow, insertTableRow, splitTableRow, isDelimiterRow } from './markdown-table.cjs'; import { textEncodingError } from './validate.cjs'; @@ -1158,7 +1162,39 @@ function cmdStateUpdateProgress(cwd: string, raw: boolean): void { // excluded sentinels, unlike the owner). The owner already handles an // absent phasesDir as a real empty, so the fs.existsSync guard folds // into it. - const { value: phaseDirs, scope } = listMilestonePhaseDirs(phasesDir, { cwd }); + // + // #2761 (round-11 BLOCKER, single-derivation hygiene): `phaseIdConvention` + // threaded explicitly (resolved ambiently off `cwd` — this call site has + // no `ws` of its own, same contract `resolvePhaseIdConvention` uses + // elsewhere in this file, e.g. the `phaseConvention` ONCE-and-THREAD + // pattern at ~:2267/:2300) rather than left `undefined`. + // + // This does NOT change `phaseScope` — `scope` (roadmap-parser.cts + // `getMilestonePhaseFilter`) is assigned at :1979/:2030, both BEFORE + // `headingConvention` resolves at ~:2048, so the #3217 withhold gate a + // few lines below is convention-independent either way (verified + // empirically: forcing `phaseIdConvention: null` here left every + // `state update-progress` assertion in + // tests/adr-612-bracket-phase-counting.test.cjs's round-11 BLOCKER block + // unchanged). What DOES depend on convention is `phaseDirs`/`totalPlans` + // — the enumerated `.value` these two lines feed into the #3233 + // zero-plans no-op check just below. The actual `percent` this command + // reports/writes comes from a separate, already-correctly-threaded scan + // (`computeUpdateProgressPreview` -> `buildStateFrontmatter`, which + // resolves its own `phaseConvention` at :2267). Threading here removes a + // second, silent, lazily-resolved answer for the SAME question that scan + // already answers explicitly — the single-derivation discipline this + // file's own :2300 comment states as a rule — rather than fixing an + // observed defect. #2761 round-12: the #3233 gate IS the one place this + // is observable, so it — not the reported percent — is what + // tests/adr-612-bracket-phase-counting.test.cjs's round-12 addition to + // the round-11 BLOCKER block pins: a bracket milestone with no plans on + // disk versus a decoy directory outside the milestone window that must + // not be swept in by a pass-all degrade. + const { value: phaseDirs, scope } = listMilestonePhaseDirs(phasesDir, { + cwd, + phaseIdConvention: cwd ? resolvePhaseIdConvention(cwd) : null, + }); phaseScope = scope; for (const dir of phaseDirs) { const { planCount } = scanPhasePlans(path.join(phasesDir, dir)); @@ -2155,7 +2191,56 @@ function cmdStateSnapshot(cwd: string, raw: boolean): void { // ROADMAP phase token against an on-disk phase directory — moved to the // phase-id owner module in #2562 so every consumer derives BOTH sides of a // phase comparison from the same function (see phase-id.cts). Imported at the -// top of this file; call sites below are unchanged. +// top of this file; call sites below are unchanged. #612 threads the optional +// `convention` through that owner's `phaseKeyFromDir` (see phase-id.cts) rather +// than re-deriving a bracket-aware key here. + +/** + * #612: is the asserted milestone bounded to a heading in this ROADMAP? + * + * The legacy rule matches STATE's milestone STRING (`v2.0`) inside a heading. + * The ADR-canonical bracket milestone heading is `## [GSD.02] Foundation` — a + * name, no version — so that rule finds nothing, the milestone reads as + * unbounded, and total_phases falls back to the on-disk directory count. Under + * the bracket convention the milestone integer in the bracket is matched against + * the `vN` of the milestone string instead (READING-B parity). Gated, and only + * consulted after the legacy rule has already failed, so no non-bracket repo + * changes answer. + */ +function isMilestoneBounded(roadmapRaw: string, milestone: string, convention?: string | null): boolean { + // #3184: preserve roadmap-parser's canonical legacy answer and compose the + // gated bracket extension on top of it. Re-deriving the version-heading + // grammar here would restore the boundary drift that #3184 removed. + if (isMilestoneBoundedInRoadmap(roadmapRaw, String(milestone).trim())) return true; + if (convention !== 'bracket') return false; + const vMatch = String(milestone).trim().match(/^v(\d+)/i); + const milestoneInt = vMatch ? parseInt(vMatch[1], 10) : NaN; + if (!Number.isSafeInteger(milestoneInt)) return false; + // Canonical spelling only — see the note in roadmap-parser's scoping branch. + // Accepting `0*N` here bounded a milestone whose phases were invisible, which + // un-suppressed a progress percent computed off an unscoped disk count. + // #2761 M3: that padding rule and the grammar both come from the owner's + // `bracketMilestoneIntroSrcFor`. This line and roadmap-parser's selector were + // character-identical re-typings of one pattern, so "canonical spelling only" + // was a convention two files had to keep agreeing on by hand — and the drift + // guard could not see either copy. + // #612 round-4 (Major 1, F12): fence-aware via tokenizeHeadings, not a raw + // `.test(roadmapRaw)` — a FENCED `[GSD.02]` example heading (the ONLY one + // in the document, with no real section for the asserted milestone at + // all) previously bounded a milestone that isn't actually in the roadmap, + // un-suppressing a percent computed off the wrong (prior-milestone-plus- + // whole-disk) phase set. tokenizeHeadings never produces a token for a + // fenced line, so a fenced-only example can no longer satisfy this test. + const bracketMilestoneHeadingRe = new RegExp(`^${bracketMilestoneIntroSrcFor(milestoneInt)}`, 'i'); + // #612 round-5 (Minor 1): skip ≤3-space-indented tokens — `h.offset` is + // tokenizeHeadings' LINE-START offset, not the `#` character, so an + // indented heading here would bound a milestone the line-start-anchored + // raw predecessor never matched. Restores raw parity; see roadmap-parser's + // matching selector-reconstruction comment for the full rationale. + return tokenizeHeadings(roadmapRaw).some( + (h) => h.level <= 3 && roadmapRaw[h.offset] === '#' && bracketMilestoneHeadingRe.test(h.text), + ); +} /** * Extract the set of retired/folded phase keys from a ROADMAP milestone scope @@ -2178,15 +2263,30 @@ function cmdStateSnapshot(cwd: string, raw: boolean): void { * decimal, and project-code IDs are detected alike. Returns canonical keys * (see phaseKeyFromToken). */ -function extractRetiredPhaseNumbers(scope: string): Set { +function extractRetiredPhaseNumbers(scope: string, convention?: string | null): Set { const retired = new Set(); const isChecklistOrHeading = /^\s*(?:[-*+]\s*\[[ xX]\]|#{1,6}\s)/; - for (const line of scope.split(/\r?\n/)) { + // #612: the retirement filter has to widen with the counter it protects. The + // canonical #1514 gesture strikes the checklist BULLET and leaves the detail + // heading intact, so a bracket-form retirement went undetected and the phase + // stayed in the denominator forever — a shipped bracket milestone could never + // reach 100%. Same selection rule as the counter: a non-bracket repo compiles + // the bare `Phase\s+` this line spelled before. + const introSrc = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention); + const phaseRefRe = new RegExp(`^[\\s*_]*${introSrc}([\\w][\\w.-]*)`, 'i'); + // #612 round-5 (Major 1): fence-aware on the BRACKET path only — a fenced + // AUTHORING EXAMPLE of the #1514 retirement gesture, spelled in bracket + // form, must not retire a real phase. Reuses markdown-sectionizer's + // single-owner stripFencedCode rather than a second fence parser. Legacy + // stays the raw `scope`, byte-identical — its own fenced-example hazard is + // pre-existing and out of scope. + const scanScope = convention === 'bracket' ? stripFencedCode(scope).text : scope; + for (const line of scanScope.split(/\r?\n/)) { if (!isChecklistOrHeading.test(line)) continue; const strikeSpan = /~~([^~]*?)~~/g; let s: RegExpExecArray | null; while ((s = strikeSpan.exec(line)) !== null) { - const phaseRef = /^[\s*_]*Phase\s+([\w][\w.-]*)/i.exec(s[1]); + const phaseRef = phaseRefRe.exec(s[1]); // Require a digit so struck prose like ~~Phase Overview~~ is ignored. if (phaseRef && /\d/.test(phaseRef[1])) retired.add(phaseKeyFromToken(phaseRef[1])); } @@ -2194,6 +2294,94 @@ function extractRetiredPhaseNumbers(scope: string): Set { return retired; } +/** + * #612 (round-4 fix): the single shared implementation for the phase-heading + * counter `buildStateFrontmatter` (read path) and `cmdStateSync` (write + * path) each built inline as an independent copy. The comment at each call + * site already claimed "the two counters must see the same phases or + * `state json` and `state sync` report different totals for one repo + * (#3242 Bug B)" — this makes that invariant STRUCTURAL (one implementation, + * two call sites) instead of two copies a future edit could silently + * diverge. + * + * Two DELIBERATELY DIFFERENT counting strategies, selected by `convention`: + * + * - BRACKET: counts via `tokenizeHeadings(scope)` at levels 2-4 (mirroring + * `getMilestonePhaseFilter`'s own level bound, `roadmap-parser.cts:1090`), + * testing each heading's (hash-stripped, fence-STRIPPED-by-construction) + * text against the phase-heading-intro grammar directly. Fence-aware by + * construction — `tokenizeHeadings` never produces a token for a fenced + * line — closing round-4's Major 1: a fenced EXAMPLE phase heading in the + * preamble (`` ### [GSD.02] 05: Example phase `` inside a + * ` ```markdown ` block) previously inflated this count via the raw regex + * below, which ran over the whole scope STRING with no fence awareness at + * all (F9, F10 — `total_phases` read 3 where the milestone has 2 real + * phases). The producer (`extractCurrentMilestone`'s returned scope + * string) is deliberately NOT changed — every other consumer of that + * string needs its full content fidelity, and the legacy path's identity + * forbids touching the string all consumers share; this fixes the + * COUNTING, not the scope. + * + * - LEGACY (any non-bracket convention, including unresolved/null): retain + * the existing raw `content.exec()` counting strategy. On the read path, + * route sentinel exclusion through #3185's canonical predicate; the sync + * path intentionally retains its pre-existing absence of that exclusion. + * + * `applyConventionTokenSentinelRules` makes the remaining convention-specific + * asymmetry explicit. Both read and sync exclude bare bracket token 999; only + * the read path excludes canonical legacy sentinels. Both bracket paths also + * retain the bracket-id and bare-0 rules. Sharing the implementation therefore + * cannot silently move either convention's total. + */ +function countRoadmapPhaseHeadings( + scope: string, + convention: string | null | undefined, + retiredPhaseNums: Set, + applyConventionTokenSentinelRules: boolean, +): number { + let count = 0; + if (convention === 'bracket') { + const introSrc = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, true); + const phaseHeadingPattern = new RegExp(`^${introSrc}([\\w][\\w.-]*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'i'); + for (const h of tokenizeHeadings(scope)) { + if (h.level < 2 || h.level > 4) continue; + const m = phaseHeadingPattern.exec(h.text); + if (!m) continue; + const bracketId = m[1]; + const token = m[2]; + // Only count tokens that contain at least one digit — excludes + // pure-word section headings (Overview, Details) while keeping + // numeric phases (01, 05.1) and project-code IDs (PROJ-42). + if (!/\d/.test(token)) continue; + // #612 READING-B: a bracket heading carries its sentinel in the + // bracket, so `### [GSD.999] 01:` is an icebox item even though its + // token is `01`. + if (bracketId && isSentinelPhaseId(`${bracketId}-${token}`, 'bracket')) continue; + // #612: under bracket the token rule composes with the bracket-id + // check as the engine's {0, 999} sentinel set. + if (bracketId && /^0\b/.test(token)) continue; + if (applyConventionTokenSentinelRules && /^999\b/.test(token)) continue; + // #1514: retired/folded phases are struck through in the ROADMAP; + // exclude them from the denominator (they can never be completed). + if (retiredPhaseNums.has(phaseKeyFromToken(token))) continue; + count++; + } + return count; + } + // LEGACY stays on the pre-round-4 raw exec loop. #3185 owns the read-path + // sentinel predicate; sync deliberately preserves its prior behavior. + const phaseHeadingPattern = new RegExp(`#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, true)}([\\w][\\w.-]*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'gi'); + let m: RegExpExecArray | null; + while ((m = phaseHeadingPattern.exec(scope)) !== null) { + const token = m[1]; + if (!/\d/.test(token)) continue; + if (applyConventionTokenSentinelRules && isSentinelPhaseId(token)) continue; + if (retiredPhaseNums.has(phaseKeyFromToken(token))) continue; + count++; + } + return count; +} + /** * Extract machine-readable fields from STATE.md markdown body and build * a YAML frontmatter object. Allows hooks and scripts to read state @@ -2292,6 +2480,11 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto // does not touch and which predates listMilestonePhaseDirs entirely. let diskScope: Scope = SCOPE.COMPLETE; + // #612: resolved ONCE per call, federated workstream -> root, and shared by + // the heading counter, the retirement filter and the retired-directory skip so + // no two of them can split on different answers. + const phaseConvention = cwd ? resolvePhaseIdConvention(cwd) : null; + if (cwd) { try { const phasesDir = planningPaths(cwd).phases; @@ -2312,7 +2505,7 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto roadmapRaw = platformReadSync(roadmapPath); if (roadmapRaw !== null) { roadmapScope = extractCurrentMilestone(roadmapRaw, cwd); - retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope); + retiredPhaseNums = extractRetiredPhaseNumbers(roadmapScope, phaseConvention); } } catch { /* fall through: no roadmap scope → no retired exclusion */ } @@ -2323,7 +2516,11 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto // CURRENT (stored) milestone" — routed through the canonical owner // instead of a hand-rolled readdirSync + isDirInMilestone filter // (which also never excluded sentinels, unlike the owner). - const { value: allMatchingDirs, scope: phaseDirScope } = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: storedMilestone ?? null }); + const { value: allMatchingDirs, scope: phaseDirScope } = listMilestonePhaseDirs(phasesDir, { + cwd, + versionOverride: storedMilestone ?? null, + phaseIdConvention: phaseConvention, + }); // Bug #2445: when stale phase dirs from a prior milestone remain in // .planning/phases/ alongside new dirs with the same phase number, @@ -2336,7 +2533,7 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto // artifact; drop it from the disk phase set so it counts toward // neither the denominator nor the numerator (mirrors the heading // exclusion below). Project-code-aware via phaseKeyFromDir. - if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir))) continue; + if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir, phaseConvention))) continue; // #3185: dedup grouping routed through the canonical phaseKeyFromDir // (src/phase-id.cts) instead of a local leading-digits regex that // diverged from extractPhaseToken/phaseKeyFromDir on @@ -2344,7 +2541,7 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto // so a `PROJ-05`/`PROJ-05-slug` pair never deduped) and on // multi-segment milestone dirs. Same key surface used two lines // above for the retiredPhaseNums exclusion, so both filters agree. - const key = phaseKeyFromDir(dir); + const key = phaseKeyFromDir(dir, phaseConvention); if (!seenPhaseNums.has(key)) { seenPhaseNums.set(key, dir); } else { @@ -2390,29 +2587,15 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto // leave the third" gap §7.4's forcing function rules out. if (isPhaseComplete(phaseDir).value.complete) diskCompletedPhases++; } - // Count phase headings from ROADMAP using a digit-containing pattern - // that matches both numeric phases (01, 05.1) and project-code phases - // (PROJ-42, CK-05) but excludes pure-word section headers like - // `## Phase Overview:` or `## Phase Details:` — single source of - // truth for total_phases (#549). - let roadmapPhaseCount = 0; - if (roadmapScope !== null) { - // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi; - let m: RegExpExecArray | null; - while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) { - // Only count tokens that contain at least one digit — excludes - // pure-word section headings (Overview, Details) while keeping - // numeric phases (01, 05.1) and project-code IDs (PROJ-42). - // Also exclude sentinel phases (0 and 999.x backlog). - // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0. - if (!/\d/.test(m[1]) || isSentinelPhaseId(m[1])) continue; - // #1514: retired/folded phases are struck through in the ROADMAP; - // exclude them from the denominator (they can never be completed). - if (retiredPhaseNums.has(phaseKeyFromToken(m[1]))) continue; - roadmapPhaseCount++; - } - } + // Count phase headings from ROADMAP — single source of truth for + // total_phases (#549). #612 round-4: shared with cmdStateSync's + // identical-purpose counter via countRoadmapPhaseHeadings (above + // extractRetiredPhaseNumbers). The shared helper composes its + // fence-aware bracket strategy with #3185's canonical legacy + // sentinel predicate for this read-path call. + const roadmapPhaseCount = roadmapScope !== null + ? countRoadmapPhaseHeadings(roadmapScope, phaseConvention, retiredPhaseNums, true) + : 0; cached = (() => { // #1761 read-path: mirror the cmdStateSync guard (#1794). When the @@ -2438,7 +2621,7 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto // the prior inline regex had no boundary assertion after the // version token, so `v2.0` matched inside `v2.0.1` (#2562-class // defect, design row 17). - milestoneBounded = isMilestoneBoundedInRoadmap(roadmapRaw, String(assertedMilestoneVersion).trim()); + milestoneBounded = isMilestoneBounded(roadmapRaw, String(assertedMilestoneVersion).trim(), phaseConvention); } // #2828: distinguish a FLAT unmilestoned roadmap (no milestone sectioning // at all — only Phase headings) from a MILESTONED-but-unbounded one @@ -5005,10 +5188,28 @@ function cmdStateValidate(cwd: string, raw: boolean, opts: { strict?: boolean } emit({ valid: false, warnings, scope }); return; } + // #612: #3208 replaced this lookup's `startsWith` prefix test with the + // canonical key comparison — which is the right surface, and is exactly why it + // now needs the convention. `phaseKeyFromDir` refuses to read a bracket + // directory without an explicit signal (a bracket dir is string- + // indistinguishable from the legacy letter-prefixed-decimal family, ADR-2121), + // so un-threaded it returns the WHOLE dir name as the key — + // `GSD.02-05-delta` -> `GSD.02-5-DELTA` — while `selectedPhaseKey` is the bare + // `05` that `parsePhaseFromProse` yields. The two sides of one comparison were + // derived under different conventions, which is #2562's defect class and the + // thing this file's other three `phaseKeyFromDir` call sites already thread + // against. Un-threaded, a bracket repo whose phase directory plainly exists + // reports `no phase directory matches phase 05` and `valid: false` — a + // wrong-and-confident answer on precisely the repos this convention supports. + // Resolved here rather than reusing a caller's value because cmdStateValidate + // has no other convention-dependent read. Non-bracket conventions (null, + // 'milestone-prefixed', unresolvable) are byte-identical to the un-threaded + // call by construction: `extractPhaseToken` branches only on `=== 'bracket'`. + const validateConvention = resolvePhaseIdConvention(cwd); let phaseDirPath: string; try { const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name) === selectedPhaseKey); + const phaseDir = entries.find(entry => entry.isDirectory() && phaseKeyFromDir(entry.name, validateConvention) === selectedPhaseKey); if (!phaseDir) { warnings.push(stateDiagnostic( 'S004', @@ -5227,22 +5428,52 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b let syncRoadmapScope: string | null = null; let syncRoadmapRaw: string | null = null; let syncRetiredPhaseNums = new Set(); + const syncConvention = resolvePhaseIdConvention(cwd); try { const roadmapRaw = platformReadSync(path.join(planningDir(cwd), 'ROADMAP.md')); if (roadmapRaw !== null) { syncRoadmapRaw = roadmapRaw; syncRoadmapScope = extractCurrentMilestone(roadmapRaw, cwd); - syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope); + syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope, syncConvention); } } catch { /* fall through: no roadmap scope → no retired exclusion */ } + // #2761 Major 1 (round-2 adversarial review): this disk scan fed + // totalDiskPlans/totalDiskSummaries/diskCompletedPhases/syncTotalPhases + // below UNFILTERED — no milestone-window filter, unlike + // buildStateFrontmatter's identical-purpose scan a few hundred lines above + // (`:1698`). One command (`state sync`) therefore wrote TWO contradictory + // numbers into the same STATE.md: frontmatter total_phases/completed_phases + // milestone-scoped correctly (via the READ derivation), body Progress + // percent computed from the whole disk. On the ADR-canonical version-less + // bracket fixture (4 dirs, 3 complete; asserted milestone = 2 phases, both + // complete): body wrote 75% where 100% is true (repro3). + // + // GATED on `syncConvention === 'bracket'` — an unconditional filter would + // ALSO move LEGACY sync percents, since the milestone-scoping-vs-whole-disk + // divergence this fixes is engine-wide, not bracket-specific; the gate + // keeps legacy byte-identical, which is the binding constraint here. This + // is a DEVIATION from an earlier "mirror :1698 unconditionally" phrasing — + // deliberate, not an oversight: legacy repos are DOWNSTREAM of a Progress + // percent that has read this way for a long time, and moving it as a side + // effect of a bracket-only PR is out of this fix's scope. + // Upstream #3185 made `listMilestonePhaseDirs` the sole phase-directory + // enumeration owner; it delegates window membership to + // getMilestonePhaseFilter. Cache that owner's bracket result as a set and + // compose it with this scan, rather than restoring the retired direct + // parser dependency. Legacy retains this scan's prior pass-all behavior. + const syncMilestonePhaseDirs = syncConvention === 'bracket' + ? new Set(listMilestonePhaseDirs(phasesDir, { cwd, phaseIdConvention: syncConvention }).value) + : null; + // Scan all phases let entries: string[]; try { entries = fs.readdirSync(phasesDir, { withFileTypes: true }) .filter(e => e.isDirectory()) .map(e => e.name) - .filter(name => !(syncRetiredPhaseNums.size > 0 && syncRetiredPhaseNums.has(phaseKeyFromDir(name)))) + .filter(name => !(syncRetiredPhaseNums.size > 0 && syncRetiredPhaseNums.has(phaseKeyFromDir(name, syncConvention)))) + .filter(name => syncMilestonePhaseDirs === null || syncMilestonePhaseDirs.has(name)) .sort(); } catch { output({ synced: true, changes: [], dry_run: !!verify }, raw, undefined); @@ -5291,26 +5522,18 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b } // Determine total phases from ROADMAP (may be larger than realized disk dirs). - // Mirrors the logic in buildStateFrontmatter so both report consistent percents (#3242 Bug B). - // DEAD catch removed (#2245 audit): every operation in this block is a regex - // exec/test over an already-read string plus pure Set/Math ops — none of - // which can throw — so the try/catch could never be triggered. + // #612 round-4: shares countRoadmapPhaseHeadings with buildStateFrontmatter + // (defined just above extractRetiredPhaseNumbers) so both report + // consistent totals off the SAME implementation, not two independently + // maintained copies (#3242 Bug B). + // #612 round-5: bracket sync enables the same bare-token 999 exclusion as + // the read path and getMilestonePhaseFilter, preventing frontmatter/body + // disagreement. Non-bracket conventions still pass false, preserving the + // pre-existing legacy sync behavior while #3185 remains the read-path owner. let syncTotalPhases: number | null = null; - let roadmapPhaseCount = 0; - if (syncRoadmapScope !== null) { - // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi; - let m: RegExpExecArray | null; - while ((m = phaseHeadingPattern.exec(syncRoadmapScope)) !== null) { - // Only count tokens that contain at least one digit — excludes - // pure-word section headings (Overview, Details) while keeping - // numeric phases (01, 05.1) and project-code IDs (PROJ-42). - if (!/\d/.test(m[1])) continue; - // #1514: retired/folded phases are struck through; exclude from total. - if (syncRetiredPhaseNums.has(phaseKeyFromToken(m[1]))) continue; - roadmapPhaseCount++; - } - } + const roadmapPhaseCount = syncRoadmapScope !== null + ? countRoadmapPhaseHeadings(syncRoadmapScope, syncConvention, syncRetiredPhaseNums, syncConvention === 'bracket') + : 0; if (roadmapPhaseCount > 0) { syncTotalPhases = Math.max(entries.length, roadmapPhaseCount); } else { @@ -5330,8 +5553,9 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b if (versionStr !== null && syncRoadmapRaw !== null) { // #3184: routed through the single owner (roadmap-parser.cjs) instead of // a hand-rolled, unbounded-substring re-derivation — see the identical - // fix in buildStateFrontmatter above. - milestoneBounded = isMilestoneBoundedInRoadmap(syncRoadmapRaw, versionStr); + // fix in buildStateFrontmatter above. #612 composes its gated bracket + // extension on top inside isMilestoneBounded. + milestoneBounded = isMilestoneBounded(syncRoadmapRaw, versionStr, syncConvention); } let percent: number | null = null; if (!milestoneBounded) { @@ -5348,6 +5572,20 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b // it here (discarding `.value`, which duplicates `entries`'s own // retired-phase-filtered listing) gets the real scope without changing // the disk-scan totals computed above. + // + // #2761 (round-11 M2 follow-up): deliberately NOT threading + // `phaseIdConvention` here, unlike the other call sites this same PR + // converts. Only `.scope` is consumed (the `.value` directory list is + // thrown away), and inside `getMilestonePhaseFilter` `scope` is computed + // from `extractCurrentMilestoneScoped`/`classifyMilestoneWindow` BEFORE + // `headingConvention` is resolved — `phaseIdConvention` only reaches the + // heading/dir MEMBERSHIP scan (`scanMilestonePhaseIds`, `isDirInMilestone`) + // that produces `.value`, never the scope discriminator itself. So the + // `undefined` default here (lazy resolve-from-config) and an explicitly + // threaded `syncConvention` would compute the identical `scope` either + // way — there is no silent-inherit exposure to close at this site, only + // at sites (milestone.cts, cmdStateUpdateProgress above) that also + // consume `.value`. const syncScope: Scope = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: versionStr }).scope; if (syncScope !== SCOPE.COMPLETE) { changes.push(`Progress: skipped — milestone phase scope is "${syncScope}", not COMPLETE (#3217)`); diff --git a/src/validate.cts b/src/validate.cts index a80e30ec6..050f1028c 100644 --- a/src/validate.cts +++ b/src/validate.cts @@ -42,7 +42,24 @@ const { CASE_FLEXIBLE_PROJECT_CODE_PREFIX_SOURCE, CASE_FLEXIBLE_PHASE_NUMBER_TOKEN_SOURCE, PHASE_CONTINUATION_SEGMENT_SOURCE, + BRACKET_DIR_PREFIX_SRC, + phaseHeadingPrefixSrcFor, + PHASE_HEADING_BASELINE, + extractPhaseToken, + isSentinelPhaseId, + // #612 / #3309 re-homing: `checkBracketCoherence` (below) moved into this + // module when #3309 migrated `cmdValidateHealth` onto the rule table and + // deleted every local helper it used to sit beside in `verify.cts`. These + // four are the grammar owners it consumes, imported unchanged — nothing about + // the check is widened by the relocation. + BRACKET_ID_SRC, + PHASE_NUMBER_TOKEN_SOURCE, + // #2761 M3: canonical bracket milestone intro, re-homed here with + // checkBracketCoherence when health diagnostics moved to the rule table. + BRACKET_MILESTONE_INTRO_CAPTURING_SRC, + foldBracketId, } = phaseIdMod; +import { tokenizeHeadings } from './markdown-sectionizer.cjs'; // ── Issue #26: regex constants (W005, W006-archived) ──────────────────────── // Matches legacy numeric dirs (01-setup), milestone-prefixed dirs (02-01-setup), @@ -76,6 +93,70 @@ export const PHASE_TOKEN_FROM_DIR_RE = new RegExp( ); export const MILESTONE_ARCHIVE_DIR_RE = /^v\d+.*-phases$/i; +// ── #612: bracket phase-directory recognition (convention-gated) ──────────── +// `{CODE}.{MM}-{PP}[.{SS}][-slug]`, built from the one bracket identity grammar. +// +// This lives BESIDE phaseDirNameRe / PHASE_TOKEN_FROM_DIR_RE rather than being +// folded into them. The `{CODE}.{MM}-` prefix is string-indistinguishable from +// the legacy letter-prefixed-decimal family this repo documents as "ambiguous +// with a padded bracket dir", and folding a bracket branch in changes those +// constants' answers on exactly that family: `P0.34-56-name` goes null -> "56", +// and phaseDirNameRe goes false -> true, silencing a W005 that fires today. A +// RegExp constant has nowhere to attach a convention gate, so the gate goes on +// the functions and the constants stay byte-identical for every consumer. +// +// The numeric run mirrors the EMIT grammar rather than accepting any digit run: +// CANONICAL_NUMERIC_RE (what toDir enforces) is digits-only with at most one +// sub-phase, so `GSD.02-12A-hotfix` and `GSD.02-05.03.07-x` are not bracket +// directories. Admitting them would make this recognizer disagree with +// extractPhaseToken, which the milestone-complete check resolves through — and +// then W006/W007 would resolve a directory that W021 simultaneously reported +// unstarted, inside one `validate health` run. +export const BRACKET_PHASE_DIR_RE = new RegExp( + `^(?:${BRACKET_DIR_PREFIX_SRC})\\d+(?:\\.\\d+)?(?:-[\\w-]+)?$`, + 'i', +); + +// The constants these functions wrap are consumed as `e.name.match(RE)`, which +// throws on a non-string. Coercing instead would invent a phase token out of a +// number (`42` -> `"42"`), so the contract is preserved rather than softened. +function assertDirName(value: unknown, fn: string): string { + if (typeof value !== 'string') { + throw new TypeError(`${fn}: directory name must be a string, received ${typeof value}`); + } + return value; +} + +/** + * True when `dirName` is a recognizable phase directory under `convention`. + * Under 'bracket' the `{CODE}.{MM}-{PP}` form is additionally accepted, so W005 + * stops reporting every bracket phase directory as malformed. Every other + * convention value delegates to the unchanged `phaseDirNameRe`. + */ +export function isPhaseDirName(dirName: string, convention?: string | null): boolean { + const name = assertDirName(dirName, 'isPhaseDirName'); + if (convention === 'bracket' && BRACKET_PHASE_DIR_RE.test(name)) return true; + return phaseDirNameRe.test(name); +} + +/** + * Extract a phase token from a directory name under `convention`, or null when + * the name is not a phase directory — the same contract as + * `PHASE_TOKEN_FROM_DIR_RE.exec()[1]`. + * + * Under 'bracket' the SHAPE is recognized here and the TOKEN is delegated to the + * canonical owner, so this and every other bracket directory reader resolve + * identically by construction rather than by two regexes agreeing today. + */ +export function phaseTokenFromDir(dirName: string, convention?: string | null): string | null { + const name = assertDirName(dirName, 'phaseTokenFromDir'); + if (convention === 'bracket' && BRACKET_PHASE_DIR_RE.test(name)) { + return extractPhaseToken(name, 'bracket'); + } + const legacy = name.match(PHASE_TOKEN_FROM_DIR_RE); + return legacy ? legacy[1] : null; +} + // ── Issue #26: I001 canonicalization ──────────────────────────────────────── export function canonicalPlanStem(stem: string): string { // #2043: the plan component (after the phase number) must be zero-padded, @@ -95,6 +176,20 @@ export function canonicalPlanStem(stem: string): string { export interface RoadmapPhaseVariantsResult { roadmapPhases: Set; roadmapPhaseVariants: Set; + /** + * #612: tokens borne ONLY by sentinel-bracket headings (0.x backlog / 999.x + * icebox). Populated only under the bracket convention; empty otherwise, so no + * legacy caller changes behaviour. Surfaced rather than filtered in place + * because roadmapPhases feeds both a membership check and a missing-directory + * warning, and only the latter should ignore an icebox item. + * + * OCCURRENCE-AWARE, and that is the whole subtlety: roadmapPhases is a TOKEN + * set, so `[GSD.999] 01` and `[GSD.02] 01` collapse to one entry. Keying + * suppression on the token alone let an icebox heading silence a REAL phase + * that happens to share its number — a false negative worse than the warning + * it removed. A token is suppressed only when NO non-sentinel heading bears it. + */ + sentinelPhases: Set; } // ── Issue #6: phase variant helpers (W006/W007) ────────────────────────────── @@ -133,34 +228,66 @@ export function phaseVariants(phase: string): Set { return variants; } -export function buildRoadmapPhaseVariants(roadmapContent: string): RoadmapPhaseVariantsResult { +export function buildRoadmapPhaseVariants(roadmapContent: string, convention?: string | null): RoadmapPhaseVariantsResult { const roadmapPhases = new Set(); const roadmapPhaseVariants = new Set(); + const sentinelOnly = new Set(); + const realTokens = new Set(); // Matches both legacy numeric (Phase 1:), decimal (Phase 2.1:), milestone-prefixed (Phase 2-01:), // and bracket-prefixed (### [GSD] Phase 2-01:) headings. // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phasePattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi; + // #612: SELECTED by the resolved convention. This capture class is + // letter-tolerant, which makes it the site where an ungated widening does the + // most damage — `### [RFC.2119] 5:` enters roadmapPhases as a phantom and + // becomes a W007 "in ROADMAP.md but no directory on disk" on a repo that never + // opted in. A non-bracket repo compiles the base source unchanged. + const capturing = convention === 'bracket'; + const g = capturing ? 1 : 0; + const phasePattern = new RegExp(`#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention, capturing)}([\\w][\\w.-]*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'gi'); let m: RegExpExecArray | null; while ((m = phasePattern.exec(roadmapContent)) !== null) { - roadmapPhases.add(m[1]); - for (const variant of phaseVariants(m[1])) roadmapPhaseVariants.add(variant); + const token = m[1 + g]; + const bracketId = g ? m[1] : undefined; + if (bracketId && isSentinelPhaseId(`${bracketId}-${token}`, 'bracket')) sentinelOnly.add(token); + else realTokens.add(token); + roadmapPhases.add(token); + for (const variant of phaseVariants(token)) roadmapPhaseVariants.add(variant); } // Also matches checklist-style entries (checked or unchecked): // - [x] **Phase 01: name** - [X] **Phase 2-01: name** - [ ] **Phase 3: name** // This is a supported ROADMAP format (parallel to buildNotStartedPhaseVariants). - const checklistPattern = /-\s*\[[ xX]\]\s*\*{0,2}Phase\s+([\w][\w.-]*)\s*:/gi; + // #612: CAPTURING, exactly as the sibling checklist scan in roadmap.cts does + // and for the same stated reason — "the bracket id rides along so the sentinel + // filter below is not blind to `- [ ] **[GSD.999] 01: Icebox**`". Left + // un-capturing here, this scan called every checklist token REAL, and the + // occurrence-aware un-suppression loop below then deleted the icebox token that + // the HEADING scan had correctly marked sentinel — so `validate consistency` + // warned that a bracket ICEBOX phase had no directory, in the house ROADMAP + // shape (bold bullet index + detail headings) where the icebox appears as both. + // `validate health` stayed silent on the same repo, so the two verbs disagreed + // — the disagreement `sentinelPhases` exists to close. + const checklistPattern = new RegExp(`-\\s*\\[[ xX]\\]\\s*\\*{0,2}${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, capturing)}([\\w][\\w.-]*)\\s*:`, 'gi'); let cm: RegExpExecArray | null; while ((cm = checklistPattern.exec(roadmapContent)) !== null) { - roadmapPhases.add(cm[1]); - for (const variant of phaseVariants(cm[1])) roadmapPhaseVariants.add(variant); + const cBracketId = g ? cm[1] : undefined; + const cToken = cm[1 + g]; + if (cBracketId && isSentinelPhaseId(`${cBracketId}-${cToken}`, 'bracket')) sentinelOnly.add(cToken); + else realTokens.add(cToken); + roadmapPhases.add(cToken); + for (const variant of phaseVariants(cToken)) roadmapPhaseVariants.add(variant); } - return { roadmapPhases, roadmapPhaseVariants }; + // A token borne by BOTH a sentinel and a real heading is not suppressed. + for (const t of realTokens) sentinelOnly.delete(t); + return { roadmapPhases, roadmapPhaseVariants, sentinelPhases: sentinelOnly }; } -export function buildNotStartedPhaseVariants(roadmapContent: string): Set { +export function buildNotStartedPhaseVariants(roadmapContent: string, convention?: string | null): Set { const notStartedPhases = new Set(); // Also matches milestone-prefixed and bracket-prefixed checklist items. - const uncheckedPattern = /-\s*\[\s\]\s*\*{0,2}Phase\s+([\w][\w.-]*)[:\s*]/gi; + // Trailing class is `[:\s*]` — a SPACE terminates the token here, not only a + // colon — so this site is the loosest of the three and the one where a + // retro-granted bracket tolerance would suppress a live W006. + const uncheckedPattern = new RegExp(`-\\s*\\[\\s\\]\\s*\\*{0,2}${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention)}([\\w][\\w.-]*)[:\\s*]`, 'gi'); let um: RegExpExecArray | null; while ((um = uncheckedPattern.exec(roadmapContent)) !== null) { for (const variant of phaseVariants(um[1])) notStartedPhases.add(variant); @@ -168,6 +295,127 @@ export function buildNotStartedPhaseVariants(roadmapContent: string): Set isSentinelPhaseId(String(n)); + const pad2 = (n: number) => (Number.isSafeInteger(n) ? String(n).padStart(2, '0') : 'unknown'); + + // A bracket PHASE heading: `### [CODE.MM] 05:` or `### [CODE.MM] Phase 5:`. + const bracketPhaseRe = new RegExp(`^\\[(${BRACKET_ID_SRC})\\][ \\t]*(?:Phase\\s+)?(${PHASE_NUMBER_TOKEN_SOURCE})\\s*:`, 'i'); + // A NON-bracket phase heading — requires the literal `Phase` label, so a bare + // `2026:` year heading is not a phase. Token is the full phase-number grammar + // so M-NN and letter-suffixed ids are RECOGNIZED (and flagged), not skipped. + const legacyPhaseRe = new RegExp(`^Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE}|\\d+(?:-\\d+)+[A-Z]?(?:\\.\\d+)*)\\s*:`, 'i'); + // A bracket MILESTONE section heading. + const bracketSectionRe = new RegExp(`^${BRACKET_MILESTONE_INTRO_CAPTURING_SRC}`, 'i'); + // A legacy milestone section heading (`## v2.0 — Name`). + const legacyMilestoneRe = /^v\d+(?:\.\d+)*\b/i; + + let sectionMilestone: number | null = null; + for (const heading of tokenizeHeadings(roadmapContent)) { + const text = heading.text.trim(); + const bracketPhase = text.match(bracketPhaseRe); + const legacyPhase = bracketPhase ? null : text.match(legacyPhaseRe); + const isPhaseHeading = Boolean(bracketPhase || legacyPhase); + + if (!isPhaseHeading && heading.level <= 3) { + const bracketSection = text.match(bracketSectionRe); + if (bracketSection) { + const mm = parseInt(bracketSection[1], 10); + sectionMilestone = Number.isSafeInteger(mm) ? mm : null; + continue; + } + // Only a MILESTONE heading closes the section. Any other heading (`### Notes`, + // `## Backlog`) leaves the scope exactly as it was. + if (legacyMilestoneRe.test(text.replace(/^\[[^\]]{0,200}\][ \t]*/, ''))) sectionMilestone = null; + continue; + } + if (!isPhaseHeading) continue; + if (sectionMilestone === null || isSentinel(sectionMilestone)) continue; + + if (bracketPhase) { + const folded = foldBracketId(bracketPhase[1]); + const dot = folded.lastIndexOf('.'); + const phaseMilestone = parseInt(folded.slice(dot + 1), 10); + if (!Number.isSafeInteger(phaseMilestone) || isSentinel(phaseMilestone)) continue; + if (phaseMilestone !== sectionMilestone) { + incoherences.push({ + kind: 'mismatch', + phaseId: bracketPhase[2], + sectionMilestone: pad2(sectionMilestone), + phaseMilestone: pad2(phaseMilestone), + }); + } + continue; + } + + incoherences.push({ + kind: 'missing-bracket', + phaseId: legacyPhase![1], + sectionMilestone: pad2(sectionMilestone), + phaseMilestone: pad2(sectionMilestone), + }); + } + return incoherences; +} + /** * Detect binary corruption (embedded NUL bytes) in a text artifact's bytes. * diff --git a/src/workstream-inventory.cts b/src/workstream-inventory.cts index bcdb9bd96..d053f05d2 100644 --- a/src/workstream-inventory.cts +++ b/src/workstream-inventory.cts @@ -97,7 +97,19 @@ function countRoadmapPhases(roadmapPath: string, fallbackCount: number, cwd?: st try { if (!fs.existsSync(roadmapPath)) return fallbackCount; if (!cwd) return fallbackCount; - const filter = getMilestonePhaseFilter(cwd, versionOverride ?? null, null, ws ?? null); + // #2761 B1 (trek-e review): `undefined`, NOT `null`. The third argument + // discriminates "not resolved yet — resolve it from this workstream's + // config" (`undefined`) from "resolved, and it is not bracket" (`null`). + // This site meant the former and spelled the latter, so a workstream that + // opted into `phase_id_convention: "bracket"` had its ROADMAP scanned with + // the LEGACY grammar: the heading set came back empty, `phaseCount` was 0, + // and the count silently fell back to the on-disk directory count — the + // very degrade the #3185 rewrite of this function existed to remove. + // Measured on a bracket workstream whose milestone declares 3 phases: + // `null` -> 0 (fallback), `undefined` -> 3. A non-bracket workstream + // resolves to a value that compiles the same base grammar `null` did, so + // its count is unchanged (verified against a legacy control). + const filter = getMilestonePhaseFilter(cwd, versionOverride ?? null, undefined, ws ?? null); // A pass-all degrade (phaseCount 0) means the window declared no phases — // fall back rather than reporting a confident zero. return filter.phaseCount > 0 ? filter.phaseCount : fallbackCount; @@ -550,7 +562,13 @@ function inspectWorkstream(cwd: string, name: string, options: InspectWorkstream // filtering. Consulted only when it is genuinely scoped to a single milestone // (`versionScoped`); the unversioned whole-roadmap shape spans the project's // lifetime and would re-admit prior-milestone phases — the very defect here. - const headingFilter = getMilestonePhaseFilter(cwd, currentVersion, null, name); + // + // #2761 B1 (trek-e review): `undefined`, not `null` — same discriminator fix + // as `countRoadmapPhases` above. This site iterates workstreams by NAME, so + // it is exactly the caller that cannot set `GSD_WORKSTREAM`; spelling `null` + // pinned every workstream to the legacy grammar and made `headingScoped` + // unreachable (`phaseCount > 0` never held) on a bracket workstream. + const headingFilter = getMilestonePhaseFilter(cwd, currentVersion, undefined, name); const headingScoped = headingFilter.versionScoped && headingFilter.phaseCount > 0; // Phase keys the ROADMAP attributes to some OTHER milestone. A row carrying an diff --git a/tests/adr-612-bracket-coherence.test.cjs b/tests/adr-612-bracket-coherence.test.cjs new file mode 100644 index 000000000..12ff2b016 --- /dev/null +++ b/tests/adr-612-bracket-coherence.test.cjs @@ -0,0 +1,583 @@ +'use strict'; + +/** + * PR-2 (#2761 / epic #612) — verify.cts: the advisory bracket-coherence W021 and + * the milestone-complete (B6) read. + * + * Postures differ on purpose. checkBracketCoherence is a CHECK THAT CAN FAIL A + * REPO, so it is gated on `phase_id_convention === 'bracket'`. B6 is pinned by + * bug-557 with an empty config, so it fires on every repo — but its heading + * grammar is still SELECTED, never inferred: an earlier design read 'bracket' + * off the shape of a matched bracket, which ran a repo-failing check against a + * legacy ROADMAP that merely contained `### [RFC.2119] 5:`. + * + * Both commands resolve the convention through the SAME federated + * workstream->root resolver. Reading it from two different bases split + * `validate consistency` from `validate health` on workstream repos: one widened + * the ROADMAP read while the other kept the directory read narrow, so every + * bracket phase was reported either missing from disk or malformed on disk + * depending on which side you looked at. + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +let tmpDir; + +function writeProject({ roadmap, convention, status = 'executing', phaseDirs = [], ws = null }) { + const root = path.join(tmpDir, '.planning'); + const base = ws ? path.join(root, 'workstreams', ws) : root; + fs.mkdirSync(base, { recursive: true }); + fs.writeFileSync(path.join(base, 'ROADMAP.md'), roadmap, 'utf-8'); + fs.writeFileSync(path.join(base, 'STATE.md'), ['---', 'gsd_state_version: 1.0', + 'milestone: v2.0', 'milestone_name: Expansion', `status: ${status}`, '---', '', + '# Project State', '', '**Phase:** 05', ''].join('\n'), 'utf-8'); + fs.writeFileSync(path.join(root, 'config.json'), + JSON.stringify(convention === undefined ? {} : { phase_id_convention: convention }), 'utf-8'); + const phasesDir = path.join(base, 'phases'); + fs.mkdirSync(phasesDir, { recursive: true }); + for (const d of phaseDirs) fs.mkdirSync(path.join(phasesDir, d), { recursive: true }); +} + +const codes = (code, env = {}) => { + const r = runGsdTools(['validate', 'health'], tmpDir, env); + const out = JSON.parse(r.output); + return [...(out.issues || []), ...(out.warnings || [])] + .filter(i => i.code === code).map(i => i.message); +}; +const w021 = (env) => codes('W021', env); + +const COHERENT = `# Roadmap + +## [GSD.02] v2.0 — Expansion + +### [GSD.02] 05: Real work +**Goal:** Build it + +### [GSD.02] 06: Follow-up +**Goal:** Polish it +`; + +// ─── B6 / the ungated milestone-complete warning ─────────────────────────── + +describe('#612 PR-2: bracket phases resolve to their dirs, so W026 stays silent', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-b6-'); }); + afterEach(() => { cleanup(tmpDir); }); + + // #3309 split the old overloaded W021: milestone-prefix coherence remains + // W021, while the milestone-complete/missing-directory subject is W026. + const w026 = (env) => codes('W026', env); + + test('milestone complete + every bracket dir present => NO W021', () => { + writeProject({ roadmap: COHERENT, convention: 'bracket', status: 'milestone complete', + phaseDirs: ['GSD.02-05-real-work', 'GSD.02-06-follow-up'] }); + assert.deepEqual(w026(), []); + }); + + test('a missing dir still fires — the check is not merely disabled', () => { + writeProject({ roadmap: COHERENT, convention: 'bracket', status: 'milestone complete', + phaseDirs: ['GSD.02-05-real-work'] }); + const m = w026(); + assert.equal(m.length, 1, JSON.stringify(m)); + assert.match(m[0], /ROADMAP lists 1 unstarted phase/); + }); + + test('a legacy repo containing a citation heading gains NOTHING', () => { + // The shape-inference design ran this repo-failing check against a repo that + // never opted in: base emitted nothing, the branch emitted W006 + W021. + writeProject({ roadmap: `# Roadmap + +## v2.0 + +### [RFC.2119] 5: Keyword definitions +**Goal:** not a phase +`, convention: undefined, status: 'milestone complete' }); + assert.deepEqual(w026(), []); + assert.deepEqual(codes('W006'), []); + }); + + test('a bracket SENTINEL heading is not an unstarted phase', () => { + writeProject({ roadmap: `# Roadmap + +## [GSD.02] v2.0 + +### [GSD.999] 01: Icebox item +**Goal:** Someday + +### [GSD.02] 05: Real work +**Goal:** Build it +`, convention: 'bracket', status: 'milestone complete', phaseDirs: ['GSD.02-05-real-work'] }); + assert.deepEqual(w026(), []); + }); + + test('DISCLOSED: a bracket roadmap with the convention unset is invisible, not false-firing', () => { + writeProject({ roadmap: COHERENT, convention: undefined, status: 'milestone complete' }); + assert.deepEqual(w026(), [], 'silent invisibility, never a phantom unstarted phase'); + }); +}); + +// ─── The federated resolver, end to end ──────────────────────────────────── + +describe('#612 PR-2: workstream repos resolve one convention, not two', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-ws-'); }); + afterEach(() => { cleanup(tmpDir); }); + + test('root config + active workstream: consistency and health agree', () => { + writeProject({ roadmap: COHERENT, convention: 'bracket', ws: 'ws1', + phaseDirs: ['GSD.02-05-real-work', 'GSD.02-06-follow-up'] }); + const env = { GSD_WORKSTREAM: 'ws1' }; + const consistency = JSON.parse(runGsdTools(['validate', 'consistency'], tmpDir, env).output); + assert.deepEqual( + (consistency.warnings || []).filter(w => /no directory on disk/.test(w)), [], + 'the ROADMAP read and the directory read must resolve from the same config', + ); + assert.deepEqual(codes('W005', env), [], 'and health must not call the same dirs malformed'); + }); +}); + +// ─── checkBracketCoherence: the gate ─────────────────────────────────────── + +describe('#612 PR-2: bracket-coherence is gated on the active convention', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-gate-'); }); + afterEach(() => { cleanup(tmpDir); }); + + const INCOHERENT = `# Roadmap + +## [GSD.02] v2.0 — Expansion + +### [GSD.03] 05: Wrong milestone +**Goal:** Build it +`; + + test('fires under the bracket convention, with the field names the right way round', () => { + writeProject({ roadmap: INCOHERENT, convention: 'bracket' }); + const m = w021(); + assert.equal(m.length, 1, JSON.stringify(m)); + assert.match(m[0], /bracket milestone 03 does not match its section milestone 02/); + }); + + for (const convention of [undefined, 'milestone-prefixed', 'Bracket']) { + test(`SILENT when the convention is ${JSON.stringify(convention)}`, () => { + writeProject({ roadmap: INCOHERENT, convention }); + assert.deepEqual(w021().filter(m => /bracket/.test(m)), []); + }); + } + + test('a coherent bracket roadmap is silent', () => { + writeProject({ roadmap: COHERENT, convention: 'bracket' }); + assert.deepEqual(w021(), []); + }); +}); + +// ─── Scope rules ─────────────────────────────────────────────────────────── + +describe('#612 PR-2: coherence scope rules', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-scope-'); }); + afterEach(() => { cleanup(tmpDir); }); + + test('a non-phase level-3 heading does NOT clear the section', () => { + // `### Notes` used to reset the scope and silently disable both sub-checks + // for every phase after it. + writeProject({ roadmap: `# Roadmap + +## [GSD.02] v2.0 + +### [GSD.02] 01: Setup +**Goal:** a + +### Notes +Some prose. + +### [GSD.03] 05: WRONG MILESTONE +**Goal:** b +`, convention: 'bracket' }); + const m = w021(); + assert.equal(m.length, 1, `a prose heading must not disable the check: ${JSON.stringify(m)}`); + assert.match(m[0], /bracket milestone 03 does not match its section milestone 02/); + }); + + test('an M-NN phase heading raises missing-bracket AND does not end the section', () => { + // A single M-NN heading — the mid-migration content this epic targets — used + // to be treated as a section reset and silenced everything after it. + writeProject({ roadmap: `# Roadmap + +## [GSD.02] v2.0 + +### Phase 2-01: Mnn Legacy +**Goal:** a + +### [GSD.03] 05: WRONG MILESTONE +**Goal:** b +`, convention: 'bracket' }); + const m = w021(); + assert.equal(m.length, 2, JSON.stringify(m)); + assert.match(m[0], /Phase 2-01: heading is not in bracket form/); + assert.match(m[1], /bracket milestone 03 does not match its section milestone 02/); + }); + + test('a legacy MILESTONE heading DOES close the bracket section', () => { + // Phases under `## v3.0` are out of scope, not compared against — and + // reported against — a section they are not in. + writeProject({ roadmap: `# Roadmap + +## [GSD.02] v2.0 + +### [GSD.02] 05: Real +**Goal:** a + +## v3.0 — Legacy milestone + +### Phase 7: Legacy phase +**Goal:** b +`, convention: 'bracket' }); + assert.deepEqual(w021(), []); + }); + + test('a bare `N:` heading is not a phase and raises nothing', () => { + // `#### 2026: Timeline` and `### 3.5: Rollout` were flagged as phases + // needing migration. + writeProject({ roadmap: `# Roadmap + +## [GSD.02] v2.0 + +#### 2026: Timeline +### 3.5: Rollout options + +### [GSD.02] 05: Real +**Goal:** a +`, convention: 'bracket' }); + assert.deepEqual(w021(), []); + }); + + test('h5 and h6 phase headings are checked, like every other reader counts them', () => { + writeProject({ roadmap: `# Roadmap + +## [GSD.02] v2.0 + +##### [GSD.03] 08: Deep mismatch +**Goal:** a +`, convention: 'bracket' }); + const m = w021(); + assert.equal(m.length, 1, `h5 must not be invisible here: ${JSON.stringify(m)}`); + assert.match(m[0], /bracket milestone 03 does not match its section milestone 02/); + }); + + test('sentinel sections are exempt', () => { + writeProject({ roadmap: `# Roadmap + +## [GSD.999] Backlog + +### Phase 1: Icebox in legacy form +**Goal:** a + +### [GSD.02] 07: Wrong milestone in an icebox section +**Goal:** b +`, convention: 'bracket' }); + assert.deepEqual(w021(), []); + }); + + test('a fenced code block raises nothing', () => { + writeProject({ roadmap: `# Roadmap + +## [GSD.02] v2.0 + +\`\`\`markdown +### [GSD.09] 42: An example heading in docs +### Phase 7: A legacy example +\`\`\` + +### [GSD.02] 05: Real work +**Goal:** a +`, convention: 'bracket' }); + assert.deepEqual(w021(), []); + }); + + test('BOUNDARY: a flat, section-less bracket roadmap gets no checking', () => { + writeProject({ roadmap: `# Roadmap + +### [GSD.03] 05: No enclosing section +**Goal:** a + +### Phase 6: Also legacy form +**Goal:** b +`, convention: 'bracket' }); + assert.deepEqual(w021(), []); + }); + + test('the ADR-canonical name-only milestone heading opens a section', () => { + // `## [GSD.02] Foundation` — name, no version — is the form ADR-612 pins. + writeProject({ roadmap: `# Roadmap + +## [GSD.02] Foundation + +### [GSD.03] 05: Wrong milestone +**Goal:** a +`, convention: 'bracket' }); + const m = w021(); + assert.equal(m.length, 1, JSON.stringify(m)); + assert.match(m[0], /section milestone 02/); + }); + + test('a milestone section heading is not mistaken for a phase heading', () => { + writeProject({ roadmap: `# Roadmap + +## [GSD.02] v2.0 + +### [GSD.03] 05: Wrong +**Goal:** a + +### [GSD.04] 06: Also wrong +**Goal:** b +`, convention: 'bracket' }); + assert.equal(w021().length, 2); + }); +}); + +// ─── G1: the shipped milestone-prefixed W021 gate stays root-only ────────── + +describe('#612 PR-2: the M-NN W021 gate is unmoved by workstream config', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-mnn-'); }); + afterEach(() => { cleanup(tmpDir); }); + + const MNN_ROADMAP = `# Roadmap + +## [GSD] v2.0 + +### Phase 1-01: Setup +**Goal:** a +`; + + const writeSplit = (rootCfg, wsCfg) => { + const root = path.join(tmpDir, '.planning'); + const ws = path.join(root, 'workstreams', 'ws1'); + fs.mkdirSync(path.join(ws, 'phases'), { recursive: true }); + fs.writeFileSync(path.join(ws, 'ROADMAP.md'), MNN_ROADMAP, 'utf-8'); + fs.writeFileSync(path.join(ws, 'STATE.md'), ['---', 'gsd_state_version: 1.0', + 'milestone: v2.0', 'status: executing', '---', '', '# Project State', '', + '**Phase:** 1-01', ''].join('\n'), 'utf-8'); + fs.writeFileSync(path.join(root, 'config.json'), JSON.stringify(rootCfg), 'utf-8'); + if (wsCfg !== undefined) { + fs.writeFileSync(path.join(ws, 'config.json'), JSON.stringify(wsCfg), 'utf-8'); + } + }; + const mnnW021 = () => w021({ GSD_WORKSTREAM: 'ws1' }) + .filter(m => /integer prefix implies/.test(m)); + + test('ADDED-warning direction: a workstream M-NN config must not switch the gate on', () => { + // Root has no convention, so base is silent. Federating this gate made the + // workstream config turn a shipped legacy check on. + writeSplit({}, { phase_id_convention: 'milestone-prefixed' }); + assert.deepEqual(mnnW021(), [], 'root config governs this gate'); + }); + + test('VANISHING-warning direction: a workstream override must not switch it off', () => { + // Worse direction — a warning that fires at base disappears, so the repo + // looks healthier than it is. + writeSplit({ phase_id_convention: 'milestone-prefixed' }, { phase_id_convention: 'bracket' }); + const m = mnnW021(); + assert.equal(m.length, 1, `the root-configured gate must still fire: ${JSON.stringify(m)}`); + assert.match(m[0], /Phase 1-01: integer prefix implies v1\.0 but listed under v2\.0/); + }); + + test('root-configured, no workstream config: fires (base parity)', () => { + writeSplit({ phase_id_convention: 'milestone-prefixed' }, undefined); + assert.equal(mnnW021().length, 1); + }); +}); + +// ─── G2: an unpadded bracket milestone is uniformly malformed ────────────── + +describe('#612 PR-2: unpadded bracket milestones scope nothing', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-unpadded-'); }); + afterEach(() => { cleanup(tmpDir); }); + + test('an unpadded phase heading does not re-scope the coherence check', () => { + // `### [GSD.3] 05:` was not a phase (id grammar) but WAS a section (section + // grammar), so it silently re-scoped every warning after it to milestone 03. + writeProject({ roadmap: `# Roadmap + +## [GSD.02] v2.0 + +### [GSD.3] 05: Unpadded +**Goal:** a + +### [GSD.05] 06: Real mismatch +**Goal:** b +`, convention: 'bracket' }); + const m = w021(); + const mismatch = m.filter(x => /does not match its section milestone/.test(x)); + assert.equal(mismatch.length, 1, JSON.stringify(m)); + assert.match(mismatch[0], /section milestone 02/, 'scope must stay on the real section'); + }); +}); + +describe('#612 PR-2: B6 keeps its narrow baseline behaviourally', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-b6-mode-'); }); + afterEach(() => { cleanup(tmpDir); }); + + test('an any-bracket phantom does not reach the milestone-complete check', () => { + // Behavioural companion to the source-level call-site pin: flipping this + // site to the wider baseline makes `### [v1.2] Phase 3:` a phase, which has + // no directory, so the ungated W021 fires on a repo whose real phases are + // all on disk. The source pin catches the edit; this catches the effect. + writeProject({ + roadmap: `# Roadmap + +## [GSD.02] v2.0 + +### [v1.2] Phase 3: Not a phase heading +Some prose. + +### [GSD.02] 05: Real work +**Goal:** a +`, + convention: 'bracket', + status: 'milestone complete', + phaseDirs: ['GSD.02-05-real-work'], + }); + assert.deepEqual(w021(), [], 'the phantom must not be counted as unstarted'); + }); +}); + +// ─── G3: adversarial malformed bracket tokens reaching the coherence check ── + +/** + * `checkBracketCoherence` compares a phase's OWN bracket milestone against the + * milestone of the section enclosing it, so it consumes two independently + * matched brackets. A structurally broken one — non-numeric, unclosed, nested — + * is the input most likely to make those two disagree about what they matched, + * and W021 is a check that can fail a repo. + * + * The contract: a malformed token is not a phase and not a section, so it can + * neither raise a W021 of its own nor re-scope the W021s around it (the G2 + * failure mode, arrived at from a different shape), and `validate health` still + * exits cleanly. G2 above pins the unpadded case; these pin the broken ones. + */ +describe('#612 PR-2: malformed bracket tokens neither warn nor re-scope', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-malformed-w021-'); }); + afterEach(() => { cleanup(tmpDir); }); + + const BROKEN_HEADINGS = [ + ['non-numeric milestone', '### [GSD.AB] 05: Broken'], + ['unclosed bracket', '### [GSD.02 05: Broken'], + ['nested bracket', '### [GSD.[02]] 05: Broken'], + ['empty bracket', '### [] 05: Broken'], + ['dot, no milestone', '### [GSD.] 05: Broken'], + ['double dot', '### [GSD..02] 05: Broken'], + ]; + + for (const [label, broken] of BROKEN_HEADINGS) { + test(`${label}: raises no W021 of its own`, () => { + writeProject({ roadmap: `# Roadmap + +## [GSD.02] v2.0 — Expansion + +${broken} +**Goal:** a +`, convention: 'bracket' }); + assert.deepEqual(w021(), [], `${label} warned`); + }); + + test(`${label}: does not re-scope the W021 that follows it`, () => { + // The G2 shape: a heading that is not a phase but IS read as a section + // silently moves every later warning onto the wrong milestone. + writeProject({ roadmap: `# Roadmap + +## [GSD.02] v2.0 — Expansion + +${broken} +**Goal:** a + +### [GSD.07] 06: Real mismatch +**Goal:** b +`, convention: 'bracket' }); + const mismatch = w021().filter(x => /does not match its section milestone/.test(x)); + assert.equal(mismatch.length, 1, `${label}: ${JSON.stringify(w021())}`); + assert.match(mismatch[0], /section milestone 02/, `${label}: scope moved off the real section`); + }); + } + + test('the whole broken corpus in one ROADMAP leaves validate health clean', () => { + writeProject({ roadmap: `# Roadmap + +## [GSD.02] v2.0 — Expansion + +${BROKEN_HEADINGS.map(([, h]) => `${h}\n**Goal:** x\n`).join('\n')} +### [GSD.02] 05: Real work +**Goal:** ok +`, convention: 'bracket', phaseDirs: ['GSD.02-05-real-work'] }); + const r = runGsdTools(['validate', 'health'], tmpDir); + assert.ok(r.success, `validate health failed on the broken corpus: ${r.error}`); + assert.deepEqual(w021(), []); + }); + + // #2761 M2 (trek-e review): this asserted a `Date.now()` delta against a 20s + // ceiling, which measures the host machine rather than the SUT and flakes on + // a loaded CI runner (RULESET.TESTS.no-timing-assertion). The property it + // guarded — the widened bracket patterns do not backtrack catastrophically — + // is KEPT, expressed as an ALGORITHMIC bound instead of a wall-clock one: the + // same attack runs at 1x and 4x the pathological length and must produce the + // SAME correct result. Catastrophic backtracking is superlinear in input + // size, so a regression cannot satisfy the 4x leg under any ceiling, while a + // bounded matcher is indifferent to the scaling. The `timeout` option is a + // hang backstop, not an assertion: it turns a runaway into a deterministic + // failure instead of a suite that never returns. + for (const width of [4000, 16000]) { + test(`a pathological unclosed bracket (${width} chars) validates correctly`, { timeout: 60_000 }, () => { + writeProject({ roadmap: `# Roadmap + +## [GSD.02] v2.0 — Expansion + +### [${'A'.repeat(width)} 05: Attack +**Goal:** a + +### [GSD.02] 05: Real work +**Goal:** ok +`, convention: 'bracket', phaseDirs: ['GSD.02-05-real-work'] }); + const r = runGsdTools(['validate', 'health'], tmpDir); + assert.ok(r.success, `validate health failed: ${r.error}`); + assert.deepEqual(w021(), [], 'an unclosed bracket is not a phase heading at any width'); + }); + } +}); + +// ─── #2761 round-7 Minor 2: the W021 remediation hint must name no ──────── +// ─── unsupported `--convention` value ─────────────────────────────────────── +// +// `checkBracketCoherence`'s W021 previously attached a `fix` string telling +// users to run `gsd-tools roadmap upgrade --convention bracket` — but +// `roadmap-command-router.cts` only supports `--convention milestone-prefixed` +// (the bracket migrator is #612 PR-3, not yet landed); that command hard-errors +// with "Only --convention milestone-prefixed is supported". Nothing in the +// suite asserted the `fix` string's content, so the unfollowable hint shipped +// unpinned. This pins the corrected string and, more importantly, the +// invariant a future edit must not re-break: no unsupported `--convention` +// value named in remediation text users are expected to run verbatim. +describe('#612 PR-2 round-7 Minor 2: bracket W021 fix string names no unsupported --convention value', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-r7m2-'); }); + afterEach(() => { cleanup(tmpDir); }); + + test('the fix string does not tell users to run `--convention bracket` (unsupported, hard-errors)', () => { + writeProject({ roadmap: `# Roadmap + +## [GSD.02] v2.0 + +### Phase 2-01: Mnn Legacy +**Goal:** a +`, convention: 'bracket' }); + const r = runGsdTools(['validate', 'health'], tmpDir); + assert.ok(r.success, `validate health failed: ${r.error}`); + const out = JSON.parse(r.output); + const issues = [...(out.issues || []), ...(out.warnings || [])].filter((i) => i.code === 'W021'); + assert.equal(issues.length, 1, JSON.stringify(issues)); + assert.doesNotMatch(issues[0].fix, /--convention bracket/, + 'pinned before this fix — the hint named a command that hard-errors: "Only --convention milestone-prefixed is supported"'); + assert.equal( + issues[0].fix, + 'Bracket migration lands with the #612 migrator (PR-3); until then, manually align the bracket milestone in this heading to match the enclosing section.', + ); + }); +}); diff --git a/tests/adr-612-bracket-heading-selection.test.cjs b/tests/adr-612-bracket-heading-selection.test.cjs new file mode 100644 index 000000000..26caf9e64 --- /dev/null +++ b/tests/adr-612-bracket-heading-selection.test.cjs @@ -0,0 +1,719 @@ +'use strict'; + +/** + * PR-2 (#2761 / epic #612) — convention-GATED heading-intro selection. + * + * The design this file pins, and why it replaced the previous one: + * + * PR-2 first widened every heading reader unconditionally, resting on the claim + * that the newly-admitted shape — a `[CODE.MM]` bracket followed by a digit — + * "cannot occur in a legacy ROADMAP". That claim is false. `### [RFC.2119] 5:`, + * `### [v1.0] 2024:`, `### [ADR.612] 3:`, `### [SPEC.1] 3:` and + * `### [ISO.8601] 2026:` are all ordinary headings a project that never heard of + * this convention can contain, and every one of them was claimed as a phase: + * `phase_count` and `total_phases` moved, and `validate health` grew W006s, on + * repos that never opted in. + * + * No amount of narrowing rescues an ungated widening, because the argument it + * needs — "no legacy document contains this" — is unprovable about documents we + * do not control. So the widening is now SELECTED, not argued: a repo whose + * resolved `phase_id_convention` is not 'bracket' compiles the same source + * string it compiled before, and the question of what that string does or does + * not match never arises. + * + * THE STRUCTURAL TEST is the load-bearing assertion here. It carries its own + * transcription of each call site's base spelling — copied from + * `git show d04592de:src/.cts` — and asserts byte-equality against what + * the selector returns. It deliberately does NOT compare the selector against a + * constant the selector itself is built from: that would restate the + * implementation and pass no matter what either side said. Byte-equality with an + * independently transcribed literal is the whole proof, and it needs no corpus. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); + +const core = require('../gsd-core/bin/lib/phase-id.cjs'); +const B = core.PHASE_HEADING_BASELINE; + +// ─── Base spellings, transcribed by hand from d04592de ───────────────────── +// One entry per call site PR-2 converts. `src` is what that site's regex +// contains on the base commit, character for character. If a rebase moves a +// site's base spelling, this table is what fails. +const BASE_SITES = [ + // --- baseline: the site already tolerates `[anything] Phase N` + { file: 'roadmap.cts', site: 'searchPhaseInContent headingPattern', + baseline: B.ANY_BRACKET, src: '(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+' }, + { file: 'roadmap.cts', site: 'cmdRoadmapAnalyze phasePattern', + baseline: B.ANY_BRACKET, src: '(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+' }, + { file: 'roadmap.cts', site: 'cmdRoadmapAnalyze nextHeader', + baseline: B.ANY_BRACKET, src: '(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+' }, + { file: 'validate.cts', site: 'buildRoadmapPhaseVariants phasePattern', + baseline: B.ANY_BRACKET, src: '(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+' }, + { file: 'roadmap-parser.cts', site: 'getMilestonePhaseFilter phaseHeadingPattern', + baseline: B.ANY_BRACKET, src: '(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+' }, + // #2761 B2: BRACKET_PHASE_TAIL_RE (isBracketMilestoneBoundary's phase-tail + // discriminator) always passes the literal 'bracket' convention — it is not + // itself convention-gated (the CALLER, isBracketMilestoneBoundary, is only + // ever consulted when bracketBoundaryActive is already true) — but it still + // shares the SAME ANY_BRACKET baseline and produces the identical BASE + // source on a non-bracket convention as every other ANY_BRACKET site, so it + // is pinned here for count-exactness rather than left as an unpinned hole. + // + // #2761 round-3 Minor 1: `runtimeGated: true` below is the load-bearing + // fact this row's STRUCTURAL IDENTITY assertion (the loop just below) + // cannot see — that assertion is a property of `phaseHeadingPrefixSrcFor` + // (the FUNCTION: "called with a non-bracket convention, it returns the + // base source"), not of THIS site, and it would pass unchanged even if + // this call were deleted entirely. Safety at this specific call site rests + // ENTIRELY on a runtime gate the guard cannot see: `isBracketMilestoneBoundary` + // has exactly two callers, both guarded — `computeSectionEnd`'s + // `bracketBoundaryActive && isBracketMilestoneBoundary(...)` and the + // preambleCutoff scan's own call, itself nested inside an + // `if (bracketBoundaryActive) { … }` block (`src/roadmap-parser.cts`, + // grep `isBracketMilestoneBoundary(` for current line numbers — both + // round-3 fixes shifted them since this note was first written) — and + // `BRACKET_PHASE_TAIL_RE` has TWO consumers as of #2761 round-4 (its own + // use inside `isBracketMilestoneBoundary`, plus `bracketHeadingHasMatchingChild`'s + // same-id-PHASE-child conjunct added by 65d257ce) — both still gated on + // the same `bracketBoundaryActive` flag, but NOT both through the same + // mechanism: `bracketHeadingHasMatchingChild` is reached only via its one + // caller (`:593`, inside the `if (bracketBoundaryActive) { … }` block this + // note names above), while `isBracketMilestoneBoundary` — see this row's + // own "exactly two callers" paragraph above — is reached via TWO different + // mechanisms, an inline `bracketBoundaryActive &&` conjunct at one call + // site and that same block at the other. (round-5 Nit 1 first added this + // sentence to note the consumer count was stale by one commit; round-6 + // Nit 2 corrected the mechanism claim that sentence introduced — the + // CONCLUSION is unaffected either time: every consumer is still + // runtime-gated on the same flag.) + // `BRACKET_HEADING_INTRO_RE` has THREE consumers as of #2761 round-4 + // (`isBracketMilestoneBoundary` itself, plus two uses inside + // `bracketHeadingHasMatchingChild` — its own id and its same-id-PHASE-child + // scan) — all still nested inside the same `bracketBoundaryActive` runtime + // gate this note is about (round-4 Nit 1: this sentence was stale by one + // commit, claiming zero other consumers when 2e06aef5 had already added + // two; the CONCLUSION is unaffected — every consumer is still + // runtime-gated, and `BRACKET_HEADING_INTRO_RE` is built from + // `BRACKET_ID_SRC`, not `phaseHeadingPrefixSrcFor`, so it was never a + // selector site to begin with). The marker exists so a future reader does + // not mistake this row for "selector-covered like the other 14." + { file: 'roadmap-parser.cts', site: 'isBracketMilestoneBoundary BRACKET_PHASE_TAIL_RE', + baseline: B.ANY_BRACKET, src: '(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+', runtimeGated: true }, + // --- baseline: the site spells a BARE `Phase ` with no bracket tolerance + { file: 'roadmap.cts', site: 'searchPhaseInContent checklistPattern', + baseline: B.LABEL_ONLY, src: 'Phase\\s+' }, + { file: 'roadmap.cts', site: 'cmdRoadmapAnalyze checkboxPattern', + baseline: B.LABEL_ONLY, src: 'Phase\\s+' }, + { file: 'roadmap.cts', site: 'cmdRoadmapAnalyze checklistPattern', + baseline: B.LABEL_ONLY, src: 'Phase\\s+' }, + { file: 'validate.cts', site: 'buildRoadmapPhaseVariants checklistPattern', + baseline: B.LABEL_ONLY, src: 'Phase\\s+' }, + { file: 'validate.cts', site: 'buildNotStartedPhaseVariants uncheckedPattern', + baseline: B.LABEL_ONLY, src: 'Phase\\s+' }, + { file: 'state.cts', site: 'buildStateFrontmatter roadmapPhaseCount', + baseline: B.LABEL_ONLY, src: 'Phase\\s+' }, + { file: 'state.cts', site: 'cmdStateSync roadmapPhaseCount', baseline: B.LABEL_ONLY, src: 'Phase\\s+' }, + { file: 'state.cts', site: 'extractRetiredPhaseNumbers phaseRef', + baseline: B.LABEL_ONLY, src: 'Phase\\s+' }, + // #3309/#3310 moved the health reads out of verify.cts and into the parsed + // planning snapshot consumed by the diagnostic rule table. Pin the same two + // ROADMAP reads at their new owner so neither can silently narrow. + { file: 'planning-snapshot.cts', site: 'buildCurrentMilestoneRoadmapPhaseIdsField (W026, ex-verify.cts B6)', + baseline: B.LABEL_ONLY, src: 'Phase\\s+' }, + { file: 'planning-snapshot.cts', site: 'buildRoadmapPhaseCheckboxesField (W011/W006 not-started)', + baseline: B.LABEL_ONLY, src: 'Phase\\s+' }, +]; + +// Every convention value that is NOT the bracket convention. A repo carrying any +// of these must read exactly as it did at base. +const NON_BRACKET = [undefined, null, '', 'milestone-prefixed', 'Bracket', 'BRACKET', 'brackets', 'bracket-ish']; + +describe('#612 PR-2 STRUCTURAL IDENTITY: a non-bracket repo compiles the BASE pattern', () => { + for (const { file, site, baseline, src, runtimeGated } of BASE_SITES) { + // #2761 round-3 Minor 1: the title makes the gating mechanism visible in + // test output, not just in a source comment — a row with no + // `[runtime-gated]` suffix IS selector-covered by this test; one WITH it + // is safe only because of a call-site guard this test cannot see. + test(`${file} — ${site}${runtimeGated ? ' [runtime-gated, not selector-covered]' : ''}`, () => { + for (const convention of NON_BRACKET) { + assert.strictEqual( + core.phaseHeadingPrefixSrcFor(baseline, convention), + src, + `convention ${JSON.stringify(convention)} must compile the base source byte-for-byte`, + ); + assert.strictEqual( + core.phaseHeadingPrefixSrcFor(baseline, convention, true), + src, + 'the capturing variant adds no group when there is no bracket alternative', + ); + } + }); + } + + test('only the exact string "bracket" selects the widened form', () => { + for (const convention of NON_BRACKET) { + assert.ok( + !core.phaseHeadingPrefixSrcFor(B.LABEL_ONLY, convention).includes('['), + `${JSON.stringify(convention)} must not admit any bracket alternative`, + ); + } + assert.ok(core.phaseHeadingPrefixSrcFor(B.LABEL_ONLY, 'bracket').includes('[')); + }); + + test('an unknown baseline is treated as label-only, never as widened', () => { + assert.strictEqual( + core.phaseHeadingPrefixSrcFor('nonsense', null), core.BASE_PHASE_LABEL_PREFIX_SRC, + ); + }); +}); + +// ─── What the legacy counterexamples do under each convention ────────────── + +const scan = (prefixSrc, doc) => { + const re = new RegExp(`#{2,4}\\s*${prefixSrc}([\\w][\\w.-]*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'gi'); + const out = []; + let m; + while ((m = re.exec(doc)) !== null) out.push(m[1]); + return out; +}; + +describe('#612 PR-2: the reviewer counterexamples, under each convention', () => { + // Every one of these was claimed as a phase by the ungated widening. + const COUNTEREXAMPLES = [ + '### [RFC.2119] 5: Keyword definitions', + '### [v1.0] 2024: Retrospective', + '### [v1.0] 2026-01-15: Shipped release notes', + '### [ADR.612] 3: Decisions to ratify', + '### [SPEC.1] 3: Scope', + '### [ISO.8601] 2026: Dates', + '### [rev.2] 9: Revision nine notes', + '### [Fig.3] 2: Diagram', + ]; + + test('a legacy repo claims NONE of them (this is the fix)', () => { + for (const heading of COUNTEREXAMPLES) { + for (const convention of NON_BRACKET) { + assert.deepEqual( + scan(core.phaseHeadingPrefixSrcFor(B.ANY_BRACKET, convention), heading), [], + `${JSON.stringify(heading)} under ${JSON.stringify(convention)}`, + ); + } + } + }); + + test('a legacy repo still reads its own real headings', () => { + const doc = '### Phase 5: Real\n### [GSD] Phase 2-01: Legacy\n#### Phase Details:'; + assert.deepEqual(scan(core.phaseHeadingPrefixSrcFor(B.ANY_BRACKET, null), doc), ['5', '2-01', 'Details']); + }); + + test('DISCLOSED: on a BRACKET repo some of them are still claimed', () => { + // Gating removes the legacy blast radius; it does not make `[RFC.2119] 5:` + // unambiguous. On a repo that HAS opted in, a citation-shaped bracket whose + // milestone matches the emit width still reads as a phase. Pinned rather + // than hidden — the coherence check surfaces it as a milestone mismatch. + const src = core.phaseHeadingPrefixSrcFor(B.ANY_BRACKET, 'bracket'); + assert.deepEqual(scan(src, '### [RFC.2119] 5: Keyword definitions'), ['5']); + // The emit-width rule does exclude the 1-digit-milestone family outright. + assert.deepEqual(scan(src, '### [SPEC.1] 3: Scope'), []); + assert.deepEqual(scan(src, '### [Fig.3] 2: Diagram'), []); + }); +}); + +// ─── The label-only sites keep their narrowness on bracket repos too ─────── + +describe('#612 PR-2: a label-only site never gains any-bracket tolerance', () => { + const bullets = (prefixSrc, doc) => { + const re = new RegExp(`-\\s*\\[[ xX]\\]\\s*\\*{0,2}${prefixSrc}([\\w][\\w.-]*)\\s*:`, 'gi'); + const out = []; + let m; + while ((m = re.exec(doc)) !== null) out.push(m[1]); + return out; + }; + + test('`[GSD] Phase 2-01` stays unmatched under BOTH conventions', () => { + const doc = '- [x] **[GSD] Phase 2-01: Legacy**'; + assert.deepEqual(bullets(core.phaseHeadingPrefixSrcFor(B.LABEL_ONLY, null), doc), []); + assert.deepEqual(bullets(core.phaseHeadingPrefixSrcFor(B.LABEL_ONLY, 'bracket'), doc), [], + 'the bracket convention widens to bracket IDs, not to arbitrary bracket text'); + }); + + test('`[v1.2] Phase 3` — the retro-grant counterexample — stays unmatched on legacy', () => { + const doc = '- [ ] **[v1.2] Phase 3: Something legacy**'; + assert.deepEqual(bullets(core.phaseHeadingPrefixSrcFor(B.LABEL_ONLY, null), doc), []); + }); + + test('a bracket repo does admit the bracket-ID form at a label-only site', () => { + const doc = '- [ ] **[GSD.02] 05: Real**'; + assert.deepEqual(bullets(core.phaseHeadingPrefixSrcFor(B.LABEL_ONLY, null), doc), []); + assert.deepEqual(bullets(core.phaseHeadingPrefixSrcFor(B.LABEL_ONLY, 'bracket'), doc), ['05']); + }); +}); + +// ─── The one identity grammar (F3) ───────────────────────────────────────── + +describe('#612 PR-2: one bracket identity grammar, one width rule', () => { + test('the three former spellings now agree on the 1-digit-milestone family', () => { + // `GSD.2-05-feature` used to have a qualified key and a token but not be a + // phase directory, depending on which private spelling a caller reached. + assert.equal(core.bracketQualifiedKey('GSD.2-05-feature', 'bracket'), null); + assert.equal(core.extractPhaseToken('GSD.2-05-feature', 'bracket'), 'GSD.2-05-feature'); + assert.ok(!new RegExp(`^${core.BRACKET_DIR_PREFIX_SRC}`, 'i').test('GSD.2-05-feature')); + }); + + test('the canonical padded forms resolve identically everywhere', () => { + assert.equal(core.bracketQualifiedKey('GSD.02-05-feature', 'bracket'), 'GSD.2-5'); + assert.equal(core.extractPhaseToken('GSD.02-05-feature', 'bracket'), '05'); + assert.ok(new RegExp(`^${core.BRACKET_DIR_PREFIX_SRC}`, 'i').test('GSD.02-05-feature')); + }); + + test('case folding: a lowercase bracket id passes every identity test', () => { + assert.equal(core.isSentinelPhaseId('gsd.999-01', 'bracket'), true, 'lowercase icebox is a sentinel'); + assert.equal(core.isSentinelPhaseId('GSD.999-01', 'bracket'), true); + assert.equal(core.isSentinelPhaseId('gsd.00-01', 'bracket'), true); + assert.equal(core.isSentinelPhaseId('gsd.02-05', 'bracket'), false); + assert.equal(core.getMilestoneFromPhaseId('gsd.02-05', 'bracket'), 'v2.0'); + assert.equal(core.bracketQualifiedKey('ck.03-02', 'bracket'), 'CK.3-2'); + }); + + test('sentinel milestones and their neighbours', () => { + for (const mm of ['00', '999']) { + assert.equal(core.isSentinelPhaseId(`GSD.${mm}-01`, 'bracket'), true, mm); + } + for (const mm of ['01', '99', '100', '998', '1000']) { + assert.equal(core.isSentinelPhaseId(`GSD.${mm}-01`, 'bracket'), false, mm); + } + // Widths toDir cannot emit are not bracket ids at all — pad2 never produces + // a bare `0`, and the emit validator rejects a leading-zero 3+ run. + for (const mm of ['0', '000', '0999', '002', '2']) { + assert.equal(core.isSentinelPhaseId(`GSD.${mm}-01`, 'bracket'), false, `${mm} is malformed`); + } + }); + + test('an out-of-range milestone integer is refused, not collapsed to Infinity', () => { + const huge = 'A.' + '9'.repeat(400) + '-1'; + assert.equal(core.bracketQualifiedKey(huge, 'bracket'), null, + 'two 400-digit milestones must not share one key'); + }); + + test('G3: the capturing variant captures the id in BOTH bracket forms', () => { + // `### [GSD.999] Phase 07:` used to fall through to the base alternative, + // which captures nothing — so the reader saw no bracket, applied the legacy + // token rule, and counted a labeled icebox heading while excluding the + // label-less one beside it. + for (const baseline of [B.ANY_BRACKET, B.LABEL_ONLY]) { + const re = new RegExp( + `^${core.phaseHeadingPrefixSrcFor(baseline, 'bracket', true)}([\\w][\\w.-]*)\\s*:`, 'i'); + for (const heading of ['[GSD.999] Phase 07: Icebox', '[GSD.999] 07: Icebox']) { + const m = heading.match(re); + assert.ok(m, `${baseline}: ${heading}`); + assert.equal(m[1], 'GSD.999', `${baseline}: ${heading} — bracket id must be captured`); + assert.equal(m[2], '07'); + } + } + }); + + test('G7: the qualified key shares the dir token boundary and width', () => { + // A qualified hit returns UNCONDITIONALLY from phaseTokenMatches, so a key + // that matches a directory isPhaseDirName rejects is a final wrong answer. + assert.equal(core.bracketQualifiedKey('GSD.02-12A-hotfix', 'bracket'), null); + assert.equal(core.bracketQualifiedKey('GSD.02-05.03.07-x', 'bracket'), null); + assert.equal(core.bracketQualifiedKey('GSD.2-05', 'bracket'), null, 'unpadded is malformed'); + assert.equal(core.bracketQualifiedKey('GSD.02-05-slug', 'bracket'), 'GSD.2-5'); + assert.equal(core.bracketQualifiedKey('GSD.02-05.03', 'bracket'), 'GSD.2-5.3'); + }); + + test('G7: the qualified branch resolves its own milestone and refuses malformed dirs', () => { + // Kills the dead-branch mutant: deleting the qualified branch must fail here. + assert.equal(core.phaseTokenMatches('CK.03-02-shell', 'CK.03-02', 'bracket'), true); + assert.equal(core.phaseTokenMatches('CK.02-02-other', 'CK.03-02', 'bracket'), false, + 'must not resolve to another milestone same-numbered dir'); + assert.equal(core.phaseTokenMatches('GSD.02-12A-hotfix', 'GSD.02-12', 'bracket'), false, + 'a directory isPhaseDirName rejects must not satisfy a qualified query'); + }); + + test('the bracket path stays OFF without an explicit signal', () => { + assert.equal(core.bracketQualifiedKey('CK.03-02'), null); + assert.equal(core.bracketQualifiedKey('CK.03-02', 'milestone-prefixed'), null); + assert.equal(core.extractPhaseToken('GSD.02-05.03-01'), 'GSD.02-05.03-01'); + // The #2043 numeric-tail family keeps its convention-less reading. + assert.equal(core.phaseTokenMatches('P0.03-02-tenant', 'P0.3-2'), false); + }); +}); + +// ─── `\s*` must not span newlines (NIT 12) ───────────────────────────────── + +describe('#612 PR-2: the bracket alternative does not span lines', () => { + test('prose on the line after a bracket-terminated heading is not a phase', () => { + const doc = '### [GSD.02]\n\n05: Orphan digits\n'; + assert.deepEqual(scan(core.phaseHeadingPrefixSrcFor(B.ANY_BRACKET, 'bracket'), doc), [], + 'a heading that ends at the bracket claims nothing on later lines'); + }); + + test('a tab between the bracket and the token is still one heading', () => { + assert.deepEqual( + scan(core.phaseHeadingPrefixSrcFor(B.ANY_BRACKET, 'bracket'), '### [GSD.02]\t05: Tabbed'), + ['05'], + ); + }); +}); + +// ─── The federated convention resolver ───────────────────────────────────── + +describe('#612 PR-2: phase_id_convention resolves workstream -> root', () => { + const fs = require('fs'); + const path = require('path'); + const os = require('os'); + const { resolvePhaseIdConvention } = require('../gsd-core/bin/lib/planning-workspace.cjs'); + const { cleanup } = require('./helpers.cjs'); + + let dir; + const setup = ({ root, ws }) => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'adr-612-fed-')); + fs.mkdirSync(path.join(dir, '.planning', 'workstreams', 'ws1'), { recursive: true }); + if (root !== undefined) { + fs.writeFileSync(path.join(dir, '.planning', 'config.json'), + JSON.stringify({ phase_id_convention: root })); + } + if (ws !== undefined) { + fs.writeFileSync(path.join(dir, '.planning', 'workstreams', 'ws1', 'config.json'), + JSON.stringify({ phase_id_convention: ws })); + } + return dir; + }; + const withWs = (name, fn) => { + const prev = process.env.GSD_WORKSTREAM; + if (name === null) delete process.env.GSD_WORKSTREAM; + else process.env.GSD_WORKSTREAM = name; + try { return fn(); } finally { + if (prev === undefined) delete process.env.GSD_WORKSTREAM; + else process.env.GSD_WORKSTREAM = prev; + } + }; + const cleanupDir = () => cleanup(dir); + + test('config at ROOT only, no workstream active', () => { + const d = setup({ root: 'bracket' }); + try { withWs(null, () => assert.equal(resolvePhaseIdConvention(d), 'bracket')); } finally { cleanupDir(); } + }); + + test('config at ROOT only, workstream active — falls back to root', () => { + // This is the split that made a workstream repo report every bracket phase + // missing from disk: the ROADMAP read resolved from one base, the directory + // read from the other. + const d = setup({ root: 'bracket' }); + try { withWs('ws1', () => assert.equal(resolvePhaseIdConvention(d), 'bracket')); } finally { cleanupDir(); } + }); + + test('config at WORKSTREAM only, workstream active', () => { + const d = setup({ ws: 'bracket' }); + try { withWs('ws1', () => assert.equal(resolvePhaseIdConvention(d), 'bracket')); } finally { cleanupDir(); } + }); + + test('config at BOTH — the workstream wins', () => { + // Governs the #612 bracket-selection reads ONLY. The shipped + // milestone-prefixed W021 gate keeps its own root-only read: re-basing a + // legacy convention's gate onto this resolver moved its answer in both + // directions on workstream repos, and that is pinned at the CLI in + // tests/adr-612-bracket-coherence.test.cjs. + const d = setup({ root: 'milestone-prefixed', ws: 'bracket' }); + try { + withWs('ws1', () => assert.equal(resolvePhaseIdConvention(d), 'bracket')); + } finally { cleanupDir(); } + }); + + test('a PROJECT-scoped config stands alone — no root fallback', () => { + // config-loader falls back to the root config only under `if (ws)`, so a + // project-only split must not inherit the root's value. + const fsx = require('fs'); + const d = setup({ root: 'bracket' }); + try { + fsx.mkdirSync(path.join(d, '.planning', 'proj1'), { recursive: true }); + const prev = process.env.GSD_PROJECT; + process.env.GSD_PROJECT = 'proj1'; + try { + assert.equal(resolvePhaseIdConvention(d), null, 'root must not leak into a project scope'); + fsx.writeFileSync(path.join(d, '.planning', 'proj1', 'config.json'), + JSON.stringify({ phase_id_convention: 'bracket' })); + assert.equal(resolvePhaseIdConvention(d), 'bracket'); + } finally { + if (prev === undefined) delete process.env.GSD_PROJECT; else process.env.GSD_PROJECT = prev; + } + } finally { cleanupDir(); } + }); + + test('config at WORKSTREAM only, no workstream active — not visible', () => { + const d = setup({ ws: 'bracket' }); + try { withWs(null, () => assert.equal(resolvePhaseIdConvention(d), null)); } finally { cleanupDir(); } + }); + + test('absent, empty, and unparseable configs all resolve to null', () => { + const d = setup({}); + try { + withWs(null, () => assert.equal(resolvePhaseIdConvention(d), null, 'absent')); + fs.writeFileSync(path.join(d, '.planning', 'config.json'), '{}'); + withWs(null, () => assert.equal(resolvePhaseIdConvention(d), null, 'empty object')); + fs.writeFileSync(path.join(d, '.planning', 'config.json'), '{ not json'); + withWs(null, () => assert.equal(resolvePhaseIdConvention(d), null, 'unparseable')); + fs.writeFileSync(path.join(d, '.planning', 'config.json'), JSON.stringify({ phase_id_convention: '' })); + withWs(null, () => assert.equal(resolvePhaseIdConvention(d), null, 'empty string')); + fs.writeFileSync(path.join(d, '.planning', 'config.json'), JSON.stringify({ phase_id_convention: true })); + withWs(null, () => assert.equal(resolvePhaseIdConvention(d), null, 'non-string')); + } finally { cleanupDir(); } + }); + + // ─── #2761 B1: the workstream is an ARGUMENT, not only an env var ──────── + + test('the ws ARGUMENT selects the config, with no GSD_WORKSTREAM set', () => { + const d = setup({ root: 'milestone-prefixed', ws: 'bracket' }); + try { + withWs(null, () => assert.equal( + resolvePhaseIdConvention(d, 'ws1'), 'bracket', + 'a workstream passed by argument must resolve its OWN config, not the root', + )); + } finally { cleanupDir(); } + }); + + test('arg and env resolve identically — `--workstream ws1` === `GSD_WORKSTREAM=ws1`', () => { + const d = setup({ root: 'milestone-prefixed', ws: 'bracket' }); + try { + const viaArg = withWs(null, () => resolvePhaseIdConvention(d, 'ws1')); + const viaEnv = withWs('ws1', () => resolvePhaseIdConvention(d)); + assert.equal(viaArg, viaEnv, 'arg-driven and env-driven callers must agree'); + assert.equal(viaArg, 'bracket'); + } finally { cleanupDir(); } + }); + + test('an explicit ws overrides GSD_WORKSTREAM rather than being ignored', () => { + const d = setup({ root: 'bracket' }); + try { + fs.writeFileSync(path.join(d, '.planning', 'workstreams', 'ws1', 'config.json'), + JSON.stringify({ phase_id_convention: 'milestone-prefixed' })); + // env names ws1; the ARGUMENT names no workstream at all -> root scope. + withWs('ws1', () => assert.equal(resolvePhaseIdConvention(d, null), 'bracket')); + // env names nothing; the ARGUMENT names ws1 -> the workstream's own value. + withWs(null, () => assert.equal(resolvePhaseIdConvention(d, 'ws1'), 'milestone-prefixed')); + } finally { cleanupDir(); } + }); + + test('omitting ws keeps the env fallback — pre-#2761 call sites are unchanged', () => { + const d = setup({ root: 'milestone-prefixed', ws: 'bracket' }); + try { + withWs('ws1', () => assert.equal(resolvePhaseIdConvention(d), 'bracket')); + withWs(null, () => assert.equal(resolvePhaseIdConvention(d), 'milestone-prefixed')); + } finally { cleanupDir(); } + }); +}); + +// ─── #2761 B1: workstream isolation, end-to-end through the readers ───────── + +describe('#2761 B1: a workstream\'s convention scopes ITS OWN roadmap read', () => { + const fs = require('fs'); + const path = require('path'); + const os = require('os'); + const { extractCurrentMilestone, getMilestonePhaseFilter } = + require('../gsd-core/bin/lib/roadmap-parser.cjs'); + const { cleanup } = require('./helpers.cjs'); + + // Two bracket milestones, NEITHER carrying a version string. Under the + // bracket convention STATE's `v2.0` selects `[GSD.02]` alone; under any other + // convention nothing matches and the window is the whole document. So "which + // convention did this read use" is directly observable in the window. + const ROADMAP = [ + '# Roadmap', '', + '## [GSD.01] Foundation', '', + '### [GSD.01] 01: Alpha', + '### [GSD.01] 02: Alpha2', '', + '## [GSD.02] Second', '', + '### [GSD.02] 01: Beta', + '### [GSD.02] 02: Beta2', + '### [GSD.02] 03: Beta3', '', + ].join('\n'); + + let dir; + const setup = ({ root, ws }) => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'adr-612-b1-')); + const wsDir = path.join(dir, '.planning', 'workstreams', 'foo'); + fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true }); + if (root !== undefined) { + fs.writeFileSync(path.join(dir, '.planning', 'config.json'), + JSON.stringify({ phase_id_convention: root })); + } + if (ws !== undefined) { + fs.writeFileSync(path.join(wsDir, 'config.json'), + JSON.stringify({ phase_id_convention: ws })); + } + fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), ROADMAP); + fs.writeFileSync(path.join(wsDir, 'STATE.md'), '---\nmilestone: v2.0\n---\n'); + return dir; + }; + const withWs = (name, fn) => { + const prev = process.env.GSD_WORKSTREAM; + if (name === null) delete process.env.GSD_WORKSTREAM; + else process.env.GSD_WORKSTREAM = name; + try { return fn(); } finally { + if (prev === undefined) delete process.env.GSD_WORKSTREAM; + else process.env.GSD_WORKSTREAM = prev; + } + }; + const cleanupDir = () => cleanup(dir); + + const scopedToGsd02 = (window) => + window.includes('[GSD.02] 01: Beta') && !window.includes('[GSD.01] 01: Alpha'); + + // Repro A (trek-e). The workstream declares milestone-prefixed. Flipping ONLY + // the ROOT config used to change which milestone this workstream extracted, + // because the convention was resolved from `planningDir(cwd)` — the root, + // since no GSD_WORKSTREAM was set — while the DOCUMENT came from the + // workstream. The workstream's own declaration was never consulted at all. + test('repro A: flipping the ROOT config cannot move a workstream that owns its convention', () => { + const windows = []; + for (const root of ['bracket', 'milestone-prefixed']) { + const d = setup({ root, ws: 'milestone-prefixed' }); + try { + const content = fs.readFileSync( + path.join(d, '.planning', 'workstreams', 'foo', 'ROADMAP.md'), 'utf-8'); + windows.push(withWs(null, () => extractCurrentMilestone(content, d, 'foo'))); + } finally { cleanupDir(); } + } + assert.equal(windows[0], windows[1], + 'root=bracket and root=milestone-prefixed must produce the SAME window for a ' + + 'workstream whose own config says milestone-prefixed'); + assert.ok(!scopedToGsd02(windows[0]), + 'a milestone-prefixed workstream must not take the bracket scoping path'); + }); + + // The federation itself is preserved: a workstream that declares NOTHING + // still inherits the root, exactly as config-loader does. That is inheritance, + // not the leak — pinned so the B1 fix cannot be over-applied into isolation. + test('a workstream that declares no convention still inherits the root', () => { + const d = setup({ root: 'bracket', ws: undefined }); + try { + const content = fs.readFileSync( + path.join(d, '.planning', 'workstreams', 'foo', 'ROADMAP.md'), 'utf-8'); + assert.ok(scopedToGsd02(withWs(null, () => extractCurrentMilestone(content, d, 'foo')))); + } finally { cleanupDir(); } + }); + + // Repro B (trek-e): `--workstream foo` vs `GSD_WORKSTREAM=foo`. + test('repro B: the ws ARGUMENT and GSD_WORKSTREAM produce the same window', () => { + const d = setup({ root: 'milestone-prefixed', ws: 'bracket' }); + try { + const content = fs.readFileSync( + path.join(d, '.planning', 'workstreams', 'foo', 'ROADMAP.md'), 'utf-8'); + const viaArg = withWs(null, () => extractCurrentMilestone(content, d, 'foo')); + const viaEnv = withWs('foo', () => extractCurrentMilestone(content, d, undefined)); + assert.equal(viaArg, viaEnv, 'arg-driven and env-driven reads must agree'); + assert.ok(scopedToGsd02(viaArg), 'both must take the workstream\'s own bracket scoping'); + } finally { cleanupDir(); } + }); + + test('repro B: arg/env parity holds through getMilestonePhaseFilter too', () => { + const d = setup({ root: 'milestone-prefixed', ws: 'bracket' }); + try { + const viaArg = withWs(null, () => getMilestonePhaseFilter(d, 'v2.0', undefined, 'foo')); + const viaEnv = withWs('foo', () => getMilestonePhaseFilter(d, 'v2.0', undefined, undefined)); + assert.equal(viaArg.phaseCount, viaEnv.phaseCount); + assert.equal(viaArg.phaseCount, 3, 'v2.0 declares exactly 3 bracket phases'); + } finally { cleanupDir(); } + }); + + // The workstream-inventory delta: those two sites passed a literal `null` + // ("resolved, and it is not bracket") where they meant `undefined` ("resolve + // it"). On a bracket workstream `null` collapsed the heading set to empty. + test('undefined resolves the workstream convention where null pinned legacy', () => { + const d = setup({ root: undefined, ws: 'bracket' }); + try { + withWs(null, () => { + assert.equal(getMilestonePhaseFilter(d, 'v2.0', undefined, 'foo').phaseCount, 3, + 'undefined must resolve foo\'s bracket convention'); + assert.equal(getMilestonePhaseFilter(d, 'v2.0', null, 'foo').phaseCount, 0, + 'null still means "explicitly not bracket" — the discriminator is intact'); + }); + } finally { cleanupDir(); } + }); + + test('a NON-bracket workstream counts identically under null and undefined', () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'adr-612-b1-legacy-')); + const wsDir = path.join(dir, '.planning', 'workstreams', 'bar'); + fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true }); + fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), [ + '# Roadmap', '', '## v2.0 — Second', '', + '### Phase 01: Beta', '### Phase 02: Beta2', '', + ].join('\n')); + fs.writeFileSync(path.join(wsDir, 'STATE.md'), '---\nmilestone: v2.0\n---\n'); + try { + withWs(null, () => { + const asNull = getMilestonePhaseFilter(dir, 'v2.0', null, 'bar'); + const asUndef = getMilestonePhaseFilter(dir, 'v2.0', undefined, 'bar'); + assert.equal(asNull.phaseCount, asUndef.phaseCount); + assert.equal(asUndef.phaseCount, 2); + }); + } finally { cleanupDir(); } + }); +}); + +// ─── G8: the pin reads LIVE source, not a transcription ──────────────────── + +describe('#612 PR-2: every selector call site declares the right baseline (live src)', () => { + // The structural table above pins transcription <-> selector. It cannot see a + // call site whose BASELINE ARGUMENT is wrong: flipping the planning + // snapshot's milestone-complete site from LABEL_ONLY to ANY_BRACKET grants a + // fires-on-every-repo check `[anything] Phase N` tolerance it has never had, + // and every behavioural test still passed. So the mode at each site is pinned + // count-exact against the shipped sources. + // + // #2761 M4 (trek-e review): the source READING is no longer done here. This + // block claimed the no-source-grep escape with a source-text-is-the-product + // reason, but that escape (CONTEXT.md: RULESET.TESTS.no-source-grep.exemption) + // is reserved for tests whose subject is a runtime CONTRACT FILE — STATE.md, + // config.toml, hooks.json, agent .md — and `src/*.cts` is none of those. + // Worse, the escape is FILE-level (eslint-rules/no-source-grep.cjs matches the + // marker in any comment), so one block's claim disarmed the rule for all ~700 + // lines of this suite. Rather than widen the documented scope to fit the test, + // the scan moved to the seam's own guard script + // (`scripts/lint-phase-id-drift.cjs`, where source scanning is sanctioned and + // already happens for the grammar rules) and is consumed here as STRUCTURED + // DATA. No file text reaches this file and no marker remains, so the rule is + // live again across the whole suite — the escape is gone, not relocated. + const { scanSelectorBaselines } = require('../scripts/lint-phase-id-drift.cjs'); + const CENSUS = scanSelectorBaselines(require('path').join(__dirname, '..')); + + // file -> [ANY_BRACKET count, LABEL_ONLY count] + const EXPECTED = { + 'roadmap.cts': [3, 3], + 'validate.cts': [1, 2], + 'state.cts': [0, 3], + 'planning-snapshot.cts': [0, 2], + 'roadmap-parser.cts': [2, 0], + }; + + for (const [file, [anyBracket, labelOnly]] of Object.entries(EXPECTED)) { + test(`${file}: ${anyBracket} any-bracket + ${labelOnly} label-only, and nothing else`, () => { + const c = CENSUS[file]; + assert.ok(c, `${file} no longer consumes the selector at all — update EXPECTED`); + assert.equal(c.ANY_BRACKET, anyBracket, `${file} any-bracket call count`); + assert.equal(c.LABEL_ONLY, labelOnly, `${file} label-only call count`); + assert.equal( + c.total, anyBracket + labelOnly, + `${file} has a phaseHeadingPrefixSrcFor call that does not name a PHASE_HEADING_BASELINE mode`, + ); + }); + } + + test('no OTHER src file consumes the selector unpinned', () => { + const unpinned = Object.keys(CENSUS).filter(f => !(f in EXPECTED)).sort(); + assert.deepEqual(unpinned, [], 'a new selector consumer must be added to EXPECTED'); + }); + + test('the census is live — it found the consumers, not an empty scan', () => { + // A census that silently returned {} would make every count assertion above + // fail loudly, but the unpinned check would pass vacuously. Pin the floor. + assert.deepEqual(Object.keys(CENSUS).sort(), Object.keys(EXPECTED).sort()); + }); + + test('the transcription table covers exactly the live call sites', () => { + const live = Object.values(EXPECTED).reduce((n, [a, l]) => n + a + l, 0); + assert.equal(BASE_SITES.length, live, 'BASE_SITES row count must equal live call-site count'); + }); +}); diff --git a/tests/adr-612-bracket-phase-counting.test.cjs b/tests/adr-612-bracket-phase-counting.test.cjs new file mode 100644 index 000000000..d6a1c5e75 --- /dev/null +++ b/tests/adr-612-bracket-phase-counting.test.cjs @@ -0,0 +1,3221 @@ +'use strict'; + +// docs-guard-exempt: this file performs no filesystem read of any docs/ path. +// Its only docs/ occurrence is the ADR-612 Decision 1 citation inside a line +// comment further down; every read call here targets a tmpdir .planning +// fixture. It trips Detector 3 because an odd prose backtick in the comment +// block above that citation opens a span the template-literal heuristic reads +// as a literal containing the citation. Registering it in the docs-guard lane +// would run this suite on docs/ changes whose content it never reads, so the +// marker is the correct side of that trade per this lint's own guidance. + +/** + * PR-2 (#2761 / epic #612) — total_phases counts bracket phase headings. + * + * `total_phases` is derived TWICE: buildStateFrontmatter feeds `state json`, and + * cmdStateSync feeds `state sync`. The second carries the comment "Mirrors the + * logic in buildStateFrontmatter so both report consistent percents (#3242 Bug + * B)". Teaching one and not the other ships that divergence. + * + * TWO ORACLE HAZARDS this file is shaped around: + * + * 1. #1446 removed total_phases from the ratchet, so it corrects DOWNWARD + * silently. Every assertion here is an EXACT number; "no error" or ">= n" + * passes straight through the bug. + * + * 2. Reading `state json` after `state sync` measures the READ derivation + * twice — sync leaves STATE.md untouched when its computed total already + * matches, so the write-path guard is never observed and a mutation to it + * survives. The sync assertions below pre-write a WRONG total into the + * frontmatter, run sync, and then read THE FILE, so the number asserted is + * the one sync actually wrote. + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const phaseIdHelpers = require('../gsd-core/bin/lib/phase-id.cjs'); + +let tmpDir; + +const stateMd = () => [ + '---', + 'gsd_state_version: 1.0', + 'milestone: v2.0', + 'milestone_name: Expansion', + 'status: executing', + '---', + '', + '# Project State', + '', + '**Phase:** 05', + '', + // The body Progress line is what makes sync derive and WRITE the progress + // block; without it sync has nothing to update and STATE.md is left untouched. + '**Progress:** [░░░░░░░░░░] 0%', + '', +].join('\n'); + +function writeProject(roadmap, convention, dirs = ['GSD.02-01-setup']) { + const planning = path.join(tmpDir, '.planning'); + fs.writeFileSync(path.join(planning, 'ROADMAP.md'), roadmap, 'utf-8'); + fs.writeFileSync(path.join(planning, 'STATE.md'), stateMd(), 'utf-8'); + fs.writeFileSync( + path.join(planning, 'config.json'), + JSON.stringify(convention === undefined ? {} : { phase_id_convention: convention }), 'utf-8', + ); + // `state sync` short-circuits with no phase directories, leaving STATE.md + // byte-unchanged — which is exactly how a mutation to the write-path counter + // survives a test that reads `state json` afterwards. Give it real work. + // #3186 made completion disk-strict: a PLAN and SUMMARY no longer imply a + // completed phase without a passing verification verdict. Keep these fixtures + // modeling the complete phases they modeled before that upstream change. + // A dir spec is either `'name'` (a PLAN, SUMMARY, and passing verification) + // or `['name', false]` (a PLAN alone — incomplete). The mixed fixtures need + // both shapes to distinguish completed_phases from total_phases. + // Write verification last so it cannot be stale relative to the summary. + for (const spec of dirs) { + const [d, complete = true] = Array.isArray(spec) ? spec : [spec, true]; + const dir = path.join(planning, 'phases', d); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, '01-01-x-PLAN.md'), '# plan\n', 'utf-8'); + if (complete) { + fs.writeFileSync(path.join(dir, '01-01-x-SUMMARY.md'), '# summary\n', 'utf-8'); + fs.writeFileSync( + path.join(dir, verificationNameFor(d)), + '---\nstatus: passed\n---\n# Verification\n', + 'utf-8', + ); + } + } +} + +/** + * Name each fixture's verification report for that phase's own padded token, + * matching cmdScaffold. A hardcoded `01-VERIFICATION.md` turns every other + * "complete" phase into a cross-phase stray once artifact scans are scoped. + * Prefix stripping stays fixture-local because these cases intentionally model + * the unqualified artifact layout; padding comes from the production owner. + */ +function verificationNameFor(dir) { + const rest = dir.replace(/^[A-Z][A-Z0-9_]*\.\d+-/i, ''); + const token = []; + for (const segment of rest.split('-')) { + if (/^\d+(?:\.\d+)*$/.test(segment)) token.push(segment); + else break; + } + assert.ok(token.length > 0, `fixture dir ${dir} carries no phase token to name its verification`); + return `${phaseIdHelpers.normalizePhaseName(token.join('-'))}-VERIFICATION.md`; +} + +/** total_phases as the READ path derives it. */ +function readTotal() { + const r = runGsdTools(['state', 'json'], tmpDir); + assert.ok(r.success, `state json failed: ${r.error}`); + return JSON.parse(r.output).progress?.total_phases ?? null; +} + +/** + * total_phases as the WRITE path derives it — read back out of STATE.md, not out + * of `state json`. This is the assertion that observes cmdStateSync at all. + */ +function syncedTotal() { + const r = runGsdTools(['state', 'sync'], tmpDir); + assert.ok(r.success, `state sync failed: ${r.error}`); + const raw = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + const m = raw.match(/^\s*total_phases:\s*(\d+)\s*$/m); + assert.ok(m, 'state sync must have written a total_phases into STATE.md'); + return parseInt(m[1], 10); +} + +/** + * The PERCENT `state sync` wrote into the STATE.md body. + * + * This is the only observable of cmdStateSync's own counter. Its + * `syncTotalPhases` never reaches the frontmatter `total_phases` field — that + * one is written by the read derivation — it reaches `computeProgressPercent` + * and nothing else. Asserting the frontmatter number after a sync therefore + * measures the READ path twice and lets a mutation to the write-path counter + * survive, which is exactly how this guard shipped untested the first time. + * Percent is completed/total, so the denominator is visible here. + */ +function syncedPercent() { + const r = runGsdTools(['state', 'sync'], tmpDir); + assert.ok(r.success, `state sync failed: ${r.error}`); + const raw = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + // eslint-disable-next-line local/no-unbounded-quantifier -- parses the STATE.md this test just wrote, fixed-size fixture output, not adversarial input + const m = raw.match(/^\*\*Progress:\*\*[^\r\n]*?(\d+)%/m); + assert.ok(m, `state sync must have written a Progress percent; got:\n${raw}`); + return parseInt(m[1], 10); +} + +/** The explicit reason `state sync` withheld progress for an unsafe scope. */ +function syncSkipReason() { + const r = runGsdTools(['state', 'sync'], tmpDir); + assert.ok(r.success, `state sync failed: ${r.error}`); + const changes = JSON.parse(r.output).changes || []; + const skipped = changes.find((change) => /^Progress: skipped/.test(change)); + assert.ok(skipped, `state sync must record why Progress was skipped; got: ${JSON.stringify(changes)}`); + const raw = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + assert.match(raw, /^\*\*Progress:\*\* \[░{10}\] 0%$/m, + 'no percent may be rendered when sync says it skipped'); + return skipped; +} + +const BRACKET_ROADMAP = `# Roadmap + +## [GSD.02] v2.0 + +### [GSD.02] 01: Setup +**Goal:** a + +### [GSD.02] 05: Real work +**Goal:** b + +### [GSD.02] 06: Follow-up +**Goal:** c +`; + +describe('#612 PR-2: bracket phase headings enter total_phases', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-count-'); }); + afterEach(() => { cleanup(tmpDir); }); + + test('the read path counts all three (exact)', () => { + writeProject(BRACKET_ROADMAP, 'bracket'); + assert.equal(readTotal(), 3); + }); + + test('#3242: the WRITE path writes the same number, observed in STATE.md', () => { + // Stale total forces sync to do real work, so the number in the file is the + // one cmdStateSync computed rather than the one that was already there. + writeProject(BRACKET_ROADMAP, 'bracket'); + assert.equal(syncedTotal(), 3, 'state sync must WRITE 3'); + assert.equal(readTotal(), 3, 'and the read path must agree'); + }); + + test('a NON-bracket repo counts neither form of the same roadmap', () => { + // One phase directory on disk, three bracket headings the reader cannot see: + // the total falls back to the directory count, exactly as it did before. + writeProject(BRACKET_ROADMAP, undefined); + assert.equal(readTotal(), 1, 'bracket headings are invisible without the convention'); + assert.equal(syncedTotal(), 1); + }); + + test('a mixed legacy + bracket roadmap counts both on a bracket repo', () => { + writeProject(`# Roadmap + +## v2.0 + +### Phase 1: Legacy one +**Goal:** a + +### [GSD.02] 05: Bracket one +**Goal:** b + +### Phase Overview: +`, 'bracket', + // #2761 Major 1: the default single dir ('GSD.02-01-setup') names a phase + // number ("01") this roadmap never declares — only "1" (legacy-labelled) + // and "05" (bracket) are real — so post-Major-1 the disk-side milestone + // filter correctly excludes it and `state sync` becomes a no-op (nothing + // to write). Naming the ACTUAL bracket-declared phase here keeps this + // test about what it says it's about (mixed heading-style counting), not + // an accidental side effect of the disk-scan gating fixed elsewhere. + ['GSD.02-05-bracket-one']); + assert.equal(readTotal(), 2, '`Phase Overview:` still excluded'); + assert.equal(syncedTotal(), 2); + }); +}); + +describe('#612 PR-2: bracket sentinels stay OUT of both derivations', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-count-sent-'); }); + afterEach(() => { cleanup(tmpDir); }); + + const SENTINEL_ROADMAP = `# Roadmap + +## [GSD.02] v2.0 + +### [GSD.999] 01: Icebox item +**Goal:** a + +### [GSD.00] 02: Pre-milestone +**Goal:** b + +### [GSD.02] 05: Real work +**Goal:** c + +### [GSD.02] 06: Follow-up +**Goal:** d +`; + + test('999.x and 0.x are excluded from the READ derivation (exact)', () => { + writeProject(SENTINEL_ROADMAP, 'bracket'); + assert.equal(readTotal(), 2); + }); + + test('999.x and 0.x are excluded from the WRITE derivation too (observed in STATE.md)', () => { + // The assertion that actually exercises cmdStateSync's sentinel guard — + // reading `state json` after sync would measure the read path a second time + // and let a mutation to the write path survive. + // One completed phase directory — named for the REAL "05" phase this + // roadmap declares (#2761 Major 1: the default dir names phase "01", + // which this roadmap never declares, and the disk-side milestone filter + // now correctly excludes an undeclared phase number from the WRITE-path + // scan too, making `state sync` a no-op on the default dir). With the + // sentinel guard the denominator is 2 (05, 06) so the percent is 50; + // without it the two sentinel headings inflate it to 4 and the percent + // drops to 25. + writeProject(SENTINEL_ROADMAP, 'bracket', ['GSD.02-05-real-work']); + assert.equal(syncedPercent(), 50, 'sentinel headings must not inflate the sync denominator'); + }); + + test('a LOWERCASE sentinel bracket is excluded from both', () => { + const doc = SENTINEL_ROADMAP.replace(/GSD\./g, 'gsd.'); + writeProject(doc, 'bracket', ['gsd.02-05-real-work']); + assert.equal(readTotal(), 2); + assert.equal(syncedTotal(), 2); + }); + + test('G5: a 999 token under a real milestone is still a backlog sentinel', () => { + writeProject(`# Roadmap + +## [GSD.02] v2.0 + +### [GSD.02] 999: Late work +**Goal:** a + +### [GSD.02] 05: Real +**Goal:** b +`, 'bracket', ['GSD.02-05-real']); + // The composed rule: bracket-sentinel OR legacy 999 token. + assert.equal(readTotal(), 1); + assert.equal(syncedTotal(), 1); + }); +}); + +describe('#612 PR-2: #1514 retired bracket phases leave the denominator', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-retired-'); }); + afterEach(() => { cleanup(tmpDir); }); + + // The canonical #1514 gesture, verbatim from the shipped legacy tests: strike + // the checklist BULLET and leave the detail heading intact. A bracket-form + // retirement went undetected, so the phase stayed in the denominator forever + // and a shipped bracket milestone could never reach 100%. + const retiredRoadmap = (bullet, heading) => `# Roadmap + +## [GSD.02] v2.0 + +- [x] ${bullet} — folded into 05; number retired +- [ ] **[GSD.02] 05: Real work** +- [ ] **[GSD.02] 06: Follow-up** + +${heading} +**Goal:** folded + +### [GSD.02] 05: Real work +**Goal:** b + +### [GSD.02] 06: Follow-up +**Goal:** c +`; + + test('bullet-only strike (the canonical gesture) excludes the phase', () => { + writeProject( + retiredRoadmap('~~**[GSD.02] 04: Delta**~~', '### [GSD.02] 04: Delta'), 'bracket', + // #2761 Major 1: name one of the two REAL (non-retired) phases so the + // WRITE-path milestone filter has something to admit — the retired "04" + // itself is deliberately never used here (that's a separate dedicated + // test below). + ['GSD.02-05-real-work']); + assert.equal(readTotal(), 2, 'the retired bracket phase must leave the denominator'); + assert.equal(syncedTotal(), 2); + }); + + test('unbolded bullet strike also excludes', () => { + writeProject( + retiredRoadmap('~~[GSD.02] 04: Delta~~', '### [GSD.02] 04: Delta'), 'bracket'); + assert.equal(readTotal(), 2); + }); + + test('a struck HEADING is excluded as well (it simply stops matching)', () => { + writeProject( + retiredRoadmap('~~**[GSD.02] 04: Delta**~~', '#### ~~**[GSD.02] 04: Delta**~~'), 'bracket'); + assert.equal(readTotal(), 2); + }); + + test('the legacy retirement gesture is unchanged', () => { + writeProject(`# Roadmap + +## v2.0 + +- [x] ~~**Phase 04: Delta**~~ — folded into Phase 05; number retired +- [ ] **Phase 05: Real** + +### Phase 04: Delta +**Goal:** folded + +### Phase 05: Real +**Goal:** b + +### Phase 06: Other +**Goal:** c +`, undefined); + assert.equal(readTotal(), 2, 'legacy 04 retired, 05 and 06 remain'); + }); + + test('a retired bracket phase DIRECTORY is skipped too', () => { + // The other half of the same comparison: the retired key has to match the + // directory's key, and phaseKeyFromDir needs the convention to produce one. + writeProject( + retiredRoadmap('~~**[GSD.02] 04: Delta**~~', '### [GSD.02] 04: Delta'), 'bracket', + ['GSD.02-04-delta', 'GSD.02-05-real-work', 'GSD.02-06-follow-up']); + // Three directories on disk, one of them retired. If phaseKeyFromDir cannot + // key a bracket directory the retired one is counted anyway and the total is + // 3 — the denominator a shipped bracket milestone could never work off. + assert.equal(readTotal(), 2, 'the retired phase must not be re-added by its directory'); + }); +}); + +describe('#612 PR-2: legacy counting is byte-identical', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-count-legacy-'); }); + afterEach(() => { cleanup(tmpDir); }); + + test('#549: pure-word section headings still excluded (exact)', () => { + writeProject(`# Roadmap + +## v2.0 + +## Phase Overview: + +### Phase 1: One +**Goal:** a + +### Phase 2.1: Two point one +**Goal:** b + +### Phase 12A: Letter suffix +**Goal:** c + +#### Phase Details: +`, undefined); + assert.equal(readTotal(), 3); + assert.equal(syncedTotal(), 3); + }); + + test('#1445: a legacy 999.x heading is still excluded from the read path', () => { + writeProject(`# Roadmap + +## v2.0 + +### Phase 999.1: Icebox +**Goal:** a + +### Phase 5: Real +**Goal:** b +`, undefined); + assert.equal(readTotal(), 1); + }); + + test('a project-code phase id still counts (exact)', () => { + writeProject(`# Roadmap + +## v2.0 + +### Phase PROJ-42: Coded +**Goal:** a + +### Phase 5: Real +**Goal:** b +`, undefined); + assert.equal(readTotal(), 2); + assert.equal(syncedTotal(), 2); + }); +}); + +describe('#612 PR-2: the ADR-canonical milestone heading scopes the milestone', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-scope-'); }); + afterEach(() => { cleanup(tmpDir); }); + + // ADR-612 Decision 1 pins the bracket milestone heading as `## [GSD.02] Foundation` + // — a NAME, with no version. Milestone scoping matches STATE's `milestone: v2.0` + // STRING against a heading, so the canonical form matched nothing, scoping was + // lost, and total_phases fell back to the on-disk directory count. Every earlier + // fixture in this file embeds `v2.0` in the heading and so never ran the form + // the ADR actually specifies. + const roadmap = (heading) => `# Roadmap + +${heading} + +### [GSD.02] 05: Real work +**Goal:** a + +### [GSD.02] 06: Follow-up +**Goal:** b + +### [GSD.02] 07: Third +**Goal:** c +`; + + test('name-only heading: total_phases comes from the ROADMAP, not the dir count', () => { + writeProject(roadmap('## [GSD.02] Foundation'), 'bracket', ['GSD.02-05-real-work']); + assert.equal(readTotal(), 3, 'three headings in scope, not one directory'); + }); + + test('the dir count no longer drives the answer', () => { + // The tell for the fallback: without scoping the total tracks the number of + // directories instead of staying at the ROADMAP's phase count. + writeProject(roadmap('## [GSD.02] Foundation'), 'bracket', + ['GSD.02-05-real-work', 'GSD.02-06-follow-up']); + assert.equal(readTotal(), 3); + }); + + test('the version-embedded heading still works', () => { + writeProject(roadmap('## [GSD.02] v2.0 — Foundation'), 'bracket', ['GSD.02-05-real-work']); + assert.equal(readTotal(), 3); + }); + + test('an unpadded bracket milestone scopes NOTHING (emit-grammar strict)', () => { + // Post-unification an unpadded `[GSD.2]` is malformed: it is not a phase id, + // so it must not bound or scope a milestone either. The tell is that the + // bracket reading equals the null-convention reading — if either behaviour + // flips, these two numbers diverge. + // The tell is that the unpadded heading SCOPES NOTHING: with it, the reading + // must equal the reading of a roadmap that has no milestone heading at all. + // If `[GSD.2]` ever starts scoping again, these two diverge. + const dirs = ['GSD.02-05-real-work']; + writeProject(roadmap('## [GSD.2] Foundation'), 'bracket', dirs); + const unpadded = readTotal(); + writeProject(roadmap('## Some heading with no milestone'), 'bracket', dirs); + const unscoped = readTotal(); + assert.equal(unpadded, unscoped, + 'an unpadded bracket milestone must bound nothing, exactly like no milestone heading'); + // And the canonical spelling DOES scope, so the pair is not trivially equal. + writeProject(roadmap('## [GSD.02] Foundation'), 'bracket', dirs); + assert.equal(readTotal(), 3, 'the padded spelling scopes'); + }); + + test('a milestone that does NOT match STATE is not scoped in', () => { + // Use two genuine milestone sections, neither matching STATE's v2.0. This + // removes the old fixture's accidental dependence on the word "milestone" + // appearing in a bracket phase title. Upstream #3354/#3480, strengthened by + // #3642 for the single-section sibling, classifies this as milestoned but + // unbounded: neither the whole-document count nor the on-disk directory + // count is authoritative. With no stored total, `state json` reports null + // rather than guessing. + writeProject(`# Roadmap + +## [GSD.03] Later milestone + +### [GSD.03] 09: Not scoped here +**Goal:** a + +## [GSD.04] Even later milestone + +### [GSD.04] 10: Also not scoped +**Goal:** b +`, 'bracket', ['GSD.02-05-real-work']); + assert.equal(readTotal(), null, 'no phases for the asserted milestone — withheld, not computed'); + }); + + test('a NON-bracket repo does not gain bracket scoping', () => { + writeProject(roadmap('## [GSD.02] Foundation'), undefined, ['GSD.02-05-real-work']); + assert.equal(readTotal(), 1, 'no scoping, no counting — invisible as designed'); + }); +}); + +describe('#612 PR-2: labeled sentinels, composed sentinels, and the disk-side filter', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-g3g4g5-'); }); + afterEach(() => { cleanup(tmpDir); }); + + const analyze = () => { + const r = runGsdTools(['roadmap', 'analyze'], tmpDir); + assert.ok(r.success, `roadmap analyze failed: ${r.error}`); + return JSON.parse(r.output); + }; + + test('G3: a LABELED bracket sentinel is excluded from every counter', () => { + // `### [GSD.999] Phase 07:` fell through to the base alternative, which + // captures nothing — so analyze applied the legacy token rule and counted it + // while state json excluded it. Two derivations of one ROADMAP disagreed. + writeProject(`# Roadmap + +## [GSD.02] v2.0 + +### [GSD.999] Phase 07: Icebox labeled +**Goal:** a + +### [GSD.999] 08: Icebox bare +**Goal:** b + +### [GSD.02] 01: Real +**Goal:** c +`, 'bracket'); + const out = analyze(); + assert.deepEqual(out.phases.map(p => p.number), ['01'], 'labeled and bare both excluded'); + assert.equal(readTotal(), 1); + assert.equal(out.phase_count, readTotal(), 'analyze and state must agree'); + }); + + test('G5: the legacy 999/0 token rule still applies to a bracketed heading', () => { + // READING-B ADDS a rule; it does not replace one. A mid-migration ROADMAP + // carrying a legacy backlog block under bracket headings must not gain + // denominator entries. + writeProject(`# Roadmap + +## [GSD.02] v2.0 + +### [GSD.02] 01: One +**Goal:** a + +### [GSD.02] 999: Backlog +**Goal:** b + +### [GSD.02] 0: Zero +**Goal:** c +`, 'bracket'); + const out = analyze(); + assert.deepEqual(out.phases.map(p => p.number), ['01'], + 'analyze excludes both the 999 and the 0 token, as it does for legacy headings'); + // DISCLOSED, pre-existing: the state counter's legacy token rule is 999-only + // — it has never excluded a bare `0` — so `[GSD.02] 0:` still reaches the + // denominator there. Adding a 0 filter would move legacy totals, which is out + // of scope; what this pin asserts is that the 999 rule was not DROPPED for + // bracketed headings. + // Under bracket the token rule composes as the full {0, 999} set, so this + // counter now agrees with roadmap analyze. The LEGACY path keeps its + // pre-existing 999-only rule — pinned separately. + assert.equal(readTotal(), 1, 'both 999 and 0 excluded under bracket'); + }); + + test('G4: the disk-side milestone filter does not count another milestone dirs', () => { + // getMilestonePhaseFilter's heading scan collected nothing on a bracket + // ROADMAP, so it degraded to pass-all and Math.max(dirs, roadmap) counted + // the previous milestone's directories — making bracket strictly worse than + // the M-NN convention it supersedes. + writeProject(`# Roadmap + +## [GSD.02] v2.0 + +### [GSD.02] 01: One +**Goal:** a + +### [GSD.02] 02: Two +**Goal:** b + +### [GSD.02] 03: Three +**Goal:** c +`, 'bracket', ['GSD.01-01-prev', 'GSD.01-02-prev2', 'GSD.02-01-one', 'GSD.02-02-two', 'GSD.02-03-three']); + assert.equal(readTotal(), 3, 'scoped to this milestone, not the whole disk'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// The four numbers `total_phases` was hiding. +// +// Every bracket counting assertion above reads `total_phases` and nothing else, +// and `total_phases` is the ONE number the disk-side milestone filter cannot +// move: `Math.max(phaseDirs.length, roadmapPhaseCount)` (state.cts) floors it at +// the ROADMAP count no matter how many directories the filter rejects. So a +// filter that rejects EVERY bracket directory leaves that number right and +// zeroes `completed_phases`, `total_plans`, `completed_plans` and `percent` — +// green suite, `state json` reporting 0% on a repo `state sync` calls 67% in the +// same second. These pin all five. +// +// THE ORACLE IS THE LEGACY TWIN, and it is computed in the same run rather than +// quoted: each test builds the identical repo in the flat legacy spelling and +// asserts the bracket reading equals it, number for number. Exact literals are +// asserted too — an equality alone would pass with both sides broken. +// +// WHY FLAT LEGACY AND NOT M-NN. The M-NN spelling of these shapes cannot serve +// as the oracle: buildStateFrontmatter's #2445 de-dup key is +// `dir.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/)`, which captures only the LEADING +// integer, so `02-01-one`, `02-02-two` and `02-03-three` all key to `2` and two +// of the three directories are dropped before they are ever counted. Measured +// on the true base build (d04592de), the M-NN twin of the first shape below +// reads `[3,0,1,0,0]` where flat legacy reads `[3,2,3,2,67]`; the divergence is +// present identically at base and is untouched by this PR. It is a legacy defect +// in a key space bracket directories cannot enter — `GSD.02-01-one` does not +// match that regex at all, so each bracket dir keys to its own name. The +// source's own `phase-id-owner:` sanction at that line records the divergence. +// ───────────────────────────────────────────────────────────────────────────── + +/** The whole progress block as the READ path derives it. */ +function readProgress() { + const r = runGsdTools(['state', 'json'], tmpDir); + assert.ok(r.success, `state json failed: ${r.error}`); + const p = JSON.parse(r.output).progress || {}; + return [p.total_phases, p.completed_phases, p.total_plans, p.completed_plans, p.percent]; +} + +/** + * The progress block `state sync` WROTE into STATE.md. + * + * Labelled precisely: this is the READ derivation observed a second time (sync + * rebuilds the frontmatter through buildStateFrontmatter). It is asserted + * because a write that disagrees with `state json` is the #3242 Bug B artifact + * this file exists to prevent — but it is NOT coverage of cmdStateSync's own + * counter. That counter reaches `computeProgressPercent` and nothing else, so + * `syncedPercent()` above is its only observable. + * + * `state json` echoes a `progress:` frontmatter block verbatim when one exists + * and only derives when there is none, so this must be read out of the FILE and + * `stateMd()` must stay block-free. (Measured: same repo, block-free `state + * json` → 3/2/3/2/67; with a `progress: 99…` block → 99/99/99/99/99.) + */ +function syncedProgress() { + const r = runGsdTools(['state', 'sync'], tmpDir); + assert.ok(r.success, `state sync failed: ${r.error}`); + const raw = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + const m = raw.match( + // eslint-disable-next-line local/no-unbounded-quantifier -- parses the STATE.md this test just wrote, fixed-size fixture output, not adversarial input + /total_phases:\s*(\d+)[\s\S]*?completed_phases:\s*(\d+)[\s\S]*?total_plans:\s*(\d+)[\s\S]*?completed_plans:\s*(\d+)[\s\S]*?percent:\s*(-?\d+)/); + assert.ok(m, `state sync must have written a full progress block; got:\n${raw}`); + return m.slice(1).map(Number); +} + +describe('#612 PR-2: the disk-side filter scopes bracket dirs — all five numbers', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-fivenum-'); }); + afterEach(() => { cleanup(tmpDir); }); + + // ── SHAPE 1: one milestone, three phases, three dirs, the first two complete ── + const ONE_MILESTONE_BRACKET = `# Roadmap + +## [GSD.02] v2.0: Current + +### [GSD.02] 01: One +**Goal:** a + +### [GSD.02] 02: Two +**Goal:** b + +### [GSD.02] 03: Three +**Goal:** c +`; + const ONE_MILESTONE_LEGACY = `# Roadmap + +## v2.0: Current + +### Phase 01: One +**Goal:** a + +### Phase 02: Two +**Goal:** b + +### Phase 03: Three +**Goal:** c +`; + // The M-NN spelling of the same shape — pinned as a CHARACTERIZATION at the end + // of this block, not used as an oracle. See the comment there. + const ONE_MILESTONE_MNN = `# Roadmap + +## v2.0: Current + +### Phase 2-01: One +**Goal:** a + +### Phase 2-02: Two +**Goal:** b + +### Phase 2-03: Three +**Goal:** c +`; + const ONE_BRACKET_DIRS = ['GSD.02-01-one', 'GSD.02-02-two', ['GSD.02-03-three', false]]; + const ONE_LEGACY_DIRS = ['01-one', '02-two', ['03-three', false]]; + const ONE_MNN_DIRS = ['02-01-one', '02-02-two', ['02-03-three', false]]; + + test('shape 1 READ: 3 phases, 2 complete, 3 plans, 2 done, 67% — not four zeros', () => { + writeProject(ONE_MILESTONE_BRACKET, 'bracket', ONE_BRACKET_DIRS); + assert.deepEqual(readProgress(), [3, 2, 3, 2, 67], + 'every bracket directory must satisfy the milestone filter'); + }); + + test('shape 1 READ equals its flat-legacy twin exactly', () => { + writeProject(ONE_MILESTONE_BRACKET, 'bracket', ONE_BRACKET_DIRS); + const bracket = readProgress(); + writeProject(ONE_MILESTONE_LEGACY, undefined, ONE_LEGACY_DIRS); + const legacy = readProgress(); + assert.deepEqual(bracket, legacy, 'bracket must read exactly what the legacy twin reads'); + assert.deepEqual(legacy, [3, 2, 3, 2, 67], 'and the twin is the right answer, not a shared wrong one'); + }); + + test('shape 1 WRITE: sync writes 67%, and its frontmatter agrees with `state json`', () => { + writeProject(ONE_MILESTONE_BRACKET, 'bracket', ONE_BRACKET_DIRS); + // The write derivation's own observable. + assert.equal(syncedPercent(), 67, 'state sync must write 67% into the body'); + // …and the block it wrote must not contradict it (#3242 Bug B). + assert.deepEqual(syncedProgress(), [3, 2, 3, 2, 67]); + assert.deepEqual(readProgress(), syncedProgress(), 'the two derivations must not disagree'); + }); + + test('shape 1 WRITE percent equals its flat-legacy twin', () => { + writeProject(ONE_MILESTONE_BRACKET, 'bracket', ONE_BRACKET_DIRS); + const bracket = syncedPercent(); + writeProject(ONE_MILESTONE_LEGACY, undefined, ONE_LEGACY_DIRS); + assert.equal(bracket, syncedPercent()); + assert.equal(bracket, 67); + }); + + // ── SHAPE 2: two milestones, scoped to v2.0, two stale prior-milestone dirs ── + const TWO_MILESTONE_BRACKET = `# Roadmap + +## [GSD.01] v1.0: Prior + +### [GSD.01] 01: Old one +**Goal:** a + +### [GSD.01] 02: Old two +**Goal:** b + +## [GSD.02] v2.0: Current + +### [GSD.02] 01: One +**Goal:** c + +### [GSD.02] 02: Two +**Goal:** d + +### [GSD.02] 03: Three +**Goal:** e +`; + const TWO_MILESTONE_LEGACY = `# Roadmap + +## v1.0: Prior + +### Phase 01: Old one +**Goal:** a + +### Phase 02: Old two +**Goal:** b + +## v2.0: Current + +### Phase 03: One +**Goal:** c + +### Phase 04: Two +**Goal:** d + +### Phase 05: Three +**Goal:** e +`; + const TWO_BRACKET_DIRS = ['GSD.01-01-old-one', 'GSD.01-02-old-two', 'GSD.02-01-one', + ['GSD.02-02-two', false], ['GSD.02-03-three', false]]; + const TWO_LEGACY_DIRS = ['01-old-one', '02-old-two', '03-one', ['04-two', false], ['05-three', false]]; + + test('shape 2 READ: the prior milestone dirs are excluded — 3/1/3/1/33', () => { + // Both milestones number their phases 01/02/…, so the bare token cannot tell + // `GSD.01-01-old-one` from `GSD.02-01-one`. Only the milestone-qualified key + // separates them; matching on the token would read 5/3/5/3/60 instead. + writeProject(TWO_MILESTONE_BRACKET, 'bracket', TWO_BRACKET_DIRS); + assert.deepEqual(readProgress(), [3, 1, 3, 1, 33]); + }); + + test('shape 2 READ equals its flat-legacy twin exactly', () => { + writeProject(TWO_MILESTONE_BRACKET, 'bracket', TWO_BRACKET_DIRS); + const bracket = readProgress(); + writeProject(TWO_MILESTONE_LEGACY, undefined, TWO_LEGACY_DIRS); + const legacy = readProgress(); + assert.deepEqual(bracket, legacy); + assert.deepEqual(legacy, [3, 1, 3, 1, 33]); + }); + + test('shape 2 WRITE (#2761 Major 1 fix): bracket closes to 33% (matches read); legacy stays at the disclosed 60%', () => { + // UPDATED by #2761 Major 1 — this test used to pin the BUG this fix + // closes, titled "the DISCLOSED legacy gap, mirrored — not closed": + // cmdStateSync did its own `fs.readdirSync` and never called the + // milestone filter, so its denominator was the whole disk (3 summaries + // over 5 plans = 60%) against the read path's scoped 33% — for BOTH + // bracket and legacy alike, since the divergence was engine-wide, not + // bracket-specific. + // + // Major 1 gates cmdStateSync's disk scan by getMilestonePhaseFilter under + // `phase_id_convention === 'bracket'` ONLY — deliberately NOT + // unconditionally: an unconditional filter would ALSO move every legacy + // repo's persisted percent, which is out of this fix's scope (the + // binding constraint is "legacy stays byte-identical"). So: bracket now + // closes to 33% (agrees with the read path — repro3's fix, verified + // here on a SECOND fixture with real prior-milestone noise dirs); + // legacy stays at the pre-existing 60% (unchanged, deliberately). + writeProject(TWO_MILESTONE_BRACKET, 'bracket', TWO_BRACKET_DIRS); + assert.equal(syncedPercent(), 33, 'bracket: the gap is now closed, agrees with the read path'); + writeProject(TWO_MILESTONE_LEGACY, undefined, TWO_LEGACY_DIRS); + assert.equal(syncedPercent(), 60, 'legacy: the gap remains — moving it is out of this fix\'s scope'); + }); + + test('shape 2 WRITE frontmatter agrees with `state json` on both spellings', () => { + writeProject(TWO_MILESTONE_BRACKET, 'bracket', TWO_BRACKET_DIRS); + assert.deepEqual(syncedProgress(), [3, 1, 3, 1, 33]); + assert.deepEqual(readProgress(), syncedProgress()); + writeProject(TWO_MILESTONE_LEGACY, undefined, TWO_LEGACY_DIRS); + assert.deepEqual(syncedProgress(), [3, 1, 3, 1, 33]); + assert.deepEqual(readProgress(), syncedProgress()); + }); + + test('a bracket repo carrying LEGACY-shaped dirs is unaffected (the branch is additive)', () => { + // The bracket branch tries the qualified key first and FALLS THROUGH on a + // miss, so the three legacy dir checks still run on a bracket project. An + // early `return false` there would silently drop this repo to zero. + writeProject(ONE_MILESTONE_LEGACY, 'bracket', ONE_LEGACY_DIRS); + assert.deepEqual(readProgress(), [3, 2, 3, 2, 67]); + }); + + test('CHARACTERIZATION: the M-NN twin of shape 1 no longer collapses — three plans, not one', () => { + // Holds the oracle substitution honest. The two "equals its flat-legacy + // twin" tests above compare bracket against FLAT legacy; nothing else in the + // suite pins the M-NN spelling, so the changeset's claim that the M-NN + // divergence is pre-existing and untouched would go stale silently the first + // time a sibling slice widens the de-dup key. + // + // Upstream #3185 replaced the leading-integer de-dup key with canonical + // phaseKeyFromDir ownership. The M-NN directories now key as 02-01, + // 02-02, and 02-03, so all three distinct phases and plans survive. + writeProject(ONE_MILESTONE_MNN, undefined, ONE_MNN_DIRS); + const mnn = readProgress(); + assert.equal(mnn[0], 3, 'M-NN: total_phases still tracks the ROADMAP'); + assert.equal(mnn[2], 3, 'M-NN: all three dirs survive the de-dup after #3185'); + // Build the flat-legacy twin in a fresh project. Reusing this project would + // stack both directory spellings now that the M-NN entries no longer + // collapse under the old leading-integer key. + cleanup(tmpDir); + tmpDir = createTempProject('adr-612-fivenum-mnn-twin-'); + writeProject(ONE_MILESTONE_LEGACY, undefined, ONE_LEGACY_DIRS); + assert.deepEqual(readProgress(), [3, 2, 3, 2, 67]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// R4-m3 — the milestone-qualified key is a string SPLICE, so a heading whose +// token carries its own hyphen mis-parses into the wrong directory. +// +// `### [GSD.02] Phase 02-01:` spliced to `GSD.02-02-01`, which the qualified-key +// grammar reads as milestone 02 / phase 02 — the trailing `-01` truncated, both +// such headings collapsing to one key, and the heading claiming +// `GSD.02-02-two` (the directory it does NOT name) while rejecting +// `GSD.02-01-one` (the one it does). +// +// The oracle is the SAME ROADMAP read under `milestone-prefixed`, which is +// base-identical on this shape — so the bracket acceptance vector must equal it. +// Scope note: only the ACCEPTANCE VECTOR is claimed base-equivalent. +// `total_phases` on this fixture does move 1 -> 2, because the bracket heading +// count is the feature this PR ships; measured, that move is identical with and +// without this guard, and identical to what the canonical `### [GSD.02] 01:` +// spelling does (both read 2 with zero directories on disk, where base reads 0). +// ───────────────────────────────────────────────────────────────────────────── +describe('#612 PR-2: a hyphenated heading token forms no qualified key', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-hyphen-tok-'); }); + afterEach(() => { cleanup(tmpDir); }); + + const MIXED = `# Roadmap + +## [GSD.02] v2.0: Current + +### [GSD.02] Phase 02-01: One +**Goal:** a + +### [GSD.02] Phase 02-02: Two +**Goal:** b +`; + const DIRS = ['GSD.02-02-two', 'GSD.02-01-one', '02-01-mnn', '02-2026-photos', '46-6-rs-thing', '2-01-x']; + + /** The disk-side filter's own acceptance vector, read straight off the module. */ + const acceptance = () => { + const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs'); + const f = rp.getMilestonePhaseFilter(tmpDir); + return Object.fromEntries(DIRS.map(d => [d, !!f(d)])); + }; + + test('the heading does not claim the directory it does not name', () => { + writeProject(MIXED, 'bracket', DIRS); + const a = acceptance(); + assert.equal(a['GSD.02-02-two'], false, + '`[GSD.02] Phase 02-01` must not claim GSD.02-02-two by a truncated key'); + assert.equal(a['GSD.02-01-one'], false, + 'and it does not resolve its own dir either — unqualified, exactly as at base'); + assert.equal(a['02-01-mnn'], true, 'the legacy fall-through is untouched'); + }); + + test('the acceptance vector equals the milestone-prefixed control on the same ROADMAP', () => { + writeProject(MIXED, 'bracket', DIRS); + const bracket = acceptance(); + cleanup(tmpDir); + tmpDir = createTempProject('adr-612-hyphen-tok-ctl-'); + writeProject(MIXED, 'milestone-prefixed', DIRS); + const control = acceptance(); + assert.deepEqual(bracket, control, + 'a hyphenated token must read the disk identically under both conventions'); + }); + + test('a CANONICAL bracket heading still forms its qualified key', () => { + // Guards the guard: `!token.includes('-')` must not disable qualified + // matching for the spelling the convention actually specifies. + writeProject(`# Roadmap + +## [GSD.02] v2.0: Current + +### [GSD.02] 01: One +**Goal:** a +`, 'bracket', ['GSD.02-01-one', 'GSD.01-01-old-one']); + const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs'); + const f = rp.getMilestonePhaseFilter(tmpDir); + assert.equal(f('GSD.02-01-one'), true, 'the canonical qualified key still resolves'); + assert.equal(f('GSD.01-01-old-one'), false, 'and still scopes out the foreign milestone'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// R4-m2 — extractCurrentMilestone must not throw. +// +// Its bracket-scoping fallback calls resolvePhaseIdConvention, which reaches +// planningDir, which throws a plain Error for a GSD_PROJECT/GSD_WORKSTREAM +// segment containing `/`, `\` or `..`. At base the only planningDir call in this +// function sits inside the STATE-read try, so the function returned normally; +// an unguarded call broke the never-throws invariant that getRoadmapPhaseInternal +// and getMilestoneInfo carry #2245 / ADR-227 notes about. +// +// Module level on purpose: the CLI rejects a bad GSD_WORKSTREAM up front, so +// this contract is only observable to an in-process embedder — which is exactly +// who the invariant protects. +// ───────────────────────────────────────────────────────────────────────────── +describe('#612 PR-2: extractCurrentMilestone never throws on a poisoned env', () => { + const ROADMAP = `# Roadmap + +## Milestones + +- 🚧 **v1.0 Alpha** — in progress + +## Alpha + +### Phase 01: Setup +`; + + test('a traversal segment in GSD_WORKSTREAM degrades instead of escaping', () => { + const dir = createTempProject('adr-612-envguard-'); + fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), ROADMAP, 'utf-8'); + fs.writeFileSync(path.join(dir, '.planning', 'config.json'), '{}', 'utf-8'); + const prior = process.env.GSD_WORKSTREAM; + try { + process.env.GSD_WORKSTREAM = '../evil'; + const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs'); + const out = rp.extractCurrentMilestone(ROADMAP, dir); + assert.equal(typeof out, 'string', 'must return content, not throw'); + assert.ok(out.length > 0); + } finally { + // Restore before anything else in this chunk runs — a leaked + // GSD_WORKSTREAM would poison every later test in the same process. + if (prior === undefined) delete process.env.GSD_WORKSTREAM; + else process.env.GSD_WORKSTREAM = prior; + cleanup(dir); + } + }); +}); + +// ─── REGRESSION: the version-less bracket milestone heading now scopes ────── + +/** + * FIXED (#2761 B1, reviewer trek-e blocker). Every other bracket fixture in + * this repo writes its milestone heading as `## [GSD.02] v2.0: Current` — with a + * version. ADR-612's canonical form is a NAME and no version + * (`## [GSD.02] Foundation`), which `isMilestoneBounded`'s own doc comment in + * state.cts calls out as canonical. Until this fix, that form left the + * disk-side filter unscoped: directories from BOTH the prior and the later + * milestone were admitted into the current one. + * + * Mechanism, in `extractCurrentMilestone` (roadmap-parser.cts): + * - the bracket scope branch selects the right `currentSection`, but + * - `preambleCutoff` was driven only by `anyMilestonePattern`, which requires + * `v\d+\.\d+` or a status emoji. A version-less roadmap matches none, so the + * cutoff fell back to the CURRENT milestone's own offset and every PRIOR + * milestone landed in the preamble — whose phase-stripping regex only strips + * `Phase N:`-labelled headings, so bracket phase headings survived it; and + * - `computeSectionEnd` accepted a boundary only if the heading carried a + * version or emoji, so with none present the section ran to EOF and every + * LATER milestone was swept in too. + * + * The leak was bidirectional and had two sites. The fix: under the bracket + * scope branch (`bracketScopeConvention === 'bracket'`), both + * `computeSectionEnd` and the `preambleCutoff` scan ALSO accept a + * `#{1,2}\s+\[CODE.MM\]` heading as a milestone boundary, built from + * phase-id.cts's `BRACKET_ID_SRC` (the single owner of the bracket-id grammar) + * rather than a re-typed literal. `#{1,2}` is the discriminator because bracket + * PHASE headings are `###` and carry the same `[CODE.MM]` prefix (a `#{1,3}` + * pattern would match both, which is why the scope branch's own matcher returns + * the phase headings as well). Reachable ONLY when the bracket scope branch has + * fired, so version-bearing/emoji repos and non-bracket conventions take the + * exact pre-existing code path. + * + * These tests used to assert the pre-fix reading; they are inverted here as the + * fix's regression proof, not deleted. + */ +describe('#612 PR-2 REGRESSION: a version-less bracket milestone scopes correctly', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-versionless-'); }); + afterEach(() => { cleanup(tmpDir); }); + + const VERSIONLESS = `# Roadmap + +## [GSD.01] Prior Milestone + +### [GSD.01] 01: Old one +**Goal:** a + +## [GSD.02] Current Milestone + +### [GSD.02] 01: One +**Goal:** b + +### [GSD.02] 02: Two +**Goal:** c + +## [GSD.03] Later Milestone + +### [GSD.03] 01: Later one +**Goal:** d +`; + // Same roadmap, milestone headings carrying their version — the shape every + // other fixture in this file uses, and the control that proves the difference + // is the VERSION STRING and nothing else. + const VERSIONED = VERSIONLESS + .replace('## [GSD.01] Prior Milestone', '## [GSD.01] v1.0: Prior Milestone') + .replace('## [GSD.02] Current Milestone', '## [GSD.02] v2.0: Current Milestone') + .replace('## [GSD.03] Later Milestone', '## [GSD.03] v3.0: Later Milestone'); + + const DIRS = ['GSD.01-01-old-one', 'GSD.02-01-one', 'GSD.02-02-two', 'GSD.03-01-later-one']; + + const accepts = () => { + const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs'); + const f = rp.getMilestonePhaseFilter(tmpDir); + return Object.fromEntries(DIRS.map(d => [d, !!f(d)])); + }; + + test('CONTROL: with a version in the heading, scoping works in both directions', () => { + writeProject(VERSIONED, 'bracket', DIRS); + assert.deepEqual(accepts(), { + 'GSD.01-01-old-one': false, + 'GSD.02-01-one': true, + 'GSD.02-02-two': true, + 'GSD.03-01-later-one': false, + }); + }); + + test('without a version, scoping ALSO works in both directions (regression proof)', () => { + // Mirrors the CONTROL assertion exactly, on the version-less fixture — the + // fix makes the two shapes agree. + writeProject(VERSIONLESS, 'bracket', DIRS); + assert.deepEqual(accepts(), { + 'GSD.01-01-old-one': false, + 'GSD.02-01-one': true, + 'GSD.02-02-two': true, + 'GSD.03-01-later-one': false, + }); + }); + + test('without a version, the PRIOR milestone no longer leaks in (preambleCutoff)', () => { + writeProject(VERSIONLESS, 'bracket', DIRS); + assert.equal(accepts()['GSD.01-01-old-one'], false, + 'regression proof — pinned true before the #2761 B1 scoping fix'); + }); + + test('without a version, the LATER milestone no longer leaks in (computeSectionEnd)', () => { + writeProject(VERSIONLESS, 'bracket', DIRS); + assert.equal(accepts()['GSD.03-01-later-one'], false, + 'regression proof — pinned true before the #2761 B1 scoping fix'); + }); + + test('total_phases counts only the asserted milestone, not the whole disk', () => { + writeProject(VERSIONLESS, 'bracket', DIRS); + // 4 directories on disk, 2 phases in the milestone STATE.md asserts. + assert.equal(readTotal(), 2, 'regression proof — pinned 4 before the #2761 B1 scoping fix'); + }); + + test('the current milestone\'s own dirs are admitted either way (no under-count)', () => { + // Whatever else leaked before the fix, the milestone's real phases must + // always resolve — that property held before and still holds after. + writeProject(VERSIONLESS, 'bracket', DIRS); + const a = accepts(); + assert.equal(a['GSD.02-01-one'], true); + assert.equal(a['GSD.02-02-two'], true); + }); +}); + +// ─── #2761 B1 FOLLOW-UP: mixed heading shapes and boundary heading levels ─── +// +// Two gaps flagged during self-review of the B1 fix above, closed here with +// deterministic fixtures: +// +// 1. The earliest-of-either comparison added to `preambleCutoff` (taking +// whichever of the version/emoji match or the bracket match sits first in +// the document) was only exercised where the two patterns happen to agree +// on the same heading (every milestone in the REGRESSION block above is +// uniformly version-bearing or uniformly version-less). A genuinely mixed +// roadmap — one milestone version-bearing, its sibling version-less — was +// untested. +// +// 2. `computeSectionEnd`'s `h.level <= 2` conjunct (added alongside the +// pre-existing `h.level > level` skip) is REDUNDANT whenever the selected +// milestone heading is level 2 — the ADR-canonical shape, and every +// existing fixture in this repo: `h.level > level` alone already implies +// `h.level <= 2` there, so a mutant deleting the conjunct would survive +// every test written before this one. It is NOT redundant when the +// selected heading is level 3 (or level 1) — see the fixtures below. +describe('#612 PR-2 B1 FOLLOW-UP: mixed heading shapes and boundary heading levels', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-mixed-'); }); + afterEach(() => { cleanup(tmpDir); }); + + const acceptsFor = (dirs) => { + const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs'); + const f = rp.getMilestonePhaseFilter(tmpDir); + return Object.fromEntries(dirs.map((d) => [d, !!f(d)])); + }; + + test('mixed shape: version-bearing PRIOR + version-less CURRENT — prior stays out of the preamble leak set', () => { + // The mid-migration shape: an already-versioned milestone sits before a + // newer one that has not yet had its version added. anyMilestonePattern + // alone already finds GSD.01 here — it's the first (and only) version- + // bearing heading in the document — so this fixture pins that the + // earliest-of-either comparison does not regress that pre-existing path + // when the two patterns agree on the same heading, while computeSectionEnd + // still needs the bracket-boundary fix to correctly exclude GSD.03 (which + // remains version-less). + const roadmap = `# Roadmap + +## [GSD.01] v1.0: Prior Milestone + +### [GSD.01] 01: Old one +**Goal:** a + +## [GSD.02] Current Milestone + +### [GSD.02] 01: One +**Goal:** b + +### [GSD.02] 02: Two +**Goal:** c + +## [GSD.03] Later Milestone + +### [GSD.03] 01: Later one +**Goal:** d +`; + const dirs = ['GSD.01-01-old-one', 'GSD.02-01-one', 'GSD.02-02-two', 'GSD.03-01-later-one']; + writeProject(roadmap, 'bracket', dirs); + assert.deepEqual(acceptsFor(dirs), { + 'GSD.01-01-old-one': false, + 'GSD.02-01-one': true, + 'GSD.02-02-two': true, + 'GSD.03-01-later-one': false, + }); + assert.equal(readTotal(), 2); + }); + + test('boundary heading level: a level-3 CURRENT milestone heading still scopes correctly (kills the h.level<=2 equivalent-mutant)', () => { + // The selected milestone heading is written with THREE hashes + // (`### [GSD.02] Foundation`) — unusual, but syntactically admitted by the + // same `#{1,3}` grammar every heading matcher in this function already + // compiles. With level=3, `h.level > level` alone no longer excludes a + // level-3 heading, so computeSectionEnd's own first phase heading + // (`### [GSD.02] 01: One`) — itself bracket-shaped — would ALSO satisfy the + // bracket-boundary test if the `h.level <= 2` conjunct were removed, + // truncating the section to nothing but the bare milestone heading and + // dropping BOTH of its own phases. A real PRIOR milestone precedes it so the + // preamble side-channel cannot independently rescue the truncated phases — + // confirmed by hand-mutating a throwaway build copy: without the guard this + // fixture's own phases vanish from the returned scope entirely, and + // getMilestonePhaseFilter's zero-token pass-all degrade then admits every + // directory on disk instead (the exact pre-#612 symptom). + const roadmap = `# Roadmap + +## [GSD.01] v1.0: Prior Milestone + +### [GSD.01] 01: Old +**Goal:** z + +### [GSD.02] Foundation + +### [GSD.02] 01: One +**Goal:** a + +### [GSD.02] 02: Two +**Goal:** b + +## [GSD.03] v3.0: Next Milestone + +### [GSD.03] 01: Later +**Goal:** c +`; + const dirs = ['GSD.01-01-old', 'GSD.02-01-one', 'GSD.02-02-two', 'GSD.03-01-later']; + writeProject(roadmap, 'bracket', dirs); + assert.deepEqual(acceptsFor(dirs), { + 'GSD.01-01-old': false, + 'GSD.02-01-one': true, + 'GSD.02-02-two': true, + 'GSD.03-01-later': false, + }); + assert.equal(readTotal(), 2); + }); + + test('boundary heading level: a level-1 CURRENT milestone heading also scopes correctly (#{1,2} tolerance, not just level 2)', () => { + // A level-1/level-1 pairing (consistent heading-level convention across + // sibling milestones) — distinct from the level-3 case above: this pins + // that the `#{1,2}` bracket-boundary source tolerates level 1, not only the + // ADR-canonical level 2. + const roadmap = `# [GSD.02] Foundation + +### [GSD.02] 01: One +**Goal:** a + +### [GSD.02] 02: Two +**Goal:** b + +# [GSD.03] v3.0: Next Milestone + +### [GSD.03] 01: Later +**Goal:** c +`; + const dirs = ['GSD.02-01-one', 'GSD.02-02-two', 'GSD.03-01-later']; + writeProject(roadmap, 'bracket', dirs); + assert.deepEqual(acceptsFor(dirs), { + 'GSD.02-01-one': true, + 'GSD.02-02-two': true, + 'GSD.03-01-later': false, + }); + assert.equal(readTotal(), 2); + }); +}); + +// ─── #2761 B1 (round-2 adversarial review, Blocker 1): a SAME-MILESTONE ────── +// ─── continuation heading is not mistaken for a DIFFERENT milestone's ─────── +// ─── boundary ──────────────────────────────────────────────────────────────── +// +// The B1 fix above (08d5b0c4) taught computeSectionEnd/preambleCutoff to +// recognise ANY `#{1,2} [CODE.MM]` heading as a milestone boundary. It did not +// distinguish "a heading for a DIFFERENT milestone" (a real boundary) from "a +// heading that merely CONTINUES the CURRENT milestone's own section" (e.g. a +// version-less checklist/detail split: `## [GSD.02] Foundation (Phase +// Details)`, or an ad-hoc continuation heading `## [GSD.02] Foundation — +// continued`) — the latter is not a boundary at all. The `(Phase Details)` +// re-append at `detailsMatch` below only searches `allMatches` (the +// VERSION-STRING matches from the top of this function), so a version-less +// continuation heading was cut out by the boundary and never re-appended: the +// milestone's OWN later phases silently vanished from the returned scope, +// while `getMilestonePhaseFilter`'s phaseCount stayed non-zero (unlike the +// original #612 defect), so the pass-all degrade never caught it either — a +// confidently wrong, non-degraded phase count for a still-incomplete +// milestone. +// +// Fixed here by teaching isBracketMilestoneBoundary a same-milestone +// exclusion: a candidate heading whose OWN bracket id (case-folded) equals the +// SELECTED milestone's bracket id is never a boundary. +describe('#612 PR-2 B1 round-2: a same-milestone continuation heading is not a boundary', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-b1r2-'); }); + afterEach(() => { cleanup(tmpDir); }); + + const D = [['GSD.02-01-one', true], ['GSD.02-02-two', false]]; + + /** + * Per the file header's oracle-hazard #2: pre-write a WRONG total_phases + * into STATE.md's frontmatter before calling `syncedTotal()`/ + * `syncedPercent()`, so `state sync` is forced to do real work rather than + * silently no-op because its computed total already happens to match. + */ + function poisonTotalPhases() { + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + const raw = fs.readFileSync(statePath, 'utf-8'); + fs.writeFileSync(statePath, raw.replace(/^---\r?\n/, '---\ntotal_phases: 999\n'), 'utf-8'); + } + + test('RED (repro8 case 1): version-bearing head + version-less "(Phase Details)" continuation — 2/1/50, not 1/1/100', () => { + writeProject(`# Roadmap + +## [GSD.02] v2.0: Foundation + +### [GSD.02] 01: One +**Goal:** b + +## [GSD.02] Foundation (Phase Details) + +### [GSD.02] 02: Two +**Goal:** c +`, 'bracket', D); + assert.equal(readTotal(), 2, 'pinned 1 before this fix — the continuation heading truncated the section'); + poisonTotalPhases(); + assert.equal(syncedTotal(), 2, 'the WRITE path must agree with the READ path'); + assert.equal(syncedPercent(), 50, 'pinned 100 before this fix'); + }); + + test('RED (repro5): fully version-less milestone split across two headings, no siblings — 2/1/50, not 1/1/100', () => { + writeProject(`# Roadmap + +## [GSD.02] Foundation + +### [GSD.02] 01: One +**Goal:** b + +## [GSD.02] Foundation — continued + +### [GSD.02] 02: Two +**Goal:** c +`, 'bracket', D); + assert.equal(readTotal(), 2, 'pinned 1 before this fix'); + poisonTotalPhases(); + assert.equal(syncedTotal(), 2); + // The roadmap is version-less while STATE pins v2.0, so upstream #3217 + // withholds the percentage rather than treating the scope as complete. + assert.match(syncSkipReason(), /scope is "unscoped", not COMPLETE \(#3217\)/); + }); + + test('PIN (repro8 case 3): a trailing DIFFERENT-id icebox section still terminates the primary section — unchanged at 2/1/50', () => { + writeProject(`# Roadmap + +## [GSD.02] v2.0: Foundation + +### [GSD.02] 01: One +**Goal:** b + +### [GSD.02] 02: Two +**Goal:** c + +## [GSD.999] Icebox + +### [GSD.999] 07: Someday +**Goal:** z +`, 'bracket', D); + assert.equal(readTotal(), 2); + poisonTotalPhases(); + assert.equal(syncedTotal(), 2); + assert.equal(syncedPercent(), 50); + }); + + test('PIN (repro10 A1): all-version-bearing + icebox + "(Phase Details)" — exact 2/1/50, no double-count', () => { + // The same-milestone exclusion must not make the PRIMARY section swallow + // the (Phase Details) section a second time on top of the pre-existing + // detailsMatch re-append — total_phases must read EXACTLY 2, not 4. + const dirs = [ + ['GSD.01-01-old', true], + ['GSD.02-01-one', true], + ['GSD.02-02-two', false], + ['GSD.03-01-later', true], + ]; + writeProject(`# Roadmap + +## [GSD.01] v1.0: Prior + +### [GSD.01] 01: Old +**Goal:** a + +## [GSD.02] v2.0: Current + +### [GSD.02] 01: One +**Goal:** b + +## [GSD.03] v3.0: Later + +### [GSD.03] 01: Later +**Goal:** d + +## [GSD.02] v2.0: Current (Phase Details) + +### [GSD.02] 02: Two +**Goal:** c + +## [GSD.999] Icebox + +### [GSD.999] 07: Someday +**Goal:** z +`, 'bracket', dirs); + assert.equal(readTotal(), 2, 'exact — a regression here would double-count to 4'); + // NOTE: no syncedTotal()/syncedPercent() assertions here — this fixture + // carries dirs OUTSIDE the current milestone (GSD.01-01-old, + // GSD.03-01-later), which is exactly the shape that exposes Major 1 + // (cmdStateSync's body percent is computed from an unfiltered whole-disk + // scan). Asserting the synced percent here before Major 1's fix lands + // would fail on a DIFFERENT, not-yet-fixed defect. Covered once Major 1 is + // fixed (#2761 Major 1 commit), where this fixture's synced values are + // asserted directly. + }); +}); + +// ─── #2761 B2 (round-2 adversarial review, Blocker 2): the heading ────────── +// ─── discriminator is CONTENT, not level ──────────────────────────────────── +// +// Three sites disagreed about which heading levels are a bracket milestone: +// the selector (`^#{1,3}\s+\[CODE.MM\]`) and `isMilestoneBounded` (state.cts) +// both admit level 1-3, but the B1 boundary only admitted level 1-2 +// (`h.level <= 2`). A `###`-level bracket milestone was therefore SELECTED and +// BOUNDED but never TERMINATED: computeSectionEnd ran with level=3, a level-3 +// SIBLING milestone survived `h.level > level` (not deeper), failed the +// version/emoji test (version-less), then failed `h.level <= 2` — so the +// function fell through to `return content.length`, sweeping the sibling +// milestone's own phases into the current one. This reproduces trek-e's +// original #612 defect verbatim (a safe degrade became a confidently-wrong +// persisted number) on a heading level the selector and bounding predicate +// both already admit. +// +// ADR-612 Decision 1 (docs/adr/612-bracket-phase-id-convention.md:56) +// specifies the discriminator as CONTENT: "a phase heading is a bracket +// followed by a digit-then-colon; a milestone heading is a bracket followed +// by a name." Fixed by replacing the `level > 2` rejection with +// BRACKET_PHASE_TAIL_RE (built from phase-id.cts's single-owner +// phaseHeadingPrefixSrcFor, not a re-typed grammar), with the level check +// widened to a depth-sanity cap of 3 (mirroring the selector's own `#{1,3}` +// ceiling — NOT a phase/milestone discriminator itself). +describe('#612 PR-2 B2 round-2: the bracket boundary is a CONTENT discriminator, not a level cap', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-b2r2-'); }); + afterEach(() => { cleanup(tmpDir); }); + + test('RED (repro2 case C): all milestones ###, all phases ####, version-less — total 2, completion scoped, percent withheld', () => { + // GSD.02 (asserted milestone) has 2 phases, both complete. isMilestoneBounded + // already returns true at #{1,3} (state.cts, unaffected by this fix), so the + // phase totals remain directly observable — pinned 4/3 (whole-disk + // fallback) before this fix. Upstream #3573 now threads the asserted v2.0 + // milestone into truthful progress scoping. Because this deliberately + // version-less roadmap cannot resolve that versioned window, percentage + // is withheld instead of manufacturing 100 from an UNSCOPED input. + const dirs = [ + ['GSD.01-01-old', true], + ['GSD.02-01-one', true], + ['GSD.02-02-two', true], + ['GSD.03-01-later', false], + ]; + writeProject(`# Roadmap + +### [GSD.01] Prior Milestone + +#### [GSD.01] 01: Old one +**Goal:** a + +### [GSD.02] Current Milestone + +#### [GSD.02] 01: One +**Goal:** b + +#### [GSD.02] 02: Two +**Goal:** c + +### [GSD.03] Later Milestone + +#### [GSD.03] 01: Later one +**Goal:** d +`, 'bracket', dirs); + assert.equal(readTotal(), 2, 'pinned 4 before this fix (whole-doc fallback)'); + const r = runGsdTools(['state', 'json'], tmpDir); + assert.ok(r.success, `state json failed: ${r.error}`); + const progress = JSON.parse(r.output).progress; + assert.equal(progress?.total_phases, 2, 'pinned 4 before this fix (whole-doc fallback)'); + assert.equal(progress?.completed_phases, 2, 'both real GSD.02 phases are complete — scoping, not the whole disk, is what this fixture pins'); + // Upstream #3573 threads STATE's stored `milestone: v2.0` into this read. + // A version-less ROADMAP cannot resolve that version, so the scope remains + // UNSCOPED and the percent is withheld even though the bracket-scoped + // totals above are correct. + assert.equal(progress?.percent, undefined, 'percent is withheld (upstream #3573 x version-less-bracket-document interaction) — disclosed, not a scoping regression'); + }); + + test('mechanism (repro7): extractCurrentMilestone actually scopes a level-3 milestone, not the whole document', () => { + const dirs = ['GSD.01-01-old', 'GSD.02-01-one', 'GSD.02-02-two', 'GSD.03-01-later']; + const roadmap = `# Roadmap + +### [GSD.01] Prior + +#### [GSD.01] 01: Old +**Goal:** a + +### [GSD.02] Current + +#### [GSD.02] 01: One +**Goal:** b + +#### [GSD.02] 02: Two +**Goal:** c + +### [GSD.03] Later + +#### [GSD.03] 01: Later +**Goal:** d +`; + writeProject(roadmap, 'bracket', dirs); + const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs'); + const scope = rp.extractCurrentMilestone( + fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'), + tmpDir, + ); + assert.ok(scope.length < roadmap.length, 'pinned scope === full document before this fix (fell through to content.length)'); + assert.ok(scope.includes('01: One') && scope.includes('02: Two'), 'the milestone\'s own phases must survive scoping'); + assert.ok(!scope.includes('Old') && !scope.includes('Later'), 'sibling milestones must not leak in'); + }); + + test('PIN: a dotted sub-phase heading ([GSD.02] 05.03:) is phase-tail-shaped, not a boundary', () => { + // The tail grammar must cover BOTH `05:` and the dotted `05.03:` forms — + // this is precisely where a regex slip in the tail discriminator would + // hide (a milestone-shaped false negative would truncate the section at + // what is actually a real sub-phase heading). + const dirs = ['GSD.02-01-one', 'GSD.02-05.03-sub', 'GSD.03-01-later']; + writeProject(`# Roadmap + +## [GSD.02] Current + +### [GSD.02] 01: One +**Goal:** a + +### [GSD.02] 05.03: Name +**Goal:** b + +## [GSD.03] Later + +### [GSD.03] 01: Later +**Goal:** c +`, 'bracket', dirs); + const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs'); + const f = rp.getMilestonePhaseFilter(tmpDir); + assert.equal(!!f('GSD.02-01-one'), true); + assert.equal(!!f('GSD.02-05.03-sub'), true, 'the dotted sub-phase heading must not be excluded as a boundary'); + assert.equal(!!f('GSD.03-01-later'), false); + assert.equal(readTotal(), 2, 'both GSD.02 phases (01 and 05.03) must be counted'); + }); + + test('PIN (repro8 case 3, content-discriminator mechanism): a trailing DIFFERENT-id icebox section still terminates — 2/1/50', () => { + // Re-pins the same shape as the B1 round-2 block above, now that the + // discriminator is CONTENT rather than level: [GSD.999] is bracket-shaped + // and NOT phase-tail-shaped ("Icebox" carries no digit-colon), and its id + // differs from the selected milestone's — a boundary either way. + const D = [['GSD.02-01-one', true], ['GSD.02-02-two', false]]; + writeProject(`# Roadmap + +## [GSD.02] v2.0: Foundation + +### [GSD.02] 01: One +**Goal:** b + +### [GSD.02] 02: Two +**Goal:** c + +## [GSD.999] Icebox + +### [GSD.999] 07: Someday +**Goal:** z +`, 'bracket', D); + assert.equal(readTotal(), 2); + }); +}); + +// ─── #2761 Blocker 3 (round-2 adversarial review): the preamble-cutoff ────── +// ─── bracket scan is now fence-aware ───────────────────────────────────────── +// +// `preambleCutoff`'s bracket branch used a raw `content.match`/`matchAll` — +// blind to fenced code blocks — while its sibling `computeSectionEnd` (a few +// lines above it) already consumed `tokenizeHeadings(content)`, which strips +// fences. A fenced markdown example in the preamble containing a bracket +// heading (ADR-612's own docs do exactly this) was textually the earliest +// `#{1,3} [CODE.MM]` match: `preambleCutoff` landed INSIDE the fence, +// `preamble = content.slice(0, preambleCutoff)` ended with an unclosed +// opener, and the unbalanced fence then blinded +// `getMilestonePhaseFilter`'s own `tokenizeHeadings(scope)` call — EVERY +// heading in the returned scope vanished, `phaseCount` degraded to 0, and the +// pass-all filter admitted every directory on disk (repro11's mechanism). +// Regression vs round-1, which had no bracket pattern to blind and so fell +// back to the correct (non-fenced) heading. +// +// Fixed by scanning the SAME `currentMilestoneHeadings` token list +// computeSectionEnd consumes, instead of a raw regex — closing the asymmetry +// between the two halves of one boundary semantic. +describe('#612 PR-2 Blocker 3 round-2: preambleCutoff is fence-aware (bracket branch only)', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-b3fence-'); }); + afterEach(() => { cleanup(tmpDir); }); + + const D = [ + ['GSD.01-01-stray', true], + ['GSD.02-01-one', true], + ['GSD.02-02-two', false], + ['GSD.03-01-stray', true], + ]; + + test('RED (repro12 bracket row): a fenced example bracket heading in the preamble no longer blinds the scan — 2/1/50', () => { + writeProject(`# Roadmap + +Authoring guide: + +\`\`\`markdown +## [GSD.00] Example milestone heading +\`\`\` + +## [GSD.02] Current + +### [GSD.02] 01: One +**Goal:** b + +### [GSD.02] 02: Two +**Goal:** c +`, 'bracket', D); + assert.equal(readTotal(), 2, 'pinned 4 before this fix (fence-blind scan degraded phaseCount to 0)'); + }); + + test('mechanism (repro11): the returned scope has an EVEN, balanced fence count and a non-degraded phaseCount', () => { + const roadmap = `# Roadmap + +Docs for authors: + +\`\`\`markdown +## [GSD.00] Example milestone heading +### [GSD.00] 01: Example phase +\`\`\` + +## [GSD.02] Current + +### [GSD.02] 01: One +**Goal:** b + +### [GSD.02] 02: Two +**Goal:** c + +## [GSD.03] Later + +### [GSD.03] 01: Later +**Goal:** d +`; + const dirs = ['GSD.01-01-old', 'GSD.02-01-one', 'GSD.02-02-two', 'GSD.03-01-later']; + writeProject(roadmap, 'bracket', dirs); + const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs'); + const ms = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); + const scope = rp.extractCurrentMilestone( + fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'), + tmpDir, + ); + const fenceCount = (scope.match(/^```/gm) || []).length; + assert.equal(fenceCount % 2, 0, 'pinned an ODD (unbalanced) fence count before this fix'); + const headings = ms.tokenizeHeadings(scope).map((h) => h.text); + assert.ok(headings.some((t) => /GSD\.02.*01: One/.test(t)), 'real headings must survive tokenization, not collapse to just "Roadmap"'); + const f = rp.getMilestonePhaseFilter(tmpDir); + assert.notEqual(f.phaseCount, 0, 'pinned 0 (pass-all degrade) before this fix'); + assert.deepEqual( + Object.fromEntries(dirs.map((d) => [d, !!f(d)])), + { 'GSD.01-01-old': false, 'GSD.02-01-one': true, 'GSD.02-02-two': true, 'GSD.03-01-later': false }, + 'pinned every directory admitted (pass-all) before this fix', + ); + }); + + test('UPSTREAM COMPOSITION (repro12 LEGACY control): #3573 scopes the fenced-example fixture to total 2', () => { + // This bracket-only fix still does not widen the LEGACY + // `anyMilestonePattern` raw-match path. Upstream's newer state derivation, + // however, routes this read through fence-aware `sliceMilestoneWindow` + // using STATE's stored milestone. The fixture therefore no longer reaches + // the blind path and must pin the improved scoped result, not the old + // whole-document total of 4. + writeProject(`# Roadmap + +Authoring guide: + +\`\`\`markdown +## Milestone v9.0: Example +\`\`\` + +## Milestone v2.0: Current + +### Phase 01: One +**Goal:** b + +### Phase 02: Two +**Goal:** c +`, undefined, [['03-stray', true], ['01-one', true], ['02-two', false], ['04-stray', true]]); + assert.equal(readTotal(), 2, 'upstream #3573 now scopes this read via sliceMilestoneWindow instead of the fence-blind anyMilestonePattern path'); + }); + + test('PIN (repro10 A3): a fenced heading INSIDE the current section still must not terminate it', () => { + const dirs = ['GSD.01-01-old', 'GSD.02-01-one', 'GSD.02-02-two', 'GSD.03-01-later']; + writeProject(`# Roadmap + +## [GSD.02] Current + +### [GSD.02] 01: One +**Goal:** b + +\`\`\`markdown +## [GSD.03] Not a real heading +\`\`\` + +### [GSD.02] 02: Two +**Goal:** c + +## [GSD.03] Later + +### [GSD.03] 01: Later +**Goal:** d +`, 'bracket', dirs); + assert.equal(readTotal(), 2); + }); +}); + +// ─── #2761 B3 (self-caught, round-2 verification): CURRENT version-bearing, ── +// ─── a sibling milestone is not ────────────────────────────────────────────── +// +// The B1 fix (commit 08d5b0c4) resolved `bracketScopeConvention` only inside +// the `if (headingMatches.length === 0)` gate that also drives SELECTION's own +// bracket fallback. That gate is correct for SELECTION (only try the bracket +// heading shape when the version-string match found nothing), but +// `bracketScopeConvention` also feeds `computeSectionEnd`'s and +// `preambleCutoff`'s boundary-detection — which accidentally inherited +// SELECTION's gate instead of having its own. +// +// Trigger shape: the CURRENT milestone heading is ITSELF version-bearing +// (`## [GSD.02] v2.0: Current Milestone`), so the primary version-string +// `sectionPattern` matches it directly — `headingMatches.length !== 0` from the +// very first check — and the entire bracket-resolution branch is skipped. A +// sibling milestone (PRIOR or LATER) that is version-less then gets NEITHER +// the version/emoji boundary rule (it has no version) NOR the bracket boundary +// rule (never resolved) — reproducing the exact #612 defect symptom +// (total_phases falls back to the whole-disk count) through a structural +// shape B1's own fixtures never exercised (every B1 fixture is uniformly +// version-bearing or uniformly version-less across all three milestones). +describe('#612 PR-2 B3: bracket boundaries engage even when CURRENT is version-bearing but a sibling is not', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-b3-'); }); + afterEach(() => { cleanup(tmpDir); }); + + const ROADMAP = `# Roadmap + +## [GSD.01] Prior Milestone + +### [GSD.01] 01: Old one +**Goal:** a + +## [GSD.02] v2.0: Current Milestone + +### [GSD.02] 01: One +**Goal:** b + +### [GSD.02] 02: Two +**Goal:** c + +## [GSD.03] Later Milestone + +### [GSD.03] 01: Later one +**Goal:** d +`; + const DIRS = ['GSD.01-01-old-one', 'GSD.02-01-one', 'GSD.02-02-two', 'GSD.03-01-later-one']; + + const acceptsFor = (dirs) => { + const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs'); + const f = rp.getMilestonePhaseFilter(tmpDir); + return Object.fromEntries(dirs.map((d) => [d, !!f(d)])); + }; + + test('CURRENT version-bearing + both siblings version-less: scoping works in both directions', () => { + writeProject(ROADMAP, 'bracket', DIRS); + assert.deepEqual(acceptsFor(DIRS), { + 'GSD.01-01-old-one': false, + 'GSD.02-01-one': true, + 'GSD.02-02-two': true, + 'GSD.03-01-later-one': false, + }); + }); + + test('the PRIOR (version-less) milestone does not leak into the preamble (preambleCutoff twin)', () => { + writeProject(ROADMAP, 'bracket', DIRS); + assert.equal(acceptsFor(DIRS)['GSD.01-01-old-one'], false); + }); + + test('the LATER (version-less) milestone does not leak into scope (computeSectionEnd twin)', () => { + writeProject(ROADMAP, 'bracket', DIRS); + assert.equal(acceptsFor(DIRS)['GSD.03-01-later-one'], false); + }); + + test('total_phases counts only the asserted milestone, not the whole disk', () => { + writeProject(ROADMAP, 'bracket', DIRS); + // 4 directories on disk, 2 phases in the milestone STATE.md asserts. + assert.equal(readTotal(), 2); + }); +}); + +// ─── #2761 Blocker 1 (round-3 adversarial re-verify): the preambleCutoff ──── +// ─── scan drops current-milestone content whenever ANY bracket-shaped ────── +// ─── heading precedes the selected milestone heading ──────────────────────── +// +// The round-2 fix (39c42a89) threaded `selectedBracketId` as the REAL value +// at computeSectionEnd but as bare `null` at the preambleCutoff scan — the +// deviation's own rationale ("the selected heading's own occurrence is +// always the correct earliest answer") was right, but `null` disables the +// same-milestone check for EVERY heading, not just the selected one. Any +// bracket-shaped heading earlier than the selected milestone — same id +// (cases A, B) or a DIFFERENT id with no children of its own (case D) — was +// wrongly accepted as the earliest boundary, and the region between it and +// the real `sectionStart` was silently dropped: a completed phase vanished +// and `state sync` persisted a confident 0% where base and round-1 both +// correctly wrote 50%. +// +// Fixed with two changes, both scoped to the preambleCutoff scan only +// (computeSectionEnd is untouched — it already threads the real +// `selectedBracketId`): +// (a) `h.offset === sectionStart` bypasses BOTH the same-milestone check +// and the same-id-child rule below — the selected heading's own +// position is definitionally correct, and this is the one case where +// neither discriminator should even run (rejecting it would be +// rejecting the heading against ITSELF). +// (b) every OTHER bracket-shaped, non-phase-tail-shaped candidate must +// additionally have a matching-id CHILD (the next strictly-deeper +// heading beneath it) to count as a boundary — `bracketHeadingHasMatchingChild`. +// This is what distinguishes a genuine sibling milestone (whose own +// phase children carry ITS bracket id) from an unrelated bracket-shaped +// PROSE heading like `## [ADR.612] Heading convention used by this +// roadmap` sitting above the current milestone's own content. +describe('#612 PR-2 Blocker 1 round-3: preambleCutoff identity is offset- and child-aware', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-b1r3-'); }); + afterEach(() => { cleanup(tmpDir); }); + + const D = [['GSD.02-01-one', true], ['GSD.02-02-two', false]]; + + function poisonTotalPhases() { + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + const raw = fs.readFileSync(statePath, 'utf-8'); + fs.writeFileSync(statePath, raw.replace(/^---\r?\n/, '---\ntotal_phases: 999\n'), 'utf-8'); + } + + test('RED case A (rv-attack1): a same-milestone version-LESS heading earlier than the version-bearing selected heading — 2/1/50, not 1/0/0', () => { + writeProject(`# Roadmap + +## [GSD.02] Foundation + +- [ ] **[GSD.02] 01: One** +- [ ] **[GSD.02] 02: Two** + +### [GSD.02] 01: One +**Goal:** a + +## [GSD.02] v2.0: Foundation (Phase Details) + +### [GSD.02] 02: Two +**Goal:** b +`, 'bracket', D); + assert.equal(readTotal(), 2, 'pinned 1 before this fix — the earlier checklist heading dropped everything before sectionStart'); + poisonTotalPhases(); + assert.equal(syncedTotal(), 2); + assert.equal(syncedPercent(), 50, 'pinned 0 before this fix — a confidently wrong persisted 0%'); + }); + + test('RED case B (rv-attack1): a same-milestone OVERVIEW heading (no "(Phase Details)" spelling) earlier than the version-bearing selected heading — 2/1/50, not 1/0/0', () => { + writeProject(`# Roadmap + +## [GSD.02] Foundation (overview) + +### [GSD.02] 01: One +**Goal:** a + +## [GSD.02] v2.0: Foundation + +### [GSD.02] 02: Two +**Goal:** b +`, 'bracket', D); + assert.equal(readTotal(), 2, 'pinned 1 before this fix'); + poisonTotalPhases(); + assert.equal(syncedTotal(), 2); + assert.equal(syncedPercent(), 50, 'pinned 0 before this fix'); + }); + + test('RED case D (rv-attack1b): a DIFFERENT-id bracket-shaped PROSE heading before the selected milestone — 2/1/50, not 1/0/0', () => { + writeProject(`# Roadmap + +## [ADR.612] Heading convention used by this roadmap + +Phases are listed under their milestone. + +### [GSD.02] 01: One +**Goal:** a + +## [GSD.02] v2.0: Foundation + +### [GSD.02] 02: Two +**Goal:** b +`, 'bracket', D); + assert.equal(readTotal(), 2, 'pinned 1 before this fix — [ADR.612] read as a genuine boundary with no child-id check'); + poisonTotalPhases(); + assert.equal(syncedTotal(), 2); + assert.equal(syncedPercent(), 50, 'pinned 0 before this fix'); + }); + + test('PIN: a genuine prior sibling milestone (real children sharing its own id) is still excluded from the preamble', () => { + const dirs = ['GSD.01-01-old-one', 'GSD.02-01-one', 'GSD.02-02-two']; + writeProject(`# Roadmap + +## [GSD.01] Prior Milestone + +### [GSD.01] 01: Old one +**Goal:** a + +## [GSD.02] v2.0: Foundation + +### [GSD.02] 01: One +**Goal:** b + +### [GSD.02] 02: Two +**Goal:** c +`, 'bracket', dirs.map((d, i) => [d, i !== 2])); + const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs'); + const f = rp.getMilestonePhaseFilter(tmpDir); + assert.equal(!!f('GSD.01-01-old-one'), false, 'the prior milestone must still be excluded — the child rule must not re-admit a genuine sibling'); + assert.equal(readTotal(), 2); + }); + + test('PIN: a CHILDLESS prior sibling milestone (no phases of its own) degrades to NOT cutting the preamble — over-inclusive, safe', () => { + // "## [GSD.01] Empty Prior Milestone" has no deeper heading before the + // next heading at its own level — the child rule rejects it as a + // boundary, so its own heading TEXT stays in the preamble. That text is + // not phase-shaped (no digit-colon), so it contributes nothing to any + // count — over-inclusive, not under-inclusive, the safe direction. + writeProject(`# Roadmap + +## [GSD.01] Empty Prior Milestone + +## [GSD.02] v2.0: Current + +### [GSD.02] 01: One +**Goal:** a + +### [GSD.02] 02: Two +**Goal:** b +`, 'bracket', D); + assert.equal(readTotal(), 2, 'the current milestone\'s own two phases must still be counted, unpolluted by the childless sibling'); + poisonTotalPhases(); + assert.equal(syncedPercent(), 50); + }); + + test('Nit 2 PIN: a colon-less bracket heading ("[GSD.02] 05" with no trailing colon) does not spuriously terminate the preamble', () => { + // A colon-less bracket heading is bracket-shaped but NOT phase-tail-shaped + // (BRACKET_PHASE_TAIL_RE requires the trailing colon), so + // isBracketMilestoneBoundary alone would read it as a boundary. It is + // also — precisely because it is malformed/incomplete rather than a real + // milestone — childless (nothing deeper follows it before the next + // same-or-shallower heading), so the child rule neutralizes it here. + writeProject(`# Roadmap + +### [GSD.02] 05 + +## [GSD.02] v2.0: Current + +### [GSD.02] 01: One +**Goal:** a + +### [GSD.02] 02: Two +**Goal:** b +`, 'bracket', D); + assert.equal(readTotal(), 2); + }); + + test('mechanism (rv-mech1): extractCurrentMilestone now scopes the FULL document for case A, phaseCount reflects both real phases', () => { + const roadmap = `# Roadmap + +## [GSD.02] Foundation + +- [ ] **[GSD.02] 01: One** +- [ ] **[GSD.02] 02: Two** + +### [GSD.02] 01: One +**Goal:** a + +## [GSD.02] v2.0: Foundation (Phase Details) + +### [GSD.02] 02: Two +**Goal:** b +`; + writeProject(roadmap, 'bracket', D); + const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs'); + const scope = rp.extractCurrentMilestone( + fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'), + tmpDir, + ); + assert.equal(scope, roadmap, 'pinned a truncated scope (bytes [11,124) dropped) before this fix'); + const f = rp.getMilestonePhaseFilter(tmpDir); + assert.equal(f.phaseCount, 2, 'pinned phaseCount 1 before this fix'); + assert.equal(!!f('GSD.02-01-one'), true, 'pinned false before this fix — a real dir of the CURRENT milestone'); + assert.equal(!!f('GSD.02-02-two'), true); + }); +}); + +// ─── #2761 Major 1 (round-3 adversarial re-verify): the version/emoji half ── +// ─── of preambleCutoff's min() is now fence-aware too, on the bracket ─────── +// ─── branch only ───────────────────────────────────────────────────────────── +// +// ff6bf0a8 (round-2 Blocker 3) made the BRACKET half of preambleCutoff's +// "earliest milestone-shaped heading" search fence-aware, but left the +// VERSION/emoji half a raw `content.match` even on the bracket branch. A +// fenced VERSION-BEARING example heading in a bracket repo's preamble +// (ADR-612's own docs illustrate the LEGACY heading shape this way, inside a +// fenced authoring-guide block) was still textually the earliest match for +// that raw regex, winning the min() and formerly un-suppressing a wrong +// persisted 75%. On the rebased base, #3217 keeps the unsafe percentage +// suppressed explicitly when the version-less milestone cannot yield a +// COMPLETE version override. +describe('#612 PR-2 Major 1 round-3: preambleCutoff\'s version/emoji half is fence-aware on the bracket branch', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-m1r3-'); }); + afterEach(() => { cleanup(tmpDir); }); + + const D = [ + ['GSD.02-01-one', true], ['GSD.02-02-two', false], + ['GSD.03-01-x', true], ['GSD.03-02-y', true], + ]; + + test('RED case C1 (rv-attack3c): a fenced VERSION-bearing example does not inflate the total; unsafe percent stays withheld', () => { + writeProject(`# Roadmap + +Authoring guide — a milestone heading looks like: + +\`\`\`markdown +## Milestone v9.0: Example +\`\`\` + +## [GSD.02] Current + +### [GSD.02] 01: One +### [GSD.02] 02: Two + +## [GSD.03] Later + +### [GSD.03] 01: X +### [GSD.03] 02: Y +`, 'bracket', D); + assert.equal(readTotal(), 2, 'pinned 4 before this fix — the fenced VERSION heading won the min() and swallowed everything before it into the preamble unstripped'); + poisonTotalPhases(); + assert.match(syncSkipReason(), /scope is "unscoped", not COMPLETE \(#3217\)/, + 'upstream must keep the unsafe percentage withheld rather than resurface the old 75%'); + }); + + test('PIN case C2: a fenced BRACKET-shaped heading in the same position (round-2\'s own fix target) stays unchanged at 2/1/50', () => { + writeProject(`# Roadmap + +Authoring guide — a milestone heading looks like: + +\`\`\`markdown +## [GSD.00] Example +\`\`\` + +## [GSD.02] Current + +### [GSD.02] 01: One +### [GSD.02] 02: Two + +## [GSD.03] Later + +### [GSD.03] 01: X +### [GSD.03] 02: Y +`, 'bracket', D); + assert.equal(readTotal(), 2); + }); + + function poisonTotalPhases() { + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + const raw = fs.readFileSync(statePath, 'utf-8'); + fs.writeFileSync(statePath, raw.replace(/^---\r?\n/, '---\ntotal_phases: 999\n'), 'utf-8'); + } +}); + +// ─── #2761 round-3 hardening (team-lead review of f87bba0e): two edges in ── +// ─── the new preambleCutoff code ──────────────────────────────────────────── +// +// AMENDMENT 1 — bracketHeadingHasMatchingChild originally checked only +// `headings[index + 1]` (the IMMEDIATE next heading), not the candidate's +// whole subtree. A genuine prior sibling milestone whose section opens with +// a non-bracket subsection before its first phase (`## [GSD.01] Setup` / +// `### Notes` / `### [GSD.01] 01: Old`) was therefore wrongly rejected as a +// boundary — its own real phase heading is TWO headings deep, not one — and +// its entire section leaked into the preamble unstripped. +// +// CONFIRMED RED at f87bba0e before this fix (per the team lead's request to +// check observability, not just theory): the leak is NOT merely inert — +// `GSD.01-01-old`'s directory was wrongly admitted into the CURRENT +// milestone's filter via the leaked heading's qualified key +// (`GSD.01-01`), reading **3/2/67%** where truth is **2/1/50%**. Scope +// membership DOES drive the disk-side filter on this shape. Fixed by +// scanning the candidate's full SUBTREE (continue past a non-matching +// deeper heading instead of returning false immediately; only a +// same-or-shallower heading actually closes the subtree). +// +// AMENDMENT 2 — the round-3 Major 1 fix (c483552a) ported the version/emoji +// half of preambleCutoff to the token-based scan with no level cap; the raw +// `content.match(anyMilestonePattern)` it replaced was anchored `^#{1,3}\s+`. +// A level-4+ version-bearing heading in the preamble (`#### v2.0 notes`) +// therefore won the scan on the bracket branch where the raw pattern (and +// the legacy path, unaffected) ignores it outright — a heading neither the +// selector nor `isMilestoneBounded` would ever treat as a milestone marker. +// Fixed with `if (h.level > 3) continue;`, mirroring the depth-sanity cap +// `isBracketMilestoneBoundary` already applies to the bracket half. +describe('#612 PR-2 round-3 hardening: subtree child scan + level cap on preambleCutoff', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-r3h-'); }); + afterEach(() => { cleanup(tmpDir); }); + + test('RED (rv2-amend1): a genuine prior sibling with an intervening non-bracket subsection is still excluded — 2/1/50, not 3/2/67', () => { + const dirs = [['GSD.01-01-old', true], ['GSD.02-01-one', true], ['GSD.02-02-two', false]]; + writeProject(`# Roadmap + +## [GSD.01] Setup + +### Notes + +Some prose about the prior milestone. + +### [GSD.01] 01: Old + +## [GSD.02] v2.0: Current + +### [GSD.02] 01: One + +### [GSD.02] 02: Two +`, 'bracket', dirs); + assert.equal(readTotal(), 2, 'pinned 3 before this fix — the immediate-next-heading-only check rejected [GSD.01] Setup as a boundary because its FIRST child (### Notes) is not bracket-shaped, even though its SECOND child (### [GSD.01] 01: Old) is'); + const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs'); + const f = rp.getMilestonePhaseFilter(tmpDir); + assert.equal(!!f('GSD.01-01-old'), false, 'pinned true before this fix — the leaked heading\'s qualified key wrongly admitted the prior milestone\'s own directory'); + assert.equal(!!f('GSD.02-01-one'), true); + assert.equal(!!f('GSD.02-02-two'), true); + }); + + test('PIN (rv2-amend2): a level-4 version-bearing preamble heading is NOT a cutoff on the bracket branch — the preamble text survives unstripped', () => { + const roadmap = `# Roadmap + +#### v2.0 notes + +Some prose that happens to mention v2.0 in a deep heading. + +## [GSD.02] Current + +### [GSD.02] 01: One + +### [GSD.02] 02: Two +`; + writeProject(roadmap, 'bracket', [['GSD.02-01-one', true], ['GSD.02-02-two', false]]); + const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs'); + const scope = rp.extractCurrentMilestone( + fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'), + tmpDir, + ); + assert.ok(scope.includes('v2.0 notes'), 'pinned dropped before this fix — the level-4 heading wrongly won the earliest-of-either scan and truncated the preamble at itself'); + assert.equal(readTotal(), 2); + }); + + test('PIN: the LEGACY control for the level-4 preamble heading is unchanged (raw content.match path untouched)', () => { + writeProject(`# Roadmap + +#### v2.0 notes + +Some prose that happens to mention v2.0 in a deep heading. + +## Milestone v2.0: Current + +### Phase 01: One + +### Phase 02: Two +`, undefined, [['01-one', true], ['02-two', false]]); + assert.equal(readTotal(), 2); + }); +}); + +// ─── #2761 round-4 (team-lead re-verify of fbfd0fca): the child rule must ── +// ─── require a same-id PHASE, not merely a same-id heading ────────────────── +// +// bracketHeadingHasMatchingChild's subtree scan (fbfd0fca) proved SAME-ID-NESS +// but never asked whether the matching child was PHASE-shaped. Case F1 +// reopens round-3's case D one heading later: `## [ADR.612] Heading +// convention` is followed by its OWN sub-heading `### [ADR.612] Examples` — +// same bracket id as the candidate, but MILESTONE-shaped (a name, no +// digit-then-colon), not a phase. Same-id-ness alone satisfied the subtree +// scan, re-cutting the preamble at exactly the shape the round-3 hardening +// was written to close. +function poisonTotalPhasesR4(dir) { + const statePath = path.join(dir, '.planning', 'STATE.md'); + const raw = fs.readFileSync(statePath, 'utf-8'); + fs.writeFileSync(statePath, raw.replace(/^---\r?\n/, '---\ntotal_phases: 999\n'), 'utf-8'); +} + +describe('#612 PR-2 round-4 Blocker 1: the same-id child must be PHASE-shaped', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-r4b1-'); }); + afterEach(() => { cleanup(tmpDir); }); + + test('RED (F1): a same-id MILESTONE-shaped child on a prose heading no longer satisfies the child rule — 2/1/50, not 1/0/0', () => { + writeProject(`# Roadmap + +## [ADR.612] Heading convention + +### [ADR.612] Examples + +Prose about the convention. + +### [GSD.02] 01: One +**Goal:** a + +## [GSD.02] v2.0: Foundation + +### [GSD.02] 02: Two +**Goal:** b +`, 'bracket', [['GSD.02-01-one', true], ['GSD.02-02-two', false]]); + assert.equal(readTotal(), 2, 'pinned 1 before this fix — [ADR.612]\'s own MILESTONE-shaped sub-heading satisfied the same-id-only rule'); + poisonTotalPhasesR4(tmpDir); + assert.equal(syncedTotal(), 2); + assert.equal(syncedPercent(), 50, 'pinned 0 before this fix — state sync reported "nothing to do" because its wrong 0% happened to equal the seed'); + }); + + test('PIN (F11): a genuine prior sibling whose same-id child lacks the trailing colon is still correctly excluded (inert leak, not a boundary either way)', () => { + const dirs = [['GSD.01-01-old', true], ['GSD.02-01-one', true], ['GSD.02-02-two', false]]; + writeProject(`# Roadmap + +## [GSD.01] Setup + +### [GSD.01] 01 Old + +## [GSD.02] v2.0: Current + +### [GSD.02] 01: One +**Goal:** a + +### [GSD.02] 02: Two +**Goal:** b +`, 'bracket', dirs); + assert.equal(readTotal(), 2); + const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs'); + const f = rp.getMilestonePhaseFilter(tmpDir); + assert.equal(!!f('GSD.01-01-old'), false, 'the colon-less heading forms no qualified key either, so the childless-degrade leak is inert'); + }); + + test('PIN (F11b): a genuine prior sibling whose phases exist only as bullets is still correctly excluded', () => { + const dirs = [['GSD.01-01-old', true], ['GSD.02-01-one', true], ['GSD.02-02-two', false]]; + writeProject(`# Roadmap + +## [GSD.01] Setup + +- [x] **[GSD.01] 01: Old** + +### [GSD.01] Retrospective + +## [GSD.02] v2.0: Current + +### [GSD.02] 01: One +**Goal:** a + +### [GSD.02] 02: Two +**Goal:** b +`, 'bracket', dirs); + assert.equal(readTotal(), 2); + const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs'); + const f = rp.getMilestonePhaseFilter(tmpDir); + assert.equal(!!f('GSD.01-01-old'), false, 'a bracket bullet never forms a qualified key (BULLET_PHASE_LINE_PATTERN needs a literal **Phase )'); + }); + + test('re-verify: the four existing child-rule pins (F2-F5 shapes) are unaffected by the phase-shape requirement', () => { + // F2: subtree closure — same-id child appears only after a same-level + // heading closes the subtree; must not count (over-inclusive degrade). + writeProject(`# Roadmap + +## [GSD.01] Setup + +Prose, no children. + +## [DOC.99] Reference + +### [GSD.01] 01: Old + +### [GSD.02] 01: One +**Goal:** a + +## [GSD.02] v2.0: Current + +### [GSD.02] 02: Two +**Goal:** b +`, 'bracket', [['GSD.01-01-old', true], ['GSD.02-01-one', true], ['GSD.02-02-two', false]]); + assert.equal(readTotal(), 3, 'unchanged — the declared over-inclusive/safe degrade (Minor 1), not a regression'); + + // F3: deep wrong-id children then a same-id PHASE child inside the subtree — must count. + writeProject(`# Roadmap + +## [GSD.01] Setup + +### Notes + +#### Deep note + +### [DOC.99] Aside + +### [GSD.01] 01: Old + +## [GSD.02] v2.0: Current + +### [GSD.02] 01: One +**Goal:** a + +### [GSD.02] 02: Two +**Goal:** b +`, 'bracket', [['GSD.01-01-old', true], ['GSD.02-01-one', true], ['GSD.02-02-two', false]]); + assert.equal(readTotal(), 2); + + // F4: the same-id PHASE child is itself level 4 — the child scan has no depth ceiling. + writeProject(`# Roadmap + +## [GSD.01] Setup + +### Notes + +#### [GSD.01] 01: Old + +## [GSD.02] v2.0: Current + +### [GSD.02] 01: One +**Goal:** a + +### [GSD.02] 02: Two +**Goal:** b +`, 'bracket', [['GSD.01-01-old', true], ['GSD.02-01-one', true], ['GSD.02-02-two', false]]); + assert.equal(readTotal(), 2); + + // F5: trailing bracket sibling at document end (childless) — computeSectionEnd + // excludes it without needing the child rule at all. + writeProject(`# Roadmap + +## [GSD.02] v2.0: Current + +### [GSD.02] 01: One +**Goal:** a + +### [GSD.02] 02: Two +**Goal:** b + +## [GSD.03] Later +`, 'bracket', [['GSD.02-01-one', true], ['GSD.02-02-two', false], ['GSD.03-01-later', true]]); + assert.equal(readTotal(), 2); + }); +}); + +// ─── #2761 round-4 Major 1: the four fence-blind sites known at round 4 ──── +// ─── on the bracket path (a fifth — the retirement scan — was found at ──── +// ─── round 5 and has its own block further below) ────────────────────────── +// +// The scope string extractCurrentMilestone returns is fence-BALANCED and +// fence-STRIPPED-by-tokenizeHeadings only when its CONSUMERS ask it that way. +// Four sites still read it (or the raw ROADMAP) with a plain regex `.exec`, +// blind to fences: +// +// (a) roadmapPhaseCount — TWO independent copies, buildStateFrontmatter +// (read path) and cmdStateSync (write path). A fenced EXAMPLE phase +// heading in the preamble inflated total_phases (F10); on a +// version-less roadmap the SAME fence-blindness compounds with (c) +// below (F9). +// (b) isMilestoneBounded — a raw `.test(roadmapRaw)`. A fenced-ONLY bracket +// heading (no real section for the asserted milestone at all) wrongly +// BOUNDED a milestone that isn't in the roadmap, un-suppressing a +// percent that should stay suppressed (F12). +// (c) the bracket-fallback SELECTOR (only reachable when the version-string +// selection found nothing) — a raw `content.matchAll`. A fenced example +// sharing the CURRENT project's own bracket id could be SELECTED as the +// current milestone, landing `sectionStart` inside a fence (F9). +// +// Fixed by extracting ONE shared counter (countRoadmapPhaseHeadings, in +// src/state.cts, immediately above buildStateFrontmatter) used by both (a) +// copies, and converting (b) and (c) to tokenizeHeadings-based scans. The +// PRODUCER (extractCurrentMilestone's returned scope string) is deliberately +// UNCHANGED — every other consumer of that string needs its full content +// fidelity, and legacy identity forbids touching the shared string. Legacy +// (non-bracket) behavior at all four sites is byte-identical; this is the +// ONLY commit in this arc that touches SELECTION. +describe('#612 PR-2 round-4 Major 1: the four fence-blind sites known at round 4 (a fifth was found at round 5, see its own block below)', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-r4m1-'); }); + afterEach(() => { cleanup(tmpDir); }); + + const D2 = [['GSD.02-01-one', true], ['GSD.02-02-two', false]]; + + function poisonTotalPhasesR4M1() { + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + const raw = fs.readFileSync(statePath, 'utf-8'); + fs.writeFileSync(statePath, raw.replace(/^---\r?\n/, '---\ntotal_phases: 999\n'), 'utf-8'); + } + + test('RED (F10): a fenced SAME-id PHASE heading in the preamble no longer inflates roadmapPhaseCount — 2/1/50, not 3/1/33', () => { + writeProject(`# Roadmap + +Authoring guide: + +\`\`\`markdown +### [GSD.02] 05: Example phase +\`\`\` + +## [GSD.02] v2.0: Foundation + +### [GSD.02] 01: One +**Goal:** a + +### [GSD.02] 02: Two +**Goal:** b +`, 'bracket', D2); + assert.equal(readTotal(), 2, 'pinned 3 before this fix — the fenced example phase heading was counted by the fence-blind raw .exec()'); + poisonTotalPhasesR4M1(); + assert.equal(syncedTotal(), 2); + assert.equal(syncedPercent(), 50, 'pinned 33 before this fix'); + }); + + test('PIN (F10 LEGACY control): the same fenced-phase shape on a non-bracket repo is unchanged (correct on every build)', () => { + writeProject(`# Roadmap + +Authoring guide: + +\`\`\`markdown +### Phase 05: Example phase +\`\`\` + +## Milestone v2.0: Foundation + +### Phase 01: One +**Goal:** a + +### Phase 02: Two +**Goal:** b +`, undefined, [['01-one', true], ['02-two', false]]); + assert.equal(readTotal(), 2); + }); + + test('PIN (F10c): same document, fenced line is NOT phase-shaped — unaffected either way', () => { + writeProject(`# Roadmap + +Authoring guide: + +\`\`\`markdown +### [GSD.02] Example section +\`\`\` + +## [GSD.02] v2.0: Foundation + +### [GSD.02] 01: One +**Goal:** a + +### [GSD.02] 02: Two +**Goal:** b +`, 'bracket', D2); + assert.equal(readTotal(), 2); + }); + + test('RED (F9): version-LESS roadmap + a fenced example sharing the SAME milestone id — both the selector and the counter must be fence-aware — 2/1/50, not 3/1/33', () => { + writeProject(`# Roadmap + +Authoring guide: + +\`\`\`markdown +## [GSD.02] Example milestone heading +### [GSD.02] 05: Example phase +\`\`\` + +## [GSD.02] Foundation + +### [GSD.02] 01: One +**Goal:** a + +### [GSD.02] 02: Two +**Goal:** b +`, 'bracket', D2); + assert.equal(readTotal(), 2, 'pinned 3 before this fix'); + poisonTotalPhasesR4M1(); + assert.equal(syncedTotal(), 2); + // The roadmap is version-less while STATE pins v2.0, so upstream #3217 + // withholds the percentage rather than treating the scope as complete. + assert.match(syncSkipReason(), /scope is "unscoped", not COMPLETE \(#3217\)/); + }); + + test('RED (F12): a fenced-ONLY bracket heading no longer bounds a milestone absent from the roadmap — percent stays suppressed', () => { + const dirs = [['GSD.01-01-old', true], ['GSD.02-01-one', true], ['GSD.02-02-two', false]]; + writeProject(`# Roadmap + +Authoring guide: + +\`\`\`markdown +## [GSD.02] Example milestone heading +\`\`\` + +## [GSD.01] v1.0: Prior + +### [GSD.01] 01: Old +**Goal:** a +`, 'bracket', dirs); + const r = runGsdTools(['state', 'json'], tmpDir); + assert.ok(r.success, `state json failed: ${r.error}`); + const progress = JSON.parse(r.output).progress; + assert.equal(progress?.percent, undefined, 'pinned 67 before this fix — a fenced-only [GSD.02] example wrongly bounded a milestone with no real section'); + // state sync must not persist a percent either — the body stays at its seed. + const syncResult = runGsdTools(['state', 'sync'], tmpDir); + assert.ok(syncResult.success); + const raw = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + assert.match(raw, /\*\*Progress:\*\*[^\r\n]*?0%/, 'pinned 67% persisted before this fix'); + }); + + test('PIN: unfenced bracket-fallback selection is byte-identical — first real milestone-shaped heading still wins', () => { + // No version anywhere, no fences — the selector's plain first-match-wins + // behavior over REAL headings must be completely unaffected by routing it + // through tokenizeHeadings. + const dirs = ['GSD.01-01-old', 'GSD.02-01-one', 'GSD.02-02-two', 'GSD.03-01-later']; + writeProject(`# Roadmap + +## [GSD.01] Prior Milestone + +### [GSD.01] 01: Old +**Goal:** a + +## [GSD.02] Current Milestone + +### [GSD.02] 01: One +**Goal:** b + +### [GSD.02] 02: Two +**Goal:** c + +## [GSD.03] Later Milestone + +### [GSD.03] 01: Later +**Goal:** d +`, 'bracket', dirs); + const rp = require('../gsd-core/bin/lib/roadmap-parser.cjs'); + const f = rp.getMilestonePhaseFilter(tmpDir); + assert.deepEqual(Object.fromEntries(dirs.map((d) => [d, !!f(d)])), { + 'GSD.01-01-old': false, + 'GSD.02-01-one': true, + 'GSD.02-02-two': true, + 'GSD.03-01-later': false, + }); + assert.equal(readTotal(), 2); + }); +}); + +// ─── #2761 round-5 Blocker 1: 3be5c412's merge dropped the `bracketId &&` ─── +// ─── guard countRoadmapPhaseHeadings' bracket branch needs ────────────────── +// +// Every other isSentinelPhaseId call site in src/ (roadmap-parser.cts, +// roadmap.cts, validate.cts ×2, verify.cts) guards the call with +// `bracketId &&`. The shared counter's bracket branch omitted it: when the +// LEGACY alternative of the intro grammar matches (a `### Phase 00:` heading +// in a `phase_id_convention: "bracket"` repo — the mid-migration shape this +// PR exists for), `m[1]` (bracketId) is `undefined`, and the call becomes +// `isSentinelPhaseId("undefined-00", 'bracket')` — which is TRUE, silently +// dropping a real phase from the denominator. `getMilestonePhaseFilter` +// still counts it and admits its directory, so the filter and the counter +// disagree — a half-done milestone reads as 100% complete. +describe('#612 PR-2 round-5 Blocker 1: countRoadmapPhaseHeadings restores the bracketId guard', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-r5b1-'); }); + afterEach(() => { cleanup(tmpDir); }); + + function poisonTotalPhasesR5(dir) { + const statePath = path.join(dir, '.planning', 'STATE.md'); + const raw = fs.readFileSync(statePath, 'utf-8'); + fs.writeFileSync(statePath, raw.replace(/^---\r?\n/, '---\ntotal_phases: 999\n'), 'utf-8'); + } + + test('RED (G3): a legacy `### Phase 00:` heading in a bracket repo is no longer dropped — 3/2/67, not 2/2/100', () => { + writeProject(`# Roadmap + +## Milestone v2.0: Foundation + +### Phase 00: Bootstrap +**Goal:** z + +### Phase 01: One +**Goal:** a + +### Phase 02: Two +**Goal:** b +`, 'bracket', [['00-bootstrap', true], ['01-one', true]]); + assert.equal(readTotal(), 3, 'pinned 2 before this fix — "undefined-00" read as a sentinel'); + poisonTotalPhasesR5(tmpDir); + assert.equal(syncedTotal(), 3); + assert.equal(syncedPercent(), 67, 'pinned 100 before this fix — a half-done milestone read as shipped'); + }); + + test('PIN (G3 LEGACY control): the identical document under the legacy convention is unaffected', () => { + writeProject(`# Roadmap + +## Milestone v2.0: Foundation + +### Phase 00: Bootstrap +**Goal:** z + +### Phase 01: One +**Goal:** a + +### Phase 02: Two +**Goal:** b +`, undefined, [['00-bootstrap', true], ['01-one', true]]); + // Upstream #3185 canonically treats legacy 00 as a sentinel; the bracket + // branch deliberately remains narrower, which is the distinction above. + assert.equal(readTotal(), 2); + }); + + test('RED (G3d, mixed mid-migration): bracket headings PLUS one legacy `### Phase 00:` — 3/2/67, not 2/2/100', () => { + writeProject(`# Roadmap + +## [GSD.02] v2.0: Foundation + +### Phase 00: Bootstrap +**Goal:** z + +### [GSD.02] 01: One +**Goal:** a + +### [GSD.02] 02: Two +**Goal:** b +`, 'bracket', [['00-bootstrap', true], ['GSD.02-01-one', true]]); + assert.equal(readTotal(), 3, 'pinned 2 before this fix'); + poisonTotalPhasesR5(tmpDir); + assert.equal(syncedTotal(), 3); + assert.equal(syncedPercent(), 67, 'pinned 100 before this fix'); + }); + + test('PIN (G3b, isolates the counter): a `### Phase 000:` heading with no directory — total still 3, no dir to admit', () => { + writeProject(`# Roadmap + +## Milestone v2.0: Foundation + +### Phase 000: Bootstrap +**Goal:** z + +### Phase 01: One +**Goal:** a + +### Phase 02: Two +**Goal:** b +`, 'bracket', [['01-one', true], ['02-two', false]]); + assert.equal(readTotal(), 3, 'pinned 2 before this fix'); + }); + + test('PIN (G3c control): legacy `### Phase 01:`/`02:` only, no sentinel-shaped token — unaffected', () => { + writeProject(`# Roadmap + +## Milestone v2.0: Foundation + +### Phase 01: One +**Goal:** a + +### Phase 02: Two +**Goal:** b +`, 'bracket', [['01-one', true], ['02-two', false]]); + assert.equal(readTotal(), 2); + }); +}); + +// ─── #2761 round-5 Major 1: extractRetiredPhaseNumbers is a FIFTH ─────────── +// ─── fence-blind site on the bracket path ─────────────────────────────────── +// +// The retirement gesture's own line scan iterates `scope.split(/\r?\n/)` +// with no fence awareness. Once the bracket alternative is compiled into +// `introSrc` (this PR's own change), a FENCED authoring EXAMPLE showing the +// #1514 retirement gesture in bracket spelling is indistinguishable from a +// real one — it retires a genuine phase, shrinking the denominator and +// jumping the percent to a confident 100%. +describe('#612 PR-2 round-5 Major 1: extractRetiredPhaseNumbers is fence-aware on the bracket path', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-r5m1-'); }); + afterEach(() => { cleanup(tmpDir); }); + + const D2 = [['GSD.02-01-one', true], ['GSD.02-02-two', false]]; + + test('RED (G2): a fenced retired-strikethrough bracket bullet in the preamble no longer retires a real phase — 2/1/50, not 1/1/100', () => { + writeProject(`# Roadmap + +Retiring a phase looks like this: + +\`\`\`markdown +- [x] ~~**[GSD.02] 02: Two**~~ — folded into 03; number retired +\`\`\` + +## [GSD.02] v2.0: Foundation + +### [GSD.02] 01: One +**Goal:** a + +### [GSD.02] 02: Two +**Goal:** b +`, 'bracket', D2); + assert.equal(readTotal(), 2, 'pinned 1 before this fix — the fenced EXAMPLE retirement gesture wrongly retired phase 02'); + }); + + test('RED (G2b): the same fenced retirement example placed INSIDE the milestone section — not a preamble-scoping artifact', () => { + writeProject(`# Roadmap + +## [GSD.02] v2.0: Foundation + +Retiring a phase looks like this: + +\`\`\`markdown +- [x] ~~**[GSD.02] 02: Two**~~ — folded; number retired +\`\`\` + +### [GSD.02] 01: One +**Goal:** a + +### [GSD.02] 02: Two +**Goal:** b +`, 'bracket', D2); + assert.equal(readTotal(), 2, 'pinned 1 before this fix'); + }); + + test('PIN (G2 LEGACY control): the same fenced-example shape on a non-bracket repo is unchanged (pre-existing, out of scope)', () => { + writeProject(`# Roadmap + +Retiring a phase looks like this: + +\`\`\`markdown +- [x] ~~**Phase 02: Two**~~ — folded into 03; number retired +\`\`\` + +## Milestone v2.0: Foundation + +### Phase 01: One +**Goal:** a + +### Phase 02: Two +**Goal:** b +`, undefined, [['01-one', true], ['02-two', false]]); + assert.equal(readTotal(), 1, 'pre-existing legacy hazard — deliberately unchanged, base is wrong here too'); + }); +}); + +// ─── #2761 round-5 Major 2: state sync's own counter now excludes the ────── +// ─── bare `999` icebox token like the other two derivations ───────────────── +// +// Under bracket, READING-B puts the sentinel in the BRACKET +// (isSentinelPhaseId), so `/^999\b/` on the bare TOKEN is the only thing +// excluding a `### [GSD.02] 999:` icebox heading — and it ran on the read +// path (buildStateFrontmatter) and getMilestonePhaseFilter, but not on +// cmdStateSync's own counter. One `state sync` call could leave a single +// STATE.md with its frontmatter (percent 50, from the read-path re-sync) and +// its body (percent 33, from the write-path counter that still counted the +// icebox heading) disagreeing. +describe('#612 PR-2 round-5 Major 2: state sync excludes the bracket 999 icebox token like the read path', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-r5m2-'); }); + afterEach(() => { cleanup(tmpDir); }); + + test('RED (G1): state sync\'s body percent now agrees with state json\'s percent on a bracket 999 icebox heading', () => { + writeProject(`# Roadmap + +## [GSD.02] v2.0: Foundation + +### [GSD.02] 01: One +**Goal:** a + +### [GSD.02] 02: Two +**Goal:** b + +### [GSD.02] 999: Backlog item +**Goal:** later +`, 'bracket', [['GSD.02-01-one', true], ['GSD.02-02-two', false]]); + const readPercent = (() => { + const r = runGsdTools(['state', 'json'], tmpDir); + assert.ok(r.success, `state json failed: ${r.error}`); + return JSON.parse(r.output).progress?.percent; + })(); + assert.equal(readPercent, 50); + assert.equal(syncedPercent(), readPercent, 'pinned 33 (vs read-path 50) before this fix — one STATE.md, two disagreeing numbers'); + }); + + test('PIN (G1 LEGACY control): the pre-existing legacy read/write divergence on the same shape is unchanged', () => { + writeProject(`# Roadmap + +## Milestone v2.0: Foundation + +### Phase 01: One +**Goal:** a + +### Phase 02: Two +**Goal:** b + +### Phase 999: Backlog item +**Goal:** later +`, undefined, [['01-one', true], ['02-two', false]]); + const r = runGsdTools(['state', 'json'], tmpDir); + assert.ok(r.success, `state json failed: ${r.error}`); + assert.equal(JSON.parse(r.output).progress?.percent, 50); + assert.equal(syncedPercent(), 33, 'the legacy asymmetry is genuinely pre-existing — deliberately unchanged'); + }); +}); + +// ─── #2761 round-5 Minor 1: the two tokenizeHeadings reconstructions ─────── +// ─── (bracket-fallback selector, isMilestoneBounded) accept indented ─────── +// ─── headings their raw line-start-anchored predecessors never did ───────── +// +// `HeadingToken.offset` is `tokenizeHeadings`' LINE-START offset. For a +// ≤3-space-indented heading that is NOT the `#` character's own offset, so +// a token the raw `^#{1,3}\s+\[...` regex never matched (indentation moves +// it off the line-start anchor) was still accepted by the reconstruction, +// which then mis-parses (selectedBracketId null, a sibling milestone's +// phases leaking into the scope). +describe('#612 PR-2 round-5 Minor 1: bracket-fallback selector skips indented headings (raw parity)', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-r5min1-'); }); + afterEach(() => { cleanup(tmpDir); }); + + test('RED (G6): a 2-space-indented, version-less bracket milestone heading no longer leaks the NEXT milestone\'s phases in — total 2, not 3', () => { + writeProject(`# Roadmap + + ## [GSD.02] Foundation + +### [GSD.02] 01: One +**Goal:** a + +### [GSD.02] 02: Two +**Goal:** b + +## [GSD.03] Later + +### [GSD.03] 01: Later one +**Goal:** c +`, 'bracket', [['GSD.02-01-one', true], ['GSD.02-02-two', false], ['GSD.03-01-later-one', true]]); + assert.equal(readTotal(), 2, 'pinned 3 before this fix — GSD.03\'s phase leaked into scope'); + assert.match(syncSkipReason(), /scope is "unscoped", not COMPLETE \(#3217\)/, + 'upstream must withhold the unsafe version-less percentage'); + }); + + test('PIN (G6c UNINDENTED control): the identical document with no leading indent is unaffected — total 2', () => { + writeProject(`# Roadmap + +## [GSD.02] Foundation + +### [GSD.02] 01: One +**Goal:** a + +### [GSD.02] 02: Two +**Goal:** b + +## [GSD.03] Later + +### [GSD.03] 01: Later one +**Goal:** c +`, 'bracket', [['GSD.02-01-one', true], ['GSD.02-02-two', false], ['GSD.03-01-later-one', true]]); + assert.equal(readTotal(), 2); + assert.match(syncSkipReason(), /scope is "unscoped", not COMPLETE \(#3217\)/); + }); + + // G6 above happens to leave isMilestoneBounded's own verdict unchanged + // either way — the phase headings (`### [GSD.02] 01: One`, unindented) + // already satisfy its loose bracket-prefix regex, so it returns bounded=true + // both before and after this fix on that fixture. This second fixture + // isolates isMilestoneBounded specifically: the ONLY `[GSD.02]`-shaped + // heading anywhere in the document is indented, mirroring round-4's F12 + // (fenced-ONLY) shape but with indentation as the parity gap instead of a + // fence. + test('RED (isMilestoneBounded site, indented-ONLY): an indented-only bracket heading no longer bounds a milestone absent from the roadmap — percent stays suppressed', () => { + const dirs = [['GSD.01-01-old', true], ['GSD.02-01-one', true], ['GSD.02-02-two', false]]; + writeProject(`# Roadmap + +Authoring guide: + + ## [GSD.02] Example milestone heading + +## [GSD.01] v1.0: Prior + +### [GSD.01] 01: Old +**Goal:** a +`, 'bracket', dirs); + const r = runGsdTools(['state', 'json'], tmpDir); + assert.ok(r.success, `state json failed: ${r.error}`); + const progress = JSON.parse(r.output).progress; + assert.equal(progress?.percent, undefined, 'pinned 100 before this fix — an indented-only [GSD.02] example wrongly bounded a milestone with no real section'); + const syncResult = runGsdTools(['state', 'sync'], tmpDir); + assert.ok(syncResult.success); + const raw = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + assert.match(raw, /\*\*Progress:\*\*[^\r\n]*?0%/, 'pinned 100% persisted before this fix; body must stay at its seed'); + }); +}); + +// ─── #2761 round-6 Blocker 1: countRoadmapPhaseHeadings' bracket-only ────── +// ─── `/^0\b/` sibling rule needs the SAME `bracketId &&` guard round-5's ─── +// ─── Blocker 1 restored two lines above it ────────────────────────────────── +// +// Round-5's Blocker 1 was `isSentinelPhaseId("undefined-00")`. This is the +// identical failure one line down: when the phase-heading grammar's LEGACY +// alternative matches (`### Phase 0:` in a `phase_id_convention: "bracket"` +// repo — the mid-migration shape this PR exists for), `bracketId` is +// `undefined`, and the unguarded `/^0\b/` fires on the bare token anyway. +// Neither the LEGACY branch of this same function nor +// `getMilestonePhaseFilter` has a `/^0\b/` rule at all, so the filter counts +// the phase and admits its completed directory while the counter refuses to +// count its heading — a milestone with an unstarted phase 02 persists as a +// confident 100%. `/^0\b/` matches `0` and `0.5` (word boundary before the +// dot) but not `00` (no boundary between the two zeros) — which is exactly +// why round-5's G3/G3d fixtures (both spelled `Phase 00:`) never tripped +// this one. +describe('#612 PR-2 round-6 Blocker 1: countRoadmapPhaseHeadings\' bracket-only /^0\\b/ rule restores the bracketId guard', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-r6b1-'); }); + afterEach(() => { cleanup(tmpDir); }); + + function poisonTotalPhasesR6(dir) { + const statePath = path.join(dir, '.planning', 'STATE.md'); + const raw = fs.readFileSync(statePath, 'utf-8'); + fs.writeFileSync(statePath, raw.replace(/^---\r?\n/, '---\ntotal_phases: 999\n'), 'utf-8'); + } + + test('RED (T0): a legacy `### Phase 0:` heading in a bracket repo is no longer dropped — 3/2/67, not 2/2/100', () => { + writeProject(`# Roadmap + +## Milestone v2.0: Foundation + +### Phase 0: Bootstrap +**Goal:** z + +### Phase 01: One +**Goal:** a + +### Phase 02: Two +**Goal:** b +`, 'bracket', [['0-bootstrap', true], ['01-one', true]]); + assert.equal(readTotal(), 3, 'pinned 2 before this fix — "0" read as a sentinel with no bracketId guard'); + poisonTotalPhasesR6(tmpDir); + assert.equal(syncedTotal(), 3); + assert.equal(syncedPercent(), 67, 'pinned 100 before this fix — a milestone with an unstarted phase 02 read as shipped'); + }); + + test('PIN (T0L LEGACY control): the identical document follows upstream legacy sentinel semantics', () => { + writeProject(`# Roadmap + +## Milestone v2.0: Foundation + +### Phase 0: Bootstrap +**Goal:** z + +### Phase 01: One +**Goal:** a + +### Phase 02: Two +**Goal:** b +`, undefined, [['0-bootstrap', true], ['01-one', true]]); + assert.equal(readTotal(), 2, 'upstream #3185 canonically excludes legacy phase 0'); + }); + + test('RED (T05): a legacy `### Phase 0.5:` heading in a bracket repo — same defect, second spelling — 3/2/67, not 2/2/100', () => { + writeProject(`# Roadmap + +## Milestone v2.0: Foundation + +### Phase 0.5: Bootstrap +**Goal:** z + +### Phase 01: One +**Goal:** a + +### Phase 02: Two +**Goal:** b +`, 'bracket', [['0.5-bootstrap', true], ['01-one', true]]); + assert.equal(readTotal(), 3, 'pinned 2 before this fix'); + poisonTotalPhasesR6(tmpDir); + assert.equal(syncedTotal(), 3); + assert.equal(syncedPercent(), 67, 'pinned 100 before this fix'); + }); + + test('PIN (T05L LEGACY control): the identical document follows upstream legacy sentinel semantics', () => { + writeProject(`# Roadmap + +## Milestone v2.0: Foundation + +### Phase 0.5: Bootstrap +**Goal:** z + +### Phase 01: One +**Goal:** a + +### Phase 02: Two +**Goal:** b +`, undefined, [['0.5-bootstrap', true], ['01-one', true]]); + assert.equal(readTotal(), 2, 'upstream #3185 canonically excludes legacy phase 0.5'); + }); + + test('PIN (B0, bracket-spelled `0` token — Minor 1, NOT fixed this round): counter/filter disagreement is base-parity, deliberately unchanged', () => { + // `### [GSD.02] 0: Bootstrap` — the SAME token shape, spelled in bracket + // form rather than legacy form. `isSentinelPhaseId('GSD.02-0','bracket')` + // is false (READING-B puts the sentinel in the bracket, not the token), + // so `/^0\b/` is the only thing that could exclude it — and per round-6's + // review this shape reads 2/2/100 on base AND HEAD (never closed by any + // build in this arc), so it is a pre-existing gap, not a regression. + // Disclosed in the changeset; pinned here as a base-parity characterization. + writeProject(`# Roadmap + +## [GSD.02] v2.0: Foundation + +### [GSD.02] 0: Bootstrap +**Goal:** z + +### [GSD.02] 01: One +**Goal:** a + +### [GSD.02] 02: Two +**Goal:** b +`, 'bracket', [['GSD.02-0-bootstrap', true], ['GSD.02-01-one', true]]); + assert.equal(readTotal(), 2, 'base-parity: this shape has never been closed by any build in this arc'); + }); +}); + +// ═════════════════════════════════════════════════════════════════════════ +// #2761 round-11 BLOCKER (trek-e review): `listMilestonePhaseDirs`'s third +// param (`phaseIdConvention`) lost its `= null` default so a caller that +// omits it now gets "resolve from config" instead of "explicitly not +// bracket" — a deliberate flip, but the changeset claimed "the archival and +// milestone-completion paths are unchanged." False: `cmdMilestoneComplete` +// (src/milestone.cts) and `cmdStateUpdateProgress` (src/state.cts) both call +// the enumerator and neither file was in the PR-2 diff. Both now thread the +// convention explicitly (resolved once, ambiently, off `cwd`) instead of +// silently inheriting the lazy default. These two blocks PIN the enumerated +// set / write behavior on a bracket project so a future regression to a +// hardcoded (non-bracket) default — or to a different resolve-from-config +// answer — is a failing test, not a silent set change on a command that +// renames/archives directories or writes STATE.md. +// ═════════════════════════════════════════════════════════════════════════ + +describe('#2761 round-11 BLOCKER: cmdMilestoneComplete enumerates the bracket-scoped set', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-milestone-complete-'); }); + afterEach(() => { cleanup(tmpDir); }); + + test('PIN: --dry-run would_archive.phases is exactly the 3 bracket-declared phase dirs, not the whole phases/ directory', () => { + // BRACKET_ROADMAP (above) declares milestone v2.0 with phases 01, 05, 06 + // under the [GSD.02] bracket tag. One directory per declared phase, PLUS + // a decoy that belongs to NO declared phase. Correct (convention-threaded) + // scoping excludes the decoy. A regression back to the pre-#612 hardcoded + // "not bracket" default would fail to recognize the bracket HEADINGS at + // all, `milestonePhaseNums` would come back empty, and + // `getMilestonePhaseFilter` degrades to a pass-all predicate — which + // would sweep the decoy in too. Either failure mode shows up as a + // mismatch against the pinned set below. + writeProject(BRACKET_ROADMAP, 'bracket', [ + 'GSD.02-01-setup', + 'GSD.02-05-real-work', + 'GSD.02-06-follow-up', + // Incomplete: the decoy carries no phase token, so it cannot be named + // by verificationNameFor's production-derived scheme (see its + // docstring) — and completeness is irrelevant to what this test pins, + // which is exclusion from would_archive.phases regardless. + ['not-a-declared-phase', false], + ]); + + const r = runGsdTools(['milestone', 'complete', 'v2.0', '--dry-run', '--raw'], tmpDir); + assert.ok(r.success, `milestone complete --dry-run failed: ${r.error}`); + const parsed = JSON.parse(r.output); + + assert.deepStrictEqual( + [...parsed.would_archive.phases].sort(), + ['GSD.02-01-setup', 'GSD.02-05-real-work', 'GSD.02-06-follow-up'], + 'would_archive.phases must be exactly the 3 declared bracket phases — never the decoy, ' + + 'and never empty (the pre-#612 hardcoded-legacy failure mode)', + ); + assert.strictEqual(parsed.stats.phases, 3, 'the read-only stats loop shares the same single enumeration'); + }); + + test('CONTROL: the identical shape under the legacy (non-bracket) convention is unaffected', () => { + // Same directory/decoy shape, legacy headings instead of bracket ones — + // this is the "byte-identical for null/milestone-prefixed projects" + // guarantee the changeset makes; pinned here at the cmdMilestoneComplete + // consumer, not just at the enumerator's own unit level. + writeProject(`# Roadmap + +## v2.0 + +### Phase 01: Setup +**Goal:** a + +### Phase 05: Real work +**Goal:** b + +### Phase 06: Follow-up +**Goal:** c +`, undefined, ['01-setup', '05-real-work', '06-follow-up', ['not-a-declared-phase', false]]); + + const r = runGsdTools(['milestone', 'complete', 'v2.0', '--dry-run', '--raw'], tmpDir); + assert.ok(r.success, `milestone complete --dry-run failed: ${r.error}`); + const parsed = JSON.parse(r.output); + + assert.deepStrictEqual( + [...parsed.would_archive.phases].sort(), + ['01-setup', '05-real-work', '06-follow-up'], + ); + }); +}); + +describe('#2761 round-11 BLOCKER: cmdStateUpdateProgress computes+writes a percent on bracket projects', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-update-progress-'); }); + afterEach(() => { cleanup(tmpDir); }); + + test('PIN: `state update-progress` writes a real (non-withheld) percent for a bracket-scoped milestone', () => { + // One fully-verified phase (01) and one plan-only phase (05) under the + // [GSD.02] v2.0 bracket milestone — mirrors writeProject's own + // PLAN+SUMMARY+VERIFICATION shape for "complete" entries. Round-11 named + // this exact command: "cmdStateUpdateProgress ... now computes/writes a + // completion percent on bracket projects where it previously withheld + // one." Pinning `updated: true` plus the exact percent/completed/total + // triple is what makes a future regression to the withheld shape (or to + // silently mis-scoped counts) a failing assertion instead of an + // unobserved behavior change. + writeProject(BRACKET_ROADMAP, 'bracket', [ + ['GSD.02-01-setup', true], + ['GSD.02-05-real-work', false], + ]); + + const r = runGsdTools(['state', 'update-progress'], tmpDir); + assert.ok(r.success, `state update-progress failed: ${r.error}`); + const parsed = JSON.parse(r.output); + + assert.strictEqual(parsed.updated, true, + 'a bracket project with a resolvable milestone window must not withhold — got: ' + JSON.stringify(parsed)); + assert.strictEqual(parsed.completed, 1); + assert.strictEqual(parsed.total, 2); + + const raw = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + // eslint-disable-next-line local/no-unbounded-quantifier -- parses the STATE.md this test just wrote, fixed-size fixture output, not adversarial input + const m = raw.match(/^\*\*Progress:\*\*[^\r\n]*?(\d+)%/m); + assert.ok(m, `state update-progress must have rendered a Progress percent into STATE.md; got:\n${raw}`); + assert.strictEqual(parseInt(m[1], 10), parsed.percent, + 'the percent update-progress reports and the one it writes into the body must agree'); + }); + + test('#2761 round-12 GAP: the enumerator .value THIS call site feeds (not the separately-scoped ' + + 'percent) gates the #3233 zero-plans no-op — a decoy outside the bracket window must not count', () => { + // Round-11's PIN test above (and its own inline comment at state.cts + // ~:965) established that `cmdStateUpdateProgress`'s reported/written + // percent is IMMUNE to this call site's convention threading — it comes + // from computeUpdateProgressPreview -> buildStateFrontmatter, a separate, + // independently-scoped derivation. Mutation testing on the round-11 + // response confirmed that empirically: reverting this call site's thread + // left the PIN test above still green. + // + // What THIS call site's enumerated `.value` actually feeds is + // `totalPlans` (summed across the enumerated dirs, just below) and the + // #3233 zero-plans no-op gate built on it — the ONLY place that value has + // any observable effect on this command's output. This fixture pins + // exactly that gate: BRACKET_ROADMAP declares milestone v2.0's phases + // (01/05/06 under [GSD.02]) but none of their directories are scaffolded + // on disk — zero plans exist for this milestone's real window either way. + // A directory that plainly does not belong to it ('not-a-declared-phase': + // no bracket tag, no phase token at all) sits alongside it WITH a plan. + // + // Correctly bracket-scoped, the decoy is excluded (it matches no + // GSD.02-qualified id) and totalPlans stays 0 -> the #3233 no-op fires. + // Degraded to the pass-all legacy reading (BRACKET_ROADMAP's headings are + // invisible under a non-bracket read — proven above by "a NON-bracket + // repo counts neither form of the same roadmap" — so + // `milestonePhaseNums` comes back empty and the filter admits + // everything), the decoy is swept in and totalPlans flips nonzero -> the + // no-op never fires and the command proceeds past it. + writeProject(BRACKET_ROADMAP, 'bracket', [['not-a-declared-phase', false]]); + + const r = runGsdTools(['state', 'update-progress'], tmpDir); + assert.ok(r.success, `state update-progress failed: ${r.error}`); + const parsed = JSON.parse(r.output); + + assert.strictEqual(parsed.updated, false, + 'a decoy directory outside the bracket-declared milestone window must never contribute to ' + + 'totalPlans for that milestone — got: ' + JSON.stringify(parsed)); + assert.match(String(parsed.reason ?? ''), /no plans found/, + 'must be the #3233 zero-plans no-op specifically (proves the enumerator excluded the decoy), ' + + 'not some other withhold reason — got: ' + JSON.stringify(parsed)); + }); + + test('CONTROL: the identical shape under the legacy (non-bracket) convention behaves the same way', () => { + writeProject(`# Roadmap + +## v2.0 + +### Phase 01: Setup +**Goal:** a + +### Phase 05: Real work +**Goal:** b +`, undefined, [ + ['01-setup', true], + ['05-real-work', false], + ]); + + const r = runGsdTools(['state', 'update-progress'], tmpDir); + assert.ok(r.success, `state update-progress failed: ${r.error}`); + const parsed = JSON.parse(r.output); + + assert.strictEqual(parsed.updated, true); + assert.strictEqual(parsed.completed, 1); + assert.strictEqual(parsed.total, 2); + }); +}); diff --git a/tests/adr-612-bracket-read-tolerance.test.cjs b/tests/adr-612-bracket-read-tolerance.test.cjs new file mode 100644 index 000000000..e159b3cda --- /dev/null +++ b/tests/adr-612-bracket-read-tolerance.test.cjs @@ -0,0 +1,1577 @@ +'use strict'; + +/** + * PR-2 (#2761 / epic #612) — the bracket-tolerant ROADMAP read path, at CLI level. + * + * Every reader here selects its heading grammar from the resolved + * `phase_id_convention`. The two things that buys, both pinned below: + * + * 1. A project that has not opted in reads exactly as it did before. The + * counterexample corpus is the one that falsified the earlier ungated + * design — `### [RFC.2119] 5:`, `### [v1.0] 2026-01-15:`, `### [ADR.612] 3:`, + * `### [rev.2] 9:`, `### [Cluster B] Phase 26:` — each of which was claimed + * as a phase and moved `phase_count` / `total_phases` / W006 on legacy + * repos. They are asserted against the CLI, not against a regex. + * + * 2. A project that HAS opted in gets its bracket headings read, including the + * sentinel exclusion that the widening would otherwise break: under + * READING-B the sentinel milestone lives in the bracket, so a filter that + * tests the phase token is blind to `### [GSD.999] 01:` and counts an + * icebox item as a real phase (#1445 / #1580) — quietly, since #1446 took + * total_phases out of the ratchet. + * + * Fixtures are raw markdown strings, never rendered through renderPhaseId/toDir. + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +let tmpDir; + +const write = (roadmap, convention) => { + const planning = path.join(tmpDir, '.planning'); + fs.writeFileSync(path.join(planning, 'ROADMAP.md'), roadmap, 'utf-8'); + fs.writeFileSync( + path.join(planning, 'config.json'), + JSON.stringify(convention === undefined ? {} : { phase_id_convention: convention }), + 'utf-8', + ); +}; + +const analyze = () => { + const r = runGsdTools(['roadmap', 'analyze'], tmpDir); + assert.ok(r.success, `roadmap analyze failed: ${r.error}`); + return JSON.parse(r.output); +}; + +// Upstream's shared diagnostic table returns coded IssueEntry objects; retain +// compatibility with the older string form so these assertions test messages, +// not the transport shape. +const consistencyMessages = (r) => + (JSON.parse(r.output).warnings || []).map(w => (typeof w === 'string' ? w : w.message)); + +const BRACKET_ROADMAP = `# Roadmap + +## [GSD.02] v2.0 — Foundation + +- [x] **[GSD.02] 01: Setup** +- [ ] **[GSD.02] 05: Real work** +- [ ] **[GSD.02] 06: Follow-up** + +### [GSD.02] 01: Setup +**Goal:** Lay the groundwork + +### [GSD.02] 05: Real work +**Goal:** Build the thing + +### [GSD.02] 06: Follow-up +**Goal:** Polish it +`; + +// ─── The legacy no-op guarantee (the whole point of gating) ──────────────── + +describe('#612 PR-2: a non-bracket repo is untouched by the bracket read path', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-legacy-'); }); + afterEach(() => { cleanup(tmpDir); }); + + // Each of these was claimed as a phase by the ungated design. `expect` is what + // the base build reports, so a regression here is a visible number change. + const COUNTEREXAMPLES = [ + ['### [RFC.2119] 5: Keyword definitions', 'RFC citation'], + ['### [v1.0] 2026-01-15: Shipped release notes', 'version tag + date'], + ['### [v1.0] 2024: Retrospective', 'version tag + year'], + ['### [ADR.612] 3: Decisions to ratify', 'ADR citation'], + ['### [rev.2] 9: Revision nine notes', 'lowercase tag'], + ['### [ISO.8601] 2026: Dates', 'standard citation'], + ['### [Cluster B] Phase 26: Clustered work', 'any-bracket + Phase label'], + ['### [GSD] Phase 7: Bracketed legacy', 'project tag + Phase label'], + ]; + + for (const [heading, label] of COUNTEREXAMPLES) { + for (const convention of [undefined, 'milestone-prefixed']) { + test(`${label} adds no phase (convention=${convention ?? 'unset'})`, () => { + write(`# Roadmap + +## v1.0 — Foundation + +### Phase 01: Setup +**Goal:** Groundwork + +${heading} +**Goal:** Not a phase +`, convention); + const out = analyze(); + // `[Cluster B] Phase 26` and `[GSD] Phase 7` DO match at base — the + // any-bracket tolerance is pre-existing — so they legitimately count. + const expected = /Phase \d/.test(heading) ? 2 : 1; + assert.equal( + out.phase_count, expected, + `phase_count moved; phases=${JSON.stringify(out.phases.map(p => p.number))}`, + ); + }); + } + } + + test('a legacy roadmap reads its own headings, tags and checkboxes unchanged', () => { + write(`# Roadmap + +## v1.0 — Foundation + +## Phase Overview: + +- [x] **Phase 1: Foundation** +- [ ] **Phase 2-01: API** + +### Phase 1: Foundation +**Goal:** Set up + +### Phase 2-01 (INSERTED): API +**Goal:** Build it + +#### Phase Details: +`, undefined); + const out = analyze(); + assert.deepEqual(out.phases.map(p => [p.number, p.name]), [['1', 'Foundation'], ['2-01', 'API']]); + assert.deepEqual(out.phases.map(p => p.roadmap_complete), [true, false]); + }); + + test('a bracket roadmap on a NON-bracket repo is invisible, not miscounted', () => { + // Mid-migration disclosure: headings written in the new form before the + // config is switched are not read. Silent invisibility is the deliberate + // trade — the alternative is claiming phases on repos that never opted in. + write(BRACKET_ROADMAP, undefined); + const out = analyze(); + assert.equal(out.phase_count, 0, 'no phases, and no phantoms either'); + }); +}); + +// ─── The bracket repo actually reads ─────────────────────────────────────── + +describe('#612 PR-2: a bracket repo reads its bracket headings', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-read-'); }); + afterEach(() => { cleanup(tmpDir); }); + + test('counts every phase and reads its NAME from the right capture group', () => { + write(BRACKET_ROADMAP, 'bracket'); + const out = analyze(); + assert.equal(out.phase_count, 3); + assert.deepEqual( + out.phases.map(p => [p.number, p.name]), + [['01', 'Setup'], ['05', 'Real work'], ['06', 'Follow-up']], + 'number AND name — a group-offset error garbles the name first', + ); + }); + + test('reads the Goal of each section (the heading is a section boundary)', () => { + write(BRACKET_ROADMAP, 'bracket'); + assert.deepEqual( + analyze().phases.map(p => p.goal), + ['Lay the groundwork', 'Build the thing', 'Polish it'], + ); + }); + + test('reads summary-checkbox completion through a bracket bullet', () => { + write(BRACKET_ROADMAP, 'bracket'); + assert.deepEqual(analyze().phases.map(p => p.roadmap_complete), [true, false, false]); + }); + + test('a legacy heading on a bracket repo still reads (migration window)', () => { + write(`# Roadmap + +## [GSD.02] v2.0 + +### [GSD.02] 05: Bracket form +**Goal:** a + +### Phase 6: Legacy form +**Goal:** b +`, 'bracket'); + assert.deepEqual(analyze().phases.map(p => p.number), ['05', '6']); + }); + + test('get-phase resolves a bracket heading', () => { + write(BRACKET_ROADMAP, 'bracket'); + const r = runGsdTools(['roadmap', 'get-phase', '05'], tmpDir); + assert.ok(r.success, r.error); + const out = JSON.parse(r.output); + assert.equal(out.found, true); + assert.equal(out.phase_name, 'Real work'); + assert.equal(out.goal, 'Build the thing'); + }); + + test('a checklist-only bracket phase reports malformed_roadmap', () => { + write(`# Roadmap + +## [GSD.02] v2.0 + +- [ ] **[GSD.02] 09: Summary only** + +### [GSD.02] 05: Real work +**Goal:** a +`, 'bracket'); + const out = JSON.parse(runGsdTools(['roadmap', 'get-phase', '09'], tmpDir).output); + assert.equal(out.found, false); + assert.equal(out.error, 'malformed_roadmap'); + assert.equal(out.phase_name, 'Summary only'); + }); +}); + +// ─── Sentinels (READING-B) ───────────────────────────────────────────────── + +describe('#612 PR-2: bracket sentinel milestones never count', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-sentinel-'); }); + afterEach(() => { cleanup(tmpDir); }); + + test('999 and 00 bracket milestones are excluded from phase_count', () => { + write(`# Roadmap + +## [GSD.02] v2.0 + +### [GSD.999] 01: Icebox item +**Goal:** Someday + +### [GSD.00] 02: Pre-milestone groundwork +**Goal:** Before v1 + +### [GSD.02] 05: Real work +**Goal:** Build it +`, 'bracket'); + const out = analyze(); + assert.deepEqual(out.phases.map(p => `${p.number}:${p.name}`), ['05:Real work']); + assert.equal(out.phase_count, 1); + }); + + test('a LOWERCASE sentinel bracket is excluded too', () => { + // Readers recognize `/i`; the identity helpers match `[A-Z]`. Without folding + // the captured id, `[gsd.999]` failed every sentinel test and the icebox item + // counted as a real phase. + write(`# Roadmap + +## [gsd.02] v2.0 + +### [gsd.999] 07: Icebox +**Goal:** Someday + +### [gsd.00] 08: Pre-milestone +**Goal:** Before + +### [gsd.02] 01: Real +**Goal:** Build +`, 'bracket'); + const out = analyze(); + assert.deepEqual(out.phases.map(p => p.number), ['01'], 'lowercase sentinels excluded'); + }); + + test('a 999 bracket CHECKLIST entry is not a missing-detail phantom', () => { + write(`# Roadmap + +## [GSD.02] v2.0 + +- [ ] **[GSD.999] 01: Icebox item** +- [ ] **[GSD.02] 07: Genuinely missing** +- [ ] **[GSD.02] 05: Real work** + +### [GSD.02] 05: Real work +**Goal:** Build it +`, 'bracket'); + assert.deepEqual(analyze().missing_phase_details, ['07']); + }); + + // ─── #2761 M1: sentinel classification is PER-OCCURRENCE ───────────────── + // + // The checklist scan keyed its bracket-id map by the bare TOKEN, first-wins, + // and the detail set was keyed by the bare token too. Under READING-B the + // sentinel lives in the BRACKET, so two checklist entries sharing a token + // across different brackets — the icebox shape the ADR itself documents — + // had ONE classification between them, decided by document order. + + for (const [label, checklist] of [ + ['sentinel first', ['- [ ] **[GSD.999] 01: Icebox item**', '- [ ] **[GSD.02] 01: Genuinely missing**']], + ['sentinel last', ['- [ ] **[GSD.02] 01: Genuinely missing**', '- [ ] **[GSD.999] 01: Icebox item**']], + ]) { + test(`a real phase sharing an icebox token is reported missing (${label})`, () => { + write(`# Roadmap + +## [GSD.02] v2.0 + +${checklist.join('\n')} + +### [GSD.02] 05: Real work +**Goal:** Build it +`, 'bracket'); + assert.deepEqual( + analyze().missing_phase_details, ['01'], + '[GSD.02] 01 has no detail heading and is not a sentinel — it is missing ' + + 'in BOTH orders. Order-dependence here means the icebox entry\'s ' + + 'classification was applied to the real phase.', + ); + }); + } + + test('an icebox token is still suppressed when NO real phase shares it', () => { + // The other direction: per-occurrence classification must not turn the + // sentinel suppression itself into a false POSITIVE. + write(`# Roadmap + +## [GSD.02] v2.0 + +- [ ] **[GSD.999] 01: Icebox item** +- [ ] **[GSD.00] 02: Pre-milestone** + +### [GSD.02] 05: Real work +**Goal:** Build it +`, 'bracket'); + assert.equal(analyze().missing_phase_details, null, 'both entries are sentinels'); + }); + + test('a detail heading does not satisfy a SAME-TOKEN entry from another bracket', () => { + // The order-INDEPENDENT half of the same defect: the detail set was keyed + // by bare token, so `[GSD.02] 01`'s heading marked token `01` present and + // masked `[GSD.03] 01`, which has no heading at all. No version strings and + // no STATE milestone, so the window is the whole document — the shape that + // puts two real brackets in one scan. + write(`# Roadmap + +## [GSD.02] Foundation + +- [ ] **[GSD.02] 01: Has a heading** + +### [GSD.02] 01: Has a heading +**Goal:** Build it + +## [GSD.03] Second + +- [ ] **[GSD.03] 01: Has no heading** +`, 'bracket'); + assert.deepEqual( + analyze().missing_phase_details, ['01'], + '[GSD.03] 01 has no detail heading; [GSD.02] 01\'s heading must not cover it', + ); + }); + + test('G5: a 999 token under a real milestone is STILL a backlog sentinel', () => { + write(`# Roadmap + +## [GSD.02] v2.0 + +### [GSD.02] 999: Late work +**Goal:** Build it +`, 'bracket'); + // READING-B adds the bracket rule; it does not repeal the engine-wide + // 0/999 backlog convention (#1445/#1580). A bracketed heading is a sentinel + // when EITHER its bracket milestone or its token is reserved. + assert.deepEqual(analyze().phases.map(p => p.number), []); + }); + + test('legacy sentinel filtering is unchanged on a legacy repo', () => { + write(`# Roadmap + +## v2.0 + +### Phase 999.1: Icebox +**Goal:** a + +### Phase 0: Pre-milestone +**Goal:** b + +### Phase 5: Real +**Goal:** c +`, undefined); + assert.deepEqual(analyze().phases.map(p => p.number), ['5']); + }); +}); + +// ─── validate.cts: the W006/W007 feeders and directory recognition ───────── + +describe('#612 PR-2: validate.cts heading builders are convention-selected', () => { + const validate = require('../gsd-core/bin/lib/validate.cjs'); + + // The letter-tolerant `[\w][\w.-]*` capture lives here, so this is where an + // ungated widening does the most damage: a phantom becomes a W007. + const PHANTOM_HEADINGS = [ + '### [RFC.2119] 5: Keyword definitions', + '### [v1.0] 2024: Retrospective', + '### [ADR.612] 3: Decisions to ratify', + '### [ISO.8601] 2026: Dates', + ]; + + test('a non-bracket repo admits none of the phantom headings', () => { + const doc = ['### Phase 5: Real work', ...PHANTOM_HEADINGS].join('\n'); + for (const convention of [undefined, null, 'milestone-prefixed', 'Bracket']) { + const { roadmapPhases } = validate.buildRoadmapPhaseVariants(doc, convention); + assert.deepEqual([...roadmapPhases], ['5'], `convention=${convention}`); + } + }); + + test('a bracket repo reads bracket headings', () => { + const { roadmapPhases } = validate.buildRoadmapPhaseVariants( + '### [GSD.02] 05: Real work\n### [GSD.02] 06: Follow-up\n', 'bracket', + ); + assert.deepEqual([...roadmapPhases].sort(), ['05', '06']); + }); + + test('legacy headings and bullets are byte-identical either way', () => { + const doc = [ + '### Phase 1: Foundation', '### Phase 2-01 (INSERTED): API', '### Phase 12A: Hotfix', + '#### Phase Details:', '- [x] **Phase 3: Done**', '- [ ] **Phase 4: Todo**', + ].join('\n'); + const legacy = [...validate.buildRoadmapPhaseVariants(doc).roadmapPhases].sort(); + assert.deepEqual(legacy, ['1', '12A', '2-01', '3', '4', 'Details'].sort()); + assert.deepEqual([...validate.buildRoadmapPhaseVariants(doc, 'bracket').roadmapPhases].sort(), legacy); + }); + + test('a label-only bullet site never gains any-bracket tolerance', () => { + const doc = '- [x] **[GSD] Phase 2-01: Legacy**\n- [ ] **[GSD.02] 07: Bracket**\n'; + assert.deepEqual([...validate.buildRoadmapPhaseVariants(doc).roadmapPhases], []); + assert.deepEqual([...validate.buildRoadmapPhaseVariants(doc, 'bracket').roadmapPhases], ['07']); + }); + + test('the unchecked-bullet site keeps its live W006 on a legacy repo', () => { + // `[v1.0] Phase 05` in an unchecked bullet used to SUPPRESS a W006 that + // fires at base — a vanishing warning, worse than an added one. + const doc = '- [ ] **[v1.0] Phase 05: Thing**\n'; + assert.deepEqual([...validate.buildNotStartedPhaseVariants(doc)], [], + 'the bullet must not register phase 05 as not-started on a legacy repo'); + // `[v1.0]` is no longer a bracket id at all: the milestone width is now the + // emit grammar's, and pad2 never produces a bare `0`. The residual this + // previously disclosed is gone rather than merely gated. + assert.deepEqual([...validate.buildNotStartedPhaseVariants(doc, 'bracket')], []); + }); + + test('a bracket repo picks up unchecked bracket bullets', () => { + const notStarted = validate.buildNotStartedPhaseVariants( + '- [ ] **[GSD.02] 05: Real work**\n- [x] **[GSD.02] 01: Done**\n', 'bracket'); + assert.ok(notStarted.has('05')); + assert.ok(!notStarted.has('01')); + }); +}); + +describe('#612 PR-2: directory recognition is convention-gated', () => { + const validate = require('../gsd-core/bin/lib/validate.cjs'); + const core = require('../gsd-core/bin/lib/phase-id.cjs'); + + const LEGACY_DIRS = [ + '02-01-setup', '01-setup', 'GSD-02-01-setup', '999.1-backlog', '14-2026-photos', + '02-04-01-deep', '12A-hotfix', 'not-a-phase', 'P0.34-56-name', 'P0.12-34-name', + 'P0.3-2-tenant', 'P0.16-gate', + ]; + + test('legacy dirs answer identically to the untouched constants', () => { + for (const d of LEGACY_DIRS) { + assert.equal(validate.isPhaseDirName(d), validate.phaseDirNameRe.test(d), d); + const viaConst = d.match(validate.PHASE_TOKEN_FROM_DIR_RE); + assert.equal(validate.phaseTokenFromDir(d), viaConst ? viaConst[1] : null, d); + } + }); + + test('the default-off invariant, extended to the directory side', () => { + for (const d of ['P0.34-56-name', 'P0.12-34-name']) { + for (const convention of [undefined, null, 'milestone-prefixed', 'BRACKET']) { + assert.equal(validate.isPhaseDirName(d, convention), false, `${d} / ${convention}`); + assert.equal(validate.phaseTokenFromDir(d, convention), null); + } + assert.equal(validate.isPhaseDirName(d, 'bracket'), true, `${d} opted in`); + } + }); + + test('bracket dirs resolve under the bracket convention', () => { + for (const [dir, token] of [ + ['GSD.02-05-feature', '05'], ['GSD.02-05.03-feature', '05.03'], + ['GSD.02-05', '05'], ['CK.01-12.04-feature', '12.04'], ['GSD_X2.100-05-feature', '05'], + ]) { + assert.equal(validate.isPhaseDirName(dir, 'bracket'), true, dir); + assert.equal(validate.phaseTokenFromDir(dir, 'bracket'), token, dir); + assert.equal(validate.isPhaseDirName(dir), false, `${dir} without the signal`); + } + }); + + test('shapes outside the emit grammar are not bracket dirs', () => { + // Admitting these would make the recognizer disagree with the resolver. + for (const d of ['GSD.02-12A-hotfix', 'GSD.02-05.03.07-x', 'GSD.2-05-x', 'not-a-phase', 'GSD.02']) { + assert.equal(validate.isPhaseDirName(d, 'bracket'), false, d); + } + }); + + test('the two bracket dir readers agree on ACCEPTED and REJECTED input alike', () => { + const corpus = [ + 'GSD.02-05-feature', 'GSD.02-05.03-feature', 'GSD.02-05', 'CK.01-12.04-f', + 'GSD_X2.100-05-f', 'GSD.999-01-icebox', 'GSD.02-12A-hotfix', 'GSD.02-05.03.07-x', + 'GSD.2-05-x', '02-01-setup', 'GSD-02-01-setup', 'not-a-phase', 'P0.34-56-name', + ]; + for (const dir of corpus) { + const shaped = validate.BRACKET_PHASE_DIR_RE.test(dir); + const viaOwner = core.extractPhaseToken(dir, 'bracket'); + if (shaped) { + assert.equal(validate.phaseTokenFromDir(dir, 'bracket'), viaOwner, `accepted: ${dir}`); + } else { + // Rejected by the recognizer — the owner must NOT resolve it through the + // bracket branch either, or W005 calls a directory malformed in the same + // run that the milestone-complete check treats it as a real phase dir. + const legacyToken = core.extractPhaseToken(dir); + assert.equal( + viaOwner, legacyToken, + `rejected by the recognizer but bracket-resolved by the owner: ${dir}`, + ); + } + } + }); + + test('non-string input throws, matching the constants they wrap', () => { + for (const bad of [42, undefined, null, {}, [], true]) { + assert.throws(() => validate.isPhaseDirName(bad, 'bracket'), TypeError, String(bad)); + assert.throws(() => validate.phaseTokenFromDir(bad, 'bracket'), TypeError, String(bad)); + } + }); +}); + +// ─── G6: validate consistency suppresses bracket sentinels ───────────────── + +describe('#612 PR-2: bracket sentinels do not warn as missing directories', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-consist-'); }); + afterEach(() => { cleanup(tmpDir); }); + + const consistencyWarnings = () => { + const r = runGsdTools(['validate', 'consistency'], tmpDir); + return consistencyMessages(r).filter(w => /no directory on disk/.test(w)); + }; + + test('an icebox bracket phase is not reported as missing from disk', () => { + // validate health suppressed these via notStartedPhases while consistency + // did not, so the two verbs disagreed on the same repo. + write(`# Roadmap + +## [GSD.02] v2.0 + +### [gsd.999] 07: Icebox +**Goal:** a + +### [GSD.999] 08: Icebox +**Goal:** b + +### [GSD.02] 01: Real +**Goal:** c +`, 'bracket'); + assert.deepEqual( + consistencyWarnings().filter(w => /\b0[78]\b/.test(w)), [], + 'sentinel phases legitimately have no directory', + ); + }); + + test('a real bracket phase with no directory still warns', () => { + write(`# Roadmap + +## [GSD.02] v2.0 + +### [GSD.02] 09: Real but absent +**Goal:** a +`, 'bracket'); + const w = consistencyWarnings(); + assert.equal(w.length, 1, JSON.stringify(w)); + assert.match(w[0], /Phase 09/); + }); + + // INVERTED by the merge of next @ 86101ee6. This case previously asserted the + // INHERITED WART — that a legacy `### Phase 999:` still warned here while + // `validate health` suppressed it — which this branch disclosed rather than + // fixed, because the legacy path was to stay byte-identical. + // + // ae7dc529 (#3225) fixed that wart upstream by adding the `isSentinelPhaseId` + // guard to this very loop, so the disagreement it pinned no longer exists and + // the old assertion inverted on the merge. The case is kept (not deleted) and + // flipped to assert the FIXED behaviour: it is the negative-space proof that + // this branch's `sentinelPhases` guard did not have to grow a legacy reading + // of its own, and it reds if a future resolution drops upstream's guard while + // keeping ours. + test('#3225 (merged): a legacy `### Phase 999:` no longer warns here', () => { + write(`# Roadmap + +## v2.0 + +### Phase 999: Backlog +**Goal:** a +`, undefined); + assert.deepEqual( + consistencyWarnings(), [], + 'the legacy leading-int sentinel rule suppresses this since #3225', + ); + }); + + // Negative space for the case above: the #3225 guard is SENTINEL-scoped, not a + // blanket silencer. Without this, dropping the whole loop would also pass. + test('#3225 scope: a legacy NON-sentinel phase with no directory still warns', () => { + write(`# Roadmap + +## v2.0 + +### Phase 09: Real but absent +**Goal:** a +`, undefined); + const w = consistencyWarnings(); + assert.equal(w.length, 1, JSON.stringify(w)); + assert.match(w[0], /Phase 09/); + }); +}); + +// ─── #2761 B2: validate health and validate consistency must agree ───────── +// +// buildRoadmapPhaseVariants() surfaces `sentinelPhases` — tokens borne ONLY by +// a bracket-sentinel heading ([GSD.999] icebox / [GSD.00] pre-milestone) — so a +// backlog item's heading-only entry does not need a directory. cmdValidateConsistency +// consumes it (see the describe block above); cmdValidateHealth's W006 loop did +// not, so the SAME sentinel-only bracket ROADMAP produced a silent `validate +// consistency` and a false W006 from `validate health` — the two verbs +// contradicting each other about the same repo. +describe('#612 PR-2 B2: validate health and validate consistency agree on bracket sentinels', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-b2-agree-'); }); + afterEach(() => { cleanup(tmpDir); }); + + const SENTINEL_ONLY = `# Roadmap + +## [GSD.999] Icebox + +### [GSD.999] 07: Someday +**Goal:** a +`; + + const healthW006 = () => { + const r = runGsdTools(['validate', 'health'], tmpDir); + const out = JSON.parse(r.output); + return [...(out.errors || []), ...(out.warnings || [])] + .filter((i) => i.code === 'W006') + .map((i) => i.message); + }; + + const consistencyMissingDirWarnings = () => { + const r = runGsdTools(['validate', 'consistency'], tmpDir); + return consistencyMessages(r).filter((message) => /no directory on disk/.test(message)); + }; + + test('a sentinel-only bracket roadmap: neither validator warns about the missing directory', () => { + write(SENTINEL_ONLY, 'bracket'); + assert.deepEqual( + consistencyMissingDirWarnings(), [], + 'validate consistency already excludes sentinel phases via sentinelPhases', + ); + assert.deepEqual( + healthW006(), [], + 'validate health must ALSO exclude sentinel phases — the two validators must agree on the same ROADMAP', + ); + }); + + test('CONTROL: a real (non-sentinel) phase with no directory still warns on both validators', () => { + // The agreement above must not be achieved by suppressing W006 outright. + write(`# Roadmap + +## [GSD.02] v2.0 + +### [GSD.02] 09: Real but absent +**Goal:** a +`, 'bracket'); + const consistency = consistencyMissingDirWarnings(); + const health = healthW006(); + assert.equal(consistency.length, 1, JSON.stringify(consistency)); + assert.equal(health.length, 1, JSON.stringify(health)); + assert.match(consistency[0], /Phase 09/); + assert.match(health[0], /Phase 09/); + }); +}); + +describe('#612 PR-2: sentinel suppression is occurrence-aware', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-occ-'); }); + afterEach(() => { cleanup(tmpDir); }); + + const consistencyWarnings = () => { + const r = runGsdTools(['validate', 'consistency'], tmpDir); + return consistencyMessages(r).filter(w => /no directory on disk/.test(w)); + }; + + test('a token borne ONLY by an icebox heading is suppressed', () => { + write(`# Roadmap + +## [GSD.02] v2.0 + +### [GSD.999] 07: Icebox only +**Goal:** a + +### [GSD.02] 02: Real two +**Goal:** b +`, 'bracket'); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'GSD.02-02-real-two'), { recursive: true }); + assert.deepEqual(consistencyWarnings().filter(w => /\b07\b/.test(w)), []); + }); + + test('a token SHARED with a real heading still warns', () => { + // roadmapPhases is a token set, so `[GSD.999] 01` and `[GSD.02] 01` collapse + // to one entry. Keying suppression on the token alone let the icebox item + // silence a real phase that has no directory — a false negative worse than + // the warning it removed. + write(`# Roadmap + +## [GSD.02] v2.0 + +### [GSD.999] 01: Icebox one +**Goal:** a + +### [GSD.02] 01: REAL one, dir missing +**Goal:** b + +### [GSD.02] 02: Real two +**Goal:** c +`, 'bracket'); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'GSD.02-02-real-two'), { recursive: true }); + const w = consistencyWarnings(); + assert.equal(w.length, 1, JSON.stringify(w)); + assert.match(w[0], /Phase 01/); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// R4-M1 — the DIRECTORY read inside `roadmap analyze`. +// +// `cmdRoadmapAnalyze` resolves the convention once and threads it into all four +// of its heading/checklist patterns, but the single `phaseTokenMatches` call +// that decides `disk_status` / `plan_count` / `summary_count` / `has_context` / +// `has_research` was left two-argument. Every canonical `{CODE}.{MM}-{PP}-slug` +// directory then read as `no_directory` with zero counts — while the SAME build +// resolved those same directories correctly in three other places on the same +// repo (W006/W007, `state json`, and the W021 milestone-complete read). Only the +// directory shape the convention exists to name failed; a mid-migration bracket +// repo carrying legacy `01-one` dirs resolved fine. +// +// Pinned the way the rest of this PR's gate is pinned: against the flat-legacy +// twin, computed in the same test run, PLUS exact literals so a shared wrong +// answer cannot pass. `grep disk_status tests/adr-612-*` was zero before this. +// ───────────────────────────────────────────────────────────────────────────── +describe('#612 PR-2: roadmap analyze resolves bracket phase DIRECTORIES', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-analyze-dir-'); }); + afterEach(() => { cleanup(tmpDir); }); + + const BRK = `# Roadmap + +## [GSD.02] v2.0: Current + +### [GSD.02] 01: One +**Goal:** a + +### [GSD.02] 02: Two +**Goal:** b +`; + const LEG = `# Roadmap + +## v2.0: Current + +### Phase 01: One +**Goal:** a + +### Phase 02: Two +**Goal:** b +`; + + /** + * Phase 01 complete (PLAN + SUMMARY + a passing `*-VERIFICATION.md`), + * phase 02 planned (PLAN only). + * + * #3186 (ADR-3180 §7.4) made completion disk-strict: a fully summarized + * phase is still partial until its verification verdict passes. The legacy + * twin uses the same convention-agnostic completion predicate. + */ + const mkDirs = (specs) => { + for (const [dir, stem, complete] of specs) { + const q = path.join(tmpDir, '.planning', 'phases', dir); + fs.mkdirSync(q, { recursive: true }); + fs.writeFileSync(path.join(q, `${stem}-01-PLAN.md`), '# plan\n', 'utf-8'); + if (complete) { + fs.writeFileSync(path.join(q, `${stem}-01-SUMMARY.md`), '# summary\n', 'utf-8'); + fs.writeFileSync( + path.join(q, `${stem}-VERIFICATION.md`), '---\nstatus: passed\n---\n# Verification\n', 'utf-8'); + } + } + }; + const diskShape = () => analyze().phases.map( + p => [p.number, p.disk_status, p.plan_count, p.summary_count]); + + const BRK_DIRS = [['GSD.02-01-one', 'GSD.02-01', true], ['GSD.02-02-two', 'GSD.02-02', false]]; + const LEG_DIRS = [['01-one', '01', true], ['02-two', '02', false]]; + + test('bracket dirs report complete/planned with real counts — not no_directory/0/0', () => { + write(BRK, 'bracket'); + mkDirs(BRK_DIRS); + assert.deepEqual(diskShape(), [['01', 'complete', 1, 1], ['02', 'planned', 1, 0]]); + }); + + test('and that shape equals its flat-legacy twin exactly', () => { + write(BRK, 'bracket'); + mkDirs(BRK_DIRS); + const bracket = diskShape(); + cleanup(tmpDir); + tmpDir = createTempProject('adr-612-analyze-dir-leg-'); + write(LEG, undefined); + mkDirs(LEG_DIRS); + const legacy = diskShape(); + assert.deepEqual(bracket, legacy, 'bracket must read the disk exactly as the legacy twin does'); + assert.deepEqual(legacy, [['01', 'complete', 1, 1], ['02', 'planned', 1, 0]], + 'and the twin is the right answer, not a shared wrong one'); + }); + + test('a bracket repo carrying LEGACY-shaped dirs still resolves (the read is additive)', () => { + // This shape resolved even with the two-argument call, which is why the bug + // was invisible: it failed ONLY for the canonical bracket directory name. + write(BRK, 'bracket'); + mkDirs(LEG_DIRS); + assert.deepEqual(diskShape(), [['01', 'complete', 1, 1], ['02', 'planned', 1, 0]]); + }); + + test('a NON-bracket repo is unaffected by the threaded convention', () => { + write(LEG, undefined); + mkDirs(LEG_DIRS); + assert.deepEqual(diskShape(), [['01', 'complete', 1, 1], ['02', 'planned', 1, 0]]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// R4-m1 — the CHECKLIST scan in buildRoadmapPhaseVariants. +// +// The heading scan is sentinel-aware; its checklist twin was compiled +// non-capturing and called every token REAL. The occurrence-aware un-suppression +// loop then deleted the icebox token the heading scan had correctly marked +// sentinel, and `validate consistency` warned that a bracket ICEBOX phase had no +// directory — in the HOUSE ROADMAP shape, where the icebox appears as both a +// bold bullet and a detail heading. `validate health` stayed silent on the same +// repo, so the two verbs disagreed. +// +// Both directions are pinned here: the icebox must be silent, AND a REAL phase +// carrying the same token must still warn. A fix that over-suppresses fails the +// second test. +// ───────────────────────────────────────────────────────────────────────────── +describe('#612 PR-2: a bracket sentinel in the CHECKLIST index is suppressed too', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-checklist-sent-'); }); + afterEach(() => { cleanup(tmpDir); }); + + const warnings = () => { + const r = runGsdTools(['validate', 'consistency'], tmpDir); + return consistencyMessages(r).filter(w => /no directory on disk/.test(w)); + }; + const realTwoOnDisk = () => fs.mkdirSync( + path.join(tmpDir, '.planning', 'phases', 'GSD.02-02-real-two'), { recursive: true }); + + test('house shape: icebox as BOTH a bold bullet and a heading is silent', () => { + write(`# Roadmap + +## [GSD.02] v2.0: Current + +- [ ] **[GSD.999] 01: Icebox item** +- [ ] **[GSD.02] 02: Real two** + +### [GSD.999] 01: Icebox item +**Goal:** z + +### [GSD.02] 02: Real two +**Goal:** b +`, 'bracket'); + realTwoOnDisk(); + assert.deepEqual(warnings(), [], 'an icebox phase legitimately has no directory'); + }); + + test('CONTROL: the same ROADMAP without the icebox lines is also silent', () => { + // Makes the assertion above non-vacuous: silence must come from suppression, + // not from the repo having nothing to say. + write(`# Roadmap + +## [GSD.02] v2.0: Current + +- [ ] **[GSD.02] 02: Real two** + +### [GSD.02] 02: Real two +**Goal:** b +`, 'bracket'); + realTwoOnDisk(); + assert.deepEqual(warnings(), []); + }); + + test('a REAL phase sharing the sentinel token still warns (no over-suppression)', () => { + // The opposite direction. A checklist bullet for a REAL `[GSD.02] 01` must + // un-suppress the token that the `[GSD.999] 01` heading marked sentinel. + write(`# Roadmap + +## [GSD.02] v2.0: Current + +- [x] **[GSD.02] 01: Real one** +- [ ] **[GSD.02] 02: Real two** + +### [GSD.999] 01: Icebox item +**Goal:** z + +### [GSD.02] 02: Real two +**Goal:** b +`, 'bracket'); + realTwoOnDisk(); + const w = warnings(); + assert.equal(w.length, 1, JSON.stringify(w)); + assert.match(w[0], /Phase 01/); + }); +}); + +// ─── Adversarial malformed bracket tokens ─────────────────────────────────── + +/** + * A tolerant reader's worst input is not a well-formed id it should reject — + * that is what the emit-grammar tests above cover — but a token that is + * STRUCTURALLY broken: the milestone is not a number, the bracket never closes, + * or a bracket is nested inside another. Each one is a plausible hand-edit or a + * half-finished migration, and each reaches every widened reader in this PR. + * + * The contract is the same for all three: no phantom phase, no throw, and the + * answer is IDENTICAL to what a non-opted-in repo gives, because none of these + * is in the emit grammar. A malformed bracket must not be "partly" read — a + * reader that recovers the `01` out of `[GSD.02] 01:` but not out of + * `[GSD.AB] 01:` is fine; one that recovers it from BOTH has invented a phase + * the ROADMAP does not declare. + */ +describe('#612 PR-2: malformed bracket tokens produce no phantom phase', () => { + const validate = require('../gsd-core/bin/lib/validate.cjs'); + + beforeEach(() => { tmpDir = createTempProject('adr-612-malformed-'); }); + afterEach(() => { cleanup(tmpDir); }); + + const MALFORMED = [ + ['non-numeric milestone', '### [GSD.AB] 01: Broken'], + ['unclosed bracket', '### [GSD.02 01: Broken'], + ['nested bracket', '### [GSD.[02]] 01: Broken'], + ['empty bracket', '### [] 01: Broken'], + ['bracket with no dot', '### [GSD02] 01: Broken'], + ['dot but empty milestone', '### [GSD.] 01: Broken'], + ['milestone is a float', '### [GSD.0.2] 01: Broken'], + ['negative milestone', '### [GSD.-2] 01: Broken'], + ['whitespace milestone', '### [GSD. ] 01: Broken'], + ['double dot', '### [GSD..02] 01: Broken'], + ]; + + for (const [label, heading] of MALFORMED) { + test(`${label} is read as a phase by NO convention`, () => { + const doc = `### Phase 5: Real work\n${heading}\n`; + const opted = [...validate.buildRoadmapPhaseVariants(doc, 'bracket').roadmapPhases].sort(); + const legacy = [...validate.buildRoadmapPhaseVariants(doc).roadmapPhases].sort(); + assert.deepEqual(opted, ['5'], `${label}: bracket repo invented a phase — ${JSON.stringify(opted)}`); + assert.deepEqual(opted, legacy, `${label}: opted-in and legacy repos must agree`); + }); + } + + test('the malformed corpus reaches roadmap analyze without inventing a phase', () => { + write(`# Roadmap + +## [GSD.02] v2.0: Current + +### [GSD.02] 01: Real one +**Goal:** a + +${MALFORMED.map(([, h]) => `${h}\n**Goal:** x\n`).join('\n')}`, 'bracket'); + assert.deepEqual(analyze().phases.map(p => p.number), ['01']); + }); + + test('a malformed bracket DIRECTORY is not recognized, and never throws', () => { + const dirs = [ + 'GSD.AB-01-broken', 'GSD.02-01-ok', 'GSD.[02]-01-broken', 'GSD.-01-broken', + 'GSD..02-01-broken', '.02-01-broken', 'GSD.02--01-broken', 'GSD.0.2-01-broken', + ]; + for (const dir of dirs) { + for (const convention of [undefined, null, 'milestone-prefixed', 'bracket']) { + assert.doesNotThrow(() => validate.isPhaseDirName(dir, convention), `${dir} / ${convention}`); + assert.doesNotThrow(() => validate.phaseTokenFromDir(dir, convention), `${dir} / ${convention}`); + } + if (dir === 'GSD.02-01-ok') continue; + assert.equal(validate.isPhaseDirName(dir, 'bracket'), false, `${dir} must not be a bracket dir`); + } + assert.equal(validate.isPhaseDirName('GSD.02-01-ok', 'bracket'), true, 'control'); + }); + + test('the not-started variant builder agrees with the roadmap builder on the corpus', () => { + // Two builders, one grammar. If only one widens, `validate consistency` + // reports a phase the health check does not — the #3242 Bug B shape. + for (const [label, heading] of MALFORMED) { + const doc = `- [ ] **Phase 5: Real**\n${heading.replace('### ', '- [ ] **')}**\n`; + const a = [...validate.buildNotStartedPhaseVariants(doc, 'bracket')].sort(); + const b = [...validate.buildNotStartedPhaseVariants(doc)].sort(); + assert.deepEqual(a, b, `${label}: the two conventions disagreed — ${JSON.stringify([a, b])}`); + } + }); + + // #2761 M2 (trek-e review): this measured `process.hrtime.bigint()` against a + // 1s ceiling — an assertion about the host machine, not the SUT, and a flake + // on a loaded CI runner (RULESET.TESTS.no-timing-assertion). The property it + // guarded is kept and stated ALGORITHMICALLY instead: every widened pattern + // is built from BRACKET_ID_SRC, whose milestone field is a bounded + // alternation, so the classic ReDoS shape (nested quantifiers over a long + // unclosed bracket) must be LINEAR in input length. Running each attack at 1x + // and 4x and requiring byte-identical results tests exactly that — a + // catastrophically backtracking matcher cannot complete the 4x leg under any + // ceiling, whereas a bounded one is indifferent to the scaling. The `timeout` + // option is a hang backstop, not an assertion. + // Each attack states the phases its reading must name, as a function of n. + // Four are malformed and must name none; the fifth is well-formed but + // oversized, and must name exactly its (n-digit) token — a reading that is + // linear in n BY CONSTRUCTION, which is the property under test. + const ATTACKS = [ + ['unclosed bracket code', (n) => `### [${'A'.repeat(n)} 01: x`, () => []], + ['unclosed bracket numeric', (n) => `### [GSD.${'0'.repeat(n)} 01: x`, () => []], + ['nested open brackets', (n) => `### ${'['.repeat(Math.floor(n / 2.5))}GSD.02] 01: x`, () => []], + ['repeated bracket group', (n) => `### [${'A.02] ['.repeat(Math.floor(n / 5))}A.02] 01: x`, () => []], + ['oversized milestone + token', (n) => `### [GSD.${'9'.repeat(n)}] ${'1'.repeat(n)}: x`, (n) => ['1'.repeat(n)]], + ]; + const readAll = (doc) => { + const r = validate.buildRoadmapPhaseVariants(doc, 'bracket'); + return { + phases: [...r.roadmapPhases].sort(), + variantCount: r.roadmapPhaseVariants.size, + sentinels: [...r.sentinelPhases].sort(), + notStarted: [...validate.buildNotStartedPhaseVariants(doc, 'bracket')].sort(), + isDir: validate.isPhaseDirName(doc, 'bracket'), + token: validate.phaseTokenFromDir(doc, 'bracket'), + }; + }; + + test('pathological bracket input reads correctly at 1x and 4x length', { timeout: 60_000 }, () => { + for (const [label, doc, expectedPhases] of ATTACKS) { + const readings = [5000, 20000].map((n) => [n, readAll(doc(n))]); + for (const [n, r] of readings) { + assert.deepEqual(r.phases, expectedPhases(n), `${label} @${n}: wrong phase set`); + assert.deepEqual(r.sentinels, [], `${label} @${n}: named a sentinel`); + assert.deepEqual(r.notStarted, [], `${label} @${n}: named a not-started phase`); + assert.equal(r.isDir, false, `${label} @${n}: a heading is not a phase directory`); + assert.equal(r.token, null, `${label} @${n}: a heading yields no directory token`); + } + // Scale invariance of the STRUCTURE: quadrupling the input may lengthen + // an extracted token (linear), but must not change how many things the + // reading names. A backtracking regression never reaches this line. + assert.equal( + readings[0][1].variantCount, readings[1][1].variantCount, + `${label}: quadrupling the input changed the variant cardinality`, + ); + } + }); + + test('control: the same readers DO extract a well-formed bracket heading', () => { + // Non-vacuity guard — "names no phase" above must mean "the input is + // malformed", not "these readers are inert". + assert.deepEqual(readAll('### [GSD.02] 01: Real work').phases, ['01']); + assert.equal(validate.isPhaseDirName('GSD.02-01-real-work', 'bracket'), true); + assert.equal(validate.phaseTokenFromDir('GSD.02-01-real-work', 'bracket'), '01'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── + +describe('#612 PR-2: state validate resolves bracket phase DIRECTORIES', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-validate-dir-'); }); + afterEach(() => { cleanup(tmpDir); }); + + /** + * #3208 (merged to next as part of "resolve active state phase before drift + * scan") rewrote cmdStateValidate's directory lookup from a `startsWith` + * prefix test to the canonical `phaseKeyFromDir(...) === selectedPhaseKey` + * comparison. That is the correct surface — and it is exactly why the lookup + * now needs the resolved convention, which the rewrite does not pass. + * + * `phaseKeyFromDir` deliberately refuses to read a bracket directory without + * an explicit signal (ADR-2121: a bracket dir is string-indistinguishable + * from the legacy letter-prefixed-decimal family), so un-threaded it returns + * the whole dir name as the key — `GSD.02-05-real-work` -> `GSD.02-5-REAL-WORK` + * — while the STATE side is the bare `05` that `parsePhaseFromProse` yields. + * Both sides of one comparison derived under different conventions is #2562's + * defect class, and this file's other three `phaseKeyFromDir` call sites + * already thread against it. + * + * Observable symptom without the thread: a bracket repo whose phase directory + * plainly exists reports `valid: false` and "no phase directory matches phase + * 05" — wrong-and-confident on precisely the repos the convention supports, + * and drift detection (plan-count mismatch, verification status) never runs. + * + * The legacy twin below is the byte-identity control: threading a non-bracket + * convention must change nothing, since `extractPhaseToken` branches only on + * `=== 'bracket'`. + */ + const BRK = `# Roadmap + +## [GSD.02] v2.0: Current + +### [GSD.02] 05: Real work +**Goal:** a +`; + const LEG = `# Roadmap + +## v2.0: Current + +### Phase 05: Real work +**Goal:** a +`; + + // STATE.md asserts 3 plans; disk carries 1. The drift warning is the PROOF + // the scan actually ran — a lookup that fails to find the directory returns + // before it, so "no plan_count drift reported" and "drift never ran" are + // distinguishable here rather than both reading as silence. + const STATE = `--- +gsd_state_version: '1.0' +milestone: v2.0 +current_phase: '05' +status: executing +total_plans_in_phase: 3 +--- +# Project State + +## Current Position +**Phase:** 05 — Real work +`; + + const seed = (roadmap, convention, dir) => { + write(roadmap, convention); + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), STATE, 'utf-8'); + const q = path.join(tmpDir, '.planning', 'phases', dir); + fs.mkdirSync(q, { recursive: true }); + fs.writeFileSync(path.join(q, '05-01-PLAN.md'), '# plan\n', 'utf-8'); + }; + + // #3884 (e20744eac, "failure is a value") made argv strict: an unrecognized + // flag is now a hard `unknown flag ...; accepted: --strict` error on stderr + // with EMPTY stdout, where it was previously ignored. `state validate` never + // had a `--json` flag — JSON is its only output shape — so the token was a + // silent no-op that strict argv now rejects. Dropping it restores the same + // envelope this helper already parsed; the assertions below are unchanged. + const validateState = () => { + const r = runGsdTools(['state', 'validate'], tmpDir); + return JSON.parse(r.output); + }; + + // #3310 (ADR-3180 Phase 12) replaced `cmdStateValidate`'s `drift` + // object with coded `warnings: Diagnostic[]`: phase-directory misses are + // S004 and plan-count drift is S005. Keep these tests on their behavior-level + // subject while reading the current upstream envelope. + const codes = (out, code) => + (out.warnings || []).filter((w) => w.code === code).map((w) => w.message); + const phaseDirWarnings = (out) => + codes(out, 'S004').filter((m) => /no phase directory matches/.test(m)); + const planCountWarnings = (out) => codes(out, 'S005'); + + test('a bracket phase directory is FOUND — no phantom "no phase directory matches"', () => { + seed(BRK, 'bracket', 'GSD.02-05-real-work'); + const out = validateState(); + assert.deepEqual( + phaseDirWarnings(out), + [], + `the directory exists and must resolve; an S004 phase-directory warning means the lookup missed it, got: ${JSON.stringify(out.warnings)}`, + ); + }); + + test('and the drift scan actually RUNS — plan-count mismatch is reported', () => { + seed(BRK, 'bracket', 'GSD.02-05-real-work'); + const out = validateState(); + assert.deepEqual( + planCountWarnings(out), + ['Plan count mismatch: STATE.md says 3 plans, disk has 1'], + 'resolving the directory must let the plan-count drift check run', + ); + }); + + test('and that answer equals its flat-legacy twin exactly', () => { + seed(BRK, 'bracket', 'GSD.02-05-real-work'); + const bracket = validateState(); + cleanup(tmpDir); + tmpDir = createTempProject('adr-612-validate-dir-leg-'); + seed(LEG, undefined, '05-real-work'); + const legacy = validateState(); + assert.deepEqual(bracket.warnings, legacy.warnings, + 'bracket must validate exactly as the legacy twin does'); + assert.deepEqual( + planCountWarnings(legacy), + ['Plan count mismatch: STATE.md says 3 plans, disk has 1'], + 'and the twin is the right answer, not a shared wrong one', + ); + }); + + test('a NON-bracket repo is unaffected by the threaded convention', () => { + seed(LEG, undefined, '05-real-work'); + const out = validateState(); + assert.deepEqual(phaseDirWarnings(out), []); + assert.deepEqual( + planCountWarnings(out), + ['Plan count mismatch: STATE.md says 3 plans, disk has 1'], + ); + }); + + test('a genuinely absent phase directory still reports not_found on bracket', () => { + // Non-vacuity guard: the thread must not make the lookup match ANYTHING. + seed(BRK, 'bracket', 'GSD.02-09-unrelated'); + const out = validateState(); + assert.equal(phaseDirWarnings(out).length, 1, JSON.stringify(out.warnings)); + assert.equal(out.valid, false); + }); +}); + +// ─── The #3309/#3310 re-homing: the reads that moved into the rule table ─── + +/** + * #3309/#3310 migrated `cmdValidateHealth` and `cmdValidateConsistency` onto the + * health-diagnostic rule table and deleted every helper this PR threaded — + * `collectDiskPhases`, `collectDiskPhaseEntries`, `collectArchivedPhaseDirNames`, + * `forEachArchivedPhaseToken`. Their reads now live in + * `src/planning-snapshot.cts` and the reads' CONSUMERS in + * `src/health-diagnostic-rules/*.cts`, so this PR's convention threading moved + * with them. + * + * Three of those re-homed sites turned out to be unfalsifiable by the rest of + * this suite once relocated: reverting the convention argument changed real CLI + * output and NOT ONE existing test went red. The pre-migration sites were + * covered indirectly, through helpers that no longer exist. Each case below + * pins one of them at the CLI, with its flat-legacy twin as the byte-identity + * control, so the threading is falsifiable in its new home rather than trusted. + * + * The fourth re-homed site — `buildValidPhaseSet`'s `extractPhaseToken(dir, + * convention)` in W002 (`state-consistency.cts`) — is deliberately NOT pinned + * here, because it is not observable: that rule unions the disk tokens with + * `roadmapDeclaredPhases` and `archivedPhaseTokens`, and any STATE.md reference + * a bracket disk token would rescue is already rescued by the ROADMAP half. + * It is threaded for provenance (it restores `collectDiskPhases(planBase, + * convention)`'s derivation exactly) and is disclosed as droppable rather than + * given a test that cannot fail for the right reason. + */ +describe('#612 PR-2: the re-homed W006/W007 reads still select by convention', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-rehome-'); }); + afterEach(() => { cleanup(tmpDir); }); + + const healthCodes = (code) => { + const out = JSON.parse(runGsdTools(['validate', 'health'], tmpDir).output); + return [...(out.errors || []), ...(out.warnings || [])] + .filter(i => i.code === code) + .map(i => i.message); + }; + const mkdir = (...parts) => + fs.mkdirSync(path.join(tmpDir, '.planning', ...parts), { recursive: true }); + + // ── archivedPhaseTokens (planning-snapshot.cts) ────────────────────────── + // Pre-migration this was `forEachArchivedPhaseToken(planBase, cb, convention)` + // feeding W006's existence check. `PHASE_TOKEN_FROM_DIR_RE` rejects + // `GSD.02-05-shipped` outright, so un-threaded the archive is invisible and a + // phase whose only directory was archived draws a W006 it should not. + + test('an ARCHIVED bracket phase directory satisfies W006', () => { + mkdir('milestones', 'v1.0-phases', 'GSD.02-05-shipped'); + mkdir('phases'); + write(`# Roadmap + +## [GSD.02] v2.0 + +### [GSD.02] 05: Shipped work +**Goal:** a +`, 'bracket'); + assert.deepEqual(healthCodes('W006'), [], + 'the phase\'s directory lives in a milestone archive — that is not "no directory on disk"'); + }); + + test('CONTROL: the flat-legacy twin of the same repo is equally silent', () => { + mkdir('milestones', 'v1.0-phases', '05-shipped'); + mkdir('phases'); + write(`# Roadmap + +## v2.0 + +### Phase 05: Shipped work +**Goal:** a +`, undefined); + assert.deepEqual(healthCodes('W006'), []); + }); + + test('CONTROL: with the archive removed the same bracket repo DOES warn', () => { + // Non-vacuity: the silence above must come from the archive being read, + // not from W006 being unable to fire on this fixture at all. + mkdir('phases'); + write(`# Roadmap + +## [GSD.02] v2.0 + +### [GSD.02] 05: Shipped work +**Goal:** a +`, 'bracket'); + const w = healthCodes('W006'); + assert.equal(w.length, 1, JSON.stringify(w)); + assert.match(w[0], /Phase 05/); + }); + + // ── roadmapPhaseCheckboxes (planning-snapshot.cts) ─────────────────────── + // Pre-migration this exclusion came from `buildNotStartedPhaseVariants( + // roadmapContent, convention)`; the rule table replaced that call with the + // `roadmapPhaseCheckboxes` field. Un-threaded, a bracket repo's `- [ ]` + // bullets never parse, so every not-yet-started bracket phase draws a W006. + + test('an UNCHECKED bracket checklist bullet excludes its phase from W006', () => { + mkdir('phases'); + write(`# Roadmap + +## [GSD.02] v2.0 + +- [ ] **[GSD.02] 09: Not started** + +### [GSD.02] 09: Not started +**Goal:** a +`, 'bracket'); + assert.deepEqual(healthCodes('W006'), [], + 'an unstarted phase legitimately has no directory yet'); + }); + + test('CONTROL: the flat-legacy twin of the same repo is equally silent', () => { + mkdir('phases'); + write(`# Roadmap + +## v2.0 + +- [ ] **Phase 09: Not started** + +### Phase 09: Not started +**Goal:** a +`, undefined); + assert.deepEqual(healthCodes('W006'), []); + }); + + test('CONTROL: a CHECKED bracket bullet is not excluded', () => { + // Non-vacuity in the direction that matters: the exclusion must read the + // checkbox STATE, not merely the presence of a bracket bullet. + mkdir('phases'); + write(`# Roadmap + +## [GSD.02] v2.0 + +- [x] **[GSD.02] 09: Claimed complete** + +### [GSD.02] 09: Claimed complete +**Goal:** a +`, 'bracket'); + const w = healthCodes('W006'); + assert.equal(w.length, 1, JSON.stringify(w)); + assert.match(w[0], /Phase 09/); + }); + + // ── W007's token (roadmap-disk-consistency.cts) ────────────────────────── + // Pre-migration W007 iterated `collectDiskPhaseEntries`' keys, which were + // built by the convention-aware extractor; the migrated rule iterates + // directory NAMES and tokenizes per entry. Un-threaded it reports the whole + // directory name as the phase id. + + test('an ORPHAN bracket directory is reported by its phase token, not its dir name', () => { + mkdir('phases', 'GSD.02-77-orphan'); + write(`# Roadmap + +## [GSD.02] v2.0 + +### [GSD.02] 05: Real +**Goal:** a +`, 'bracket'); + const w = healthCodes('W007'); + assert.equal(w.length, 1, JSON.stringify(w)); + assert.match(w[0], /^Phase 77 exists on disk/, + 'un-threaded this reads "Phase GSD.02-77-orphan exists on disk"'); + }); + + test('CONTROL: the flat-legacy twin names its token the same way', () => { + mkdir('phases', '77-orphan'); + write(`# Roadmap + +## v2.0 + +### Phase 05: Real +**Goal:** a +`, undefined); + const w = healthCodes('W007'); + assert.equal(w.length, 1, JSON.stringify(w)); + assert.match(w[0], /^Phase 77 exists on disk/); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// The #3165 TRUNCATED-WINDOW FALLBACK — `roadmap analyze`'s second reading pass. +// +// #3428 (merged in at next @ 483083ae) gave `cmdRoadmapAnalyze` a recovery path: +// when the scoped milestone window is suspect (non-COMPLETE scope), found zero +// phases, and phase directories exist on disk, it re-scans the +// shipped-milestone-stripped document through the SAME extracted enrichment +// (`collectAnalyzePhases`). That extraction turned every seam this PR threads +// into a TWO-call-site seam, and the second site was invisible to the suite: +// passing a null convention there — while the scoped site stayed threaded — +// was green across all 518 tests of the bracket, roadmap and milestone-window +// files. Every pre-existing bracket assertion reaches the enrichment through +// the SCOPED window, so none of them can see the fallback's reading at all. +// +// The shape below is the one that reaches it on a bracket repo: a MID-MIGRATION +// ROADMAP whose ACTIVE milestone is bracketed, whose phase-detail sections sit +// after an intervening CLOSED legacy milestone (so the window closes over prose +// only), and which still carries one legacy `### Phase N:` section of its own +// (so `hasPhaseEntries` — a convention-BLIND reader, see the disclosure below — +// sees the document as containing phases and the window as not, which is what +// classifies the window TRUNCATED). +// +// DISCLOSURE, measured and deliberately NOT fixed here: `hasPhaseEntries` +// (src/roadmap-parser.cts) is one of the convention-less readers this PR does +// not widen. On a PURE-bracket ROADMAP it finds no phase entries anywhere, so +// `classifyMilestoneWindow`'s row 8 cannot fire and the window classifies +// COMPLETE. The consequence is larger than a scope label: on the same layout +// as below but with no legacy section — bracket milestone, bracket phase +// headings after a closed milestone, canonical bracket directories on disk — +// `roadmap analyze` returns +// `{"scope":"complete","phase_count":0,"next_phase":null,"phases":[]}` where +// the flat-legacy twin of that document returns +// `{"scope":"truncated","phase_count":2,...}`. `scope: "complete"` with +// `phase_count: 0` is the "genuinely empty milestone" answer — precisely the +// indistinguishability #3184 introduced `scope` to remove — and +// `workflows/next.md` Route 0 iterates `.phases[]`, so the safety invariant +// #3165 exists to protect silently does not run, on the convention this epic +// is for. +// +// Not fixed in a merge round: widening `hasPhaseEntries` changes the value of +// the upstream-owned `scope` FIELD on bracket repos, which is a behaviour +// slice (the PR-2.5/PR-4 convention-less-readers question), not a conflict +// resolution. The mid-migration shape below is the reachable half — and it is +// the half a repo actually mid-migration has. +// ───────────────────────────────────────────────────────────────────────────── +describe('#612 PR-2: the #3165 truncated-window fallback reads bracket phases too', () => { + beforeEach(() => { tmpDir = createTempProject('adr-612-analyze-fallback-'); }); + afterEach(() => { cleanup(tmpDir); }); + + const writeWithMilestone = (roadmap, convention) => { + write(roadmap, convention); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), '---\nmilestone: v2.0\n---\n', 'utf-8'); + }; + + // The checklist bullets sit BESIDE the detail headings, past the closed + // milestone heading, deliberately: inside the window they would give the + // window phase entries of its own, `classifyMilestoneWindow` would call it + // COMPLETE, and the recovery path this block is about would never fire. + const BRK = `# Roadmap + +## [GSD.02] v2.0 Current 🚧 + +Active milestone prose. + +## v1.0 Old ✅ SHIPPED + +### Phase 99: Legacy leftover +**Goal:** z + +- [ ] **[GSD.02] 01: One** +- [ ] **[GSD.02] 02: Two** + +### [GSD.02] 01: One +**Goal:** a + +### [GSD.02] 02: Two +**Goal:** b +`; + const LEG = `# Roadmap + +## v2.0 Current 🚧 + +Active milestone prose. + +## v1.0 Old ✅ SHIPPED + +### Phase 99: Legacy leftover +**Goal:** z + +- [ ] **Phase 01: One** +- [ ] **Phase 02: Two** + +### Phase 01: One +**Goal:** a + +### Phase 02: Two +**Goal:** b +`; + + const mkDirs = (specs) => { + for (const [dir, stem, complete] of specs) { + const q = path.join(tmpDir, '.planning', 'phases', dir); + fs.mkdirSync(q, { recursive: true }); + fs.writeFileSync(path.join(q, `${stem}-01-PLAN.md`), '# plan\n', 'utf-8'); + if (complete) { + fs.writeFileSync(path.join(q, `${stem}-01-SUMMARY.md`), '# summary\n', 'utf-8'); + fs.writeFileSync( + path.join(q, `${stem}-VERIFICATION.md`), '---\nstatus: passed\n---\n# Verification\n', 'utf-8'); + } + } + }; + const BRK_DIRS = [['GSD.02-01-one', 'GSD.02-01', true], ['GSD.02-02-two', 'GSD.02-02', false]]; + const LEG_DIRS = [['01-one', '01', true], ['02-two', '02', false]]; + const diskShape = (a) => a.phases.map(p => [p.number, p.disk_status, p.plan_count, p.summary_count]); + + const EXPECTED = [ + ['99', 'no_directory', 0, 0], + ['01', 'complete', 1, 1], + ['02', 'planned', 1, 0], + ]; + + test('the fallback is what supplies the phases here — without dirs on disk it stays gated off', () => { + // Non-vacuity: the SCOPED window contributes nothing on this document. With + // the on-disk evidence removed, #3428's own precondition fails and the + // result is the empty one the recovery path exists to replace. Any later + // assertion about `disk_status` below is therefore an assertion about the + // FALLBACK's reading, not the scoped scan's. + writeWithMilestone(BRK, 'bracket'); + const a = analyze(); + assert.equal(a.scope, 'truncated', 'the window must be suspect or nothing recovers'); + assert.equal(a.phase_count, 0, 'the scoped window genuinely finds no phases'); + }); + + test('bracket phases recovered by the fallback resolve their DIRECTORIES', () => { + writeWithMilestone(BRK, 'bracket'); + mkDirs(BRK_DIRS); + const a = analyze(); + assert.equal(a.scope, 'truncated', 'still flagged best-effort, per #3165'); + assert.deepEqual(diskShape(a), EXPECTED, + 'un-threaded, the fallback does not match the bracket headings at all: phase_count 1'); + }); + + test('and the recovered phases are not then reported as missing detail sections', () => { + // #2761 M1 x #3165: `detailKeys` — the occurrence index the checklist scan + // is filtered against — must be swapped for the FALLBACK scan's, in the + // same breath as `phases` and `effectiveContent`. Left holding the scoped + // scan's (empty) index while the checklist scan reads the fallback + // document, every phase the recovery just found is reported as a checklist + // entry with no detail section: `missing_phase_details: ["01","02"]`, + // wrong-and-confident on exactly the output #3165 exists to populate. + writeWithMilestone(BRK, 'bracket'); + mkDirs(BRK_DIRS); + const a = analyze(); + assert.equal(a.phase_count, 3, 'guard: the recovery fired, so this is the fallback index'); + assert.equal(a.missing_phase_details, null, + 'every checklist bullet has a detail heading in the same document'); + }); + + test('CONTROL: a checklist entry with NO detail heading is still reported', () => { + // The companion direction: a fix that simply suppressed the report would + // pass the test above. A bullet with no heading anywhere must still surface. + writeWithMilestone(BRK.replace( + '- [ ] **[GSD.02] 02: Two**', + '- [ ] **[GSD.02] 02: Two**\n- [ ] **[GSD.02] 07: Never written**'), 'bracket'); + mkDirs(BRK_DIRS); + assert.deepEqual(analyze().missing_phase_details, ['07']); + }); + + test('CONTROL: and that shape equals its flat-legacy twin exactly', () => { + writeWithMilestone(BRK, 'bracket'); + mkDirs(BRK_DIRS); + const bracket = diskShape(analyze()); + cleanup(tmpDir); + tmpDir = createTempProject('adr-612-analyze-fallback-leg-'); + writeWithMilestone(LEG, undefined); + mkDirs(LEG_DIRS); + const legacy = diskShape(analyze()); + assert.deepEqual(bracket, legacy, + 'the fallback must read a bracket document exactly as it reads the legacy twin'); + assert.deepEqual(legacy, EXPECTED, 'and the twin is the right answer, not a shared wrong one'); + }); + + test('CONTROL: a NON-bracket repo takes the same path and is unaffected', () => { + writeWithMilestone(LEG, undefined); + mkDirs(LEG_DIRS); + assert.deepEqual(diskShape(analyze()), EXPECTED); + }); +}); diff --git a/tests/adr-612-bracket-selection.property.test.cjs b/tests/adr-612-bracket-selection.property.test.cjs new file mode 100644 index 000000000..8c8a1b840 --- /dev/null +++ b/tests/adr-612-bracket-selection.property.test.cjs @@ -0,0 +1,401 @@ +'use strict'; + +/** + * PR-2 (#2761 / epic #612) — generative properties for the SELECTION/TOLERANCE + * layer. + * + * Scope, and why this is not a second copy of PR-1's grammar properties: + * PR-1 (#2258) owns the bracket grammar itself — `parsePhaseId`/`renderPhaseId`/ + * `toDir` and their round-trip/bijection properties, which live in + * tests/adr-612-bracket-grammar.test.cjs. PR-2 owns a different contract: WHICH + * pattern each reader compiles, decided by the project's resolved + * `phase_id_convention`. That contract is what this file generates against. + * + * Four properties, one per claim the PR actually makes: + * P1 a repo that OPTED IN reads ADR-canonical bracket headings/dirs; a repo + * that did not is byte-blind to exactly the same input. + * P2 under a non-bracket convention every selected reader agrees with the + * hand-transcribed BASE source on generated content — including hostile + * legacy content that merely LOOKS bracketed (`[RFC.2119] 5:`). + * P3 a mutated bracket token is not silently accepted; a case-varied one is + * accepted and FOLDS to the same key. + * P4 both sides of a phase comparison derive the same key under the same + * convention, and convention-less call sites are byte-identical. + * + * Generator discipline (the lessons #2258 rounds 1-2 were burned by, applied): + * - Every input is TEMPLATED FROM RAW PRIMITIVES. Nothing is seeded through + * `renderPhaseId`/`toDir`, so the generator's domain is not defined by the + * code under test — the `p2()` tautology that made round 1's property test + * structurally unable to find B1. + * - The generator domain is >= the code domain: the milestone/token + * arbitraries reach past 99 into the 3+-digit branch (round 2's + * `numArb`-capped-at-99 miss) and hit both sentinels (`00`, `999`). + * - Sub-phases are FORCED IN at high weight rather than left to chance + * (round 2's missing sub-phase mutation). + * - P3 is a NEGATIVE property: canonical input is mutated per-field and + * rejection is asserted. Deterministic boundary literals for the same + * region live alongside, in adr-612-bracket-heading-selection.test.cjs. + * + * Deterministic per CONTRIBUTING.md: seed and numRuns are pinned by + * tests/helpers/fast-check-setup.cjs (seed 42, numRuns 200); set GSD_FC_SEED to + * explore locally. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); + +const fc = require('./helpers/fast-check-setup.cjs'); +const core = require('../gsd-core/bin/lib/phase-id.cjs'); + +const B = core.PHASE_HEADING_BASELINE; +const { + phaseHeadingPrefixSrcFor, + extractPhaseToken, + phaseKeyFromDir, + phaseKeyFromToken, + bracketQualifiedKey, + foldBracketId, + isSentinelPhaseId, + phaseTokenMatches, +} = core; + +// ─── Base spellings, transcribed by hand (NOT read from the selector) ──────── +// These are what each call site's regex contained before PR-2. P2 compares the +// selector's non-bracket output against these over GENERATED CONTENT, which is +// what makes it a property rather than a restatement of the 14-site structural +// identity test in adr-612-bracket-heading-selection.test.cjs. +const BASE_SRC = { + [B.ANY_BRACKET]: '(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+', + [B.LABEL_ONLY]: 'Phase\\s+', +}; + +const NON_BRACKET_CONVENTIONS = [null, undefined, 'milestone-prefixed', 'legacy', '']; + +/** + * Compile a reader the way the shipped call sites do. The leading group is the + * markdown FURNITURE those sites carry (`#{2,4}\s*` on the heading scanners, + * `[\s*_]*` plus a checkbox on the checklist scanners); without it every + * generated line that looks like real ROADMAP content would fail to match on + * BOTH sides and the differential property below would hold vacuously. + */ +const compile = introSrc => + new RegExp( + `^(?:#{2,4}[ \t]*|[-*+][ \t]*\\[[ xX]\\][ \t]*)?[*_]*${introSrc}([\\w][\\w.-]*)\\s*:`, + 'i', + ); + +const heading = (baseline, convention) => compile(phaseHeadingPrefixSrcFor(baseline, convention)); + +// ─── Generators: raw primitives, hand-templated ────────────────────────────── + +const UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; +const CODE_TAIL = UPPER + '0123456789_'; + +/** `GSD`, `A`, `CK2`, `PROJ_X` — the emit grammar's project-code shape. */ +const codeArb = fc + .tuple( + fc.constantFrom(...UPPER), + fc.string({ unit: fc.constantFrom(...CODE_TAIL), maxLength: 5 }), + ) + .map(([head, tail]) => head + tail); + +/** + * Milestone integers the bracket grammar admits: EXACTLY two digits, or three + * or more with no leading zero. Deliberately reaches past 99 and pins both + * sentinel milestones (`00` pre-milestone, `999` icebox). + */ +const milestoneArb = fc.oneof( + { arbitrary: fc.integer({ min: 0, max: 99 }).map(n => String(n).padStart(2, '0')), weight: 4 }, + { arbitrary: fc.integer({ min: 100, max: 99999 }).map(String), weight: 3 }, + { arbitrary: fc.constantFrom('00', '01', '99', '100', '101', '998', '999', '1000'), weight: 2 }, +); + +/** Phase tokens: 2-digit, 3+-digit, and sub-phases FORCED IN at high weight. */ +const tokenArb = fc.oneof( + { arbitrary: fc.integer({ min: 0, max: 99 }).map(n => String(n).padStart(2, '0')), weight: 4 }, + { arbitrary: fc.integer({ min: 100, max: 9999 }).map(String), weight: 2 }, + { + arbitrary: fc + .tuple(fc.integer({ min: 0, max: 99 }), fc.integer({ min: 0, max: 99 })) + .map(([p, s]) => `${String(p).padStart(2, '0')}.${String(s).padStart(2, '0')}`), + weight: 3, + }, + { arbitrary: fc.constantFrom('00', '01', '99', '100', '999', '01.01', '100.100'), weight: 1 }, +); + +const nameArb = fc + .string({ unit: fc.constantFrom(...(UPPER + 'abcdefghijklmnopqrstuvwxyz ')), minLength: 1, maxLength: 20 }) + .map(s => s.trim() || 'Name'); + +/** Markdown furniture the real call sites tolerate ahead of the intro. */ +const furnitureArb = fc.constantFrom('', '## ', '### ', '#### ', '- [ ] ', '- [x] ', '- [x] **'); + +/** The ADR-canonical, LABEL-LESS bracket heading — the shape only opt-in reads. */ +const bracketHeadingArb = fc + .record({ code: codeArb, milestone: milestoneArb, token: tokenArb, name: nameArb, furniture: furnitureArb }) + .map(r => ({ ...r, text: `${r.furniture}[${r.code}.${r.milestone}] ${r.token}: ${r.name}` })); + +/** The bracket DIRECTORY shape `{CODE}.{MM}-{PP}[.{SS}]-slug`. */ +const bracketDirArb = fc + .record({ code: codeArb, milestone: milestoneArb, token: tokenArb, slug: fc.constantFrom('alpha', 'beta-two', 'x', 'a-b-c') }) + .map(r => { + const dir = `${r.code}.${r.milestone}-${r.token}-${r.slug}`; + // A short letters+digit project-code prefix is string-indistinguishable + // from the shipped letter-prefixed-decimal family (for example A1.00-00). + // Derive that legacy oracle from generator primitives, not the parser. + const firstDigit = [...r.code].findIndex(ch => ch >= '0' && ch <= '9'); + const hasLegacyLetterDecimalPrefix = firstDigit >= 1 + && firstDigit <= 3 + && [...r.code.slice(0, firstDigit)].every(ch => UPPER.includes(ch)); + const legacyReading = hasLegacyLetterDecimalPrefix + ? `${r.code}.${r.milestone}-${r.token}` + : dir; + return { ...r, dir, legacyReading }; + }); + +// ─── P1 — the selection gate ───────────────────────────────────────────────── + +describe('#612 PR-2 property: the convention selects, and only the convention', () => { + test('P1a a label-less bracket heading is read IFF the convention is bracket', () => { + fc.assert( + fc.property(bracketHeadingArb, fc.constantFrom(B.ANY_BRACKET, B.LABEL_ONLY), (h, baseline) => { + const opted = heading(baseline, 'bracket').exec(h.text); + assert.ok(opted, `opted-in repo failed to read ${JSON.stringify(h.text)}`); + assert.equal(opted[1], h.token, 'the captured token must be the phase token, not the bracket'); + + for (const convention of NON_BRACKET_CONVENTIONS) { + assert.equal( + heading(baseline, convention).test(h.text), + false, + `convention ${JSON.stringify(convention)} must stay blind to ${JSON.stringify(h.text)}`, + ); + } + }), + ); + }); + + test('P1b a bracket DIRECTORY yields its phase token IFF the convention is bracket', () => { + fc.assert( + fc.property(bracketDirArb, d => { + assert.equal(extractPhaseToken(d.dir, 'bracket'), d.token); + for (const convention of NON_BRACKET_CONVENTIONS) { + assert.equal( + extractPhaseToken(d.dir, convention), + d.legacyReading, + `convention ${JSON.stringify(convention)} must preserve the exact legacy reading of ${JSON.stringify(d.dir)}`, + ); + } + }), + ); + }); + + test('P1c the bracket MILESTONE carries the sentinel, independent of the token', () => { + // READING-B: `### [GSD.999] 01:` is an icebox item even though its token is + // an ordinary `01`. The token alone can never decide this. + fc.assert( + fc.property(bracketHeadingArb, h => { + const milestoneInt = parseInt(h.milestone, 10); + const expected = milestoneInt === 0 || milestoneInt === 999; + assert.equal( + isSentinelPhaseId(`${h.code}.${h.milestone}-${h.token}`, 'bracket'), + expected, + `milestone ${h.milestone} sentinel classification`, + ); + }), + ); + }); +}); + +// ─── P2 — differential invariance for every non-bracket convention ─────────── + +/** Legacy and hostile-but-plausible legacy lines. No bracket phase headings. */ +const legacyLineArb = fc.oneof( + { + arbitrary: fc.record({ t: tokenArb, n: nameArb }).map(r => `### Phase ${r.t}: ${r.n}`), + weight: 4, + }, + { + arbitrary: fc.record({ t: tokenArb, n: nameArb }).map(r => `- [x] **Phase ${r.t}: ${r.n}**`), + weight: 3, + }, + { + // The refutation corpus: bracket-DOTTED prose headings that predate this + // convention and must never be claimed as phases by a non-bracket repo. + arbitrary: fc + .record({ + tag: fc.constantFrom('RFC.2119', 'v1.0', 'ADR.612', 'SPEC.1', 'ISO.8601', 'GSD.02'), + t: tokenArb, + n: nameArb, + }) + .map(r => `### [${r.tag}] ${r.t}: ${r.n}`), + weight: 3, + }, + { + arbitrary: fc.record({ tag: fc.constantFrom('GSD.02', 'x'), t: tokenArb, n: nameArb }) + .map(r => `### [${r.tag}] Phase ${r.t}: ${r.n}`), + weight: 2, + }, + { arbitrary: nameArb.map(n => `Some prose about ${n}.`), weight: 1 }, +); + +describe('#612 PR-2 property: a repo that did not opt in compiles the base reader', () => { + test('P2 every non-bracket convention agrees with the BASE source on generated content', () => { + fc.assert( + fc.property( + fc.array(legacyLineArb, { minLength: 1, maxLength: 12 }), + fc.constantFrom(B.ANY_BRACKET, B.LABEL_ONLY), + (lines, baseline) => { + const base = compile(BASE_SRC[baseline]); + for (const convention of NON_BRACKET_CONVENTIONS) { + const selected = heading(baseline, convention); + for (const line of lines) { + const a = base.exec(line); + const b = selected.exec(line); + assert.equal( + a === null, + b === null, + `recognition diverged from base on ${JSON.stringify(line)} (convention ${JSON.stringify(convention)})`, + ); + if (a && b) { + assert.equal(b[1], a[1], `captured token diverged from base on ${JSON.stringify(line)}`); + } + } + } + }, + ), + ); + }); +}); + +// ─── P3 — negative properties: mutate every field, assert non-acceptance ───── + +/** + * Each mutation breaks ONE field of a canonical bracket heading. `folds` marks + * the one mutation that must still be ACCEPTED (case is folded, not rejected) — + * asserting rejection there would pin the wrong contract. + */ +const MUTATIONS = [ + { name: 'unpadded milestone', folds: false, apply: h => h.milestone.length === 2 && h.milestone[0] === '0' ? `${h.furniture}[${h.code}.${h.milestone.slice(1)}] ${h.token}: ${h.name}` : null }, + { name: 'overpadded milestone', folds: false, apply: h => `${h.furniture}[${h.code}.0${h.milestone}] ${h.token}: ${h.name}` }, + { name: 'non-numeric milestone', folds: false, apply: h => `${h.furniture}[${h.code}.AB] ${h.token}: ${h.name}` }, + { name: 'unclosed bracket', folds: false, apply: h => `${h.furniture}[${h.code}.${h.milestone} ${h.token}: ${h.name}` }, + { name: 'nested bracket', folds: false, apply: h => `${h.furniture}[${h.code}.[${h.milestone}]] ${h.token}: ${h.name}` }, + { name: 'hyphen for dot', folds: false, apply: h => `${h.furniture}[${h.code}-${h.milestone}] ${h.token}: ${h.name}` }, + { name: 'colon for dot', folds: false, apply: h => `${h.furniture}[${h.code}:${h.milestone}] ${h.token}: ${h.name}` }, + { name: 'paren for close bracket', folds: false, apply: h => `${h.furniture}[${h.code}.${h.milestone}) ${h.token}: ${h.name}` }, + { name: 'lowercased code', folds: true, apply: h => `${h.furniture}[${h.code.toLowerCase()}.${h.milestone}] ${h.token}: ${h.name}` }, +]; + +describe('#612 PR-2 property: a broken bracket token is not silently accepted', () => { + for (const mutation of MUTATIONS) { + test(`P3 ${mutation.name} — ${mutation.folds ? 'folds to the same key' : 'is not read as a phase'}`, () => { + fc.assert( + fc.property(bracketHeadingArb, fc.constantFrom(B.ANY_BRACKET, B.LABEL_ONLY), (h, baseline) => { + const mutated = mutation.apply(h); + if (mutated === null) return; // mutation not applicable to this sample + const reader = heading(baseline, 'bracket'); + + if (mutation.folds) { + assert.ok(reader.test(mutated), `case variation must still be read: ${JSON.stringify(mutated)}`); + assert.equal( + bracketQualifiedKey(`${h.code.toLowerCase()}.${h.milestone}-${h.token}`, 'bracket'), + bracketQualifiedKey(`${h.code}.${h.milestone}-${h.token}`, 'bracket'), + 'a case variation must fold to the identical qualified key', + ); + return; + } + + assert.equal( + reader.test(mutated), + false, + `${mutation.name} must not be read as a phase: ${JSON.stringify(mutated)}`, + ); + }), + ); + }); + } + + test('P3z a malformed bracket forms no qualified key, on any convention', () => { + fc.assert( + fc.property(bracketHeadingArb, fc.constantFrom(...MUTATIONS.filter(m => !m.folds).map(m => m.name)), (h, name) => { + const mutation = MUTATIONS.find(m => m.name === name); + const mutated = mutation.apply(h); + if (mutated === null) return; + // The id form of the same breakage: strip the heading furniture. + const id = mutated.replace(/^(?:#{2,4}[ \t]*|[-*+][ \t]*\[[ xX]\][ \t]*)?[*_]*/, '').replace(/^\[/, '').replace(/\]\s*/, '-').replace(/:.*$/, '').trim(); + assert.equal(bracketQualifiedKey(id, 'bracket'), null, `${name} must form no key: ${JSON.stringify(id)}`); + for (const convention of NON_BRACKET_CONVENTIONS) { + assert.equal(bracketQualifiedKey(id, convention), null); + } + }), + ); + }); +}); + +// ─── P4 — both sides of a comparison, one convention ───────────────────────── + +describe('#612 PR-2 property: comparison sides derive under the same convention', () => { + test('P4a a bracket DIR and its ROADMAP TOKEN produce the identical key', () => { + // The #2562 defect class reached from the other side: the promoted + // `phaseKeyFromDir` derives both sides with the same FUNCTION, but a bracket + // dir still needs the same CONVENTION or it keys to its own whole name. + fc.assert( + fc.property(bracketDirArb, d => { + assert.equal(phaseKeyFromDir(d.dir, 'bracket'), phaseKeyFromToken(d.token)); + }), + ); + }); + + test('P4b a convention-less call site is byte-identical across all its spellings', () => { + fc.assert( + fc.property( + fc.oneof( + bracketDirArb.map(d => d.dir), + fc.record({ t: tokenArb, s: fc.constantFrom('alpha', 'beta-two', 'x') }).map(r => `${r.t}-${r.s}`), + fc.record({ c: codeArb, t: tokenArb }).map(r => `${r.c}-${r.t}-slug`), + ), + dir => { + const bare = phaseKeyFromDir(dir); + assert.equal(phaseKeyFromDir(dir, undefined), bare); + assert.equal(phaseKeyFromDir(dir, null), bare); + assert.equal(phaseKeyFromDir(dir, 'milestone-prefixed'), bare); + }, + ), + ); + }); + + test('P4c the qualified key separates same-token phases of different milestones', () => { + fc.assert( + fc.property( + fc.record({ code: codeArb, a: milestoneArb, b: milestoneArb, token: tokenArb, slug: fc.constantFrom('one', 'two') }), + r => { + fc.pre(parseInt(r.a, 10) !== parseInt(r.b, 10)); + const dirA = `${r.code}.${r.a}-${r.token}-${r.slug}`; + const qualifiedB = `${r.code}.${r.b}-${r.token}`; + assert.equal(phaseTokenMatches(dirA, `${r.code}.${r.a}-${r.token}`, 'bracket'), true); + assert.equal( + phaseTokenMatches(dirA, qualifiedB, 'bracket'), + false, + `${dirA} must not answer to milestone ${r.b}`, + ); + assert.notEqual( + bracketQualifiedKey(`${r.code}.${r.a}-${r.token}`, 'bracket'), + bracketQualifiedKey(qualifiedB, 'bracket'), + ); + }, + ), + ); + }); + + test('P4d foldBracketId is idempotent and case-collapsing', () => { + fc.assert( + fc.property(bracketDirArb, d => { + const id = `${d.code}.${d.milestone}-${d.token}`; + const folded = foldBracketId(id); + assert.equal(foldBracketId(folded), folded, 'fold must be idempotent'); + assert.equal(foldBracketId(id.toLowerCase()), folded); + assert.equal(foldBracketId(id.toUpperCase()), folded); + }), + ); + }); +}); diff --git a/tests/continuation-grammar-parity.test.cjs b/tests/continuation-grammar-parity.test.cjs index 419eecca8..c6472853d 100644 --- a/tests/continuation-grammar-parity.test.cjs +++ b/tests/continuation-grammar-parity.test.cjs @@ -28,6 +28,10 @@ * 5. roadmap-parser.cjs getMilestonePhaseFilter → isDirInMilestone (hyphenated mode) * 6. phase-id.cjs BRACKET_PHASE_TOKEN_SOURCE (slug-adjacent position only — * see the divergence block at the foot of this file) + * 7. validate.cjs buildRoadmapPhaseVariants (#2761 bracket heading read) + * 8. validate.cjs phaseTokenFromDir vs phase-id.cjs extractPhaseToken + * (#2761 bracket DIRECTORY read — the two readers that + * resolve a phase directory on the `validate health` path) */ const { test, describe } = require('node:test'); @@ -370,6 +374,81 @@ describe('#2232 continuation-grammar parity — roadmap isDirInMilestone (hyphen // dot no slug can contain), not heuristically recognized, so they carry the // canonical width toDir emits — while #2232's cap defends the one position that // sits against a slug. +// ── Surface 7: the heading read agrees with the dir read about WHICH phase ── +describe('#2761 surface 7 — heading read and dir read name the same phase', () => { + for (const { seg, absorbed, note } of WIDTH_CORPUS) { + test(`width ${seg.length} (${JSON.stringify(seg)}): absorbed=${absorbed} — ${note}`, () => { + const owner = phaseId.isPhaseContinuationSegment(seg); + const headingToken = `14-${seg}`; + const dir = `14-${seg}-photos-performance`; + // A heading token carries no slug, so its grammar is the letter-tolerant + // one, NOT the continuation grammar. What must hold is that the two agree + // about which phase a `MM-` pair names — otherwise a phase named in + // the ROADMAP resolves to the wrong directory, or to none. + const { roadmapPhases } = validate.buildRoadmapPhaseVariants(`### Phase ${headingToken}: Photos`); + assert.ok(roadmapPhases.has(headingToken), `heading token dropped: ${headingToken}`); + assert.strictEqual( + phaseId.extractPhaseToken(dir) === headingToken, owner, + `heading/dir disagreement on ${JSON.stringify(headingToken)}`, + ); + // The BRACKET spelling of the same heading must yield the same phase set: + // the widened intro changes which SPELLINGS are seen, never which TOKEN a + // heading yields. + const bracket = validate.buildRoadmapPhaseVariants( + `### [GSD.01] ${headingToken}: Photos`, 'bracket'); + assert.deepEqual([...bracket.roadmapPhases], [...roadmapPhases], + 'bracket and legacy spellings of one heading must yield the same phase set'); + }); + } +}); + +// ── Surface 8: the two bracket DIRECTORY readers, both directions ─────────── +// `validate health` resolves a bracket phase directory twice in one run: W005 / +// W006 / W007 through validate.phaseTokenFromDir, and the W021 +// milestone-complete check through phaseTokenMatches -> extractPhaseToken. A +// disagreement makes the run contradict itself — W007 resolving a directory that +// W021 simultaneously reports as an unstarted phase. +describe('#2761 surface 8 — one bracket directory token rule, two call paths', () => { + const ACCEPTED = [ + 'GSD.02-05-feature', 'GSD.02-05.03-feature', 'GSD.02-05', 'CK.01-12.04-feature', + 'GSD_X2.100-05-feature', 'GSD.02-05-2026-photos', 'GSD.999-01-icebox', + // DISCLOSED: string-indistinguishable from a padded bracket dir, so a repo + // that has opted into bracket reads it as one. Listed here because the point + // of this surface is that BOTH readers do the same thing with it. + 'P0.34-56-name', + ]; + // Shapes outside the emit grammar (CANONICAL_NUMERIC_RE is digits-only with at + // most one sub-phase), plus legacy and ambiguous forms. + const REJECTED = [ + 'GSD.02-12A-hotfix', 'GSD.02-05.03.07-x', 'GSD.2-05-x', 'GSD.02', + '02-01-setup', 'GSD-02-01-setup', 'not-a-phase', 'P0.3-2-tenant', 'P0.16-gate', + ]; + + for (const dir of ACCEPTED) { + test(`accepted: ${dir} — both readers agree`, () => { + assert.ok(validate.BRACKET_PHASE_DIR_RE.test(dir), 'precondition: recognized'); + assert.strictEqual( + validate.phaseTokenFromDir(dir, 'bracket'), + phaseId.extractPhaseToken(dir, 'bracket'), + ); + }); + } + + for (const dir of REJECTED) { + test(`rejected: ${dir} — the owner does not bracket-resolve it either`, () => { + assert.strictEqual(validate.BRACKET_PHASE_DIR_RE.test(dir), false, 'precondition: rejected'); + // The half that was previously unpinned: agreement on REJECTED input. The + // owner must fall through to its legacy reading rather than produce a + // bracket token the recognizer refuses. + assert.strictEqual( + phaseId.extractPhaseToken(dir, 'bracket'), + phaseId.extractPhaseToken(dir), + 'owner bracket-resolved a directory the recognizer rejects', + ); + }); + } +}); + describe('#612 bracket divergence — wider only where the delimiter disambiguates', () => { const tokenOf = (s) => s.match(new RegExp(phaseId.BRACKET_PHASE_TOKEN_SOURCE))?.[0]; @@ -467,3 +546,77 @@ describe('#2528 grammar edges without production consumers', () => { } }); }); + +// ─── #2761 M3: the BRACKET MILESTONE INTRO grammar — one owner, two shapes ── +// +// trek-e's finding: this grammar was re-typed in roadmap-parser (the +// bracket-fallback selector), state (`isMilestoneBounded`) and verify +// (`checkBracketCoherence`), which #2761's own gate forbids, and +// `check:phase-id-drift` could not see it. All three now consume +// `phase-id.cjs`, so the LITERAL divergence is closed and +// tests/phase-id-drift-guard.test.cjs proves the guard now fails on each +// shipped copy. +// +// This is the behavioral half: the owner exports the intro in TWO shapes — +// PINNED to one milestone, and CAPTURING over any — and nothing structurally +// forces them to agree about what a milestone intro IS. They are two doors onto +// one rule, so a corpus drives both and requires the same verdict. Widening +// either one alone (the `0*N` acceptance that reopened the unscoped-milestone +// defect) fails here. +describe('#2761 bracket milestone intro — the pinned and capturing shapes agree', () => { + // Stated as POLICY, independently of either regex: the canonical spelling is + // exactly what toDir emits — pad2 for 0-99, no leading zero beyond that. + // Anything else is malformed and scopes nothing. + const INTRO_CORPUS = [ + { text: '[GSD.00] Zero', milestone: 0, canonical: true, note: 'pad2 lower bound' }, + { text: '[GSD.02] Foundation', milestone: 2, canonical: true, note: 'the ADR example' }, + { text: '[GSD.99] Late', milestone: 99, canonical: true, note: 'pad2 upper bound' }, + { text: '[GSD.100] Later', milestone: 100, canonical: true, note: '3-digit, no leading zero' }, + { text: '[GSD.999] Icebox', milestone: 999, canonical: true, note: 'the backlog sentinel' }, + { text: '[A_B9.02] Underscored', milestone: 2, canonical: true, note: 'the full code class' }, + { text: '[gsd.02] Lowercased', milestone: 2, canonical: true, note: 'readers compile /i' }, + { text: '[GSD.2] Unpadded', milestone: 2, canonical: false, note: 'unpadded scopes nothing' }, + { text: '[GSD.002] Overpadded', milestone: 2, canonical: false, note: 'over-padded is malformed' }, + { text: '[9SD.02] Digit-led code', milestone: 2, canonical: false, note: 'a code starts with a letter' }, + { text: '[GSD-02] Hyphenated', milestone: 2, canonical: false, note: 'the separator is a dot' }, + { text: '[GSD.] Empty milestone', milestone: 0, canonical: false, note: 'the milestone field is required' }, + ]; + + const capturing = new RegExp(`^${phaseId.BRACKET_MILESTONE_INTRO_CAPTURING_SRC}`, 'i'); + + for (const { text, milestone, canonical, note } of INTRO_CORPUS) { + test(`${JSON.stringify(text)}: both shapes say canonical=${canonical} — ${note}`, () => { + const pinned = new RegExp(`^${phaseId.bracketMilestoneIntroSrcFor(milestone)}`, 'i'); + const byPinned = pinned.test(text); + const byCapturing = capturing.test(text); + assert.strictEqual( + byPinned, byCapturing, + `the pinned and capturing shapes disagreed on ${JSON.stringify(text)} — ` + + 'they are two doors onto one rule and must never diverge', + ); + assert.strictEqual(byPinned, canonical, `verdict must match the locked policy — ${note}`); + }); + } + + test('the capturing shape reports the milestone the pinned shape was built for', () => { + // Beyond agreeing on accept/reject: when both accept, they must be talking + // about the SAME milestone. A capture-group or padding slip shows up here. + for (const { text, milestone, canonical } of INTRO_CORPUS) { + if (!canonical) continue; + assert.strictEqual( + parseInt(text.match(capturing)[1], 10), milestone, + `${text}: the captured milestone must be ${milestone}`, + ); + } + }); + + test('a milestone intro is not confused with a phase id sharing its code', () => { + // `[GSD.02]` is a MILESTONE intro; `GSD.02-01` is a phase DIRECTORY. Both + // are built from BRACKET_PROJECT_CODE_SRC, so this pins that sharing one + // code class does not collapse the two readings. + const pinned = new RegExp(`^${phaseId.bracketMilestoneIntroSrcFor(2)}`, 'i'); + assert.ok(!pinned.test('GSD.02-01-setup'), 'a directory name is not a bracket intro'); + assert.ok(pinned.test('[GSD.02] 01: Setup'), 'a bracket PHASE heading still carries the intro'); + assert.strictEqual(phaseId.bracketQualifiedKey('GSD.02-01', 'bracket'), 'GSD.2-1'); + }); +}); diff --git a/tests/edit-phase-milestone-scope-guard.test.cjs b/tests/edit-phase-milestone-scope-guard.test.cjs index 58c3b47f5..2975fa7c6 100644 --- a/tests/edit-phase-milestone-scope-guard.test.cjs +++ b/tests/edit-phase-milestone-scope-guard.test.cjs @@ -285,3 +285,157 @@ describe('#3262 phase add-batch milestone-scope heading guard', () => { } }); }); + +// ─── #612 / #2761: the bracket-convention arm of the same guard ────────────── + +/** + * The #3262 guard is defined as a MIRROR of the parser's terminator vocabulary. + * On a bracket-convention repo this branch teaches `computeMilestoneSectionEnd`'s + * bracket extension a terminator the legacy vocabulary has no word for: the + * ADR-canonical `## [GSD.09] Hidden` carries no vN.N token, no ✅/📋/🚧/🔄 + * marker, and not the word "Milestone". Unmirrored, the guard accepted exactly + * the description that narrows the window — measured at this CLI seam: two + * `phase add` calls, the second phase present in ROADMAP.md and absent from + * `roadmap milestone-scope`'s phase set. + * + * Every case below has a NON-bracket control, because the whole contract is + * that a project which has not opted in behaves byte-identically to base. + */ + +const BRACKET_ROADMAP = [ + '# Roadmap', + '', + '## [GSD.02] Foundation', + '', + '### [GSD.02] 01: One', + '', + '**Goal:** one', + '', +].join('\n'); + +function writeBracketProject(tmpDir) { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ project_code: 'GSD', phase_id_convention: 'bracket' }, null, 2) + ); + writeStateMilestone(tmpDir, 'v2.0'); + writeRoadmap(tmpDir, BRACKET_ROADMAP); +} + +function writeLegacyTwinProject(tmpDir) { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ project_code: 'GSD' }, null, 2) + ); + writeStateMilestone(tmpDir, 'v2.0'); + writeRoadmap(tmpDir, BRACKET_ROADMAP); +} + +describe('#2761 milestone-scope guard — bracket convention arm (#612)', () => { + test('roadmap milestone-scope reports the bracket window phase set instead of an empty one', () => { + const tmp = createTempProject('gsd-2761-scope-'); + try { + writeBracketProject(tmp); + writeRoadmap(tmp, [ + BRACKET_ROADMAP, + '### [GSD.02] 02: Two', + '', + '**Goal:** two', + '', + '## [GSD.03] Later', + '', + '### [GSD.03] 01: Later one', + '', + '**Goal:** later', + '', + ].join('\n')); + const result = runMilestoneScope(tmp); + // Blind, this probe returns [] on a bracket ROADMAP — before AND after any + // write — so the workflow's equality check can never fail. + assert.deepEqual(result.phases, ['01', '02'], 'bracket phase ids must be visible to the probe'); + assert.equal(result.phase_count, 2, "the sibling milestone's phase must stay out of the window"); + } finally { + cleanup(tmp); + } + }); + + test('phase add rejects a bracket milestone heading in the description; ROADMAP and phase dirs untouched', () => { + const tmp = createTempProject('gsd-2761-add-'); + try { + writeBracketProject(tmp); + const before = readRoadmap(tmp); + const dirsBefore = listPhaseDirs(tmp); + const res = runGsdTools(['phase', 'add', 'Sneaky\n## [GSD.09] Hidden'], tmp); + assert.equal(res.success, false, 'phase add must reject a bracket milestone heading on a bracket repo'); + assert.match(res.error || res.output, /\[GSD\.09\] Hidden/, 'error must name the offending line'); + assert.equal(readRoadmap(tmp), before, 'ROADMAP.md must be byte-unchanged'); + assert.deepEqual(listPhaseDirs(tmp), dirsBefore, 'no phase directory may be created'); + } finally { + cleanup(tmp); + } + }); + + test('the same description is ACCEPTED on a non-bracket twin (opt-in only, base behaviour preserved)', () => { + const tmp = createTempProject('gsd-2761-add-legacy-'); + try { + writeLegacyTwinProject(tmp); + const res = runGsdTools(['phase', 'add', 'Sneaky\n## [GSD.09] Hidden'], tmp); + assert.equal(res.success, true, `a project that has not opted in must be unaffected: ${res.error || ''}`); + } finally { + cleanup(tmp); + } + }); + + test('phase insert and add-batch reject the same shape on a bracket repo', () => { + const tmp = createTempProject('gsd-2761-batch-'); + try { + writeBracketProject(tmp); + const before = readRoadmap(tmp); + const batch = runGsdTools( + ['phase', 'add-batch', '--descriptions', JSON.stringify(['Good phase', 'Bad\n## [GSD.09] Hidden'])], + tmp + ); + assert.equal(batch.success, false, 'add-batch must reject the bracket shape'); + assert.equal(readRoadmap(tmp), before, 'ROADMAP.md must be byte-unchanged (all-or-nothing)'); + + const insert = runGsdTools(['phase', 'insert', '1', 'Sneaky\n## [GSD.09] Hidden'], tmp); + assert.equal(insert.success, false, 'phase insert must reject the bracket shape'); + assert.equal(readRoadmap(tmp), before, 'ROADMAP.md must be byte-unchanged'); + } finally { + cleanup(tmp); + } + }); + + test('a bracket PHASE heading and a FENCED bracket milestone heading are not violations', () => { + const tmp = createTempProject('gsd-2761-nofalse-'); + try { + writeBracketProject(tmp); + const phaseHeading = runGsdTools(['phase', 'add', 'Refs\n### [GSD.02] 07: cross ref'], tmp); + assert.equal( + phaseHeading.success, + true, + `a bracket PHASE heading never terminates a window and must not be flagged: ${phaseHeading.error || ''}` + ); + + const fenced = runGsdTools(['phase', 'add', 'Docs\n\n```markdown\n## [GSD.09] Example\n```\n'], tmp); + assert.equal( + fenced.success, + true, + `a FENCED bracket milestone heading must not be flagged (parser is fence-aware): ${fenced.error || ''}` + ); + } finally { + cleanup(tmp); + } + }); + + test('ordinary bracket-repo descriptions still succeed (no false rejection)', () => { + const tmp = createTempProject('gsd-2761-ok-'); + try { + writeBracketProject(tmp); + const res = runGsdTools(['phase', 'add', 'Authentication and sessions'], tmp); + assert.equal(res.success, true, `ordinary add must keep working on a bracket repo: ${res.error || ''}`); + } finally { + cleanup(tmp); + } + }); +}); diff --git a/tests/enumeration-drift-guard.test.cjs b/tests/enumeration-drift-guard.test.cjs index 019ceb4d3..23fdacdb5 100644 --- a/tests/enumeration-drift-guard.test.cjs +++ b/tests/enumeration-drift-guard.test.cjs @@ -76,6 +76,34 @@ describe('#3185 phase-enumeration drift scanner: findPhaseEnumerationDrift (pure [], ); }); + + test('#2761 narrow 999-only rules are exempt only inside their named owners', () => { + const roadmapSource = [ + 'function scanMilestonePhaseIdSets() {', + ' return /^999\\b/.test(token);', + '}', + 'function unrelatedRoadmapReader() {', + ' return /^999\\b/.test(token);', + '}', + ].join('\n'); + assert.deepEqual( + findPhaseEnumerationDrift(roadmapSource, path.join('src', 'roadmap-parser.cts')), + [{ line: 5, found: '/^999\\b/' }], + ); + + const stateSource = [ + 'function countRoadmapPhaseHeadings() {', + ' return /^999\\b/.test(token);', + '}', + 'function unrelatedStateReader() {', + ' return /^999\\b/.test(token);', + '}', + ].join('\n'); + assert.deepEqual( + findPhaseEnumerationDrift(stateSource, path.join('src', 'state.cts')), + [{ line: 5, found: '/^999\\b/' }], + ); + }); }); describe('#3185 phase-enumeration drift scanner: stripComments (pure)', () => { diff --git a/tests/phase-id-drift-guard.test.cjs b/tests/phase-id-drift-guard.test.cjs index 149cbf39b..6df0196ce 100644 --- a/tests/phase-id-drift-guard.test.cjs +++ b/tests/phase-id-drift-guard.test.cjs @@ -24,7 +24,7 @@ const fs = require('node:fs'); const path = require('node:path'); const ROOT = path.join(__dirname, '..'); -const { findPhaseIdRegexDrift, scanRepo } = require( +const { findPhaseIdRegexDrift, findBracketGrammarDrift, scanRepo } = require( path.join(ROOT, 'scripts', 'lint-phase-id-drift.cjs'), ); const phaseId = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'phase-id.cjs')); @@ -46,6 +46,16 @@ const CANONICAL = [ 'phaseMarkdownRegexSourceExact', 'comparePhaseNum', 'extractPhaseToken', 'phaseTokenMatches', 'parsePhaseFromProse', 'stripConfiguredProjectCodePrefix', 'isForeignPrefixedPhaseQuery', 'roadmapPhaseLookupSources', + // #612 PR-2: the one bracket identity grammar + the gated heading-intro selector. + 'BRACKET_ID_SRC', 'BRACKET_MILESTONE_NUMERIC_SRC', 'BRACKET_DIR_PREFIX_SRC', + 'BASE_ANY_BRACKET_HEADING_PREFIX_SRC', 'BASE_PHASE_LABEL_PREFIX_SRC', + 'PHASE_HEADING_BASELINE', 'phaseHeadingPrefixSrcFor', 'foldBracketId', + 'bracketQualifiedKey', + // #2761 M3: the bracket project-code class and the two milestone-intro shapes + // three readers used to re-type. Locked here so a consumer cannot re-export a + // divergent copy of what it now imports. + 'BRACKET_PROJECT_CODE_SRC', 'bracketMilestoneIntroSrcFor', + 'BRACKET_MILESTONE_INTRO_CAPTURING_SRC', ]; describe('#2128 phase-id drift scanner: findPhaseIdRegexDrift (pure)', () => { @@ -121,16 +131,171 @@ describe('#2128 phase-id drift scanner: findPhaseIdRegexDrift (pure)', () => { }); }); +// ─── #2761 M3: the BRACKET grammar rule ──────────────────────────────────── +// +// trek-e's finding: the bracket grammar was re-typed in roadmap-parser, state +// and verify — a violation of #2761's own "no token literal outside +// src/phase-id.cts" gate — and `check:phase-id-drift` passed anyway, because +// its detector only knew the phase-NUMBER token. These are the guard's negative +// fixtures: the three literals AS THEY SHIPPED, transcribed here so the rule is +// proven against the real drift and not against a convenient stand-in. + +describe('#2761 M3 bracket drift scanner: findBracketGrammarDrift (pure)', () => { + const SHIPPED_DRIFT = [ + ['roadmap-parser.cts bracket-fallback selector', + 'const bracketMilestoneHeadingRe = new RegExp(`^\\\\[[A-Z][A-Z0-9_]*\\\\.${canonical}\\\\]`, \'i\');'], + ['state.cts isMilestoneBounded', + 'const bracketMilestoneHeadingRe = new RegExp(`^\\\\[[A-Z][A-Z0-9_]*\\\\.${canonical}\\\\]`, \'i\');'], + ['verify.cts checkBracketCoherence', + 'const bracketSectionRe = new RegExp(`^\\\\[[A-Z][A-Z0-9_]*\\\\.(${BRACKET_MILESTONE_NUMERIC_SRC})\\\\]`, \'i\');'], + ]; + + for (const [label, line] of SHIPPED_DRIFT) { + test(`flags the literal that shipped in ${label}`, () => { + const v = findBracketGrammarDrift(line); + assert.equal(v.length, 1, `the guard must flag ${label}`); + assert.equal(v[0].found, '[A-Z][A-Z0-9_]*'); + }); + } + + test('an owner reference on the SAME LINE does not excuse a re-typed class', () => { + // The precise blind spot. verify.cts's copy referenced the owner for the + // MILESTONE field while re-typing the PROJECT-CODE class, so a line-level + // "mentions the owner, therefore clean" escape — which the phase-token rule + // does carry — would wave the reported site straight through. Partial + // ownership is the drift. + assert.equal( + findBracketGrammarDrift( + 'new RegExp(`[A-Z][A-Z0-9_]*\\\\.(${BRACKET_MILESTONE_NUMERIC_SRC})`)', + ).length, + 1, + ); + }); + + test('the case-widened rewrite does not evade the rule', () => { + assert.equal(findBracketGrammarDrift('/^\\\\[[A-Za-z][A-Za-z0-9_]*\\\\./').length, 1); + }); + + test('a dedicated phase-id-owner comment sanctions the site', () => { + assert.deepEqual( + findBracketGrammarDrift(' // phase-id-owner: deliberate\n const re = /[A-Z][A-Z0-9_]*/;'), + [], + ); + // …but only as its own line, never trailing the code — same rule the + // phase-token scanner enforces. + assert.equal( + findBracketGrammarDrift('const re = /[A-Z][A-Z0-9_]*/; // phase-id-owner: not honored here').length, + 1, + ); + }); + + test('the spellings that replaced the drift are clean', () => { + for (const line of [ + 'const re = new RegExp(`^${bracketMilestoneIntroSrcFor(milestoneInt)}`, \'i\');', + 'const re = new RegExp(`^${BRACKET_MILESTONE_INTRO_CAPTURING_SRC}`, \'i\');', + 'const re = new RegExp(`^\\\\[(${BRACKET_ID_SRC})\\\\]`, \'i\');', + ]) { + assert.deepEqual(findBracketGrammarDrift(line), [], line); + } + }); + + test('reports the 1-indexed line', () => { + assert.equal(findBracketGrammarDrift('a\nconst re = /[A-Z][A-Z0-9_]*/;\nc')[0].line, 2); + }); +}); + +// ─── #2761 M3: parity between the owner and what the call sites spelled ───── + +describe('#2761 M3 bracket grammar: one owner, byte-identical to the sites it replaced', () => { + // Transcribed by hand from the pre-fix sources, NOT assembled from the + // constants under test — comparing the owner against something built from the + // owner would restate the implementation and pass whatever either side said. + // Byte-equality with an independent transcription is the whole proof. + const PRE_FIX = { + // roadmap-parser.cts and state.cts, character-identical to each other, with + // `canonical` = String(milestoneInt).padStart(2, '0'). + pinned: (canonical) => `\\[[A-Z][A-Z0-9_]*\\.${canonical}\\]`, + // verify.cts, with the milestone field captured. + capturing: (numericSrc) => `\\[[A-Z][A-Z0-9_]*\\.(${numericSrc})\\]`, + }; + + test('bracketMilestoneIntroSrcFor reproduces both re-typed pinned copies', () => { + for (const milestone of [0, 1, 2, 9, 10, 99, 100, 999]) { + assert.equal( + phaseId.bracketMilestoneIntroSrcFor(milestone), + PRE_FIX.pinned(String(milestone).padStart(2, '0')), + `milestone ${milestone}`, + ); + } + }); + + test('BRACKET_MILESTONE_INTRO_CAPTURING_SRC reproduces the verify copy', () => { + assert.equal( + phaseId.BRACKET_MILESTONE_INTRO_CAPTURING_SRC, + PRE_FIX.capturing(phaseId.BRACKET_MILESTONE_NUMERIC_SRC), + ); + }); + + test('the owner also composes BRACKET_ID_SRC, so the two cannot drift apart', () => { + assert.ok( + phaseId.BRACKET_ID_SRC.startsWith(phaseId.BRACKET_PROJECT_CODE_SRC), + 'BRACKET_ID_SRC must be built from BRACKET_PROJECT_CODE_SRC', + ); + assert.equal(phaseId.BRACKET_PROJECT_CODE_SRC, '[A-Z][A-Z0-9_]*'); + }); + + test('the pinned builder owns the pad2 rule, not just the grammar', () => { + // "Canonical spelling only, not `0*N`" was restated beside each re-typed + // regex. An unpadded `[GSD.2]` scopes a milestone no phase heading resolves + // into, which is how total_phases fell back to the on-disk count. + const re = new RegExp(`^${phaseId.bracketMilestoneIntroSrcFor(2)}`, 'i'); + assert.ok(re.test('[GSD.02] Foundation'), 'canonical pad2 spelling matches'); + assert.ok(!re.test('[GSD.2] Foundation'), 'unpadded is malformed'); + assert.ok(!re.test('[GSD.002] Foundation'), 'over-padded is malformed'); + assert.ok(re.test('[gsd.02] Foundation'), 'recognition is case-insensitive at the reader'); + }); + + test('the capturing shape puts the milestone digits in group 1', () => { + const re = new RegExp(`^${phaseId.BRACKET_MILESTONE_INTRO_CAPTURING_SRC}`, 'i'); + assert.equal('[GSD.02] Foundation'.match(re)[1], '02'); + assert.equal('[A_B9.100] Later'.match(re)[1], '100'); + assert.equal('[GSD.2] Foundation'.match(re), null, 'unpadded is not a milestone intro'); + }); +}); + describe('#2128 phase-id drift scanner: the live repo is clean', () => { - test('scanRepo finds zero unsanctioned phase-token re-derivations', () => { + test('scanRepo finds zero unsanctioned re-derivations (token AND bracket)', () => { const violations = scanRepo(ROOT); assert.deepEqual( violations, [], - 'unsanctioned phase-token re-derivation(s) — build from PHASE_NUMBER_TOKEN_SOURCE or add // phase-id-owner:\n' + - violations.map((d) => ` ${d.file}:${d.line} ${d.found}`).join('\n'), + 'unsanctioned re-derivation(s) — build from the phase-id.cjs owner or add // phase-id-owner:\n' + + violations.map((d) => ` [${d.kind}] ${d.file}:${d.line} ${d.found}`).join('\n'), ); }); + + test('scanRepo actually runs the bracket rule (coverage, not just a clean result)', () => { + // A clean scan is also what a scanner that forgot to call the bracket rule + // returns. Plant the shipped verify.cts literal into a temp tree and require + // the scan to fail on it — the same end-to-end path `check:phase-id-drift` + // takes, proving the rule is wired into scanRepo and not merely exported. + const os = require('node:os'); + const { cleanup } = require('./helpers.cjs'); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'phase-id-drift-')); + try { + fs.mkdirSync(path.join(tmp, 'src')); + fs.writeFileSync( + path.join(tmp, 'src', 'planted.cts'), + 'const bracketSectionRe = new RegExp(`^\\\\[[A-Z][A-Z0-9_]*\\\\.(${BRACKET_MILESTONE_NUMERIC_SRC})\\\\]`, \'i\');\n', + ); + const found = scanRepo(tmp); + assert.equal(found.length, 1, 'scanRepo must report the planted bracket literal'); + assert.equal(found[0].kind, 'bracket'); + assert.equal(found[0].file, path.join('src', 'planted.cts')); + } finally { + cleanup(tmp); + } + }); }); describe('#2128 phase-id single-owner identity guard', () => { diff --git a/tests/roadmap-parser.test.cjs b/tests/roadmap-parser.test.cjs index eae4bb804..bed776812 100644 --- a/tests/roadmap-parser.test.cjs +++ b/tests/roadmap-parser.test.cjs @@ -3382,7 +3382,10 @@ describe('#1881 unreadable ROADMAP vs absent ROADMAP', () => { fs3.readFileSync(path3.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf8'), tmpDir, ); - const ids = rp3.scanMilestonePhaseIds(scoped.value); + // #612: pass the resolved convention explicitly. The public owner stays + // a directly iterable Set; qualified bracket ids remain internal to the + // milestone directory filter. + const ids = rp3.scanMilestonePhaseIds(scoped.value, undefined); a3.ok(ids.has('20') || [...ids].some((i) => i.replace(/^0+/, '') === '20'), `ids must contain 20; got ${[...ids]}`); a3.ok(ids.has('21') || [...ids].some((i) => i.replace(/^0+/, '') === '21'), `ids must contain 21; got ${[...ids]}`); }); @@ -3416,7 +3419,8 @@ describe('#1881 unreadable ROADMAP vs absent ROADMAP', () => { fs3.readFileSync(path3.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf8'), tmpDir, ); - const ids = [...rp3.scanMilestonePhaseIds(scoped.value)].map((i) => i.replace(/^0+/, '')); + // #612: same explicit convention as the table-declared-ids test above. + const ids = [...rp3.scanMilestonePhaseIds(scoped.value, undefined)].map((i) => i.replace(/^0+/, '')); a3.ok(!ids.includes('3'), `a RoadmapProgress row is a progress marker, not a declaration; got ${ids}`); a3.ok(!ids.includes('77'), `a fenced table example must not count; got ${ids}`); const p77 = rp3.getRoadmapPhaseInternal(tmpDir, '77');