diff --git a/CHANGELOG.md b/CHANGELOG.md index 309b15476..fdf7eda81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index fa637294e..808208e09 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -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 diff --git a/tests/bug-2787-milestone-fenced-block-truncation.test.cjs b/tests/bug-2787-milestone-fenced-block-truncation.test.cjs new file mode 100644 index 000000000..a9849ba3b --- /dev/null +++ b/tests/bug-2787-milestone-fenced-block-truncation.test.cjs @@ -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', + '', + '
', + '✅ v1.0 Foundation — SHIPPED', + '', + '### Phase 1: Bootstrap', + '**Goal:** Bootstrap the project', + '', + '
', + '', + '## 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'); + }); +});