fix(#3165): recover phase_count when scoped window is truncated (#3428)

* fix(#3165): recover phase_count when scoped window is truncated

* chore(#3165): add changeset for roadmap.analyze phase_count recovery

* chore(#3165): backfill PR number in changeset

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-13 23:47:23 -04:00
committed by GitHub
parent 7976b1ca0d
commit 4792bc02f5
3 changed files with 218 additions and 55 deletions

View File

@@ -318,21 +318,37 @@ function cmdRoadmapGetPhase(cwd: string, phaseNum: string, raw: boolean): void {
// ─── cmdRoadmapAnalyze ────────────────────────────────────────────────────────
function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
const roadmapPath = planningPaths(cwd).roadmap;
if (!fs.existsSync(roadmapPath)) {
output({ error: 'ROADMAP.md not found', milestones: [], phases: [], current_phase: null }, raw, undefined);
return;
}
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
// #3184/#3165: use the scoped variant so a truncated window is a
// distinguishable signal in the output instead of a silent `phase_count: 0`
// indistinguishable from a genuinely empty milestone.
const { value: content, scope } = extractCurrentMilestoneScoped(rawContent, cwd);
const phasesDir = planningPaths(cwd).phases;
/**
* #3165: a single phase-detail heading enriched with its on-disk status, as
* `cmdRoadmapAnalyze` reports it. Extracted so the SAME enrichment runs on both
* the scoped milestone window and, when that window is suspect (non-COMPLETE
* scope, zero phases, phase dirs on disk), the shipped-milestone-stripped
* fallback document.
*/
type AnalyzePhase = {
number: string;
name: string;
goal: string | null;
mode: string | null;
depends_on: string | null;
plan_count: number;
summary_count: number;
has_context: boolean;
has_research: boolean;
disk_status: string;
roadmap_complete: boolean;
};
/**
* #3165: scan `content` for phase-detail headings (`##/###/#### Phase N: Name`)
* and enrich each with its on-disk plan/summary/completion status and ROADMAP
* checkbox. Pure extraction over `content` + the pre-built `phaseDirNames`
* lookup index — no milestone windowing of its own; the caller chooses the
* content (scoped window or fallback). Extracted verbatim from
* `cmdRoadmapAnalyze`'s former inline loop so the fallback re-runs the EXACT
* same enrichment, not a second derivation.
*/
function collectAnalyzePhases(content: string, phasesDir: string, phaseDirNames: string[]): AnalyzePhase[] {
// Extract all phase headings: ## Phase N: Name or ### Phase N: Name
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
// phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
@@ -341,44 +357,8 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
// ([A-Za-z]?) covers letter-prefixed ids without breaking numeric-leading ones.
// phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
const phasePattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([A-Za-z]?\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi;
const phases: Array<{
number: string;
name: string;
goal: string | null;
mode: string | null;
depends_on: string | null;
plan_count: number;
summary_count: number;
has_context: boolean;
has_research: boolean;
disk_status: string;
roadmap_complete: boolean;
}> = [];
const phases: AnalyzePhase[] = [];
let match: RegExpExecArray | null;
// #3185 (ADR-3180 Decision 1): the local `isSentinelPhase` closure was a
// fourth independent copy of the sentinel rule (`parseInt(num,10) === 0 ||
// === 999`). Deleted in favour of the canonical `isSentinelPhaseId`
// (src/phase-id.cts, SENTINEL_RANGES) so the engine-wide convention #1580
// describes has exactly one implementation. Phase 0 (pre-milestone) and
// Phase 999 (backlog) are sentinels, not real phases: they legitimately
// have no directory and must never be surfaced as current/next phase or
// counted in phase_count.
// Build phase directory lookup once (O(1) readdir instead of O(N) per phase)
// #3185 exemption (documented reason, not a file allowlist — ADR-3180
// Decision 4a): this is a heading->directory LOOKUP INDEX, not a milestone
// enumeration. It must see the PHYSICAL set so a heading already scoped by
// extractCurrentMilestoneScoped above can find its directory; filtering it
// through listMilestonePhaseDirs would scope the same set twice.
const _phaseDirNames = (() => {
try {
return fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name);
} catch { return []; }
})();
while ((match = phasePattern.exec(content)) !== null) {
const phaseNum = match[1];
if (isSentinelPhaseId(phaseNum)) continue;
@@ -417,7 +397,7 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
// readdirSync is self-guarded, and it delegates to scanPhasePlans, which
// never throws) — nothing in this block can throw, so the try/catch could
// never be triggered.
const dirMatch = matchPhaseDirs(_phaseDirNames, normalized).matches[0];
const dirMatch = matchPhaseDirs(phaseDirNames, normalized).matches[0];
if (dirMatch) {
const counts = countPhasePlansAndSummaries(path.join(phasesDir, dirMatch));
@@ -473,6 +453,70 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
roadmap_complete: roadmapComplete,
});
}
return phases;
}
function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
const roadmapPath = planningPaths(cwd).roadmap;
if (!fs.existsSync(roadmapPath)) {
output({ error: 'ROADMAP.md not found', milestones: [], phases: [], current_phase: null }, raw, undefined);
return;
}
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
// #3184/#3165: use the scoped variant so a truncated window is a
// distinguishable signal in the output instead of a silent `phase_count: 0`
// indistinguishable from a genuinely empty milestone.
const { value: content, scope } = extractCurrentMilestoneScoped(rawContent, cwd);
const phasesDir = planningPaths(cwd).phases;
// Build phase directory lookup once (O(1) readdir instead of O(N) per phase)
// #3185 exemption (documented reason, not a file allowlist — ADR-3180
// Decision 4a): this is a heading->directory LOOKUP INDEX, not a milestone
// enumeration. It must see the PHYSICAL set so a heading already scoped by
// extractCurrentMilestoneScoped above can find its directory; filtering it
// through listMilestonePhaseDirs would scope the same set twice.
const _phaseDirNames = (() => {
try {
return fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name);
} catch { return []; }
})();
// Scan the scoped milestone window for phase-detail headings and enrich each
// with its on-disk status. Extracted into `collectAnalyzePhases` (#3165) so
// the SAME enrichment re-runs on the fallback below — not a second copy.
let phases = collectAnalyzePhases(content, phasesDir, _phaseDirNames);
// `effectiveContent` is what the downstream checklist scan (missing_details)
// iterates. Defaults to the scoped window; switched to the fallback document
// when the recovery path below fires, so a phase found via fallback is not
// falsely reported as "in checklist but missing a detail section."
let effectiveContent = content;
// #3165: recover phase_count when the scoped window came back empty. A
// CLOSED milestone heading sitting between the active milestone heading and
// its own phase-detail sections closes `extractCurrentMilestoneScoped`'s
// window over prose only — `phases` is empty, and the consuming resume gate
// (`workflows/next.md` Route 0) iterates `.phases[]` so a safety invariant
// silently never runs. When the window is suspect (non-COMPLETE scope), the
// scoped scan found nothing, AND phase directories exist on disk (real
// evidence phases exist), re-scan the shipped-milestone-stripped document so
// the phase list reflects the real phases instead of a silent zero. The
// `scope` field retains its non-COMPLETE value downstream so consumers can
// still tell this is a best-effort count, not a cleanly scoped one. Position
// alone cannot attribute phases to the active vs the intervening closed
// milestone, so this never claims COMPLETE — it converts silence into a
// populated, flagged result.
if (phases.length === 0 && scope !== SCOPE.COMPLETE && _phaseDirNames.length > 0) {
const fallbackContent = stripShippedMilestones(rawContent);
const fallbackPhases = collectAnalyzePhases(fallbackContent, phasesDir, _phaseDirNames);
if (fallbackPhases.length > 0) {
phases = fallbackPhases;
effectiveContent = fallbackContent;
}
}
// Extract milestone info. #3216: routed through the canonical
// `listMilestoneHeadings` owner (deleted the inline `##…` regex, which
@@ -502,7 +546,7 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+([A-Za-z]?\d+[A-Z]?(?:[.-]\d+)*)/gi;
const checklistPhases = new Set<string>();
let checklistMatch: RegExpExecArray | null;
while ((checklistMatch = checklistPattern.exec(content)) !== null) {
while ((checklistMatch = checklistPattern.exec(effectiveContent)) !== null) {
checklistPhases.add(checklistMatch[1]);
}
const detailPhases = new Set(phases.map(p => p.number));