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:
@@ -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 <path>` 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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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