diff --git a/packages/react-doctor/README.md b/packages/react-doctor/README.md index 23b211d..1dd85a0 100644 --- a/packages/react-doctor/README.md +++ b/packages/react-doctor/README.md @@ -119,14 +119,15 @@ If both exist, `react-doctor.config.json` takes precedence. ### Config options -| Key | Type | Default | Description | -| -------------- | ------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `ignore.rules` | `string[]` | `[]` | Rules to suppress, using the `plugin/rule` format shown in diagnostic output (e.g. `react/no-danger`, `knip/exports`, `knip/types`) | -| `ignore.files` | `string[]` | `[]` | File paths to exclude, supports glob patterns (`src/generated/**`, `**/*.test.tsx`) | -| `lint` | `boolean` | `true` | Enable/disable lint checks (same as `--no-lint`) | -| `deadCode` | `boolean` | `true` | Enable/disable dead code detection (same as `--no-dead-code`) | -| `verbose` | `boolean` | `false` | Show file details per rule (same as `--verbose`) | -| `diff` | `boolean \| string` | — | Force diff mode (`true`) or pin a base branch (`"main"`). Set to `false` to disable auto-detection. | +| Key | Type | Default | Description | +| --------------- | ------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ignore.rules` | `string[]` | `[]` | Rules to suppress, using the `plugin/rule` format shown in diagnostic output (e.g. `react/no-danger`, `knip/exports`, `knip/types`) | +| `ignore.files` | `string[]` | `[]` | File paths to exclude, supports glob patterns (`src/generated/**`, `**/*.test.tsx`) | +| `lint` | `boolean` | `true` | Enable/disable lint checks (same as `--no-lint`) | +| `deadCode` | `boolean` | `true` | Enable/disable dead code detection (same as `--no-dead-code`) | +| `verbose` | `boolean` | `false` | Show file details per rule (same as `--verbose`) | +| `diff` | `boolean \| string` | — | Force diff mode (`true`) or pin a base branch (`"main"`). Set to `false` to disable auto-detection. | +| `accessibility` | `"minimal" \| "recommended" \| "strict" \| false` | `"minimal"` | Accessibility checking level. `minimal`: 15 high-impact rules, `recommended`: 31 rules (jsx-a11y/recommended), `strict`: 33 rules as errors. Set to `false` to disable. | CLI flags always override config values. @@ -150,6 +151,7 @@ The `diagnose` function accepts an optional second argument: const result = await diagnose(".", { lint: true, // run lint checks (default: true) deadCode: true, // run dead code detection (default: true) + accessibility: "recommended", // "minimal" | "recommended" | "strict" | false (default: "minimal") }); ``` diff --git a/packages/react-doctor/src/index.ts b/packages/react-doctor/src/index.ts index 14384d4..5be5e9c 100644 --- a/packages/react-doctor/src/index.ts +++ b/packages/react-doctor/src/index.ts @@ -1,6 +1,13 @@ import path from "node:path"; import { performance } from "node:perf_hooks"; -import type { Diagnostic, DiffInfo, ProjectInfo, ReactDoctorConfig, ScoreResult } from "./types.js"; +import type { + AccessibilityPreset, + Diagnostic, + DiffInfo, + ProjectInfo, + ReactDoctorConfig, + ScoreResult, +} from "./types.js"; import { calculateScore } from "./utils/calculate-score.js"; import { combineDiagnostics, computeJsxIncludePaths } from "./utils/combine-diagnostics.js"; import { discoverProject } from "./utils/discover-project.js"; @@ -8,13 +15,21 @@ import { loadConfig } from "./utils/load-config.js"; import { runKnip } from "./utils/run-knip.js"; import { runOxlint } from "./utils/run-oxlint.js"; -export type { Diagnostic, DiffInfo, ProjectInfo, ReactDoctorConfig, ScoreResult }; +export type { + AccessibilityPreset, + Diagnostic, + DiffInfo, + ProjectInfo, + ReactDoctorConfig, + ScoreResult, +}; export { getDiffInfo, filterSourceFiles } from "./utils/get-diff-files.js"; export interface DiagnoseOptions { lint?: boolean; deadCode?: boolean; includePaths?: string[]; + accessibility?: AccessibilityPreset | false; } export interface DiagnoseResult { @@ -38,6 +53,7 @@ export const diagnose = async ( const effectiveLint = options.lint ?? userConfig?.lint ?? true; const effectiveDeadCode = options.deadCode ?? userConfig?.deadCode ?? true; + const effectiveAccessibility = options.accessibility ?? userConfig?.accessibility ?? "minimal"; if (!projectInfo.reactVersion) { throw new Error("No React dependency found in package.json"); @@ -54,6 +70,7 @@ export const diagnose = async ( projectInfo.framework, projectInfo.hasReactCompiler, jsxIncludePaths, + effectiveAccessibility, ).catch((error: unknown) => { console.error("Lint failed:", error); return emptyDiagnostics; diff --git a/packages/react-doctor/src/oxlint-config.ts b/packages/react-doctor/src/oxlint-config.ts index 8dfb564..e260304 100644 --- a/packages/react-doctor/src/oxlint-config.ts +++ b/packages/react-doctor/src/oxlint-config.ts @@ -1,8 +1,70 @@ import { createRequire } from "node:module"; -import type { Framework } from "./types.js"; +import type { AccessibilityPreset, Framework } from "./types.js"; const esmRequire = createRequire(import.meta.url); +// Minimal preset: The original curated set of high-impact rules +const A11Y_RULES_MINIMAL: Record = { + "jsx-a11y/alt-text": "error", + "jsx-a11y/anchor-is-valid": "warn", + "jsx-a11y/click-events-have-key-events": "warn", + "jsx-a11y/no-static-element-interactions": "warn", + "jsx-a11y/no-noninteractive-element-interactions": "warn", + "jsx-a11y/role-has-required-aria-props": "error", + "jsx-a11y/no-autofocus": "warn", + "jsx-a11y/heading-has-content": "warn", + "jsx-a11y/html-has-lang": "warn", + "jsx-a11y/no-redundant-roles": "warn", + "jsx-a11y/scope": "warn", + "jsx-a11y/tabindex-no-positive": "warn", + "jsx-a11y/label-has-associated-control": "warn", + "jsx-a11y/no-distracting-elements": "error", + "jsx-a11y/iframe-has-title": "warn", +}; + +// Recommended preset: All jsx-a11y recommended rules +const A11Y_RULES_RECOMMENDED: Record = { + ...A11Y_RULES_MINIMAL, + "jsx-a11y/anchor-has-content": "warn", + "jsx-a11y/aria-activedescendant-has-tabindex": "warn", + "jsx-a11y/aria-props": "warn", + "jsx-a11y/aria-proptypes": "warn", + "jsx-a11y/aria-role": "warn", + "jsx-a11y/aria-unsupported-elements": "warn", + "jsx-a11y/autocomplete-valid": "warn", + "jsx-a11y/img-redundant-alt": "warn", + "jsx-a11y/interactive-supports-focus": "warn", + "jsx-a11y/media-has-caption": "warn", + "jsx-a11y/mouse-events-have-key-events": "warn", + "jsx-a11y/no-access-key": "warn", + "jsx-a11y/no-interactive-element-to-noninteractive-role": "warn", + "jsx-a11y/no-noninteractive-element-to-interactive-role": "warn", + "jsx-a11y/no-noninteractive-tabindex": "warn", + "jsx-a11y/role-supports-aria-props": "warn", +}; + +// Strict preset: All recommended rules plus strict-only rules, with errors instead of warnings +const A11Y_RULES_STRICT: Record = { + ...Object.fromEntries(Object.entries(A11Y_RULES_RECOMMENDED).map(([rule]) => [rule, "error"])), + "jsx-a11y/anchor-ambiguous-text": "error", + "jsx-a11y/control-has-associated-label": "error", +}; + +const getAccessibilityRules = (preset: AccessibilityPreset | false): Record => { + switch (preset) { + case false: + return {}; + case "minimal": + return A11Y_RULES_MINIMAL; + case "recommended": + return A11Y_RULES_RECOMMENDED; + case "strict": + return A11Y_RULES_STRICT; + default: + return A11Y_RULES_MINIMAL; + } +}; + const NEXTJS_RULES: Record = { "react-doctor/nextjs-no-img-element": "warn", "react-doctor/nextjs-async-client-component": "error", @@ -56,12 +118,14 @@ interface OxlintConfigOptions { pluginPath: string; framework: Framework; hasReactCompiler: boolean; + accessibilityPreset: AccessibilityPreset | false; } export const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, + accessibilityPreset, }: OxlintConfigOptions) => ({ categories: { correctness: "off", @@ -72,7 +136,11 @@ export const createOxlintConfig = ({ style: "off", nursery: "off", }, - plugins: ["react", "jsx-a11y", ...(hasReactCompiler ? [] : ["react-perf"])], + plugins: [ + "react", + ...(accessibilityPreset !== false ? ["jsx-a11y"] : []), + ...(hasReactCompiler ? [] : ["react-perf"]), + ], jsPlugins: [ ...(hasReactCompiler ? [{ name: "react-hooks-js", specifier: esmRequire.resolve("eslint-plugin-react-hooks") }] @@ -93,21 +161,7 @@ export const createOxlintConfig = ({ "react/require-render-return": "error", "react/no-unknown-property": "warn", - "jsx-a11y/alt-text": "error", - "jsx-a11y/anchor-is-valid": "warn", - "jsx-a11y/click-events-have-key-events": "warn", - "jsx-a11y/no-static-element-interactions": "warn", - "jsx-a11y/no-noninteractive-element-interactions": "warn", - "jsx-a11y/role-has-required-aria-props": "error", - "jsx-a11y/no-autofocus": "warn", - "jsx-a11y/heading-has-content": "warn", - "jsx-a11y/html-has-lang": "warn", - "jsx-a11y/no-redundant-roles": "warn", - "jsx-a11y/scope": "warn", - "jsx-a11y/tabindex-no-positive": "warn", - "jsx-a11y/label-has-associated-control": "warn", - "jsx-a11y/no-distracting-elements": "error", - "jsx-a11y/iframe-has-title": "warn", + ...getAccessibilityRules(accessibilityPreset), ...(hasReactCompiler ? REACT_COMPILER_RULES : {}), diff --git a/packages/react-doctor/src/scan.ts b/packages/react-doctor/src/scan.ts index 7dd8749..a279701 100644 --- a/packages/react-doctor/src/scan.ts +++ b/packages/react-doctor/src/scan.ts @@ -463,6 +463,7 @@ export const scan = async ( const options = mergeScanOptions(inputOptions, userConfig); const { includePaths } = options; const isDiffMode = includePaths.length > 0; + const effectiveAccessibility = userConfig?.accessibility ?? "minimal"; if (!projectInfo.reactVersion) { throw new Error("No React dependency found in package.json"); @@ -490,6 +491,7 @@ export const scan = async ( projectInfo.framework, projectInfo.hasReactCompiler, jsxIncludePaths, + effectiveAccessibility, resolvedNodeBinaryPath, ); lintSpinner?.succeed("Running lint checks."); diff --git a/packages/react-doctor/src/types.ts b/packages/react-doctor/src/types.ts index b0c4827..b2a28e5 100644 --- a/packages/react-doctor/src/types.ts +++ b/packages/react-doctor/src/types.ts @@ -167,6 +167,8 @@ export interface ReactDoctorIgnoreConfig { files?: string[]; } +export type AccessibilityPreset = "minimal" | "recommended" | "strict"; + export interface ReactDoctorConfig { ignore?: ReactDoctorIgnoreConfig; lint?: boolean; @@ -174,4 +176,5 @@ export interface ReactDoctorConfig { verbose?: boolean; diff?: boolean | string; failOn?: FailOnLevel; + accessibility?: AccessibilityPreset | false; } diff --git a/packages/react-doctor/src/utils/run-oxlint.ts b/packages/react-doctor/src/utils/run-oxlint.ts index b1c6a3f..7c368c8 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 { + AccessibilityPreset, + CleanedDiagnostic, + Diagnostic, + Framework, + OxlintOutput, +} from "../types.js"; import { neutralizeDisableDirectives } from "./neutralize-disable-directives.js"; const esmRequire = createRequire(import.meta.url); @@ -251,7 +257,10 @@ const cleanDiagnosticMessage = ( return { message: REACT_COMPILER_MESSAGE, help: rawMessage || help }; } const cleaned = message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim(); - return { message: cleaned || message, help: help || RULE_HELP_MAP[rule] || "" }; + return { + message: cleaned || message, + help: help || RULE_HELP_MAP[rule] || "", + }; }; const parseRuleCode = (code: string): { plugin: string; rule: string } => { @@ -379,6 +388,7 @@ export const runOxlint = async ( framework: Framework, hasReactCompiler: boolean, includePaths?: string[], + accessibilityPreset: AccessibilityPreset | false = "minimal", nodeBinaryPath: string = process.execPath, ): Promise => { if (includePaths !== undefined && includePaths.length === 0) { @@ -387,7 +397,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 config = createOxlintConfig({ + pluginPath, + framework, + hasReactCompiler, + accessibilityPreset, + }); const restoreDisableDirectives = neutralizeDisableDirectives(rootDirectory); try { diff --git a/packages/react-doctor/tests/oxlint-config.test.ts b/packages/react-doctor/tests/oxlint-config.test.ts new file mode 100644 index 0000000..b3b397b --- /dev/null +++ b/packages/react-doctor/tests/oxlint-config.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; +import { createOxlintConfig } from "../src/oxlint-config.js"; + +type OxlintConfig = ReturnType; +type Rules = Record; + +const getRules = (config: OxlintConfig): Rules => config.rules as Rules; + +describe("createOxlintConfig", () => { + const baseOptions = { + pluginPath: "/path/to/plugin.js", + framework: "unknown" as const, + hasReactCompiler: false, + }; + + describe("accessibility presets", () => { + it("includes jsx-a11y plugin when accessibility is enabled", () => { + const config = createOxlintConfig({ + ...baseOptions, + accessibilityPreset: "minimal", + }); + expect(config.plugins).toContain("jsx-a11y"); + }); + + it("excludes jsx-a11y plugin when accessibility is false", () => { + const config = createOxlintConfig({ + ...baseOptions, + accessibilityPreset: false, + }); + expect(config.plugins).not.toContain("jsx-a11y"); + }); + + it("includes minimal a11y rules for minimal preset", () => { + const config = createOxlintConfig({ + ...baseOptions, + accessibilityPreset: "minimal", + }); + const rules = getRules(config); + + // Core minimal rules + expect(rules["jsx-a11y/alt-text"]).toBe("error"); + expect(rules["jsx-a11y/click-events-have-key-events"]).toBe("warn"); + expect(rules["jsx-a11y/no-static-element-interactions"]).toBe("warn"); + expect(rules["jsx-a11y/role-has-required-aria-props"]).toBe("error"); + + // Rules NOT in minimal but in recommended + expect(rules["jsx-a11y/mouse-events-have-key-events"]).toBeUndefined(); + expect(rules["jsx-a11y/aria-props"]).toBeUndefined(); + }); + + it("includes all recommended a11y rules for recommended preset", () => { + const config = createOxlintConfig({ + ...baseOptions, + accessibilityPreset: "recommended", + }); + const rules = getRules(config); + + // All minimal rules should be present + expect(rules["jsx-a11y/alt-text"]).toBe("error"); + expect(rules["jsx-a11y/click-events-have-key-events"]).toBe("warn"); + + // Additional recommended rules + expect(rules["jsx-a11y/mouse-events-have-key-events"]).toBe("warn"); + expect(rules["jsx-a11y/aria-props"]).toBe("warn"); + expect(rules["jsx-a11y/aria-proptypes"]).toBe("warn"); + expect(rules["jsx-a11y/interactive-supports-focus"]).toBe("warn"); + + // Rules NOT in recommended but in strict + expect(rules["jsx-a11y/anchor-ambiguous-text"]).toBeUndefined(); + expect(rules["jsx-a11y/control-has-associated-label"]).toBeUndefined(); + }); + + it("includes all strict a11y rules with error severity for strict preset", () => { + const config = createOxlintConfig({ + ...baseOptions, + accessibilityPreset: "strict", + }); + const rules = getRules(config); + + // All rules should be errors in strict mode + expect(rules["jsx-a11y/alt-text"]).toBe("error"); + expect(rules["jsx-a11y/click-events-have-key-events"]).toBe("error"); + expect(rules["jsx-a11y/mouse-events-have-key-events"]).toBe("error"); + expect(rules["jsx-a11y/aria-props"]).toBe("error"); + + // Strict-only rules + expect(rules["jsx-a11y/anchor-ambiguous-text"]).toBe("error"); + expect(rules["jsx-a11y/control-has-associated-label"]).toBe("error"); + }); + + it("excludes all a11y rules when accessibility is false", () => { + const config = createOxlintConfig({ + ...baseOptions, + accessibilityPreset: false, + }); + const rules = getRules(config); + + const a11yRules = Object.keys(rules).filter((rule) => rule.startsWith("jsx-a11y/")); + expect(a11yRules).toHaveLength(0); + }); + + it("counts correct number of rules per preset", () => { + const minimalConfig = createOxlintConfig({ + ...baseOptions, + accessibilityPreset: "minimal", + }); + const recommendedConfig = createOxlintConfig({ + ...baseOptions, + accessibilityPreset: "recommended", + }); + const strictConfig = createOxlintConfig({ + ...baseOptions, + accessibilityPreset: "strict", + }); + + const countA11yRules = (config: OxlintConfig) => + Object.keys(config.rules).filter((rule) => rule.startsWith("jsx-a11y/")).length; + + expect(countA11yRules(minimalConfig)).toBe(15); + expect(countA11yRules(recommendedConfig)).toBe(31); + expect(countA11yRules(strictConfig)).toBe(33); + }); + }); + + describe("framework configuration", () => { + it("includes nextjs rules when framework is nextjs", () => { + const config = createOxlintConfig({ + ...baseOptions, + accessibilityPreset: "minimal", + framework: "nextjs", + }); + const rules = getRules(config); + expect(rules["react-doctor/nextjs-no-img-element"]).toBe("warn"); + expect(rules["react-doctor/nextjs-async-client-component"]).toBe("error"); + }); + + it("excludes nextjs rules when framework is not nextjs", () => { + const config = createOxlintConfig({ + ...baseOptions, + accessibilityPreset: "minimal", + framework: "unknown", + }); + const rules = getRules(config); + expect(rules["react-doctor/nextjs-no-img-element"]).toBeUndefined(); + expect(rules["react-doctor/nextjs-async-client-component"]).toBeUndefined(); + }); + }); + + describe("react compiler configuration", () => { + it("includes react-perf plugin when react compiler is disabled", () => { + const config = createOxlintConfig({ + ...baseOptions, + accessibilityPreset: "minimal", + hasReactCompiler: false, + }); + expect(config.plugins).toContain("react-perf"); + }); + + it("excludes react-perf plugin when react compiler is enabled", () => { + const config = createOxlintConfig({ + ...baseOptions, + accessibilityPreset: "minimal", + hasReactCompiler: true, + }); + expect(config.plugins).not.toContain("react-perf"); + }); + + it("includes react compiler rules when react compiler is enabled", () => { + const config = createOxlintConfig({ + ...baseOptions, + accessibilityPreset: "minimal", + hasReactCompiler: true, + }); + const rules = getRules(config); + expect(rules["react-hooks-js/immutability"]).toBe("error"); + expect(rules["react-hooks-js/purity"]).toBe("error"); + }); + }); +});