fix(#3982): strip closed-milestone details from the current window (#4177)

* test(#3982): archived details must not leak into the current-milestone window

Parser-level regression (newest-first layout, two archived details
blocks) plus the issue's end-to-end phase.complete fixture: completing
phase 20 must advance to 21, never backwards into the archived range.

* fix(#3982): strip closed-milestone details from the current window

The heading-located window stripped <details> archives from the
preamble but not from currentSection; on newest-first roadmaps the
archived titles sit in summary tags rather than headings, so the
section walk reached end-of-document and the window swallowed every
collapsed archive below the active milestone. The strip is gated on
isClosedMilestoneHeading over each block's summary — the issue's
prescribed narrow form — so the active milestone's own collapsed
blocks (#1341) survive instead of trading this bug for the
phase_count: 0 class (#557/#2947).

* chore(#3982): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-02 02:33:54 -04:00
committed by GitHub
parent 9eef1b791b
commit 383c2f6b34
4 changed files with 149 additions and 1 deletions

View File

@@ -14919,3 +14919,55 @@ describe('#3701 phase complete — next_phase follows roadmap order, not disk',
);
});
});
// ─── #3982: phase.complete must not pick an archived details-block phase ─────
describe('bug #3982: archived details leak into lowest-outstanding scan', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3982-'));
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '20-first-thing'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), [
'---', 'milestone: v0.3', 'current_phase: 20', '---', '',
].join('\n'));
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [
'# Roadmap', '',
'### 🚧 v0.3 — Third Milestone (Phases 20-22) — ACTIVE', '',
'- [x] **Phase 20: First Thing** - does the first thing.',
'- [ ] **Phase 21: Second Thing** - does the second thing.',
'- [ ] **Phase 22: Third Thing** - does the third thing.',
'',
'<details>',
'<summary>✅ v0.2 Second Milestone (Phases 10-12) — ARCHIVED</summary>', '',
'- [ ] **Phase 10: Never Finished** - was left unchecked when v0.2 closed.',
'- [x] **Phase 11: Done Thing** - completed.',
'- [x] **Phase 12: Other Done Thing** - completed.',
'',
'</details>', '',
].join('\n'));
const phaseDir = path.join(tmpDir, '.planning', 'phases', '20-first-thing');
fs.writeFileSync(path.join(phaseDir, '20-01-PLAN.md'), [
'---', 'phase: 20-first-thing', 'plan: 01', '---', '',
'<objective>Do the first thing.</objective>', '',
].join('\n'));
fs.writeFileSync(path.join(phaseDir, '20-01-SUMMARY.md'), [
'---', 'phase: 20-first-thing', 'plan: 01', 'status: complete', '---', '',
'Done.', '',
].join('\n'));
});
afterEach(() => { cleanup(tmpDir); });
test('phase complete 20 advances to 21, not the archived range', () => {
const out = runPhaseComplete(tmpDir, { phase: '20', tolerateExit: true });
const payload = JSON.parse(out.slice(out.indexOf('{')));
assert.strictEqual(payload.next_phase, '21',
'completing phase 20 must advance to the real next phase 21 (#3982)');
assert.notStrictEqual(payload.next_phase, '10',
'an archived milestone\'s unchecked phase must never win the lowest-outstanding scan (#3982)');
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(!/current_phase:\s*10(\s|$)/m.test(state),
`STATE.md current_phase must not jump backwards into the archived range; got: ${state}`);
});
});