#!/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 [--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 } from 'node:fs'; import { join, extname } from 'node:path'; import { spawnSync } from 'node:child_process'; import { resolveRepo, detectTools, runTool } from './repo-tools.mjs'; import { latestPostEditRecords } from '../../hooks/lib/post-edit.mjs'; import { runContract } from '../../hooks/lib/contract-cli.mjs'; import { isMain } from '../util/is-main.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 }; } if (isMain(import.meta.url)) { 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 [--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); }