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>
295 lines
14 KiB
JavaScript
295 lines
14 KiB
JavaScript
// lib/verification/arch-rules.mjs
|
|
// K5 — the architecture-rule runner. Runs the target repo's OWN architecture
|
|
// tool and reports one verdict. It installs nothing and never reaches for a
|
|
// package runner that could (no npx): a tool counts only when its config AND
|
|
// a local binary are both present.
|
|
//
|
|
// node arch-rules.mjs --repo <dir> [--target <path>]...
|
|
// 0 = every detected tool passed
|
|
// 1 = a tool reported a violation
|
|
// 2 = not measured: no tool, a tool that could not run, or a tool that
|
|
// checked nothing. Never green.
|
|
// JSON on stdout: {code, status, repo, tools: [{tool, status, targets?, reason?, violations?}]}
|
|
//
|
|
// node arch-rules.mjs --repo <dir> --prove --rule <name> --plant <file>=<repo-relative-dest> [...]
|
|
// A rule is accepted only once it has been seen to pass, fail on a planted
|
|
// violation, and pass again. Runs the tools, copies each plant in (never
|
|
// over an existing path, never outside the repo, symlinks resolved), runs
|
|
// them again, removes the plants (and the directories made for them), runs
|
|
// a third time. The planted run must report a violation of THIS rule
|
|
// (`--rule`: a dependency-cruiser rule name or an import-linter contract
|
|
// name); another rule tripping proves nothing.
|
|
// 0 = proven (0 → 1 with the rule → 0), 1 = not proven, 2 = not measured
|
|
// or the plant could not be placed.
|
|
//
|
|
// Adapters (output shapes measured against the real tools on 2026-10-06):
|
|
// dependency-cruiser 18.5.0 — config .dependency-cruiser.{cjs,js,mjs,json},
|
|
// binary node_modules/.bin/depcruise, `--output-type json`. It exits 0
|
|
// even with violations; the verdict is `summary.error` (> 0 = violation),
|
|
// `summary.totalCruised` 0 = checked nothing. Default targets: every one of
|
|
// src/ lib/ app/ packages/ source/ that exists, else the repo root.
|
|
// import-linter 2.15 — config .importlinter, setup.cfg [importlinter] or
|
|
// pyproject.toml [tool.importlinter]; binary .venv/bin, venv/bin or PATH.
|
|
// Exit 0 + "Contracts: N kept, 0 broken." (N > 0) = pass; exit 1 + "M
|
|
// broken" (M > 0) = violation, one per "<contract> BROKEN" line; anything
|
|
// else (0 contracts included) = not measured. The repo root is put first
|
|
// on PYTHONPATH so an uninstalled root package resolves.
|
|
// Not used: madge 8.0.0 reported no cycle for a planted .mjs cycle without
|
|
// extra configuration (measured) — a false green, so it is not an adapter.
|
|
//
|
|
// Borrowed: "use the de-facto tool" and the module map from agent-skills
|
|
// (MIT); pass → fail → pass from Pocock's skills (MIT).
|
|
|
|
import {
|
|
existsSync, readFileSync, statSync, lstatSync, realpathSync, mkdirSync, copyFileSync,
|
|
rmSync, rmdirSync, readdirSync, constants as fsc,
|
|
} from 'node:fs';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { join, resolve, dirname, basename, relative, isAbsolute, delimiter, sep } from 'node:path';
|
|
import { isMain } from '../util/is-main.mjs';
|
|
|
|
const TIMEOUT_MS = 180_000;
|
|
const DEPCRUISE_CONFIGS = ['.dependency-cruiser.cjs', '.dependency-cruiser.js', '.dependency-cruiser.mjs', '.dependency-cruiser.json'];
|
|
const DEFAULT_TARGETS = ['src', 'lib', 'app', 'packages', 'source'];
|
|
|
|
function isFile(p) {
|
|
try { return statSync(p).isFile(); } catch { return false; }
|
|
}
|
|
|
|
function isDir(p) {
|
|
try { return statSync(p).isDirectory(); } catch { return false; }
|
|
}
|
|
|
|
/** True when anything sits at p — a file, a directory, or a symlink, dangling or not. */
|
|
function occupied(p) {
|
|
try { lstatSync(p); return true; } catch { return false; }
|
|
}
|
|
|
|
function fileHas(p, re) {
|
|
try { return re.test(readFileSync(p, 'utf-8')); } catch { return false; }
|
|
}
|
|
|
|
function onPath(bin) {
|
|
for (const d of (process.env.PATH ?? '').split(delimiter)) {
|
|
if (d && isFile(join(d, bin))) return join(d, bin);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Which architecture tools does this repo configure? (configured ≠ runnable) */
|
|
export function detectTools(repo) {
|
|
const tools = [];
|
|
const dcConfig = DEPCRUISE_CONFIGS.find(c => isFile(join(repo, c)));
|
|
if (dcConfig) {
|
|
const bin = join(repo, 'node_modules', '.bin', 'depcruise');
|
|
tools.push({ tool: 'dependency-cruiser', config: dcConfig, bin: isFile(bin) ? bin : null, wanted: 'node_modules/.bin/depcruise' });
|
|
}
|
|
const ilConfig = isFile(join(repo, '.importlinter')) ? '.importlinter'
|
|
: fileHas(join(repo, 'setup.cfg'), /^\[importlinter\]/m) ? 'setup.cfg'
|
|
: fileHas(join(repo, 'pyproject.toml'), /^\[tool\.importlinter\]/m) ? 'pyproject.toml'
|
|
: null;
|
|
if (ilConfig) {
|
|
const local = ['.venv', 'venv'].map(v => join(repo, v, 'bin', 'lint-imports')).find(isFile);
|
|
tools.push({ tool: 'import-linter', config: ilConfig, bin: local ?? onPath('lint-imports'), wanted: '.venv/bin/lint-imports (or lint-imports on PATH)' });
|
|
}
|
|
return tools;
|
|
}
|
|
|
|
function exec(bin, args, repo, env = {}) {
|
|
return spawnSync(bin, args, {
|
|
cwd: repo, encoding: 'utf-8', timeout: TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024,
|
|
env: { ...process.env, ...env },
|
|
});
|
|
}
|
|
|
|
function runDepcruise(t, repo, targets) {
|
|
const tg = targets.length > 0 ? targets : DEFAULT_TARGETS.filter(d => isDir(join(repo, d)));
|
|
const use = tg.length > 0 ? tg : ['.'];
|
|
const r = exec(t.bin, ['--config', t.config, '--output-type', 'json', ...use], repo);
|
|
let j = null;
|
|
try { j = JSON.parse(r.stdout); } catch { /* handled below */ }
|
|
const s = j?.summary;
|
|
if (!s || typeof s.error !== 'number') {
|
|
const why = r.error ? r.error.message : (r.stderr || '').trim().split('\n')[0] || `exit ${r.status}, no JSON summary`;
|
|
return { status: 'not-measured', targets: use, reason: `dependency-cruiser could not run: ${why}` };
|
|
}
|
|
if (!s.totalCruised) return { status: 'not-measured', targets: use, reason: 'dependency-cruiser checked 0 modules' };
|
|
const violations = (s.violations ?? [])
|
|
.filter(v => v?.rule?.severity === 'error')
|
|
.map(v => ({ rule: v.rule.name, text: `${v.type ?? 'violation'}: ${v.from} -> ${v.to}` }));
|
|
if (s.error > 0) return { status: 'violation', targets: use, violations };
|
|
return { status: 'passed', targets: use, checked: s.totalCruised, warn: s.warn ?? 0 };
|
|
}
|
|
|
|
function runImportLinter(t, repo) {
|
|
const py = process.env.PYTHONPATH ? `${repo}${delimiter}${process.env.PYTHONPATH}` : repo;
|
|
const r = exec(t.bin, ['--no-cache'], repo, { PYTHONPATH: py });
|
|
const out = `${r.stdout ?? ''}${r.stderr ?? ''}`;
|
|
const m = out.match(/Contracts: (\d+) kept, (\d+) broken\./);
|
|
if (!m) {
|
|
const why = r.error ? r.error.message : out.trim().split('\n').pop() || `exit ${r.status}`;
|
|
return { status: 'not-measured', reason: `import-linter could not run: ${why}` };
|
|
}
|
|
const kept = Number(m[1]);
|
|
const broken = Number(m[2]);
|
|
if (kept + broken === 0) return { status: 'not-measured', reason: 'import-linter checked 0 contracts' };
|
|
if (r.status === 0 && broken === 0) return { status: 'passed', checked: kept };
|
|
if (r.status === 1 && broken > 0) {
|
|
const names = [...out.matchAll(/^(.+?) BROKEN$/gm)].map(x => x[1].trim());
|
|
const detail = out.split('\n').filter(l => /not allowed to import|^- .+ -> /.test(l)).slice(0, 50).map(l => l.trim());
|
|
const violations = names.length > 0
|
|
? names.map(n => ({ rule: n, text: `${n} BROKEN${detail.length ? `: ${detail.join(' ')}` : ''}` }))
|
|
: [{ rule: 'import-linter', text: detail.join(' ') || 'a contract is broken' }];
|
|
return { status: 'violation', violations };
|
|
}
|
|
return { status: 'not-measured', reason: `import-linter: exit ${r.status} with "${m[0]}"` };
|
|
}
|
|
|
|
/** Run every detected tool once. */
|
|
export function runArchRules({ repo, targets = [] }) {
|
|
if (!repo || !isDir(repo)) {
|
|
return { code: 2, status: 'not-measured', repo, tools: [], reason: `repo dir not found: ${repo}` };
|
|
}
|
|
const tools = detectTools(repo).map((t) => {
|
|
if (!t.bin) return { tool: t.tool, config: t.config, status: 'not-measured', reason: `${t.config} found but no ${t.wanted}; this runner installs nothing` };
|
|
const res = t.tool === 'dependency-cruiser' ? runDepcruise(t, repo, targets) : runImportLinter(t, repo);
|
|
return { tool: t.tool, config: t.config, ...res };
|
|
});
|
|
if (tools.length === 0) {
|
|
return { code: 2, status: 'not-measured', repo, tools, reason: 'no architecture tool configured in this repo (dependency-cruiser, import-linter)' };
|
|
}
|
|
if (tools.some(t => t.status === 'violation')) return { code: 1, status: 'violation', repo, tools };
|
|
if (tools.some(t => t.status === 'not-measured')) return { code: 2, status: 'not-measured', repo, tools };
|
|
return { code: 0, status: 'passed', repo, tools };
|
|
}
|
|
|
|
/**
|
|
* Is dest (repo-relative) inside the repo once symlinks are resolved?
|
|
* Resolves the deepest existing ancestor, so `link/x` through a symlink that
|
|
* points outside the repo is refused.
|
|
*/
|
|
function insideRepo(repo, dest) {
|
|
if (isAbsolute(dest)) return false;
|
|
let root;
|
|
try { root = realpathSync(repo); } catch { return false; }
|
|
let probe = resolve(repo, dest);
|
|
const tail = [];
|
|
while (!existsSync(probe)) {
|
|
tail.unshift(basename(probe));
|
|
const up = dirname(probe);
|
|
if (up === probe) return false;
|
|
probe = up;
|
|
}
|
|
let real;
|
|
try { real = realpathSync(probe); } catch { return false; }
|
|
const full = join(real, ...tail);
|
|
const rel = relative(root, full);
|
|
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
}
|
|
|
|
const rulesIn = run => (run.tools ?? []).flatMap(t => (t.violations ?? []).map(v => v.rule));
|
|
|
|
/** pass → fail on the planted violation of `rule` → pass. */
|
|
export function proveRule({ repo, targets = [], plants, rule }) {
|
|
const phases = [];
|
|
const base = { mode: 'prove', repo, rule: rule ?? null, phases };
|
|
const notMeasured = reason => ({ ...base, code: 2, proven: false, reason });
|
|
if (typeof rule !== 'string' || rule === '') return notMeasured('--prove needs --rule <name>: the rule the plant must trip');
|
|
if (!Array.isArray(plants) || plants.length === 0) return notMeasured('--prove needs at least one --plant <file>=<dest>');
|
|
for (const p of plants) {
|
|
if (!isFile(p.src)) return notMeasured(`plant source not found: ${p.src}`);
|
|
if (!insideRepo(repo, p.dest)) return notMeasured(`plant destination is outside the repo (after resolving symlinks): ${p.dest}`);
|
|
if (occupied(join(repo, p.dest))) return notMeasured(`refusing to overwrite an existing path: ${p.dest}`);
|
|
}
|
|
const before = runArchRules({ repo, targets });
|
|
phases.push({ phase: 'before', code: before.code, status: before.status });
|
|
if (before.code !== 0) {
|
|
return { ...base, code: before.code === 2 ? 2 : 1, proven: false, reason: 'the base does not pass, so a failure after planting would prove nothing', detail: before };
|
|
}
|
|
|
|
const madeDirs = [];
|
|
const planted = [];
|
|
const cleanup = () => {
|
|
for (const f of planted.splice(0)) rmSync(f, { force: true });
|
|
for (const d of madeDirs.splice(0).reverse()) {
|
|
try { if (readdirSync(d).length === 0) rmdirSync(d); } catch { /* not empty: left in place, never force-removed */ }
|
|
}
|
|
};
|
|
// A Ctrl-C during the planted run must not leave the plants in the repo.
|
|
const onSignal = (sig) => { cleanup(); process.exit(sig === 'SIGINT' ? 130 : 143); };
|
|
process.once('SIGINT', onSignal);
|
|
process.once('SIGTERM', onSignal);
|
|
let during;
|
|
try {
|
|
for (const p of plants) {
|
|
const destAbs = join(repo, p.dest);
|
|
let d = dirname(destAbs);
|
|
const chain = [];
|
|
while (!existsSync(d)) { chain.push(d); d = dirname(d); }
|
|
for (const c of chain.reverse()) { mkdirSync(c); madeDirs.push(c); }
|
|
// EXCL: if something appeared at dest since the check, fail rather than overwrite it.
|
|
copyFileSync(p.src, destAbs, fsc.COPYFILE_EXCL);
|
|
planted.push(destAbs);
|
|
}
|
|
during = runArchRules({ repo, targets });
|
|
} catch (e) {
|
|
cleanup();
|
|
process.removeListener('SIGINT', onSignal);
|
|
process.removeListener('SIGTERM', onSignal);
|
|
return notMeasured(`could not place the plant: ${e.message}`);
|
|
}
|
|
cleanup();
|
|
process.removeListener('SIGINT', onSignal);
|
|
process.removeListener('SIGTERM', onSignal);
|
|
phases.push({ phase: 'planted', code: during.code, status: during.status, rules: rulesIn(during) });
|
|
const after = runArchRules({ repo, targets });
|
|
phases.push({ phase: 'after', code: after.code, status: after.status });
|
|
if (during.code === 2 || after.code === 2) return { ...base, code: 2, proven: false, reason: 'a run was not measured', detail: { during, after } };
|
|
if (during.code !== 1) return { ...base, code: 1, proven: false, reason: 'the tool did not catch the planted violation: the rule does not bite', detail: during };
|
|
if (!rulesIn(during).includes(rule)) {
|
|
return { ...base, code: 1, proven: false, reason: `the plant tripped ${rulesIn(during).join(', ')}, not ${rule}: this proves nothing about ${rule}`, detail: during };
|
|
}
|
|
if (after.code !== 0) return { ...base, code: 1, proven: false, reason: 'the check still fails after the plant was removed', detail: after };
|
|
return { ...base, code: 0, proven: true, caught: during.tools };
|
|
}
|
|
|
|
// ---- CLI --------------------------------------------------------------------
|
|
|
|
function parseArgs(argv) {
|
|
const o = { targets: [], plants: [], prove: false, repo: null, rule: null, bad: null };
|
|
const value = (i, flag) => {
|
|
const v = argv[i + 1];
|
|
if (v === undefined || v.startsWith('--')) { o.bad = o.bad ?? `${flag} needs a value`; return null; }
|
|
return v;
|
|
};
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a === '--repo') { o.repo = value(i, a); i++; }
|
|
else if (a === '--target') { const v = value(i, a); if (v) o.targets.push(v); i++; }
|
|
else if (a === '--rule') { o.rule = value(i, a); i++; }
|
|
else if (a === '--prove') o.prove = true;
|
|
else if (a === '--plant') {
|
|
const v = value(i, a) ?? '';
|
|
i++;
|
|
const at = v.lastIndexOf('=');
|
|
if (at <= 0 || at === v.length - 1) o.bad = o.bad ?? `--plant expects <file>=<dest>, got "${v}"`;
|
|
else o.plants.push({ src: resolve(v.slice(0, at)), dest: v.slice(at + 1).split('/').join(sep) });
|
|
} else o.bad = o.bad ?? `unknown argument: ${a}`;
|
|
}
|
|
return o;
|
|
}
|
|
|
|
if (isMain(import.meta.url)) {
|
|
const o = parseArgs(process.argv.slice(2));
|
|
let result;
|
|
if (o.bad || !o.repo) {
|
|
result = { code: 2, status: 'not-measured', reason: o.bad ?? 'usage: arch-rules.mjs --repo <dir> [--target <path>]... [--prove --rule <name> --plant <file>=<dest>...]' };
|
|
} else {
|
|
const repo = resolve(o.repo);
|
|
result = o.prove
|
|
? proveRule({ repo, targets: o.targets, plants: o.plants, rule: o.rule })
|
|
: runArchRules({ repo, targets: o.targets });
|
|
}
|
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
process.exit(result.code);
|
|
}
|