fix(roadmap): fall back to full ROADMAP.md for backlog phases (#1640)

* fix(roadmap): fall back to full ROADMAP.md for backlog and planned phases

Closes #1634

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(roadmap): prevent checklist-only match from blocking full header fallback

When the current milestone had a checklist reference to a phase (e.g.
`- [ ] **Phase 50: Cleanup**`) but the full `### Phase 50:` header
existed in a different milestone, the malformed_roadmap result from the
first searchPhaseInContent call short-circuited the `||` operator and
prevented the fallback to the full roadmap content.

Now a malformed_roadmap result is deferred so the full content search
can find the actual header match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-04-04 07:14:24 -04:00
committed by GitHub
parent 4abcfa1e3a
commit d790408aaa
2 changed files with 327 additions and 60 deletions

View File

@@ -6,6 +6,72 @@ const fs = require('fs');
const path = require('path');
const { escapeRegex, normalizePhaseName, planningPaths, withPlanningLock, output, error, findPhaseInternal, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone } = require('./core.cjs');
/**
* Search for a phase header (and its section) within the given content string.
* Returns a result object if found (either a full match or a malformed_roadmap
* checklist-only match), or null if the phase is not present at all.
*/
function searchPhaseInContent(content, escapedPhase, phaseNum) {
// Match "## Phase X:", "### Phase X:", or "#### Phase X:" with optional name
const phasePattern = new RegExp(
`#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`,
'i'
);
const headerMatch = content.match(phasePattern);
if (!headerMatch) {
// Fallback: check if phase exists in summary list but missing detail section
const checklistPattern = new RegExp(
`-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${escapedPhase}:\\s*([^*]+)\\*\\*`,
'i'
);
const checklistMatch = content.match(checklistPattern);
if (checklistMatch) {
return {
found: false,
phase_number: phaseNum,
phase_name: checklistMatch[1].trim(),
error: 'malformed_roadmap',
message: `Phase ${phaseNum} exists in summary list but missing "### Phase ${phaseNum}:" detail section. ROADMAP.md needs both formats.`
};
}
return null;
}
const phaseName = headerMatch[1].trim();
const headerIndex = headerMatch.index;
// Find the end of this section (next ## or ### phase header, or end of file)
const restOfContent = content.slice(headerIndex);
const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i);
const sectionEnd = nextHeaderMatch
? headerIndex + nextHeaderMatch.index
: content.length;
const section = content.slice(headerIndex, sectionEnd).trim();
// Extract goal if present (supports both **Goal:** and **Goal**: formats)
const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
const goal = goalMatch ? goalMatch[1].trim() : null;
// Extract success criteria as structured array
const criteriaMatch = section.match(/\*\*Success Criteria\*\*[^\n]*:\s*\n((?:\s*\d+\.\s*[^\n]+\n?)+)/i);
const success_criteria = criteriaMatch
? criteriaMatch[1].trim().split('\n').map(line => line.replace(/^\s*\d+\.\s*/, '').trim()).filter(Boolean)
: [];
return {
found: true,
phase_number: phaseNum,
phase_name: phaseName,
goal,
success_criteria,
section,
};
}
function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
const roadmapPath = planningPaths(cwd).roadmap;
@@ -15,76 +81,32 @@ function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
}
try {
const content = extractCurrentMilestone(fs.readFileSync(roadmapPath, 'utf-8'), cwd);
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
const milestoneContent = extractCurrentMilestone(rawContent, cwd);
// Escape special regex chars in phase number, handle decimal
const escapedPhase = escapeRegex(phaseNum);
// Match "## Phase X:", "### Phase X:", or "#### Phase X:" with optional name
const phasePattern = new RegExp(
`#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`,
'i'
);
const headerMatch = content.match(phasePattern);
if (!headerMatch) {
// Fallback: check if phase exists in summary list but missing detail section
const checklistPattern = new RegExp(
`-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${escapedPhase}:\\s*([^*]+)\\*\\*`,
'i'
);
const checklistMatch = content.match(checklistPattern);
if (checklistMatch) {
// Phase exists in summary but missing detail section - malformed ROADMAP
output({
found: false,
phase_number: phaseNum,
phase_name: checklistMatch[1].trim(),
error: 'malformed_roadmap',
message: `Phase ${phaseNum} exists in summary list but missing "### Phase ${phaseNum}:" detail section. ROADMAP.md needs both formats.`
}, raw, '');
return;
}
// Search the current milestone slice first, then fall back to full roadmap.
// A malformed_roadmap result (checklist-only) from the milestone should not
// block finding a full header match in the wider roadmap content.
const fullContent = stripShippedMilestones(rawContent);
const milestoneResult = searchPhaseInContent(milestoneContent, escapedPhase, phaseNum);
const result = (milestoneResult && !milestoneResult.error)
? milestoneResult
: searchPhaseInContent(fullContent, escapedPhase, phaseNum) || milestoneResult;
if (!result) {
output({ found: false, phase_number: phaseNum }, raw, '');
return;
}
const phaseName = headerMatch[1].trim();
const headerIndex = headerMatch.index;
if (result.error) {
output(result, raw, '');
return;
}
// Find the end of this section (next ## or ### phase header, or end of file)
const restOfContent = content.slice(headerIndex);
const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i);
const sectionEnd = nextHeaderMatch
? headerIndex + nextHeaderMatch.index
: content.length;
const section = content.slice(headerIndex, sectionEnd).trim();
// Extract goal if present (supports both **Goal:** and **Goal**: formats)
const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
const goal = goalMatch ? goalMatch[1].trim() : null;
// Extract success criteria as structured array
const criteriaMatch = section.match(/\*\*Success Criteria\*\*[^\n]*:\s*\n((?:\s*\d+\.\s*[^\n]+\n?)+)/i);
const success_criteria = criteriaMatch
? criteriaMatch[1].trim().split('\n').map(line => line.replace(/^\s*\d+\.\s*/, '').trim()).filter(Boolean)
: [];
output(
{
found: true,
phase_number: phaseNum,
phase_name: phaseName,
goal,
success_criteria,
section,
},
raw,
section
);
output(result, raw, result.section);
} catch (e) {
error('Failed to read ROADMAP.md: ' + e.message);
}