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
JavaScript
;
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