voyage/tests/lib/autonomy-gate.test.mjs
Kjell Tore Guttormsen efe981f7bc
refactor(cleanup): simplify pass on the R-a diff
engine-compare imports the coordinator's title limit and trim instead
of a copy whose reason went with Arm B; the autonomy gate exports one
parser and its test runs in-process with two CLI runs; one import
scanner in lib-imports; the fenced-section walker shared from
tests/helpers/md-section.mjs instead of six copies; plan-gate hash
in-process; living-paths scans once; routing drops the orchestrator
filter whose files are gone; small leftovers removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 13:31:30 +01:00

198 lines
11 KiB
JavaScript

// tests/lib/autonomy-gate.test.mjs
// Cover the --gates flag parser (lib/util/autonomy-gate.mjs): the exported
// parser function, the CLI's JSON-on-stdout contract, and the one CLI line
// each of the four commands runs.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseGatesArgs } from '../../lib/util/autonomy-gate.mjs';
import { sectionText } from '../helpers/md-section.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const SHIM = join(ROOT, 'lib', 'util', 'autonomy-gate.mjs');
const GATES_USAGE = 'usage: --gates takes true, false, closed or open';
const USAGE = 'usage: autonomy-gate.mjs [--gates [true|false|closed|open]]';
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
// ---- the exported parser ----------------------------------------------------
test('parseGatesArgs: bare --gates and true|closed are on, false|open and no flag are off, in both forms', () => {
const on = (argv) => assert.deepEqual(parseGatesArgs(argv), { ok: true, gates: true }, JSON.stringify(argv));
const off = (argv) => assert.deepEqual(parseGatesArgs(argv), { ok: true, gates: false }, JSON.stringify(argv));
on(['--gates']);
off([]);
for (const v of ['true', 'closed']) { on(['--gates', v]); on([`--gates=${v}`]); }
for (const v of ['false', 'open']) { off(['--gates', v]); off([`--gates=${v}`]); }
});
test('parseGatesArgs: any other value is the exact --gates usage error', () => {
for (const bad of [['--gates', 'TRUE'], ['--gates', 'yes'], ['--gates', 'adaptive'], ['--gates=maybe'], ['--gates=']]) {
assert.deepEqual(parseGatesArgs(bad), { ok: false, error: GATES_USAGE }, bad.join(' '));
}
});
test('parseGatesArgs: any other argument (the old --state/--event too) is the exact usage error', () => {
assert.deepEqual(parseGatesArgs(['--gates', '--state', 'idle']), { ok: false, error: USAGE });
});
// ---- the CLI ----------------------------------------------------------------
const runShim = (args) => spawnSync(process.execPath, [SHIM, ...args], { encoding: 'utf8' });
test('CLI: --gates closed prints {"ok":true,"gates":true}, exit 0', () => {
const r = runShim(['--gates', 'closed']);
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.equal(r.stdout, '{"ok":true,"gates":true}\n');
});
test('CLI: a bad value prints the usage error as JSON, exit 1', () => {
const r = runShim(['--gates', 'maybe']);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.equal(r.stdout, `${JSON.stringify({ ok: false, error: GATES_USAGE })}\n`);
});
// ---- the four commands' --gates rule and CLI line ----------------------------
// Each of the four commands holds one rule: the next token is the flag's value
// only when it is true, false, closed or open; the CLI maps it, run once; a
// broken CLI falls back to the same mapping in prose and the run goes on.
const GATES_COMMANDS = ['trekbrief', 'trekresearch', 'trekplan', 'trekexecute'];
const GATES_LINE = 'node ${CLAUDE_PLUGIN_ROOT}/lib/util/autonomy-gate.mjs --gates [value]';
const GATES_RULE = [
'`--gates` takes the next token as its value only when it is `true`, `false`, `closed` or `open` (strip both); any other next token is not its value (strip only the flag).',
'Run the shim once to map the value, bare when there is no value:',
'`gates: true` sets `gates_mode = true`, `false` sets it false.',
'On exit 1 or no JSON (a broken shim; the four values cannot cause it), print the error in one line and map in prose instead: bare, `true`, `closed` → true; `false`, `open` → false. The run goes on either way.',
];
/** Whitespace (line breaks too) collapsed to one space. */
const flat = (s) => s.replace(/\s+/g, ' ').trim();
/** The command's one CLI line, `[value]` filled in with `value` (left out when ''). */
function gatesLine(cmd, value) {
const hits = read(`commands/${cmd}.md`).split('\n')
.filter((l) => /^\s*node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/util\/autonomy-gate\.mjs\b/.test(l));
assert.equal(hits.length, 1, `${cmd}: expected exactly one autonomy-gate.mjs line; found ${hits.length}`);
const l = hits[0].trim();
assert.equal(l, GATES_LINE, `${cmd}: the line must read "${GATES_LINE}"`);
return l.replace(' [value]', value ? ` ${value}` : '');
}
const lineCache = new Map();
/** Run a CLI line from the prose with the plugin root filled in; its `gates`. Each distinct line runs once. */
function lineGates(cmdLine) {
if (!lineCache.has(cmdLine)) {
const r = spawnSync('bash', ['-c', cmdLine.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT)], { cwd: ROOT, encoding: 'utf8' });
assert.equal(r.status, 0, `CLI exited ${r.status}: ${r.stdout}${r.stderr}`);
const parsed = JSON.parse(r.stdout);
assert.equal(parsed.ok, true);
lineCache.set(cmdLine, parsed.gates);
}
return lineCache.get(cmdLine);
}
for (const cmd of GATES_COMMANDS) {
test(`[#1] commands/${cmd}.md holds the shared --gates rule and a CLI line that maps closed to gates true`, () => {
const text = read(`commands/${cmd}.md`);
const f = flat(text);
for (const s of GATES_RULE) assert.ok(f.includes(s), `${cmd}: missing the shared --gates sentence: ${s}`);
assert.doesNotMatch(text, /--state \w+ --event \w+/, `${cmd}: the state-machine line is still there`);
assert.doesNotMatch(f, /next_state|auto_running|gates_on/, `${cmd}: still reads a state of the old state machine`);
assert.equal(lineGates(gatesLine(cmd, 'closed')), true);
});
}
// `/trekplan --gates --project d` and `/trekexecute --gates plan.md` are a bare
// --gates (the next token is not a value), so the line runs bare.
test('every command\'s CLI line maps a bare --gates to true and open to false', () => {
for (const cmd of GATES_COMMANDS) {
assert.equal(lineGates(gatesLine(cmd, '')), true, `${cmd}: bare --gates`);
assert.equal(lineGates(gatesLine(cmd, 'open')), false, `${cmd}: --gates open`);
}
});
// ---- [#1] siblings: what --gates does, said the same everywhere --------------
// /trekbrief adds one pause, before its auto chain; /trekexecute accepts the
// flag and pauses on nothing; the docs give the four values and no strictness
// policy (fix round 4-5a, #1).
const EXEC = 'commands/trekexecute.md';
const GATES_ONE_PAUSE = '`gates_mode = true` adds one pause, before the auto chain starts (Phase 5); the chain does not pass '
+ '`--gates` on to `/trekresearch` or `/trekplan`. Default `gates_mode = false` skips that pause.';
test('[#1] sibling: commands/trekbrief.md promises no execute pause and no pause at every wave in the executor', () => {
const f = flat(read('commands/trekbrief.md'));
assert.doesNotMatch(f, /every wave in the executor/, 'trekbrief still promises a pause at every wave in the executor');
assert.doesNotMatch(f, /\(research, plan, execute\)/, 'trekbrief still promises an execute pause');
assert.ok(f.includes(GATES_ONE_PAUSE), 'trekbrief does not say --gates adds one pause before its auto chain');
});
test('[#1] sibling: /trekbrief Phase 1 says --gates adds one pause, before the auto chain', () => {
const p1 = flat(sectionText(read('commands/trekbrief.md'), '## Phase 1 — Parse mode and validate input'));
assert.ok(p1.includes(GATES_ONE_PAUSE), 'Phase 1 does not say --gates adds one pause before the auto chain');
assert.ok(!p1.includes('every phase boundary in the downstream pipeline'), 'Phase 1 still promises a pause at every downstream phase boundary');
assert.ok(!p1.includes('runs continuously until the main-merge gate'), 'Phase 1 still says the auto chain runs until the main-merge gate');
});
test('[#1] sibling: /trekbrief Phase 5 says the auto chain ends at the plan, never at the main-merge gate', () => {
const p5 = flat(sectionText(read('commands/trekbrief.md'), '## Phase 5 — Auto-orchestration opt-in (if research_topics > 0)'));
assert.ok(p5.includes('The chain ends when `/trekplan` has written the plan (Step 6e); it never starts `/trekexecute`.'),
'Phase 5 does not say the chain ends when /trekplan has written the plan');
assert.ok(!p5.includes('The chain stops only at the main-merge gate'), 'Phase 5 still says the chain stops only at the main-merge gate');
});
test('[#1] sibling: docs/operations.md, docs/command-modes.md and README.md say /trekexecute accepts the flag and pauses on nothing', () => {
for (const rel of ['docs/operations.md', 'docs/command-modes.md', 'README.md']) {
assert.ok(flat(read(rel)).includes('`/trekexecute` accepts the flag and pauses on nothing'), `${rel}: missing "accepts the flag and pauses on nothing"`);
}
for (const rel of ['docs/operations.md', 'docs/command-modes.md', 'README.md', EXEC]) {
const f = flat(read(rel));
for (const old of ['records `gates_mode`', 'Record gates_mode', 'records the setting']) {
assert.ok(!f.includes(old), `${rel}: still says "${old}"`);
}
}
});
test('[#1] sibling: commands/trekexecute.md: `gates_mode` changes nothing in `/trekexecute`, and its one stop is the merge into main', () => {
const f = flat(read('commands/trekexecute.md'));
assert.doesNotMatch(f, /consumed by the autonomy-gate state machine/);
assert.ok(f.includes('`gates_mode` changes nothing in `/trekexecute`: no phase of it pauses on it. The one stop is the merge into main, which always asks (Phase 8).'),
'trekexecute does not say gates_mode changes nothing there and the one stop is the merge into main');
});
test('[#1] sibling: docs/architecture.md does not call --gates open the executor\'s low effort', () => {
const text = read('docs/architecture.md');
assert.doesNotMatch(text, /--gates open/);
assert.match(text, /\(`\/trekexecute` low effort = one session at a time\)/);
});
test('[#1] sibling: docs/command-modes.md gives every --gates row the four values and no strictness policy', () => {
const text = read('docs/command-modes.md');
assert.doesNotMatch(text, /--gates \{true\\\|false\}/);
assert.doesNotMatch(text, /\{open\\?\|closed\\?\|adaptive\}/);
assert.doesNotMatch(text, /Policy \(`gates_mode`\)/, 'a row still points at a gates_mode strictness policy');
const rows = text.split('\n').filter((l) => l.startsWith('| `--gates'));
assert.equal(rows.length, 4, 'one --gates row per command');
for (const r of rows) {
assert.ok(r.startsWith('| `--gates [true\\|false\\|closed\\|open]` |'), `row does not give the four values: ${r}`);
assert.match(r, /`## Autonomy mode` in `docs\/operations\.md`/);
}
});
test('[#1] sibling: README.md gives the four values, and no strictness table', () => {
const text = read('README.md');
const sec = flat(sectionText(text, '### Autonomy mode (`--gates`, v3.4.0)'));
assert.doesNotMatch(text, /\{open\|closed\|adaptive\}/);
assert.doesNotMatch(sec, /\| Value \| Behavior \|/, 'the strictness table is still there');
assert.doesNotMatch(sec, /`adaptive` \(default\)/);
assert.match(sec, /accept `--gates \[true\|false\|closed\|open\]`/);
});