* 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:
5
.changeset/sunny-bears-greet.md
Normal file
5
.changeset/sunny-bears-greet.md
Normal 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)
|
||||
@@ -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*(.*)$`,
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user