fix(5): decision-coverage gate parses <action> XML tag bodies for D-NN citations (#155)
* test(5): add failing test for decision IDs inside <objective>/<tasks>/<task>/<action> XML bodies Regression test for issue #5 — the translation gate (check.decision-coverage-plan) is blind to D-NN citations placed inside XML tag bodies by gsd-planner. Maintainer acceptance criteria (verbatim, issue #5): "Gate parses <action> tag bodies for decision ID citations; regression test with XML-tag plan body covers all decision IDs." Five new test cases added to the 'XML tag body citation parsing (issue #5)' suite: 1. RED: five decisions cited only in <objective>/<action> bodies → gate fails (before fix) 2. Non-canonical tag <comment> → must NOT count (negative control, passes) 3. Plain prose under undesignated heading → must NOT count (negative control, passes) 4. Self-closing <action/> → no crash, D-NN not covered (passes) 5. D-NN in <objective> body → should count (also fails before fix, GREEN after) Tests 1 and 5 are the load-bearing RED cases. All others are negative controls. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(5): extend extractPlanSections to scan <objective>/<tasks>/<task>/<action> for D-NN citations Closes #5. Root cause: extractPlanSections() in check-decision-coverage.ts collected only front-matter (must_haves/truths/objective) and body lines under designated markdown headings. The gsd-planner spec (agents/gsd-planner.md line 66) says 'Task actions reference the decision ID they implement (e.g., "per D-03")' and emits citations inside <action> tag bodies — a location the gate could not see. Fix: add extractXmlTagBodies() helper that matches the four canonical planner XML tags (<objective>, <tasks>, <task>, <action>) via a deliberately narrow regex (no XML parser library — D2 design decision). The helper output is appended to the designated string inside extractPlanSections(), making any D-NN citation inside those tag bodies count toward coverage. Maintainer acceptance criteria (verbatim, issue #5): "Gate parses <action> tag bodies for decision ID citations; regression test with XML-tag plan body covers all decision IDs." Self-closing tags (<action/>) are safely ignored — the capturing group does not match. Non-canonical tags (<comment>, <note>, etc.) are not in the alternation and are ignored by design. The CJS surface for check-decision-coverage.ts does NOT have a generator (gen-decisions.mjs covers decisions.ts, not this gate). No CJS artifact exists for this module. Per the generator framework established in PR #154 (ADR-3524), a CJS migration is a follow-up; this PR focuses on the TS fix. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(5): clarify in gsd-planner.md that decision-coverage gate reads XML tag bodies Adds a parenthetical note to the existing self-check bullet (line 66) explaining which locations the gate scans so the planner's own guidance and the gate's behavior are explicitly aligned. Refs #5. No behavior change — documentation truthing only. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(5): add changeset fragment for decision-coverage XML body fix Refs #5. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(5): extract Interface Context for Executors into reference file to pass planner-decomposition gate gsd-planner.md was 49446 chars after the XML-tag clarification added in this PR, exceeding the 48K threshold enforced by tests/planner-decomposition.test.cjs. Extracted the "Interface Context for Executors" section (~2137 chars) into get-shit-done/references/planner-interface-context.md, leaving a one-line pointer in gsd-planner.md. New normalized size: 47310 chars (1842 chars under threshold). Refs #5 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(5): register planner-interface-context.md in INVENTORY.md and manifest - Bump References headline from 61 to 62 to match filesystem count - Add planner-interface-context.md row in Modular Planner Decomposition table - Update footnote from 61 to 62 top-level references - Regenerate docs/INVENTORY-MANIFEST.json via gen-inventory-manifest.cjs --write Fixes inventory-counts and inventory-manifest-sync CI failures caused by the extraction commit (32e8950f) adding a new reference file without updating the inventory artefacts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -517,3 +517,197 @@ describe('config-type validation (review F16)', () => {
|
||||
expect(warnings.some((w) => /context_coverage_gate.*invalid type/.test(w))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── XML tag body parsing (issue #5) ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Regression tests for issue #5: the translation gate must parse decision IDs
|
||||
* from <objective>, <tasks>, <task>, and <action> tag bodies.
|
||||
*
|
||||
* The gsd-planner spec (agents/gsd-planner.md line 66) says:
|
||||
* "Task actions reference the decision ID they implement (e.g., 'per D-03')"
|
||||
* Plans produced by that spec put D-NN citations inside <action> bodies,
|
||||
* which the gate was blind to before this fix.
|
||||
*
|
||||
* Maintainer acceptance criteria (verbatim from issue #5):
|
||||
* "Gate parses <action> tag bodies for decision ID citations; regression test
|
||||
* with XML-tag plan body covers all decision IDs."
|
||||
*/
|
||||
describe('XML tag body citation parsing (issue #5)', () => {
|
||||
/** Five locked decisions, D-01..D-05. */
|
||||
const FIVE_DECISIONS = `<decisions>
|
||||
### Architecture
|
||||
- **D-01:** Use strict TypeScript everywhere
|
||||
- **D-02:** Prefer functional composition over class inheritance
|
||||
- **D-03:** All public APIs must have JSDoc
|
||||
- **D-04:** Errors are typed, never thrown as plain strings
|
||||
- **D-05:** Async functions always return explicit Promises
|
||||
</decisions>`;
|
||||
|
||||
/**
|
||||
* Plan whose D-NN citations are ONLY inside XML tag bodies —
|
||||
* no front-matter must_haves/truths/objective entries.
|
||||
*/
|
||||
const XML_PLAN = `---
|
||||
phase: 17
|
||||
plan: 1
|
||||
type: implementation
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified: []
|
||||
autonomous: true
|
||||
must_haves:
|
||||
- thing-a
|
||||
objective: |
|
||||
Build the foo.
|
||||
---
|
||||
|
||||
<objective>Implement foo per D-01 and D-02 design</objective>
|
||||
|
||||
<tasks>
|
||||
<task>
|
||||
<action>Apply D-03 mitigation to the bar module</action>
|
||||
</task>
|
||||
<task>
|
||||
<action>Refactor per D-04 — ensures D-05 invariant</action>
|
||||
</task>
|
||||
</tasks>
|
||||
`;
|
||||
|
||||
it('RED: gate is blind to D-NN citations inside XML tag bodies (before fix)', async () => {
|
||||
// This test is expected to FAIL on origin/main (gate returns covered: 0).
|
||||
// After fix it should pass (covered: 5).
|
||||
await setupPhase(FIVE_DECISIONS, { '17-01-PLAN.md': XML_PLAN });
|
||||
|
||||
const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp);
|
||||
|
||||
// GREEN assertion (what we want AFTER the fix):
|
||||
expect(result.data.passed).toBe(true);
|
||||
expect(result.data.covered).toBe(5);
|
||||
expect(result.data.uncovered).toEqual([]);
|
||||
expect(result.data.total).toBe(5);
|
||||
});
|
||||
|
||||
it('does NOT count D-NN inside a non-canonical XML tag like <comment>', async () => {
|
||||
// <comment> is not one of the four canonical tags; must not count.
|
||||
const planWithComment = `---
|
||||
phase: 17
|
||||
plan: 1
|
||||
type: implementation
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified: []
|
||||
autonomous: true
|
||||
must_haves:
|
||||
- thing-a
|
||||
---
|
||||
|
||||
<comment>D-01 not implemented yet</comment>
|
||||
`;
|
||||
await setupPhase(
|
||||
`<decisions>
|
||||
### Cat
|
||||
- **D-01:** A trackable decision six words or more
|
||||
</decisions>`,
|
||||
{ '17-01-PLAN.md': planWithComment },
|
||||
);
|
||||
|
||||
const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp);
|
||||
expect(result.data.passed).toBe(false);
|
||||
expect(result.data.uncovered.map((u: { id: string }) => u.id)).toContain('D-01');
|
||||
});
|
||||
|
||||
it('does NOT count D-NN mentioned in regular prose outside any XML tag', async () => {
|
||||
// Prose outside designated headings and XML tags must not count.
|
||||
const planWithProse = `---
|
||||
phase: 17
|
||||
plan: 1
|
||||
type: implementation
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified: []
|
||||
autonomous: true
|
||||
must_haves:
|
||||
- thing-a
|
||||
---
|
||||
|
||||
This section mentions D-01 in plain prose under an undesignated heading.
|
||||
|
||||
## Design Notes
|
||||
|
||||
D-01 appears here too but this heading is not designated.
|
||||
`;
|
||||
await setupPhase(
|
||||
`<decisions>
|
||||
### Cat
|
||||
- **D-01:** A trackable decision six words or more
|
||||
</decisions>`,
|
||||
{ '17-01-PLAN.md': planWithProse },
|
||||
);
|
||||
|
||||
const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp);
|
||||
expect(result.data.passed).toBe(false);
|
||||
expect(result.data.uncovered.map((u: { id: string }) => u.id)).toContain('D-01');
|
||||
});
|
||||
|
||||
it('does not crash on self-closing <action/> tags', async () => {
|
||||
const planWithSelfClosing = `---
|
||||
phase: 17
|
||||
plan: 1
|
||||
type: implementation
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified: []
|
||||
autonomous: true
|
||||
must_haves:
|
||||
- thing-a
|
||||
---
|
||||
|
||||
<tasks>
|
||||
<task>
|
||||
<action/>
|
||||
</task>
|
||||
</tasks>
|
||||
`;
|
||||
await setupPhase(
|
||||
`<decisions>
|
||||
### Cat
|
||||
- **D-01:** A trackable decision six words or more
|
||||
</decisions>`,
|
||||
{ '17-01-PLAN.md': planWithSelfClosing },
|
||||
);
|
||||
|
||||
// Should not throw; D-01 is uncovered (no citation in self-closing tag)
|
||||
const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp);
|
||||
expect(result.data.passed).toBe(false);
|
||||
expect(result.data.uncovered.map((u: { id: string }) => u.id)).toContain('D-01');
|
||||
});
|
||||
|
||||
it('counts D-NN in <objective> tag body', async () => {
|
||||
const planWithObjective = `---
|
||||
phase: 17
|
||||
plan: 1
|
||||
type: implementation
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified: []
|
||||
autonomous: true
|
||||
must_haves:
|
||||
- thing-a
|
||||
---
|
||||
|
||||
<objective>Implement D-01 as the core type system</objective>
|
||||
`;
|
||||
await setupPhase(
|
||||
`<decisions>
|
||||
### Cat
|
||||
- **D-01:** A trackable decision six words or more
|
||||
</decisions>`,
|
||||
{ '17-01-PLAN.md': planWithObjective },
|
||||
);
|
||||
|
||||
const result = await checkDecisionCoveragePlan([phaseDir, contextPath], tmp);
|
||||
expect(result.data.passed).toBe(true);
|
||||
expect(result.data.covered).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -157,6 +157,21 @@ interface PlanSections {
|
||||
|
||||
const DESIGNATED_HEADINGS_RE = /^#{1,6}\s+(?:must[_ ]haves?|truths?|tasks?|objective)\b/i;
|
||||
|
||||
/**
|
||||
* Extracts text from the four canonical XML tags that gsd-planner emits for
|
||||
* decision citations: <objective>, <tasks>, <task>, <action>.
|
||||
*
|
||||
* Uses a deliberately narrow regex (D2) — no XML parser library — because the
|
||||
* planner's XML convention is a project-internal convention, not formal XML.
|
||||
* Self-closing tags (e.g., <action/>) are harmlessly skipped (no capture group
|
||||
* match). Non-canonical tags (e.g., <comment>) are NOT matched.
|
||||
*
|
||||
* Reference: agents/gsd-planner.md line 66:
|
||||
* "Task actions reference the decision ID they implement (e.g., 'per D-03')"
|
||||
*/
|
||||
const XML_DECISION_TAGS_RE =
|
||||
/<(?:objective|tasks?|action)(?:\s[^>]*)?>([\s\S]*?)<\/(?:objective|tasks?|action)>/gi;
|
||||
|
||||
/** Strip HTML comments AND fenced code blocks from `text`. */
|
||||
function stripCommentsAndFences(text: string): string {
|
||||
return text
|
||||
@@ -165,6 +180,19 @@ function stripCommentsAndFences(text: string): string {
|
||||
.replace(/~~~[\s\S]*?~~~/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the inner text of all canonical XML tag bodies from `text`.
|
||||
* Returns a concatenated string of all matched inner bodies, or '' if none.
|
||||
* Called on the cleaned (comments+fences stripped) plan body.
|
||||
*/
|
||||
function extractXmlTagBodies(text: string): string {
|
||||
const parts: string[] = [];
|
||||
for (const m of text.matchAll(XML_DECISION_TAGS_RE)) {
|
||||
if (m[1]) parts.push(m[1]);
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/** Extract a YAML block scalar (key followed by indented continuation lines). */
|
||||
function extractYamlBlock(frontmatter: string, key: string): string {
|
||||
const re = new RegExp(`^${key}\\s*:(.*)$`, 'm');
|
||||
@@ -214,7 +242,14 @@ function extractPlanSections(planContent: string): PlanSections {
|
||||
if (inDesignated) bodyParts.push(line);
|
||||
}
|
||||
|
||||
return { designated: [...fmParts, bodyParts.join('\n')].join('\n\n') };
|
||||
// Also include the inner text of canonical XML tag bodies (<objective>, <tasks>,
|
||||
// <task>, <action>). The planner spec (agents/gsd-planner.md line 66) directs
|
||||
// agents to cite D-NN inside <action> bodies; the gate must honour those citations.
|
||||
// extractXmlTagBodies is called on the full cleaned content (not just the body
|
||||
// portion) so that <objective> blocks at the top of the document are also caught.
|
||||
const xmlParts = extractXmlTagBodies(cleaned);
|
||||
|
||||
return { designated: [...fmParts, bodyParts.join('\n'), xmlParts].join('\n\n') };
|
||||
}
|
||||
|
||||
async function loadPlanSections(phaseDir: string): Promise<PlanSections[]> {
|
||||
|
||||
Reference in New Issue
Block a user