frontend-standards-checker
Version:
A comprehensive frontend standards validation tool with TypeScript support
1,071 lines โข 105 kB
JavaScript
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