* 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 <sim@local>
This commit is contained in:
5
.changeset/daring-orcas-dart.md
Normal file
5
.changeset/daring-orcas-dart.md
Normal file
@@ -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)
|
||||
27
src/uat.cts
27
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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user