fix(phase-lifecycle): detect phases in bullet/bold ROADMAP formats
phaseAdd's phase-number regex only matched heading format (## Phase N:), missing bullet checklist (- [x] Phase N:) and bold (**Phase N:**) entries. When zero regex matches, newPhaseId defaulted to 1. Fix: broaden regex to match all three formats, and add filesystem fallback scanning .planning/phases/ when ROADMAP scan finds nothing. Closes #2726
This commit is contained in:
@@ -245,6 +245,77 @@ describe('phaseAdd', () => {
|
||||
expect(phaseIdx).toBeLessThan(sepIdx);
|
||||
expect(phaseIdx).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('detects max phase from bullet checklist format (regression #2726)', async () => {
|
||||
const { phaseAdd } = await import('./phase-lifecycle.js');
|
||||
|
||||
const roadmap = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Current Milestone: v5.0',
|
||||
'',
|
||||
'- [x] Phase 76: Data Import',
|
||||
'- [x] Phase 77: Data Transform',
|
||||
'- [ ] Phase 88: Final Cleanup',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
await setupTestProject(tmpDir, {
|
||||
roadmap,
|
||||
state: MINIMAL_STATE,
|
||||
phases: [],
|
||||
});
|
||||
|
||||
const result = await phaseAdd(['new-feature'], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
|
||||
expect(data.phase_number).toBe(89);
|
||||
expect(data.padded).toBe('89');
|
||||
});
|
||||
|
||||
it('detects max phase from bold inline format (regression #2726)', async () => {
|
||||
const { phaseAdd } = await import('./phase-lifecycle.js');
|
||||
|
||||
const roadmap = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Current Milestone: v5.0',
|
||||
'',
|
||||
'**Phase 50: Core Infrastructure**',
|
||||
'**Phase 51: API Layer**',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
await setupTestProject(tmpDir, {
|
||||
roadmap,
|
||||
state: MINIMAL_STATE,
|
||||
phases: [],
|
||||
});
|
||||
|
||||
const result = await phaseAdd(['new-feature'], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
|
||||
expect(data.phase_number).toBe(52);
|
||||
});
|
||||
|
||||
it('falls back to filesystem scan when no phase matches in ROADMAP (regression #2726)', async () => {
|
||||
const { phaseAdd } = await import('./phase-lifecycle.js');
|
||||
|
||||
// ROADMAP with no recognizable phase entries
|
||||
const roadmap = '# Roadmap\n\n## Current Milestone: v5.0\n\nSome content without phases\n';
|
||||
|
||||
await setupTestProject(tmpDir, {
|
||||
roadmap,
|
||||
state: MINIMAL_STATE,
|
||||
phases: ['45-legacy-phase', '46-another-phase'],
|
||||
});
|
||||
|
||||
const result = await phaseAdd(['new-feature'], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
|
||||
// Should detect phases 45 and 46 on disk, so new phase = 47
|
||||
expect(data.phase_number).toBe(47);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── phaseAddBatch ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -191,7 +191,8 @@ export const phaseAdd: QueryHandler = async (args, projectDir, workstream) => {
|
||||
} else {
|
||||
// Sequential mode: find highest integer phase number (in current milestone only)
|
||||
// Skip 999.x backlog phases — they live outside the active sequence
|
||||
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
|
||||
// Matches heading (## Phase N:), bullet checklist (- [x] Phase N:), and bold (**Phase N:**)
|
||||
const phasePattern = /(?:^|\n)\s*(?:[-*]\s*(?:\[[x ]\]\s*)?|#{2,4}\s*|\*{1,2}\s*)Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
|
||||
let maxPhase = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = phasePattern.exec(content)) !== null) {
|
||||
@@ -200,6 +201,26 @@ export const phaseAdd: QueryHandler = async (args, projectDir, workstream) => {
|
||||
if (num > maxPhase) maxPhase = num;
|
||||
}
|
||||
|
||||
// Belt-and-suspenders: if ROADMAP scan found nothing, fall back to scanning
|
||||
// .planning/phases/ directory names as the canonical source of truth
|
||||
if (maxPhase === 0) {
|
||||
const phasesDir = planningPaths(projectDir, workstream).phases;
|
||||
try {
|
||||
const entries = await readdir(phasesDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const dirMatch = /^(\d+)[A-Z]?(?:\.\d+)*-/.exec(entry.name);
|
||||
if (dirMatch) {
|
||||
const num = parseInt(dirMatch[1], 10);
|
||||
if (num >= 999) continue;
|
||||
if (num > maxPhase) maxPhase = num;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// phases dir may not exist yet — leave maxPhase as 0
|
||||
}
|
||||
}
|
||||
|
||||
newPhaseId = maxPhase + 1;
|
||||
const paddedNum = String(newPhaseId).padStart(2, '0');
|
||||
dirName = `${prefix}${paddedNum}-${slug}`;
|
||||
|
||||
Reference in New Issue
Block a user