fix(#4699): skip already-complete phases in the next_phase cascade (#4820)

* test(#4699): add failing-first coverage for skipping complete phases in next_phase

* fix(#4699): skip already-complete phases in the next_phase cascade

Both next-phase scans selected the numerically lowest phase above N
without consulting completion state, so completing a reopened phase
persisted an already-[x] phase as STATE.md current_phase while
roadmap.analyze correctly named the outstanding one (issue repro:
completing 2 with phases 1 and 3 already [x] returned next_phase 03).

The cascade collects the complete phase numbers from the roadmap
checkboxes (milestone-scoped, comparePhaseNum-deduped) and skips them in
both the disk scan and the roadmap scan; a [x] checkbox row and its
heading sibling both name a phase that is never next. Heading-only and
checkbox-less roadmaps behave exactly as before.

* test(#4699): align the negative-control expectation with the disk spelling

* test(#4699): pin the STATE.md persistence and the all-later-complete tail corner

Review findings: the regression never asserted STATE.md current_phase
(the issue's actual harm), and the all-later-phases-[x] corner
(is_last_phase true, next_phase null) was unpinned. A changeset fragment
is included.

* docs(#4699): backfill changeset PR number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-17 04:17:39 -04:00
committed by GitHub
parent 2bfff17ff8
commit d707318e0c
3 changed files with 180 additions and 0 deletions

View File

@@ -4237,6 +4237,37 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
let roadmapNextNum: string | null = null;
let roadmapNextName: string | null = null;
// #4699: a phase whose roadmap checkbox is `[x]` is already complete and
// must never be selected as next_phase — out-of-order completion (a
// reopened phase finished after later phases shipped) otherwise persists
// the already-done phase as STATE.md current_phase. Collected from the
// same milestone-scoped text the roadmap scan walks; membership is
// comparePhaseNum-based so `02` and `2` dedupe. With no ROADMAP.md (or no
// parseable rows) the set is empty and the scans behave exactly as
// before.
const roadmapCompleteNums: string[] = [];
if (roadmapContent !== null) {
try {
const milestoneForComplete = extractCurrentMilestone(roadmapContent, cwd);
const completePattern = new RegExp(
`-\\s*\\[[xX]\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})`,
'gi'
);
let cm: RegExpExecArray | null;
while ((cm = completePattern.exec(milestoneForComplete)) !== null) {
if (isSentinelPhaseId(cm[1])) continue;
if (!roadmapCompleteNums.some((n) => comparePhaseNum(cm![1], n) === 0)) {
roadmapCompleteNums.push(cm[1]);
}
}
} catch {
/* best-effort: an unreadable milestone section leaves the complete
* set empty — the scans then behave exactly as they did pre-#4699. */
}
}
const isCompletePhaseNum = (num: string): boolean =>
roadmapCompleteNums.some((n) => comparePhaseNum(num, n) === 0);
try {
// #3185 (ADR-3180 Decision 1): "which phase directories belong to
// the CURRENT milestone" — routed through the canonical owner
@@ -4252,6 +4283,9 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
if (dm) {
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
if (isSentinelPhaseId(dm[1])) continue;
// #4699: an already-complete phase (roadmap checkbox [x]) is never
// a next_phase candidate — out-of-order completion must skip it.
if (roadmapContent !== null && isCompletePhaseNum(dm[1])) continue;
// Numeric MINIMUM above N, not "first encountered". `listMilestonePhaseDirs`
// does sort by `comparePhaseNum`, so a `break` on the first hit happens to be
// correct today — but that makes this scan's correctness depend on an
@@ -4321,6 +4355,10 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
// already skips sentinel dirs on disk via isSentinelPhaseId (#3185);
// stage 2's heading scan must not advance into backlog headings either.
if (isSentinelPhaseId(pmNum)) continue;
// #4699: skip complete phases — a `[x]` checkbox row and the
// `## Phase Details` heading of an already-done phase both name a
// phase that must never be next_phase.
if (roadmapContent !== null && isCompletePhaseNum(pmNum)) continue;
// #3701 review: the numeric MINIMUM above N, not the first row above N in
// DOCUMENT order. This scan walks raw roadmap text, and one global regex
// sweeps both the `## Phases` checklist and the `## Phase Details`