* 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
5
.changeset/graceful-badgers-click.md
Normal file
5
.changeset/graceful-badgers-click.md
Normal file
@@ -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.
|
||||
@@ -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]);
|
||||
|
||||
@@ -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<string, unknown>[] = [];
|
||||
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]);
|
||||
|
||||
@@ -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[] => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?(?<h>#{2,4})\\s*Phase\\s+${escaped}\\s*:[\\s\\S]*?(?=\\n\\k<h>(?!#)\\s+Phase\\s+[^\\n:]+\\s*:|$)`,
|
||||
`\\n?(?<h>#{2,4})\\s*Phase\\s+${escaped}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:[\\s\\S]*?(?=\\n\\k<h>(?!#)\\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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(/<details>[\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(/<details>[\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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -104,7 +104,8 @@ export function buildRoadmapPhaseVariants(roadmapContent: string): RoadmapPhaseV
|
||||
const roadmapPhaseVariants = new Set<string>();
|
||||
// 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]);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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+<num>` 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;
|
||||
|
||||
Reference in New Issue
Block a user