fix(#3170): anchor milestone one-liner extraction to a summary-shaped heading (#3401)

* test(#3170): extractOneLinerFromBody must anchor to a summary-shaped heading

Regression for #3170: the function matched the first heading's first bold
run, so an incidental first heading (rule list, deviation notes) contributed
its bold text as the milestone accomplishment. Rows 1/2 fail RED on next; rows
3/4 guard Overview recognition and the #2660 Summary-heading form.

* fix(#3170): anchor milestone one-liner extraction to a summary-shaped heading

extractOneLinerFromBody matched the first heading's first bold run regardless
of section, so an incidental first heading (rule list, deviation notes)
contributed its bold text as the milestone accomplishment written into
MILESTONES.md. Iterate headings and extract from the first Summary/Overview/
Accomplishments one with a bold run, falling back to null when none exists.
The #2660 Summary-heading forms and the frontmatter one-liner precedence are
preserved.

* docs(#3170): add changeset

* test(#3170): align extractOneLinerFromBody unit fixtures with summary-heading contract

The core-utils unit fixtures used generic # Title headings encoding the old
'any first heading' contract; the #3170 fix anchors to a Summary/Overview/
Accomplishments heading (the function is summary-specific). Update the
heading text to Summary-shaped; the extraction assertions (bold, frontmatter
strip, colon-label, CRLF, unicode) are unchanged.

* docs(#3170): backfill changeset PR number (3401)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-12 22:21:53 -04:00
committed by GitHub
parent b8cb031ce2
commit 2283168612
4 changed files with 89 additions and 14 deletions

View File

@@ -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)

View File

@@ -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 ───────────────────────────────────────────────────────────

View File

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

View File

@@ -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)
// ────────────────────────────────────────────────────────────────────────
{