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

@@ -1974,6 +1974,205 @@ describe('#2766 parseDeferredItems: GFM table shape', () => {
});
});
// ─── #3457: heading-delimited deferred entries ────────────────────────────────
describe('#3457 parseDeferredItems: heading-delimited entries', () => {
const items = (md) => parseDeferredItems(md);
const names = (md) => items(md).map(i => i.name);
test('issue minimal repro: heading + sibling field bullets = ONE item', () => {
const got = items([
'# Deferred Items',
'',
'## Deferred Items',
'',
'### Widget layout suite — 3 failing assertions',
'',
'- **What:** three assertions fail on widget alignment.',
'- **Cause:** a pre-existing uncommitted edit in the working tree.',
'- **Scope:** out of this plan\'s scope.',
'- **Disposition:** NOT fixed here; left for a follow-up plan.',
].join('\n'));
assert.strictEqual(got.length, 1, JSON.stringify(got.map(i => i.name)));
assert.match(got[0].name, /Widget layout suite — 3 failing assertions/);
assert.match(got[0].name, /three assertions fail/);
assert.strictEqual(got[0].result, 'unresolved');
assert.strictEqual(got[0].category, 'deferred');
});
test('flat shape: `#` title + `##` entries — title is not an item', () => {
const got = names([
'# Deferred Items',
'',
'## DEF-01 renderer fix',
'',
'- **What:** a.',
'',
'## DEF-02 seed drift',
'',
'- **What:** b.',
].join('\n'));
assert.strictEqual(got.length, 2, JSON.stringify(got));
assert.match(got[0], /^DEF-01 renderer fix/);
assert.match(got[1], /^DEF-02 seed drift/);
});
test('container shape: `##` group label + `###` entries — group is not an item, entries not collapsed', () => {
// The shape both shallow-boundary rules get wrong: "count all headings"
// counts the group; "shallowest level" collapses both entries into one.
const got = names([
'# Deferred Items',
'',
'## Plan 28-02 provenance',
'',
'### Entry A — flaky seed',
'',
'- **What:** a.',
'',
'### Entry B — slow build',
'',
'- **What:** b.',
].join('\n'));
assert.strictEqual(got.length, 2, JSON.stringify(got));
assert.match(got[0], /^Entry A — flaky seed/);
assert.match(got[1], /^Entry B — slow build/);
// A following entry's heading must not be swallowed into the previous
// entry's name (the pre-fix bullet-split folded it in).
assert.ok(!got[0].includes('Entry B'), got[0]);
});
test('mixed shape: loose preamble bullets before a later heading group stay one-per-bullet', () => {
const got = names([
'# Deferred Items',
'',
'- loose preamble item one',
'- loose preamble item two',
'',
'## Group under here',
'',
'### Entry C',
'- **What:** c.',
].join('\n'));
assert.deepStrictEqual(
got.map(n => n.replace(/\s+- \*\*What:\*\*.*$/, '')),
['loose preamble item one', 'loose preamble item two', 'Entry C'],
JSON.stringify(got),
);
});
test('mixed depths: childless `##` entry alongside a `##` group with `###` children — all counted', () => {
// The case "deepest heading level present" rules miss: the childless ##
// is shallower than the deepest level in the file but is still an entry.
const got = names([
'# Deferred Items',
'',
'## Group with children',
'',
'### Entry A',
'- **What:** a.',
'',
'### Entry B',
'- **What:** b.',
'',
'## Standalone entry',
'',
'- **What:** standalone.',
].join('\n'));
assert.strictEqual(got.length, 3, JSON.stringify(got));
assert.ok(got.some(n => /^Standalone entry/.test(n)), JSON.stringify(got));
});
test('no headings at all → one-bullet-per-item, unchanged names (no regression)', () => {
assert.deepStrictEqual(
names('## Deferred Items\n\n- entry one\n- entry two\n'),
['entry one', 'entry two'],
);
});
test('bolded `- **Status:** resolved` under a leaf heading resolves the entry', () => {
const got = names([
'## Deferred Items',
'',
'### Item resolved inline',
'',
'- **What:** x.',
'- **Status:** resolved',
].join('\n'));
assert.deepStrictEqual(got, [], JSON.stringify(got));
});
test('bolded `- **Status:** resolved` with no headings: resolves itself, never surfaces as its own item', () => {
// The issue's negative control: previously count = 2 with a literal
// `**Status:** resolved` pseudo-entry; must match the bare form's count = 1.
const got = names('## Deferred Items\n\n- **What:** one deferred item.\n- **Status:** resolved\n');
assert.strictEqual(got.length, 1, JSON.stringify(got));
assert.match(got[0], /\*\*What:\*\* one deferred item\./);
assert.ok(!got.some(n => /Status/.test(n)), JSON.stringify(got));
});
test('bare `status: resolved` controls keep working (no regression on #2287)', () => {
// Headless continuation form.
assert.strictEqual(names('## Deferred Items\n\n- a\n status: resolved\n- b\n').length, 1);
// Bare status as a sibling bullet under a leaf heading.
assert.strictEqual(names([
'## Deferred Items',
'',
'### Item resolved bare',
'',
'- **What:** x.',
' status: resolved',
].join('\n')).length, 0);
});
test('leaf heading over a table-only body → table rows only, no double-count', () => {
// parseDeferredTableItems owns the rows; the heading must not add an item.
const got = names([
'## Discovered during 01-03',
'',
'| Test | Failing seeds |',
'|------|---------------|',
'| test_a | 0, 1 |',
].join('\n'));
assert.deepStrictEqual(got, ['test_a — 0, 1'], JSON.stringify(got));
});
test('prose-only or bare headings contribute no items', () => {
// "Prose is not an item" is this parser's pre-existing contract (#2766
// `# Notes` case) — heading mode must not start counting prose sections.
assert.deepStrictEqual(names('## Deferred Items\n\n### Musings\n\njust prose here.\n'), []);
assert.deepStrictEqual(names('## Deferred Items\n\n### A bare heading with no body\n'), []);
});
test('CRLF files: heading entries still split and resolve', () => {
const got = names('## Deferred Items\r\n\r\n### Entry\r\n\r\n- **What:** x.\r\n- **Status:** resolved\r\n');
assert.deepStrictEqual(got, [], JSON.stringify(got));
});
test('mid-line `status: resolved` decoy under a heading must not resolve the entry', () => {
// The #2287 decoy invariant, ported to the heading shape: a status-shaped
// phrase inside entry prose is never a field.
const got = items([
'## Deferred Items',
'',
'### Entry with decoy prose',
'',
'- note: saw a status: resolved message in the log',
].join('\n'));
assert.strictEqual(got.length, 1, JSON.stringify(got.map(i => i.name)));
assert.strictEqual(got[0].result, 'unresolved');
});
});
// ─── Bug 3: table-shaped ## Gaps section ──────────────────────────────────────
describe('#2766 parseGapsItems: GFM table shape', () => {