Wave-1 order 4 (P7, P12, the P9 test lock, the P3 plan-approval guard). All command hooks; none runs a model. - P12: pre-bash-executor answers permissionDecision "ask" before merge into main, push, tag/publish and destructive git (holds in auto mode). post-approval-log writes exactly what the person was asked to the K2 approval log: merge/push to main as `merge`, tag/publish as `release`, with a one-hour expiry; nothing when no person was asked. - P3: pre-plan-gate runs the K3 plan gate on a Voyage plan leaving plan mode and sends a refused plan back; "not measured" asks, never passes. - P9: lib/util/test-lock.mjs (K8). pre-write-executor refuses edits to locked test files; stop-test-lock refuses "done" for the locking session (stop_hook_active guarded). - P7: post-edit-check runs the target repo's own formatter/linter per edit, only during a Voyage plan run or after opt-in (.claude/voyage-guards.json); lib/verification/step-check.mjs (K9) fails the step on findings; no tool reads "not measured", never green. - STORM cap matcher also names `Agent` (the tool's name since 2.1.63). - Mod precondition stated in README, SECURITY.md and every PreToolUse stop guard: Voyage's guards are deterministic only when no installed mod handles `tool.check`. K2, K3 and K5 are stubbed in tests until orders 2 and 3 are merged. Two independent review rounds; every finding has a regression test. Suite 1600 (1598 pass / 0 fail / 2 skipped). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
275 lines
12 KiB
JavaScript
275 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
// lib/verification/repo-tools.mjs
|
|
// Finds the target repository's OWN formatter, linter and type checker, and
|
|
// runs them. Used by the post-edit guard (P7, hooks/scripts/post-edit-check.mjs,
|
|
// the fast per-file tier) and by the step check (K9, step-check.mjs, the
|
|
// step-end and full tiers).
|
|
//
|
|
// Rules:
|
|
// - A tool counts only when the repo configures it AND it is installed:
|
|
// node tools in the repo's node_modules/.bin, the rest on PATH. Nothing is
|
|
// ever downloaded (no npx), so a check never reaches the network.
|
|
// - Every run is check-only (prettier --check, ruff format --check, ...): the
|
|
// guard reports, it never rewrites a file behind Claude's back.
|
|
// - Each tool's exit code maps to clean / finding / error. An error is a
|
|
// tool that could not give an answer; it is never read as clean.
|
|
// - Spawned without a shell; file names are passed as arguments.
|
|
// The exit-code meanings below are the tools' documented conventions as I
|
|
// know them; they were not re-read per tool for this module (ikke verifisert).
|
|
//
|
|
// The tiers (fast per file / step end / full) and "not measured" are from
|
|
// agent-skills' constraint-driven development (MIT, `9d0c60d`); the split
|
|
// between per-file and step-end checks is Kiro's (docs only). Ideas only.
|
|
//
|
|
// CLI: node lib/verification/repo-tools.mjs --repo <dir>
|
|
// prints the detected tools as JSON. Exit 0 = at least one available tool,
|
|
// 2 = none available (not measured) or not a repository.
|
|
|
|
import { existsSync, readFileSync, statSync, realpathSync, accessSync, constants } from 'node:fs';
|
|
import { dirname, join, resolve, isAbsolute, extname, delimiter } from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The repository
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Real path of `p`, or of its nearest existing ancestor joined with the rest:
|
|
// macOS reaches /var through /private/var, and a path that does not exist yet
|
|
// must still compare equal to the same path once it does. The native realpath
|
|
// also returns the on-disk letter case, so `Tests/a.js` and `tests/a.js` on a
|
|
// case-insensitive file system are one path (the JS realpath keeps the case
|
|
// it was given).
|
|
export function canonicalPath(p) {
|
|
let cur = resolve(p);
|
|
const rest = [];
|
|
while (!existsSync(cur)) {
|
|
const up = dirname(cur);
|
|
if (up === cur) break;
|
|
rest.unshift(cur.slice(up.length).replace(/^\/+/, ''));
|
|
cur = up;
|
|
}
|
|
let real = cur;
|
|
try { real = realpathSync.native(cur); } catch { /* keep resolved */ }
|
|
return rest.length ? join(real, ...rest) : real;
|
|
}
|
|
|
|
/**
|
|
* The repository a path belongs to: { root, gitDir } (real paths), or null.
|
|
* Reads the filesystem only; follows a worktree's `.git` file to its git dir.
|
|
*/
|
|
export function resolveRepo(start) {
|
|
let dir = canonicalPath(start || process.cwd());
|
|
try { if (!statSync(dir).isDirectory()) dir = dirname(dir); } catch { dir = dirname(dir); }
|
|
while (!existsSync(dir)) dir = dirname(dir);
|
|
for (;;) {
|
|
const dotGit = join(dir, '.git');
|
|
if (existsSync(dotGit)) {
|
|
try {
|
|
if (statSync(dotGit).isDirectory()) return { root: dir, gitDir: realpathSync.native(dotGit) };
|
|
const m = readFileSync(dotGit, 'utf8').match(/^gitdir:\s*(.+?)\s*$/m);
|
|
if (!m) return null;
|
|
const gd = isAbsolute(m[1]) ? m[1] : resolve(dir, m[1]);
|
|
return { root: dir, gitDir: realpathSync.native(gd) };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
const up = dirname(dir);
|
|
if (up === dir) return null;
|
|
dir = up;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The tools
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const JS_EXT = ['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.mts', '.cts', '.vue'];
|
|
const PRETTIER_EXT = [...JS_EXT, '.json', '.css', '.scss', '.less', '.md', '.yaml', '.yml', '.html'];
|
|
|
|
function readText(p) {
|
|
try { return readFileSync(p, 'utf8'); } catch { return null; }
|
|
}
|
|
|
|
function anyFile(root, names) {
|
|
return names.some((n) => existsSync(join(root, n)));
|
|
}
|
|
|
|
function pkgKey(root, key) {
|
|
const txt = readText(join(root, 'package.json'));
|
|
if (!txt) return false;
|
|
try { return Object.prototype.hasOwnProperty.call(JSON.parse(txt), key); } catch { return false; }
|
|
}
|
|
|
|
function tomlHas(root, file, section) {
|
|
const txt = readText(join(root, file));
|
|
return !!txt && txt.split('\n').some((l) => l.trim() === `[${section}]` || l.trim().startsWith(`[${section}.`));
|
|
}
|
|
|
|
function isExecutable(p) {
|
|
try { accessSync(p, constants.X_OK); return statSync(p).isFile(); } catch { return false; }
|
|
}
|
|
|
|
function localBin(root, name) {
|
|
const p = join(root, 'node_modules', '.bin', name);
|
|
return isExecutable(p) ? p : null;
|
|
}
|
|
|
|
function onPath(name, env) {
|
|
for (const dir of String(env.PATH || '').split(delimiter)) {
|
|
if (!dir) continue;
|
|
const p = join(dir, name);
|
|
if (isExecutable(p)) return p;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Each spec: when the repo configures it, where its binary lives, how to call
|
|
// it for a list of files (file tier) or for the whole repo (repo tier), and
|
|
// which exit codes mean "found problems".
|
|
const SPECS = [
|
|
{
|
|
name: 'prettier', kind: 'format', tier: 'file', ext: PRETTIER_EXT,
|
|
configured: (r) => anyFile(r, ['.prettierrc', '.prettierrc.json', '.prettierrc.yaml', '.prettierrc.yml', '.prettierrc.json5',
|
|
'.prettierrc.js', '.prettierrc.cjs', '.prettierrc.mjs', '.prettierrc.toml', 'prettier.config.js', 'prettier.config.cjs',
|
|
'prettier.config.mjs']) || pkgKey(r, 'prettier'),
|
|
bin: (r) => localBin(r, 'prettier'),
|
|
argv: (files) => ['--check', ...files], findingCodes: [1],
|
|
},
|
|
{
|
|
name: 'biome', kind: 'lint', tier: 'file', ext: [...JS_EXT, '.json', '.jsonc', '.css'],
|
|
configured: (r) => anyFile(r, ['biome.json', 'biome.jsonc']),
|
|
bin: (r) => localBin(r, 'biome'),
|
|
argv: (files) => ['check', ...files], findingCodes: [1],
|
|
},
|
|
{
|
|
name: 'eslint', kind: 'lint', tier: 'file', ext: JS_EXT,
|
|
configured: (r) => anyFile(r, ['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs', 'eslint.config.ts',
|
|
'.eslintrc', '.eslintrc.js', '.eslintrc.cjs', '.eslintrc.json', '.eslintrc.yml', '.eslintrc.yaml']) || pkgKey(r, 'eslintConfig'),
|
|
bin: (r) => localBin(r, 'eslint'),
|
|
argv: (files) => [...files], findingCodes: [1],
|
|
},
|
|
{
|
|
name: 'tsc', kind: 'typecheck', tier: 'repo',
|
|
configured: (r) => existsSync(join(r, 'tsconfig.json')),
|
|
bin: (r) => localBin(r, 'tsc'),
|
|
argv: () => ['--noEmit', '-p', '.'], findingCodes: [1, 2],
|
|
},
|
|
{
|
|
name: 'ruff-check', kind: 'lint', tier: 'file', ext: ['.py', '.pyi'],
|
|
configured: (r) => anyFile(r, ['ruff.toml', '.ruff.toml']) || tomlHas(r, 'pyproject.toml', 'tool.ruff'),
|
|
bin: (r, env) => (isExecutable(join(r, '.venv', 'bin', 'ruff')) ? join(r, '.venv', 'bin', 'ruff') : onPath('ruff', env)),
|
|
argv: (files) => ['check', ...files], findingCodes: [1],
|
|
},
|
|
{
|
|
name: 'ruff-format', kind: 'format', tier: 'file', ext: ['.py', '.pyi'],
|
|
configured: (r) => anyFile(r, ['ruff.toml', '.ruff.toml']) || tomlHas(r, 'pyproject.toml', 'tool.ruff'),
|
|
bin: (r, env) => (isExecutable(join(r, '.venv', 'bin', 'ruff')) ? join(r, '.venv', 'bin', 'ruff') : onPath('ruff', env)),
|
|
argv: (files) => ['format', '--check', ...files], findingCodes: [1],
|
|
},
|
|
{
|
|
name: 'mypy', kind: 'typecheck', tier: 'repo',
|
|
configured: (r) => anyFile(r, ['mypy.ini', '.mypy.ini']) || tomlHas(r, 'pyproject.toml', 'tool.mypy') || tomlHas(r, 'setup.cfg', 'mypy'),
|
|
bin: (r, env) => (isExecutable(join(r, '.venv', 'bin', 'mypy')) ? join(r, '.venv', 'bin', 'mypy') : onPath('mypy', env)),
|
|
argv: () => ['.'], findingCodes: [1],
|
|
},
|
|
{
|
|
// gofmt -l lists unformatted files and exits 0: a listed file is a finding.
|
|
name: 'gofmt', kind: 'format', tier: 'file', ext: ['.go'],
|
|
configured: (r) => existsSync(join(r, 'go.mod')),
|
|
bin: (_r, env) => onPath('gofmt', env),
|
|
argv: (files) => ['-l', ...files], findingCodes: [], stdoutIsFinding: true,
|
|
},
|
|
{
|
|
name: 'go-vet', kind: 'lint', tier: 'repo',
|
|
configured: (r) => existsSync(join(r, 'go.mod')),
|
|
bin: (_r, env) => onPath('go', env),
|
|
argv: () => ['vet', './...'], findingCodes: [1],
|
|
},
|
|
{
|
|
name: 'rustfmt', kind: 'format', tier: 'file', ext: ['.rs'],
|
|
configured: (r) => existsSync(join(r, 'Cargo.toml')),
|
|
bin: (_r, env) => onPath('rustfmt', env),
|
|
argv: (files) => ['--check', ...files], findingCodes: [1],
|
|
},
|
|
{
|
|
name: 'swiftformat', kind: 'format', tier: 'file', ext: ['.swift'],
|
|
configured: (r) => existsSync(join(r, '.swiftformat')),
|
|
bin: (_r, env) => onPath('swiftformat', env),
|
|
argv: (files) => ['--lint', ...files], findingCodes: [1],
|
|
},
|
|
];
|
|
|
|
/**
|
|
* The tools the repo at `root` configures, each marked available or not.
|
|
* @returns {Array<{name, kind, tier, ext?, available: boolean, bin: string|null, reason: string}>}
|
|
*/
|
|
export function detectTools(root, { env = process.env } = {}) {
|
|
const out = [];
|
|
for (const s of SPECS) {
|
|
if (!s.configured(root)) continue;
|
|
const bin = s.bin(root, env);
|
|
out.push({
|
|
name: s.name, kind: s.kind, tier: s.tier, ext: s.ext, bin, available: !!bin,
|
|
reason: bin ? '' : `${s.name} is configured in the repo but not installed`,
|
|
spec: s,
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Available file-tier tools that apply to this file.
|
|
export function toolsForFile(tools, file) {
|
|
const ext = extname(file).toLowerCase();
|
|
return tools.filter((t) => t.available && t.tier === 'file' && t.ext.includes(ext));
|
|
}
|
|
|
|
/**
|
|
* Run one tool. `files` is ignored for repo-tier tools.
|
|
* @returns {{ name, kind, status: 'clean'|'finding'|'error', exit: number|null, output: string }}
|
|
*/
|
|
export function runTool(tool, files, { cwd, timeout = 120_000, env = process.env } = {}) {
|
|
const s = tool.spec;
|
|
// A file named like `-x.js` or `--config=y` would reach the tool as an
|
|
// option; `./` keeps it a file for every tool, with or without `--` support.
|
|
const safe = files.map((f) => (f.startsWith('-') ? `./${f}` : f));
|
|
const r = spawnSync(tool.bin, s.argv(safe), { cwd, encoding: 'utf8', timeout, env });
|
|
const output = `${r.stdout || ''}${r.stderr || ''}`.trim().slice(0, 4000);
|
|
let status;
|
|
if (r.error || r.status === null) status = 'error';
|
|
else if (s.stdoutIsFinding) status = r.status === 0 ? ((r.stdout || '').trim() ? 'finding' : 'clean') : 'error';
|
|
else if (r.status === 0) status = 'clean';
|
|
else status = s.findingCodes.includes(r.status) ? 'finding' : 'error';
|
|
return { name: tool.name, kind: tool.kind, status, exit: r.status ?? null, output: r.error ? String(r.error.message) : output };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CLI
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function isMain() {
|
|
try {
|
|
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (isMain()) {
|
|
const i = process.argv.indexOf('--repo');
|
|
const start = i !== -1 ? process.argv[i + 1] : null;
|
|
if (!start) {
|
|
process.stderr.write('usage: repo-tools.mjs --repo <dir>\n');
|
|
process.exit(2);
|
|
}
|
|
const repo = resolveRepo(start);
|
|
if (!repo) {
|
|
process.stdout.write(JSON.stringify({ status: 'not_measured', reason: 'not a git repository', tools: [] }, null, 2) + '\n');
|
|
process.exit(2);
|
|
}
|
|
const tools = detectTools(repo.root).map(({ spec, ...t }) => t);
|
|
const any = tools.some((t) => t.available);
|
|
process.stdout.write(JSON.stringify({ status: any ? 'tools' : 'not_measured', root: repo.root, tools }, null, 2) + '\n');
|
|
process.exit(any ? 0 : 2);
|
|
}
|