fix(#4788): code spans in the decision bold lead-in are opaque to the separator grammar (#4883)

* test(#4788): failing-first — code spans in the decision bold lead-in are opaque to the separator grammar

* fix(#4788): the decision lead-in runs are code-span-aware — a backticked span is data, never grammar

* chore(#4788): backfill changeset PR number (4881)

* chore(#4788): correct changeset PR number (4883, was a guessed 4881)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-19 22:46:48 -04:00
committed by GitHub
parent 5906a24ede
commit 09e1110e68
3 changed files with 110 additions and 11 deletions

View File

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

View File

@@ -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*(.*)$`,
);
/**

View File

@@ -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) => `<decisions>\n${bullets.map((b) => '- ' + b).join('\n')}\n</decisions>`;
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']);
});
});