diff --git a/.changeset/graceful-badgers-click.md b/.changeset/graceful-badgers-click.md new file mode 100644 index 000000000..cec90ae43 --- /dev/null +++ b/.changeset/graceful-badgers-click.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1765 +--- +Phase headers that place a parenthetical tag before the colon (`### Phase 26 (Cluster B): Title`) now resolve and enumerate the same as untagged headers. Previously the resolver returned not-found and `roadmap analyze`/listing silently dropped the phase (wrong phase_count, progress, and next_phase). Tag tolerance is applied at every phase-header read site; untagged and all existing header formats parse unchanged. diff --git a/src/commands.cts b/src/commands.cts index af2c15c78..ed54ae44e 100644 --- a/src/commands.cts +++ b/src/commands.cts @@ -1514,7 +1514,8 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void { const roadmapContent = extractCurrentMilestone(roadmapRaw, cwd); // Matches both plain numeric (Phase 1:) and milestone-prefixed (Phase 2-01:) headings. // Also tolerates optional [bracket-token] scope prefix on phase headings. - const headingPattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:\s*([^\n]+)/gi; + // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + const headingPattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi; let match: RegExpExecArray | null; while ((match = headingPattern.exec(roadmapContent)) !== null) { const key = normalizePhaseName(match[1]); diff --git a/src/init.cts b/src/init.cts index 34ec9acb5..1b9de1cb0 100644 --- a/src/init.cts +++ b/src/init.cts @@ -1187,7 +1187,8 @@ function cmdInitMilestoneOp(cwd: string, raw: boolean): void { const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md'); const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8'); const currentSection = extractCurrentMilestone(roadmapRaw, cwd); - const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi; + // #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; let m: RegExpExecArray | null; while ((m = phasePattern.exec(currentSection)) !== null) { if (/^999(?:\.|$)/.test(m[1])) continue; @@ -1350,7 +1351,8 @@ function cmdInitManager(cwd: string, raw: boolean): void { _checkboxStates.set(_cbMatch[2], _cbMatch[1].toLowerCase() === 'x'); } - const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; + // #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 phases: Record[] = []; let match: RegExpExecArray | null; @@ -1722,7 +1724,8 @@ function cmdInitProgress(cwd: string, raw: boolean): void { fs.readFileSync(path.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8'), cwd, ); - const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; + // #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; let hm: RegExpExecArray | null; while ((hm = headingPattern.exec(roadmapContent)) !== null) { roadmapPhaseNums.add(hm[1]); diff --git a/src/milestone.cts b/src/milestone.cts index c167e2672..6b078e3ac 100644 --- a/src/milestone.cts +++ b/src/milestone.cts @@ -176,7 +176,8 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo if (stateVersion && stateVersion === version) { const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); const scopedContent = extractCurrentMilestone(roadmapContent, cwd); - const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; + // #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 noDirectoryPhases: string[] = []; let pm: RegExpExecArray | null; const phaseDirEntries = ((): string[] => { diff --git a/src/phase-id.cts b/src/phase-id.cts index 79bca6a0a..a401e3168 100644 --- a/src/phase-id.cts +++ b/src/phase-id.cts @@ -23,6 +23,24 @@ const PROJECT_CODE_PREFIX_STRIP_RE_I = /^[A-Z][A-Z0-9_]*-(?=\d)/i; const PROJECT_CODE_PREFIX_CAPTURE_RE_I = /^([A-Z][A-Z0-9_]*)-(\d.*)/i; const OPTIONAL_PROJECT_CODE_PREFIX_SOURCE = '(?:[A-Z][A-Z0-9_]*-)?'; +// #1729: phase headers may carry a parenthetical tag between the number and the +// colon, e.g. `### Phase 26 (Cluster B): Title`. This optional, non-capturing +// fragment is injected at every phase-header regex call site (immediately after +// the phase-number token, before the colon/space delimiter) so the resolver +// tolerates the tag — mirroring how `[...]` is already tolerated before `Phase`. +// `[^)\n]*` keeps the match single-line (headers are one line) to avoid +// over-consuming across a malformed multi-line document. Injected at the call +// site (not baked into phaseMarkdownRegexSource) so it applies uniformly to +// both the numeric and project-code-exact escaped sources, and so the decimal +// sub-phase patterns can place it after the `.N` segment. +// +// Enumeration/parse call sites that read phase headers from a regex *literal* +// (rather than a `new RegExp` built from an interpolated phase number) cannot +// reference this constant; they inline its literal-regex mirror instead — +// `(?:\s*\([^)\n]*\))?` — kept character-for-character equivalent to this +// source. Both forms must change together; see the #1729 regression test. +const OPTIONAL_PHASE_TAG_SOURCE = '(?:\\s*\\([^)\\n]*\\))?'; + function stripProjectCodePrefix(value: unknown, caseInsensitive = true): string { const input = String(value); const re = caseInsensitive ? PROJECT_CODE_PREFIX_STRIP_RE_I : PROJECT_CODE_PREFIX_STRIP_RE; @@ -220,6 +238,7 @@ function phaseTokenMatches(dirName: string, normalized: string): boolean { export = { escapeRegex, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, + OPTIONAL_PHASE_TAG_SOURCE, stripProjectCodePrefix, normalizePhaseName, getMilestoneFromPhaseId, diff --git a/src/phase.cts b/src/phase.cts index b6e4a5ae6..6dca595d1 100644 --- a/src/phase.cts +++ b/src/phase.cts @@ -36,6 +36,7 @@ const { comparePhaseNum, phaseTokenMatches, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, + OPTIONAL_PHASE_TAG_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'); @@ -215,7 +216,7 @@ function cmdPhaseNextDecimal(cwd: string, basePhase: string, raw: boolean): void try { const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); const phasePattern = new RegExp( - `#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalized)}\\.(\\d+)\\s*:`, + `#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalized)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'gi', ); let pm: RegExpExecArray | null; @@ -262,7 +263,7 @@ function getRoadmapModeForPhase(cwd: string, phaseNum: string): string | null { const milestoneContent = extractCurrentMilestone(rawContent, cwd); const fullContent = stripShippedMilestones(rawContent); const escapedPhase = phaseMarkdownRegexSource(phaseNum); - const phaseHeader = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}\\s*:`, 'i'); + const phaseHeader = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'i'); for (const content of [milestoneContent, fullContent]) { const headerMatch = content.match(phaseHeader); @@ -706,7 +707,8 @@ function cmdPhaseAdd(cwd: string, description: string, raw: boolean, customId?: // (section header, roadmap bullet, or on-disk directory) is counted: // 1) Section headers: ### Phase N: / ## Phase N: / #### Phase N: - const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi; + // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]*\))?:/gi; // 2) Roadmap bullet entries: - [ ] **Phase N: ...** (all checkbox variants) // The lookahead accepts colon, decimal-dot, whitespace, bold-close asterisk, // or end-of-line so titleless forms ("- [ ] **Phase 11**", "- [ ] Phase 11") @@ -803,7 +805,8 @@ function cmdPhaseAddBatch(cwd: string, descriptions: string[], raw: boolean): vo const content = extractCurrentMilestone(rawContent, cwd); let maxPhase = 0; if (config.phase_naming !== 'custom') { - const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi; + // #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]*\))?:/gi; let m: RegExpExecArray | null; while ((m = phasePattern.exec(content)) !== null) { const num = parseInt(m[1], 10); @@ -885,11 +888,11 @@ function cmdPhaseInsert(cwd: string, afterPhase: string, description: string, ra const normalizedAfter = normalizePhaseName(afterPhase); const afterPhaseEscaped = phaseMarkdownRegexSource(normalizedAfter); - const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+${afterPhaseEscaped}:`, 'i'); + const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:`, 'i'); const headingMatch = targetPattern.test(content); const bulletPattern = new RegExp( - `-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}[:\\s]`, + `-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i', ); const anyHeadingPattern = /#{2,4}\s*Phase\s+\d/i; @@ -898,7 +901,7 @@ function cmdPhaseInsert(cwd: string, afterPhase: string, description: string, ra if (!headingMatch && !isBulletStyle) { const checklistPattern = new RegExp( - `-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}[:\\s]`, + `-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i', ); if (checklistPattern.test(content)) { @@ -928,7 +931,7 @@ function cmdPhaseInsert(cwd: string, afterPhase: string, description: string, ra } const rmPhasePattern = new RegExp( - `#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalizedBase)}\\.(\\d+)\\s*:`, + `#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalizedBase)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'gi', ); let rmMatch: RegExpExecArray | null; @@ -951,7 +954,7 @@ function cmdPhaseInsert(cwd: string, afterPhase: string, description: string, ra if (isBulletStyle) { const boldBulletPattern = new RegExp( - `-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${afterPhaseEscaped}:`, + `-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:`, 'i', ); const useBold = boldBulletPattern.test(content); @@ -961,7 +964,7 @@ function cmdPhaseInsert(cwd: string, afterPhase: string, description: string, ra const bulletEntry = `\n- [ ] ${phaseLabel}`; const targetBulletPattern = new RegExp( - `(-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}[:\\s][^\\n]*)`, + `(-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`, 'i', ); const bulletMatchResult = rawContent.match(targetBulletPattern); @@ -988,7 +991,7 @@ function cmdPhaseInsert(cwd: string, afterPhase: string, description: string, ra `\n### Phase ${_decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${formatGsdSlash('plan-phase', resolveRuntime(cwd)) as string} ${_decimalPhase} to break down)\n`; const headerPattern = new RegExp( - `(#{2,4}\\s*Phase\\s+${afterPhaseEscaped}:[^\\n]*\\n)`, + `(#{2,4}\\s*Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:[^\\n]*\\n)`, 'i', ); const headerMatch = rawContent.match(headerPattern); @@ -1168,13 +1171,13 @@ function updateRoadmapAfterPhaseRemoval( content = content.replace( new RegExp( - `\\n?(?#{2,4})\\s*Phase\\s+${escaped}\\s*:[\\s\\S]*?(?=\\n\\k(?!#)\\s+Phase\\s+[^\\n:]+\\s*:|$)`, + `\\n?(?#{2,4})\\s*Phase\\s+${escaped}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:[\\s\\S]*?(?=\\n\\k(?!#)\\s+Phase\\s+[^\\n:]+\\s*:|$)`, 'i', ), '', ); content = content.replace( - new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${escaped}[:\\s][^\\n]*`, 'gi'), + new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${escaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*`, 'gi'), '', ); content = content.replace( @@ -1183,8 +1186,10 @@ function updateRoadmapAfterPhaseRemoval( ); if (!isDecimal) { + // #1729: fold an optional pre-colon ( ) tag into the suffix capture so it + // is re-emitted verbatim — a tagged later phase still gets renumbered. content = content.replace( - /(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)(\s*:)/gi, + /(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)((?:\s*\([^)\n]*\))?\s*:)/gi, (_match, prefix: string, num: string, suffix: string) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`, ); @@ -1354,7 +1359,7 @@ function writePlanningFileSet(writes: WriteSpec[]): void { function phaseDisplayNameFromRoadmap(roadmapContent: string | null, phaseNum: string | null): string | null { if (!roadmapContent || !phaseNum) return null; const phaseEscaped = phaseMarkdownRegexSource(phaseNum); - const heading = roadmapContent.match(new RegExp(`^#{2,4}\\s*Phase\\s+${phaseEscaped}\\s*:\\s*([^\\n]+)`, 'im')); + const heading = roadmapContent.match(new RegExp(`^#{2,4}\\s*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:\\s*([^\\n]+)`, 'im')); if (!heading) return null; const name = heading[1].replace(/\(INSERTED\)/i, '').trim(); return name || null; @@ -1443,7 +1448,7 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void { const phaseEscaped = phaseMarkdownRegexSource(phaseNum); const checkboxPattern = new RegExp( - `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`, + `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`, 'i', ); roadmapContent = roadmapContent.replace( @@ -1507,7 +1512,7 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void { const currentMilestoneRoadmap = extractCurrentMilestone(roadmapContent, cwd); const phaseSectionMatch = currentMilestoneRoadmap.match( new RegExp( - `(#{2,4}\\s*Phase\\s+${phaseEsc}[:\\s][\\s\\S]*?)(?=#{2,4}\\s*Phase\\s+|$)`, + `(#{2,4}\\s*Phase\\s+${phaseEsc}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][\\s\\S]*?)(?=#{2,4}\\s*Phase\\s+|$)`, 'i', ), ); @@ -1660,7 +1665,10 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void { // phase. Allow optional `**`/`__` emphasis after the marker and stop // the name capture at emphasis so bold names slug cleanly; the number // capture is unchanged. - const phasePattern = /(?:#{2,4}|-\s*\[[ xX]\])\s*(?:\*\*|__)?\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n*]+)/gi; + // #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; let pm: RegExpExecArray | null; while ((pm = phasePattern.exec(roadmapForPhases)) !== null) { if (comparePhaseNum(pm[1], phaseNum) > 0) { diff --git a/src/roadmap-command-router.cts b/src/roadmap-command-router.cts index 0ba4ceb24..97f6a0090 100644 --- a/src/roadmap-command-router.cts +++ b/src/roadmap-command-router.cts @@ -70,9 +70,10 @@ function checkW021(content: string): W021Warning[] { const MILESTONE_RE = /^#{1,3}\s+(?:\[[^\]]+\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.\d+(?:\s|:|\s*—)/iu; // Migrated phase heading: ### Phase M-NN: Name (M-NN or unpadded M-N form) - const PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+)-(\d+)(?:-\d+)*\s*:/i; + // #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) - const UNPREFIXED_PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Za-z]?(?:\.\d+)*)\s*:/i; + const UNPREFIXED_PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Za-z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:/i; let currentMilestoneMajor: number | null = null; const lines = content.split('\n'); diff --git a/src/roadmap-parser.cts b/src/roadmap-parser.cts index 882e3b6c7..d4523c98b 100644 --- a/src/roadmap-parser.cts +++ b/src/roadmap-parser.cts @@ -25,6 +25,7 @@ const { phaseMarkdownRegexSourceExact, stripProjectCodePrefix, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, + OPTIONAL_PHASE_TAG_SOURCE, } = phaseIdModule; // eslint-disable-next-line @typescript-eslint/no-require-imports import planningWorkspace = require('./planning-workspace.cjs'); @@ -96,7 +97,8 @@ function extractCurrentMilestone(content: string, cwd?: string): string { const preambleCutoff = firstMilestoneMatch ? firstMilestoneMatch.index! : detailsOpenIdx; const preamble = content.slice(0, preambleCutoff) .replace(/
[\s\S]*?<\/details>/gi, '') - .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '') + // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]*\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '') .replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, ''); return preamble + content.slice(detailsOpenIdx, detailsEnd); } @@ -176,7 +178,8 @@ function extractCurrentMilestone(content: string, cwd?: string): string { const preamble = beforeMilestones .replace(/
[\s\S]*?<\/details>/gi, '') - .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '') + // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]*\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '') .replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, ''); return detailsSection @@ -209,8 +212,9 @@ interface RoadmapPhaseResult { } function findRoadmapPhaseInContent(content: string, phaseNum: unknown, phaseSource?: string): RoadmapPhaseResult | null { + // #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag. const headingPattern = new RegExp( - `^(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseSource ?? phaseMarkdownRegexSource(phaseNum)}:\\s*(.+)$`, + `^(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseSource ?? phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, 'i' ); const headings = tokenizeHeadings(content); @@ -439,7 +443,8 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p // Use tokenizeHeadings (fence-aware) instead of stripFencedLines + regex. // T4 seam migration: phase headings inside fences are excluded automatically. - const phaseHeadingPattern = /^(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/i; + // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + const phaseHeadingPattern = /^(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/i; for (const h of tokenizeHeadings(roadmap)) { if (h.level < 2 || h.level > 4) continue; const pm = phaseHeadingPattern.exec(h.text); diff --git a/src/roadmap.cts b/src/roadmap.cts index 02547f4a4..0de363a7a 100644 --- a/src/roadmap.cts +++ b/src/roadmap.cts @@ -14,7 +14,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, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, phaseTokenMatches, stripProjectCodePrefix } = phaseIdMod; +const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, phaseTokenMatches, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE } = phaseIdMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseLocatorMod = require('./phase-locator.cjs'); const { findPhaseInternal } = phaseLocatorMod; @@ -118,8 +118,9 @@ function countPhasePlansAndSummaries(phaseDir: string): PhasePlansAndSummaries { * checklist-only match), or null if the phase is not present at all. */ function searchPhaseInContent(content: string, escapedPhase: string, phaseNum: string): PhaseSearchResult | null { + // #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag. const headingPattern = new RegExp( - `^(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${escapedPhase}:\\s*(.+)$`, + `^(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, 'i' ); const headings = tokenizeHeadings(content); @@ -129,7 +130,7 @@ function searchPhaseInContent(content: string, escapedPhase: string, phaseNum: s if (!headerMatch) { // Fallback: check if phase exists in summary list but missing detail section const checklistPattern = new RegExp( - `-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${escapedPhase}:\\s*([^*]+)\\*\\*`, + `-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*([^*]+)\\*\\*`, 'i' ); const checklistMatch = content.match(checklistPattern); @@ -310,7 +311,8 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void { const phasesDir = planningPaths(cwd).phases; // Extract all phase headings: ## Phase N: Name or ### Phase N: Name - const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)\s*:\s*([^\n]+)/gi; + // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi; const phases: Array<{ number: string; name: string; @@ -399,7 +401,7 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void { // #3537: padding-tolerant fragment — the heading discovered above may use // a different padding than the summary-bullet checkbox below it (mixed // padding inside one ROADMAP is legal and seen in real projects). - const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+${phaseMarkdownRegexSource(phaseNum)}[:\\s]`, 'i'); + const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+${phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i'); const checkboxMatch = content.match(checkboxPattern); const roadmapComplete = checkboxMatch ? checkboxMatch[1] === 'x' : false; @@ -546,7 +548,7 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und // `**Plans:** N plans` — bold "Plans:" (colon inside bold) // `Plans: N plans` — plain text header const planCountPattern = new RegExp( - `(#{2,4}\\s*Phase\\s+${phasePattern}(?=[:\\s])[\\s\\S]*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)[^\\n]+`, + `(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])[\\s\\S]*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)[^\\n]+`, 'i' ); const planCountText = isComplete @@ -557,7 +559,7 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und // If complete: check checkbox if (isComplete) { const checkboxPattern = new RegExp( - `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phasePattern}[:\\s][^\\n]*)`, + `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`, 'i' ); roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`); @@ -616,11 +618,11 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und // Pattern A: anchor to bare `Plans:` header (preferred). // Pattern B: fallback to bold summary when no bare header exists. const insertRowsPatternA = new RegExp( - `(#{2,4}\\s*Phase\\s+${phasePattern}(?=[:\\s])[\\s\\S]*?(?:^|\\n)(?:Plans:)[^\\n]*)`, + `(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])[\\s\\S]*?(?:^|\\n)(?:Plans:)[^\\n]*)`, 'i' ); const insertRowsPatternB = new RegExp( - `(#{2,4}\\s*Phase\\s+${phasePattern}(?=[:\\s])[\\s\\S]*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`, + `(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])[\\s\\S]*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`, 'i' ); @@ -768,7 +770,7 @@ function cmdRoadmapAnnotateDependencies(cwd: string, phaseNum: string | null | u // #3537: padding-tolerant fragment so the caller's resolved padded id // matches un-padded ROADMAP headings. const phaseEscaped = phaseMarkdownRegexSource(phaseNum); - const phaseHeaderPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEscaped}:[^\\n]*)`, 'i'); + const phaseHeaderPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:[^\\n]*)`, 'i'); const phaseMatch = content.match(phaseHeaderPattern); if (!phaseMatch) return; diff --git a/src/state.cts b/src/state.cts index 05779b201..02cfc8b59 100644 --- a/src/state.cts +++ b/src/state.cts @@ -1446,7 +1446,8 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Re // truth for total_phases (#549). let roadmapPhaseCount = 0; if (roadmapScope !== null) { - const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi; + // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/gi; let m: RegExpExecArray | null; while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) { // Only count tokens that contain at least one digit — excludes @@ -2422,7 +2423,8 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b try { let roadmapPhaseCount = 0; if (syncRoadmapScope !== null) { - const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi; + // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/gi; let m: RegExpExecArray | null; while ((m = phaseHeadingPattern.exec(syncRoadmapScope)) !== null) { // Only count tokens that contain at least one digit — excludes diff --git a/src/validate.cts b/src/validate.cts index 52e15f606..c1167a4d5 100644 --- a/src/validate.cts +++ b/src/validate.cts @@ -104,7 +104,8 @@ export function buildRoadmapPhaseVariants(roadmapContent: string): RoadmapPhaseV const roadmapPhaseVariants = new Set(); // Matches both legacy numeric (Phase 1:), decimal (Phase 2.1:), milestone-prefixed (Phase 2-01:), // and bracket-prefixed (### [GSD] Phase 2-01:) headings. - const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi; + // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/gi; let m: RegExpExecArray | null; while ((m = phasePattern.exec(roadmapContent)) !== null) { roadmapPhases.add(m[1]); diff --git a/src/verify.cts b/src/verify.cts index 3b618546c..f7dfa924f 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 } = phaseIdMod; +const { normalizePhaseName, phaseTokenMatches, escapeRegex, getMilestoneFromPhaseId, OPTIONAL_PHASE_TAG_SOURCE } = phaseIdMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseLocatorMod = require('./phase-locator.cjs'); const { findPhaseInternal } = phaseLocatorMod; @@ -1081,7 +1081,8 @@ function checkMilestonePrefixMismatches( } for (const section of sections) { const content = roadmapContent.slice(section.start, section.end); - const phaseRx = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi; + // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). + const phaseRx = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/gi; let pm: RegExpExecArray | null; while ((pm = phaseRx.exec(content)) !== null) { const phaseId = pm[1]; @@ -1571,7 +1572,7 @@ function cmdValidateHealth( if (currentPhaseMatch) { const statePhase = currentPhaseMatch[1].replace(/^0+/, ''); const phaseCheckboxRe = new RegExp( - `-\\s*\\[x\\].*Phase\\s+0*${escapeRegex(statePhase)}[:\\s]`, + `-\\s*\\[x\\].*Phase\\s+0*${escapeRegex(statePhase)}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i', ); if (phaseCheckboxRe.test(roadmapContentFull)) { @@ -1807,7 +1808,8 @@ function cmdValidateHealth( if (isMarkedComplete) { const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8'); const scopedContent = extractCurrentMilestone(roadmapRaw, cwd); - const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; + // #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 unstarted: string[] = []; let pm: RegExpExecArray | null; // Non-hoisted: load-order matters (circular dep guard) diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs index dacecc5ef..9680a54fa 100644 --- a/tests/phase.test.cjs +++ b/tests/phase.test.cjs @@ -239,6 +239,200 @@ describe('phases list command', () => { // roadmap get-phase command // ───────────────────────────────────────────────────────────────────────────── +// #1729 regression: a phase header may carry a parenthetical tag BEFORE the +// colon — `### Phase 26 (Cluster B): Title`. The header regexes built +// `Phase\s+` immediately followed by the colon delimiter, so the resolver +// returned found:false and phase commands silently no-op'd (the failure is +// silent — not-found, not an error — so an author can lose work without a +// signal). The fix injects a shared OPTIONAL_PHASE_TAG_SOURCE fragment at every +// header call site, mirroring how `[...]` is already tolerated before `Phase`. +// +// Parity assertion (per the #3537/#3599 generative-fix discipline): a pre-colon +// tag must resolve the SAME phase as the equivalent post-colon tag, padding +// tolerance must survive, and the optional tag must not enable cross-phase +// false matches. A shared seam + parity test keeps the next call site from +// drifting back undetected. +describe('#1729 regression: parenthetical tag before the colon in a phase header', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + function writeRoadmap(lines) { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + ['# Roadmap', ''].concat(lines, ['']).join('\n'), + ); + } + + function getPhase(query) { + const result = runGsdTools(['roadmap', 'get-phase', String(query)], tmpDir); + assert.ok(result.success, `get-phase ${query} failed: ${result.error}`); + return JSON.parse(result.output); + } + + test('resolves a header whose tag sits before the colon (the reported bug)', () => { + writeRoadmap([ + '### Phase 26 (Cluster B): Engine-adapter caveats', + 'Plans: 2', + ]); + const phase = getPhase(26); + assert.equal(phase.found, true, 'pre-colon-tagged phase must resolve'); + assert.equal( + phase.phase_name, + 'Engine-adapter caveats', + 'the pre-colon tag must be excluded from the resolved name', + ); + }); + + test('a pre-colon tag resolves the same phase as a post-colon tag', () => { + // Post-colon placement is the documented workaround and already worked. + // Both placements must resolve found:true for the same phase number; the + // only contracted difference is that a post-colon tag is part of the title. + writeRoadmap(['### Phase 26: Engine-adapter caveats (Cluster B)', 'Plans: 2']); + const post = getPhase(26); + assert.equal(post.found, true); + assert.equal(post.phase_name, 'Engine-adapter caveats (Cluster B)'); + + writeRoadmap(['### Phase 26 (Cluster B): Engine-adapter caveats', 'Plans: 2']); + const pre = getPhase(26); + assert.equal(pre.found, true, 'pre-colon placement must resolve like post-colon'); + assert.equal(pre.phase_number, post.phase_number); + }); + + test('padding tolerance (#3537) survives: both `6` and `06` resolve a tagged header', () => { + writeRoadmap(['### Phase 6 (Cluster B): Padded test', 'Plans: 1']); + const unpadded = getPhase(6); + const padded = getPhase('06'); + assert.equal(unpadded.found, true, 'unpadded query must resolve'); + assert.equal(padded.found, true, 'padded query must resolve'); + assert.equal(padded.phase_name, unpadded.phase_name, 'padded/unpadded must agree'); + assert.equal(unpadded.phase_name, 'Padded test'); + }); + + test('decimal sub-phase headers tolerate a pre-colon tag', () => { + writeRoadmap([ + '### Phase 26 (Cluster B): Base', + 'Plans: 1', + '', + '### Phase 26.1 (Sub tag): Decimal subphase', + 'Plans: 1', + ]); + const sub = getPhase('26.1'); + assert.equal(sub.found, true, 'decimal sub-phase with pre-colon tag must resolve'); + assert.equal(sub.phase_name, 'Decimal subphase'); + }); + + test('the optional tag does not enable a cross-phase false match', () => { + // `0*2` must not latch onto `Phase 26 (...)`: querying phase 2 against a + // roadmap that only has phase 26 must still report not-found. + writeRoadmap(['### Phase 26 (Cluster B): Engine caveats', 'Plans: 1']); + assert.equal(getPhase(2).found, false, 'phase 2 must not match phase 26'); + assert.equal(getPhase(26).found, true, 'sanity: phase 26 still resolves'); + }); + + test('exposes a shared OPTIONAL_PHASE_TAG_SOURCE seam to prevent call-site drift', () => { + const phaseId = require('../gsd-core/bin/lib/phase-id.cjs'); + assert.equal( + typeof phaseId.OPTIONAL_PHASE_TAG_SOURCE, + 'string', + 'the shared tag fragment must be exported so every header site composes it', + ); + const re = new RegExp(`Phase\\s+0*26${phaseId.OPTIONAL_PHASE_TAG_SOURCE}:`); + assert.ok(re.test('### Phase 26 (Cluster B): X'), 'seam matches a pre-colon tag'); + assert.ok(re.test('### Phase 26: X'), 'seam stays optional when no tag is present'); + }); + + test('enumeration (roadmap analyze) lists a pre-colon-tagged phase, not just the resolver', () => { + // The resolver (get-phase) and the capture-all enumeration regexes are + // separate code paths. Fixing only the resolver left `roadmap analyze` + // silently dropping a tagged phase from its phase list — wrong phase_count, + // progress_percent, and next_phase. Both a tagged and an untagged phase must + // appear so the enumeration is coherent with the resolver. + writeRoadmap([ + '### Phase 26 (Cluster B): Engine-adapter caveats', + 'Plans: 1', + '', + '### Phase 27: Coordinator playbook', + 'Plans: 1', + ]); + const result = runGsdTools(['roadmap', 'analyze'], tmpDir); + assert.ok(result.success, `roadmap analyze failed: ${result.error}`); + const analysis = JSON.parse(result.output); + const numbers = analysis.phases.map((p) => p.number); + assert.ok(numbers.includes('26'), 'tagged phase 26 must appear in enumeration'); + assert.ok(numbers.includes('27'), 'untagged phase 27 must appear in enumeration'); + assert.equal(analysis.phase_count, 2, 'tagged phase must count toward phase_count'); + const p26 = analysis.phases.find((p) => p.number === '26'); + assert.equal(p26.name, 'Engine-adapter caveats', 'pre-colon tag must be excluded from the enumerated name'); + }); + + test('phase remove renumbers a later tagged header and preserves its tag', () => { + // The renumber-on-removal rewrite (phase.cts) captured `(num)(\s*:)`, so a + // later pre-colon-tagged header was skipped — leaving a stale/duplicate + // number after an earlier phase was removed. The tag must survive the + // rewrite (it is folded into the re-emitted suffix, not dropped). + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + [ + '# Roadmap', + '', + '### Phase 1: Foundation', + '**Goal:** Setup', + '', + '### Phase 2: Auth', + '**Goal:** Authentication', + '', + '### Phase 3 (Cluster B): Features', + '**Goal:** Core features', + '', + ].join('\n'), + ); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-features'), { recursive: true }); + + const result = runGsdTools('phase remove 2', tmpDir); + assert.ok(result.success, `phase remove failed: ${result.error}`); + + const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); + assert.ok( + /###\s*Phase 2 \(Cluster B\): Features/.test(roadmap), + `tagged header must renumber 3->2 and keep its tag; got:\n${roadmap}`, + ); + assert.ok(!/Phase 3 \(Cluster B\)/.test(roadmap), 'old number 3 must be gone'); + }); + + test('the literal enumeration mirror stays equivalent to the exported seam (drift guard)', () => { + // Resolver sites compose OPTIONAL_PHASE_TAG_SOURCE; literal enumeration sites + // inline `(?:\s*\([^)\n]*\))?`. If one is edited without the other the two + // header families silently diverge. Assert behavioral equivalence over a + // representative header corpus so the split cannot drift undetected. + const phaseId = require('../gsd-core/bin/lib/phase-id.cjs'); + const LITERAL_MIRROR = '(?:\\s*\\([^)\\n]*\\))?'; + const seam = new RegExp(`^Phase\\s+26${phaseId.OPTIONAL_PHASE_TAG_SOURCE}\\s*:`); + const mirror = new RegExp(`^Phase\\s+26${LITERAL_MIRROR}\\s*:`); + for (const sample of [ + 'Phase 26: X', + 'Phase 26 (Cluster B): X', + 'Phase 26 (a) (b): X', + 'Phase 26 (unterminated: X', + 'Phase 26 : X', + ]) { + assert.equal( + seam.test(sample), + mirror.test(sample), + `seam and literal mirror must agree on: ${JSON.stringify(sample)}`, + ); + } + }); +}); + describe('phase next-decimal command', () => { let tmpDir;