From b431ae9f0dc00b5d38f46de77cbe4ea6ba34efae Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 29 Aug 2026 15:51:58 -0400 Subject: [PATCH] fix(#3898): a separator-shaped line in ## Gaps is skipped, not made an entry (#4057) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(#3898): a spaced-hyphen thematic break in ## Gaps is not an entry (failing first) * fix(#3898): a separator-shaped line in ## Gaps is skipped, not made an entry splitGapsEntriesCore's opener regex (/^(\s*)-\s/) matched a spaced hyphen thematic break, fabricating a gap named '- -' with result 'unknown' — surfaced by audit-uat as an outstanding finding that cannot be cleared by editing any entry, because there is no entry, only the separator the author wrote for readability. Five shapes were affected (- - -, -, and wider/indented variants); the unaffected ones (---, ----, * * *, ___) were safe only by accident — the path never matched them, not because it understood breaks. A line whose content after the opening marker is solely hyphens and whitespace (with at least one further hyphen) is now skipped entirely — neither an opener nor a continuation. Deliberately option 2 from the issue, not a full thematic-break concept: a break does not close the Gaps list, entries after it keep parsing, and the deliberately-frozen byte-for-byte Gaps behavior changes ONLY for documents carrying such a separator (which previously produced a phantom). A real entry whose truth begins with a hyphen (- truth: "-5 error budget...") is untouched — its remainder contains non-hyphen characters. * fix(#3898): review fold-ins — span-contiguous skip, property coverage The skip is narrowed to where the phantom came from: a separator-shaped line BETWEEN entries (nothing open, or it would open a top-level entry). One landing strictly inside a live entry (indent > baseIndent) folds back as a continuation line, so entry lines and the GapsEntrySpan agree byte-for-byte — the span invariant and the #3805 ack writer's identity re-verification both hold (the review traced the unconditional skip to a match_verification_failed refusal in that corner). Adds the parser- convention property test (arbitrary hyphen counts/indents/spacings) and a span-contiguity pin. * chore(#3898): changeset fragment (pr number backfilled after PR creation) * chore(#3898): backfill changeset PR number (4057) --------- Co-authored-by: sim --- .changeset/daring-orcas-dart.md | 5 +++ src/uat.cts | 27 ++++++++++++ tests/uat.test.cjs | 74 +++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 .changeset/daring-orcas-dart.md diff --git a/.changeset/daring-orcas-dart.md b/.changeset/daring-orcas-dart.md new file mode 100644 index 000000000..c5b3fc0e9 --- /dev/null +++ b/.changeset/daring-orcas-dart.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4057 +--- +a spaced-hyphen thematic break (- - -) inside a UAT file ## Gaps section is no longer parsed as a gap entry — it fabricated a phantom open gap named "- -" with result unknown that audit-uat surfaced as outstanding work which could not be cleared by editing any entry (#3898) diff --git a/src/uat.cts b/src/uat.cts index 42d743741..4acba0713 100644 --- a/src/uat.cts +++ b/src/uat.cts @@ -2613,9 +2613,36 @@ function splitGapsEntriesCore(sectionBody: string): GapsEntrySpan[] { } }; + // #3898: a spaced-hyphen thematic break (`- - -`, `- -`, `- - -`, …) is a + // SEPARATOR, not an entry. The opener regex below matches it (hyphen + + // whitespace), which fabricated a gap named `- -` with result 'unknown' — + // an item that cannot be cleared by editing any entry, because there is no + // entry, only the separator the author wrote deliberately. A line whose + // content after the opening marker consists solely of hyphens and spaces + // (with at least one further hyphen) is skipped entirely: it neither opens + // an entry nor is folded into the current one. This is deliberately NOT a + // full thematic-break concept (option 2 in the issue): a break does not + // close the Gaps list — entries after it keep parsing. + const isSeparatorShaped = (line: string, bulletPrefixLen: number): boolean => { + const remainder = line.slice(bulletPrefixLen); + return /^[-\s]*$/.test(remainder) && remainder.includes('-'); + }; + rawLines.forEach((rawLine, idx) => { const line = rawLine.replace(/\r$/, ''); const bulletMatch = line.match(/^(\s*)-\s/); + // Narrowed skip (review disposition a): a separator-shaped line is skipped + // only when it sits BETWEEN entries (nothing open yet, or it would open a + // top-level entry — where the phantom came from). One landing strictly + // INSIDE a live entry (indent > baseIndent) folds back as a continuation + // line, so the entry's GapsEntrySpan stays byte-contiguous — the span + // invariant below and the ack writer's identity re-verification both hold. + if ( + bulletMatch && isSeparatorShaped(line, bulletMatch[0].length) && + (current === null || bulletMatch[1].length <= (baseIndent ?? 0)) + ) { + return; // separator line between entries — neither an opener nor a continuation + } if (bulletMatch) { const indent = bulletMatch[1].length; if (baseIndent === null) baseIndent = indent; diff --git a/tests/uat.test.cjs b/tests/uat.test.cjs index c2484cea8..d50a95bb2 100644 --- a/tests/uat.test.cjs +++ b/tests/uat.test.cjs @@ -511,6 +511,80 @@ All checks passed. // Regression: #2286 — parseUatItems never scanned a `## Gaps` section, so a // *-UAT.md file recording its only outstanding findings there returned // total_items: 0 (false-clean). Boundary: 0 / 1 / 2+ unresolved entries. + describe('Gaps separator lines are not items (#3898)', () => { + // The reporter's exact measurement table: every separator shape must + // yield ONLY the real entry. A spaced hyphen break matched the item + // opener regex (/^(\s*)-\s/) and fabricated a gap named '- -' with + // result 'unknown' — unfixable by editing any entry, because there is + // no entry, only the separator the author put there deliberately. + const mkDoc = (sep) => [ + '---', 'status: partial', 'phase: 01-x', '---', '', + '## Gaps', '', + sep, + '- truth: real', ' status: open', '', + ].join('\n'); + + const SEPARATORS = [ + '- - -', + '- -', + '- - -', + '- - - -', + ' - - -', + // unaffected forms stay unaffected (accidentally today, by handling after the fix) + '---', + '----', + '* * *', + '___', + ]; + for (const sep of SEPARATORS) { + test(`separator ${JSON.stringify(sep)} yields only the real entry`, () => { + const items = parseUatItems(mkDoc(sep)); + assert.deepStrictEqual( + items.map((i) => i.name), + ['real'], + `a thematic break must be a separator, not an entry (#3898); got ${JSON.stringify(items.map((i) => i.name))}`, + ); + }); + } + + test('property: any bullet line whose remainder is only hyphens/spaces (>=2 hyphens) yields no item', () => { + // CLAUDE.md's parser-contract convention: table coverage above, property + // coverage here — arbitrary spacings and counts, not just the table's nine. + fc.assert(fc.property( + fc.integer({ min: 2, max: 6 }), // extra hyphens + fc.integer({ min: 0, max: 3 }), // leading indent + fc.integer({ min: 1, max: 3 }), // spaces between hyphens + (hyphens, indent, gap) => { + const pad = ' '.repeat(indent); + const sep = pad + Array(hyphens + 1).fill('-').join(' '.repeat(gap)); + const items = parseUatItems(mkDoc(sep)); + return items.length === 1 && items[0].name === 'real'; + }, + ), { seed: 20260829, numRuns: 60 }); + }); + + test('#3898 review: a separator inside a live entry keeps its span contiguous (ack-able)', () => { + // Disposition (a): a separator deeper than baseIndent folds back as a + // continuation line, so entry lines and the entry's byte span agree — + // the ack writer's identity re-verification still matches. + const items = parseUatItems([ + '---', 'status: partial', 'phase: 01-x', '---', '', + '## Gaps', '', + '- truth: real', ' - - -', ' status: open', '', + ].join('\n')); + assert.deepStrictEqual(items.map((i) => i.name), ['real']); + }); + + test('a real entry whose text starts with a hyphen is still an entry (no over-skip)', () => { + const items = parseUatItems([ + '---', 'status: partial', 'phase: 01-x', '---', '', + '## Gaps', '', + '- truth: "-5 error budget remaining"', ' status: open', '', + ].join('\n')); + assert.deepStrictEqual(items.map((i) => i.name), ['-5 error budget remaining']); + }); + }); + describe('Gaps section scanning (#2286)', () => { test('a Gaps-only UAT file with 0 unresolved entries (all resolved) yields no items', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');