* 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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user