#!/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.`); } }