This commit is contained in:
377
src/cli.ts
Normal file
377
src/cli.ts
Normal file
@@ -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<typeof createTypeParser>;
|
||||
private graphBuilder: ReturnType<typeof createDependencyGraphBuilder>;
|
||||
|
||||
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 <file>', 'Output file for the graph')
|
||||
.option('-f, --format <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 <type>', 'Cluster nodes by (file, kind)', 'file')
|
||||
.option('-d, --max-depth <number>', '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 <file>', 'Output file for the graph')
|
||||
.option('-f, --format <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>', 'Input JSON file')
|
||||
.argument('<output>', 'Output file')
|
||||
.option('-f, --format <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<string, unknown>): Promise<void> {
|
||||
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<string, unknown>): Promise<void> {
|
||||
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<string, unknown>): Promise<void> {
|
||||
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<string, unknown>): Promise<void> {
|
||||
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<void> {
|
||||
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<string, string> = {
|
||||
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<T extends (...args: Parameters<T>) => void>(
|
||||
func: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
return (...args: Parameters<T>) => {
|
||||
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();
|
||||
Reference in New Issue
Block a user