/simplify over a97efb6..HEAD (the plan_version 1.9 / trial-4 delivery). - coordinator-contract: engine names come from engine-findings ENGINES; one markedMajorCodeReview predicate under both lowering checks; isEngineFinding everywhere; the unreachable branch (c) of illegalBriefLowering folded into (e). - armB workflow: RULE_KEYS = Object.keys(RULE_TIERS); the engine map is a literal pinned equal to ENGINES by the workflow test. - plan-schema: usesFilesGrammar() is the one 1.9 check (validator, sweep, testFirstFiles, stepTestsOnlyFromFields); opensEntry built on opensChunk. - plan-validator: one issue builder for the Files and Test first grammar. - plan-scope-sweep: the 1.9 and the 6.2.0 reader are one if/else. - artifact-commit: TRAILER_RE/trailerProblem no longer exported (no importer); the order comment says the CLI finds the repo root first. - tests: the lock drivers in session-id-isolation (d) run side by side per mode (~100 s -> ~30 s); a shared artifact-repo helper; severityRank and extractFindingsBlock reused; childEnv(env) without a spread; one copy of the PLAN_VERSION_CURRENT check. Plan readers: validator errors/warnings, plannedPaths, fieldPaths, testFirstFiles and stepTestsOnlyFromFields compared byte for byte against HEAD over 510 plans / 1692 steps plus 210 synthetic 1.7-1.9 cases: same. Suite 2384 (2382/0/2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
266 lines
13 KiB
JavaScript
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); }
|
|
});
|