#!/usr/bin/env node import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import ts from "typescript"; import { collectSourceFiles, collectStronglyConnectedComponents, } from "./lib/import-cycle-graph.ts"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const scanRoots = ["src", "extensions", "ui"] as const; const sourceExtensions = [".ts"] as const; const ignoredPathPartPattern = /(^|\/)(node_modules|dist|build|coverage|\.artifacts|\.git|assets)(\/|$)/; function shouldSkipRepoPath(repoPath: string): boolean { return ignoredPathPartPattern.test(repoPath); } function loadCompilerOptions(): ts.CompilerOptions { const configPath = path.join(repoRoot, "tsconfig.json"); const config = ts.readConfigFile(configPath, (filePath) => ts.sys.readFile(filePath)); if (config.error) { throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, "\n")); } return ts.parseJsonConfigFileContent(config.config, ts.sys, repoRoot).options; } function collectStaticModuleSpecifiers(sourceFile: ts.SourceFile): string[] { const specifiers: string[] = []; const visit = (node: ts.Node) => { if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { specifiers.push(node.moduleSpecifier.text); } else if ( ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) ) { specifiers.push(node.moduleSpecifier.text); } ts.forEachChild(node, visit); }; visit(sourceFile); return specifiers; } function createImportGraph(files: readonly string[]): Map { const compilerOptions = loadCompilerOptions(); const compilerHost = ts.createCompilerHost(compilerOptions, false); const resolutionCache = ts.createModuleResolutionCache( repoRoot, (value) => value, compilerOptions, ); const absoluteToRepoPath = new Map( files.map((file): [string, string] => [path.resolve(repoRoot, file), file]), ); const graph = new Map(); for (const file of files) { const absoluteFile = path.join(repoRoot, file); const sourceFile = ts.createSourceFile( file, readFileSync(absoluteFile, "utf8"), ts.ScriptTarget.Latest, true, ); const imports = collectStaticModuleSpecifiers(sourceFile).flatMap((specifier) => { const resolved = ts.resolveModuleName( specifier, absoluteFile, compilerOptions, compilerHost, resolutionCache, ).resolvedModule?.resolvedFileName; if (!resolved) { return []; } const repoPath = absoluteToRepoPath.get(path.resolve(resolved)); return repoPath ? [repoPath] : []; }); graph.set( file, imports.toSorted((left, right) => left.localeCompare(right)), ); } return graph; } function main(): number { const files = scanRoots.flatMap((root) => collectSourceFiles(path.join(repoRoot, root), { repoRoot, sourceExtensions, shouldSkipRepoPath, }), ); const graph = createImportGraph(files); const cycles = collectStronglyConnectedComponents(graph); console.log(`Madge import cycle check: ${cycles.length} cycle(s).`); if (cycles.length === 0) { return 0; } console.error("\nMadge circular dependencies:"); for (const [index, cycle] of cycles.entries()) { console.error(`\n# cycle ${index + 1}`); console.error(` ${cycle.join("\n -> ")}`); } console.error( "\nBreak the cycle or extract a leaf contract instead of routing through a barrel.", ); return 1; } process.exitCode = main();