voyage/lib/verification/step-check.mjs
Kjell Tore Guttormsen 2ee8137e7c
feat(hooks): wave-1 guards -- approval ask, plan gate, test lock, post-edit check
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>
2026-10-06 20:09:32 +02:00

127 lines
6 KiB
JavaScript

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