From dfad3a7510a2b73cecf08c6b83f5aa51a74b7453 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 10 Jul 2026 08:49:36 -0400 Subject: [PATCH] refactor(#2128): single-source 23 phase-token re-derivations; sanction 14 context-specific sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route 23 literal re-derivations of the canonical phase-number token through phase-id.cjs `PHASE_NUMBER_TOKEN_SOURCE` (via new RegExp). Each conversion was proven BYTE-IDENTICAL (old.source === new.source && old.flags === new.flags), so the runtime regexes are unchanged — zero behavior change by construction. The remaining 14 phase-token sites are genuine but context-specific and stay literal with a `// phase-id-owner: ` sanction: dir-name parses whose dash-continuation semantics differ from extractPhaseToken, and the [A-Za-z] case-variant / [.-] dot-or-dash separator forms that are not source-byte-equal to the canonical token. Scanner (`npm run check:phase-id-drift`) is now green. Co-Authored-By: Claude Opus 4.8 --- src/audit.cts | 3 +++ src/init.cts | 20 ++++++++++++-------- src/milestone.cts | 4 ++-- src/phase.cts | 19 +++++++++++++------ src/roadmap-command-router.cts | 1 + src/roadmap-parser.cts | 1 + src/roadmap-upgrade.cts | 18 ++++++++++++------ src/roadmap.cts | 2 ++ src/state.cts | 5 +++-- src/uat.cts | 1 + src/validate.cts | 4 ++-- src/verify.cts | 12 ++++++++---- 12 files changed, 60 insertions(+), 30 deletions(-) diff --git a/src/audit.cts b/src/audit.cts index 41bf72a93..c7bf2f983 100644 --- a/src/audit.cts +++ b/src/audit.cts @@ -482,6 +482,7 @@ function scanUatGaps(planDir: string): UatGapItem[] { for (const dir of dirs) { const phaseDir = path.join(phasesDir, dir); + // phase-id-owner: cosmetic phase label derived from a dir name for JSON output; the single-segment capture is not equivalent to extractPhaseToken dash-continuation semantics, so not a behavior-preserving drop-in. const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); const phaseNum = phaseMatch ? phaseMatch[1] : dir; @@ -552,6 +553,7 @@ function scanVerificationGaps(planDir: string): VerificationGapItem[] { for (const dir of dirs) { const phaseDir = path.join(phasesDir, dir); + // phase-id-owner: cosmetic phase label derived from a dir name for JSON output; the single-segment capture is not equivalent to extractPhaseToken dash-continuation semantics, so not a behavior-preserving drop-in. const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); const phaseNum = phaseMatch ? phaseMatch[1] : dir; @@ -614,6 +616,7 @@ function scanContextQuestions(planDir: string): ContextQuestionItem[] { for (const dir of dirs) { const phaseDir = path.join(phasesDir, dir); + // phase-id-owner: cosmetic phase label derived from a dir name for JSON output; the single-segment capture is not equivalent to extractPhaseToken dash-continuation semantics, so not a behavior-preserving drop-in. const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); const phaseNum = phaseMatch ? phaseMatch[1] : dir; diff --git a/src/init.cts b/src/init.cts index ee6e7ffa2..ab6bcafb1 100644 --- a/src/init.cts +++ b/src/init.cts @@ -73,7 +73,7 @@ const { extractCurrentMilestone, } = roadmapParser; const { pathExistsInternal, generateSlugInternal, toPosixPath } = coreUtils; -const { normalizePhaseName, phaseTokenMatches, stripProjectCodePrefix } = phaseId; +const { normalizePhaseName, phaseTokenMatches, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE } = phaseId; const { pruneOrphanedWorktrees } = worktreeSafety; const { @@ -1162,7 +1162,7 @@ function cmdInitMilestoneOp(cwd: string, raw: boolean): void { const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8'); const currentSection = extractCurrentMilestone(roadmapRaw, cwd); // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:/gi; + const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]*\\))?\\s*:`, 'gi'); let m: RegExpExecArray | null; while ((m = phasePattern.exec(currentSection)) !== null) { if (/^999(?:\.|$)/.test(m[1])) continue; @@ -1181,6 +1181,7 @@ function cmdInitMilestoneOp(cwd: string, raw: boolean): void { const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); for (const e of entries) { if (!e.isDirectory()) continue; + // phase-id-owner: dir-name phase-token parse; extractPhaseToken dash-separated sub-phase semantics differ, so a token-source swap would risk remapping phase<->directory matches. Kept local. const m = stripProjectCodePrefix(e.name).match(/^(\d+[A-Z]?(?:\.\d+)*)/); if (!m) continue; diskPhaseDirs.set(canonicalizePhase(m[1]), e.name); @@ -1319,14 +1320,14 @@ function cmdInitManager(cwd: string, raw: boolean): void { })(); const _checkboxStates = new Map(); - const _cbPattern = /-\s*\[(x| )\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi; + const _cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, 'gi'); let _cbMatch: RegExpExecArray | null; while ((_cbMatch = _cbPattern.exec(content)) !== null) { _checkboxStates.set(_cbMatch[2], _cbMatch[1].toLowerCase() === 'x'); } // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi; + const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]*\\))?\\s*:\\s*([^\\n]+)`, 'gi'); const phases: Record[] = []; let match: RegExpExecArray | null; @@ -1465,7 +1466,7 @@ function cmdInitManager(cwd: string, raw: boolean): void { ); const phaseMap = new Map(phases.map((p) => [normalizePhaseNumber(p['number'] as string), p])); - const _allCompletedPattern = /-\s*\[x\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi; + const _allCompletedPattern = new RegExp(`-\\s*\\[x\\]\\s*.*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, 'gi'); let _allMatch: RegExpExecArray | null; while ((_allMatch = _allCompletedPattern.exec(rawContent)) !== null) { const phaseNum = normalizePhaseNumber(_allMatch[1]); @@ -1499,7 +1500,7 @@ function cmdInitManager(cwd: string, raw: boolean): void { ) { phase['deps_satisfied'] = true; } else { - const depNums = (phase['depends_on'] as string).match(/\d+[A-Z]?(?:\.\d+)*/gi) || []; + const depNums = (phase['depends_on'] as string).match(new RegExp(`${PHASE_NUMBER_TOKEN_SOURCE}`, 'gi')) || []; phase['deps_satisfied'] = depNums.every((n) => completedNums.has(normalizePhaseNumber(n))); phase['dep_phases'] = depNums; } @@ -1689,13 +1690,13 @@ function cmdInitProgress(cwd: string, raw: boolean): void { cwd, ); // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi; + const headingPattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]*\\))?\\s*:\\s*([^\\n]+)`, 'gi'); let hm: RegExpExecArray | null; while ((hm = headingPattern.exec(roadmapContent)) !== null) { roadmapPhaseNums.add(hm[1]); roadmapPhaseNames.set(hm[1], hm[2].replace(/\(INSERTED\)/i, '').trim()); } - const cbPattern = /-\s*\[(x| )\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi; + const cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, 'gi'); let cbm: RegExpExecArray | null; while ((cbm = cbPattern.exec(roadmapContent)) !== null) { roadmapCheckboxStates.set(cbm[2], cbm[1].toLowerCase() === 'x'); @@ -1714,13 +1715,16 @@ function cmdInitProgress(cwd: string, raw: boolean): void { .map((e) => e.name) .filter(isDirInMilestone) .sort((a, b) => { + // phase-id-owner: dir-name phase-token parse; extractPhaseToken dash-separated sub-phase semantics differ, so a token-source swap would risk remapping phase<->directory matches. Kept local. const pa = a.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); + // phase-id-owner: dir-name phase-token parse; extractPhaseToken dash-separated sub-phase semantics differ, so a token-source swap would risk remapping phase<->directory matches. Kept local. const pb = b.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); if (!pa || !pb) return a.localeCompare(b); return parseInt(pa[1], 10) - parseInt(pb[1], 10); }); for (const dir of dirs) { + // phase-id-owner: dir-name phase-token parse; extractPhaseToken dash-separated sub-phase semantics differ, so a token-source swap would risk remapping phase<->directory matches. Kept local. const dirMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i); const phaseNumber = dirMatch ? dirMatch[1] : dir; const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null; diff --git a/src/milestone.cts b/src/milestone.cts index cafb7717a..3d4fcb5de 100644 --- a/src/milestone.cts +++ b/src/milestone.cts @@ -23,7 +23,7 @@ import ioMod = require('./io.cjs'); const { output, error } = ioMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseIdMod = require('./phase-id.cjs'); -const { escapeRegex, normalizePhaseName, phaseTokenMatches } = phaseIdMod; +const { escapeRegex, normalizePhaseName, phaseTokenMatches, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import roadmapParserMod = require('./roadmap-parser.cjs'); const { getMilestonePhaseFilter, extractCurrentMilestone, getMilestoneInfo } = roadmapParserMod; @@ -177,7 +177,7 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); const scopedContent = extractCurrentMilestone(roadmapContent, cwd); // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi; + const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]*\\))?\\s*:\\s*([^\\n]+)`, 'gi'); const noDirectoryPhases: string[] = []; let pm: RegExpExecArray | null; const phaseDirEntries = ((): string[] => { diff --git a/src/phase.cts b/src/phase.cts index deb41fe4b..6de08a4d1 100644 --- a/src/phase.cts +++ b/src/phase.cts @@ -37,6 +37,7 @@ const { phaseTokenMatches, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, + PHASE_NUMBER_TOKEN_SOURCE, } = phaseIdMod; // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module import phaseLocatorMod = require('./phase-locator.cjs'); @@ -374,8 +375,9 @@ function cmdFindPhase(cwd: string, phase: string, raw: boolean): void { if (!match) continue; const dirMatch = - match.match(new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+[A-Z]?(?:\\.\\d+)*)-?(.*)`, 'i')) || - match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i); + match.match( + new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i') + ) || match.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i')); const phaseNumber = dirMatch ? dirMatch[1] : normalized; const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null; @@ -1672,7 +1674,7 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void { .sort((a, b) => comparePhaseNum(a, b)); for (const dir of dirs) { - const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i); + const dm = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i')); if (dm) { if (/^999(?:\.|$)/.test(dm[1])) continue; if (comparePhaseNum(dm[1], phaseNum) > 0) { @@ -1705,7 +1707,10 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void { // #1729: `(?:\s*\([^)\n]*\))?` after the number tolerates a pre-colon // ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE) so // `### Phase N (Cluster B): X` resolves. Captures are unchanged. - const phasePattern = /(?:#{2,4}|-\s*\[[ xX]\])\s*(?:\*\*|__)?\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n*]+)/gi; + const phasePattern = new RegExp( + `(?:#{2,4}|-\\s*\\[[ xX]\\])\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]*\\))?\\s*:\\s*([^\\n*]+)`, + 'gi' + ); let pm: RegExpExecArray | null; while ((pm = phasePattern.exec(roadmapForPhases)) !== null) { if (comparePhaseNum(pm[1], phaseNum) > 0) { @@ -1741,8 +1746,10 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void { if (isLastPhase && roadmapContent !== null) { try { const milestoneScope = extractCurrentMilestone(roadmapContent, cwd); - const cbPattern = - /-\s*\[(x| )\]\s*(?:\*\*|__)?\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n*]+)/gi; + const cbPattern = new RegExp( + `-\\s*\\[(x| )\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]*\\))?\\s*:\\s*([^\\n*]+)`, + 'gi' + ); let cbm: RegExpExecArray | null; let lowestOutstanding: { num: string; name: string } | null = null; while ((cbm = cbPattern.exec(milestoneScope)) !== null) { diff --git a/src/roadmap-command-router.cts b/src/roadmap-command-router.cts index 97f6a0090..2e23b7318 100644 --- a/src/roadmap-command-router.cts +++ b/src/roadmap-command-router.cts @@ -73,6 +73,7 @@ function checkW021(content: string): W021Warning[] { // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). const PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+)-(\d+)(?:-\d+)*(?:\s*\([^)\n]*\))?\s*:/i; // Unprefixed legacy phase heading: ### Phase N: Name (no hyphen sub-index) + // phase-id-owner: UNPREFIXED_PHASE_RE token uses the [A-Za-z] case-variant (identical to the canonical [A-Z] token under /i); kept literal, not source-byte-equal to PHASE_NUMBER_TOKEN_SOURCE. const UNPREFIXED_PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Za-z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:/i; let currentMilestoneMajor: number | null = null; diff --git a/src/roadmap-parser.cts b/src/roadmap-parser.cts index f2ab3f648..8647da8d1 100644 --- a/src/roadmap-parser.cts +++ b/src/roadmap-parser.cts @@ -459,6 +459,7 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p // the milestone as a bogus "46-6" id. const numericRe = roadmapUsesHyphenedIds ? /^0*(\d+(?:-\d{2,})*[A-Za-z]?(?:\.\d+)*)/ + // phase-id-owner: [A-Za-z] case-variant token (identical under /i); kept literal, not source-byte-equal to the canonical PHASE_NUMBER_TOKEN_SOURCE. : /^0*(\d+[A-Za-z]?(?:\.\d+)*)/; function isDirInMilestone(dirName: string): boolean { diff --git a/src/roadmap-upgrade.cts b/src/roadmap-upgrade.cts index 3487a0334..e7202a495 100644 --- a/src/roadmap-upgrade.cts +++ b/src/roadmap-upgrade.cts @@ -16,13 +16,16 @@ import planningWorkspace = require('./planning-workspace.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseIdMod = require('./phase-id.cjs'); const { planningDir } = planningWorkspace; -const { stripProjectCodePrefix } = phaseIdMod; +const { stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod; // ─── Regex helpers ──────────────────────────────────────────────────────────── // Matches legacy phase headings: ### Phase N: Name (also decimal: Phase 2.1:) // Captures: (hashes)(spaces)(phase-number)(rest-of-line) -const LEGACY_PHASE_HEADING_RE = /^(#{2,4})\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:(.*)/i; +const LEGACY_PHASE_HEADING_RE = new RegExp( + `^(#{2,4})\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})\\s*:(.*)`, + 'i' +); // Matches already-migrated phase headings: ### Phase M-NN: Name const MIGRATED_PHASE_HEADING_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+\d+-\d{2}\s*:/i; @@ -172,6 +175,7 @@ function extractPhaseNumFromDir(dirName: string): string | null { const stripped = stripProjectCodePrefix(dirName); // Matches: digits + optional letter + optional decimal suffix, followed by '-' or end. // e.g. "02.1-hotfix" → "02.1", "01-setup" → "01" + // phase-id-owner: strips a leading phase number from a dir name; extractPhaseToken returns the project-code-prefixed token, so it is not a behavior-preserving drop-in. const m = stripped.match(/^(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i); return m ? m[1] : null; } @@ -188,7 +192,7 @@ function buildNewDirName(oldDirName: string, newId: string, projectCode: string const stripped = stripProjectCodePrefix(oldDirName); // Extract slug: everything after "NN-" (the old phase num, including decimal like 02.1) - const slugMatch = stripped.match(/^\d+[A-Z]?(?:\.\d+)*-(.*)/i); + const slugMatch = stripped.match(new RegExp(`^${PHASE_NUMBER_TOKEN_SOURCE}-(.*)`, 'i')); const slug = slugMatch ? slugMatch[1] : stripped; // Build M-NN prefix (zero-pad both parts) @@ -341,7 +345,7 @@ function computeMigrationPlan(cwd: string, options: Record = {} // Rewrite heading line: "### Phase N: Name" → "### Phase M-NN: Name" const oldLine = lines[entry.lineIndex]; const newLine = oldLine.replace( - /^(#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+)\d+[A-Z]?(?:\.\d+)*(\s*:)/i, + new RegExp(`^(#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+)${PHASE_NUMBER_TOKEN_SOURCE}(\\s*:)`, 'i'), `$1${mapping.newId}$2` ); if (newLine !== oldLine) { @@ -364,7 +368,9 @@ function computeMigrationPlan(cwd: string, options: Record = {} if (roadmapEdits.some(e => e.lineIndex === i)) continue; // Match checklist items: "- [ ] **Phase N:**" or "- [x] Phase N:" (also decimal) - const checklistMatch = line.match(/^(\s*-\s*\[[ x]\]\s*\*{0,2}Phase\s+)(\d+[A-Z]?(?:\.\d+)*)(\s*[:\s*])/i); + const checklistMatch = line.match( + new RegExp(`^(\\s*-\\s*\\[[ x]\\]\\s*\\*{0,2}Phase\\s+)(${PHASE_NUMBER_TOKEN_SOURCE})(\\s*[:\\s*])`, 'i') + ); if (checklistMatch) { const legacyNum = checklistMatch[2]; const cIntPart = parseInt(legacyNum, 10); @@ -393,7 +399,7 @@ function computeMigrationPlan(cwd: string, options: Record = {} if (newId) { const newLine = line.replace( - /^(\s*-\s*\[[ x]\]\s*\*{0,2}Phase\s+)\d+[A-Z]?(?:\.\d+)*(\s*[:\s*])/i, + new RegExp(`^(\\s*-\\s*\\[[ x]\\]\\s*\\*{0,2}Phase\\s+)${PHASE_NUMBER_TOKEN_SOURCE}(\\s*[:\\s*])`, 'i'), `$1${newId}$2` ); if (newLine !== line) { diff --git a/src/roadmap.cts b/src/roadmap.cts index 27f0e36c3..168dad3cb 100644 --- a/src/roadmap.cts +++ b/src/roadmap.cts @@ -298,6 +298,7 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void { // Extract all phase headings: ## Phase N: Name or ### Phase N: Name // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches. const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi; const phases: Array<{ number: string; @@ -437,6 +438,7 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void { // The char class must allow `-` (not just `.`) so dash-separated milestone-prefixed // IDs (e.g. `1-01`) match the detail-heading scanner above; otherwise they truncate // at the dash (`1-01` -> `1`) and every such phase reports a phantom missing detail. + // phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches. const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)/gi; const checklistPhases = new Set(); let checklistMatch: RegExpExecArray | null; diff --git a/src/state.cts b/src/state.cts index a4778f1c1..9bd235342 100644 --- a/src/state.cts +++ b/src/state.cts @@ -16,7 +16,7 @@ import configLoaderMod = require('./config-loader.cjs'); const { loadConfig } = configLoaderMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseIdMod = require('./phase-id.cjs'); -const { escapeRegex, normalizePhaseName, extractPhaseToken, parsePhaseFromProse } = phaseIdMod; +const { escapeRegex, normalizePhaseName, extractPhaseToken, parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import roadmapParserMod = require('./roadmap-parser.cjs'); const { getMilestoneInfo, getMilestonePhaseFilter, extractCurrentMilestone } = roadmapParserMod; @@ -1406,6 +1406,7 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Re // neither the denominator nor the numerator (mirrors the heading // exclusion below). Project-code-aware via phaseKeyFromDir. if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir))) continue; + // phase-id-owner: dir-name dedup grouping; diverges from extractPhaseToken/phaseKeyFromDir on project-code-prefixed and multi-segment milestone dirs. Kept local. const m = dir.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/); const key = m ? m[1].toLowerCase() : dir; if (!seenPhaseNums.has(key)) { @@ -2394,7 +2395,7 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b if (completed) diskCompletedPhases++; // Track the highest phase with incomplete plans (or any plans) - const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); + const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i')); if (phaseMatch && plans > 0) { if (summaries < plans) { // Incomplete phase — this is likely the current one diff --git a/src/uat.cts b/src/uat.cts index 9846f45bb..c5c3900ab 100644 --- a/src/uat.cts +++ b/src/uat.cts @@ -82,6 +82,7 @@ function cmdAuditUat(cwd: string, raw: boolean): void { .sort(); for (const dir of dirs) { + // phase-id-owner: display phase field derived from a dir name (same family as the audit.cts sites); not equivalent to extractPhaseToken for dash-form dirs. const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); const phaseNum = phaseMatch ? phaseMatch[1] : dir; const phaseDir = path.join(phasesDir, dir); diff --git a/src/validate.cts b/src/validate.cts index 5ed32d96c..05f96bd73 100644 --- a/src/validate.cts +++ b/src/validate.cts @@ -33,7 +33,7 @@ // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseIdMod = require('./phase-id.cjs'); -const { OPTIONAL_PROJECT_CODE_PREFIX_SOURCE } = phaseIdMod; +const { OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod; // ── Issue #26: regex constants (W005, W006-archived) ──────────────────────── // Matches legacy numeric dirs (01-setup), milestone-prefixed dirs (02-01-setup), @@ -62,7 +62,7 @@ export function canonicalPlanStem(stem: string): string { // #2043: the plan component (after the phase number) must be zero-padded // (≥2 digits), so a digit-leading slug word (e.g. "46-6-rs-…") is not mistaken // for a "46-6" phase/plan pair. - const m = stem.match(/^(\d+[A-Z]?(?:\.\d+)*-\d{2,})/i); + const m = stem.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE}-\\d{2,})`, 'i')); return m ? m[1] : stem; } diff --git a/src/verify.cts b/src/verify.cts index f7dfa924f..488598df9 100644 --- a/src/verify.cts +++ b/src/verify.cts @@ -37,7 +37,7 @@ import configLoaderMod = require('./config-loader.cjs'); const { loadConfig, CONFIG_DEFAULTS } = configLoaderMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseIdMod = require('./phase-id.cjs'); -const { normalizePhaseName, phaseTokenMatches, escapeRegex, getMilestoneFromPhaseId, OPTIONAL_PHASE_TAG_SOURCE } = phaseIdMod; +const { normalizePhaseName, phaseTokenMatches, escapeRegex, getMilestoneFromPhaseId, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseLocatorMod = require('./phase-locator.cjs'); const { findPhaseInternal } = phaseLocatorMod; @@ -1302,14 +1302,18 @@ function cmdValidateHealth( repairs.push('regenerateState'); } else { const stateContent = fs.readFileSync(statePath, 'utf-8'); - const phaseRefs = [...stateContent.matchAll(/[Pp]hase\s+(\d+[A-Z]?(?:\.\d+)*)/g)].map( + const phaseRefs = [ + ...stateContent.matchAll(new RegExp(`[Pp]hase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})`, 'g')), + ].map( (m) => m[1], ); const validPhases = collectDiskPhases(planBase); try { if (fs.existsSync(roadmapPath)) { const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8'); - const all = [...roadmapRaw.matchAll(/#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)/gi)]; + const all = [ + ...roadmapRaw.matchAll(new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})`, 'gi')), + ]; for (const m of all) validPhases.add(m[1]); } } catch { @@ -1809,7 +1813,7 @@ function cmdValidateHealth( const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8'); const scopedContent = extractCurrentMilestone(roadmapRaw, cwd); // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi; + const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]*\\))?\\s*:\\s*([^\\n]+)`, 'gi'); const unstarted: string[] = []; let pm: RegExpExecArray | null; // Non-hoisted: load-order matters (circular dep guard)