152 lines
5.9 KiB
JavaScript
152 lines
5.9 KiB
JavaScript
#!/usr/bin/env node
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { SyntaxKind } from 'typescript/unstable/ast';
|
|
import { createScanner } from 'typescript/unstable/ast/scanner';
|
|
|
|
const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
|
function normalized(value) {
|
|
return value.replaceAll(path.sep, '/').replace(/^\.\//, '');
|
|
}
|
|
|
|
function relativeTarget(importer, specifier) {
|
|
if (!specifier.startsWith('.')) return null;
|
|
return normalized(path.posix.normalize(path.posix.join(path.posix.dirname(importer), specifier)));
|
|
}
|
|
|
|
function featurePath(file) {
|
|
const match = /^src\/(server|web)\/features\/([^/]+)(?:\/(.*))?$/.exec(file);
|
|
return match ? { layer: match[1], name: match[2], privatePath: match[3] || '' } : null;
|
|
}
|
|
|
|
export function importBoundaryViolation(importerValue, specifier) {
|
|
const importer = normalized(importerValue);
|
|
const target = relativeTarget(importer, specifier);
|
|
if (!target) return null;
|
|
|
|
if (importer.startsWith('src/shared/') && /^src\/(server|web)\//.test(target)) {
|
|
return 'shared cannot import server or web';
|
|
}
|
|
if (importer.startsWith('src/server/') && target.startsWith('src/web/')) {
|
|
return 'server cannot import web';
|
|
}
|
|
if (importer.startsWith('src/web/') && target.startsWith('src/server/')) {
|
|
return 'web cannot import server';
|
|
}
|
|
if (importer.startsWith('src/server/http/') && target.startsWith('src/server/infrastructure/')) {
|
|
return 'server/http cannot import infrastructure directly';
|
|
}
|
|
if (importer.startsWith('src/server/features/') && target.startsWith('src/server/http/')) {
|
|
return 'server/features cannot import http';
|
|
}
|
|
if (importer.startsWith('src/web/ui/')
|
|
&& /^src\/web\/(?:features(?:\/|$)|api(?:\/|\.[cm]?[jt]sx?$|$))/.test(target)) {
|
|
return 'web/ui cannot import api or features';
|
|
}
|
|
|
|
const fromFeature = featurePath(importer);
|
|
const toFeature = featurePath(target);
|
|
if (fromFeature && toFeature
|
|
&& fromFeature.layer === toFeature.layer
|
|
&& fromFeature.name !== toFeature.name
|
|
&& toFeature.privatePath
|
|
&& !/^index(?:\.[cm]?[jt]sx?)?$/.test(toFeature.privatePath)) {
|
|
return 'cross-feature imports must use the feature index';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function filesUnder(directory) {
|
|
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
const absolute = path.join(directory, entry.name);
|
|
return entry.isDirectory() ? filesUnder(absolute) : [absolute];
|
|
});
|
|
}
|
|
|
|
function importedSpecifiers(source) {
|
|
const specifiers = [];
|
|
const scanner = createScanner(true, undefined, source);
|
|
const tokens = [];
|
|
for (let token = scanner.scan(); token !== SyntaxKind.EndOfFile; token = scanner.scan()) {
|
|
if (token === SyntaxKind.SlashToken) token = scanner.reScanSlashToken();
|
|
tokens.push({ kind: token, text: scanner.getTokenText(), value: scanner.getTokenValue() });
|
|
}
|
|
|
|
for (let index = 0; index < tokens.length; index += 1) {
|
|
const token = tokens[index];
|
|
const previous = tokens[index - 1];
|
|
const next = tokens[index + 1];
|
|
const isProperty = previous?.kind === SyntaxKind.DotToken
|
|
|| previous?.kind === SyntaxKind.QuestionDotToken;
|
|
|
|
if (token.text === 'import' && !isProperty) {
|
|
if (next?.kind === SyntaxKind.StringLiteral) {
|
|
specifiers.push(next.value);
|
|
} else if (next?.kind === SyntaxKind.OpenParenToken) {
|
|
const argument = tokens[index + 2];
|
|
if (argument?.kind === SyntaxKind.StringLiteral) specifiers.push(argument.value);
|
|
} else if (next?.kind === SyntaxKind.OpenBraceToken
|
|
|| next?.kind === SyntaxKind.AsteriskToken
|
|
|| next?.kind === SyntaxKind.Identifier
|
|
|| next?.text === 'type') {
|
|
for (let cursor = index + 1; cursor < tokens.length; cursor += 1) {
|
|
if (tokens[cursor].kind === SyntaxKind.SemicolonToken) break;
|
|
if (tokens[cursor].text === 'from'
|
|
&& tokens[cursor + 1]?.kind === SyntaxKind.StringLiteral) {
|
|
specifiers.push(tokens[cursor + 1].value);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} else if (token.text === 'export' && !isProperty) {
|
|
if (next?.kind !== SyntaxKind.OpenBraceToken
|
|
&& next?.kind !== SyntaxKind.AsteriskToken
|
|
&& next?.text !== 'type') continue;
|
|
for (let cursor = index + 1; cursor < tokens.length; cursor += 1) {
|
|
if (tokens[cursor].kind === SyntaxKind.SemicolonToken) break;
|
|
if (tokens[cursor].text === 'from'
|
|
&& tokens[cursor + 1]?.kind === SyntaxKind.StringLiteral) {
|
|
specifiers.push(tokens[cursor + 1].value);
|
|
break;
|
|
}
|
|
}
|
|
} else if (token.text === 'require' && !isProperty) {
|
|
if (next?.kind === SyntaxKind.OpenParenToken
|
|
&& tokens[index + 2]?.kind === SyntaxKind.StringLiteral) {
|
|
specifiers.push(tokens[index + 2].value);
|
|
}
|
|
}
|
|
}
|
|
return specifiers;
|
|
}
|
|
|
|
export function checkImportBoundaries(repositoryRoot = root) {
|
|
const sourceRoot = path.join(repositoryRoot, 'src');
|
|
const files = filesUnder(sourceRoot).filter((file) => SOURCE_FILE.test(file));
|
|
const violations = [];
|
|
for (const file of files) {
|
|
const importer = normalized(path.relative(repositoryRoot, file));
|
|
const source = fs.readFileSync(file, 'utf8');
|
|
for (const specifier of importedSpecifiers(source)) {
|
|
const rule = importBoundaryViolation(importer, specifier);
|
|
if (rule) violations.push({ importer, specifier, rule });
|
|
}
|
|
}
|
|
return { filesChecked: files.length, violations };
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
const result = checkImportBoundaries();
|
|
if (result.violations.length) {
|
|
for (const violation of result.violations) {
|
|
console.error(`${violation.importer}: ${violation.rule} (${violation.specifier})`);
|
|
}
|
|
process.exitCode = 1;
|
|
} else {
|
|
console.log(`Import boundaries: ${result.filesChecked} files checked.`);
|
|
}
|
|
}
|