diff --git a/.changeset/jolly-bears-sprint.md b/.changeset/jolly-bears-sprint.md new file mode 100644 index 000000000..c23329e66 --- /dev/null +++ b/.changeset/jolly-bears-sprint.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3998 +--- +**`audit-open acknowledge` works on heading-shaped deferred-items.md** — the CLI writer previously refused every entry in any file using the heading-delimited (#3457) convention (a real project saw 0 of 107 items acknowledgeable); leaf headings and interleaved headless bullets now acknowledge through the same span-anchored, span-verified write the bullet shape uses, with a human `Status: resolved` never downgraded. Only entries embedding a GFM table row still refuse. (#3781) diff --git a/src/audit.cts b/src/audit.cts index 4c49a8e26..5fe6de592 100644 --- a/src/audit.cts +++ b/src/audit.cts @@ -1657,7 +1657,10 @@ function cmdAuditAcknowledge(cwd: string, args: string[], raw: boolean): void { if (result.status === 'ambiguous') ioError(`--text "${text as string}" matches more than one deferred item — text must be unique`); if (result.status === 'already_resolved') ioError(`deferred item is already "status: resolved" — acknowledging a resolved item is a no-op`); if (result.status === 'unsupported_heading_shape') { - ioError('this deferred-items.md uses the heading-delimited (#3457) entry shape, which the CLI writer does not yet support — edit the file directly'); + // #3781: heading-shaped entries are supported; the remaining refusal + // cause is a GFM table row embedded in the entry's span (non-contiguous + // — a write cannot be anchored safely). + ioError('this deferred item\'s span embeds a GFM table row, so the CLI writer cannot anchor a safe write to it — edit the file directly'); } if (result.status === 'match_verification_failed') { ioError(`internal error: matched span for --text "${text as string}" did not re-verify before write — refused rather than risk writing the wrong entry`); diff --git a/src/uat.cts b/src/uat.cts index a89c5fac1..d40db7b19 100644 --- a/src/uat.cts +++ b/src/uat.cts @@ -1948,14 +1948,25 @@ interface AcknowledgeDeferredItemResult { * `status:` away from `acknowledged` (or delete the field) and it resurfaces * with no separate cleanup step, exactly like every other category's marker. * - * Deliberately refuses (`unsupported_heading_shape`) rather than guess when - * the section uses the heading-delimited (#3457) entry shape: reliably - * mapping a `splitDeferredHeadingEntries` entry back to its EXACT source line - * span is not safely derivable without re-deriving that function's - * leaf/container walk against a document that may also mix in headless - * (`splitGapsEntries`-derived) entries between headings — attempting it risks - * writing into the WRONG entry. The bullet-only (headless) shape below is the - * primary, documented SCOPE BOUNDARY convention and is handled precisely. + * #3781: the heading-delimited (#3457) entry shape is SUPPORTED, via + * `splitDeferredHeadingEntriesWithSpans` — a span-carrying sibling of the + * reader's walk that records each entry's (start, end) character span in the + * SAME pass that groups its lines (the identical technique + * `splitGapsEntriesWithSpans` uses for the headless shape). Leaf entries keep + * their RAW heading line as `lines[0]` so `sectionBody.slice(start, end)` is + * byte-verbatim; pending (preamble / container-direct) regions are contiguous + * slices handed to `splitGapsEntriesWithSpans` with a baseOffset translation. + * Two write rules differ from the headless path on this shape: the status + * search runs over the READER-form lines (what the reader actually parses — + * including the leaf line-0 corner where the heading text itself parses as a + * status field, whose raw line is rewritten with its ATX prefix preserved), + * and the insert branch inserts after the entry's LAST NON-BLANK line — a + * heading entry's body is frequently a soft-wrapped sentence, and splicing + * after line 0 would split it in half (#3781's sentence-split trap). + * Entries whose span embeds a GFM table row are non-contiguous (table lines + * are excluded from entries) and still refuse (`unsupported_heading_shape`) + * rather than risk a wrong-entry write; the fully-headless shape below is + * byte-for-byte the pre-#3781 path. * * Also refuses `ambiguous` (2+ entries share the exact same text — status must * be unique to identify one) and `not_found`, and is a no-op @@ -2002,8 +2013,11 @@ function acknowledgeDeferredItem(content: string, targetText: string): Acknowled ); const sectionBody = deferredSection ? deferredSection.body : content; - if (splitDeferredHeadingEntries(sectionBody) !== null) { - return { content, status: 'unsupported_heading_shape' }; + // #3781: the heading-delimited shape carries its own span walk; the + // headless path below is unchanged. + const headingEntries = splitDeferredHeadingEntriesWithSpans(sectionBody); + if (headingEntries !== null) { + return acknowledgeHeadingShapedEntry({ content, sectionBody, deferredSection, headingEntries, targetText }); } const entries = splitGapsEntriesWithSpans(sectionBody); @@ -2105,6 +2119,280 @@ function acknowledgeDeferredItem(content: string, targetText: string): Acknowled return { content: newContent, status: 'ok' }; } +/** + * #3781 — one entry of the heading-delimited deferred shape, carrying the + * exact character span it occupies within the `sectionBody` it was derived + * from. `lines[0]` of a LEAF entry is the RAW heading line (hashes intact) so + * `sectionBody.slice(start, end)` is byte-verbatim; `readerLines` is the + * reader-form of the same lines (ATX-stripped line 0, bullet-stripped body) + * the identity text and field extraction are computed over. `embeddedTable` + * marks entries whose span contains a GFM table line — table lines are + * excluded from entries, so such a span is non-contiguous and its entries + * refuse rather than risk a wrong write. + */ +interface DeferredHeadingEntrySpan { + kind: 'leaf' | 'pending'; + lines: string[]; + readerLines: string[]; + text: string; + fields: Record; + start: number; + end: number; + embeddedTable: boolean; +} + +/** + * #3781 — strip an ATX heading prefix, mirroring `tokenizeHeadings`' own ATX + * regex (≤3 leading spaces, 1–6 `#`, space/tab separator, optional closing + * `#` sequence) so the raw heading line reconciles byte-exactly with the + * hash-stripped `text` the reader exposes. Returns null when the line is not + * an ATX heading line. + */ +function stripAtxPrefix(line: string): string | null { + const m = /^( {0,3})(#{1,6})([ \t]+.*|[ \t]*)?$/.exec(line.replace(/\r$/, '')); + if (!m) return null; + return m[3] === undefined + ? '' + : m[3].replace(/^[ \t]+/, '').replace(/[ \t]+#+[ \t]*$/, '').replace(/^#+[ \t]*$/, '').trim(); +} + +/** + * #3781 — span-carrying sibling of `splitDeferredHeadingEntries`: ONE walk, + * identical grouping rules (leaf = childless heading whose body carries a + * bullet; container = next heading deeper; preamble/container-direct lines → + * headless entries; table lines excluded), additionally recording each + * entry's (start, end) character span within `sectionBody`. Returns null when + * the body contains no heading at all — the caller then takes the unchanged + * fully-headless path. + */ +function splitDeferredHeadingEntriesWithSpans(sectionBody: string): DeferredHeadingEntrySpan[] | null { + const headings = tokenizeHeadings(sectionBody); + if (headings.length === 0) return null; + + const lines = sectionBody.split('\n'); + const lineStarts: number[] = []; + const lineEnds: number[] = []; + let cursor = 0; + for (const rawLine of lines) { + lineStarts.push(cursor); + cursor += rawLine.length; + lineEnds.push(cursor); + cursor += 1; + } + + const headingByLine = new Map(); + for (let i = 0; i < headings.length; i++) { + const isContainer = i + 1 < headings.length && headings[i + 1].level > headings[i].level; + headingByLine.set(headings[i].line, { text: headings[i].text, isContainer }); + } + + const isTableLine = (l: string): boolean => /^\s*\|/.test(l.replace(/\r$/, '')); + const isBulletLine = (l: string): boolean => /^\s*-\s/.test(l.replace(/\r$/, '')); + + const entries: DeferredHeadingEntrySpan[] = []; + let current: string[] | null = null; + let currentReaderLine0: string | null = null; + let currentStartLine = -1; + let currentEndLine = -1; + let currentHasBullet = false; + let currentTable = false; + let pendingStartLine = -1; + let pendingEndLine = -1; + + const flushCurrent = (): void => { + if (current !== null && currentHasBullet && currentReaderLine0 !== null) { + const bodyReader = current.slice(1).map(stripLeadingBulletMarker); + entries.push({ + kind: 'leaf', + lines: current, + readerLines: [currentReaderLine0, ...bodyReader], + text: rawGapEntryText([currentReaderLine0, ...current.slice(1)]), + fields: extractGapEntryFields([currentReaderLine0, ...bodyReader]), + start: lineStarts[currentStartLine], + end: lineEnds[currentEndLine], + embeddedTable: currentTable, + }); + } + current = null; + currentReaderLine0 = null; + currentStartLine = -1; + currentEndLine = -1; + currentHasBullet = false; + currentTable = false; + }; + const flushPending = (): void => { + if (pendingStartLine === -1) return; + // The pending region is contiguous (a heading flushes it), but table lines + // inside it were skipped by the walk: the reader's identity for this + // region is computed over the table-FILTERED join, which may merge + // entries across the gap, so spans cannot be translated faithfully — + // mark the region's entries as refusing instead. + let regionTable = false; + for (let i = pendingStartLine; i <= pendingEndLine; i++) { + if (isTableLine(lines[i])) regionTable = true; + } + const base = lineStarts[pendingStartLine]; + const regionText = sectionBody.slice(lineStarts[pendingStartLine], lineEnds[pendingEndLine]); + for (const e of splitGapsEntriesWithSpans(regionText)) { + entries.push({ + kind: 'pending', + lines: e.lines, + readerLines: e.lines, + text: rawGapEntryText(e.lines), + fields: extractGapEntryFields(e.lines), + start: base + e.start, + end: base + e.end, + embeddedTable: regionTable, + }); + } + pendingStartLine = -1; + pendingEndLine = -1; + }; + + for (let i = 0; i < lines.length; i++) { + const heading = headingByLine.get(i + 1); + if (heading !== undefined) { + flushCurrent(); + flushPending(); + if (!heading.isContainer) { + current = [lines[i]]; + currentReaderLine0 = heading.text; + currentStartLine = i; + currentEndLine = i; + currentHasBullet = false; + currentTable = false; + } + continue; + } + if (isTableLine(lines[i])) { + if (current !== null) currentTable = true; + continue; + } + if (current !== null) { + current.push(lines[i]); + currentEndLine = i; + if (isBulletLine(lines[i])) currentHasBullet = true; + } else { + if (pendingStartLine === -1) pendingStartLine = i; + pendingEndLine = i; + } + } + flushCurrent(); + flushPending(); + + return entries; +} + +/** + * #3781 — the heading-shaped half of `acknowledgeDeferredItem`, sharing the + * headless path's guards (not_found / ambiguous / already_resolved / + * match_verification_failed) and its rewrite/insert machinery, with the two + * shape-specific rules documented on `acknowledgeDeferredItem` (reader-form + * status search incl. the leaf line-0 ATX corner; insert after the entry's + * last non-blank line). Extracted so the headless path stays byte-identical. + */ +function acknowledgeHeadingShapedEntry({ content, sectionBody, deferredSection, headingEntries, targetText }: { + content: string; + sectionBody: string; + deferredSection: { body: string; bodyStart: number } | null; + headingEntries: DeferredHeadingEntrySpan[]; + targetText: string; +}): AcknowledgeDeferredItemResult { + const matches = headingEntries.filter((e) => e.text === targetText); + if (matches.length === 0) return { content, status: 'not_found' }; + if (matches.length > 1) return { content, status: 'ambiguous' }; + const entry = matches[0]; + if (entry.embeddedTable) return { content, status: 'unsupported_heading_shape' }; + if (entry.fields.status && entry.fields.status.toLowerCase() === 'resolved') { + return { content, status: 'already_resolved' }; + } + + const sectionOffset = deferredSection ? deferredSection.bodyStart : 0; + const rawSlice = sectionBody.slice(entry.start, entry.end); + const rawSliceLines = rawSlice.split('\n'); + + // Genuine invariant re-verification: re-derive the entry's identity from + // the span's own bytes and compare against the targetText that selected it + // — the span was recorded by an offset bookkeeping independent of the + // identity comparison above. + const verifyText = entry.kind === 'leaf' + ? (() => { + const stripped = stripAtxPrefix(rawSliceLines[0]); + return stripped === null ? null : rawGapEntryText([stripped, ...rawSliceLines.slice(1)]); + })() + : rawGapEntryText(rawSliceLines.map((l) => l.replace(/\r$/, ''))); + if (verifyText !== targetText) { + return { content, status: 'match_verification_failed' }; + } + + // Status search over the READER-form lines — the exact set the reader + // parses (bolded any case + bare lowercase, per #3775; line-0 forms per + // #3740). Reader lines are index-aligned 1:1 with the raw lines. + const statusFieldBoldedRe = /^\s*\*+status:\*+/i; + const statusFieldBareRe = /^\s*status:/; + const statusFieldBoldedReLine0 = /^\s*(?:-\s+)?\*+status:\*+/i; + const statusFieldBareReLine0 = /^\s*(?:-\s+)?status:/; + const readerLines = entry.kind === 'leaf' + ? [ + stripAtxPrefix(rawSliceLines[0]) ?? rawSliceLines[0].replace(/\r$/, ''), + ...rawSliceLines.slice(1).map((l) => stripLeadingBulletMarker(l.replace(/\r$/, ''))), + ] + : rawSliceLines.map((l) => l.replace(/\r$/, '')); + const statusLineIdx = readerLines.findIndex((line, idx) => + idx === 0 + ? (statusFieldBoldedReLine0.test(line) || statusFieldBareReLine0.test(line)) + : (statusFieldBoldedRe.test(line) || statusFieldBareRe.test(line))); + + let newRawLines: string[]; + if (statusLineIdx === -1) { + // Insert branch: after the entry's LAST NON-BLANK line — a heading + // entry's body is frequently a soft-wrapped sentence, and splicing after + // line 0 would split it in half (#3781's sentence trap). The headless + // (no-heading-anywhere) path keeps its own splice-after-line-0 shape. + let lastNonBlank = rawSliceLines.length - 1; + while (lastNonBlank > 0 && rawSliceLines[lastNonBlank].replace(/\r$/, '').trim() === '') { + lastNonBlank--; + } + const indent = entry.kind === 'pending' + ? (() => { + const bulletIndentMatch = rawSliceLines[0].match(/^(\s*)-\s+/); + return ' '.repeat((bulletIndentMatch ? bulletIndentMatch[1].length : 0) + 2); + })() + : ' '; + newRawLines = [ + ...rawSliceLines.slice(0, lastNonBlank + 1), + `${indent}status: acknowledged`, + ...rawSliceLines.slice(lastNonBlank + 1), + ]; + } else { + newRawLines = rawSliceLines.slice(); + if (entry.kind === 'leaf' && statusLineIdx === 0) { + // Leaf line 0 is the RAW heading line — rewrite the heading-text portion + // the reader treats as a field, with the ATX prefix preserved. + const replacedReader = readerLines[statusLineIdx].replace( + /^(\s*(?:-\s+)?)(\*+status:\*+|status:)(\s*).*$/i, + (_m, indent: string, key: string, ws: string) => `${indent}${key}${ws}acknowledged`, + ); + const atxMatch = /^(\s*#+[ \t]*)(.*)$/.exec(rawSliceLines[0].replace(/\r$/, '')); + newRawLines[0] = atxMatch ? atxMatch[1] + replacedReader : replacedReader; + } else { + // Every other status line is rewritten on its RAW line, so the bullet + // marker and indent survive the write (the reader-form line has the + // marker stripped — writing it back would mangle the markdown shape and + // change the entry's identity text). Same replacement regex as the + // headless path. + newRawLines[statusLineIdx] = rawSliceLines[statusLineIdx].replace( + /^(\s*(?:-\s+)?)(\*+status:\*+|status:)(\s*).*$/i, + (_m, indent: string, key: string, ws: string) => `${indent}${key}${ws}acknowledged`, + ); + } + } + + const matchIndexInContent = sectionOffset + entry.start; + const newContent = content.slice(0, matchIndexInContent) + newRawLines.join('\n') + content.slice(matchIndexInContent + (entry.end - entry.start)); + return { content: newContent, status: 'ok' }; +} + /** * Strip one leading `- ` bullet marker (#3457). Heading-delimited deferred * entries carry their fields as sibling bullets; `extractGapEntryFields` only diff --git a/tests/audit-command-cutover.test.cjs b/tests/audit-command-cutover.test.cjs index d3536cfeb..c240d448f 100644 --- a/tests/audit-command-cutover.test.cjs +++ b/tests/audit-command-cutover.test.cjs @@ -2077,17 +2077,39 @@ describe('bug #950: quick-task SUMMARY must carry status: complete', () => { assert.match(result.error, /no deferred item matched/i); }); - test('BLOCKER 1: heading-delimited (#3457) deferred-items shape is refused as unsupported_heading_shape, not guessed at', () => { + test('BLOCKER 1 (updated for #3781): heading-delimited (#3457) entries ack via the CLI; only table-embedded spans still refuse', () => { const phaseDir = planningPath('phases', '01-alpha'); fs.mkdirSync(phaseDir, { recursive: true }); const filePath = path.join(phaseDir, 'deferred-items.md'); - const before = ['## Deferred Items', '', '### Something out of scope', '', 'Some detail line.', ''].join('\n'); - fs.writeFileSync(filePath, before); - const result = ack(tmpDir, ['--category', 'deferred_items', '--phase', '01', '--file', 'deferred-items.md', '--text', 'Something out of scope', '--milestone', 'v1.0']); - assert.equal(result.success, false, 'heading-delimited shape must be refused'); - assert.match(result.error, /heading-delimited/i); - assert.equal(fs.readFileSync(filePath, 'utf-8'), before, 'file must be byte-identical — nothing written on refusal'); + // #3781: the heading shape is now SUPPORTED — a bullet-bearing leaf + // acks through the CLI writer. + const ackable = ['## Deferred Items', '', '### Something out of scope', '', '- a real bullet', ''].join('\n'); + fs.writeFileSync(filePath, ackable); + // --text must be the reader-derived identity: rawGapEntryText joins the + // heading text, the blank line, and the bullet with single spaces — + // the blank line contributes an empty segment, hence the double space. + const okResult = ack(tmpDir, ['--category', 'deferred_items', '--phase', '01', '--file', 'deferred-items.md', '--text', 'Something out of scope - a real bullet', '--milestone', 'v1.0']); + assert.equal(okResult.success, true, `heading-shaped entries must be acknowledgeable; stderr: ${okResult.error}`); + assert.match(fs.readFileSync(filePath, 'utf-8'), /status: acknowledged/); + + // A table row embedded in the entry's span is still refused — a + // non-contiguous span cannot anchor a safe write. + const tabled = ['## Deferred Items', '', '### Tabled finding', '', '- a bullet', '| x | y |', ''].join('\n'); + fs.writeFileSync(filePath, tabled); + const refuse = ack(tmpDir, ['--category', 'deferred_items', '--phase', '01', '--file', 'deferred-items.md', '--text', 'Tabled finding - a bullet', '--milestone', 'v1.0']); + assert.equal(refuse.success, false, 'table-embedded spans must still refuse'); + assert.match(refuse.error, /GFM table row/i); + assert.equal(fs.readFileSync(filePath, 'utf-8'), tabled, 'file must be byte-identical — nothing written on refusal'); + + // Prose-only headings were never items (reader contract) — not_found, + // never a write. + const proseOnly = ['## Deferred Items', '', '### Something out of scope', '', 'Some detail line.', ''].join('\n'); + fs.writeFileSync(filePath, proseOnly); + const nf = ack(tmpDir, ['--category', 'deferred_items', '--phase', '01', '--file', 'deferred-items.md', '--text', 'Something out of scope', '--milestone', 'v1.0']); + assert.equal(nf.success, false, 'prose-only headings contribute no entry'); + assert.match(nf.error, /no deferred item matched/i); + assert.equal(fs.readFileSync(filePath, 'utf-8'), proseOnly, 'file must be byte-identical'); }); // ── F1 (#3458 follow-up review, HIGH): the writer must splice by the diff --git a/tests/uat.test.cjs b/tests/uat.test.cjs index 4cf135467..c2484cea8 100644 --- a/tests/uat.test.cjs +++ b/tests/uat.test.cjs @@ -6636,58 +6636,212 @@ describe('#3740: acknowledge round-trips through the reader (parse → acknowled }); // ──────────────────────────────────────────────────────────────────────── -// #3775: the case axis of the writer/reader disagreement #3740 fixed for -// markers. The ack writer searched status lines case-INSENSITIVELY on both -// the bolded and bare alternatives, but the reader lowercases BOLDED keys -// only — bare keys keep their literal case (#3457 design). A bare -// `Status:`/`STATUS:` line was therefore rewritten in place while the reader -// stored it under key `Status`, never `fields.status`: the ack returned ok, -// the entry stayed outstanding, and a human `Status: resolved` was silently -// downgraded. The writer now matches exactly what the reader reads back — -// bolded in any case, bare in lowercase only — so Title-case shapes take the -// insert branch whose lowercase line the reader reads. -describe('#3775: ack matches exactly the status-line shapes the reader reads back', () => { - function roundTrip(body) { - const content = '## Deferred Items\n\n' + body + '\n'; - const before = parseDeferredItemsWithStatus(content); - assert.equal(before.length, 1, `fixture must parse to one entry, got ${before.length}`); - const ack = acknowledgeDeferredItem(content, before[0].name); +// #3781: acknowledge on the heading-delimited (#3457) entry shape. The +// writer refused EVERY entry in any heading-shaped deferred-items.md +// (`unsupported_heading_shape`) because the reader carried no character +// spans to anchor a write. The fix adds a span-carrying sibling of the +// heading walk; leaves rewrite in place / insert after their last +// non-blank line, and pending (preamble / container-direct) bullets reuse +// the headless span machinery with a baseOffset translation. Entries with +// an embedded GFM table row still refuse — a table line makes the span +// non-contiguous. +describe('#3781: acknowledge supports the heading-delimited entry shape', () => { + const leafDoc = [ + '## Deferred Items', + '', + '### Finding one', + '- did a thing', + '- evidence gathered', + '', + ].join('\n'); + + test('leaf entry without status acks via the insert branch the reader reads', () => { + const before = parseDeferredItemsWithStatus(leafDoc); + assert.equal(before.length, 1); + assert.equal(before[0].status, ''); + + const ack = acknowledgeDeferredItem(leafDoc, before[0].name); + assert.equal(ack.status, 'ok', `pre-fix this refused unsupported_heading_shape; got ${ack.status}`); + const after = parseDeferredItemsWithStatus(ack.content); - assert.equal(after.length, 1, 'acknowledged file must still parse to one entry'); - return { ack, before: before[0], after: after[0], content: ack.content }; - } + assert.equal(after.length, 1); + assert.equal(after[0].status, 'acknowledged', 'the entry must read acknowledged afterward'); - test('bare Title-case and UPPER status lines ack via the insert branch the reader reads', () => { - for (const body of [ - '- alpha\n Status: open', // A: bare Title-case continuation - '- Status: open', // D: bare Title-case on line 0 - '- alpha\n STATUS: open', // E: bare UPPER continuation - ]) { - const r = roundTrip(body); - assert.equal(r.ack.status, 'ok'); - assert.equal(r.after.status, 'acknowledged', - `#3775: ${JSON.stringify(body)} must end acknowledged via the insert branch; got "${r.after.status}"`); - } + // AC7 sentence trap: the marker goes after the LAST non-blank line, never + // spliced mid-entry after the heading line. + const lines = ack.content.split('\n'); + const markerIdx = lines.findIndex((l) => /status: acknowledged/.test(l)); + const lastBodyIdx = lines.findIndex((l) => l === '- evidence gathered'); + assert.ok(markerIdx > lastBodyIdx, 'marker must follow the entry\'s last non-blank line'); + assert.ok(lines.includes('- did a thing') && lines.includes('- evidence gathered'), + 'the soft-wrapped body must remain intact'); }); - test('a human-written bare Status: resolved line is never clobbered', () => { - const r = roundTrip('- alpha\n Status: resolved'); - assert.equal(r.ack.status, 'ok'); - assert.equal(r.after.status, 'acknowledged', 'the entry must read acknowledged afterward'); - assert.ok(r.content.includes(' Status: resolved'), - `#3775: the human's resolution marker must remain byte-untouched; got:\n${r.content}`); - assert.ok(r.content.includes('status: acknowledged'), - 'the inserted lowercase marker line the reader consumes must be present'); + test('leaf entry with an existing Status field is replaced in place', () => { + const doc = leafDoc.replace('- did a thing', '- **Status:** open\n- did a thing'); + const before = parseDeferredItemsWithStatus(doc); + assert.equal(before[0].status, 'open'); + + const ack = acknowledgeDeferredItem(doc, before[0].name); + assert.equal(ack.status, 'ok'); + + const statusLines = ack.content.split('\n').filter((l) => /status:/i.test(l) && l.trim() !== ''); + assert.equal(statusLines.length, 1, `exactly one status line, got ${JSON.stringify(statusLines)}`); + assert.match(statusLines[0], /\*\*Status:\*\*\s*acknowledged/); + assert.equal(parseDeferredItemsWithStatus(ack.content)[0].status, 'acknowledged'); }); - test('#3775 control: bolded and lowercase status lines still rewrite in place', () => { - for (const body of ['- alpha\n **Status:** open', '- alpha\n status: open']) { - const r = roundTrip(body); - assert.equal(r.ack.status, 'ok'); - assert.equal(r.after.status, 'acknowledged'); - const statusLines = r.content.split('\n').filter((l) => /\*{0,2}\s*status:\s*/i.test(l) && l.trim() !== ''); - assert.equal(statusLines.length, 1, - `control must rewrite in place — exactly one status line, got ${JSON.stringify(statusLines)}`); - } + test('pending bullets alongside heading entries ack without disturbing siblings', () => { + // Inner headings must be H3+ — a sibling H2 would end the level-bounded + // '## Deferred Items' section (collectSection), so the realistic shapes + // are H3 containers with H4 leaves. + const doc = [ + '## Deferred Items', + '', + '- loose preamble item', + '', + '### Findings group', + '', + '- container-direct item', + '', + '#### Finding one', + '- did a thing', + '', + ].join('\n'); + const before = parseDeferredItemsWithStatus(doc); + assert.equal(before.length, 3, `fixture must parse to three entries, got ${JSON.stringify(before)}`); + + const ackPreamble = acknowledgeDeferredItem(doc, 'loose preamble item'); + assert.equal(ackPreamble.status, 'ok'); + let items = parseDeferredItemsWithStatus(ackPreamble.content); + assert.equal(items[0].status, 'acknowledged'); + assert.equal(items[1].status, '', 'container-direct sibling untouched'); + assert.equal(items[2].status, '', 'leaf sibling untouched'); + + const ackContainerDirect = acknowledgeDeferredItem(ackPreamble.content, 'container-direct item'); + assert.equal(ackContainerDirect.status, 'ok'); + items = parseDeferredItemsWithStatus(ackContainerDirect.content); + assert.deepEqual(items.map((e) => e.status), ['acknowledged', 'acknowledged', ''], + 'each pending bullet acks independently'); + assert.ok(ackContainerDirect.content.includes('- did a thing'), 'leaf entry text untouched'); + }); + + test('already_resolved / ambiguous / not_found semantics match the headless shape', () => { + const resolvedDoc = leafDoc.replace('- did a thing', '- **Status:** resolved\n- did a thing'); + const resolved = acknowledgeDeferredItem(resolvedDoc, parseDeferredItemsWithStatus(resolvedDoc)[0].name); + assert.equal(resolved.status, 'already_resolved'); + assert.equal(resolved.content, resolvedDoc, 'file unchanged'); + + const dupDoc = [ + '## Deferred Items', + '', + '### Finding one', + '- did a thing', + '', + '### Finding two', + '- did a thing', + '', + ].join('\n'); + // Both leaves carry bullet `- did a thing`; identity text differs by heading, + // so force ambiguity with identical heading+body. + const dupDoc2 = [ + '## Deferred Items', + '', + '### Same', + '- did a thing', + '', + '### Same', + '- did a thing', + '', + ].join('\n'); + const dup = acknowledgeDeferredItem(dupDoc2, parseDeferredItemsWithStatus(dupDoc2)[0].name); + assert.equal(dup.status, 'ambiguous'); + assert.equal(dup.content, dupDoc2, 'file unchanged'); + + const missing = acknowledgeDeferredItem(leafDoc, 'no such entry'); + assert.equal(missing.status, 'not_found'); + assert.equal(missing.content, leafDoc); + void dupDoc; + }); + + test('entries with embedded GFM table rows still refuse', () => { + const doc = [ + '## Deferred Items', + '', + '### Finding one', + '- did a thing', + '| a | b |', + '- more evidence', + '', + ].join('\n'); + const before = parseDeferredItemsWithStatus(doc); + // The leaf entry AND the table row itself (parseDeferredTableItems unions + // over the same section) both surface. + assert.equal(before.length, 2, 'fixture self-check: leaf entry + table row'); + const ack = acknowledgeDeferredItem(doc, before[0].name); + assert.equal(ack.status, 'unsupported_heading_shape', + 'a table line inside the entry body makes its span non-contiguous — refuse'); + assert.equal(ack.content, doc, 'file unchanged'); + }); + + test('leaf line-0 corner: a heading whose text parses as a status field', () => { + const doc = '## Deferred Items\n\n### status: open\n- did a thing\n'; + const before = parseDeferredItemsWithStatus(doc); + assert.equal(before.length, 1); + assert.equal(before[0].status, 'open', 'fixture self-check: the reader reads the heading text itself as the field'); + + const ack = acknowledgeDeferredItem(doc, before[0].name); + assert.equal(ack.status, 'ok'); + assert.ok(ack.content.includes('### status: acknowledged'), + 'the ATX prefix must be preserved on the rewritten heading line'); + const after = parseDeferredItemsWithStatus(ack.content); + assert.equal(after[0].status, 'acknowledged', 'first-wins must read the rewritten heading text'); + }); + + test('CRLF pending entry verifies and acks (review finding)', () => { + const doc = '## Deferred Items\r\n\r\n- alpha\r\n continuation line\r\n\r\n### Finding one\r\n- did a thing\r\n'; + const before = parseDeferredItemsWithStatus(doc); + assert.equal(before.length, 2, 'fixture self-check: preamble pending + leaf'); + + const ack = acknowledgeDeferredItem(doc, before[0].name); + assert.equal(ack.status, 'ok', 'a CRLF pending entry must not false-refuse match_verification_failed'); + const after = parseDeferredItemsWithStatus(ack.content); + assert.equal(after[0].status, 'acknowledged'); + }); + + test('flat and mixed heading-depth files ack their leaf entries', () => { + const flatSection = '## Deferred Items\n\n### Notes\n\n#### Finding A\n- item one\n\n#### Finding B\n- item two\n'; + let items = parseDeferredItemsWithStatus(flatSection); + assert.equal(items.length, 2, 'container group + two leaves'); + const ackA = acknowledgeDeferredItem(flatSection, 'Finding A - item one'); + assert.equal(ackA.status, 'ok'); + assert.equal(parseDeferredItemsWithStatus(ackA.content).map((e) => e.status).filter(Boolean).length, 1, + 'exactly one entry acknowledged'); + + const mixed = '## Deferred Items\n\n### Childless group\n- solo item\n\n### Parent group\n#### Child one\n- child item\n'; + items = parseDeferredItemsWithStatus(mixed); + assert.ok(items.length >= 2, 'fixture self-check: mixed depths parse'); + const ackSolo = acknowledgeDeferredItem(mixed, 'Childless group - solo item'); + assert.equal(ackSolo.status, 'ok', 'a childless ## leaf alongside a ## container acks'); + }); + + test('leaf body status line keeps its bullet marker and indent on rewrite', () => { + const doc = '## Deferred Items\n\n### Finding one\n- did a thing\n - **Status:** open\n'; + const before = parseDeferredItemsWithStatus(doc); + assert.equal(before[0].status, 'open'); + + const ack = acknowledgeDeferredItem(doc, before[0].name); + assert.equal(ack.status, 'ok'); + assert.ok(ack.content.includes(' - **Status:** acknowledged'), + `the raw line's bullet marker and indent must survive the rewrite; got:\n${ack.content}`); + }); + + test('fully-headless file is byte-for-byte unchanged by this feature', () => { + const doc = '## Deferred Items\n\n- alpha\n status: open\n'; + const before = parseDeferredItemsWithStatus(doc); + const ack = acknowledgeDeferredItem(doc, before[0].name); + assert.equal(ack.status, 'ok'); + assert.equal(ack.content, '## Deferred Items\n\n- alpha\n status: acknowledged\n', + 'the pre-existing headless splice shape must be untouched'); }); });