fix(#2770): decision-coverage gate fails closed on empty arg + workflow recomputes CONTEXT_PATH in-block (#2881)

* test(#2770): empty contextPath argument must fail closed, not green-skip the decision-coverage gate

The handler conflated empty-arg (caller error) with file-missing (legitimate skip),
returning passed:true/skipped on an empty argument. Add: empty arg → passed:false;
real-path-to-absent-file → legitimate green skip preserved; omitted arg → fail closed.

* fix(#2770): decision-coverage gate fails closed on empty arg + workflow recomputes CONTEXT_PATH in-block

Handler (check-command-router.cts): split the guard — empty/missing contextPath
argument is a caller error (fail closed, passed:false, mirrors #1365); a real path
whose file genuinely does not exist keeps the legitimate green skip.

Workflow (plan-phase.md): recompute CONTEXT_PATH inside the consuming Bash block
(it was set in the step-1 init block, which does not survive into the separately-
spawned gate block — so the gate ran with an empty arg and silently green-skipped).

* chore(#2770): changeset fragment

* fix(#2770): guard workflow empty-glob case (review blocker) + update drift-guard test

The handler now fails closed on an empty contextPath arg, so the workflow's
unguarded glob (empty when a phase genuinely has no CONTEXT.md) would invoke the
gate with an empty arg → passed:false → exit 1, hard-halting the legitimate
'Continue without context' plan-phase path. Guard the empty-glob case: only run the
gate when a CONTEXT.md actually exists. Update the F1 drift-guard test (which gave
false coverage — it only checked for the ${CONTEXT_PATH} token) to assert the
in-block recompute AND the empty-glob guard.

* fix(#2770): keep plan-phase.md under ADR-857 size cap + ack emitted drift + fix drift-guard window

The workflow fix grew plan-phase.md past the ADR-857 phase-6 size cap (94519B) and
triggered emitted-attribution. Condense adjacent §13a prose/JSON to offset (net
+89B, under cap). Add tests/emitted-drift-ack.json acknowledging the residual growth.
Widen the drift-guard test window (the gate invocation is now nested in the empty-glob
guard, so the old 400-char window missed the glob recompute).

* chore(#2770): backfill changeset PR number (2881)

---------

Co-authored-by: Test <test@example.com>
This commit is contained in:
Tom Boucher
2026-07-30 16:55:10 -04:00
committed by GitHub
parent 7e9ce08e2c
commit 185da024cb
6 changed files with 119 additions and 43 deletions

View File

@@ -1124,3 +1124,59 @@ describe('check.decision-coverage-plan — planner-canonical tag scanning (#2372
assert.strictEqual(parsed.passed, true);
});
});
// ─── #2770: empty contextPath argument must fail closed, not green-skip ──────
// The handler conflated "empty argument" (a CALLER ERROR — the workflow forgot to
// pass the path) with "file missing" (a LEGITIMATE green skip). An empty arg
// returned passed:true/skipped/reason:"CONTEXT.md missing", silently certifying a
// blocking gate. Must fail closed (mirrors #1365 fail-loud).
describe('check.decision-coverage-plan — empty contextPath argument fails closed (#2770)', () => {
let tmpDir;
let planningDir;
let phaseDir;
beforeEach(() => {
tmpDir = createTempProject('gsd-2770-');
planningDir = path.join(tmpDir, '.planning');
phaseDir = path.join(planningDir, 'phases', '01-init');
fs.mkdirSync(phaseDir, { recursive: true });
});
afterEach(() => cleanup(tmpDir));
test('empty contextPath argument → passed:false (caller error, fail closed)', () => {
const result = runDecisionCoveragePlan(phaseDir, '', tmpDir);
const parsed = JSON.parse(result.output || '{}');
assert.strictEqual(parsed.passed, false,
`Empty contextPath argument must fail closed (caller error), not green-skip. Got: ${JSON.stringify(parsed)}`);
const reason = (parsed.reason || '').toLowerCase();
assert.ok(
reason.includes('missing') && reason.includes('argument'),
`Reason must identify the missing argument. Got: "${parsed.reason}"`
);
});
test('real path to a genuinely-absent CONTEXT.md → legitimate green skip preserved (#2770)', () => {
// Negative space: a REAL path whose file does not exist is the legitimate skip.
const absentPath = path.join(phaseDir, 'CONTEXT.md'); // never written
const result = runDecisionCoveragePlan(phaseDir, absentPath, tmpDir);
const parsed = JSON.parse(result.output || '{}');
assert.strictEqual(parsed.passed, true,
`A real path to a genuinely-absent CONTEXT.md is a legitimate green skip. Got: ${JSON.stringify(parsed)}`);
assert.strictEqual(parsed.skipped, true);
assert.ok(
(parsed.reason || '').toLowerCase().includes('context.md missing'),
`Reason must be the legitimate CONTEXT.md-missing skip. Got: "${parsed.reason}"`
);
});
test('undefined-ish argument omitted entirely → passed:false (fail closed)', () => {
// The CLI invocation drops a trailing empty arg in some shells; the handler must
// still fail closed when args[3] is absent (not just empty string).
const result = runGsdTools(['query', 'check.decision-coverage-plan', phaseDir], tmpDir);
const parsed = JSON.parse(result.output || '{}');
assert.strictEqual(parsed.passed, false,
`Missing contextPath argument must fail closed. Got: ${JSON.stringify(parsed)}`);
});
});