frontend-standards-checker
Version:
A comprehensive frontend standards validation tool with TypeScript support
379 lines • 16.4 kB
JavaScript
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