* test(#4130): failing-first regressions for --context flag + parseDecisions hardening Block A (flag): check decision-coverage-plan --context <path> must route identically to the positional form; flag wins over positional context; valueless --context falls through to the #2770 fail-closed caller error; verify keeps its positional surface (flag is plan-only). RED on base: the flag token lands in the args[2] phase slot (false uncovered) or the args[3] context slot (silent CONTEXT.md-missing skip). Block B (hardening): regex-lattice asserts pin the atomic-ID wrapper (?=(X))\1 and the em-dash first-separator narrowing [^*—–]*[—–] plus the no-adjacent-overlap property; a differential property compares the module against a frozen copy of the pre-hardening grammars (reference validated against the base build: 60k generated lines, 0 mismatches); 40k cliff shapes assert correct outcomes with no wall-time asserts (repo rule). A12: partitionPredicateArgs keeps one parser behind parsePredicateFlags. * fix(#4130): --context flag for check decision-coverage-plan + quadratic-backtracking hardening in parseDecisions (A) check decision-coverage-plan --context <path> — sibling convention (check predicate, #2008): --flag value pairs parsed by the new shared partitionPredicateArgs (parsePredicateFlags reimplemented as its flags half — one parser, cannot diverge), the flag winning over a same-purpose positional, positionals kept (no sibling deprecates them; the plan-phase workflow caller passes positionals), valueless --context falls through to the #2770 fail-closed caller error. Repair of the routing accident where --context landed in the args[2] phase slot (false uncovered) or the literal token in the args[3] context slot (silent green skip). (B) parseDecisions regex seam hardened, byte-identical on all legal inputs: the three bullet grammars consume the ID atomically via the (?=(X))\1 lookahead emulation (kills the tail/[^:*]* O(n^2) re-split, ~1.1s @ 40k), and the em-dash first separator narrows [^*]*[—–] to [^*—–]*[—–] (kills the dash-position O(n^2) retry, ~1.7s @ 40k). Group indices unchanged (handlers untouched). Pinned by regex-lattice tests, a differential fast-check property vs the frozen pre-hardening grammars, and 40k cliff/legal-shape outcome tests (no wall-time asserts per repo rule — no deterministic engine step counter exists in Node). * docs+test(#4130): document --context invocation; harden lattice test tooling - docs/CONFIGURATION.md Decision Coverage Gates: new 'Invoking the plan gate directly' block documenting both the positional and --context forms, flag precedence, and the valueless-flag fail-closed semantics (same place the gate's behavior is documented; sibling check predicate documents its flags the same way). - Two changeset fragments per the maintainer brief (Added: flag; Fixed: hardening), PR numbers to be backfilled. - tests/decisions.test.cjs review fixes: readRegExpTemplate template escaping (bare ')' SyntaxError), range-aware lattice checker with backreference skip and template unescape, honest A1 contract, lint escape warning. * fix(#4130): valueless --context fails closed per #2770; A8 isolates flag-vs-positional context Suite-caught fixes from the first verify run: - cmdDecisionCoveragePlan now refuses a flag-shaped token as the positional context path: a bare valueless --context stays a positional (sibling parser semantics, unchanged) but reading it as a PATH would turn a caller mistake into a silent 'CONTEXT.md missing' green skip — exactly what #2770's fail-closed law forbids. Now falls through to the missing-context-argument error, as documented. - A8 test compares decoy-positional+flag against flag-with-phase (phase held constant) so the row isolates WHICH context was read; the old form compared against a no-phase invocation that could never match. * chore(#4130): backfill PR number in changeset fragments (PR #4374) --------- Co-authored-by: sim <sim@local>
156 lines
6.4 KiB
JavaScript
156 lines
6.4 KiB
JavaScript
'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([]), {});
|
|
});
|
|
});
|
|
|
|
// ─── #4130 follow-up: partitionPredicateArgs (flags + positionals, one parser) ─
|
|
|
|
/**
|
|
* `partitionPredicateArgs` is the single pass behind `parsePredicateFlags`:
|
|
* it returns BOTH the --flag value map AND the non-consumed positional tokens
|
|
* under the exact same skip/consume/last-wins semantics. `check
|
|
* decision-coverage-plan --context <path>` uses it so the flag and the
|
|
* positional surface share one parser with `check predicate` — the two
|
|
* parsers cannot diverge because there is only one.
|
|
*/
|
|
describe('partitionPredicateArgs (#4130 follow-up)', () => {
|
|
const { partitionPredicateArgs } = require('../gsd-core/bin/lib/check-command-router.cjs');
|
|
|
|
test('splits --flag value pairs from positionals', () => {
|
|
const { flags, positionals } = partitionPredicateArgs(
|
|
['check', 'decision-coverage-plan', '--context', '/tmp/CONTEXT.md', 'phases/01-init'],
|
|
);
|
|
assert.deepEqual(flags, { context: '/tmp/CONTEXT.md' });
|
|
assert.deepEqual(positionals, ['check', 'decision-coverage-plan', 'phases/01-init']);
|
|
});
|
|
|
|
test('parsePredicateFlags is exactly the flags half (one source of truth)', () => {
|
|
const vectors = [
|
|
['check', 'predicate', '--predicate', '{"kind":"x"}', '--phase-number', '03', '--raw'],
|
|
['--phase-number', '01', '--phase-number', '02'],
|
|
['--predicate', '--phase-number'],
|
|
[],
|
|
['--context'],
|
|
['a', '--context', 'b', '--context', 'c', 'd'],
|
|
];
|
|
for (const v of vectors) {
|
|
assert.deepEqual(partitionPredicateArgs(v).flags, parsePredicateFlags(v),
|
|
`flags half must equal parsePredicateFlags for ${JSON.stringify(v)}`);
|
|
}
|
|
});
|
|
|
|
test('value that starts with -- is not consumed: both stay flags, neither becomes positional', () => {
|
|
const { flags, positionals } = partitionPredicateArgs(['--context', '--other']);
|
|
assert.deepEqual(flags, {});
|
|
assert.deepEqual(positionals, ['--context', '--other']);
|
|
});
|
|
|
|
test('last write wins; flag values never leak into positionals', () => {
|
|
const { flags, positionals } = partitionPredicateArgs(['p1', '--context', 'a', 'p2', '--context', 'b', 'p3']);
|
|
assert.equal(flags.context, 'b');
|
|
assert.deepEqual(positionals, ['p1', 'p2', 'p3']);
|
|
});
|
|
});
|