328 lines
14 KiB
JavaScript
328 lines
14 KiB
JavaScript
/**
|
|
* 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
|