refactor(#3184): milestone windowing has one owner and a decidable failure signal (#3209)

* test(#3184): failing-first milestone-window single-owner suite

Covers the 50 input classes in the phase test matrix: scope classification
(genuinely-empty vs truncated vs unscoped vs unreadable), the section-end
owner's level boundaries, consumer-output identity per ADR-3180 Decision 4(c),
the milestone.complete refusal with negative proof that no directory moved,
the version-token boundary defect, drift-guard behavior, and three fast-check
properties over document-shaped generators.

Committed alone so the remote runner records the failure before the fix lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kfkRFNUESoBspUYcAQaT3

* refactor(#3184): milestone windowing routes through one owner

Three copies of the milestone section-end walk lived in roadmap-parser.cts —
two distinct computeSectionEnd function nodes plus an inline third in
getMilestonePhaseFilter's versionOverride branch. computeMilestoneSectionEnd is
now the sole owner and the other two are deleted, not kept in sync by comment.

The whole-repo drift guard found what the epic did not: state.cts held three
more re-derivations of the same vocabulary — two byte-identical milestone
bounding checks carrying a defect neither reported copy has (no boundary after
the version token, so v2.0 matched inside v2.0.1), and a milestone-sectioning
predicate. All three route through the owner now.

A composition-level duplicate appeared inside this change's own first pass:
getMilestonePhaseFilter and cmdMilestoneComplete each re-assembled a window out
of the owner's primitives, and had already diverged on whether to skip a closed
milestone heading. sliceMilestoneWindow is the one composition.

Windows now carry the ADR-3180 SCOPE discriminator, so a truncated window is
distinguishable from a genuinely empty milestone — those were output-identical,
which is the whole failure class. roadmap analyze emits it (#3165), and
milestone complete refuses to archive on anything but COMPLETE rather than
pass-all moving every phase directory on disk (#3166). The pass-all degrade is
preserved where its premise holds: making the filter deny-all would trade a
silent over-inclusive answer for a silent under-inclusive one on the read paths
that count with it.

extractCurrentMilestone keeps its signature — 200+ affected symbols across 41
files and 25 process flows — and is a one-line wrapper over the scoped owner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kfkRFNUESoBspUYcAQaT3

* fix(#3184): fence-aware phase detection and one heading-selection owner

Review fixes from the two orthogonal passes.

The blocker: hasPhaseEntries matched ATX phase headings fence-aware via
tokenizeHeadings but tested the #2199 bullet form against un-stripped markdown,
so a fenced EXAMPLE of the bullet syntax counted as a real phase. A genuinely
empty milestone then classified TRUNCATED and milestone complete refused a
legitimate archive — a false positive in the destructive direction, worse than
the defect this phase set out to fix. Both that path and getMilestonePhaseFilter
own pre-existing bullet scan now run on stripFencedCode, since leaving one meant
the owner file gave two different answers to the same question.

The selection rule — locate, prefer the non-closed heading, else the first — had
been written three more times inside the file whose thesis is single ownership.
selectMilestoneHeading owns it; all three sites route through it. The copies were
behaviorally identical, so this is de-duplication with no observable change,
verified by probing that all three paths select the same heading.

roadmap analyze emitting a scope no consumer read left #3165's actual symptom
alive, so Route 0 in next.md now treats a non-complete scope as scan-failed
rather than as a clean empty scan, and the ADR amendment no longer overstates
what shipped.

Also: the scope refusal moved above the archive-directory create, so a refusal
leaves nothing on disk; the versionOverride comment names all four consumers;
COMMANDS.md documents the new guard beside its sibling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kfkRFNUESoBspUYcAQaT3

* test(#2658): exclude the changelog from the malformed-path scan

The gate walks every emitted .md/.js/.cjs file in an installed tree and asserts
none contains `.claude/.trae/rules` or `.trae/.trae/rules`. CHANGELOG.md ships
into that tree, and its #2658 entry quotes both malformed paths while describing
the fix that removed them — so the release note documenting the fix trips the
fix's own regression test. Red on next before this branch.

The installer is correct: a probe over a real --trae --local install found 621
emitted files, exactly one hit, and it was gsd-core/CHANGELOG.md. The scan scope
was the defect, not the product.

Excluded by exact relative path rather than by loosening the patterns or skipping
all markdown — the emitted agent and command markdown is precisely what #2658 was
about, so the gate stays strong everywhere it matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kfkRFNUESoBspUYcAQaT3

* test(#3184): regenerate install-tree fixtures for the shared drift scanner

scripts/lib/ ships in the npm package and installer, so extracting the shared
tree-walk into scripts/lib/drift-scan.cjs adds one path to every runtime's
install tree. Regenerated via npm run gen:install-tree; the delta is exactly
that one path per fixture.

The two drift guards themselves do not ship (scripts/lint-*.cjs is excluded),
so only the extracted library moves. This matches the existing
scripts/lib/allowlist-ratchet.cjs precedent, which is likewise a lint-only
helper carried in the shipped tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kfkRFNUESoBspUYcAQaT3

* fix(#3184): restore the #730 sub-milestone boundary and narrow the refusal

The remote runner caught two regressions this branch introduced. Both were mine,
and neither review pass found them — only running the existing suite did.

The version-token boundary. I replaced locateMilestoneHeadings' \b with
(?![\w.-]), reasoning that v2.0 matching inside v2.0.1 was the same defect #2562
fixed in isMilestoneShippedInRoadmap. It is not the same question. A milestone
state of v8.0 legitimately selects the '## v8.0-B' sub-milestone section over a
closed v8.0-A sibling (#730), and \b is what allows it while the stricter
boundary forbids it — nine tests in roadmap-phase-fallback said so. Reverted to
\b; the state.cts consolidation is now a straight merge with no behavior change,
and the v2.0/v2.0.1 ambiguity is left exactly as it was. The ADR amendment and
the design doc no longer claim otherwise.

The refusal scope. I refused whenever the window was not COMPLETE, but #3166 is
about the TRUNCATED window specifically — the heading is found and the section
closes before the phase region, so pass-all archives everything. UNREADABLE and
UNSCOPED are pre-existing, legitimately handled states, and refusing on them
broke 'handles missing ROADMAP.md gracefully' and three archive tests. Narrowed
to TRUNCATED; docs corrected to match.

One of the new tests was also wrong: its fixture gave the shipped and current
milestones' phases the same numeric id, and the filter matches on that id, so it
could not have distinguished the two windows. Fixture corrected to exercise what
it claims to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kfkRFNUESoBspUYcAQaT3

* fix(#3184): enumerate drift-scan.cjs for uninstall

The installer copies scripts/lib/ wholesale, but uninstall removes an explicit
set — deliberately, so a user's own helpers in that directory survive. The
extracted drift-scan.cjs was copied in and never enumerated, so it outlived
uninstall, left the directory non-empty, and the rmdir that follows failed.

Added to GSD_SCRIPTS_LIB_FILES, following allowlist-ratchet.cjs, which is
likewise a lint-only helper that ships there and is enumerated. Verified with a
real install-then-uninstall into a temp target: scripts/lib/ held exactly the
three GSD files and was gone afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kfkRFNUESoBspUYcAQaT3

* test(#3184): assert install and uninstall agree on scripts/lib and scripts/changeset

Found while shipping this phase, and fixed here rather than noted.

install() copies scripts/lib/ and scripts/changeset/ into the target WHOLESALE —
the comment at the copy site literally says "and any future lib helpers".
uninstall() removes them by hardcoded enumeration, deliberately, so a user's own
helpers in those directories survive. A wholesale writer paired with an
enumerated remover cannot stay in sync by construction: any file added to either
directory ships to every user and is then orphaned in their repo forever, since
it survives uninstall, leaves the directory non-empty, and the rmdir that follows
fails. Nothing reported this. 31,225 tests were green over it.

That is the same divergence class this epic exists to delete, sitting in the
installer, so it gets the same remedy CLAUDE.md prescribes for it: a parity
assertion that fails the moment the two surfaces disagree. The test compares each
directory's real contents against its enumeration and names the offending file
plus the constant to add it to.

Both enumerations are hoisted to module scope and exported, so the test asserts
on the actual arrays rather than pattern-matching the installer's source — no
allow-test-rule annotation needed. Proven non-vacuous both ways: empty diff on
the current tree, correct report when an unenumerated file is injected.

scripts/changeset/ turned out to carry the identical defect and is covered too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kfkRFNUESoBspUYcAQaT3

* chore(#3184): backfill changeset PR number

Also narrows the wording to match the shipped behavior: the refusal fires on a
truncated window specifically, not on any non-complete scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015kfkRFNUESoBspUYcAQaT3

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-08-08 09:50:35 -04:00
committed by GitHub
parent 343835facc
commit 342590c70e
39 changed files with 2564 additions and 381 deletions

View File

@@ -29,7 +29,15 @@ import phaseIdMod = require('./phase-id.cjs');
const { escapeRegex, normalizePhaseName, phaseTokenMatches, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapParserMod = require('./roadmap-parser.cjs');
const { getMilestonePhaseFilter, extractCurrentMilestone, getMilestoneInfo } = roadmapParserMod;
const {
getMilestonePhaseFilter,
extractCurrentMilestone,
getMilestoneInfo,
sliceMilestoneWindow,
} = roadmapParserMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningScopeMod = require('./planning-scope.cjs');
const { SCOPE } = planningScopeMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import coreUtilsMod = require('./core-utils.cjs');
const { extractOneLinerFromBody, countMatchedSummaries } = coreUtilsMod;
@@ -520,18 +528,40 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
const today = realClock.localToday();
const milestoneName = options.name || version;
// Ensure archive directory exists (skipped in dry-run — no mutations)
if (!options.dryRun) {
platformEnsureDir(archiveDir);
}
// Scope stats and accomplishments to only the phases belonging to the
// current milestone's ROADMAP. Uses the shared filter from roadmap-parser.cjs
// (same logic used by cmdPhasesList and other callers).
// #3184 review finding: this scope computation + refusal MUST run BEFORE
// `platformEnsureDir(archiveDir)` below — a refused run (scope not COMPLETE,
// no --force) must be a true no-op on disk, and creating the archive
// directory first left an empty directory behind even on refusal.
const isDirInMilestone = getMilestonePhaseFilter(cwd, version);
if (isDirInMilestone.missingExplicitVersion) {
error(`no phases found for milestone ${version} in ROADMAP.md`);
}
// #3184/#3166: `milestone complete` is the ONE-WAY-DOOR consumer of the
// milestone window (ROADMAP/REQUIREMENTS archived, phase directories
// MOVED). #3166 is specifically the TRUNCATED case: the milestone's
// heading IS found but its section closes before the phase region, and the
// phase filter degrades to pass-all (see getMilestonePhaseFilter above) —
// silently archiving every phase directory on disk. UNREADABLE (no
// ROADMAP.md at all) and UNSCOPED (no section for this version) are
// pre-existing, legitimately-handled states — `missingExplicitVersion`
// above already errors where that matters, and a missing ROADMAP.md has
// its own documented graceful path — so only TRUNCATED is refused here.
// The read-path consumers keep the pass-all degrade for every scope
// (ADR-3180 Decision 3's Rejected section: deny-all there would trade one
// silent wrong answer for another); this write path refuses on TRUNCATED
// alone, positioned before `platformEnsureDir` so a refusal stays a no-op
// on disk.
if (isDirInMilestone.scope === SCOPE.TRUNCATED && !options.force) {
error(
`Cannot mark milestone complete: the ROADMAP window for "${version}" is truncated ` +
`(the milestone heading was found but its section ends before reaching any phase ` +
`entries, even though the ROADMAP has phase entries elsewhere), so phase scoping ` +
`cannot be trusted for this destructive operation. Re-run with --force to override.`,
);
}
// Guard: prevent marking complete when ROADMAP still lists phases that have
// no directory on disk (disk_status: no_directory). This catches the case
@@ -587,7 +617,21 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
}
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
const scopedContent = extractCurrentMilestone(roadmapContent, cwd);
// #3184/#2946: scope the unstarted-phase guard to the same `version`
// window `getMilestonePhaseFilter` used above, NOT to
// extractCurrentMilestone's own STATE.md-derived window — those two
// can disagree (that disagreement is exactly what the WARNING above
// detects), and scoping this guard to the wrong window under-detects
// unstarted phases on the destructive completion path. Calls the same
// sliceMilestoneWindow owner getMilestonePhaseFilter's versionOverride
// branch calls (a prior pass here re-composed locate+select+section-end
// locally, which review caught as a second, disagreeing derivation of
// the same window — ADR-3180 Decision 4(c)); falls back to
// extractCurrentMilestone's whole-document result only for the
// free-form (no versioned milestones anywhere) shape, where both
// windows converge to the same value regardless of which version drove
// the lookup.
const scopedContent = sliceMilestoneWindow(roadmapContent, version) ?? extractCurrentMilestone(roadmapContent, cwd);
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
const noDirectoryPhases: string[] = [];
@@ -737,6 +781,14 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
return;
}
// Ensure archive directory exists. Deliberately placed AFTER the dry-run
// early return and every refusal/guard above (missingExplicitVersion, the
// scope refusal, the unstarted-phase guard) — #3184 review finding: this
// used to run before those checks, so a refused run still left an empty
// archive directory behind. Reaching this point means the run is
// committed to mutating.
platformEnsureDir(archiveDir);
// Archive ROADMAP.md
if (fs.existsSync(roadmapPath)) {
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');

View File

@@ -36,8 +36,12 @@ import { platformReadSync } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import unusableInputMod = require('./unusable-input.cjs');
const { UNUSABLE_REASON, warnUnusableInput } = unusableInputMod;
import { tokenizeHeadings, stripTaggedBlocks, withSection } from './markdown-sectionizer.cjs';
import { tokenizeHeadings, stripTaggedBlocks, withSection, stripFencedCode } from './markdown-sectionizer.cjs';
import type { HeadingToken } from './markdown-sectionizer.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningScopeMod = require('./planning-scope.cjs');
const { SCOPE } = planningScopeMod;
type Scope = planningScopeMod.Scope;
// ─── Roadmap milestone scoping ───────────────────────────────────────────────
@@ -97,7 +101,194 @@ function isMilestoneShippedInRoadmap(content: string, version: string): boolean
}
/**
* Extract the current milestone section from ROADMAP.md by positive lookup.
* #3184 (epic #3180 Phase 2): the sole owner of "where does this milestone
* heading's section end". Lifted from `currentMilestoneRawRanges`'s prior
* inline copy — the only one of three byte-identical copies that carried a
* "keep in sync" comment (evidence the risk was known, not controlled).
* `extractCurrentMilestoneScoped`, `currentMilestoneRawRanges`, and
* `getMilestonePhaseFilter`'s versionOverride branch all call this instead of
* re-deriving it.
*/
function computeMilestoneSectionEnd(content: string, headingText: string, headingStart: number): 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);
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;
}
return content.length;
}
/**
* #3184: the sole milestone-heading locator. Boundary-matched on the version
* token with `\b`, NOT the stricter `(?![\w.-])`: this function keeps `\b`
* because a milestone STATE legitimately selects its own sub-milestone
* heading (`v8.0` matching `## v8.0-B …` — `0` is a word char, `-` is not, so
* `\b` matches) — that is deliberate, load-bearing behavior (#730). The
* stricter `(?![\w.-])` boundary answers a DIFFERENT question — "is exactly
* this milestone shipped" (`isMilestoneShippedInRoadmap`) / "which Phase
* Details section belongs to exactly this one's version token"
* (`detailsVersionBoundary`) — and applying it here breaks #730 sub-milestone
* selection. `extractCurrentMilestoneScoped`, `currentMilestoneRawRanges`,
* and `getMilestonePhaseFilter`'s versionOverride branch all consume this
* instead of re-deriving their own heading-location regex.
*/
function locateMilestoneHeadings(content: string, version: string): RegExpExecArray[] {
const escapedVersion = escapeRegex(version);
const pattern = new RegExp(
`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}\\b[^\\n]*)`,
'gmi',
);
const matches: RegExpExecArray[] = [];
let m: RegExpExecArray | null;
while ((m = pattern.exec(content)) !== null) {
matches.push(m);
}
return matches;
}
/**
* #3184: named predicate replacing the two `state.cts` re-derivations
* (`buildStateFrontmatter`, `syncStateFrontmatter`) that each hand-rolled the
* same "is this version bounded to a versioned ROADMAP heading" regex. A
* straight consolidation of the two identical `state.cts` regexes onto the
* shared `locateMilestoneHeadings` owner — no behavior change.
*/
function isMilestoneBoundedInRoadmap(content: string, version: string): boolean {
return locateMilestoneHeadings(content, version).length > 0;
}
/**
* #3184: does this ROADMAP carry ANY versioned milestone heading (`v1.2`-style
* token on a level 1-3 non-Phase heading), independent of any particular
* version. `extractCurrentMilestoneScoped` (free-form-vs-versioned row 3/4
* classification) and `getMilestonePhaseFilter` (the deprecation warning +
* the same row 3/4 classification for its versionOverride branch) each
* hand-rolled this identically — the guard does not catch intra-owner-file
* copies by construction, so this was found by review instead.
*/
function hasVersionedMilestones(content: string): boolean {
return /^#{1,3}\s+.*v\d+\.\d+/mi.test(content);
}
/**
* #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.
*/
function hasMilestoneSectioning(content: string): boolean {
return /^#{2,3}\s+(?!Phase\s+\S)/mi.test(content);
}
/**
* #3184: the sole "which heading is this milestone's" rule — locate the version's
* headings, prefer the first that is not marked CLOSED/SHIPPED, else fall back to the
* first match. Returns null when the version has no heading at all.
*
* Extracted because three sites had written this same two-line selection
* independently (sliceMilestoneWindow, extractCurrentMilestoneScoped,
* currentMilestoneRawRanges) — the composition-level divergence ADR-3180
* Decision 4(c) covers: calling the owner's primitives and re-assembling the
* result locally is indistinguishable from re-deriving it.
*/
function selectMilestoneHeading(content: string, version: string): RegExpExecArray | null {
const matches = locateMilestoneHeadings(content, version);
if (matches.length === 0) return null;
return matches.find((m) => !isClosedMilestoneHeading(m[1])) ?? matches[0];
}
/**
* #3184: the sole "give me this version's window" composition. Delegates
* heading selection to `selectMilestoneHeading` (the sole selection owner)
* and then to `computeMilestoneSectionEnd` for the slice. Returns null when
* the version has no heading at all, so callers can distinguish "no such
* milestone section" from "empty section".
*
* Review finding (post-merge of this phase's first pass): `getMilestonePhaseFilter`'s
* versionOverride branch and `cmdMilestoneComplete`'s unstarted-phase guard
* had each independently composed `locateMilestoneHeadings` +
* `computeMilestoneSectionEnd` into a window — the SAME derivation written
* twice, and they disagreed (one skipped CLOSED headings, the other did not)
* — exactly the composition-level divergence ADR-3180 Decision 4(c) warns
* about: calling the owner and then re-assembling the result locally is
* indistinguishable from re-deriving it. Both sites now call this instead.
*/
function sliceMilestoneWindow(content: string, version: string): string | null {
const selected = selectMilestoneHeading(content, version);
if (selected === null) return null;
return content.slice(selected.index, computeMilestoneSectionEnd(content, selected[0], selected.index));
}
/**
* #3184: counts RAW phase references — a `#{2,4} Phase <id>:` heading
* (fence-aware via `tokenizeHeadings`) or a `#2199` bullet entry — BEFORE any
* sentinel filter. Used for BOTH sides of `classifyMilestoneWindow`'s row-8
* comparison (does the window contain phase entries; does the document).
* Deliberately does NOT filter `999.x`/Phase 0 sentinels: the question here
* is "did the window reach the phase region", not "how many real phases
* exist" — a window containing only sentinel phases still reached the
* region and must read COMPLETE, not TRUNCATED.
*/
function hasPhaseEntries(markdown: string): boolean {
// #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;
for (const h of tokenizeHeadings(markdown)) {
if (h.level < 2 || h.level > 4) continue;
if (phaseHeadingPattern.test(h.text)) return true;
}
// #3184 review finding: the bullet fallback must be fence-aware too, or a
// FENCED markdown EXAMPLE of the `- [ ] **Phase N — Name**` syntax (e.g. a
// doc showing the convention) counts as a real phase entry. Strip fences
// through the canonical seam before testing, matching tokenizeHeadings'
// fence-awareness above.
return BULLET_PHASE_LINE_PATTERN.test(stripFencedCode(markdown).text);
}
/**
* #3184: pure decision table (no I/O, no regex construction from caller
* data) implementing the design's Behavior table rows 1-8 (the remaining
* rows 9-17 reduce to one of these six through how the caller constructs its
* input, not additional branches here). Kernighan's Law fired during design:
* `getMilestonePhaseFilter` is already cyclomatic 36, so this discriminator
* is extracted as its own named, separately-testable function rather than
* inlined.
*/
function classifyMilestoneWindow(input: {
readable: boolean;
versionResolved: boolean;
hasVersionedMilestones: boolean;
headingFound: boolean;
windowHasPhaseEntries: boolean;
documentHasPhaseEntries: boolean;
}): Scope {
const { readable, versionResolved, hasVersionedMilestones, headingFound, windowHasPhaseEntries, documentHasPhaseEntries } = input;
return (
!readable ? SCOPE.UNREADABLE : // row 2
!versionResolved && !hasVersionedMilestones ? SCOPE.COMPLETE : // row 3: free-form legacy roadmap
!versionResolved && hasVersionedMilestones ? SCOPE.UNSCOPED : // row 4
versionResolved && !headingFound ? SCOPE.UNSCOPED : // row 5
headingFound && !windowHasPhaseEntries && documentHasPhaseEntries ? SCOPE.TRUNCATED : // row 8
SCOPE.COMPLETE // rows 6, 7
);
}
/**
* Extract the current milestone section from ROADMAP.md by positive lookup,
* carrying a `scope` discriminator (ADR-3180 Decision 2) alongside the value.
*
* @param content - ROADMAP.md content.
* @param cwd - Project working directory, used to read the companion STATE.md
@@ -106,9 +297,18 @@ function isMilestoneShippedInRoadmap(content: string, version: string): boolean
* `.planning/workstreams/<ws>/` instead of the project root. Omitted (the
* default) preserves the prior `planningDir(cwd)` resolution exactly,
* including its `GSD_WORKSTREAM` env fallback.
*
* #3184: `extractCurrentMilestone`'s CRITICAL blast radius (200+ affected
* symbols, 20 direct callers) means its signature and return type do not
* change. This is the real owner; `extractCurrentMilestone` becomes a
* one-line wrapper returning `.value` so every existing caller is untouched.
*/
function extractCurrentMilestone(content: string, cwd?: string, ws?: string | null): string {
if (!cwd) return stripShippedMilestones(content);
function extractCurrentMilestoneScoped(content: string, cwd?: string, ws?: string | null): { value: string; scope: Scope } {
if (!cwd) {
// Row 1: a deliberate unscoped read (no cwd supplied) is a real answer —
// the caller asked for no scoping, so whole-document is COMPLETE.
return { value: stripShippedMilestones(content), scope: SCOPE.COMPLETE };
}
let version: string | null = null;
try {
@@ -129,18 +329,33 @@ function extractCurrentMilestone(content: string, cwd?: string, ws?: string | nu
}
}
if (!version) return stripShippedMilestones(content);
const versionResolved = version !== null;
// #3184: routed through the shared owner (was an inline copy — see the
// twin copy in `getMilestonePhaseFilter`, the intra-owner-file duplicate
// review caught since the drift guard exempts this file by construction).
const versionedMilestonesPresent = hasVersionedMilestones(content);
const escapedVersion = escapeRegex(version);
const sectionPattern = new RegExp(
`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}\\b[^\\n]*)`,
'gmi'
);
if (!version) {
const value = stripShippedMilestones(content);
return {
value,
scope: classifyMilestoneWindow({
readable: true,
versionResolved,
hasVersionedMilestones: versionedMilestonesPresent,
headingFound: false,
windowHasPhaseEntries: hasPhaseEntries(value),
documentHasPhaseEntries: hasPhaseEntries(value),
}),
};
}
const documentHasPhaseEntries = hasPhaseEntries(stripShippedMilestones(content));
const summaryPattern = new RegExp(
`<summary[^>]*>([^<]*${escapedVersion}[^<]*)<\\/summary>`,
`<summary[^>]*>([^<]*${escapeRegex(version)}[^<]*)<\\/summary>`,
'i'
);
const headingMatches = [...content.matchAll(sectionPattern)];
const headingMatches = locateMilestoneHeadings(content, version);
if (headingMatches.length === 0) {
const summaryMatch = content.match(summaryPattern);
@@ -161,39 +376,46 @@ function extractCurrentMilestone(content: string, cwd?: string, ws?: string | nu
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
.replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]{0,200}\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
.replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
return preamble + content.slice(detailsOpenIdx, detailsEnd);
const value = preamble + content.slice(detailsOpenIdx, detailsEnd);
return {
value,
scope: classifyMilestoneWindow({
readable: true,
versionResolved,
hasVersionedMilestones: versionedMilestonesPresent,
headingFound: true,
windowHasPhaseEntries: hasPhaseEntries(value),
documentHasPhaseEntries,
}),
};
}
}
return stripShippedMilestones(content);
const value = stripShippedMilestones(content);
return {
value,
scope: classifyMilestoneWindow({
readable: true,
versionResolved,
hasVersionedMilestones: versionedMilestonesPresent,
headingFound: false,
windowHasPhaseEntries: hasPhaseEntries(value),
documentHasPhaseEntries,
}),
};
}
const allMatches = headingMatches;
const isClosed = isClosedMilestoneHeading;
const firstMatch = allMatches[0];
const selected = allMatches.find((m) => !isClosed(m[1])) || firstMatch;
// #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)!;
const sectionStart = selected.index;
const computeSectionEnd = (headingText: string, headingStart: number): 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);
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;
}
return content.length;
};
const sectionEnd = computeSectionEnd(selected[0], sectionStart);
const sectionEnd = computeMilestoneSectionEnd(content, selected[0], sectionStart);
const anyMilestonePattern = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧)/im;
const firstMilestoneMatch = content.match(anyMilestonePattern);
@@ -231,7 +453,7 @@ function extractCurrentMilestone(content: string, cwd?: string, ws?: string | nu
const detailsStart = detailsMatch.index ?? 0;
detailsSection = content.slice(
detailsStart,
computeSectionEnd(detailsMatch[0], detailsStart),
computeMilestoneSectionEnd(content, detailsMatch[0], detailsStart),
);
}
@@ -250,9 +472,31 @@ function extractCurrentMilestone(content: string, cwd?: string, ws?: string | nu
.replace(currentSectionHasPhaseDetails ? /^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]{0,200}\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim : /$/, '')
.replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
return detailsSection
const value = detailsSection
? preamble + currentSection + '\n' + detailsSection
: preamble + currentSection;
return {
value,
scope: classifyMilestoneWindow({
readable: true,
versionResolved,
hasVersionedMilestones: versionedMilestonesPresent,
headingFound: true,
windowHasPhaseEntries: hasPhaseEntries(value),
documentHasPhaseEntries,
}),
};
}
/**
* #3184: thin wrapper preserving `extractCurrentMilestone`'s exact signature
* and return type — CRITICAL blast radius (20 direct callers), so the type
* stays `string`. `extractCurrentMilestoneScoped` is the real owner; callers
* that need to branch on scope opt in to it directly.
*/
function extractCurrentMilestone(content: string, cwd?: string, ws?: string | null): string {
return extractCurrentMilestoneScoped(content, cwd, ws).value;
}
/**
@@ -603,6 +847,13 @@ type MilestonePhaseFilter = ((dirName: string) => boolean) & {
* the current milestone's.
*/
versionSectionFound: boolean;
/**
* #3184 (ADR-3180 Decision 2): the same window-classification carried by
* `extractCurrentMilestoneScoped`. The filter's FUNCTION behavior is
* UNCHANGED by this field — pass-all still passes all; a destructive
* consumer (`cmdMilestoneComplete`) reads `scope` to refuse instead.
*/
scope: Scope;
};
/**
@@ -629,13 +880,23 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
let missingExplicitVersion = false;
let versionScoped = false;
let versionSectionFound = false;
let scope: Scope = SCOPE.UNREADABLE;
try {
const roadmapPath = path.join(planningDir(cwd, ws), 'ROADMAP.md');
const roadmapContent = platformReadSync(roadmapPath);
if (roadmapContent === null) throw new Error('missing');
let roadmap = extractCurrentMilestone(roadmapContent, cwd, ws);
const scopedResult = extractCurrentMilestoneScoped(roadmapContent, cwd, ws);
let roadmap = scopedResult.value;
// Default: the filter's window IS extractCurrentMilestoneScoped's own
// window (reused verbatim, not re-derived — ADR-3180 Decision 4c).
// Overwritten below when `versionOverride` scopes to a DIFFERENT window.
scope = scopedResult.scope;
const hasVersionedMilestonesGlobal = /^#{1,3}\s+.*v\d+\.\d+/mi.test(roadmapContent);
// #3184: routed through the shared owner (was an inline copy — see the
// twin copy in `extractCurrentMilestoneScoped`, the intra-owner-file
// duplicate review caught since the drift guard exempts this file by
// construction).
const hasVersionedMilestonesGlobal = hasVersionedMilestones(roadmapContent);
const hasPhaseHeadings = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+[\w]/i.test(roadmapContent);
if (!hasVersionedMilestonesGlobal && hasPhaseHeadings && phaseIdConvention === 'milestone-prefixed') {
console.warn(
@@ -646,51 +907,48 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
}
if (versionOverride) {
const escapedVersion = escapeRegex(versionOverride);
const sectionPattern = new RegExp(`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}[^\\n]*)`, 'mi');
let sectionMatch = roadmapContent.match(sectionPattern);
// #3184: route the whole "locate headings -> pick the active one ->
// section-end" composition through the single owner (sliceMilestoneWindow)
// instead of assembling it here. This branch used to be a bare `.match()`
// — first hit, no closed-heading skip, no version-token boundary — and a
// review pass caught it independently re-composing the SAME primitives
// `cmdMilestoneComplete`'s guard composed, disagreeing on closed-heading
// skipping. Now both sites call one function. Boundary-matched
// (`(?![\w.-])`) and closed-heading-skipping is a declared Tier-2 change
// affecting every caller that passes `versionOverride`: `roadmap.analyze`
// / `milestone complete` (this module, `cmdMilestoneComplete` in
// milestone.cts), `inspectWorkstream` (workstream-inventory.cts:518,
// via `currentVersion`), and `buildStateFrontmatter` (state.cts:1700,
// via `storedMilestone`).
const sliced = sliceMilestoneWindow(roadmapContent, versionOverride);
if (!sectionMatch) {
const summaryPat = new RegExp(`<summary[^>]*>[^<]*${escapedVersion}[^<]*<\\/summary>`, 'i');
const summaryHit = roadmapContent.match(summaryPat);
if (summaryHit) {
const beforeSummary = roadmapContent.slice(0, summaryHit.index);
const detailsIdx = beforeSummary.lastIndexOf('<details');
if (detailsIdx !== -1) {
sectionMatch = null;
}
}
}
const documentHasPhaseEntries = hasPhaseEntries(stripShippedMilestones(roadmapContent));
if (!sectionMatch) {
const hasVersionedMilestones = /^#{1,3}\s+(?!Phase\s+\S).*v\d+\.\d+/mi.test(roadmapContent);
if (sliced !== null) {
versionScoped = true;
versionSectionFound = true;
roadmap = sliced;
} else {
const escapedVersion = escapeRegex(versionOverride);
const versionInSummary = new RegExp(`<summary[^>]*>[^<]*${escapedVersion}[^<]*<\\/summary>`, 'i').test(roadmapContent);
if (hasVersionedMilestones && !versionInSummary) {
if (hasVersionedMilestonesGlobal && !versionInSummary) {
roadmap = '';
missingExplicitVersion = true;
}
} else {
versionScoped = true;
versionSectionFound = true;
const sectionStart = sectionMatch.index!;
const headingLevel = (sectionMatch[1].match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
const afterHeading = sectionStart + sectionMatch[0].length;
// Use tokenizeHeadings (fence-aware, offsets into original content) to find
// the next milestone-boundary heading. T4 seam migration.
const allHeadings = tokenizeHeadings(roadmapContent);
let sectionEnd = roadmapContent.length;
for (const h of allHeadings) {
if (h.offset < afterHeading) continue;
if (h.level > headingLevel) continue;
if (/^Phase\s+\S/i.test(h.text)) continue;
if (!/v\d+\.\d+|✅|📋|🚧/i.test(h.text)) continue;
sectionEnd = h.offset;
break;
}
const currentSection = roadmapContent.slice(sectionStart, sectionEnd);
roadmap = currentSection;
// else: version appears only inside a `<summary>`, or there are no
// versioned milestones anywhere — `roadmap` keeps
// extractCurrentMilestoneScoped's own (STATE-scoped) result, matching
// the pre-existing summary-block / free-form fallback shape.
}
scope = classifyMilestoneWindow({
readable: true,
versionResolved: true,
hasVersionedMilestones: hasVersionedMilestonesGlobal,
headingFound: sliced !== null,
windowHasPhaseEntries: hasPhaseEntries(roadmap),
documentHasPhaseEntries,
});
}
// Use tokenizeHeadings (fence-aware) instead of stripFencedLines + regex.
@@ -706,10 +964,15 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
// #2199: also count bullet/checkbox phase entries (`- [ ] **Phase N — name**`)
// so a bullet-house-style ROADMAP populates the milestone phase set instead of
// collapsing to a zero-count pass-all filter.
// #3184 review finding: this scan must be fence-aware like `hasPhaseEntries`
// above — otherwise a fenced markdown EXAMPLE of the bullet syntax inflates
// milestonePhaseNums / phaseCount. Strip fences through the canonical seam
// first.
{
let bm: RegExpExecArray | null;
const scanner = new RegExp(BULLET_PHASE_LINE_PATTERN.source, 'gim');
while ((bm = scanner.exec(roadmap)) !== null) {
const roadmapUnfenced = stripFencedCode(roadmap).text;
while ((bm = scanner.exec(roadmapUnfenced)) !== null) {
if (!/^999\b/.test(bm[1])) milestonePhaseNums.add(bm[1]);
}
}
@@ -719,7 +982,9 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
* any failure milestonePhaseNums stays empty, which below already
* degrades to the same pass-all filter this function returns when a
* ROADMAP genuinely has zero recognizable phase headings — a safe,
* non-corrupting (over-inclusive, never under-inclusive) degrade. */
* non-corrupting (over-inclusive, never under-inclusive) degrade.
* #3184: `scope` was set to SCOPE.UNREADABLE before the try (row 2) and
* is left as-is here — the read/parse fault IS the unreadable case. */
}
if (milestonePhaseNums.size === 0) {
@@ -731,6 +996,10 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
// `versionScoped` is reset here — this is the only surviving evidence that
// the current milestone exists in the ROADMAP and simply has no phases yet.
passAll.versionSectionFound = versionSectionFound;
// #3184: the filter's FUNCTION behavior is unchanged — pass-all still
// passes all. `scope` is the decidable signal a destructive consumer
// reads to refuse instead (ADR-3180 Decision 3's two-tier policy).
passAll.scope = scope;
return passAll;
}
@@ -776,6 +1045,7 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
(isDirInMilestone as MilestonePhaseFilter).missingExplicitVersion = missingExplicitVersion;
(isDirInMilestone as MilestonePhaseFilter).versionScoped = versionScoped;
(isDirInMilestone as MilestonePhaseFilter).versionSectionFound = versionSectionFound;
(isDirInMilestone as MilestonePhaseFilter).scope = scope;
return isDirInMilestone as MilestonePhaseFilter;
}
@@ -785,12 +1055,12 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
* writer) so they cannot touch a backticked prose literal, a Backlog entry, or a
* same-numbered phase in a shipped milestone.
*
* Mirrors the region selection in `extractCurrentMilestone` (version detection →
* active heading → next milestone boundary → optional Phase Details section).
* Returns null when there is no versioned active milestone; callers then fall
* back to whole-content mutation (the prior behaviour).
*
* NOTE: keep the region logic here in sync with extractCurrentMilestone.
* Mirrors the region selection in `extractCurrentMilestoneScoped` (version
* detection → active heading → next milestone boundary → optional Phase
* Details section) — both consume the same `locateMilestoneHeadings` /
* `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).
*/
function currentMilestoneRawRanges(
content: string,
@@ -813,33 +1083,16 @@ function currentMilestoneRawRanges(
}
if (!version) return null;
const escapedVersion = escapeRegex(version);
const sectionPattern = new RegExp(
`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}\\b[^\\n]*)`,
'gmi',
);
const headingMatches = [...content.matchAll(sectionPattern)];
const headingMatches = locateMilestoneHeadings(content, version);
if (headingMatches.length === 0) return null;
const isClosed = isClosedMilestoneHeading;
const firstMatch = headingMatches[0];
const selected = headingMatches.find((m) => !isClosed(m[1])) || firstMatch;
// #3184: selection collapses to the sole owner; `headingMatches` is still
// needed below for the detailsMatch search over all headings.
const selected = selectMilestoneHeading(content, version)!;
const sectionStart = selected.index ?? 0;
const computeSectionEnd = (headingText: string, headingStart: number): number => {
const level = (headingText.match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
const afterHeading = headingStart + headingText.length;
for (const h of tokenizeHeadings(content)) {
if (h.offset <= headingStart) continue;
if (h.offset < afterHeading) continue;
if (h.level > level) continue;
if (/^Phase\s+\S/i.test(h.text)) continue;
if (!/v\d+\.\d+|✅|📋|🚧/i.test(h.text)) continue;
return h.offset;
}
return content.length;
};
const sectionEnd = computeSectionEnd(selected[0], sectionStart);
const sectionEnd = computeMilestoneSectionEnd(content, selected[0], sectionStart);
const selectedVersionToken = selected[1].match(
/v\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*/i,
@@ -857,7 +1110,7 @@ function currentMilestoneRawRanges(
let details: { start: number; end: number } | null = null;
if (detailsMatch) {
const detailsStart = detailsMatch.index ?? 0;
details = { start: detailsStart, end: computeSectionEnd(detailsMatch[0], detailsStart) };
details = { start: detailsStart, end: computeMilestoneSectionEnd(content, detailsMatch[0], detailsStart) };
}
return { primary: { start: sectionStart, end: sectionEnd }, details };
@@ -866,11 +1119,23 @@ function currentMilestoneRawRanges(
export = {
stripShippedMilestones,
extractCurrentMilestone,
extractCurrentMilestoneScoped,
isMilestoneShippedInRoadmap,
isMilestoneBoundedInRoadmap,
replaceInCurrentMilestone,
getRoadmapPhaseInternal,
getMilestoneInfo,
getMilestonePhaseFilter,
currentMilestoneRawRanges,
withPhaseSection,
computeMilestoneSectionEnd,
locateMilestoneHeadings,
selectMilestoneHeading,
classifyMilestoneWindow,
// #3184: the sole "give me this version's window" composition — see its
// own doc comment. milestone.cts's destructive-consumer guard consumes
// this instead of composing locate+select+section-end itself.
sliceMilestoneWindow,
hasVersionedMilestones,
hasMilestoneSectioning,
};

View File

@@ -20,7 +20,7 @@ import phaseLocatorMod = require('./phase-locator.cjs');
const { findPhaseInternal } = phaseLocatorMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapParserModule = require('./roadmap-parser.cjs');
const { stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone } = roadmapParserModule;
const { stripShippedMilestones, extractCurrentMilestone, extractCurrentMilestoneScoped, replaceInCurrentMilestone } = roadmapParserModule;
import { tokenizeHeadings } from './markdown-sectionizer.cjs';
import { updateTableCell } from './markdown-table.cjs';
import { platformWriteSync } from './shell-command-projection.cjs';
@@ -309,7 +309,10 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
}
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
const content = extractCurrentMilestone(rawContent, cwd);
// #3184/#3165: use the scoped variant so a truncated window is a
// distinguishable signal in the output instead of a silent `phase_count: 0`
// indistinguishable from a genuinely empty milestone.
const { value: content, scope } = extractCurrentMilestoneScoped(rawContent, cwd);
const phasesDir = planningPaths(cwd).phases;
// Extract all phase headings: ## Phase N: Name or ### Phase N: Name
@@ -486,6 +489,11 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
current_phase: currentPhase ? currentPhase.number : null,
next_phase: nextPhase ? nextPhase.number : null,
missing_phase_details: missingDetails.length > 0 ? missingDetails : null,
// #3184/#3165: distinguishes a genuinely empty milestone (`scope:
// "complete"`, `phase_count: 0`) from a window that could not be fully
// resolved (`"truncated"` / `"unscoped"` / `"unreadable"`) — those cases
// were previously output-identical.
scope,
};
output(result, raw, undefined);

