// tests/lib/arch-rules.test.mjs // K5 — the architecture-rule runner. `arch-rules.mjs --repo `: 0 = passed, // 1 = violation, 2 = not measured (never green). JSON on stdout. It runs the // target repo's OWN tool and installs nothing: no local tool → 2. // // The tools are test doubles that print the shapes measured from the real tools // on 2026-10-06: dependency-cruiser 18.5.0 `--output-type json` (exit 0 even // with violations; the verdict is `summary.error`, the violation carries // `type: "cycle"`, `rule.name`, `from`, `to`) and import-linter 2.15 (exit 0 // kept, exit 1 broken, summary line `Contracts: N kept, M broken.`). The // dependency-cruiser double really walks relative imports, so the planted // cycle below is a real cycle in the fixture source. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { spawnSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync, chmodSync, existsSync, symlinkSync, lstatSync, readlinkSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { childEnv } from '../helpers/child-env.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const RUNNER = join(HERE, '..', '..', 'lib', 'verification', 'arch-rules.mjs'); const DEPCRUISE_DOUBLE = `#!/usr/bin/env node // Test double for dependency-cruiser: walks relative ESM imports and reports // cycles in the JSON shape dependency-cruiser 18.5.0 prints. const fs = require('node:fs'); const path = require('node:path'); const args = process.argv.slice(2); if (process.env.DOUBLE_CRASH) { process.stderr.write('ERROR: boom'); process.exit(1); } const targets = []; for (let i = 0; i < args.length; i++) { if (args[i].startsWith('--')) { i++; continue; } targets.push(args[i]); } const files = []; const walk = d => { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else if (p.endsWith('.mjs')) files.push(p); } }; for (const t of targets) { if (!fs.existsSync(t)) { process.stderr.write("ERROR: Can't open '" + t + "' for reading. Does it exist?"); process.exit(1); } walk(t); } const edges = {}; for (const f of files) { edges[f] = []; for (const m of fs.readFileSync(f, 'utf8').matchAll(/from '(\\.[^']+)'/g)) edges[f].push(path.join(path.dirname(f), m[1])); } const violations = []; const seen = new Set(); for (const a of files) for (const b of edges[a]) if ((edges[b] || []).includes(a) && !seen.has([a, b].sort().join())) { seen.add([a, b].sort().join()); violations.push({ type: 'cycle', from: a, to: b, rule: { severity: 'error', name: 'no-circular' }, cycle: [{ name: b }, { name: a }] }); } process.stdout.write(JSON.stringify({ modules: files.map(f => ({ source: f })), summary: { violations, error: violations.length, warn: 0, info: 0, ignore: 0, advisedExitCode: violations.length ? 1 : 0, totalCruised: process.env.DOUBLE_EMPTY ? 0 : files.length } })); `; const LINT_IMPORTS_DOUBLE = `#!/usr/bin/env node const mode = require('node:fs').readFileSync(process.cwd() + '/linter-mode', 'utf8').trim(); if (mode === 'kept') { console.log('ui above domain KEPT\\n\\nContracts: 1 kept, 0 broken.'); process.exit(0); } if (mode === 'broken') { console.log('ui above domain BROKEN\\n\\nContracts: 0 kept, 1 broken.\\n\\napp.domain is not allowed to import app.ui:\\n\\n- app.domain.core -> app.ui.view (l.1)'); process.exit(1); } if (mode === 'zero') { console.log('Contracts: 0 kept, 0 broken.'); process.exit(0); } console.log('Could not read any configuration.'); process.exit(1); `; function exe(path, body) { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, body); chmodSync(path, 0o755); } function jsRepo({ cycle = false, binary = true } = {}) { const dir = mkdtempSync(join(tmpdir(), 'arch-rules-')); mkdirSync(join(dir, 'src')); writeFileSync(join(dir, 'src', 'a.mjs'), "import { b } from './b.mjs';\nexport const a = 1;\n"); writeFileSync(join(dir, 'src', 'b.mjs'), cycle ? "import { a } from './a.mjs';\nexport const b = 2;\n" : 'export const b = 2;\n'); writeFileSync(join(dir, '.dependency-cruiser.cjs'), "module.exports = { forbidden: [{ name: 'no-circular', severity: 'error', from: {}, to: { circular: true } }] };\n"); if (binary) exe(join(dir, 'node_modules', '.bin', 'depcruise'), DEPCRUISE_DOUBLE); return dir; } function run(args, env = {}) { const r = spawnSync('node', [RUNNER, ...args], { encoding: 'utf-8', env: childEnv({ ...env }) }); let json = null; try { json = JSON.parse(r.stdout); } catch { /* asserted by caller */ } return { code: r.status, json, out: r.stdout, err: r.stderr }; } const cleanup = d => rmSync(d, { recursive: true, force: true }); // ---- the tests that failed first ---- test('K5: a planted cycle under the repo\'s own dependency-cruiser → 1, violation listed', () => { const dir = jsRepo({ cycle: true }); try { const r = run(['--repo', dir]); assert.equal(r.code, 1, r.out + r.err); assert.equal(r.json.status, 'violation'); const t = r.json.tools.find(x => x.tool === 'dependency-cruiser'); assert.equal(t.status, 'violation'); assert.equal(t.violations[0].rule, 'no-circular'); assert.match(t.violations[0].text, /src\/a\.mjs/); } finally { cleanup(dir); } }); test('K5: a repo with no architecture tool → 2 "not measured", never green', () => { const dir = mkdtempSync(join(tmpdir(), 'arch-rules-none-')); try { writeFileSync(join(dir, 'index.mjs'), 'export {};\n'); const r = run(['--repo', dir]); assert.equal(r.code, 2, r.out + r.err); assert.equal(r.json.status, 'not-measured'); assert.deepEqual(r.json.tools, []); } finally { cleanup(dir); } }); test('K5: the same repo without the cycle → 0', () => { const dir = jsRepo(); try { const r = run(['--repo', dir]); assert.equal(r.code, 0, r.out + r.err); assert.equal(r.json.status, 'passed'); } finally { cleanup(dir); } }); test('K5: config without a local binary → 2 (never npx, never installs)', () => { const dir = jsRepo({ binary: false }); try { const r = run(['--repo', dir]); assert.equal(r.code, 2); assert.match(r.json.tools[0].reason, /node_modules\/\.bin\/depcruise/); assert.ok(!existsSync(join(dir, 'node_modules')), 'the runner must not create node_modules'); } finally { cleanup(dir); } }); test('K5: a tool that crashes or cruises nothing → 2', () => { const dir = jsRepo({ cycle: true }); try { assert.equal(run(['--repo', dir], { DOUBLE_CRASH: '1' }).code, 2); assert.equal(run(['--repo', dir], { DOUBLE_EMPTY: '1' }).code, 2); assert.equal(run(['--repo', dir, '--target', 'nosuchdir']).code, 2); } finally { cleanup(dir); } }); test('K5: import-linter — kept → 0, broken → 1, no summary line → 2', () => { const dir = mkdtempSync(join(tmpdir(), 'arch-rules-py-')); try { writeFileSync(join(dir, '.importlinter'), '[importlinter]\nroot_package = app\n'); exe(join(dir, '.venv', 'bin', 'lint-imports'), LINT_IMPORTS_DOUBLE); writeFileSync(join(dir, 'linter-mode'), 'kept'); assert.equal(run(['--repo', dir]).code, 0); writeFileSync(join(dir, 'linter-mode'), 'broken'); const broken = run(['--repo', dir]); assert.equal(broken.code, 1); assert.equal(broken.json.tools[0].violations[0].rule, 'ui above domain', 'the broken contract is the rule'); assert.match(broken.json.tools[0].violations[0].text, /not allowed to import/); writeFileSync(join(dir, 'linter-mode'), 'noconfig'); assert.equal(run(['--repo', dir]).code, 2); writeFileSync(join(dir, 'linter-mode'), 'zero'); assert.equal(run(['--repo', dir]).code, 2, '0 contracts checked is not measured, not passed'); } finally { cleanup(dir); } }); test('K5: a missing --repo dir → 2', () => { assert.equal(run(['--repo', '/nonexistent/repo']).code, 2); }); // ---- pass → fail on a planted violation → pass ---- test('--prove: pass → fail on the planted cycle → pass, and the plant is removed', () => { const dir = jsRepo(); const plantDir = mkdtempSync(join(tmpdir(), 'arch-plant-')); try { writeFileSync(join(plantDir, 'c.mjs'), "import { d } from './d.mjs';\nexport const c = 1;\n"); writeFileSync(join(plantDir, 'd.mjs'), "import { c } from './c.mjs';\nexport const d = 1;\n"); const r = run(['--repo', dir, '--prove', '--rule', 'no-circular', '--plant', `${join(plantDir, 'c.mjs')}=src/planted/c.mjs`, '--plant', `${join(plantDir, 'd.mjs')}=src/planted/d.mjs`]); assert.equal(r.code, 0, r.out + r.err); assert.equal(r.json.proven, true); assert.deepEqual(r.json.phases.map(p => p.code), [0, 1, 0]); assert.ok(!existsSync(join(dir, 'src', 'planted')), 'planted files and the dirs made for them are removed'); } finally { cleanup(dir); cleanup(plantDir); } }); test('--prove: a plant the tool does not catch → 1 (the rule is not proven)', () => { const dir = jsRepo(); const plantDir = mkdtempSync(join(tmpdir(), 'arch-plant-')); try { writeFileSync(join(plantDir, 'e.mjs'), 'export const e = 1;\n'); const r = run(['--repo', dir, '--prove', '--rule', 'no-circular', '--plant', `${join(plantDir, 'e.mjs')}=src/e.mjs`]); assert.equal(r.code, 1, r.out); assert.equal(r.json.proven, false); assert.ok(!existsSync(join(dir, 'src', 'e.mjs'))); } finally { cleanup(dir); cleanup(plantDir); } }); test('--prove refuses to overwrite an existing file → 2, file untouched', () => { const dir = jsRepo(); const plantDir = mkdtempSync(join(tmpdir(), 'arch-plant-')); try { writeFileSync(join(plantDir, 'x.mjs'), 'export const x = 1;\n'); const r = run(['--repo', dir, '--prove', '--rule', 'no-circular', '--plant', `${join(plantDir, 'x.mjs')}=src/a.mjs`]); assert.equal(r.code, 2, r.out); assert.ok(existsSync(join(dir, 'src', 'a.mjs'))); } finally { cleanup(dir); cleanup(plantDir); } }); // ---- review findings (independent code review, 06.10) ---- function cyclePlants() { const plantDir = mkdtempSync(join(tmpdir(), 'arch-plant-')); writeFileSync(join(plantDir, 'c.mjs'), "import { d } from './d.mjs';\nexport const c = 1;\n"); writeFileSync(join(plantDir, 'd.mjs'), "import { c } from './c.mjs';\nexport const d = 1;\n"); return plantDir; } test('--prove refuses a plant destination outside the repo: ../ and through a symlink → 2, nothing written', () => { const dir = jsRepo(); const outside = mkdtempSync(join(tmpdir(), 'arch-outside-')); const plantDir = cyclePlants(); try { symlinkSync(outside, join(dir, 'src', 'link')); for (const dest of ['../escaped.mjs', 'src/link/escaped.mjs']) { const r = run(['--repo', dir, '--prove', '--rule', 'no-circular', '--plant', `${join(plantDir, 'c.mjs')}=${dest}`]); assert.equal(r.code, 2, `${dest}: ${r.out}`); assert.match(r.json.reason, /outside the repo/); } assert.ok(!existsSync(join(outside, 'escaped.mjs'))); assert.ok(!existsSync(join(dir, '..', 'escaped.mjs'))); } finally { cleanup(dir); cleanup(outside); cleanup(plantDir); } }); test('--prove refuses a dangling symlink at the destination and leaves it in place', () => { const dir = jsRepo(); const plantDir = cyclePlants(); try { symlinkSync(join(dir, 'nowhere.mjs'), join(dir, 'src', 'dangling.mjs')); const r = run(['--repo', dir, '--prove', '--rule', 'no-circular', '--plant', `${join(plantDir, 'c.mjs')}=src/dangling.mjs`]); assert.equal(r.code, 2, r.out); assert.ok(lstatSync(join(dir, 'src', 'dangling.mjs')).isSymbolicLink()); assert.equal(readlinkSync(join(dir, 'src', 'dangling.mjs')), join(dir, 'nowhere.mjs')); assert.ok(!existsSync(join(dir, 'nowhere.mjs'))); } finally { cleanup(dir); cleanup(plantDir); } }); test('--prove: a plant that trips another rule proves nothing about the named one → 1', () => { const dir = jsRepo(); const plantDir = cyclePlants(); try { const r = run(['--repo', dir, '--prove', '--rule', 'no-orphans', '--plant', `${join(plantDir, 'c.mjs')}=src/p/c.mjs`, '--plant', `${join(plantDir, 'd.mjs')}=src/p/d.mjs`]); assert.equal(r.code, 1, r.out); assert.match(r.json.reason, /not no-orphans/); assert.ok(!existsSync(join(dir, 'src', 'p'))); assert.equal(run(['--repo', dir, '--prove', '--plant', `${join(plantDir, 'c.mjs')}=src/p/c.mjs`]).code, 2, '--rule is required'); } finally { cleanup(dir); cleanup(plantDir); } }); test('K5: with src/ and lib/ both present, a cycle in lib/ is caught (every default dir is cruised)', () => { const dir = jsRepo(); try { mkdirSync(join(dir, 'lib')); writeFileSync(join(dir, 'lib', 'x.mjs'), "import { y } from './y.mjs';\nexport const x = 1;\n"); writeFileSync(join(dir, 'lib', 'y.mjs'), "import { x } from './x.mjs';\nexport const y = 1;\n"); const r = run(['--repo', dir]); assert.equal(r.code, 1, r.out); assert.deepEqual(r.json.tools[0].targets, ['src', 'lib']); } finally { cleanup(dir); } }); test('K5: a flag without its value → 2, never a crash', () => { const dir = jsRepo(); try { for (const args of [['--repo', dir, '--target'], ['--repo'], ['--repo', dir, '--prove', '--rule']]) { const r = run(args); assert.equal(r.code, 2, `${args.join(' ')}: ${r.err}`); assert.ok(r.json, 'JSON on stdout'); } } finally { cleanup(dir); } });