voyage/lib/verification/step-check.mjs
Kjell Tore Guttormsen 29b4ac91b3
fix: round 7 of the release review of 6.3.0, for 6.3.1
Round 7 (516dffa..45aa9e1, WARN: 12 MAJOR, 0 BLOCKER) and the fence defect
found while writing its review.md. Each fix began with a test that failed on
0f400d3 (v6.3.0); suite 2208 (2206/0/2), 14 tests more.

- Tests-only (operator 2026-10-11): the plan's Files: alone decides, and the
  text says so (red-proof, trekexecute Sub-step B, step-implementer,
  HANDOVER-CONTRACTS). red-proof no longer reads the step's diff, so a change
  already in the tree when the step began no longer counts against it, and a
  plan placed outside .claude/ reads the same. The round-6 test that had the
  diff overrule the plan is rewritten to the new rule.
- The Files: reader keeps a plain file beside a backticked path
  (", Dockerfile", "the CLI entry src/cli.mjs", "and Makefile"). Measured on
  the 520 Files: fields in 182 plans: no field reads differently.
- Red proof reports a command its timeout killed as killed after the timeout,
  not "could not start"; the release check tells a timeout from output past
  its buffer and from another signal. One reading of a stop
  (criteria-runner stoppedBy) for red proof, the criteria runner and the
  release check.
- A trailing `# comment` is dropped in a backticked check too, and in a
  command passed as written, so the words screened are the words run.
- The release check reads a relative --review in --repo.
- The 15-minute timeout is defined once (criteria-runner.mjs).
- The findings check reads each cited file once per validation.
- One isMain helper (lib/util/is-main.mjs) for every CLI: the 31 copies of
  the realpath guard and the 20 modules still on the file:// guard, which
  exited 0 having done nothing under a symlink or a path with a space.
- The Bash guard writes its no-one-to-ask refusal once, on a direct condition.
- The findings check ends a ```json block only at a fence on its own line, so
  three backticks inside a JSON string no longer end it (also in 6.2.0).
- Brief-conformance notes: a test for test-lock green on exit 127 (it
  passed at once; it fails when 127 is read as could-not-run); the prose now
  names the git revision `@~1` as the one leading-@ exception.

Not changed: the example plan's steps 1-2 keep their tests-only warning
(operator decision 2026-10-09; the r5 test records it). Brief note 4 (a
Regression: whose runner is not installed goes to the fix rounds) is a
routing question for the operator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:11:57 +02:00

119 lines
5.9 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 } 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 <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);
}