* 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:
@@ -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