From 36427f13601efb6dece503b1d7f45b05a94054c6 Mon Sep 17 00:00:00 2001 From: Mateusz Choma Date: Thu, 2 Apr 2026 10:29:27 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20configurable=20per-file=20thr?= =?UTF-8?q?esholds=20for=20no-giant-component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support `rules` config in react-doctor.config.json: ```json { "rules": { "no-giant-component": { "maxLines": 250, "overrides": [ { "files": ["**/views/**/createView*.tsx"], "maxLines": 500 } ] } } } ``` - Inject rules config into plugin via globalThis preamble - Match filenames against glob overrides (first match wins) - Fall back to global maxLines, then hardcoded default - Backwards compatible — no config = same behavior --- packages/react-doctor/package.json | 2 +- packages/react-doctor/src/index.ts | 2 + .../src/plugin/rules/architecture.ts | 6 +- .../react-doctor/src/plugin/thresholds.ts | 73 +++++++++++++++++++ packages/react-doctor/src/scan.ts | 1 + packages/react-doctor/src/types.ts | 15 ++++ packages/react-doctor/src/utils/run-oxlint.ts | 38 +++++++++- 7 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 packages/react-doctor/src/plugin/thresholds.ts diff --git a/packages/react-doctor/package.json b/packages/react-doctor/package.json index 69ed7bc49..549054980 100644 --- a/packages/react-doctor/package.json +++ b/packages/react-doctor/package.json @@ -1,6 +1,6 @@ { "name": "@proda-ai/react-doctor", - "version": "0.0.30-proda.2", + "version": "0.0.30-proda.4", "description": "Diagnose and fix performance issues in your React app", "keywords": [ "diagnostics", diff --git a/packages/react-doctor/src/index.ts b/packages/react-doctor/src/index.ts index ba304d26b..525f5f7d6 100644 --- a/packages/react-doctor/src/index.ts +++ b/packages/react-doctor/src/index.ts @@ -57,6 +57,8 @@ export const diagnose = async ( projectInfo.framework, projectInfo.hasReactCompiler, lintIncludePaths, + undefined, + userConfig?.rules, ).catch((error: unknown) => { console.error("Lint failed:", error); return emptyDiagnostics; diff --git a/packages/react-doctor/src/plugin/rules/architecture.ts b/packages/react-doctor/src/plugin/rules/architecture.ts index 9e300aa01..890f18683 100644 --- a/packages/react-doctor/src/plugin/rules/architecture.ts +++ b/packages/react-doctor/src/plugin/rules/architecture.ts @@ -4,6 +4,7 @@ import { RENDER_FUNCTION_PATTERN, } from "../constants.js"; import { isComponentAssignment, isComponentDeclaration, isUppercaseName } from "../helpers.js"; +import { getThreshold } from "../thresholds.js"; import type { EsTreeNode, Rule, RuleContext } from "../types.js"; export const noGenericHandlerNames: Rule = { @@ -29,6 +30,9 @@ export const noGenericHandlerNames: Rule = { export const noGiantComponent: Rule = { create: (context: RuleContext) => { + const filename = context.getFilename?.() ?? ""; + const threshold = getThreshold("no-giant-component", filename, GIANT_COMPONENT_LINE_THRESHOLD); + const reportOversizedComponent = ( nameNode: EsTreeNode, componentName: string, @@ -36,7 +40,7 @@ export const noGiantComponent: Rule = { ): void => { if (!bodyNode.loc) return; const lineCount = bodyNode.loc.end.line - bodyNode.loc.start.line + 1; - if (lineCount > GIANT_COMPONENT_LINE_THRESHOLD) { + if (lineCount > threshold) { context.report({ node: nameNode, message: `Component "${componentName}" is ${lineCount} lines — consider breaking it into smaller focused components`, diff --git a/packages/react-doctor/src/plugin/thresholds.ts b/packages/react-doctor/src/plugin/thresholds.ts new file mode 100644 index 000000000..bbdfad55b --- /dev/null +++ b/packages/react-doctor/src/plugin/thresholds.ts @@ -0,0 +1,73 @@ +interface RuleOverride { + files: string[]; + maxLines: number; +} + +interface RuleConfig { + maxLines?: number; + overrides?: RuleOverride[]; +} + +type RulesMap = Record; + +const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g; + +const compileGlobPattern = (pattern: string): RegExp => { + const normalizedPattern = pattern.replace(/\\/g, "/").replace(/^\//, ""); + let regexSource = "^"; + let i = 0; + + while (i < normalizedPattern.length) { + if (normalizedPattern[i] === "*" && normalizedPattern[i + 1] === "*") { + if (normalizedPattern[i + 2] === "/") { + regexSource += "(?:.+/)?"; + i += 3; + } else { + regexSource += ".*"; + i += 2; + } + } else if (normalizedPattern[i] === "*") { + regexSource += "[^/]*"; + i++; + } else if (normalizedPattern[i] === "?") { + regexSource += "[^/]"; + i++; + } else { + regexSource += (normalizedPattern[i] as string).replace(REGEX_SPECIAL_CHARACTERS, "\\$&"); + i++; + } + } + + regexSource += "$"; + return new RegExp(regexSource); +}; + +const getRulesMap = (): RulesMap | undefined => { + try { + return (globalThis as Record).__REACT_DOCTOR_RULES__ as RulesMap | undefined; + } catch { + return undefined; + } +}; + +export const getThreshold = (ruleName: string, filename: string, defaultValue: number): number => { + const rules = getRulesMap(); + if (!rules) return defaultValue; + + const config = rules[ruleName]; + if (config === undefined) return defaultValue; + + const normalizedFilename = filename.replace(/\\/g, "/").replace(/^\.\//, ""); + + if (Array.isArray(config.overrides)) { + for (const override of config.overrides) { + for (const pattern of override.files) { + if (compileGlobPattern(pattern).test(normalizedFilename)) { + return override.maxLines; + } + } + } + } + + return config.maxLines ?? defaultValue; +}; diff --git a/packages/react-doctor/src/scan.ts b/packages/react-doctor/src/scan.ts index e486bf6b8..90d3493d7 100644 --- a/packages/react-doctor/src/scan.ts +++ b/packages/react-doctor/src/scan.ts @@ -499,6 +499,7 @@ export const scan = async ( projectInfo.hasReactCompiler, lintIncludePaths, resolvedNodeBinaryPath, + userConfig?.rules, ); lintSpinner?.succeed("Running lint checks."); return lintDiagnostics; diff --git a/packages/react-doctor/src/types.ts b/packages/react-doctor/src/types.ts index b0c4827df..757298011 100644 --- a/packages/react-doctor/src/types.ts +++ b/packages/react-doctor/src/types.ts @@ -167,6 +167,20 @@ export interface ReactDoctorIgnoreConfig { files?: string[]; } +export interface RuleOverride { + files: string[]; + maxLines: number; +} + +export interface NoGiantComponentConfig { + maxLines?: number; + overrides?: RuleOverride[]; +} + +export interface RulesConfig { + "no-giant-component"?: NoGiantComponentConfig; +} + export interface ReactDoctorConfig { ignore?: ReactDoctorIgnoreConfig; lint?: boolean; @@ -174,4 +188,5 @@ export interface ReactDoctorConfig { verbose?: boolean; diff?: boolean | string; failOn?: FailOnLevel; + rules?: RulesConfig; } diff --git a/packages/react-doctor/src/utils/run-oxlint.ts b/packages/react-doctor/src/utils/run-oxlint.ts index edab8c71c..5513f778c 100644 --- a/packages/react-doctor/src/utils/run-oxlint.ts +++ b/packages/react-doctor/src/utils/run-oxlint.ts @@ -10,7 +10,13 @@ import { SPAWN_ARGS_MAX_LENGTH_CHARS, } from "../constants.js"; import { createOxlintConfig } from "../oxlint-config.js"; -import type { CleanedDiagnostic, Diagnostic, Framework, OxlintOutput } from "../types.js"; +import type { + CleanedDiagnostic, + Diagnostic, + Framework, + OxlintOutput, + RulesConfig, +} from "../types.js"; import { neutralizeDisableDirectives } from "./neutralize-disable-directives.js"; const esmRequire = createRequire(import.meta.url); @@ -277,6 +283,27 @@ const resolvePluginPath = (): string => { return pluginPath; }; +const preparePluginWithRules = ( + pluginPath: string, + rules: RulesConfig | undefined, +): { effectivePluginPath: string; cleanup: () => void } => { + if (!rules || Object.keys(rules).length === 0) { + return { effectivePluginPath: pluginPath, cleanup: () => {} }; + } + + const originalPlugin = fs.readFileSync(pluginPath, "utf-8"); + const preamble = `globalThis.__REACT_DOCTOR_RULES__=${JSON.stringify(rules)};\n`; + const tempPluginPath = path.join(os.tmpdir(), `react-doctor-plugin-${process.pid}.js`); + fs.writeFileSync(tempPluginPath, preamble + originalPlugin); + + return { + effectivePluginPath: tempPluginPath, + cleanup: () => { + if (fs.existsSync(tempPluginPath)) fs.unlinkSync(tempPluginPath); + }, + }; +}; + const resolveDiagnosticCategory = (plugin: string, rule: string): string => { const ruleKey = `${plugin}/${rule}`; return RULE_CATEGORY_MAP[ruleKey] ?? PLUGIN_CATEGORY_MAP[plugin] ?? "Other"; @@ -388,6 +415,7 @@ export const runOxlint = async ( hasReactCompiler: boolean, includePaths?: string[], nodeBinaryPath: string = process.execPath, + rules?: RulesConfig, ): Promise => { if (includePaths !== undefined && includePaths.length === 0) { return []; @@ -395,7 +423,12 @@ export const runOxlint = async ( const configPath = path.join(os.tmpdir(), `react-doctor-oxlintrc-${process.pid}.json`); const pluginPath = resolvePluginPath(); - const config = createOxlintConfig({ pluginPath, framework, hasReactCompiler }); + const { effectivePluginPath, cleanup: cleanupPlugin } = preparePluginWithRules(pluginPath, rules); + const config = createOxlintConfig({ + pluginPath: effectivePluginPath, + framework, + hasReactCompiler, + }); const restoreDisableDirectives = neutralizeDisableDirectives(rootDirectory, includePaths); try { @@ -420,6 +453,7 @@ export const runOxlint = async ( return allDiagnostics; } finally { + cleanupPlugin(); restoreDisableDirectives(); if (fs.existsSync(configPath)) { fs.unlinkSync(configPath);