View File

@@ -19,7 +19,7 @@ import phaseIdMod = require('./phase-id.cjs');
const { escapeRegex, parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE, phaseKeyFromToken, phaseKeyFromDir } = phaseIdMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapParserMod = require('./roadmap-parser.cjs');
const { getMilestoneInfo, getMilestonePhaseFilter, extractCurrentMilestone } = roadmapParserMod;
const { getMilestoneInfo, getMilestonePhaseFilter, extractCurrentMilestone, isMilestoneBoundedInRoadmap, hasMilestoneSectioning } = roadmapParserMod;
import { platformWriteSync, platformReadSync, platformEnsureDir, retryRenameSync, toPosixPath } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs');
@@ -1775,11 +1775,12 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto
// downstream (mirrors the sync write-path guard).
let milestoneBounded = true;
if (milestone && roadmapRaw !== null) {
const versionedHeading = new RegExp(
`^#{1,3}\\s+(?!Phase\\s+\\S).*${escapeRegex(String(milestone).trim())}`,
'mi',
);
milestoneBounded = versionedHeading.test(roadmapRaw);
// #3184: routed through the single owner (roadmap-parser.cjs)
// instead of a hand-rolled, unbounded-substring re-derivation —
// 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(milestone).trim());
}
// #2828: distinguish a FLAT unmilestoned roadmap (no milestone sectioning
// at all — only Phase headings) from a MILESTONED-but-unbounded one
@@ -1787,10 +1788,14 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto
// On a flat roadmap the whole-doc count is correct (no sibling milestones to
// conflate); on a sectioned-but-unbounded one it conflates siblings (#1761),
// so fall back to phaseDirs.length.
const hasMilestoneSectioning = roadmapRaw !== null
&& /^#{2,3}\s+(?!Phase\s+\S)/mi.test(roadmapRaw);
// #3184: routed through the single owner (roadmap-parser.cjs) —
// deliberately weaker than isMilestoneBoundedInRoadmap above (no
// version-token requirement); see hasMilestoneSectioning's own
// doc comment for why that distinction is load-bearing.
const roadmapHasMilestoneSectioning = roadmapRaw !== null
&& hasMilestoneSectioning(roadmapRaw);
const safeToUseRoadmapCount = milestoneBounded
|| (roadmapPhaseCount > 0 && !hasMilestoneSectioning);
|| (roadmapPhaseCount > 0 && !roadmapHasMilestoneSectioning);
return {
totalPhases: safeToUseRoadmapCount
? Math.max(phaseDirs.length, roadmapPhaseCount)
@@ -2986,8 +2991,10 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b
const versionStr = typeof fmVersion === 'string' && fmVersion.trim() ? fmVersion.trim() : null;
let milestoneBounded = true;
if (versionStr !== null && syncRoadmapRaw !== null) {
const versionedHeading = new RegExp(`^#{1,3}\\s+(?!Phase\\s+\\S).*${escapeRegex(versionStr)}`, 'mi');
milestoneBounded = versionedHeading.test(syncRoadmapRaw);
// #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);
}
let percent: number | null = null;
if (!milestoneBounded) {