mirror of
https://github.com/johndoe6345789/tsmorph.git
synced 2026-04-24 13:54:58 +00:00
Clarify variable LOC in docs
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# TSX Refactoring with ts-morph
|
# TSX Refactoring with ts-morph
|
||||||
|
|
||||||
Automated TSX/TypeScript refactoring tools using [ts-morph](https://ts-morph.com/) to extract large code blocks (>150 LOC) into separate, well-typed, lint-compliant files with automatic import management.
|
Automated TSX/TypeScript refactoring tools using [ts-morph](https://ts-morph.com/) to extract code blocks into separate, well-typed, lint-compliant files with automatic import management. The intent is to make smaller components practical (e.g., 50/100/150 LOC thresholds) by pushing logic into hooks/utilities and iterating via a feedback loop.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -8,6 +8,8 @@ Automated TSX/TypeScript refactoring tools using [ts-morph](https://ts-morph.com
|
|||||||
- Extracts types and interfaces to separate `.types.ts` files
|
- Extracts types and interfaces to separate `.types.ts` files
|
||||||
- Extracts utility functions to separate `.utils.ts` files
|
- Extracts utility functions to separate `.utils.ts` files
|
||||||
- Automatically generates and fixes imports
|
- Automatically generates and fixes imports
|
||||||
|
- Supports smaller component targets (e.g., 50/100/150 LOC) by pushing logic into hooks and helpers
|
||||||
|
- Designed for iterative runs so each pass can shrink the component further
|
||||||
|
|
||||||
🔍 **Type Analysis & Auto-Fixing**
|
🔍 **Type Analysis & Auto-Fixing**
|
||||||
- Infers and adds missing return types
|
- Infers and adds missing return types
|
||||||
@@ -80,9 +82,19 @@ npm run type-check
|
|||||||
| `npm run lint` | Check for lint issues |
|
| `npm run lint` | Check for lint issues |
|
||||||
| `npm run format` | Format all files with Prettier |
|
| `npm run format` | Format all files with Prettier |
|
||||||
|
|
||||||
|
### CLI Options (Target Any App)
|
||||||
|
|
||||||
|
Use the scripts directly for non-demo components:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ts-node scripts/refactor-tsx.ts --file path/to/Dashboard.tsx --min-function-lines 50 --min-variable-lines 25
|
||||||
|
ts-node scripts/refactor-tsx-pass2.ts --file path/to/Dashboard.tsx --helper-pattern "^(validate|get|format|handle)"
|
||||||
|
ts-node scripts/analyze-types.ts --files path/to/Dashboard.types.ts,path/to/Dashboard.utils.ts,path/to/Dashboard.tsx
|
||||||
|
```
|
||||||
|
|
||||||
## Refactoring Examples
|
## Refactoring Examples
|
||||||
|
|
||||||
### Before: Monolithic Component (603 LOC)
|
### Before: Monolithic Component
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// UserManagementDashboard.tsx - Everything in one file
|
// UserManagementDashboard.tsx - Everything in one file
|
||||||
@@ -169,7 +181,7 @@ export const formatDate = (dateString: string): string => {
|
|||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
**UserManagementDashboard.tsx** (532 LOC)
|
**UserManagementDashboard.tsx** (size varies)
|
||||||
```tsx
|
```tsx
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { User, FormData, ValidationErrors } from './UserManagementDashboard.types';
|
import { User, FormData, ValidationErrors } from './UserManagementDashboard.types';
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -1,6 +1,6 @@
|
|||||||
# tsmorph
|
# tsmorph
|
||||||
|
|
||||||
Automated TSX/TypeScript refactoring toolkit using [ts-morph](https://ts-morph.com/) to intelligently extract large code blocks (>150 LOC) into separate, well-typed, lint-compliant files with automatic import management.
|
Automated TSX/TypeScript refactoring toolkit using [ts-morph](https://ts-morph.com/) to intelligently extract code blocks into separate, well-typed, lint-compliant files with automatic import management. The goal is to support smaller components (e.g., 50/100/150 LOC thresholds) and push more logic into hooks/utilities via a feedback loop.
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
@@ -19,6 +19,8 @@ npm run type-check # Verify everything compiles
|
|||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- **Automated Code Extraction**: Extracts types, interfaces, and utility functions to separate files
|
- **Automated Code Extraction**: Extracts types, interfaces, and utility functions to separate files
|
||||||
|
- **Smaller Component Targets**: Supports tighter LOC targets (e.g., 50/100/150) by encouraging extraction into hooks and utilities
|
||||||
|
- **Feedback Loop Friendly**: Re-run the pipeline to keep shrinking components after each extraction pass
|
||||||
- **Smart Import Management**: Automatically adds, removes, and organizes imports
|
- **Smart Import Management**: Automatically adds, removes, and organizes imports
|
||||||
- **Type Analysis**: Infers and adds missing return types, parameter types, and fixes `any` types
|
- **Type Analysis**: Infers and adds missing return types, parameter types, and fixes `any` types
|
||||||
- **Lint Auto-Fixing**: Integrates with ESLint and Prettier for consistent code quality
|
- **Lint Auto-Fixing**: Integrates with ESLint and Prettier for consistent code quality
|
||||||
@@ -26,11 +28,11 @@ npm run type-check # Verify everything compiles
|
|||||||
|
|
||||||
## 📊 Results
|
## 📊 Results
|
||||||
|
|
||||||
Starting with a monolithic **603-line** TSX component, the tools automatically refactor it into:
|
Starting with a monolithic TSX component, the tools automatically refactor it into:
|
||||||
|
|
||||||
| File | LOC | Purpose |
|
| File | LOC | Purpose |
|
||||||
|------|-----|---------|
|
|------|-----|---------|
|
||||||
| `UserManagementDashboard.tsx` | 532 | Main component logic |
|
| `UserManagementDashboard.tsx` | Varies | Main component logic |
|
||||||
| `UserManagementDashboard.types.ts` | 22 | Type definitions & interfaces |
|
| `UserManagementDashboard.types.ts` | 22 | Type definitions & interfaces |
|
||||||
| `UserManagementDashboard.utils.ts` | 50 | Utility functions with precise types |
|
| `UserManagementDashboard.utils.ts` | 50 | Utility functions with precise types |
|
||||||
|
|
||||||
@@ -61,6 +63,16 @@ See [DOCUMENTATION.md](./DOCUMENTATION.md) for comprehensive guides on:
|
|||||||
| `npm run lint` | Check for lint issues |
|
| `npm run lint` | Check for lint issues |
|
||||||
| `npm run format` | Format all files with Prettier |
|
| `npm run format` | Format all files with Prettier |
|
||||||
|
|
||||||
|
## 🧰 CLI Usage for Another App
|
||||||
|
|
||||||
|
Target any TSX file by passing file paths and thresholds directly to the scripts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ts-node scripts/refactor-tsx.ts --file path/to/Dashboard.tsx --min-function-lines 50 --min-variable-lines 25
|
||||||
|
ts-node scripts/refactor-tsx-pass2.ts --file path/to/Dashboard.tsx --helper-pattern "^(validate|get|format|handle)"
|
||||||
|
ts-node scripts/analyze-types.ts --files path/to/Dashboard.types.ts,path/to/Dashboard.utils.ts,path/to/Dashboard.tsx
|
||||||
|
```
|
||||||
|
|
||||||
## 📦 Example Output
|
## 📦 Example Output
|
||||||
|
|
||||||
### Type Analysis Report
|
### Type Analysis Report
|
||||||
@@ -86,4 +98,3 @@ See [DOCUMENTATION.md](./DOCUMENTATION.md) for comprehensive guides on:
|
|||||||
## 📝 License
|
## 📝 License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env ts-node
|
#!/usr/bin/env ts-node
|
||||||
|
|
||||||
import { Project, SourceFile, SyntaxKind, Type, Node } from 'ts-morph';
|
import { Project, SourceFile, Node } from 'ts-morph';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,6 +14,51 @@ import * as path from 'path';
|
|||||||
* 5. Makes types more specific where possible
|
* 5. Makes types more specific where possible
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
interface CliArgs {
|
||||||
|
files: string[];
|
||||||
|
help?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseArgs = (args: string[]): CliArgs => {
|
||||||
|
const result: CliArgs = { files: [] };
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i += 1) {
|
||||||
|
const arg = args[i];
|
||||||
|
if (!arg) continue;
|
||||||
|
|
||||||
|
switch (arg) {
|
||||||
|
case '--files': {
|
||||||
|
const next = args[i + 1];
|
||||||
|
if (next) {
|
||||||
|
result.files.push(...next.split(',').map(entry => entry.trim()).filter(Boolean));
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case '--file': {
|
||||||
|
const next = args[i + 1];
|
||||||
|
if (next) {
|
||||||
|
result.files.push(next);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case '--help':
|
||||||
|
case '-h':
|
||||||
|
result.help = true;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const printUsage = (): void => {
|
||||||
|
console.log(`\nUsage:\n ts-node scripts/analyze-types.ts [options]\n\nOptions:\n --file <path> Single file to analyze (repeatable)\n --files <a,b,c> Comma-separated list of files to analyze\n --help Show this help message\n`);
|
||||||
|
};
|
||||||
|
|
||||||
class TypeAnalyzer {
|
class TypeAnalyzer {
|
||||||
private project: Project;
|
private project: Project;
|
||||||
|
|
||||||
@@ -304,30 +349,19 @@ class TypeAnalyzer {
|
|||||||
/**
|
/**
|
||||||
* Process all refactored files
|
* Process all refactored files
|
||||||
*/
|
*/
|
||||||
processAllFiles(): void {
|
processAllFiles(filePaths?: string[]): void {
|
||||||
const componentsDir = path.join(__dirname, '..', 'src', 'components');
|
const files = filePaths && filePaths.length > 0 ? filePaths : this.getDefaultFiles();
|
||||||
const files = [
|
|
||||||
path.join(componentsDir, 'UserManagementDashboard.types.ts'),
|
|
||||||
path.join(componentsDir, 'UserManagementDashboard.utils.ts'),
|
|
||||||
path.join(componentsDir, 'UserManagementDashboard.tsx'),
|
|
||||||
];
|
|
||||||
|
|
||||||
files.forEach(file => this.processFile(file));
|
files.forEach(file => this.processFile(file));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate type coverage report
|
* Generate type coverage report
|
||||||
*/
|
*/
|
||||||
generateTypeCoverageReport(): void {
|
generateTypeCoverageReport(filePaths?: string[]): void {
|
||||||
console.log('\n📈 Type Coverage Report');
|
console.log('\n📈 Type Coverage Report');
|
||||||
console.log('========================\n');
|
console.log('========================\n');
|
||||||
|
|
||||||
const componentsDir = path.join(__dirname, '..', 'src', 'components');
|
const files = filePaths && filePaths.length > 0 ? filePaths : this.getDefaultFiles();
|
||||||
const files = [
|
|
||||||
path.join(componentsDir, 'UserManagementDashboard.types.ts'),
|
|
||||||
path.join(componentsDir, 'UserManagementDashboard.utils.ts'),
|
|
||||||
path.join(componentsDir, 'UserManagementDashboard.tsx'),
|
|
||||||
];
|
|
||||||
|
|
||||||
files.forEach(filePath => {
|
files.forEach(filePath => {
|
||||||
if (!require('fs').existsSync(filePath)) return;
|
if (!require('fs').existsSync(filePath)) return;
|
||||||
@@ -368,18 +402,34 @@ class TypeAnalyzer {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getDefaultFiles(): string[] {
|
||||||
|
const componentsDir = path.join(__dirname, '..', 'src', 'components');
|
||||||
|
return [
|
||||||
|
path.join(componentsDir, 'UserManagementDashboard.types.ts'),
|
||||||
|
path.join(componentsDir, 'UserManagementDashboard.utils.ts'),
|
||||||
|
path.join(componentsDir, 'UserManagementDashboard.tsx'),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('🚀 TypeScript Type Analysis and Auto-Fixing Tool\n');
|
console.log('🚀 TypeScript Type Analysis and Auto-Fixing Tool\n');
|
||||||
|
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
if (args.help) {
|
||||||
|
printUsage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const analyzer = new TypeAnalyzer();
|
const analyzer = new TypeAnalyzer();
|
||||||
|
|
||||||
// Process all files
|
// Process all files
|
||||||
analyzer.processAllFiles();
|
const files = args.files.map(file => path.resolve(file));
|
||||||
|
analyzer.processAllFiles(files);
|
||||||
|
|
||||||
// Generate report
|
// Generate report
|
||||||
analyzer.generateTypeCoverageReport();
|
analyzer.generateTypeCoverageReport(files);
|
||||||
|
|
||||||
console.log('\n✅ Type analysis complete!');
|
console.log('\n✅ Type analysis complete!');
|
||||||
console.log('\n💡 Next steps:');
|
console.log('\n💡 Next steps:');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env ts-node
|
#!/usr/bin/env ts-node
|
||||||
|
|
||||||
import { Project, SyntaxKind, SourceFile, VariableDeclaration } from 'ts-morph';
|
import { Project, SyntaxKind, SourceFile } from 'ts-morph';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -8,16 +8,93 @@ import * as path from 'path';
|
|||||||
* This extracts helper functions like validateForm, getRoleBadgeColor, etc.
|
* This extracts helper functions like validateForm, getRoleBadgeColor, etc.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
interface Pass2Options {
|
||||||
|
targetFile: string;
|
||||||
|
utilsFile: string;
|
||||||
|
typesFile: string;
|
||||||
|
helperNamePattern: RegExp;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CliArgs {
|
||||||
|
file?: string;
|
||||||
|
utils?: string;
|
||||||
|
types?: string;
|
||||||
|
helperPattern?: string;
|
||||||
|
help?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_HELPER_PATTERN = '^(validate|get|format|handle)';
|
||||||
|
|
||||||
|
const toModuleSpecifier = (fromFile: string, toFile: string): string => {
|
||||||
|
const relativePath = path
|
||||||
|
.relative(path.dirname(fromFile), toFile)
|
||||||
|
.replace(/\\/g, '/')
|
||||||
|
.replace(/\.(tsx?|jsx?)$/, '');
|
||||||
|
return relativePath.startsWith('.') ? relativePath : `./${relativePath}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveOutputPath = (inputFile: string, suffix: string): string => {
|
||||||
|
if (inputFile.endsWith('.tsx')) {
|
||||||
|
return inputFile.replace(/\.tsx$/, suffix);
|
||||||
|
}
|
||||||
|
if (inputFile.endsWith('.ts')) {
|
||||||
|
return inputFile.replace(/\.ts$/, suffix);
|
||||||
|
}
|
||||||
|
return `${inputFile}${suffix}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseArgs = (args: string[]): CliArgs => {
|
||||||
|
const result: CliArgs = {};
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i += 1) {
|
||||||
|
const arg = args[i];
|
||||||
|
if (!arg) continue;
|
||||||
|
|
||||||
|
switch (arg) {
|
||||||
|
case '--file':
|
||||||
|
result.file = args[i + 1];
|
||||||
|
i += 1;
|
||||||
|
break;
|
||||||
|
case '--utils':
|
||||||
|
result.utils = args[i + 1];
|
||||||
|
i += 1;
|
||||||
|
break;
|
||||||
|
case '--types':
|
||||||
|
result.types = args[i + 1];
|
||||||
|
i += 1;
|
||||||
|
break;
|
||||||
|
case '--helper-pattern':
|
||||||
|
result.helperPattern = args[i + 1];
|
||||||
|
i += 1;
|
||||||
|
break;
|
||||||
|
case '--help':
|
||||||
|
case '-h':
|
||||||
|
result.help = true;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const printUsage = (): void => {
|
||||||
|
console.log(`\nUsage:\n ts-node scripts/refactor-tsx-pass2.ts --file <path> [options]\n\nOptions:\n --utils <path> Output utils file path (default: <file>.utils.ts)\n --types <path> Types file path for type-only imports\n --helper-pattern <regex> Regex for helper function names (default: ${DEFAULT_HELPER_PATTERN})\n --help Show this help message\n`);
|
||||||
|
};
|
||||||
|
|
||||||
class TSXRefactorer2 {
|
class TSXRefactorer2 {
|
||||||
private project: Project;
|
private project: Project;
|
||||||
private sourceFile: SourceFile;
|
private sourceFile: SourceFile;
|
||||||
|
private options: Pass2Options;
|
||||||
|
|
||||||
constructor(filePath: string) {
|
constructor(options: Pass2Options) {
|
||||||
this.project = new Project({
|
this.project = new Project({
|
||||||
tsConfigFilePath: path.join(__dirname, '..', 'tsconfig.json'),
|
tsConfigFilePath: path.join(__dirname, '..', 'tsconfig.json'),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.sourceFile = this.project.addSourceFileAtPath(filePath);
|
this.sourceFile = this.project.addSourceFileAtPath(options.targetFile);
|
||||||
|
this.options = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,10 +103,13 @@ class TSXRefactorer2 {
|
|||||||
extractHelperFunctions(): void {
|
extractHelperFunctions(): void {
|
||||||
console.log('\n🔄 Extracting helper functions (2nd pass)...');
|
console.log('\n🔄 Extracting helper functions (2nd pass)...');
|
||||||
|
|
||||||
const utilsFilePath = this.sourceFile.getFilePath().replace('.tsx', '.utils.ts');
|
const utilsFilePath = this.options.utilsFile;
|
||||||
|
|
||||||
// Find the main component
|
// Find the main component
|
||||||
const componentDecl = this.sourceFile.getVariableDeclaration('UserManagementDashboard');
|
const componentDecl = this.sourceFile.getVariableDeclarations().find(decl => {
|
||||||
|
const initializer = decl.getInitializer();
|
||||||
|
return initializer?.isKind(SyntaxKind.ArrowFunction);
|
||||||
|
});
|
||||||
if (!componentDecl) {
|
if (!componentDecl) {
|
||||||
console.log(' ⚠️ Could not find component');
|
console.log(' ⚠️ Could not find component');
|
||||||
return;
|
return;
|
||||||
@@ -69,7 +149,7 @@ class TSXRefactorer2 {
|
|||||||
const text = stmt.getText();
|
const text = stmt.getText();
|
||||||
|
|
||||||
// Extract these specific helper functions
|
// Extract these specific helper functions
|
||||||
if (name.match(/^(validate|getRoleBadgeColor|getStatusBadgeColor|formatDate)/)) {
|
if (this.options.helperNamePattern.test(name)) {
|
||||||
helperFunctions.push({ name, text });
|
helperFunctions.push({ name, text });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,9 +177,9 @@ class TSXRefactorer2 {
|
|||||||
' * Auto-generated by ts-morph refactoring script',
|
' * Auto-generated by ts-morph refactoring script',
|
||||||
' */',
|
' */',
|
||||||
'',
|
'',
|
||||||
"import type { FormData, ValidationErrors, User } from './UserManagementDashboard.types';",
|
this.getTypeImportStatement(utilsFilePath),
|
||||||
'',
|
'',
|
||||||
].join('\n');
|
].filter(Boolean).join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the helper functions
|
// Add the helper functions
|
||||||
@@ -132,20 +212,19 @@ class TSXRefactorer2 {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Add import
|
// Add import
|
||||||
const existingImport = this.sourceFile.getImportDeclaration('./UserManagementDashboard.utils');
|
const moduleSpecifier = toModuleSpecifier(this.options.targetFile, utilsFilePath);
|
||||||
|
const existingImport = this.sourceFile.getImportDeclaration(moduleSpecifier);
|
||||||
if (existingImport) {
|
if (existingImport) {
|
||||||
// Remove existing and recreate with combined imports
|
|
||||||
const namedImports = existingImport.getNamedImports().map(ni => ni.getName());
|
const namedImports = existingImport.getNamedImports().map(ni => ni.getName());
|
||||||
const newImports = [...new Set([...namedImports, ...functionNames])];
|
const newImports = [...new Set([...namedImports, ...functionNames])];
|
||||||
existingImport.remove();
|
existingImport.remove();
|
||||||
this.sourceFile.addImportDeclaration({
|
this.sourceFile.addImportDeclaration({
|
||||||
moduleSpecifier: './UserManagementDashboard.utils',
|
moduleSpecifier,
|
||||||
namedImports: newImports,
|
namedImports: newImports,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Create new import
|
|
||||||
this.sourceFile.addImportDeclaration({
|
this.sourceFile.addImportDeclaration({
|
||||||
moduleSpecifier: './UserManagementDashboard.utils',
|
moduleSpecifier,
|
||||||
namedImports: functionNames,
|
namedImports: functionNames,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -158,20 +237,60 @@ class TSXRefactorer2 {
|
|||||||
this.sourceFile.saveSync();
|
this.sourceFile.saveSync();
|
||||||
console.log('\n✅ Second pass refactoring complete!');
|
console.log('\n✅ Second pass refactoring complete!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getExportedTypeNames(): string[] {
|
||||||
|
if (!this.options.typesFile) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const typesFile = this.project.getSourceFile(this.options.typesFile);
|
||||||
|
if (!typesFile) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const interfaces = typesFile.getInterfaces();
|
||||||
|
const typeAliases = typesFile.getTypeAliases();
|
||||||
|
|
||||||
|
return [...interfaces, ...typeAliases]
|
||||||
|
.filter(type => type.isExported())
|
||||||
|
.map(type => type.getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTypeImportStatement(utilsFilePath: string): string | null {
|
||||||
|
if (!this.options.typesFile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeNames = this.getExportedTypeNames();
|
||||||
|
if (typeNames.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `import type { ${typeNames.join(', ')} } from '${toModuleSpecifier(utilsFilePath, this.options.typesFile)}';`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('🚀 TSX Refactoring Tool - Second Pass\n');
|
console.log('🚀 TSX Refactoring Tool - Second Pass\n');
|
||||||
|
|
||||||
const targetFile = path.join(
|
const args = parseArgs(process.argv.slice(2));
|
||||||
__dirname,
|
if (args.help) {
|
||||||
'..',
|
printUsage();
|
||||||
'src',
|
return;
|
||||||
'components',
|
}
|
||||||
'UserManagementDashboard.tsx'
|
|
||||||
);
|
|
||||||
|
|
||||||
const refactorer = new TSXRefactorer2(targetFile);
|
const targetFile = args.file
|
||||||
|
? path.resolve(args.file)
|
||||||
|
: path.join(__dirname, '..', 'src', 'components', 'UserManagementDashboard.tsx');
|
||||||
|
|
||||||
|
const typesFile = args.types ? path.resolve(args.types) : resolveOutputPath(targetFile, '.types.ts');
|
||||||
|
|
||||||
|
const refactorer = new TSXRefactorer2({
|
||||||
|
targetFile,
|
||||||
|
utilsFile: path.resolve(args.utils ?? resolveOutputPath(targetFile, '.utils.ts')),
|
||||||
|
typesFile,
|
||||||
|
helperNamePattern: new RegExp(args.helperPattern ?? DEFAULT_HELPER_PATTERN),
|
||||||
|
});
|
||||||
refactorer.extractHelperFunctions();
|
refactorer.extractHelperFunctions();
|
||||||
refactorer.save();
|
refactorer.save();
|
||||||
|
|
||||||
|
|||||||
@@ -19,16 +19,107 @@ interface ExtractionCandidate {
|
|||||||
type: 'function' | 'component' | 'interface' | 'type' | 'variable';
|
type: 'function' | 'component' | 'interface' | 'type' | 'variable';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RefactorOptions {
|
||||||
|
targetFile: string;
|
||||||
|
typesFile: string;
|
||||||
|
utilsFile: string;
|
||||||
|
minFunctionLines: number;
|
||||||
|
minVariableLines: number;
|
||||||
|
utilNamePattern: RegExp;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CliArgs {
|
||||||
|
file?: string;
|
||||||
|
types?: string;
|
||||||
|
utils?: string;
|
||||||
|
minFunctionLines?: number;
|
||||||
|
minVariableLines?: number;
|
||||||
|
utilPattern?: string;
|
||||||
|
help?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MIN_FUNCTION_LINES = 20;
|
||||||
|
const DEFAULT_MIN_VARIABLE_LINES = 10;
|
||||||
|
const DEFAULT_UTIL_PATTERN = '^(validate|format|get|handle)';
|
||||||
|
|
||||||
|
const toModuleSpecifier = (fromFile: string, toFile: string): string => {
|
||||||
|
const relativePath = path
|
||||||
|
.relative(path.dirname(fromFile), toFile)
|
||||||
|
.replace(/\\/g, '/')
|
||||||
|
.replace(/\.(tsx?|jsx?)$/, '');
|
||||||
|
return relativePath.startsWith('.') ? relativePath : `./${relativePath}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseArgs = (args: string[]): CliArgs => {
|
||||||
|
const result: CliArgs = {};
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i += 1) {
|
||||||
|
const arg = args[i];
|
||||||
|
if (!arg) continue;
|
||||||
|
|
||||||
|
switch (arg) {
|
||||||
|
case '--file':
|
||||||
|
result.file = args[i + 1];
|
||||||
|
i += 1;
|
||||||
|
break;
|
||||||
|
case '--types':
|
||||||
|
result.types = args[i + 1];
|
||||||
|
i += 1;
|
||||||
|
break;
|
||||||
|
case '--utils':
|
||||||
|
result.utils = args[i + 1];
|
||||||
|
i += 1;
|
||||||
|
break;
|
||||||
|
case '--min-function-lines':
|
||||||
|
result.minFunctionLines = Number(args[i + 1]);
|
||||||
|
i += 1;
|
||||||
|
break;
|
||||||
|
case '--min-variable-lines':
|
||||||
|
result.minVariableLines = Number(args[i + 1]);
|
||||||
|
i += 1;
|
||||||
|
break;
|
||||||
|
case '--util-pattern':
|
||||||
|
result.utilPattern = args[i + 1];
|
||||||
|
i += 1;
|
||||||
|
break;
|
||||||
|
case '--help':
|
||||||
|
case '-h':
|
||||||
|
result.help = true;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const printUsage = (): void => {
|
||||||
|
console.log(`\nUsage:\n ts-node scripts/refactor-tsx.ts --file <path> [options]\n\nOptions:\n --types <path> Output types file path (default: <file>.types.ts)\n --utils <path> Output utils file path (default: <file>.utils.ts)\n --min-function-lines <num> Minimum lines before extracting functions (default: ${DEFAULT_MIN_FUNCTION_LINES})\n --min-variable-lines <num> Minimum lines before extracting variables (default: ${DEFAULT_MIN_VARIABLE_LINES})\n --util-pattern <regex> Regex for utility names (default: ${DEFAULT_UTIL_PATTERN})\n --help Show this help message\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveOutputPath = (inputFile: string, suffix: string): string => {
|
||||||
|
if (inputFile.endsWith('.tsx')) {
|
||||||
|
return inputFile.replace(/\.tsx$/, suffix);
|
||||||
|
}
|
||||||
|
if (inputFile.endsWith('.ts')) {
|
||||||
|
return inputFile.replace(/\.ts$/, suffix);
|
||||||
|
}
|
||||||
|
return `${inputFile}${suffix}`;
|
||||||
|
};
|
||||||
|
|
||||||
class TSXRefactorer {
|
class TSXRefactorer {
|
||||||
private project: Project;
|
private project: Project;
|
||||||
private sourceFile: SourceFile;
|
private sourceFile: SourceFile;
|
||||||
|
private options: RefactorOptions;
|
||||||
|
|
||||||
constructor(filePath: string) {
|
constructor(options: RefactorOptions) {
|
||||||
this.project = new Project({
|
this.project = new Project({
|
||||||
tsConfigFilePath: path.join(__dirname, '..', 'tsconfig.json'),
|
tsConfigFilePath: path.join(__dirname, '..', 'tsconfig.json'),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.sourceFile = this.project.addSourceFileAtPath(filePath);
|
this.sourceFile = this.project.addSourceFileAtPath(options.targetFile);
|
||||||
|
this.options = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,7 +135,7 @@ class TSXRefactorer {
|
|||||||
const functions = this.sourceFile.getFunctions();
|
const functions = this.sourceFile.getFunctions();
|
||||||
functions.forEach(func => {
|
functions.forEach(func => {
|
||||||
const lineCount = this.getNodeLineCount(func);
|
const lineCount = this.getNodeLineCount(func);
|
||||||
if (lineCount > 20) { // Lower threshold for demonstration
|
if (lineCount > this.options.minFunctionLines) {
|
||||||
candidates.push({
|
candidates.push({
|
||||||
name: func.getName() || 'anonymous',
|
name: func.getName() || 'anonymous',
|
||||||
node: func,
|
node: func,
|
||||||
@@ -62,7 +153,7 @@ class TSXRefactorer {
|
|||||||
const lineCount = this.getNodeLineCount(decl);
|
const lineCount = this.getNodeLineCount(decl);
|
||||||
const name = decl.getName();
|
const name = decl.getName();
|
||||||
|
|
||||||
if (lineCount > 10) {
|
if (lineCount > this.options.minVariableLines) {
|
||||||
candidates.push({
|
candidates.push({
|
||||||
name,
|
name,
|
||||||
node: decl,
|
node: decl,
|
||||||
@@ -113,7 +204,7 @@ class TSXRefactorer {
|
|||||||
|
|
||||||
console.log('\n🔄 Extracting types to separate file...');
|
console.log('\n🔄 Extracting types to separate file...');
|
||||||
|
|
||||||
const typesFilePath = this.sourceFile.getFilePath().replace('.tsx', '.types.ts');
|
const typesFilePath = this.options.typesFile;
|
||||||
const typesFile = this.project.createSourceFile(typesFilePath, '', { overwrite: true });
|
const typesFile = this.project.createSourceFile(typesFilePath, '', { overwrite: true });
|
||||||
|
|
||||||
// Build the content for the types file
|
// Build the content for the types file
|
||||||
@@ -145,13 +236,11 @@ class TSXRefactorer {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Add import to original file
|
// Add import to original file
|
||||||
const relativePath = './UserManagementDashboard.types';
|
|
||||||
const typeNames = typeCandidates.map(c => c.name);
|
const typeNames = typeCandidates.map(c => c.name);
|
||||||
|
if (typeNames.length > 0) {
|
||||||
this.sourceFile.addImportDeclaration({
|
const moduleSpecifier = toModuleSpecifier(this.options.targetFile, typesFilePath);
|
||||||
moduleSpecifier: relativePath,
|
this.addOrUpdateNamedImport(moduleSpecifier, typeNames);
|
||||||
namedImports: typeNames,
|
}
|
||||||
});
|
|
||||||
|
|
||||||
typesFile.saveSync();
|
typesFile.saveSync();
|
||||||
console.log(` 💾 Saved: ${path.basename(typesFilePath)}`);
|
console.log(` 💾 Saved: ${path.basename(typesFilePath)}`);
|
||||||
@@ -162,7 +251,7 @@ class TSXRefactorer {
|
|||||||
*/
|
*/
|
||||||
extractUtilities(candidates: ExtractionCandidate[]): void {
|
extractUtilities(candidates: ExtractionCandidate[]): void {
|
||||||
const utilCandidates = candidates.filter(c =>
|
const utilCandidates = candidates.filter(c =>
|
||||||
c.type === 'variable' && c.name.match(/^(validate|format|get|handle)/)
|
c.type === 'variable' && this.options.utilNamePattern.test(c.name)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (utilCandidates.length === 0) {
|
if (utilCandidates.length === 0) {
|
||||||
@@ -172,7 +261,7 @@ class TSXRefactorer {
|
|||||||
|
|
||||||
console.log('\n🔄 Extracting utility functions...');
|
console.log('\n🔄 Extracting utility functions...');
|
||||||
|
|
||||||
const utilsFilePath = this.sourceFile.getFilePath().replace('.tsx', '.utils.ts');
|
const utilsFilePath = this.options.utilsFile;
|
||||||
const utilsFile = this.project.createSourceFile(utilsFilePath, '', { overwrite: true });
|
const utilsFile = this.project.createSourceFile(utilsFilePath, '', { overwrite: true });
|
||||||
|
|
||||||
const extractedNames: string[] = [];
|
const extractedNames: string[] = [];
|
||||||
@@ -199,25 +288,28 @@ class TSXRefactorer {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Build the complete utils file content
|
// Build the complete utils file content
|
||||||
|
const typeNames = this.getExportedTypeNames();
|
||||||
|
const typeImport = typeNames.length > 0
|
||||||
|
? `import type { ${typeNames.join(', ')} } from '${toModuleSpecifier(utilsFilePath, this.options.typesFile)}';`
|
||||||
|
: null;
|
||||||
|
|
||||||
const utilsContent = [
|
const utilsContent = [
|
||||||
'/**',
|
'/**',
|
||||||
' * Extracted utility functions',
|
' * Extracted utility functions',
|
||||||
' * Auto-generated by ts-morph refactoring script',
|
' * Auto-generated by ts-morph refactoring script',
|
||||||
' */',
|
' */',
|
||||||
'',
|
'',
|
||||||
"import type { FormData, ValidationErrors, User } from './UserManagementDashboard.types';",
|
...(typeImport ? [typeImport, ''] : []),
|
||||||
'',
|
|
||||||
...utilStatements,
|
...utilStatements,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
utilsFile.replaceWithText(utilsContent);
|
utilsFile.replaceWithText(utilsContent);
|
||||||
|
|
||||||
// Add import to original file
|
// Add import to original file
|
||||||
const relativePath = './UserManagementDashboard.utils';
|
if (extractedNames.length > 0) {
|
||||||
this.sourceFile.addImportDeclaration({
|
const moduleSpecifier = toModuleSpecifier(this.options.targetFile, utilsFilePath);
|
||||||
moduleSpecifier: relativePath,
|
this.addOrUpdateNamedImport(moduleSpecifier, extractedNames);
|
||||||
namedImports: extractedNames,
|
}
|
||||||
});
|
|
||||||
|
|
||||||
utilsFile.saveSync();
|
utilsFile.saveSync();
|
||||||
console.log(` 💾 Saved: ${path.basename(utilsFilePath)}`);
|
console.log(` 💾 Saved: ${path.basename(utilsFilePath)}`);
|
||||||
@@ -267,21 +359,69 @@ class TSXRefactorer {
|
|||||||
const end = node.getEndLineNumber();
|
const end = node.getEndLineNumber();
|
||||||
return end - start + 1;
|
return end - start + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private addOrUpdateNamedImport(moduleSpecifier: string, names: string[]): void {
|
||||||
|
const existingImport = this.sourceFile.getImportDeclaration(moduleSpecifier);
|
||||||
|
if (existingImport) {
|
||||||
|
const existingNames = existingImport.getNamedImports().map(imp => imp.getName());
|
||||||
|
const mergedNames = Array.from(new Set([...existingNames, ...names]));
|
||||||
|
existingImport.remove();
|
||||||
|
this.sourceFile.addImportDeclaration({
|
||||||
|
moduleSpecifier,
|
||||||
|
namedImports: mergedNames,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sourceFile.addImportDeclaration({
|
||||||
|
moduleSpecifier,
|
||||||
|
namedImports: names,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getExportedTypeNames(): string[] {
|
||||||
|
const typesFile = this.project.getSourceFile(this.options.typesFile);
|
||||||
|
if (!typesFile) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const interfaces = typesFile.getInterfaces();
|
||||||
|
const typeAliases = typesFile.getTypeAliases();
|
||||||
|
|
||||||
|
return [...interfaces, ...typeAliases]
|
||||||
|
.filter(type => type.isExported())
|
||||||
|
.map(type => type.getName());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main execution
|
// Main execution
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('🚀 TSX Refactoring Tool using ts-morph\n');
|
console.log('🚀 TSX Refactoring Tool using ts-morph\n');
|
||||||
|
|
||||||
const targetFile = path.join(
|
const args = parseArgs(process.argv.slice(2));
|
||||||
__dirname,
|
if (args.help) {
|
||||||
'..',
|
printUsage();
|
||||||
'src',
|
return;
|
||||||
'components',
|
}
|
||||||
'UserManagementDashboard.tsx'
|
|
||||||
);
|
|
||||||
|
|
||||||
const refactorer = new TSXRefactorer(targetFile);
|
const targetFile = args.file
|
||||||
|
? path.resolve(args.file)
|
||||||
|
: path.join(__dirname, '..', 'src', 'components', 'UserManagementDashboard.tsx');
|
||||||
|
|
||||||
|
const options: RefactorOptions = {
|
||||||
|
targetFile,
|
||||||
|
typesFile: path.resolve(args.types ?? resolveOutputPath(targetFile, '.types.ts')),
|
||||||
|
utilsFile: path.resolve(args.utils ?? resolveOutputPath(targetFile, '.utils.ts')),
|
||||||
|
minFunctionLines: Number.isFinite(args.minFunctionLines)
|
||||||
|
? Number(args.minFunctionLines)
|
||||||
|
: DEFAULT_MIN_FUNCTION_LINES,
|
||||||
|
minVariableLines: Number.isFinite(args.minVariableLines)
|
||||||
|
? Number(args.minVariableLines)
|
||||||
|
: DEFAULT_MIN_VARIABLE_LINES,
|
||||||
|
utilNamePattern: new RegExp(args.utilPattern ?? DEFAULT_UTIL_PATTERN),
|
||||||
|
};
|
||||||
|
|
||||||
|
const refactorer = new TSXRefactorer(options);
|
||||||
|
|
||||||
// Step 1: Analyze
|
// Step 1: Analyze
|
||||||
const candidates = refactorer.analyzeFile();
|
const candidates = refactorer.analyzeFile();
|
||||||
|
|||||||
Reference in New Issue
Block a user