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>
This commit is contained in:
Tom Boucher
2026-04-28 20:37:47 -04:00
committed by GitHub
parent 9e58c45ea1
commit eeaf9c556f
3 changed files with 230 additions and 8 deletions

View File

@@ -31,6 +31,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
overrides a root value. (#2714)
### Fixed
- **`extractCurrentMilestone` no longer truncates ROADMAP.md at heading-like lines inside fenced code blocks** — the milestone-end search now scans line-by-line while tracking ` ``` ` / `~~~` fence state, so a line like `# Ops runbook (v1.0 compat)` inside a code block no longer acts as a milestone boundary. Previously, any phase defined after such a block was invisible to `roadmap analyze`, `roadmap get-phase`, `/gsd-autonomous`, and all phase-number commands. (#2787)
- **Codex install no longer corrupts existing `~/.codex/config.toml`** — the installer
now defensively strips legacy `[agents]` (single-bracket) and `[[agents]]` (sequence)
blocks regardless of GSD marker presence (both invalid in current Codex schema), emits

View File

@@ -1380,8 +1380,10 @@ function extractCurrentMilestone(content, cwd) {
const sectionStart = sectionMatch.index;
// Find the end: next milestone heading at same or higher level, or EOF
// Find the end: next milestone heading at same or higher level, or EOF.
// Milestone headings look like: ## v2.0, ## Roadmap v2.0, ## ✅ v1.0, etc.
// Scan line-by-line so that heading-like lines inside fenced code blocks
// (``` or ~~~) are not mistaken for milestone boundaries. See #2787.
const headingLevel = sectionMatch[1].match(/^(#{1,3})\s/)[1].length;
const restContent = content.slice(sectionStart + sectionMatch[0].length);
// Exclude phase headings (e.g. "### Phase 12: v1.0 Tech-Debt Closure") from
@@ -1389,15 +1391,31 @@ function extractCurrentMilestone(content, cwd) {
// the title. Phase headings always start with the literal `Phase `. See #2619.
const nextMilestonePattern = new RegExp(
`^#{1,${headingLevel}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`,
'mi'
'i'
);
const nextMatch = restContent.match(nextMilestonePattern);
let sectionEnd;
if (nextMatch) {
sectionEnd = sectionStart + sectionMatch[0].length + nextMatch.index;
} else {
sectionEnd = content.length;
let sectionEnd = content.length;
let fenceChar = null;
let fenceLen = 0;
let charOffset = 0;
for (const line of restContent.split('\n')) {
const fenceMatch = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/);
if (fenceMatch) {
const char = fenceMatch[1][0];
const len = fenceMatch[1].length;
const trailing = fenceMatch[2] || '';
if (!fenceChar) {
fenceChar = char;
fenceLen = len;
} else if (char === fenceChar && len >= fenceLen && /^\s*$/.test(trailing)) {
fenceChar = null;
fenceLen = 0;
}
} else if (!fenceChar && nextMilestonePattern.test(line)) {
sectionEnd = sectionStart + sectionMatch[0].length + charOffset;
break;
}
charOffset += line.length + 1;
}
// Return everything before the current milestone section (non-milestone content

View 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');
});
});