From b5a02da10640a109e1c71445bf2fcaa209b62cbc Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 8 Jun 2026 14:04:47 -0400 Subject: [PATCH] fix(#875): make getMilestonePhaseFilter fence-aware (#880) Closes #875 --- ...875-getmilestonephasefilter-fence-aware.md | 5 +++ src/roadmap-parser.cts | 43 ++++++++++++++++++- tests/roadmap-parser.test.cjs | 41 ++++++++++++++---- 3 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 .changeset/875-getmilestonephasefilter-fence-aware.md diff --git a/.changeset/875-getmilestonephasefilter-fence-aware.md b/.changeset/875-getmilestonephasefilter-fence-aware.md new file mode 100644 index 000000000..b95fb8fe5 --- /dev/null +++ b/.changeset/875-getmilestonephasefilter-fence-aware.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 880 +--- +`getMilestonePhaseFilter` now excludes phase headings inside fenced code blocks (``` ``` ``` or `~~~`) — consistent with the fence-aware behavior of `extractCurrentMilestone`. Previously, a `### Phase N:` line inside a fenced block was wrongly counted as a real phase. (#875) diff --git a/src/roadmap-parser.cts b/src/roadmap-parser.cts index 9e9c15cba..c89f90e52 100644 --- a/src/roadmap-parser.cts +++ b/src/roadmap-parser.cts @@ -327,6 +327,46 @@ function getMilestoneInfo(cwd: string): MilestoneInfo { } } +// ─── Fence-aware text helper ────────────────────────────────────────────────── + +/** + * Return a copy of `text` with every line that lies inside a fenced code block + * replaced by an empty string, using the same fence semantics as + * `computeSectionEnd` (backtick/tilde, ≥3 chars, indent ≤3 spaces, toggle; + * an unclosed fence treats remaining content as fenced). + */ +function stripFencedLines(text: string): string { + let fenceChar: string | null = null; + let fenceLen = 0; + const lines = text.split('\n'); + const result: string[] = []; + for (const line of lines) { + const fm = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/); + if (fm) { + const ch = fm[1][0]; + const ln = fm[1].length; + const trailing = fm[2] || ''; + if (!fenceChar) { + fenceChar = ch; + fenceLen = ln; + // The fence-open line itself is not a content line — blank it. + result.push(''); + } else if (ch === fenceChar && ln >= fenceLen && /^\s*$/.test(trailing)) { + fenceChar = null; + fenceLen = 0; + // The fence-close line — blank it. + result.push(''); + } else { + // A fence marker that doesn't close the current fence (different char or shorter) — keep treating as fenced content. + result.push(fenceChar ? '' : line); + } + } else { + result.push(fenceChar ? '' : line); + } + } + return result.join('\n'); +} + // ─── Milestone phase filter ─────────────────────────────────────────────────── type MilestonePhaseFilter = ((dirName: string) => boolean) & { @@ -416,8 +456,9 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null): } const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi; + const roadmapUnfenced = stripFencedLines(roadmap); let m: RegExpExecArray | null; - while ((m = phasePattern.exec(roadmap)) !== null) { + while ((m = phasePattern.exec(roadmapUnfenced)) !== null) { milestonePhaseNums.add(m[1]); } } catch { /* intentionally empty */ } diff --git a/tests/roadmap-parser.test.cjs b/tests/roadmap-parser.test.cjs index 36c973fca..43f5ca58f 100644 --- a/tests/roadmap-parser.test.cjs +++ b/tests/roadmap-parser.test.cjs @@ -467,7 +467,7 @@ describe('roadmap-parser: getMilestonePhaseFilter', () => { assert.strictEqual(filter.phaseCount, 1, 'deduplication: only 1 unique phase'); }); - test('adversarial: characterizes current fence-blind behavior for backtick fence (pending #875)', () => { + test('adversarial: phase heading inside backtick fence is excluded (fix #875)', () => { writeRoadmap(tmpDir, [ '## v1.0: Real', '```', @@ -478,10 +478,10 @@ describe('roadmap-parser: getMilestonePhaseFilter', () => { ].join('\n')); const filter = getMilestonePhaseFilter(tmpDir); - // KNOWN BUG #875: getMilestonePhaseFilter is fence-blind — phase headings inside - // fenced code blocks are incorrectly parsed as real phases. Flip to false when #875 is fixed. + // Phase headings inside fenced code blocks must NOT be counted as real phases. + // getMilestonePhaseFilter is fence-aware (fix #875). assert.strictEqual(filter('01-real'), true, 'real phase matches'); - assert.strictEqual(filter('999-fake'), true, 'characterization: getMilestonePhaseFilter is currently fence-blind (KNOWN BUG #875) — flip to false when #875 is fixed'); + assert.strictEqual(filter('999-fake'), false, 'fenced phase heading is correctly excluded'); }); test('adversarial: unclosed fence block — does not crash', () => { @@ -501,7 +501,7 @@ describe('roadmap-parser: getMilestonePhaseFilter', () => { assert.ok(typeof filter === 'function', 'filter is a function'); }); - test('adversarial: characterizes current fence-blind behavior for tilde fence (pending #875)', () => { + test('adversarial: phase heading inside tilde fence is excluded (fix #875)', () => { writeRoadmap(tmpDir, [ '## v1.0: Tilde', '~~~', @@ -511,10 +511,35 @@ describe('roadmap-parser: getMilestonePhaseFilter', () => { ].join('\n')); const filter = getMilestonePhaseFilter(tmpDir); - // KNOWN BUG #875: getMilestonePhaseFilter is fence-blind — phase headings inside - // tilde-fenced code blocks are incorrectly parsed as real phases. Flip to false when #875 is fixed. + // Phase headings inside tilde-fenced code blocks must NOT be counted as real phases. + // getMilestonePhaseFilter is fence-aware (fix #875). assert.strictEqual(filter('01-real'), true, 'real phase matches despite tilde fence'); - assert.strictEqual(filter('999-fake'), true, 'characterization: getMilestonePhaseFilter is currently fence-blind (KNOWN BUG #875) — flip to false when #875 is fixed'); + assert.strictEqual(filter('999-fake'), false, 'tilde-fenced phase heading is correctly excluded'); + }); + + test('adversarial: phase heading inside fence is excluded with CRLF endings (fix #875)', () => { + const crlf = '## v1.0: CRLF Fence\r\n```\r\n### Phase 999: Fake\r\n```\r\n### Phase 1: Real\r\n'; + writeRoadmap(tmpDir, crlf); + const filter = getMilestonePhaseFilter(tmpDir); + assert.strictEqual(filter('01-real'), true, 'real phase matches in CRLF file'); + assert.strictEqual(filter('999-fake'), false, 'fenced phase excluded in CRLF file'); + }); + + test('adversarial: phase headings in back-to-back fences are excluded (fix #875)', () => { + writeRoadmap(tmpDir, [ + '## v1.0: Adjacent', + '```', + '### Phase 998: Fake A', + '```', + '```', + '### Phase 999: Fake B', + '```', + '### Phase 1: Real', + ].join('\n')); + const filter = getMilestonePhaseFilter(tmpDir); + assert.strictEqual(filter('01-real'), true, 'real phase matches'); + assert.strictEqual(filter('998-fake'), false, 'first fenced phase excluded'); + assert.strictEqual(filter('999-fake'), false, 'second fenced phase excluded'); }); test('adversarial: CRLF line endings in roadmap', () => {