diff --git a/.changeset/vivid-cranes-march.md b/.changeset/vivid-cranes-march.md new file mode 100644 index 000000000..49d8679de --- /dev/null +++ b/.changeset/vivid-cranes-march.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3953 +--- +**`/gsd-plan-phase` no longer hard-blocks on a CONTEXT.md whose decision titles wrap** — a `` bullet whose bold lead-in runs across a line break is now read as the one decision it is, instead of counting as an unparseable bullet that forced the decision-coverage gate to `could-not-parse`. diff --git a/src/decisions.cts b/src/decisions.cts index c7846b417..1eb2cac1f 100644 --- a/src/decisions.cts +++ b/src/decisions.cts @@ -106,6 +106,216 @@ const bulletTitledColonRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([ */ const boldLeadInBulletRe = /^\s*-\s+\*\*[A-Z]+[0-9]*-[A-Za-z0-9]/m; +/** + * #3939: a decision bullet's DECLARATION line — the `- **D-NN … **` bold lead-in + * the three grammars above anchor on — may wrap across a line break. Physical + * line breaks inside a bullet are markdown-insignificant, and GSD's own + * discuss-phase writer emits the wrapped shape whenever a decision title runs + * past the wrap column. All three grammars require the closing `**` in the same + * string as the `- **D-` anchor, so a wrapped declaration matched none of them + * and fell to the #1365 parse-miss guard, forcing `could-not-parse` (which + * hard-blocks `check.decision-coverage-plan`) on a well-formed CONTEXT.md. + * + * The repair is confined to how the LOGICAL bullet is assembled — the grammars + * themselves are untouched, so every single-line form parses exactly as before. + */ +const decisionBulletStartRe = /^\s*-\s+\*\*D-/; + +/** + * A line that opens a new BLOCK-LEVEL construct, and therefore terminates the + * bullet above it: a list marker of any family (`-`, `*`, `+`, `1.`, `1)`), an + * ATX heading, a blockquote, or a table row. Joining never reaches across one of + * these (nor across a blank/whitespace-only line, checked separately), so a + * declaration whose bold run genuinely never closes cannot absorb the block + * below it and get "closed" by an unrelated inline `**` — it stays a parse-miss + * and still fails loud, which #1365's contract requires. + * + * The four MARKER families demand trailing whitespace so that a continuation + * line opening with emphasis (`*in* the header.** …`) is text, not a bullet. + * The table-row alternative deliberately does not: CommonMark tables may open + * flush (`|Col1|Col2|`), and a leading `|` is never ordinary decision prose. + * A `- ` line at ANY indent stops the join: a deeper one is #3169 nested + * elaboration, which the main loop folds into the open decision itself. + * + * DELIBERATE DIVERGENCE from the sectionizer seam (ADR-1372): `iterateBullets` + * recognises only the `N. ` ordered-list form (`numberedRe`, + * src/markdown-sectionizer.cts), while this set also stops at the `N) ` form. + * That is intentional and one-directional — this regex answers "may the join + * cross this line?", where recognising MORE block openers is the conservative + * answer (a missed terminator can manufacture a decision; a spare one can only + * make a malformed bullet fail loud, which #1365 already wants). `N)` is a + * CommonMark ordered-list marker, so a join must not reach across it whether or + * not the seam's own bullet iterator yields it. Both forms are pinned by tests, + * and a drift test asserts the seam still does NOT treat `N)` as a bullet, so + * this divergence stays visible if either side moves. + * + * Accepted over-termination: continuation prose that happens to open with + * digits-then-`.`/`)` ("10. really keeps going") or a literal `|` stops the join + * early, so such a bullet fails loud rather than parsing. That is the same + * markdown ambiguity every line-oriented reader carries, and this direction of + * the trade is the one #1365 asks for — fail loud, never guess. + */ +const blockConstructRe = /^(?:[-*+]\s|\d+[.)]\s|#{1,6}\s|>\s|\|)/; + +/** + * The id-adjacent `[tags]` REGION of a logical bullet, as far as it has been + * assembled. Matching means the region is still unsettled, in one of two ways: + * capture group 1 is present when the bracket is open (group 1 is the content + * seen so far), and absent when the id has been read but no `[` has followed + * yet — so a bracket may still open on the next absorbed line. + * + * A NON-match means the region is settled for good: the bracket closed, or + * something other than `[` followed the id. Either way the join no longer has + * to watch for a splice. + * + * The id character class is deliberately looser than the grammars' (it admits + * an empty id, so a bare `- **D-` still counts as unsettled). This regex only + * answers "may an id-adjacent bracket still open here?", where recognising MORE + * shapes is the conservative direction: an over-broad match can only make a + * malformed bullet fail loud, while a missed one silently re-classifies. + * + * Only the ID-ADJACENT bracket matters: that is the one the three grammars turn + * into `tags` (and therefore into `trackable`). A `[` further along the title is + * ordinary text and does not restrict the join. + */ +const tagRegionRe = /^\s*-\s+\*\*D-[A-Za-z0-9_-]*\s*(?:\[([^\]]*))?$/; + +/** + * #3939 (review): would folding `next` onto a lead-in whose `[tags]` bracket is + * still open splice the inserted space INTO a tag token? + * + * Tags are comma-split and trimmed, so a space landing next to a delimiter + * (`[`, `,`, `]`) changes nothing — `[informational,` + `deferred]` is still + * exactly `[informational, deferred]`. A space landing anywhere else splits one + * token into two (`[defer` + `red]` → `defer red`), which would not fail; it + * would parse to a DIFFERENT tag, silently flipping `trackable` on a gate that + * decides whether a decision must be covered. Refusing to join there leaves the + * bullet unchanged, so it reaches the #1365 parse-miss guard and fails loud — + * a wrong answer about coverage is worse than a blocked gate. + * + * `tail` is the bracket content accumulated so far, `next` the trimmed + * continuation line. + */ +function wouldSpliceTagToken(tail: string, next: string): boolean { + const before = tail.trimEnd(); + if (before === '' || before.endsWith(',') || before.endsWith('[')) return false; + return !(next.startsWith(',') || next.startsWith(']')); +} + +/** + * True when the bullet's own bold lead-in — the FIRST bold run on the line — + * is still open at end-of-line. Deliberately asks only about that first run + * (not `**`-parity over the whole string), because that is the run the three + * grammars anchor on: a balanced inline `**bold**` later in the body must not + * make a terminated lead-in look open. + */ +function boldLeadInIsUnterminated(text: string): boolean { + const open = text.indexOf('**'); + if (open === -1) return false; + return text.indexOf('**', open + 2) === -1; +} + +/** + * Fold a decision bullet whose bold lead-in wraps into ONE logical line, so the + * declaration grammars see the whole lead-in (#3939). + * + * Bounded and fail-loud-preserving: a wrapped declaration absorbs following + * lines only until its lead-in closes, and a blank/whitespace-only line, a new + * block-level construct (`blockConstructRe`), or the end of the block stops it. + * If the lead-in never closes, the original line is emitted UNCHANGED — a + * genuinely malformed bullet (e.g. an unterminated bold run) still reaches the + * parse-miss guard and still fails loud, exactly as #1365 requires. Non-decision + * lines pass through untouched, so continuation lines (#1372 FIX) and nested + * cross-reference bullets (#3169) are handled by the main loop as before. + * + * The joined line keeps the FIRST physical line's leading whitespace, so the + * `indentWidth` signal #3169 depends on is unchanged. Absorbed lines are + * trimmed and re-joined with a single space, which is what a soft line break + * means in markdown — so the join reproduces the rendered one-line text rather + * than concatenating the raw bytes. + * + * One place that equivalence does not hold is inside the id-adjacent `[tags]` + * bracket, where an inserted space can split a tag token and silently flip + * `trackable`. The join stops there instead (`wouldSpliceTagToken`), leaving the + * bullet to fail loud. + * + * Absorption stops at the first `**` on a continuation line, so an inline + * `**bold**` INSIDE a wrapped title closes the run early. That is deliberate: + * the result is byte-identical to what the same bullet written on one physical + * line parses to (the text past the early close re-attaches through the main + * loop's continuation folding), which is the whole contract here — wrapping is + * markdown-insignificant, never a second grammar. + */ +function joinWrappedBoldLeadIns(lines: string[]): string[] { + const joined: string[] = []; + + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + if (!decisionBulletStartRe.test(line) || !boldLeadInIsUnterminated(line)) { + joined.push(line); + continue; + } + + // Absorbed lines accumulate as SEGMENTS joined by a single space, and each + // new segment is searched on its own: the lead-in is known to be open at the + // end of the declaration line, and the inserted space means a closing `**` + // can never straddle a segment boundary, so the first `**` in any later + // segment is the close. Scanning per segment (rather than re-searching the + // accumulated string, which forces a rope flatten every iteration) keeps a + // long unterminated run linear on the plan gate's hot path. + const segments = [line]; + // Bracket content accumulated while the id-adjacent `[tags]` bracket is + // still open; null when it is not open. O(1) per segment. + let tagTail: string | null = null; + // The logical text assembled so far, kept ONLY while the id-adjacent + // bracket has yet to open, so a bracket that opens on ANY absorbed line + // arms the splice guard — not just one that opens on the declaration line. + // Null once the region settles (the bracket opened and `tagTail` took over, + // or the id was followed by something else), so this never re-walks a long + // absorption: a non-empty segment that is not a bracket-open settles the + // region immediately, which bounds the string to a single extra join. + let tagRegion: string | null = null; + const declRegion = tagRegionRe.exec(line); + if (declRegion !== null) { + if (declRegion[1] === undefined) tagRegion = line; + else tagTail = declRegion[1]; + } + let scan = i + 1; + let closed = false; + while (scan < lines.length) { + const trimmed = lines[scan].trim(); + if (trimmed === '' || blockConstructRe.test(trimmed)) break; + if (tagTail !== null && wouldSpliceTagToken(tagTail, trimmed)) break; + segments.push(trimmed); + scan += 1; + if (tagTail !== null) { + tagTail = trimmed.indexOf(']') === -1 ? trimmed : null; + } else if (tagRegion !== null) { + tagRegion = `${tagRegion} ${trimmed}`; + const opened = tagRegionRe.exec(tagRegion); + if (opened === null) tagRegion = null; + else if (opened[1] !== undefined) { + tagTail = opened[1]; + tagRegion = null; + } + } + if (trimmed.indexOf('**') !== -1) { + closed = true; + break; + } + } + + if (closed) { + joined.push(segments.join(' ')); + i = scan - 1; + } else { + joined.push(line); + } + } + + return joined; +} + interface ParseDecisionLinesResult { decisions: Decision[]; parseMisses: number; @@ -119,9 +329,12 @@ interface ParseDecisionLinesResult { * FIX B (#1365): parseMisses > 0 means the caller must treat the result as * could-not-parse even when some decisions were extracted — a silent drop is * worse than a fail-loud signal. + * + * #3939: physical lines are folded into logical bullets first, so a declaration + * whose bold lead-in wraps is matched as the one bullet it is. */ function parseDecisionLines(block: string): ParseDecisionLinesResult { - const lines = block.split(/\r?\n/); + const lines = joinWrappedBoldLeadIns(block.split(/\r?\n/)); const out: Decision[] = []; let category = ''; let inDiscretion = false; diff --git a/tests/decisions.test.cjs b/tests/decisions.test.cjs index 84fd81fad..334c87659 100644 --- a/tests/decisions.test.cjs +++ b/tests/decisions.test.cjs @@ -28,7 +28,10 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); +const fc = require('./helpers/fast-check-setup.cjs'); + const { parseDecisions, extractDecisions } = require('../gsd-core/bin/lib/decisions.cjs'); +const { iterateBullets } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); // ─── Regression #1364: markdown-header fallback ─────────────────────────────── @@ -1300,3 +1303,916 @@ describe('check.decision-coverage-plan — empty contextPath argument fails clos `Missing contextPath argument must fail closed. Got: ${JSON.stringify(parsed)}`); }); }); + +// ─── #3939: decision bullet whose bold lead-in wraps across a line break ────── +// +// parseDecisionLines matched each PHYSICAL line against the three bullet +// grammars, and all three require the closing `**` on the same line as the +// opening one. A decision bullet whose bold lead-in wraps — the shape GSD's own +// discuss-phase writer emits whenever a title runs past the wrap column — closes +// its bold run on a line the grammars never see together with the `- **D-` +// anchor, so all three miss and the #1365 parse-miss guard fires. One such miss +// forces `could-not-parse`, which hard-blocks check.decision-coverage-plan. +// +// Fix: assemble the LOGICAL bullet before matching. A decision bullet whose bold +// run is still open at end-of-line absorbs following lines until the run closes; +// only then do the three grammars (unchanged) see it. Joining is bounded — a +// blank line, a new bullet, a heading, or end-of-block stops it — so a genuinely +// malformed bullet (an unterminated bold run) still reaches the parse-miss guard +// and still fails loud. The wrapped bullet must parse to exactly what the same +// bullet written on one physical line parses to (parity, not a new grammar). + +describe('#3939: decision bullet with a wrapped bold lead-in parses as one logical bullet', () => { + test('titled-colon form wrapped across a line break is parsed, not a parse-miss', () => { + const md = [ + '', + '- **D-01: A titled-colon decision whose bold title wraps onto the', + ' next line.** body text', + '', + ].join('\n'); + const result = extractDecisions(md); + assert.strictEqual(result.outcome, 'parsed', + `A wrapped bold lead-in must not force could-not-parse. Got: ${JSON.stringify(result)}`); + assert.deepStrictEqual(result.decisions.map((d) => d.id), ['D-01'], + `D-01 must be extracted. Got: ${JSON.stringify(result.decisions)}`); + assert.strictEqual(result.decisions[0].text, 'body text', + `Text is the body after the closing bold run. Got: ${JSON.stringify(result.decisions[0].text)}`); + }); + + test('em-dash form wrapped across a line break is parsed, not a parse-miss', () => { + const md = [ + '', + '- **D-02 — an em-dash title that wraps onto the', + ' next line** body text', + '', + ].join('\n'); + const result = extractDecisions(md); + assert.strictEqual(result.outcome, 'parsed', + `A wrapped em-dash lead-in must not force could-not-parse. Got: ${JSON.stringify(result)}`); + assert.deepStrictEqual(result.decisions.map((d) => d.id), ['D-02']); + assert.strictEqual(result.decisions[0].text, 'body text'); + }); + + test('colon-immediate form wrapped across a line break is parsed, not a parse-miss', () => { + const md = [ + '', + '- **D-03 a long pre-colon prose run that keeps', + ' going:** body text', + '', + ].join('\n'); + const result = extractDecisions(md); + assert.strictEqual(result.outcome, 'parsed', + `A wrapped colon-immediate lead-in must not force could-not-parse. Got: ${JSON.stringify(result)}`); + assert.deepStrictEqual(result.decisions.map((d) => d.id), ['D-03']); + assert.strictEqual(result.decisions[0].text, 'body text'); + }); + + test('a wrapped bullet parses to exactly what the same bullet on one line parses to', () => { + const wrapped = extractDecisions([ + '', + '- **D-04 [informational]: A title that wraps', + ' here.** body text continues', + '', + ].join('\n')); + const oneLine = extractDecisions([ + '', + '- **D-04 [informational]: A title that wraps here.** body text continues', + '', + ].join('\n')); + assert.deepStrictEqual(wrapped, oneLine, + `Wrapping must be markdown-insignificant: the wrapped bullet must parse identically to the same bullet on one physical line. Wrapped: ${JSON.stringify(wrapped)} One-line: ${JSON.stringify(oneLine)}`); + assert.strictEqual(wrapped.decisions[0].trackable, false, + 'the [informational] tag must survive the join (non-trackable)'); + }); + + test('a bold lead-in wrapped across three physical lines is parsed', () => { + const md = [ + '', + '- **D-05: A very long title that', + ' keeps going and', + ' going.** body text', + '', + ].join('\n'); + const result = extractDecisions(md); + assert.strictEqual(result.outcome, 'parsed', + `A lead-in wrapped over three lines must parse. Got: ${JSON.stringify(result)}`); + assert.deepStrictEqual(result.decisions.map((d) => d.id), ['D-05']); + }); + + test('body continuation lines after a wrapped lead-in still fold into the decision text', () => { + const md = [ + '', + '- **D-06: A title that wraps onto the', + ' next line.** first body line', + ' second body line', + '', + ].join('\n'); + const result = extractDecisions(md); + assert.strictEqual(result.outcome, 'parsed'); + assert.strictEqual(result.decisions[0].text, 'first body line second body line', + `Continuation handling must be unchanged after the join. Got: ${JSON.stringify(result.decisions[0].text)}`); + }); + + test('wrapped and single-line bullets in one block all parse (the reported field shape)', () => { + const md = [ + '', + '### Implementation', + '- **D-01: A decision whose bold title wraps onto the', + ' next line.** body one', + '- **D-02:** a single-line colon-immediate decision', + '- **D-03 — an em-dash title that wraps onto the', + ' next line** body three', + '- **D-04: A single-line titled decision.** body four', + '', + ].join('\n'); + const result = extractDecisions(md); + assert.strictEqual(result.outcome, 'parsed', + `A block mixing wrapped and single-line bullets must parse. Got: ${JSON.stringify(result)}`); + assert.deepStrictEqual(result.decisions.map((d) => d.id), ['D-01', 'D-02', 'D-03', 'D-04'], + `Every bullet must be extracted in document order. Got: ${JSON.stringify(result.decisions.map((d) => d.id))}`); + assert.strictEqual(result.decisions[0].category, 'Implementation', + 'the category heading must still attach to a wrapped bullet'); + }); + + test('CRLF newlines: a wrapped bold lead-in is parsed (cross-platform)', () => { + const md = [ + '', + '- **D-01: A title that wraps onto the', + ' next line.** body text', + '', + ].join('\r\n'); + const result = extractDecisions(md); + assert.strictEqual(result.outcome, 'parsed', + `CRLF content must join identically to LF. Got: ${JSON.stringify(result)}`); + assert.deepStrictEqual(result.decisions.map((d) => d.id), ['D-01']); + assert.strictEqual(result.decisions[0].text, 'body text', + `No stray carriage return may survive the join. Got: ${JSON.stringify(result.decisions[0].text)}`); + }); + + test('markdown-header fallback path also parses a wrapped bold lead-in (#1364 path)', () => { + const md = [ + '## Locked decisions', + '- **D-01: A title that wraps onto the', + ' next line.** body text', + '- **D-02:** single line', + ].join('\n'); + const result = extractDecisions(md); + assert.strictEqual(result.outcome, 'parsed', + `The header-fallback path shares parseDecisionLines and must behave identically. Got: ${JSON.stringify(result)}`); + assert.deepStrictEqual(result.decisions.map((d) => d.id), ['D-01', 'D-02']); + }); + + // ── Negative proof: the #1365 fail-loud guard must still fire ─────────────── + + test('NEGATIVE: an unterminated bold run is still a parse-miss (fail-loud preserved)', () => { + const md = [ + '', + '- **D-01: a bold run that never closes', + ' more prose that never closes it either', + '', + ].join('\n'); + const result = extractDecisions(md); + assert.strictEqual(result.outcome, 'could-not-parse', + `A genuinely malformed bullet must still fail loud, not be swallowed by the join. Got: ${JSON.stringify(result)}`); + assert.deepStrictEqual(result.decisions, [], + `No decision may be manufactured from an unterminated bold run. Got: ${JSON.stringify(result.decisions)}`); + }); + + test('NEGATIVE: a blank line stops the join — closing ** after it is still a parse-miss', () => { + const md = [ + '', + '- **D-01: a title interrupted by a blank line', + '', + ' closes here.** body text', + '', + ].join('\n'); + const result = extractDecisions(md); + assert.strictEqual(result.outcome, 'could-not-parse', + `A blank line terminates a bullet; the join must not reach across it. Got: ${JSON.stringify(result)}`); + }); + + test('NEGATIVE: a whitespace-only line stops the join', () => { + const md = [ + '', + '- **D-01: a title interrupted by a whitespace-only line', + ' ', + ' closes here.** body text', + '', + ].join('\n'); + const result = extractDecisions(md); + assert.strictEqual(result.outcome, 'could-not-parse', + `A whitespace-only line must terminate the bullet exactly like a blank one. Got: ${JSON.stringify(result)}`); + }); + + test('NEGATIVE: a following bullet stops the join and is still parsed on its own', () => { + const md = [ + '', + '- **D-01: a title whose bold run never closes', + '- **D-02:** a well-formed sibling', + '', + ].join('\n'); + const result = extractDecisions(md); + assert.strictEqual(result.outcome, 'could-not-parse', + `The unterminated D-01 must still fail loud. Got: ${JSON.stringify(result)}`); + assert.deepStrictEqual(result.decisions.map((d) => d.id), ['D-02'], + `The join must not swallow the sibling bullet. Got: ${JSON.stringify(result.decisions.map((d) => d.id))}`); + }); + + test('NEGATIVE: a heading stops the join', () => { + const md = [ + '', + '- **D-01: a title whose bold run never closes', + '### Implementation', + '- **D-02:** a well-formed decision under the heading', + '', + ].join('\n'); + const result = extractDecisions(md); + assert.strictEqual(result.outcome, 'could-not-parse', + `The unterminated D-01 must still fail loud. Got: ${JSON.stringify(result)}`); + assert.deepStrictEqual(result.decisions.map((d) => d.id), ['D-02']); + assert.strictEqual(result.decisions[0].category, 'Implementation', + `The heading must not be swallowed into the open bullet. Got: ${JSON.stringify(result.decisions[0].category)}`); + }); + + test('NEGATIVE: a numbered list item stops the join — its inline ** must not close the run', () => { + // Without the full block-construct terminator set the join would absorb the + // list item and "close" on the OPENING ** of its inline bold, manufacturing a + // decision out of a bullet whose own bold run never closes. + const md = [ + '', + '- **D-01: a title whose bold run never closes', + ' 1. step with **em** here', + '', + ].join('\n'); + const result = extractDecisions(md); + assert.strictEqual(result.outcome, 'could-not-parse', + `A nested ordered-list item is a new block; the unterminated bullet must still fail loud. Got: ${JSON.stringify(result)}`); + assert.deepStrictEqual(result.decisions, [], + `No decision may be manufactured from the list item's inline bold. Got: ${JSON.stringify(result.decisions)}`); + }); + + test('NEGATIVE: star and plus list markers stop the join', () => { + for (const marker of ['*', '+']) { + const md = [ + '', + '- **D-01: a title whose bold run never closes', + ` ${marker} item with **em** here`, + '', + ].join('\n'); + const result = extractDecisions(md); + assert.strictEqual(result.outcome, 'could-not-parse', + `A '${marker}' list marker is a new block; the unterminated bullet must still fail loud. Got: ${JSON.stringify(result)}`); + } + }); + + test('NEGATIVE: a table row and a blockquote stop the join', () => { + for (const block of ['| col **x** | y |', '> quoted **em** text']) { + const md = [ + '', + '- **D-01: a title whose bold run never closes', + ` ${block}`, + '', + ].join('\n'); + const result = extractDecisions(md); + assert.strictEqual(result.outcome, 'could-not-parse', + `A block-level construct must stop the join. Got for ${JSON.stringify(block)}: ${JSON.stringify(result)}`); + } + }); + + test('a continuation line opening with emphasis is text, not a list marker', () => { + // `*in*` has no space after the `*`, so it is emphasis — the join must absorb + // it. (The joined title then contains `*`, which the titled-colon grammar's + // `[^:*]*` discipline rejects, exactly as it does on one physical line.) + const wrapped = extractDecisions([ + '', + '- **D-01: sanitised on the way', + ' *in*.** body text', + '', + ].join('\n')); + const oneLine = extractDecisions([ + '', + '- **D-01: sanitised on the way *in*.** body text', + '', + ].join('\n')); + assert.deepStrictEqual(wrapped, oneLine, + `An emphasis-opening continuation line must join, giving the same verdict as the one-line form. Wrapped: ${JSON.stringify(wrapped)} One-line: ${JSON.stringify(oneLine)}`); + }); + + test('an inline **bold** inside a wrapped title gives the same result as on one line', () => { + // Absorption stops at the first ** on a continuation line, so an inline bold + // inside the title closes the run early. Parity with the one-line form is the + // contract — the text past the early close re-attaches via continuation folding. + const wrapped = extractDecisions([ + '', + '- **D-01: a title that', + ' has **inline bold** inside.** body text', + '', + ].join('\n')); + const oneLine = extractDecisions([ + '', + '- **D-01: a title that has **inline bold** inside.** body text', + '', + ].join('\n')); + assert.deepStrictEqual(wrapped, oneLine, + `Wrapping must stay markdown-insignificant even when the title carries inline bold. Wrapped: ${JSON.stringify(wrapped)} One-line: ${JSON.stringify(oneLine)}`); + }); + + test('NEGATIVE: a long unterminated run still fails loud (scan is bounded, not quadratic)', () => { + // The join scans each absorbed line ONCE (per-segment search, no re-scan of + // the accumulated candidate), so a pathological block degrades linearly + // instead of quadratically — this runs on the plan gate's hot path over + // user-authored files. + const lines = ['', '- **D-01: a bold run that never closes']; + for (let i = 0; i < 5000; i += 1) lines.push(` filler prose line ${i} that never closes the run`); + lines.push(''); + const result = extractDecisions(lines.join('\n')); + assert.strictEqual(result.outcome, 'could-not-parse', + `A long unterminated run must still fail loud. Got: ${JSON.stringify(result.outcome)}`); + assert.deepStrictEqual(result.decisions, []); + }); + + test('NEGATIVE GUARD: the single-line malformed bullet of FIX B is still a parse-miss', () => { + // `- **D-02** ratio 3:1` closes its bold run on the same line with neither a + // colon-before-`**` nor an em-dash — a genuine miss, unrelated to wrapping. + const md = [ + '', + '- **D-01:** use JWT tokens', + '- **D-02** ratio 3:1', + '', + ].join('\n'); + const result = extractDecisions(md); + assert.strictEqual(result.outcome, 'could-not-parse', + `FIX B behaviour must be unchanged. Got: ${JSON.stringify(result)}`); + assert.deepStrictEqual(result.decisions.map((d) => d.id), ['D-01']); + }); + + test('NEGATIVE GUARD: a nested cross-reference bullet is still elaboration, not a join target (#3169)', () => { + const md = [ + '', + '- **D-15: A title that wraps onto the', + ' next line.** some decision', + " - **D-06's fix does not close this.** a nested cross-reference", + '', + ].join('\n'); + const result = extractDecisions(md); + assert.strictEqual(result.outcome, 'parsed', + `#3169 handling must survive the join. Got: ${JSON.stringify(result)}`); + assert.deepStrictEqual(result.decisions.map((d) => d.id), ['D-15'], + `The nested bullet is elaboration, not a second entry. Got: ${JSON.stringify(result.decisions.map((d) => d.id))}`); + assert.ok(result.decisions[0].text.includes("D-06's fix does not close this"), + `The nested bullet must still fold into D-15's text. Got: ${JSON.stringify(result.decisions[0].text)}`); + }); + + // ── Review round 3: the id-adjacent [tags] bracket ─────────────────────── + // + // Folding a soft line break to a single space is markdown's own rule, and it + // is invisible everywhere in a bullet EXCEPT inside the `[tags]` bracket the + // grammars turn into `trackable`. There a spliced space would split one tag + // token into two (`[defer` + `red]` → `defer red`), which does not fail — it + // parses to a DIFFERENT tag, silently flipping whether the decision-coverage + // gate demands coverage. The join refuses that splice so the bullet reaches + // the #1365 guard and fails loud instead. + + test('NEGATIVE: a wrap that splices a tag token fails loud, never a silent re-classification', () => { + const wrapped = extractDecisions([ + '', + '- **D-01 [defer', + ' red]: Use the thing.** body text', + '', + ].join('\n')); + assert.strictEqual(wrapped.outcome, 'could-not-parse', + `A wrap inside a tag token must fail loud, not guess. Got: ${JSON.stringify(wrapped)}`); + assert.deepStrictEqual(wrapped.decisions, [], + `No decision may be manufactured from a spliced tag. Got: ${JSON.stringify(wrapped.decisions)}`); + + // The failure mode this prevents: [deferred] is non-trackable, [defer red] + // is trackable — the gate's answer about this decision would silently flip. + const intended = extractDecisions([ + '', + '- **D-01 [deferred]: Use the thing.** body text', + '', + ].join('\n')); + assert.strictEqual(intended.decisions[0].trackable, false, + 'guard fixture: the intended one-line bullet is non-trackable'); + }); + + test('a wrap at a tag-list delimiter joins and parses identically to the one-line bullet', () => { + // A space landing next to `[`, `,` or `]` survives the parser's comma-split + // and trim, so these wraps are safe and must NOT be refused. + const oneLine = extractDecisions([ + '', + '- **D-01 [informational, deferred]: A title.** body text', + '', + ].join('\n')); + const variants = { + 'comma at end of line': ['- **D-01 [informational,', ' deferred]: A title.** body text'], + 'comma at start of line': ['- **D-01 [informational', ' , deferred]: A title.** body text'], + }; + for (const [label, lines] of Object.entries(variants)) { + const wrapped = extractDecisions(['', ...lines, ''].join('\n')); + assert.deepStrictEqual(wrapped, oneLine, + `A delimiter-adjacent wrap (${label}) must parse like the one-line bullet. Wrapped: ${JSON.stringify(wrapped)} One-line: ${JSON.stringify(oneLine)}`); + } + + // …across more than two physical lines, too. + const threeLine = extractDecisions([ + '', + '- **D-01 [informational,', + ' deferred,', + ' folded]: A title.** body text', + '', + ].join('\n')); + assert.deepStrictEqual(threeLine.decisions.map((d) => d.tags), [['informational', 'deferred', 'folded']], + `A tag list wrapped over three lines must keep every tag. Got: ${JSON.stringify(threeLine)}`); + assert.strictEqual(threeLine.decisions[0].trackable, false); + }); + + test('NEGATIVE: a tag token spliced on a LATER continuation line also fails loud', () => { + const result = extractDecisions([ + '', + '- **D-01 [informational,', + ' defer', + ' red]: A title.** body text', + '', + ].join('\n')); + assert.strictEqual(result.outcome, 'could-not-parse', + `The splice check must hold for every absorbed line, not just the first. Got: ${JSON.stringify(result)}`); + assert.deepStrictEqual(result.decisions, []); + }); + + test('a bracket in the TITLE is not the tag bracket — the join is unaffected', () => { + // Only the id-adjacent bracket becomes `tags`; a bracket further along the + // title is ordinary text and must not restrict wrapping. + const wrapped = extractDecisions([ + '', + '- **D-01: prefer [the new', + ' API] here.** body text', + '', + ].join('\n')); + const oneLine = extractDecisions([ + '', + '- **D-01: prefer [the new API] here.** body text', + '', + ].join('\n')); + assert.deepStrictEqual(wrapped, oneLine, + `A title bracket must not trigger the tag-splice guard. Wrapped: ${JSON.stringify(wrapped)} One-line: ${JSON.stringify(oneLine)}`); + assert.strictEqual(wrapped.outcome, 'parsed'); + }); + + // ── Review round 2: the `N)` ordered-list terminator ───────────────────── + + test('NEGATIVE: an `N)` ordered list item stops the join, like `N.`', () => { + for (const marker of ['1.', '1)', '10.', '10)']) { + const result = extractDecisions([ + '', + '- **D-01: a title whose bold run never closes', + ` ${marker} step with **em** here`, + '', + ].join('\n')); + assert.strictEqual(result.outcome, 'could-not-parse', + `An '${marker}' ordered-list marker is a new block; the unterminated bullet must still fail loud. Got: ${JSON.stringify(result)}`); + assert.deepStrictEqual(result.decisions, [], + `No decision may be manufactured from the '${marker}' item's inline bold.`); + } + }); + + test('DRIFT GUARD: the sectionizer seam still does not treat `N)` as a bullet', () => { + // blockConstructRe stops at both `N. ` and `N) `; the seam's iterateBullets + // (ADR-1372) yields only `N. `. That divergence is deliberate and + // one-directional — a terminator set may recognise MORE block openers than + // the bullet iterator, since a spare terminator can only make a malformed + // bullet fail loud, never manufacture a decision. This test fails if the + // seam starts yielding `N)`, so the two are re-reconciled on purpose rather + // than drifting silently. + assert.deepStrictEqual(iterateBullets('1. numbered item').map((b) => b.text), ['numbered item'], + 'guard fixture: the seam yields the `N. ` ordered form'); + assert.deepStrictEqual(iterateBullets('1) numbered item'), [], + 'The seam does not yield `N) `. If this now fails, revisit blockConstructRe’s documented divergence in src/decisions.cts.'); + }); + + test('accepted over-termination: continuation prose opening `10.` or `|` stops the join', () => { + // Inherent markdown ambiguity: prose that happens to start with a numbered + // marker or a pipe is indistinguishable from a new block. Pinned as accepted + // behaviour — the bullet fails loud rather than being guessed at (#1365). + for (const continuation of ['10. really keeps going.** body', '| pipes open a table row.** body']) { + const result = extractDecisions([ + '', + '- **D-01: a title that wraps and then', + ` ${continuation}`, + '', + ].join('\n')); + assert.strictEqual(result.outcome, 'could-not-parse', + `Over-termination must fail loud, not silently drop the bullet. Got for ${JSON.stringify(continuation)}: ${JSON.stringify(result)}`); + } + }); + + // ── Review round 3: whitespace fidelity and #3169 nesting ──────────────── + + test('a double space inside the title survives every wrap position around it', () => { + const oneLine = extractDecisions([ + '', + '- **D-01: Title with double space.** body', + '', + ].join('\n')); + const variants = { + 'wrapped before both spaces': ['- **D-01: Title', ' with double space.** body'], + 'wrapped between the two spaces': ['- **D-01: Title ', ' with double space.** body'], + 'wrapped after both spaces': ['- **D-01: Title ', 'with double space.** body'], + }; + for (const [label, lines] of Object.entries(variants)) { + const wrapped = extractDecisions(['', ...lines, ''].join('\n')); + assert.deepStrictEqual(wrapped, oneLine, + `Interior title whitespace must not change the parse (${label}). Wrapped: ${JSON.stringify(wrapped)} One-line: ${JSON.stringify(oneLine)}`); + } + }); + + test('a double space inside the BODY is preserved verbatim across a wrapped lead-in', () => { + // The body sits past the closing `**`; the join must not touch it. + const result = extractDecisions([ + '', + '- **D-01: A title that wraps onto the', + ' next line.** body with double space', + '', + ].join('\n')); + assert.strictEqual(result.decisions[0].text, 'body with double space', + `Body whitespace must survive the join byte-for-byte. Got: ${JSON.stringify(result.decisions[0].text)}`); + }); + + test('a WRAPPED bullet nested under an already-open decision behaves like the one-line nested form (#3169)', () => { + // The existing #3169 guard above uses an already-single-line nested bullet. + // This pins the case the join actually touches: the nested bullet's own bold + // lead-in wraps. It must still be elaboration folded into the open decision, + // never a second entry and never a parse-miss. + const wrapped = extractDecisions([ + '', + '- **D-01: First decision.** Body of first.', + ' - **D-02: A nested cross-reference whose title', + ' wraps.** nested body', + '', + ].join('\n')); + const oneLine = extractDecisions([ + '', + '- **D-01: First decision.** Body of first.', + ' - **D-02: A nested cross-reference whose title wraps.** nested body', + '', + ].join('\n')); + assert.deepStrictEqual(wrapped, oneLine, + `A wrapped NESTED bullet must parse like its one-line form. Wrapped: ${JSON.stringify(wrapped)} One-line: ${JSON.stringify(oneLine)}`); + assert.strictEqual(wrapped.outcome, 'parsed'); + assert.deepStrictEqual(wrapped.decisions.map((d) => d.id), ['D-01'], + `The nested bullet stays elaboration, not a second entry. Got: ${JSON.stringify(wrapped.decisions.map((d) => d.id))}`); + assert.ok(wrapped.decisions[0].text.includes('wraps.'), + `The wrapped nested bullet must fold into D-01's text in one piece. Got: ${JSON.stringify(wrapped.decisions[0].text)}`); + }); +}); + +// ─── #3939 properties: wrapping is markdown-insignificant ──────────────────── +// +// RULESET.TESTS.property-based-testing (CONTEXT.md): a parsing/transformation +// contract needs at least one fast-check property asserting a domain invariant. +// The invariant this fix rests on is a round-trip one — where a bold lead-in +// happens to wrap is not information, so a wrapped bullet must parse to exactly +// what the same bullet written on one physical line parses to, for EVERY grammar, +// every tag/category combination, and every wrap column. The example-based tests +// above pin four hand-picked wrap points; this generalizes over all of them. + +// Word corpus deliberately free of markdown metacharacters: `:` and `*` change +// which grammar matches (the `[^:*]*` discipline of #1639), and a generated +// token opening a block construct (`-`, `#`, `>`, `|`, …) would legitimately +// terminate the join. Those are separate, example-tested behaviors — this +// property isolates the wrap-position dimension. +const PROSE_WORDS = [ + 'persist', 'raw', 'delivery', 'headers', 'resumable', 'backfill', 'inline', + 'sync', 'column', 'migration', 'denylisted', 'triage', 'payload', 'budget', + 'structural', 'fixture', 'corpus', 'resolver', 'idempotent', 'verbatim', +]; + +const proseArb = (minWords, maxWords) => + fc.array(fc.constantFrom(...PROSE_WORDS), { minLength: minWords, maxLength: maxWords }) + .map((words) => words.join(' ')); + +const decisionIdArb = fc.oneof( + fc.integer({ min: 1, max: 99 }).map((n) => `D-${String(n).padStart(2, '0')}`), + fc.constantFrom('D-INFRA-01', 'D-CARRY-2', 'D-7'), +); + +const tagsArb = fc.constantFrom('', ' [informational]', ' [deferred]', ' [folded]'); + +/** The three declaration grammars, each rendered on ONE physical line. */ +const BULLET_FORMS = { + colonImmediate: (id, tags, title, body) => `- **${id}${tags} ${title}:** ${body}`, + titledColon: (id, tags, title, body) => `- **${id}${tags}: ${title}.** ${body}`, + emDash: (id, tags, title, body) => `- **${id}${tags} — ${title}** ${body}`, +}; + +/** + * Render one declaration line, failing the property outright on an unknown form + * rather than letting `undefined` propagate. `form` is drawn from + * `Object.keys(BULLET_FORMS)`, so this can only fire if the generator and the + * table are edited apart — which is exactly when it should. + */ +function renderBullet(form, id, tags, title, body) { + const render = BULLET_FORMS[form]; + assert.ok(typeof render === 'function', `unknown bullet form: ${JSON.stringify(form)}`); + return render(id, tags, title, body); +} + +/** + * Re-render a one-line bullet with its bold lead-in wrapped at the space + * selected by `seed`, continuation indented like discuss-phase writes it. + * Returns null when the lead-in holds no interior space to wrap at. + */ +function wrapBoldLeadIn(line, seed) { + const open = line.indexOf('**'); + const close = line.indexOf('**', open + 2); + const spaces = []; + for (let i = open + 2; i < close; i += 1) { + if (line[i] === ' ') spaces.push(i); + } + if (spaces.length === 0) return null; + const at = spaces[seed % spaces.length]; + return `${line.slice(0, at)}\n ${line.slice(at + 1)}`; +} + +/** + * Like `wrapBoldLeadIn`, but breaks the lead-in at two or more DISTINCT + * positions chosen by `seeds`, and at ANY position — not only at a space. + * + * Both generalizations are load-bearing for the #3953 review Blocker, and + * neither is reachable through `wrapBoldLeadIn`: + * + * - Two-plus breaks let the id-adjacent `[tags]` bracket open on a segment + * that is NOT the declaration line, which is the state whose splice guard + * was never armed. A single break always left the bracket either wholly on + * line 1 or wholly on line 2. + * - Breaking mid-token is what makes that state OBSERVABLE. A break at a + * space round-trips exactly (the join re-inserts the space it replaced), so + * a disarmed guard is indistinguishable from an armed one; a hard break + * inside a tag token is the case where the inserted space changes the token + * — `[inform` / `ational]` folding to the tag `inform ational` — and so + * changes `trackable`. + * + * A break AT a space replaces it (a markdown soft break renders as one space); + * a break anywhere else inserts the newline, which is the hard-wrapped shape a + * fixed-column writer produces. + * + * Breaks start AFTER the decision id, which every form renders flush against + * the opening `**`. Breaking inside the id is excluded because it lands in + * behavior this property is not about, in two different ways: a break inside + * `**`, or between `D` and `-01`, leaves the block with no `D-` token at all, + * so `extractDecisions` reports `none-present` — correctly, since nothing there + * is decision-shaped; and a break inside the id's own characters (`D-0` / `1`) + * yields a bullet that parses under a TRUNCATED id, which is pre-existing + * behavior identical before and after this fix. Both are generator artifacts. + * Everything the Blocker needs is downstream of the id: the space before `[`, + * the bracket interior, and the title. Returns null when fewer than two + * distinct positions were drawn. + */ +function wrapBoldLeadInMulti(line, id, seeds) { + const open = line.indexOf('**'); + const close = line.indexOf('**', open + 2); + const start = open + 2 + id.length; + const span = close - start; + if (span < 2) return null; + + const cuts = [...new Set(seeds.map((seed) => start + (seed % span)))] + .sort((a, b) => b - a); + if (cuts.length < 2) return null; + + // Highest index first, so each break leaves the earlier indices valid. + let out = line; + for (const at of cuts) { + const drop = out[at] === ' ' ? 1 : 0; + out = `${out.slice(0, at)}\n ${out.slice(at + drop)}`; + } + return out; +} + +const inBlock = (body) => ['', body, ''].join('\n'); + +describe('#3939 properties: a wrapped bold lead-in parses like the one-line bullet', () => { + test('property: wrapping a bold lead-in anywhere is indistinguishable from not wrapping it', () => { + fc.assert( + fc.property( + fc.constantFrom(...Object.keys(BULLET_FORMS)), + decisionIdArb, + tagsArb, + proseArb(2, 8), + proseArb(1, 6), + fc.nat(), + fc.boolean(), + (form, id, tags, title, body, seed, withCategory) => { + const line = renderBullet(form, id, tags, title, body); + const wrappedLine = wrapBoldLeadIn(line, seed); + if (wrappedLine === null) return true; + + const heading = withCategory ? '### Implementation\n' : ''; + const oneLine = extractDecisions(inBlock(heading + line)); + const wrapped = extractDecisions(inBlock(heading + wrappedLine)); + + assert.deepStrictEqual(wrapped, oneLine, + `Wrap position must carry no information. form=${form} id=${id} tags=${JSON.stringify(tags)}\nONE-LINE: ${JSON.stringify(line)} → ${JSON.stringify(oneLine)}\nWRAPPED: ${JSON.stringify(wrappedLine)} → ${JSON.stringify(wrapped)}`); + return true; + }, + ), + ); + }); + + test('property: a wrapped declaration is never a parse-miss (outcome parsed, id preserved)', () => { + fc.assert( + fc.property( + fc.constantFrom(...Object.keys(BULLET_FORMS)), + decisionIdArb, + tagsArb, + proseArb(2, 8), + proseArb(1, 6), + fc.nat(), + (form, id, tags, title, body, seed) => { + const wrappedLine = wrapBoldLeadIn(renderBullet(form, id, tags, title, body), seed); + if (wrappedLine === null) return true; + + const result = extractDecisions(inBlock(wrappedLine)); + assert.strictEqual(result.outcome, 'parsed', + `A well-formed wrapped declaration must never reach the parse-miss guard. form=${form} line=${JSON.stringify(wrappedLine)} → ${JSON.stringify(result)}`); + assert.deepStrictEqual(result.decisions.map((d) => d.id), [id], + `The declared id must survive the join. form=${form} line=${JSON.stringify(wrappedLine)} → ${JSON.stringify(result.decisions)}`); + return true; + }, + ), + ); + }); + + test('property: a lead-in that never closes still fails loud, however long the run', (t) => { + // The join must not manufacture a decision out of an unterminated bold run, + // no matter how many lines it would have to absorb before giving up. + const originalWarn = console.warn; + console.warn = () => {}; + t.after(() => { console.warn = originalWarn; }); + + fc.assert( + fc.property( + decisionIdArb, + proseArb(2, 8), + fc.array(proseArb(1, 6), { minLength: 0, maxLength: 12 }), + (id, title, trailing) => { + const lines = [`- **${id}: ${title}`, ...trailing.map((t2) => ` ${t2}`)]; + const result = extractDecisions(inBlock(lines.join('\n'))); + assert.strictEqual(result.outcome, 'could-not-parse', + `An unterminated bold run must stay fail-loud. lines=${JSON.stringify(lines)} → ${JSON.stringify(result)}`); + assert.deepStrictEqual(result.decisions, [], + `No decision may be manufactured from an unterminated run. → ${JSON.stringify(result.decisions)}`); + return true; + }, + ), + ); + }); + + test('property: a wrap inside the [tags] bracket never silently re-classifies a decision', (t) => { + // The one place a spliced space is NOT invisible. The invariant is a + // disjunction, deliberately: wherever the wrap lands, the parse either + // matches the one-line bullet exactly, or it fails loud with nothing + // extracted. What it must never do is yield a decision whose tags — and so + // whose `trackable` verdict, which decides if the gate demands coverage — + // differ from the one-line form. Multi-word tags are in the corpus so both + // branches are reached (a wrap inside such a tag cannot be told apart from a + // mid-token splice, so it takes the fail-loud branch). + const originalWarn = console.warn; + console.warn = () => {}; + t.after(() => { console.warn = originalWarn; }); + + const tagTokenArb = fc.constantFrom( + 'informational', 'deferred', 'folded', 'carried', 'deferred to phase 3', + ); + + fc.assert( + fc.property( + fc.constantFrom(...Object.keys(BULLET_FORMS)), + decisionIdArb, + fc.array(tagTokenArb, { minLength: 1, maxLength: 3 }), + proseArb(2, 6), + proseArb(1, 4), + fc.nat(), + (form, id, tagTokens, title, body, seed) => { + const tags = ` [${tagTokens.join(', ')}]`; + const line = renderBullet(form, id, tags, title, body); + const wrappedLine = wrapBoldLeadIn(line, seed); + if (wrappedLine === null) return true; + + const oneLine = extractDecisions(inBlock(line)); + const wrapped = extractDecisions(inBlock(wrappedLine)); + + if (wrapped.decisions.length === 0) { + assert.strictEqual(wrapped.outcome, 'could-not-parse', + `Extracting nothing must be the fail-loud outcome, never a silent pass. line=${JSON.stringify(wrappedLine)} → ${JSON.stringify(wrapped)}`); + return true; + } + assert.deepStrictEqual(wrapped, oneLine, + `A wrap that DOES parse must parse exactly like the one-line bullet — tags and trackable included.\nONE-LINE: ${JSON.stringify(line)} → ${JSON.stringify(oneLine)}\nWRAPPED: ${JSON.stringify(wrappedLine)} → ${JSON.stringify(wrapped)}`); + return true; + }, + ), + ); + }); + + test('property: the same holds when the lead-in wraps at two or more points', (t) => { + // #3953 review (Blocker): the splice guard was armed only from the bullet's + // FIRST physical line, so a `[` that opened on a later absorbed segment left + // it a no-op — `- **D-01` / `[inform` / `ational]: …**` yielded + // tags `['inform ational']`, trackable `true`, where the one-line form gives + // `['informational']`, trackable `false`. A silently wrong coverage-gate + // answer, with no thrown error and no parse-miss to signal it. + // + // Every generator above wraps at exactly one point (`wrapBoldLeadIn` inserts + // a single `\n`), which is why three review rounds and the property suite all + // missed it. This one wraps at two or more, so the bracket-opens-later state + // is reachable, and asserts the SAME disjunction: parse identically to the + // one-line bullet, or fail loud with nothing extracted. + const originalWarn = console.warn; + console.warn = () => {}; + t.after(() => { console.warn = originalWarn; }); + + const tagTokenArb = fc.constantFrom( + 'informational', 'deferred', 'folded', 'carried', 'deferred to phase 3', + ); + + fc.assert( + fc.property( + fc.constantFrom(...Object.keys(BULLET_FORMS)), + decisionIdArb, + fc.array(tagTokenArb, { minLength: 1, maxLength: 3 }), + proseArb(2, 6), + proseArb(1, 4), + fc.array(fc.nat(), { minLength: 2, maxLength: 4 }), + (form, id, tagTokens, title, body, seeds) => { + const tags = ` [${tagTokens.join(', ')}]`; + const line = renderBullet(form, id, tags, title, body); + const wrappedLine = wrapBoldLeadInMulti(line, id, seeds); + if (wrappedLine === null) return true; + + const oneLine = extractDecisions(inBlock(line)); + const wrapped = extractDecisions(inBlock(wrappedLine)); + + if (wrapped.decisions.length === 0) { + assert.strictEqual(wrapped.outcome, 'could-not-parse', + `Extracting nothing must be the fail-loud outcome, never a silent pass. line=${JSON.stringify(wrappedLine)} → ${JSON.stringify(wrapped)}`); + return true; + } + assert.deepStrictEqual(wrapped, oneLine, + `A multi-wrap that DOES parse must parse exactly like the one-line bullet — tags and trackable included.\nONE-LINE: ${JSON.stringify(line)} → ${JSON.stringify(oneLine)}\nWRAPPED: ${JSON.stringify(wrappedLine)} → ${JSON.stringify(wrapped)}`); + return true; + }, + ), + ); + }); +}); + +// ─── #3939 gate-level: a wrapped bold lead-in must not hard-block the gate ──── + +describe('check.decision-coverage-plan — wrapped bold lead-in does not hard-block (#3939)', () => { + let tmpDir; + let planningDir; + let phaseDir; + + beforeEach(() => { + tmpDir = createTempProject('gsd-3939-'); + planningDir = path.join(tmpDir, '.planning'); + phaseDir = path.join(planningDir, 'phases', '01-init'); + fs.mkdirSync(phaseDir, { recursive: true }); + }); + + afterEach(() => cleanup(tmpDir)); + + test('FAIL-FIRST: CONTEXT.md whose decision titles wrap → gate reports real coverage, not could-not-parse', () => { + // Before the fix: both bullets are parse-misses → outcome could-not-parse → + // the gate hard-blocks with passed:false even though the plan covers both. + writeContextFile(phaseDir, [ + '# Phase 1 Context', + '', + '', + '### Implementation', + '- **D-01: Persist the raw delivery headers. Do not resolve by a second', + ' round-trip.** JSON arrays preserve repeated headers in order.', + '- **D-02: A one-time, resumable backfill fills the new columns for already', + ' indexed rows.** Sync fills them inline going forward.', + '', + ].join('\n')); + writePlanFile(phaseDir, '01', [ + '# Plan', + '', + '## Must Haves', + '', + '- D-01: persist the raw delivery headers', + '- D-02: implement the resumable backfill command', + ].join('\n')); + + const contextPath = path.join(phaseDir, 'CONTEXT.md'); + const result = runDecisionCoveragePlan(phaseDir, contextPath, tmpDir); + const parsed = JSON.parse(result.output || '{}'); + assert.strictEqual(parsed.passed, true, + `A CONTEXT.md whose decision titles merely wrap must not hard-block the gate. Got: ${JSON.stringify(parsed)}`); + assert.strictEqual(parsed.total, 2, + `Both wrapped decisions must be counted. Got: ${JSON.stringify(parsed)}`); + assert.strictEqual(parsed.covered, 2, + `Both wrapped decisions must be seen as covered by the plan. Got: ${JSON.stringify(parsed)}`); + }); +});