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>
267 lines
11 KiB
JavaScript
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);
|
|
}
|