Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wise-dogs-frolic.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
type: Fixed
pr: 5030
---
**`check decision-coverage-plan` no longer reports `covered: 0` when the phase-dir argument is not a directory or holds no plans.** Passing the phase number (e.g. `01`) where the phase directory belongs loaded zero plans, and the gate then listed every decision as uncovered, which looked exactly like a real coverage gap. Both decision-coverage gates now answer `reason: "phase directory not found"` (or `"phase path is not a directory"` when the argument names a file) and name the argument: the plan gate still fails closed, with `total`/`covered` null instead of a measured-looking zero, and the verify gate returns a non-blocking warning with no decision reported not-honored. A phase dir that cannot be read (e.g. `EACCES`) answers `reason: "phase directory unreadable"` with the error code and exits UNAVAILABLE (69) rather than being reported as missing. A phase dir with no PLAN file (for verify, no PLAN or SUMMARY file) answers `reason: "phase directory holds no plans"` the same way. Calling the plan gate with no phase-dir argument behaves as before. (#4939)
7 changes: 7 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -1440,6 +1440,13 @@ a caller error — the gate fails closed with the missing-argument error, the
same as calling it with no context path at all, rather than silently
skipping. The phase directory remains a separate positional argument (there
is no `--phase` flag; the workflow caller passes both positionals).
A phase-dir argument that names no directory is a caller error too: the gate
fails closed with `total`/`covered` null and `reason` `"phase directory not
found"` (nothing is there, e.g. the phase number `01` was passed), `"phase
path is not a directory"` (it names a file) or `"phase directory unreadable"`
(it could not be read; the exit status is then UNAVAILABLE, 69). A phase
directory that holds no PLAN file answers `reason` `"phase directory holds no
plans"` the same way: zero plans loaded is not zero coverage.

### How to write decisions the gates accept

Expand Down
4 changes: 2 additions & 2 deletions docs/INVENTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -588,8 +588,8 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `gate-codebase-drift.cjs` | `check verify-codebase-drift` gate module (compiled from `src/gate-codebase-drift.cts`, gitignored; #5219, ADR-5057 §4) — `evaluateCodebaseDriftGate({projectDir, args})` returns a `GateResult`; the `drift` capability's non-blocking gate at `execute:wave:post` (blocks only when `detectDrift` reports `actionRequired`); no stamped baseline, no `STRUCTURE.md` or no git repository is a `skip`; a stamp that is not a hex object id (7–64 hex characters: a ref name, a shorter abbreviation, an option-shaped value) or a baseline git cannot resolve, a failing `git diff`, an unreadable `STRUCTURE.md` or, for a non-blocking answer, an unreadable sibling map document, is outcome `unreadable` (exit UNAVAILABLE); a throw is a non-blocking `unreadable` verdict; never imports `io.cjs` |
| `gate-config.cjs` | Gate config reader (compiled from `src/gate-config.cts`, gitignored; #5139, fixes #4978) — `readWorkflowConfigValue` and `isDecisionCoverageGateEnabled`: `workflow.*` switches read NESTED-ONLY through the shared dot-path resolver `config-get` is defined by; a top-level key is never read; QUIET (a malformed `config.json` is "key absent", nothing on stderr); also used by `verify context-drift` / `verify codebase-drift` |
| `gate-context-drift.cjs` | `check verify-context-drift` gate module (compiled from `src/gate-context-drift.cts`, gitignored; #5219, ADR-5057 §4) — `evaluateContextDriftGate({projectDir, args})` returns a `GateResult` (and `computeContextDrift`, the strict-`<` staleness comparator); the `drift` capability's gate at `plan:pre`, blocking only under `workflow.context_drift_action: block`; an absent phases tree, no `CONTEXT.md` or no upstream artifact is a `skip`; a phases tree or phase that cannot be examined or resolved, or a throw, is a non-blocking `unreadable` verdict (exit UNAVAILABLE); usage failure `Usage: verify context-drift <phase>` (reason `unknown`); never imports `io.cjs` |
| `gate-decision-coverage-plan.cjs` | `check decision-coverage-plan` gate module (compiled from `src/gate-decision-coverage-plan.cts`, gitignored; #5139) — `evaluateDecisionCoveragePlan({projectDir, args})` returns a `GateResult` (blocking, fail-closed); an unreadable CONTEXT.md or plan, or a plan scan that did not see every plan, is outcome `unreadable` (exit UNAVAILABLE), never "no plan cites the decision"; never imports `io.cjs` |
| `gate-decision-coverage-verify.cjs` | `check decision-coverage-verify` gate module (compiled from `src/gate-decision-coverage-verify.cts`, gitignored; #5139) — `evaluateDecisionCoverageVerify({projectDir, args})` returns a `GateResult`; advisory, always `blocking:false`; an unreadable CONTEXT.md, SUMMARY or listed file is outcome `unreadable` (exit UNAVAILABLE), never an empty haystack; never imports `io.cjs` |
| `gate-decision-coverage-plan.cjs` | `check decision-coverage-plan` gate module (compiled from `src/gate-decision-coverage-plan.cts`, gitignored; #5139) — `evaluateDecisionCoveragePlan({projectDir, args})` returns a `GateResult` (blocking, fail-closed); an unreadable CONTEXT.md or plan, or a plan scan that did not see every plan, is outcome `unreadable` (exit UNAVAILABLE), never "no plan cites the decision"; a phase-dir argument that is not a directory is a caller error (#4939) — `reason` `phase directory not found` (ENOENT/ENOTDIR), `phase path is not a directory` or `phase directory unreadable` (outcome `unreadable`), and a directory with no PLAN file is `phase directory holds no plans`, blocking, with null `total`/`covered`; never imports `io.cjs` |
| `gate-decision-coverage-verify.cjs` | `check decision-coverage-verify` gate module (compiled from `src/gate-decision-coverage-verify.cts`, gitignored; #5139) — `evaluateDecisionCoverageVerify({projectDir, args})` returns a `GateResult`; advisory, always `blocking:false`; an unreadable CONTEXT.md, SUMMARY or listed file is outcome `unreadable` (exit UNAVAILABLE), never an empty haystack; a phase-dir argument that is not a directory is a caller error (#4939) — `reason` `phase directory not found` (ENOENT/ENOTDIR), `phase path is not a directory` or `phase directory unreadable` (outcome `unreadable`), and a directory with no PLAN or SUMMARY file is `phase directory holds no plans`, with null `total`/`honored`; never imports `io.cjs` |
| `gate-evaluation-scope.cjs` | The one evaluation-scope resolver (compiled from `src/gate-evaluation-scope.cts`, gitignored; #5164, ADR-5057 §4) — `resolveEvaluationScope(projectDir, unit, options)` answers which commits and files a gate or workflow step evaluates for a phase, plan or quick task: the union of the unit's own commits' file sets, branch-reachable only, an empty union degraded loudly, what it drops named by path; a phase whose directory, SUMMARYs or plan scan (an unreadable nested `plans/`) could not be read is `unresolvable` — `check evaluation-scope` outcome `unreadable`, exit UNAVAILABLE — never a narrower scope; `evaluateEvaluationScope` is the `check evaluation-scope` verb; never imports `io.cjs` |
| `gate-evidence.cjs` | Typed gate evidence (compiled from `src/gate-evidence.cts`, gitignored; #5170, ADR-5057 §4) — `Evidence<T>` (`found` / `none` / `unreadable{reason, span?}`) with its constructors `evidenceFound` / `evidenceNone` / `evidenceUnreadable` / `evidenceFromError`, `Found<T>` and `Observed<T>` (evidence whose absence the caller already decided), the readers `readTextEvidence` / `readDirEvidence` / `readDirEntriesEvidence` / `statEvidence` (ENOENT and ENOTDIR are `none`; every other failure is `unreadable` carrying the errno), `readPlanScanEvidence` / `readPlanSetEvidence` (the phase's plan and summary set from `scanPhasePlans`; any scope other than `COMPLETE` — an existing nested `plans/` that could not be read — is `unreadable`, never a short list) and `verdictFromEvidence`, whose `unreadable` arm is typed to return an `UnreadableVerdict` so a passing verdict from it does not type-check; `scripts/lint-gate-evidence-drift.cjs` enforces the rest |
| `gate-exit.cjs` | The exit status follows the verdict (compiled from `src/gate-exit.cts`, gitignored; #5170, ADR-5057 §4) — `gateExitOutcome(verdict, 'payload' or 'status')` is total (`pass`, `skip`, `advisory` → PASS; `block` → PASS in payload mode, FAIL in status mode; `unreadable` → UNAVAILABLE in both; `empty` — ran, the scope is genuinely empty — → NO_INPUT in status mode, PASS in payload mode) and `declareGateExit(verdict, mode)` declares it through `cli-exit` after `output()`; it takes a real `GateVerdict`, never a synthetic `{outcome}` |
Expand Down
9 changes: 8 additions & 1 deletion gsd-core/references/verifier-phase-gates.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ fi
```

The handler returns JSON `{ skipped, blocking: false, total, honored,
not_honored: [...], message }`. A non-zero `DECISION_EXIT` (`69` `UNAVAILABLE`: the gate
not_honored: [...], message }`, plus `reason` on a skip or a caller error. On a
caller error about the phase directory (`reason` is `'phase directory not found'`,
`'phase path is not a directory'`, `'phase directory unreadable'` or
`'phase directory holds no plans'`), `total` and `honored` are `null` and
`not_honored` is `[]`:
nothing was checked, so there is no count to report. A non-zero `DECISION_EXIT` (`69` `UNAVAILABLE`: the gate
could not read CONTEXT.md or the phase artifacts, #5170) is **not** "no decisions": record
`Decision coverage: not evaluated (the gate could not read its evidence)` in VERIFICATION.md
and do not report counts.
Expand All @@ -49,6 +54,8 @@ section) to VERIFICATION.md regardless of outcome — even when all
decisions are honored, recording the count helps reviewers spot drift over
time. Set `decision_coverage` in the verification result to
`{honored, total, not_honored: [...]}` so downstream tooling can read it.
On a caller error that is `{honored: null, total: null, not_honored: []}`;
pass the nulls through rather than coercing them to `0`.

**Status impact:** none. The decision gate does NOT influence the
`gaps_found` / `human_needed` / `passed` decision tree in Step 9. Its
Expand Down
4 changes: 4 additions & 0 deletions gsd-core/workflows/plan-phase.md
Original file line number Diff line number Diff line change
Expand Up @@ -1373,6 +1373,10 @@ The handler returns JSON:
"uncovered": [{ "id": "D-01", "text": "...", "category": "..." }], "message": "..." }
```

On a caller error (non-file context, `could-not-parse`, a bad phase dir or one with no plans)
`total` and `covered` are `null`, `uncovered` is omitted, `passed` stays
`false`. A missing context argument answers `total: 0`.

**If `passed` is true (or `skipped` is true):** Display
`✓ Decision coverage: {M}/{N} decisions covered` (or `(skipped)`) and proceed
to step 13b.
Expand Down
18 changes: 17 additions & 1 deletion src/decision-coverage-support.cts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import type { Decision } from './decisions.cjs';
import { locateFrontmatterFence } from './frontmatter-fence.cjs';
import { stripFencedCode, collectSections, extractXmlTagBodies } from './markdown-sectionizer.cjs';
import { tryWithinRoot, PathAcceptance } from './security.cjs';
import { readDirEvidence, readTextEvidence, evidenceFound } from './gate-evidence.cjs';
import { readDirEvidence, readTextEvidence, statEvidence, evidenceFound } from './gate-evidence.cjs';
import type { Evidence, Observed } from './gate-evidence.cjs';
import { resolveEvaluationScope } from './gate-evaluation-scope.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
Expand Down Expand Up @@ -63,6 +63,22 @@ export function decisionMentioned(haystack: string | null | undefined, decision:

// ─── File reading ─────────────────────────────────────────────────────────────

// #4939: loadPlanContents answers no files for a path that is not there, and that reads
// as "no plan cites anything" — so a phase-dir argument that names no directory
// (the phase NUMBER in the phase-dir slot) must be told apart BEFORE the scan.
// Answers null for a directory, else what is wrong with the path and what to do
// about it. Like the plan gate's contextKind (#4794), the message names the real
// stat outcome: a permission or I/O failure (EACCES, ELOOP, EIO) is reported as
// unreadable, not as a missing directory. Only ENOENT/ENOTDIR mean the path is not there.
export function phaseDirProblem(dirPath: string): { reason: string; what: string; hint: string; unreadable: boolean } | null {
const passDirectory = 'Pass the phase DIRECTORY (e.g. .planning/phases/01-slug), not the phase number.';
const st = statEvidence(dirPath);
if (st.kind === 'none') return { reason: 'phase directory not found', what: 'does not exist', hint: passDirectory, unreadable: false };
if (st.kind === 'unreadable') return { reason: 'phase directory unreadable', what: `could not be read (${st.reason})`, hint: 'Check that the directory is readable.', unreadable: true };
if (st.value.isDirectory()) return null;
return { reason: 'phase path is not a directory', what: st.value.isFile() ? 'is a file, not a directory' : 'is not a directory', hint: passDirectory, unreadable: false };
}

/**
* The contents of the phase files `pick` selects (#5170, ADR-5057 §4). An ABSENT phase directory
* is "no files" (`found []`); a directory or file that exists but cannot be read is `unreadable` —
Expand Down
17 changes: 17 additions & 0 deletions src/gate-decision-coverage-plan.cts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
decisionMentioned,
extractPlanDesignatedSections,
loadPlanContents,
phaseDirProblem,
loadDecisionExtraction,
buildPlanMessage,
} from './decision-coverage-support.cjs';
Expand Down Expand Up @@ -156,8 +157,24 @@ export function evaluateDecisionCoveragePlan(input: { projectDir: string; args:
return gateVerdict('skip', false, { passed: true, skipped: true, reason: 'no trackable decisions', total: 0, covered: 0, uncovered: [], message: 'No trackable decisions in CONTEXT.md.' });
}

// #4939: a phase-dir argument that was GIVEN but is not a directory is a caller
// error like #2770's empty context argument and #4794's non-file context path:
// no plan was scanned, so nothing was measured. Fail closed with total/covered
// null and `uncovered` omitted. An OMITTED phase dir is left to the scan below —
// the #4130 `--context <path>` alone tests pin that as a coverage gap.
const phaseDirIssue = phaseDir ? phaseDirProblem(phaseDir) : null;
if (phaseDirIssue) {
const phaseDirResult = { passed: false, skipped: false, reason: phaseDirIssue.reason, total: null, covered: null, message: `Decision coverage gate: the phase directory "${positionals[0]}" ${phaseDirIssue.what}, so no plans were scanned. ${phaseDirIssue.hint}` };
return phaseDirIssue.unreadable ? gateUnreadable(true, phaseDirResult) : gateVerdict('block', true, phaseDirResult);
}

const planContents = loadPlanContents(phaseDir);
if (planContents.kind === 'unreadable') return unreadableDecisionGate(`${planContents.span ?? phaseDir}: ${planContents.reason}`);
// #4939: a phase directory that holds no PLAN file is the same caller error: zero plans
// loaded is not zero coverage.
if (phaseDir && planContents.value.length === 0) {
return gateVerdict('block', true, { passed: false, skipped: false, reason: 'phase directory holds no plans', total: null, covered: null, message: `Decision coverage gate: the phase directory "${positionals[0]}" holds no PLAN files, so no plans were scanned. Pass the phase DIRECTORY that holds the plans (e.g. .planning/phases/01-slug), not the phase number.` });
}
const sections = planContents.value.map(extractPlanDesignatedSections);
const uncovered: UncoveredItem[] = [];
let covered = 0;
Expand Down
15 changes: 15 additions & 0 deletions src/gate-decision-coverage-verify.cts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { isDecisionCoverageGateEnabled } from './gate-config.cjs';
import {
decisionMentioned,
loadPlanContents,
phaseDirProblem,
loadSummaryContents,
loadDecisionExtraction,
readModifiedFilesContent,
Expand Down Expand Up @@ -103,12 +104,26 @@ export function evaluateDecisionCoverageVerify(input: { projectDir: string; args
return gateVerdict('skip', false, { skipped: true, blocking: false, reason: 'no trackable decisions', total: 0, honored: 0, not_honored: [], message: 'No trackable decisions in CONTEXT.md.' });
}

// #4939: mirror the plan gate. A phase-dir argument that names no directory
// means no plan or summary was scanned, so no decision can be reported
// not-honored. Warn (verify stays non-blocking) rather than list every decision.
const phaseDirIssue = phaseDir ? phaseDirProblem(phaseDir) : null;
if (phaseDirIssue) {
const phaseDirResult = { skipped: false, blocking: false, reason: phaseDirIssue.reason, total: null, honored: null, not_honored: [], message: `Decision coverage verify (warning): the phase directory "${args[0]}" ${phaseDirIssue.what}, so no plans or summaries were scanned. ${phaseDirIssue.hint}` };
return phaseDirIssue.unreadable ? gateUnreadable(false, phaseDirResult) : gateVerdict('advisory', false, phaseDirResult);
}

// Every file the haystack is built from is typed evidence (#5170): one that exists but cannot be
// read is `unreadable`, never an empty slice of the haystack that makes a decision look unhonored.
const planContents = loadPlanContents(phaseDir);
if (planContents.kind === 'unreadable') return unreadableVerify(`${planContents.span ?? phaseDir}: ${planContents.reason}`);
const summaryParts = loadSummaryContents(phaseDir);
if (summaryParts.kind === 'unreadable') return unreadableVerify(`${summaryParts.span ?? phaseDir}: ${summaryParts.reason}`);
// #4939: mirror the plan gate. A phase directory with no PLAN and no SUMMARY file gave the
// verifier nothing to read, which is a caller error, not a phase whose decisions were all missed.
if (phaseDir && planContents.value.length === 0 && summaryParts.value.length === 0) {
return gateVerdict('advisory', false, { skipped: false, blocking: false, reason: 'phase directory holds no plans', total: null, honored: null, not_honored: [], message: `Decision coverage verify (warning): the phase directory "${args[0]}" holds no PLAN or SUMMARY files, so nothing was scanned. Pass the phase DIRECTORY that holds the plans (e.g. .planning/phases/01-slug), not the phase number.` });
}
const modifiedFiles = readModifiedFilesContent(projectDir, summaryParts.value);
if (modifiedFiles.kind === 'unreadable') return unreadableVerify(`${modifiedFiles.span ?? 'files_modified'}: ${modifiedFiles.reason}`);
const haystack = [
Expand Down
Loading
Loading