fix(#3457): split deferred items on leaf headings, not bullets (#3488)

* fix(#3457): split deferred items on leaf headings, not bullets

* fix(#3457): backfill changeset pr with 3488

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-14 12:31:03 -04:00
committed by GitHub
parent 8bead8b0ff
commit 08940c9071
3 changed files with 347 additions and 3 deletions

View File

@@ -890,6 +890,13 @@ function parseGapsTableItems(sectionBody: string): UatItem[] {
* `.planning/todos/pending/*.md` entry required). Every other entry —
* including one with no `status:` field at all — is UNRESOLVED and is
* surfaced.
*
* #3457: when the section body contains headings, entries are delimited by
* LEAF headings (see `splitDeferredHeadingEntries`) rather than by bullets —
* the executor convention writes one deferred item as a heading followed by
* sibling `- **Field:** …` bullets, which the bullet-only split mis-counted as
* one item PER BULLET. A body with no headings keeps the original
* one-bullet-per-item split unchanged.
*/
function parseDeferredItems(content: string): UatItem[] {
const deferredSection = collectSection(
@@ -901,8 +908,23 @@ function parseDeferredItems(content: string): UatItem[] {
const items: UatItem[] = [];
for (const entryLines of splitGapsEntries(sectionBody)) {
const fields = extractGapEntryFields(entryLines);
// #3457: heading-delimited shape — an entry's fields live in sibling bullets
// (`- **Status:** resolved`), so the bullet marker is stripped on EVERY line
// before field extraction, not just line 0 (which `extractGapEntryFields`
// does for the headless/Gaps shape, where a later `- ` line is a nested
// sub-list, not a field).
const headingEntries = splitDeferredHeadingEntries(sectionBody);
const entries = headingEntries !== null
? headingEntries.map((entryLines) => ({
lines: entryLines,
fields: extractGapEntryFields(entryLines.map(stripLeadingBulletMarker)),
}))
: splitGapsEntries(sectionBody).map((entryLines) => ({
lines: entryLines,
fields: extractGapEntryFields(entryLines),
}));
for (const { lines: entryLines, fields } of entries) {
const rawStatus = fields.status;
if (rawStatus && rawStatus.toLowerCase() === 'resolved') continue;
@@ -924,6 +946,111 @@ function parseDeferredItems(content: string): UatItem[] {
return items;
}
/**
* Strip one leading `- ` bullet marker (#3457). Heading-delimited deferred
* entries carry their fields as sibling bullets; `extractGapEntryFields` only
* de-bullets line 0 (Gaps-protective — there, a later `- ` line is a nested
* sub-list), so the deferred heading path de-bullets every line itself before
* field extraction. Non-bullet lines pass through untouched.
*/
function stripLeadingBulletMarker(line: string): string {
return line.replace(/^(\s*)-\s+/, '');
}
/**
* Split a deferred-items section body into entries delimited by LEAF headings
* (#3457). Returns `null` when the body contains no heading at all — the
* caller then falls back to `splitGapsEntries`, keeping headless
* one-bullet-per-item files byte-for-byte on the pre-#3457 path.
*
* A heading is a CONTAINER (group/provenance/title label, contributes no
* entry) iff the NEXT heading is deeper — a deeper heading lives inside its
* span. Otherwise it is a LEAF: an entry boundary. This handles all three
* corpus shapes without hardcoding a depth: flat `#` title + `##` entries
* (title's next heading is deeper → container; each `##` followed by a
* same-or-shallower heading → leaf), a `##` container with `###` entries
* (container's next heading is deeper), and mixed-depth files where a
* childless `##` entry sits alongside a `##` group with `###` children — every
* childless heading is a leaf at whatever depth it is written. The shallower
* rules the issue reports as already tried (split on every heading; shallowest
* level; deepest level) each mis-count one of these shapes.
*
* A leaf entry is [heading text, ...body lines up to the next heading] and is
* kept only when its body (minus table lines) contains at least one `- `
* bullet:
* - a prose-only or bare heading contributes nothing — "prose is not an item"
* is this parser's pre-existing contract (see the `# Notes` case);
* - a table-only body is left entirely to `parseDeferredTableItems`, which
* unions over the same section body, so the heading cannot double-count the
* table's rows.
*
* Lines before the first heading, and lines directly under a container heading
* (before its first child), are split one-bullet-per-item by the unchanged
* `splitGapsEntries` — headless parity, so loose bullets before a later
* heading group (the mixed shape) stay one item each.
*/
function splitDeferredHeadingEntries(sectionBody: string): string[][] | null {
const headings = tokenizeHeadings(sectionBody);
if (headings.length === 0) return null;
const lines = sectionBody.split('\n');
const headingByLine = new Map<number, { text: string; isContainer: boolean }>();
for (let i = 0; i < headings.length; i++) {
// Container iff the next heading is deeper (see doc comment). An empty
// heading text (`##` alone) does not itself mean container — the flag is
// carried explicitly so a bare LEAF heading still opens an entry.
const isContainer = i + 1 < headings.length && headings[i + 1].level > headings[i].level;
headingByLine.set(headings[i].line, { text: headings[i].text, isContainer });
}
const entries: string[][] = [];
let current: string[] | null = null; // accumulating a leaf heading's entry
let pending: string[] = []; // preamble / container-heading body lines
let currentHasBullet = false;
const flushCurrent = (): void => {
// Keep the leaf entry only when its body carries a bullet; the heading
// text line itself (element 0) never counts as one.
if (current !== null && currentHasBullet) entries.push(current);
current = null;
currentHasBullet = false;
};
const flushPending = (): void => {
entries.push(...splitGapsEntries(pending.join('\n')));
pending = [];
};
for (let i = 0; i < lines.length; i++) {
const lineNo = i + 1;
const heading = headingByLine.get(lineNo);
if (heading !== undefined) {
flushCurrent();
// Headless-shaped region (preamble / container-direct bullets) ends at
// ANY heading; flushing here keeps entries in document order even when
// a container's direct bullets precede its first child entry.
flushPending();
if (!heading.isContainer) {
// Leaf heading: open an entry with the heading text as line 0.
current = [heading.text];
currentHasBullet = false;
}
continue;
}
// Table lines belong to parseDeferredTableItems, never to a heading entry.
if (/^\s*\|/.test(lines[i].replace(/\r$/, ''))) continue;
if (current !== null) {
current.push(lines[i]);
if (/^\s*-\s/.test(lines[i].replace(/\r$/, ''))) currentHasBullet = true;
} else {
pending.push(lines[i]);
}
}
flushCurrent();
flushPending();
return entries;
}
/**
* Extract deferred entries from GFM pipe tables in a deferred-items.md body
* (#2766) — a UNION with the bullet scan in `parseDeferredItems`.
@@ -1021,10 +1148,22 @@ function splitGapsEntries(sectionBody: string): string[][] {
* any nested sub-list content in the template's field ordering); later
* `key:`-shaped nested-list content is captured, if it parses as one, but
* never overrides an already-seen top-level field.
*
* #3457: markdown emphasis around the KEY (`**Status:** resolved` — the
* deferred-items convention bolds every field, and a bolded resolution marker
* previously failed this regex outright and surfaced as its own bogus
* unresolved entry) is unwrapped before the match, still anchored at the
* start of the line. The unwrapped key is lower-cased, because the bolded
* convention form is Title-cased (`**Status:**`) while the field vocabulary
* this module reads is lowercase (`status`) — the same normalization
* `mapGapsHeader` already applies to table header cells. Bare (unbolded) keys
* keep their literal case, and mid-line emphasis is untouched, preserving the
* start-anchored decoy invariant above.
*/
function extractGapEntryFields(entryLines: string[]): Record<string, string> {
const fields: Record<string, string> = {};
const fieldLineRe = /^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/;
const boldedKeyRe = /^\*+([A-Za-z_][A-Za-z0-9_-]*):\*+/;
entryLines.forEach((rawLine, idx) => {
const line = rawLine.replace(/\r$/, '');
@@ -1033,7 +1172,8 @@ function extractGapEntryFields(entryLines: string[]): Record<string, string> {
// `splitGapsEntries` already folding it in — it is not itself a field
// line unless it independently matches `key: value` after stripping.
const bulletStripped = line.match(/^(\s*)-\s+(.*)$/);
const content = idx === 0 && bulletStripped ? bulletStripped[2] : line.trim();
const content = (idx === 0 && bulletStripped ? bulletStripped[2] : line.trim())
.replace(boldedKeyRe, (_m, key: string) => `${key.toLowerCase()}:`);
const m = fieldLineRe.exec(content);
if (!m) return;