// 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
[--target ]...
// 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 --prove --rule --plant = [...]
// 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 " 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 { fileURLToPath } from 'node:url';
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 : the rule the plant must trip');
if (!Array.isArray(plants) || plants.length === 0) return notMeasured('--prove needs at least one --plant =');
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 isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
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 =, 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()) {
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 [--target ]... [--prove --rule --plant =...]' };
} 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);
}