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

@@ -15812,3 +15812,140 @@ describe('bug #3982: archived details leak into lowest-outstanding scan', () =>
`STATE.md current_phase must not jump backwards into the archived range; got: ${state}`);
});
});
// ── #4699 — next_phase must skip phases whose roadmap checkbox is [x] ────────
// Out-of-order completion (a reopened phase finished after later phases
// shipped) used to persist the already-complete phase as next_phase /
// STATE.md current_phase: both next-phase scans select the numerically lowest
// phase above N without consulting completion state. Roadmap checkbox state
// is the completion rule (#2028) — an [x] phase is never "next".
describe('phase complete skips already-complete phases as next_phase (#4699)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('gsd-4699-');
});
afterEach(() => {
cleanup(tmpDir);
});
function writeRoadmap({ thirdBox = '[x]', fourthBox = '[ ]' } = {}) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
## Phases
- [x] **Phase 1: One** - Goal one
- [ ] **Phase 2: Two** - Goal two
- ${thirdBox} **Phase 3: Three** - Goal three
- ${fourthBox} **Phase 4: Four** - Goal four
### Phase 1: One
**Goal**: Goal one
### Phase 2: Two
**Goal**: Goal two
### Phase 3: Three
**Goal**: Goal three
### Phase 4: Four
**Goal**: Goal four
`,
);
}
function scaffoldPhaseDir(n, slug) {
const padded = String(n).padStart(2, '0');
const dir = path.join(tmpDir, '.planning', 'phases', `${padded}-${slug}`);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, `${padded}-01-PLAN.md`), '# Plan');
fs.writeFileSync(path.join(dir, `${padded}-01-SUMMARY.md`), '# Summary');
fs.writeFileSync(
path.join(dir, `${padded}-VERIFICATION.md`),
['---', 'status: passed', '---', '', '# Verification', ''].join('\n'),
);
}
function writeMinimalState() {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# State\n\n**Current Phase:** 2\n**Status:** In progress\n',
);
}
test('completing phase 2 out of order skips the already-complete phase 3 (#4699)', () => {
writeRoadmap();
writeMinimalState();
scaffoldPhaseDir(1, 'one');
scaffoldPhaseDir(2, 'two');
scaffoldPhaseDir(3, 'three');
const result = runGsdTools('phase complete 2', tmpDir);
const output = JSON.parse(result.output);
assert.equal(output.next_phase, '4',
'next_phase must skip the already-[x] phase 3 and select the outstanding phase 4');
assert.equal(output.is_last_phase, false);
// #4699's actual harm was persistence: STATE.md used to carry the
// already-complete phase as current_phase.
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.doesNotMatch(state, /current_phase:\s*3(\s|$)/m,
'STATE.md must not carry the already-complete phase as current_phase');
});
test('all later phases already [x] completes the milestone tail (#4699 corner)', () => {
writeRoadmap({ fourthBox: '[x]' });
writeMinimalState();
scaffoldPhaseDir(1, 'one');
scaffoldPhaseDir(2, 'two');
scaffoldPhaseDir(3, 'three');
const result = runGsdTools('phase complete 2', tmpDir);
const output = JSON.parse(result.output);
assert.equal(output.is_last_phase, true,
'when every phase above N is already [x], completing N is the milestone tail');
assert.equal(output.next_phase, null);
});
test('uppercase [X] checkboxes are recognized as complete (#4699)', () => {
writeRoadmap({ thirdBox: '[X]' });
writeMinimalState();
scaffoldPhaseDir(1, 'one');
scaffoldPhaseDir(2, 'two');
scaffoldPhaseDir(3, 'three');
const result = runGsdTools('phase complete 2', tmpDir);
const output = JSON.parse(result.output);
assert.equal(output.next_phase, '4', '[X] is a complete checkbox, case-insensitively');
});
test('checkbox completion matches phase numbers across zero-padding (#4699)', () => {
// Roadmap spells the phase without padding; the directory carries the
// zero-padded token — comparePhaseNum must dedupe them in the complete set.
writeRoadmap({ thirdBox: '[x]' });
writeMinimalState();
scaffoldPhaseDir(1, 'one');
scaffoldPhaseDir(2, 'two');
scaffoldPhaseDir(3, 'three');
const result = runGsdTools('phase complete 2', tmpDir);
const output = JSON.parse(result.output);
assert.equal(output.next_phase, '4');
});
test('an outstanding phase 3 (unchecked) is still selected — negative control (#4699)', () => {
writeRoadmap({ thirdBox: '[ ]' });
writeMinimalState();
scaffoldPhaseDir(1, 'one');
scaffoldPhaseDir(2, 'two');
scaffoldPhaseDir(3, 'three');
const result = runGsdTools('phase complete 2', tmpDir);
const output = JSON.parse(result.output);
assert.equal(output.next_phase, '03',
'without the fix scope change: an unchecked phase 3 stays a valid candidate (disk spelling wins)');
});
});