Skip to content
Open
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
922 changes: 922 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

39 changes: 29 additions & 10 deletions packages/react-doctor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ One command scans your codebase for security, performance, correctness, and arch

### [See it in action →](https://react.doctor)

https://github.com/user-attachments/assets/07cc88d9-9589-44c3-aa73-5d603cb1c570
<https://github.com/user-attachments/assets/07cc88d9-9589-44c3-aa73-5d603cb1c570>

## How it works

Expand Down Expand Up @@ -73,7 +73,7 @@ The action outputs a `score` (0–100) you can use in subsequent steps.

## Options

```
```text
Usage: react-doctor [directory] [options]

Options:
Expand All @@ -84,8 +84,11 @@ Options:
--score output only the score
-y, --yes skip prompts, scan all workspace projects
--project <name> select workspace project (comma-separated for multiple)
--framework <name> override framework detection
--diff [base] scan only files changed vs base branch
--offline skip telemetry used for score estimation
--no-ami skip Ami-related prompts
--fail-on <level> exit with error code on diagnostics: error, warning, none
--fix open Ami to auto-fix all issues
-h, --help display help for command
```
Expand Down Expand Up @@ -119,17 +122,33 @@ 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. |
| `failOn` | `"error" \| "warning" \| "none"` | `"none"` | Exit with a non-zero code when diagnostics meet the configured threshold. |

CLI flags always override config values.

## Roblox / React Luau (`roblox-ts`)

React Doctor can detect Roblox React Luau projects built through `roblox-ts`.

Auto-detection requires `@rbxts/react` plus one of:

- `tsconfig.json` marker types (`@rbxts/types`, `@rbxts/react-types`, or `@rbxts/react-roblox-types`)
- Roblox toolchain dependencies (`@rbxts/types` or `roblox-ts`)

If detection misses your setup, force it:

```bash
npx -y react-doctor@latest . --framework roblox-ts
```

## Node.js API

You can also use React Doctor programmatically:
Expand Down
35 changes: 35 additions & 0 deletions packages/react-doctor/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
DiffInfo,
EstimatedScoreResult,
FailOnLevel,
Framework,
ReactDoctorConfig,
ScanOptions,
} from "./types.js";
Expand Down Expand Up @@ -37,10 +38,42 @@ interface CliFlags {
offline: boolean;
ami: boolean;
project?: string;
framework?: string;
diff?: boolean | string;
failOn: string;
}

const SUPPORTED_FRAMEWORKS: Framework[] = [
"nextjs",
"vite",
"cra",
"remix",
"gatsby",
"expo",
"react-native",
"roblox-ts",
"unknown",
];

const parseFrameworkOverride = (frameworkName?: string): Framework | undefined => {
if (!frameworkName) {
return undefined;
}

const normalizedFrameworkName = frameworkName.toLowerCase();
const supportedFramework = SUPPORTED_FRAMEWORKS.find(
(frameworkNameCandidate) => frameworkNameCandidate === normalizedFrameworkName,
);

if (supportedFramework) {
return supportedFramework;
}

throw new Error(
`Invalid framework override "${frameworkName}". Supported values: ${SUPPORTED_FRAMEWORKS.join(", ")}`,
);
};

const VALID_FAIL_ON_LEVELS = new Set<FailOnLevel>(["error", "warning", "none"]);

const isValidFailOnLevel = (level: string): level is FailOnLevel =>
Expand Down Expand Up @@ -90,6 +123,7 @@ const resolveCliScanOptions = (
verbose: isCliOverride("verbose") ? Boolean(flags.verbose) : (userConfig?.verbose ?? false),
scoreOnly: flags.score,
offline: flags.offline,
frameworkOverride: parseFrameworkOverride(flags.framework),
};
};

Expand Down Expand Up @@ -141,6 +175,7 @@ const program = new Command()
.option("--score", "output only the score")
.option("-y, --yes", "skip prompts, scan all workspace projects")
.option("--project <name>", "select workspace project (comma-separated for multiple)")
.option("--framework <name>", "override framework detection")
.option("--diff [base]", "scan only files changed vs base branch")
.option("--offline", "skip telemetry (anonymous, not stored, only used to calculate score)")
.option("--no-ami", "skip Ami-related prompts")
Expand Down
250 changes: 151 additions & 99 deletions packages/react-doctor/src/oxlint-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,62 @@ const REACT_COMPILER_RULES: Record<string, string> = {
"react-hooks-js/todo": "error",
};

const JSX_A11Y_RULES: Record<string, string> = {
"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",
};

const ROBLOX_TS_SPECIFIC_RULES: Record<string, string> = {
"react-doctor/rbx-no-uncleaned-connection": "error",
"react-doctor/rbx-no-print": "warn",
"react-doctor/rbx-no-direct-instance-mutation": "warn",
"react-doctor/rbx-no-unstored-connection": "warn",
};

const ROBLOX_TS_RULE_OVERRIDES: Record<string, string> = {
"react/jsx-no-script-url": "off",
"react/no-unknown-property": "off",

"react-doctor/no-layout-property-animation": "off",
"react-doctor/rendering-animate-svg-wrapper": "off",
"react-doctor/rendering-hydration-no-flicker": "off",

"react-doctor/no-transition-all": "off",
"react-doctor/no-global-css-variable-animation": "off",
"react-doctor/no-large-animated-blur": "off",
"react-doctor/no-scale-from-zero": "off",
"react-doctor/no-permanent-will-change": "off",

"react-doctor/no-secrets-in-client-code": "off",

"react-doctor/prefer-dynamic-import": "off",
"react-doctor/use-lazy-motion": "off",
"react-doctor/no-undeferred-third-party": "off",

"react-doctor/no-prevent-default": "off",

"react-doctor/server-auth-actions": "off",
"react-doctor/server-after-nonblocking": "off",

"react-doctor/client-passive-event-listeners": "off",

"react-doctor/js-batch-dom-css": "off",
"react-doctor/js-cache-storage": "off",
};

interface OxlintConfigOptions {
pluginPath: string;
framework: Framework;
Expand All @@ -62,102 +118,98 @@ export const createOxlintConfig = ({
pluginPath,
framework,
hasReactCompiler,
}: OxlintConfigOptions) => ({
categories: {
correctness: "off",
suspicious: "off",
pedantic: "off",
perf: "off",
restriction: "off",
style: "off",
nursery: "off",
},
plugins: ["react", "jsx-a11y", ...(hasReactCompiler ? [] : ["react-perf"])],
jsPlugins: [
...(hasReactCompiler
? [{ name: "react-hooks-js", specifier: esmRequire.resolve("eslint-plugin-react-hooks") }]
: []),
pluginPath,
],
rules: {
"react/rules-of-hooks": "error",
"react/no-direct-mutation-state": "error",
"react/jsx-no-duplicate-props": "error",
"react/jsx-key": "error",
"react/no-children-prop": "warn",
"react/no-danger": "warn",
"react/jsx-no-script-url": "error",
"react/no-render-return-value": "warn",
"react/no-string-refs": "warn",
"react/no-is-mounted": "warn",
"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",

...(hasReactCompiler ? REACT_COMPILER_RULES : {}),

"react-doctor/no-derived-state-effect": "error",
"react-doctor/no-fetch-in-effect": "error",
"react-doctor/no-cascading-set-state": "warn",
"react-doctor/no-effect-event-handler": "warn",
"react-doctor/no-derived-useState": "warn",
"react-doctor/prefer-useReducer": "warn",
"react-doctor/rerender-lazy-state-init": "warn",
"react-doctor/rerender-functional-setstate": "warn",
"react-doctor/rerender-dependencies": "error",

"react-doctor/no-giant-component": "warn",
"react-doctor/no-render-in-render": "warn",
"react-doctor/no-nested-component-definition": "error",

"react-doctor/no-usememo-simple-expression": "warn",
"react-doctor/no-layout-property-animation": "error",
"react-doctor/rerender-memo-with-default-value": "warn",
"react-doctor/rendering-animate-svg-wrapper": "warn",
"react-doctor/no-inline-prop-on-memo-component": "warn",
"react-doctor/rendering-hydration-no-flicker": "warn",

"react-doctor/no-transition-all": "warn",
"react-doctor/no-global-css-variable-animation": "error",
"react-doctor/no-large-animated-blur": "warn",
"react-doctor/no-scale-from-zero": "warn",
"react-doctor/no-permanent-will-change": "warn",

"react-doctor/no-secrets-in-client-code": "error",

"react-doctor/no-barrel-import": "warn",
"react-doctor/no-full-lodash-import": "warn",
"react-doctor/no-moment": "warn",
"react-doctor/prefer-dynamic-import": "warn",
"react-doctor/use-lazy-motion": "warn",
"react-doctor/no-undeferred-third-party": "warn",

"react-doctor/no-array-index-as-key": "warn",
"react-doctor/rendering-conditional-render": "warn",
"react-doctor/no-prevent-default": "warn",

"react-doctor/server-auth-actions": "error",
"react-doctor/server-after-nonblocking": "warn",

"react-doctor/client-passive-event-listeners": "warn",

"react-doctor/async-parallel": "warn",
...(framework === "nextjs" ? NEXTJS_RULES : {}),
...(framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {}),
},
});
}: OxlintConfigOptions) => {
const isRobloxTsFramework = framework === "roblox-ts";

return {
categories: {
correctness: "off",
suspicious: "off",
pedantic: "off",
perf: "off",
restriction: "off",
style: "off",
nursery: "off",
},
plugins: [
"react",
...(isRobloxTsFramework ? [] : ["jsx-a11y"]),
...(hasReactCompiler ? [] : ["react-perf"]),
],
jsPlugins: [
...(hasReactCompiler
? [{ name: "react-hooks-js", specifier: esmRequire.resolve("eslint-plugin-react-hooks") }]
: []),
pluginPath,
],
rules: {
"react/rules-of-hooks": "error",
"react/no-direct-mutation-state": "error",
"react/jsx-no-duplicate-props": "error",
"react/jsx-key": "error",
"react/no-children-prop": "warn",
"react/no-danger": "warn",
"react/jsx-no-script-url": "error",
"react/no-render-return-value": "warn",
"react/no-string-refs": "warn",
"react/no-is-mounted": "warn",
"react/require-render-return": "error",
"react/no-unknown-property": "warn",

...(isRobloxTsFramework ? {} : JSX_A11Y_RULES),

...(hasReactCompiler ? REACT_COMPILER_RULES : {}),

"react-doctor/no-derived-state-effect": "error",
"react-doctor/no-fetch-in-effect": "error",
"react-doctor/no-cascading-set-state": "warn",
"react-doctor/no-effect-event-handler": "warn",
"react-doctor/no-derived-useState": "warn",
"react-doctor/prefer-useReducer": "warn",
"react-doctor/rerender-lazy-state-init": "warn",
"react-doctor/rerender-functional-setstate": "warn",
"react-doctor/rerender-dependencies": "error",

"react-doctor/no-giant-component": "warn",
"react-doctor/no-render-in-render": "warn",
"react-doctor/no-nested-component-definition": "error",

"react-doctor/no-usememo-simple-expression": "warn",
"react-doctor/no-layout-property-animation": "error",
"react-doctor/rerender-memo-with-default-value": "warn",
"react-doctor/rendering-animate-svg-wrapper": "warn",
"react-doctor/no-inline-prop-on-memo-component": "warn",
"react-doctor/rendering-hydration-no-flicker": "warn",

"react-doctor/no-transition-all": "warn",
"react-doctor/no-global-css-variable-animation": "error",
"react-doctor/no-large-animated-blur": "warn",
"react-doctor/no-scale-from-zero": "warn",
"react-doctor/no-permanent-will-change": "warn",

"react-doctor/no-secrets-in-client-code": "error",

"react-doctor/no-barrel-import": "warn",
"react-doctor/no-full-lodash-import": "warn",
"react-doctor/no-moment": "warn",
"react-doctor/prefer-dynamic-import": "warn",
"react-doctor/use-lazy-motion": "warn",
"react-doctor/no-undeferred-third-party": "warn",

"react-doctor/no-array-index-as-key": "warn",
"react-doctor/rendering-conditional-render": "warn",
"react-doctor/no-prevent-default": "warn",

"react-doctor/server-auth-actions": "error",
"react-doctor/server-after-nonblocking": "warn",

"react-doctor/client-passive-event-listeners": "warn",

"react-doctor/async-parallel": "warn",
...(isRobloxTsFramework ? ROBLOX_TS_SPECIFIC_RULES : {}),
...(isRobloxTsFramework ? ROBLOX_TS_RULE_OVERRIDES : {}),
...(framework === "nextjs" ? NEXTJS_RULES : {}),
...(framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {}),
},
};
};
Loading