fix: handle multi-level decimal phases and escape regex in phase operations (#720)

Phase number parsing only matched single-decimal (e.g. 03.2) but crashed
on multi-level decimals (e.g. 03.2.1). Requirement IDs with regex
metacharacters (parentheses, dots) were interpolated raw into RegExp
constructors, causing SyntaxError crashes.

- Add escapeRegex() utility for safe regex interpolation
- Update normalizePhaseName/comparePhaseNum for multi-level decimals
- Replace all .replace('.', '\\.') with escapeRegex() across modules
- Escape reqId before regex interpolation in cmdPhaseComplete
- Update all phase dir matching regexes from (?:\.\d+)? to (?:\.\d+)*
- Add regression test for phase complete 03.2.1

Closes #621

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
TÂCHES
2026-02-23 13:32:02 -06:00
committed by GitHub
parent 7715e6d1df
commit bc77456204
8 changed files with 135 additions and 46 deletions

View File

@@ -1032,6 +1032,75 @@ describe('phase complete command', () => {
const result = runGsdTools('phase complete 1', tmpDir);
assert.ok(result.success, `Command should succeed even without REQUIREMENTS.md: ${result.error}`);
});
test('handles multi-level decimal phase without regex crash', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [x] Phase 3: Lorem
- [x] Phase 3.2: Ipsum
- [ ] Phase 3.2.1: Dolor Sit
- [ ] Phase 4: Amet
### Phase 3: Lorem
**Goal:** Setup
**Plans:** 1/1 plans complete
**Requirements:** LOR-01
### Phase 3.2: Ipsum
**Goal:** Build
**Plans:** 1/1 plans complete
**Requirements:** IPS-01
### Phase 03.2.1: Dolor Sit Polish (INSERTED)
**Goal:** Polish
**Plans:** 1/1 plans complete
### Phase 4: Amet
**Goal:** Deliver
**Requirements:** AMT-01: Filter items by category with AND logic (items matching ALL selected categories)
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
`# Requirements
- [ ] **LOR-01**: Lorem database schema
- [ ] **IPS-01**: Ipsum rendering engine
- [ ] **AMT-01**: Filter items by category
`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# State
**Current Phase:** 03.2.1
**Current Phase Name:** Dolor Sit Polish
**Status:** Execution complete
**Current Plan:** 03.2.1-01
**Last Activity:** 2025-01-01
**Last Activity Description:** Working
`
);
const p32 = path.join(tmpDir, '.planning', 'phases', '03.2-ipsum');
const p321 = path.join(tmpDir, '.planning', 'phases', '03.2.1-dolor-sit');
const p4 = path.join(tmpDir, '.planning', 'phases', '04-amet');
fs.mkdirSync(p32, { recursive: true });
fs.mkdirSync(p321, { recursive: true });
fs.mkdirSync(p4, { recursive: true });
fs.writeFileSync(path.join(p321, '03.2.1-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p321, '03.2.1-01-SUMMARY.md'), '# Summary');
const result = runGsdTools('phase complete 03.2.1', tmpDir);
assert.ok(result.success, `Command should not crash on regex metacharacters: ${result.error}`);
const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8');
assert.ok(req.includes('- [ ] **AMT-01**'), 'AMT-01 should remain unchanged');
});
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -1085,6 +1154,14 @@ describe('comparePhaseNum', () => {
assert.strictEqual(comparePhaseNum('12a', '12A'), 0);
});
test('sorts multi-level decimal phases correctly', () => {
assert.ok(comparePhaseNum('3.2', '3.2.1') < 0);
assert.ok(comparePhaseNum('3.2.1', '3.2.2') < 0);
assert.ok(comparePhaseNum('3.2.1', '3.3') < 0);
assert.ok(comparePhaseNum('3.2.1', '4') < 0);
assert.strictEqual(comparePhaseNum('3.2.1', '3.2.1'), 0);
});
test('falls back to localeCompare for non-phase strings', () => {
const result = comparePhaseNum('abc', 'def');
assert.strictEqual(typeof result, 'number');
@@ -1117,6 +1194,11 @@ describe('normalizePhaseName', () => {
assert.strictEqual(normalizePhaseName('12b.1'), '12B.1');
});
test('handles multi-level decimal phases', () => {
assert.strictEqual(normalizePhaseName('3.2.1'), '03.2.1');
assert.strictEqual(normalizePhaseName('12.3.4'), '12.3.4');
});
test('returns non-matching input unchanged', () => {
assert.strictEqual(normalizePhaseName('abc'), 'abc');
});