diff --git a/.changeset/noble-bears-munch.md b/.changeset/noble-bears-munch.md new file mode 100644 index 000000000..0a4904317 --- /dev/null +++ b/.changeset/noble-bears-munch.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1665 +--- +**`check.decision-coverage-plan` no longer false-passes when CONTEXT.md decisions use the titled-colon bullet form** — `parseDecisions` recognized the colon-immediate (`- **D-NN:** text`) and em-dash (`- **D-NN — title** body`) forms but dropped the titled-colon form (`- **D-NN: Title.** body`, where a title sits between the colon and the closing `**`) via the parse-miss guard. When all decisions used the titled convention, the parser returned 0 decisions and the coverage gate passed vacuously. A third per-form regex (checked last, a strict superset of the colon form) now parses the titled-colon form; id and `[tags]` trackability are honored. diff --git a/src/decisions.cts b/src/decisions.cts index add518973..c6c19ff3f 100644 --- a/src/decisions.cts +++ b/src/decisions.cts @@ -71,6 +71,19 @@ const bulletColonRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+) */ const bulletEmDashRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^*]*[—–][^*]*\*\*\s*(.*)$/; +/** + * Titled-colon form: `- **D-NN[ [tags]]: Title.** body` + * 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) + */ +const bulletTitledColonRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^:*]*:[^:*]*\*\*\s*(.*)$/; + interface ParseDecisionLinesResult { decisions: Decision[]; parseMisses: number; @@ -150,6 +163,22 @@ function parseDecisionLines(block: string): ParseDecisionLinesResult { continue; } + // Titled-colon form: `- **D-NN[ [tags]]: Title.** body` (#1639). Checked LAST — it is + // a strict superset of bulletColonRe, so it only catches bullets the colon-immediate + // and em-dash forms missed (minimal blast radius). id + [tags] trackability honored; + // the body after the closing bold run is reported as text. + const titledColonMatch = line.match(bulletTitledColonRe); + if (titledColonMatch) { + flush(); + const id = `D-${titledColonMatch[1]}`; + const tags = titledColonMatch[2] + ? titledColonMatch[2].split(',').map((t) => t.trim().toLowerCase()).filter(Boolean) + : []; + const trackable = !inDiscretion && !tags.some((t) => NON_TRACKABLE_TAGS.has(t)); + current = { id, text: titledColonMatch[3] || '', category, tags, trackable }; + continue; + } + // Parse-miss guard (FIX B + #1343): a line that looks like a `D-NN` decision // bullet but failed both patterns — flush, warn, and record the miss. // parseMisses > 0 forces could-not-parse even when other decisions parsed. diff --git a/tests/decisions.test.cjs b/tests/decisions.test.cjs index 362f57a26..e1909329e 100644 --- a/tests/decisions.test.cjs +++ b/tests/decisions.test.cjs @@ -791,3 +791,40 @@ describe('FIX D: gap-checker surfaces decision could-not-parse even when require ); }); }); + +// ─── Regression #1639: titled-colon bullet form '- **D-NN: Title.** body' ───── +// Both bulletColonRe (':**' anchor) and bulletEmDashRe (em-dash) miss the form where a +// title sits between the colon and the closing **, so it was dropped by the parse-miss +// guard and check.decision-coverage-plan passed vacuously when all decisions were titled. +describe('parseDecisions — titled-colon bullet form (#1639)', () => { + test('titled-colon bullet is parsed, not dropped', () => { + const md = '## Locked decisions\n- **D-01: Default sandbox ON.** body\n- **D-02: Reject unsigned.** body two\n'; + const out = parseDecisions(md); + assert.equal(out.length, 2, 'should extract both titled-colon decisions (not 0)'); + assert.equal(out[0].id, 'D-01'); + assert.equal(out[1].id, 'D-02'); + }); + + test('titled-colon coexists with colon-immediate and em-dash forms', () => { + const md = '## Locked decisions\n- **D-01:** plain colon\n- **D-02 — emdash** body\n- **D-03: Titled.** body\n'; + const out = parseDecisions(md); + assert.equal(out.length, 3); + assert.deepEqual(out.map((d) => d.id), ['D-01', 'D-02', 'D-03']); + }); + + test('titled-colon with [tags] still parses id and tags', () => { + const md = '## Locked decisions\n- **D-01 [informational]: Title.** body\n'; + const out = parseDecisions(md); + assert.equal(out.length, 1); + assert.equal(out[0].id, 'D-01'); + assert.ok(out[0].tags.includes('informational'), `tags should include informational, got ${JSON.stringify(out[0].tags)}`); + }); + + test('all-titled CONTEXT.md parses every decision (no vacuous 0)', () => { + // The reporter case: 13 decisions all titled → previously all dropped → gate passed vacuously. + let md = '## Locked decisions\n'; + for (let i = 1; i <= 13; i++) md += `- **D-${String(i).padStart(2, '0')}: Decision ${i}.** body\n`; + const out = parseDecisions(md); + assert.equal(out.length, 13, 'all 13 titled-colon decisions must parse (not vacuously 0)'); + }); +});