#!/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 // 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 \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); }