fix(#4078): phase.complete next-phase cascade reads dash-grammar checkbox rows (#4301)

* test(#4078): phase.complete mixed-grammar roadmap picks lowest outstanding phase, not positional-last

* fix(#4078): accept dash-grammar checkbox rows in phase.complete next-phase cascade

Stage 2 (roadmap identity scan) and stage 3 (#2028 lowest-outstanding
override) required a colon separator after the phase number, while the
canonical phase lookup has accepted the bullet-house dash grammar
(- [ ] **Phase N — Name**, #2199) for years. On a mixed-grammar roadmap
the only parseable row above N was a later phase.add-ingested colon-form
phase - positionally last - and it won the numeric-minimum vote it should
never have been alone in: completing Phase 1 of 18 selected Phase 18 and
skipped phases 2-17 (#4078).

The checkbox branches now accept the #2199 separator class (em/en-dash,
hyphen, colon); heading branches stay colon-only, mirroring
findRoadmapPhaseInContent exactly.

* test(#4078): align regression fixtures with slug name + checked-box semantics

* fix(#4078): drop unnecessary type assertion flagged by eslint

* chore(#4078): add changeset fragment

* chore(#4078): backfill PR number in changeset

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-04 22:03:43 -04:00
committed by GitHub
parent 30c40e0fe2
commit 02ad0b91f3
3 changed files with 263 additions and 7 deletions

View File

@@ -4182,16 +4182,35 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
// #1729: `(?:\s*\([^)\n]{0,200}\))?` after the number tolerates a pre-colon
// ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE) so
// `### Phase N (Cluster B): X` resolves. Captures are unchanged.
//
// #4078: the checkbox branch's separator is no longer colon-only. The
// canonical phase lookup has accepted the bullet-house dash grammar
// (`- [ ] **Phase N — Name**`, em/en-dash/hyphen/colon) since #2199
// (`BULLET_PHASE_LINE_PATTERN`, roadmap-parser.cjs), but this scan still
// required `:`, so on a roadmap whose original rows use the dash grammar
// the ONLY parseable row above N was typically a later phase.add-ingested
// colon-form phase — positionally last — and it won the numeric-minimum
// vote it should never have been alone in (observed: 18 of 18 selected,
// phases 2–17 skipped). The heading branch stays colon-only, mirroring
// `findRoadmapPhaseInContent`'s heading grammar exactly; only the
// checkbox branch widens, and only to the separators #2199 already
// accepts. The two branches keep separate capture groups, normalized
// just below the loop.
const phasePattern = new RegExp(
`(?:#{2,4}|-\\s*\\[[ xX]\\])\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)`,
`(?:#{2,4}\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)` +
`|-\\s*\\[[ xX]\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*[—–:\\-]\\s*([^\\n*]+))`,
'gi'
);
let pm: RegExpExecArray | null;
while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
// #4078: normalize the two alternation branches' captures (heading
// branch → groups 1/2, widened checkbox branch → groups 3/4).
const pmNum = pm[1] ?? pm[3];
const pmName = pm[2] ?? pm[4];
// #2786: skip sentinel phase ids (999.x backlog, 0.x drafts) — stage 1
// already skips sentinel dirs on disk via isSentinelPhaseId (#3185);
// stage 2's heading scan must not advance into backlog headings either.
if (isSentinelPhaseId(pm[1])) continue;
if (isSentinelPhaseId(pmNum)) continue;
// #3701 review: the numeric MINIMUM above N, not the first row above N in
// DOCUMENT order. This scan walks raw roadmap text, and one global regex
// sweeps both the `## Phases` checklist and the `## Phase Details`
@@ -4206,10 +4225,10 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
// Phase NUMBERS define sequence here, exactly as `comparePhaseNum` does for
// the disk scan and for #2028's lowest-outstanding override; the roadmap
// defines which phases EXIST and which milestone they belong to.
if (comparePhaseNum(pm[1], phaseNum) > 0
&& (roadmapNextNum === null || comparePhaseNum(pm[1], roadmapNextNum) < 0)) {
roadmapNextNum = pm[1];
roadmapNextName = pm[2]
if (comparePhaseNum(pmNum, phaseNum) > 0
&& (roadmapNextNum === null || comparePhaseNum(pmNum, roadmapNextNum) < 0)) {
roadmapNextNum = pmNum;
roadmapNextName = pmName
.replace(/\(INSERTED\)/i, '')
.trim()
.toLowerCase()
@@ -4271,8 +4290,13 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
if (roadmapContent !== null) {
try {
const milestoneScope = extractCurrentMilestone(roadmapContent, cwd);
// #4078: the separator class here mirrors stage 2's widened checkbox
// branch (and #2199's BULLET_PHASE_LINE_PATTERN): em/en-dash/hyphen/colon.
// Without it, this lowest-outstanding override was blind to dash-grammar
// rows and could not correct an out-of-order completion on the same
// mixed-grammar roadmaps that broke stage 2.
const cbPattern = new RegExp(
`-\\s*\\[(x| )\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)`,
`-\\s*\\[(x| )\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*[—–:\\-]\\s*([^\\n*]+)`,
'gi'
);
let cbm: RegExpExecArray | null;