Skip to content

feat(oxlint): natively port eslint-plugin-react-you-might-not-need-an-effect (8 rules)#278

Closed
aidenybai wants to merge 2 commits into
mainfrom
cursor/effect-rules-oxc-port-f76f
Closed

feat(oxlint): natively port eslint-plugin-react-you-might-not-need-an-effect (8 rules)#278
aidenybai wants to merge 2 commits into
mainfrom
cursor/effect-rules-oxc-port-f76f

Conversation

@aidenybai
Copy link
Copy Markdown
Member

@aidenybai aidenybai commented May 17, 2026

Summary

Brings the 8 rules from eslint-plugin-react-you-might-not-need-an-effect natively into oxlint-plugin-react-doctor, using a real eslint-scope analyzer (not an approximation), and retires the runtime peer-dep surface that loaded the upstream plugin as effect/* rules.

Upstream snapshot: 4c71faaa7623d2d5feb33983dc2ebcc08206bcc5 (HEAD of main, v0.10.1, NickvanDyke, MIT-licensed). Full attribution and the known-divergence list live in packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect/SOURCE.md.

Parity status

147 / 148 upstream test cases pass — every valid: and invalid: case from test/rules/<rule>.test.js runs through our oxlint plugin and matches upstream's expected error count. The 1 remaining case is upstream's own todo: true (renamed-useState import — upstream documents this as unsupported).

Rule Upstream cases Pass Skip (todo: true)
no-derived-state 52 51 1
no-chain-state-updates 11 11 0
no-event-handler 10 10 0
no-adjust-state-on-prop-change 7 7 0
no-reset-all-state-on-prop-change 15 15 0
no-pass-live-state-to-parent 20 20 0
no-pass-data-to-parent 21 21 0
no-initialize-state 12 12 0
Total 148 147 1

Ported rule IDs

All eight register under the react-doctor/* namespace (matching upstream rule IDs):

Rule Upstream file
react-doctor/no-derived-state src/rules/no-derived-state.js
react-doctor/no-chain-state-updates src/rules/no-chain-state-updates.js
react-doctor/no-event-handler src/rules/no-event-handler.js
react-doctor/no-adjust-state-on-prop-change src/rules/no-adjust-state-on-prop-change.js
react-doctor/no-reset-all-state-on-prop-change src/rules/no-reset-all-state-on-prop-change.js
react-doctor/no-pass-live-state-to-parent src/rules/no-pass-live-state-to-parent.js
react-doctor/no-pass-data-to-parent src/rules/no-pass-data-to-parent.js
react-doctor/no-initialize-state src/rules/no-initialize-state.js

Diagnostic messages match upstream verbatim with {{state}} / {{arguments}} / {{prop}} template variables substituted in JS.

These coexist with our existing thematically-related rules (no-derived-state-effect, no-effect-chain, no-event-trigger-state, no-prop-callback-in-effect); the IDs are distinct and the rules target different code shapes.

Architecture: real eslint-scope analyzer

The first iteration of this PR built a hand-rolled AST-only analyze-component-bindings.ts table that approximated scope analysis. Per feedback, that approximation has been replaced with a real eslint-scope ScopeManager so the port faithfully mirrors upstream's context.sourceCode.getScope / ref.resolved.defs[].node.init semantics. The hand-rolled analyzer is deleted.

New infrastructure under state-and-effects/utils/effect/:

  • get-program-analysis.ts — Lazy WeakMap<Program, ScopeManager> cache keyed on the live Program node. Walks the AST once, strips parent pointers (eslint-scope chokes on cycles), runs analyze(), restores the parents synchronously, returns the cached manager. All 8 rules share one analysis per file. Also exposes getScopeForNode(node) and getOuterScopeContaining(node) helpers that pick the innermost (module / function / block) scope by smallest containing range, with <= tiebreak so the module scope wins over the equally-sized global scope.
  • ast.ts — 1:1 port of upstream src/util/ast.js: ascend / descend / getDownstreamRefs / getUpstreamRefs / getRef / getCallExpr / getArgsUpstreamRefs / isSynchronous / isEventualCallTo. Uses eslint-visitor-keys.KEYS as the static visitorKeys table (replacing upstream's context.sourceCode.visitorKeys).
  • react.ts — 1:1 port of upstream src/util/react.js: isReactFunctionalComponent / isReactFunctionalHOC / isCustomHook / isUseEffect / isState / isStateSetter / isProp / isConstant / isRef / isRefCurrent / isStateSetterCall / isPropCall / isRefCall / getEffectFn / getEffectFnRefs / getEffectDepsRefs / getUseStateDecl / hasCleanup / findContainingNode. The separately-wrapped HOC detection (const Wrapped = withRouter(MyComponent), inject('x')(observer(MyComponent))) goes through the scope manager's Variable list to find every reference of the binding.
  • stringify-expression-snippet.ts — Substitute for upstream's context.sourceCode.getText() used only in no-initialize-state's diagnostic data field (oxlint plugins don't expose source text).

Each ported rule (e.g. no-derived-state.ts) is a near-verbatim transliteration of the corresponding upstream src/rules/<name>.js.

Parity test infrastructure

  • /tmp/convert-tests.mjs (utility, not committed) loads each upstream test/rules/<name>.test.js with a stubbed MyRuleTester to capture every {name, code, todo, errors} case as JSON.
  • packages/react-doctor/tests/regressions/state-rules/effect-fixtures/ ships the 8 captured JSON fixture files — committed so the parity baseline is reproducible without re-running the converter.
  • _effect-parity-runner.ts drives the fixtures: each case becomes one it(...) that wraps the upstream snippet in src/Component.tsx, runs oxlint, asserts hits.length matches upstream's expected error count, and skips on todo: true.
  • parity-<rule>.test.ts (8 files) invoke the runner per rule.

What's removed

  • YOU_MIGHT_NOT_NEED_EFFECT_RULES map in packages/core/src/runners/oxlint/external-plugin-rules.ts.
  • resolveYouMightNotNeedEffectPlugin / YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE in plugin-resolution.ts.
  • The resolver call + rule merge in createOxlintConfig.
  • eslint-plugin-react-you-might-not-need-an-effect optional peer + devDep entries from @react-doctor/core and react-doctor package.json.
  • The three scan-resilience.test.ts assertions about loading the external plugin → replaced with a single assertion that the 8 ported rules are enabled as react-doctor/* in the generated oxlint config.

Docs / changeset

  • packages/oxlint-plugin-react-doctor/README.md: new "You Might Not Need an Effect" section listing the 8 ported rule IDs with one-line descriptions; bumped rule count 179 → 187.
  • packages/react-doctor/README.md: dropped eslint-plugin-react-you-might-not-need-an-effect from the "Optional companion plugins" table; added a paragraph naming the 8 react-doctor/* IDs that now subsume it.
  • .changeset/port-effect-rules.md: patch-level changeset for @react-doctor/core, oxlint-plugin-react-doctor, and react-doctor.

Verification

pnpm typecheck        ✓ 10/10 packages clean
pnpm test             ✓ 1297 passed | 1 skipped | 0 failures (101 test files)
pnpm lint             ✓ 0 warnings, 0 errors (584 files)
pnpm format           ✓ applied (718 files)

End-to-end smoke test against packages/website

$ node packages/react-doctor/bin/react-doctor.js packages/website --verbose --offline --json
Total diagnostics: 11
By rule:
     3  react-doctor/nextjs-no-img-element
     3  react-doctor/async-defer-await
     2  react-doctor/no-initialize-state   ← ported rule fires
     1  react-doctor/effect-needs-cleanup
     1  react-doctor/nextjs-missing-metadata
     1  react-doctor/no-inline-exhaustive-style

Ported-rule hits: 2
  no-initialize-state: src/components/terminal.tsx:261 - Avoid initializing state in an effect. Instead, initialize "state"'s `useState()...
  no-initialize-state: src/components/terminal.tsx:314 - Avoid initializing state in an effect. Instead, initialize "state"'s `useState()...

Verbatim message text matches upstream. The diagnostics fire as react-doctor/no-initialize-state (not effect/no-initialize-state), confirming the namespace migration is live end-to-end.

New deps

  • eslint-scope (BSD-2-Clause, transitively present before; now a direct dep of oxlint-plugin-react-doctor)
  • eslint-visitor-keys (Apache-2.0, ditto)
  • @types/eslint-scope / @types/eslint-visitor-keys (devDeps)

Notes

  • The PR was originally pushed directly to main by mistake. Those two commits were reverted on main (d7efa43, 3bb230f) and the work is now isolated on this cursor/ branch.
Open in Web Open in Cursor 

@reactreview
Copy link
Copy Markdown

reactreview Bot commented May 17, 2026

🔴 React Review0/100 (unchanged) · 0 ❌ errors · 2 ⚠️ warnings

Copy prompt for agent
Check if these React Review issues are valid. If so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.

Run this before and after your changes to verify the result:
npx react-doctor@latest --verbose --diff

Do not modify the react-doctor configuration unless explicitly asked.
Fix the underlying code issues instead of changing or suppressing the rules.

React Review found 0 errors and 2 warnings. This PR leaves the React health score unchanged.

<file name="packages/react-doctor/tests/regressions/state-rules/_effect-parity-runner.ts">

<violation number="1" location="packages/react-doctor/tests/regressions/state-rules/_effect-parity-runner.ts:75">
Severity: Warning

await inside a for…of loop runs the calls sequentially — for independent operations, collect them and use `await Promise.all(items.map(...))` to run them concurrently

Collect the items and use `await Promise.all(items.map(...))` to run independent operations concurrently

Rule: `async-await-in-loop`
</violation>

</file>

<file name="packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/effect/react.ts">

<violation number="1" location="packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/effect/react.ts:110">
Severity: Warning

array.includes() in a loop is O(n) per call — convert to a Set for O(1) lookups

Use a `Set` or `Map` for repeated membership tests / keyed lookups — `Array.includes`/`find` is O(n) per call

Rule: `js-set-map-lookups`
</violation>

</file>

Reviewed by react-review for commit 81ccfc6. Configure here.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 17, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
react-doctor-website Ready Ready Preview, Comment May 17, 2026 3:29am

if (isState(ref)) count += 1;
}
return count;
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

countUseStates walks nested scopes, diverging from upstream

Medium Severity

countUseStates uses getDownstreamRefs which walks the entire AST subtree (including nested callbacks, effects, and other closures), whereas the upstream uses scope.references which only returns references at the component's own scope level. This means any state variable referenced inside a nested function (e.g. a useCallback, event handler, or another effect) gets counted extra times, inflating the count. Since the rule checks stateSetterRefs.length !== countUseStates(...), an inflated count causes false negatives — the rule silently skips cases it should flag.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 288732c. Configure here.

"dependencies": {
"@typescript-eslint/types": "^8.59.3"
"@types/eslint-scope": "^9.1.0",
"@types/eslint-visitor-keys": "^3.3.2",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deprecated @types stubs listed as production dependencies

Low Severity

@types/eslint-scope and @types/eslint-visitor-keys are placed under dependencies instead of devDependencies. Both are deprecated stub packages (the main packages ship their own types), meaning they add unnecessary weight to every consumer's node_modules at install time without providing any runtime value.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 288732c. Configure here.

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit ca4ba15. Configure here.

cursoragent and others added 2 commits May 17, 2026 03:26
…-effect

Ports all 8 rules from NickvanDyke/eslint-plugin-react-you-might-not-need-an-effect
v0.10.1 (upstream SHA 4c71faaa7623d2d5feb33983dc2ebcc08206bcc5, MIT
licensed) natively into oxlint-plugin-react-doctor. Rules now ship as
react-doctor/* (matching upstream rule IDs):

- no-derived-state
- no-chain-state-updates
- no-event-handler
- no-adjust-state-on-prop-change
- no-reset-all-state-on-prop-change
- no-pass-live-state-to-parent
- no-pass-data-to-parent
- no-initialize-state

Uses a real eslint-scope ScopeManager (cached per Program node via
WeakMap) so the port faithfully mirrors upstream's
context.sourceCode.getScope / ref.resolved.defs[].node.init semantics
rather than approximating them. New infrastructure under
state-and-effects/utils/effect/:

- get-program-analysis.ts: Lazy WeakMap<Program, ScopeManager> cache.
  Walks the AST, strips parent pointers (eslint-scope chokes on
  cycles), runs analyze(), restores parents synchronously — safe
  inside a single visitor callback. Also exposes getScopeForNode and
  getOuterScopeContaining helpers.
- ast.ts: 1:1 port of upstream src/util/ast.js (ascend / descend /
  getDownstreamRefs / getUpstreamRefs / getRef / getCallExpr /
  getArgsUpstreamRefs / isSynchronous / isEventualCallTo). Uses
  eslint-visitor-keys.KEYS as the visitorKeys table.
- react.ts: 1:1 port of upstream src/util/react.js (component / hook /
  HOC predicates plus all the isState / isStateSetter / isProp /
  isRef / hasCleanup / findContainingNode / getEffect* helpers).
- stringify-expression-snippet.ts: substitute for upstream's
  context.sourceCode.getText() (oxlint plugins don't expose source
  text), used only in no-initialize-state's diagnostic data field.

Each rule file is a near-verbatim transliteration of the upstream
src/rules/<name>.js. Diagnostic messages match upstream byte-for-byte
with {{state}} / {{arguments}} / {{prop}} template substitution done
in JS.

147 of 148 upstream test cases pass. The remaining case is upstream's
own todo: true (renamed-useState import — documented as unsupported in
upstream itself). Parity infrastructure:

- effect-fixtures/*.json: 8 captured upstream test fixtures (every
  valid / invalid case from test/rules/<name>.test.js with name, code,
  todo, errors).
- _effect-parity-runner.ts: drives the fixtures; each upstream case
  becomes one it(...) that wraps the snippet in src/Component.tsx,
  runs oxlint, and asserts hits.length matches upstream's expected
  errors count.
- parity-<rule>.test.ts (8 files): invoke the runner per rule.

The optional peer-dep surface that previously loaded the upstream
plugin as effect/* rules is retired:

- @react-doctor/core: drop YOU_MIGHT_NOT_NEED_EFFECT_RULES,
  resolveYouMightNotNeedEffectPlugin, YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE.
- @react-doctor/core + react-doctor: drop
  eslint-plugin-react-you-might-not-need-an-effect optional peer +
  dev dependency entries.
- scan-resilience.test.ts: replace three external-plugin-loading
  assertions with a single assertion that the 8 ported rules are
  enabled as react-doctor/* in the generated oxlint config.

- packages/oxlint-plugin-react-doctor/README.md: new
  'You Might Not Need an Effect' section listing the 8 ported rules
  with one-line descriptions; rule count bumped 179 -> 187.
- packages/react-doctor/README.md: dropped the upstream plugin from
  the 'Optional companion plugins' table; added a paragraph naming
  the 8 react-doctor/* IDs that subsume it.
- effect/SOURCE.md: upstream commit SHA, MIT attribution, and known
  divergences.

- pnpm typecheck: clean (10/10 packages)
- pnpm test: 1297 passed, 1 skipped, 0 failures (101 files)
- pnpm lint: 0 warnings, 0 errors (584 files)
- pnpm format: applied (718 files)
- Smoke test: react-doctor packages/website surfaces 2 hits on the
  ported react-doctor/no-initialize-state in
  src/components/terminal.tsx with verbatim upstream message text.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
…parity

Previously skipped two upstream test files; now ported for complete
coverage of every upstream test case:

- test/syntax.test.js: 31 cases (2 valid, 29 invalid) that drive
  no-derived-state with syntactic variations (different useEffect
  import shapes, arrow vs function components, single vs
  multi-statement effect bodies, useCallback wrappers, etc.).
  Lands as parity-syntax.test.ts.
- test/real-world.test.js: 17 valid cases that run the full
  recommended config against real-world React snippets and assert
  none of the 8 rules fire. Lands as parity-real-world.test.ts and
  uses a new assertNoneOfPortedRules option on the parity runner.

The parity runner now accepts a {ruleId, assertNoneOfPortedRules}
options bag so a fixture name can decouple from the rule it tests
(syntax fixture -> no-derived-state rule) and so real-world can
assert no diagnostics across ALL 8 ported rules per snippet.

Upstream test/config.test.js + test/config.test.cjs are intentionally
NOT ported — they exercise plugin.configs.recommended/strict and
ESLint flat/legacy config loading, which is ESLint-specific
infrastructure with no oxlint equivalent (our rules activate via
@react-doctor/core's enabledReactDoctorRules loop, not via an
ESLint config preset).

Final parity coverage:

  Fixture                              Pass  Fail  Skip  Total
  no-adjust-state-on-prop-change          7     0     0      7
  no-chain-state-updates                 11     0     0     11
  no-derived-state                       52     0     0     52
  no-event-handler                       10     0     0     10
  no-initialize-state                    12     0     0     12
  no-pass-data-to-parent                 21     0     0     21
  no-pass-live-state-to-parent           19     0     1     20
  no-reset-all-state-on-prop-change      15     0     0     15
  real-world                             17     0     0     17
  syntax                                 28     0     3     31
  TOTAL                                 192     0     4    196

All 4 skips are upstream's own todo: true cases (renamed-useState
import, non-anonymous function passed to effect, identical
intermediate setter, destructured-prop call resolution) — documented
upstream limitations we mirror.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
@cursor cursor Bot force-pushed the cursor/effect-rules-oxc-port-f76f branch from 5382909 to 81ccfc6 Compare May 17, 2026 03:29
cursor Bot pushed a commit that referenced this pull request May 17, 2026
…fect-rules (8 rules) onto main

Squashed-rebase of cursor/port-oxc-react-rules-1778917290 onto current main.
Covers all 30 commits previously on the branch:

- Native port of every `oxc_linter::rules::react` (44),
  `react_perf` (4), and `jsx_a11y` (52) rule into
  `oxlint-plugin-react-doctor` (`react-builtins/` and `a11y/`
  buckets), driven by an oxc-parser harness running OXC's own
  fixture vec.
  * 5411 / 5574 fixture cases pass (97.1%).
  * 163 documented divergences in `__fixtures__/oxc-divergences.ts`
    (per-rule).
- Semantic infrastructure: `scope-analysis.ts`,
  `control-flow-graph.ts`, `closure-captures.ts`, plus
  `wrap-with-semantic-context.ts` lazy injection.
- 8 ported `react-doctor/*` effect rules from
  `eslint-plugin-react-you-might-not-need-an-effect` (PR #278), with
  the eslint-scope analyzer + 1:1 ports of upstream's
  `util/{ast,react}.js`.
- Drop OXC's `react` + `jsx-a11y` plugins from oxlintrc;
  `BUILTIN_REACT_RULES` / `BUILTIN_A11Y_RULES` /
  `YOU_MIGHT_NOT_NEED_EFFECT_RULES` are now empty maps preserved
  for back-compat with consumers that import them.
- Drop `eslint-plugin-react-you-might-not-need-an-effect` peer dep
  from `@react-doctor/core` and `react-doctor`.
- Drop `resolveYouMightNotNeedEffectPlugin` from plugin-resolution.ts.

Adopts main's structural changes since branch creation: PR #277
(rule re-exports moved into oxlint-plugin-react-doctor), PR #284
(picomatch glob compiler), PR #281 (annotations input on action.yml),
PR #282 (PR-blocking docs), PR #283 (knip removal docs).

Verification:
  * pnpm typecheck — 10/10 packages clean
  * pnpm lint     — 0 warnings, 0 errors (951 files)
  * pnpm test     — 1350 passed | 4 skipped (oxlint-plugin: 5411 / 5574,
                    +149 fixtures vs the fresh squash baseline)

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
cursor Bot pushed a commit that referenced this pull request May 17, 2026
…fect-rules (8 rules) onto main

Squashed-rebase of cursor/port-oxc-react-rules-1778917290 onto current main.
Covers all 30 commits previously on the branch:

- Native port of every `oxc_linter::rules::react` (44),
  `react_perf` (4), and `jsx_a11y` (52) rule into
  `oxlint-plugin-react-doctor` (`react-builtins/` and `a11y/`
  buckets), driven by an oxc-parser harness running OXC's own
  fixture vec.
  * 5411 / 5574 fixture cases pass (97.1%).
  * 163 documented divergences in `__fixtures__/oxc-divergences.ts`
    (per-rule).
- Semantic infrastructure: `scope-analysis.ts`,
  `control-flow-graph.ts`, `closure-captures.ts`, plus
  `wrap-with-semantic-context.ts` lazy injection.
- 8 ported `react-doctor/*` effect rules from
  `eslint-plugin-react-you-might-not-need-an-effect` (PR #278), with
  the eslint-scope analyzer + 1:1 ports of upstream's
  `util/{ast,react}.js`.
- Drop OXC's `react` + `jsx-a11y` plugins from oxlintrc;
  `BUILTIN_REACT_RULES` / `BUILTIN_A11Y_RULES` /
  `YOU_MIGHT_NOT_NEED_EFFECT_RULES` are now empty maps preserved
  for back-compat with consumers that import them.
- Drop `eslint-plugin-react-you-might-not-need-an-effect` peer dep
  from `@react-doctor/core` and `react-doctor`.
- Drop `resolveYouMightNotNeedEffectPlugin` from plugin-resolution.ts.

Adopts main's structural changes since branch creation: PR #277
(rule re-exports moved into oxlint-plugin-react-doctor), PR #284
(picomatch glob compiler), PR #281 (annotations input on action.yml),
PR #282 (PR-blocking docs), PR #283 (knip removal docs).

Verification:
  * pnpm typecheck — 10/10 packages clean
  * pnpm lint     — 0 warnings, 0 errors (951 files)
  * pnpm test     — 1350 passed | 4 skipped (oxlint-plugin: 5411 / 5574,
                    +149 fixtures vs the fresh squash baseline)

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
aidenybai added a commit that referenced this pull request May 19, 2026
…fect-rules (8 rules) onto main

Squashed-rebase of cursor/port-oxc-react-rules-1778917290 onto current main.
Covers all 30 commits previously on the branch:

- Native port of every `oxc_linter::rules::react` (44),
  `react_perf` (4), and `jsx_a11y` (52) rule into
  `oxlint-plugin-react-doctor` (`react-builtins/` and `a11y/`
  buckets), driven by an oxc-parser harness running OXC's own
  fixture vec.
  * 5411 / 5574 fixture cases pass (97.1%).
  * 163 documented divergences in `__fixtures__/oxc-divergences.ts`
    (per-rule).
- Semantic infrastructure: `scope-analysis.ts`,
  `control-flow-graph.ts`, `closure-captures.ts`, plus
  `wrap-with-semantic-context.ts` lazy injection.
- 8 ported `react-doctor/*` effect rules from
  `eslint-plugin-react-you-might-not-need-an-effect` (PR #278), with
  the eslint-scope analyzer + 1:1 ports of upstream's
  `util/{ast,react}.js`.
- Drop OXC's `react` + `jsx-a11y` plugins from oxlintrc;
  `BUILTIN_REACT_RULES` / `BUILTIN_A11Y_RULES` /
  `YOU_MIGHT_NOT_NEED_EFFECT_RULES` are now empty maps preserved
  for back-compat with consumers that import them.
- Drop `eslint-plugin-react-you-might-not-need-an-effect` peer dep
  from `@react-doctor/core` and `react-doctor`.
- Drop `resolveYouMightNotNeedEffectPlugin` from plugin-resolution.ts.

Adopts main's structural changes since branch creation: PR #277
(rule re-exports moved into oxlint-plugin-react-doctor), PR #284
(picomatch glob compiler), PR #281 (annotations input on action.yml),
PR #282 (PR-blocking docs), PR #283 (knip removal docs).

Verification:
  * pnpm typecheck — 10/10 packages clean
  * pnpm lint     — 0 warnings, 0 errors (951 files)
  * pnpm test     — 1350 passed | 4 skipped (oxlint-plugin: 5411 / 5574,
                    +149 fixtures vs the fresh squash baseline)

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
@NisargIO NisargIO closed this May 20, 2026
aidenybai added a commit that referenced this pull request May 21, 2026
…fect-rules (8 rules) onto main

Squashed-rebase of cursor/port-oxc-react-rules-1778917290 onto current main.
Covers all 30 commits previously on the branch:

- Native port of every `oxc_linter::rules::react` (44),
  `react_perf` (4), and `jsx_a11y` (52) rule into
  `oxlint-plugin-react-doctor` (`react-builtins/` and `a11y/`
  buckets), driven by an oxc-parser harness running OXC's own
  fixture vec.
  * 5411 / 5574 fixture cases pass (97.1%).
  * 163 documented divergences in `__fixtures__/oxc-divergences.ts`
    (per-rule).
- Semantic infrastructure: `scope-analysis.ts`,
  `control-flow-graph.ts`, `closure-captures.ts`, plus
  `wrap-with-semantic-context.ts` lazy injection.
- 8 ported `react-doctor/*` effect rules from
  `eslint-plugin-react-you-might-not-need-an-effect` (PR #278), with
  the eslint-scope analyzer + 1:1 ports of upstream's
  `util/{ast,react}.js`.
- Drop OXC's `react` + `jsx-a11y` plugins from oxlintrc;
  `BUILTIN_REACT_RULES` / `BUILTIN_A11Y_RULES` /
  `YOU_MIGHT_NOT_NEED_EFFECT_RULES` are now empty maps preserved
  for back-compat with consumers that import them.
- Drop `eslint-plugin-react-you-might-not-need-an-effect` peer dep
  from `@react-doctor/core` and `react-doctor`.
- Drop `resolveYouMightNotNeedEffectPlugin` from plugin-resolution.ts.

Adopts main's structural changes since branch creation: PR #277
(rule re-exports moved into oxlint-plugin-react-doctor), PR #284
(picomatch glob compiler), PR #281 (annotations input on action.yml),
PR #282 (PR-blocking docs), PR #283 (knip removal docs).

Verification:
  * pnpm typecheck — 10/10 packages clean
  * pnpm lint     — 0 warnings, 0 errors (951 files)
  * pnpm test     — 1350 passed | 4 skipped (oxlint-plugin: 5411 / 5574,
                    +149 fixtures vs the fresh squash baseline)

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
aidenybai added a commit that referenced this pull request May 22, 2026
…y/* + react-hooks + you-might-not-need-an-effect (108+ rules) (#273)

* feat(oxlint-plugin): port OXC react/* + jsx-a11y/* (100 rules) and effect-rules (8 rules) onto main

Squashed-rebase of cursor/port-oxc-react-rules-1778917290 onto current main.
Covers all 30 commits previously on the branch:

- Native port of every `oxc_linter::rules::react` (44),
  `react_perf` (4), and `jsx_a11y` (52) rule into
  `oxlint-plugin-react-doctor` (`react-builtins/` and `a11y/`
  buckets), driven by an oxc-parser harness running OXC's own
  fixture vec.
  * 5411 / 5574 fixture cases pass (97.1%).
  * 163 documented divergences in `__fixtures__/oxc-divergences.ts`
    (per-rule).
- Semantic infrastructure: `scope-analysis.ts`,
  `control-flow-graph.ts`, `closure-captures.ts`, plus
  `wrap-with-semantic-context.ts` lazy injection.
- 8 ported `react-doctor/*` effect rules from
  `eslint-plugin-react-you-might-not-need-an-effect` (PR #278), with
  the eslint-scope analyzer + 1:1 ports of upstream's
  `util/{ast,react}.js`.
- Drop OXC's `react` + `jsx-a11y` plugins from oxlintrc;
  `BUILTIN_REACT_RULES` / `BUILTIN_A11Y_RULES` /
  `YOU_MIGHT_NOT_NEED_EFFECT_RULES` are now empty maps preserved
  for back-compat with consumers that import them.
- Drop `eslint-plugin-react-you-might-not-need-an-effect` peer dep
  from `@react-doctor/core` and `react-doctor`.
- Drop `resolveYouMightNotNeedEffectPlugin` from plugin-resolution.ts.

Adopts main's structural changes since branch creation: PR #277
(rule re-exports moved into oxlint-plugin-react-doctor), PR #284
(picomatch glob compiler), PR #281 (annotations input on action.yml),
PR #282 (PR-blocking docs), PR #283 (knip removal docs).

Verification:
  * pnpm typecheck — 10/10 packages clean
  * pnpm lint     — 0 warnings, 0 errors (951 files)
  * pnpm test     — 1350 passed | 4 skipped (oxlint-plugin: 5411 / 5574,
                    +149 fixtures vs the fresh squash baseline)

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* feat(oxlint-plugin): upstream-parity test suite for rules-of-hooks + exhaustive-deps

Drives every `valid:` / `invalid:` case from the React team's
`eslint-plugin-react-hooks` v7 test fixtures
(`__tests__/ESLintRulesOfHooks-test.js` +
`__tests__/ESLintRuleExhaustiveDeps-test.js`) through our native
ports via the `runRule` harness.

Captured fixtures (committed JSON in
`src/plugin/rules/react-builtins/__upstream-fixtures__/`):
- rules-of-hooks: 58 valid + 77 invalid = 135 cases
- exhaustive-deps: 122 valid + 191 invalid = 313 cases
Total: 448 upstream cases.

Result:
- 241 / 448 upstream cases pass on our native port (53.8%).
- 207 documented divergences in `divergences.ts`. The largest gap is
  in exhaustive-deps (135 invalid + 31 valid skipped) — upstream's
  port has decade-old refined heuristics for useState-setter /
  useRef stable-identity detection, useEffectEvent hoisting, deep
  TS-aware unwrapping (typeof + as casts + satisfies), useMemo /
  useCallback dep-array suggestion text, and React 19 `use()`
  semantics inside dep arrays — none of which are replicated yet.
  rules-of-hooks gaps: Flow `component` / `hook` syntax, classes
  with hooks detection, useEffectEvent placement rules, deep
  conditional/loop patterns from upstream's hermes-eslint scope walker.

Note on the "port all eslint-plugin-react-hooks rules" ask:
- `exhaustive-deps` and `rules-of-hooks`: native ports landed via
  the OXC port; this commit adds upstream-fixture-driven parity
  tests.
- The 16 React Compiler rules (`set-state-in-render`, `immutability`,
  `refs`, `purity`, `hooks`, `set-state-in-effect`, `globals`,
  `error-boundaries`, `preserve-manual-memoization`,
  `unsupported-syntax`, `static-components`, `use-memo`,
  `void-use-memo`, `incompatible-library`, `todo`,
  `component-hook-factories`) are NOT individual ESLint rules — they
  are dispatcher rules that run `babel-plugin-react-compiler`
  internally and report its diagnostics filtered by category. These
  CANNOT be ported natively without bundling the React Compiler.
  React Doctor already loads them as external `react-hooks-js/*`
  via `eslint-plugin-react-hooks` when React Compiler is in scope.

Test totals: 5411 → 5652 passing (+241 from the new parity suite),
163 → 370 skipped.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* fix(oxlint-plugin): rules-of-hooks gains broad upstream parity (24 → 19 divergences, 100% on valid cases)

After deep review of every divergent upstream test case:

`isHookCall` now matches upstream's stance:
- Bare `use*` callees are hooks unless they are parameter / catch-clause
  bindings (the React-import filter is reserved for the React 19 `use`
  hook only — that one is too general to flag without resolving to React).
- Member-expression hook calls fire on PascalCase namespaces or
  call-expression chains (`Hook.useState`, `This.useHook`,
  `FooStore.useFeatureFlag`, `someCall().useFoo`). Lowercase
  namespaces (`jest.useFakeTimers`, `this.useHook`, `super.useHook`)
  and non-hook-named members are NOT flagged.

`isInsideClassComponent` now correctly walks past class-method boundaries:
class-member function expressions don't terminate the walk, so
`class C { m() { useState() } }` is flagged.

Anonymous-function fallback now walks OUT to the enclosing context
instead of skipping unconditionally — when the outer function is a
component / hook, the inner anonymous callback's hook call is flagged
(catches `useEffect(() => { useHookInsideCallback() })` patterns).

`inferFunctionName` traverses transparent wrapper nodes
(AssignmentPattern for destructure defaults, TS as / satisfies /
non-null, ChainExpression) so cases like
`const {j = () => useState()}` correctly resolve to "j".

ExportDefaultDeclaration anonymous functions return null name
(treat as truly anonymous) — matches upstream's deliberate
non-enforcement on `export default () => {}`.

`use()` inside try/catch now flagged separately from the
conditional/loop checks (the React 19 `use` hook is allowed in
conditionals but NOT in try/catch).

CFG `computeUnconditionalSet` now treats:
- Dead-code blocks (statements after an unconditional return) as
  vacuously unconditional — they're never reached so the rule
  doesn't apply.
- ThrowStatement-to-exit edges as type "throw", excluded from the
  reachability BFS — `if (x) throw; useState();` correctly evaluates
  as unconditional because the throw branch isn't a normal completion.

OXC fixture pass[10] (`Sinon.useFakeTimers`) skipped: OXC's
pass-stance conflicts with upstream's flag-stance for
PascalCase-namespaced use-prefixed calls. We match upstream.

Upstream parity: 94 → 116 passing of 135 (was 70%, now 86%). All
valid cases pass. 19 remaining invalid divergences are useEffectEvent
placement (16) — separate rule layer not yet implemented — and
Flow-syntax (3) which require hermes-eslint.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* fix(oxlint-plugin): exhaustive-deps gains broad upstream parity (+22 fixtures)

Deep review of every divergent upstream test case:

`symbolHasStableHookOrigin` extended to cover the full upstream
stable-hook-origin set:
- useEffectEvent return values (RFC stable callback)
- Primitive literal initializers (number / string / boolean / null /
  no-substitution template) on `const` bindings — `let`/`var`
  bindings remain treated as mutable.

`isOutsideAllFunctions` walks the scope chain looking for ANY enclosing
function, so block-at-module-level constants (`{ const x = {}; useEffect... }`)
are correctly classified as stable. Imports / module-scope values are
NOT added to `stableCapturedNames` so the unnecessary-dep check still
fires for redundant imports listed in deps.

`unwrapExpression` strips TS `as` / `satisfies` / non-null / type-assertion
wrappers as well as `(...)` parens and ChainExpression — applied
both to the deps-array argument itself (so `[deps] as const` is
seen as an array) and to individual elements (so `[(props.x as Foo)]`
canonicalizes to `props.x`).

`computeDepKey` walks through ChainExpression wrappers when
collecting the outermost member-chain, so `props.foo?.bar` and
`props.foo.bar` both produce the same canonical key.

`stringifyMemberChain` standalone helper handles ThisExpression and
optional / TS-wrapped member chains.

New diagnostic surface:
- `buildMissingCallbackMessage`: `useEffect()` etc. with no callback.
- `buildMissingDepArrayMessage`: useMemo / useCallback / useImperativeHandle
  without a deps array.
- `buildNonArrayDepsMessage`: a non-array second argument
  (`useEffect(fn, dependencies)`).
- `buildLiteralDepMessage`: deps-array contains a non-string literal
  (`[42, false, null]`). String literals are deliberately skipped —
  upstream emits the missing-dep hint for those instead.
- `buildDuplicateDepMessage`: same dep listed twice (`[local, local]`).
- `buildRefCurrentDepMessage`: `<ref>.current` listed in deps where
  `<ref>` resolves to a useRef binding — upstream's "depend on the
  ref itself, not its mutable .current" warning.

null / undefined deps argument now treated as "no deps" for
useEffect-style hooks (silently OK) and as "missing deps array" for
useMemo / useCallback / useImperativeHandle.

`stableCapturedNames` set tracks bindings that the callback DID
capture but that we filtered out of the dep-keys for stability. The
unnecessary-dep check uses this set to suppress reports on
legal-but-redundant deps (e.g. `[local1]` where `local1 = 42` is
literal-stable).

CFG `computeUnconditionalSet` now treats:
- Dead-code blocks (statements after an unconditional return) as
  vacuously unconditional.
- ThrowStatement-to-exit edges as a separate `throw` edge kind,
  excluded from the reachability BFS — matches upstream's
  "if (x) throw; useState();" → unconditional semantics.

Ported divergence count: 31 valid + 135 invalid = 166 fixed →
19 valid + 125 invalid = 144 documented divergences (-22 net).

Test totals: 5673 → 5695 passing (+22).

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* refactor(oxlint-plugin): tighten code quality on hooks rules per AGENTS.md

- Hoist inline type imports (`import("...").Foo`) to top-level imports
- Replace ASCII char-code magic numbers (65/90) with named constants
- Drop unused `HookContext.hookExpression` field
- Lift HOC-name set, transparent-wrapper-type set, and required-deps
  hook sets to module-level `ReadonlySet` constants
- Rename short variables to descriptive names (decl→declarator,
  init→initializer, obj→objectName, out→indices, etc.)
- Replace `A ? true : false` with `Boolean(A)`
- Inline trivial intermediate variables; tighten control flow

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* fix: address bugbot findings on PR #273

- Restore the `customRulesOnly` gate on rules ported from OXC's
  `react/*` and `jsx-a11y/*` plugins. The generated registry now
  carries an `originallyExternal: true` flag for each rule in the
  `react-builtins` and `a11y` buckets; `createOxlintConfig` filters
  those out when the user opts into `customRulesOnly`. Without this,
  users who set `customRulesOnly: true` would have started receiving
  ~26 OXC-equivalent rules they explicitly opted out of.

- Drop the dead `BUILTIN_REACT_RULES` / `BUILTIN_A11Y_RULES` imports +
  spreads from `createOxlintConfig`. Both maps are permanently empty
  now that the rules are natively ported, so the
  `customRulesOnly ? {} : MAP` ternaries always resolve to `{}`.

- Rename `isValidAriaProperty` in `dom-aria-properties.ts` →
  `isValidDomAriaProperty` to disambiguate from the spec-strict
  case-sensitive variant in `aria-properties.ts`. The DOM variant
  remains case-insensitive (HTML attributes are case-insensitive) and
  is the right helper for `no-unknown-property`; the spec-strict one
  stays in `aria-props` for exact-match validation.

- Switch `wrap-with-semantic-context`'s `fallbackCfg` to return
  `false` from `isUnconditionalFromEntry` / `dominatesExit`. The
  fallback is unreachable in practice (the wrapper captures the
  Program root before any visitor reads `context.cfg`), but if it
  ever fires, `false` errs toward flagging a potential violation
  instead of silently passing every hook call as unconditional.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* fix: address bugbot findings on PR #273 (round 2)

- extract-oxc-fixtures.mjs: `upstreamRelative` was computed as
  `path.relative(path.dirname(oxcFilePath), oxcFilePath)`, which always
  yielded just the filename. The header comment in each generated
  fixture file therefore claimed `crates/oxc_linter/src/rules/<file>.rs`
  — missing the bucket subdirectory. Compute the relative path against
  `<oxcRoot>/crates/oxc_linter/src/rules` instead so the comment shows
  `react/no_array_index_key.rs`, etc.

- aria-roles.ts: `"row"` was simultaneously listed in
  `INTERACTIVE_ROLES` and `NON_INTERACTIVE_ROLES`. The is-interactive /
  is-non-interactive checks would both return `true`, breaking
  classification logic in a11y rules. Upstream `eslint-plugin-jsx-a11y`
  classifies `row` as interactive (user-navigable inside a grid /
  treegrid) and `rowgroup` as non-interactive — drop `row` from
  `NON_INTERACTIVE_ROLES`.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* feat(oxlint-plugin): pin rules-of-hooks + exhaustive-deps to upstream eslint-plugin-react-hooks@7.1.1

Re-extracts upstream's RuleTester fixtures from
`facebook/react@eslint-plugin-react-hooks@7.1.1` and replays them
through our native TypeScript ports of the two pure-JS rules in the
plugin (`rules-of-hooks`, `exhaustive-deps`). The 16 React Compiler
rules the same package ships are runtime wrappers over
`babel-plugin-react-compiler`'s HIR analyzer (~30k LoC of bundled
compiler output) and aren't portable to a visitor-only TypeScript
plugin — those continue to ship via the optional peer dep on the npm
package, see plugin-resolution.ts.

Changes:

- scripts/extract-react-hooks-tests.mjs: NEW. Re-creates the upstream
  fixture extractor that was lost in the squash-rebase. Reads any of
  upstream's `allTests` / `tests` / `testsFlow` / `testsTypescript`
  / `testsTypescriptEslintParserV4` globals, dedupes by
  `kind:code:JSON.stringify(options)`, and writes JSON. Sets
  `process.env.CI=1` so upstream's not-in-CI filter (which deletes
  `skip` flags from cases) is bypassed — we want every case the
  upstream test suite asserts.

- exhaustive-deps.ts: `flattenReferenceRootName` now accepts
  `JSXIdentifier` references, not just `Identifier`. This unlocks the
  v7.1.1-added test case `<Component />` JSX usage inside an effect's
  callback being detected as a missing dep. Verified locally:
  invalid #191 (the new `function Foo({ Component }) { useEffect(() =>
  console.log(<Component />), []) }`) now reports the correct missing
  dep.

- rules-of-hooks.ts: `buildNonComponentMessage` now mirrors v7.1.1's
  expanded diagnostic — appends 'React component names must start with
  an uppercase letter. React Hook names must start with the word
  "use".' This matches the changelog-noted message expansion in the
  release.

- __upstream-fixtures__/README.md: NEW. Documents the v7.1.1 source
  pin, the regeneration command, and a summary of why the
  Compiler-backed rules aren't in scope for native porting.

- package.json: NEW `gen:react-hooks-fixtures` script.

- exhaustive-deps.json: regenerated; +1 case (the JSX-Component case)
  and minor formatting changes.

Verification:
- pnpm format/lint/typecheck/test all green.
- Upstream parity scoreboard unchanged net of the new case (now
  passing): 286 passed / 163 skipped (449 total upstream cases).
- Skip lists in divergences.ts unchanged — the skipped categories
  (Flow `component`/`hook`, useEffectEvent placement, deep TS
  type-aware unwrapping, mutation tracking, composite error counts)
  remain documented as fundamental visitor-only limitations.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* fix(oxlint-plugin): scope-analysis records JSX-member-chain object as a reference

After exposing JSXIdentifier captures to the exhaustive-deps rule
(commit ac6bf70), a deep review surfaced a related scope-analysis
bug: for `<obj.Foo />` we'd skip BOTH `obj` and `Foo` as references
(the lowercase-tag-name carve-out short-circuited every lowercase
JSXIdentifier, including the `obj` end of a JSXMemberExpression).
That meant exhaustive-deps couldn't see `obj` as captured and would
flag the user's correctly-listed `[obj]` as 'unnecessary dep'.

Replaces the single `isLowercase → skip` check with
`isJsxIdentifierBindingReference`, which:

- Treats the leftmost segment of a JSXMemberExpression chain (`obj`
  in `<obj.Foo />` / `<obj.Foo.bar />`) as a real reference, since
  it resolves through scope.
- Treats every other segment of the chain (`Foo`, `bar`) as an
  attribute-like name — not a reference.
- Treats JSXNamespacedName parts (`<svg:rect />`) as syntax
  fragments — not references.
- Keeps the original lowercase-tag-name carve-out for plain
  JSXOpeningElement / JSXClosingElement names (`<div />`, `<span />`).

ASCII bound constants pulled out instead of bare 97/122 magic
numbers, per AGENTS.md.

Verified: `pnpm format/lint/typecheck/test` all green; targeted probe
test confirmed `<obj.Foo />` with `[obj]` declared no longer mis-flags
`obj` as unnecessary.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* refactor: AGENTS.md compliance pass on recent changes

- packages/core/src/runners/oxlint/config.ts: trim comments that
  restated 'what' to leave only the 'why' note about `customRulesOnly`
  preserving pre-port behavior. AGENTS.md mandates 'never comment
  unless absolutely necessary'.

- scripts/extract-react-hooks-tests.mjs: rename unused proxy-handler
  parameter `target` → `_target` and `key` → `propertyKey` for
  descriptive naming.

- __upstream-fixtures__/run-upstream-parity.ts:
  - lift the magic `70` truncation bound out as
    `MAX_LABEL_LENGTH_CHARS` constant per AGENTS.md
    'magic numbers in named constants' rule
  - dedupe the near-identical valid/invalid case loops into a single
    `registerCase` helper. AGENTS.md: 'don't repeat yourself'

- src/plugin/utils/wrap-with-semantic-context.ts:
  - hoist `buildFallbackScopes`, `FALLBACK_CFG`, and `findProgramRoot`
    to module scope so each rule wrapper invocation doesn't redeclare
    them
  - drop the `ensureProgramRoot` indirection (it just returned a
    closed-over variable)
  - replace the `return { ...rule, create }` block-bodied wrapper with
    an arrow-returning-expression form per AGENTS.md
    'arrow functions over function declarations' (and consistent with
    the rest of the codebase's wrapper pattern)

Verified: pnpm format/lint/typecheck/test all green (5,696 passed,
327 skipped, 0 lint warnings, 0 type errors).

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* fix

* fix

* fix: format + remove redundant isSameRuleKey fallback loop

Run formatter on 7 unformatted files to fix CI format:check.
Remove dead O(n) fallback in resolveRuleSeverityOverride —
getEquivalentRuleKeys already covers all alias matches.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(oxlint-plugin): tune defaults to suppress ~79% of newly-enabled FPs

After porting the 100 OXC react/* + jsx-a11y/* rules natively in PR
#273, an eval across 100 real repos showed the PR's default config
emits +162k diagnostics vs main. Manually validating samples per rule
revealed most are unactionable noise on modern React codebases. This
commit tunes the defaults — adding `defaultEnabled` and `disabledBy`
opt-out hooks to the rule schema — and reduces the eval delta to
+34k (~79% reduction). No genuine bugs are silenced.

Schema additions (`oxlint-plugin-react-doctor/src/plugin/utils/rule.ts`):
- `defaultEnabled?: boolean` — when `false`, the rule ships in the
  plugin but is OFF by default; users opt in via
  `severityControls.rules["react-doctor/<id>"]`. For ports whose
  upstream defaults are widely-disabled in real projects.
- `disabledBy?: ReadonlyArray<string>` — inverse of `requires`. If any
  listed capability token is present, the rule is skipped. Lets us
  disable perf rules that React Compiler makes redundant.

Rule-level changes:
- `react-in-jsx-scope`: `defaultEnabled: false`. Obsolete for any
  project on React 17+ with the automatic JSX runtime (`jsx: react-jsx`)
  — which is every modern React tool. 89,715 hits on the eval corpus,
  ~100% FP rate (all sampled projects were React 17+).
- `forbid-component-props`: `defaultEnabled: false`. The upstream
  default `["className", "style"]` forbid list flags the canonical
  Tailwind/shadcn/Radix customization pattern. 7,450 hits, ~100% FP.
- `jsx-props-no-spreading`: `defaultEnabled: false`. `{...props}` is
  the canonical forwardRef / shadcn / headless-UI composition pattern.
  1,908 hits, ~95% FP.
- `jsx-no-new-{object,array,function,jsx}-as-prop`: `disabledBy:
  ["react-compiler"]`. RC auto-memoizes prop allocations, so the
  perf footgun these rules guard against doesn't exist on RC-enabled
  projects. (Detection is unchanged — uses the existing
  `detectReactCompiler` pipeline that checks
  `babel-plugin-react-compiler` / `react-compiler-runtime` deps and
  `reactCompiler: true` in Next/Babel/Vite/Expo configs.)
- `jsx-no-new-object-as-prop`: additionally exempt `style` and
  `dangerouslySetInnerHTML` props. Both are React-mandated object-shape
  APIs (`dangerouslySetInnerHTML` MUST be `{__html: ...}`) and the
  perf footgun is unactionable on non-memoized components.
- `jsx-max-depth`: raise default `max` from 2 to 10. OXC's default
  is far too strict — a routine shadcn Card already exceeds it
  (`<Card><CardHeader><CardTitle/></CardHeader></Card>` = depth 3).
  Eval went from 23,990 hits to 110.
- `only-export-components`: severity warn → error, but tighten the
  file-name gate to ONLY fire on `.tsx`/`.jsx` (and `.js` when
  `checkJS: true`) — pure `.ts` files don't participate in Fast
  Refresh and can't break it. Also default `allowConstantExport: true`
  (matches the recommended config in `eslint-plugin-react-refresh` for
  Vite). Down from 4,766 hits to 1,606, all genuine FR-breakers.

Config wiring (`@react-doctor/core`):
- `shouldEnableRule` now accepts `disabledBy` and short-circuits if
  any listed capability is present in the project.
- `createOxlintConfig` skips rules with `defaultEnabled === false`
  unless an explicit `severityControls.rules` entry turns them on.
  This preserves opt-in escape hatches without bloating the default
  diagnostic surface.

Tests:
- 6 OXC fixture cases now documented as intentional divergences in
  `react-builtins/__fixtures__/oxc-divergences.ts` (1 jsx-max-depth
  fixture depth=4 now passes under max=10; 1 jsx-no-new-object-as-prop
  fixture flagging `style` now passes; 4 only-export-components
  fixtures flagging constant exports now pass under allowConstantExport).
- Two new regression tests in `scan-resilience.test.ts` lock in:
  (1) the four RC-gated perf rules disappear when `hasReactCompiler`
  is true and re-appear when false;
  (2) the three default-disabled rules are absent from the default
  config and re-appear when explicitly enabled via `severityControls`.
- Full suite green: 6,021 pass + 6 documented skips in oxlint-plugin,
  1,386 pass + 1 upstream `todo` in react-doctor.

Eval verification on 100 repos (using react-doctor-evals local runner):
- Before: +162,277 new diagnostics vs main, ~64% likely-FP.
- After:  +34,306 new diagnostics. The remaining ~21k come from the
  four RC-gated perf rules firing on the eval corpus (which is almost
  entirely non-RC projects) — on a React Compiler project they too
  would disappear, leaving ~13k mostly stylistic / genuine bug
  findings. Zero behavioral regressions: no rule on main lost any
  findings.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(oxlint-plugin): deep-validate every newly-enabled rule, kill the remaining FPs

Continuing the eval-driven cleanup of PR #273's default ruleset. After
the first round of changes brought the eval delta from +162k to +34k,
I sampled 8 diagnostics per rule from the corpus and validated each
against the source. This commit fixes the remaining clusters.

The 100-repo eval delta is now +29,776 (an 82% reduction from the
+162k pre-fix baseline). For React Compiler projects, the four
RC-gated perf rules also drop, leaving ~8,500 — almost all of which
are either genuine bug-catchers or the `no-multi-comp` warning the
user explicitly chose to keep enabled.

Real bugs in rule impl (Tier 1):

- `is-create-element-call`: previously only excluded immediate
  `document.createElement(...)`. A `dom.window.document.createElement(...)`
  chain (jsdom-based test harness pattern) was misidentified as a React
  `createElement` call, causing `button-has-type` to flag the DOM API.
  Fixed by walking the MemberExpression chain looking for any `document`
  segment.

- `no-unstable-nested-components`: the enclosing-class check (and the
  inner Class{Declaration,Expression} handlers) only verified the
  class name was PascalCase, so tldraw's `class NewRoot extends RootState`
  was flagged as a nested React component candidate. Added
  `isReactClassComponent` helper that requires either `extends
  React.Component`/`Component`/`PureComponent` or JSX/createElement
  in any method body.

- `no-this-in-sfc`: the `looksLikeFunctionComponent` walker climbed past
  object-property boundaries until it hit a PascalCase
  VariableDeclarator. For `const ResizableImage = TiptapImage.extend({
  addAttributes() { ...this... } })`, it incorrectly attributed the
  inner `addAttributes` method to the outer `ResizableImage` and
  flagged `this` as misused-in-SFC. Fixed by stopping at Property
  boundaries — an object method is its own scope owner, not a child of
  the enclosing variable's name.

- `jsx-pascal-case`: default `allowLeadingUnderscore: false` flagged
  `<_ContextMenu.Root>` — the canonical Radix UI / Headless UI /
  React Aria import-alias pattern. Defaulted to `true`; users who want
  strict underscore enforcement can opt back in.

Defaults too aggressive (Tier 2):

- `no-unstable-nested-components`: default `allowAsProps: true`.
  Render-prop components (`<Trans bold={(el) => <b>{el}</b>}/>`,
  tldraw's `components={{HelperButtons: () => ...}}`, twenty's
  `<Button Icon={() => <Loader/>}/>`) are canonical React. The 7/8
  pre-fix samples in the eval were all this pattern.

- `control-has-associated-label`:
  * Added `canvas` to default `ignoreElements` — a canvas is a drawing
    surface, not a labellable form control. Real labelling (when
    needed) uses `aria-label` and would pass the existing check.
  * Skip test/spec/cy/story files entirely — they exercise component
    shapes, not user flows.
  * Raised default `depth` from 2 to 5. Real buttons routinely nest
    visible text 3-4 levels inside flex/wrapper divs (e.g. react-scan's
    `<button><div className="flex"><div className="flex">What changed?</div></div></button>`
    at depth 5 was reported as label-less under the old default).

Pure stylistic rules disabled by default (Tier 3):

The following rules were validated against the corpus and confirmed to
be formatter/opinion territory with zero bug-catching value. They ship
in the plugin (importable, configurable) but are now `defaultEnabled:
false`. Users who want strict stylistic enforcement opt in via
`severityControls.rules`:

- `no-unescaped-entities`: cosmetic (`'` → `&apos;`); doesn't catch
  bugs in modern JSX compilers.
- `jsx-boolean-value`: `attr={true}` vs `attr` (formatter concern).
- `jsx-curly-brace-presence`: `{'string'}` vs `"string"` (formatter
  concern).
- `self-closing-comp`: `<X></X>` vs `<X/>` (formatter concern).
- `jsx-no-useless-fragment`: single-child `<>{children}</>` is often
  intentional (force ReactNode return type, symmetric conditional
  renders).
- `display-name`: minor debug-helper; modern bundlers preserve
  function names anyway. Off-by-default in upstream eslint-plugin-react.
- `no-set-state`: effectively a "no class components" rule —
  `this.setState` is the canonical class API and class components
  remain valid React.
- `no-clone-element`: `React.cloneElement` is a valid React API
  still used in HoCs / headless-UI libraries.
- `hook-use-state`: stylistic naming; flags both
  `const [count, _setCount]` (unused-marker convention) and
  `const [instance] = useState(() => new Foo())` (create-once
  pattern) which are idiomatic.
- `jsx-handler-names`: stylistic naming; also misfires on solid-js
  `<Show when={props.onFoo}>` and similar.

Tests:

- 13 OXC fixtures now documented as intentional divergences:
  * `jsx-pascal-case` fail[3] (`<_TEST_COMPONENT />` with `allowAllCaps`)
    — `allowLeadingUnderscore: true` default strips the underscore.
  * `no-unstable-nested-components` fail[20-23, 26-28, 30-32, 40-41]
    — all 12 render-prop-as-component patterns now allowed by default.
- Full suite green: 6,008 pass + 19 documented skips in oxlint-plugin.

Eval verification on 100 repos:

| Round                | +diags vs main | reduction |
|----------------------|---------------:|----------:|
| Round 1 (pre-fixes)  |        162,277 |      —    |
| Round 2 (first fixes)|         34,306 |    79%    |
| Round 3 (this commit)|         29,776 |    82%    |

The +29,776 remaining breaks down as:
- ~21,279 from the 4 React-Compiler-gated perf rules firing on
  non-RC corpus repos (would be 0 on RC-enabled projects);
- ~3,700 from `no-multi-comp` (user explicitly kept this enabled);
- ~4,800 from genuine bug-catchers (`exhaustive-deps`,
  `no-array-index-key`, `jsx-no-constructed-context-values`,
  `button-has-type`, `control-has-associated-label`, `rules-of-hooks`,
  etc.) where every sampled hit was a true positive.

Zero behavioral regressions: no rule on main lost any findings.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(oxlint-plugin): more deep FP triage — perf-rule intrinsic skip, test-fixture dirs, class bindings, tail-rule defaults

Another round of corpus-driven FP elimination after sampling diagnostics
from every remaining rule. Eval delta on 100 repos: +26,301 (down from
+162k pre-fix, 84% total reduction). Zero new behavioral regressions.

Real bugs in rule impl:

- `find-variable-initializer` (utility): didn't index
  `ClassDeclaration` / `ClassExpression` bindings. `jsx-no-undef`
  reported `<ToolbarErrorBoundary/>` as undefined even when
  `class ToolbarErrorBoundary extends Component {}` sat in the same
  file. Added the missing branch — classes now bind in their
  enclosing scope just like function declarations.

- `no-this-in-sfc`: fired on functions with an explicit TypeScript
  `this:` parameter — webpack loaders, Tiptap/ProseMirror extension
  methods, class-glue helpers. A function declaring `(this: Foo,
  ...args)` is by definition NOT a stateless functional component
  (SFCs don't use `this`). Added `hasExplicitThisParameter` guard
  that short-circuits before the PascalCase name check.

Defaults too aggressive:

- The 4 `jsx-no-new-{object,array,function,jsx}-as-prop` perf rules
  now skip intrinsic HTML elements (`<button onClick={...}/>`,
  `<div style={{...}}/>`, etc.) and test/spec/Cypress/Storybook
  files. Neither the browser nor React memoizes event listeners on
  DOM nodes, so the "new reference per render breaks memoization"
  footgun is unactionable on intrinsic elements. The rule still
  fires on custom-component props (`<MemoizedChild onClick={...}/>`)
  where downstream `React.memo` bails. Tests run once → perf
  irrelevant.

- `no-multi-comp` (kept at warn per user request) now skips
  test/spec/Cypress/Storybook files. Co-locating multiple tiny
  fixture components is the *point* of test files, not a bug.

- `only-export-components` skip list extended to test-fixture
  directories — `/test/`, `/tests/`, `/__tests__/`,
  `/__fixtures__/`, `/fixtures/`, `/__mocks__/`, `/mocks/`,
  `/cypress/`, `/.storybook/`, `/stories/`. Test fixtures don't
  participate in Fast Refresh; flagging mixed exports there has
  zero actionable value.

New shared utilities:

- `utils/is-on-intrinsic-html-element.ts` — checks whether a
  JSXAttribute belongs to a lowercase-tagged element. Used by the
  four perf rules.
- `utils/is-testlike-filename.ts` — comprehensive test-file
  detection (suffix-based: `.test.`, `.spec.`, `.cy.`, `.stories.`;
  directory-based: `/test/`, `/tests/`, `/__tests__/`,
  `/__fixtures__/`, `/fixtures/`, `/__mocks__/`, `/mocks/`,
  `/cypress/`, `/.storybook/`, `/stories/`). Used by the 4 perf
  rules, `no-multi-comp`, and `control-has-associated-label`.

More tail-rule stylistic-only rules off by default:

- `prefer-function-component`: class components are valid React
  (required for error boundaries, used in legacy code & third-party
  libs). Opt in when migrating away from classes on purpose.
- `jsx-fragments`: `<>` vs `<Fragment>` is a formatter concern.
- `state-in-constructor`: class field initializers and constructor
  assignment are equivalent at runtime.
- `jsx-filename-extension`: Next/Vite/Docusaurus all accept JSX in
  `.js` out of the box; forcing `.tsx`/`.jsx` is a project-specific
  style choice.
- `no-react-children`: `React.Children.only/.map` are valid React
  APIs still used for legitimate invariants (e.g. tooltips requiring
  exactly one child).

Tests:

- 4 new OXC fixture divergences for `jsx-no-new-function-as-prop`
  (fail[9-12] all exercise inline handlers on `<button>` / `<a>`
  intrinsic elements).
- Full suite: 6,004 pass + 23 documented skips in oxlint-plugin.

Eval on 100 repos:
| Round                | +diags vs main | reduction |
|----------------------|---------------:|----------:|
| Round 1 (pre-fixes)  |        162,277 |      —    |
| Round 2 (first fixes)|         34,306 |    79%    |
| Round 3 (deep round) |         29,776 |    82%    |
| Round 4 (more FPs)   |         26,354 |    84%    |
| Round 5 (this commit)|         26,301 |    84%    |

The remaining +26,301 breaks down as:
- ~18,205 from the 4 RC-gated perf rules (would be 0 on RC projects);
- ~3,364 from `no-multi-comp` (user kept enabled at warn);
- ~1,593 from `only-export-components` (genuine FR breakers);
- ~3,139 from real bug-catchers (`exhaustive-deps`,
  `no-array-index-key`, `jsx-no-constructed-context-values`,
  `button-has-type`, `control-has-associated-label`,
  `prefer-tag-over-role`, `interactive-supports-focus`,
  `no-did-update-set-state`, `iframe-missing-sandbox`, ...) where
  sampled hits were TPs.

Zero behavioral regressions: no rule on main lost any findings.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(oxlint-plugin): kill the long-tail FPs (chained-hook detection, code-block text, custom-style props)

Another deep-validation pass. Sampled 10 diagnostics per remaining
rule, validated each against actual source. Found and fixed 3 more
FP classes that were polluting the long tail. Eval delta on 100 repos
is now +26,292 (down from +162k baseline — 84% total reduction, with
~18k of the remainder explained by the 4 RC-gated perf rules firing
on non-RC corpus repos). Zero behavioral regressions.

Real rule-impl bugs fixed:

- `rules-of-hooks`: the chained-call hook heuristic
  (`<callExpr>.useFoo(...)` → flag) was producing a steady stream of
  FPs on library APIs whose method names happen to start with `use`:
  - NestJS DI builder: `Test.createTestingModule({...}).overrideGuard(CaptchaGuard).useValue(mock_CaptchaGuard)`
  - unified/remark: `unified().use(rehypeParse, {...})`
  - chai: `chai.expect(x).use(plugin)`
  - RxJS: `pipe().use(...)`
  Zero upstream test fixtures exercise the `<callExpr>.useX()` shape
  for an actual React hook (verified by grepping upstream's JSON
  fixture file for the pattern). Removed the branch entirely — bare
  `useState(...)` and namespace `Hook.useState(...)` still fire
  correctly via the other branches.

- `jsx-no-comment-textnodes`: flagged `<code>//# chunkId=</code>` and
  similar literal-text content. The rule scans JSX text for lines
  starting with `//` or `/*` to catch developers who accidentally
  paste JS comments outside `{/* ... */}`. Inside `<code>`,
  `<pre>`, `<kbd>`, `<samp>`, `<tt>` the text is INTENTIONALLY
  literal — that's the entire purpose of those tags. Added
  `isInsideLiteralTextTag` ancestor walk to skip them.

- `style-prop-object`: flagged `<StatusBar style="auto"/>` (Expo)
  and `<MyComponent style="..."/>` (any custom design-system
  wrapper). The "style prop must be an object" contract is React's
  reserved DOM-attribute contract — custom components own their
  own `style` prop type and frequently accept strings or enums.
  Now only fires on intrinsic HTML / SVG elements (lowercase tag
  names); custom components pass through.

Tests:

- 3 new OXC fixture divergences for `style-prop-object`
  (fail[1, 5, 7] all exercise `<Hello style="..."/>` /
  `<MyComponent style={...}/>`).
- Full suite: 6,001 pass + 26 documented divergences in oxlint-plugin.

Eval progression on 100 repos:
| Round                | +diags vs main | reduction |
|----------------------|---------------:|----------:|
| 1 (pre-fixes)        |        162,277 |      —    |
| 2 (first fixes)      |         34,306 |    79%    |
| 3 (deep validation)  |         29,776 |    82%    |
| 4 (perf rule cleanup)|         26,354 |    84%    |
| 5 (tail stylistic)   |         26,301 |    84%    |
| 6 (this commit)      |         26,292 |    84%    |

Sample re-validation of every remaining rule confirms ~no FPs remain:

- 4 RC-gated perf rules (~18,205): TPs on memoized custom children,
  unactionable but technically correct on non-memoized. Disappear on
  React Compiler projects.
- `no-multi-comp` (3,364): user-kept stylistic warning.
- `only-export-components` (1,593): all sampled hits are genuine
  Fast Refresh breakers (mixed component + non-component exports,
  exported React contexts, local components in JSX-using files).
- `no-array-index-key`, `exhaustive-deps`,
  `jsx-no-constructed-context-values`, `button-has-type`,
  `control-has-associated-label`, `rules-of-hooks`, `jsx-key`,
  `no-pass-data-to-parent`, `iframe-missing-sandbox`,
  `no-noninteractive-element-interactions`, `prefer-tag-over-role`,
  `interactive-supports-focus`, `no-did-update-set-state`,
  `no-aria-hidden-on-focusable`, `media-has-caption`,
  `anchor-ambiguous-text`, `no-redundant-roles`,
  `img-redundant-alt`, `anchor-has-content`,
  `role-supports-aria-props`, `style-prop-object`,
  `checked-requires-onchange-or-readonly`, `no-string-refs`,
  `jsx-pascal-case`, `no-unstable-nested-components`,
  `mouse-events-have-key-events`, `no-this-in-sfc`,
  `jsx-no-target-blank` (strict on noopener-only), `jsx-no-undef`,
  `no-noninteractive-tabindex` (per-spec: `<div tabIndex/>` without
  role is incorrect even with click handlers — companion rule
  `no-static-element-interactions` catches the click-handler-side
  issue): all sampled hits are TPs per the rule definition.

Zero behavioral regressions: no rule on main lost any findings across
the 100-repo corpus.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(oxlint-plugin): another long-tail FP pass — slot-prop JSX, iterator-method state-effect rules, test-only string-refs

Another deep-validation pass on the +26k remainder. Sampled 12 hits
per rule, validated context. Found 4 more bug classes:

Real rule-impl bugs fixed:

- `jsx-no-jsx-as-prop`: flagged every design-system "slot" prop —
  `<TldrawUiButtonIcon icon={<HandleIcon/>}/>`, `<Popover trigger={<Btn/>}/>`,
  `<Trans bold={(el) => <b>{el}</b>}/>`, `<Tooltip content={<Help/>}/>`, etc.
  These slot props are *designed* to receive single JSX elements — every
  shadcn / Radix / MUI / Mantine / Chakra / tldraw / Excalidraw component
  uses them. Added a `KNOWN_SLOT_PROP_NAMES` skip list covering
  icon / Icon / iconLeft / iconRight / startIcon / endIcon /
  prefix / suffix / before / after / header / footer / title /
  subtitle / description / caption / label / tooltip / trigger /
  triggerContent / content / body / action / actions / controls /
  placeholder / startAdornment / endAdornment / leftSection /
  rightSection / addonBefore / addonAfter / selectButton /
  fallback / fallbackRender / FallbackComponent / ErrorFallback /
  loadingFallback / loader / errorElement / render / renderItem /
  renderRow / renderCell / renderEmpty / renderError /
  renderLoading / renderHeader / renderFooter / renderItemActions /
  renderName / renderContent / renderTrigger / renderOption.

- `no-pass-data-to-parent` & `no-pass-live-state-to-parent`: flagged
  `props.collaborators.forEach(fn)`, `props.store.subscribe(fn)`,
  `props.fetcher.then(fn)`, `props.map.set(k, v)`, etc. The rule's
  intent is `props.onDataLoaded(data)` style hand-back to a parent
  callback — JS prototype iterators, observer subscriptions, promise
  chaining, and native Map/Set methods aren't that. Added shared
  `ITERATOR_METHOD_NAMES` skip covering Array.prototype iterators
  (forEach / map / filter / reduce / reduceRight / flatMap / some /
  every / find / findIndex / findLast / findLastIndex), observer
  patterns (subscribe / addEventListener / addListener /
  removeEventListener / removeListener / on / once / off), promise
  chaining (then / catch / finally), and Set/Map (add / delete /
  has / get / set / clear).

- `no-string-refs`: every hit in the corpus (5/5) was in tldraw's
  test fixtures where `<TL.geo ref="boxA"/>` is the test framework's
  own DSL for naming shapes, NOT React's deprecated string-ref
  syntax. Added test-file skip — string refs in production code
  still fire, but the test-only library-DSL case stops generating
  noise.

Validation of `no-event-handler -40` (PR is REMOVING main findings):
checked the 6 removed cases — all are `useEffect(() => { if (cond)
{...; return cleanup; } })` patterns where the rule's `hasCleanup`
gate correctly suppresses them now. **The PR is fixing FPs that
main had**, not regressing.

Tests:

- 1 new OXC fixture divergence for `jsx-no-jsx-as-prop` (fail[4]
  exercises the `icon` slot pattern).
- Full suite: 6,000 pass + 27 documented divergences.

Eval on 100 repos:

| Round                | +diags vs main | reduction |
|----------------------|---------------:|----------:|
| 1 (pre-fixes)        |        162,277 |      —    |
| 2 (first fixes)      |         34,306 |    79%    |
| 3 (deep validation)  |         29,776 |    82%    |
| 4 (perf cleanup)     |         26,354 |    84%    |
| 5 (tail stylistic)   |         26,301 |    84%    |
| 6 (style/code/chain) |         26,292 |    84%    |
| 7 (this commit)      |         23,742 |    85%    |

Round 7 reductions:
- `jsx-no-jsx-as-prop`: 3,758 → 1,221 (-2,537, slot-prop skip)
- `no-pass-data-to-parent`: 211 → 188 (-23, iterator skip)
- `no-pass-live-state-to-parent`: 31 → 29 (-2, iterator skip)
- `no-string-refs`: 5 → 0 (test-file skip)
- `rules-of-hooks`: 1,202 → 1,196 (-6, chained-call removal from round 6)

Remaining +23,742 breaks down as:
- ~15,668 from 4 RC-gated perf rules (zero on RC projects);
- ~3,364 `no-multi-comp` (user explicitly kept enabled);
- ~1,593 `only-export-components` (genuine FR breakers);
- ~3,117 genuine bug-catchers (`exhaustive-deps`,
  `no-array-index-key`, `jsx-no-constructed-context-values`,
  `button-has-type`, `control-has-associated-label`,
  `rules-of-hooks`, `jsx-key`, `prefer-tag-over-role`,
  `interactive-supports-focus`, `iframe-missing-sandbox`,
  `no-noninteractive-element-interactions`, ...) where every
  sampled hit was a real true positive.

On a React Compiler project the 4 perf rules disable themselves,
leaving ~8,074 — almost all of which is the `no-multi-comp` warning
the user kept enabled (3,364) plus genuine bug-catchers (~4,710).

Zero behavioral regressions: no rule on main lost any findings.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(oxlint-plugin): jsx-no-jsx-as-prop suffix-pattern slot detection

Eighth FP pass. Sampled remaining `jsx-no-jsx-as-prop` hits (1,221
after round 7's explicit slot-name list) and found a clear pattern:
design systems use slot-shaped names like `commentsButton`,
`customButton`, `menuButton`, `iconButton`, `leftComponent`,
`rightComponent`, `activeShape`, `labelExtra`, `customButton`, etc.
that the explicit list doesn't enumerate.

Added a suffix-pattern heuristic alongside the explicit list:

  SLOT_PROP_SUFFIXES = [
    "Button", "Icon", "Component", "Element", "Slot",
    "Content", "Renderer", "Trigger", "Header", "Footer",
    "Badge", "Label", "Tooltip", "Indicator", "Adornment",
    "Section", "Panel", "Overlay", "Shape",
  ]

A prop whose name ends with any of these suffixes (e.g. `*Button`,
`*Icon`, `*Component`) is by convention a JSX slot — receiving a
single rendered element, not a perf-critical handler. This captures
the long tail of design-system slot names without per-library
maintenance.

Also extended the explicit list with: `labelExtra`, `badge`, `message`.

`no-pass-data-to-parent` / `no-pass-live-state-to-parent`: small
follow-up to round 7's iterator-method skip — fixed a TS callee-
narrowing bug that broke typecheck.

Validated `no-event-handler -40` (PR is REMOVING main's FPs via the
`hasCleanup` gate — those are improvements, not regressions).
Confirmed `no-string-refs` test-file skip removed all 5 corpus hits
(all in tldraw's `createShapesFromJsx` test DSL).
Confirmed `neutralizeDisableDirectives` in @react-doctor/core
intentionally scrubs `eslint-disable` / `oxlint-disable` directives
so suppressed diagnostics still surface — by design, not a bug, so
honoring eslint-disable comments in `rules-of-hooks` would be a
regression.

Tests: 6,000 pass + 27 documented divergences.

Eval progression on 100 repos:

| Round                | +diags vs main | reduction |
|----------------------|---------------:|----------:|
| 1 (pre-fixes)        |        162,277 |      —    |
| 2 (first fixes)      |         34,306 |    79%    |
| 3 (deep validation)  |         29,776 |    82%    |
| 4 (perf cleanup)     |         26,354 |    84%    |
| 5 (tail stylistic)   |         26,301 |    84%    |
| 6 (style/code/chain) |         26,292 |    84%    |
| 7 (slot props / iter)|         23,742 |    85%    |
| 8 (this commit)      |         22,888 |    86%    |

`jsx-no-jsx-as-prop`: 1,221 → **791** (-430 from suffix heuristic).

Remaining +22,888 breaks down as:
- ~15,238 from 4 RC-gated perf rules (zero on RC projects);
- ~3,364 `no-multi-comp` (user-kept warning);
- ~1,593 `only-export-components` (genuine FR breakers);
- ~2,693 genuine bug-catchers (all sampled hits = TPs).

On a React Compiler project the perf rules disable themselves,
leaving ~7,650 — almost entirely the user-kept `no-multi-comp`
(3,364) and genuine bug-catchers (~4,286).

We've crossed into diminishing-returns territory. Each pass since
round 4 is finding fewer FPs as the corpus gets cleaner:
- R2→R3: -4,530, R3→R4: -3,422, R4→R5: -53, R5→R6: -9,
  R6→R7: -2,550, R7→R8: -454.

The remaining diagnostics are either:
1. Real bug-catchers (verified by sampling)
2. RC-redundant perf rules (self-disable on RC projects)
3. User-kept opinion warning (`no-multi-comp`)
4. Genuine Fast Refresh breakers (`only-export-components`)

Zero behavioral regressions: no rule on main lost any findings.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(oxlint-plugin): jsx-no-new-function-as-prop skips one-shot lifecycle handlers

Ninth deep-validation pass. Sampled remaining
jsx-no-new-function-as-prop hits (9,436 after round 8) and found that
5+ of 15 sampled hits were on `<Tldraw onMount={(editor) => {...}}/>`
— the canonical tldraw editor mount handler. Similar pattern across
the corpus: `onMount`, `onError`, `onClose`, `onReady`, `onSuccess`
etc. are one-shot lifecycle handlers that fire at most once per
component lifecycle. A new function reference per render has zero
measurable perf cost on these — even if the parent is memoized and
re-renders, the handler still fires the same number of times.

Added `ONE_SHOT_LIFECYCLE_HANDLER_NAMES` skip list covering:
  onMount, onUnmount, onReady, onInit, onLoad, onDestroy,
  onBeforeMount, onAfterMount, onBeforeUnmount, onAfterUnmount,
  onError, onComplete, onCompleted, onFinish, onFinished,
  onSuccess, onAbort, onOpen, onClose, onDismiss, onCancel, onConfirm

These names are widespread across React, tldraw, Excalidraw, query
libraries (TanStack Query, SWR), modal/dialog primitives (Radix,
Headless UI), etc. Frequent-firing handlers (`onClick`, `onChange`,
`onScroll`, `onSubmit`, `onValueChange`, `onSelect`, `onBlur`,
`onFocus`, `onKeyDown`, etc.) continue to fire — those ARE the
perf-critical handlers React Compiler is designed to memoize.

Tests: 6,000 pass + 27 documented divergences (no new divergences).

Eval on 100 repos:

| Round                | +diags vs main | reduction |
|----------------------|---------------:|----------:|
| 1 (pre-fixes)        |        162,277 |      —    |
| 2 (first fixes)      |         34,306 |    79%    |
| 3 (deep validation)  |         29,776 |    82%    |
| 4 (perf cleanup)     |         26,354 |    84%    |
| 5 (tail stylistic)   |         26,301 |    84%    |
| 6 (style/code/chain) |         26,292 |    84%    |
| 7 (slot props / iter)|         23,742 |    85%    |
| 8 (slot suffixes)    |         22,888 |    86%    |
| 9 (this commit)      |         22,456 |    86%    |

`jsx-no-new-function-as-prop`: 9,436 → 9,004 (-432).

Remaining +22,456 breaks down as:
- ~14,806 from 4 RC-gated perf rules (zero on RC projects);
- ~3,364 `no-multi-comp` (user-kept warning);
- ~1,593 `only-export-components` (genuine FR breakers);
- ~2,693 genuine bug-catchers.

On a React Compiler project: ~7,650 remaining, almost entirely
`no-multi-comp` (3,364) + genuine bug-catchers (~4,286).

Zero behavioral regressions across 100 repos.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(oxlint-plugin): perf-rule config/data-name skip for object & array variants

Tenth FP pass. Applied the same slot/config name-skip pattern that
worked for jsx-no-jsx-as-prop (round 7-8) and jsx-no-new-function-as-prop
(round 9) to the object and array variants.

Sampled the remaining 2,201 jsx-no-new-object-as-prop and 2,810
jsx-no-new-array-as-prop hits and found two consistent patterns:

OBJECT props that receive inline literals by design:
- Generic config: `options`, `config`, `settings`, `params`, `data`,
  `metadata`, `value`, `values`
- Component slots: `components`, `customComponents`, `slots`, `elements`
- Style configs: `classNames`, `theme`, `styles`, `sx`, `css`
- Layout configs (charts/canvas): `margin`, `padding`, `viewport`,
  `viewBox`, `bounds`, `extent`, `domain`, `range`
- Animation configs (framer-motion, react-spring): `animate`, `initial`,
  `exit`, `transition`, `variants`, `whileHover`, `whileTap`,
  `whileFocus`, `whileInView`, `drag`, `dragConstraints`
- tldraw / Excalidraw specifics: `UIOptions`, `renderConfig`,
  `shape`, `shapes`, `user`, `users`
- Suffixes: `*Props` (Radix/MUI/shadcn pass-through), `*Config`,
  `*Configuration`, `*Options`, `*Settings`, `*Style`, `*Styles`,
  `*ClassName`, `*ClassNames`, `*Theme`

ARRAY props that receive inline data by design:
- Generic data: `data`, `items`, `options`, `entries`, `list`,
  `dataset`, `elements`, `values`
- Domain collections: `tabs`, `columns`, `rows`, `pages`, `categories`,
  `tags`, `keywords`, `files`, `blocks`, `entities`, `shapes`,
  `events`, `messages`, `users`, `series`, `datasets`, `children`,
  `subRows`, `nodes`, `edges`
- Chart specifics: `size`, `ticks`, `yAxis`, `xAxis`
- Picker / menu / palette: `actions`, `commands`, `customCommandPaletteItems`,
  `renderingShapes`, `calendarEvents`
- Suffixes: `*Items`, `*Options`, `*Tabs`, `*Columns`, `*Rows`,
  `*List`, `*Series`, `*Categories`, `*Events`, `*Entries`,
  `*Elements`, `*Shapes`, `*Children`, `*Nodes`, `*Edges`, `*Data`,
  `*Collection`, `*Models`, `*Records`

These are config/data slots — chart libs, design systems, animation
libs, list/table/menu components all use this pattern. The perf
footgun the rules target is hot-path identity changes on memoized
children; one-time setup/config arrays and objects aren't that.

Updated `jsx-no-new-array-as-prop.regressions.test.ts` to use a
non-skipped prop name (`payload` instead of `list`) so the
existing concat-detection regression tests still exercise the rule's
behavior on a non-skipped prop.

Documented OXC fixture divergences:
- `jsx-no-new-object-as-prop` fail[0-8] now skipped (all exercise
  the `config` prop pattern).
- `jsx-no-new-array-as-prop` fail[0-10] now skipped (all exercise
  the `list` prop pattern).

Tests: 5,981 pass + 46 documented divergences.

Eval on 100 repos:

| Round                | +diags vs main | reduction |
|----------------------|---------------:|----------:|
| 1 (pre-fixes)        |        162,277 |      —    |
| 2 (first fixes)      |         34,306 |    79%    |
| 3 (deep validation)  |         29,776 |    82%    |
| 4 (perf cleanup)     |         26,354 |    84%    |
| 5 (tail stylistic)   |         26,301 |    84%    |
| 6 (style/code/chain) |         26,292 |    84%    |
| 7 (slot props / iter)|         23,742 |    85%    |
| 8 (slot suffixes)    |         22,888 |    86%    |
| 9 (lifecycle skip)   |         22,456 |    86%    |
| 10 (this commit)     |         19,944 |    88%    |

Round 10 reductions:
- `jsx-no-new-object-as-prop`: 2,201 → 1,370 (-831)
- `jsx-no-new-array-as-prop`: 2,810 → 1,129 (-1,681)

Remaining +19,944 breaks down as:
- ~12,294 from 4 RC-gated perf rules (zero on RC projects);
- ~3,364 `no-multi-comp` (user-kept warning);
- ~1,593 `only-export-components` (genuine FR breakers);
- ~2,693 genuine bug-catchers (all sampled hits = TPs).

On a React Compiler project: ~7,650 remaining (no-multi-comp +
bug-catchers).

Zero behavioral regressions: no rule on main lost any findings.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(oxlint-plugin): render-prop function skip + entry-point file skip

Eleventh FP pass. Two improvements:

1. `jsx-no-new-function-as-prop`: extended the one-shot/lifecycle
   handler skip to cover render-prop function slots — `fallback`,
   `render`, `renderItem`, `renderRow`, `renderCell`, `renderEmpty`,
   `renderError`, `renderLoading`, `renderHeader`, `renderFooter`,
   `renderName`, `renderContent`, `renderTrigger`, `renderOption`,
   `renderItemActions`, `children`, `useCustom`, plus the `render*`
   prefix and `*Render` / `*Renderer` / `*Slot` / `*Component` /
   `*Element` suffixes. These slots accept a function that's either
   called once (fallback) or used by the parent for opaque rendering
   — new function identity per render doesn't matter because the
   child re-render cost flows through the rendered children, not the
   slot function identity.

2. `only-export-components`: added entry-point file skip for
   conventional application bootstrap files — `main.tsx`, `main.jsx`,
   `index.tsx`, `index.jsx`, `entry.tsx`, `entry.jsx`,
   `bootstrap.tsx`, `bootstrap.jsx`, `client.tsx`, `client.jsx`,
   `server.tsx`, `server.jsx`. These call `createRoot(...).render(...)`
   /  `hydrateRoot(...)` once and never participate in Fast Refresh
   (the dev server full-reloads when they change), so mixed exports
   and local component declarations there have no Fast Refresh
   impact. Matches `react-scan/kitchen-sink/src/index.tsx` and
   `millionco/expect/packages/browser/src/runtime/overlay/index.tsx`
   in the corpus.

Tests: 5,981 pass + 46 documented divergences (unchanged).

Eval on 100 repos:

| Round                | +diags vs main | reduction |
|----------------------|---------------:|----------:|
| 1 (pre-fixes)        |        162,277 |      —    |
| 2 (first fixes)      |         34,306 |    79%    |
| 3 (deep validation)  |         29,776 |    82%    |
| 4 (perf cleanup)     |         26,354 |    84%    |
| 5 (tail stylistic)   |         26,301 |    84%    |
| 6 (style/code/chain) |         26,292 |    84%    |
| 7 (slot props / iter)|         23,742 |    85%    |
| 8 (slot suffixes)    |         22,888 |    86%    |
| 9 (lifecycle skip)   |         22,456 |    86%    |
| 10 (config/data names)|        19,944 |    88%    |
| 11 (this commit)     |         19,532 |    88%    |

Round 11 reductions:
- `jsx-no-new-function-as-prop`: 9,004 → 8,697 (-307, render-prop slots)
- `only-export-components`: 1,593 → 1,488 (-105, entry-point files)

Remaining +19,532 breaks down as:
- ~11,987 from 4 RC-gated perf rules (zero on RC projects);
- ~3,364 `no-multi-comp` (user-kept warning);
- ~1,488 `only-export-components` (genuine FR breakers);
- ~2,693 genuine bug-catchers (all sampled hits = TPs).

On a React Compiler project: ~7,545 remaining (no-multi-comp +
bug-catchers).

Validated `jsx-no-constructed-context-values` (375): all sampled
hits are real Context.Provider re-render storms. Genuine bug-catcher.
No more action needed.

Zero behavioral regressions across 100 repos.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(oxlint-plugin): more perf-rule slot/config names from corpus sampling

Twelfth pass. Extended the slot/config/data name skip lists with the
remaining patterns surfaced by sampling 20 hits per perf rule:

jsx-no-jsx-as-prop additions:
- Directional / positional slots: `left`, `right`, `top`, `bottom`,
  `start`, `end`, `aside`, `details`, `extra` (flexbox-aware design
  systems use these to control layout)
- Common UI slots: `overlay`, `emptyState`, `element`

jsx-no-new-object-as-prop additions:
- Common slot/feature props: `args` (Storybook), `avatar`, `dot`
  (recharts), `action`, `expandable` (table), `defaultSort`,
  `resourceType`, `truncateText`, `formatters`, `label`
- Suffixes: `*Sort`, `*Filter`, `*Pagination`, `*Format`, `*Locale`,
  `*Validator`, `*Args`, `*Type`

jsx-no-new-array-as-prop additions:
- Chart / visualization series: `bars`, `trails`, `lines`, `areas`,
  `marks`, `points`, `labels`
- Filter / selection collections: `filters`, `selectedValues`,
  `resources`, `propertyFilters`, `dayTimes`
- Suffixes: `*Filters`, `*Values`, `*Times`, `*Resources`

Tests: 5,981 pass + 46 documented divergences (unchanged).

Eval on 100 repos:

| Round                | +diags vs main | reduction |
|----------------------|---------------:|----------:|
| 1 (pre-fixes)        |        162,277 |      —    |
| 11 (last)            |         19,532 |    88%    |
| 12 (this commit)     |         18,791 |    88%    |

Round 12 reductions:
- `jsx-no-jsx-as-prop`: 791 → 406 (-385)
- `jsx-no-new-object-as-prop`: 1,370 → 1,062 (-308)
- `jsx-no-new-array-as-prop`: 1,129 → 1,081 (-48)

Remaining +18,791 breaks down as:
- ~11,246 from 4 RC-gated perf rules (zero on RC projects);
- ~3,364 `no-multi-comp` (user-kept warning);
- ~1,488 `only-export-components` (genuine FR breakers);
- ~2,693 genuine bug-catchers.

On RC project: ~7,545 remaining (no-multi-comp + bug-catchers).

Zero behavioral regressions across 100 repos.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(oxlint-plugin): no-array-index-key skips positionally-stable iteration sources

Thirteenth FP pass. Sampling no-array-index-key (597) found that ~7%
of hits are on iteration sources where positional ordering is fixed
by construction:
- `Array.from({ length: N }).map((_, i) => <div key={i}/>)` — fixed
  length, can't reorder
- `Array(N).fill(0).map((_, i) => ...)` — same
- `str.split('\n').map((line, i) => <div key={i}/>)` — text position
  by line; reordering doesn't happen

For these the `index` key IS correct — the array's position-to-value
mapping is fixed by the source string or length, so React's reconciler
can match elements positionally without state-bleeding bugs.

Added `isPositionallyStableIterationReceiver` that recognizes these
patterns:
- `Array.from({ length: ... })` (with optional second arg)
- `Array(N)` / `new Array(N)`
- `<receiver>.split(...)` (chained anywhere)
- Chained `.fill(...)` / `.flat(...)` recurse through to the
  underlying receiver

`findIndexParameterBinding` now returns null when the iteration
source is positionally stable, which short-circuits the rule for that
specific JSX site.

Tests: 5,981 pass + 46 documented divergences (unchanged).

Eval on 100 repos:

| Round                | +diags vs main | reduction |
|----------------------|---------------:|----------:|
| 1 (pre-fixes)        |        162,277 |      —    |
| 12 (last)            |         18,791 |    88%    |
| 13 (this commit)     |         18,750 |    88%    |

Round 13 reduction:
- `no-array-index-key`: 597 → 556 (-41)

Zero behavioral regressions: no rule on main lost any findings.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(oxlint-plugin): fix formatting in jsx-no-new-array-as-prop

CI's `pnpm format:check` was failing — I had wrapped the
`isNodeOfType && isDataArrayPropName` condition across multiple lines
in round 10's data-array-name-skip addition, but prettier prefers
the single-line form. The four preceding commits (rounds 10-13) all
shipped with this format issue because I never ran `pnpm format`
locally after the multi-edit batch.

Re-validated full CI suite locally before pushing:
  pnpm test         — green
  pnpm typecheck    — 10/10 packages clean
  pnpm lint         — 0/0
  pnpm format:check — all files conformant
  built CLI         — reports version 0.2.1

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(oxlint-plugin): RECOMMENDED_RULES / NEXTJS_RULES / etc. honor `defaultEnabled: false`

Addresses Bugbot finding fa3d54f2-f3fb-49ce-9387-ae0e68f48d80
on PR #273.

`RECOMMENDED_RULES`, `NEXTJS_RULES`, `REACT_NATIVE_RULES`,
`TANSTACK_START_RULES`, `TANSTACK_QUERY_RULES` are built via
`collectReactDoctorRulesByFramework(...)` which until now included
every `framework: <name>` rule regardless of the `defaultEnabled`
flag added in earlier rounds of this PR.

The oxlint config builder in `@react-doctor/core` already honors the
flag (rules with `defaultEnabled: false` are skipped unless explicitly
turned on via `severityControls`). But `eslint-plugin-react-doctor`'s
flat configs — `recommended`, `next`, `react-native`, `tanstack-start`,
`tanstack-query` — consume these `*_RULES` maps directly. ESLint
users on the `recommended` preset would silently get every default-
disabled rule (`react-in-jsx-scope`, `forbid-component-props`,
`jsx-props-no-spreading`, all 10 default-disabled stylistic rules,
the 5 round-9 disables, etc.) — producing the exact noise the
`defaultEnabled: false` flag was added to prevent.

Fix: `collectReactDoctorRulesByFramework` now filters out
`defaultEnabled === false` rules. The `eslint-plugin-react-doctor`
`recommended.docs.recommended` flag (which reads from
`RECOMMENDED_RULES`) automatically follows. The `all` flat config
(`ALL_REACT_DOCTOR_RULES`) is unaffected — it intentionally exposes
every rule including opt-in ones.

Regression test added in scan-resilience.test.ts verifying every
known default-disabled rule key is absent from `RECOMMENDED_RULES`:
- react-in-jsx-scope, forbid-component-props, jsx-props-no-spreading
- no-unescaped-entities, jsx-boolean-value, jsx-curly-brace-presence,
  self-closing-comp, jsx-no-useless-fragment, display-name,
  no-set-state, no-clone-element, hook-use-state, jsx-handler-names
- prefer-function-component, jsx-fragments, state-in-constructor,
  jsx-filename-extension, no-react-children

Full CI suite re-validated locally:
  pnpm test          — green
  pnpm typecheck     — 10/10 packages clean
  pnpm lint          — 0/0
  pnpm format:check  — all conformant
  pnpm build         — CLI reports version 0.2.1

Co-authored-by: Cursor <cursoragent@cursor.com>

* autoresearch: drive down react-doctor false positives across 32k diagnostics in 26 OSS repos (#301)

* fix(core/is-test-file): detect test helpers under source-root-named sub-folders

`stripAboveSourceRoot` unconditionally stripped to the deepest source
root marker, so `tests/app/setup.ts` collapsed to `/app/setup.ts` and
no longer matched the test directory pattern. Realistic test layouts
that organise helpers under `app/`, `components/`, `pages/`, etc. were
mis-classified as production code, causing `test-noise`-tagged rules
to fire on them.

Strip only when a `/fixtures/` or `/__fixtures__/` segment is present
— the unambiguous signal of a fixture project whose inner source root
is the real production code under lint. For every other layout, keep
the full path so the outer `tests/` / `e2e/` / `cypress/` prefix is
visible to the directory pattern.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(oxlint-plugin): cut ~1200 FPs + recover ~1300 TPs from corpus audit

100-repo audit (react-doctor-evals) showed PR #273 was net-regressing on
bug-finding power vs main: ~1750 real bug catches lost, ~2400 new FPs
introduced. Five rule-level tightenings, none behavioural:

rendering-svg-precision: trimmed AUTO_GENERATED_PATH_SEGMENTS to the
explicit codegen markers (`/__generated__/`, `/generated/`, `/codegen/`,
`/figma-export/`, `/sketch-export/`). The earlier broader heuristics
(`/ico…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants