Skip to content
Draft
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
35 changes: 34 additions & 1 deletion packages/react-doctor/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ interface CliFlags {
project?: string;
diff?: boolean | string;
failOn: string;
plugin?: string[];
rule?: string[];
}

const VALID_FAIL_ON_LEVELS = new Set<FailOnLevel>(["error", "warning", "none"]);
Expand Down Expand Up @@ -87,6 +89,30 @@ const AUTOMATED_ENVIRONMENT_VARIABLES = [
const isAutomatedEnvironment = (): boolean =>
AUTOMATED_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable]));

const VALID_RULE_LEVELS = new Set(["off", "warn", "error"]);

const parseRuleEntries = (ruleEntries: string[] | undefined): Record<string, string> => {
if (!ruleEntries) return {};
const ruleLevels: Record<string, string> = {};
for (const ruleEntry of ruleEntries) {
const separatorIndex = ruleEntry.indexOf("=");
if (separatorIndex <= 0 || separatorIndex === ruleEntry.length - 1) {
throw new Error(`Invalid --rule value "${ruleEntry}". Expected format: plugin/rule=level`);
}

const ruleName = ruleEntry.slice(0, separatorIndex).trim();
const level = ruleEntry.slice(separatorIndex + 1).trim();

if (!VALID_RULE_LEVELS.has(level)) {
throw new Error(
`Invalid rule level "${level}" in --rule "${ruleEntry}". Expected one of: off, warn, error`,
);
}
ruleLevels[ruleName] = level;
}
return ruleLevels;
};

const resolveCliScanOptions = (
flags: CliFlags,
userConfig: ReactDoctorConfig | null,
Expand All @@ -101,6 +127,8 @@ const resolveCliScanOptions = (
verbose: isCliOverride("verbose") ? Boolean(flags.verbose) : (userConfig?.verbose ?? false),
scoreOnly: flags.score,
offline: flags.offline,
plugins: isCliOverride("plugin") ? (flags.plugin ?? []) : (userConfig?.plugins ?? []),
ruleLevels: isCliOverride("rule") ? parseRuleEntries(flags.rule) : {},
};
};

