fix(#3781): span-carrying heading walk unblocks acknowledge on heading-shaped deferred items (#3998)
* test(#3781): heading-shaped deferred entries must be acknowledgeable * fix(#3781): span-carrying heading walk unblocks acknowledge on heading-shaped deferred items * test(#3781): table fixture counts the row; BLOCKER 1 updated to the supported contract * chore(#3781): changeset fragment (pr number backfilled after PR creation) * chore(#3781): backfill changeset PR number (3998) --------- Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/jolly-bears-sprint.md
Normal file
5
.changeset/jolly-bears-sprint.md
Normal file
@@ -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)
|
||||
@@ -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`);
|
||||
|
||||
308
src/uat.cts
308
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<string, string>;
|
||||
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<number, { text: string; isContainer: boolean }>();
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user