diff --git a/.changeset/silly-jaguars-caper.md b/.changeset/silly-jaguars-caper.md new file mode 100644 index 000000000..41e0bde92 --- /dev/null +++ b/.changeset/silly-jaguars-caper.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3401 +--- +**`milestone.complete` no longer records the wrong line as a release's accomplishment** — the one-liner was extracted from the first bold text under the SUMMARY's first heading, so an incidental first heading (a rule list, deviation notes) could contribute `Rule 1 - Bug` or `NeutralPath` as the milestone's permanent accomplishment in MILESTONES.md. Extraction now anchors to a Summary/Overview/Accomplishments heading and falls back to empty when none is present. (#3170) diff --git a/src/core-utils.cts b/src/core-utils.cts index 5f142ffc4..105cf9ea6 100644 --- a/src/core-utils.cts +++ b/src/core-utils.cts @@ -70,15 +70,26 @@ function extractOneLinerFromBody(content: string | null | undefined): string | n if (!content) return null; const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const body = normalized.replace(/^---\n[\s\S]*?\n---\n*/, ''); - const match = body.match(/^#[^\n]*\n+\*\*([^*\n]+)\*\*([^\n]*)/m); - if (!match) return null; - const boldInner = match[1].trim(); - const afterBold = match[2]; - if (/:\s*$/.test(boldInner)) { - const prose = afterBold.trim(); - return prose.length > 0 ? prose : null; + // #3170: anchor to a summary-shaped heading (Summary / Overview / + // Accomplishments) so an incidental first heading (a rule list, task + // breakdown, deviation note) does not contribute its first bold run as the + // deliverable one-liner. Iterate headings in document order and extract from + // the first summary-shaped one that has a bold run; fall back to null (not the + // wrong text) when no such heading exists. + const headingRe = /^#+\s*([^\n]*)\n+\*\*([^*\n]+)\*\*([^\n]*)/gm; + let match: RegExpExecArray | null; + while ((match = headingRe.exec(body)) !== null) { + if (!/summary|overview|accomplish/i.test(match[1])) continue; + const boldInner = match[2].trim(); + const afterBold = match[3]; + if (/:\s*$/.test(boldInner)) { + const prose = afterBold.trim(); + if (prose.length > 0) return prose; + } else if (boldInner.length > 0) { + return boldInner; + } } - return boldInner.length > 0 ? boldInner : null; + return null; } // ─── Misc utilities ─────────────────────────────────────────────────────────── diff --git a/tests/core-utils.test.cjs b/tests/core-utils.test.cjs index 67075d410..499f742a6 100644 --- a/tests/core-utils.test.cjs +++ b/tests/core-utils.test.cjs @@ -502,33 +502,38 @@ describe('extractOneLinerFromBody', () => { assert.strictEqual(coreUtils.extractOneLinerFromBody(''), null); }); + // #3170: extractOneLinerFromBody anchors to a Summary/Overview/Accomplishments + // heading (the function is summary-specific — both callers extract a SUMMARY + // deliverable one-liner). These fixtures use Summary-shaped headings; the + // extraction mechanism under test (heading + bold → bold, frontmatter strip, + // colon-label → prose, CRLF, unicode) is unchanged. test('extracts bold text after a heading as one-liner', () => { - const content = '# Phase Title\n\n**Implement the feature**\n\nMore details here.\n'; + const content = '# Phase Summary\n\n**Implement the feature**\n\nMore details here.\n'; assert.strictEqual(coreUtils.extractOneLinerFromBody(content), 'Implement the feature'); }); test('returns null when no bold text after heading', () => { - const content = '# Phase Title\n\nSome prose without bold.\n'; + const content = '# Phase Summary\n\nSome prose without bold.\n'; assert.strictEqual(coreUtils.extractOneLinerFromBody(content), null); }); test('strips frontmatter before searching', () => { - const content = '---\nstatus: done\n---\n# Title\n\n**One liner here**\n'; + const content = '---\nstatus: done\n---\n# Summary\n\n**One liner here**\n'; assert.strictEqual(coreUtils.extractOneLinerFromBody(content), 'One liner here'); }); test('when bold ends with colon, returns text after the bold', () => { - const content = '# Title\n\n**Objective:** Complete the work\n'; + const content = '# Summary\n\n**Objective:** Complete the work\n'; assert.strictEqual(coreUtils.extractOneLinerFromBody(content), 'Complete the work'); }); test('CRLF line endings are normalized', () => { - const content = '# Title\r\n\r\n**Bold line**\r\nmore\r\n'; + const content = '# Summary\r\n\r\n**Bold line**\r\nmore\r\n'; assert.strictEqual(coreUtils.extractOneLinerFromBody(content), 'Bold line'); }); test('adversarial: unicode in bold text', () => { - const content = '# Title\n\n**中文 one-liner**\n\nMore.\n'; + const content = '# Summary\n\n**中文 one-liner**\n\nMore.\n'; assert.strictEqual(coreUtils.extractOneLinerFromBody(content), '中文 one-liner'); }); }); diff --git a/tests/milestone.test.cjs b/tests/milestone.test.cjs index bdefd314b..7ee976af3 100644 --- a/tests/milestone.test.cjs +++ b/tests/milestone.test.cjs @@ -2106,6 +2106,60 @@ describe('bug #2660: extractOneLinerFromBody', () => { } // ──────────────────────────────────────────────────────────────────────── +describe('#3170: extractOneLinerFromBody anchors to a summary-shaped heading', () => { + // Self-contained require (the #2660 block's require is fold-scoped). + const path = require('path'); + const { extractOneLinerFromBody } = require( + path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'core-utils.cjs') + ); + + test('row 1 — an incidental first heading does not contribute its bold run; the Summary heading does', () => { + const content = [ + '# Rules', + '', + '**Rule 1 - Bug** Task 2 spawned a real agent CLI process on first attempt.', + '', + '## Summary', + '', + '**Shipped unattended dogfooding resume.** Real deliverable description.', + '', + ].join('\n'); + assert.strictEqual( + extractOneLinerFromBody(content), + 'Shipped unattended dogfooding resume.', + `must anchor to the Summary heading, not the incidental # Rules one` + ); + }); + + test('row 2 — no summary-shaped heading at all returns null (not the wrong text)', () => { + const content = [ + '# Deviation Notes', + '', + '**NeutralPath** is the production fix.', + '', + '## Follow-ups', + '', + '**The production fix is one expression.** Not a deliverable summary.', + '', + ].join('\n'); + assert.strictEqual( + extractOneLinerFromBody(content), + null, + `no Summary/Overview/Accomplishments heading → null, not incidental bold text` + ); + }); + + test('row 3 — an Overview heading is recognized', () => { + const content = '# Overview\n\n**Shipped the thing.** Details follow.\n'; + assert.strictEqual(extractOneLinerFromBody(content), 'Shipped the thing.'); + }); + + test('row 4 — #2660 form (heading contains "Summary") is unchanged', () => { + const content = '# Phase 1: Foundation Summary\n\n**One-liner:** Real prose here.\n'; + assert.strictEqual(extractOneLinerFromBody(content), 'Real prose here.'); + }); +}); + // Folded from tests/enh-72-business-context.test.cjs — consolidation epic #1969 (B8 #1977) // ──────────────────────────────────────────────────────────────────────── {