fix(#2372): widen decision-coverage-plan to all planner-canonical tags, drop misleading "(or body)" (#2443)
* fix(#2372): widen decision-coverage scan to planner-canonical tags, fix message Bug: check.decision-coverage-plan's remediation message told the user to cite decisions "(or body)" but extractPlanDesignatedSections only scanned <objective>/<tasks>/<task>/<action>. A decision cited in <read_first>, <behavior>, <verify>, <acceptance_criteria>, or <done> was invisible to the gate — false BLOCKING coverage gap, plus the message's own fix-hint sent the user to "the body" where re-citing still failed. Two-part fix (must change together — that drift was the bug): 1. Widen XML_DECISION_TAGS_RE in src/check-command-router.cts to also match <read_first>, <behavior>, <verify>, <acceptance_criteria>, <done>. These are all planner-canonical tags the planner is told to use (plan-phase.md:830-862, plan-phase.md:772). The body negative- lookahead mirrors the opening-tag set so each tag's body is captured independently. 2. Correct buildPlanMessage to name ONLY the surfaces the extractor actually scans (front-matter must_haves/truths/objective, designated markdown headings, and the nine planner-canonical tag bodies). The misleading "(or body)" clause is gone. Also updates the planner's documented contract (agents/gsd-planner.md:69) and user-facing docs (docs/CONFIGURATION.md, docs/USER-GUIDE.md) to reflect the wider scan. Regression tests in tests/decisions.test.cjs cover each newly-scanned tag body, a control (no citation still uncovered), and a message/extractor parity assertion that names every scanned surface — so the two cannot drift apart again. Out of scope (per triage): cmdDecisionCoverageVerify/buildVerifyMessage is a separate command (decision-coverage-verify) checking shipped artifacts, not plan citations — untouched. * chore(#2372): regenerate agent-size-baseline + golden-install-parity fixtures gsd-planner.md grew 49172 → 49294 (+122 chars) from the widened decision- coverage contract (5 new scanned tag names + heading clarification). Growth is justified: the contract surface is itself the fix — the prior text under-described what the gate scans, which was the bug. Updates: - tests/agent-size-baseline.json (gsd-planner.md: 49172 → 49294) - 17 tests/fixtures/golden-install-parity/*.json (one hash per runtime) - tests/fixtures/install-tree/*.json (regenerated by gen:golden) * fix(#2372): per-tag matching — outer-tag citations survive inner-tag nesting Code review (subagent) flagged a Medium edge-case regression from the single-alternation regex: when a newly-scanned tag nests inside another scanned tag, the alternation's negative lookahead halts the outer tag's body at the inner tag — losing any D-NN citation in the outer tag's prefix prose. Concretely: <action>per D-05 <verify>npm test</verify></action> → 3-tag alternation (old): captured 'per D-05 <verify>npm test</verify>' as <action> body → D-05 caught → 9-tag alternation (bug): captured 'npm test' only (from <verify>); D-05 in <action> prefix LOST Switches extractXmlTagBodies to per-tag matching: each tag gets its own regex whose negative-lookahead tempers only against the SAME tag's reopening. So <verify> inside <action> is absorbed into <action>'s body (D-05 caught) AND <verify> is matched separately on its own pass. Per-tag preserves both: - the reporter's case (sibling tags inside <read_first>) - nested-tag citations in outer-tag prefix prose - ReDoS safety (each per-tag regex keeps the #2128 body tempering) Also adds the reviewer's other requested edge-case tests: - non-scanned tag (<name>) bearing D-NN must NOT count - self-closing form <read_first /> safely ignored - attribute form <verify type="...">D-NN</verify> (canonical planner shape) - CRLF newlines in tag body do not break capture * chore(changeset): backfill pr:2443 in .changeset/noble-elks-chatter.md
This commit is contained in:
@@ -139,7 +139,30 @@ function loadPlanContents(phaseDir: string): string[] {
|
||||
}
|
||||
|
||||
const DESIGNATED_HEADINGS_RE = /^#{1,6}\s+(?:must[_ ]haves?|truths?|tasks?|objective)\b/i;
|
||||
const XML_DECISION_TAGS_RE = /<(?:objective|tasks?|action)(?:\s[^>]{0,1000})?>((?:(?!<(?:objective|tasks?|action)[\s>])[\s\S])*?)<\/(?:objective|tasks?|action)>/gi;
|
||||
// #2372: scanned-tag set must match the planner-canonical surfaces where a D-NN citation
|
||||
// is meaningful. `<objective>`/`<tasks>`/`<task>`/`<action>` are the historical core. The
|
||||
// planner is also explicitly told (plan-phase.md) to cite decisions in `<read_first>`,
|
||||
// `<behavior>`, `<verify>`, `<acceptance_criteria>`, and `<done>` — those are now scanned too,
|
||||
// so the gate no longer reports a false coverage gap when a decision is cited in any of them.
|
||||
//
|
||||
// Implementation: per-tag matching, NOT a single wide alternation. A single alternation
|
||||
// like `<(?:a|b|c)>...<\/(?:a|b|c)>` halts the outer tag's body capture at any inner tag
|
||||
// in the set, dropping any citation in the outer tag's prefix prose — e.g.
|
||||
// `<action>per D-05 <verify>npm test</verify></action>` would lose D-05 because `<verify>`
|
||||
// halts the `<action>` body before the citation. Per-tag matching avoids this: each tag's
|
||||
// body terminates only at its OWN closing tag, so `<verify>` inside `<action>` is absorbed
|
||||
// into `<action>`'s body (D-05 caught) AND `<verify>` is matched separately on its own pass.
|
||||
// Each per-tag regex keeps the ReDoS-safe negative-lookahead tempering (#2128).
|
||||
const XML_DECISION_TAG_NAMES = ['objective', 'tasks', 'task', 'action', 'read_first', 'behavior', 'verify', 'acceptance_criteria', 'done'] as const;
|
||||
|
||||
function buildXmlDecisionTagRegex(tagName: string): RegExp {
|
||||
// Per-tag: body tempering stops only at the SAME tag's reopening or closing — other
|
||||
// scanned tags pass through as text into this body. Non-greedy `*?` to first close.
|
||||
return new RegExp(
|
||||
`<${tagName}(?:\\s[^>]{0,1000})?>((?:(?!<${tagName}[\\s>])[\\s\\S])*?)<\\/${tagName}>`,
|
||||
'gi',
|
||||
);
|
||||
}
|
||||
|
||||
function stripCommentsAndFences(text: string): string {
|
||||
// HTML-comment stripping stays caller-side (the seam does not strip HTML comments).
|
||||
@@ -167,8 +190,11 @@ function extractYamlBlock(frontmatter: string, key: string): string {
|
||||
|
||||
function extractXmlTagBodies(text: string): string {
|
||||
const parts: string[] = [];
|
||||
for (const match of text.matchAll(XML_DECISION_TAGS_RE)) {
|
||||
if (match[1]) parts.push(match[1]);
|
||||
for (const tagName of XML_DECISION_TAG_NAMES) {
|
||||
const re = buildXmlDecisionTagRegex(tagName);
|
||||
for (const match of text.matchAll(re)) {
|
||||
if (match[1]) parts.push(match[1]);
|
||||
}
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
@@ -219,7 +245,10 @@ function buildPlanMessage(uncovered: UncoveredItem[]): string {
|
||||
'',
|
||||
...uncovered.map((item) => `- **${item.id}** (${item.category || 'uncategorized'}): ${item.text}`),
|
||||
'',
|
||||
'Resolve by citing `D-NN:` in a relevant plan\'s `must_haves`/`truths` (or body),',
|
||||
'Resolve by citing `D-NN:` in any of the scanned plan surfaces: front-matter',
|
||||
'`must_haves`/`truths`/`objective`, a `## must_haves`/`truths`/`tasks`/`objective`',
|
||||
'heading, or an `<objective>`/`<tasks>`/`<task>`/`<action>`/`<read_first>`/`<behavior>`/`<verify>`/`<acceptance_criteria>`/`<done>`',
|
||||
'tag body. Other locations (prose outside those headings, comments, other XML tags) are not scanned.',
|
||||
'OR move the decision to `### Claude\'s Discretion` / tag it `[informational]` if it should not be tracked.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user