* test(#3701): failing-first coverage for roadmap-order next_phase resolution * fix(#3701): resolve next_phase from roadmap order, keeping the disk scan for spelling and fallback * fix(#3701): select the numerically lowest successor in both scans, not the first row encountered * chore(#3701): backfill changeset PR number --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -3036,6 +3036,37 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
// #3701 — the ROADMAP decides WHICH phase is next; the disk decides only HOW it
|
||||
// is spelled. Both scans select the numerically lowest phase above N.
|
||||
//
|
||||
// Both scans below are unchanged in what they match; what changed is that
|
||||
// the roadmap is no longer gated behind "the disk found nothing". It used
|
||||
// to be (`if (isLastPhase && roadmapContent !== null)`), which made a wrong
|
||||
// disk answer uncorrectable: phase directories are created lazily, but
|
||||
// `phase insert` scaffolds an inserted phase's directory immediately, so an
|
||||
// inserted decimal is routinely the ONLY directory above N and outranked
|
||||
// every phase preceding it in the roadmap. Observed: roadmap `1, 2, 02.1,
|
||||
// 3` with directories for 01 and 02.1 only reported `next_phase: "02.1"`
|
||||
// after completing 1 — and PERSISTED it to STATE.md — while
|
||||
// `roadmap.analyze` correctly said `2`.
|
||||
//
|
||||
// #3581 fixed exactly this at `init.progress` and named the rule: "the
|
||||
// frontier is ROADMAP ORDER, not artifact presence". This call site was not
|
||||
// in that change's scope.
|
||||
//
|
||||
// Why the disk scan survives, rather than being replaced:
|
||||
// 1. It is the only resolver when there is no ROADMAP.md, or when its
|
||||
// phase rows do not parse.
|
||||
// 2. When both agree, it carries the SPELLING the output has always used
|
||||
// — the zero-padded directory token and the on-disk slug (`02`/`beta`),
|
||||
// where the roadmap would give `2` and a slugified title. Promoting the
|
||||
// roadmap without this would silently change the reported value on
|
||||
// every aligned project, which is the majority case.
|
||||
let diskNextNum: string | null = null;
|
||||
let diskNextName: string | null = null;
|
||||
let roadmapNextNum: string | null = null;
|
||||
let roadmapNextName: string | null = null;
|
||||
|
||||
try {
|
||||
// #3185 (ADR-3180 Decision 1): "which phase directories belong to
|
||||
// the CURRENT milestone" — routed through the canonical owner
|
||||
@@ -3051,11 +3082,15 @@ 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;
|
||||
if (comparePhaseNum(dm[1], phaseNum) > 0) {
|
||||
nextPhaseNum = dm[1];
|
||||
nextPhaseName = dm[2] || null;
|
||||
isLastPhase = false;
|
||||
break;
|
||||
// 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
|
||||
// upstream sort nothing here states. Selecting the minimum explicitly costs
|
||||
// one comparison and removes the hidden coupling.
|
||||
if (comparePhaseNum(dm[1], phaseNum) > 0
|
||||
&& (diskNextNum === null || comparePhaseNum(dm[1], diskNextNum) < 0)) {
|
||||
diskNextNum = dm[1];
|
||||
diskNextName = dm[2] || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3069,7 +3104,7 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
|
||||
* — not a silent data-loss path. */
|
||||
}
|
||||
|
||||
if (isLastPhase && roadmapContent !== null) {
|
||||
if (roadmapContent !== null) {
|
||||
try {
|
||||
const roadmapForPhases = extractCurrentMilestone(roadmapContent, cwd);
|
||||
// #1591: match BOTH heading-style phases (`### Phase N:`) AND
|
||||
@@ -3097,15 +3132,28 @@ 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(pm[1])) continue;
|
||||
if (comparePhaseNum(pm[1], phaseNum) > 0) {
|
||||
nextPhaseNum = pm[1];
|
||||
nextPhaseName = pm[2]
|
||||
// #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`
|
||||
// headings, so "first match" is a statement about where a line sits in the
|
||||
// file — not about which phase comes next.
|
||||
//
|
||||
// It mattered only once this scan started deciding the answer. Before, it
|
||||
// ran solely when the disk scan found nothing; now it outranks the disk, so
|
||||
// a roadmap listing rows out of numeric sequence (`1, 3, 2`) reported
|
||||
// `next_phase: 3` and PERSISTED it, skipping Phase 2 — on an input the
|
||||
// pre-#3701 code got right, because the disk scan is numerically sorted.
|
||||
// Phase NUMBERS define sequence here, exactly as `comparePhaseNum` does for
|
||||
// the disk scan and for #2028's lowest-outstanding override; the roadmap
|
||||
// defines which phases EXIST and which milestone they belong to.
|
||||
if (comparePhaseNum(pm[1], phaseNum) > 0
|
||||
&& (roadmapNextNum === null || comparePhaseNum(pm[1], roadmapNextNum) < 0)) {
|
||||
roadmapNextNum = pm[1];
|
||||
roadmapNextName = pm[2]
|
||||
.replace(/\(INSERTED\)/i, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-');
|
||||
isLastPhase = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -3116,6 +3164,26 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Resolve. The roadmap wins on identity; the disk wins on spelling when it
|
||||
// is talking about the same phase.
|
||||
if (roadmapNextNum !== null) {
|
||||
// Same comparator both scans already use to order phases, so "the disk
|
||||
// and the roadmap mean the same phase" cannot drift from "N is above the
|
||||
// one just completed". `02` and `2` compare equal, which is the whole
|
||||
// point — they are the same phase spelled two ways.
|
||||
const diskAgrees = diskNextNum !== null && comparePhaseNum(diskNextNum, roadmapNextNum) === 0;
|
||||
nextPhaseNum = diskAgrees ? diskNextNum : roadmapNextNum;
|
||||
nextPhaseName = diskAgrees ? diskNextName : roadmapNextName;
|
||||
isLastPhase = false;
|
||||
} else if (diskNextNum !== null) {
|
||||
// No usable roadmap (absent, unreadable, or no parseable phase rows) —
|
||||
// the disk is all there is. Unchanged from the pre-#3701 behaviour.
|
||||
nextPhaseNum = diskNextNum;
|
||||
nextPhaseName = diskNextName;
|
||||
isLastPhase = false;
|
||||
}
|
||||
|
||||
// #2028: don't stamp "All phases complete" when a LOWER-numbered phase is
|
||||
// still outstanding. The two blocks above only clear isLastPhase when a
|
||||
// HIGHER-numbered phase exists, so completing the numerically-highest phase
|
||||
|
||||
Reference in New Issue
Block a user