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 ───────────────────────────────────────────────────
|
// ─── Milestone phase filter ───────────────────────────────────────────────────
|
||||||
|
|
||||||
type MilestonePhaseFilter = ((dirName: string) => boolean) & {
|
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 phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi;
|
||||||
|
const roadmapUnfenced = stripFencedLines(roadmap);
|
||||||
let m: RegExpExecArray | null;
|
let m: RegExpExecArray | null;
|
||||||
while ((m = phasePattern.exec(roadmap)) !== null) {
|
while ((m = phasePattern.exec(roadmapUnfenced)) !== null) {
|
||||||
milestonePhaseNums.add(m[1]);
|
milestonePhaseNums.add(m[1]);
|
||||||
}
|
}
|
||||||
} catch { /* intentionally empty */ }
|
} catch { /* intentionally empty */ }
|
||||||
|
|||||||
@@ -467,7 +467,7 @@ describe('roadmap-parser: getMilestonePhaseFilter', () => {
|
|||||||
assert.strictEqual(filter.phaseCount, 1, 'deduplication: only 1 unique phase');
|
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, [
|
writeRoadmap(tmpDir, [
|
||||||
'## v1.0: Real',
|
'## v1.0: Real',
|
||||||
'```',
|
'```',
|
||||||
@@ -478,10 +478,10 @@ describe('roadmap-parser: getMilestonePhaseFilter', () => {
|
|||||||
].join('\n'));
|
].join('\n'));
|
||||||
|
|
||||||
const filter = getMilestonePhaseFilter(tmpDir);
|
const filter = getMilestonePhaseFilter(tmpDir);
|
||||||
// KNOWN BUG #875: getMilestonePhaseFilter is fence-blind — phase headings inside
|
// Phase headings inside fenced code blocks must NOT be counted as real phases.
|
||||||
// fenced code blocks are incorrectly parsed as real phases. Flip to false when #875 is fixed.
|
// getMilestonePhaseFilter is fence-aware (fix #875).
|
||||||
assert.strictEqual(filter('01-real'), true, 'real phase matches');
|
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', () => {
|
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');
|
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, [
|
writeRoadmap(tmpDir, [
|
||||||
'## v1.0: Tilde',
|
'## v1.0: Tilde',
|
||||||
'~~~',
|
'~~~',
|
||||||
@@ -511,10 +511,35 @@ describe('roadmap-parser: getMilestonePhaseFilter', () => {
|
|||||||
].join('\n'));
|
].join('\n'));
|
||||||
|
|
||||||
const filter = getMilestonePhaseFilter(tmpDir);
|
const filter = getMilestonePhaseFilter(tmpDir);
|
||||||
// KNOWN BUG #875: getMilestonePhaseFilter is fence-blind — phase headings inside
|
// Phase headings inside tilde-fenced code blocks must NOT be counted as real phases.
|
||||||
// tilde-fenced code blocks are incorrectly parsed as real phases. Flip to false when #875 is fixed.
|
// getMilestonePhaseFilter is fence-aware (fix #875).
|
||||||
assert.strictEqual(filter('01-real'), true, 'real phase matches despite tilde fence');
|
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', () => {
|
test('adversarial: CRLF line endings in roadmap', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user