diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..94d57f8 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,377 @@ +#!/usr/bin/env node + +import { Command } from 'commander'; +import * as path from 'path'; +import * as fs from 'fs'; +import { createTypeParser } from './parsers/typeParser'; +import { createDependencyGraphBuilder } from './analyzers/dependencyGraph'; +import { detectCircularDependencies } from './analyzers/circularDetector'; +import { analyzeTypeWidening } from './analyzers/typeWidening'; +import { analyzeTypeNarrowing } from './analyzers/typeNarrowing'; +import { exportToDOT } from './exporters/dotExporter'; +import { exportToGraphML } from './exporters/graphmlExporter'; +import { exportToJSON } from './exporters/jsonExporter'; +import { FileFinder } from './utils/fileFinder'; +import { createFileWatcher } from './utils/watcher'; +import { DependencyGraph, ExportOptions, AnalysisResult } from './types'; + +import debug from 'debug'; + +interface CLIOptions { + input?: string; + output?: string; + format?: string; + watch?: boolean; + includeCircular?: boolean; + includeWidening?: boolean; + includeNarrowing?: boolean; + clusterBy?: string; + maxDepth?: number; +} + +class TypeFlowCLI { + private program: Command; + private parser: ReturnType; + private graphBuilder: ReturnType; + + constructor() { + this.program = new Command(); + this.parser = createTypeParser({ skipErrors: true }); + this.graphBuilder = createDependencyGraphBuilder(); + + this.setupProgram(); + } + + private setupProgram(): void { + this.program + .name('typeflow') + .description('CLI Type Dependency Tracer - Visualize and analyze TypeScript type dependencies') + .version('1.0.0'); + + this.program + .command('analyze') + .description('Analyze type dependencies in a directory or file') + .argument('[path]', 'Path to analyze (default: current directory)', '.') + .option('-o, --output ', 'Output file for the graph') + .option('-f, --format ', 'Output format (dot, graphml, json)', 'dot') + .option('--no-circular', 'Skip circular dependency detection') + .option('--no-widening', 'Skip type widening analysis') + .option('--no-narrowing', 'Skip type narrowing analysis') + .option('--cluster-by ', 'Cluster nodes by (file, kind)', 'file') + .option('-d, --max-depth ', 'Maximum dependency depth', '10') + .action((path, options) => this.analyze(path, options)); + + this.program + .command('watch') + .description('Watch files and incrementally analyze') + .argument('[path]', 'Path to watch (default: current directory)', '.') + .option('-o, --output ', 'Output file for the graph') + .option('-f, --format ', 'Output format (dot, graphml, json)', 'dot') + .action((path, options) => this.watch(path, options)); + + this.program + .command('export') + .description('Export an existing analysis') + .argument('', 'Input JSON file') + .argument('', 'Output file') + .option('-f, --format ', 'Output format (dot, graphml)', 'dot') + .action((input, output, options) => this.export(input, output, options)); + + this.program + .command('check') + .description('Check for issues without generating a graph') + .argument('[path]', 'Path to check (default: current directory)', '.') + .option('--no-circular', 'Skip circular dependency check') + .option('--no-widening', 'Skip type widening check') + .option('--no-narrowing', 'Skip type narrowing check') + .action((path, options) => this.check(path, options)); + + this.program + .command('init') + .description('Initialize a typeflow config file') + .action(() => this.init()); + } + + private async analyze(inputPath: string, options: Record): Promise { + const opts = options as CLIOptions; + const input = path.resolve(inputPath); + const output = opts.output || 'typeflow graph'; + + debug(`Analyzing: ${input}`); + + const files = this.findFiles(input); + + if (files.length === 0) { + console.error('No TypeScript files found.'); + process.exit(1); + } + + console.log(`Found ${files.length} files to analyze.`); + + const parsedFiles = this.parseFiles(files); + const graph = this.graphBuilder.build(parsedFiles, input); + + if (opts.clusterBy === 'file') { + this.graphBuilder.clusterByFile(); + } else if (opts.clusterBy === 'kind') { + this.graphBuilder.clusterByKind(); + } + + const result: AnalysisResult = { + graph, + circularDependencies: (opts.includeCircular !== false) ? detectCircularDependencies(graph) : [], + wideningIssues: (opts.includeWidening !== false) ? analyzeTypeWidening(graph) : [], + narrowingIssues: (opts.includeNarrowing !== false) ? analyzeTypeNarrowing(graph) : [], + summary: { + totalNodes: graph.nodes.size, + totalEdges: graph.edges.length, + totalCircular: 0, + totalWidening: 0, + totalNarrowing: 0, + filesAnalyzed: files.length, + analysisTimeMs: 0 + } + }; + + result.summary.totalCircular = result.circularDependencies.length; + result.summary.totalWidening = result.wideningIssues.length; + result.summary.totalNarrowing = result.narrowingIssues.length; + + this.printSummary(result); + + const format = (opts.format as string) || 'dot'; + this.exportGraph(graph, output, format); + } + + private async watch(inputPath: string, options: Record): Promise { + const input = path.resolve(inputPath); + + console.log(`Starting watch mode on: ${input}`); + + const watcher = createFileWatcher({ ignoreInitial: false }); + + let isAnalyzing = false; + const debounceAnalysis = this.debounce(() => { + if (isAnalyzing) return; + isAnalyzing = true; + this.analyze(inputPath, options).then(() => { + isAnalyzing = false; + }); + }, 500); + + watcher.on('add', debounceAnalysis); + watcher.on('change', debounceAnalysis); + watcher.on('unlink', debounceAnalysis); + + await watcher.watch([input]); + + console.log('Watching for changes. Press Ctrl+C to stop.'); + } + + private async export(inputFile: string, outputFile: string, options: Record): Promise { + const format = (options.format as string) || 'dot'; + + if (!fs.existsSync(inputFile)) { + console.error(`Input file not found: ${inputFile}`); + process.exit(1); + } + + const content = fs.readFileSync(inputFile, 'utf-8'); + const graph: DependencyGraph = JSON.parse(content); + + this.exportGraph(graph, outputFile, format); + console.log(`Exported to: ${outputFile}`); + } + + private async check(inputPath: string, options: Record): Promise { + const opts = options as CLIOptions; + const input = path.resolve(inputPath); + + debug(`Checking: ${input}`); + + const files = this.findFiles(input); + const parsedFiles = this.parseFiles(files); + const graph = this.graphBuilder.build(parsedFiles, input); + + let hasIssues = false; + + if ((opts.includeCircular as unknown as boolean) !== false) { + const circularDeps = detectCircularDependencies(graph); + if (circularDeps.length > 0) { + console.log(`\nCircular dependencies found (${circularDeps.length}):`); + for (const cycle of circularDeps) { + console.log(` - ${cycle.cycle.join(' -> ')}`); + } + hasIssues = true; + } + } + + if ((opts.includeWidening as unknown as boolean) !== false) { + const wideningIssues = analyzeTypeWidening(graph); + if (wideningIssues.length > 0) { + console.log(`\nType widening issues found (${wideningIssues.length}):`); + for (const issue of wideningIssues.slice(0, 10)) { + console.log(` - ${issue.typeName}: ${issue.description}`); + } + if (wideningIssues.length > 10) { + console.log(` ... and ${wideningIssues.length - 10} more`); + } + hasIssues = true; + } + } + + if ((opts.includeNarrowing as unknown as boolean) !== false) { + const narrowingIssues = analyzeTypeNarrowing(graph); + if (narrowingIssues.length > 0) { + console.log(`\nType narrowing issues found (${narrowingIssues.length}):`); + for (const issue of narrowingIssues.slice(0, 10)) { + console.log(` - ${issue.typeName}: ${issue.description}`); + } + if (narrowingIssues.length > 10) { + console.log(` ... and ${narrowingIssues.length - 10} more`); + } + hasIssues = true; + } + } + + if (!hasIssues) { + console.log('No issues found.'); + } + + process.exit(hasIssues ? 1 : 0); + } + + private async init(): Promise { + const configPath = path.resolve('typeflow.config.json'); + + if (fs.existsSync(configPath)) { + console.log('Config file already exists: typeflow.config.json'); + process.exit(1); + } + + const config = { + includePatterns: ['**/*.ts', '**/*.tsx'], + excludePatterns: ['node_modules', 'dist', '*.d.ts'], + maxDepth: 10, + exportFormats: ['dot', 'graphml', 'json'], + analysis: { + circularDependencies: true, + typeWidening: true, + typeNarrowing: true + } + }; + + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + console.log('Created config file: typeflow.config.json'); + } + + private findFiles(inputPath: string): string[] { + const stat = fs.statSync(inputPath); + + if (stat.isFile() && inputPath.endsWith('.ts')) { + return [inputPath]; + } + + const finder = new FileFinder({ + includePatterns: ['**/*.ts', '**/*.tsx'], + excludePatterns: ['node_modules', 'dist', '*.d.ts'] + }); + + return finder.find(inputPath).map((f) => f.filePath); + } + + private parseFiles(filePaths: string[]): import('./types').ParsedFile[] { + return filePaths.map((filePath) => { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + return this.parser.parse(content, filePath); + } catch (error) { + debug(`Error parsing ${filePath}: ${error}`); + return { + filePath, + types: [], + imports: [], + errors: [{ message: (error as Error).message, line: 0, column: 0, severity: 'error' }] + }; + } + }); + } + + private exportGraph(graph: DependencyGraph, outputPath: string, format: string): void { + const fullPath = this.getOutputPath(outputPath, format); + + const exportOptions: ExportOptions = { + format: format as 'dot' | 'graphml' | 'json', + outputPath: fullPath, + includeMetadata: true + }; + + let content: string; + switch (format) { + case 'dot': + content = exportToDOT(graph, exportOptions); + break; + case 'graphml': + content = exportToGraphML(graph, exportOptions); + break; + case 'json': + content = exportToJSON(graph, exportOptions); + break; + default: + console.error(`Unknown format: ${format}`); + process.exit(1); + } + + fs.writeFileSync(fullPath, content, 'utf-8'); + console.log(`Graph exported to: ${fullPath}`); + } + + private getOutputPath(basePath: string, format: string): string { + const ext = this.getExtension(format); + if (basePath.endsWith(ext)) { + return basePath; + } + return `${basePath}${ext}`; + } + + private getExtension(format: string): string { + const extensions: Record = { + dot: '.dot', + graphml: '.graphml', + json: '.json' + }; + return extensions[format] || '.dot'; + } + + private printSummary(result: AnalysisResult): void { + console.log('\n=== Analysis Summary ==='); + console.log(`Files analyzed: ${result.summary.filesAnalyzed}`); + console.log(`Nodes: ${result.summary.totalNodes}`); + console.log(`Edges: ${result.summary.totalEdges}`); + console.log(`Circular dependencies: ${result.summary.totalCircular}`); + console.log(`Widening issues: ${result.summary.totalWidening}`); + console.log(`Narrowing issues: ${result.summary.totalNarrowing}`); + } + + private debounce) => void>( + func: T, + wait: number + ): (...args: Parameters) => void { + let timeout: NodeJS.Timeout | null = null; + return (...args: Parameters) => { + if (timeout) clearTimeout(timeout); + timeout = setTimeout(() => func(...args), wait); + }; + } + + run(): void { + this.program.parse(process.argv); + + if (process.argv.length < 3) { + this.program.outputHelp(); + } + } +} + +const cli = new TypeFlowCLI(); +cli.run();