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:
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user