fix(#3204): milestone sectioning is vocabulary, not heading position (#3230)

* 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:
Tom Boucher
2026-08-08 22:01:20 -04:00
committed by GitHub
parent 86bebcefa2
commit 2a73f53cb3
6 changed files with 906 additions and 18 deletions

View File

@@ -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;
}
/**

View File

@@ -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 {