/** * V3 File Organization Hook * * TypeScript conversion of V2 file-hook.sh. * Enforces file organization, blocks writes to root folder, * suggests proper directories, and recommends formatters. * * @module v3/shared/hooks/safety/file-organization */ import * as path from 'path'; import { HookEvent, HookPriority, } from '../types.js'; /** * File type mapping to directories */ const FILE_TYPE_DIRECTORIES = [ // Test files (MUST come before source files - more specific patterns first) { pattern: /\.test\.(ts|tsx|js|jsx)$/, directories: ['tests/', '__tests__/', 'test/'], type: 'test file', blockRoot: true }, { pattern: /\.spec\.(ts|tsx|js|jsx)$/, directories: ['tests/', '__tests__/', 'test/', 'spec/'], type: 'spec file', blockRoot: true }, { pattern: /_test\.go$/, directories: ['tests/', 'test/'], type: 'Go test file', blockRoot: true }, { pattern: /test_.*\.py$/, directories: ['tests/', 'test/'], type: 'Python test file', blockRoot: true }, { pattern: /.*_test\.py$/, directories: ['tests/', 'test/'], type: 'Python test file', blockRoot: true }, // Source files { pattern: /\.(ts|tsx)$/, directories: ['src/', 'lib/'], type: 'TypeScript source', blockRoot: true }, { pattern: /\.(js|jsx|mjs|cjs)$/, directories: ['src/', 'lib/', 'dist/'], type: 'JavaScript source', blockRoot: true }, { pattern: /\.py$/, directories: ['src/', 'lib/', 'app/'], type: 'Python source', blockRoot: true }, { pattern: /\.go$/, directories: ['cmd/', 'pkg/', 'internal/'], type: 'Go source', blockRoot: true }, { pattern: /\.rs$/, directories: ['src/'], type: 'Rust source', blockRoot: true }, { pattern: /\.java$/, directories: ['src/main/java/', 'src/'], type: 'Java source', blockRoot: true }, { pattern: /\.rb$/, directories: ['lib/', 'app/'], type: 'Ruby source', blockRoot: true }, { pattern: /\.php$/, directories: ['src/', 'app/'], type: 'PHP source', blockRoot: true }, { pattern: /\.cs$/, directories: ['src/'], type: 'C# source', blockRoot: true }, { pattern: /\.cpp?$/, directories: ['src/'], type: 'C/C++ source', blockRoot: true }, { pattern: /\.swift$/, directories: ['Sources/'], type: 'Swift source', blockRoot: true }, { pattern: /\.kt$/, directories: ['src/main/kotlin/', 'src/'], type: 'Kotlin source', blockRoot: true }, // Config files (usually allowed at root) { pattern: /\.(json|yaml|yml|toml)$/, directories: ['config/', './', 'configs/'], type: 'config file', blockRoot: false }, { pattern: /\.(env|env\.[a-z]+)$/, directories: ['./'], type: 'environment file', blockRoot: false }, // Documentation { pattern: /\.md$/, directories: ['docs/', './'], type: 'Markdown documentation', blockRoot: false }, { pattern: /\.rst$/, directories: ['docs/'], type: 'reStructuredText documentation', blockRoot: true }, { pattern: /\.adoc$/, directories: ['docs/'], type: 'AsciiDoc documentation', blockRoot: true }, // Assets { pattern: /\.(css|scss|sass|less)$/, directories: ['styles/', 'src/styles/', 'assets/css/'], type: 'stylesheet', blockRoot: true }, { pattern: /\.(png|jpg|jpeg|gif|svg|ico)$/, directories: ['assets/', 'public/', 'images/', 'static/'], type: 'image', blockRoot: true }, { pattern: /\.(woff2?|ttf|otf|eot)$/, directories: ['assets/fonts/', 'fonts/', 'public/fonts/'], type: 'font', blockRoot: true }, // Scripts { pattern: /\.sh$/, directories: ['scripts/', 'bin/'], type: 'shell script', blockRoot: true }, { pattern: /\.ps1$/, directories: ['scripts/', 'bin/'], type: 'PowerShell script', blockRoot: true }, // Data { pattern: /\.sql$/, directories: ['migrations/', 'db/', 'database/'], type: 'SQL file', blockRoot: true }, { pattern: /\.csv$/, directories: ['data/', 'fixtures/', 'test/fixtures/'], type: 'CSV data', blockRoot: true }, ]; /** * Formatter recommendations by file extension */ const FORMATTERS = { '.ts': { name: 'Prettier', command: 'prettier --write', configFile: '.prettierrc' }, '.tsx': { name: 'Prettier', command: 'prettier --write', configFile: '.prettierrc' }, '.js': { name: 'Prettier', command: 'prettier --write', configFile: '.prettierrc' }, '.jsx': { name: 'Prettier', command: 'prettier --write', configFile: '.prettierrc' }, '.json': { name: 'Prettier', command: 'prettier --write', configFile: '.prettierrc' }, '.md': { name: 'Prettier', command: 'prettier --write', configFile: '.prettierrc' }, '.yaml': { name: 'Prettier', command: 'prettier --write', configFile: '.prettierrc' }, '.yml': { name: 'Prettier', command: 'prettier --write', configFile: '.prettierrc' }, '.py': { name: 'Black', command: 'black', configFile: 'pyproject.toml' }, '.go': { name: 'gofmt', command: 'gofmt -w', configFile: undefined }, '.rs': { name: 'rustfmt', command: 'rustfmt', configFile: 'rustfmt.toml' }, '.java': { name: 'google-java-format', command: 'google-java-format -i', configFile: undefined }, '.rb': { name: 'RuboCop', command: 'rubocop -a', configFile: '.rubocop.yml' }, '.php': { name: 'PHP-CS-Fixer', command: 'php-cs-fixer fix', configFile: '.php-cs-fixer.php' }, '.cs': { name: 'dotnet-format', command: 'dotnet format', configFile: '.editorconfig' }, '.swift': { name: 'swift-format', command: 'swift-format -i', configFile: '.swift-format' }, '.kt': { name: 'ktlint', command: 'ktlint -F', configFile: '.editorconfig' }, '.css': { name: 'Prettier', command: 'prettier --write', configFile: '.prettierrc' }, '.scss': { name: 'Prettier', command: 'prettier --write', configFile: '.prettierrc' }, '.html': { name: 'Prettier', command: 'prettier --write', configFile: '.prettierrc' }, }; /** * Linter recommendations by file extension */ const LINTERS = { '.ts': { name: 'ESLint', command: 'eslint --fix', configFile: '.eslintrc' }, '.tsx': { name: 'ESLint', command: 'eslint --fix', configFile: '.eslintrc' }, '.js': { name: 'ESLint', command: 'eslint --fix', configFile: '.eslintrc' }, '.jsx': { name: 'ESLint', command: 'eslint --fix', configFile: '.eslintrc' }, '.py': { name: 'Pylint', command: 'pylint', configFile: 'pylintrc' }, '.go': { name: 'golangci-lint', command: 'golangci-lint run', configFile: '.golangci.yml' }, '.rs': { name: 'Clippy', command: 'cargo clippy', configFile: 'Cargo.toml' }, '.rb': { name: 'RuboCop', command: 'rubocop', configFile: '.rubocop.yml' }, '.php': { name: 'PHPStan', command: 'phpstan analyse', configFile: 'phpstan.neon' }, }; /** * Naming convention checks */ const NAMING_CONVENTIONS = [ { pattern: /^[a-z][a-z0-9-]*\.[a-z]+$/, convention: 'kebab-case', fileTypes: /\.(tsx?|jsx?|css|scss)$/ }, { pattern: /^[a-z][a-z0-9_]*\.[a-z]+$/, convention: 'snake_case', fileTypes: /\.py$/ }, { pattern: /^[a-z][a-z0-9_]*\.[a-z]+$/, convention: 'snake_case', fileTypes: /\.go$/ }, { pattern: /^[A-Z][a-zA-Z0-9]*\.[a-z]+$/, convention: 'PascalCase', fileTypes: /\.(java|kt|cs)$/ }, ]; /** * File Organization Hook Manager */ export class FileOrganizationHook { registry; projectRoot = process.cwd(); constructor(registry) { this.registry = registry; this.registerHooks(); } /** * Register file organization hooks */ registerHooks() { // Pre-edit hook this.registry.register(HookEvent.PreEdit, this.analyzeFileOperation.bind(this), HookPriority.High, { name: 'file-organization:pre-edit' }); // Pre-write hook this.registry.register(HookEvent.PreWrite, this.analyzeFileOperation.bind(this), HookPriority.High, { name: 'file-organization:pre-write' }); } /** * Analyze file operation for organization issues */ async analyzeFileOperation(context) { const fileInfo = context.file; if (!fileInfo) { return this.createResult(false, []); } const filePath = fileInfo.path; const fileName = path.basename(filePath); const dirName = path.dirname(filePath); const ext = path.extname(filePath); const issues = []; const warnings = []; let blocked = false; let blockReason; let suggestedPath; let suggestedDirectory; // Check if writing to root folder const isRootWrite = this.isRootDirectory(dirName); const fileTypeInfo = this.getFileTypeInfo(fileName); if (isRootWrite && fileTypeInfo?.blockRoot) { blocked = true; blockReason = `Source files should not be written to root folder. Suggested: ${fileTypeInfo.directories[0]}`; suggestedDirectory = fileTypeInfo.directories[0]; suggestedPath = path.join(suggestedDirectory, fileName); issues.push({ type: 'root-write', severity: 'error', description: `${fileTypeInfo.type} files should not be in the root directory`, suggestedFix: `Move to ${suggestedDirectory}`, }); } // Check if file is in wrong directory if (!isRootWrite && fileTypeInfo) { const isInCorrectDir = fileTypeInfo.directories.some(dir => this.normalizePath(dirName).includes(this.normalizePath(dir))); if (!isInCorrectDir) { issues.push({ type: 'wrong-directory', severity: 'warning', description: `${fileTypeInfo.type} typically goes in: ${fileTypeInfo.directories.join(' or ')}`, suggestedFix: `Consider moving to ${fileTypeInfo.directories[0]}`, }); warnings.push(`File may be in wrong directory. Expected: ${fileTypeInfo.directories.join(' or ')}`); } } // Check naming convention const namingIssue = this.checkNamingConvention(fileName, ext); if (namingIssue) { issues.push(namingIssue); warnings.push(namingIssue.description); } // Get formatter recommendation const formatter = this.getFormatterRecommendation(ext); // Get linter recommendation const linter = this.getLinterRecommendation(ext); return { success: true, blocked, blockReason, suggestedPath, suggestedDirectory, formatter, linter, fileType: fileTypeInfo?.type, warnings: warnings.length > 0 ? warnings : undefined, issues: issues.length > 0 ? issues : undefined, abort: blocked, data: blocked ? undefined : { file: { ...fileInfo, path: suggestedPath || filePath, }, metadata: { formatter: formatter?.command, linter: linter?.command, }, }, }; } /** * Check if directory is root */ isRootDirectory(dirName) { const normalized = this.normalizePath(dirName); return normalized === '.' || normalized === './' || normalized === '' || normalized === this.normalizePath(this.projectRoot); } /** * Normalize path for comparison */ normalizePath(p) { return p.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, ''); } /** * Get file type information */ getFileTypeInfo(fileName) { for (const info of FILE_TYPE_DIRECTORIES) { if (info.pattern.test(fileName)) { return { directories: info.directories, type: info.type, blockRoot: info.blockRoot, }; } } return null; } /** * Check naming convention */ checkNamingConvention(fileName, ext) { for (const rule of NAMING_CONVENTIONS) { if (rule.fileTypes.test(ext)) { const baseName = fileName.replace(ext, ''); if (!rule.pattern.test(fileName)) { return { type: 'naming-convention', severity: 'info', description: `File name may not follow ${rule.convention} convention`, suggestedFix: `Consider renaming to follow ${rule.convention}`, }; } } } return null; } /** * Get formatter recommendation */ getFormatterRecommendation(ext) { return FORMATTERS[ext]; } /** * Get linter recommendation */ getLinterRecommendation(ext) { return LINTERS[ext]; } /** * Create result object */ createResult(blocked, issues) { return { success: true, blocked, issues: issues.length > 0 ? issues : undefined, }; } /** * Manually analyze a file path */ async analyze(filePath) { const context = { event: HookEvent.PreEdit, timestamp: new Date(), file: { path: filePath, operation: 'write', }, }; return this.analyzeFileOperation(context); } /** * Get suggested directory for a file */ getSuggestedDirectory(fileName) { const info = this.getFileTypeInfo(fileName); return info?.directories[0] || null; } /** * Check if a file path would be blocked */ wouldBlock(filePath) { const fileName = path.basename(filePath); const dirName = path.dirname(filePath); const isRoot = this.isRootDirectory(dirName); const info = this.getFileTypeInfo(fileName); return isRoot && (info?.blockRoot ?? false); } /** * Set project root directory */ setProjectRoot(root) { this.projectRoot = root; } /** * Get all formatter recommendations */ getAllFormatters() { return { ...FORMATTERS }; } /** * Get all linter recommendations */ getAllLinters() { return { ...LINTERS }; } } /** * Create file organization hook */ export function createFileOrganizationHook(registry) { return new FileOrganizationHook(registry); } //# sourceMappingURL=file-organization.js.map