feat(rush-commands): add revert-useless-changes tool with functional programming design
Implement a comprehensive tool to automatically detect and revert files with only cosmetic changes (whitespace or comments) in Git repositories. Features: - Functional programming architecture with pure functions - Rule-based analysis system (whitespace and AST comment detection) - Commander.js CLI with comprehensive options - ESM module system throughout - Proper handling of new/added files - TypeScript with strict typing - Comprehensive documentation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2
common/autoinstallers/rush-commands/.gitignore
vendored
Normal file
2
common/autoinstallers/rush-commands/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
lib
|
||||
dist
|
||||
@ -1,20 +1,29 @@
|
||||
{
|
||||
"name": "rush-commands",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"private": true,
|
||||
"description": "Rush command tools and utilities",
|
||||
"keywords": [],
|
||||
"license": "Apache-2.0",
|
||||
"author": "",
|
||||
"main": "index.js",
|
||||
"scripts": {},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rimraf lib/",
|
||||
"revert-useless-changes": "node lib/revert-useless-changes/cli.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^11.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"chalk": "^4.1.2",
|
||||
"commander": "^12.0.0",
|
||||
"glob": "^10.3.10",
|
||||
"simple-git": "^3.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"rimraf": "^5.0.0",
|
||||
"sucrase": "^3.32.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
require('sucrase/register');
|
||||
|
||||
const { main } = require('./revert-useless-changes/cli.ts');
|
||||
|
||||
main();
|
||||
@ -0,0 +1,244 @@
|
||||
# Revert Useless Changes
|
||||
|
||||
A functional programming-based tool to automatically detect and revert files with only cosmetic changes (whitespace or comments) in Git repositories.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### 🏗️ Core Design Principles
|
||||
|
||||
- **Functional Programming**: Pure functions with minimal side effects
|
||||
- **Rule-Based Analysis**: Extensible rule system for different change types
|
||||
- **Immutable Data Flow**: Data flows through pure transformation functions
|
||||
- **Type Safety**: Full TypeScript coverage with strict typing
|
||||
|
||||
### 📦 Module Structure
|
||||
|
||||
```
|
||||
src/revert-useless-changes/
|
||||
├── types.ts # Core type definitions
|
||||
├── config.ts # Constants and configuration
|
||||
├── utils/ # Pure utility functions
|
||||
│ ├── git.ts # Git operations (getChangedFiles, revertFile, etc.)
|
||||
│ └── file.ts # File system operations (exists, readFile, etc.)
|
||||
├── rules/ # Analysis rules
|
||||
│ ├── index.ts # Rule registry and matching
|
||||
│ ├── whitespace-rule.ts # Detects whitespace-only changes
|
||||
│ └── ast-comment-rule.ts # Detects comment-only changes via AST
|
||||
├── reporter.ts # Output formatting (console, JSON)
|
||||
├── orchestrator.ts # Main workflow coordination
|
||||
├── cli.ts # Command-line interface (Commander.js)
|
||||
└── index.ts # Public API exports
|
||||
```
|
||||
|
||||
## 🔄 Core Workflow
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[CLI Input] --> B[Parse Arguments]
|
||||
B --> C[Get Changed Files from Git]
|
||||
C --> D[Filter Files by Include/Exclude]
|
||||
D --> E[Analyze Each File]
|
||||
E --> F[Apply Applicable Rules]
|
||||
F --> G[Generate Analysis Report]
|
||||
G --> H{Dry Run?}
|
||||
H -->|Yes| I[Display Results]
|
||||
H -->|No| J[Revert Matching Files]
|
||||
J --> I[Display Results]
|
||||
```
|
||||
|
||||
## 🧩 Key Modules
|
||||
|
||||
### 1. **Orchestrator** (`orchestrator.ts`)
|
||||
**Purpose**: Coordinates the entire analysis workflow
|
||||
|
||||
**Core Function**: `execute(config: Config) -> Promise<AnalysisReport>`
|
||||
|
||||
**Flow**:
|
||||
```typescript
|
||||
1. getChangedFiles() -> string[] // Get Git changed files
|
||||
2. filterFiles() -> string[] // Apply include/exclude patterns
|
||||
3. analyzeFiles() -> FileAnalysis[] // Analyze each file with rules
|
||||
4. generateReport() -> AnalysisReport // Create summary statistics
|
||||
5. performReverts() -> AnalysisReport // Revert files (if not dry run)
|
||||
```
|
||||
|
||||
### 2. **Rule System** (`rules/`)
|
||||
**Purpose**: Pluggable analysis rules for different change types
|
||||
|
||||
**Rule Interface**:
|
||||
```typescript
|
||||
interface Rule {
|
||||
name: string; // Unique identifier
|
||||
description: string; // Human-readable description
|
||||
filePatterns: readonly string[]; // Glob patterns for applicable files
|
||||
}
|
||||
|
||||
type RuleAnalyzer = (filePath: string, config: Config) => Promise<RuleResult>
|
||||
```
|
||||
|
||||
**Built-in Rules**:
|
||||
- **WhitespaceRule**: Uses `git diff -w` to detect whitespace-only changes
|
||||
- **AstCommentRule**: Parses JS/TS with AST, removes comments, compares structure
|
||||
|
||||
**Rule Registry**:
|
||||
```typescript
|
||||
getRulesForFile(filePath) -> RuleDefinition[] // Get applicable rules
|
||||
getAllRules() -> RuleDefinition[] // Get all available rules
|
||||
```
|
||||
|
||||
### 3. **Git Utilities** (`utils/git.ts`)
|
||||
**Purpose**: Pure functions for Git operations
|
||||
|
||||
**Key Functions**:
|
||||
```typescript
|
||||
getChangedFiles(options) -> string[] // List modified files
|
||||
hasOnlyWhitespaceChanges(file, options) -> boolean
|
||||
getFileContentAtRef(file, options) -> string // Get file at Git ref
|
||||
revertFile(file, options) -> void // Revert single file
|
||||
findGitRepositoryRoot(startDir) -> string | null // Find Git repo root
|
||||
validateGitRepository(cwd) -> void // Validate Git repo or throw
|
||||
```
|
||||
|
||||
### 4. **File Utilities** (`utils/file.ts`)
|
||||
**Purpose**: Pure functions for file system operations
|
||||
|
||||
**Key Functions**:
|
||||
```typescript
|
||||
exists(path) -> boolean // Check file existence
|
||||
readFile(path) -> string // Read file content
|
||||
matchesPattern(path, patterns) -> boolean // Pattern matching
|
||||
toAbsolutePath(path, base) -> string // Path resolution
|
||||
```
|
||||
|
||||
### 5. **Reporter** (`reporter.ts`)
|
||||
**Purpose**: Output formatting and user feedback
|
||||
|
||||
**Key Functions**:
|
||||
```typescript
|
||||
generateReport(report, config) -> void // Main report output
|
||||
logProgress(message, config) -> void // Progress updates
|
||||
logVerbose(message, config) -> void // Detailed logging
|
||||
logError/logWarning/logSuccess(...) // Status messages
|
||||
```
|
||||
|
||||
## 🔍 Analysis Process
|
||||
|
||||
### File Analysis Flow
|
||||
```typescript
|
||||
1. Check file existence
|
||||
2. Find applicable rules based on file patterns
|
||||
3. Execute each rule analyzer function
|
||||
4. Combine results: shouldRevert = any(rule.shouldRevert)
|
||||
5. Create FileAnalysis with results and matched rule
|
||||
```
|
||||
|
||||
### Rule Execution
|
||||
```typescript
|
||||
// Whitespace Rule
|
||||
hasOnlyWhitespaceChanges(file) -> RuleResult {
|
||||
shouldRevert: git diff -w shows no changes
|
||||
}
|
||||
|
||||
// AST Comment Rule
|
||||
analyzeAstCommentRule(file) -> RuleResult {
|
||||
1. Parse current and previous versions with TypeScript parser
|
||||
2. Remove comment nodes from both ASTs
|
||||
3. Deep compare cleaned ASTs
|
||||
4. shouldRevert: ASTs are structurally identical
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 Data Flow
|
||||
|
||||
### Configuration Flow
|
||||
```
|
||||
CLI Arguments -> Config Object -> All Functions
|
||||
```
|
||||
|
||||
### Analysis Flow
|
||||
```
|
||||
Git Files -> File Filters -> Rule Analysis -> Report Generation -> Output
|
||||
```
|
||||
|
||||
### Types Flow
|
||||
```typescript
|
||||
Config -> FileAnalysis[] -> AnalysisReport -> Console/JSON Output
|
||||
```
|
||||
|
||||
## 🎯 Extension Points
|
||||
|
||||
### Adding New Rules
|
||||
```typescript
|
||||
// 1. Define rule constant
|
||||
export const NEW_RULE: Rule = {
|
||||
name: 'new-rule',
|
||||
description: 'Detects new type of changes',
|
||||
filePatterns: ['**/*.ext']
|
||||
};
|
||||
|
||||
// 2. Implement analyzer function
|
||||
export const analyzeNewRule = async (filePath: string, config: Config): Promise<RuleResult> => {
|
||||
// Analysis logic here
|
||||
return { filePath, ruleName: NEW_RULE.name, shouldRevert: true/false };
|
||||
};
|
||||
|
||||
// 3. Register in rules/index.ts
|
||||
export const AVAILABLE_RULES = [
|
||||
{ rule: WHITESPACE_RULE, analyzer: analyzeWhitespaceRule },
|
||||
{ rule: AST_COMMENT_RULE, analyzer: analyzeAstCommentRule },
|
||||
{ rule: NEW_RULE, analyzer: analyzeNewRule }
|
||||
];
|
||||
```
|
||||
|
||||
### Adding New Output Formats
|
||||
```typescript
|
||||
// Extend reporter.ts
|
||||
export const generateXmlReport = (report: AnalysisReport) => {
|
||||
// XML formatting logic
|
||||
};
|
||||
```
|
||||
|
||||
## 🧪 Testing Strategy
|
||||
|
||||
### Unit Testing
|
||||
- Test each pure function in isolation
|
||||
- Mock Git operations for deterministic tests
|
||||
- Test rule analyzers with sample files
|
||||
|
||||
### Integration Testing
|
||||
- Test full workflow with temporary Git repos
|
||||
- Test CLI argument parsing
|
||||
- Test error handling scenarios
|
||||
|
||||
## 🚀 Usage
|
||||
|
||||
### Programmatic API
|
||||
```typescript
|
||||
import { analyzeAndRevert } from './index';
|
||||
|
||||
const report = await analyzeAndRevert({
|
||||
cwd: '/path/to/project',
|
||||
dryRun: true,
|
||||
verbose: false
|
||||
});
|
||||
```
|
||||
|
||||
### CLI Usage
|
||||
```bash
|
||||
# Analyze and show what would be reverted
|
||||
revert-useless-changes --dry-run --verbose
|
||||
|
||||
# Actually revert files with only whitespace/comment changes
|
||||
revert-useless-changes
|
||||
|
||||
# Analyze specific file types only
|
||||
revert-useless-changes --include "**/*.ts" --include "**/*.js"
|
||||
```
|
||||
|
||||
## 💡 Key Benefits
|
||||
|
||||
1. **Safe Operations**: Dry-run mode prevents accidental changes
|
||||
2. **Intelligent Analysis**: AST-based comment detection, Git-based whitespace detection
|
||||
3. **Extensible**: Easy to add new rule types
|
||||
4. **Fast**: Functional approach enables efficient processing
|
||||
5. **Reliable**: Pure functions are predictable and testable
|
||||
@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { Config } from './types';
|
||||
import { resolve } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { validateGitRepository } from './utils/git';
|
||||
import { execute } from './orchestrator';
|
||||
|
||||
/**
|
||||
* Command line interface for the revert-useless-changes tool
|
||||
*/
|
||||
export async function main(): Promise<void> {
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('revert-useless-changes')
|
||||
.description(
|
||||
'Analyze and revert files with only whitespace or comment changes',
|
||||
)
|
||||
.version('1.0.0')
|
||||
.option('--cwd <path>', 'Working directory to analyze', process.cwd())
|
||||
.option(
|
||||
'-d, --dry-run',
|
||||
'Show what would be reverted without actually reverting',
|
||||
false,
|
||||
)
|
||||
.option('-v, --verbose', 'Show verbose output during analysis', false)
|
||||
.option('-j, --json', 'Output results in JSON format', false)
|
||||
.option(
|
||||
'--include <patterns...>',
|
||||
'File patterns to include (glob patterns)',
|
||||
[],
|
||||
)
|
||||
.option(
|
||||
'--exclude <patterns...>',
|
||||
'File patterns to exclude (glob patterns)',
|
||||
['**/node_modules/**', '**/tmp/**', '**/.git/**'],
|
||||
)
|
||||
.addHelpText(
|
||||
'after',
|
||||
`
|
||||
Examples:
|
||||
$ revert-useless-changes Analyze current directory and revert files
|
||||
$ revert-useless-changes --dry-run Show what would be reverted without reverting
|
||||
$ revert-useless-changes --cwd /path/to/project Analyze a specific directory
|
||||
$ revert-useless-changes --verbose Show detailed analysis information
|
||||
$ revert-useless-changes --json Output results in JSON format
|
||||
$ revert-useless-changes --include "**/*.ts" --include "**/*.js" Only analyze TS/JS files
|
||||
$ revert-useless-changes --exclude "**/test/**" Exclude test directories from analysis
|
||||
`,
|
||||
);
|
||||
|
||||
program.parse();
|
||||
const options = program.opts();
|
||||
|
||||
// Create configuration from command line arguments
|
||||
const config: Config = {
|
||||
cwd: resolve(options.cwd),
|
||||
dryRun: options.dryRun,
|
||||
verbose: options.verbose,
|
||||
json: options.json,
|
||||
include:
|
||||
options.include && options.include.length > 0
|
||||
? options.include
|
||||
: undefined,
|
||||
exclude:
|
||||
options.exclude && options.exclude.length > 0
|
||||
? options.exclude
|
||||
: undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
// Validate working directory exists
|
||||
if (!existsSync(config.cwd)) {
|
||||
console.error(`Error: Directory does not exist: ${config.cwd}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check if we're in a git repository
|
||||
validateGitRepository(config.cwd);
|
||||
|
||||
// Run the analysis workflow
|
||||
const report = await execute(config);
|
||||
|
||||
// Set exit code based on results
|
||||
if (report.revertErrors.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Fatal error:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
if (config.verbose && error instanceof Error && error.stack) {
|
||||
console.error('Stack trace:', error.stack);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle unhandled promise rejections
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Handle uncaught exceptions
|
||||
process.on('uncaughtException', error => {
|
||||
console.error('Uncaught Exception:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Run the CLI
|
||||
if (require.main === module) {
|
||||
main().catch(error => {
|
||||
console.error('CLI execution failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
import { Config } from './types';
|
||||
|
||||
/**
|
||||
* Default configuration values
|
||||
*/
|
||||
export const DEFAULT_CONFIG: Omit<Config, 'cwd'> = {
|
||||
dryRun: false,
|
||||
verbose: false,
|
||||
json: false,
|
||||
exclude: [
|
||||
'node_modules/**',
|
||||
'.git/**',
|
||||
'tmp/**',
|
||||
'**/*.log',
|
||||
'**/.DS_Store',
|
||||
'**/dist/**',
|
||||
'**/build/**',
|
||||
'**/lib/**',
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* File patterns for different rule types
|
||||
*/
|
||||
export const FILE_PATTERNS = {
|
||||
TYPESCRIPT_JAVASCRIPT: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
|
||||
ALL_FILES: ['**/*'],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Constants for the tool
|
||||
*/
|
||||
export const CONSTANTS = {
|
||||
TOOL_NAME: 'revert-useless-changes',
|
||||
VERSION: '1.0.0',
|
||||
DEFAULT_GIT_REF: 'HEAD',
|
||||
} as const;
|
||||
@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Main entry point for revert-useless-changes tool
|
||||
*/
|
||||
|
||||
// Export main types
|
||||
export type {
|
||||
Config,
|
||||
Rule,
|
||||
RuleResult,
|
||||
FileAnalysis,
|
||||
AnalysisReport,
|
||||
RevertError,
|
||||
ChangeType
|
||||
} from './types';
|
||||
|
||||
// Export main orchestrator functions
|
||||
export { execute } from './orchestrator';
|
||||
|
||||
// Export CLI
|
||||
export { main as runCLI } from './cli';
|
||||
|
||||
// Export reporter functions
|
||||
export * as reporter from './reporter';
|
||||
|
||||
// Export rules and rule registry
|
||||
export * from './rules';
|
||||
|
||||
// Export utilities
|
||||
export * as gitUtils from './utils/git';
|
||||
export * as fileUtils from './utils/file';
|
||||
|
||||
// Export configuration constants
|
||||
export { FILE_PATTERNS } from './config';
|
||||
|
||||
/**
|
||||
* Programmatic API for analyzing and reverting files
|
||||
*/
|
||||
export async function analyzeAndRevert(config: import('./types').Config): Promise<import('./types').AnalysisReport> {
|
||||
const { execute } = await import('./orchestrator');
|
||||
return execute(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default export for CLI usage
|
||||
*/
|
||||
export default { analyzeAndRevert };
|
||||
@ -0,0 +1,297 @@
|
||||
import { Config, AnalysisReport, FileAnalysis, RevertError } from './types';
|
||||
import { getRulesForFile } from './rules';
|
||||
import { getChangedFiles, revertFile } from './utils/git';
|
||||
import { toAbsolutePath, toRelativePath, exists, matchesPattern } from './utils/file';
|
||||
import * as reporter from './reporter';
|
||||
|
||||
/**
|
||||
* Execute the full analysis and revert workflow
|
||||
*/
|
||||
export const execute = async (config: Config): Promise<AnalysisReport> => {
|
||||
reporter.logProgress('Starting analysis...', config);
|
||||
|
||||
// Get list of changed files from Git
|
||||
const changedFiles = getChangedFilesFiltered(config);
|
||||
reporter.logProgress(`Found ${changedFiles.length} changed files`, config);
|
||||
|
||||
if (changedFiles.length === 0) {
|
||||
reporter.logWarning('No changed files found. Nothing to analyze.', config);
|
||||
return createEmptyReport(config);
|
||||
}
|
||||
|
||||
// Analyze each file
|
||||
const fileAnalyses = await analyzeFiles(changedFiles, config);
|
||||
|
||||
// Generate report
|
||||
const report = generateReport(fileAnalyses, config);
|
||||
|
||||
// Perform reverts if not dry run
|
||||
const updatedReport = !config.dryRun
|
||||
? await performReverts(report, config)
|
||||
: report;
|
||||
|
||||
// Display results
|
||||
reporter.generateReport(updatedReport, config);
|
||||
|
||||
return updatedReport;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get list of changed files from Git with filtering applied
|
||||
*/
|
||||
const getChangedFilesFiltered = (config: Config): string[] => {
|
||||
try {
|
||||
const files = getChangedFiles({ cwd: config.cwd });
|
||||
|
||||
// Filter out files based on patterns if specified
|
||||
let filteredFiles = files;
|
||||
|
||||
if (config.include && config.include.length > 0) {
|
||||
filteredFiles = filteredFiles.filter(file =>
|
||||
matchesPattern(file, config.include!)
|
||||
);
|
||||
}
|
||||
|
||||
if (config.exclude && config.exclude.length > 0) {
|
||||
filteredFiles = filteredFiles.filter(file =>
|
||||
!matchesPattern(file, config.exclude!)
|
||||
);
|
||||
}
|
||||
|
||||
return filteredFiles;
|
||||
} catch (error) {
|
||||
reporter.logError(`Failed to get changed files: ${error instanceof Error ? error.message : String(error)}`, config);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Analyze all files using applicable rules
|
||||
*/
|
||||
const analyzeFiles = async (filePaths: string[], config: Config): Promise<FileAnalysis[]> => {
|
||||
const analyses: FileAnalysis[] = [];
|
||||
|
||||
for (let i = 0; i < filePaths.length; i++) {
|
||||
const filePath = filePaths[i];
|
||||
reporter.logProgress(`Analyzing file ${i + 1}/${filePaths.length}: ${toRelativePath(filePath, config.cwd)}`, config);
|
||||
|
||||
try {
|
||||
const analysis = await analyzeFile(filePath, config);
|
||||
analyses.push(analysis);
|
||||
} catch (error) {
|
||||
reporter.logError(`Failed to analyze ${filePath}: ${error instanceof Error ? error.message : String(error)}`, config);
|
||||
|
||||
// Create error analysis
|
||||
analyses.push(createErrorAnalysis(filePath, error));
|
||||
}
|
||||
}
|
||||
|
||||
return analyses;
|
||||
};
|
||||
|
||||
/**
|
||||
* Analyze a single file using applicable rules
|
||||
*/
|
||||
const analyzeFile = async (filePath: string, config: Config): Promise<FileAnalysis> => {
|
||||
const absolutePath = toAbsolutePath(filePath, config.cwd);
|
||||
const fileExists = exists(absolutePath);
|
||||
|
||||
reporter.logVerbose(`Checking if file exists: ${absolutePath} = ${fileExists}`, config);
|
||||
|
||||
if (!fileExists) {
|
||||
return createDeletedFileAnalysis(absolutePath);
|
||||
}
|
||||
|
||||
// Get rules that apply to this file
|
||||
const applicableRules = getRulesForFile(absolutePath);
|
||||
reporter.logVerbose(`Found ${applicableRules.length} applicable rules for ${absolutePath}`, config);
|
||||
|
||||
if (applicableRules.length === 0) {
|
||||
return createNoRulesAnalysis(absolutePath);
|
||||
}
|
||||
|
||||
// Apply each rule
|
||||
const ruleResults = [];
|
||||
let shouldRevert = false;
|
||||
let matchedRule: string | undefined = undefined;
|
||||
|
||||
for (const { rule, analyzer } of applicableRules) {
|
||||
reporter.logVerbose(`Applying rule: ${rule.name}`, config);
|
||||
|
||||
try {
|
||||
const result = await analyzer(absolutePath, config);
|
||||
ruleResults.push(result);
|
||||
|
||||
// If any rule says we should revert, we should revert
|
||||
if (result.shouldRevert && !shouldRevert) {
|
||||
shouldRevert = true;
|
||||
matchedRule = rule.name;
|
||||
}
|
||||
} catch (error) {
|
||||
reporter.logVerbose(`Rule ${rule.name} failed: ${error}`, config);
|
||||
ruleResults.push(createRuleErrorResult(absolutePath, rule.name, error));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
filePath: absolutePath,
|
||||
exists: true,
|
||||
shouldRevert,
|
||||
matchedRule,
|
||||
ruleResults
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create analysis for a deleted file
|
||||
*/
|
||||
const createDeletedFileAnalysis = (filePath: string): FileAnalysis => ({
|
||||
filePath,
|
||||
exists: false,
|
||||
shouldRevert: false,
|
||||
matchedRule: undefined,
|
||||
ruleResults: [{
|
||||
filePath,
|
||||
ruleName: 'file-deleted',
|
||||
shouldRevert: false,
|
||||
reason: 'File was deleted'
|
||||
}]
|
||||
});
|
||||
|
||||
/**
|
||||
* Create analysis for a file with no applicable rules
|
||||
*/
|
||||
const createNoRulesAnalysis = (filePath: string): FileAnalysis => ({
|
||||
filePath,
|
||||
exists: true,
|
||||
shouldRevert: false,
|
||||
matchedRule: undefined,
|
||||
ruleResults: [{
|
||||
filePath,
|
||||
ruleName: 'no-rules',
|
||||
shouldRevert: false,
|
||||
reason: 'No applicable rules found for this file type'
|
||||
}]
|
||||
});
|
||||
|
||||
/**
|
||||
* Create error analysis for a file that failed to analyze
|
||||
*/
|
||||
const createErrorAnalysis = (filePath: string, error: unknown): FileAnalysis => ({
|
||||
filePath,
|
||||
exists: exists(filePath),
|
||||
shouldRevert: false,
|
||||
matchedRule: undefined,
|
||||
ruleResults: [{
|
||||
filePath,
|
||||
ruleName: 'analysis-error',
|
||||
shouldRevert: false,
|
||||
reason: 'Analysis failed',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}]
|
||||
});
|
||||
|
||||
/**
|
||||
* Create error result for a rule that failed
|
||||
*/
|
||||
const createRuleErrorResult = (filePath: string, ruleName: string, error: unknown) => ({
|
||||
filePath,
|
||||
ruleName,
|
||||
shouldRevert: false,
|
||||
reason: 'Rule execution failed',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
/**
|
||||
* Generate analysis report from file analyses
|
||||
*/
|
||||
const generateReport = (fileAnalyses: FileAnalysis[], config: Config): AnalysisReport => {
|
||||
const summary = {
|
||||
totalFiles: fileAnalyses.length,
|
||||
revertableFiles: fileAnalyses.filter(f => f.shouldRevert).length,
|
||||
whitespaceOnlyFiles: fileAnalyses.filter(f => f.matchedRule === 'whitespace-only').length,
|
||||
commentOnlyFiles: fileAnalyses.filter(f => f.matchedRule === 'ast-comment-only').length,
|
||||
unchangedFiles: fileAnalyses.filter(f => !f.shouldRevert && f.exists).length,
|
||||
deletedFiles: fileAnalyses.filter(f => !f.exists).length,
|
||||
errorFiles: fileAnalyses.filter(f => f.ruleResults.some(r => r.error)).length
|
||||
};
|
||||
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
config,
|
||||
summary,
|
||||
fileAnalyses,
|
||||
revertedFiles: [],
|
||||
revertErrors: []
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Perform file reverts based on analysis
|
||||
*/
|
||||
const performReverts = async (report: AnalysisReport, config: Config): Promise<AnalysisReport> => {
|
||||
const filesToRevert = report.fileAnalyses
|
||||
.filter(analysis => analysis.shouldRevert)
|
||||
.map(analysis => analysis.filePath);
|
||||
|
||||
if (filesToRevert.length === 0) {
|
||||
reporter.logProgress('No files to revert.', config);
|
||||
return report;
|
||||
}
|
||||
|
||||
reporter.logProgress(`Reverting ${filesToRevert.length} files...`, config);
|
||||
|
||||
const revertedFiles: string[] = [];
|
||||
const revertErrors: RevertError[] = [];
|
||||
|
||||
for (const filePath of filesToRevert) {
|
||||
try {
|
||||
reporter.logVerbose(`Reverting: ${toRelativePath(filePath, config.cwd)}`, config);
|
||||
|
||||
revertFile(filePath, { cwd: config.cwd });
|
||||
revertedFiles.push(filePath);
|
||||
|
||||
reporter.logVerbose(`Successfully reverted: ${filePath}`, config);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
revertErrors.push({ file: filePath, error: errorMessage });
|
||||
|
||||
reporter.logError(`Failed to revert ${filePath}: ${errorMessage}`, config);
|
||||
}
|
||||
}
|
||||
|
||||
if (revertedFiles.length > 0) {
|
||||
reporter.logSuccess(`Successfully reverted ${revertedFiles.length} files`, config);
|
||||
}
|
||||
|
||||
if (revertErrors.length > 0) {
|
||||
reporter.logWarning(`Failed to revert ${revertErrors.length} files`, config);
|
||||
}
|
||||
|
||||
// Return updated report
|
||||
return {
|
||||
...report,
|
||||
revertedFiles,
|
||||
revertErrors
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an empty report for when no files are found
|
||||
*/
|
||||
const createEmptyReport = (config: Config): AnalysisReport => ({
|
||||
timestamp: new Date().toISOString(),
|
||||
config,
|
||||
summary: {
|
||||
totalFiles: 0,
|
||||
revertableFiles: 0,
|
||||
whitespaceOnlyFiles: 0,
|
||||
commentOnlyFiles: 0,
|
||||
unchangedFiles: 0,
|
||||
deletedFiles: 0,
|
||||
errorFiles: 0
|
||||
},
|
||||
fileAnalyses: [],
|
||||
revertedFiles: [],
|
||||
revertErrors: []
|
||||
});
|
||||
@ -0,0 +1,216 @@
|
||||
import { AnalysisReport, Config } from './types';
|
||||
import { toRelativePath } from './utils/file';
|
||||
import chalk from 'chalk';
|
||||
|
||||
/**
|
||||
* Generate and display the analysis report
|
||||
*/
|
||||
export const generateReport = (report: AnalysisReport, config: Config): void => {
|
||||
if (config.json) {
|
||||
outputJson(report);
|
||||
} else {
|
||||
outputConsole(report, config);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Log progress during analysis
|
||||
*/
|
||||
export const logProgress = (message: string, config: Config): void => {
|
||||
if (!config.json) {
|
||||
console.log(chalk.blue('🔍'), message);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Log verbose messages
|
||||
*/
|
||||
export const logVerbose = (message: string, config: Config): void => {
|
||||
if (config.verbose && !config.json) {
|
||||
console.log(chalk.gray(`[VERBOSE] ${message}`));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Log error messages
|
||||
*/
|
||||
export const logError = (message: string, config: Config): void => {
|
||||
if (!config.json) {
|
||||
console.error(chalk.red('❌'), message);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Log warning messages
|
||||
*/
|
||||
export const logWarning = (message: string, config: Config): void => {
|
||||
if (!config.json) {
|
||||
console.warn(chalk.yellow('⚠️'), message);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Log success messages
|
||||
*/
|
||||
export const logSuccess = (message: string, config: Config): void => {
|
||||
if (!config.json) {
|
||||
console.log(chalk.green('✅'), message);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Output results as JSON
|
||||
*/
|
||||
const outputJson = (report: AnalysisReport): void => {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
};
|
||||
|
||||
/**
|
||||
* Output results to console with formatting
|
||||
*/
|
||||
const outputConsole = (report: AnalysisReport, config: Config): void => {
|
||||
console.log();
|
||||
console.log(chalk.bold('📊 ANALYSIS REPORT'));
|
||||
console.log('='.repeat(60));
|
||||
|
||||
// Summary
|
||||
outputSummary(report);
|
||||
|
||||
// File categorization
|
||||
outputFileCategorization(report, config);
|
||||
|
||||
// Revert results (if not dry run)
|
||||
if (!config.dryRun) {
|
||||
outputRevertResults(report, config);
|
||||
}
|
||||
|
||||
// Footer
|
||||
outputFooter(report, config);
|
||||
};
|
||||
|
||||
/**
|
||||
* Output summary statistics
|
||||
*/
|
||||
const outputSummary = (report: AnalysisReport): void => {
|
||||
console.log(chalk.bold('\n📈 Summary:'));
|
||||
console.log(`${chalk.blue('📁 Total files analyzed:')} ${report.summary.totalFiles}`);
|
||||
console.log(`${chalk.green('🔄 Revertable files:')} ${report.summary.revertableFiles}`);
|
||||
console.log(` ${chalk.cyan('├─ Whitespace-only:')} ${report.summary.whitespaceOnlyFiles}`);
|
||||
console.log(` ${chalk.cyan('└─ Comment-only:')} ${report.summary.commentOnlyFiles}`);
|
||||
console.log(`${chalk.yellow('📝 Files with changes:')} ${report.summary.unchangedFiles}`);
|
||||
console.log(`${chalk.red('🗑️ Deleted files:')} ${report.summary.deletedFiles}`);
|
||||
console.log(`${chalk.red('❌ Error files:')} ${report.summary.errorFiles}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Output file categorization
|
||||
*/
|
||||
const outputFileCategorization = (report: AnalysisReport, config: Config): void => {
|
||||
const revertableFiles = report.fileAnalyses.filter(f => f.shouldRevert);
|
||||
const codeChangeFiles = report.fileAnalyses.filter(f => !f.shouldRevert && f.exists);
|
||||
|
||||
if (revertableFiles.length > 0) {
|
||||
console.log(chalk.bold('\n🔄 REVERTABLE FILES:'));
|
||||
console.log('-'.repeat(60));
|
||||
revertableFiles.slice(0, 15).forEach((analysis, index) => {
|
||||
const relativePath = toRelativePath(analysis.filePath, config.cwd);
|
||||
const ruleType = analysis.matchedRule === 'whitespace-only' ? '🔤 Whitespace' : '💬 Comments';
|
||||
console.log(`${(index + 1).toString().padStart(3)}. ${ruleType} - ${relativePath}`);
|
||||
});
|
||||
|
||||
if (revertableFiles.length > 15) {
|
||||
console.log(`... and ${revertableFiles.length - 15} more files`);
|
||||
}
|
||||
}
|
||||
|
||||
if (codeChangeFiles.length > 0) {
|
||||
console.log(chalk.bold('\n📝 FILES WITH CODE CHANGES (keeping):'));
|
||||
console.log('-'.repeat(60));
|
||||
codeChangeFiles.slice(0, 10).forEach((analysis, index) => {
|
||||
const relativePath = toRelativePath(analysis.filePath, config.cwd);
|
||||
console.log(`${(index + 1).toString().padStart(3)}. ${relativePath}`);
|
||||
});
|
||||
|
||||
if (codeChangeFiles.length > 10) {
|
||||
console.log(`... and ${codeChangeFiles.length - 10} more files`);
|
||||
}
|
||||
}
|
||||
|
||||
const deletedFiles = report.fileAnalyses.filter(f => !f.exists);
|
||||
if (deletedFiles.length > 0) {
|
||||
console.log(chalk.bold('\n🗑️ DELETED FILES:'));
|
||||
console.log('-'.repeat(60));
|
||||
deletedFiles.forEach((analysis, index) => {
|
||||
const relativePath = toRelativePath(analysis.filePath, config.cwd);
|
||||
console.log(`${(index + 1).toString().padStart(3)}. ${relativePath}`);
|
||||
});
|
||||
}
|
||||
|
||||
const errorFiles = report.fileAnalyses.filter(f =>
|
||||
f.ruleResults.some(r => r.error)
|
||||
);
|
||||
if (errorFiles.length > 0) {
|
||||
console.log(chalk.bold('\n❌ ERROR FILES:'));
|
||||
console.log('-'.repeat(60));
|
||||
errorFiles.forEach((analysis, index) => {
|
||||
const relativePath = toRelativePath(analysis.filePath, config.cwd);
|
||||
const errors = analysis.ruleResults.filter(r => r.error).map(r => r.error).join('; ');
|
||||
console.log(`${(index + 1).toString().padStart(3)}. ${relativePath}`);
|
||||
console.log(` Error: ${errors}`);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Output revert operation results
|
||||
*/
|
||||
const outputRevertResults = (report: AnalysisReport, config: Config): void => {
|
||||
if (report.revertedFiles.length > 0 || report.revertErrors.length > 0) {
|
||||
console.log(chalk.bold('\n🔄 REVERT RESULTS:'));
|
||||
console.log('='.repeat(60));
|
||||
|
||||
if (report.revertedFiles.length > 0) {
|
||||
console.log(`${chalk.green('✅ Successfully reverted:')} ${report.revertedFiles.length} files`);
|
||||
if (config.verbose) {
|
||||
report.revertedFiles.forEach(file => {
|
||||
const relativePath = toRelativePath(file, config.cwd);
|
||||
console.log(` - ${relativePath}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (report.revertErrors.length > 0) {
|
||||
console.log(`${chalk.red('❌ Failed to revert:')} ${report.revertErrors.length} files`);
|
||||
report.revertErrors.forEach(({ file, error }) => {
|
||||
const relativePath = toRelativePath(file, config.cwd);
|
||||
console.log(` - ${relativePath}: ${error}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Output footer with recommendations
|
||||
*/
|
||||
const outputFooter = (report: AnalysisReport, config: Config): void => {
|
||||
console.log(chalk.bold('\n🎯 RECOMMENDATIONS:'));
|
||||
console.log('='.repeat(60));
|
||||
|
||||
if (config.dryRun && report.summary.revertableFiles > 0) {
|
||||
console.log(chalk.green(`✅ Found ${report.summary.revertableFiles} files that can be safely reverted`));
|
||||
console.log(chalk.cyan('💡 Run without --dry-run to actually revert these files'));
|
||||
} else if (!config.dryRun && report.revertedFiles.length > 0) {
|
||||
console.log(chalk.green(`✅ Successfully reverted ${report.revertedFiles.length} files`));
|
||||
console.log(chalk.cyan("💡 Run 'git status' to see remaining changes"));
|
||||
}
|
||||
|
||||
if (report.summary.unchangedFiles > 0) {
|
||||
console.log(chalk.yellow(`⚠️ ${report.summary.unchangedFiles} files have actual code/content changes and were kept`));
|
||||
}
|
||||
|
||||
if (report.summary.errorFiles > 0) {
|
||||
console.log(chalk.red(`❌ ${report.summary.errorFiles} files had analysis errors`));
|
||||
}
|
||||
|
||||
console.log();
|
||||
};
|
||||
@ -0,0 +1,185 @@
|
||||
import { RuleResult, Config, Rule } from '../types';
|
||||
import { FILE_PATTERNS } from '../config';
|
||||
import { readFile, isJavaScriptOrTypeScript, exists } from '../utils/file';
|
||||
import { getFileContentAtRef, isNewFile } from '../utils/git';
|
||||
import { parse } from '@typescript-eslint/parser';
|
||||
|
||||
/**
|
||||
* Rule definition for detecting comment-only changes using AST
|
||||
*/
|
||||
export const AST_COMMENT_RULE: Rule = {
|
||||
name: 'ast-comment-only',
|
||||
description: 'Detects JS/TS files that have only comment changes using AST comparison',
|
||||
filePatterns: FILE_PATTERNS.TYPESCRIPT_JAVASCRIPT
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Create a result object for the AST comment rule
|
||||
*/
|
||||
const createResult = (
|
||||
filePath: string,
|
||||
shouldRevert: boolean,
|
||||
reason?: string,
|
||||
error?: string
|
||||
): RuleResult => ({
|
||||
filePath,
|
||||
ruleName: AST_COMMENT_RULE.name,
|
||||
shouldRevert,
|
||||
reason,
|
||||
error
|
||||
});
|
||||
|
||||
/**
|
||||
* Log verbose messages if enabled
|
||||
*/
|
||||
const log = (message: string, config: Config): void => {
|
||||
if (config.verbose) {
|
||||
console.log(`[${AST_COMMENT_RULE.name}] ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse file content and return AST with comments removed
|
||||
*/
|
||||
const parseWithoutComments = (filePath: string, content: string): any => {
|
||||
const isTypeScript = filePath.match(/\.(ts|tsx)$/);
|
||||
const isJSX = filePath.match(/\.(tsx|jsx)$/);
|
||||
|
||||
const ast = parse(content, {
|
||||
loc: true,
|
||||
range: true,
|
||||
comment: true,
|
||||
ecmaVersion: 'latest' as any,
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: !!isJSX,
|
||||
},
|
||||
...(isTypeScript && {
|
||||
filePath,
|
||||
}),
|
||||
});
|
||||
|
||||
return removeComments(ast);
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively remove comment-related properties from AST nodes
|
||||
*/
|
||||
const removeComments = (node: any): any => {
|
||||
if (!node || typeof node !== 'object') {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(item => removeComments(item));
|
||||
}
|
||||
|
||||
const cleaned: any = {};
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
// Skip comment-related properties
|
||||
if (key === 'comments' || key === 'leadingComments' || key === 'trailingComments') {
|
||||
continue;
|
||||
}
|
||||
// Skip location information that might differ due to comments
|
||||
if (key === 'range' || key === 'loc' || key === 'start' || key === 'end') {
|
||||
continue;
|
||||
}
|
||||
|
||||
cleaned[key] = removeComments(value);
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deep equality comparison of two objects
|
||||
*/
|
||||
const deepEqual = (obj1: any, obj2: any): boolean => {
|
||||
if (obj1 === obj2) return true;
|
||||
|
||||
if (obj1 == null || obj2 == null) return obj1 === obj2;
|
||||
|
||||
if (typeof obj1 !== typeof obj2) return false;
|
||||
|
||||
if (typeof obj1 !== 'object') return obj1 === obj2;
|
||||
|
||||
if (Array.isArray(obj1) !== Array.isArray(obj2)) return false;
|
||||
|
||||
if (Array.isArray(obj1)) {
|
||||
if (obj1.length !== obj2.length) return false;
|
||||
for (let i = 0; i < obj1.length; i++) {
|
||||
if (!deepEqual(obj1[i], obj2[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const keys1 = Object.keys(obj1);
|
||||
const keys2 = Object.keys(obj2);
|
||||
|
||||
if (keys1.length !== keys2.length) return false;
|
||||
|
||||
for (const key of keys1) {
|
||||
if (!keys2.includes(key)) return false;
|
||||
if (!deepEqual(obj1[key], obj2[key])) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Analyze a file for comment-only changes using AST comparison
|
||||
*/
|
||||
export const analyzeAstCommentRule = async (filePath: string, config: Config): Promise<RuleResult> => {
|
||||
log(`Analyzing file for comment-only changes: ${filePath}`, config);
|
||||
|
||||
try {
|
||||
// Check if file exists
|
||||
if (!exists(filePath)) {
|
||||
return createResult(filePath, false, 'File does not exist');
|
||||
}
|
||||
|
||||
// Check if it's a JS/TS file
|
||||
if (!isJavaScriptOrTypeScript(filePath)) {
|
||||
return createResult(filePath, false, 'Not a JavaScript/TypeScript file');
|
||||
}
|
||||
|
||||
// Skip new files - they don't have a previous version to compare
|
||||
if (isNewFile(filePath, { cwd: config.cwd })) {
|
||||
return createResult(filePath, false, 'File is newly added, skipping analysis');
|
||||
}
|
||||
|
||||
// Get current file content
|
||||
const currentContent = readFile(filePath);
|
||||
|
||||
// Get previous file content from git
|
||||
const previousContent = getFileContentAtRef(filePath, { cwd: config.cwd });
|
||||
|
||||
// Parse both versions and compare ASTs without comments
|
||||
const currentAst = parseWithoutComments(filePath, currentContent);
|
||||
const previousAst = parseWithoutComments(filePath, previousContent);
|
||||
|
||||
// Compare ASTs
|
||||
const astEqual = deepEqual(currentAst, previousAst);
|
||||
|
||||
if (astEqual) {
|
||||
return createResult(
|
||||
filePath,
|
||||
true,
|
||||
'File has only comment changes based on AST comparison'
|
||||
);
|
||||
} else {
|
||||
return createResult(
|
||||
filePath,
|
||||
false,
|
||||
'File has code changes beyond comments'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
return createResult(
|
||||
filePath,
|
||||
false,
|
||||
'Failed to perform AST comparison',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,62 @@
|
||||
import { Rule, RuleAnalyzer } from '../types';
|
||||
import { WHITESPACE_RULE, analyzeWhitespaceRule } from './whitespace-rule';
|
||||
import { AST_COMMENT_RULE, analyzeAstCommentRule } from './ast-comment-rule';
|
||||
|
||||
/**
|
||||
* Registry of all available rules with their analyzers
|
||||
*/
|
||||
export interface RuleDefinition {
|
||||
rule: Rule;
|
||||
analyzer: RuleAnalyzer;
|
||||
}
|
||||
|
||||
/**
|
||||
* All available rules with their analysis functions
|
||||
*/
|
||||
export const AVAILABLE_RULES: readonly RuleDefinition[] = [
|
||||
{ rule: WHITESPACE_RULE, analyzer: analyzeWhitespaceRule },
|
||||
{ rule: AST_COMMENT_RULE, analyzer: analyzeAstCommentRule }
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Get all available rules
|
||||
*/
|
||||
export const getAllRules = (): readonly RuleDefinition[] => {
|
||||
return AVAILABLE_RULES;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get rules that apply to a specific file
|
||||
*/
|
||||
export const getRulesForFile = (filePath: string): readonly RuleDefinition[] => {
|
||||
return AVAILABLE_RULES.filter(({ rule }) =>
|
||||
rule.filePatterns.some(pattern => matchesPattern(filePath, pattern))
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a rule by name
|
||||
*/
|
||||
export const getRuleByName = (name: string): RuleDefinition | undefined => {
|
||||
return AVAILABLE_RULES.find(({ rule }) => rule.name === name);
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple pattern matching logic
|
||||
*/
|
||||
const matchesPattern = (filePath: string, pattern: string): boolean => {
|
||||
if (pattern === '**/*') return true;
|
||||
|
||||
// Simple extension matching for patterns like '**/*.ts'
|
||||
const extensionMatch = pattern.match(/\*\*\/\*\.(\w+)$/);
|
||||
if (extensionMatch && extensionMatch[1]) {
|
||||
return filePath.endsWith(`.${extensionMatch[1]}`);
|
||||
}
|
||||
|
||||
// More complex pattern matching could be implemented here
|
||||
return false;
|
||||
};
|
||||
|
||||
// Re-export rule constants and analyzers for convenience
|
||||
export { WHITESPACE_RULE, analyzeWhitespaceRule } from './whitespace-rule';
|
||||
export { AST_COMMENT_RULE, analyzeAstCommentRule } from './ast-comment-rule';
|
||||
@ -0,0 +1,75 @@
|
||||
import { RuleResult, Config, Rule } from '../types';
|
||||
import { FILE_PATTERNS } from '../config';
|
||||
import { hasOnlyWhitespaceChanges } from '../utils/git';
|
||||
import { exists } from '../utils/file';
|
||||
|
||||
/**
|
||||
* Rule definition for detecting whitespace-only changes
|
||||
*/
|
||||
export const WHITESPACE_RULE: Rule = {
|
||||
name: 'whitespace-only',
|
||||
description: 'Detects files that have only whitespace changes (spaces, tabs, newlines)',
|
||||
filePatterns: FILE_PATTERNS.ALL_FILES
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Create a result object for the whitespace rule
|
||||
*/
|
||||
const createResult = (
|
||||
filePath: string,
|
||||
shouldRevert: boolean,
|
||||
reason?: string,
|
||||
error?: string
|
||||
): RuleResult => ({
|
||||
filePath,
|
||||
ruleName: WHITESPACE_RULE.name,
|
||||
shouldRevert,
|
||||
reason,
|
||||
error
|
||||
});
|
||||
|
||||
/**
|
||||
* Log verbose messages if enabled
|
||||
*/
|
||||
const log = (message: string, config: Config): void => {
|
||||
if (config.verbose) {
|
||||
console.log(`[${WHITESPACE_RULE.name}] ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Analyze a file for whitespace-only changes
|
||||
*/
|
||||
export const analyzeWhitespaceRule = async (filePath: string, config: Config): Promise<RuleResult> => {
|
||||
log(`Analyzing file for whitespace changes: ${filePath}`, config);
|
||||
|
||||
try {
|
||||
// Check if file exists
|
||||
if (!exists(filePath)) {
|
||||
return createResult(filePath, false, 'File does not exist');
|
||||
}
|
||||
|
||||
const hasOnlyWhitespace = hasOnlyWhitespaceChanges(filePath, { cwd: config.cwd });
|
||||
|
||||
if (hasOnlyWhitespace) {
|
||||
return createResult(
|
||||
filePath,
|
||||
true,
|
||||
'File has only whitespace changes (spaces, tabs, newlines)'
|
||||
);
|
||||
} else {
|
||||
return createResult(
|
||||
filePath,
|
||||
false,
|
||||
'File has non-whitespace changes'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
return createResult(
|
||||
filePath,
|
||||
false,
|
||||
'Failed to check whitespace changes',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Configuration options for the revert-useless-changes tool
|
||||
*/
|
||||
export interface Config {
|
||||
/** Working directory to analyze */
|
||||
cwd: string;
|
||||
/** Only analyze, don't actually revert files */
|
||||
dryRun: boolean;
|
||||
/** Enable verbose output */
|
||||
verbose: boolean;
|
||||
/** Output results as JSON */
|
||||
json: boolean;
|
||||
/** File patterns to include in analysis */
|
||||
include?: string[];
|
||||
/** File patterns to exclude from analysis */
|
||||
exclude?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of analyzing a single file with a rule
|
||||
*/
|
||||
export interface RuleResult {
|
||||
/** The file that was analyzed */
|
||||
filePath: string;
|
||||
/** Name of the rule that was applied */
|
||||
ruleName: string;
|
||||
/** Whether this rule matched (file should be reverted) */
|
||||
shouldRevert: boolean;
|
||||
/** Optional reason why the rule matched or didn't match */
|
||||
reason?: string;
|
||||
/** Any error that occurred during analysis */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysis results for a single file across all applicable rules
|
||||
*/
|
||||
export interface FileAnalysis {
|
||||
/** The file that was analyzed */
|
||||
filePath: string;
|
||||
/** Results from each rule that was applied */
|
||||
ruleResults: RuleResult[];
|
||||
/** Whether any rule matched (file should be reverted) */
|
||||
shouldRevert: boolean;
|
||||
/** The rule that matched (if any) */
|
||||
matchedRule?: string;
|
||||
/** Whether the file exists (not deleted) */
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overall analysis report
|
||||
*/
|
||||
export interface AnalysisReport {
|
||||
/** Timestamp when analysis was performed */
|
||||
timestamp: string;
|
||||
/** Configuration used for analysis */
|
||||
config: Config;
|
||||
/** Summary statistics */
|
||||
summary: {
|
||||
totalFiles: number;
|
||||
revertableFiles: number;
|
||||
whitespaceOnlyFiles: number;
|
||||
commentOnlyFiles: number;
|
||||
deletedFiles: number;
|
||||
errorFiles: number;
|
||||
unchangedFiles: number;
|
||||
};
|
||||
/** Detailed analysis for each file */
|
||||
fileAnalyses: FileAnalysis[];
|
||||
/** Files that were successfully reverted (if not dry run) */
|
||||
revertedFiles: string[];
|
||||
/** Files that failed to revert */
|
||||
revertErrors: RevertError[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Error information for files that failed to revert
|
||||
*/
|
||||
export interface RevertError {
|
||||
/** File that failed to revert */
|
||||
file: string;
|
||||
/** Error message */
|
||||
error: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* File change type classification
|
||||
*/
|
||||
export enum ChangeType {
|
||||
WHITESPACE_ONLY = 'whitespace-only',
|
||||
COMMENT_ONLY = 'comment-only',
|
||||
CODE_CHANGES = 'code-changes',
|
||||
DELETED = 'deleted',
|
||||
ERROR = 'error',
|
||||
UNCHANGED = 'unchanged'
|
||||
}
|
||||
|
||||
/**
|
||||
* Rule definition for analyzing files
|
||||
*/
|
||||
export interface Rule {
|
||||
/** Unique name of the rule */
|
||||
readonly name: string;
|
||||
/** Description of what the rule detects */
|
||||
readonly description: string;
|
||||
/** File patterns this rule applies to (glob patterns) */
|
||||
readonly filePatterns: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Function type for analyzing files with a rule
|
||||
*/
|
||||
export type RuleAnalyzer = (filePath: string, config: Config) => Promise<RuleResult>;
|
||||
|
||||
/**
|
||||
* Options for git operations
|
||||
*/
|
||||
export interface GitOptions {
|
||||
/** Working directory for git operations */
|
||||
cwd: string;
|
||||
/** Git reference to compare against (default: HEAD) */
|
||||
ref?: string;
|
||||
}
|
||||
@ -0,0 +1,110 @@
|
||||
import { readFileSync, existsSync, statSync } from 'fs';
|
||||
import { join, relative, isAbsolute } from 'path';
|
||||
import { glob } from 'glob';
|
||||
|
||||
/**
|
||||
* Read file content safely
|
||||
*/
|
||||
export const readFile = (filePath: string): string => {
|
||||
try {
|
||||
return readFileSync(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to read file ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if file exists
|
||||
*/
|
||||
export const exists = (filePath: string): boolean => {
|
||||
return existsSync(filePath);
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert relative path to absolute path
|
||||
*/
|
||||
export const toAbsolutePath = (filePath: string, basePath: string): string => {
|
||||
if (isAbsolute(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
return join(basePath, filePath);
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert absolute path to relative path
|
||||
*/
|
||||
export const toRelativePath = (filePath: string, basePath: string): string => {
|
||||
return relative(basePath, filePath);
|
||||
};
|
||||
|
||||
/**
|
||||
* Match files against glob patterns
|
||||
*/
|
||||
export const matchFiles = (patterns: string[], options: { cwd: string; exclude?: string[] }): string[] => {
|
||||
const allMatches: string[] = [];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
try {
|
||||
const matches = glob.sync(pattern, {
|
||||
cwd: options.cwd,
|
||||
absolute: true,
|
||||
ignore: options.exclude || [],
|
||||
nodir: true
|
||||
});
|
||||
allMatches.push(...matches);
|
||||
} catch (error) {
|
||||
console.warn(`Warning: Failed to match pattern ${pattern}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates
|
||||
return Array.from(new Set(allMatches));
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a file matches any of the given patterns
|
||||
*/
|
||||
export const matchesPattern = (filePath: string, patterns: string[]): boolean => {
|
||||
return patterns.some(pattern => {
|
||||
if (pattern === '**/*') return true;
|
||||
|
||||
// Simple extension matching for patterns like '**/*.ts'
|
||||
const extensionMatch = pattern.match(/\*\*\/\*\.(\w+)$/);
|
||||
if (extensionMatch && extensionMatch[1]) {
|
||||
return filePath.endsWith(`.${extensionMatch[1]}`);
|
||||
}
|
||||
|
||||
// More complex pattern matching could be implemented here
|
||||
// For now, we'll use simple string matching
|
||||
return filePath.includes(pattern.replace(/\*\*/g, '').replace(/\*/g, ''));
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get file extension
|
||||
*/
|
||||
export const getExtension = (filePath: string): string => {
|
||||
const lastDot = filePath.lastIndexOf('.');
|
||||
if (lastDot === -1 || lastDot === 0) return '';
|
||||
return filePath.substring(lastDot + 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if file is a JavaScript/TypeScript file
|
||||
*/
|
||||
export const isJavaScriptOrTypeScript = (filePath: string): boolean => {
|
||||
const ext = getExtension(filePath).toLowerCase();
|
||||
return ['js', 'jsx', 'ts', 'tsx'].includes(ext);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get file size in bytes
|
||||
*/
|
||||
export const getFileSize = (filePath: string): number => {
|
||||
try {
|
||||
const stats = statSync(filePath);
|
||||
return stats.size;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,217 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { resolve, join, dirname } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { GitOptions } from '../types';
|
||||
|
||||
/**
|
||||
* Get the project root directory (git repository root)
|
||||
*/
|
||||
export const getProjectRoot = (): string => {
|
||||
try {
|
||||
return execSync('git rev-parse --show-toplevel', {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore']
|
||||
}).trim();
|
||||
} catch (error) {
|
||||
throw new Error('Not in a git repository or git is not available');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get list of changed files in git working directory (includes both modified and added files)
|
||||
*/
|
||||
export const getChangedFiles = (options: GitOptions = { cwd: process.cwd() }): string[] => {
|
||||
try {
|
||||
// Get both modified files and added files
|
||||
const modifiedOutput = execSync('git diff --name-only', {
|
||||
cwd: options.cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore']
|
||||
});
|
||||
|
||||
const addedOutput = execSync('git diff --cached --name-only', {
|
||||
cwd: options.cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore']
|
||||
});
|
||||
|
||||
const modifiedFiles = modifiedOutput
|
||||
.split('\n')
|
||||
.map(file => file.trim())
|
||||
.filter(file => file.length > 0);
|
||||
|
||||
const addedFiles = addedOutput
|
||||
.split('\n')
|
||||
.map(file => file.trim())
|
||||
.filter(file => file.length > 0);
|
||||
|
||||
// Combine and deduplicate
|
||||
const allFiles = Array.from(new Set([...modifiedFiles, ...addedFiles]));
|
||||
|
||||
return allFiles.map(file => resolve(options.cwd, file));
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get changed files: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get list of modified files relative to a git reference
|
||||
*/
|
||||
export const getModifiedFiles = (options: GitOptions = { cwd: process.cwd() }): string[] => {
|
||||
try {
|
||||
const output = execSync(`git diff --name-only ${options.ref || 'HEAD'}`, {
|
||||
cwd: options.cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore']
|
||||
});
|
||||
|
||||
return output
|
||||
.split('\n')
|
||||
.map(file => file.trim())
|
||||
.filter(file => file.length > 0);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get modified files: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get file content from a specific git reference
|
||||
*/
|
||||
export const getFileContentAtRef = (filePath: string, options: GitOptions): string => {
|
||||
try {
|
||||
const projectRoot = getProjectRoot();
|
||||
const relativePath = filePath.startsWith(projectRoot)
|
||||
? filePath.substring(projectRoot.length + 1)
|
||||
: filePath;
|
||||
|
||||
return execSync(`git show ${options.ref || 'HEAD'}:${relativePath}`, {
|
||||
cwd: options.cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore']
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get file content at ref: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a file is newly added (not in HEAD)
|
||||
*/
|
||||
export const isNewFile = (filePath: string, options: GitOptions): boolean => {
|
||||
try {
|
||||
const projectRoot = getProjectRoot();
|
||||
const relativePath = filePath.startsWith(projectRoot)
|
||||
? filePath.substring(projectRoot.length + 1)
|
||||
: filePath;
|
||||
|
||||
execSync(`git show ${options.ref || 'HEAD'}:${relativePath}`, {
|
||||
cwd: options.cwd,
|
||||
stdio: ['pipe', 'pipe', 'ignore']
|
||||
});
|
||||
|
||||
// If git show succeeds, the file exists in HEAD, so it's not new
|
||||
return false;
|
||||
} catch (error) {
|
||||
// If git show fails, the file doesn't exist in HEAD, so it's new
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a file has only whitespace changes
|
||||
*/
|
||||
export const hasOnlyWhitespaceChanges = (filePath: string, options: GitOptions): boolean => {
|
||||
try {
|
||||
// Skip analysis for new files
|
||||
if (isNewFile(filePath, options)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const projectRoot = getProjectRoot();
|
||||
const relativePath = filePath.startsWith(projectRoot)
|
||||
? filePath.substring(projectRoot.length + 1)
|
||||
: filePath;
|
||||
|
||||
const output = execSync(`git diff -w ${options.ref || 'HEAD'} -- "${relativePath}"`, {
|
||||
cwd: options.cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore']
|
||||
});
|
||||
|
||||
return output.trim() === '';
|
||||
} catch (error) {
|
||||
// If git diff fails, assume the file has changes
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Revert a file to its state in the git reference
|
||||
*/
|
||||
export const revertFile = (filePath: string, options: GitOptions): void => {
|
||||
try {
|
||||
const projectRoot = getProjectRoot();
|
||||
const relativePath = filePath.startsWith(projectRoot)
|
||||
? filePath.substring(projectRoot.length + 1)
|
||||
: filePath;
|
||||
|
||||
execSync(`git checkout ${options.ref || 'HEAD'} -- "${relativePath}"`, {
|
||||
cwd: options.cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to revert file: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if we're in a git repository
|
||||
*/
|
||||
export const isGitRepository = (cwd: string = process.cwd()): boolean => {
|
||||
try {
|
||||
execSync('git rev-parse --git-dir', {
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'ignore']
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Find git repository root by recursively searching for .git directory
|
||||
* @param startDir Directory to start searching from
|
||||
* @returns Git repository root path or null if not found
|
||||
*/
|
||||
export const findGitRepositoryRoot = (startDir: string): string | null => {
|
||||
// Check if .git exists in current directory
|
||||
const gitDir = join(startDir, '.git');
|
||||
if (existsSync(gitDir)) {
|
||||
return startDir;
|
||||
}
|
||||
|
||||
// Recursively check parent directories
|
||||
let currentDir = startDir;
|
||||
while (currentDir !== dirname(currentDir)) {
|
||||
const parentGitDir = join(currentDir, '.git');
|
||||
if (existsSync(parentGitDir)) {
|
||||
return currentDir;
|
||||
}
|
||||
currentDir = dirname(currentDir);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate that a directory is within a git repository
|
||||
* @param cwd Directory to validate
|
||||
* @throws Error if not in a git repository
|
||||
*/
|
||||
export const validateGitRepository = (cwd: string): void => {
|
||||
const gitRoot = findGitRepositoryRoot(cwd);
|
||||
if (!gitRoot) {
|
||||
throw new Error(`Not a git repository (or any parent directory): ${cwd}`);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user