* fix(#1729): resolve phase headers with a pre-colon parenthetical tag A phase header may carry a parenthetical tag between the number and the colon, e.g. `### Phase 26 (Cluster B): Title`. Every phase-header regex built `Phase\s+<num>` immediately against the colon delimiter, so the tagged phase was invisible: the resolver returned found:false and, just as bad, the capture-all enumeration/parse paths (roadmap analyze, milestone listing + milestone-scope filter, verify, init/import, state total_phases, validate, the command router, preamble stripping, and the phase-remove renumbering rewrite) silently dropped, miscounted, or failed to renumber it — wrong phase_count, progress_percent, next_phase, or corrupt numbering after a removal. The fix tolerates the tag at the header seam. Parameterized resolver sites compose the exported OPTIONAL_PHASE_TAG_SOURCE fragment; literal enumeration sites inline its character-for-character mirror `(?:\s*\([^)\n]*\))?`, placed immediately before the colon so it cannot alter an existing match (optional, single-line, one paren pair, no capture-group shift). In the renumber-on-removal rewrite the tag is folded into the re-emitted suffix capture so it survives verbatim. Both forms are documented to change together and a drift-guard test asserts their behavioral equivalence over a header corpus. Deliberately excluded: roadmap-upgrade.cts (legacy one-time migration), where tolerating the tag would silently drop it on header rewrite — that needs its own data-preserving treatment. Known boundaries left for follow-up: checklist/bullet-style phase entries (`- [ ] Phase N (tag):`) and a malformed space-before-colon variant, both pre-existing. Validated empirically against the issue's reproduction: `roadmap get-phase 26` resolves and `roadmap analyze` lists Phase 26 with the tag excluded from the name (phase_count 2, next 26); an all-tagged versioned roadmap now scopes correctly instead of falling back to a pass-all filter. Regression coverage in tests/phase.test.cjs asserts resolver parity (pre- vs post-colon), padding tolerance (#3537), decimal sub-phases, no cross-phase false match, the shared seam, enumeration coherence, renumber-preserves-tag, and seam/mirror drift. Full unit suite green (7291 pass, 0 fail); eslint + regression-name + resolution-provenance + changeset lints pass. Reviewed by Codex (no critical/high; the two enumeration misses it surfaced are folded in). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(#1729): add changeset for pre-colon phase-tag fix Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
d96c7ef865
commit
12b35eeeaf
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user