5
.changeset/875-getmilestonephasefilter-fence-aware.md
Normal file
5
.changeset/875-getmilestonephasefilter-fence-aware.md
Normal 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)
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user