From bc774562040d398e889d3dbe01ce089658f28bef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Mon, 23 Feb 2026 13:32:02 -0600 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + get-shit-done/bin/lib/commands.cjs | 10 ++-- get-shit-done/bin/lib/core.cjs | 31 +++++++---- get-shit-done/bin/lib/init.cjs | 2 +- get-shit-done/bin/lib/phase.cjs | 29 +++++------ get-shit-done/bin/lib/roadmap.cjs | 12 ++--- get-shit-done/bin/lib/verify.cjs | 14 ++--- tests/phase.test.cjs | 82 ++++++++++++++++++++++++++++++ 8 files changed, 135 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd6c112a1..15c464049 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `/gsd:debug` flow now requires a `human-verify` checkpoint after self-verification before marking debug sessions `resolved` and moving files to `.planning/debug/resolved/` ### Fixed +- `gsd-tools phase complete` handles multi-level decimal phases (e.g. `03.2.1`) and safely escapes requirement IDs when building regex patterns, preventing `Invalid regular expression` crashes - `gsd-tools state-snapshot` supports `--cwd ` so tooling can target a project directory when invoked from outside the repo - `/gsd:update` now installs with `npx get-shit-done-cc@latest` (instead of unpinned `npx get-shit-done-cc`) to prevent stale project-local versions from shadowing updates - `/gsd:update` now uses strict package safety checks: only `get-shit-done-cc` is allowed, scoped/user-derived package names are rejected, and install command execution is allowlisted to trusted forms diff --git a/get-shit-done/bin/lib/commands.cjs b/get-shit-done/bin/lib/commands.cjs index b2a10c1f1..d549f6b9d 100644 --- a/get-shit-done/bin/lib/commands.cjs +++ b/get-shit-done/bin/lib/commands.cjs @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); -const { safeReadFile, loadConfig, isGitIgnored, execGit, normalizePhaseName, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, resolveModelInternal, MODEL_PROFILES, output, error, findPhaseInternal } = require('./core.cjs'); +const { safeReadFile, loadConfig, isGitIgnored, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, resolveModelInternal, MODEL_PROFILES, output, error, findPhaseInternal } = require('./core.cjs'); const { extractFrontmatter } = require('./frontmatter.cjs'); function cmdGenerateSlug(text, raw) { @@ -395,14 +395,10 @@ function cmdProgressRender(cwd, format, raw) { try { const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => { - const aNum = parseFloat(a.match(/^(\d+(?:\.\d+)?)/)?.[1] || '0'); - const bNum = parseFloat(b.match(/^(\d+(?:\.\d+)?)/)?.[1] || '0'); - return aNum - bNum; - }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b)); for (const dir of dirs) { - const dm = dir.match(/^(\d+(?:\.\d+)?)-?(.*)/); + const dm = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/); const phaseNum = dm ? dm[1] : dir; const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : ''; const phaseFiles = fs.readdirSync(path.join(phasesDir, dir)); diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 61dd26154..6cefea5a2 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -147,8 +147,12 @@ function execGit(cwd, args) { // ─── Phase utilities ────────────────────────────────────────────────────────── +function escapeRegex(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + function normalizePhaseName(phase) { - const match = phase.match(/^(\d+)([A-Z])?(\.\d+)?/i); + const match = String(phase).match(/^(\d+)([A-Z])?((?:\.\d+)*)/i); if (!match) return phase; const padded = match[1].padStart(2, '0'); const letter = match[2] ? match[2].toUpperCase() : ''; @@ -157,8 +161,8 @@ function normalizePhaseName(phase) { } function comparePhaseNum(a, b) { - const pa = String(a).match(/^(\d+)([A-Z])?(\.\d+)?/i); - const pb = String(b).match(/^(\d+)([A-Z])?(\.\d+)?/i); + const pa = String(a).match(/^(\d+)([A-Z])?((?:\.\d+)*)/i); + const pb = String(b).match(/^(\d+)([A-Z])?((?:\.\d+)*)/i); if (!pa || !pb) return String(a).localeCompare(String(b)); const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10); if (intDiff !== 0) return intDiff; @@ -170,10 +174,18 @@ function comparePhaseNum(a, b) { if (!lb) return 1; return la < lb ? -1 : 1; } - // No decimal sorts before decimal: 12A < 12A.1 < 12A.2 - const da = pa[3] ? parseFloat(pa[3]) : -1; - const db = pb[3] ? parseFloat(pb[3]) : -1; - return da - db; + // Segment-by-segment decimal comparison: 12A < 12A.1 < 12A.1.2 < 12A.2 + const aDecParts = pa[3] ? pa[3].slice(1).split('.').map(p => parseInt(p, 10)) : []; + const bDecParts = pb[3] ? pb[3].slice(1).split('.').map(p => parseInt(p, 10)) : []; + const maxLen = Math.max(aDecParts.length, bDecParts.length); + if (aDecParts.length === 0 && bDecParts.length > 0) return -1; + if (bDecParts.length === 0 && aDecParts.length > 0) return 1; + for (let i = 0; i < maxLen; i++) { + const av = Number.isFinite(aDecParts[i]) ? aDecParts[i] : 0; + const bv = Number.isFinite(bDecParts[i]) ? bDecParts[i] : 0; + if (av !== bv) return av - bv; + } + return 0; } function searchPhaseInDir(baseDir, relBase, normalized) { @@ -183,7 +195,7 @@ function searchPhaseInDir(baseDir, relBase, normalized) { const match = dirs.find(d => d.startsWith(normalized)); if (!match) return null; - const dirMatch = match.match(/^(\d+[A-Z]?(?:\.\d+)?)-?(.*)/i); + const dirMatch = match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i); const phaseNumber = dirMatch ? dirMatch[1] : normalized; const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null; const phaseDir = path.join(baseDir, match); @@ -302,7 +314,7 @@ function getRoadmapPhaseInternal(cwd, phaseNum) { try { const content = fs.readFileSync(roadmapPath, 'utf-8'); - const escapedPhase = phaseNum.toString().replace(/\./g, '\\.'); + const escapedPhase = escapeRegex(phaseNum.toString()); const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, 'i'); const headerMatch = content.match(phasePattern); if (!headerMatch) return null; @@ -385,6 +397,7 @@ module.exports = { loadConfig, isGitIgnored, execGit, + escapeRegex, normalizePhaseName, comparePhaseNum, searchPhaseInDir, diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs index b8d36094e..c2933a8b0 100644 --- a/get-shit-done/bin/lib/init.cjs +++ b/get-shit-done/bin/lib/init.cjs @@ -611,7 +611,7 @@ function cmdInitProgress(cwd, raw) { const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); for (const dir of dirs) { - const match = dir.match(/^(\d+(?:\.\d+)?)-?(.*)/); + const match = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/); const phaseNumber = match ? match[1] : dir; const phaseName = match && match[2] ? match[2] : null; diff --git a/get-shit-done/bin/lib/phase.cjs b/get-shit-done/bin/lib/phase.cjs index 10a4e602d..32127a031 100644 --- a/get-shit-done/bin/lib/phase.cjs +++ b/get-shit-done/bin/lib/phase.cjs @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); -const { normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, output, error } = require('./core.cjs'); +const { escapeRegex, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, output, error } = require('./core.cjs'); const { extractFrontmatter } = require('./frontmatter.cjs'); function cmdPhasesList(cwd, options, raw) { @@ -70,7 +70,7 @@ function cmdPhasesList(cwd, options, raw) { const result = { files, count: files.length, - phase_dir: phase ? dirs[0].replace(/^\d+(?:\.\d+)?-?/, '') : null, + phase_dir: phase ? dirs[0].replace(/^\d+(?:\.\d+)*-?/, '') : null, }; output(result, raw, files.join('\n')); return; @@ -121,11 +121,7 @@ function cmdPhaseNextDecimal(cwd, basePhase, raw) { } // Sort numerically - existingDecimals.sort((a, b) => { - const aNum = parseFloat(a); - const bNum = parseFloat(b); - return aNum - bNum; - }); + existingDecimals.sort((a, b) => comparePhaseNum(a, b)); // Calculate next decimal let nextDecimal; @@ -172,7 +168,7 @@ function cmdFindPhase(cwd, phase, raw) { return; } - const dirMatch = match.match(/^(\d+[A-Z]?(?:\.\d+)?)-?(.*)/i); + const dirMatch = match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i); const phaseNumber = dirMatch ? dirMatch[1] : normalized; const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null; @@ -318,7 +314,7 @@ function cmdPhaseAdd(cwd, description, raw) { const slug = generateSlugInternal(description); // Find highest integer phase number - const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)?:/gi; + const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi; let maxPhase = 0; let m; while ((m = phasePattern.exec(content)) !== null) { @@ -599,7 +595,7 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) { let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); // Remove the target phase section - const targetEscaped = targetPhase.replace(/\./g, '\\.'); + const targetEscaped = escapeRegex(targetPhase); const sectionPattern = new RegExp( `\\n?#{2,4}\\s*Phase\\s+${targetEscaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`, 'i' @@ -720,13 +716,13 @@ function cmdPhaseComplete(cwd, phaseNum, raw) { // Checkbox: - [ ] Phase N: → - [x] Phase N: (...completed DATE) const checkboxPattern = new RegExp( - `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseNum.replace('.', '\\.')}[:\\s][^\\n]*)`, + `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${escapeRegex(phaseNum)}[:\\s][^\\n]*)`, 'i' ); roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`); // Progress table: update Status to Complete, add date - const phaseEscaped = phaseNum.replace('.', '\\.'); + const phaseEscaped = escapeRegex(phaseNum); const tablePattern = new RegExp( `(\\|\\s*${phaseEscaped}\\.?\\s[^|]*\\|[^|]*\\|)\\s*[^|]*(\\|)\\s*[^|]*(\\|)`, 'i' @@ -753,7 +749,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) { if (fs.existsSync(reqPath)) { // Extract Requirements line from roadmap for this phase const reqMatch = roadmapContent.match( - new RegExp(`Phase\\s+${phaseNum.replace('.', '\\.')}[\\s\\S]*?\\*\\*Requirements:\\*\\*\\s*([^\\n]+)`, 'i') + new RegExp(`Phase\\s+${escapeRegex(phaseNum)}[\\s\\S]*?\\*\\*Requirements:\\*\\*\\s*([^\\n]+)`, 'i') ); if (reqMatch) { @@ -761,14 +757,15 @@ function cmdPhaseComplete(cwd, phaseNum, raw) { let reqContent = fs.readFileSync(reqPath, 'utf-8'); for (const reqId of reqIds) { + const reqEscaped = escapeRegex(reqId); // Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID** reqContent = reqContent.replace( - new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqId}\\*\\*)`, 'gi'), + new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'), '$1x$2' ); // Update traceability table: | REQ-ID | Phase N | Pending | → | REQ-ID | Phase N | Complete | reqContent = reqContent.replace( - new RegExp(`(\\|\\s*${reqId}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi'), + new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi'), '$1 Complete $2' ); } @@ -789,7 +786,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) { // Find the next phase directory after current for (const dir of dirs) { - const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)?)-?(.*)/i); + const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i); if (dm) { if (comparePhaseNum(dm[1], phaseNum) > 0) { nextPhaseNum = dm[1]; diff --git a/get-shit-done/bin/lib/roadmap.cjs b/get-shit-done/bin/lib/roadmap.cjs index b9cc5b416..9717b9aae 100644 --- a/get-shit-done/bin/lib/roadmap.cjs +++ b/get-shit-done/bin/lib/roadmap.cjs @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); -const { normalizePhaseName, output, error, findPhaseInternal } = require('./core.cjs'); +const { escapeRegex, normalizePhaseName, output, error, findPhaseInternal } = require('./core.cjs'); function cmdRoadmapGetPhase(cwd, phaseNum, raw) { const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); @@ -18,7 +18,7 @@ function cmdRoadmapGetPhase(cwd, phaseNum, raw) { const content = fs.readFileSync(roadmapPath, 'utf-8'); // Escape special regex chars in phase number, handle decimal - const escapedPhase = phaseNum.replace(/\./g, '\\.'); + const escapedPhase = escapeRegex(phaseNum); // Match "## Phase X:", "### Phase X:", or "#### Phase X:" with optional name const phasePattern = new RegExp( @@ -102,7 +102,7 @@ function cmdRoadmapAnalyze(cwd, raw) { const phasesDir = path.join(cwd, '.planning', 'phases'); // Extract all phase headings: ## Phase N: Name or ### Phase N: Name - const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)?)\s*:\s*([^\n]+)/gi; + const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; const phases = []; let match; @@ -153,7 +153,7 @@ function cmdRoadmapAnalyze(cwd, raw) { } catch {} // Check ROADMAP checkbox status - const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+${phaseNum.replace('.', '\\.')}`, 'i'); + const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+${escapeRegex(phaseNum)}`, 'i'); const checkboxMatch = content.match(checkboxPattern); const roadmapComplete = checkboxMatch ? checkboxMatch[1] === 'x' : false; @@ -192,7 +192,7 @@ function cmdRoadmapAnalyze(cwd, raw) { const completedPhases = phases.filter(p => p.disk_status === 'complete').length; // Detect phases in summary list without detail sections (malformed ROADMAP) - const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+(\d+[A-Z]?(?:\.\d+)?)/gi; + const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+(\d+[A-Z]?(?:\.\d+)*)/gi; const checklistPhases = new Set(); let checklistMatch; while ((checklistMatch = checklistPattern.exec(content)) !== null) { @@ -247,7 +247,7 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) { } let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); - const phaseEscaped = phaseNum.replace('.', '\\.'); + const phaseEscaped = escapeRegex(phaseNum); // Progress table row: update Plans column (summaries/plans) and Status column const tablePattern = new RegExp( diff --git a/get-shit-done/bin/lib/verify.cjs b/get-shit-done/bin/lib/verify.cjs index a52c003ff..c43e245db 100644 --- a/get-shit-done/bin/lib/verify.cjs +++ b/get-shit-done/bin/lib/verify.cjs @@ -410,7 +410,7 @@ function cmdValidateConsistency(cwd, raw) { // Extract phases from ROADMAP const roadmapPhases = new Set(); - const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)?)\s*:/gi; + const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi; let m; while ((m = phasePattern.exec(roadmapContent)) !== null) { roadmapPhases.add(m[1]); @@ -422,7 +422,7 @@ function cmdValidateConsistency(cwd, raw) { const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); for (const dir of dirs) { - const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)?)/i); + const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); if (dm) diskPhases.add(dm[1]); } } catch {} @@ -572,14 +572,14 @@ function cmdValidateHealth(cwd, options, raw) { } else { const stateContent = fs.readFileSync(statePath, 'utf-8'); // Extract phase references from STATE.md - const phaseRefs = [...stateContent.matchAll(/[Pp]hase\s+(\d+(?:\.\d+)?)/g)].map(m => m[1]); + const phaseRefs = [...stateContent.matchAll(/[Pp]hase\s+(\d+(?:\.\d+)*)/g)].map(m => m[1]); // Get disk phases const diskPhases = new Set(); try { const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); for (const e of entries) { if (e.isDirectory()) { - const m = e.name.match(/^(\d+(?:\.\d+)?)/); + const m = e.name.match(/^(\d+(?:\.\d+)*)/); if (m) diskPhases.add(m[1]); } } @@ -620,7 +620,7 @@ function cmdValidateHealth(cwd, options, raw) { try { const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); for (const e of entries) { - if (e.isDirectory() && !e.name.match(/^\d{2}(?:\.\d+)?-[\w-]+$/)) { + if (e.isDirectory() && !e.name.match(/^\d{2}(?:\.\d+)*-[\w-]+$/)) { addIssue('warning', 'W005', `Phase directory "${e.name}" doesn't follow NN-name format`, 'Rename to match pattern (e.g., 01-setup)'); } } @@ -650,7 +650,7 @@ function cmdValidateHealth(cwd, options, raw) { if (fs.existsSync(roadmapPath)) { const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); const roadmapPhases = new Set(); - const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)?)\s*:/gi; + const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi; let m; while ((m = phasePattern.exec(roadmapContent)) !== null) { roadmapPhases.add(m[1]); @@ -661,7 +661,7 @@ function cmdValidateHealth(cwd, options, raw) { const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); for (const e of entries) { if (e.isDirectory()) { - const dm = e.name.match(/^(\d+[A-Z]?(?:\.\d+)?)/i); + const dm = e.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); if (dm) diskPhases.add(dm[1]); } } diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs index b4d687030..21d6d2951 100644 --- a/tests/phase.test.cjs +++ b/tests/phase.test.cjs @@ -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'); });