Merge pull request '[E00-S01-T04] Add dependency-boundary rule/test' (#371) from feature/157 into main
CI / Frozen lockfile install (push) Successful in 42s

This commit was merged in pull request #371.
This commit is contained in:
2026-08-28 08:32:08 +00:00
2 changed files with 504 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
{
"description": "Dependency-boundary rule for the EPPP workspace. Enforced by tests/architecture-import.test.mjs. Groups classify workspace packages by directory; boundaries declare which from-group -> to-group import edges are forbidden.",
"groups": {
"apps": { "path": "apps/" },
"packages": { "path": "packages/" },
"extensions": { "path": "extensions/" }
},
"boundaries": [
{
"from": "packages",
"to": "extensions",
"allow": false,
"reason": "Core packages must not import concrete extensions. Core depends only on extension contracts (abstract APIs); concrete extensions are wired in by the app layer."
}
]
}
+488
View File
@@ -0,0 +1,488 @@
/**
* Architecture import test — enforces the dependency-boundary rule
* declared in dependency-boundaries.json (E00-S01-T04).
*
* The rule: no core package (packages/*) may import a concrete extension
* (extensions/*). Core depends only on extension contracts (abstract APIs);
* concrete extensions are wired in by the app layer.
*
* The test loads the rule file, walks every workspace package's source and
* resolves each import/export/require specifier (bare workspace names,
* relative/absolute paths, dynamic import(), require(), export-from) to a
* workspace group, then fails on any forbidden edge.
*
* Run: `node --test tests/architecture-import.test.mjs`
* (node:test — built into Node >= 18; no dependencies, lockfile untouched.)
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { readdir } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const RULE_FILE = path.join(REPO_ROOT, 'dependency-boundaries.json');
/** Extensions scanned as package source. */
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs']);
/** Directories never scanned as source. */
const IGNORED_DIRS = new Set(['node_modules', 'dist', 'coverage', '.git']);
// ---------------------------------------------------------------------------
// Rule loading
// ---------------------------------------------------------------------------
function loadRule(file = RULE_FILE) {
const raw = JSON.parse(readFileSync(file, 'utf8'));
assert.ok(raw && typeof raw === 'object', 'rule file must be a JSON object');
assert.ok(raw.groups && typeof raw.groups === 'object', 'rule must declare groups');
for (const [name, group] of Object.entries(raw.groups)) {
assert.ok(typeof group?.path === 'string', `group "${name}" must have a string path`);
}
assert.ok(Array.isArray(raw.boundaries), 'rule must declare boundaries');
for (const b of raw.boundaries) {
assert.ok(b && typeof b === 'object', 'each boundary must be an object');
assert.ok(typeof b.from === 'string', 'boundary must have a string "from"');
assert.ok(typeof b.to === 'string', 'boundary must have a string "to"');
assert.ok(typeof b.allow === 'boolean', `boundary ${b.from} -> ${b.to} must have a boolean "allow"`);
assert.ok(raw.groups[b.from], `boundary "from" group "${b.from}" is not declared in groups`);
assert.ok(raw.groups[b.to], `boundary "to" group "${b.to}" is not declared in groups`);
}
return raw;
}
// ---------------------------------------------------------------------------
// Comment stripping (string/comment aware, preserves line count)
// ---------------------------------------------------------------------------
/**
* Replaces comments with whitespace while preserving line structure, so
* specifier extraction never fires on commented-out imports and reported
* line numbers still match the original source.
*/
function stripComments(source) {
let out = '';
let i = 0;
const n = source.length;
let state = 'code'; // code | line | block | sq | dq | tpl
const tplStack = []; // template states to resume after `${...}` closes
let braceDepth = 0;
while (i < n) {
const c = source[i];
const next = source[i + 1];
if (state === 'code') {
if (tplStack.length > 0) {
if (c === '{') {
braceDepth += 1;
out += c;
i += 1;
continue;
}
if (c === '}') {
braceDepth -= 1;
out += c;
i += 1;
if (braceDepth === 0) state = tplStack.pop();
continue;
}
}
if (c === '/' && next === '/') {
out += ' ';
i += 2;
state = 'line';
continue;
}
if (c === '/' && next === '*') {
out += ' ';
i += 2;
state = 'block';
continue;
}
if (c === "'") {
out += c;
i += 1;
state = 'sq';
continue;
}
if (c === '"') {
out += c;
i += 1;
state = 'dq';
continue;
}
if (c === '`') {
out += c;
i += 1;
state = 'tpl';
continue;
}
out += c;
i += 1;
continue;
}
if (state === 'line') {
if (c === '\n') {
out += c;
i += 1;
state = 'code';
} else {
out += ' ';
i += 1;
}
continue;
}
if (state === 'block') {
if (c === '*' && next === '/') {
out += ' ';
i += 2;
state = 'code';
} else {
out += c === '\n' ? c : ' ';
i += 1;
}
continue;
}
if (state === 'sq' || state === 'dq') {
const quote = state === 'sq' ? "'" : '"';
out += c;
if (c === '\\' && next !== undefined) {
out += next;
i += 2;
} else {
if (c === quote) state = 'code';
i += 1;
}
continue;
}
// template literal
out += c;
if (c === '\\' && next !== undefined) {
out += next;
i += 2;
continue;
}
if (c === '`') {
state = 'code';
i += 1;
continue;
}
if (c === '$' && next === '{') {
out += next;
i += 2;
tplStack.push('tpl');
braceDepth = 1;
state = 'code';
continue;
}
i += 1;
}
return out;
}
// ---------------------------------------------------------------------------
// Specifier extraction
// ---------------------------------------------------------------------------
const STATIC_IMPORT = /\b(?:import|export)\s+(?:type\s+)?(?:[\w*{},\s]*?\s+from\s*)?(['"])([^'"]+)\1/g;
const REQUIRE_CALL = /\brequire\s*\(\s*(['"])([^'"]+)\1\s*\)/g;
const DYNAMIC_IMPORT = /\bimport\s*\(\s*(['"])([^'"]+)\1\s*\)/g;
/**
* Returns [{ specifier, index }] for every module specifier in the
* (comment-stripped) source. `index` is an offset into the stripped source,
* which preserves line structure, so it maps back to the original lines.
*/
function extractSpecifiers(stripped) {
const found = [];
for (const re of [STATIC_IMPORT, REQUIRE_CALL, DYNAMIC_IMPORT]) {
re.lastIndex = 0;
let m;
while ((m = re.exec(stripped)) !== null) {
found.push({ specifier: m[2], index: m.index });
}
}
found.sort((a, b) => a.index - b.index);
return found;
}
// ---------------------------------------------------------------------------
// Workspace package discovery
// ---------------------------------------------------------------------------
async function enumeratePackages(rule) {
const packages = [];
for (const [group, { path: groupPath }] of Object.entries(rule.groups)) {
const absGroup = path.join(REPO_ROOT, groupPath);
let entries;
try {
entries = await readdir(absGroup, { withFileTypes: true });
} catch {
continue; // a declared group with no directory yet is not a package source
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const dir = path.join(absGroup, entry.name);
const manifestPath = path.join(dir, 'package.json');
let manifest;
try {
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
} catch {
continue; // no package.json -> not a workspace package
}
assert.ok(
typeof manifest.name === 'string' && manifest.name.length > 0,
`${path.relative(REPO_ROOT, manifestPath)} must declare a non-empty "name"`,
);
packages.push({
name: manifest.name,
group,
dir,
manifestPath,
});
}
}
return packages;
}
/** Walks a package directory and returns [{ absPath, content }] for source files. */
async function collectSourceFiles(packageDir) {
const files = [];
async function walk(dir) {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (!IGNORED_DIRS.has(entry.name)) await walk(path.join(dir, entry.name));
continue;
}
if (SOURCE_EXTENSIONS.has(path.extname(entry.name))) {
const absPath = path.join(dir, entry.name);
files.push({ absPath, content: readFileSync(absPath, 'utf8') });
}
}
}
await walk(packageDir);
return files;
}
// ---------------------------------------------------------------------------
// Target resolution + violation finding
// ---------------------------------------------------------------------------
/**
* Resolves a module specifier to a workspace package, or null when it is not
* a workspace-internal reference (external dependency, node builtin, or a
* path that stays outside every workspace package).
*/
function resolveTarget(specifier, importingFile, packages) {
// Bare name or scoped subpath of a workspace package: @personal-blog/core,
// @personal-blog/example-extension/subpath, ...
const byName = packages.find(
(p) => specifier === p.name || specifier.startsWith(`${p.name}/`),
);
if (byName) return byName;
// Relative or absolute (file:) path that lands inside a workspace package.
if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('file:')) {
const base = specifier.startsWith('file:') ? specifier.slice('file:'.length) : specifier;
const resolved = path.resolve(path.dirname(importingFile), base);
const matches = packages
.filter((p) => resolved === p.dir || resolved.startsWith(`${p.dir}${path.sep}`))
.sort((a, b) => b.dir.length - a.dir.length);
if (matches.length > 0) return matches[0];
}
return null;
}
function lineOf(stripped, index) {
return stripped.slice(0, index).split('\n').length;
}
/**
* Returns violations for the given source files under the rule.
* `packages` is the enumerated workspace package list; each file's importing
* package is the workspace package whose directory is its nearest ancestor.
*/
function findViolations(rule, packages, files) {
const violations = [];
for (const file of files) {
const fromPkg = packages
.filter((p) => file.absPath === p.dir || file.absPath.startsWith(`${p.dir}${path.sep}`))
.sort((a, b) => b.dir.length - a.dir.length)[0];
if (!fromPkg) continue;
const stripped = stripComments(file.content);
for (const { specifier, index } of extractSpecifiers(stripped)) {
const target = resolveTarget(specifier, file.absPath, packages);
if (!target) continue;
const ruleEntry = rule.boundaries.find(
(b) => b.from === fromPkg.group && b.to === target.group,
);
if (ruleEntry && ruleEntry.allow === false) {
violations.push({
file: path.relative(REPO_ROOT, file.absPath),
line: lineOf(stripped, index),
specifier,
from: `${fromPkg.name} [${fromPkg.group}]`,
to: `${target.name} [${target.group}]`,
rule: `${ruleEntry.from} -> ${ruleEntry.to}`,
});
}
}
}
return violations;
}
// ---------------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------------
const V = '/virtual/workspace';
function virtualPackages() {
return [
{ name: '@personal-blog/server', group: 'apps', dir: `${V}/apps/server` },
{ name: '@personal-blog/core', group: 'packages', dir: `${V}/packages/core` },
{ name: '@personal-blog/example-extension', group: 'extensions', dir: `${V}/extensions/example` },
];
}
function virtualFile(relPath, content) {
return { absPath: `${V}/${relPath}`, content };
}
const rule = {
groups: {
apps: { path: 'apps/' },
packages: { path: 'packages/' },
extensions: { path: 'extensions/' },
},
boundaries: [
{ from: 'packages', to: 'extensions', allow: false, reason: 'core must not import concrete extensions' },
],
};
const CORE_FILE = 'packages/core/src/index.ts';
const EXT_FILE = 'extensions/example/src/index.ts';
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test('dependency-boundaries.json is present and declares the core -> extensions boundary', () => {
const loaded = loadRule();
const forbidden = loaded.boundaries.filter((b) => !b.allow);
assert.ok(
forbidden.some((b) => b.from === 'packages' && b.to === 'extensions'),
'rule must forbid packages/* -> extensions/* imports',
);
});
test('comment stripping ignores commented-out imports', () => {
const src = [
'// import { x } from "@personal-blog/example-extension";',
'/* import { y } from \'@personal-blog/example-extension\'; */',
"import { ok } from './internal.js';",
'',
].join('\n');
const stripped = stripComments(src);
const specifiers = extractSpecifiers(stripped);
assert.deepEqual(specifiers.map((s) => s.specifier), ['./internal.js']);
assert.equal(lineOf(stripped, specifiers[0].index), 3);
});
test('stripping preserves line numbers across block comments', () => {
const src = 'const a = 1;\n/* multi\nline */\nimport { b } from "./x.js";\n';
const stripped = stripComments(src);
const [spec] = extractSpecifiers(stripped);
assert.equal(spec.specifier, './x.js');
assert.equal(lineOf(stripped, spec.index), 4);
});
test('core -> extension by bare package name is a violation', () => {
const violations = findViolations(rule, virtualPackages(), [
virtualFile(CORE_FILE, "import { register } from '@personal-blog/example-extension';\n"),
]);
assert.equal(violations.length, 1);
assert.equal(violations[0].rule, 'packages -> extensions');
assert.equal(violations[0].to, '@personal-blog/example-extension [extensions]');
assert.equal(violations[0].line, 1);
});
test('core -> extension by relative path is a violation', () => {
const violations = findViolations(rule, virtualPackages(), [
virtualFile(CORE_FILE, "import { x } from '../../../extensions/example/src/index.js';\n"),
]);
assert.equal(violations.length, 1);
assert.equal(violations[0].specifier, '../../../extensions/example/src/index.js');
});
test('core -> extension via export-from, dynamic import and require are violations', () => {
const content = [
"export { register } from '@personal-blog/example-extension';",
"const m = await import('@personal-blog/example-extension');",
"const r = require('@personal-blog/example-extension');",
'',
].join('\n');
const violations = findViolations(rule, virtualPackages(), [virtualFile(CORE_FILE, content)]);
assert.equal(violations.length, 3);
});
test('allowed directions produce no violations', () => {
const files = [
virtualFile(EXT_FILE, "import { lifecycle } from '@personal-blog/core';\n"),
virtualFile('apps/server/src/index.ts', "import { core } from '@personal-blog/core';\n"),
virtualFile('extensions/example/src/index.ts', "import { x } from './internal.js';\n"),
];
assert.deepEqual(findViolations(rule, virtualPackages(), files), []);
});
test('external and node builtin imports are not violations', () => {
const files = [
virtualFile(CORE_FILE, "import fastify from 'fastify';\nimport fs from 'node:fs';\nimport { join } from 'node:path';\n"),
];
assert.deepEqual(findViolations(rule, virtualPackages(), files), []);
});
test('core package importing another core package is not a violation', () => {
const packages = [
...virtualPackages(),
{ name: '@personal-blog/domain', group: 'packages', dir: `${V}/packages/domain` },
];
const files = [virtualFile('packages/domain/src/index.ts', "import { core } from '@personal-blog/core';\n")];
assert.deepEqual(findViolations(rule, packages, files), []);
});
test('the architecture import test passes on the current compliant workspace graph', async () => {
const loaded = loadRule();
const packages = await enumeratePackages(loaded);
const names = new Set(packages.map((p) => p.name));
for (const expected of ['@personal-blog/server', '@personal-blog/core', '@personal-blog/example-extension']) {
assert.ok(names.has(expected), `expected workspace package ${expected} to be discovered`);
}
const files = [];
for (const pkg of packages) {
files.push(...(await collectSourceFiles(pkg.dir)));
}
const violations = findViolations(loaded, packages, files);
const report = violations
.map((v) => ` ${v.file}:${v.line} imports ${v.specifier} (${v.to}) — violates ${v.rule}`)
.join('\n');
assert.deepEqual(violations, [], `no core package may import a concrete extension:\n${report}`);
});