fix(#3939): parse decision bullets with a wrapped bold lead-in (#3953)

* fix(#3939): parse decision bullets with a wrapped bold lead-in

parseDecisionLines matched every PHYSICAL line against the three decision-bullet
grammars, and all three require the closing `**` in the same string as the
`- **D-` anchor. A declaration whose bold lead-in wraps across a line break —
the shape discuss-phase itself writes whenever a decision title runs past the
wrap column — matched none of them and fell to the #1365 parse-miss guard, which
forces `could-not-parse` and hard-blocks check.decision-coverage-plan on a
well-formed CONTEXT.md.

Fold physical lines into logical bullets before matching: a declaration whose
bold lead-in is still open at end-of-line absorbs following lines until that run
closes. The three grammars are untouched, so every single-line form parses
exactly as before.

Joining is bounded and preserves the fail-loud contract. A blank or
whitespace-only line, any block-level construct (a list marker of any family,
an ATX heading, a blockquote, a table row), or the end of the block stops it,
and a lead-in that never closes is emitted unchanged — so a genuinely malformed
bullet still reaches the parse-miss guard and still fails loud (#1365), and
cannot be "closed" by an inline `**` belonging to the block below it. The joined
line keeps the first physical line's indent, so the nested cross-reference
signal (#3169) is unchanged. Absorbed lines are scanned once each rather than
re-searching the accumulated candidate, keeping a pathological unterminated run
linear on the plan gate's hot path.

Regression coverage lands in tests/decisions.test.cjs (the owning module's file,
per the regression-test placement policy): all three grammars wrapped, a
three-line wrap, tags/category/continuation preservation, one-line parity
(including inline bold and emphasis inside a wrapped title), CRLF, the
markdown-header path, plus negative proof that every join terminator still
yields could-not-parse and that the FIX-B and #3169 fixtures are unchanged.

Fixes #3939

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3939): add changeset fragment for PR #3953

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#3939): property-test the wrap-position invariant

Review follow-up: RULESET.TESTS.property-based-testing requires a parsing /
transformation contract to carry at least one fast-check property asserting a
domain invariant, and the join added by the fix is exactly such a
transformation. The example-based tests pinned four hand-picked wrap points;
these generalize over the whole dimension.

Three properties, on the shared tests/helpers/fast-check-setup.cjs config
(numRuns 200, seeded):

- round-trip: for every grammar (colon-immediate, titled-colon, em-dash), every
  id shape, every tag, with and without a category heading, wrapping the bold
  lead-in at ANY interior space is deepStrictEqual to not wrapping it — where a
  line happens to break carries no information;
- domain invariant: a well-formed wrapped declaration never reaches the
  parse-miss guard (outcome `parsed`) and keeps its declared id;
- fail-loud preservation: an unterminated bold run followed by 0-12 prose lines
  still yields `could-not-parse` with no decision manufactured, however many
  lines the join would have to absorb before giving up.

The corpus is deliberately free of markdown metacharacters: `:` and `*` select a
different grammar (#1639's `[^:*]*` discipline) and a block-construct token
legitimately terminates the join. Both are separate behaviours, example-tested
above; these properties isolate the wrap-position dimension.

Rebuilding the module from `next` with these in place fails 14 (was 12); the two
new failures are the round-trip and never-a-parse-miss properties. The fail-loud
property passes before and after, which is the point of it.

Refs #3939

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3939): fail loud when a wrap splices a decision tag token

Addresses review rounds 2 and 3 on PR #3953.

Folding a soft line break to a single space is markdown's own rule and is
invisible everywhere in a decision bullet except inside the id-adjacent
`[tags]` bracket, which the three grammars turn into `tags` and therefore
into `trackable`. There a spliced space splits one tag token into two
(`[defer` + `red]` -> `defer red`), which does not fail: it parses to a
DIFFERENT tag, silently flipping whether check.decision-coverage-plan
demands coverage for that decision.

The join now stops at such a splice, so the bullet reaches the #1365
parse-miss guard and fails loud instead of guessing. The check is
delimiter-aware, so wraps that land next to `[`, `,` or `]` still join and
still parse identically to the one-line bullet -- a comma-separated tag
list may wrap at any of its separators, across any number of lines. A
bracket further along the title is ordinary text and does not restrict the
join.

Also in this round:

- blockConstructRe's doc comment claimed parity with the sectionizer seam's
  `iterateBullets`, which recognises only the `N. ` ordered form while this
  set also stops at `N) `. The widening is deliberate and one-directional
  (a terminator set may recognise more block openers than a bullet iterator;
  a spare terminator can only make a malformed bullet fail loud, never
  manufacture a decision). Comment corrected to say so, both marker forms
  now tested, and a drift guard asserts the seam still does not yield `N)`
  so the divergence cannot widen silently.
- Documented that the table-row alternative deliberately has no trailing
  whitespace requirement (CommonMark tables may open flush), and that
  over-termination on prose opening `10.` or `|` is accepted fail-loud
  behaviour -- now pinned by a test.
- Coverage the review asked for: a WRAPPED bold lead-in nested under an
  already-open decision (#3169, the existing guard used a single-line nested
  bullet), and title/body whitespace fidelity across every wrap position
  around a double space.
- A fourth fast-check property: wherever a wrap lands inside a `[tags]`
  bracket, the parse either matches the one-line bullet exactly or fails
  loud with nothing extracted -- never a decision whose tags differ.
- Property helpers render through `renderBullet`, which asserts the form
  exists instead of letting an unchecked map lookup yield undefined.

Fail-first: tests/decisions.test.cjs run against origin/next's decisions.cts
fails 18 of 131; against the previous PR head it fails the 2 new tag-splice
guards. All 131 pass with this change. Real-world CONTEXT.md from the report
is unchanged at 37/44 parsed.

Refs #3939

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3939): arm the tag-splice guard on any wrapped line, not just the first

The #3953 round-3 guard read the id-adjacent `[tags]` bracket only from a
bullet's FIRST physical line, via a regex anchored to the bullet start. A
lead-in that wraps twice can open that bracket on a LATER absorbed segment,
where the guard was never armed and `wouldSpliceTagToken` became a no-op:

    - **D-01
      [inform
      ational]: A title.** body text here.

folded to the tag `inform ational` and `trackable: true`, where the one-line
form gives `informational` and `trackable: false` — a silently wrong answer to
the coverage gate, with no thrown error and no parse-miss to signal it. Exactly
the re-classification the round-3 guard exists to prevent, for the case it did
not cover.

`tagBracketOpenAtEolRe` becomes `tagRegionRe`, which asks whether the
id-adjacent bracket REGION is still unsettled rather than whether it opened on
one specific line: group 1 present means the bracket is open, group 1 absent
means the id is read but a `[` may still follow. `joinWrappedBoldLeadIns` keeps
the assembled text in `tagRegion` only while the bracket has yet to open, so a
bracket opening on any segment arms `tagTail`; once armed, the pre-existing
O(1) tail update takes over and `tagRegion` is dropped. A non-empty segment
that is not a bracket-open settles the region immediately, so this bounds the
string to a single extra join and leaves the 5000-line unterminated run linear.

The id class widens to admit an empty id, so a bare `- **D-` still counts as
unsettled. This regex only answers "may an id-adjacent bracket still open
here?", where matching MORE shapes is the conservative direction: an over-broad
match can only make a malformed bullet fail loud, a missed one re-classifies
silently.

The existing property test wraps at exactly one point, and only at spaces —
which round-trip exactly, since the join re-inserts the space it replaced — so
neither the bracket-opens-later state nor an observable splice was reachable
from it. `wrapBoldLeadInMulti` breaks at two or more arbitrary positions after
the id and asserts the same disjunction: parse identically to the one-line
bullet, or fail loud with nothing extracted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCPSU591zVS9vLPd3gKnqn

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Andreas Brauchli
2026-09-04 22:18:36 -07:00
committed by GitHub
parent eb336e9f77
commit 0ea012c519
3 changed files with 1135 additions and 1 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3953
---
**`/gsd-plan-phase` no longer hard-blocks on a CONTEXT.md whose decision titles wrap** — a `<decisions>` 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`.

View File

@@ -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;

View File

@@ -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 = [
'<decisions>',
'- **D-01: A titled-colon decision whose bold title wraps onto the',
' next line.** body text',
'</decisions>',
].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 = [
'<decisions>',
'- **D-02 — an em-dash title that wraps onto the',
' next line** body text',
'</decisions>',
].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 = [
'<decisions>',
'- **D-03 a long pre-colon prose run that keeps',
' going:** body text',
'</decisions>',
].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([
'<decisions>',
'- **D-04 [informational]: A title that wraps',
' here.** body text continues',
'</decisions>',
].join('\n'));
const oneLine = extractDecisions([
'<decisions>',
'- **D-04 [informational]: A title that wraps here.** body text continues',
'</decisions>',
].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 = [
'<decisions>',
'- **D-05: A very long title that',
' keeps going and',
' going.** body text',
'</decisions>',
].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 = [
'<decisions>',
'- **D-06: A title that wraps onto the',
' next line.** first body line',
' second body line',
'</decisions>',
].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 = [
'<decisions>',
'### 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',
'</decisions>',
].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 = [
'<decisions>',
'- **D-01: A title that wraps onto the',
' next line.** body text',
'</decisions>',
].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 = [
'<decisions>',
'- **D-01: a bold run that never closes',
' more prose that never closes it either',
'</decisions>',
].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 = [
'<decisions>',
'- **D-01: a title interrupted by a blank line',
'',
' closes here.** body text',
'</decisions>',
].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 = [
'<decisions>',
'- **D-01: a title interrupted by a whitespace-only line',
' ',
' closes here.** body text',
'</decisions>',
].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 = [
'<decisions>',
'- **D-01: a title whose bold run never closes',
'- **D-02:** a well-formed sibling',
'</decisions>',
].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 = [
'<decisions>',
'- **D-01: a title whose bold run never closes',
'### Implementation',
'- **D-02:** a well-formed decision under the heading',
'</decisions>',
].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 = [
'<decisions>',
'- **D-01: a title whose bold run never closes',
' 1. step with **em** here',
'</decisions>',
].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 = [
'<decisions>',
'- **D-01: a title whose bold run never closes',
` ${marker} item with **em** here`,
'</decisions>',
].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 = [
'<decisions>',
'- **D-01: a title whose bold run never closes',
` ${block}`,
'</decisions>',
].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([
'<decisions>',
'- **D-01: sanitised on the way',
' *in*.** body text',
'</decisions>',
].join('\n'));
const oneLine = extractDecisions([
'<decisions>',
'- **D-01: sanitised on the way *in*.** body text',
'</decisions>',
].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([
'<decisions>',
'- **D-01: a title that',
' has **inline bold** inside.** body text',
'</decisions>',
].join('\n'));
const oneLine = extractDecisions([
'<decisions>',
'- **D-01: a title that has **inline bold** inside.** body text',
'</decisions>',
].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 = ['<decisions>', '- **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('</decisions>');
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 = [
'<decisions>',
'- **D-01:** use JWT tokens',
'- **D-02** ratio 3:1',
'</decisions>',
].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 = [
'<decisions>',
'- **D-15: A title that wraps onto the',
' next line.** some decision',
" - **D-06's fix does not close this.** a nested cross-reference",
'</decisions>',
].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([
'<decisions>',
'- **D-01 [defer',
' red]: Use the thing.** body text',
'</decisions>',
].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([
'<decisions>',
'- **D-01 [deferred]: Use the thing.** body text',
'</decisions>',
].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([
'<decisions>',
'- **D-01 [informational, deferred]: A title.** body text',
'</decisions>',
].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(['<decisions>', ...lines, '</decisions>'].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([
'<decisions>',
'- **D-01 [informational,',
' deferred,',
' folded]: A title.** body text',
'</decisions>',
].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([
'<decisions>',
'- **D-01 [informational,',
' defer',
' red]: A title.** body text',
'</decisions>',
].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([
'<decisions>',
'- **D-01: prefer [the new',
' API] here.** body text',
'</decisions>',
].join('\n'));
const oneLine = extractDecisions([
'<decisions>',
'- **D-01: prefer [the new API] here.** body text',
'</decisions>',
].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([
'<decisions>',
'- **D-01: a title whose bold run never closes',
` ${marker} step with **em** here`,
'</decisions>',
].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([
'<decisions>',
'- **D-01: a title that wraps and then',
` ${continuation}`,
'</decisions>',
].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([
'<decisions>',
'- **D-01: Title with double space.** body',
'</decisions>',
].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(['<decisions>', ...lines, '</decisions>'].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([
'<decisions>',
'- **D-01: A title that wraps onto the',
' next line.** body with double space',
'</decisions>',
].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([
'<decisions>',
'- **D-01: First decision.** Body of first.',
' - **D-02: A nested cross-reference whose title',
' wraps.** nested body',
'</decisions>',
].join('\n'));
const oneLine = extractDecisions([
'<decisions>',
'- **D-01: First decision.** Body of first.',
' - **D-02: A nested cross-reference whose title wraps.** nested body',
'</decisions>',
].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) => ['<decisions>', body, '</decisions>'].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',
'',
'<decisions>',
'### 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.',
'</decisions>',
].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)}`);
});
});