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/`
|
- `/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
|
### 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-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 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
|
- `/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 fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { execSync } = require('child_process');
|
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');
|
const { extractFrontmatter } = require('./frontmatter.cjs');
|
||||||
|
|
||||||
function cmdGenerateSlug(text, raw) {
|
function cmdGenerateSlug(text, raw) {
|
||||||
@@ -395,14 +395,10 @@ function cmdProgressRender(cwd, format, raw) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
||||||
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => {
|
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
|
||||||
const aNum = parseFloat(a.match(/^(\d+(?:\.\d+)?)/)?.[1] || '0');
|
|
||||||
const bNum = parseFloat(b.match(/^(\d+(?:\.\d+)?)/)?.[1] || '0');
|
|
||||||
return aNum - bNum;
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
const dm = dir.match(/^(\d+(?:\.\d+)?)-?(.*)/);
|
const dm = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/);
|
||||||
const phaseNum = dm ? dm[1] : dir;
|
const phaseNum = dm ? dm[1] : dir;
|
||||||
const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
|
const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
|
||||||
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
|
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
|
||||||
|
|||||||
@@ -147,8 +147,12 @@ function execGit(cwd, args) {
|
|||||||
|
|
||||||
// ─── Phase utilities ──────────────────────────────────────────────────────────
|
// ─── Phase utilities ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function escapeRegex(value) {
|
||||||
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePhaseName(phase) {
|
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;
|
if (!match) return phase;
|
||||||
const padded = match[1].padStart(2, '0');
|
const padded = match[1].padStart(2, '0');
|
||||||
const letter = match[2] ? match[2].toUpperCase() : '';
|
const letter = match[2] ? match[2].toUpperCase() : '';
|
||||||
@@ -157,8 +161,8 @@ function normalizePhaseName(phase) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function comparePhaseNum(a, b) {
|
function comparePhaseNum(a, b) {
|
||||||
const pa = String(a).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);
|
const pb = String(b).match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
||||||
if (!pa || !pb) return String(a).localeCompare(String(b));
|
if (!pa || !pb) return String(a).localeCompare(String(b));
|
||||||
const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10);
|
const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10);
|
||||||
if (intDiff !== 0) return intDiff;
|
if (intDiff !== 0) return intDiff;
|
||||||
@@ -170,10 +174,18 @@ function comparePhaseNum(a, b) {
|
|||||||
if (!lb) return 1;
|
if (!lb) return 1;
|
||||||
return la < lb ? -1 : 1;
|
return la < lb ? -1 : 1;
|
||||||
}
|
}
|
||||||
// No decimal sorts before decimal: 12A < 12A.1 < 12A.2
|
// Segment-by-segment decimal comparison: 12A < 12A.1 < 12A.1.2 < 12A.2
|
||||||
const da = pa[3] ? parseFloat(pa[3]) : -1;
|
const aDecParts = pa[3] ? pa[3].slice(1).split('.').map(p => parseInt(p, 10)) : [];
|
||||||
const db = pb[3] ? parseFloat(pb[3]) : -1;
|
const bDecParts = pb[3] ? pb[3].slice(1).split('.').map(p => parseInt(p, 10)) : [];
|
||||||
return da - db;
|
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) {
|
function searchPhaseInDir(baseDir, relBase, normalized) {
|
||||||
@@ -183,7 +195,7 @@ function searchPhaseInDir(baseDir, relBase, normalized) {
|
|||||||
const match = dirs.find(d => d.startsWith(normalized));
|
const match = dirs.find(d => d.startsWith(normalized));
|
||||||
if (!match) return null;
|
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 phaseNumber = dirMatch ? dirMatch[1] : normalized;
|
||||||
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
|
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
|
||||||
const phaseDir = path.join(baseDir, match);
|
const phaseDir = path.join(baseDir, match);
|
||||||
@@ -302,7 +314,7 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(roadmapPath, 'utf-8');
|
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 phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, 'i');
|
||||||
const headerMatch = content.match(phasePattern);
|
const headerMatch = content.match(phasePattern);
|
||||||
if (!headerMatch) return null;
|
if (!headerMatch) return null;
|
||||||
@@ -385,6 +397,7 @@ module.exports = {
|
|||||||
loadConfig,
|
loadConfig,
|
||||||
isGitIgnored,
|
isGitIgnored,
|
||||||
execGit,
|
execGit,
|
||||||
|
escapeRegex,
|
||||||
normalizePhaseName,
|
normalizePhaseName,
|
||||||
comparePhaseNum,
|
comparePhaseNum,
|
||||||
searchPhaseInDir,
|
searchPhaseInDir,
|
||||||
|
|||||||
@@ -611,7 +611,7 @@ function cmdInitProgress(cwd, raw) {
|
|||||||
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
|
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
|
||||||
|
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
const match = dir.match(/^(\d+(?:\.\d+)?)-?(.*)/);
|
const match = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/);
|
||||||
const phaseNumber = match ? match[1] : dir;
|
const phaseNumber = match ? match[1] : dir;
|
||||||
const phaseName = match && match[2] ? match[2] : null;
|
const phaseName = match && match[2] ? match[2] : null;
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
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');
|
const { extractFrontmatter } = require('./frontmatter.cjs');
|
||||||
|
|
||||||
function cmdPhasesList(cwd, options, raw) {
|
function cmdPhasesList(cwd, options, raw) {
|
||||||
@@ -70,7 +70,7 @@ function cmdPhasesList(cwd, options, raw) {
|
|||||||
const result = {
|
const result = {
|
||||||
files,
|
files,
|
||||||
count: files.length,
|
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'));
|
output(result, raw, files.join('\n'));
|
||||||
return;
|
return;
|
||||||
@@ -121,11 +121,7 @@ function cmdPhaseNextDecimal(cwd, basePhase, raw) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sort numerically
|
// Sort numerically
|
||||||
existingDecimals.sort((a, b) => {
|
existingDecimals.sort((a, b) => comparePhaseNum(a, b));
|
||||||
const aNum = parseFloat(a);
|
|
||||||
const bNum = parseFloat(b);
|
|
||||||
return aNum - bNum;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate next decimal
|
// Calculate next decimal
|
||||||
let nextDecimal;
|
let nextDecimal;
|
||||||
@@ -172,7 +168,7 @@ function cmdFindPhase(cwd, phase, raw) {
|
|||||||
return;
|
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 phaseNumber = dirMatch ? dirMatch[1] : normalized;
|
||||||
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
|
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
|
||||||
|
|
||||||
@@ -318,7 +314,7 @@ function cmdPhaseAdd(cwd, description, raw) {
|
|||||||
const slug = generateSlugInternal(description);
|
const slug = generateSlugInternal(description);
|
||||||
|
|
||||||
// Find highest integer phase number
|
// 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 maxPhase = 0;
|
||||||
let m;
|
let m;
|
||||||
while ((m = phasePattern.exec(content)) !== null) {
|
while ((m = phasePattern.exec(content)) !== null) {
|
||||||
@@ -599,7 +595,7 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|||||||
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||||
|
|
||||||
// Remove the target phase section
|
// Remove the target phase section
|
||||||
const targetEscaped = targetPhase.replace(/\./g, '\\.');
|
const targetEscaped = escapeRegex(targetPhase);
|
||||||
const sectionPattern = new RegExp(
|
const sectionPattern = new RegExp(
|
||||||
`\\n?#{2,4}\\s*Phase\\s+${targetEscaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`,
|
`\\n?#{2,4}\\s*Phase\\s+${targetEscaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`,
|
||||||
'i'
|
'i'
|
||||||
@@ -720,13 +716,13 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|||||||
|
|
||||||
// Checkbox: - [ ] Phase N: → - [x] Phase N: (...completed DATE)
|
// Checkbox: - [ ] Phase N: → - [x] Phase N: (...completed DATE)
|
||||||
const checkboxPattern = new RegExp(
|
const checkboxPattern = new RegExp(
|
||||||
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseNum.replace('.', '\\.')}[:\\s][^\\n]*)`,
|
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${escapeRegex(phaseNum)}[:\\s][^\\n]*)`,
|
||||||
'i'
|
'i'
|
||||||
);
|
);
|
||||||
roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`);
|
roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`);
|
||||||
|
|
||||||
// Progress table: update Status to Complete, add date
|
// Progress table: update Status to Complete, add date
|
||||||
const phaseEscaped = phaseNum.replace('.', '\\.');
|
const phaseEscaped = escapeRegex(phaseNum);
|
||||||
const tablePattern = new RegExp(
|
const tablePattern = new RegExp(
|
||||||
`(\\|\\s*${phaseEscaped}\\.?\\s[^|]*\\|[^|]*\\|)\\s*[^|]*(\\|)\\s*[^|]*(\\|)`,
|
`(\\|\\s*${phaseEscaped}\\.?\\s[^|]*\\|[^|]*\\|)\\s*[^|]*(\\|)\\s*[^|]*(\\|)`,
|
||||||
'i'
|
'i'
|
||||||
@@ -753,7 +749,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|||||||
if (fs.existsSync(reqPath)) {
|
if (fs.existsSync(reqPath)) {
|
||||||
// Extract Requirements line from roadmap for this phase
|
// Extract Requirements line from roadmap for this phase
|
||||||
const reqMatch = roadmapContent.match(
|
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) {
|
if (reqMatch) {
|
||||||
@@ -761,14 +757,15 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|||||||
let reqContent = fs.readFileSync(reqPath, 'utf-8');
|
let reqContent = fs.readFileSync(reqPath, 'utf-8');
|
||||||
|
|
||||||
for (const reqId of reqIds) {
|
for (const reqId of reqIds) {
|
||||||
|
const reqEscaped = escapeRegex(reqId);
|
||||||
// Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
|
// Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
|
||||||
reqContent = reqContent.replace(
|
reqContent = reqContent.replace(
|
||||||
new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqId}\\*\\*)`, 'gi'),
|
new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'),
|
||||||
'$1x$2'
|
'$1x$2'
|
||||||
);
|
);
|
||||||
// Update traceability table: | REQ-ID | Phase N | Pending | → | REQ-ID | Phase N | Complete |
|
// Update traceability table: | REQ-ID | Phase N | Pending | → | REQ-ID | Phase N | Complete |
|
||||||
reqContent = reqContent.replace(
|
reqContent = reqContent.replace(
|
||||||
new RegExp(`(\\|\\s*${reqId}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi'),
|
new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi'),
|
||||||
'$1 Complete $2'
|
'$1 Complete $2'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -789,7 +786,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|||||||
|
|
||||||
// Find the next phase directory after current
|
// Find the next phase directory after current
|
||||||
for (const dir of dirs) {
|
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 (dm) {
|
||||||
if (comparePhaseNum(dm[1], phaseNum) > 0) {
|
if (comparePhaseNum(dm[1], phaseNum) > 0) {
|
||||||
nextPhaseNum = dm[1];
|
nextPhaseNum = dm[1];
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
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) {
|
function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
|
||||||
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
|
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
|
||||||
@@ -18,7 +18,7 @@ function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
|
|||||||
const content = fs.readFileSync(roadmapPath, 'utf-8');
|
const content = fs.readFileSync(roadmapPath, 'utf-8');
|
||||||
|
|
||||||
// Escape special regex chars in phase number, handle decimal
|
// 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
|
// Match "## Phase X:", "### Phase X:", or "#### Phase X:" with optional name
|
||||||
const phasePattern = new RegExp(
|
const phasePattern = new RegExp(
|
||||||
@@ -102,7 +102,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
|
|||||||
const phasesDir = path.join(cwd, '.planning', 'phases');
|
const phasesDir = path.join(cwd, '.planning', 'phases');
|
||||||
|
|
||||||
// Extract all phase headings: ## Phase N: Name or ### Phase N: Name
|
// 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 = [];
|
const phases = [];
|
||||||
let match;
|
let match;
|
||||||
|
|
||||||
@@ -153,7 +153,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
// Check ROADMAP checkbox status
|
// 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 checkboxMatch = content.match(checkboxPattern);
|
||||||
const roadmapComplete = checkboxMatch ? checkboxMatch[1] === 'x' : false;
|
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;
|
const completedPhases = phases.filter(p => p.disk_status === 'complete').length;
|
||||||
|
|
||||||
// Detect phases in summary list without detail sections (malformed ROADMAP)
|
// 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();
|
const checklistPhases = new Set();
|
||||||
let checklistMatch;
|
let checklistMatch;
|
||||||
while ((checklistMatch = checklistPattern.exec(content)) !== null) {
|
while ((checklistMatch = checklistPattern.exec(content)) !== null) {
|
||||||
@@ -247,7 +247,7 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
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
|
// Progress table row: update Plans column (summaries/plans) and Status column
|
||||||
const tablePattern = new RegExp(
|
const tablePattern = new RegExp(
|
||||||
|
|||||||
@@ -410,7 +410,7 @@ function cmdValidateConsistency(cwd, raw) {
|
|||||||
|
|
||||||
// Extract phases from ROADMAP
|
// Extract phases from ROADMAP
|
||||||
const roadmapPhases = new Set();
|
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;
|
let m;
|
||||||
while ((m = phasePattern.exec(roadmapContent)) !== null) {
|
while ((m = phasePattern.exec(roadmapContent)) !== null) {
|
||||||
roadmapPhases.add(m[1]);
|
roadmapPhases.add(m[1]);
|
||||||
@@ -422,7 +422,7 @@ function cmdValidateConsistency(cwd, raw) {
|
|||||||
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
||||||
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
||||||
for (const dir of dirs) {
|
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]);
|
if (dm) diskPhases.add(dm[1]);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -572,14 +572,14 @@ function cmdValidateHealth(cwd, options, raw) {
|
|||||||
} else {
|
} else {
|
||||||
const stateContent = fs.readFileSync(statePath, 'utf-8');
|
const stateContent = fs.readFileSync(statePath, 'utf-8');
|
||||||
// Extract phase references from STATE.md
|
// 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
|
// Get disk phases
|
||||||
const diskPhases = new Set();
|
const diskPhases = new Set();
|
||||||
try {
|
try {
|
||||||
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
if (e.isDirectory()) {
|
if (e.isDirectory()) {
|
||||||
const m = e.name.match(/^(\d+(?:\.\d+)?)/);
|
const m = e.name.match(/^(\d+(?:\.\d+)*)/);
|
||||||
if (m) diskPhases.add(m[1]);
|
if (m) diskPhases.add(m[1]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -620,7 +620,7 @@ function cmdValidateHealth(cwd, options, raw) {
|
|||||||
try {
|
try {
|
||||||
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
||||||
for (const e of entries) {
|
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)');
|
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)) {
|
if (fs.existsSync(roadmapPath)) {
|
||||||
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||||
const roadmapPhases = new Set();
|
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;
|
let m;
|
||||||
while ((m = phasePattern.exec(roadmapContent)) !== null) {
|
while ((m = phasePattern.exec(roadmapContent)) !== null) {
|
||||||
roadmapPhases.add(m[1]);
|
roadmapPhases.add(m[1]);
|
||||||
@@ -661,7 +661,7 @@ function cmdValidateHealth(cwd, options, raw) {
|
|||||||
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
if (e.isDirectory()) {
|
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]);
|
if (dm) diskPhases.add(dm[1]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1032,6 +1032,75 @@ describe('phase complete command', () => {
|
|||||||
const result = runGsdTools('phase complete 1', tmpDir);
|
const result = runGsdTools('phase complete 1', tmpDir);
|
||||||
assert.ok(result.success, `Command should succeed even without REQUIREMENTS.md: ${result.error}`);
|
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);
|
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', () => {
|
test('falls back to localeCompare for non-phase strings', () => {
|
||||||
const result = comparePhaseNum('abc', 'def');
|
const result = comparePhaseNum('abc', 'def');
|
||||||
assert.strictEqual(typeof result, 'number');
|
assert.strictEqual(typeof result, 'number');
|
||||||
@@ -1117,6 +1194,11 @@ describe('normalizePhaseName', () => {
|
|||||||
assert.strictEqual(normalizePhaseName('12b.1'), '12B.1');
|
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', () => {
|
test('returns non-matching input unchanged', () => {
|
||||||
assert.strictEqual(normalizePhaseName('abc'), 'abc');
|
assert.strictEqual(normalizePhaseName('abc'), 'abc');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user