UNPKG

structure-validation

Version:

A Node.js CLI tool for validating codebase folder and file structure using a clean declarative configuration. Part of the guardz ecosystem for comprehensive TypeScript development.

666 lines • 29.9 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.InteractiveFixService = void 0; const readline = __importStar(require("readline")); const fs = __importStar(require("fs")); const path = __importStar(require("path")); const ConfigService_1 = require("./ConfigService"); const MoveToUpperFolderService_1 = require("./MoveToUpperFolderService"); const AddFilePatternToConfigService_1 = require("./AddFilePatternToConfigService"); const ValidationOrchestrator_1 = require("./ValidationOrchestrator"); const ImportUpdateService_1 = require("./ImportUpdateService"); const FileDiscoveryService_1 = require("./FileDiscoveryService"); /** * Application service for interactive file fixing */ class InteractiveFixService { constructor() { this.rl = readline.createInterface({ input: process.stdin, output: process.stdout }); this.configService = new ConfigService_1.ConfigService('structure-validation.config.json'); this.moveToUpperFolderService = new MoveToUpperFolderService_1.MoveToUpperFolderService(); this.addFilePatternToConfigService = new AddFilePatternToConfigService_1.AddFilePatternToConfigService(this.rl); this.importUpdateService = new ImportUpdateService_1.ImportUpdateService(); this.fileDiscoveryService = new FileDiscoveryService_1.FileDiscoveryService(); } /** * Handle interactive mode for fixing validation errors */ async handleInteractiveMode(errors, suggestions) { console.log(`\nšŸ“ Found ${errors.length} file(s) with validation errors.`); console.log('Let\'s fix them one by one...\n'); let fileIndex = 0; let currentErrors = [...errors]; let currentSuggestions = [...suggestions]; while (fileIndex < currentErrors.length) { const error = currentErrors[fileIndex]; if (!error) break; fileIndex++; const suggestion = currentSuggestions.find(s => s.file.relativePath === error.file.relativePath); console.log(`\n${'='.repeat(60)}`); console.log(`File ${fileIndex}/${currentErrors.length}: ${error.file.relativePath}`); console.log(`Error: ${error.message}`); if (suggestion) { console.log(`Suggestion: Move to ${suggestion.suggestedFolder}/`); } const action = await this.getUserAction(error, suggestion); const result = await this.executeAction(action, error.file, error, suggestion); // If configuration was updated, re-validate all files and update the list if (result.shouldRestart) { console.log('\nšŸ”„ Configuration updated! Re-validating files with new rules...'); // Clear the config cache to reload with new rules await this.configService.clearCache(); // Re-validate all files with the updated configuration const orchestrator = new ValidationOrchestrator_1.ValidationOrchestrator(); // Re-validate using the same scope as the original validation // For now, we'll validate all files to be safe const newResult = await orchestrator.validateAllFiles(); // Update the current errors and suggestions with the new validation results currentErrors = [...newResult.errors]; currentSuggestions = [...newResult.suggestions]; // Reset file index to start from the beginning with new validation results fileIndex = 0; console.log(`\nšŸ“ Found ${currentErrors.length} file(s) with validation errors after configuration update.`); console.log('Continuing with updated validation results...\n'); // If no more errors, we're done if (currentErrors.length === 0) { console.log('\nāœ… All files are now valid! Interactive mode completed!'); this.rl.close(); return; } continue; } // If file was processed (renamed, moved, deleted), remove it from the current errors list if (result.fileProcessed) { // Remove the processed file from the current errors list currentErrors = currentErrors.filter(e => e.file.relativePath !== error.file.relativePath); // Also remove the corresponding suggestion currentSuggestions = currentSuggestions.filter(s => s.file.relativePath !== error.file.relativePath); // Adjust the file index since we removed an item fileIndex--; // If no more errors, we're done if (currentErrors.length === 0) { console.log('\nāœ… All files are now valid! Interactive mode completed!'); this.rl.close(); return; } } } console.log('\nāœ… Interactive mode completed!'); this.rl.close(); } /** * Get user action for a file */ async getUserAction(error, suggestion) { // Use the passed suggestion if available, otherwise get suggested subfolder based on file characteristics const suggestedSubfolder = suggestion?.suggestedFolder || await this.getSuggestedSubfolder(error.file); const hasSuggestion = !!(suggestion?.suggestedFolder || suggestedSubfolder); // Check if file is at root level const isAtRootLevel = await this.isFileAtRootLevel(error.file.path); // Define available actions based on conditions const availableActions = [ { action: 'moveToUpperFolder', label: `Move to upper folder /${suggestion?.suggestedFolder || suggestedSubfolder}`, condition: hasSuggestion && !isAtRootLevel }, { action: 'moveToSubfolder', label: `Move to subfolder /${suggestion?.suggestedFolder || suggestedSubfolder}`, condition: hasSuggestion }, { action: 'renameFile', label: 'Rename file', condition: true // Always available }, { action: 'skipFile', label: 'Skip this file', condition: true // Always available }, { action: 'addFileToConfig', label: 'Add file to structure-validation.config.json', condition: true // Always available }, { action: 'addFilePatternToConfig', label: 'Add file pattern to structure-validation.config.json', condition: true // Always available }, { action: 'deleteFile', label: 'Delete file', condition: true // Always available } ]; // Build options array with only available actions const options = []; const actionMap = []; availableActions.forEach((action) => { if (action.condition) { const optionNumber = options.length + 1; options.push(`${optionNumber} - ${action.label}`); actionMap.push(action.action); } }); console.log('\nWhat would you like to do?'); options.forEach(option => console.log(` ${option}`)); const validChoices = Array.from({ length: options.length }, (_, i) => (i + 1).toString()); const choiceRange = `1-${options.length}`; return new Promise((resolve) => { this.rl.question(`\nEnter your choice (${choiceRange}): `, (answer) => { const choice = answer.trim(); if (validChoices.includes(choice)) { // Return the action type instead of the choice number const actionIndex = parseInt(choice) - 1; resolve(actionMap[actionIndex] || choice); } else { console.log(`āŒ Invalid choice. Please enter ${choiceRange}.`); this.getUserAction(error, suggestion).then(resolve); } }); }); } /** * Get suggested subfolder name based on file characteristics and config */ async getSuggestedSubfolder(file) { try { const config = await this.configService.loadConfig(); const fileName = path.basename(file.path); // Check each rule in the config to find the best match for (const rule of config.rules) { // Skip special patterns like {root}, {a}, ** for folder suggestions if (rule.folder === '{root}' || rule.folder === '**' || rule.folder.match(/^\{[^}]+\}$/)) { continue; } // Check if the file matches this rule's patterns if (this.matchesPatterns(fileName, rule.patterns)) { return rule.folder; } } return null; } catch (error) { console.error(`āŒ Failed to load config for suggestions: ${error instanceof Error ? error.message : String(error)}`); return null; } } /** * Check if filename matches any of the given patterns */ matchesPatterns(filename, patterns) { return patterns.some(pattern => this.matchesPattern(filename, pattern)); } /** * Check if filename matches a specific pattern (glob to regex) */ matchesPattern(filename, pattern) { const regexPattern = this.globToRegex(pattern); return regexPattern.test(filename); } /** * Convert glob pattern to regex */ globToRegex(pattern) { const escaped = pattern .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') .replace(/\\\*/g, '.*') .replace(/\\\?/g, '.'); return new RegExp(`^${escaped}$`); } /** * Execute the chosen action * @returns true if configuration was updated and validation should restart */ async executeAction(actionType, file, error, suggestion) { const filePath = file.path; const fileName = path.basename(filePath); const fileExt = path.extname(filePath); try { switch (actionType) { case 'moveToSubfolder': await this.moveToSubfolder(filePath, fileName, fileExt, error, suggestion); return { shouldRestart: false, fileProcessed: true }; case 'renameFile': await this.renameFile(filePath, fileName, fileExt); return { shouldRestart: false, fileProcessed: true }; case 'moveToUpperFolder': await this.moveToUpperFolderService.executeMoveToUpperFolder(filePath, fileName, fileExt, error, suggestion); return { shouldRestart: false, fileProcessed: true }; case 'deleteFile': await this.deleteFile(filePath); return { shouldRestart: false, fileProcessed: true }; case 'addFileToConfig': await this.addFileToConfig(filePath, fileName); return { shouldRestart: true, fileProcessed: false }; // Configuration was updated case 'addFilePatternToConfig': await this.addFilePatternToConfigService.executeAddFilePatternToConfig(filePath, fileName); return { shouldRestart: true, fileProcessed: false }; // Configuration was updated case 'skipFile': await this.skipFile(filePath); return { shouldRestart: false, fileProcessed: false }; default: console.log('āŒ Invalid action type'); return { shouldRestart: false, fileProcessed: false }; } } catch (error) { console.error(`āŒ Error executing action: ${error instanceof Error ? error.message : String(error)}`); return { shouldRestart: false, fileProcessed: false }; } } /** * Move file to a subfolder */ async moveToSubfolder(filePath, fileName, _fileExt, _error, suggestion) { // Get the suggested folder name let folderName = suggestion?.suggestedFolder; if (!folderName) { // Fall back to file analysis if no validation suggestion const suggestedSubfolder = await this.getSuggestedSubfolder({ path: filePath }); folderName = suggestedSubfolder || undefined; } if (!folderName) { console.log('āŒ No folder suggestion available. Please use option 2 to rename the file instead.'); return; } const cleanName = this.sanitizeFolderName(folderName); if (!cleanName) { console.log('āŒ Invalid folder name.'); return; } const targetDir = path.join(path.dirname(filePath), cleanName); const targetPath = path.join(targetDir, fileName); try { // Check if target file already exists if (fs.existsSync(targetPath)) { console.log(`āŒ File already exists in ${cleanName}/ directory.`); return; } // Create directory if it doesn't exist if (!fs.existsSync(targetDir)) { fs.mkdirSync(targetDir, { recursive: true }); console.log(`šŸ“ Created directory: ${cleanName}/`); } // Move file fs.renameSync(filePath, targetPath); console.log(`āœ… Moved ${fileName} to ${cleanName}/`); // Update imports after the file move await this.updateImportsAfterMove(filePath, targetPath); // Clear file discovery cache this.clearFileDiscoveryCache(); } catch (error) { console.error(`āŒ Failed to move file: ${error instanceof Error ? error.message : String(error)}`); } } /** * Rename file */ async renameFile(filePath, fileName, fileExt) { // Extract current name without extension const currentNameWithoutExt = fileName.replace(fileExt, ''); // Get suggested names based on current folder and config patterns const suggestedNames = await this.getSuggestedRenameOptions(filePath, currentNameWithoutExt); return new Promise((resolve) => { let prompt = `Enter new name (without extension) [${currentNameWithoutExt}]: `; if (suggestedNames.length > 0) { prompt = `Enter new name (without extension) [${currentNameWithoutExt}]:\n`; prompt += `Suggested options:\n`; suggestedNames.forEach((suggestion, index) => { prompt += ` ${index + 1} - ${suggestion}${fileExt}\n`; }); prompt += ` Or type your own name: `; } this.rl.question(prompt, async (newName) => { let inputName = newName.trim(); // Handle suggested option selection if (inputName && !isNaN(Number(inputName)) && Number(inputName) > 0 && Number(inputName) <= suggestedNames.length) { const selectedIndex = Number(inputName) - 1; inputName = suggestedNames[selectedIndex] || ''; } // Use current name if user just presses Enter, otherwise use their input const finalName = inputName || currentNameWithoutExt; // Check if the user input already includes the extension let newFileName; if (finalName.endsWith(fileExt)) { // User included the extension, sanitize the name without the extension const nameWithoutExt = finalName.replace(fileExt, ''); const cleanName = this.sanitizeFileName(nameWithoutExt); if (!cleanName) { console.log('āŒ Invalid file name.'); resolve(); return; } newFileName = cleanName + fileExt; } else { // User didn't include extension, sanitize and add it const cleanName = this.sanitizeFileName(finalName); if (!cleanName) { console.log('āŒ Invalid file name.'); resolve(); return; } newFileName = cleanName + fileExt; } const newFilePath = path.join(path.dirname(filePath), newFileName); try { fs.renameSync(filePath, newFilePath); console.log(`āœ… Renamed ${fileName} to ${newFileName}`); // Update imports after the file rename await this.updateImportsAfterMove(filePath, newFilePath); // Clear file discovery cache this.clearFileDiscoveryCache(); } catch (error) { console.error(`āŒ Failed to rename file: ${error instanceof Error ? error.message : String(error)}`); } resolve(); }); }); } /** * Get suggested rename options based on current folder and config patterns */ async getSuggestedRenameOptions(filePath, currentName) { try { const config = await this.configService.loadConfig(); const currentFolder = path.basename(path.dirname(filePath)); const suggestions = []; // Find the rule for the current folder const folderRule = config.rules.find(rule => rule.folder === currentFolder); if (folderRule) { // Generate suggestions based on the patterns for (const pattern of folderRule.patterns) { const suggestion = this.generateNameFromPattern(currentName, pattern); if (suggestion && suggestion !== currentName && !suggestions.includes(suggestion)) { suggestions.push(suggestion); } } } return suggestions.slice(0, 3); // Limit to 3 suggestions } catch (error) { console.error(`āŒ Failed to get rename suggestions: ${error instanceof Error ? error.message : String(error)}`); return []; } } /** * Generate a name suggestion based on a pattern */ generateNameFromPattern(currentName, pattern) { // Handle common pattern types if (pattern.includes('*.enum.ts')) { return `${currentName}.enum`; } if (pattern.includes('*Enum.ts')) { return `${currentName}Enum`; } if (pattern.includes('*.constant.ts')) { return `${currentName}.constant`; } if (pattern.includes('*.util.ts')) { return `${currentName}.util`; } if (pattern.includes('*.type.ts')) { return `${currentName}.type`; } if (pattern.includes('*.interface.ts')) { return `${currentName}.interface`; } if (pattern.includes('*.d.ts')) { return `${currentName}.d`; } if (pattern.includes('is*.guardz.ts')) { return `is${currentName.charAt(0).toUpperCase() + currentName.slice(1)}`; } if (pattern.includes('use*.ts') || pattern.includes('use*.tsx')) { return `use${currentName.charAt(0).toUpperCase() + currentName.slice(1)}`; } return null; } /** * Delete file */ async deleteFile(filePath) { return new Promise((resolve) => { this.rl.question('Are you sure you want to delete this file? (y/N): ', async (answer) => { if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') { try { fs.unlinkSync(filePath); console.log(`šŸ—‘ļø Deleted ${path.basename(filePath)}`); } catch (error) { console.error(`āŒ Failed to delete file: ${error instanceof Error ? error.message : String(error)}`); } } else { console.log('ā­ļø File deletion cancelled.'); } resolve(); }); }); } /** * Add file to structure-validation.config.json */ async addFileToConfig(filePath, fileName) { const configPath = path.join(process.cwd(), 'structure-validation.config.json'); if (!fs.existsSync(configPath)) { console.log('āŒ structure-validation.config.json not found in project root.'); return; } try { // Add the specific file name to the {root} folder const escapedFolder = '{root}'.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const folderRegex = new RegExp(`("${escapedFolder}":\\s*\\[[^\\]]*)\\]`, 'g'); // Read the current config const configContent = fs.readFileSync(configPath, 'utf8'); // Check if the file already exists in {root} if (configContent.includes(`"${fileName}"`)) { console.log(`šŸ“ File "${fileName}" already exists in {root} folder.`); return; } // Add the specific file name to the {root} folder const updatedContent = configContent.replace(folderRegex, `$1, "${fileName}"]`); // Write the updated config fs.writeFileSync(configPath, updatedContent); console.log(`šŸ“ Added file "${fileName}" to {root} folder in structure-validation.config.json`); // Display the message and continue console.log('\nšŸ”„ Configuration updated! Restarting validation with new rules...'); console.log('āš ļø Files that match the new configuration will be automatically moved.'); } catch (error) { console.error(`āŒ Failed to update config: ${error instanceof Error ? error.message : String(error)}`); } } /** * Skip file */ async skipFile(filePath) { const skipFilePath = path.join(process.cwd(), 'structure-validation.skip.json'); const relativePath = path.relative(process.cwd(), filePath); try { let skippedFiles = []; // Load existing skipped files if the file exists if (fs.existsSync(skipFilePath)) { const skipContent = fs.readFileSync(skipFilePath, 'utf8'); try { skippedFiles = JSON.parse(skipContent); } catch (error) { console.warn('āš ļø Invalid skip file format, creating new one.'); skippedFiles = []; } } // Add the current file to skipped files if not already present if (!skippedFiles.includes(relativePath)) { skippedFiles.push(relativePath); fs.writeFileSync(skipFilePath, JSON.stringify(skippedFiles, null, 2)); console.log(`ā­ļø Skipped ${path.basename(filePath)} (added to structure-validation.skip.json)`); } else { console.log(`ā­ļø File ${path.basename(filePath)} is already in skip list`); } } catch (error) { console.error(`āŒ Failed to update skip file: ${error instanceof Error ? error.message : String(error)}`); } } /** * Sanitize folder name */ sanitizeFolderName(name) { // Remove invalid characters and normalize return name .replace(/[<>:"/\\|?*]/g, '') .replace(/\s+/g, '-') .toLowerCase() .trim(); } /** * Sanitize file name */ sanitizeFileName(name) { // Remove invalid characters and normalize return name .replace(/[<>:"/\\|?*]/g, '') .replace(/\s+/g, '-') .trim(); } /** * Check if file is at root level (configured root directory) * @param filePath The absolute path of the file * @returns boolean True if the file is at root level */ async isFileAtRootLevel(filePath) { try { // Get the directory name of the file const fileDir = path.dirname(filePath); // Get the configured root from the config const config = await this.configService.loadConfig(); const configuredRoot = config.root; if (!configuredRoot) { // Fallback to project root if no configured root const projectRoot = this.getProjectRoot(); return path.resolve(fileDir) === path.resolve(projectRoot); } // Resolve the configured root path const resolvedConfiguredRoot = path.resolve(configuredRoot); // Check if the file directory is the same as the configured root return path.resolve(fileDir) === resolvedConfiguredRoot; } catch (error) { console.error(`āŒ Error checking if file is at root level: ${error instanceof Error ? error.message : String(error)}`); return false; } } /** * Get the project root directory * @returns string The project root path */ getProjectRoot() { let currentDir = process.cwd(); const maxDepth = 10; // Prevent infinite loops for (let depth = 0; depth < maxDepth; depth++) { const packageJsonPath = path.join(currentDir, 'package.json'); // Check if this directory has a package.json (indicating it's a project root) if (fs.existsSync(packageJsonPath)) { return currentDir; } // Move up one directory const parentDir = path.dirname(currentDir); if (parentDir === currentDir) { // We've reached the filesystem root break; } currentDir = parentDir; } // Fallback to current working directory return process.cwd(); } /** * Update imports after a file has been moved * @param oldPath The old file path * @param newPath The new file path * @returns Promise<void> */ async updateImportsAfterMove(oldPath, newPath) { try { const movedFile = { oldPath, newPath, oldRelativePath: path.relative(process.cwd(), oldPath), newRelativePath: path.relative(process.cwd(), newPath) }; const result = await this.importUpdateService.updateImportsAfterMove([movedFile]); if (result.updated.length > 0) { console.log(`šŸ”„ Updated imports in ${result.updated.length} file(s)`); } if (result.errors.length > 0) { console.log(`āš ļø Import update errors: ${result.errors.length}`); result.errors.forEach(error => console.log(` - ${error}`)); } // Clear file discovery cache after file operations this.clearFileDiscoveryCache(); } catch (error) { console.error(`āŒ Failed to update imports: ${error instanceof Error ? error.message : String(error)}`); } } /** * Clear file discovery cache to ensure fresh file discovery after operations */ clearFileDiscoveryCache() { try { this.fileDiscoveryService.clearCache(); } catch (error) { console.error(`āŒ Failed to clear file discovery cache: ${error instanceof Error ? error.message : String(error)}`); } } } exports.InteractiveFixService = InteractiveFixService; //# sourceMappingURL=InteractiveFixService.js.map