diff --git a/.gitignore b/.gitignore index de4d1f0..327773f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ dist node_modules + +# Import graph artifacts +directory-imports.dot +directory-imports.svg diff --git a/package.json b/package.json index 78a0031..075b1ae 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,8 @@ "package": "bun run install-node && bun run build && bun run vsce-package", "format": "prettier '**/{*.{ts,json},.*.cjs}' --list-different --write", "lint": "eslint 'src/**/*.{js,ts}'", - "test": "jest" + "test": "jest", + "generate-import-graph": "bash -c 'bun run ./scripts/import-graph.ts \"$@\" > directory-imports.dot && dot -Tsvg directory-imports.dot -o directory-imports.svg' --" }, "dependencies": { "@anthropic-ai/sdk": "0.37.0", diff --git a/scripts/import-graph.ts b/scripts/import-graph.ts new file mode 100644 index 0000000..1ec3ed9 --- /dev/null +++ b/scripts/import-graph.ts @@ -0,0 +1,217 @@ +import * as fs from 'fs' +import * as path from 'path' +import { glob } from 'glob' +import * as ts from 'typescript' + +const displayDirectories = false + +function processFile(filePath: string, importGraph: Map>): void { + const sourceDir = filePath + const sourceText = fs.readFileSync(filePath, 'utf-8') + const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true) + + sourceFile.forEachChild(node => { + if (ts.isImportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) { + const importPath = node.moduleSpecifier.text + + if (importPath.startsWith('.')) { + let resolvedPath = path.resolve(path.dirname(filePath), importPath) + if (fs.existsSync(`${resolvedPath}.ts`)) { + resolvedPath = `${resolvedPath}.ts` + } + + let importedDir = resolvedPath + + if (!importGraph.has(sourceDir)) { + importGraph.set(sourceDir, new Set()) + } + importGraph.get(sourceDir)!.add(importedDir) + } + } + }) +} + +function removePackagesDistinctFromTarget( + importGraph: Map>, + packageName: string, +): Map> { + if (!packageName) { + return importGraph + } + + const filtered = new Map() + for (const [sourceFile, targetFiles] of importGraph.entries()) { + if (getFile(sourceFile).startsWith(packageName)) { + filtered.set(sourceFile, targetFiles) + } else { + const matchingTargetFiles = [...targetFiles].filter(file => getFile(file).startsWith(packageName)) + if (matchingTargetFiles.length > 0) { + filtered.set(sourceFile, matchingTargetFiles) + } + } + } + + return filtered +} + +function generateDotFile(importGraph: Map>): void { + const allDirectories = new Set() + for (const [sourceDir, targetDirs] of importGraph.entries()) { + allDirectories.add(getDirectory(sourceDir)) + targetDirs.forEach(targetDir => allDirectories.add(getDirectory(targetDir))) + } + + const parents = new Map() + for (let dir of allDirectories) { + while (dir !== '.') { + const parent = path.dirname(dir) + parents.set(dir, parent) + allDirectories.add(parent) + dir = parent + } + } + + const children = new Map>() + for (let dir of allDirectories) { + children.set(dir, new Set()) + } + + for (let dir of allDirectories) { + if (dir === '.') { + continue + } + children.get(parents.get(dir)!)!.add(dir) + } + + const allFiles = new Set() + for (const [sourceFile, targetFiles] of importGraph.entries()) { + allFiles.add(getFile(sourceFile)) + targetFiles.forEach(targetDir => allFiles.add(getFile(targetDir))) + } + + console.log('digraph DirectoryImports {') + console.log(' rankdir=TB;') + console.log(' compound=true;') + console.log(' splines=ortho;') + console.log(' node [shape=box, style=filled, fillcolor=lightblue];\n') + + const handled = new Set() + + const handle = (dir: string): void => { + if (handled.has(dir)) { + return + } + + console.log(`subgraph cluster_${dir.replace(/\//g, '_').replace(/\./, 'src')} {`) + console.log(` label="${dir === '.' ? 'src' : path.basename(dir)}";`) + console.log(' color=lightgrey;') + console.log(' node [style=filled, fillcolor=lightblue];\n') + + for (const file of [...allFiles].sort()) { + if (path.dirname(file) === dir) { + console.log(` "${file.replace(/\//g, '_')}" [label="${path.basename(file)}"];`) + } + } + + for (const child of children.get(dir)!) { + handle(child) + } + + console.log('}') + console.log() + handled.add(dir) + } + + for (const dir of [...allDirectories].sort()) { + handle(dir) + } + + const links: Map> = new Map() + for (const [sourceFile, targetFiles] of importGraph.entries()) { + const source = getFile(sourceFile).replace(/\//g, '_') + const sourceLinks = links.get(source) ?? new Set() + + for (const targetFile of targetFiles) { + sourceLinks.add(getFile(targetFile).replace(/\//g, '_')) + } + + links.set(source, sourceLinks) + } + + for (const [source, targets] of links.entries()) { + for (const target of targets) { + if (source != target) { + console.log(`"${source}" -> "${target}";`) + } + } + } + + console.log('}') +} + +function getDirectory(filePath: string): string { + return path.relative(path.resolve('./src'), path.dirname(filePath)) || '.' +} + +function getFile(filePath: string): string { + return displayDirectories ? getDirectory(filePath) : path.relative(path.resolve('./src'), filePath) +} + +// +// + +function checkCycles(importGraph: Map>): void { + const visited = new Set() + const stack = new Set() + const cycles: string[][] = [] + const cycleNodes = new Set() + + const visit = (node: string, path: string[]): void => { + if (stack.has(node)) { + const cycleStartIndex = path.indexOf(node) + if (cycleStartIndex !== -1) { + cycles.push(path.slice(cycleStartIndex)) + } + return + } + + if (visited.has(node)) { + return + } + + visited.add(node) + stack.add(node) + + const neighbors = importGraph.get(node) || [] + for (const neighbor of neighbors) { + visit(neighbor, [...path, neighbor]) + } + + stack.delete(node) + } + + for (const node of importGraph.keys()) { + visit(node, [node]) + } + + for (const cycle of cycles) { + cycle.forEach(node => cycleNodes.add(node)) + } + if (cycleNodes.size > 0) { + process.stderr.write('Found cycles in the import graph:\n\n') + + for (const cycle of cycles) { + process.stderr.write(` ${cycle.map(node => getFile(node)).join('\n\t -> ')}\n\n`) + } + } +} + +async function main(): Promise { + const files = await glob('src/**/*.ts', { absolute: true }) + const importGraph = new Map>() + files.forEach(path => processFile(path, importGraph)) + checkCycles(importGraph) + generateDotFile(removePackagesDistinctFromTarget(importGraph, process.argv[2])) +} + +main()