merge: wave 1 order 4 (guards) into main

CLAUDE.md: the review paragraph (order 5) and the guards paragraph
(order 4) are both kept (planned union line).

The launch gate went red on the merged tree: post-approval-log reads
the session's permission_mode, and its regression test feeds that value.
Both were untracked when the branch ran its suite, so the gate never saw
them there. Operator decision 2026-10-06: a counted exemption in a new
class (mode-read: 3 + 1 hits), and the binding-rule sentence in CLAUDE.md
names it. An extra hit in either file turns the gate red again (checked).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-06 20:41:21 +02:00
commit 47864f91b4
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
32 changed files with 3284 additions and 110 deletions

View file

@ -0,0 +1,275 @@
#!/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);
}

View file

@ -0,0 +1,127 @@
#!/usr/bin/env node
// lib/verification/step-check.mjs
// K9 -- the step check. At step end (and on --full) it runs the target repo's
// own tools and reads the post-edit guard's log (P7):
// - file-tier tools (formatters, linters) over the changed files -- the diff
// against HEAD plus untracked files -- or, with --full, over every tracked
// file;
// - repo-tier tools (type checkers, `go vet`) once;
// - the post-edit log: a changed file whose latest record is a finding, and
// that no tool re-checked now, still counts as a finding;
// - optionally the architecture-rule runner (K5, lib/verification/
// arch-rules.mjs, wave-1 order 3): missing or exit 2 = listed as not
// measured, exit 1 = a finding.
//
// Usage: node lib/verification/step-check.mjs --cwd <dir> [--full]
// Output: JSON on stdout { status, root, files, not_covered, checks: [...] }.
// Exit: 0 = green (something was measured, every configured tool answered,
// nothing found), 1 = findings, 2 = not measured (a configured tool
// is missing or crashed, nothing could be checked, not a repository,
// bad usage). 2 is never green. Changed files no tool covers are listed
// in `not_covered`.
//
// Credit: the tiers and "not measured" are agent-skills' constraint-driven
// development (MIT, `9d0c60d`); the stop at step end is Cherny's and Kiro's.
import { existsSync, realpathSync } from 'node:fs';
import { join, extname } from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { resolveRepo, detectTools, runTool } from './repo-tools.mjs';
import { latestPostEditRecords } from '../../hooks/lib/post-edit.mjs';
import { runContract } from '../../hooks/lib/contract-cli.mjs';
// NUL-separated and unquoted, so a name with spaces or non-ASCII letters
// comes back as it is on disk (git quotes such names by default).
function gitPaths(cwd, args) {
const r = spawnSync('git', ['-c', 'core.quotePath=false', ...args, '-z'], { cwd, encoding: 'utf8', timeout: 60_000 });
if (r.status !== 0) return null;
return r.stdout.split('\0').filter(Boolean);
}
function changedFiles(root) {
const tracked = gitPaths(root, ['diff', '--name-only', 'HEAD']) ?? gitPaths(root, ['diff', '--name-only']) ?? [];
const untracked = gitPaths(root, ['ls-files', '--others', '--exclude-standard']) ?? [];
return [...new Set([...tracked, ...untracked])].filter((f) => existsSync(join(root, f)));
}
function allFiles(root) {
return (gitPaths(root, ['ls-files']) ?? []).filter((f) => existsSync(join(root, f)));
}
export function stepCheck({ cwd, full = false }) {
const repo = resolveRepo(cwd);
if (!repo) return { status: 'not_measured', reason: `not a git repository: ${cwd}`, checks: [] };
const files = full ? allFiles(repo.root) : changedFiles(repo.root);
const tools = detectTools(repo.root);
const checks = [];
const covered = new Set();
for (const t of tools) {
if (!t.available) {
checks.push({ name: t.name, kind: t.kind, status: 'not_measured', reason: t.reason });
continue;
}
if (t.tier === 'repo') {
const r = runTool(t, [], { cwd: repo.root, timeout: 300_000 });
checks.push({ ...r, status: r.status === 'error' ? 'not_measured' : r.status });
continue;
}
const mine = files.filter((f) => t.ext.includes(extname(f).toLowerCase()));
if (mine.length === 0) continue;
const r = runTool(t, mine, { cwd: repo.root, timeout: 300_000 });
checks.push({ ...r, files: mine, status: r.status === 'error' ? 'not_measured' : r.status });
if (r.status !== 'error') mine.forEach((f) => covered.add(f));
}
const log = latestPostEditRecords(repo.gitDir);
const stale = files.filter((f) => !covered.has(f) && log.get(f)?.status === 'finding');
if (stale.length) {
checks.push({ name: 'post-edit-log', kind: 'log', status: 'finding', files: stale,
output: 'the post-edit guard logged findings for these files and no tool re-checked them now' });
}
const arch = runContract('K5', ['--repo', repo.root], { cwd: repo.root, timeout: 300_000 });
if (!arch.ran) checks.push({ name: 'arch-rules', kind: 'architecture', status: 'not_measured', reason: arch.why });
else {
const status = arch.code === 0 ? 'clean' : arch.code === 1 ? 'finding' : 'not_measured';
checks.push({ name: 'arch-rules', kind: 'architecture', status, exit: arch.code, output: (arch.stdout + arch.stderr).trim().slice(0, 4000) });
}
// Changed files no available tool covers: listed, never dropped. They do not
// turn the step red or grey on their own (a README edit in a JS repo is
// normal), but the report names them.
const notCovered = files.filter((f) => !tools.some((t) => t.available && t.tier === 'file' && t.ext.includes(extname(f).toLowerCase())));
// A tool the repo configures that gave no answer (not installed, crashed)
// means the step was not fully measured: never green. The arch-rule runner
// is optional (K5) and is exempt.
const unanswered = checks.filter((c) => c.status === 'not_measured' && c.name !== 'arch-rules');
const measured = checks.filter((c) => c.status === 'clean' || c.status === 'finding');
let status;
if (checks.some((c) => c.status === 'finding')) status = 'findings';
else if (unanswered.length || measured.length === 0) status = 'not_measured';
else status = 'green';
return { status, root: repo.root, full, files, not_covered: notCovered, checks };
}
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
const argv = process.argv.slice(2);
const i = argv.indexOf('--cwd');
const cwd = i !== -1 ? argv[i + 1] : null;
if (!cwd) {
process.stdout.write(JSON.stringify({ status: 'not_measured', reason: 'usage: step-check.mjs --cwd <dir> [--full]', checks: [] }) + '\n');
process.exit(2);
}
const report = stepCheck({ cwd, full: argv.includes('--full') });
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
process.exit(report.status === 'green' ? 0 : report.status === 'findings' ? 1 : 2);
}