Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/react-doctor/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/react-doctor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 5 additions & 1 deletion packages/react-doctor/src/plugin/rules/architecture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -29,14 +30,17 @@ 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,
bodyNode: EsTreeNode,
): 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`,
Expand Down
73 changes: 73 additions & 0 deletions packages/react-doctor/src/plugin/thresholds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
interface RuleOverride {
files: string[];
maxLines: number;
}

interface RuleConfig {
maxLines?: number;
overrides?: RuleOverride[];
}

type RulesMap = Record<string, RuleConfig | undefined>;

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<string, unknown>).__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;
};
1 change: 1 addition & 0 deletions packages/react-doctor/src/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,7 @@ export const scan = async (
projectInfo.hasReactCompiler,
lintIncludePaths,
resolvedNodeBinaryPath,
userConfig?.rules,
);
lintSpinner?.succeed("Running lint checks.");
return lintDiagnostics;
Expand Down
15 changes: 15 additions & 0 deletions packages/react-doctor/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,11 +167,26 @@ 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;
deadCode?: boolean;
verbose?: boolean;
diff?: boolean | string;
failOn?: FailOnLevel;
rules?: RulesConfig;
}
38 changes: 36 additions & 2 deletions packages/react-doctor/src/utils/run-oxlint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -388,14 +415,20 @@ export const runOxlint = async (
hasReactCompiler: boolean,
includePaths?: string[],
nodeBinaryPath: string = process.execPath,
rules?: RulesConfig,
): Promise<Diagnostic[]> => {
if (includePaths !== undefined && includePaths.length === 0) {
return [];
}

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 {
Expand All @@ -420,6 +453,7 @@ export const runOxlint = async (

return allDiagnostics;
} finally {
cleanupPlugin();
restoreDisableDirectives();
if (fs.existsSync(configPath)) {
fs.unlinkSync(configPath);
Expand Down
Loading