Expand Down Expand Up @@ -158,6 +186,8 @@ const program = new Command()
.option("--staged", "scan only staged (git index) files for pre-commit hooks")
.option("--fail-on <level>", "exit with error code on diagnostics: error, warning, none", "none")
.option("--annotations", "output diagnostics as GitHub Actions annotations")
.option("--plugin <specifier...>", "load external oxlint plugin(s) by package name or path")
.option("--rule <rule...>", "override rule levels (format: plugin/rule=off|warn|error)")
.action(async (directory: string, flags: CliFlags) => {
const isScoreOnly = flags.score;

Expand Down Expand Up @@ -274,7 +304,10 @@ const program = new Command()
logger.dim(`Scanning ${projectDirectory}...`);
logger.break();
}
const scanResult = await scan(projectDirectory, { ...scanOptions, includePaths });
const scanResult = await scan(projectDirectory, {
...scanOptions,
includePaths,
});
allDiagnostics.push(...scanResult.diagnostics);
if (!isScoreOnly) {
logger.break();
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 @@ -61,6 +61,8 @@ export const diagnose = async (
lintIncludePaths,
undefined,
effectiveCustomRulesOnly,
userConfig?.plugins ?? [],
userConfig?.rules ?? {},
).catch((error: unknown) => {
console.error("Lint failed:", error);
return emptyDiagnostics;
Expand Down
6 changes: 6 additions & 0 deletions packages/react-doctor/src/oxlint-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ interface OxlintConfigOptions {
framework: Framework;
hasReactCompiler: boolean;
customRulesOnly?: boolean;
externalJsPlugins?: string[];
ruleLevels?: Record<string, string>;
}

const BUILTIN_REACT_RULES: Record<string, string> = {
Expand Down Expand Up @@ -96,6 +98,8 @@ export const createOxlintConfig = ({
framework,
hasReactCompiler,
customRulesOnly = false,
externalJsPlugins = [],
ruleLevels = {},
}: OxlintConfigOptions) => ({
categories: {
correctness: "off",
Expand All @@ -112,6 +116,7 @@ export const createOxlintConfig = ({
? [{ name: "react-hooks-js", specifier: esmRequire.resolve("eslint-plugin-react-hooks") }]
: []),
pluginPath,
...externalJsPlugins,
],
rules: {
...(customRulesOnly ? {} : BUILTIN_REACT_RULES),
Expand Down Expand Up @@ -166,5 +171,6 @@ export const createOxlintConfig = ({
"react-doctor/async-parallel": "warn",
...(framework === "nextjs" ? NEXTJS_RULES : {}),
...(framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {}),
...ruleLevels,
},
});
9 changes: 9 additions & 0 deletions packages/react-doctor/src/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,8 @@ interface ResolvedScanOptions {
includePaths: string[];
customRulesOnly: boolean;
share: boolean;
plugins: string[];
ruleLevels: Record<string, string>;
}

const mergeScanOptions = (
Expand All @@ -423,6 +425,11 @@ const mergeScanOptions = (
includePaths: inputOptions.includePaths ?? [],
customRulesOnly: userConfig?.customRulesOnly ?? false,
share: userConfig?.share ?? true,
plugins: inputOptions.plugins ?? userConfig?.plugins ?? [],
ruleLevels: {
...(userConfig?.rules ?? {}),
...(inputOptions.ruleLevels ?? {}),
},
});

const printProjectDetection = (
Expand Down Expand Up @@ -505,6 +512,8 @@ export const scan = async (
lintIncludePaths,
resolvedNodeBinaryPath,
options.customRulesOnly,
options.plugins,
options.ruleLevels,
);
lintSpinner?.succeed("Running lint checks.");
return lintDiagnostics;
Expand Down
4 changes: 4 additions & 0 deletions packages/react-doctor/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ export interface ScanOptions {
scoreOnly?: boolean;
offline?: boolean;
includePaths?: string[];
plugins?: string[];
ruleLevels?: Record<string, string>;
configOverride?: ReactDoctorConfig | null;
}

Expand Down Expand Up @@ -171,4 +173,6 @@ export interface ReactDoctorConfig {
customRulesOnly?: boolean;
share?: boolean;
textComponents?: string[];
plugins?: string[];
rules?: Record<string, string>;
}
34 changes: 33 additions & 1 deletion packages/react-doctor/src/utils/run-oxlint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,25 @@ const resolvePluginPath = (): string => {
return pluginPath;
};

const looksLikePath = (pluginSpecifier: string): boolean =>
pluginSpecifier.startsWith(".") ||
pluginSpecifier.startsWith("/") ||
pluginSpecifier.startsWith("file:");

const resolveExternalPluginSpecifier = (
pluginSpecifier: string,
rootDirectory: string,
resolveFromRoot: ReturnType<typeof createRequire>,
): string => {
if (looksLikePath(pluginSpecifier)) {
if (pluginSpecifier.startsWith("file:")) {
return pluginSpecifier;
}
return path.resolve(rootDirectory, pluginSpecifier);
}
return resolveFromRoot.resolve(pluginSpecifier);
};

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 @@ -394,14 +413,27 @@ export const runOxlint = async (
includePaths?: string[],
nodeBinaryPath: string = process.execPath,
customRulesOnly = false,
pluginSpecifiers: string[] = [],
ruleLevels: Record<string, string> = {},
): 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, customRulesOnly });
const resolveFromRoot = createRequire(path.join(rootDirectory, "package.json"));
const externalPluginSpecifiers = pluginSpecifiers.map((pluginSpecifier) =>
resolveExternalPluginSpecifier(pluginSpecifier, rootDirectory, resolveFromRoot),
);
const config = createOxlintConfig({
pluginPath,
framework,
hasReactCompiler,
customRulesOnly,
externalJsPlugins: externalPluginSpecifiers,
ruleLevels,
});
const restoreDisableDirectives = neutralizeDisableDirectives(rootDirectory, includePaths);

try {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export default {
meta: {
name: "test-external-plugin",
},
rules: {
"no-local-storage-set-item": {
create(context) {
return {
CallExpression(node) {
if (node.callee?.type !== "MemberExpression") return;
if (node.callee.object?.type !== "Identifier") return;
if (node.callee.object.name !== "localStorage") return;
if (node.callee.property?.type !== "Identifier") return;
if (node.callee.property.name !== "setItem") return;
context.report({
node,
message: "Avoid localStorage.setItem in tests",
});
},
};
},
},
},
};
27 changes: 27 additions & 0 deletions packages/react-doctor/tests/run-oxlint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,4 +392,31 @@ describe("runOxlint", () => {
expect(reactDoctorDiagnostics.length).toBeGreaterThan(0);
});
});

describe("external plugin loading", () => {
it("loads external plugin from relative file path", async () => {
const diagnostics = await runOxlint(
BASIC_REACT_DIRECTORY,
true,
"unknown",
false,
[path.join("src", "clean.tsx")],
undefined,
true,
["./external-plugin.mjs"],
{
"test-external-plugin/no-local-storage-set-item": "error",
},
);

const externalPluginDiagnostics = diagnostics.filter(
(diagnostic) =>
diagnostic.plugin === "test-external-plugin" &&
diagnostic.rule === "no-local-storage-set-item",
);

expect(externalPluginDiagnostics.length).toBeGreaterThan(0);
expect(externalPluginDiagnostics[0].severity).toBe("error");
});
});
});
Loading