fix(phase): guard backlog dirs and YYYY-MM dates in integer phase removal (#2466)

* fix(phase): guard backlog dirs and YYYY-MM dates in integer phase removal

Closes #2435
Closes #2434

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(phase): extend date-collision guard to hyphen-adjacent context

The lookbehind `(?<!\d)` in renameIntegerPhases only excluded
digit-prefixed matches; a YYYY-MM-DD date like 2026-05-14 has a hyphen
before the month digits, which passed the original guard and caused
date corruption when renumbering a phase whose zero-padded number
matched the month. Replace with `(?<![0-9-])` lookbehind and
`(?![0-9-])` lookahead to exclude both digit- and hyphen-adjacent
contexts. Adds a regression test for the hyphen-adjacent case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-04-20 10:08:52 -04:00
committed by GitHub
parent d117c1045a
commit 4cd890b252
2 changed files with 138 additions and 2 deletions

View File

@@ -625,7 +625,7 @@ function renameIntegerPhases(phasesDir, removedInt) {
const m = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i);
if (!m) return null;
const dirInt = parseInt(m[1], 10);
return dirInt > removedInt ? { dir, oldInt: dirInt, letter: m[2] ? m[2].toUpperCase() : '', decimal: m[3] ? parseInt(m[3], 10) : null, slug: m[4] } : null;
return (dirInt > removedInt && dirInt < 999) ? { dir, oldInt: dirInt, letter: m[2] ? m[2].toUpperCase() : '', decimal: m[3] ? parseInt(m[3], 10) : null, slug: m[4] } : null;
})
.filter(Boolean)
.sort((a, b) => a.oldInt !== b.oldInt ? b.oldInt - a.oldInt : (b.decimal || 0) - (a.decimal || 0));
@@ -673,7 +673,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
const oldPad = oldStr.padStart(2, '0'), newPad = newStr.padStart(2, '0');
content = content.replace(new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}(\\s*:)`, 'gi'), `$1${newStr}$2`);
content = content.replace(new RegExp(`(Phase\\s+)${oldStr}([:\\s])`, 'g'), `$1${newStr}$2`);
content = content.replace(new RegExp(`${oldPad}-(\\d{2})`, 'g'), `${newPad}-$1`);
content = content.replace(new RegExp(`(?<![0-9-])${oldPad}-(\\d{2})(?![0-9-])`, 'g'), `${newPad}-$1`);
content = content.replace(new RegExp(`(\\|\\s*)${oldStr}\\.\\s`, 'g'), `$1${newStr}. `);
content = content.replace(new RegExp(`(Depends on:\\*\\*\\s*Phase\\s+)${oldStr}\\b`, 'gi'), `$1${newStr}`);
}

View File

@@ -1268,6 +1268,142 @@ describe('phase remove command', () => {
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(state.includes('**Total Phases:** 1'), 'total phases should be decremented');
});
test('bug-2434: integer phase remove does not rename 999.x backlog directory', () => {
// Setup: an active integer phase 4 and a backlog phase 999.1
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Foundation
**Goal:** Setup
### Phase 2: Auth
**Goal:** Authentication
### Phase 3: Features
**Goal:** Core features
### Phase 4: Extras
**Goal:** Extra stuff
### Phase 999.1: Backlog item
**Goal:** Parked backlog task
`
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-features'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '04-extras'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '999.1-backlog-item'), { recursive: true });
const result = runGsdTools('phase remove 4', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
// Backlog directory must remain at 999.1, not be decremented to 998.1
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '999.1-backlog-item')),
'backlog directory 999.1-backlog-item must not be renamed'
);
assert.ok(
!fs.existsSync(path.join(tmpDir, '.planning', 'phases', '998.1-backlog-item')),
'backlog directory must not be incorrectly renamed to 998.1'
);
});
test('bug-2435: integer phase remove does not corrupt YYYY-MM-DD dates in ROADMAP.md', () => {
// Setup: removing phase 4 from a roadmap containing 2026-04-14 date strings
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Foundation
**Goal:** Setup
**Completed:** 2026-01-15
### Phase 2: Auth
**Goal:** Authentication
**Completed:** 2026-02-20
### Phase 3: Features
**Goal:** Core features
**Completed:** 2026-04-14
### Phase 4: Extras
**Goal:** Extra stuff
### Phase 5: Final
**Goal:** Final phase
`
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-features'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '04-extras'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '05-final'), { recursive: true });
const result = runGsdTools('phase remove 4', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
// Dates must be preserved exactly
assert.ok(roadmap.includes('2026-01-15'), 'date 2026-01-15 must not be corrupted');
assert.ok(roadmap.includes('2026-02-20'), 'date 2026-02-20 must not be corrupted');
assert.ok(roadmap.includes('2026-04-14'), 'date 2026-04-14 must not be corrupted');
// Phase 5 should be renumbered to 4
assert.ok(roadmap.includes('Phase 4: Final'), 'Phase 5 should be renumbered to Phase 4');
});
test('bug-2435: integer phase remove does not corrupt date whose month matches removed phase number', () => {
// Setup: removing phase 4 from a roadmap containing 2026-05-14
// When renumbering phase 5→4, the regex must not replace "05-14" in the date "2026-05-14"
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Foundation
**Goal:** Setup
**Completed:** 2026-01-15
### Phase 2: Auth
**Goal:** Authentication
**Completed:** 2026-02-20
### Phase 3: Features
**Goal:** Core features
**Completed:** 2026-03-10
### Phase 4: Extras
**Goal:** Extra stuff
### Phase 5: Final
**Goal:** Final phase
**Due:** 2026-05-14
`
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-features'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '04-extras'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '05-final'), { recursive: true });
const result = runGsdTools('phase remove 4', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
// Date "2026-05-14" must not be corrupted to "2026-04-14" when phase 5 is renumbered to 4
assert.ok(roadmap.includes('2026-05-14'), 'date 2026-05-14 must not be corrupted when renumbering phase 5→4');
assert.ok(!roadmap.includes('2026-04-14'), 'date must not be incorrectly mutated to 2026-04-14');
// Phase 5 should be renumbered to 4
assert.ok(roadmap.includes('Phase 4: Final'), 'Phase 5 should be renumbered to Phase 4');
});
});
// ─────────────────────────────────────────────────────────────────────────────