fix(#875): make getMilestonePhaseFilter fence-aware (#880)

Closes #875
This commit is contained in:
Tom Boucher
2026-06-08 14:04:47 -04:00
committed by GitHub
parent a7b288b9df
commit b5a02da106
3 changed files with 80 additions and 9 deletions

View File

@@ -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)

View File

@@ -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 */ }

View File

@@ -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', () => {