voyage/tests/lib/arch-rules.test.mjs
Kjell Tore Guttormsen d8527976ef
fix(tests): the suite no longer hands the session id to the lock CLI
npm test now preloads tests/helpers/no-session-id.mjs, which deletes CLAUDE_CODE_SESSION_ID before any test file loads, and the 18 files that spread process.env into a child use childEnv() instead (23 sites). A live session's stop guard is no longer pointed at a temp-repo lock (S133).

engines stays >=18: --import exists from Node 18.18/20.6, and the quoted test glob already needs Node 21 (criteria-runner NODE_GLOB_MAJOR), so the real floor does not move.

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

266 lines
13 KiB
JavaScript

// tests/lib/arch-rules.test.mjs
// K5 — the architecture-rule runner. `arch-rules.mjs --repo <dir>`: 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); }
});