voyage/tests/lib/autonomy-gate.test.mjs
Kjell Tore Guttormsen 029e83117c
test(r8): move the live tests the move map missed
33 more tests of live behaviour from the deleted round files, each in
the test file named after its source and seen failing under a mutation
of that source (log in the project folder). Two stay dropped: one pinned
the removed gate states, one the old Phase 1.5 file comparison. The two
trekexecute.md sentences that still said /trekcontinue compares the two
prompt files now say it uses the project-folder file (line count kept).

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

271 lines
13 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 { execFileSync, spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { findSection } from '../../lib/parsers/plan-schema.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';
function runShim(args) {
try {
const out = execFileSync(process.execPath, [SHIM, ...args], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
return { code: 0, out };
} catch (e) {
return { code: e.status ?? 1, out: e.stdout?.toString() ?? '' };
}
}
// The exit code is asserted before any JSON.parse, so a crash or a usage
// error never reads as a wrong value.
function shimGates(args) {
const r = runShim(args);
assert.equal(r.code, 0, `expected exit 0 for ${JSON.stringify(args)}, got ${r.code}: ${r.out}`);
const parsed = JSON.parse(r.out.trim());
assert.deepEqual(parsed, { ok: true, gates: parsed.gates });
assert.equal(typeof parsed.gates, 'boolean', `gates must be a boolean: ${r.out}`);
return parsed.gates;
}
function shimUsage(args) {
const r = runShim(args);
assert.equal(r.code, 1, `expected exit 1 for ${JSON.stringify(args)}, got ${r.code}: ${r.out}`);
const parsed = JSON.parse(r.out.trim());
assert.equal(parsed.ok, false);
assert.equal(Object.keys(parsed).sort().join(','), 'error,ok');
return parsed.error;
}
// ---- the exported parser ----------------------------------------------------
test('gatesValue maps true|closed to on, false|open to off, anything else to undefined', async () => {
const mod = await import(pathToFileURL(SHIM).href);
assert.equal(typeof mod.gatesValue, 'function', 'autonomy-gate.mjs must export gatesValue');
for (const v of ['true', 'closed']) assert.equal(mod.gatesValue(v), true, v);
for (const v of ['false', 'open']) assert.equal(mod.gatesValue(v), false, v);
for (const v of ['TRUE', 'yes', 'adaptive', '', undefined]) assert.equal(mod.gatesValue(v), undefined, String(v));
});
test('the module is the flag parser only: no state machine is exported', async () => {
const mod = await import(pathToFileURL(SHIM).href);
for (const gone of ['STATES', 'EVENTS', 'transition', 'isTerminal']) {
assert.equal(mod[gone], undefined, `autonomy-gate.mjs still exports ${gone}`);
}
});
// ---- the CLI ----------------------------------------------------------------
test('CLI: bare --gates → {"ok":true,"gates":true}, exit 0', () => {
assert.equal(shimGates(['--gates']), true);
});
test('CLI: no flag → {"ok":true,"gates":false}, exit 0', () => {
assert.equal(shimGates([]), false);
});
test('CLI: --gates true and --gates closed → gates true', () => {
assert.equal(shimGates(['--gates', 'true']), true);
assert.equal(shimGates(['--gates', 'closed']), true);
});
test('CLI: --gates false and --gates open → gates false', () => {
assert.equal(shimGates(['--gates', 'false']), false);
assert.equal(shimGates(['--gates', 'open']), false);
});
test('CLI: the one-token --gates=<value> form is read as --gates <value>', () => {
assert.equal(shimGates(['--gates=true']), true);
assert.equal(shimGates(['--gates=closed']), true);
assert.equal(shimGates(['--gates=false']), false);
assert.equal(shimGates(['--gates=open']), false);
});
for (const bad of [['--gates', 'TRUE'], ['--gates', 'yes'], ['--gates', 'adaptive'], ['--gates=maybe'], ['--gates=']]) {
test(`CLI: ${bad.join(' ')} → the exact usage error, exit 1`, () => {
assert.equal(shimUsage(bad), GATES_USAGE);
});
}
for (const old of [
['--state', 'idle', '--event', 'start', '--gates', 'true'],
['--state', 'idle', '--event', 'start'],
['--gates', '--state', 'idle'],
['--event', 'start'],
]) {
test(`CLI: the old state-machine arguments are a usage error (${old.join(' ')}), exit 1`, () => {
assert.match(shimUsage(old), /^usage:/);
});
}
// ---- 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.',
];
const commandText = (cmd) => readFileSync(join(ROOT, 'commands', `${cmd}.md`), 'utf8');
/** 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 = commandText(cmd).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 = commandText(cmd);
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 read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
const EXEC = 'commands/trekexecute.md';
/**
* The text of a heading's section, up to the next heading of the same or a
* higher level outside a fence. The heading must exist.
*/
function section(text, heading) {
const level = heading.match(/^#+/)[0].length;
const s = findSection(text, heading.replace(/^#+ /, ''), level);
assert.ok(s, `missing ${heading}`);
const lines = s.text.split('\n');
let fenced = false;
for (let i = 1; i < lines.length; i++) {
if (/^\s{0,3}(`{3,}|~{3,})/.test(lines[i])) fenced = !fenced;
const m = !fenced && lines[i].match(/^(#+) /);
if (m && m[1].length < level) return lines.slice(0, i).join('\n');
}
return s.text;
}
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(section(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(section(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(section(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\]`/);
});