Files
msd-core/tests/bug-2787-milestone-fenced-block-truncation.test.cjs
Tom Boucher eeaf9c556f fix(#2787): track fenced code blocks in extractCurrentMilestone (#2812)
* fix(#2787): track fenced code blocks in extractCurrentMilestone

The milestone-end search used a multiline regex against the raw
restContent string. Lines inside fenced code blocks (``` or ~~~)
that matched the milestone-heading pattern (e.g. `# note v1.0`)
prematurely set sectionEnd, hiding all phases after the block from
roadmap analyze, roadmap get-phase, and every downstream command.

Replace the regex match with a line-by-line scan that tracks fence
state. Lines inside an open fence are skipped regardless of content.
Adds three regression tests covering backtick fences, tilde fences,
and the roadmap get-phase code path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#2787): track fence delimiter instead of toggling bare boolean

Replace the inFence boolean with fenceChar/fenceLen tracking so that
indented fences (up to 3 leading spaces) and mixed-delimiter content
(~~~ inside a backtick fence) are parsed correctly. A closing fence
is only recognised when it uses the same character as the opening
delimiter and has at least the same run length, matching the CommonMark
spec.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#2787): require fence-only closing line — reject info-string lines as closers

A closing fence delimiter must contain only optional trailing whitespace.
A line like \`\`\`js inside an open fence has an info string and must not
close it. The previous regex /^\s{0,3}([`~]{3,})/ matched the opening of any
such line, so the closing check could toggle fenceChar off on an info-string
line and expose subsequent heading-like content to the milestone-end detector.

Fix: capture the trailing portion of every fence-candidate line and only clear
fenceChar when trailing matches /^\s*$/ (per CommonMark §4.5).

Adds a regression test covering the ```text / ```js nesting scenario.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 20:37:47 -04:00

204 lines
6.5 KiB
JavaScript

'use strict';
/**
* Regression test for #2787:
* extractCurrentMilestone truncates ROADMAP.md at heading-like lines inside
* fenced code blocks. The nextMilestonePattern regex runs against the raw
* string with the `m` flag, which matches `^` at every newline — including
* newlines inside ``` blocks. A line like `# Ops runbook (v1.0 compat)` inside
* a fence matches the pattern and prematurely sets sectionEnd, hiding all
* phases defined after the fenced block.
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
describe('extractCurrentMilestone — fenced code block boundary (#2787)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('roadmap analyze returns all phases when a fenced block contains a heading-like line matching the milestone-end pattern', () => {
// ROADMAP.md: milestone v1.1 with 4 phases. Between Phase 2 and Phase 3,
// a fenced code block contains `# Ops runbook — v1.0 compat`, which
// matches ^#{1,2}\s+.*v\d+\.\d+ (the nextMilestonePattern) and would
// prematurely terminate the milestone slice before the fix.
const roadmap = [
'# Project Roadmap',
'',
'## ✅ v1.0: Foundation',
'',
'<details>',
'<summary>✅ v1.0 Foundation — SHIPPED</summary>',
'',
'### Phase 1: Bootstrap',
'**Goal:** Bootstrap the project',
'',
'</details>',
'',
'## Roadmap v1.1: New Work',
'',
'### Phase 1: Setup',
'**Goal:** Set up the environment',
'',
'### Phase 2: Core Logic',
'**Goal:** Implement core logic',
'',
'Deployment notes:',
'',
'```bash',
'# Ops runbook — v1.0 compat',
'echo "deploy complete"',
'```',
'',
'### Phase 3: Testing',
'**Goal:** Write regression tests',
'',
'### Phase 4: Deploy',
'**Goal:** Ship to production',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'---\nmilestone: v1.1\n---\n\n# GSD State\n'
);
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `roadmap analyze should succeed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.phase_count,
4,
[
'All 4 phases in the v1.1 milestone section should be found.',
`Got ${output.phase_count} phase(s): ${JSON.stringify(output.phases?.map(p => p.number))}`,
'Phases 3 and 4 are likely being cut off by the fenced code block heading match.',
].join(' ')
);
});
test('roadmap analyze returns all phases when a fenced block contains a backtick-tilde fence with milestone-like heading', () => {
// Verify tilde fences (~~~) are also tracked correctly.
const roadmap = [
'## Roadmap v2.0: Feature Work',
'',
'### Phase 1: Alpha',
'**Goal:** Alpha release',
'',
'~~~markdown',
'## Prior art (v1.9 snapshot)',
'~~~',
'',
'### Phase 2: Beta',
'**Goal:** Beta release',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'---\nmilestone: v2.0\n---\n\n# GSD State\n'
);
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `roadmap analyze should succeed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.phase_count,
2,
[
'Both phases in the v2.0 milestone section should be found.',
`Got ${output.phase_count} phase(s).`,
'Phase 2 is likely being cut off by the tilde-fenced heading match.',
].join(' ')
);
});
test('fenced block with info string (e.g. ```js) is not closed by a nested info-string line', () => {
// A closing fence MUST have only optional trailing spaces — an info string
// like ```js inside an open fence must NOT close it. Before the fix the
// regex matched any line starting with ``` regardless of what followed, so
// a line like "```js" inside the fenced block would toggle fenceChar off
// and expose the heading-like line that follows to the milestone-end check.
const roadmap = [
'## Roadmap v3.0: Info-String Edge Case',
'',
'### Phase 1: Setup',
'**Goal:** First phase',
'',
'```text',
'```js',
'# This heading-like line (v3.0 compat) must NOT end the milestone',
'```',
'',
'### Phase 2: Core',
'**Goal:** Second phase',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'---\nmilestone: v3.0\n---\n\n# GSD State\n'
);
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `roadmap analyze should succeed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.phase_count,
2,
[
'Both phases should be found; the ```js line inside the fence must not close it.',
`Got ${output.phase_count} phase(s).`,
].join(' ')
);
});
test('roadmap get-phase finds a phase defined after a fenced code block', () => {
const roadmap = [
'## Roadmap v1.1: New Work',
'',
'### Phase 1: Setup',
'**Goal:** Bootstrap',
'',
'```bash',
'# Runbook for v1.0 deploy',
'```',
'',
'### Phase 2: Core',
'**Goal:** Core implementation',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'---\nmilestone: v1.1\n---\n\n# GSD State\n'
);
const result = runGsdTools('roadmap get-phase 2', tmpDir);
assert.ok(result.success, `roadmap get-phase should succeed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.found,
[
'Phase 2 should be found even though it comes after a fenced code block.',
`Got: found=${output.found}`,
].join(' ')
);
assert.strictEqual(output.phase_number, '2', 'should return phase number 2');
});
});