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>
This commit is contained in:
parent
2c89a6b580
commit
2ee8137e7c
31 changed files with 3278 additions and 107 deletions
186
lib/util/test-lock.mjs
Normal file
186
lib/util/test-lock.mjs
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
#!/usr/bin/env node
|
||||
// lib/util/test-lock.mjs
|
||||
// K8 -- the test lock. The executor (wave-1 order 1) locks a step's test files
|
||||
// once the test is written and seen failing, and unlocks them when the test
|
||||
// passes. Two guards read the lock:
|
||||
// - pre-write-executor.mjs refuses Write/Edit/NotebookEdit on a locked file;
|
||||
// - stop-test-lock.mjs refuses "done" while the session that took the lock
|
||||
// still holds it.
|
||||
// The lock lives in the repository's git directory (<git dir>/voyage/
|
||||
// test-lock.json), so it can never be committed, and each worktree has its own.
|
||||
//
|
||||
// Usage:
|
||||
// node lib/util/test-lock.mjs lock --files <a,b> --step <N> [--cwd <dir>]
|
||||
// node lib/util/test-lock.mjs unlock [--cwd <dir>]
|
||||
// node lib/util/test-lock.mjs status [--cwd <dir>] (JSON on stdout)
|
||||
// Exit: 0 = done, 1 = refused (another step holds the lock), 2 = could not
|
||||
// run (not a repository, bad usage, unreadable lock). 2 is never "unlocked".
|
||||
// Paths in --files are relative to the --cwd (default: the current directory).
|
||||
// The session is taken from CLAUDE_CODE_SESSION_ID, which Claude Code sets in
|
||||
// Bash tool subprocesses (code.claude.com/docs/en/env-vars).
|
||||
//
|
||||
// Credit: locking tests during a fix is Claude Code's own hook pattern
|
||||
// (hooks-guide "Block edits to protected files").
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync, realpathSync, readdirSync } from 'node:fs';
|
||||
import { join, resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resolveRepo, canonicalPath } from '../verification/repo-tools.mjs';
|
||||
|
||||
export const LOCK_VERSION = 1;
|
||||
|
||||
export function lockPathFor(gitDir) {
|
||||
return join(gitDir, 'voyage', 'test-lock.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the lock for the repo that `start` (a file or directory) belongs to.
|
||||
* @returns {{ state: 'no-repo'|'none'|'locked'|'corrupt', lock?: object, path?: string, error?: string }}
|
||||
*/
|
||||
export function readLock(start) {
|
||||
const repo = resolveRepo(start);
|
||||
if (!repo) return { state: 'no-repo' };
|
||||
const path = lockPathFor(repo.gitDir);
|
||||
if (!existsSync(path)) return { state: 'none', path, repo };
|
||||
try {
|
||||
const lock = JSON.parse(readFileSync(path, 'utf8'));
|
||||
if (!lock || !Array.isArray(lock.files) || !Number.isInteger(lock.step)) {
|
||||
return { state: 'corrupt', path, repo, error: 'lock has no files/step' };
|
||||
}
|
||||
return { state: 'locked', lock, path, repo };
|
||||
} catch (e) {
|
||||
return { state: 'corrupt', path, repo, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// A pointer from the session that took a lock to the lock file, so the stop
|
||||
// guard finds it even after the session has moved to another directory.
|
||||
const SESSION_DIR = join(tmpdir(), `voyage-test-locks-${process.getuid ? process.getuid() : 'u'}`);
|
||||
const sessionPointer = (sessionId) =>
|
||||
join(SESSION_DIR, `${createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 32)}.json`);
|
||||
|
||||
// Pointers whose lock is gone are dropped on the next lock.
|
||||
function sweepPointers() {
|
||||
try {
|
||||
for (const f of readdirSync(SESSION_DIR)) {
|
||||
const p = join(SESSION_DIR, f);
|
||||
try {
|
||||
if (!existsSync(JSON.parse(readFileSync(p, 'utf8')).lock_path)) rmSync(p, { force: true });
|
||||
} catch { rmSync(p, { force: true }); }
|
||||
}
|
||||
} catch { /* no dir yet */ }
|
||||
}
|
||||
|
||||
function writeSessionPointer(sessionId, lockPath) {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
mkdirSync(SESSION_DIR, { recursive: true });
|
||||
sweepPointers();
|
||||
writeFileSync(sessionPointer(sessionId), JSON.stringify({ lock_path: lockPath }));
|
||||
} catch { /* the cwd lookup still works */ }
|
||||
}
|
||||
|
||||
function removeSessionPointer(sessionId) {
|
||||
if (sessionId) rmSync(sessionPointer(sessionId), { force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* The lock this session holds, wherever it lives: { state: 'locked', lock, path }
|
||||
* or { state: 'none' }. A pointer to a lock that is gone, or that another
|
||||
* session now holds, is not this session's lock.
|
||||
*/
|
||||
export function lockForSession(sessionId) {
|
||||
if (!sessionId) return { state: 'none' };
|
||||
try {
|
||||
const { lock_path } = JSON.parse(readFileSync(sessionPointer(sessionId), 'utf8'));
|
||||
const lock = JSON.parse(readFileSync(lock_path, 'utf8'));
|
||||
if (lock?.session === sessionId && Array.isArray(lock.files) && Number.isInteger(lock.step)) {
|
||||
return { state: 'locked', lock, path: lock_path };
|
||||
}
|
||||
} catch { /* no pointer, or the lock is gone */ }
|
||||
return { state: 'none' };
|
||||
}
|
||||
|
||||
// Is `file` one of the locked files? Compared as canonical real paths.
|
||||
export function isLockedFile(lock, file) {
|
||||
const c = canonicalPath(file);
|
||||
return lock.files.includes(c);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { _: [] };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const a = argv[i];
|
||||
if (a === '--files' || a === '--step' || a === '--cwd') { out[a.slice(2)] = argv[i + 1]; i += 1; }
|
||||
else out._.push(a);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function fail(code, msg) {
|
||||
process.stderr.write(`[voyage] test-lock: ${msg}\n`);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const args = parseArgs(argv);
|
||||
const cmd = args._[0];
|
||||
if (!['lock', 'unlock', 'status'].includes(cmd)) fail(2, 'usage: test-lock.mjs lock --files <a,b> --step <N> | unlock | status [--cwd <dir>]');
|
||||
const cwd = resolve(args.cwd || process.cwd());
|
||||
const cur = readLock(cwd);
|
||||
if (cur.state === 'no-repo') fail(2, `not a git repository: ${cwd}`);
|
||||
|
||||
if (cmd === 'status') {
|
||||
if (cur.state === 'corrupt') {
|
||||
process.stdout.write(JSON.stringify({ locked: null, state: 'corrupt', lock_path: cur.path, error: cur.error }, null, 2) + '\n');
|
||||
process.exit(2);
|
||||
}
|
||||
const l = cur.lock || {};
|
||||
process.stdout.write(JSON.stringify({
|
||||
locked: cur.state === 'locked', step: l.step ?? null, files: l.files ?? [], session: l.session ?? null,
|
||||
locked_at: l.locked_at ?? null, lock_path: cur.path,
|
||||
}, null, 2) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (cmd === 'unlock') {
|
||||
if (cur.state === 'corrupt') fail(2, `unreadable lock ${cur.path} (${cur.error}); inspect it before removing it`);
|
||||
removeSessionPointer(cur.lock?.session);
|
||||
rmSync(cur.path, { force: true });
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// lock
|
||||
if (!args.files) fail(2, 'lock needs --files <a,b>');
|
||||
if (args.step === undefined || !/^\d+$/.test(String(args.step))) fail(2, 'lock needs --step <N> (a whole number)');
|
||||
const step = Number(args.step);
|
||||
if (cur.state === 'corrupt') fail(2, `unreadable lock ${cur.path} (${cur.error})`);
|
||||
if (cur.state === 'locked' && cur.lock.step !== step) {
|
||||
fail(1, `step ${cur.lock.step} holds the lock; unlock it first`);
|
||||
}
|
||||
const files = String(args.files).split(',').map((f) => f.trim()).filter(Boolean).map((f) => canonicalPath(resolve(cwd, f)));
|
||||
if (files.length === 0) fail(2, 'lock needs at least one file');
|
||||
const lock = {
|
||||
version: LOCK_VERSION,
|
||||
step,
|
||||
files: [...new Set(files)],
|
||||
session: process.env.CLAUDE_CODE_SESSION_ID || null,
|
||||
locked_at: new Date().toISOString(),
|
||||
};
|
||||
mkdirSync(dirname(cur.path), { recursive: true });
|
||||
writeFileSync(cur.path, JSON.stringify(lock, null, 2) + '\n');
|
||||
writeSessionPointer(lock.session, cur.path);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function isMain() {
|
||||
try {
|
||||
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMain()) main(process.argv.slice(2));
|
||||
275
lib/verification/repo-tools.mjs
Normal file
275
lib/verification/repo-tools.mjs
Normal 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);
|
||||
}
|
||||
127
lib/verification/step-check.mjs
Normal file
127
lib/verification/step-check.mjs
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue