voyage/lib/verification/repo-tools.mjs
Kjell Tore Guttormsen 29b4ac91b3
fix: round 7 of the release review of 6.3.0, for 6.3.1
Round 7 (516dffa..45aa9e1, WARN: 12 MAJOR, 0 BLOCKER) and the fence defect
found while writing its review.md. Each fix began with a test that failed on
0f400d3 (v6.3.0); suite 2208 (2206/0/2), 14 tests more.

- Tests-only (operator 2026-10-11): the plan's Files: alone decides, and the
  text says so (red-proof, trekexecute Sub-step B, step-implementer,
  HANDOVER-CONTRACTS). red-proof no longer reads the step's diff, so a change
  already in the tree when the step began no longer counts against it, and a
  plan placed outside .claude/ reads the same. The round-6 test that had the
  diff overrule the plan is rewritten to the new rule.
- The Files: reader keeps a plain file beside a backticked path
  (", Dockerfile", "the CLI entry src/cli.mjs", "and Makefile"). Measured on
  the 520 Files: fields in 182 plans: no field reads differently.
- Red proof reports a command its timeout killed as killed after the timeout,
  not "could not start"; the release check tells a timeout from output past
  its buffer and from another signal. One reading of a stop
  (criteria-runner stoppedBy) for red proof, the criteria runner and the
  release check.
- A trailing `# comment` is dropped in a backticked check too, and in a
  command passed as written, so the words screened are the words run.
- The release check reads a relative --review in --repo.
- The 15-minute timeout is defined once (criteria-runner.mjs).
- The findings check reads each cited file once per validation.
- One isMain helper (lib/util/is-main.mjs) for every CLI: the 31 copies of
  the realpath guard and the 20 modules still on the file:// guard, which
  exited 0 having done nothing under a symlink or a path with a space.
- The Bash guard writes its no-one-to-ask refusal once, on a direct condition.
- The findings check ends a ```json block only at a fence on its own line, so
  three backticks inside a JSON string no longer end it (also in 6.2.0).
- Brief-conformance notes: a test for test-lock green on exit 127 (it
  passed at once; it fails when 127 is read as could-not-run); the prose now
  names the git revision `@~1` as the one leading-@ exception.

Not changed: the example plan's steps 1-2 keep their tests-only warning
(operator decision 2026-10-09; the r5 test records it). Brief note 4 (a
Regression: whose runner is not installed goes to the fix rounds) is a
routing question for the operator.

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

267 lines
11 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 { isMain } from '../util/is-main.mjs';
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
if (isMain(import.meta.url)) {
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);
}