From 19fa7364e045290604c89b0d99b85e9d31510981 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 12 Jul 2026 23:23:45 -0400 Subject: [PATCH] fix(#2199): accept bullet/em-dash phase entries in roadmap lookup + milestone filter (#2228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(#2199): cover bullet/em-dash ROADMAP phase resolution + milestone count Adds the bullet-only ROADMAP fixture the suite lacked: an all-bullet em-dash ROADMAP resolves each phase (no Phase null), colon/en-dash/hyphen bullet separators all resolve, mixed heading + bullet forms coexist, and the milestone phase-count counts bullet-form phases instead of collapsing to zero. * fix(#2199): accept bullet/em-dash phase entries in roadmap lookup + milestone filter Roadmap phase lookup (findRoadmapPhaseInContent) matched only ATX headings against a colon-required pattern, so a bullet/checkbox entry like `- [ ] **Phase N — name**` — which the bundled roadmapper emits in bullet-house- style ROADMAPs — resolved found:false and `Phase null` was written into STATE.md. The milestone phase-filter built its phase set from headings only, so a bullet- only ROADMAP collapsed to a zero-count pass-all filter and progress denominators broke. - Add a shared bullet-phase-line pattern (separator: em-dash/en-dash/hyphen/colon). - findRoadmapPhaseInContent: on a heading-match miss, fall back to the bullet line for the requested phase; return found:true with the captured name. - getMilestonePhaseFilter: also scan bullet lines into the milestone phase set. Closes #2199 * docs(#2199): add changeset fragment * fix(#2199): bullet phase lookup as last resort + consolidate test (review) Two corrections to the initial fix: 1. Regression — the bullet fallback inside findRoadmapPhaseInContent was too eager: it returned a bullet match from the scoped (current-milestone) content before the caller tried the full-content heading path, so a phase whose Requirements live in a Phase Details heading (after the active-milestone section) got a bullet-line section with no Requirements → phase_req_ids null (broke 3 init tests). Restructure: findRoadmapPhaseInContent is heading-only again; a separate findRoadmapBulletPhaseInContent runs in getRoadmapPhaseInternal ONLY after scoped + full heading lookup fails, so a heading with a Requirements section always wins. 2. lint-test-file-count — the standalone fix-2199-roadmap-bullet-phase.test.cjs collided with the over-cap 'roadmap' module (FAIL_NOVEL_FILES). Consolidate the regression into the existing tests/roadmap-parser.test.cjs (its natural home, under the 2-file cap). * test(#2199): assert heading-in-full beats bullet-in-scoped (review L3) The exact first-attempt regression: a phase has a bullet in the active-milestone scope but its heading (carrying Requirements) lives in a Phase Details section outside that scope. Pin that the heading section wins over the bullet line so req_ids resolve and the eager-bullet bug cannot return. * docs(#2199): backfill PR number in changeset fragment --- .changeset/sturdy-yaks-caper.md | 5 ++ src/roadmap-parser.cts | 54 ++++++++++++++++ tests/roadmap-parser.test.cjs | 111 ++++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 .changeset/sturdy-yaks-caper.md diff --git a/.changeset/sturdy-yaks-caper.md b/.changeset/sturdy-yaks-caper.md new file mode 100644 index 000000000..f453fcf0d --- /dev/null +++ b/.changeset/sturdy-yaks-caper.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2228 +--- +**Bullet/em-dash ROADMAP phases no longer resolve to `Phase null`** — the roadmap phase lookup matched only ATX headings with a colon, so a bullet entry like `- [ ] **Phase N — Name**` (which the roadmapper emits) failed to resolve and `Phase null` landed in STATE.md; a bullet-only ROADMAP also broke the milestone phase count. Phase lookup and the milestone filter now accept bullet/checkbox entries with an em-dash/en-dash/hyphen/colon separator. (#2199) diff --git a/src/roadmap-parser.cts b/src/roadmap-parser.cts index 33845d957..e107a84f2 100644 --- a/src/roadmap-parser.cts +++ b/src/roadmap-parser.cts @@ -202,6 +202,23 @@ function replaceInCurrentMilestone(content: string, pattern: RegExp, replacement // ─── Roadmap phase lookup ───────────────────────────────────────────────────── +// #2199: a bullet/checkbox phase entry, e.g. `- [ ] **Phase 36 — Authentication**` +// (the bundled roadmapper emits this in bullet-house-style ROADMAPs). The number +// is captured in group 1, the name in group 2; the separator may be an em-dash, +// en-dash, hyphen, or colon. Used as a fallback when no ATX heading matches, and +// to count phases in a milestone that uses the bullet form. +const BULLET_PHASE_LINE_PATTERN = + /^\s*[-*]\s+(?:\[[ xX]\]\s+)?\*\*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*[—–:\-]\s*(.+?)\*\*/im; + +/** Build a bullet-phase-line regex pinned to a specific phase number (#2199). */ +function bulletPhaseLineFor(phaseNum: unknown, phaseSource?: string): RegExp { + const num = phaseSource ?? phaseMarkdownRegexSource(phaseNum); + return new RegExp( + `^\\s*[-*]\\s+(?:\\[[ xX]\\]\\s+)?\\*\\*Phase\\s+(${num})${OPTIONAL_PHASE_TAG_SOURCE}\\s*[—–:\\-]\\s*(.+?)\\*\\*`, + 'im', + ); +} + interface RoadmapPhaseResult { found: boolean; phase_number: string; @@ -241,6 +258,22 @@ function findRoadmapPhaseInContent(content: string, phaseNum: unknown, phaseSour }; } +function findRoadmapBulletPhaseInContent(content: string, phaseNum: unknown, phaseSource?: string): RoadmapPhaseResult | null { + // #2199: bullet/checkbox entry fallback (`- [ ] **Phase N — name**`). Returns + // the single bullet line as the section (no multi-line body) — used only as a + // last resort, AFTER heading lookup on scoped + full content has failed, so a + // heading with a Requirements/Goal section always wins. + const bulletMatch = content.match(bulletPhaseLineFor(phaseNum, phaseSource)); + if (!bulletMatch) return null; + return { + found: true, + phase_number: String(phaseNum), + phase_name: bulletMatch[2].trim(), + goal: null, + section: bulletMatch[0].trim(), + }; +} + function getRoadmapPhaseInternal(cwd: string, phaseNum: unknown): RoadmapPhaseResult | null { if (!phaseNum) return null; const normalizedPhase = stripProjectCodePrefix(phaseNum); @@ -262,6 +295,17 @@ function getRoadmapPhaseInternal(cwd: string, phaseNum: unknown): RoadmapPhaseRe if (fullResult) return fullResult; } + // #2199: no ATX heading matched on scoped or full content — fall back to a + // bullet/checkbox entry (em-dash/en-dash/hyphen/colon separator). Last resort + // so a bullet never pre-empts a heading that carries the Requirements section. + for (const source of roadmapPhaseLookupSources(phaseNum)) { + const scopedBullet = findRoadmapBulletPhaseInContent(content, phaseNum, source); + if (scopedBullet) return scopedBullet; + + const fullBullet = findRoadmapBulletPhaseInContent(fullContent, phaseNum, source); + if (fullBullet) return fullBullet; + } + return null; } catch { return null; @@ -458,6 +502,16 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p // Exclude 999.x backlog phases from milestone phase set. Mirrors init.cts filter. if (pm && !/^999\b/.test(pm[1])) milestonePhaseNums.add(pm[1]); } + // #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. + { + let bm: RegExpExecArray | null; + const scanner = new RegExp(BULLET_PHASE_LINE_PATTERN.source, 'gim'); + while ((bm = scanner.exec(roadmap)) !== null) { + if (!/^999\b/.test(bm[1])) milestonePhaseNums.add(bm[1]); + } + } } catch { /* intentionally empty */ } if (milestonePhaseNums.size === 0) { diff --git a/tests/roadmap-parser.test.cjs b/tests/roadmap-parser.test.cjs index ec46fa630..f7bb1510a 100644 --- a/tests/roadmap-parser.test.cjs +++ b/tests/roadmap-parser.test.cjs @@ -1964,3 +1964,114 @@ describe('feat-3594: roadmap parser does not crash on ANY corpus fixture', () => }); }); } + +// ─── #2199: bullet/em-dash ROADMAP phase resolution ─────────────────────────── +// Self-contained block: phase lookup + milestone filter must accept bullet/ +// checkbox entries with an em-dash/en-dash/hyphen/colon separator, not just the +// ATX-heading + colon form. Previously such an entry resolved found:false and +// `Phase null` was written into STATE.md; a bullet-only ROADMAP collapsed the +// milestone filter to a zero-count pass-all. +{ + const { describe: d2, test: t2, beforeEach: be2, afterEach: ae2 } = require('node:test'); + const a2 = require('node:assert/strict'); + const fs2 = require('node:fs'); + const path2 = require('node:path'); + const { createTempProject: ctp2, cleanup: cu2 } = require('./helpers.cjs'); + const rp2 = require('../gsd-core/bin/lib/roadmap-parser.cjs'); + const writeRoadmap2 = (d, c) => fs2.writeFileSync(path2.join(d, '.planning', 'ROADMAP.md'), c); + + d2('#2199 roadmap bullet/em-dash phase resolution', () => { + let tmpDir; + be2(() => { tmpDir = ctp2('fix-2199-'); }); + ae2(() => { cu2(tmpDir); }); + + t2('an all-bullet em-dash ROADMAP resolves each phase (no Phase null)', () => { + writeRoadmap2(tmpDir, [ + '# Roadmap', '', '## v1.0 Active', '', + '- [ ] **Phase 1 — Authentication**: login flow', + '- [ ] **Phase 2 — Authorization**: RBAC', + '- [x] **Phase 3 — Audit Logging**: events', + '', + ].join('\n')); + const p1 = rp2.getRoadmapPhaseInternal(tmpDir, '1'); + a2.ok(p1 && p1.found, 'Phase 1 must resolve on a bullet ROADMAP'); + a2.strictEqual(p1.phase_name, 'Authentication'); + const p2 = rp2.getRoadmapPhaseInternal(tmpDir, '2'); + a2.ok(p2 && p2.found); + a2.strictEqual(p2.phase_name, 'Authorization'); + const p3 = rp2.getRoadmapPhaseInternal(tmpDir, '3'); + a2.ok(p3 && p3.found, 'a checked [x] bullet must also resolve'); + a2.strictEqual(p3.phase_name, 'Audit Logging'); + const absent = rp2.getRoadmapPhaseInternal(tmpDir, '99'); + a2.ok(!absent || !absent.found, 'an absent phase must not resolve'); + }); + + t2('bullet entries with colon / en-dash / hyphen separators all resolve', () => { + writeRoadmap2(tmpDir, [ + '# Roadmap', '', '## v1.0 Active', '', + '- [ ] **Phase 1: Colon Sep**: one', + '- [ ] **Phase 2 – En Dash**: two', + '- [ ] **Phase 3 - Hyphen Sep**: three', + '', + ].join('\n')); + a2.strictEqual(rp2.getRoadmapPhaseInternal(tmpDir, '1').phase_name, 'Colon Sep'); + a2.strictEqual(rp2.getRoadmapPhaseInternal(tmpDir, '2').phase_name, 'En Dash'); + a2.strictEqual(rp2.getRoadmapPhaseInternal(tmpDir, '3').phase_name, 'Hyphen Sep'); + }); + + t2('mixed heading + bullet forms both resolve', () => { + writeRoadmap2(tmpDir, [ + '# Roadmap', '', '## v1.0 Active', '', + '### Phase 1: Heading Form', + 'body', + '- [ ] **Phase 2 — Bullet Form**: two', + '', + ].join('\n')); + const p1 = rp2.getRoadmapPhaseInternal(tmpDir, '1'); + a2.ok(p1 && p1.found, 'heading form still resolves (no regression)'); + a2.ok(/Heading Form/.test(p1.phase_name)); + const p2 = rp2.getRoadmapPhaseInternal(tmpDir, '2'); + a2.ok(p2 && p2.found, 'bullet form resolves alongside heading form'); + a2.strictEqual(p2.phase_name, 'Bullet Form'); + }); + + t2('milestone phase-count counts bullet-form phases (not zero)', () => { + writeRoadmap2(tmpDir, [ + '# Roadmap', '', '## v1.0 Active', '', + '- [ ] **Phase 1 — One**: a', + '- [ ] **Phase 2 — Two**: b', + '- [ ] **Phase 3 — Three**: c', + '', + ].join('\n')); + const filter = rp2.getMilestonePhaseFilter(tmpDir); + a2.strictEqual(filter.phaseCount, 3, + 'a bullet-only ROADMAP must populate the milestone phase set (was a zero-count pass-all before #2199)'); + a2.ok(filter('1'), 'phase 1 dir is in the milestone set'); + a2.ok(filter('2'), 'phase 2 dir is in the milestone set'); + a2.ok(!filter('99'), 'a non-listed phase is excluded'); + }); + + t2('#2199 heading in Phase Details (full content) beats a bullet in the active scope', () => { + // The exact first-attempt regression: a bullet for the phase exists in the + // active-milestone scope, but the heading (carrying Requirements) lives in a + // Phase Details section outside that scope (only in fullContent). The heading + // MUST win — otherwise the bullet's single-line section yields null req_ids. + writeRoadmap2(tmpDir, [ + '# Roadmap', '', + '## Milestones', '', + '- 🚧 **v1.0 Active** - Phases 10-11', '', + '## v1.0 Active', '', + '- [ ] **Phase 11 — Second Active Phase**', + '', + '## Phase Details', '', + '### Phase 11: Second Active Phase', + '**Requirements**: REQ-02, REQ-03', + '', + ].join('\n')); + const p11 = rp2.getRoadmapPhaseInternal(tmpDir, '11'); + a2.ok(p11 && p11.found, 'phase 11 resolves'); + a2.ok(/REQ-02/.test(p11.section), + 'the heading section (with Requirements) must win over the scoped bullet line'); + }); + }); +}