fix(#2347): make the decision-shape evidence test format-agnostic (#2389)

#1365's fail-loud guard reused the parser's own D- grammar as its evidence test, so a populated <decisions> block using any other ID prefix (e.g. D5-01) was invisible to both parser and guard, collapsing could-not-parse into a clean none-present pass. Add an ID-shaped bold-lead-in probe as format-agnostic evidence on both parse paths; empty/prose scaffolds stay none-present. Graduates the #2371 d5-prefix representative fixture to its expected* assertion.

Closes #2347. Admin-merged (self-review bypass) with full green CI.
This commit is contained in:
Tom Boucher
2026-07-17 16:25:13 -04:00
committed by GitHub
parent 062f3fda90
commit f15eb5f5c9
4 changed files with 133 additions and 15 deletions

View File

@@ -84,6 +84,27 @@ const bulletEmDashRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+
*/
const bulletTitledColonRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^:*]*:[^:*]*\*\*\s*(.*)$/;
/**
* #2347: format-agnostic evidence that a block/section holds real decision
* ENTRIES the parser could not read — a bullet whose bold lead-in is an
* ID-SHAPED token (uppercase prefix, optional digits, hyphen, alnum), whatever
* the exact ID grammar. The three parser grammars above all require a `D-`
* prefix; #1365's fail-loud guard reused that same `\bD-` test as its "is this
* decision-shaped?" evidence, so any other prefix (e.g. `D5-01`) was invisible
* to BOTH parser and guard, collapsing `could-not-parse` into a clean
* `none-present` pass.
*
* The ID-shape requirement (not "any bold bullet") is deliberate: a decisions
* block or `### Claude's Discretion` sub-section legitimately contains prose
* bullets with bold labels (`- **Scope:** …`, `- **Why:** …`, `- **Note:** …`).
* Those are NOT decision entries and must stay `none-present` — a false
* `could-not-parse` hard-blocks the plan gate. `[A-Z]+[0-9]*-[A-Za-z0-9]` matches
* `D-01` / `D5-01` / `DEC-01` but not `Scope:` / `Why:` / `Follow-up:` (mixed
* case) / `TODO:` (no `-<alnum>` id) — mirroring the parser's own `D-<alnum>`
* shape without hardcoding the `D`.
*/
const boldLeadInBulletRe = /^\s*-\s+\*\*[A-Z]+[0-9]*-[A-Za-z0-9]/m;
interface ParseDecisionLinesResult {
decisions: Decision[];
parseMisses: number;
@@ -241,11 +262,13 @@ export function extractDecisions(content: unknown): DecisionExtraction {
}
// FIX A: Block present but 0 extracted and no parse-misses.
// Only report could-not-parse when there is genuine evidence of real decisions
// that failed to parse: a \bD- token in the block text, or an unterminated fence.
// An empty scaffold (<decisions></decisions>) or an all-prose block has no such
// evidence — treat as none-present so the gate passes cleanly.
// that failed to parse: a bold-lead-in bullet (`- **…**`, any ID grammar — #2347),
// a \bD- token in the block text, or an unterminated fence. An empty scaffold
// (<decisions></decisions>) or an all-prose block has no such evidence — treat
// as none-present so the gate passes cleanly.
const hasDecisionTokenInBlock = /\bD-[A-Za-z0-9]/m.test(combined);
if (hasDecisionTokenInBlock || unterminatedFence) {
const hasBoldLeadInBullet = boldLeadInBulletRe.test(combined);
if (hasDecisionTokenInBlock || hasBoldLeadInBullet || unterminatedFence) {
return { decisions: [], outcome: 'could-not-parse' };
}
return { decisions: [], outcome: 'none-present' };
@@ -271,11 +294,13 @@ export function extractDecisions(content: unknown): DecisionExtraction {
return { decisions, outcome: 'could-not-parse' };
}
// FIX A: Heading found but 0 extracted and no parse-misses.
// Only report could-not-parse when the section body contains a D- token.
// A heading with only prose, sub-headings, or all-discretion content
// (no trackable D- tokens) is a legitimate empty/discretion section → none-present.
// Report could-not-parse when the section body holds a decision-entry-shaped
// bold-lead-in bullet (`- **…**`, any ID grammar — #2347) or a D- token. A
// heading with only prose, sub-headings, or all-discretion content (no such
// evidence) is a legitimate empty/discretion section → none-present.
const hasDecisionTokenInSection = /\bD-[A-Za-z0-9]/m.test(section.body);
if (hasDecisionTokenInSection) {
const hasBoldLeadInBulletInSection = boldLeadInBulletRe.test(section.body);
if (hasDecisionTokenInSection || hasBoldLeadInBulletInSection) {
return { decisions: [], outcome: 'could-not-parse' };
}
return { decisions: [], outcome: 'none-present' };