From 7c94d1a181f026efee0fe0d42fe58d29162f87ce Mon Sep 17 00:00:00 2001 From: drakeo338 Date: Sat, 17 Oct 2026 15:31:26 +0000 Subject: [PATCH] fix(#4939): answer a caller error for a phase dir with nothing to scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decision-coverage-plan and decision-coverage-verify used to report a measured zero (covered: 0, every decision uncovered) when the phase-dir argument named nothing they could scan: the phase NUMBER, a file, an unreadable path, or a directory with no PLAN file. Both gates now check the phase dir through one shared helper (phaseDirProblem) before the scan and treat an empty scan as the same caller error after it. Totals are null, the plan gate still blocks, verify stays a non-blocking warning, and the message names the argument and asks for the phase DIRECTORY. - not found / not a directory / holds no plans: delivered caller error (exit 0); unreadable: UNAVAILABLE (exit 69). - Pinned in the gate unit suites, tests/decisions.test.cjs (in-process and through the CLI), and E1/E2 cutover arms with goldens. - Docs: CONFIGURATION.md, INVENTORY.md, verifier-phase-gates.md, plan-phase.md; changeset added; compact-content baseline regenerated. Emitted-Drift-Ack-Growth: plan-phase.md — documents the null totals the decision-coverage plan gate now answers for a caller error, including a bad phase dir or one with no plans (#4939) --- .changeset/wise-dogs-frolic.md | 5 + docs/CONFIGURATION.md | 7 + docs/INVENTORY.md | 4 +- gsd-core/references/verifier-phase-gates.md | 9 +- gsd-core/workflows/plan-phase.md | 4 + src/decision-coverage-support.cts | 18 +- src/gate-decision-coverage-plan.cts | 17 ++ src/gate-decision-coverage-verify.cts | 15 ++ tests/decisions.test.cjs | 241 ++++++++++++++++++ .../compact-content-benchmark-baseline.json | 12 +- tests/fixtures/gate-cutover/arms.cjs | 8 + ...ision-coverage-plan-phase-dir-is-file.json | 12 + ...sion-coverage-plan-phase-dir-no-plans.json | 12 + ...ion-coverage-plan-phase-dir-not-found.json | 12 + ...ion-coverage-verify-phase-dir-is-file.json | 12 + ...on-coverage-verify-phase-dir-no-plans.json | 12 + ...n-coverage-verify-phase-dir-not-found.json | 12 + .../gate-decision-coverage-plan.unit.test.cjs | 93 +++++++ ...ate-decision-coverage-verify.unit.test.cjs | 100 ++++++++ tests/representative-corpus.test.cjs | 2 + ...sdk-removal-query-family-dispatch.test.cjs | 2 + 21 files changed, 599 insertions(+), 10 deletions(-) create mode 100644 .changeset/wise-dogs-frolic.md create mode 100644 tests/fixtures/gate-cutover/decision-coverage-plan-phase-dir-is-file.json create mode 100644 tests/fixtures/gate-cutover/decision-coverage-plan-phase-dir-no-plans.json create mode 100644 tests/fixtures/gate-cutover/decision-coverage-plan-phase-dir-not-found.json create mode 100644 tests/fixtures/gate-cutover/decision-coverage-verify-phase-dir-is-file.json create mode 100644 tests/fixtures/gate-cutover/decision-coverage-verify-phase-dir-no-plans.json create mode 100644 tests/fixtures/gate-cutover/decision-coverage-verify-phase-dir-not-found.json diff --git a/.changeset/wise-dogs-frolic.md b/.changeset/wise-dogs-frolic.md new file mode 100644 index 0000000000..ecc0f89d07 --- /dev/null +++ b/.changeset/wise-dogs-frolic.md @@ -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) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index d4eb4d2807..e4116c8807 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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 diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index a12d943047..2f6b6871e9 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -589,8 +589,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 ` (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` (`found` / `none` / `unreadable{reason, span?}`) with its constructors `evidenceFound` / `evidenceNone` / `evidenceUnreadable` / `evidenceFromError`, `Found` and `Observed` (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}` | diff --git a/gsd-core/references/verifier-phase-gates.md b/gsd-core/references/verifier-phase-gates.md index d61ed09473..27c36ea1da 100644 --- a/gsd-core/references/verifier-phase-gates.md +++ b/gsd-core/references/verifier-phase-gates.md @@ -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. @@ -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 diff --git a/gsd-core/workflows/plan-phase.md b/gsd-core/workflows/plan-phase.md index f4b3ca4460..b099c2cd7e 100644 --- a/gsd-core/workflows/plan-phase.md +++ b/gsd-core/workflows/plan-phase.md @@ -1389,6 +1389,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. diff --git a/src/decision-coverage-support.cts b/src/decision-coverage-support.cts index 33319e8411..106c997815 100644 --- a/src/decision-coverage-support.cts +++ b/src/decision-coverage-support.cts @@ -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 @@ -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` — diff --git a/src/gate-decision-coverage-plan.cts b/src/gate-decision-coverage-plan.cts index f80bd1fd0b..7d2834cea9 100644 --- a/src/gate-decision-coverage-plan.cts +++ b/src/gate-decision-coverage-plan.cts @@ -27,6 +27,7 @@ import { decisionMentioned, extractPlanDesignatedSections, loadPlanContents, + phaseDirProblem, loadDecisionExtraction, buildPlanMessage, } from './decision-coverage-support.cjs'; @@ -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 ` 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; diff --git a/src/gate-decision-coverage-verify.cts b/src/gate-decision-coverage-verify.cts index 03b73d6648..09f309ca74 100644 --- a/src/gate-decision-coverage-verify.cts +++ b/src/gate-decision-coverage-verify.cts @@ -14,6 +14,7 @@ import { isDecisionCoverageGateEnabled } from './gate-config.cjs'; import { decisionMentioned, loadPlanContents, + phaseDirProblem, loadSummaryContents, loadDecisionExtraction, readModifiedFilesContent, @@ -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 = [ diff --git a/tests/decisions.test.cjs b/tests/decisions.test.cjs index 5f9809111e..33c0b30321 100644 --- a/tests/decisions.test.cjs +++ b/tests/decisions.test.cjs @@ -33,6 +33,9 @@ const fc = require('./helpers/fast-check-setup.cjs'); const { parseDecisions, extractDecisions } = require('../gsd-core/bin/lib/decisions.cjs'); const { iterateBullets } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const { routeCheckCommand } = require('../gsd-core/bin/lib/check-command-router.cjs'); +const { writeMethodFailurePreload } = require('./helpers/fs-failure.cjs'); +const { runTools } = require('./helpers/gsd-tools-cli.cjs'); // ─── Regression #1364: markdown-header fallback ─────────────────────────────── @@ -3245,6 +3248,244 @@ describe('#4794: decision-coverage answers an unmeasured shape on could-not-pars }); }); +// ─── #4939: a phase dir that names no usable plans dir is a caller error, not covered: 0 ── + +describe('#4939: decision-coverage answers a caller error for a phase directory that is not found, not a directory, unreadable, or holds no plans', () => { + let tmpDir; + let phaseDir; + let contextPath; + + beforeEach(() => { + tmpDir = createTempProject('gsd-4939-'); + phaseDir = path.join(tmpDir, '.planning', 'phases', '01-init'); + fs.mkdirSync(phaseDir, { recursive: true }); + writeContextFile(phaseDir, [ + '# Context', + '', + '', + '', + '- **D-01: The list shows one row per contact.** Nothing else changes.', + '- **D-02: Contacts render as cards.** One pair, one card.', + '', + '', + ].join('\n')); + writePlanFile(phaseDir, '01', '# Plan\n## Objective\nImplement D-01 and D-02.\n'); + contextPath = path.join(phaseDir, 'CONTEXT.md'); + }); + + afterEach(() => cleanup(tmpDir)); + + test('#4939: control — the phase DIRECTORY measures full coverage', () => { + const parsed = JSON.parse(runDecisionCoveragePlan(phaseDir, contextPath, tmpDir).output || '{}'); + assert.strictEqual(parsed.passed, true, `control must pass, got: ${JSON.stringify(parsed)}`); + assert.strictEqual(parsed.covered, 2); + }); + + test('#4939: plan gate — the phase NUMBER in the phase-dir slot fails closed as a caller error, not covered: 0', () => { + // The issue's repro: `check decision-coverage-plan 01 `. The plans + // exist and cite both decisions; nothing was scanned because "01" is not a + // directory. The gate used to answer covered: 0 with every decision uncovered. + const parsed = JSON.parse(runDecisionCoveragePlan('01', contextPath, tmpDir).output || '{}'); + + assert.strictEqual(parsed.passed, false, 'the gate must still block'); + assert.strictEqual(parsed.skipped, false, 'must not be a green skip'); + assert.strictEqual(parsed.reason, 'phase directory not found', `got: ${JSON.stringify(parsed)}`); + assert.strictEqual(parsed.total, null, 'total must be null — nothing was measured'); + assert.strictEqual(parsed.covered, null, 'covered must be null — nothing was measured'); + assert.ok(!('uncovered' in parsed), 'uncovered must be OMITTED — the list was never built'); + assert.ok((parsed.message || '').includes('"01"'), 'the message must name the argument it could not find'); + }); + + test('#4939: verify gate — the phase NUMBER in the phase-dir slot answers a non-blocking caller-error warning', () => { + const parsed = JSON.parse( + runGsdTools(['query', 'check.decision-coverage-verify', '01', contextPath], tmpDir).output || '{}', + ); + + assert.strictEqual(parsed.blocking, false, 'verify stays non-blocking'); + assert.strictEqual(parsed.skipped, false, 'must not be a silent skip'); + assert.strictEqual(parsed.reason, 'phase directory not found', `got: ${JSON.stringify(parsed)}`); + assert.deepStrictEqual(parsed.not_honored, [], 'no decision may be reported not-honored from a scan that never happened'); + assert.strictEqual(parsed.honored, null, 'honored must be null — nothing was measured'); + assert.strictEqual(parsed.total, null, 'total must be null — nothing was measured'); + assert.ok((parsed.message || '').includes('"01"'), 'the message must name the argument it could not find'); + }); + + test('#4939: plan gate — a FILE in the phase-dir slot is the same caller error', () => { + // fs.existsSync is true for a file, so the guard must ask for a directory. + const parsed = JSON.parse(runDecisionCoveragePlan(contextPath, contextPath, tmpDir).output || '{}'); + + assert.strictEqual(parsed.passed, false, 'the gate must still block'); + assert.strictEqual(parsed.reason, 'phase path is not a directory', `got: ${JSON.stringify(parsed)}`); + assert.strictEqual(parsed.covered, null, 'covered must be null — nothing was measured'); + assert.ok((parsed.message || '').includes('is a file, not a directory'), + `the message must say the path is a file, not that it does not exist: ${JSON.stringify(parsed.message)}`); + }); + + test('#4939: verify gate — control: the phase DIRECTORY measures honored decisions', () => { + const parsed = JSON.parse( + runGsdTools(['query', 'check.decision-coverage-verify', phaseDir, contextPath], tmpDir).output || '{}', + ); + + assert.strictEqual(parsed.reason, undefined, `control must not be a caller error, got: ${JSON.stringify(parsed)}`); + assert.strictEqual(parsed.total, 2); + assert.strictEqual(parsed.honored, 2); + }); + + test('#4939: verify gate — a FILE in the phase-dir slot is the same caller error', () => { + // A verify guard that only asked fs.existsSync would pass a file through. + const parsed = JSON.parse( + runGsdTools(['query', 'check.decision-coverage-verify', contextPath, contextPath], tmpDir).output || '{}', + ); + + assert.strictEqual(parsed.blocking, false, 'verify stays non-blocking'); + assert.strictEqual(parsed.skipped, false, 'must not be a silent skip'); + assert.strictEqual(parsed.reason, 'phase path is not a directory', `got: ${JSON.stringify(parsed)}`); + assert.deepStrictEqual(parsed.not_honored, [], 'no decision may be reported not-honored from a scan that never happened'); + assert.strictEqual(parsed.honored, null, 'honored must be null — nothing was measured'); + assert.strictEqual(parsed.total, null, 'total must be null — nothing was measured'); + assert.ok((parsed.message || '').includes('is a file, not a directory'), + `the message must say the path is a file, not that it does not exist: ${JSON.stringify(parsed.message)}`); + }); + + // A stat failure other than ENOENT/ENOTDIR (a permission or I/O error) must be + // named as such, not reported as a missing directory. The failure is injected + // in-process by mocking fs.statSync for the phase dir alone, not by removing + // permission bits: root bypasses them and Windows ignores them. + function runGateWithStatFailure(t, subcommand, code) { + const realStatSync = fs.statSync; + t.mock.method(fs, 'statSync', (p, ...rest) => { + if (typeof p === 'string' && path.resolve(p) === path.resolve(phaseDir)) { + const err = new Error(`${code}: injected stat failure, stat '${p}'`); + err.code = code; + throw err; + } + return realStatSync.call(fs, p, ...rest); + }); + let captured = ''; + const realWriteSync = fs.writeSync; + t.mock.method(fs, 'writeSync', (fd, ...args) => { + if (fd === 1) { + const buf = args[0]; + captured += Buffer.isBuffer(buf) ? buf.toString('utf-8') : String(buf); + return Buffer.isBuffer(buf) ? buf.length : Buffer.byteLength(String(buf)); + } + return realWriteSync.call(fs, fd, ...args); + }); + try { + routeCheckCommand({ args: ['check', subcommand, phaseDir, contextPath], cwd: tmpDir, raw: false }); + } finally { + t.mock.restoreAll(); + } + return JSON.parse(captured || '{}'); + } + + test('#4939: plan gate — an ENOENT stat failure is named as a missing directory', (t) => { + const parsed = runGateWithStatFailure(t, 'decision-coverage-plan', 'ENOENT'); + + assert.strictEqual(parsed.passed, false, 'the gate must still block'); + assert.strictEqual(parsed.reason, 'phase directory not found', `got: ${JSON.stringify(parsed)}`); + assert.strictEqual(parsed.covered, null, 'covered must be null — nothing was measured'); + }); + + test('#4939: verify gate — an ENOENT stat failure is named as a missing directory', (t) => { + const parsed = runGateWithStatFailure(t, 'decision-coverage-verify', 'ENOENT'); + + assert.strictEqual(parsed.blocking, false, 'verify stays non-blocking'); + assert.strictEqual(parsed.reason, 'phase directory not found', `got: ${JSON.stringify(parsed)}`); + assert.strictEqual(parsed.honored, null, 'honored must be null — nothing was measured'); + assert.strictEqual(parsed.total, null, 'total must be null — nothing was measured'); + }); + + test('#4939: plan gate — an ENOTDIR stat failure is named as a missing directory', (t) => { + const parsed = runGateWithStatFailure(t, 'decision-coverage-plan', 'ENOTDIR'); + + assert.strictEqual(parsed.passed, false, 'the gate must still block'); + assert.strictEqual(parsed.reason, 'phase directory not found', `got: ${JSON.stringify(parsed)}`); + assert.strictEqual(parsed.covered, null, 'covered must be null — nothing was measured'); + }); + + test('#4939: verify gate — an ENOTDIR stat failure is named as a missing directory', (t) => { + const parsed = runGateWithStatFailure(t, 'decision-coverage-verify', 'ENOTDIR'); + + assert.strictEqual(parsed.blocking, false, 'verify stays non-blocking'); + assert.strictEqual(parsed.reason, 'phase directory not found', `got: ${JSON.stringify(parsed)}`); + assert.strictEqual(parsed.honored, null, 'honored must be null — nothing was measured'); + assert.strictEqual(parsed.total, null, 'total must be null — nothing was measured'); + }); + + test('#4939: plan gate — an EACCES stat failure is named as unreadable, not as a missing directory', (t) => { + const parsed = runGateWithStatFailure(t, 'decision-coverage-plan', 'EACCES'); + + assert.strictEqual(parsed.passed, false, 'the gate must still block'); + assert.strictEqual(parsed.reason, 'phase directory unreadable', `got: ${JSON.stringify(parsed)}`); + assert.strictEqual(parsed.covered, null, 'covered must be null — nothing was measured'); + assert.ok((parsed.message || '').includes('could not be read (EACCES)'), + `the message must name the stat outcome: ${JSON.stringify(parsed.message)}`); + assert.ok(!(parsed.message || '').includes('does not exist'), + `a permission failure is not a missing directory: ${JSON.stringify(parsed.message)}`); + }); + + test('#4939: verify gate — an EACCES stat failure is named as unreadable, not as a missing directory', (t) => { + const parsed = runGateWithStatFailure(t, 'decision-coverage-verify', 'EACCES'); + + assert.strictEqual(parsed.blocking, false, 'verify stays non-blocking'); + assert.strictEqual(parsed.reason, 'phase directory unreadable', `got: ${JSON.stringify(parsed)}`); + assert.strictEqual(parsed.honored, null, 'honored must be null — nothing was measured'); + assert.strictEqual(parsed.total, null, 'total must be null — nothing was measured'); + assert.ok((parsed.message || '').includes('could not be read (EACCES)'), + `the message must name the stat outcome: ${JSON.stringify(parsed.message)}`); + assert.ok(!(parsed.message || '').includes('does not exist'), + `a permission failure is not a missing directory: ${JSON.stringify(parsed.message)}`); + }); + + // The exit status follows the verdict (#5170): a phase dir the gate could not look at is + // UNAVAILABLE (69) through the real CLI, while "not found" and "not a directory" are delivered + // caller errors (exit 0, the verdict is read from stdout). The stat failure is injected into the + // child process by a `--require` preload; an in-process mock cannot reach it. + function runGateCli(subcommand, phaseDirArg, { failStat = false } = {}) { + const preload = failStat ? writeMethodFailurePreload(tmpDir, 'statSync', `${path.sep}01-init`) : undefined; + const r = runTools(['query', `check.${subcommand}`, phaseDirArg, contextPath], tmpDir, preload === undefined ? {} : { preload }); + return { exitCode: r.exitCode, parsed: JSON.parse(r.stdout || '{}'), detail: `${r.stdout} ${r.stderr}` }; + } + + for (const [subcommand, policyHolds] of [ + ['decision-coverage-plan', (parsed) => assert.strictEqual(parsed.passed, false, 'the plan gate must still block')], + ['decision-coverage-verify', (parsed) => assert.strictEqual(parsed.blocking, false, 'verify stays non-blocking')], + ]) { + test(`#4939: ${subcommand} — an unreadable phase dir exits UNAVAILABLE (69) through the router`, () => { + const { exitCode, parsed, detail } = runGateCli(subcommand, phaseDir, { failStat: true }); + + assert.strictEqual(parsed.reason, 'phase directory unreadable', `got: ${detail}`); + assert.strictEqual(parsed.total, null, 'total must be null — nothing was measured'); + policyHolds(parsed); + assert.strictEqual(exitCode, 69, `"could not look" is never exit 0: ${detail}`); + }); + + test(`#4939: ${subcommand} — a phase dir that is not there or not a directory is a delivered caller error (exit 0)`, () => { + for (const [arg, reason] of [['01', 'phase directory not found'], [contextPath, 'phase path is not a directory']]) { + const { exitCode, parsed, detail } = runGateCli(subcommand, arg); + + assert.strictEqual(parsed.reason, reason, `${arg}: got ${detail}`); + policyHolds(parsed); + assert.strictEqual(exitCode, 0, `${arg}: a delivered caller error is not UNAVAILABLE: ${detail}`); + } + }); + + test(`#4939: ${subcommand} — an existing phase dir that holds no plans is a delivered caller error (exit 0)`, () => { + // The directory exists, so the stat guard passes; the scan then loads zero plans, + // which must not be reported as a measured covered/honored: 0. + const emptyDir = path.join(tmpDir, '.planning', 'phases', '02-empty'); + fs.mkdirSync(emptyDir, { recursive: true }); + const { exitCode, parsed, detail } = runGateCli(subcommand, emptyDir); + + assert.strictEqual(parsed.reason, 'phase directory holds no plans', `got: ${detail}`); + assert.strictEqual(parsed.total, null, 'total must be null — nothing was measured'); + policyHolds(parsed); + assert.strictEqual(exitCode, 0, `a delivered caller error is not UNAVAILABLE: ${detail}`); + }); + } +}); + // ─── #4906 Phase 3 (#4958, #4793): a second plain-prose colon in a decision title ─ // no longer causes could-not-parse — the separator is the LAST bare colon before // the closing **. A colon-less bullet must still be rejected (#1639 discipline). diff --git a/tests/fixtures/compact-content-benchmark-baseline.json b/tests/fixtures/compact-content-benchmark-baseline.json index a516213e89..9964c43d65 100644 --- a/tests/fixtures/compact-content-benchmark-baseline.json +++ b/tests/fixtures/compact-content-benchmark-baseline.json @@ -28,9 +28,9 @@ "reductionPct": 13.37 }, "plan-phase": { - "offTokens": 28447, - "onTokens": 25179, - "reductionPct": 11.49 + "offTokens": 28509, + "onTokens": 25241, + "reductionPct": 11.46 }, "verify-work": { "offTokens": 15378, @@ -39,8 +39,8 @@ } }, "aggregate": { - "offTokens": 112429, - "onTokens": 94808, - "reductionPct": 15.67 + "offTokens": 112491, + "onTokens": 94870, + "reductionPct": 15.66 } } diff --git a/tests/fixtures/gate-cutover/arms.cjs b/tests/fixtures/gate-cutover/arms.cjs index 4ef51217ee..0cb66acbea 100644 --- a/tests/fixtures/gate-cutover/arms.cjs +++ b/tests/fixtures/gate-cutover/arms.cjs @@ -539,6 +539,11 @@ arm('E1', 'decision-coverage-plan-no-trackable', 'dc', 'decision-coverage-plan', arm('E1', 'decision-coverage-plan-uncovered', 'dc', 'decision-coverage-plan', [P, `${P}/CONTEXT.md`]); arm('E1', 'decision-coverage-plan-all-covered', 'dc', 'decision-coverage-plan', [P, `${P}/CONTEXT-allcovered.md`]); arm('E1', 'decision-coverage-plan-context-flag', 'dc', 'decision-coverage-plan', [P, '--context', `${P}/CONTEXT.md`]); +// #4939: a phase-dir argument that names no directory (the phase NUMBER, or a file) is a caller error, not a measured zero. +arm('E1', 'decision-coverage-plan-phase-dir-not-found', 'dc', 'decision-coverage-plan', ['1', `${P}/CONTEXT.md`]); +arm('E1', 'decision-coverage-plan-phase-dir-is-file', 'dc', 'decision-coverage-plan', [`${P}/CONTEXT.md`, `${P}/CONTEXT.md`]); +// #4939: an existing directory that holds no PLAN (or SUMMARY) file is the same caller error; ctxdir holds only keep.txt. +arm('E1', 'decision-coverage-plan-phase-dir-no-plans', 'dc', 'decision-coverage-plan', [`${P}/ctxdir`, `${P}/CONTEXT.md`]); arm('E1', 'decision-coverage-plan-path-escape', 'dc', 'decision-coverage-plan', ['../../outside', `${P}/CONTEXT.md`]); arm('E1', 'decision-coverage-plan-dotted-verb', 'dc', 'decision.coverage.plan', [P, `${P}/CONTEXT.md`]); @@ -550,6 +555,9 @@ arm('E2', 'decision-coverage-verify-could-not-parse', 'dc', 'decision-coverage-v arm('E2', 'decision-coverage-verify-could-not-parse-partial', 'dc', 'decision-coverage-verify', [P, `${P}/CONTEXT-partial.md`]); arm('E2', 'decision-coverage-verify-no-trackable', 'dc', 'decision-coverage-verify', [P, `${P}/CONTEXT-empty.md`]); arm('E2', 'decision-coverage-verify-final', 'dc', 'decision-coverage-verify', [P, `${P}/CONTEXT-verify.md`]); +arm('E2', 'decision-coverage-verify-phase-dir-not-found', 'dc', 'decision-coverage-verify', ['1', `${P}/CONTEXT.md`]); +arm('E2', 'decision-coverage-verify-phase-dir-is-file', 'dc', 'decision-coverage-verify', [`${P}/CONTEXT.md`, `${P}/CONTEXT.md`]); +arm('E2', 'decision-coverage-verify-phase-dir-no-plans', 'dc', 'decision-coverage-verify', [`${P}/ctxdir`, `${P}/CONTEXT.md`]); arm('E2', 'decision-coverage-verify-path-escape', 'dc', 'decision-coverage-verify', ['../../outside', `${P}/CONTEXT.md`]); // R4 readModifiedFilesContent caps (through decision-coverage-verify) diff --git a/tests/fixtures/gate-cutover/decision-coverage-plan-phase-dir-is-file.json b/tests/fixtures/gate-cutover/decision-coverage-plan-phase-dir-is-file.json new file mode 100644 index 0000000000..a01e98e15e --- /dev/null +++ b/tests/fixtures/gate-cutover/decision-coverage-plan-phase-dir-is-file.json @@ -0,0 +1,12 @@ +{ + "argv": [ + "query", + "check.decision-coverage-plan", + ".planning/phases/01-decisions/CONTEXT.md", + ".planning/phases/01-decisions/CONTEXT.md" + ], + "fixture": "dc", + "stdout": "{\n \"passed\": false,\n \"skipped\": false,\n \"reason\": \"phase path is not a directory\",\n \"total\": null,\n \"covered\": null,\n \"message\": \"Decision coverage gate: the phase directory \\\".planning/phases/01-decisions/CONTEXT.md\\\" is a file, not a directory, so no plans were scanned. Pass the phase DIRECTORY (e.g. .planning/phases/01-slug), not the phase number.\"\n}", + "stderr": "", + "exitCode": 0 +} diff --git a/tests/fixtures/gate-cutover/decision-coverage-plan-phase-dir-no-plans.json b/tests/fixtures/gate-cutover/decision-coverage-plan-phase-dir-no-plans.json new file mode 100644 index 0000000000..4ddc88170f --- /dev/null +++ b/tests/fixtures/gate-cutover/decision-coverage-plan-phase-dir-no-plans.json @@ -0,0 +1,12 @@ +{ + "argv": [ + "query", + "check.decision-coverage-plan", + ".planning/phases/01-decisions/ctxdir", + ".planning/phases/01-decisions/CONTEXT.md" + ], + "fixture": "dc", + "stdout": "{\n \"passed\": false,\n \"skipped\": false,\n \"reason\": \"phase directory holds no plans\",\n \"total\": null,\n \"covered\": null,\n \"message\": \"Decision coverage gate: the phase directory \\\".planning/phases/01-decisions/ctxdir\\\" 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.\"\n}", + "stderr": "", + "exitCode": 0 +} diff --git a/tests/fixtures/gate-cutover/decision-coverage-plan-phase-dir-not-found.json b/tests/fixtures/gate-cutover/decision-coverage-plan-phase-dir-not-found.json new file mode 100644 index 0000000000..3dd969ed96 --- /dev/null +++ b/tests/fixtures/gate-cutover/decision-coverage-plan-phase-dir-not-found.json @@ -0,0 +1,12 @@ +{ + "argv": [ + "query", + "check.decision-coverage-plan", + "1", + ".planning/phases/01-decisions/CONTEXT.md" + ], + "fixture": "dc", + "stdout": "{\n \"passed\": false,\n \"skipped\": false,\n \"reason\": \"phase directory not found\",\n \"total\": null,\n \"covered\": null,\n \"message\": \"Decision coverage gate: the phase directory \\\"1\\\" does not exist, so no plans were scanned. Pass the phase DIRECTORY (e.g. .planning/phases/01-slug), not the phase number.\"\n}", + "stderr": "", + "exitCode": 0 +} diff --git a/tests/fixtures/gate-cutover/decision-coverage-verify-phase-dir-is-file.json b/tests/fixtures/gate-cutover/decision-coverage-verify-phase-dir-is-file.json new file mode 100644 index 0000000000..a1fe2c5fef --- /dev/null +++ b/tests/fixtures/gate-cutover/decision-coverage-verify-phase-dir-is-file.json @@ -0,0 +1,12 @@ +{ + "argv": [ + "query", + "check.decision-coverage-verify", + ".planning/phases/01-decisions/CONTEXT.md", + ".planning/phases/01-decisions/CONTEXT.md" + ], + "fixture": "dc", + "stdout": "{\n \"skipped\": false,\n \"blocking\": false,\n \"reason\": \"phase path is not a directory\",\n \"total\": null,\n \"honored\": null,\n \"not_honored\": [],\n \"message\": \"Decision coverage verify (warning): the phase directory \\\".planning/phases/01-decisions/CONTEXT.md\\\" is a file, not a directory, so no plans or summaries were scanned. Pass the phase DIRECTORY (e.g. .planning/phases/01-slug), not the phase number.\"\n}", + "stderr": "", + "exitCode": 0 +} diff --git a/tests/fixtures/gate-cutover/decision-coverage-verify-phase-dir-no-plans.json b/tests/fixtures/gate-cutover/decision-coverage-verify-phase-dir-no-plans.json new file mode 100644 index 0000000000..a9d3175f8c --- /dev/null +++ b/tests/fixtures/gate-cutover/decision-coverage-verify-phase-dir-no-plans.json @@ -0,0 +1,12 @@ +{ + "argv": [ + "query", + "check.decision-coverage-verify", + ".planning/phases/01-decisions/ctxdir", + ".planning/phases/01-decisions/CONTEXT.md" + ], + "fixture": "dc", + "stdout": "{\n \"skipped\": false,\n \"blocking\": false,\n \"reason\": \"phase directory holds no plans\",\n \"total\": null,\n \"honored\": null,\n \"not_honored\": [],\n \"message\": \"Decision coverage verify (warning): the phase directory \\\".planning/phases/01-decisions/ctxdir\\\" 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.\"\n}", + "stderr": "", + "exitCode": 0 +} diff --git a/tests/fixtures/gate-cutover/decision-coverage-verify-phase-dir-not-found.json b/tests/fixtures/gate-cutover/decision-coverage-verify-phase-dir-not-found.json new file mode 100644 index 0000000000..57f004591a --- /dev/null +++ b/tests/fixtures/gate-cutover/decision-coverage-verify-phase-dir-not-found.json @@ -0,0 +1,12 @@ +{ + "argv": [ + "query", + "check.decision-coverage-verify", + "1", + ".planning/phases/01-decisions/CONTEXT.md" + ], + "fixture": "dc", + "stdout": "{\n \"skipped\": false,\n \"blocking\": false,\n \"reason\": \"phase directory not found\",\n \"total\": null,\n \"honored\": null,\n \"not_honored\": [],\n \"message\": \"Decision coverage verify (warning): the phase directory \\\"1\\\" does not exist, so no plans or summaries were scanned. Pass the phase DIRECTORY (e.g. .planning/phases/01-slug), not the phase number.\"\n}", + "stderr": "", + "exitCode": 0 +} diff --git a/tests/gate-decision-coverage-plan.unit.test.cjs b/tests/gate-decision-coverage-plan.unit.test.cjs index f6c29d12f7..de92a87897 100644 --- a/tests/gate-decision-coverage-plan.unit.test.cjs +++ b/tests/gate-decision-coverage-plan.unit.test.cjs @@ -354,6 +354,99 @@ const CASES = [ }; }, }, + { + // #4939: a phase-dir argument that names no directory (the phase NUMBER in the phase-dir slot) used to + // scan nothing and answer a measured-looking `covered: 0`. It is a caller error: fail closed, null totals. + id: 'U1o', + title: 'phase dir that does not exist -> block:true, reason "phase directory not found", null totals (#4939)', + setup(dir, h) { + h.w(dir, '.planning/phases/01-x/01-CONTEXT.md', ['', '- **D-01:** Use PostgreSQL for the primary datastore layer', '', ''].join('\n')); + }, + args() { return ['1', '--context', '.planning/phases/01-x/01-CONTEXT.md']; }, + outcome: 'block', + block: true, + expected() { + return { + passed: false, + skipped: false, + reason: 'phase directory not found', + total: null, + covered: null, + message: 'Decision coverage gate: the phase directory "1" does not exist, so no plans were scanned. Pass the phase DIRECTORY (e.g. .planning/phases/01-slug), not the phase number.', + }; + }, + }, + { + id: 'U1p', + title: 'a FILE in the phase-dir slot -> block:true, reason "phase path is not a directory", null totals (#4939)', + setup(dir, h) { + h.w(dir, '.planning/phases/01-x/01-CONTEXT.md', ['', '- **D-01:** Use PostgreSQL for the primary datastore layer', '', ''].join('\n')); + }, + args() { return ['.planning/phases/01-x/01-CONTEXT.md', '--context', '.planning/phases/01-x/01-CONTEXT.md']; }, + outcome: 'block', + block: true, + expected() { + return { + passed: false, + skipped: false, + reason: 'phase path is not a directory', + total: null, + covered: null, + message: 'Decision coverage gate: the phase directory ".planning/phases/01-x/01-CONTEXT.md" is a file, not a directory, so no plans were scanned. Pass the phase DIRECTORY (e.g. .planning/phases/01-slug), not the phase number.', + }; + }, + }, + { + // A stat failure other than ENOENT/ENOTDIR is not "the directory is missing": the message must not say so. + id: 'U1q', + title: 'phase dir whose stat fails with EACCES -> unreadable outcome, reason "phase directory unreadable" (stat failure injected, #4939)', + setup(dir, h) { + h.w(dir, '.planning/phases/01-x/01-CONTEXT.md', ['', '- **D-01:** Use PostgreSQL for the primary datastore layer', '', ''].join('\n')); + const real = fs.statSync; + fs.statSync = function (p, ...rest) { + if (String(p).endsWith('01-x')) { + const err = new Error('EACCES: simulated stat failure'); + err.code = 'EACCES'; + throw err; + } + return real.call(fs, p, ...rest); + }; + return function restore() { fs.statSync = real; }; + }, + args() { return ['.planning/phases/01-x', '--context', '.planning/phases/01-x/01-CONTEXT.md']; }, + outcome: 'unreadable', + block: true, + expected() { + return { + passed: false, + skipped: false, + reason: 'phase directory unreadable', + total: null, + covered: null, + message: 'Decision coverage gate: the phase directory ".planning/phases/01-x" could not be read (EACCES), so no plans were scanned. Check that the directory is readable.', + }; + }, + }, + { + id: 'U1r', + title: 'phase dir that holds no PLAN file -> block:true, reason "phase directory holds no plans", null totals (#4939)', + setup(dir, h) { + h.w(dir, '.planning/phases/01-x/01-CONTEXT.md', ['', '- **D-01:** Use PostgreSQL for the primary datastore layer', '', ''].join('\n')); + }, + args() { return ['.planning/phases/01-x', '--context', '.planning/phases/01-x/01-CONTEXT.md']; }, + outcome: 'block', + block: true, + expected() { + return { + passed: false, + skipped: false, + reason: 'phase directory holds no plans', + total: null, + covered: null, + message: 'Decision coverage gate: the phase directory ".planning/phases/01-x" 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.', + }; + }, + }, ]; function run(c) { diff --git a/tests/gate-decision-coverage-verify.unit.test.cjs b/tests/gate-decision-coverage-verify.unit.test.cjs index 0fd2af901e..c1ef6b06ea 100644 --- a/tests/gate-decision-coverage-verify.unit.test.cjs +++ b/tests/gate-decision-coverage-verify.unit.test.cjs @@ -317,6 +317,106 @@ const CASES = [ }; }, }, + { + // #4939: mirror of the plan gate. Verify stays advisory, but a phase-dir argument that names no directory + // scanned nothing, so no decision may be reported "not honored". + id: 'U2o', + title: 'phase dir that does not exist -> advisory, reason "phase directory not found", null totals (#4939)', + git: true, + setup(dir, h) { + h.w(dir, '.planning/phases/01-x/01-CONTEXT.md', ['', '- **D-01:** Use PostgreSQL for the primary datastore layer', '', ''].join('\n')); + }, + args() { return ['1', '.planning/phases/01-x/01-CONTEXT.md']; }, + outcome: 'advisory', + block: false, + expected() { + return { + skipped: false, + blocking: false, + reason: 'phase directory not found', + total: null, + honored: null, + not_honored: [], + message: 'Decision coverage verify (warning): the phase directory "1" does not exist, so no plans or summaries were scanned. Pass the phase DIRECTORY (e.g. .planning/phases/01-slug), not the phase number.', + }; + }, + }, + { + id: 'U2p', + title: 'a FILE in the phase-dir slot -> advisory, reason "phase path is not a directory", null totals (#4939)', + git: true, + setup(dir, h) { + h.w(dir, '.planning/phases/01-x/01-CONTEXT.md', ['', '- **D-01:** Use PostgreSQL for the primary datastore layer', '', ''].join('\n')); + }, + args() { return ['.planning/phases/01-x/01-CONTEXT.md', '.planning/phases/01-x/01-CONTEXT.md']; }, + outcome: 'advisory', + block: false, + expected() { + return { + skipped: false, + blocking: false, + reason: 'phase path is not a directory', + total: null, + honored: null, + not_honored: [], + message: 'Decision coverage verify (warning): the phase directory ".planning/phases/01-x/01-CONTEXT.md" is a file, not a directory, so no plans or summaries were scanned. Pass the phase DIRECTORY (e.g. .planning/phases/01-slug), not the phase number.', + }; + }, + }, + { + id: 'U2q', + title: 'phase dir whose stat fails with EACCES -> unreadable outcome, reason "phase directory unreadable" (stat failure injected, #4939)', + git: true, + setup(dir, h) { + h.w(dir, '.planning/phases/01-x/01-CONTEXT.md', ['', '- **D-01:** Use PostgreSQL for the primary datastore layer', '', ''].join('\n')); + const real = fs.statSync; + fs.statSync = function (p, ...rest) { + if (String(p).endsWith('01-x')) { + const err = new Error('EACCES: simulated stat failure'); + err.code = 'EACCES'; + throw err; + } + return real.call(fs, p, ...rest); + }; + return function restore() { fs.statSync = real; }; + }, + args() { return ['.planning/phases/01-x', '.planning/phases/01-x/01-CONTEXT.md']; }, + outcome: 'unreadable', + block: false, + expected() { + return { + skipped: false, + blocking: false, + reason: 'phase directory unreadable', + total: null, + honored: null, + not_honored: [], + message: 'Decision coverage verify (warning): the phase directory ".planning/phases/01-x" could not be read (EACCES), so no plans or summaries were scanned. Check that the directory is readable.', + }; + }, + }, + { + id: 'U2r', + title: 'phase dir that holds no PLAN or SUMMARY file -> advisory, reason "phase directory holds no plans", null totals (#4939)', + git: true, + setup(dir, h) { + h.w(dir, '.planning/phases/01-x/01-CONTEXT.md', ['', '- **D-01:** Use PostgreSQL for the primary datastore layer', '', ''].join('\n')); + }, + args() { return ['.planning/phases/01-x', '.planning/phases/01-x/01-CONTEXT.md']; }, + outcome: 'advisory', + block: false, + expected() { + return { + skipped: false, + blocking: false, + reason: 'phase directory holds no plans', + total: null, + honored: null, + not_honored: [], + message: 'Decision coverage verify (warning): the phase directory ".planning/phases/01-x" 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.', + }; + }, + }, ]; function run(c) { diff --git a/tests/representative-corpus.test.cjs b/tests/representative-corpus.test.cjs index bea77e2767..60121024ad 100644 --- a/tests/representative-corpus.test.cjs +++ b/tests/representative-corpus.test.cjs @@ -216,6 +216,8 @@ describe('representative corpus — decision-coverage guard (#2347)', () => { const phaseDir = makePhaseDir(tmpDir, '01-repcorpus'); const contextPath = path.join(phaseDir, 'CONTEXT.md'); fs.writeFileSync(contextPath, readFixture('decision-coverage-guard', fx.file), 'utf8'); + // One plan that cites no decision: a phase dir with no plan at all is a caller error (#4939). + fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n\nNo decisions are cited here.\n', 'utf8'); const r = runTools(['query', 'check.decision-coverage-plan', phaseDir, contextPath], tmpDir); assert.ok(r.success, `gate should succeed (JSON). stderr: ${r.error}`); diff --git a/tests/sdk-removal-query-family-dispatch.test.cjs b/tests/sdk-removal-query-family-dispatch.test.cjs index 202e3c9c5e..00311f2f97 100644 --- a/tests/sdk-removal-query-family-dispatch.test.cjs +++ b/tests/sdk-removal-query-family-dispatch.test.cjs @@ -91,6 +91,8 @@ describe('SDK-removal CJS query family dispatch', () => { '- **D-02:** Keep verification warnings visible when decisions are missing.', '', ].join('\n')); + // A plan that does not cite D-02: a phase dir with no plan or summary is a caller error (#4939). + fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n\nNothing here cites the decision.\n'); const result = runGsdTools(['query', 'check.decision-coverage-verify', phaseDir, contextPath], tmpDir); assert.strictEqual(result.success, true, result.error);