feat(#2008): generic command-exit-zero gate-predicate evaluator (#2011)

* feat(#2008): add generic command-exit-zero gate-predicate evaluator

Third-party capability gates declared via check.predicate were rendered for
display but never evaluated (only built-in check.query gates fired; the
security capability's gate worked solely via a hard-coded ship.md branch).

Add a generic, deps-injected gate-predicate evaluator (src/gate-predicate-evaluator.cts)
that dispatches by predicate.kind. Built-in kind: command-exit-zero — runs a
bounded sh -c command at the project root (via shell-command-projection.execTool),
inherits env, exit 0 => pass, non-zero => block, timeout => block, fail-closed.

Wire a 'check predicate' subcommand into check-command-router.cts and extend
the three generic workflow gate-dispatch sites (execute:wave:post, execute:post,
plan:post) to route check.predicate gates to the new evaluator. The two-step
gate contract (command-failure => onError; block => halt) is unchanged.

- src/gate-predicate-evaluator.cts: pure leaf, KIND_TABLE extensible
- src/check-command-router.cts: cmdCheckPredicate + buildPredicateDeps + parsePredicateFlags
- docs/adr/2008-*, docs/reference/gate-predicates.md, docs/how-to/command-exit-zero-gate.md
- tests: 38 unit + integration tests (exit mapping, timeout, interpolation,
  property-based bijection, malformed-predicate fail-closed, real subprocess e2e)

Closes #2008

* docs(#2008): backfill changeset pr number 2011
This commit is contained in:
Tom Boucher
2026-07-05 14:04:29 -04:00
committed by GitHub
parent 97730e59a1
commit 8de2ff9121
31 changed files with 1238 additions and 38 deletions

View File

@@ -0,0 +1,106 @@
'use strict';
/**
* Integration tests for the `check predicate` subcommand wiring (#2008).
*
* These exercise the PRODUCTION stack: the real `buildPredicateDeps()` binding
* (which wraps shell-command-projection.execTool → bounded `sh -c` spawnSync) and
* the `parsePredicateFlags` arg parser. The pure evaluator logic is covered by
* gate-predicate-evaluator.test.cjs; this file proves the wiring holds against
* real subprocess exit codes and a real timeout kill.
*
* Commands run are instant (`true` / `false` / `exit 3`) or tightly bounded
* (a 100ms timeout killing `sleep 1`), so there is no orphan/leak risk.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { evaluatePredicate } = require('../gsd-core/bin/lib/gate-predicate-evaluator.cjs');
const { buildPredicateDeps, parsePredicateFlags } = require('../gsd-core/bin/lib/check-command-router.cjs');
// ─── buildPredicateDeps: real subprocess exit mapping ─────────────────────────
describe('buildPredicateDeps — real bounded sh -c subprocess', () => {
const deps = buildPredicateDeps();
const cwd = process.cwd();
test('`true` => exitCode 0, not timed out', () => {
const r = deps.runBoundedShell({ command: 'true', cwd, timeoutMs: 5000 });
assert.equal(r.exitCode, 0);
assert.equal(r.timedOut, false);
});
test('`false` => exitCode 1, not timed out', () => {
const r = deps.runBoundedShell({ command: 'false', cwd, timeoutMs: 5000 });
assert.equal(r.exitCode, 1);
assert.equal(r.timedOut, false);
});
test('`exit 3` => exitCode 3', () => {
const r = deps.runBoundedShell({ command: 'exit 3', cwd, timeoutMs: 5000 });
assert.equal(r.exitCode, 3);
});
test('stderr is captured from the subprocess', () => {
const r = deps.runBoundedShell({ command: 'echo oops >&2; exit 4', cwd, timeoutMs: 5000 });
assert.equal(r.exitCode, 4);
assert.match(r.stderr, /oops/);
});
test('timeout kills the subprocess (SIGTERM => timedOut:true)', () => {
const r = deps.runBoundedShell({ command: 'sleep 1', cwd, timeoutMs: 100 });
assert.equal(r.timedOut, true);
assert.equal(r.signal, 'SIGTERM');
});
});
// ─── evaluatePredicate + production deps: end-to-end exit mapping ─────────────
describe('evaluatePredicate + production deps — command-exit-zero e2e', () => {
const deps = buildPredicateDeps();
const ctx = { cwd: process.cwd() };
test('command `true` => block:false', () => {
const res = evaluatePredicate({ kind: 'command-exit-zero', command: 'true' }, ctx, deps);
assert.equal(res.block, false);
});
test('command `false` => block:true', () => {
const res = evaluatePredicate({ kind: 'command-exit-zero', command: 'false' }, ctx, deps);
assert.equal(res.block, true);
assert.match(res.message, /1/);
});
test('interpolation reaches the real shell ($PHASE_NUMBER via flag context)', () => {
const res = evaluatePredicate(
{ kind: 'command-exit-zero', command: 'test "${PHASE_NUMBER}" = "07" && true || false' },
{ cwd: process.cwd(), phaseNumber: '07' },
deps,
);
assert.equal(res.block, false);
});
});
// ─── parsePredicateFlags ───────────────────────────────────────────────────────
describe('parsePredicateFlags', () => {
test('extracts --flag value pairs, skips positional + bare --flags', () => {
const out = parsePredicateFlags(['check', 'predicate', '--predicate', '{"kind":"x"}', '--phase-number', '03', '--raw']);
assert.deepEqual(out, { predicate: '{"kind":"x"}', 'phase-number': '03' });
});
test('last write wins for repeated flags', () => {
const out = parsePredicateFlags(['--phase-number', '01', '--phase-number', '02']);
assert.equal(out['phase-number'], '02');
});
test('value that starts with -- is not consumed (treated as a flag)', () => {
const out = parsePredicateFlags(['--predicate', '--phase-number']);
assert.equal('predicate' in out, false);
});
test('empty args => empty map', () => {
assert.deepEqual(parsePredicateFlags([]), {});
});
});

View File

@@ -228,7 +228,7 @@
"gsd-core/workflows/docs-update.md": "1c3a0d23ecc9605c",
"gsd-core/workflows/edit-phase.md": "f685fb7063616826",
"gsd-core/workflows/eval-review.md": "2da6215ce79b2f7c",
"gsd-core/workflows/execute-phase.md": "e7713d986ebf45e0",
"gsd-core/workflows/execute-phase.md": "660fb92b8ed43129",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "1da22ba32f524c6e",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "bb9113e7bf953797",
@@ -264,7 +264,7 @@
"gsd-core/workflows/note.md": "3ce09c0aa0a20599",
"gsd-core/workflows/pause-work.md": "3196d681d4dd8c71",
"gsd-core/workflows/plan-milestone-gaps.md": "94b193dfc9ca3681",
"gsd-core/workflows/plan-phase.md": "659b52bd338720fe",
"gsd-core/workflows/plan-phase.md": "1fecfb77b67d4833",
"gsd-core/workflows/plan-phase/steps/closed-phase-gate.md": "4099ef6d0868de60",
"gsd-core/workflows/plan-phase/steps/prd-express-path.md": "473d4d6e5b1f6340",
"gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md": "e9de7a96bbfff261",

View File

@@ -298,7 +298,7 @@
"gsd-core/workflows/docs-update.md": "806ada831b961116",
"gsd-core/workflows/edit-phase.md": "e5624ac6e3f8bef5",
"gsd-core/workflows/eval-review.md": "dfcfb4f8ce031fae",
"gsd-core/workflows/execute-phase.md": "5daeb8fac7cab295",
"gsd-core/workflows/execute-phase.md": "5383dabd9fd40206",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "8898e0ea533cc643",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "abd2aca069c04a80",
@@ -334,7 +334,7 @@
"gsd-core/workflows/note.md": "5a99eb396c744619",
"gsd-core/workflows/pause-work.md": "7bcbdf27ba957c8b",
"gsd-core/workflows/plan-milestone-gaps.md": "02fee851c82e3b25",
"gsd-core/workflows/plan-phase.md": "dc0beefd1316a860",
"gsd-core/workflows/plan-phase.md": "f80ff87cec37c25c",
"gsd-core/workflows/plan-phase/steps/closed-phase-gate.md": "b36f77ac7344a072",
"gsd-core/workflows/plan-phase/steps/prd-express-path.md": "d64097345ffac40d",
"gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md": "49f58c3f75be3eb5",

View File

@@ -227,7 +227,7 @@
"gsd-core/workflows/docs-update.md": "98c30bec9350542f",
"gsd-core/workflows/edit-phase.md": "a3ab51739c5c021c",
"gsd-core/workflows/eval-review.md": "f47fd1a7a1ca3308",
"gsd-core/workflows/execute-phase.md": "fe39dd64ff9db8f6",
"gsd-core/workflows/execute-phase.md": "d8ffe62c26c3f28e",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "c133828cf177a772",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "abd2aca069c04a80",
@@ -263,7 +263,7 @@
"gsd-core/workflows/note.md": "42b66686b2c102cb",
"gsd-core/workflows/pause-work.md": "0be71264eafd16dc",
"gsd-core/workflows/plan-milestone-gaps.md": "1976bf2001969719",
"gsd-core/workflows/plan-phase.md": "f0401a3221b74b58",
"gsd-core/workflows/plan-phase.md": "21a81a2c468c53c2",
"gsd-core/workflows/plan-phase/steps/closed-phase-gate.md": "4099ef6d0868de60",
"gsd-core/workflows/plan-phase/steps/prd-express-path.md": "d64097345ffac40d",
"gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md": "e9de7a96bbfff261",

View File

@@ -231,7 +231,7 @@
"gsd-core/workflows/docs-update.md": "4aee7d852ab26710",
"gsd-core/workflows/edit-phase.md": "db501e21c762d2b2",
"gsd-core/workflows/eval-review.md": "e211cca4eea94930",
"gsd-core/workflows/execute-phase.md": "61b798388f3c28d7",
"gsd-core/workflows/execute-phase.md": "20299af3e665f539",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "84b49fd65d290399",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "bcb4ab75df626846",
@@ -267,7 +267,7 @@
"gsd-core/workflows/note.md": "5a99eb396c744619",
"gsd-core/workflows/pause-work.md": "9c1acf8c30a244fd",
"gsd-core/workflows/plan-milestone-gaps.md": "95ca791b0867fe2d",
"gsd-core/workflows/plan-phase.md": "e4256d987d376112",
"gsd-core/workflows/plan-phase.md": "b906c040d22d0424",
"gsd-core/workflows/plan-phase/steps/closed-phase-gate.md": "b36f77ac7344a072",
"gsd-core/workflows/plan-phase/steps/prd-express-path.md": "ae59c247ad4a57d2",
"gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md": "090c31e22b1508fe",

View File

@@ -298,7 +298,7 @@
"gsd-core/workflows/docs-update.md": "806ada831b961116",
"gsd-core/workflows/edit-phase.md": "e5624ac6e3f8bef5",
"gsd-core/workflows/eval-review.md": "dfcfb4f8ce031fae",
"gsd-core/workflows/execute-phase.md": "d3c1583906de7408",
"gsd-core/workflows/execute-phase.md": "05280ea81a40a29f",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "8898e0ea533cc643",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "abd2aca069c04a80",
@@ -334,7 +334,7 @@
"gsd-core/workflows/note.md": "5a99eb396c744619",
"gsd-core/workflows/pause-work.md": "7bcbdf27ba957c8b",
"gsd-core/workflows/plan-milestone-gaps.md": "02fee851c82e3b25",
"gsd-core/workflows/plan-phase.md": "9fc35fa37928c14e",
"gsd-core/workflows/plan-phase.md": "9ad0d58d4abbfcb3",
"gsd-core/workflows/plan-phase/steps/closed-phase-gate.md": "b36f77ac7344a072",
"gsd-core/workflows/plan-phase/steps/prd-express-path.md": "d64097345ffac40d",
"gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md": "49f58c3f75be3eb5",

View File

@@ -263,7 +263,7 @@
"gsd-core/workflows/docs-update.md": "c5e1ea372f4a9ee8",
"gsd-core/workflows/edit-phase.md": "c83ef0701c19c455",
"gsd-core/workflows/eval-review.md": "68d7d96bd415629b",
"gsd-core/workflows/execute-phase.md": "5db223634afa2b88",
"gsd-core/workflows/execute-phase.md": "5ac844394468bef0",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "7ae407e4435c9a2a",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "abd2aca069c04a80",
@@ -299,7 +299,7 @@
"gsd-core/workflows/note.md": "664da466ab989f9d",
"gsd-core/workflows/pause-work.md": "3c2ee96295959527",
"gsd-core/workflows/plan-milestone-gaps.md": "8ee841bcc7adc836",
"gsd-core/workflows/plan-phase.md": "5d3ed552933f81da",
"gsd-core/workflows/plan-phase.md": "58ee6f71fa269c46",
"gsd-core/workflows/plan-phase/steps/closed-phase-gate.md": "d838b87563feedf6",
"gsd-core/workflows/plan-phase/steps/prd-express-path.md": "44a12264f8b61d03",
"gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md": "f5edc589cab52a7b",

View File

@@ -229,7 +229,7 @@
"gsd-core/workflows/docs-update.md": "15436f1f87ce9bd2",
"gsd-core/workflows/edit-phase.md": "a619911fd32d1f8e",
"gsd-core/workflows/eval-review.md": "b29095c5e7149975",
"gsd-core/workflows/execute-phase.md": "dcb483d5fceffac1",
"gsd-core/workflows/execute-phase.md": "63945642ac949204",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "8d835dbbc9bc72cf",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "a01e6aae9f3e416e",
@@ -265,7 +265,7 @@
"gsd-core/workflows/note.md": "4a5ee74cf2fc1f54",
"gsd-core/workflows/pause-work.md": "324e04e675dc7f7f",
"gsd-core/workflows/plan-milestone-gaps.md": "c0eb896eb42e22d4",
"gsd-core/workflows/plan-phase.md": "371be4167168d461",
"gsd-core/workflows/plan-phase.md": "cc53a19bfc3831e8",
"gsd-core/workflows/plan-phase/steps/closed-phase-gate.md": "4099ef6d0868de60",
"gsd-core/workflows/plan-phase/steps/prd-express-path.md": "d911a080dc1cece6",
"gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md": "e9de7a96bbfff261",

View File

@@ -298,7 +298,7 @@
"gsd-core/workflows/docs-update.md": "b05a59f3079f6fa7",
"gsd-core/workflows/edit-phase.md": "a3ab51739c5c021c",
"gsd-core/workflows/eval-review.md": "8cc5788e32c15783",
"gsd-core/workflows/execute-phase.md": "c90b217abff9bbb3",
"gsd-core/workflows/execute-phase.md": "9ef2d83a416994dd",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "c133828cf177a772",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "abd2aca069c04a80",
@@ -334,7 +334,7 @@
"gsd-core/workflows/note.md": "1c1e466c764e3deb",
"gsd-core/workflows/pause-work.md": "0be71264eafd16dc",
"gsd-core/workflows/plan-milestone-gaps.md": "1976bf2001969719",
"gsd-core/workflows/plan-phase.md": "ba95020c3495b191",
"gsd-core/workflows/plan-phase.md": "f1f89fbd2bb723fb",
"gsd-core/workflows/plan-phase/steps/closed-phase-gate.md": "e06ccd4d4c0703fb",
"gsd-core/workflows/plan-phase/steps/prd-express-path.md": "d64097345ffac40d",
"gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md": "3bed01c3c906ac52",

View File

@@ -228,7 +228,7 @@
"gsd-core/workflows/docs-update.md": "c9df0f7df1ebec79",
"gsd-core/workflows/edit-phase.md": "57d807e21fe70355",
"gsd-core/workflows/eval-review.md": "d16eebac88386c05",
"gsd-core/workflows/execute-phase.md": "0167a319ed9a5bc5",
"gsd-core/workflows/execute-phase.md": "f05a000ab97b5b25",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "b890bafa4a0c8bd3",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "e34415dec69b8432",
@@ -264,7 +264,7 @@
"gsd-core/workflows/note.md": "42b66686b2c102cb",
"gsd-core/workflows/pause-work.md": "2c3abcaa1fa6d2e2",
"gsd-core/workflows/plan-milestone-gaps.md": "419744c1191354af",
"gsd-core/workflows/plan-phase.md": "e1dcabd0499a03f9",
"gsd-core/workflows/plan-phase.md": "bdd3aade4649708a",
"gsd-core/workflows/plan-phase/steps/closed-phase-gate.md": "4099ef6d0868de60",
"gsd-core/workflows/plan-phase/steps/prd-express-path.md": "99949122a3bd455e",
"gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md": "62f8e4f3b475fe5f",

View File

@@ -298,7 +298,7 @@
"gsd-core/workflows/docs-update.md": "87bf2a6b7b6ec9db",
"gsd-core/workflows/edit-phase.md": "a3ab51739c5c021c",
"gsd-core/workflows/eval-review.md": "f6183650f7fcabf9",
"gsd-core/workflows/execute-phase.md": "5c4b59ee5e04a022",
"gsd-core/workflows/execute-phase.md": "73e6a5c54af797bd",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "c133828cf177a772",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "abd2aca069c04a80",
@@ -334,7 +334,7 @@
"gsd-core/workflows/note.md": "f8c2842a2217f776",
"gsd-core/workflows/pause-work.md": "8b81699a46ca8e9b",
"gsd-core/workflows/plan-milestone-gaps.md": "1976bf2001969719",
"gsd-core/workflows/plan-phase.md": "a7642fb6484cf12e",
"gsd-core/workflows/plan-phase.md": "b58ec889ce1d1707",
"gsd-core/workflows/plan-phase/steps/closed-phase-gate.md": "4099ef6d0868de60",
"gsd-core/workflows/plan-phase/steps/prd-express-path.md": "44a12264f8b61d03",
"gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md": "e9de7a96bbfff261",

View File

@@ -264,7 +264,7 @@
"gsd-core/workflows/docs-update.md": "806ada831b961116",
"gsd-core/workflows/edit-phase.md": "e5624ac6e3f8bef5",
"gsd-core/workflows/eval-review.md": "dfcfb4f8ce031fae",
"gsd-core/workflows/execute-phase.md": "444d5f17dfc1437a",
"gsd-core/workflows/execute-phase.md": "fea95b7022cad03f",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "8898e0ea533cc643",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "abd2aca069c04a80",
@@ -300,7 +300,7 @@
"gsd-core/workflows/note.md": "5a99eb396c744619",
"gsd-core/workflows/pause-work.md": "7bcbdf27ba957c8b",
"gsd-core/workflows/plan-milestone-gaps.md": "02fee851c82e3b25",
"gsd-core/workflows/plan-phase.md": "e69e435b47d77070",
"gsd-core/workflows/plan-phase.md": "5ab304e905e4e234",
"gsd-core/workflows/plan-phase/steps/closed-phase-gate.md": "b36f77ac7344a072",
"gsd-core/workflows/plan-phase/steps/prd-express-path.md": "d64097345ffac40d",
"gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md": "49f58c3f75be3eb5",

View File

@@ -298,7 +298,7 @@
"gsd-core/workflows/docs-update.md": "93e969c3afb446a0",
"gsd-core/workflows/edit-phase.md": "ed948a400a0146c7",
"gsd-core/workflows/eval-review.md": "1ba1af74a43c07db",
"gsd-core/workflows/execute-phase.md": "f0ca3839e0d0cf8b",
"gsd-core/workflows/execute-phase.md": "24561c1fbc7b710e",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "a640b093a5a7b028",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "8b42f5df1a2c231f",
@@ -334,7 +334,7 @@
"gsd-core/workflows/note.md": "0d1374f2a2257858",
"gsd-core/workflows/pause-work.md": "c9f0b8826845dda7",
"gsd-core/workflows/plan-milestone-gaps.md": "5ec459734bf7570c",
"gsd-core/workflows/plan-phase.md": "03745f51d63d99b8",
"gsd-core/workflows/plan-phase.md": "f449b7d8f334ebb9",
"gsd-core/workflows/plan-phase/steps/closed-phase-gate.md": "4099ef6d0868de60",
"gsd-core/workflows/plan-phase/steps/prd-express-path.md": "fa7f29c323f4cb57",
"gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md": "e9de7a96bbfff261",

View File

@@ -228,7 +228,7 @@
"gsd-core/workflows/docs-update.md": "12433c1e3cf9e40e",
"gsd-core/workflows/edit-phase.md": "4ba06a05cb29f3b7",
"gsd-core/workflows/eval-review.md": "0ab8368c8edf91fb",
"gsd-core/workflows/execute-phase.md": "c739c018da3d2c0d",
"gsd-core/workflows/execute-phase.md": "e79f8e56b5d9ccdb",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "9320986ccf0e8a60",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "4116471249699255",
@@ -264,7 +264,7 @@
"gsd-core/workflows/note.md": "42b66686b2c102cb",
"gsd-core/workflows/pause-work.md": "54f1c0a9e79e2c59",
"gsd-core/workflows/plan-milestone-gaps.md": "1b820f4e70acce86",
"gsd-core/workflows/plan-phase.md": "574fc8008416e6a7",
"gsd-core/workflows/plan-phase.md": "fa7053b515270b1d",
"gsd-core/workflows/plan-phase/steps/closed-phase-gate.md": "4099ef6d0868de60",
"gsd-core/workflows/plan-phase/steps/prd-express-path.md": "451a287826b36227",
"gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md": "d050d8d551ed1756",

View File

@@ -228,7 +228,7 @@
"gsd-core/workflows/docs-update.md": "dd1050b32c2dc170",
"gsd-core/workflows/edit-phase.md": "38f9c9945073ac8c",
"gsd-core/workflows/eval-review.md": "48fdef1cf0e67525",
"gsd-core/workflows/execute-phase.md": "5e36e2287a165064",
"gsd-core/workflows/execute-phase.md": "706a2890f7556367",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "44d46d3e98942efc",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "a2fc97089560ec0a",
@@ -264,7 +264,7 @@
"gsd-core/workflows/note.md": "acc9130fb1e94f0b",
"gsd-core/workflows/pause-work.md": "09a6b8980f7b771a",
"gsd-core/workflows/plan-milestone-gaps.md": "022822b3b6971b75",
"gsd-core/workflows/plan-phase.md": "8eddecda8fcd8765",
"gsd-core/workflows/plan-phase.md": "857f539a33efa3a5",
"gsd-core/workflows/plan-phase/steps/closed-phase-gate.md": "e06ccd4d4c0703fb",
"gsd-core/workflows/plan-phase/steps/prd-express-path.md": "382dac17d70ebfac",
"gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md": "619946c879f33b9d",

View File

@@ -228,7 +228,7 @@
"gsd-core/workflows/docs-update.md": "a3c2ec2c856aaabc",
"gsd-core/workflows/edit-phase.md": "1666ca537fbef030",
"gsd-core/workflows/eval-review.md": "bb01b3300db963bc",
"gsd-core/workflows/execute-phase.md": "20063847abe7ce75",
"gsd-core/workflows/execute-phase.md": "1bd9bf34f86de403",
"gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md": "579a3dcf8d69de31",
"gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md": "7ebb7d1af6082028",
"gsd-core/workflows/execute-phase/steps/post-merge-gate.md": "bbd704d6b6f0787b",
@@ -264,7 +264,7 @@
"gsd-core/workflows/note.md": "1c1e466c764e3deb",
"gsd-core/workflows/pause-work.md": "5ce6a137bd9fc0f8",
"gsd-core/workflows/plan-milestone-gaps.md": "19911e87fd4185f2",
"gsd-core/workflows/plan-phase.md": "f6e8c5e38d5c5a52",
"gsd-core/workflows/plan-phase.md": "20062e8d37f19e3d",
"gsd-core/workflows/plan-phase/steps/closed-phase-gate.md": "e06ccd4d4c0703fb",
"gsd-core/workflows/plan-phase/steps/prd-express-path.md": "7c2be59e3e19299a",
"gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md": "3fed4740a91d0443",

View File

@@ -0,0 +1,335 @@
'use strict';
/**
* Unit tests for the gate-predicate evaluator (pure core) — issue #2008.
*
* The evaluator is a pure, deps-injected function:
* evaluatePredicate(predicate, context, deps) -> { block, message, details? }
* Malformed predicates / unknown kinds THROW (the CLI wrapper maps a throw to a
* check-command failure so the workflow's `onError` step-1 contract applies).
*
* Built-in kind: `command-exit-zero` — runs a declared command via an injected
* bounded-shell seam; exit 0 => pass, non-zero => block, timeout => block.
*
* Per RULESET.TESTS.boundary-coverage: exit code boundary (0/1/2), timedOut
* true/false, message-trim boundary (MAX / MAX+1), timeout pass-through.
* Per RULESET.TESTS.property-based: interpolation bijection property (fast-check).
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fc = require('fast-check');
const {
evaluatePredicate,
evaluateCommandExitZero,
interpolate,
COMMAND_EXIT_ZERO_DEFAULT_TIMEOUT_MS,
COMMAND_MAX_OUTPUT_CHARS,
COMMAND_MAX_LENGTH,
EVALUATOR_KINDS,
} = require('../gsd-core/bin/lib/gate-predicate-evaluator.cjs');
// ─── Fake bounded-shell seam ──────────────────────────────────────────────────
/** Build a fake runBoundedShell that records the invocation and returns a preset result. */
function fakeShell(preset) {
const calls = [];
const run = (opts) => {
calls.push(opts);
return {
exitCode: preset.exitCode ?? 0,
stdout: preset.stdout ?? '',
stderr: preset.stderr ?? '',
signal: preset.signal ?? null,
timedOut: preset.timedOut ?? false,
};
};
return { run, calls };
}
const baseCtx = { cwd: '/proj', phaseNumber: '03', phaseDir: '/proj/.planning/phases/03-x', phaseReqIds: 'R-1' };
// ─── command-exit-zero: exit-code boundary (0 / 1 / 2) ─────────────────────────
describe('evaluatePredicate — command-exit-zero exit mapping', () => {
test('exit 0 => block:false (gate passes)', () => {
const shell = fakeShell({ exitCode: 0 });
const res = evaluatePredicate(
{ kind: 'command-exit-zero', command: 'true' },
baseCtx,
{ runBoundedShell: shell.run },
);
assert.equal(res.block, false);
assert.match(res.message, /exit/i);
});
test('exit 1 => block:true', () => {
const shell = fakeShell({ exitCode: 1, stderr: 'boom' });
const res = evaluatePredicate(
{ kind: 'command-exit-zero', command: 'false' },
baseCtx,
{ runBoundedShell: shell.run },
);
assert.equal(res.block, true);
assert.match(res.message, /1/);
assert.match(res.message, /boom/);
});
test('exit 2 => block:true (boundary above 1)', () => {
const shell = fakeShell({ exitCode: 2 });
const res = evaluatePredicate(
{ kind: 'command-exit-zero', command: 'bad' },
baseCtx,
{ runBoundedShell: shell.run },
);
assert.equal(res.block, true);
});
test('exit 127 (ENOENT) => block:true, surfaces not-found', () => {
const shell = fakeShell({ exitCode: 127, stderr: 'sh: not found' });
const res = evaluatePredicate(
{ kind: 'command-exit-zero', command: 'nope' },
baseCtx,
{ runBoundedShell: shell.run },
);
assert.equal(res.block, true);
assert.match(res.message, /not found|127/);
});
test('non-zero exit with empty stderr still yields a block message', () => {
const shell = fakeShell({ exitCode: 3, stderr: '', stdout: '' });
const res = evaluatePredicate(
{ kind: 'command-exit-zero', command: 'x' },
baseCtx,
{ runBoundedShell: shell.run },
);
assert.equal(res.block, true);
assert.match(res.message, /3/);
});
});
// ─── timeout ──────────────────────────────────────────────────────────────────
describe('evaluatePredicate — command-exit-zero timeout', () => {
test('timedOut => block:true with timed-out message', () => {
const shell = fakeShell({ timedOut: true, exitCode: null, signal: 'SIGTERM' });
const res = evaluatePredicate(
{ kind: 'command-exit-zero', command: 'sleep 100', timeout: 5 },
baseCtx,
{ runBoundedShell: shell.run },
);
assert.equal(res.block, true);
assert.match(res.message, /timed out|timeout/i);
});
test('default timeout applied when predicate.timeout absent', () => {
const shell = fakeShell({ exitCode: 0 });
evaluatePredicate(
{ kind: 'command-exit-zero', command: 'true' },
baseCtx,
{ runBoundedShell: shell.run },
);
assert.equal(shell.calls[0].timeoutMs, COMMAND_EXIT_ZERO_DEFAULT_TIMEOUT_MS);
});
test('custom timeout (seconds) honored and converted to ms', () => {
const shell = fakeShell({ exitCode: 0 });
evaluatePredicate(
{ kind: 'command-exit-zero', command: 'true', timeout: 90 },
baseCtx,
{ runBoundedShell: shell.run },
);
assert.equal(shell.calls[0].timeoutMs, 90_000);
});
});
// ─── interpolation ────────────────────────────────────────────────────────────
describe('evaluatePredicate — interpolation', () => {
test('${PHASE_DIR}, ${PHASE_NUMBER}, ${PHASE_REQ_IDS} are substituted', () => {
const shell = fakeShell({ exitCode: 0 });
evaluatePredicate(
{ kind: 'command-exit-zero', command: 'check ${PHASE_DIR} ${PHASE_NUMBER} ${PHASE_REQ_IDS}' },
baseCtx,
{ runBoundedShell: shell.run },
);
assert.equal(
shell.calls[0].command,
'check /proj/.planning/phases/03-x 03 R-1',
);
});
test('undefined context var => empty string (no leftover placeholder)', () => {
const shell = fakeShell({ exitCode: 0 });
evaluatePredicate(
{ kind: 'command-exit-zero', command: 'check ${PHASE_REQ_IDS}' },
{ cwd: '/proj' },
{ runBoundedShell: shell.run },
);
assert.equal(shell.calls[0].command, 'check ');
});
test('foreign ${HOME} placeholder left untouched (shell interprets)', () => {
const shell = fakeShell({ exitCode: 0 });
evaluatePredicate(
{ kind: 'command-exit-zero', command: 'echo ${HOME}' },
baseCtx,
{ runBoundedShell: shell.run },
);
assert.equal(shell.calls[0].command, 'echo ${HOME}');
});
});
// ─── output trimming ──────────────────────────────────────────────────────────
describe('evaluatePredicate — message trimming', () => {
test('stderr longer than COMMAND_MAX_OUTPUT_CHARS is trimmed', () => {
const long = 'E'.repeat(COMMAND_MAX_OUTPUT_CHARS + 50);
const shell = fakeShell({ exitCode: 1, stderr: long });
const res = evaluatePredicate(
{ kind: 'command-exit-zero', command: 'x' },
baseCtx,
{ runBoundedShell: shell.run },
);
assert.equal(res.block, true);
assert.equal(res.message.length, COMMAND_MAX_OUTPUT_CHARS);
});
test('stderr exactly at COMMAND_MAX_OUTPUT_CHARS is not trimmed (boundary)', () => {
const exact = 'E'.repeat(COMMAND_MAX_OUTPUT_CHARS);
const shell = fakeShell({ exitCode: 1, stderr: exact });
const res = evaluatePredicate(
{ kind: 'command-exit-zero', command: 'x' },
baseCtx,
{ runBoundedShell: shell.run },
);
assert.equal(res.message.length, COMMAND_MAX_OUTPUT_CHARS);
});
});
// ─── malformed predicate / fail-closed ────────────────────────────────────────
describe('evaluatePredicate — malformed predicate throws (maps to check-cmd failure)', () => {
test('missing command throws', () => {
assert.throws(
() => evaluatePredicate({ kind: 'command-exit-zero' }, baseCtx, { runBoundedShell: fakeShell({}).run }),
/command/i,
);
});
test('non-string command throws', () => {
assert.throws(
() => evaluatePredicate({ kind: 'command-exit-zero', command: 42 }, baseCtx, { runBoundedShell: fakeShell({}).run }),
/command/i,
);
});
test('empty-string command throws', () => {
assert.throws(
() => evaluatePredicate({ kind: 'command-exit-zero', command: ' ' }, baseCtx, { runBoundedShell: fakeShell({}).run }),
/command/i,
);
});
test('oversized command throws (ARGV-overflow guard)', () => {
const huge = 'a'.repeat(COMMAND_MAX_LENGTH + 1);
assert.throws(
() => evaluatePredicate({ kind: 'command-exit-zero', command: huge }, baseCtx, { runBoundedShell: fakeShell({}).run }),
/max length/i,
);
});
test('non-positive timeout throws', () => {
assert.throws(
() => evaluatePredicate({ kind: 'command-exit-zero', command: 'x', timeout: 0 }, baseCtx, { runBoundedShell: fakeShell({}).run }),
/timeout/i,
);
assert.throws(
() => evaluatePredicate({ kind: 'command-exit-zero', command: 'x', timeout: -5 }, baseCtx, { runBoundedShell: fakeShell({}).run }),
/timeout/i,
);
});
test('non-finite timeout throws', () => {
assert.throws(
() => evaluatePredicate({ kind: 'command-exit-zero', command: 'x', timeout: 'forever' }, baseCtx, { runBoundedShell: fakeShell({}).run }),
/timeout/i,
);
});
test('unknown kind throws', () => {
assert.throws(
() => evaluatePredicate({ kind: 'no-such-kind', command: 'x' }, baseCtx, { runBoundedShell: fakeShell({}).run }),
/kind|unknown|no-such-kind/i,
);
});
test('predicate not an object throws', () => {
assert.throws(
() => evaluatePredicate('nope', baseCtx, { runBoundedShell: fakeShell({}).run }),
/predicate/i,
);
});
});
// ─── contract surface ─────────────────────────────────────────────────────────
describe('evaluatePredicate — exported contract surface', () => {
test('EVALUATOR_KINDS advertises command-exit-zero', () => {
assert.ok(Array.isArray(EVALUATOR_KINDS));
assert.ok(EVALUATOR_KINDS.includes('command-exit-zero'));
});
test('default timeout is 30s', () => {
assert.equal(COMMAND_EXIT_ZERO_DEFAULT_TIMEOUT_MS, 30_000);
});
test('cwd is passed through to the shell seam', () => {
const shell = fakeShell({ exitCode: 0 });
evaluatePredicate(
{ kind: 'command-exit-zero', command: 'true' },
{ cwd: '/custom/proj' },
{ runBoundedShell: shell.run },
);
assert.equal(shell.calls[0].cwd, '/custom/proj');
});
});
// ─── property-based: interpolation bijection on non-placeholder strings ───────
describe('evaluatePredicate — interpolation property (fast-check)', () => {
test('strings without the 3 placeholders pass through unchanged', () => {
fc.assert(
fc.property(
fc.string({ maxLength: 40 }).filter((s) => s.trim().length > 0 && !/\$\{(PHASE_NUMBER|PHASE_DIR|PHASE_REQ_IDS)\}/.test(s)),
(cmd) => {
const shell = fakeShell({ exitCode: 0 });
evaluatePredicate(
{ kind: 'command-exit-zero', command: cmd },
baseCtx,
{ runBoundedShell: shell.run },
);
// sh -c receives exactly the input; only the 3 known placeholders would have been rewritten.
assert.equal(shell.calls[0].command, cmd);
}),
{ numRuns: 100 },
);
});
test('every placeholder is fully replaced (no leftover ${PHASE_*})', () => {
fc.assert(
fc.property(fc.string({ maxLength: 20 }), (noise) => {
const cmd = `${noise} ${noise}`;
const shell = fakeShell({ exitCode: 0 });
evaluatePredicate(
{ kind: 'command-exit-zero', command: `\${PHASE_DIR}${cmd}\${PHASE_NUMBER}` },
baseCtx,
{ runBoundedShell: shell.run },
);
assert.doesNotMatch(shell.calls[0].command, /\$\{PHASE_(DIR|NUMBER|REQ_IDS)\}/);
}),
{ numRuns: 100 },
);
});
});

View File

@@ -24,7 +24,7 @@
"docs-update.md": 55662,
"edit-phase.md": 12883,
"eval-review.md": 9923,
"execute-phase.md": 93515,
"execute-phase.md": 93484,
"execute-plan.md": 32611,
"explore.md": 10497,
"extract-learnings.md": 12849,
@@ -52,7 +52,7 @@
"note.md": 6563,
"pause-work.md": 14397,
"plan-milestone-gaps.md": 11765,
"plan-phase.md": 89775,
"plan-phase.md": 90411,
"plan-review-convergence.md": 23468,
"plant-seed.md": 11741,
"pr-branch.md": 15919,