diff --git a/.changeset/tame-river-song.md b/.changeset/tame-river-song.md new file mode 100644 index 000000000..862b1ccee --- /dev/null +++ b/.changeset/tame-river-song.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3368 +--- +**Milestone phase counts no longer drop every letter-named phase directory** — `getMilestonePhaseFilter` now includes letter-named phase directories (`Phase A:`…`Phase L:`, GSD's own non-numeric phase convention per ADR-612) in milestone progress and plan counts. A greedy regex previously captured the whole hyphenated directory name (`A-tool-output-contract` was read as `A-tool-output-contract` instead of `A`), so every letter-named phase silently fell out of its milestone and the progress/plan totals were fabricated over whatever numeric directory happened to survive — a well-formed, plausible number that could even look correct at a phase boundary. Numeric and milestone-prefixed phases are unchanged. (#3213) diff --git a/src/roadmap-parser.cts b/src/roadmap-parser.cts index e34a855ed..4118501a3 100644 --- a/src/roadmap-parser.cts +++ b/src/roadmap-parser.cts @@ -1366,6 +1366,10 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p ); const roadmapUsesHyphenedIds = [...normalized].some(n => n.includes('-')); + // #3213: longest-first so a hyphenated declared ID (e.g. "proj-42") is tested + // before a prefix of it (e.g. "proj") in the segment-boundary membership loop + // below — otherwise the shorter id would admit a dir that belongs to the longer. + const normalizedIdsLongestFirst = [...normalized].sort((a, b) => b.length - a.length); // #2043: milestone-prefixed sub-phase components must be zero-padded — so a // single-digit slug word after the phase // number (e.g. dir "46-6-rs-…") captures "46" and is not silently excluded from @@ -1383,8 +1387,27 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p function isDirInMilestone(dirName: string): boolean { const m2 = dirName.match(numericRe); if (m2 && normalized.has(normalizePhaseIdSegments(m2[1]).toLowerCase())) return true; - const customMatch = dirName.match(/^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)/); - if (customMatch && normalized.has(customMatch[1].toLowerCase())) return true; + // #3213: segment-boundary membership test, scoped to LETTER-LEADING (custom- + // ID) directories only. The prior greedy capture + // `^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)` swallowed the WHOLE hyphenated + // directory name (A-tool-output-contract was captured as + // "A-tool-output-contract", not "A"), so every letter-named phase directory + // (Phase A:..Phase L: — GSD's own convention, ADR-612 first-class non-numeric + // IDs) fell out of the milestone and counts were silently fabricated over + // whatever numeric directory survived. A letter-leading directory belongs if + // its lowercased name EQUALS a declared phase ID, or BEGINS with that ID + // followed by "-" (so "A-tool-output-contract" matches ID "a"; + // "PROJ-42-description" matches ID "proj-42"; "AB-combined" does NOT match + // "a"). SCOPED TO LETTER-LEADING DIRS because numeric dirs are owned by + // numericRe above, which respects the #2232 continuation grammar — a bare + // startsWith here would wrongly admit "14-02-photos-…" to phase "14" when its + // real token is "14-02" (continuation-absorbed, not declared). + if (/^[A-Za-z]/.test(dirName)) { + const lowerDir = dirName.toLowerCase(); + for (const id of normalizedIdsLongestFirst) { + if (lowerDir === id || lowerDir.startsWith(id + '-')) return true; + } + } const stripped = stripProjectCodePrefix(dirName); if (stripped !== dirName) { const sm = stripped.match(numericRe); diff --git a/tests/roadmap-parser.test.cjs b/tests/roadmap-parser.test.cjs index a0873279f..73e93e6af 100644 --- a/tests/roadmap-parser.test.cjs +++ b/tests/roadmap-parser.test.cjs @@ -1233,6 +1233,53 @@ describe('roadmap-parser: getMilestonePhaseFilter', () => { assert.strictEqual(filter('02-01-setup'), true, 'bracket-prefixed phase 2-01 matched'); assert.strictEqual(filter('02-02-build'), true, 'bracket-prefixed phase 2-02 matched'); }); + + // #3213: the custom-ID branch used a greedy capture + // `^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)` that swallowed the whole + // hyphenated directory name (A-tool-output-contract → captured + // "A-tool-output-contract", not "A"). Every letter-named phase directory + // (GSD's own Phase A:..Phase L: convention; ADR-612 first-class non-numeric + // IDs) was silently excluded and milestone counts were fabricated over + // whatever numeric dir survived. The fix is a segment-boundary membership + // test: a dir belongs if it equals a declared phase ID or begins with id + "-". + test('#3213: letter-named phase dir is included in the milestone', () => { + writeRoadmap(tmpDir, [ + '## v1.0: Letters', + '### Phase A: Tool Output Contract', + '**Goal:** contract', + '', + '### Phase 01: Inventory', + '**Goal:** inventory', + ].join('\n')); + + const filter = getMilestonePhaseFilter(tmpDir); + assert.strictEqual(filter.phaseCount, 2, 'Phase A + Phase 01 declared'); + assert.strictEqual(filter('A-tool-output-contract'), true, 'letter phase A dir must be in-milestone (#3213)'); + assert.strictEqual(filter('01-inventory'), true, 'numeric phase 01 dir still matches'); + assert.strictEqual(filter('B-evidence-artifact-contract'), false, 'undeclared letter phase B stays excluded'); + }); + + test('#3213: letter-named phases A..L all count (not just the numeric dir)', () => { + const headings = ['## v1.0: Alpha', '### Phase 00: Inventory', '**Goal:** inv', '']; + for (const letter of ['A','B','C','D','E','F','G','H','I','J','K','L']) { + headings.push(`### Phase ${letter}: Phase ${letter}`, '**Goal:** g', ''); + } + writeRoadmap(tmpDir, headings.join('\n')); + + const filter = getMilestonePhaseFilter(tmpDir); + assert.strictEqual(filter.phaseCount, 13, 'Phase 00 + A..L = 13 phases'); + assert.strictEqual(filter('00-inventory-approval-gate'), true, '00 in-milestone'); + const dirFor = { + A: 'A-tool-output-contract', + B: 'B-evidence-artifact-contract', + C: 'C-attention-triage', + L: 'L-framework-distribution', + }; + for (const [letter, dir] of Object.entries(dirFor)) { + assert.strictEqual(filter(dir), true, `letter phase ${letter} dir "${dir}" must be in-milestone (#3213)`); + } + assert.strictEqual(filter('M-not-declared'), false, 'undeclared letter M stays excluded'); + }); }); // ─── withPhaseSection (ADR-2143 §4 — bounded mutation) ────────────────────────