Files
workflow/packages/workflow-dashboard/plugins/vite-limit-line-plugin.ts
T

227 lines
6.7 KiB
TypeScript

import { createFilter, type Plugin } from "vite";
type LimitLineOverride = {
files: string;
maxReactFCLines: number | null;
maxFileLines: number | null;
};
type LimitLineOptions = {
maxReactFCLines: number;
maxFileLines: number;
include: RegExp;
exclude: RegExp | null;
overrides: Array<LimitLineOverride>;
};
const DEFAULT_OPTIONS: LimitLineOptions = {
maxReactFCLines: 300,
maxFileLines: 600,
include: /\.[tj]sx$/,
exclude: null,
overrides: [],
};
type ResolvedLimits = {
maxReactFCLines: number | null;
maxFileLines: number | null;
};
type ComponentInfo = {
name: string;
startLine: number;
lineCount: number;
};
const PASCAL_CASE = /^[A-Z][A-Za-z0-9]*$/;
type AstNode = {
type: string;
start: number;
end: number;
id: { name: string } | null;
init: AstNode | null;
declaration: AstNode | null;
declarations: Array<{ id: { name: string }; init: AstNode | null }>;
callee: { name: string } | null;
arguments: Array<AstNode>;
body: Array<AstNode>;
[key: string]: unknown;
};
function lineAt(code: string, offset: number): number {
let line = 1;
for (let i = 0; i < offset && i < code.length; i++) {
if (code[i] === "\n") line++;
}
return line;
}
function lineSpan(code: string, start: number, end: number): { startLine: number; lineCount: number } {
const startLine = lineAt(code, start);
const endLine = lineAt(code, end);
return { startLine, lineCount: endLine - startLine + 1 };
}
function extractComponents(ast: AstNode, code: string): Array<ComponentInfo> {
const results: Array<ComponentInfo> = [];
for (const node of ast.body ?? []) {
if (node.type === "FunctionDeclaration" && node.id && PASCAL_CASE.test(node.id.name)) {
const span = lineSpan(code, node.start, node.end);
results.push({ name: node.id.name, ...span });
continue;
}
if (node.type === "ExportNamedDeclaration" && node.declaration) {
const decl = node.declaration;
if (decl.type === "FunctionDeclaration" && decl.id && PASCAL_CASE.test(decl.id.name)) {
const span = lineSpan(code, node.start, node.end);
results.push({ name: decl.id.name, ...span });
continue;
}
if (decl.type === "VariableDeclaration") {
collectFromVarDeclaration(decl, code, results);
continue;
}
}
if (node.type === "VariableDeclaration") {
collectFromVarDeclaration(node, code, results);
}
}
return results;
}
function collectFromVarDeclaration(node: AstNode, code: string, results: Array<ComponentInfo>): void {
for (const declarator of node.declarations ?? []) {
if (!declarator.id || !PASCAL_CASE.test(declarator.id.name) || !declarator.init) continue;
const init = declarator.init;
if (isFunctionLike(init)) {
const span = lineSpan(code, node.start, node.end);
results.push({ name: declarator.id.name, ...span });
} else if (isWrapperCall(init) && init.arguments.length > 0 && isFunctionLike(init.arguments[0])) {
const span = lineSpan(code, node.start, node.end);
results.push({ name: declarator.id.name, ...span });
}
}
}
function isFunctionLike(node: AstNode): boolean {
return node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression";
}
const WRAPPER_NAMES = new Set(["memo", "forwardRef", "lazy"]);
function isWrapperCall(node: AstNode): boolean {
return node.type === "CallExpression" && node.callee !== null && node.callee.name !== undefined && WRAPPER_NAMES.has(node.callee.name);
}
function createLimitResolver(options: LimitLineOptions): (id: string) => ResolvedLimits {
const matchers = options.overrides.map((override) => ({
match: createFilter(override.files),
maxReactFCLines: override.maxReactFCLines,
maxFileLines: override.maxFileLines,
}));
return (id: string): ResolvedLimits => {
let maxReactFCLines: number | null = options.maxReactFCLines;
let maxFileLines: number | null = options.maxFileLines;
for (const matcher of matchers) {
if (matcher.match(id)) {
maxReactFCLines = matcher.maxReactFCLines;
maxFileLines = matcher.maxFileLines;
}
}
return { maxReactFCLines, maxFileLines };
};
}
function shouldProcess(id: string, options: LimitLineOptions): boolean {
return options.include.test(id) && !id.includes("node_modules") && (options.exclude === null || !options.exclude.test(id));
}
function viteLimitLinePlugin(
userOptions: Partial<LimitLineOptions> = {},
): Array<Plugin> {
const options: LimitLineOptions = { ...DEFAULT_OPTIONS, ...userOptions, overrides: userOptions.overrides ?? [] };
const resolve = createLimitResolver(options);
return [
{
name: "vite-plugin-file-line-limit",
enforce: "pre",
transform(code, id) {
if (!shouldProcess(id, options)) return null;
const limits = resolve(id);
if (limits.maxFileLines === null) return null;
const totalLines = code.split("\n").length;
if (totalLines > limits.maxFileLines) {
this.error(
[
`[vite-limit-line] File too long: ${totalLines} lines (limit: ${limits.maxFileLines})`,
` file: ${id}`,
"",
"How to fix:",
" Split this file into smaller modules — extract related types, helpers,",
" or sub-components into separate files and re-export from an index.ts.",
].join("\n"),
);
}
return null;
},
},
{
name: "vite-plugin-react-fc-line-limit",
transform(code, id) {
if (!shouldProcess(id, options)) return null;
const limits = resolve(id);
if (limits.maxReactFCLines === null) return null;
const ast = this.parse(code) as unknown as AstNode;
const components = extractComponents(ast, code);
const violations = components.filter((c) => c.lineCount > (limits.maxReactFCLines as number));
if (violations.length > 0) {
const details = violations
.map(
(v) =>
` ${v.name} (line ${v.startLine}): ${v.lineCount} lines (limit: ${limits.maxReactFCLines})`,
)
.join("\n");
this.error(
[
`[vite-limit-line] React component too long in ${id}:`,
details,
"",
"How to fix:",
" Break each oversized component into smaller ones. Extract reusable",
" sections into child components, move complex logic into custom hooks,",
" and keep each component focused on a single responsibility.",
].join("\n"),
);
}
return null;
},
},
];
}
export { viteLimitLinePlugin };
export type { LimitLineOptions, LimitLineOverride };