* test(#3204): failing-first suite for the clobbered phase count A project declaring six phases with four phase directories on disk had state.record-session write progress.total_phases: 4 — #2828 regressing at 1.9.1, reported in #3204 with a deterministic reproduction. Before the fix in the following commit, these rows FAILED (wrote 4, expected 6): a flat roadmap carrying `## Progress`; one carrying `## Overview` and `## Phase Details`; the CRLF variant of the first. Two more, found by adversarial review and added after the first fix attempt, failed against that attempt: structural headings interleaved among flat phase headings, and this repo's own bundled-template shape (a `## Phases` wrapper around a single nested milestone). The #1761 control — sibling milestone sections must keep falling back to the disk count — passes both before and after, so the fix has something it must not break. Assertions read progress.total_phases through the product's own frontmatter parser via `state json --raw`, never a regex over STATE.md. Rows 12 and 13 are hostile: a phase heading carrying a version token, and a version heading inside a fenced code block; neither may count as milestone sectioning. Refs #3185, #3204 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3204): milestone sectioning is vocabulary, not heading position buildStateFrontmatter chooses total_phases between the ROADMAP's declared phase count and the on-disk directory count, and refuses the roadmap count when hasMilestoneSectioning says the document is milestone-sectioned — because a whole-document count would then conflate sibling milestones (#1761). That predicate returned true for ANY non-Phase level-2/3 heading, so a flat roadmap carrying an ordinary `## Progress` was called sectioned and the disk count clobbered the declared one: six declared phases, four directories, total_phases written as 4, converging on the truth only once the last directory happened to exist. That is #2828 regressing at 1.9.1, and it came from this epic — #3184 replaced state.cts's hand-rolled #2828 guard with this predicate, and the replacement is strictly more permissive than the guard it retired. Three position-based models were tried and all failed, because position does not carry milestone-ness: - any non-Phase heading (shipped) — over-detects, giving #3204; - strict nesting/ownership — misses same-level siblings, regressing #1761, and false-positives on the bundled template, where `## Phases` wraps a single `### v1.1`; - adjacency — reproduced live: `## Overview` and `## Notes` interleaved among six phase headings are two owning candidates, so a 6-phase roadmap with 2 directories wrote 2. A heading is now a milestone heading iff it is a non-Phase heading carrying a milestone signal: a version token, a status marker, or the word Milestone. Sectioning means two or more, since one cannot conflate siblings. Known limit, recorded in the doc comment rather than hidden: two milestone sections carrying none of those three signals are not detected. Also drops buildStateFrontmatter's local dedup-key regex, flagged in-source as diverging from the canonical token rule, for phaseKeyFromDir — the remainder of #3185, since #3222 had already routed the enumeration itself through listMilestonePhaseDirs. #1514, #2445 and #3017 are preserved untouched. Closes #3185 Fixes #3204 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#3185): changeset, glossary entry and ADR status for the phase-count fix CONTEXT.md's Roadmap Parser Module entry never named hasMilestoneSectioning, so the predicate whose semantics this change reverses had no glossary presence at all — a PR gate for a module/seam change. Added, covering the vocabulary model, the three position-based models that failed, and the residual limit. ADR-3180 recorded the fifth enumeration copy as unowned in four places. It is owned now. Amendment 4's scope table row 1 also carried an error worth keeping visible rather than rewriting: it claimed Phase 3 merged without routing the state writers, when #3222 had in fact routed the enumeration — the audit read Amendment 3's silence about the symbol names as absence of the work. The real gap was the trust discriminator one layer above, which is what #3204 was. Changeset is Fixed and leads with the symptom a user sees — a phase count that shrinks to match how many phase directories happen to exist yet — and carries the known limit forward rather than leaving it in a source comment. Refs #3185, #3204 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3185): stop quoting the retired phase-token regex in a comment The remote runner failed tests/phase-id-drift-guard.test.cjs: the comment explaining that the local dedup regex had been replaced by phaseKeyFromDir quoted that regex verbatim, and scripts/lint-phase-id-drift.cjs scans for the literal token without caring whether it sits in code or in prose. That is the guard being right, not over-eager — a quoted pattern is one paste away from being live again, which is exactly how the copy it replaced spread. Described in prose instead. Worth recording: this guard is check:phase-id-drift, which lint:ci does not run — it is enforced by tests/phase-id-drift-guard.test.cjs. A green lint:ci is therefore not evidence the drift guards pass. Refs #3185 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3185): backfill changeset PR number (#3230) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/gentle-badgers-forage.md
Normal file
5
.changeset/gentle-badgers-forage.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3230
|
||||
---
|
||||
**`state.record-session` no longer shrinks your phase count** — a project whose ROADMAP declares more phases than it has directories on disk (phases 5 and 6 planned but not started yet) had `progress.total_phases` silently overwritten with the directory count, converging on the right number only once the last phase directory happened to exist. A flat roadmap carrying an ordinary heading like `## Progress` was being misread as milestone-sectioned. Known limit: two milestone sections carrying no version token, no status marker and not the word "Milestone" are still not detected as sectioning. (#3204)
|
||||
File diff suppressed because one or more lines are too long
@@ -260,7 +260,7 @@ This section is that written rule. It is **normative**, and it is what the guard
|
||||
|
||||
**Guard.** `lint-milestone-window-drift.cjs`, token set widened by Phase 6 in the same change as the consolidation — never after, since a guard added later measures a surface already cleaned and reports a zero it did not earn. Token (a) now admits a **literal** `#`-run heading anchor in addition to the `#{N,M}` quantifier, but only inside a heading-**matcher** literal (a regex literal, or a string handed to `new RegExp(`), so a heading-**builder** template is not mistaken for a re-derivation. Token (b) additionally admits the grouped `v(\d+(?:\.\d+)+)` shape and an interpolated `${…Ver…}` placeholder.
|
||||
|
||||
#### 7.3 Phase enumeration — *Enforced for the four named consumers (Phase 3, #3222); the fifth copy is unowned*
|
||||
#### 7.3 Phase enumeration — *Enforced (Phase 3, #3222 + #3185)*
|
||||
|
||||
**Question.** Which directories under `<planning>/phases/` are phases of milestone `M`?
|
||||
|
||||
@@ -270,7 +270,11 @@ This section is that written rule. It is **normative**, and it is what the guard
|
||||
|
||||
**Consumers that must route through the owner.** `cmdRoadmapAnalyze`, `cmdProgressRender`, `cmdStats`, `cmdPhasesList`, **and `buildStateFrontmatter` / `syncStateFrontmatter`** — the fifth copy, reached by `state.record-session`, `state.sync`, `phase.complete` and every other state-mutating verb, which the epic's original scope did not name (coverage-audit gap 1).
|
||||
|
||||
**Status, precisely.** Phase 3 (#3222) enforced this rule for `cmdRoadmapAnalyze`, `cmdProgressRender`, `cmdStats` and `cmdPhasesList`, and its guard (`scripts/lint-phase-enumeration-drift.cjs`) found 54 violations where the epic scoped 4. **It did not reach `buildStateFrontmatter` / `syncStateFrontmatter`.** That copy is still live, still writes its answer to disk, and is now unowned by any phase — see Amendment 4's scope table, row 1.
|
||||
**Status, precisely.** Phase 3 (#3222) enforced this rule for `cmdRoadmapAnalyze`, `cmdProgressRender`, `cmdStats` and `cmdPhasesList`, and its guard (`scripts/lint-phase-enumeration-drift.cjs`) found 54 violations where the epic scoped 4. It also routed `buildStateFrontmatter`'s enumeration through `listMilestonePhaseDirs`, which Amendment 4's scope table row 1 did not credit it for — the audit read the *absence of the symbol names from Amendment 3* as the absence of the work. #3185's remainder was therefore smaller than recorded: the local dedup-key regex that survived alongside the routed enumeration, now on `phaseKeyFromDir`.
|
||||
|
||||
**What was actually unowned was one layer up.** `buildStateFrontmatter` decides whether it may *trust* the ROADMAP's declared count via `hasMilestoneSectioning` (§7.1), and that predicate — introduced by Phase 2 (#3184) to retire `state.cts`'s hand-rolled #2828 guard — was strictly more permissive than the guard it replaced, so a flat ROADMAP carrying an ordinary `## Progress` heading had its declared phase count discarded for the on-disk directory count (#3204). Fixed in #3185 by deciding milestone-ness from vocabulary rather than heading position; see §7.1 and the `CONTEXT.md` Roadmap Parser Module entry for the three position-based models that failed and why.
|
||||
|
||||
**The lesson this phase adds to Amendment 3's standing rule:** a copy count derived from *which symbol names appear in a prior amendment* is as unreliable as one derived from the reported issues. Read the code, not the write-up.
|
||||
|
||||
**Note on #3204.** Routing `buildStateFrontmatter` through the owner will not by itself fix #3204: its defect is the discriminator one layer *above* enumeration — "is the ROADMAP's phase count safe to trust" — which misclassifies ordinary `## Overview` / `## Progress` headings as milestone sectioning. That discriminator **is** §7.1's `isMilestoneBoundedInRoadmap`. The enumeration routing and the discriminator replacement must ship together or the defect survives the consolidation.
|
||||
|
||||
@@ -372,7 +376,7 @@ One row per derivation. A blank owner is a derivation whose contract is locked (
|
||||
|---|---|---|---|---|
|
||||
| Milestone windowing (§7.1) | `roadmap-parser.cts` | `lint-milestone-window-drift.cjs` | `src/` | enforced |
|
||||
| Milestone identity (§7.2) | `roadmap-parser.cts` (Phase 6) | same guard, token set widened by Phase 6 | `src/` | enforced |
|
||||
| Phase enumeration (§7.3) | `phase-locator.cts` | `lint-phase-enumeration-drift.cjs` | `src/` | enforced for the four named consumers; `buildStateFrontmatter`/`syncStateFrontmatter` **unowned** |
|
||||
| Phase enumeration (§7.3) | `phase-locator.cts` | `lint-phase-enumeration-drift.cjs` | `src/` | enforced — the state writers included (#3185) |
|
||||
| Phase completion (§7.4) | `verification.cts` (Phase 4) | Phase 4 | `src/` | blocked on #2957 |
|
||||
| Live-plan counting (§7.5) | `plan-scan.cts` | `lint-plan-count-drift.cjs` | `src/` | enforced |
|
||||
| Live-plan counting, prompt layer (§7.5) | — (Phase 8) | `lint-planning-prompt-drift.cjs` | `gsd-core/workflows`, `commands`, `agents`, `skills` | ratcheted, 7 sites |
|
||||
@@ -632,7 +636,7 @@ scoped 4 — so it is not restated here.
|
||||
|
||||
| # | Change | Why the existing phases do not cover it |
|
||||
|---|---|---|
|
||||
| 1 | ~~**Phase 3 widens** to include `buildStateFrontmatter` and `syncStateFrontmatter`~~ — **superseded: Phase 3 merged without them.** The fifth enumeration copy and #3204 are now unowned and need a phase of their own | Phase 3's Done-when named only `cmdProgressRender`, `cmdStats` and `cmdPhasesList`, and #3222 shipped exactly that. `buildStateFrontmatter`/`syncStateFrontmatter` appear nowhere in Amendment 3, and #3204 appears nowhere in this ADR outside this row. Routing alone would not have fixed #3204 anyway — its defect is the "is the ROADMAP count trustworthy" discriminator one layer *above* enumeration, which is §7.1's `isMilestoneBoundedInRoadmap` |
|
||||
| 1 | ~~**Phase 3 widens** to include `buildStateFrontmatter` and `syncStateFrontmatter`~~ — **twice superseded; closed by #3185.** First reading ("Phase 3 merged without them") was itself wrong: #3222 *had* routed the enumeration, and the audit mistook Amendment 3's silence for absent work. The real gap was the trust discriminator one layer above — see §7.3 | Routing alone would never have fixed #3204: a correctly scoped enumeration still returns the directory count. The defect was `hasMilestoneSectioning`, introduced by Phase 2 (#3184) and more permissive than the #2828 guard it retired |
|
||||
| 2 | **Phase 4 blocks on #2957** and its guard must fail while a third predicate exists | The audit found `buildStateFrontmatter` computing completed phases from plan scanning alone, ignoring the ROADMAP checkbox `cmdRoadmapAnalyze` honors. Checkbox-override vs disk-strict is an undecided product question, not a consolidation |
|
||||
| 3 | **Phase 6 — milestone identity** (§7.2): bind `getMilestoneInfo` to `locateMilestoneHeadings`, widen the windowing guard's token set in the same change (#3171, #3197) | A sixth derivation family. Phase 2 consolidated *windowing*; `getMilestoneInfo` hand-rolls its own heading regexes to answer a different question — *which milestone is this and what is it called* — and no named phase touches it |
|
||||
| 4 | **Phase 7 — completion-ratio scoping** (§7.6 rules 3–4). **Corrected: #3161 is NOT fixed here — Amendment 3 subsumed it.** Phase 7 keeps rule 4 and whatever consumers Phase 3 did not reach | A seventh derivation family. This amendment ships the arithmetic half. Its original claim — that enumeration consolidation "changes nothing here" — was **wrong**, and Phase 3 proved it: routing `cmdStats`/`cmdProgressRender`'s `totalPlans`/`totalSummaries` accumulation through `listMilestonePhaseDirs`'s scoped set *is* §7.6 rule 3 for those two consumers, and it is what closed #3161 |
|
||||
|
||||
@@ -254,19 +254,109 @@ function hasVersionedMilestones(content: string): boolean {
|
||||
return /^#{1,3}\s+.*v\d+\.\d+/mi.test(content);
|
||||
}
|
||||
|
||||
// This file's milestone-heading vocabulary: a version token (`v1.2`-style),
|
||||
// a ✅/🚧/📋 status marker, or the word "Milestone". Tested against a
|
||||
// non-Phase heading's own text by `hasMilestoneSectioning` below — this
|
||||
// module's sole owner of "is this heading a milestone heading".
|
||||
const MILESTONE_HEADING_SIGNAL_PATTERN = /v\d+\.\d+|✅|📋|🚧|\bMilestone\b/i;
|
||||
|
||||
/**
|
||||
* #3184/#2828/#1761: does this ROADMAP use milestone SECTIONING at all — i.e.
|
||||
* does it carry any non-Phase heading at level 2-3? Deliberately weaker than
|
||||
* `hasVersionedMilestones`: this needs to distinguish a FLAT unmilestoned
|
||||
* roadmap (Phase headings only, where a whole-document phase count is
|
||||
* correct) from a MILESTONED-but-unbounded one (where that count conflates
|
||||
* sibling milestones, #1761) — that distinction is load-bearing and must not
|
||||
* be collapsed into the versioned-milestone check. Owned here so the
|
||||
* milestone heading vocabulary has one home; routes `state.cts`'s
|
||||
* `buildStateFrontmatter` #2828 guard instead of a third hand-rolled copy.
|
||||
* #3184/#3204/#2828/#1761/#3185: could a WHOLE-DOCUMENT phase count conflate
|
||||
* two different milestones? That is the only question `buildStateFrontmatter`
|
||||
* (`state.cts`) asks its single caller of this predicate.
|
||||
*
|
||||
* Three prior models were tried, and all three tried to infer milestone-ness
|
||||
* from POSITION — where a heading sits relative to other headings — and all
|
||||
* three broke a real shape because position does not carry it:
|
||||
*
|
||||
* 1. "Is there ANY non-Phase level-2/3 heading" (pre-#3184). #3204: a FLAT
|
||||
* roadmap carrying one ordinary structural heading (`## Progress`) was
|
||||
* misclassified as milestone-sectioned, and `safeToUseRoadmapCount`
|
||||
* clobbered a correct ROADMAP-declared count down to the on-disk directory
|
||||
* count. Not-Phase-ness was never the right question.
|
||||
* 2. "Do >=2 non-Phase headings EACH own a nested (STRICTLY DEEPER) Phase
|
||||
* heading" (#3184's rewrite). Two independent review findings broke this:
|
||||
* (a) #1761 regression — real sibling milestones are commonly at the SAME
|
||||
* level as their own Phase headings (`## v1.0` / `## Phase 1:` / `## v2.0`
|
||||
* / `## Phase 3:`), so "strictly deeper" never matches for either sibling
|
||||
* and the predicate answers false, letting the whole-document count
|
||||
* conflate them exactly as #1761 did. (b) #3204 reintroduced — the
|
||||
* bundled greenfield template itself (`gsd-core/templates/roadmap.md:149-171`:
|
||||
* `## Phases` -> `### 🚧 v1.1 — …` -> `#### Phase 5: …`) nests a Phase
|
||||
* heading arbitrarily deep under EVERY ancestor in the chain, so a
|
||||
* generic wrapper heading ("Phases") with no milestone meaning of its own
|
||||
* counted as its own candidate section and single-milestone documents
|
||||
* were misclassified as sectioned again.
|
||||
* 3. "Immediate adjacency, at any level" (interim #3185 rewrite, never
|
||||
* shipped past this file's own working tree). Fixed both #3184 defects
|
||||
* above, but adjacency is STILL a positional signal, and #3185 reproduced
|
||||
* a THIRD shape it cannot see: a flat roadmap where `## Overview` happens
|
||||
* to sit immediately before `## Phase 1:` and, independently, `## Notes`
|
||||
* sits immediately before `## Phase 4:` later in the same document. Two
|
||||
* purely structural headings, zero milestone meaning, each "adjacent" to a
|
||||
* Phase heading by coincidence of document layout — ≥2 owners, so the
|
||||
* flat 6-phase roadmap was misclassified as sectioned and clobbered to the
|
||||
* 2 on-disk phase directories. Same root defect as #3204's `## Progress`,
|
||||
* wearing a different heading shape.
|
||||
*
|
||||
* The model that actually holds for every shape above abandons position
|
||||
* entirely and asks about the heading's own text: is it a MILESTONE HEADING —
|
||||
* a non-Phase heading at level 1-3 carrying a milestone VOCABULARY signal
|
||||
* (a version token, a ✅/🚧/📋 status marker, or the word "Milestone")?
|
||||
* Sectioning is present iff there are >=2 such headings — one or zero cannot
|
||||
* conflate siblings by construction, no matter where they sit. This resolves
|
||||
* every prior failure:
|
||||
* - #3204 / this file's `## Progress`: no signal — 0 milestone headings.
|
||||
* - #3185 `## Overview` / `## Notes` interleaved with flat phases: neither
|
||||
* carries a signal — 0 milestone headings, regardless of adjacency.
|
||||
* - #1761 same-level siblings (`## v1.0` / `## v2.0`): each carries a version
|
||||
* token — 2 milestone headings, sectioned, no level or adjacency test
|
||||
* needed.
|
||||
* - #1761 unmarked prose siblings (`## Milestone 1: …` / `## Milestone 2: …`):
|
||||
* each carries the word "Milestone" — 2 milestone headings, sectioned.
|
||||
* - Bundled template wrapper (`## Phases` -> `### 🚧 v1.1` -> `#### Phase 5:`):
|
||||
* `## Phases` carries no signal; `### 🚧 v1.1` carries a marker and a
|
||||
* version token but is only ONE heading — 1 milestone heading, not
|
||||
* sectioned.
|
||||
*
|
||||
* Deliberately NOT a denylist of heading names (fragile, unbounded) and NOT
|
||||
* collapsed into `hasVersionedMilestones` (a non-versioned-but-marked or
|
||||
* "Milestone"-named section still conflates siblings — see that function's
|
||||
* own doc comment, which answers a narrower question: ANY version token
|
||||
* anywhere, not "are there >=2 independently-signalled milestone headings").
|
||||
* Routed through `tokenizeHeadings` (fence- and CRLF-aware, single owner of
|
||||
* ATX heading tokenisation) rather than a second regex pass, so a heading
|
||||
* inside a fenced code block is never tokenised in the first place and
|
||||
* cannot flip this result. The Phase-heading test (`/^Phase\s+\S/i`) is the
|
||||
* SAME literal reused by `computeMilestoneSectionEnd` / `locateMilestoneHeadings`
|
||||
* above, not a fresh copy. `MILESTONE_HEADING_SIGNAL_PATTERN`'s version-token
|
||||
* and marker alternatives mirror the literal fragments already used by
|
||||
* `hasVersionedMilestones` (`v\d+\.\d+`) and `computeMilestoneSectionEnd`
|
||||
* (`✅|📋|🚧`) rather than inventing a fourth independent copy of the same
|
||||
* vocabulary; the "Milestone" word is the one signal none of those three
|
||||
* needed and this predicate does.
|
||||
*
|
||||
* Honest limit: this is a NARROWER signal than any of the three position-based
|
||||
* attempts — a heading is only a candidate if its OWN TEXT carries a version
|
||||
* token, a status marker, or the word "Milestone". Two milestone sections that
|
||||
* carry NONE of the three (e.g. `## First Chapter` / `## Second Chapter`, each
|
||||
* with their own Phase headings, no version, no marker, no "Milestone" word)
|
||||
* are not detected as sectioned, and the whole-document count is trusted even
|
||||
* though it may still conflate them. No fixture in this repo's bundled
|
||||
* template or the #3204/#1761/#3185 reports exercises that shape; it is
|
||||
* recorded here rather than hidden.
|
||||
*/
|
||||
function hasMilestoneSectioning(content: string): boolean {
|
||||
return /^#{2,3}\s+(?!Phase\s+\S)/mi.test(content);
|
||||
const isPhaseHeading = (text: string): boolean => /^Phase\s+\S/i.test(text);
|
||||
let milestoneHeadingCount = 0;
|
||||
for (const heading of tokenizeHeadings(content)) {
|
||||
if (heading.level < 1 || heading.level > 3) continue;
|
||||
if (isPhaseHeading(heading.text)) continue;
|
||||
if (!MILESTONE_HEADING_SIGNAL_PATTERN.test(heading.text)) continue;
|
||||
milestoneHeadingCount++;
|
||||
if (milestoneHeadingCount >= 2) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1753,9 +1753,14 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto
|
||||
// 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;
|
||||
// phase-id-owner: dir-name dedup grouping; diverges from extractPhaseToken/phaseKeyFromDir on project-code-prefixed and multi-segment milestone dirs. Kept local.
|
||||
const m = dir.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
|
||||
const key = m ? m[1].toLowerCase() : dir;
|
||||
// #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
|
||||
// project-code-prefixed dirs (whole dirname fell through as the key,
|
||||
// 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);
|
||||
if (!seenPhaseNums.has(key)) {
|
||||
seenPhaseNums.set(key, dir);
|
||||
} else {
|
||||
|
||||
784
tests/issue-3204-state-writer-phase-count.test.cjs
Normal file
784
tests/issue-3204-state-writer-phase-count.test.cjs
Normal file
@@ -0,0 +1,784 @@
|
||||
// allow-test-rule: source-text-is-the-product, see #3204
|
||||
// Reads STATE.md/ROADMAP.md fixture files whose deployed text IS what the
|
||||
// runtime loads — testing text content tests the deployed contract.
|
||||
|
||||
/**
|
||||
* #3204 / #3185 — failing-first regression suite for `buildStateFrontmatter`'s
|
||||
* `total_phases` selection (`src/state.cts:1620`, guard at `:1795-1805`).
|
||||
*
|
||||
* `hasMilestoneSectioning` (`src/roadmap-parser.cts:195`) is
|
||||
* /^#{2,3}\s+(?!Phase\s+\S)/mi
|
||||
* — true for ANY non-Phase level-2/3 heading, so a FLAT roadmap carrying an
|
||||
* ordinary structural heading (`## Progress`, `## Overview`, ...) is
|
||||
* misclassified as milestone-sectioned. `safeToUseRoadmapCount` then goes
|
||||
* false and the on-disk phase-directory count silently clobbers the
|
||||
* ROADMAP-declared count — a regression of #2828, reported in #3204 as
|
||||
* "roadmap declares 6 phases, 4 directories exist, state.record-session
|
||||
* writes total_phases: 4".
|
||||
*
|
||||
* DO NOT fix src/state.cts or src/roadmap-parser.cts from this file. Rows 2,
|
||||
* 3, and 14 below assert the CORRECT (post-fix) value and currently FAIL —
|
||||
* that is the point of a failing-first suite. Every other row asserts
|
||||
* behavior verified to already hold today (see phase-log for the manual CLI
|
||||
* probes that established each expected value before this file was written).
|
||||
*
|
||||
* Rows and naming follow `.gsd/phase/fix-3185-state-writer-phase-count/50-test-matrix.md`
|
||||
* verbatim (row numbers refer to that matrix, not the 8-row table in
|
||||
* `40-design.md`).
|
||||
*
|
||||
* Driven via `state record-session` (the shape #3204's own report used),
|
||||
* then read back with `state json --raw` — the product's own frontmatter
|
||||
* parser — so `progress.total_phases` is asserted as a NUMBER, never a
|
||||
* regex over rendered STATE.md text. `tests/helpers.cjs`'s `parseFrontmatter`
|
||||
* only reads flat top-level keys (it does not descend into the nested
|
||||
* `progress:` block), so `state json --raw` is the correct structured seam
|
||||
* for a nested field — it is what `tests/state.test.cjs`'s own '#1761
|
||||
* read-path' and 'milestone-scoped phase counting' suites already use for
|
||||
* this exact assertion shape.
|
||||
*/
|
||||
|
||||
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');
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Fixture builders
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Seed `.planning/phases/<padded>-phase-<n>` for each phase number in `nums`,
|
||||
* each with a single PLAN.md so the directory is a recognizable phase dir.
|
||||
*/
|
||||
function seedPhaseDirs(tmpDir, nums) {
|
||||
for (const n of nums) {
|
||||
const padded = String(n).padStart(2, '0');
|
||||
const dir = path.join(tmpDir, '.planning', 'phases', `${padded}-phase-${n}`);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, `${padded}-01-PLAN.md`), '# Plan\n');
|
||||
}
|
||||
}
|
||||
|
||||
/** Seed one arbitrarily-named phase directory (sentinel / dup / pre-milestone cases). */
|
||||
function seedNamedPhaseDir(tmpDir, dirName, planBase) {
|
||||
const dir = path.join(tmpDir, '.planning', 'phases', dirName);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, `${planBase}-01-PLAN.md`), '# Plan\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build STATE.md frontmatter + minimal body. `milestone` is always set (the
|
||||
* #3204 fixture needs it truthy — `getMilestoneInfo` defaults an absent
|
||||
* `milestone:` field to 'v1.0' anyway, so this pins the same value
|
||||
* explicitly for every row rather than relying on that fallback).
|
||||
* Lines are joined with the caller-supplied `eol` (default '\n') — row 14
|
||||
* reuses this to build the CRLF variant without a second copy.
|
||||
*/
|
||||
function buildStateMd({ milestone = 'v1.0', milestoneName = 'Test', totalPhases, currentPhase = '01', eol = '\n' }) {
|
||||
const lines = [
|
||||
'---',
|
||||
'gsd_state_version: 1.0',
|
||||
`milestone: ${milestone}`,
|
||||
`milestone_name: ${milestoneName}`,
|
||||
`current_phase: "${currentPhase}"`,
|
||||
'status: executing',
|
||||
'progress:',
|
||||
` total_phases: ${totalPhases}`,
|
||||
' completed_phases: 0',
|
||||
' total_plans: 0',
|
||||
' completed_plans: 0',
|
||||
' percent: 0',
|
||||
'---',
|
||||
'',
|
||||
'# GSD State',
|
||||
'',
|
||||
'## Current Position',
|
||||
'',
|
||||
`**Current Phase:** ${currentPhase}`,
|
||||
'**Status:** Executing',
|
||||
'',
|
||||
];
|
||||
return lines.join(eol);
|
||||
}
|
||||
|
||||
/** Invoke `state record-session` (the #3204 entry point) then read back `state json --raw`. */
|
||||
function recordSessionAndReadTotalPhases(tmpDir) {
|
||||
const recordResult = runGsdTools(
|
||||
['state', 'record-session', '--stopped-at', 'Phase 1, Plan 1', '--resume-file', 'none'],
|
||||
tmpDir,
|
||||
);
|
||||
assert.ok(recordResult.success, `state record-session failed: ${recordResult.error}`);
|
||||
|
||||
const jsonResult = runGsdTools(['state', 'json', '--raw'], tmpDir);
|
||||
assert.ok(jsonResult.success, `state json --raw failed: ${jsonResult.error}`);
|
||||
return JSON.parse(jsonResult.output);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Rows 1-3, 14 — #3204 regression: flat roadmap + a structural heading
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('#3204 buildStateFrontmatter total_phases — flat roadmap misclassified as milestone-sectioned', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('flat roadmap with no structural headings keeps the roadmap count', () => {
|
||||
// Row 1 (happy path / control) — no non-Phase heading anywhere, so
|
||||
// hasMilestoneSectioning is false today and this already passes. Guards
|
||||
// against a fix that overcorrects and breaks the trivial flat case.
|
||||
const roadmap = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Phase 1: One',
|
||||
'## Phase 2: Two',
|
||||
'## Phase 3: Three',
|
||||
'## Phase 4: Four',
|
||||
'## Phase 5: Five',
|
||||
'## Phase 6: Six',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 6 }));
|
||||
seedPhaseDirs(tmpDir, [1, 2, 3, 4]);
|
||||
|
||||
const out = recordSessionAndReadTotalPhases(tmpDir);
|
||||
assert.strictEqual(Number(out.progress.total_phases), 6, `expected roadmap count 6, got ${out.progress && out.progress.total_phases}`);
|
||||
});
|
||||
|
||||
test('#3204 flat roadmap with a Progress heading is not treated as milestone-sectioned', () => {
|
||||
// Row 2 — the crux repro, transcribed from #3204's own report: 6
|
||||
// declared phases, 4 directories, a flat '## Progress' heading. FAILS
|
||||
// TODAY: hasMilestoneSectioning misclassifies '## Progress' as
|
||||
// sectioning, safeToUseRoadmapCount goes false, and the write clobbers
|
||||
// total_phases down to the disk count (4) instead of 6.
|
||||
const roadmap = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Phase 1: One',
|
||||
'## Phase 2: Two',
|
||||
'## Phase 3: Three',
|
||||
'## Phase 4: Four',
|
||||
'## Phase 5: Five',
|
||||
'## Phase 6: Six',
|
||||
'',
|
||||
'## Progress',
|
||||
'',
|
||||
'Some progress notes.',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 6 }));
|
||||
seedPhaseDirs(tmpDir, [1, 2, 3, 4]);
|
||||
|
||||
const out = recordSessionAndReadTotalPhases(tmpDir);
|
||||
assert.strictEqual(
|
||||
Number(out.progress.total_phases),
|
||||
6,
|
||||
`#3204: total_phases must stay 6 (roadmap-declared), not clobber to the disk count of 4. Got ${out.progress && out.progress.total_phases}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('#3204 multiple structural headings still count as flat', () => {
|
||||
// Row 3 — same shape as row 2 with TWO structural headings ('## Overview',
|
||||
// '## Phase Details'); '## Phase Details' is correctly excluded by the
|
||||
// heading's own '(?!Phase\s+\S)' lookahead, but '## Overview' still trips
|
||||
// the misclassification. FAILS TODAY for the same reason as row 2.
|
||||
const roadmap = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Overview',
|
||||
'',
|
||||
'Some overview text.',
|
||||
'',
|
||||
'## Phase 1: One',
|
||||
'## Phase 2: Two',
|
||||
'## Phase 3: Three',
|
||||
'## Phase 4: Four',
|
||||
'## Phase 5: Five',
|
||||
'## Phase 6: Six',
|
||||
'',
|
||||
'## Phase Details',
|
||||
'',
|
||||
'More detail prose.',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 6 }));
|
||||
seedPhaseDirs(tmpDir, [1, 2, 3, 4]);
|
||||
|
||||
const out = recordSessionAndReadTotalPhases(tmpDir);
|
||||
assert.strictEqual(
|
||||
Number(out.progress.total_phases),
|
||||
6,
|
||||
`#3204: multiple structural headings must still count as flat (6), got ${out.progress && out.progress.total_phases}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('#3204 repro under CRLF', () => {
|
||||
// Row 14 — row 2's exact repro, all fixture content authored with CRLF
|
||||
// line endings, proving the bug (and required fix) is not an artifact of
|
||||
// LF-only fixtures. FAILS TODAY for the same reason as row 2.
|
||||
const roadmapLines = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Phase 1: One',
|
||||
'## Phase 2: Two',
|
||||
'## Phase 3: Three',
|
||||
'## Phase 4: Four',
|
||||
'## Phase 5: Five',
|
||||
'## Phase 6: Six',
|
||||
'',
|
||||
'## Progress',
|
||||
'',
|
||||
'Some progress notes.',
|
||||
'',
|
||||
];
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmapLines.join('\r\n'));
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
buildStateMd({ totalPhases: 6, eol: '\r\n' }),
|
||||
);
|
||||
seedPhaseDirs(tmpDir, [1, 2, 3, 4]);
|
||||
|
||||
const out = recordSessionAndReadTotalPhases(tmpDir);
|
||||
assert.strictEqual(
|
||||
Number(out.progress.total_phases),
|
||||
6,
|
||||
`#3204 under CRLF: total_phases must stay 6, got ${out.progress && out.progress.total_phases}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Rows 4-11 — negative space and boundaries (must hold both before and after the fix)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('#3204 buildStateFrontmatter total_phases — negative space / boundaries (must not regress)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('bounded milestone uses its own section count', () => {
|
||||
// Row 4 — versioned roadmap with two sibling milestone sections ('## v1.0'
|
||||
// owning phases 1-2, '## v2.0' owning phases 3-5). The asserted milestone
|
||||
// ('v2.0') IS bound to its own heading, so `sliceMilestoneWindow`/
|
||||
// `extractCurrentMilestoneScoped` narrow to that section and
|
||||
// roadmapPhaseCount is the SECTION's count (3), not the whole-document
|
||||
// count (5) and not the disk count (2 dirs seeded).
|
||||
const roadmap = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## v1.0',
|
||||
'## Phase 1: One',
|
||||
'## Phase 2: Two',
|
||||
'',
|
||||
'## v2.0',
|
||||
'## Phase 3: Three',
|
||||
'## Phase 4: Four',
|
||||
'## Phase 5: Five',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
buildStateMd({ milestone: 'v2.0', milestoneName: 'Second', totalPhases: 3 }),
|
||||
);
|
||||
seedPhaseDirs(tmpDir, [3, 4]);
|
||||
|
||||
const out = recordSessionAndReadTotalPhases(tmpDir);
|
||||
assert.strictEqual(
|
||||
Number(out.progress.total_phases),
|
||||
3,
|
||||
`a bounded milestone must use its own section's phase count (3), not the whole document (5) or the disk count (2). Got ${out.progress && out.progress.total_phases}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a single milestone section cannot conflate siblings', () => {
|
||||
// Row 6 — exactly ONE '## v2.0' section owning phases, with the asserted
|
||||
// milestone ('v9.9') absent from the roadmap entirely. One milestone
|
||||
// heading can never satisfy hasMilestoneSectioning's >=2 threshold, so
|
||||
// this is NOT sectioned and the roadmap-declared count is still used.
|
||||
const roadmap = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## v2.0',
|
||||
'## Phase 1: One',
|
||||
'## Phase 2: Two',
|
||||
'## Phase 3: Three',
|
||||
'## Phase 4: Four',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
buildStateMd({ milestone: 'v9.9', milestoneName: 'Absent', totalPhases: 4 }),
|
||||
);
|
||||
seedPhaseDirs(tmpDir, [1, 2]);
|
||||
|
||||
const out = recordSessionAndReadTotalPhases(tmpDir);
|
||||
assert.strictEqual(
|
||||
Number(out.progress.total_phases),
|
||||
4,
|
||||
`a single milestone section cannot conflate siblings; expected the roadmap count (4), got ${out.progress && out.progress.total_phases}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('#1761 sibling milestone sections still fall back to the disk count', () => {
|
||||
// Row 5 — TWO sibling (unversioned) milestone sections, asserted
|
||||
// milestone ('v3.0') absent from either. This is genuinely
|
||||
// milestone-sectioned (2 phase-bearing sections would conflate if
|
||||
// whole-doc counted), so total_phases must stay the disk count. Passes
|
||||
// today; a fix that touches hasMilestoneSectioning must not break it.
|
||||
const roadmap = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Milestone 1: First Milestone',
|
||||
'### Phase 1: a',
|
||||
'### Phase 2: b',
|
||||
'### Phase 3: c',
|
||||
'### Phase 4: d',
|
||||
'',
|
||||
'## Milestone 2: Second Milestone',
|
||||
'### Phase 5: e',
|
||||
'### Phase 6: f',
|
||||
'### Phase 7: g',
|
||||
'### Phase 8: h',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
buildStateMd({ milestone: 'v3.0', milestoneName: 'Third', totalPhases: 8 }),
|
||||
);
|
||||
seedPhaseDirs(tmpDir, [1, 2, 3]);
|
||||
|
||||
const out = recordSessionAndReadTotalPhases(tmpDir);
|
||||
assert.strictEqual(
|
||||
Number(out.progress.total_phases),
|
||||
3,
|
||||
`#1761: unbounded sibling milestones must fall back to the disk count (3), got ${out.progress && out.progress.total_phases}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('zero phase directories keeps the declared count', () => {
|
||||
// Row 7 — boundary limit-1: 0 dirs vs 6 declared.
|
||||
const roadmap = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Phase 1: One',
|
||||
'## Phase 2: Two',
|
||||
'## Phase 3: Three',
|
||||
'## Phase 4: Four',
|
||||
'## Phase 5: Five',
|
||||
'## Phase 6: Six',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 6 }));
|
||||
// No phase dirs seeded.
|
||||
|
||||
const out = recordSessionAndReadTotalPhases(tmpDir);
|
||||
assert.strictEqual(Number(out.progress.total_phases), 6, `expected 6, got ${out.progress && out.progress.total_phases}`);
|
||||
});
|
||||
|
||||
test('equal counts agree', () => {
|
||||
// Row 8 — boundary limit: 6 dirs vs 6 declared.
|
||||
const roadmap = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Phase 1: One',
|
||||
'## Phase 2: Two',
|
||||
'## Phase 3: Three',
|
||||
'## Phase 4: Four',
|
||||
'## Phase 5: Five',
|
||||
'## Phase 6: Six',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 6 }));
|
||||
seedPhaseDirs(tmpDir, [1, 2, 3, 4, 5, 6]);
|
||||
|
||||
const out = recordSessionAndReadTotalPhases(tmpDir);
|
||||
assert.strictEqual(Number(out.progress.total_phases), 6, `expected 6, got ${out.progress && out.progress.total_phases}`);
|
||||
});
|
||||
|
||||
test('extra directories win via max()', () => {
|
||||
// Row 9 — boundary limit+1. 6 heading-declared phases + a 7th phase
|
||||
// declared only via the bullet-entry syntax ('- [ ] **Phase 7 — Extra**',
|
||||
// #2199 bullet house style), which the directory-membership filter
|
||||
// counts but the heading-only roadmapPhaseCount scan does not — so disk
|
||||
// (7, all pass the membership filter) legitimately exceeds the
|
||||
// heading-only roadmap count (6), and max() must pick 7.
|
||||
//
|
||||
// NOTE: a naive "N heading-declared phases + N+1 plain directories" does
|
||||
// NOT exercise this path — the directory-membership filter
|
||||
// (getMilestonePhaseFilter, roadmap-parser.cts) excludes any directory
|
||||
// whose phase number has no matching roadmap entry at all, so an
|
||||
// out-of-roadmap directory number is silently dropped from the disk
|
||||
// count rather than inflating it. Verified against the running CLI
|
||||
// before authoring this fixture.
|
||||
const roadmap = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Phase 1: One',
|
||||
'## Phase 2: Two',
|
||||
'## Phase 3: Three',
|
||||
'## Phase 4: Four',
|
||||
'## Phase 5: Five',
|
||||
'## Phase 6: Six',
|
||||
'',
|
||||
'- [ ] **Phase 7 — Extra**',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 6 }));
|
||||
seedPhaseDirs(tmpDir, [1, 2, 3, 4, 5, 6, 7]);
|
||||
|
||||
const out = recordSessionAndReadTotalPhases(tmpDir);
|
||||
assert.strictEqual(
|
||||
Number(out.progress.total_phases),
|
||||
7,
|
||||
`expected max(7 dirs, 6 heading-declared) = 7, got ${out.progress && out.progress.total_phases}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('absent roadmap falls back to disk', () => {
|
||||
// Row 10 — no ROADMAP.md at all.
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 4 }));
|
||||
seedPhaseDirs(tmpDir, [1, 2, 3, 4]);
|
||||
|
||||
const out = recordSessionAndReadTotalPhases(tmpDir);
|
||||
assert.strictEqual(Number(out.progress.total_phases), 4, `expected disk count 4, got ${out.progress && out.progress.total_phases}`);
|
||||
});
|
||||
|
||||
test('roadmap with no phase headings falls back to disk', () => {
|
||||
// Row 11 — ROADMAP.md present but zero Phase headings anywhere.
|
||||
const roadmap = ['# Roadmap', '', '## Notes', '', 'No phases declared yet.', ''].join('\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 4 }));
|
||||
seedPhaseDirs(tmpDir, [1, 2, 3, 4]);
|
||||
|
||||
const out = recordSessionAndReadTotalPhases(tmpDir);
|
||||
assert.strictEqual(Number(out.progress.total_phases), 4, `expected disk count 4, got ${out.progress && out.progress.total_phases}`);
|
||||
});
|
||||
|
||||
test('a phase heading carrying a version token is not a milestone heading', () => {
|
||||
// Row 12 (hostile, negative space) — '### Phase 3: Ship v2.0 gaps' carries
|
||||
// a version token in its own text, but hasMilestoneSectioning's
|
||||
// isPhaseHeading check excludes any heading matching '^Phase\s+\S' before
|
||||
// the vocabulary signal is ever tested, so this must NOT count as a
|
||||
// milestone heading. Otherwise-flat roadmap, so the roadmap-declared
|
||||
// count must be used, not the disk count.
|
||||
const roadmap = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Phase 1: One',
|
||||
'## Phase 2: Two',
|
||||
'### Phase 3: Ship v2.0 gaps',
|
||||
'## Phase 4: Four',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 4 }));
|
||||
seedPhaseDirs(tmpDir, [1, 2]);
|
||||
|
||||
const out = recordSessionAndReadTotalPhases(tmpDir);
|
||||
assert.strictEqual(
|
||||
Number(out.progress.total_phases),
|
||||
4,
|
||||
`a version token borne by a Phase heading must not trigger milestone sectioning; expected roadmap count 4, got ${out.progress && out.progress.total_phases}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a version heading inside a fence is not sectioning', () => {
|
||||
// Row 13 (hostile, negative space) — '## v2.0' appears only inside a
|
||||
// fenced code block (a documentation example of the heading syntax) on an
|
||||
// otherwise flat roadmap. hasMilestoneSectioning is routed through
|
||||
// tokenizeHeadings (fence-aware), so a fenced heading is never tokenised
|
||||
// and must NOT count as sectioning. The roadmap-declared count must be
|
||||
// used, not the disk count.
|
||||
const roadmap = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Phase 1: One',
|
||||
'## Phase 2: Two',
|
||||
'## Phase 3: Three',
|
||||
'## Phase 4: Four',
|
||||
'',
|
||||
'Example heading syntax:',
|
||||
'',
|
||||
'```',
|
||||
'## v2.0',
|
||||
'```',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 4 }));
|
||||
seedPhaseDirs(tmpDir, [1, 2]);
|
||||
|
||||
const out = recordSessionAndReadTotalPhases(tmpDir);
|
||||
assert.strictEqual(
|
||||
Number(out.progress.total_phases),
|
||||
4,
|
||||
`a version heading inside a fence must not trigger milestone sectioning; expected roadmap count 4, got ${out.progress && out.progress.total_phases}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #3185 adversarial review — hasMilestoneSectioning ownership-model shapes
|
||||
// missed by the original suite (BLOCKER + MAJOR findings against the #3184
|
||||
// "strictly-deeper nesting" rewrite).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('#3185 review — hasMilestoneSectioning shapes the original suite missed', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('BLOCKER: same-level sibling milestones fall back to the disk count', () => {
|
||||
// Adversarial review BLOCKER (#1761 regression): the #3184 rewrite
|
||||
// required a candidate milestone heading's owned Phase heading to be
|
||||
// STRICTLY DEEPER (next.level > candidate.level). Real sibling
|
||||
// milestones are frequently at the SAME level as their own Phase
|
||||
// headings ('## v1.0' / '## Phase 1:' / '## v2.0' / '## Phase 3:'), so
|
||||
// that predicate answered false and the whole-document count conflated
|
||||
// both milestones. The asserted milestone ('v3.0') is unbound (matches
|
||||
// neither v1.0 nor v2.0), so this is genuinely sectioned and must fall
|
||||
// back to the disk count.
|
||||
const roadmap = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## v1.0',
|
||||
'## Phase 1: One',
|
||||
'## Phase 2: Two',
|
||||
'',
|
||||
'## v2.0',
|
||||
'## Phase 3: Three',
|
||||
'## Phase 4: Four',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
buildStateMd({ milestone: 'v3.0', milestoneName: 'Third', totalPhases: 4 }),
|
||||
);
|
||||
seedPhaseDirs(tmpDir, [1, 2]);
|
||||
|
||||
const out = recordSessionAndReadTotalPhases(tmpDir);
|
||||
assert.strictEqual(
|
||||
Number(out.progress.total_phases),
|
||||
2,
|
||||
`same-level sibling milestones must fall back to the disk count (2), got ${out.progress && out.progress.total_phases}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('#3185 repro: structural headings interleaved among flat phases keep the roadmap count', () => {
|
||||
// #3185's own reproduction of the adjacency model this suite's
|
||||
// predecessor shipped: '## Overview' sits immediately before
|
||||
// '## Phase 1:' and '## Notes' sits immediately before '## Phase 4:',
|
||||
// giving an adjacency-based predicate 2 "owning" candidates even though
|
||||
// neither heading carries any milestone vocabulary (no version token, no
|
||||
// status marker, no "Milestone" word) and the roadmap is genuinely flat.
|
||||
const roadmap = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Overview',
|
||||
'## Phase 1: One',
|
||||
'## Phase 2: Two',
|
||||
'## Phase 3: Three',
|
||||
'## Notes',
|
||||
'## Phase 4: Four',
|
||||
'## Phase 5: Five',
|
||||
'## Phase 6: Six',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
buildStateMd({ milestone: 'v9.9', milestoneName: 'Test', totalPhases: 6 }),
|
||||
);
|
||||
seedPhaseDirs(tmpDir, [1, 2]);
|
||||
|
||||
const out = recordSessionAndReadTotalPhases(tmpDir);
|
||||
assert.strictEqual(
|
||||
Number(out.progress.total_phases),
|
||||
6,
|
||||
`#3185: structural headings adjacent to phase headings must not be treated as milestone sectioning; expected roadmap count 6, got ${out.progress && out.progress.total_phases}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('MAJOR: wrapper + single nested milestone keeps the roadmap count', () => {
|
||||
// Adversarial review MAJOR (#3204 reintroduction): every ancestor in a
|
||||
// nesting chain was counted as its own candidate section under the
|
||||
// #3184 rewrite, so a generic wrapper heading with only ONE real
|
||||
// milestone nested under it was misclassified as sectioned. Mirrors
|
||||
// this repo's own bundled template shape (gsd-core/templates/roadmap.md:
|
||||
// '## Phases' -> '### 🚧 v1.1 [Name] (In Progress)' -> '#### Phase N:').
|
||||
// The asserted milestone ('v9.9') is deliberately unbound so the
|
||||
// assertion exercises hasMilestoneSectioning itself, not
|
||||
// isMilestoneBoundedInRoadmap.
|
||||
const roadmap = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Phases',
|
||||
'',
|
||||
'### 🚧 v1.1 [Name] (In Progress)',
|
||||
'',
|
||||
'#### Phase 1: One',
|
||||
'#### Phase 2: Two',
|
||||
'#### Phase 3: Three',
|
||||
'#### Phase 4: Four',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
buildStateMd({ milestone: 'v9.9', milestoneName: 'Unbound', totalPhases: 2 }),
|
||||
);
|
||||
seedPhaseDirs(tmpDir, [1, 2]);
|
||||
|
||||
const out = recordSessionAndReadTotalPhases(tmpDir);
|
||||
assert.strictEqual(
|
||||
Number(out.progress.total_phases),
|
||||
4,
|
||||
`wrapper + single nested milestone must keep the roadmap-declared count (4), not clobber to the disk count of 2. Got ${out.progress && out.progress.total_phases}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Rows 15-18 — #3185 consolidation independence checks
|
||||
//
|
||||
// These exercise the directory-enumeration owner (listMilestonePhaseDirs /
|
||||
// getMilestonePhaseFilter), not hasMilestoneSectioning. Verified PASSING
|
||||
// against the current build (manual CLI probe) before being added here —
|
||||
// included per the dispatch brief's "include only if they pass today"
|
||||
// condition. If a future change to the #3204 fix regresses one of these,
|
||||
// that is a SEPARATE finding from the #3204 repro above, not folded into it.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('#3185 buildStateFrontmatter total_phases — directory-enumeration independence (currently passing)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('sentinel directories are excluded by the canonical enumeration', () => {
|
||||
// Row 15 — a 999.x backlog directory alongside 3 real phase directories
|
||||
// must not inflate total_phases.
|
||||
const roadmap = ['# Roadmap', '', '## Phase 1: One', '## Phase 2: Two', '## Phase 3: Three', ''].join('\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 3 }));
|
||||
seedPhaseDirs(tmpDir, [1, 2, 3]);
|
||||
seedNamedPhaseDir(tmpDir, '999.1-backlog-idea', '999.1');
|
||||
|
||||
const out = recordSessionAndReadTotalPhases(tmpDir);
|
||||
assert.strictEqual(
|
||||
Number(out.progress.total_phases),
|
||||
3,
|
||||
`sentinel 999.x directory must be excluded, expected 3, got ${out.progress && out.progress.total_phases}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('pre-milestone directories are excluded', () => {
|
||||
// Row 16 — a '0-*' pre-milestone directory must not be counted.
|
||||
const roadmap = ['# Roadmap', '', '## Phase 1: One', '## Phase 2: Two', '## Phase 3: Three', ''].join('\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 3 }));
|
||||
seedPhaseDirs(tmpDir, [1, 2, 3]);
|
||||
seedNamedPhaseDir(tmpDir, '0-premilestone', '0');
|
||||
|
||||
const out = recordSessionAndReadTotalPhases(tmpDir);
|
||||
assert.strictEqual(
|
||||
Number(out.progress.total_phases),
|
||||
3,
|
||||
`pre-milestone '0-*' directory must be excluded, expected 3, got ${out.progress && out.progress.total_phases}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('duplicate phase-number directories count once', () => {
|
||||
// Row 17 — two directories both keyed to phase number 2 must dedup to a
|
||||
// single count.
|
||||
const roadmap = ['# Roadmap', '', '## Phase 1: One', '## Phase 2: Two', '## Phase 3: Three', ''].join('\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 3 }));
|
||||
seedPhaseDirs(tmpDir, [1, 2, 3]);
|
||||
seedNamedPhaseDir(tmpDir, '02-phase-2-dup', '02');
|
||||
|
||||
const out = recordSessionAndReadTotalPhases(tmpDir);
|
||||
assert.strictEqual(
|
||||
Number(out.progress.total_phases),
|
||||
3,
|
||||
`duplicate phase-2 directories must dedup to a single count (3), got ${out.progress && out.progress.total_phases}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('re-running record-session does not move total_phases', () => {
|
||||
// Row 18 — idempotence: a second record-session call over an unchanged
|
||||
// tree, with the clock pinned so 'Last session' does not itself vary,
|
||||
// must produce a byte-identical STATE.md.
|
||||
const roadmap = ['# Roadmap', '', '## Phase 1: One', '## Phase 2: Two', '## Phase 3: Three', ''].join('\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
||||
const sessionState = [
|
||||
'# GSD State',
|
||||
'',
|
||||
'## Session',
|
||||
'',
|
||||
'**Last session:** 2024-01-01T00:00:00.000Z',
|
||||
'**Stopped at:** None',
|
||||
'**Resume file:** None',
|
||||
'',
|
||||
'## Current Position',
|
||||
'',
|
||||
'**Current Phase:** 01',
|
||||
'**Status:** Executing',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), sessionState);
|
||||
seedPhaseDirs(tmpDir, [1, 2, 3]);
|
||||
|
||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||||
const pinnedEnv = { GSD_TEST_MODE: '1', GSD_NOW_MS: '1600000000000' };
|
||||
const args = ['state', 'record-session', '--stopped-at', 'Phase 1, Plan 1', '--resume-file', 'none'];
|
||||
|
||||
const first = runGsdTools(args, tmpDir, pinnedEnv);
|
||||
assert.ok(first.success, `first record-session failed: ${first.error}`);
|
||||
const afterFirst = fs.readFileSync(statePath, 'utf8');
|
||||
|
||||
const second = runGsdTools(args, tmpDir, pinnedEnv);
|
||||
assert.ok(second.success, `second record-session failed: ${second.error}`);
|
||||
const afterSecond = fs.readFileSync(statePath, 'utf8');
|
||||
|
||||
assert.strictEqual(
|
||||
afterSecond,
|
||||
afterFirst,
|
||||
're-running record-session on an unchanged tree with a pinned clock must produce a byte-identical STATE.md',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user