* 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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
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