// 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 { 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 : 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 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(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 [--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); }