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>
127 lines
6 KiB
JavaScript
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);
|
|
}
|