Last active
July 2, 2025 06:52
-
-
Save grrowl/d31e80250f900c8122185848e0bad06e to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env node | |
import * as fs from "fs"; | |
import * as path from "path"; | |
import * as ts from "typescript"; | |
interface ModuleInfo { | |
filePath: string; | |
exports: Map<string, string>; // exported name -> type (function, class, interface, etc.) | |
imports: Map<string, string>; // imported name -> source module | |
functionCalls: Set<string>; | |
} | |
interface DependencyGraph { | |
modules: Map<string, ModuleInfo>; | |
connections: Array<{ | |
from: string; | |
to: string; | |
exportName: string; | |
importName: string; | |
exportType?: string; | |
}>; | |
domains: Map<string, Set<string>>; // domain -> module paths | |
} | |
class TypeScriptAnalyzer { | |
private program: ts.Program; | |
private typeChecker: ts.TypeChecker; | |
private graph: DependencyGraph = { | |
modules: new Map(), | |
connections: [], | |
domains: new Map(), | |
}; | |
constructor( | |
private projectPath: string, | |
private tsconfigPath?: string, | |
) { | |
// Resolve to absolute path to ensure consistent comparisons | |
this.projectPath = path.resolve(projectPath); | |
const configPath = this.tsconfigPath || this.findTsConfig(); | |
const configFile = ts.readConfigFile(configPath, ts.sys.readFile); | |
if (configFile.error) { | |
throw new Error( | |
`Error reading tsconfig: ${configFile.error.messageText}`, | |
); | |
} | |
const parsedConfig = ts.parseJsonConfigFileContent( | |
configFile.config, | |
ts.sys, | |
path.dirname(configPath), | |
); | |
if (parsedConfig.errors.length > 0) { | |
console.error( | |
"TSConfig errors:", | |
parsedConfig.errors.map((e) => e.messageText), | |
); | |
} | |
this.program = ts.createProgram( | |
parsedConfig.fileNames, | |
parsedConfig.options, | |
); | |
this.typeChecker = this.program.getTypeChecker(); | |
} | |
private findTsConfig(): string { | |
let dir = this.projectPath; | |
// If projectPath is a file, start from its directory | |
if (fs.existsSync(dir) && fs.statSync(dir).isFile()) { | |
dir = path.dirname(dir); | |
} | |
while (dir !== path.dirname(dir)) { | |
const configPath = path.join(dir, "tsconfig.json"); | |
if (fs.existsSync(configPath)) { | |
return configPath; | |
} | |
dir = path.dirname(dir); | |
} | |
// Fallback: check current working directory | |
const cwdConfig = path.join(process.cwd(), "tsconfig.json"); | |
if (fs.existsSync(cwdConfig)) { | |
return cwdConfig; | |
} | |
throw new Error("tsconfig.json not found"); | |
} | |
private getRelativeModulePath(filePath: string): string { | |
const relativePath = path.relative(this.projectPath, filePath); | |
// Normalize path separators for cross-platform compatibility | |
return relativePath.replace(/\\/g, "/"); | |
} | |
private resolveImportPath(importPath: string, currentFile: string): string { | |
if (importPath.startsWith(".")) { | |
// Relative import | |
const currentDir = path.dirname(currentFile); | |
const resolved = path.resolve(currentDir, importPath); | |
const extensions = [".ts", ".tsx", ".js", ".jsx"]; | |
// Try exact path first | |
for (const ext of extensions) { | |
const withExt = resolved + ext; | |
if (fs.existsSync(withExt)) { | |
return this.getRelativeModulePath(withExt); | |
} | |
} | |
// Try index file | |
for (const ext of extensions) { | |
const indexPath = path.join(resolved, "index" + ext); | |
if (fs.existsSync(indexPath)) { | |
return this.getRelativeModulePath(indexPath); | |
} | |
} | |
// Try without extension (already exists) | |
if (fs.existsSync(resolved)) { | |
return this.getRelativeModulePath(resolved); | |
} | |
// Return as-is if we can't resolve (might be in our module list anyway) | |
return this.getRelativeModulePath(resolved); | |
} | |
// External module or absolute path - return as-is | |
return importPath; | |
} | |
analyze(verbose: boolean = false): DependencyGraph { | |
const allSourceFiles = this.program.getSourceFiles(); | |
if (verbose) { | |
console.error(`Found ${allSourceFiles.length} total source files`); | |
console.error(`Project path: ${this.projectPath}`); | |
console.error(`Absolute project path: ${path.resolve(this.projectPath)}`); | |
} | |
const sourceFiles = allSourceFiles.filter((sf) => { | |
const isNotDeclaration = !sf.isDeclarationFile; | |
const isInProject = | |
sf.fileName.includes(path.resolve(this.projectPath)) || | |
sf.fileName.includes(this.projectPath); | |
const isNotNodeModules = !sf.fileName.includes("node_modules"); | |
if (verbose) { | |
console.error(`File: ${sf.fileName}`); | |
console.error(` - Not declaration: ${isNotDeclaration}`); | |
console.error(` - In project: ${isInProject}`); | |
console.error(` - Not node_modules: ${isNotNodeModules}`); | |
console.error( | |
` - Included: ${isNotDeclaration && isInProject && isNotNodeModules}`, | |
); | |
} | |
return isNotDeclaration && isInProject && isNotNodeModules; | |
}); | |
if (verbose) { | |
console.error(`Filtered to ${sourceFiles.length} project source files`); | |
} | |
// First pass: collect all exports | |
sourceFiles.forEach((sourceFile) => { | |
const modulePath = this.getRelativeModulePath(sourceFile.fileName); | |
const moduleInfo: ModuleInfo = { | |
filePath: modulePath, | |
exports: new Map(), | |
imports: new Map(), | |
functionCalls: new Set(), | |
}; | |
this.extractModuleInfo(sourceFile, moduleInfo); | |
this.graph.modules.set(modulePath, moduleInfo); | |
if (verbose) { | |
console.error(`Processed module: ${modulePath}`); | |
console.error( | |
` - Exports: ${Array.from(moduleInfo.exports.entries()) | |
.map(([name, type]) => `${type} ${name}`) | |
.join(", ")}`, | |
); | |
console.error( | |
` - Imports: ${Array.from(moduleInfo.imports.keys()).join(", ")}`, | |
); | |
} | |
}); | |
// Second pass: resolve import-export connections | |
this.graph.modules.forEach((moduleInfo, modulePath) => { | |
moduleInfo.imports.forEach((sourcePath, importName) => { | |
const resolvedPath = this.resolveImportPath( | |
sourcePath, | |
path.join(this.projectPath, modulePath), | |
); | |
const sourceModule = this.graph.modules.get(resolvedPath); | |
if (sourceModule && resolvedPath !== modulePath) { | |
// Avoid self-references | |
// Check if the import matches an export | |
const hasExport = | |
sourceModule.exports.has(importName) || | |
sourceModule.exports.has("default") || | |
sourceModule.exports.has("*") || | |
(importName === "*" && sourceModule.exports.size > 0); | |
if (hasExport) { | |
const exportType = | |
sourceModule.exports.get(importName) || | |
sourceModule.exports.get("default") || | |
sourceModule.exports.get("*") || | |
"unknown"; | |
this.graph.connections.push({ | |
from: resolvedPath, | |
to: modulePath, | |
exportName: importName, | |
importName: importName, | |
exportType: exportType, | |
}); | |
} | |
if (verbose) { | |
console.error(`Connection check: ${resolvedPath} -> ${modulePath}`); | |
console.error(` Import: ${importName}, Has export: ${hasExport}`); | |
console.error( | |
` Source exports: ${Array.from(sourceModule.exports.entries()) | |
.map(([name, type]) => `${type} ${name}`) | |
.join(", ")}`, | |
); | |
} | |
} | |
}); | |
}); | |
return this.graph; | |
} | |
analyzeWithDomains( | |
domainDepth: number = 1, | |
verbose: boolean = false, | |
): DependencyGraph { | |
this.analyze(verbose); | |
this.organizeDomains(domainDepth); | |
return this.graph; | |
} | |
private extractModuleInfo( | |
sourceFile: ts.SourceFile, | |
moduleInfo: ModuleInfo, | |
): void { | |
const visit = (node: ts.Node): void => { | |
// Extract exports with types | |
if (ts.isExportDeclaration(node)) { | |
if (node.exportClause && ts.isNamedExports(node.exportClause)) { | |
node.exportClause.elements.forEach((element) => { | |
const exportName = element.propertyName?.text || element.name.text; | |
moduleInfo.exports.set(exportName, "export"); // Re-export type | |
}); | |
} | |
if (!node.exportClause && node.moduleSpecifier) { | |
moduleInfo.exports.set("*", "namespace"); // export * from '...' | |
} | |
} | |
// Export default | |
if (node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) { | |
if ( | |
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) | |
) { | |
const nodeType = this.getNodeType(node); | |
moduleInfo.exports.set("default", nodeType); | |
} | |
} | |
// Named function exports | |
if ( | |
ts.isFunctionDeclaration(node) && | |
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) | |
) { | |
if (node.name) { | |
moduleInfo.exports.set(node.name.text, "function"); | |
} else if ( | |
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) | |
) { | |
moduleInfo.exports.set("default", "function"); | |
} | |
} | |
// Variable exports (export const, let, var) | |
if ( | |
ts.isVariableStatement(node) && | |
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) | |
) { | |
node.declarationList.declarations.forEach((decl) => { | |
if (ts.isIdentifier(decl.name)) { | |
// Try to infer type from initializer | |
let varType = "variable"; | |
if (decl.initializer) { | |
if ( | |
ts.isArrowFunction(decl.initializer) || | |
ts.isFunctionExpression(decl.initializer) | |
) { | |
varType = "function"; | |
} else if ( | |
ts.isNewExpression(decl.initializer) || | |
ts.isClassExpression(decl.initializer) | |
) { | |
varType = "class"; | |
} else if (ts.isObjectLiteralExpression(decl.initializer)) { | |
varType = "object"; | |
} else if (ts.isArrayLiteralExpression(decl.initializer)) { | |
varType = "array"; | |
} | |
} | |
moduleInfo.exports.set(decl.name.text, varType); | |
} | |
}); | |
} | |
// Class exports | |
if ( | |
ts.isClassDeclaration(node) && | |
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) | |
) { | |
if (node.name) { | |
moduleInfo.exports.set(node.name.text, "class"); | |
} else if ( | |
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) | |
) { | |
moduleInfo.exports.set("default", "class"); | |
} | |
} | |
// Interface and type exports | |
if ( | |
ts.isInterfaceDeclaration(node) && | |
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) | |
) { | |
if (node.name) { | |
moduleInfo.exports.set(node.name.text, "interface"); | |
} | |
} | |
if ( | |
ts.isTypeAliasDeclaration(node) && | |
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) | |
) { | |
if (node.name) { | |
moduleInfo.exports.set(node.name.text, "type"); | |
} | |
} | |
// Enum exports | |
if ( | |
ts.isEnumDeclaration(node) && | |
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) | |
) { | |
if (node.name) { | |
moduleInfo.exports.set(node.name.text, "enum"); | |
} | |
} | |
// Extract imports | |
if ( | |
ts.isImportDeclaration(node) && | |
node.moduleSpecifier && | |
ts.isStringLiteral(node.moduleSpecifier) | |
) { | |
const modulePath = node.moduleSpecifier.text; | |
if (node.importClause) { | |
// Default import | |
if (node.importClause.name) { | |
moduleInfo.imports.set(node.importClause.name.text, modulePath); | |
} | |
// Named imports | |
if ( | |
node.importClause.namedBindings && | |
ts.isNamedImports(node.importClause.namedBindings) | |
) { | |
node.importClause.namedBindings.elements.forEach((element) => { | |
const importName = | |
element.propertyName?.text || element.name.text; | |
const localName = element.name.text; | |
moduleInfo.imports.set(importName, modulePath); | |
}); | |
} | |
// Namespace import | |
if ( | |
node.importClause.namedBindings && | |
ts.isNamespaceImport(node.importClause.namedBindings) | |
) { | |
moduleInfo.imports.set("*", modulePath); | |
} | |
} | |
} | |
// Extract function calls | |
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { | |
moduleInfo.functionCalls.add(node.expression.text); | |
} | |
if ( | |
ts.isPropertyAccessExpression(node) && | |
ts.isCallExpression(node.parent) | |
) { | |
const fullName = this.getFullPropertyName(node); | |
if (fullName) { | |
moduleInfo.functionCalls.add(fullName); | |
} | |
} | |
ts.forEachChild(node, visit); | |
}; | |
ts.forEachChild(sourceFile, visit); | |
} | |
private getNodeType(node: ts.Node): string { | |
if (ts.isFunctionDeclaration(node)) return "function"; | |
if (ts.isClassDeclaration(node)) return "class"; | |
if (ts.isInterfaceDeclaration(node)) return "interface"; | |
if (ts.isTypeAliasDeclaration(node)) return "type"; | |
if (ts.isEnumDeclaration(node)) return "enum"; | |
if (ts.isVariableStatement(node)) return "variable"; | |
return "unknown"; | |
} | |
private getFullPropertyName( | |
node: ts.PropertyAccessExpression, | |
): string | null { | |
const parts: string[] = []; | |
let current: ts.Node = node; | |
while (ts.isPropertyAccessExpression(current)) { | |
parts.unshift(current.name.text); | |
current = current.expression; | |
} | |
if (ts.isIdentifier(current)) { | |
parts.unshift(current.text); | |
return parts.join("."); | |
} | |
return null; | |
} | |
generateMermaidDiagram(useDomains: boolean = false): string { | |
if (!useDomains || this.graph.domains.size === 0) { | |
return this.generateBasicMermaidDiagram(); | |
} | |
const lines = ["graph TD"]; | |
// Add domain subgraphs | |
this.graph.domains.forEach((modules, domain) => { | |
const domainId = this.sanitizeForMermaid(domain); | |
lines.push(` subgraph ${domainId} ["${domain}"]`); | |
modules.forEach((modulePath) => { | |
const nodeId = this.sanitizeForMermaid(modulePath); | |
const displayName = path.basename(modulePath, path.extname(modulePath)); | |
lines.push(` ${nodeId}["${displayName}"]`); | |
}); | |
lines.push(` end`); | |
}); | |
// Add edges | |
this.graph.connections.forEach((conn) => { | |
const fromId = this.sanitizeForMermaid(conn.from); | |
const toId = this.sanitizeForMermaid(conn.to); | |
const typePrefix = conn.exportType ? `${conn.exportType} ` : ""; | |
const label = | |
conn.exportName !== conn.importName | |
? `${typePrefix}${conn.exportName}→${conn.importName}` | |
: `${typePrefix}${conn.exportName}`; | |
lines.push(` ${fromId} -->|"${label}"| ${toId}`); | |
}); | |
return lines.join("\n"); | |
} | |
private generateBasicMermaidDiagram(): string { | |
const lines = ["graph TD"]; | |
// Add nodes | |
this.graph.modules.forEach((moduleInfo, modulePath) => { | |
const nodeId = this.sanitizeForMermaid(modulePath); | |
const displayName = path.basename(modulePath, path.extname(modulePath)); | |
lines.push(` ${nodeId}["${displayName}"]`); | |
}); | |
// Add edges | |
this.graph.connections.forEach((conn) => { | |
const fromId = this.sanitizeForMermaid(conn.from); | |
const toId = this.sanitizeForMermaid(conn.to); | |
const typePrefix = conn.exportType ? `${conn.exportType} ` : ""; | |
const label = | |
conn.exportName !== conn.importName | |
? `${typePrefix}${conn.exportName}→${conn.importName}` | |
: `${typePrefix}${conn.exportName}`; | |
lines.push(` ${fromId} -->|"${label}"| ${toId}`); | |
}); | |
return lines.join("\n"); | |
} | |
generateDotDiagram(useDomains: boolean = false): string { | |
if (!useDomains || this.graph.domains.size === 0) { | |
return this.generateBasicDotDiagram(); | |
} | |
const lines = ["digraph Dependencies {"]; | |
lines.push(" rankdir=LR;"); | |
lines.push(" node [shape=box, style=rounded];"); | |
lines.push(" compound=true;"); // Allow edges between clusters | |
// Add domain clusters with styled labels | |
let clusterIndex = 0; | |
const domainNodes = new Map<string, string>(); // domain -> representative node | |
this.graph.domains.forEach((modules, domain) => { | |
const clusterId = `cluster_${clusterIndex++}`; | |
lines.push(` subgraph ${clusterId} {`); | |
lines.push(` label=<<B>${this.escapeHtml(domain)}</B>>;`); // Bold domain title | |
lines.push(" style=filled;"); | |
lines.push(" color=lightgrey;"); | |
lines.push(" labeljust=l;"); // Left-align cluster label | |
let firstNode: string | undefined; | |
modules.forEach((modulePath) => { | |
const nodeId = this.sanitizeForDot(modulePath); | |
const displayName = path.basename(modulePath, path.extname(modulePath)); | |
lines.push( | |
` "${nodeId}" [label="${this.escapeHtml(displayName)}"];`, | |
); | |
if (!firstNode) firstNode = nodeId; | |
}); | |
lines.push(" }"); | |
if (firstNode) { | |
domainNodes.set(domain, firstNode); | |
} | |
}); | |
// Add edges with styled labels | |
this.graph.connections.forEach((conn) => { | |
const fromId = this.sanitizeForDot(conn.from); | |
const toId = this.sanitizeForDot(conn.to); | |
// Create styled label with greyed-out type and normal symbol name | |
const styledLabel = this.createStyledEdgeLabel( | |
conn.exportType, | |
conn.exportName, | |
); | |
const fromDomain = this.getDomainFromPath(conn.from); | |
const toDomain = this.getDomainFromPath(conn.to); | |
if (fromDomain === toDomain) { | |
// Same domain - regular edge | |
lines.push(` "${fromId}" -> "${toId}" [label=<${styledLabel}>];`); | |
} else { | |
// Cross-domain edge - more subdued dark red | |
lines.push( | |
` "${fromId}" -> "${toId}" [label=<${styledLabel}>, style=bold, color="#8B0000"];`, | |
); | |
} | |
}); | |
lines.push("}"); | |
return lines.join("\n"); | |
} | |
private generateBasicDotDiagram(): string { | |
const lines = ["digraph Dependencies {"]; | |
lines.push(" rankdir=LR;"); | |
lines.push(" node [shape=box, style=rounded];"); | |
// Add nodes | |
this.graph.modules.forEach((moduleInfo, modulePath) => { | |
const nodeId = this.sanitizeForDot(modulePath); | |
const displayName = path.basename(modulePath, path.extname(modulePath)); | |
lines.push(` "${nodeId}" [label="${this.escapeHtml(displayName)}"];`); | |
}); | |
// Add edges with styled labels | |
this.graph.connections.forEach((conn) => { | |
const fromId = this.sanitizeForDot(conn.from); | |
const toId = this.sanitizeForDot(conn.to); | |
const styledLabel = this.createStyledEdgeLabel( | |
conn.exportType, | |
conn.exportName, | |
); | |
lines.push(` "${fromId}" -> "${toId}" [label=<${styledLabel}>];`); | |
}); | |
lines.push("}"); | |
return lines.join("\n"); | |
} | |
private createStyledEdgeLabel( | |
exportType?: string, | |
exportName?: string, | |
): string { | |
if (!exportType || !exportName) { | |
return this.escapeHtml(exportName || "unknown"); | |
} | |
// Style: greyed-out type, normal black symbol name | |
const greyType = `<FONT COLOR="#666666">${this.escapeHtml(exportType)}</FONT>`; | |
const blackName = `<FONT COLOR="#000000">${this.escapeHtml(exportName)}</FONT>`; | |
return `${greyType} ${blackName}`; | |
} | |
private escapeHtml(text: string): string { | |
return text | |
.replace(/&/g, "&") | |
.replace(/</g, "<") | |
.replace(/>/g, ">") | |
.replace(/"/g, """) | |
.replace(/'/g, "'"); | |
} | |
private getDomainFromPath( | |
modulePath: string, | |
domainDepth: number = 1, | |
): string { | |
const parts = modulePath.split("/").filter((p) => p.length > 0); | |
// Skip common prefixes like 'src' | |
let startIndex = 0; | |
if (parts[0] === "src" || parts[0] === "lib") { | |
startIndex = 1; | |
} | |
if (parts.length <= startIndex) { | |
return "root"; | |
} | |
const domainParts = parts.slice(startIndex, startIndex + domainDepth); | |
return domainParts.join("/") || "root"; | |
} | |
private organizeDomains(domainDepth: number = 1): void { | |
this.graph.domains.clear(); | |
this.graph.modules.forEach((moduleInfo, modulePath) => { | |
const domain = this.getDomainFromPath(modulePath, domainDepth); | |
if (!this.graph.domains.has(domain)) { | |
this.graph.domains.set(domain, new Set()); | |
} | |
this.graph.domains.get(domain)!.add(modulePath); | |
}); | |
} | |
private sanitizeForMermaid(str: string): string { | |
return str.replace(/[^a-zA-Z0-9]/g, "_"); | |
} | |
private sanitizeForDot(str: string): string { | |
// For node IDs, just remove problematic characters | |
return str.replace(/[^a-zA-Z0-9_]/g, "_"); | |
} | |
generateReport(useDomains: boolean = false): string { | |
const lines = ["# TypeScript Dependency Analysis Report\n"]; | |
lines.push(`## Summary`); | |
lines.push(`- Total modules: ${this.graph.modules.size}`); | |
lines.push(`- Total connections: ${this.graph.connections.length}`); | |
if (useDomains && this.graph.domains.size > 0) { | |
lines.push(`- Total domains: ${this.graph.domains.size}`); | |
} | |
lines.push(""); | |
if (useDomains && this.graph.domains.size > 0) { | |
return this.generateDomainReport(lines); | |
} else { | |
return this.generateBasicReport(lines); | |
} | |
} | |
private generateDomainReport(lines: string[]): string { | |
// Domain overview | |
lines.push(`## Domain Overview\n`); | |
this.graph.domains.forEach((modules, domain) => { | |
lines.push(`### ${domain} (${modules.size} modules)`); | |
const moduleNames = Array.from(modules) | |
.map((m) => path.basename(m, path.extname(m))) | |
.join(", "); | |
lines.push(`Modules: ${moduleNames}\n`); | |
}); | |
// Cross-domain dependencies | |
lines.push(`## Cross-Domain Dependencies\n`); | |
const crossDomainConnections = this.graph.connections.filter((conn) => { | |
const fromDomain = this.getDomainFromPath(conn.from); | |
const toDomain = this.getDomainFromPath(conn.to); | |
return fromDomain !== toDomain; | |
}); | |
if (crossDomainConnections.length > 0) { | |
crossDomainConnections.forEach((conn) => { | |
const fromDomain = this.getDomainFromPath(conn.from); | |
const toDomain = this.getDomainFromPath(conn.to); | |
const fromFile = path.basename(conn.from, path.extname(conn.from)); | |
const toFile = path.basename(conn.to, path.extname(conn.to)); | |
const typePrefix = conn.exportType ? `${conn.exportType} ` : ""; | |
lines.push( | |
`- **${fromDomain}** → **${toDomain}**: ${fromFile}.${typePrefix}${conn.exportName} → ${toFile}`, | |
); | |
}); | |
} else { | |
lines.push("No cross-domain dependencies found."); | |
} | |
lines.push(""); | |
// Domain details | |
lines.push(`## Domain Details\n`); | |
this.graph.domains.forEach((modules, domain) => { | |
lines.push(`### ${domain}\n`); | |
modules.forEach((modulePath) => { | |
const moduleInfo = this.graph.modules.get(modulePath)!; | |
const fileName = path.basename(modulePath, path.extname(modulePath)); | |
lines.push(`#### ${fileName}`); | |
lines.push(`- Path: ${modulePath}`); | |
const exportsList = Array.from(moduleInfo.exports.entries()) | |
.map(([name, type]) => `${type} ${name}`) | |
.join(", "); | |
lines.push(`- Exports: ${exportsList || "none"}`); | |
lines.push( | |
`- Imports: ${Array.from(moduleInfo.imports.keys()).join(", ") || "none"}`, | |
); | |
lines.push( | |
`- Function calls: ${Array.from(moduleInfo.functionCalls).join(", ") || "none"}\n`, | |
); | |
}); | |
}); | |
return lines.join("\n"); | |
} | |
private generateBasicReport(lines: string[]): string { | |
lines.push(`## Modules\n`); | |
this.graph.modules.forEach((moduleInfo, modulePath) => { | |
const fileName = path.basename(modulePath, path.extname(modulePath)); | |
lines.push(`### ${fileName}`); | |
lines.push(`- Path: ${modulePath}`); | |
const exportsList = Array.from(moduleInfo.exports.entries()) | |
.map(([name, type]) => `${type} ${name}`) | |
.join(", "); | |
lines.push(`- Exports: ${exportsList || "none"}`); | |
lines.push( | |
`- Imports: ${Array.from(moduleInfo.imports.keys()).join(", ") || "none"}`, | |
); | |
lines.push( | |
`- Function calls: ${Array.from(moduleInfo.functionCalls).join(", ") || "none"}\n`, | |
); | |
}); | |
return lines.join("\n"); | |
} | |
} | |
// CLI Interface | |
function main() { | |
const args = process.argv.slice(2); | |
if (args.length === 0) { | |
console.log(` | |
Usage: ts-analyzer <project-path> [options] | |
Options: | |
--format <mermaid|dot|report> Output format (default: mermaid) | |
--output <file> Output file (default: stdout) | |
--tsconfig <path> Path to tsconfig.json | |
--domains Group modules by domain/folder structure | |
--domain-depth <number> Domain grouping depth (default: 1) | |
--verbose Enable verbose logging | |
Examples: | |
ts-analyzer ./src --format mermaid > deps.mmd | |
ts-analyzer ./src --format dot --domains | dot -Tsvg > deps.svg | |
ts-analyzer ./src --format report --domains --domain-depth 2 | |
ts-analyzer ./src --domains --verbose | |
Domain Examples: | |
src/fetch/api.ts, src/fetch/utils.ts → "fetch" domain | |
src/insights/charts/bar.ts → "insights" domain (depth=1) | |
src/insights/charts/bar.ts → "insights/charts" domain (depth=2) | |
DOT Styling Features: | |
- Domain titles in bold | |
- Symbol types in grey (function, class, interface, etc.) | |
- Symbol names in normal black | |
- Cross-domain dependencies highlighted | |
`); | |
process.exit(1); | |
} | |
const projectPath = args[0]; | |
let format = "mermaid"; | |
let outputFile: string | undefined; | |
let tsconfigPath: string | undefined; | |
let verbose = false; | |
let useDomains = false; | |
let domainDepth = 1; | |
for (let i = 1; i < args.length; i++) { | |
switch (args[i]) { | |
case "--format": | |
format = args[i + 1]; | |
i++; // Skip next arg since we consumed it | |
break; | |
case "--output": | |
outputFile = args[i + 1]; | |
i++; // Skip next arg since we consumed it | |
break; | |
case "--tsconfig": | |
tsconfigPath = args[i + 1]; | |
i++; // Skip next arg since we consumed it | |
break; | |
case "--domain-depth": | |
domainDepth = parseInt(args[i + 1], 10); | |
if (isNaN(domainDepth) || domainDepth < 1) { | |
console.error("Domain depth must be a positive integer"); | |
process.exit(1); | |
} | |
i++; // Skip next arg since we consumed it | |
break; | |
case "--domains": | |
useDomains = true; | |
break; | |
case "--verbose": | |
verbose = true; | |
break; | |
} | |
} | |
try { | |
const analyzer = new TypeScriptAnalyzer(projectPath, tsconfigPath); | |
if (useDomains) { | |
analyzer.analyzeWithDomains(domainDepth, verbose); | |
} else { | |
analyzer.analyze(verbose); | |
} | |
let output: string; | |
switch (format) { | |
case "mermaid": | |
output = analyzer.generateMermaidDiagram(useDomains); | |
break; | |
case "dot": | |
output = analyzer.generateDotDiagram(useDomains); | |
break; | |
case "report": | |
output = analyzer.generateReport(useDomains); | |
break; | |
default: | |
throw new Error(`Unknown format: ${format}`); | |
} | |
if (outputFile) { | |
fs.writeFileSync(outputFile, output); | |
console.log(`Output written to ${outputFile}`); | |
} else { | |
console.log(output); | |
} | |
} catch (error) { | |
console.error("Error:", error.message); | |
if (verbose) { | |
console.error(error.stack); | |
} | |
process.exit(1); | |
} | |
} | |
if (require.main === module) { | |
main(); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment