* 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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user