voyage/tests/lib/plugin-root-resolution.test.mjs
Kjell Tore Guttormsen 920fbd3c47
feat(trekexecute): couple the executor to the review gate, the plan approval and the step checks
Wave 2, order 6. /trekexecute now:
- refuses a plan whose logged approval does not cover its current sha256
  (K2, approval-log verify --kind plan), before step 1;
- runs the step check (K9) and the step's optional Regression: (K10, new
  lib/verification/regression-check.mjs through the criteria runner) at step
  end, before the checkpoint; exit 2 is "not measured", never green;
- runs the architecture rules (K5) and the plan-file sweep (K6) after the last
  step; a violation or a file outside the plan turns completed into partial;
- reviews by itself in the closing run (Phase 7.2): run the thing, /simplify,
  /trekreview, verdict-gate; a BLOCK adds the review's remediation steps after
  the plan validator accepts them and the operator re-approves the plan, capped
  at TREKEXECUTE_MAX_REVIEW_ROUNDS (3);
- makes the main-merge gate a lock on the verdict: BLOCK or undecided keeps
  the merge closed; when open, the executor merges and the guard's question is
  the one question (operator decision 2026-10-08). The --gates pause before
  the wave merge-back is gone for the same reason.

The guard refuses a merge into main and a push in the permission mode that
shows no prompts, since no one can be asked (operator decision 2026-10-09; two
mode-read exemptions approved by the operator).

progress.json gains optional coupling fields (Handover 5); the validator
rejects the runner's exitCode in plan_verification.failed_criteria (C15).
Pointer lines name the skills test-first and debugging (S2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:22:00 +02:00

89 lines
4.2 KiB
JavaScript

// tests/lib/plugin-root-resolution.test.mjs
// Every `VOYAGE_ROOT=` block in commands/*.md must resolve the installed plugin
// root, because the node helper that follows it is guarded by
// `[ -f "$VOYAGE_ROOT/lib/..." ] || exit 2`. A block that never resolves turns
// the whole phase into a permanent exit 2.
//
// Two conditions, both run through bash exactly as the block is written:
// substituted Claude Code replaces the LITERAL `${CLAUDE_PLUGIN_ROOT}` in a
// plugin command's text (measured in recorded sessions: commands
// ran `node <cache>/voyage/5.11.1/lib/...`). The Bash tool's env
// does not carry the variable. A `${CLAUDE_PLUGIN_ROOT:-}` form is
// not that literal, so it is never replaced.
// unsubstituted the text arrived as written; the fallback must find the newest
// installed version under cache/<marketplace>/voyage/<version>/,
// not the version-less parent directory, and by version number,
// not by name or mtime (measured: mtime put 5.11.0 above 5.11.1).
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, utimesSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const LITERAL = '${CLAUDE_PLUGIN_ROOT}';
// Each block runs from its `VOYAGE_ROOT=` line through the first `esac`.
function resolutionBlocks() {
const blocks = [];
for (const f of readdirSync(join(ROOT, 'commands')).filter(n => n.endsWith('.md')).sort()) {
const lines = readFileSync(join(ROOT, 'commands', f), 'utf-8').split('\n');
lines.forEach((line, i) => {
if (!line.startsWith('VOYAGE_ROOT=')) return;
const end = lines.findIndex((l, j) => j > i && l.trim() === 'esac');
assert.ok(end > i, `commands/${f}:${i + 1}: VOYAGE_ROOT block has no esac`);
blocks.push({ where: `commands/${f}:${i + 1}`, text: lines.slice(i, end + 1).join('\n') });
});
}
return blocks;
}
function run(blockText, home) {
const env = { PATH: process.env.PATH, HOME: home };
return execFileSync('bash', ['-c', `${blockText}\nprintf '%s' "$VOYAGE_ROOT"`], { env, encoding: 'utf-8' });
}
// trekexecute blocks 3 and 4 are Sub-step D4 (step check + regression) and Phase 7's K5/K6 checks (wave 2, order 6).
test('commands carry exactly the six known VOYAGE_ROOT blocks', () => {
assert.deepEqual(resolutionBlocks().map(b => b.where.split(':')[0]), [
'commands/trekexecute.md', 'commands/trekexecute.md', 'commands/trekexecute.md', 'commands/trekexecute.md',
'commands/trekresearch.md', 'commands/trekreview.md',
]);
});
test('substituted: every block takes the root Claude Code wrote into the text', () => {
const home = mkdtempSync(join(tmpdir(), 'vr-home-'));
const root = '/opt/fake-cache/mkt/voyage/9.9.9';
try {
for (const b of resolutionBlocks()) {
assert.ok(b.text.includes(LITERAL), `${b.where}: no literal ${LITERAL} for Claude Code to substitute`);
assert.equal(run(b.text.split(LITERAL).join(root), home), root, b.where);
}
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('unsubstituted: every block falls back to the highest installed version directory', () => {
const home = mkdtempSync(join(tmpdir(), 'vr-home-'));
const base = join(home, '.claude', 'plugins', 'cache', 'mkt', 'voyage');
try {
// Created highest first and given the OLDEST mtime, so neither name order
// ("5.9.0" > "5.11.1") nor mtime order picks the right one by accident.
const versions = ['5.11.1', '5.9.0', '5.10.1'];
versions.forEach((v, i) => {
mkdirSync(join(base, v, 'lib'), { recursive: true });
const t = new Date(Date.UTC(2026, 0, 1 + i));
utimesSync(join(base, v), t, t);
});
for (const b of resolutionBlocks()) {
assert.equal(run(b.text, home), join(base, '5.11.1'), b.where);
}
} finally {
rmSync(home, { recursive: true, force: true });
}
});