UNPKG

frontend-standards-checker

Version:

A comprehensive frontend standards validation tool with TypeScript support

379 lines 16.4 kB
import path from "path"; export const isConfigOrConstantsFile = (filePath) => { return /config|constants|enums/i.test(filePath) && filePath.endsWith(".ts"); }; /** * Find the first PascalCase directory from the file path upwards * Used for component naming validation to handle nested component structures */ export function findPascalCaseDirectory(filePath) { const dirs = filePath.split(path.sep); // Remove the file name dirs.pop(); for (let i = dirs.length - 1; i >= 0; i--) { const currentDir = dirs[i]; if (typeof currentDir === "string" && /^[A-Z][a-zA-Z0-9]*$/.test(currentDir)) { return currentDir; } } // Fallback to immediate parent directory if no PascalCase found return path.basename(path.dirname(filePath)); } export function shouldProcessFile(filePath) { const fileName = path.basename(filePath); return (fileName === "index.tsx" && (filePath.includes("/components/") || filePath.includes("/containers/") || filePath.includes("/screens/"))); } export function findFunctionMatch(content) { // 0) Allow anonymous default arrow exports (no name to validate) if (/export\s+default\s+\([^)]*\)\s*=>\s*/.test(content) || /export\s+default\s+<[^>]*>\s*\([^)]*\)\s*=>\s*/.test(content)) { return null; } // ---------- Helpers ---------- const MAX_NAME = 40; const ident = `[A-Za-z_$][\\w$]{0,${MAX_NAME - 1}}`; const lineOf = (index) => content.substring(0, index).split("\n").length; /** * Recursively resolve a symbol to its real implementation declaration. * Follows aliases and common HOC wrappers (memo/forwardRef/anyHOC(X)). */ function findDeclarationDeep(name, seen = new Set()) { if (!name || seen.has(name)) return null; seen.add(name); // Direct declarations const directPatterns = [ new RegExp(`(^|\\n)\\s*function\\s+${name}\\s*\\(`, "g"), new RegExp(`(^|\\n)\\s*const\\s+${name}\\s*(?::\\s*[^=]*)?=\\s*\\([^)]*\\)\\s*(?::\\s*[^=]+)?\\s*=>`, "g"), new RegExp(`(^|\\n)\\s*const\\s+${name}\\s*:\\s*React\\.?FC\\b`, "g"), new RegExp(`(^|\\n)\\s*const\\s+${name}\\s*=\\s*<[^>]*>\\s*\\([^)]*\\)\\s*(?::\\s*[^=]+)?\\s*=>`, "g"), new RegExp(`(^|\\n)\\s*const\\s+${name}\\s*=\\s*(?:React\\.)?forwardRef\\s*\\(`, "g"), new RegExp(`(^|\\n)\\s*const\\s+${name}\\s*=\\s*(?:React\\.)?memo\\s*\\(`, "g"), new RegExp(`(^|\\n)\\s*const\\s+${name}\\s*:\\s*FC\\b`, "g"), ]; for (const rx of directPatterns) { const m = rx.exec(content); if (m) { const idx = m.index + (m[1] ? m[1].length : 0); return { name, index: idx }; } } // Aliases / HOCs const aliasPatterns = [ // const A = B; new RegExp(`(^|\\n)\\s*const\\s+${name}\\s*=\\s*(${ident})\\s*(;|\\n|$)`, "g"), // const A = memo(B) / forwardRef(B) new RegExp(`(^|\\n)\\s*const\\s+${name}\\s*=\\s*(?:React\\.)?(?:memo|forwardRef)\\s*\\(\\s*(${ident})\\s*\\)`, "g"), // const A = SomeHOC(B) new RegExp(`(^|\\n)\\s*const\\s+${name}\\s*=\\s*${ident}\\s*\\(\\s*(${ident})\\s*\\)`, "g"), ]; for (const rx of aliasPatterns) { const m = rx.exec(content); if (m?.[2]) { const target = m[2]; const resolved = findDeclarationDeep(target, seen); if (resolved) return resolved; } } return null; } // ---------- 1) ORIGINAL PATH: export default (\w+) then try direct patterns ---------- { const exportDefaultMatch = /export\s+default\s+(\w+)/g.exec(content); if (exportDefaultMatch?.[1]) { const exportedName = exportDefaultMatch[1]; // Original direct patterns for the exported symbol const functionPatterns = [ new RegExp(`function\\s+${exportedName}\\s*\\(`, "g"), new RegExp(`const\\s+${exportedName}\\s*(?::\\s*[^=]*)?=\\s*\\([^)]*\\)\\s*(?::\\s*[^=]+)?\\s*=>`, "g"), new RegExp(`const\\s+${exportedName}\\s*:\\s*React\\.?FC`, "g"), new RegExp(`const\\s+${exportedName}\\s*=\\s*<[^>]*>\\s*\\([^)]*\\)\\s*(?::\\s*[^=]+)?\\s*=>`, "g"), ]; for (const pattern of functionPatterns) { const match = pattern.exec(content); if (match) { return { functionName: exportedName, lineNumber: content.substring(0, match.index).split("\n").length, }; } } // If not found directly, try deep resolution (aliases/HOCs) const resolved = findDeclarationDeep(exportedName); if (resolved) { return { functionName: exportedName, lineNumber: lineOf(resolved.index), }; } } } // ---------- 2) OTHER DEFAULT SHAPES ---------- // a) export default function Name(...) { ... } { const m = new RegExp(`export\\s+default\\s+function\\s+(${ident})\\s*\\(`, "m").exec(content); if (m?.[1]) { return { functionName: m[1], lineNumber: lineOf(m.index) }; } } // b) export default memo(Name) / forwardRef(Name) { const m = new RegExp(`export\\s+default\\s+(?:React\\.)?(?:memo|forwardRef)\\s*\\(\\s*(${ident})\\s*\\)`, "m").exec(content); if (m?.[1]) { const resolved = findDeclarationDeep(m[1]); if (resolved) return { functionName: m[1], lineNumber: lineOf(resolved.index) }; } } // c) export { a as default } { const m = /export\s*\{\s*([A-Za-z_$][\w$]{0,39})\s+as\s+default\s*\}/m.exec(content); if (m?.[1]) { const resolved = findDeclarationDeep(m[1]); if (resolved) return { functionName: m[1], lineNumber: lineOf(resolved.index) }; } } // ---------- 3) FALLBACKS (SUPERSET OF ORIGINAL + FIXES) ---------- const fallbackPatterns = [ // export const Name = (...) => ... new RegExp(`export\\s+const\\s+(${ident})\\s*(?::\\s*[^=]*)?=\\s*\\([^)]*\\)\\s*(?::\\s*[^=]+)?\\s*=>\\s*`, "g"), // export const Name = <T>(...) => ... new RegExp(`export\\s+const\\s+(${ident})\\s*(?::\\s*[^=]*)?=\\s*<[^>]*>\\s*\\([^)]*\\)\\s*(?::\\s*[^=]+)?\\s*=>\\s*`, "g"), // export const Name = SomeHOC(Target) new RegExp(`export\\s+const\\s+(${ident})\\s*=\\s*${ident}\\s*\\(\\s*(${ident})\\s*\\)`, "g"), // export const Name = Target new RegExp(`export\\s+const\\s+(${ident})\\s*=\\s*(${ident})\\s*(;|\\n|$)`, "g"), // export default React.memo(Name) / React.forwardRef(Name) new RegExp(`export\\s+(?:const\\s+(${ident})\\s*=|default\\s+)\\s*React\\.${ident}\\s*\\(`, "g"), // export class Name extends React.Component/PureComponent new RegExp(`export\\s+class\\s+(${ident})\\s+extends\\s+(?:React\\.)?(?:Component|PureComponent)\\s*(?:<[^>]*>)?\\s*\\{`, "g"), // Non-exported declarations (original had these too as fallbacks) /const\s+([A-Za-z_$][A-Za-z0-9_$]{0,39})\s*=\s*forwardRef/g, /const\s+([A-Za-z_$][A-Za-z0-9_$]{0,39})\s*=\s*memo/g, /const\s+(\w+)\s*:\s*React\.?FC/g, /const\s+(\w+)\s*:\s*FC</g, ]; for (const pattern of fallbackPatterns) { const match = pattern.exec(content); if (match?.[1]) { // If this is an alias export (patterns with two groups), resolve to real line if (match[2] && /^[A-Za-z_$]/.test(match[2])) { const resolved = findDeclarationDeep(match[2]); if (resolved) { return { functionName: match[1], lineNumber: lineOf(resolved.index) }; } } return { functionName: match[1], lineNumber: lineOf(match.index), }; } } // ---------- 4) Named exports block: export { a, b as c } ---------- { const block = /export\s*\{\s*([^}]+)\s*\}/m.exec(content); if (block?.[1]) { const items = block[1] .split(",") .map((s) => s.trim()) .map((s) => { const mm = new RegExp(`^(${ident})(?:\\s+as\\s+(${ident}))?$`).exec(s); if (!mm) return null; const original = mm[1]; const exported = mm[2] ?? mm[1]; return { original, exported }; }) .filter(Boolean); for (const { original, exported } of items) { const resolved = findDeclarationDeep(original); if (resolved) { return { functionName: exported, lineNumber: lineOf(resolved.index) }; } } } } // ---------- 5) Last resort: top-level PascalCase declarations ---------- { const lines = content.split("\n"); let depth = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i] ?? ""; const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("//") || trimmed.startsWith("*")) continue; const opens = (line.match(/{/g) || []).length; const closes = (line.match(/}/g) || []).length; depth += opens - closes; if (depth <= 1) { const mConst = /^\s*const\s+([A-Z][A-Za-z0-9_$]{0,39})\s*=/.exec(line); if (mConst?.[1]) return { functionName: mConst[1], lineNumber: i + 1 }; const mFn = /^\s*function\s+([A-Z][A-Za-z0-9_$]{0,39})\s*\(/.exec(line); if (mFn?.[1]) return { functionName: mFn[1], lineNumber: i + 1 }; } } } return null; } export function validateFunctionName(functionName, dirName, isPascalCase, filePath, lineNumber) { if (!isPascalCase) { if (functionName.toLowerCase() !== dirName.toLowerCase()) { return createNameMismatchError(functionName, dirName, filePath, lineNumber); } } else if (functionName !== dirName) { return createNameMismatchError(functionName, dirName, filePath, lineNumber); } return null; } export function createNameMismatchError(functionName, dirName, filePath, lineNumber) { return { rule: "Component function name match", message: `The function '${functionName}' (line ${lineNumber}) must have the same name as its containing folder '${dirName}'. ${dirName === dirName.toLowerCase() ? "The folder must follow PascalCase and the function must have exactly the same name." : `Found: function='${functionName}', folder='${dirName}'.`}`, filePath: filePath, line: lineNumber, severity: "error", category: "naming", }; } export function createNoFunctionError(dirName, filePath) { return { rule: "Component function name match", message: `No main exported function found in index.tsx. The folder '${dirName}' must contain a function with the same name.`, filePath: filePath, line: 1, severity: "error", category: "naming", }; } export function shouldSkipLine(trimmedLine) { return (!trimmedLine || trimmedLine.startsWith("//") || trimmedLine.startsWith("*") || trimmedLine.startsWith("/*")); } export function detectFunctionDeclaration(trimmedLine) { // Split the complex regex into simpler ones const patterns = [ /export\s+const\s+([a-zA-Z_$][a-zA-Z0-9_$]{1,39})\s*=\s*\(.*\)\s*=>/, /export\s+const\s+([a-zA-Z_$][a-zA-Z0-9_$]{1,39})\s*=\s*async\s*\(.*\)\s*=>/, /const\s+([a-zA-Z_$][a-zA-Z0-9_$]{1,39})\s*=\s*async\s*\(.*\)\s*=>/, /export\s+function\s+([a-zA-Z_$][a-zA-Z0-9_$]{1,39})\s*\(/, /const\s+([a-zA-Z_$][a-zA-Z0-9_$]{1,39})\s*=\s*function\s*\(/, /function\s+([a-zA-Z_$][a-zA-Z0-9_$]{1,39})\s*\(/, ]; for (const pattern of patterns) { const match = pattern.exec(trimmedLine); if (match) { return match; } } return null; } export function getFunctionName(functionMatch) { return functionMatch[1] ?? null; } export function shouldSkipFunction(trimmedLine, _functionName) { if (trimmedLine.includes("interface ") || trimmedLine.includes("type ")) { return true; } return (trimmedLine.includes("=>") && trimmedLine.length < 80 && !trimmedLine.includes("async")); } export function analyzeFunctionComplexity(lines, startIndex, content) { let complexityScore = 0; let braceCount = 0; let inFunction = false; let linesInFunction = 0; const functionStartLine = lines[startIndex] ?? ""; // Fallback to empty string if undefined for (let j = startIndex; j < Math.min(startIndex + 30, lines.length); j++) { const bodyLine = lines[j]; if (!bodyLine) continue; braceCount = updateBraceCount(bodyLine, braceCount); inFunction = inFunction || braceCount > 0; if (inFunction) { linesInFunction++; complexityScore += calculateLineComplexity(bodyLine); } if (inFunction && braceCount === 0) { break; } } const functionContent = content.indexOf(functionStartLine) >= 0 ? content.substring(content.indexOf(functionStartLine)) : ""; const isComplex = determineComplexity(complexityScore, linesInFunction, functionContent); return { complexityScore, linesInFunction, isComplex }; } function updateBraceCount(line, currentCount) { const openBraces = (line.match(/\{/g) || []).length; const closeBraces = (line.match(/\}/g) || []).length; return currentCount + openBraces - closeBraces; } function calculateLineComplexity(line) { let score = 0; if (/\b(if|else if|switch|case)\b/.test(line)) score += 1; if (/\b(for|while|do)\b/.test(line)) score += 2; if (/\b(try|catch|finally)\b/.test(line)) score += 2; if (/\b(async|await|Promise\.all|Promise\.resolve|Promise\.reject|\.then|\.catch)\b/.test(line)) score += 2; if (/\.(map|filter|reduce|forEach|find|some|every)\s*\(/.test(line)) score += 1; if (/\?\s*[a-zA-Z0-9_$,\s=[]{}:.<>]{0,100}\s*:/.test(line)) score += 1; // Ternary operators if (/&&|\|\|/.test(line)) score += 0.5; // Logical operators return score; } function determineComplexity(score, lines, functionContent) { return (score >= 3 || lines > 8 || (score >= 2 && /async|await|Promise/.test(functionContent))); } export function hasProperComments(lines, functionLineIndex, _content) { for (let k = Math.max(0, functionLineIndex - 15); k < functionLineIndex; k++) { const commentLine = lines[k]; if (!commentLine) continue; const trimmedCommentLine = commentLine.trim(); if (isValidComment(trimmedCommentLine)) { return true; } } return false; } function isValidComment(commentLine) { return (commentLine.includes("/**") || commentLine.includes("*/") || (commentLine.startsWith("*") && commentLine.length > 5) || commentLine.includes("/*") || (commentLine.startsWith("//") && commentLine.length > 15 && !/^\s*\/\/\s*(TODO|FIXME|NOTE|HACK)/.test(commentLine))); } export function createCommentError(functionName, analysis, filePath, lineNumber) { return { rule: "Missing comment in complex function", message: `Complex function '${functionName}' (complexity: ${analysis.complexityScore.toFixed(1)}, lines: ${analysis.linesInFunction}) must have comments explaining its behavior.`, filePath: `${filePath}:${lineNumber + 1}`, line: lineNumber + 1, severity: "warning", category: "documentation", }; } //# sourceMappingURL=additionalValidators.helper.js.map