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:
@@ -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)}`);
|
||||
});
|
||||
});
|
||||
|
||||
8
tests/emitted-drift-ack.json
Normal file
8
tests/emitted-drift-ack.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"version": 1,
|
||||
"paths": {
|
||||
"plan-phase.md": {
|
||||
"reason": "#2770: decision-coverage gate recomputes CONTEXT_PATH in-block + guards the empty-glob case (handler now fails closed on empty arg). Net growth kept under the ADR-857 size cap by condensing adjacent §13a prose/JSON; the residual +89 bytes are the irreducible glob+guard logic."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1604,25 +1604,26 @@ describe('plan-phase decision-coverage gate (#2492)', () => {
|
||||
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');
|
||||
|
||||
test('plan-phase Decision Coverage Gate recomputes CONTEXT_PATH in-block and guards the empty-glob case (#2770)', () => {
|
||||
// #2770: the CONTEXT_PATH set in the step-1 init Bash block does NOT survive into
|
||||
// the separately-spawned gate block, so the gate used to run with an empty arg and
|
||||
// silently green-skip. The gate must now (a) recompute CONTEXT_PATH locally from the
|
||||
// phase dir, and (b) guard the empty case so a genuinely CONTEXT.md-less phase still
|
||||
// skips (the handler now fails closed on an empty arg, so an unguarded empty path
|
||||
// would hard-halt the legitimate "Continue without context" flow).
|
||||
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);
|
||||
// The gate invocation is now nested inside the empty-glob guard, so slice a wide
|
||||
// window around it to capture both the recompute and the guard.
|
||||
const snippet = md.slice(Math.max(0, gateIdx - 600), gateIdx + 400);
|
||||
assert.ok(
|
||||
snippet.includes('${CONTEXT_PATH}'),
|
||||
'Gate snippet must reference ${CONTEXT_PATH} (uppercase) to match the variable defined in Step 4',
|
||||
snippet.includes('CONTEXT_PATH=$(ls "${PHASE_DIR}"/*-CONTEXT.md'),
|
||||
'Gate must recompute CONTEXT_PATH in-block from the phase-dir glob (not rely on the init-block variable) (#2770)',
|
||||
);
|
||||
assert.ok(
|
||||
!snippet.includes('${context_path}'),
|
||||
'Gate snippet must NOT reference ${context_path} (lowercase) — that name is undefined in shell scope',
|
||||
snippet.includes('if [ -n "$CONTEXT_PATH" ]'),
|
||||
'Gate must guard the empty-glob case so a CONTEXT.md-less phase still skips (handler now fails closed on empty arg) (#2770)',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user