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

@@ -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

View File

@@ -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));

View File

@@ -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,

View File

@@ -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;

View File

@@ -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];

View File

@@ -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(

View File

@@ -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]);
}
}

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');
});