lib/util/autonomy-gate.mjs keeps only gatesValue + parseGatesArgs and the
CLI ({"ok":true,"gates":true|false}; exact usage error on a bad value;
the old --state/--event arguments are a usage error). The four commands
run 'autonomy-gate.mjs --gates [value]' and read gates. Prose (README,
operations, architecture, sdlc-playbook-gap) and the S24, gates-flag
and [#1] tests follow (R9).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
173 lines
7.6 KiB
JavaScript
173 lines
7.6 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';
|
|
|
|
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`);
|
|
}
|
|
});
|