* 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>
This commit is contained in:
203
tests/bug-2787-milestone-fenced-block-truncation.test.cjs
Normal file
203
tests/bug-2787-milestone-fenced-block-truncation.test.cjs
Normal file
@@ -0,0 +1,203 @@
|
||||
'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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user