* 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:
5
.changeset/vivid-cranes-march.md
Normal file
5
.changeset/vivid-cranes-march.md
Normal 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`.
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}`);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user