Add CLI entry point and main index
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
2026-01-30 00:56:12 +00:00
parent 3bb39032df
commit 73064a49b7

377
src/cli.ts Normal file
View 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();