From 9f1996b8f9929edcdda81a32be69017c5a3a3206 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 28 Aug 2026 08:59:55 -0400 Subject: [PATCH] fix(#3775): ack matches exactly the status-line case shapes the reader reads back (#3989) * test(#3775): bare Title-case status lines must ack through the reader-visible path * fix(#3775): match exactly the status-line case shapes the reader reads back * chore(#3775): changeset fragment (pr number backfilled after PR creation) * chore(#3775): backfill changeset PR number (3989) --------- Co-authored-by: sim --- .changeset/merry-orcas-parade.md | 5 +++ src/uat.cts | 22 +++++++++--- tests/uat.test.cjs | 57 ++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 .changeset/merry-orcas-parade.md diff --git a/.changeset/merry-orcas-parade.md b/.changeset/merry-orcas-parade.md new file mode 100644 index 000000000..70538ce8e --- /dev/null +++ b/.changeset/merry-orcas-parade.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3989 +--- +**`audit-open acknowledge` no longer silently strands or clobbers Title-case status lines** — a bare `Status:`/`STATUS:` marker is now acknowledged through a line the reader actually parses instead of being rewritten in place invisibly, and a human-written `Status: resolved` is left untouched rather than downgraded to `acknowledged`. (#3775) diff --git a/src/uat.cts b/src/uat.cts index 51112084a..a89c5fac1 100644 --- a/src/uat.cts +++ b/src/uat.cts @@ -2047,10 +2047,24 @@ function acknowledgeDeferredItem(content: string, targetText: string): Acknowled // a real field there (and first-wins means the insert branch could not // outrank it). Everything else falls through to the insert branch below, // which the marker-free and no-status controls already round-trip. - const statusFieldRe = /^\s*(\*+status:\*+|status:)/i; - const statusFieldReLine0 = /^\s*(?:-\s+)?(\*+status:\*+|status:)/i; - const statusLineIdx = matchedLines.findIndex((rawLine, idx) => - (idx === 0 ? statusFieldReLine0 : statusFieldRe).test(rawLine.replace(/\r$/, ''))); + // + // #3775: the CASE axis of the same rule. The reader lowercases BOLDED + // keys only; bare keys keep their literal case (#3457 design), so a bare + // `Status:`/`STATUS:` line is stored under key `Status` and never read as + // fields.status. The search therefore matches a bolded key in ANY case + // and a bare key in LOWERCASE only — never a bare Title-case/UPPER line, + // which must fall through to the insert branch whose lowercase output the + // reader consumes (leaving any human `Status: resolved` untouched). + const statusFieldBoldedRe = /^\s*\*+status:\*+/i; + const statusFieldBareRe = /^\s*status:/; + const statusFieldBoldedReLine0 = /^\s*(?:-\s+)?\*+status:\*+/i; + const statusFieldBareReLine0 = /^\s*(?:-\s+)?status:/; + const statusLineIdx = matchedLines.findIndex((rawLine, idx) => { + const line = rawLine.replace(/\r$/, ''); + return idx === 0 + ? (statusFieldBoldedReLine0.test(line) || statusFieldBareReLine0.test(line)) + : (statusFieldBoldedRe.test(line) || statusFieldBareRe.test(line)); + }); // No CRLF-preservation branch here (WARNING 1, #3458 follow-up review): // every write goes through `platformWriteSync` → `normalizeContent`, which diff --git a/tests/uat.test.cjs b/tests/uat.test.cjs index 3a1e5a6dd..4cf135467 100644 --- a/tests/uat.test.cjs +++ b/tests/uat.test.cjs @@ -6634,3 +6634,60 @@ describe('#3740: acknowledge round-trips through the reader (parse → acknowled assert.equal(r.after.status, 'acknowledged'); }); }); + +// ──────────────────────────────────────────────────────────────────────── +// #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); + 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 }; + } + + 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}"`); + } + }); + + 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('#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)}`); + } + }); +});