* 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
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user