`phase remove N` for an integer phase silently deleted the adjacent
`### Phase N.1:` decimal section when the decimal was a peer-depth
heading. The bug was in the section-removal regex inside
get-shit-done/bin/lib/phase.cjs:updateRoadmapAfterPhaseRemoval:
(?=\n#{2,4}\s+Phase\s+\d+\s*:|$)
The lookahead required the next header's digits to be followed by
`\s*:` — true for `### Phase 3:` but false for `### Phase 2.1:` because
the `.1` breaks the match. The non-greedy `[\s\S]*?` body then consumed
`Phase 2.1` along with `Phase 2` until it found the next integer
header. The on-disk phase directory `.planning/phases/02.1-*` survived
but its ROADMAP entry was gone — disk/ROADMAP inconsistency.
The fix uses a depth-aware lookahead: capture the hash count of the
header being removed with a named group `(?<h>#{2,4})` and require the
end-of-section lookahead to match the SAME depth via `\k<h>(?!#)`. The
`(?!#)` guards against `###` accidentally matching a deeper `####`
header by anchoring on the captured hash count.
This preserves two contracts simultaneously:
- #3601: removing `### Phase 2:` (depth 3) stops at the next depth-3
header, including `### Phase 2.1:` — the peer-level decimal is
preserved.
- #3355: removing `### Phase 27:` (depth 3) continues past
`#### Phase 27.1:` (depth 4, a child of the integer phase) until it
reaches the next depth-3 header. The child decimal is part of the
integer phase being removed.
The regression test exercises the public CLI via runGsdTools and
asserts on typed JSON output from `roadmap get-phase --json` — no raw
text matching on ROADMAP.md content (per CONTRIBUTING.md
"Prohibited: Raw Text Matching on Test Outputs").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
160 lines
5.5 KiB
JavaScript
160 lines
5.5 KiB
JavaScript
/**
|
|
* Bug #3601: `phase remove N` for an integer phase can also delete the
|
|
* adjacent decimal phase section (`### Phase N.1:`) when the decimal is a
|
|
* peer-level header at the same depth as the integer being removed.
|
|
*
|
|
* Root cause: the section-removal regex in
|
|
* `get-shit-done/bin/lib/phase.cjs:updateRoadmapAfterPhaseRemoval` used a
|
|
* depth-blind lookahead (`(?=\n#{2,4}\s+Phase\s+\d+\s*:|$)`) that required
|
|
* the next header's digits to be followed by `\s*:`. `### Phase 2.1:`
|
|
* (depth 3, decimal) did not satisfy `\d+\s*:` because of the `.1`, so
|
|
* the non-greedy match consumed `Phase 2.1` along with `Phase 2` until it
|
|
* reached the next integer header.
|
|
*
|
|
* The fix makes the lookahead depth-aware: it captures the hash count of
|
|
* the header being removed and stops only at a subsequent header of the
|
|
* SAME depth, integer or decimal. That preserves the #3355 contract
|
|
* (`#### Phase 27.1:` at depth 4 is a CHILD of `### Phase 27:` at depth 3
|
|
* and must be removed alongside it) while fixing the #3601 contract
|
|
* (`### Phase 2.1:` at depth 3 is a PEER of `### Phase 2:` and must be
|
|
* preserved).
|
|
*
|
|
* Assertions go through the typed `roadmap get-phase --json` query so no
|
|
* test asserts on raw ROADMAP.md text content.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
|
|
function writeRoadmap(tmpDir, body) {
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), body);
|
|
}
|
|
function writeState(tmpDir, version) {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
`---\nmilestone: ${version}\n---\n`,
|
|
);
|
|
}
|
|
function ensurePhaseDir(tmpDir, name) {
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', name), { recursive: true });
|
|
}
|
|
function getPhase(tmpDir, phaseNum) {
|
|
const r = runGsdTools(['roadmap', 'get-phase', phaseNum, '--json'], tmpDir);
|
|
if (!r.success) return { found: false, error: r.error };
|
|
return JSON.parse(r.output);
|
|
}
|
|
|
|
describe('bug #3601: phase remove preserves peer-depth decimal sections', () => {
|
|
let tmpDir;
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject('bug-3601-');
|
|
});
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('removing Phase 2 preserves peer-depth Phase 2.1 and renumbers Phase 3 → 2', () => {
|
|
writeState(tmpDir, 'v1.0.0');
|
|
writeRoadmap(
|
|
tmpDir,
|
|
[
|
|
'# Roadmap',
|
|
'',
|
|
'## Current Milestone: v1.0.0 - Test',
|
|
'',
|
|
'### Phase 2: Parent',
|
|
'**Goal:** RemoveMeGoal',
|
|
'',
|
|
'### Phase 2.1: Follow-up',
|
|
'**Goal:** PreserveDecimalGoal',
|
|
'',
|
|
'### Phase 3: Trailing',
|
|
'**Goal:** PreserveTrailingGoal',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
ensurePhaseDir(tmpDir, '02-parent');
|
|
ensurePhaseDir(tmpDir, '02.1-follow-up');
|
|
ensurePhaseDir(tmpDir, '03-trailing');
|
|
|
|
const r = runGsdTools(['phase', 'remove', '2'], tmpDir);
|
|
assert.ok(r.success, `phase remove failed: ${r.error || r.output}`);
|
|
|
|
// The peer-depth decimal (Phase 2.1) must still be queryable — its
|
|
// unique goal proves the section body survived.
|
|
const decimal = getPhase(tmpDir, '2.1');
|
|
assert.strictEqual(decimal.found, true, 'Phase 2.1 deleted alongside Phase 2');
|
|
assert.strictEqual(decimal.phase_name, 'Follow-up');
|
|
assert.strictEqual(decimal.goal, 'PreserveDecimalGoal');
|
|
|
|
// Phase 3 must have been renumbered to Phase 2.
|
|
const renumbered = getPhase(tmpDir, '2');
|
|
assert.strictEqual(renumbered.found, true);
|
|
assert.strictEqual(renumbered.phase_name, 'Trailing');
|
|
assert.strictEqual(
|
|
renumbered.goal,
|
|
'PreserveTrailingGoal',
|
|
'Phase 3 → Phase 2 renumber did not carry the right section content',
|
|
);
|
|
|
|
// The removed Parent goal must not be retrievable from any current phase.
|
|
const parentLookup = getPhase(tmpDir, '3');
|
|
assert.notStrictEqual(
|
|
parentLookup.goal,
|
|
'RemoveMeGoal',
|
|
'removed parent goal reappeared under a phase header',
|
|
);
|
|
});
|
|
|
|
test('removing Phase 5 preserves Phase 5.1 and Phase 5.2 (multiple peer decimals)', () => {
|
|
writeState(tmpDir, 'v1.0.0');
|
|
writeRoadmap(
|
|
tmpDir,
|
|
[
|
|
'# Roadmap',
|
|
'',
|
|
'## Current Milestone: v1.0.0 - Test',
|
|
'',
|
|
'### Phase 5: Parent',
|
|
'**Goal:** RemoveParent',
|
|
'',
|
|
'### Phase 5.1: First child',
|
|
'**Goal:** ChildAGoal',
|
|
'',
|
|
'### Phase 5.2: Second child',
|
|
'**Goal:** ChildBGoal',
|
|
'',
|
|
'### Phase 6: Tail',
|
|
'**Goal:** TailGoal',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
ensurePhaseDir(tmpDir, '05-parent');
|
|
ensurePhaseDir(tmpDir, '05.1-first-child');
|
|
ensurePhaseDir(tmpDir, '05.2-second-child');
|
|
ensurePhaseDir(tmpDir, '06-tail');
|
|
|
|
const r = runGsdTools(['phase', 'remove', '5'], tmpDir);
|
|
assert.ok(r.success);
|
|
|
|
const decimalA = getPhase(tmpDir, '5.1');
|
|
assert.strictEqual(decimalA.found, true, 'Phase 5.1 deleted');
|
|
assert.strictEqual(decimalA.goal, 'ChildAGoal');
|
|
|
|
const decimalB = getPhase(tmpDir, '5.2');
|
|
assert.strictEqual(decimalB.found, true, 'Phase 5.2 deleted');
|
|
assert.strictEqual(decimalB.goal, 'ChildBGoal');
|
|
|
|
const tail = getPhase(tmpDir, '5');
|
|
assert.strictEqual(tail.found, true);
|
|
assert.strictEqual(tail.goal, 'TailGoal', 'Phase 6 → Phase 5 renumber misfired');
|
|
});
|
|
});
|