UNPKG

frontend-standards-checker

Version:

A comprehensive frontend standards validation tool with TypeScript support

1,071 lines โ€ข 105 kB
import fs from 'fs'; import path from 'path'; import { isReactNativeProject } from '../utils/file-scanner.js'; import { ConfigLoaderHelper } from '../helpers/configLoader.helper.js'; /** * Configuration loader and manager * Handles loading custom rules and project configuration */ export class ConfigLoader { rootDir; logger; configFileName; helper; constructor(rootDir, logger) { this.rootDir = rootDir; this.logger = logger; this.configFileName = 'checkFrontendStandards.config.mjs'; this.helper = new ConfigLoaderHelper(logger); } /** * Resolve the configuration file path * @param customConfigPath Optional custom config path * @returns Resolved config file path */ resolveConfigPath(customConfigPath = null) { if (customConfigPath) { return path.isAbsolute(customConfigPath) ? customConfigPath : path.resolve(this.rootDir, customConfigPath); } return path.resolve(this.rootDir, this.configFileName); } /** * Load configuration from file or use defaults * @param customConfigPath Optional custom config path * @returns Configuration object */ async load(customConfigPath = null) { const configPath = this.resolveConfigPath(customConfigPath); if (!fs.existsSync(configPath)) { this.logger.info('๐Ÿ“‹ Using default configuration'); return this.getDefaultConfig(); } try { this.logger.info(`๐Ÿ“‹ Loading configuration from: ${configPath}`); const customConfig = await this.helper.tryLoadConfig(configPath); if (customConfig) { return this.mergeWithDefaults(customConfig); } } catch (error) { this.logger.warn(`Failed to load config from ${configPath}:`, error instanceof Error ? error.message : String(error)); } this.logger.info('๐Ÿ“‹ Using default configuration'); return this.getDefaultConfig(); } /** * Merge custom configuration with defaults * @param customConfig Custom configuration * @returns Merged configuration */ mergeWithDefaults(customConfig) { const defaultConfig = this.getDefaultConfig(); const defaultRules = this.getDefaultRules(); if (typeof customConfig === 'function') { return this.handleFunctionConfig(customConfig, defaultConfig, defaultRules); } if (Array.isArray(customConfig)) { return this.handleArrayConfig(customConfig, defaultConfig, defaultRules); } if (customConfig && typeof customConfig === 'object') { return this.handleObjectConfig(customConfig, defaultConfig, defaultRules); } return defaultConfig; } /** * Handle function-based configuration */ handleFunctionConfig(customConfig, defaultConfig, defaultRules) { const result = customConfig(Object.values(defaultRules).flat()); if (Array.isArray(result)) { return { ...defaultConfig, rules: result, }; } return result; } /** * Handle array-based configuration */ handleArrayConfig(customConfig, defaultConfig, defaultRules) { return { ...defaultConfig, rules: [...Object.values(defaultRules).flat(), ...customConfig], }; } /** * Handle object-based configuration */ handleObjectConfig(customConfig, defaultConfig, defaultRules) { // Handle merge=false case if (customConfig.merge === false && Array.isArray(customConfig.rules)) { return { ...defaultConfig, rules: customConfig.rules, }; } // Handle array rules case if (Array.isArray(customConfig.rules)) { return { ...defaultConfig, ...customConfig, rules: [...Object.values(defaultRules).flat(), ...customConfig.rules], }; } // Determine final rules based on rules property const finalRules = this.determineFinalRules(customConfig, defaultRules); return { ...defaultConfig, ...customConfig, rules: finalRules, }; } /** * Determine final rules based on configuration */ determineFinalRules(customConfig, defaultRules) { if (customConfig.rules && typeof customConfig.rules === 'function') { // Execute the function with default rules const result = customConfig.rules(Object.values(defaultRules).flat()); return Array.isArray(result) ? result : Object.values(defaultRules).flat(); } if (customConfig.rules && !Array.isArray(customConfig.rules)) { // Convert object format to array format return this.convertObjectRulesToArray(customConfig.rules, defaultRules); } if (Array.isArray(customConfig.rules)) { return customConfig.rules; } return Object.values(defaultRules).flat(); } /** * Check if a file is a configuration file that should be excluded from validation * @param filePath The file path to check * @returns True if the file is a configuration file */ isConfigFile(filePath) { const fileName = path.basename(filePath); // Common configuration file patterns const configPatterns = [ /\.config\.(js|ts|mjs|cjs|json)$/, /^(jest|vite|webpack|tailwind|next|eslint|prettier|babel|rollup|tsconfig)\.config\./, /^(vitest|nuxt|quasar)\.config\./, /^tsconfig.*\.json$/, /^\.eslintrc/, /^\.prettierrc/, /^babel\.config/, /^postcss\.config/, /^stylelint\.config/, /^cypress\.config/, /^playwright\.config/, /^storybook\.config/, /^metro\.config/, /^expo\.config/, ]; return configPatterns.some((pattern) => pattern.test(fileName)); } /** * Get default configuration * @returns Default configuration */ getDefaultConfig() { const defaultRules = this.getDefaultRules(); return { merge: true, onlyChangedFiles: true, // By default, only check changed files rules: Object.values(defaultRules).flat(), zones: { includePackages: false, customZones: [], }, extensions: ['.js', '.ts', '.jsx', '.tsx'], ignorePatterns: [ 'node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.nuxt', 'out', '*.log', '*.lock', '.env*', '.DS_Store', 'Thumbs.db', ], }; } /** * Get default rules organized by category * @returns Default rules structure */ getDefaultRules() { return { structure: this.getStructureRules(), naming: this.getNamingRules(), content: this.getContentRules(), style: this.getStyleRules(), documentation: this.getDocumentationRules(), typescript: this.getTypeScriptRules(), react: this.getReactRules(), imports: this.getImportRules(), performance: this.getPerformanceRules(), accessibility: this.getAccessibilityRules(), }; } /** * Get structure validation rules * @returns Structure rules */ getStructureRules() { return [ { name: 'Folder structure', category: 'structure', severity: 'warning', check: (_content, filePath) => { // Check for basic folder structure requirements const pathParts = filePath.split('/'); const isInSrc = pathParts.includes('src'); const hasProperStructure = isInSrc && pathParts.length > 2; return (!hasProperStructure && filePath.includes('/components/') && !filePath.includes('index.')); }, message: 'Components should follow proper folder structure within src/', }, { name: 'Src structure', category: 'structure', severity: 'warning', check: (_content, filePath) => { const requiredFolders = ['components', 'utils', 'types']; const isRootFile = !filePath.includes('/') || filePath.split('/').length === 2; return (isRootFile && !filePath.includes('config') && !requiredFolders.some((folder) => filePath.includes(folder))); }, message: 'Files should be organized in proper src/ structure', }, { name: 'Component size limit', category: 'structure', severity: 'warning', check: (content, filePath) => { if (!filePath.endsWith('.tsx') && !filePath.endsWith('.jsx')) return false; const lines = content.split('\n').length; return lines > 200; // Components should not exceed 200 lines }, message: 'Component is too large (>200 lines). Consider breaking it into smaller components.', }, { name: 'No circular dependencies', category: 'structure', severity: 'warning', check: (content, filePath) => { const fileName = path.basename(filePath); const fileDir = path.dirname(filePath); // Skip configuration files if (fileName.includes('.config.') || fileName.startsWith('jest.config.') || fileName.startsWith('vite.config.') || fileName.startsWith('webpack.config.') || fileName.startsWith('tailwind.config.') || fileName.startsWith('next.config.') || fileName.startsWith('tsconfig.') || fileName.startsWith('eslint.config.') || fileName.includes('test.config.') || fileName.includes('spec.config.')) { return false; } // Detect potential circular dependencies (improved: only if import points to this file itself) const imports = content.match(/import.*from\s+['"]([^'"]+)['"]/g) || []; return imports.some((imp) => { const importRegex = /from\s+['"]([^'"]+)['"]/; const importMatch = importRegex.exec(imp); if (importMatch?.[1]) { const importPath = importMatch[1]; // Only check relative imports if (importPath.startsWith('./') || importPath.startsWith('../')) { // Resolve the import path relative to the current file const resolvedImport = path.resolve(fileDir, importPath); const resolvedImportNoExt = resolvedImport.replace(/\.[^.]+$/, ''); const filePathNoExt = filePath.replace(/\.[^.]+$/, ''); // Only mark as circular if the import points to this file itself if (resolvedImportNoExt === filePathNoExt) { return true; } } } return false; }); }, message: 'Potential circular dependency detected. Review import structure.', }, { name: 'Missing test files', category: 'structure', severity: 'info', // Changed from 'warning' to 'info' check: (_content, filePath) => { // Skip if this is already a test file if (filePath.includes('__test__') || filePath.includes('.test.') || filePath.includes('.spec.')) { return false; } // Only apply to main components (not all files) // Only for files ending in Component.tsx or being main hooks if (!filePath.endsWith('Component.tsx') && !filePath.endsWith('.hook.ts') && !filePath.endsWith('.helper.ts') && !(filePath.includes('/components/') && filePath.endsWith('index.tsx'))) { return false; } // Skip configuration, types, constants, etc. files if (filePath.includes('/types/') || filePath.includes('/constants/') || filePath.includes('/enums/') || filePath.includes('/config/') || filePath.includes('/styles/') || filePath.includes('layout.tsx') || filePath.includes('page.tsx') || filePath.includes('not-found.tsx') || filePath.includes('global-error.tsx') || filePath.includes('instrumentation.ts') || filePath.includes('next.config.') || filePath.includes('tailwind.config.') || filePath.includes('jest.config.')) { return false; } const fileName = path.basename(filePath); const dirPath = path.dirname(filePath); const testDir = path.join(dirPath, '__tests__'); const testFileName = fileName.replace(/\.(tsx?|jsx?)$/, '.test.$1'); const testFilePath = path.join(testDir, testFileName); // Check if corresponding test file exists try { return !require('fs').existsSync(testFilePath); } catch { return true; } }, message: 'Important components and hooks should have corresponding test files', }, { name: 'Test file naming convention', category: 'naming', severity: 'error', check: (_content, filePath) => { // Only check test files if (!filePath.includes('__test__') && !filePath.includes('.test.') && !filePath.includes('.spec.')) { return false; } const fileName = path.basename(filePath); // Test files should follow *.test.tsx or *.spec.tsx pattern return !/\.(test|spec)\.(tsx?|jsx?)$/.test(fileName); }, message: 'Test files should follow *.test.tsx or *.spec.tsx naming convention', }, { name: 'Missing index.ts in organization folders', category: 'structure', severity: 'warning', check: (_content, filePath) => { if (this.isConfigFile(filePath)) { return false; } const organizationFolders = [ '/components/', '/types/', '/enums/', '/hooks/', '/constants/', '/styles/', '/helpers/', '/utils/', '/lib/', ]; const matchedFolder = organizationFolders.find((folder) => filePath.includes(folder)); if (!matchedFolder) return false; const orgFolderIndex = filePath.indexOf(matchedFolder); const relativePathAfterOrgFolder = filePath.slice(orgFolderIndex + matchedFolder.length); const firstLevelFolder = relativePathAfterOrgFolder.split('/')[0]; // If the file is directly inside the organizational folder, no index is required if (!firstLevelFolder || firstLevelFolder.endsWith('.ts') || firstLevelFolder.endsWith('.tsx')) { return false; } const baseFolder = path.join(filePath.slice(0, orgFolderIndex + matchedFolder.length), firstLevelFolder); const indexTsPath = path.join(baseFolder, 'index.ts'); const indexTsxPath = path.join(baseFolder, 'index.tsx'); const hasIndexTs = fs.existsSync(indexTsPath); const hasIndexTsx = fs.existsSync(indexTsxPath); return !hasIndexTs && !hasIndexTsx; }, message: 'Organization subfolders (like /components/Foo/) should contain an index.ts or index.tsx file for exports.', }, ]; } /** * Get naming validation rules * @returns Naming rules */ getNamingRules() { return [ { name: 'Constant export naming UPPERCASE', category: 'naming', severity: 'error', check: (content, filePath) => { // Solo aplicar a archivos .constant.ts if (!filePath.endsWith('.constant.ts')) { return []; } const lines = content.split('\n'); const violationLines = []; // Buscar export const NOMBRE = ... const exportConstRegex = /^\s*export\s+const\s+(\w+)\s*=/; lines.forEach((line, idx) => { const match = exportConstRegex.exec(line); if (match && typeof match[1] === 'string') { const constName = match[1]; // Debe ser UPPERCASE (letras, nรบmeros y guiones bajos) if (!/^([A-Z0-9_]+)$/.test(constName)) { violationLines.push(idx + 1); } } }); return violationLines; }, message: 'Constant names exported in .constant.ts files must be UPPERCASE (e.g., export const DEFAULT_MIN_WAIT_TIME)', }, { name: 'Component naming', category: 'naming', severity: 'error', check: (_content, filePath) => { // Skip configuration files if (this.isConfigFile(filePath)) { return false; } const fileName = path.basename(filePath); const dirName = path.basename(path.dirname(filePath)); // Skip hook files - they have their own naming rule if (fileName.includes('.hook.')) { return false; } // Skip files directly in /hooks/ directory (but not subdirectories like hooks/constants/) if (filePath.includes('/hooks/')) { const pathParts = filePath.split('/'); const hooksIndex = pathParts.lastIndexOf('hooks'); const afterHooksPath = pathParts.slice(hooksIndex + 1); // Only skip if it's directly in hooks folder (not in subdirectories) if (afterHooksPath.length === 1) { return false; } } // Skip helper files - they have their own naming rule if (filePath.includes('/helpers/') || fileName.includes('.helper.')) { return false; } // Component files should be PascalCase if (filePath.endsWith('.tsx') && filePath.includes('/components/')) { if (fileName === 'index.tsx') { // For index.tsx files, the parent directory should be PascalCase return !/^[A-Z][a-zA-Z0-9]*$/.test(dirName); } else { // Direct component files should be PascalCase const componentName = fileName.replace('.tsx', ''); return !/^[A-Z][a-zA-Z0-9]*$/.test(componentName); } } return false; }, message: 'Component files should start with uppercase letter (PascalCase). For index.tsx files, the parent directory should be PascalCase.', }, { name: 'Hook naming', category: 'naming', severity: 'error', check: (_content, filePath) => { // Skip configuration files if (this.isConfigFile(filePath)) { return false; } const fileName = path.basename(filePath); // Allow index.ts/index.tsx files in hooks directories (used for exporting hooks) if (fileName === 'index.ts' || fileName === 'index.tsx') { return false; } // Hook files should follow useHookName.hook.ts pattern (PascalCase) if (fileName.includes('.hook.')) { const hookPattern = /^use[A-Z][a-zA-Z0-9]*\.hook\.(ts|tsx)$/; if (!hookPattern.test(fileName)) { return true; } } // Files directly in /hooks/ directory should be hooks (not in subdirectories like constants, types, etc.) if (filePath.includes('/hooks/') && !fileName.includes('.hook.')) { // Skip if this is in a subdirectory of hooks (like hooks/constants/, hooks/types/, etc.) const pathParts = filePath.split('/'); const hooksIndex = pathParts.lastIndexOf('hooks'); const afterHooksPath = pathParts.slice(hooksIndex + 1); // If there are more than 1 path segments after 'hooks', it's in a subdirectory if (afterHooksPath.length > 1) { return false; // Skip files in subdirectories } // Files directly in hooks folder should follow hook naming const hookPattern = /^use[A-Z][a-zA-Z0-9]*\.hook\.(ts|tsx)$/; if (!hookPattern.test(fileName)) { return true; } } return false; }, message: 'Hook files should follow "useHookName.hook.ts" pattern with PascalCase (e.g., useFormInputPassword.hook.tsx, useApiData.hook.ts)', }, { name: 'Type naming', category: 'naming', severity: 'error', check: (_content, filePath) => { // Skip configuration files if (this.isConfigFile(filePath)) { return false; } const fileName = path.basename(filePath); const normalizedPath = filePath.replace(/\\/g, '/'); // Allow index.ts/index.tsx files in types directories (used for exporting types) if (fileName === 'index.ts' || fileName === 'index.tsx') { return false; } // Skip all .d.ts files (TypeScript declaration files follow different naming conventions) if (fileName.endsWith('.d.ts')) { return false; } // Type files should be camelCase and end with .type.ts or .types.ts if (normalizedPath.includes('/types/') || fileName.endsWith('.type.ts') || fileName.endsWith('.types.ts')) { const typePattern = /^[a-z][a-zA-Z0-9]*(\.[a-z][a-zA-Z0-9]*)*\.types?\.ts$/; if (!typePattern.test(fileName)) { return true; } } return false; }, message: 'Type files should be camelCase and end with .type.ts (index.ts files are allowed for exports)', }, { name: 'Constants naming', category: 'naming', severity: 'error', check: (_content, filePath) => { const fileName = path.basename(filePath); // Skip index files if (fileName === 'index.ts') { return false; } // Reject files ending in constants.ts (plural) - must be constant.ts (singular) if (fileName.endsWith('constants.ts')) { return true; } // Constants files should be camelCase and end with .constant.ts if (filePath.includes('/constants/') || fileName.endsWith('.constant.ts')) { const constantPattern = /^[a-z][a-zA-Z0-9]*\.constant\.ts$/; if (!constantPattern.test(fileName)) { return true; } } return false; }, message: 'Constants files should be camelCase and end with .constant.ts (not constants.ts)', }, { name: 'Helper naming', category: 'naming', severity: 'error', check: (_content, filePath) => { const fileName = path.basename(filePath); // Skip index files if (fileName === 'index.ts' || fileName === 'index.tsx') { return false; } // Skip all .d.ts files (TypeScript declaration files follow different naming conventions) if (fileName.endsWith('.d.ts')) { return false; } // Helper files should be camelCase and end with .helper.ts or .helper.tsx if (filePath.includes('/helpers/') || fileName.endsWith('.helper.ts') || fileName.endsWith('.helper.tsx')) { const helperPattern = /^[a-z][a-zA-Z0-9]*\.helper\.(ts|tsx)$/; if (!helperPattern.test(fileName)) { return true; } } return false; }, message: 'Helper files should be camelCase and end with .helper.ts or .helper.tsx', }, { name: 'Style naming', category: 'naming', severity: 'error', check: (_content, filePath) => { const fileName = path.basename(filePath); // Skip index files - they are organization files, not style files if (fileName === 'index.ts' || fileName === 'index.tsx') { return false; } // Style files should be camelCase and end with .style.ts if (filePath.includes('/styles/') || fileName.endsWith('.style.ts')) { const stylePattern = /^[a-z][a-zA-Z0-9]*\.style\.ts$/; if (!stylePattern.test(fileName)) { return true; } } return false; }, message: 'Style files should be camelCase and end with .style.ts', }, { name: 'Assets naming', category: 'naming', severity: 'error', check: (_content, filePath) => { const fileName = path.basename(filePath); const fileExt = path.extname(fileName); const baseName = fileName.replace(fileExt, ''); // Assets should follow kebab-case (service-error.svg) if (filePath.includes('/assets/') && /\.(svg|png|jpg|jpeg|gif|webp|ico)$/.test(fileName)) { if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(baseName)) { return true; } } return false; }, message: 'Assets should follow kebab-case naming (e.g., service-error.svg)', }, { name: 'Folder naming convention', category: 'naming', severity: 'error', check: (_content, filePath) => { // Skip configuration files if (this.isConfigFile(filePath)) { return false; } // Check for incorrect singular folder names const incorrectFolders = [ '/helper/', '/hook/', '/type/', '/constant/', '/enum/', ]; return incorrectFolders.some((folder) => filePath.includes(folder)); }, message: 'Use plural folder names: helpers, hooks, types, constants, enums (not singular)', }, { name: 'Directory naming convention', category: 'naming', severity: 'info', check: (_content, filePath) => { // Skip configuration files if (this.isConfigFile(filePath)) { return false; } const pathParts = filePath.split('/'); // Check each directory part for proper naming for (let i = 0; i < pathParts.length - 1; i++) { const dirName = pathParts[i]; // Skip empty parts and common framework directories if (!dirName || [ 'src', 'app', 'pages', 'public', 'components', 'hooks', 'utils', 'lib', 'styles', 'types', 'constants', 'helpers', 'assets', 'enums', 'config', 'context', 'i18n', ].includes(dirName)) { continue; } // Skip route directories (Next.js app router) - these can be kebab-case if (pathParts.includes('app') && /^[a-z0-9]+(-[a-z0-9]+)*$/.test(dirName)) { continue; } // Skip common framework patterns if ((dirName.startsWith('(') && dirName.endsWith(')')) || // Next.js route groups (dirName.startsWith('[') && dirName.endsWith(']')) || // Next.js dynamic routes (dirName.includes('-') && pathParts.includes('app')) // kebab-case in app router ) { continue; } // General directories should be camelCase or PascalCase if (!/^[a-z][a-zA-Z0-9]*$/.test(dirName) && !/^[A-Z][a-zA-Z0-9]*$/.test(dirName)) { return true; } } return false; }, message: 'Directories should follow camelCase or PascalCase convention (kebab-case allowed for Next.js routes)', }, { name: 'Interface naming with I prefix', category: 'naming', severity: 'error', check: (content) => { // Omit any file that contains 'declare module' if (/declare\s+module/.test(content)) { return []; } // Check for interface declarations that don't start with I const lines = content.split('\n'); const violationLines = []; const interfaceRegex = /^\s*interface\s+([A-Z][a-zA-Z0-9]*)/; lines.forEach((line, idx) => { const match = interfaceRegex.exec(line); if (match && typeof match[1] === 'string') { const interfaceName = match[1]; // Interface must start with "I" followed by PascalCase if (!interfaceName.startsWith('I') || !/^I[A-Z][a-zA-Z0-9]*$/.test(interfaceName)) { violationLines.push(idx + 1); } } }); return violationLines; }, message: 'Interfaces must be prefixed with "I" followed by PascalCase (e.g., IGlobalStateHashProviderProps)', }, ]; } /** * Get content validation rules * @returns Content rules */ getContentRules() { // Variables and functions for the circular dependency rule const helper = this.helper; let dependencyGraph = {}; let graphBuiltFor = null; function hasCircularDependency(startFile, currentFile, visited) { if (!dependencyGraph[currentFile]) return false; for (const dep of dependencyGraph[currentFile]) { if (dep === startFile) return true; if (!visited.has(dep)) { visited.add(dep); if (hasCircularDependency(startFile, dep, visited)) return true; } } return false; } return [ { name: 'No console.log', category: 'content', severity: 'error', check: (content) => { const lines = content.split('\n'); const violationLines = []; let inJSDoc = false; let inMultiLineComment = false; lines.forEach((line, idx) => { const trimmed = line.trim(); // Detect start/end of JSDoc if (trimmed.startsWith('/**')) inJSDoc = true; if (inJSDoc && trimmed.includes('*/')) { inJSDoc = false; return; } // Detect start/end of multiline comment (not JSDoc) if (trimmed.startsWith('/*') && !trimmed.startsWith('/**')) inMultiLineComment = true; if (inMultiLineComment && trimmed.includes('*/')) { inMultiLineComment = false; return; } // Skip if inside any comment block if (inJSDoc || inMultiLineComment) return; // Skip single line comments if (trimmed.startsWith('//')) return; // Only flag true console.log outside comments if (/console\.log\s*\(/.test(line)) { violationLines.push(idx + 1); } }); return violationLines; }, message: 'The use of console.log is not allowed. Remove debug statements from production code.', }, // ...rest of rules... { name: 'No circular dependencies', category: 'content', severity: 'warning', check: (_content, filePath) => { const extensions = ['.js', '.ts', '.jsx', '.tsx']; // Only rebuild the graph if for a new root file if (graphBuiltFor !== filePath) { helper.buildDependencyGraph(filePath, extensions, dependencyGraph); graphBuiltFor = filePath; } return hasCircularDependency(filePath, filePath, new Set([filePath])); }, message: 'Potential circular dependency detected. Refactor to avoid circular imports (direct or indirect).', }, { name: 'No inline styles', category: 'content', check: (content, filePath) => { // Skip files inside Svg folders for React Native projects const isRNProject = isReactNativeProject(filePath); if (isRNProject && /\/Svg\//.test(filePath)) { return []; } // Detect inline styles in JSX/TSX const lines = content.split('\n'); const violationLines = []; const inlineStyleRegex = /style\s*=\s*\{\{[^}]*\}\}/; let inJSDoc = false; let inMultiLineComment = false; lines.forEach((line, idx) => { const trimmed = line.trim(); // Detect start/end of JSDoc if (trimmed.startsWith('/**')) inJSDoc = true; if (inJSDoc && trimmed.includes('*/')) { inJSDoc = false; return; } // Detect start/end of multiline comment (not JSDoc) if (trimmed.startsWith('/*') && !trimmed.startsWith('/**')) inMultiLineComment = true; if (inMultiLineComment && trimmed.includes('*/')) { inMultiLineComment = false; return; } // Skip if inside any comment block if (inJSDoc || inMultiLineComment) return; // Only flag true inline style objects outside comments if (inlineStyleRegex.test(line)) { violationLines.push(idx + 1); } }); return violationLines; }, message: 'Avoid inline styles, use CSS classes or styled components', }, { name: 'No var', category: 'content', severity: 'error', check: (content) => { const lines = content.split('\n'); const violationLines = []; lines.forEach((line, idx) => { if (/\bvar\s+/.test(line)) { violationLines.push(idx + 1); } }); return violationLines; }, message: 'Use let or const instead of var', }, { name: 'No any type', category: 'typescript', // Severity is determined at runtime in the reporting system severity: 'warning', check: (content, filePath) => { // Detect if it's a React Native project const isRNProject = isReactNativeProject(filePath); // Skip configuration and type declaration files if (this.isConfigFile(filePath)) { return []; } if (content.includes('declare')) return []; // Allow 'any' in props/interfaces for icon/component in RN if (/icon\s*:\s*any|Icon\s*:\s*any|component\s*:\s*any/.test(content)) { return []; } // Search for explicit 'any' (excluding comments) const lines = content.split('\n'); const violationLines = []; lines.forEach((line, idx) => { const trimmed = line.trim(); if (trimmed.startsWith('//') || trimmed.startsWith('*')) return; if (/:\s*any\b|<any>|Array<any>|Promise<any>|\bas\s+any\b/.test(line)) { violationLines.push(idx + 1); } }); // @ts-ignore: Custom property for the reporting system violationLines.severity = isRNProject ? 'warning' : 'error'; return violationLines; }, message: 'Avoid using "any" type. Use specific types or unknown instead', }, { name: 'Next.js Image optimization', category: 'performance', severity: 'warning', check: (content, filePath) => { if (!filePath.endsWith('.tsx') && !filePath.endsWith('.jsx')) return false; const hasImgTag = /<img\s/i.test(content); const hasNextImage = /import.*Image.*from.*next\/image/.test(content); return hasImgTag && !hasNextImage; }, message: 'Use Next.js Image component instead of <img> for better performance', }, { name: 'Image alt text', category: 'accessibility', severity: 'warning', check: (content) => { const lines = content.split('\n'); const violationLines = []; lines.forEach((line, idx) => { const imgRegex = /<img[^>]*>/gi; let match; while ((match = imgRegex.exec(line)) !== null) { const imgTag = match[0]; if (!imgTag.includes('alt=')) { violationLines.push(idx + 1); } } }); return violationLines; }, message: 'Images should have alt text for accessibility', }, { name: 'No alert', category: 'content', severity: 'error', check: (content) => { const lines = content.split('\n'); const violationLines = []; lines.forEach((line, idx) => { // Skip if alert() appears in string literals (inside quotes) const hasAlertInString = /'[^']*\balert\s*\([^']*'/.test(line) || /"[^"]*\balert\s*\([^"]*"/.test(line) || /`[^`]*\balert\s*\([^`]*`/.test(line); // Skip if it's a comment const isComment = /^\s*\/\//.test(line) || /^\s*\/\*/.test(line) || /\*\/\s*$/.test(line); // Skip if it's a rule message const isRuleMessage = /message\s*:\s*['"][^'"]*\balert\s*\([^'"]*['"]/.test(line); // Check for alert( usage if ((/\balert\s*\(/.test(line) || /window\.alert\s*\(/.test(line)) && !hasAlertInString && !isComment && !isRuleMessage) { // Exclude only Alert.alert( (React Native) if (!/Alert\.alert\s*\(/.test(line)) { violationLines.push(idx + 1); } } }); return violationLines; }, message: 'The use of alert() is not allowed. Use proper notifications or toast messages instead.', }, { name: 'No hardcoded URLs', category: 'content', severity: 'error', check: (content, filePath) => { // Skip configuration files if (this.isConfigFile(filePath)) { return []; } // Exception: allow URLs in .constant.ts files if (filePath && /\.(constant|constants)\.ts$/.test(filePath)) { return []; } // Skip setup and test files const isSetupFile = /(setup|mock|__tests__|\.test\.|\.spec\.|instrumentation|sentry)/.test(filePath); if (isSetupFile) { return []; } // Detect if this is a React Native project const isRNProject = isReactNativeProject(filePath); // For React Native projects, be more permisivo con componentes SVG if (isRNProject) { // Skip SVG components that often have legitimate xmlns URLs if (filePath.includes('/assets/') || filePath.includes('/Svg/') || filePath.includes('.svg')) { return []; } } // Check for hardcoded URLs but exclude common valid cases const violationLines = []; const lines = content.split('\n'); lines.forEach((line, idx) => { const urlMatch = /https?:\/\/[^\s"'`]+/.exec(line); if (urlMatch) { const beforeUrl = line.substring(0, urlMatch.index); const isComment = /\/\//.test(beforeUrl) || /\/\*/.test(beforeUrl); if (!isComment) { violationLines.push(idx + 1); } } }); return violationLines; }, message: 'No hardcoded URLs allowed. Use environment variables or constants.', }, { name: 'Must use async/await', category: 'content', severity: 'warning', check: (content) => { // Check for .then() usage without async/await in the same context const hasThen = /\.then\s*\(/.test(content); const hasAsyncAwait = /async|await/.test(content); return hasThen && !hasAsyncAwait; }, message: 'Prefer async/await over .then() for bet