diff --git a/.changeset/sunny-bears-greet.md b/.changeset/sunny-bears-greet.md new file mode 100644 index 000000000..761800a5e --- /dev/null +++ b/.changeset/sunny-bears-greet.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4883 +--- +**Decision bullets with code spans in their titles now parse** — a backticked token like `node:http` or `*-UAT.md` inside a decision's bold lead-in was treated as a malformed separator and the whole decisions block failed to parse, hard-blocking the decision-coverage gate; backticked spans are now opaque to the grammar, while a bare colon outside a code span still fails loud. (#4788) diff --git a/src/decisions.cts b/src/decisions.cts index b277e08d8..b89ae3909 100644 --- a/src/decisions.cts +++ b/src/decisions.cts @@ -115,7 +115,7 @@ const ID_ATTEMPT_SOURCE = 'D(?:[0-9][A-Za-z0-9]*)?-'; /** * Colon form: `- **D[phase]-NN[ [tags]]:** text` - * (#1343: `[^:*]*` subsumes any pre-colon prose, stops at `:**`) + * (#1343: the pre-colon run subsumes any pre-colon prose, stops at `:**`; #4788: the run is code-span-aware — a backticked span is consumed whole, so a `:` or `*` INSIDE it is data, never grammar) * Group 1 captures the FULL id including any phase prefix (#4130). * The ID is consumed atomically `(?=(…))\1` — see the hardening note above * the constants (#4130 follow-up); with the tail unable to give back, the @@ -123,7 +123,7 @@ const ID_ATTEMPT_SOURCE = 'D(?:[0-9][A-Za-z0-9]*)?-'; * linear in line length. */ const bulletColonRe = new RegExp( - `^\\s*-\\s+\\*\\*(?=(${DECISION_ID_SOURCE}))\\1(?:\\s*\\[([^\\]]+)\\])?[^:*]*:\\*\\*\\s*(.*)$`, + `^\\s*-\\s+\\*\\*(?=(${DECISION_ID_SOURCE}))\\1(?:\\s*\\[([^\\]]+)\\])?(?:\\u0060[^\\u0060]*\\u0060|[^:*\\u0060])*:\\*\\*\\s*(.*)$`, ); /** @@ -146,7 +146,7 @@ const bulletColonRe = new RegExp( * dash position. The ID is atomic like the other forms (driver #1). */ const bulletEmDashRe = new RegExp( - `^\\s*-\\s+\\*\\*(?=(${DECISION_ID_SOURCE}))\\1(?:\\s*\\[([^\\]]+)\\])?[^*—–]*[—–][^*]*\\*\\*\\s*(.*)$`, + `^\\s*-\\s+\\*\\*(?=(${DECISION_ID_SOURCE}))\\1(?:\\s*\\[([^\\]]+)\\])?(?:\\u0060[^\\u0060]*\\u0060|[^*—–\\u0060])*[—–](?:\\u0060[^\\u0060]*\\u0060|[^*\\u0060])*\\*\\*\\s*(.*)$`, ); /** @@ -154,17 +154,19 @@ const bulletEmDashRe = new RegExp( * A title sits between the colon and the closing `**` (so the `:**` anchor of * bulletColonRe fails, and there is no em-dash for bulletEmDashRe). This is a strict * superset of the colon-immediate form, so it MUST be checked AFTER bulletColonRe and - * bulletEmDashRe — it only catches bullets those two miss. The title run is `[^:*]*` (no - * colon, no `*`) so a genuinely-malformed bullet with a colon in the pre-separator run - * (e.g. `D-07 ratio 3:1:**`) still fails the anchor and falls through to the parse-miss - * guard — matching bulletColonRe's `[^:*]*` discipline that the separator colon is the - * only colon permitted before `**`. (#1639) + * bulletEmDashRe — it only catches bullets those two miss. The title runs are code-span-aware + * (#4788) but still exclude BARE colons and `*`, so a genuinely-malformed bullet with a bare + * colon in the pre-separator run (e.g. `D-07 ratio 3:1:**` — the `:**` supplies the second + * colon) still fails the anchor and falls through to the parse-miss guard — the #1639 + * fail-loud discipline: the separator must be the only BARE colon before `**`. A code span's + * `:`/`*` is data, never grammar; an UNTERMINATED backtick now fails loud (previously it was + * an ordinary character) — the deliberate cost of span opacity. * * The ID is consumed atomically `(?=(…))\1` like the other forms — the * hardening note above the constants explains why (#4130 follow-up). */ const bulletTitledColonRe = new RegExp( - `^\\s*-\\s+\\*\\*(?=(${DECISION_ID_SOURCE}))\\1(?:\\s*\\[([^\\]]+)\\])?[^:*]*:[^:*]*\\*\\*\\s*(.*)$`, + `^\\s*-\\s+\\*\\*(?=(${DECISION_ID_SOURCE}))\\1(?:\\s*\\[([^\\]]+)\\])?(?:\\u0060[^\\u0060]*\\u0060|[^:*\\u0060])*:(?:\\u0060[^\\u0060]*\\u0060|[^:*\\u0060])*\\*\\*\\s*(.*)$`, ); /** diff --git a/tests/decisions.test.cjs b/tests/decisions.test.cjs index 0d07f18bd..ecb4c6140 100644 --- a/tests/decisions.test.cjs +++ b/tests/decisions.test.cjs @@ -2795,8 +2795,14 @@ describe('parseDecisions hardening — regex lattice pins the mechanism (#4130 f // `[^*—–]*[—–]` has exactly one. The narrowing is behavior-preserving // because every candidate dash lies before the first `*` and the second // `[^*]*` scan reaches that same first star from any candidate. - assert.ok(emDashSrc.includes('[^*—–]*[—–]'), - `bulletEmDashRe must use the narrowed first separator:\n${emDashSrc}`); + // + // #4788 makes the leading run code-span-aware (`\u0060` = escaped backtick + // in the template): the class-only branch keeps the dash exclusion, so a + // dash-laden title WITHOUT spans still has exactly one viable split, and a + // span-internal dash is never a separator candidate — the span branch + // consumes it atomically. The #4130 no-re-split property is preserved. + assert.ok(emDashSrc.includes('[^*—–\\u0060])*[—–]'), + `bulletEmDashRe must use the span-aware narrowed first separator:\n${emDashSrc}`); assert.ok(!emDashSrc.includes('[^*]*[—–]'), `bulletEmDashRe must not retain the overlapping first separator:\n${emDashSrc}`); }); @@ -3041,3 +3047,89 @@ describe('parseDecisions hardening — pathological single bullets terminate cor assert.strictEqual(r.decisions[0].text, text); }); }); + +// ─── #4786 sibling family: code spans in the bold lead-in are opaque to the separator grammar (#4788) ── + +describe('#4788: code spans in the bold lead-in are opaque to the separator grammar', () => { + // Decision titles name modules, paths and globs — `node:http`, `*-UAT.md` — + // and inside backticks a `:` is not a separator and a `*` is not emphasis. + // The `[^:*]*` runs treated both as grammar characters, so the bullet fell + // to the parse-miss guard and a single miss forced outcome:"could-not-parse" + // (which blocks check.decision-coverage-plan during plan-phase). + const contentWith = (bullets) => `\n${bullets.map((b) => '- ' + b).join('\n')}\n`; + + test('colon-immediate form: a code span containing a colon sits in the pre-separator run', () => { + const md = contentWith([ + '**D-12 `cfg:a`:** does the thing', + ]); + const ds = parseDecisions(md); + assert.strictEqual(ds.length, 1, `expected the bullet to parse, got: ${JSON.stringify(ds)}`); + assert.strictEqual(ds[0].id, 'D-12'); + }); + + test('titled-colon form: a code span containing a colon parses (the D-91 shape)', () => { + const md = contentWith([ + '**D-91: Serve the hub over `node:http` with no framework.** Dropped: colon inside a code span.', + ]); + const ds = parseDecisions(md); + assert.strictEqual(ds.length, 1); + assert.strictEqual(ds[0].id, 'D-91'); + assert.ok(ds[0].text.includes('Dropped: colon inside a code span'), 'body after the closing ** must be preserved'); + }); + + test('titled-colon form: a code span containing an asterisk parses (the D-119 shape)', () => { + const md = contentWith([ + '**D-119: UAT reports are named `*-UAT.md` beside the plan.** Dropped: asterisk inside a code span.', + ]); + const ds = parseDecisions(md); + assert.strictEqual(ds.length, 1); + assert.strictEqual(ds[0].id, 'D-119'); + }); + + test('em-dash form: a code span containing an asterisk parses', () => { + const md = contentWith([ + '**D-13 `*-UAT.md` — the naming rule** body text', + ]); + const ds = parseDecisions(md); + assert.strictEqual(ds.length, 1); + assert.strictEqual(ds[0].id, 'D-13'); + }); + + test('#1639 negative: a bare second colon before the closing ** still fails loud', () => { + const r = extractDecisions(contentWith([ + '**D-07 ratio 3:1:** body', + ])); + assert.strictEqual(r.outcome, 'could-not-parse', 'the genuinely-malformed bullet must still fail loud'); + }); + + test('an unterminated backtick in the lead-in now fails loud (pinned trade-off)', () => { + // Before the span-aware runs, a lone backtick was an ordinary character and + // the bullet parsed. The span-aware grammar treats an unterminated ` as the + // start of an opaque span that never closes — the bullet fails to the + // parse-miss guard. Deliberate: fail-loud beats mis-parsing prose as grammar. + const r = extractDecisions(contentWith([ + '**D-11: see `docs.** body', + ])); + assert.strictEqual(r.outcome, 'could-not-parse'); + }); + + test('a safe code span (no :/* inside) keeps parsing (control)', () => { + const md = contentWith([ + '**D-14: Title with `code`.** body', + ]); + const ds = parseDecisions(md); + assert.strictEqual(ds.length, 1); + assert.strictEqual(ds[0].id, 'D-14'); + }); + + test('a real mixed block parses end to end (the issue measured 2 of 36 missing)', () => { + const r = extractDecisions(contentWith([ + '**D-01: Single-line title with no code span.** Parses fine.', + '**D-91: Serve the hub over `node:http` with no framework.** Dropped: colon inside a code span.', + '**D-119: UAT reports are named `*-UAT.md` beside the plan.** Dropped: asterisk inside a code span.', + '**D-92: Serve the hub with no framework.** Uses `node:http`. Parses: the same token in the body.', + ])); + assert.strictEqual(r.outcome, 'parsed'); + assert.deepEqual(r.decisions.map((d) => d.id), ['D-01', 'D-91', 'D-119', 'D-92']); + }); +});