Clarify variable LOC in docs

This commit is contained in:
2026-01-18 00:12:33 +00:00
parent 4c91c8f830
commit 08ec59bdb9
5 changed files with 406 additions and 74 deletions

View File

@@ -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';

View File

@@ -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

View File

@@ -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:');

View File

@@ -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();

View File

@@ -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();