Files
get-shit-done/tests/bug-2492-context-coverage-gate.test.cjs
Tom Boucher f30da8326a feat: add gates ensuring discuss-phase decisions are translated to plans and verified (closes #2492) (#2611)
* feat(#2492): add gates ensuring discuss-phase decisions are translated and verified

Two gates close the loop between CONTEXT.md `<decisions>` and downstream
work, fixing #2492:

- Plan-phase **translation gate** (BLOCKING). After requirements
  coverage, refuses to mark a phase planned when a trackable decision
  is not cited (by id `D-NN` or by 6+-word phrase) in any plan's
  `must_haves`, `truths`, or body. Failure message names each missed
  decision with id, category, text, and remediation paths.

- Verify-phase **validation gate** (NON-BLOCKING). Searches plans,
  SUMMARY.md, files modified, and recent commit subjects for each
  trackable decision. Misses are written to VERIFICATION.md as a
  warning section but do not change verification status. Asymmetry is
  deliberate — fuzzy-match miss should not fail an otherwise green
  phase.

Shared helper `parseDecisions()` lives in `sdk/src/query/decisions.ts`
so #2493 can consume the same parser.

Decisions opt out of both gates via `### Claude's Discretion` heading
or `[informational]` / `[folded]` / `[deferred]` tags.

Both gates skip silently when `workflow.context_coverage_gate=false`
(default `true`).

Closes #2492

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#2492): make plan-phase decision gate actually block (review F1, F8, F9, F10, F15)

- F1: replace `${context_path}` with `${CONTEXT_PATH}` in the plan-phase
  gate snippet so the BLOCKING gate receives a non-empty path. The
  variable was defined in Step 4 (`CONTEXT_PATH=$(_gsd_field "$INIT" ...)`)
  and the gate snippet referenced the lowercase form, leaving the gate to
  run with an empty path argument and silently skip.
- F15: wrap the SDK call with `jq -e '.data.passed == true' || exit 1` so
  failure halts the workflow instead of being printed and ignored. The
  verify-phase counterpart deliberately keeps no exit-1 (non-blocking by
  design) and now carries an inline note documenting the asymmetry.
- F10: tag the JSON example fence as `json` and the options-list fence as
  `text` (MD040).
- F8/F9: anchor the heading-presence test regexes to `^## 13[a-z]?\\.` so
  prose substrings like "Requirements Coverage Gate" mentioned in body
  text cannot satisfy the assertion. Added two new regression tests
  (variable-name match, exit-1 guard) so a future revert is caught.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#2492): tighten decision-coverage gates against false positives and config drift (review F3,F4,F5,F6,F7,F16,F18,F19)

- F3: forward `workstream` arg through both gate handlers so workstream-scoped
  `workflow.context_coverage_gate=false` actually skips. Added negative test
  that creates a workstream config disabling the gate while the root config
  has it enabled and asserts the workstream call is skipped.
- F4: restrict the plan-phase haystack to designated sections — front-matter
  `must_haves` / `truths` / `objective` plus body sections under headings
  matching `must_haves|truths|tasks|objective`. HTML comments and fenced
  code blocks are stripped before extraction so a commented-out citation or
  a literal example never counts as coverage. Verify-phase keeps the broader
  artifact-wide haystack by design (non-blocking).
- F5: reject decisions with fewer than 6 normalized words from soft-matching
  (previously only rejected when the resulting phrase was under 12 chars
  AFTER slicing — too lenient). Short decisions now require an explicit
  `D-NN` citation, with regression tests for the boundary.
- F6: walk every `*-SUMMARY.md` independently and use `matchAll` with the
  `/g` flag so multiple `files_modified:` blocks across multiple summaries
  are all aggregated. Previously only the first block in the concatenated
  string was parsed, silently dropping later plans' files.
- F7: validate every `files_modified` path stays inside `projectDir` after
  resolution (rejects absolute paths, `../` traversal). Cap each file read
  at 256 KB. Skipped paths emit a stderr warning naming the entry.
- F16: validate `workflow.context_coverage_gate` is boolean in
  `loadGateConfig`; warn loudly on numeric or other-shaped values and
  default to ON. Mirrors the schema-vs-loadConfig validation gap from
  #2609.
- F18: bump verify-phase `git log -n` cap from 50 to 200 so longer-running
  phases are not undercounted. Documented as a precision-vs-recall tradeoff
  appropriate for a non-blocking gate.
- F19: tighten `QueryResult` / `QueryHandler` to be parameterized
  (`<T = unknown>`). Drops the `as unknown as Record<string, unknown>`
  casts in the gate handlers and surfaces shape mismatches at compile time
  for callers that pass a typed `data` value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#2492): harden decisions parser and verify-phase glob (review F11,F12,F13,F14,F17,F20)

- F11: strip fenced code blocks from CONTEXT.md before searching for
  `<decisions>` so an example block inside ``` ``` is not mis-parsed.
- F12: accept tab-indented continuation lines (previously required a leading
  space) so decisions split with `\t` continue cleanly.
- F13: parse EVERY `<decisions>` block in the file via `matchAll`, not just
  the first. CONTEXT.md may legitimately carry more than one block.
- F14: `decisions.parse` handler now resolves a relative path against
  `projectDir` — symmetric with the gate handlers — and still accepts
  absolute paths.
- F17: replace `ls "${PHASE_DIR}"/*-CONTEXT.md | head -1` in verify-phase.md
  with a glob loop (ShellCheck SC2012 fix). Also avoids spawning an extra
  subprocess and survives filenames with whitespace.
- F20: extend the unicode quote-stripping in the discretion-heading match
  to cover U+2018/2019/201A/201B and the U+201C-F double-quote variants
  plus backtick, so any rendering of "Claude's Discretion" collapses to
  the same key.

Each fix has a regression test in `decisions.test.ts`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:26:53 -04:00

170 lines
7.5 KiB
JavaScript

/**
* Bug #2492: Add gates to ensure discuss-phase decisions are translated to
* plans (plan-phase, BLOCKING) and verified against shipped artifacts
* (verify-phase, NON-BLOCKING).
*
* These workflow files are loaded as prompts by the corresponding subagents.
* The tests below verify that the prompt text contains the gate steps and
* the config-toggle skip clauses — losing them silently would regress the
* fix.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const PLAN_PHASE = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'plan-phase.md');
const VERIFY_PHASE = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'verify-phase.md');
const CONFIG_TS = path.join(__dirname, '..', 'sdk', 'src', 'config.ts');
const CONFIG_MUTATION_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'config-mutation.ts');
const CONFIG_GATES_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'config-gates.ts');
const QUERY_INDEX_TS = path.join(__dirname, '..', 'sdk', 'src', 'query', 'index.ts');
describe('plan-phase decision-coverage gate (#2492)', () => {
const md = fs.readFileSync(PLAN_PHASE, 'utf-8');
test('contains a Decision Coverage Gate step', () => {
assert.ok(
/Decision Coverage Gate/i.test(md),
'plan-phase.md must define a Decision Coverage Gate step',
);
});
test('invokes the check.decision-coverage-plan handler', () => {
assert.ok(
md.includes('check.decision-coverage-plan'),
'plan-phase.md must call gsd-sdk query check.decision-coverage-plan',
);
});
test('mentions workflow.context_coverage_gate skip clause', () => {
assert.ok(
md.includes('workflow.context_coverage_gate'),
'plan-phase.md must reference workflow.context_coverage_gate to allow skipping',
);
});
test('decision gate appears AFTER the existing Requirements Coverage Gate', () => {
// Anchored heading regexes — avoid prose-substring traps (review F8/F9).
const reqIdx = md.search(/^## 13[a-z]?\.\s+Requirements Coverage Gate/m);
const decIdx = md.search(/^## 13[a-z]?\.\s+Decision Coverage Gate/m);
assert.ok(reqIdx !== -1, 'Requirements Coverage Gate heading must exist as ## 13[a-z]?.');
assert.ok(decIdx !== -1, 'Decision Coverage Gate heading must exist as ## 13[a-z]?.');
assert.ok(decIdx > reqIdx, 'Decision gate must run after Requirements gate');
});
test('decision gate appears BEFORE plans are committed', () => {
const decIdx = md.search(/^## 13[a-z]?\.\s+Decision Coverage Gate/m);
const commitIdx = md.search(/^## 13[a-z]?\.\s+Commit Plans/m);
assert.ok(decIdx !== -1, 'Decision Coverage Gate heading must exist as ## 13[a-z]?.');
assert.ok(commitIdx !== -1, 'Commit Plans heading must exist as ## 13[a-z]?.');
assert.ok(decIdx < commitIdx, 'Decision gate must run before commit so failures block the commit');
});
test('plan-phase Decision Coverage Gate uses CONTEXT_PATH variable defined in INIT extraction (review F1)', () => {
// The CONTEXT_PATH bash variable is defined at Step 4 (`CONTEXT_PATH=$(_gsd_field "$INIT" context_path)`).
// The plan-phase gate snippet must reference the same casing — `${CONTEXT_PATH}` — not `${context_path}`,
// otherwise the BLOCKING gate is invoked with an empty path and silently skips.
const defIdx = md.indexOf('CONTEXT_PATH=$(_gsd_field "$INIT" context_path)');
assert.ok(defIdx !== -1, 'CONTEXT_PATH must be defined from INIT JSON');
const gateIdx = md.indexOf('check.decision-coverage-plan');
assert.ok(gateIdx !== -1, 'check.decision-coverage-plan invocation must exist');
// Slice the surrounding gate snippet (~600 chars) and verify variable casing matches the definition.
const snippet = md.slice(Math.max(0, gateIdx - 200), gateIdx + 400);
assert.ok(
snippet.includes('${CONTEXT_PATH}'),
'Gate snippet must reference ${CONTEXT_PATH} (uppercase) to match the variable defined in Step 4',
);
assert.ok(
!snippet.includes('${context_path}'),
'Gate snippet must NOT reference ${context_path} (lowercase) — that name is undefined in shell scope',
);
});
test('plan-phase blocking gate exits non-zero on failure (review F15)', () => {
// The gate is documented as BLOCKING. To actually block, the shell snippet must
// exit with non-zero status when `passed` is false. Without exit-1 the workflow
// continues silently past the failure.
const gateIdx = md.indexOf('check.decision-coverage-plan');
assert.ok(gateIdx !== -1);
const snippet = md.slice(gateIdx, gateIdx + 800);
// Accept either an inline `|| exit 1` or a `|| { ...; exit 1; }` group.
const hasJqGuard = /jq[^\n]*passed\s*==\s*true/.test(snippet);
const hasExitOne = /\|\|\s*(?:exit\s+1|\{[\s\S]{0,200}?exit\s+1)/.test(snippet);
assert.ok(
hasJqGuard && hasExitOne,
'plan-phase gate must guard with `jq -e .passed == true || exit 1` (or `|| { ...; exit 1; }`) to actually block',
);
});
});
describe('verify-phase decision-coverage gate (#2492)', () => {
const md = fs.readFileSync(VERIFY_PHASE, 'utf-8');
test('contains a verify_decisions step', () => {
assert.ok(
/verify_decisions/.test(md),
'verify-phase.md must define a verify_decisions step',
);
});
test('invokes the check.decision-coverage-verify handler', () => {
assert.ok(
md.includes('check.decision-coverage-verify'),
'verify-phase.md must call gsd-sdk query check.decision-coverage-verify',
);
});
test('declares the decision gate as non-blocking / warning only', () => {
const lower = md.toLowerCase();
assert.ok(
lower.includes('non-blocking') || lower.includes('warning only') || lower.includes('not block'),
'verify-phase.md must declare the decision gate is non-blocking',
);
});
test('mentions workflow.context_coverage_gate skip clause', () => {
assert.ok(
md.includes('workflow.context_coverage_gate'),
'verify-phase.md must reference workflow.context_coverage_gate to allow skipping',
);
});
});
describe('SDK wiring for #2492 gates', () => {
test('config.ts WorkflowConfig has context_coverage_gate key', () => {
const c = fs.readFileSync(CONFIG_TS, 'utf-8');
assert.ok(c.includes('context_coverage_gate'), 'WorkflowConfig must declare context_coverage_gate');
assert.ok(
/context_coverage_gate:\s*true/.test(c),
'CONFIG_DEFAULTS.workflow.context_coverage_gate must default to true',
);
});
test('config-mutation.ts VALID_CONFIG_KEYS allows workflow.context_coverage_gate', () => {
const c = fs.readFileSync(CONFIG_MUTATION_TS, 'utf-8');
assert.ok(
c.includes("'workflow.context_coverage_gate'"),
'workflow.context_coverage_gate must be in VALID_CONFIG_KEYS',
);
});
test('config-gates.ts surfaces context_coverage_gate', () => {
const c = fs.readFileSync(CONFIG_GATES_TS, 'utf-8');
assert.ok(
c.includes('context_coverage_gate'),
'check.config-gates must expose context_coverage_gate to workflows',
);
});
test('query index.ts registers the new handlers', () => {
const c = fs.readFileSync(QUERY_INDEX_TS, 'utf-8');
assert.ok(c.includes('check.decision-coverage-plan'), 'check.decision-coverage-plan handler must be registered');
assert.ok(c.includes('check.decision-coverage-verify'), 'check.decision-coverage-verify handler must be registered');
assert.ok(c.includes('decisions.parse'), 'decisions.parse handler must be registered');
});
});