frontend-standards-checker
Version:
A comprehensive frontend standards validation tool with TypeScript support
972 lines • 41 kB
JavaScript
import fs from 'fs';
import path from 'path';
import * as acorn from 'acorn';
import * as acornWalk from 'acorn-walk';
import { isReactNativeProject } from '../utils/file-scanner.js';
// Removed duplicate type import from body
import { isConfigOrConstantsFile, shouldProcessFile, findFunctionMatch, validateFunctionName, createNoFunctionError, shouldSkipLine, detectFunctionDeclaration, getFunctionName, shouldSkipFunction, analyzeFunctionComplexity, hasProperComments, createCommentError, } from '../helpers/index.js';
// Naming conventions by file type
const NAMING_RULES = [
{
dir: 'components',
regex: /^[A-Z][a-zA-Z0-9]+\.tsx$/,
desc: 'Components must be in PascalCase and end with .tsx',
},
{
dir: 'hooks',
regex: /^use[A-Z][a-zA-Z0-9]*\.hook\.(ts|tsx)$/,
desc: 'Hooks must start with use followed by PascalCase and end with .hook.ts or .hook.tsx',
},
{
dir: 'constants',
regex: /^[a-z][a-zA-Z0-9]*\.constant\.ts$/,
desc: 'Constants must be camelCase and end with .constant.ts',
},
{
dir: 'helper',
regex: /^[a-z][a-zA-Z0-9]*\.helper\.ts$/,
desc: 'Helpers must be camelCase and end with .helper.ts',
},
{
dir: 'helpers',
regex: /^[a-z][a-zA-Z0-9]*\.helper\.ts$/,
desc: 'Helpers must be camelCase and end with .helper.ts',
},
{
dir: 'types',
regex: /^[a-z][a-zA-Z0-9]*(\.[a-z][a-zA-Z0-9]*)*\.type\.ts$/,
desc: 'Types must be camelCase and end with .type.ts (may include additional extensions like .provider.type.ts)',
},
{
dir: 'styles',
regex: /^[a-z][a-zA-Z0-9]*\.style\.ts$/,
desc: 'Styles must be camelCase and end with .style.ts',
},
{
dir: 'enums',
regex: /^[a-z][a-zA-Z0-9]*\.enum\.ts$/,
desc: 'Enums must be camelCase and end with .enum.ts',
},
{
dir: 'assets',
regex: /^[a-z0-9]+(-[a-z0-9]+)*\.(svg|png|jpg|jpeg|gif|webp|ico)$/,
desc: 'Assets must be in kebab-case (e.g., service-error.svg)',
},
];
// Keep track of flagged directories to avoid duplicate reports
const flaggedDirectories = new Set();
/**
* Check for inline styles
*/
export function checkInlineStyles(content, filePath) {
const lines = content.split('\n');
const errors = [];
// Detect if this is a React Native project
const isRNProject = isReactNativeProject(filePath);
// For React Native projects, be more permissive with SVG components
if (isRNProject) {
// Skip inline style validation for SVG components in React Native
if (filePath.includes('/assets/Svg/') ||
filePath.includes('/Svg/') ||
filePath.includes('.svg')) {
return errors;
}
}
lines.forEach((line, idx) => {
if (/style\s*=\s*\{\{[^}]*\}\}/.test(line)) {
errors.push({
rule: 'No inline styles',
message: 'Avoid inline styles, use CSS classes or styled components',
filePath: filePath,
line: idx + 1,
severity: 'error',
category: 'style',
});
}
});
return errors;
}
/**
* Check for commented code
*/
export function checkCommentedCode(content, filePath) {
const lines = content.split('\n');
const errors = [];
let inJSDoc = false;
let inMultiLineComment = false;
lines.forEach((line, idx) => {
const trimmedLine = line.trim();
// Track JSDoc comment state
if (/^\s*\/\*\*/.test(line)) {
inJSDoc = true;
return;
}
if (inJSDoc && /\*\//.test(line)) {
inJSDoc = false;
return;
}
if (/^\s*\/\*/.test(line) && !/^\s*\/\*\*/.test(line)) {
inMultiLineComment = true;
return;
}
if (inMultiLineComment && /\*\//.test(line)) {
inMultiLineComment = false;
return;
}
// Skip if we're inside any comment block
if (inJSDoc || inMultiLineComment || /^\s*\*/.test(line)) {
return;
}
// Check for single-line comments that might be commented code
if (/^\s*\/\//.test(line)) {
// Skip if it's a valid comment (not commented code)
const commentContent = trimmedLine.replace(/^\/\/\s*/, '');
// Skip common valid comment patterns
if (
// ESLint/TSLint directives
/eslint|tslint|@ts-|prettier/.test(line) ||
// Task comments
/^(TODO|FIXME|NOTE|HACK|BUG|XXX):/i.test(commentContent) ||
// Documentation comments
/^(This|The|When|If|For|To|Used)/.test(commentContent) ||
/^(Returns?|Handles?|Checks?|Sets?|Gets?)/.test(commentContent) ||
// Explanation comments
/because|since|due to|in order to|to ensure|to avoid|to prevent|explanation|reason/i.test(commentContent) ||
// Configuration comments
/config|setting|option|parameter|default|override/i.test(commentContent) ||
// Single word or very short explanatory comments
/^[A-Z][a-z]*(\s+[a-z]+){0,3}\.?$/.test(commentContent) ||
// Comments with common English sentence patterns
/^(and|or|but|however|therefore|thus|also|additionally)/.test(commentContent) ||
// Comments that end with periods (likely explanations)
commentContent.trim().endsWith('.test') ||
// Comments that are clearly explanatory
(commentContent.length > 50 && !/^[a-z]+\(/.test(commentContent))) {
return;
}
// Check if it looks like commented code
const looksLikeCode =
// Function calls
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*\(/.test(commentContent) ||
// Variable assignments
/^(const|let|var|[a-zA-Z_$][a-zA-Z0-9_$]*)\s*=/.test(commentContent) ||
// Return statements
/^return\s+/.test(commentContent) ||
// Import/export statements
/^(import|export)\s+/.test(commentContent) ||
// Object/array syntax
/^[{[].*}]$/.test(commentContent) ||
// Console statements
/^console\.[a-z]+\s*\(/.test(commentContent) ||
// Control flow statements with parentheses
/^(if|for|while|switch|try|catch)\s*\(/.test(commentContent);
if (looksLikeCode) {
errors.push({
rule: 'Commented code',
message: 'Leaving commented code in the repository is not allowed.',
filePath: filePath,
line: idx + 1,
severity: 'error',
category: 'content',
});
}
}
});
return errors;
}
/**
* Check for hardcoded data
*/
export function checkHardcodedData(content, filePath) {
const lines = content.split('\n');
const errors = [];
// Skip configuration, setup, helper, config, and constants files entirely
if (isConfigOrConstantsFile(filePath)) {
return errors;
}
// Detect if this is a React Native project
const isRNProject = isReactNativeProject(filePath);
// For React Native projects, be more permissive with SVG components and assets
if (isRNProject) {
// Skip SVG components and asset files in React Native projects
if (filePath.includes('/assets/') ||
filePath.includes('/Svg/') ||
filePath.includes('.svg') ||
filePath.includes('/constants/') ||
filePath.includes('/config/')) {
return errors;
}
}
if (filePath.includes('jest.setup.ts')) {
return errors;
}
// Track JSDoc comment blocks
let inJSDocComment = false;
lines.forEach((line, idx) => {
// Track JSDoc comment state
if (/^\s*\/\*\*/.test(line)) {
inJSDocComment = true;
}
if (inJSDocComment && /\*\//.test(line)) {
inJSDocComment = false;
return; // Skip this line as it ends the JSDoc
}
// Skip if we're inside a JSDoc comment
if (inJSDocComment || /^\s*\*/.test(line)) {
return;
}
// Check for hardcoded data but exclude CSS classes, Tailwind classes, and other valid cases
const hasHardcodedPattern = /(['"])[^'"\n]{0,200}?(\d{3,}|lorem|dummy|test|prueba|foo|bar|baz)[^'"\n]{0,200}?\1/.test(line);
const isCSSClass = /className\s*=|class\s*=|style\s*=/.test(line);
// Comprehensive Tailwind CSS pattern matching (include classes with brackets)
const tailwindPatterns = [
/\b[pmwh]-\d+\b/,
/\b(text|bg|border|rounded|shadow)-\d+\b/,
/\b(grid|flex|space|gap)-\d+\b/,
/\b(top|bottom|left|right|inset)-\d+\b/,
/\b(font|leading|tracking|opacity)-\d+\b/,
/\b(scale|rotate|translate)-\d+\b/,
/\b(cursor|select)-\d+\b/,
/\b(transition|duration|ease)-\d+\b/,
/\b(hover|focus|active|disabled)-\d+\b/,
/\b(absolute|relative|fixed|static|sticky|block|inline|hidden|visible)-\d+\b/,
/\b([smd]|lg|xl|2xl):/,
/\b(text|bg|border)-([a-z]+)-(50|100|200|300|400|500|600|700|800|900|950)\b/,
/\b(from|via|to|ring|outline|divide|decoration)-([a-z]+)-(50|100|200|300|400|500|600|700|800|900|950)\b/,
/\b(text|bg|border)-(semantic|custom|brand|primary|secondary|accent|success|warning|error|info|muted|disabled)-[a-z]+-(?:[5-9]0|[1-9]00|950)\b/,
/\b(text|bg|border)-[a-z]+-[a-z]*-?\d{2,3}\b/,
/[a-z-]{1,40}-\[[^\]\n]{1,100}\]/,
];
const isTailwindClass = tailwindPatterns.some((pattern) => pattern.test(line));
const isTestFile = /mock|__test__|\.test\.|\.spec\./.test(filePath);
const isImportStatement = /import.*from/.test(line.trim());
const isURL = /https?:\/.\//.test(line);
const isSingleLineComment = /^\s*\/\//.test(line);
const isMultiLineComment = /^\s*\/\*/.test(line) && /\*\//.test(line);
// Additional check: if line contains common CSS/Tailwind context
const hasClassContext = /(className|class)\s*[:=]\s*['"`]/.test(line) ||
/['"`]\s*\?\s*['"`][^'"`\n]{0,50}\d+[^'"`\n]{0,50}['"`]\s*:\s*['"`]/.test(line);
// Robust translation key detection: allow for whitespace, any key, and any translation function (t, useTranslations, i18n, etc.)
const isTranslationAssignment = /:\s*t\s*\(\s*['"`][^'"`]+['"`]\s*\)/.test(line) ||
/=\s*t\s*\(\s*['"`][^'"`]+['"`]\s*\)/.test(line) ||
/:\s*useTranslations\s*\(\s*['"`][^'"`]+['"`]\s*\)/.test(line) ||
/=\s*useTranslations\s*\(\s*['"`][^'"`]+['"`]\s*\)/.test(line) ||
/:\s*i18n\s*\(\s*['"`][^'"`]+['"`]\s*\)/.test(line) ||
/=\s*i18n\s*\(\s*['"`][^'"`]+['"`]\s*\)/.test(line);
// Check for valid configuration contexts that should not be flagged as hardcoded data
const isValidConfiguration = /(weight|subsets|style|display)\s*:\s*\[/.test(line) ||
(/weight\s*:\s*\[/.test(content.substring(Math.max(0, content.indexOf(line) - 200), content.indexOf(line) + 100)) &&
/['"]\d{3}['"]/.test(line)) ||
/\b(timeout|port|delay|duration|interval|retry|maxRetries|limit|size|width|height|fontSize|lineHeight)\s*:\s*['"]?\d+['"]?/.test(line) ||
/['"](\d+\.){1,2}\d+['"]/.test(line) ||
/['"]\/api\/v\d+\//.test(line) ||
/(from|to|via|offset|opacity|scale|rotate|skew|translate)\s*:\s*['"][\d-]+['"]/.test(line) ||
/(fontSize|spacing|borderRadius|colors)\s*:\s*\{/.test(line) ||
/\b(useTranslations|t)\s*\(\s*['"][a-zA-Z]+(\.[a-zA-Z]+)*['"]/.test(line) ||
/\b(toast|notification)\.(success|error|info|warning)\s*\(\s*t\s*\(/.test(line) ||
/['"][a-zA-Z]+(\.[a-zA-Z]+){2,}['"]/.test(line);
const isConfigurationFile = /\/(config|configs|constants|theme|styles|fonts)\//.test(filePath) ||
/\.(config|constants|theme|styles|fonts)\.(ts|tsx|js|jsx)$/.test(filePath) ||
/\/fonts\//.test(filePath);
// Skip property accessor patterns like Colors['color-complementary-cyan-500']
const isPropertyAccessor = /\w{1,40}\s*\[\s*['"][^'"]{1,100}['"]\s*\]/.test(line);
// Skip legitimate method calls that might contain trigger words
const isLegitimateMethodCall = /\.(endsWith|startsWith|includes|indexOf|replace|match|test)\s*\(\s*['"][^'"]*\b(test|dummy|foo|bar|baz)\b[^'"]*['"]\s*\)/.test(line) ||
/\.(contains|equals|equalsIgnoreCase)\s*\(\s*['"][^'"]*\b(test|dummy|foo|bar|baz)\b[^'"]*['"]\s*\)/.test(line);
// Skip legitimate configuration values and common patterns
const isLegitimateConfiguration =
// TypeScript/acorn configuration values
/ecmaVersion\s*:\s*['"]latest['"]/.test(line) ||
// Testing directory names
/===?\s*['"]__tests?__['"]/.test(line) ||
/currentDirName\s*===?\s*['"]__tests?__['"]/.test(line) ||
// Rule validation messages (not actual usage)
/message\s*:\s*['"][^'"]*\balert\(\)[^'"]*['"]/.test(line) ||
/Issue\s*:\s*['"][^'"]*\balert\(\)[^'"]*['"]/.test(line) ||
// Common legitimate string comparisons
/\.\s*includes\s*\(\s*['"][^'"]*\b(test|dummy|foo|bar|baz)\b[^'"]*['"]\s*\)/.test(line) ||
// Directory and file name checks
/\s*(===?|!==?)\s*['"][^'"]*\b(__tests?__|__mocks?__|test|spec)\b[^'"]*['"]/.test(line) ||
// Configuration arrays (ignorePatterns, defaultIgnorePatterns, etc.)
/\b(default)?[Ii]gnorePatterns?\s*=\s*\[/.test(content) ||
/\b(exclude|ignore|skip)Patterns?\s*:\s*\[/.test(content) ||
// Array elements for configuration patterns
(/^\s*['"][^'"]*['"],?\s*$/.test(line.trim()) &&
/\b(default)?[Ii]gnorePatterns?\s*=\s*\[/m.test(content));
if (hasHardcodedPattern &&
!isCSSClass &&
!isTailwindClass &&
!hasClassContext &&
!isTestFile &&
!isImportStatement &&
!isURL &&
!isSingleLineComment &&
!isMultiLineComment &&
!isValidConfiguration &&
!isConfigurationFile &&
!isTranslationAssignment &&
!isPropertyAccessor &&
!isLegitimateMethodCall &&
!isLegitimateConfiguration) {
errors.push({
rule: 'Hardcoded data',
message: 'No hardcoded data should be left in the code except in mocks.',
filePath: filePath,
line: idx + 1,
severity: 'error',
category: 'content',
});
}
});
return errors;
}
/**
* Check for missing comments in complex functions
* This function analyzes JavaScript/TypeScript code to identify functions with high complexity
* that lack proper documentation. It calculates complexity based on control flow structures,
* async operations, array methods, and function length. Functions exceeding complexity
* thresholds require explanatory comments or JSDoc documentation.
*/
export function checkFunctionComments(content, filePath) {
const lines = content.split('\n');
const errors = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line)
continue;
const trimmedLine = line.trim();
if (shouldSkipLine(trimmedLine))
continue;
const functionMatch = detectFunctionDeclaration(trimmedLine);
if (!functionMatch)
continue;
const functionName = getFunctionName(functionMatch);
if (!functionName || shouldSkipFunction(trimmedLine, functionName))
continue;
const functionAnalysis = analyzeFunctionComplexity(lines, i, content);
if (!functionAnalysis.isComplex)
continue;
if (!hasProperComments(lines, i, content)) {
errors.push(createCommentError(functionName, functionAnalysis, filePath, i));
}
}
return errors;
}
/**
* Check for unused variables
*/
export function checkUnusedVariables(content, filePath) {
const errors = [];
try {
// Enable location tracking to get line numbers
const ast = acorn.parse(content, {
ecmaVersion: 'latest',
sourceType: 'module',
locations: true, // Important for line numbers
});
const declared = new Map(); // name -> { node, exported: false }
const used = new Set();
const exportedViaSpecifier = new Set();
// Find `export { foo }` and `export default foo`
acornWalk.simple(ast, {
ExportNamedDeclaration(node) {
const exportNode = node;
if (exportNode.specifiers && Array.isArray(exportNode.specifiers)) {
for (const specifier of exportNode.specifiers) {
if (specifier &&
typeof specifier === 'object' &&
'local' in specifier &&
specifier.local &&
'name' in specifier.local) {
exportedViaSpecifier.add(specifier.local.name);
}
}
}
},
ExportDefaultDeclaration(node) {
const exportNode = node;
if (exportNode.declaration &&
'name' in exportNode.declaration &&
exportNode.declaration.name) {
exportedViaSpecifier.add(exportNode.declaration.name);
}
},
});
// Pass 1: Find all declarations and mark if they are exported.
acornWalk.ancestor(ast, {
VariableDeclarator(node, ancestors) {
const declNode = node;
if (declNode.id &&
declNode.id.type === 'Identifier' &&
'name' in declNode.id &&
declNode.id.name) {
const name = declNode.id.name;
const parent = ancestors[ancestors.length - 2];
const grandParent = ancestors.length > 2
? ancestors[ancestors.length - 3]
: null;
const isInlineExport = parent &&
parent.type === 'VariableDeclaration' &&
grandParent &&
grandParent.type === 'ExportNamedDeclaration';
const isSpecifierExport = exportedViaSpecifier.has(name);
if (!declared.has(name)) {
declared.set(name, {
node: declNode.id,
exported: Boolean(isInlineExport ?? isSpecifierExport),
});
}
}
},
});
// Pass 2: Find all usages.
acornWalk.ancestor(ast, {
Identifier(node, ancestors) {
const identNode = node;
const parent = ancestors[ancestors.length - 2];
if (!('name' in identNode) || !identNode.name) {
return;
}
const isDeclaration = (parent &&
parent.type === 'VariableDeclarator' &&
parent.id === identNode) ||
(parent &&
parent.type === 'FunctionDeclaration' &&
parent.id === identNode) ||
(parent &&
parent.type === 'ClassDeclaration' &&
parent.id === identNode) ||
(parent &&
parent.type === 'ImportSpecifier' &&
parent.local === identNode) ||
(parent &&
parent.type === 'ImportDefaultSpecifier' &&
parent.local === identNode) ||
(parent &&
parent.type === 'ImportNamespaceSpecifier' &&
parent.local === identNode) ||
(parent &&
(parent.type === 'FunctionDeclaration' ||
parent.type === 'FunctionExpression' ||
parent.type === 'ArrowFunctionExpression') &&
parent.params &&
Array.isArray(parent.params) &&
parent.params.includes(identNode));
const isPropertyKey = parent &&
parent.type === 'Property' &&
parent.key === identNode &&
!('computed' in parent && parent.computed);
if (!isDeclaration && !isPropertyKey) {
used.add(identNode.name);
}
},
});
for (const [name, decl] of declared.entries()) {
if (!used.has(name) && !decl.exported && !name.startsWith('_')) {
const nodeWithLoc = decl.node;
const lineNumber = nodeWithLoc?.loc?.start?.line ?? 1;
errors.push({
rule: 'No unused variables',
message: `Variable '${name}' is declared but never used. (@typescript-eslint/no-unused-vars rule)`,
filePath: filePath,
line: lineNumber,
severity: 'warning',
category: 'content',
});
}
}
}
catch (error) {
// If acorn fails to parse (e.g., complex TS syntax), we skip silently
// This is expected for complex TypeScript syntax that acorn can't handle
console.debug(`Could not parse ${filePath} for unused variable analysis: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
return errors;
}
/**
* Check for function naming conventions
*/
export function checkFunctionNaming(content, filePath) {
const errors = [];
const lines = content.split('\n');
lines.forEach((line, idx) => {
// Match function declarations
const functionDeclMatch = line.match(/function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/g);
// Match arrow function assignments
const arrowFuncMatch = line.match(/(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*(?:async\s*)?\(/g);
const matches = [];
if (functionDeclMatch)
matches.push(...functionDeclMatch);
if (arrowFuncMatch)
matches.push(...arrowFuncMatch);
matches.forEach((match) => {
const nameMatch = /(?:function\s+|const\s+|let\s+|var\s+)([a-zA-Z_$][a-zA-Z0-9_$]*)/.exec(match);
if (!nameMatch?.[1]) {
return;
}
const functionName = nameMatch[1];
// Skip React components (PascalCase) and hooks (start with 'use')
if (/^[A-Z]/.test(functionName) || functionName.startsWith('use')) {
return;
}
// Function should follow camelCase
if (!/^[a-z][a-zA-Z0-9]*$/.test(functionName)) {
errors.push({
rule: 'Function naming',
message: 'Functions must follow camelCase convention (e.g., getProvinces)',
filePath: filePath,
line: idx + 1,
severity: 'error',
category: 'naming',
});
}
});
});
return errors;
}
/**
* Check for interface naming conventions
*/
export function checkInterfaceNaming(content, filePath) {
const errors = [];
const lines = content.split('\n');
const seen = new Set();
lines.forEach((line, idx) => {
const interfaceMatch = line.match(/export\s+interface\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/g);
if (interfaceMatch) {
interfaceMatch.forEach((match) => {
const nameMatch = /export\s+interface\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/.exec(match);
if (!nameMatch?.[1]) {
return;
}
const interfaceName = nameMatch[1];
if (seen.has(interfaceName))
return;
seen.add(interfaceName);
if (!/^I[A-Z][a-zA-Z0-9]*$/.test(interfaceName)) {
errors.push({
rule: 'Interface naming',
message: 'Exported interfaces must start with "I" and follow PascalCase (e.g., IButtonProps)',
filePath: filePath,
line: idx + 1,
severity: 'error',
category: 'naming',
});
}
});
}
});
// Patch: Suppress "Type naming" and "No any type" in .d.ts files
return errors;
}
/**
* Check for style conventions
*/
export function checkStyleConventions(content, filePath) {
const errors = [];
// Only check .style.ts files
if (!filePath.endsWith('.style.ts')) {
return errors;
}
const lines = content.split('\n');
lines.forEach((line, idx) => {
// Check if StyleSheet.create is used (for React Native)
if (/StyleSheet\.create\s*\(/.test(line)) {
// This is good, StyleSheet.create is being used
return;
}
// Check for style object exports
const exportMatch = /export\s+const\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=/.exec(line);
if (exportMatch?.[1]) {
const styleName = exportMatch[1];
// Style names should be in camelCase and end with 'Styles'
if (!/^[a-z][a-zA-Z0-9]*Styles$/.test(styleName)) {
errors.push({
rule: 'Style naming',
message: `Style object '${styleName}' should be in camelCase and end with 'Styles' (e.g., cardPreviewStyles)`,
filePath: filePath,
line: idx + 1,
severity: 'error',
category: 'naming',
});
}
}
});
return errors;
}
/**
* Check for enums outside types folder
*/
export function checkEnumsOutsideTypes(filePath) {
// Check if enum files are incorrectly placed inside types directory
if (filePath.includes('types') && filePath.endsWith('.enum.ts')) {
return {
rule: 'Enum outside of types',
message: 'Enums must be in a separate directory from types (use /enums/ instead of /types/).',
filePath: filePath,
severity: 'error',
category: 'structure',
};
}
return null;
}
/**
* Check for hook file extension
*/
export function checkHookFileExtension(filePath) {
// Only check for hooks (use*.hook.ts[x]?)
const fileName = path.basename(filePath);
const dirName = path.dirname(filePath);
if (!/^use[a-zA-Z0-9]+\.hook\.(ts|tsx)$/.test(fileName))
return null;
// Omit if index.ts in the same folder
if (fs.existsSync(path.join(dirName, 'index.ts')))
return null;
try {
const content = fs.readFileSync(filePath, 'utf8');
// Heuristic: if contains JSX (return < or React.createElement), must be .tsx
const needsRender = /return\s*<|React\.createElement/.test(content);
const isTSX = fileName.endsWith('.tsx');
if (needsRender && !isTSX) {
return {
rule: 'Hook file extension',
message: 'Hooks that render JSX must have a .tsx extension.',
filePath: filePath,
severity: 'error',
category: 'naming',
};
}
if (!needsRender && isTSX) {
return {
rule: 'Hook file extension',
message: 'Hooks that do not render JSX should have a .ts extension.',
filePath: filePath,
severity: 'error',
category: 'naming',
};
}
}
catch {
// If we can't read the file, skip validation silently
return null;
}
return null;
}
/**
* Check for asset naming conventions
*/
export function checkAssetNaming(filePath) {
const fileName = path.basename(filePath);
const fileExt = path.extname(fileName);
const baseName = fileName.replace(fileExt, '');
// Check if file is in assets directory
if (!filePath.includes('/assets/') && !filePath.includes('\\assets\\')) {
return null;
}
// Skip TypeScript declaration files (.d.ts)
if (fileExt === '.ts' && fileName.endsWith('.d.ts')) {
return null;
}
// Detect if this is a React Native project
const isRNProject = isReactNativeProject(filePath);
// For React Native projects, be more permissive with SVG components
if (isRNProject) {
// Skip asset naming validation for SVG components in React Native
if (filePath.includes('/Svg/') ||
filePath.includes('/assets/Svg/') ||
fileExt === '.tsx' ||
fileExt === '.jsx') {
return null;
}
}
// Assets should follow kebab-case
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(baseName)) {
return {
rule: 'Asset naming',
message: 'Assets must follow kebab-case convention (e.g., service-error.svg)',
filePath: filePath,
severity: 'error',
category: 'naming',
};
}
return null;
}
/**
* Check naming conventions for files
*/
export function checkNamingConventions(filePath) {
const rel = filePath.split(path.sep);
const fname = rel[rel.length - 1];
const parentDir = rel[rel.length - 2]; // Get immediate parent directory
// Omit index.tsx and index.ts from naming convention checks
if (fname === 'index.tsx' || fname === 'index.ts') {
return null;
}
if (!fname || !parentDir) {
return null;
}
for (const rule of NAMING_RULES) {
// Check if the immediate parent directory matches the rule directory
if (parentDir === rule.dir) {
if (!rule.regex.test(fname)) {
return {
rule: 'Naming',
message: rule.desc,
filePath: filePath,
severity: 'error',
category: 'naming',
};
}
}
}
return null;
}
/**
* Check directory naming conventions
*/
export function checkDirectoryNaming(dirPath) {
const errors = [];
const currentDirName = path.basename(dirPath);
// Skip excluded directories and files
if (currentDirName.startsWith('.') ||
currentDirName === 'node_modules' ||
currentDirName === '__tests__' ||
currentDirName === '__test__' ||
currentDirName.includes('(') ||
currentDirName.includes(')') ||
currentDirName.includes('[') ||
currentDirName.includes(']') ||
currentDirName === 'coverage' ||
currentDirName === 'dist' ||
currentDirName === 'build' ||
currentDirName === 'public' ||
currentDirName === 'static' ||
currentDirName === 'temp' ||
currentDirName === 'tmp' ||
currentDirName.startsWith('__') ||
// Skip framework-specific directories that are allowed to have different naming
currentDirName === 'api' ||
currentDirName === 'lib' ||
currentDirName === 'utils' ||
currentDirName === 'pages' ||
currentDirName === 'components' ||
currentDirName === 'styles' ||
currentDirName === 'types' ||
currentDirName === 'hooks' ||
currentDirName === 'constants' ||
currentDirName === 'helpers' ||
currentDirName === 'assets' ||
currentDirName === 'enums') {
return errors;
}
// Skip if it's the root directory or first level directories in monorepo (apps, packages, etc)
if (['apps', 'packages', 'config', 'k8s', 'src'].includes(currentDirName)) {
return errors;
}
// Only check directories that are actually inside meaningful project structure
const ROOT_DIR = process.cwd();
const relativePath = path.relative(ROOT_DIR, dirPath);
if (relativePath.includes('/src/') &&
!relativePath.includes('/node_modules/')) {
// Check if current directory follows camelCase convention
// Allow PascalCase for component directories and camelCase for others
const isCamelCase = /^[a-z][a-zA-Z0-9]*$/.test(currentDirName);
const isPascalCase = /^[A-Z][a-zA-Z0-9]*$/.test(currentDirName);
// Special case: Allow kebab-case for route directories (Next.js routing)
const isValidRouteDir = /^[a-z0-9]+(-[a-z0-9]+)*$/.test(currentDirName) &&
(relativePath.includes('/app/') || relativePath.includes('/pages/'));
if (!isCamelCase && !isPascalCase && !isValidRouteDir) {
// Check if we've already flagged this directory name in any path
const alreadyFlagged = Array.from(flaggedDirectories).some((flaggedPath) => path.basename(flaggedPath) === currentDirName);
if (!alreadyFlagged) {
// Generate a proper camelCase suggestion based on the actual directory name
const camelCaseSuggestion = currentDirName
.toLowerCase()
.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
errors.push({
rule: 'Directory naming',
message: `Directory '${currentDirName}' should follow camelCase convention (e.g., '${camelCaseSuggestion}')`,
filePath: dirPath,
severity: 'error',
category: 'naming',
});
// Mark this directory name as flagged
flaggedDirectories.add(dirPath);
}
}
}
return errors;
}
/**
* Helper function to check index file requirements
*/
function checkIndexFileRequirements(componentDir, componentName, isUtilityDir) {
const errors = [];
const indexTsxFile = path.join(componentDir, 'index.tsx');
const indexTsFile = path.join(componentDir, 'index.ts');
if (isUtilityDir) {
// Utility directories should have index.ts
if (!fs.existsSync(indexTsFile)) {
errors.push({
rule: 'Component structure',
message: `Utility directory '${componentName}' should have an index.ts file for exports`,
filePath: indexTsFile,
severity: 'warning',
category: 'structure',
});
}
}
else if (!fs.existsSync(indexTsxFile) && !fs.existsSync(indexTsFile)) {
// Component directories should have index.tsx or index.ts
errors.push({
rule: 'Component structure',
message: 'Component must have an index.tsx file (for components) or index.ts file (for exports)',
filePath: indexTsxFile,
severity: 'warning',
category: 'structure',
});
}
return errors;
}
/**
* Helper function to check hooks directory structure
*/
function checkHooksDirectory(componentDir) {
const errors = [];
const hooksDir = path.join(componentDir, 'hooks');
if (fs.existsSync(hooksDir)) {
try {
const hookFiles = fs
.readdirSync(hooksDir)
.filter((file) => file.endsWith('.hook.ts') || file.endsWith('.hook.tsx'));
if (hookFiles.length === 0) {
errors.push({
rule: 'Component structure',
message: 'Hooks directory should contain hook files with .hook.ts or .hook.tsx extension',
filePath: hooksDir,
severity: 'warning',
category: 'structure',
});
}
}
catch {
// Directory access error - skip this check
}
}
return errors;
}
/**
* Helper function to check type file naming in types directory
*/
function checkTypesDirectory(componentDir) {
const errors = [];
const typesDir = path.join(componentDir, 'types');
if (fs.existsSync(typesDir)) {
try {
const typeFiles = fs
.readdirSync(typesDir)
.filter((file) => file.endsWith('.ts') &&
!file.includes('.test.') &&
!file.includes('.spec.') &&
file !== 'index.ts');
for (const typeFile of typeFiles) {
// Exception: if .d.ts and in type or types folder, do not require .type.ts
const isDeclaration = typeFile.endsWith('.d.ts');
const parentDir = path.basename(typesDir).toLowerCase();
if (isDeclaration && (parentDir === 'type' || parentDir === 'types')) {
continue;
}
if (!typeFile.endsWith('.type.ts')) {
const typeFilePath = path.join(typesDir, typeFile);
errors.push({
rule: 'Component type naming',
message: 'Type file should end with .type.ts',
filePath: typeFilePath,
severity: 'error',
category: 'naming',
});
}
}
}
catch {
// Directory access error - skip this check
}
}
return errors;
}
/**
* Helper function to check style file naming in styles directory
*/
function checkStylesDirectory(componentDir) {
const errors = [];
const stylesDir = path.join(componentDir, 'styles');
if (fs.existsSync(stylesDir)) {
try {
const styleFiles = fs
.readdirSync(stylesDir)
.filter((file) => file.endsWith('.ts') &&
!file.includes('.test.') &&
!file.includes('.spec.'));
for (const styleFile of styleFiles) {
if (!styleFile.endsWith('.style.ts')) {
const styleFilePath = path.join(stylesDir, styleFile);
errors.push({
rule: 'Component style naming',
message: 'Style file should end with .style.ts',
filePath: styleFilePath,
severity: 'error',
category: 'naming',
});
}
}
}
catch {
// Directory access error - skip this check
}
}
return errors;
}
/**
* Check component structure
*/
export function checkComponentStructure(componentDir) {
const errors = [];
const componentName = path.basename(componentDir);
// Skip validation for generic 'components' directories that are just containers
if (componentName === 'components') {
return errors;
}
// Different expectations based on directory type
const isUtilityDir = [
'hooks',
'types',
'constants',
'helpers',
'utils',
].includes(componentName);
// Check index file requirements
errors.push(...checkIndexFileRequirements(componentDir, componentName, isUtilityDir));
// Check subdirectories
errors.push(...checkHooksDirectory(componentDir));
errors.push(...checkTypesDirectory(componentDir));
errors.push(...checkStylesDirectory(componentDir));
return errors;
}
/**
* Check component function name matches folder name
*/
export function checkComponentFunctionNameMatch(content, filePath) {
if (!shouldProcessFile(filePath)) {
return null;
}
const dirName = path.basename(path.dirname(filePath));
const isPascalCase = /^[A-Z][a-zA-Z0-9]*$/.test(dirName);
const functionMatch = findFunctionMatch(content);
if (!functionMatch) {
return createNoFunctionError(dirName, filePath);
}
const { functionName, lineNumber } = functionMatch;
return validateFunctionName(functionName, dirName, isPascalCase, filePath, lineNumber);
}
//# sourceMappingURL=additional-validators.js.map