fix(#2660): capture prose after labeled bold in extractOneLinerFromBody (#2679)

* fix(#2660): capture prose after label in extractOneLinerFromBody

The regex `\*\*([^*]+)\*\*` matched the first bold span, so for the new
SUMMARY template `**One-liner:** Real prose here.` it captured the label
`One-liner:` instead of the prose. MILESTONES.md then wrote bullets like
`- One-liner:` with no content.

Handle both template forms:
- Labeled:  `**One-liner:** prose`  → prose
- Bare:     `**prose**`             → prose (legacy)

Empty prose after a label returns null so no bogus bullets are emitted.

Note: existing MILESTONES.md entries generated under the bug are not
regenerated here — that is a follow-up.

Closes #2660

* fix(#2660): normalize CRLF before one-liner extraction

Windows-authored SUMMARY files use CRLF line endings; the LF-only regex
in extractOneLinerFromBody would fail to match. Normalize \r\n and \r
to \n before stripping frontmatter and matching the one-liner pattern.

Adds test case (h) covering CRLF input.
This commit is contained in:
Tom Boucher
2026-04-24 20:22:29 -04:00
committed by GitHub
parent 34b39f0a37
commit c811792967
2 changed files with 103 additions and 4 deletions

View File

@@ -1756,11 +1756,28 @@ function resolveReasoningEffortInternal(cwd, agentType) {
*/
function extractOneLinerFromBody(content) {
if (!content) return null;
// Normalize EOLs so matching works for LF and CRLF files.
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
// Strip frontmatter first
const body = content.replace(/^---\n[\s\S]*?\n---\n*/, '');
// Find the first **...** line after a # heading
const match = body.match(/^#[^\n]*\n+\*\*([^*]+)\*\*/m);
return match ? match[1].trim() : null;
const body = normalized.replace(/^---\n[\s\S]*?\n---\n*/, '');
// Find the first **...** span on a line after a # heading.
// Two supported template forms:
// 1) Labeled: **One-liner:** Real prose here. (bug #2660 — new template)
// 2) Bare: **Real prose here.** (legacy template)
// For (1), the first bold span ends in a colon and the prose that follows
// on the same line is the one-liner. For (2), the bold span itself is the
// one-liner.
const match = body.match(/^#[^\n]*\n+\*\*([^*\n]+)\*\*([^\n]*)/m);
if (!match) return null;
const boldInner = match[1].trim();
const afterBold = match[2];
// Labeled form: bold span is a "Label:" prefix — capture prose after it.
if (/:\s*$/.test(boldInner)) {
const prose = afterBold.trim();
return prose.length > 0 ? prose : null;
}
// Bare form: the bold content itself is the one-liner.
return boldInner.length > 0 ? boldInner : null;
}
// ─── Misc utilities ───────────────────────────────────────────────────────────

View File

@@ -0,0 +1,82 @@
/**
* Bug #2660: `gsd-tools milestone complete <version>` writes MILESTONES.md
* bullets that read "- One-liner:" (the literal label) instead of the prose
* after the label.
*
* Root cause: extractOneLinerFromBody() matches the first **...** span. In
* `**One-liner:** prose`, the first span contains only `One-liner:` so the
* function returns the label instead of the prose after it.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const { extractOneLinerFromBody } = require(
path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'core.cjs')
);
describe('bug #2660: extractOneLinerFromBody', () => {
test('a) body-style **One-liner:** label returns prose after the label', () => {
const content =
'# Phase 2 Plan 01: Foundation Summary\n\n**One-liner:** Real prose here.\n';
assert.strictEqual(extractOneLinerFromBody(content), 'Real prose here.');
});
test('b) frontmatter-only one-liner returns null (caller handles frontmatter)', () => {
const content =
'---\none-liner: Set up project\n---\n\n# Phase 1: Foundation Summary\n\nBody prose with no bold line.\n';
assert.strictEqual(extractOneLinerFromBody(content), null);
});
test('c) no one-liner at all returns null', () => {
const content =
'# Phase 1: Foundation Summary\n\nJust some narrative, no bold line.\n';
assert.strictEqual(extractOneLinerFromBody(content), null);
});
test('d) bold spans inside the prose are preserved', () => {
const content =
'# Phase 1: Foundation Summary\n\n**One-liner:** This is **important** stuff.\n';
assert.strictEqual(
extractOneLinerFromBody(content),
'This is **important** stuff.'
);
});
test('e) empty prose after label returns null (no bogus bullet)', () => {
const empty =
'# Phase 1: Foundation Summary\n\n**One-liner:**\n\nRest of body.\n';
const whitespace =
'# Phase 1: Foundation Summary\n\n**One-liner:** \n\nRest of body.\n';
assert.strictEqual(extractOneLinerFromBody(empty), null);
assert.strictEqual(extractOneLinerFromBody(whitespace), null);
});
test('f) legacy bare **prose** format still works (no label, no colon)', () => {
// Preserve pre-existing behavior: SUMMARY files historically used
// `**bold prose**` with no label. See tests/commands.test.cjs:366 and
// tests/milestone.test.cjs:451 — both assert this form.
const content =
'---\nphase: "01"\n---\n\n# Phase 1: Foundation Summary\n\n**JWT auth with refresh rotation using jose library**\n\n## Performance\n';
assert.strictEqual(
extractOneLinerFromBody(content),
'JWT auth with refresh rotation using jose library'
);
});
test('g) other **Label:** prefixes (e.g. Summary:) also capture prose after label', () => {
const content =
'# Phase 1: Foundation Summary\n\n**Summary:** Built the thing.\n';
assert.strictEqual(extractOneLinerFromBody(content), 'Built the thing.');
});
test('h) CRLF line endings (Windows) are handled', () => {
const content =
'---\r\nphase: "01"\r\n---\r\n\r\n# Phase 1: Foundation Summary\r\n\r\n**One-liner:** Windows-authored prose.\r\n';
assert.strictEqual(
extractOneLinerFromBody(content),
'Windows-authored prose.'
);
});
});