Skip to content

feat(oxlint-plugin): native TypeScript ports of OXC react/* + jsx-a11y/* + react-hooks + you-might-not-need-an-effect (108+ rules)#273

Merged
aidenybai merged 36 commits into
mainfrom
cursor/oxc-react-rules-migration-230a
May 22, 2026
Merged

feat(oxlint-plugin): native TypeScript ports of OXC react/* + jsx-a11y/* + react-hooks + you-might-not-need-an-effect (108+ rules)#273
aidenybai merged 36 commits into
mainfrom
cursor/oxc-react-rules-migration-230a

Conversation

@aidenybai
Copy link
Copy Markdown
Member

@aidenybai aidenybai commented May 16, 2026

Summary

Native TypeScript ports of every React linter rule we previously delegated to OXC. The plugin no longer needs plugins: ["react", "jsx-a11y"] in oxlint config — every rule runs in-process through oxlint-plugin-react-doctor's visitor pipeline.

What's ported

Bucket Count Source
react/* 56 oxc_linter::rules::react::* (Rust)
jsx-a11y/* 44 oxc_linter::rules::jsx_a11y::* (Rust)
react-hooks 2 eslint-plugin-react-hooks@7.1.1 (rules-of-hooks, exhaustive-deps)
you-might-not-need-an-effect 8 NickvanDyke / MIT

All ports ship with their original fixtures wired through runOxcFixtures / runUpstreamParity, plus the semantic infrastructure (ScopeAnalysis, ControlFlowAnalysis, closureCaptures) the hooks rules need.

Parity status

After deep iteration, upstream parity is at 100% on every replay-able test case:

Source Cases Passing Skipped
eslint-plugin-react-hooks@7.1.1 rules-of-hooks 135 135 / 135 (100%) 0
eslint-plugin-react-hooks@7.1.1 exhaustive-deps 314 314 / 314 (100%) 0
OXC react/* + jsx-a11y/* fixtures 5,528 5,528 / 5,528 (100%) 0
you-might-not-need-an-effect 196 196 / 196 (100%) 1 (upstream todo: true)
Combined 6,173 6,173 (100%) 1

The only harness-level concession is regex-rewriting Flow component/hook keywords into function for 22 upstream cases that oxc-parser can't parse natively; rule semantics still trigger correctly because the naming heuristics survive the rewrite. No fixtures are stubbed or silenced.

Semantic infrastructure

  • src/plugin/semantic/scope-analysis.ts (980 LoC, 362 LoC tests) — TS port of oxc_semantic: scope tree, symbol table, reference resolution, hoisting, block scoping.
  • src/plugin/semantic/control-flow-graph.ts (642 LoC, 242 LoC tests) — per-function CFG mirroring oxc_cfg: basic blocks, typed edges, isUnconditionalFromEntry (dominance-based), dominatesExit.
  • closure-captures.ts — captured-binding helper used by exhaustive-deps.
  • utils/wrap-with-semantic-context.ts — lazily injects scopes / CFG into RuleContext per file (cached, conservative fallback returns false so missed captures err toward flagging).

Out of scope: 16 React Compiler rules

eslint-plugin-react-hooks ships 16 additional 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). These are dispatcher rules over babel-plugin-react-compiler's HIR analyzer (~30k LoC of bundled compiler code) — they can't be ported to a visitor-only TS plugin without bundling the compiler itself. We continue to delegate them to the user's installed eslint-plugin-react-hooks@7+ via oxlint's JS plugin host (plugin-resolution.ts → resolveReactHooksJsPlugin), only when React Compiler is detected in scope.

Configuration surface

  • packages/core/src/runners/oxlint/config.ts — drops plugins: ["react", "jsx-a11y"]; BUILTIN_REACT_RULES / BUILTIN_A11Y_RULES / YOU_MIGHT_NOT_NEED_EFFECT_RULES are permanently empty.
  • customRulesOnly mode preserved via a new originallyExternal: true flag on the 108 ported rules in rule-registry.ts; the 179 native react-doctor rules carry originallyExternal: false.
  • packages/core/src/runners/oxlint/external-plugin-rules.ts — emptied.
  • resolveYouMightNotNeedEffectPlugin removed (the optional peer dep is gone).

Verification (latest)

  • pnpm build — 7/7 packages green
  • pnpm typecheck — 10/10 packages green
  • pnpm lint — 0 warnings, 0 errors (966 files)
  • pnpm test6,027 / 6,027 passing in oxlint-plugin (0 skipped, 0 failing), 1,385 / 1,386 in react-doctor (1 upstream todo: true)
  • Harness strictness verified by injection: neutering the rule body causes 62 / 135 upstream-parity assertions to fail immediately.

Changesets included for @react-doctor/core, oxlint-plugin-react-doctor, react-doctor.

Reviewer notes

  • AGENTS.md compliance: arrow functions, kebab-case files, descriptive names, top-level types, Boolean() over !!, magic numbers in named constants, minimal type casts.
  • Comments are intentionally sparse; non-obvious code is gated behind // HACK: or short "why not what" notes.
  • Ports are 1:1 behavioral with the source; divergences are documented inline with reasons.

Branch state

This session's work is on cursor/port-oxc-react-rules-1778917290 (clean rebase + squash onto latest main). The PR head branch is up to date with verification numbers above.

Open in Web Open in Cursor 

@reactreview
Copy link
Copy Markdown

reactreview Bot commented May 16, 2026

0 score

Copy as prompt
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 26 warnings. This PR leaves the React health score unchanged.

<file name="packages/oxlint-plugin-react-doctor/src/plugin/constants/aria-element-roles.ts">

<violation number="1" location="packages/oxlint-plugin-react-doctor/src/plugin/constants/aria-element-roles.ts:86">
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>

<violation number="2" location="packages/oxlint-plugin-react-doctor/src/plugin/constants/aria-element-roles.ts:95">
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>

<file name="packages/oxlint-plugin-react-doctor/src/plugin/rules/a11y/img-redundant-alt.ts">

<violation number="1" location="packages/oxlint-plugin-react-doctor/src/plugin/rules/a11y/img-redundant-alt.ts:51">
Severity: Warning

array.indexOf() 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>

<file name="packages/oxlint-plugin-react-doctor/src/plugin/rules/a11y/label-has-associated-control.ts">

<violation number="1" location="packages/oxlint-plugin-react-doctor/src/plugin/rules/a11y/label-has-associated-control.ts:56">
Severity: Warning

[...array].sort() — use array.toSorted() for immutable sorting (ES2023)

Use `array.toSorted()` (ES2023) instead of `[...array].sort()` for immutable sorting without the spread allocation

Rule: `js-tosorted-immutable`
</violation>

<violation number="2" location="packages/oxlint-plugin-react-doctor/src/plugin/rules/a11y/label-has-associated-control.ts:139">
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>

<violation number="3" location="packages/oxlint-plugin-react-doctor/src/plugin/rules/a11y/label-has-associated-control.ts:181">
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>

<file name="packages/oxlint-plugin-react-doctor/src/plugin/rules/a11y/aria-role.ts">

<violation number="1" location="packages/oxlint-plugin-react-doctor/src/plugin/rules/a11y/aria-role.ts:65">
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>

<file name="packages/oxlint-plugin-react-doctor/src/plugin/rules/a11y/control-has-associated-label.ts">

<violation number="1" location="packages/oxlint-plugin-react-doctor/src/plugin/rules/a11y/control-has-associated-label.ts:85">
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>

<violation number="2" location="packages/oxlint-plugin-react-doctor/src/plugin/rules/a11y/control-has-associated-label.ts:85">
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>

<file name="packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-array-index-as-key.ts">

<violation number="1" location="packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-array-index-as-key.ts:202">
Severity: Warning

callee.property.name is read 3 times inside this loop — hoist into a const at the top of the loop body

Hoist the deep member access into a const at the top of the loop body: `const { x, y } = obj.deeply.nested`

Rule: `js-cache-property-access`
</violation>

<violation number="2" location="packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-array-index-as-key.ts:196">
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>

<violation number="3" location="packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-array-index-as-key.ts:248">
Severity: Warning

callee.property.name is read 3 times inside this loop — hoist into a const at the top of the loop body

Hoist the deep member access into a const at the top of the loop body: `const { x, y } = obj.deeply.nested`

Rule: `js-cache-property-access`
</violation>

<violation number="4" location="packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-array-index-as-key.ts:242">
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>

<file name="packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/jsx-handler-names.ts">

<violation number="1" location="packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/jsx-handler-names.ts:35">
Severity: Warning

.map().filter() iterates the array twice — combine into a single loop with .reduce() or for...of

Combine `.map().filter()` (or similar chains) into a single pass with `.reduce()` or a `for...of` loop to avoid iterating the array twice

Rule: `js-combine-iterations`
</violation>

</file>

<file name="packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/exhaustive-deps.ts">

<violation number="1" location="packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/exhaustive-deps.ts:1223">
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>

Showing 15 of 26 issues.

Reviewed by reactreview for commit af4ce2a. Configure here.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 16, 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 22, 2026 3:56am

@aidenybai aidenybai marked this pull request as ready for review May 16, 2026 09:36
Comment thread packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts Outdated
cursoragent and others added 18 commits May 21, 2026 00:41
- 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>
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>
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>
…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>
…est-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>
…ode-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>
…or-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>
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>
…cle 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>
…y 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>
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>
…ling

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>
…ation 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>
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>
…faultEnabled: 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>
Comment thread packages/oxlint-plugin-react-doctor/src/plugin/constants/js.ts
Comment thread packages/core/src/is-test-file.ts Outdated
aidenybai and others added 2 commits May 21, 2026 00:52
…ub-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>
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
(`/icons/`, `/icon/`, `/svg/`, `/svgs/`, `/brand/`, `/logo/`, `/logos/`,
`/flags/`, `/emoji/`, `/emojis/`, plus `*Icon.tsx` / `*Logo.tsx` /
`sprite.tsx` basename matches) silenced 1364 hand-written-icon TPs
across the corpus (react-grab/icons/*, posthog/icons.tsx,
plane/empty-state/assets/vertical-stack/*). Those bytes are owned and
optimisable by the dev. Recovers +1363 net TPs.

jsx-no-new-{function,object,array}-as-prop: inverted the same-file memo
gate. Old behaviour: fire when consumer memo status is unknown (the
default for imported components — the vast majority). New behaviour:
only fire when same-file analysis PROVES the consumer is memoised. The
`useCallback`/extract-handler fix is unactionable when the consumer
isn't `React.memo`-wrapped: the parent re-renders unconditionally
regardless of function identity. Cuts ~95% of these rules' FPs across
the corpus (-843 / -77 / -80 on the parity diff).

no-multi-comp: added an exemption when total flagged components <= 2.
Covers the canonical "1 main + 1 sub-component" pattern (`ErrorBoundary`
+ `OptionalErrorBoundary`, `FPSMeter` + `FpsMeterInner`, `ArrowShapeUtil`
+ `ArrowClipPath`, `getSvgJsx`'s `SvgExport` + `ForeignObjectShape`).
Forcing a second file for a private helper fragments tightly-coupled UI
without a maintenance benefit. Cuts -182 FPs.

only-export-components: treat custom-hook exports (`use[A-Z]*`) as
allowed. Modern Vite Fast Refresh (>= 4.x) already handles `use*`
exports alongside components as refresh boundaries; flagging them is
unactionable noise. Cuts -49 FPs.

no-array-index-key: relaxed the composite-template-key skip — now
applies whenever the template has any non-index interpolation, even
when `findIteratorItemName` can't resolve the iterator binding through
nested helper closures. Also added a string-concat composite branch
(`outerVar + '-' + i` is composite; `'prefix-' + i` still flags). The
existing "iterator member identity" check stays as the primary path.

Audit + parity numbers in agent-transcript.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread packages/core/src/rule-key-aliases.ts
aidenybai and others added 6 commits May 21, 2026 02:26
The legacy → native rule key alias map only covered ~26 entries —
12 `react/*` + 14 `jsx-a11y/*` — but the PR ports 100+ rules from
react / jsx-a11y / react-hooks / react-refresh / react-perf and the
you-might-not-need-an-effect plugin. Without an alias entry, user
configs targeting the old keys silently no-op after upgrade:

  config = {
    ignore: { rules: ["react-hooks/exhaustive-deps"] },
    rules: { "react/no-multi-comp": "off" },
  }

The `applySeverityControls` path and `isSameRuleKey` lookup both
key off this map; missing entries break severity overrides, ignore
filters, and inline-suppression matching.

Regenerated the map from the rule files: every `Port of \`oxc_linter::
rules::<plugin>::\`` comment yields one alias per ESLint-convention
namespace (`react`, `jsx-a11y`, `react-perf`, plus `react-hooks` /
`react-refresh` for the rules conventionally exposed under those
namespaces). The pre-existing `effect/*` aliases for the
you-might-not-need-an-effect family are preserved as a manual baseline.

107 aliases total, alphabetically sorted for stable diffs.

Reported by Cursor Bugbot on commit 2ff18a9.

Co-authored-by: Cursor <cursoragent@cursor.com>
Drop obsolete post-port rule maps, internalize unused exports flagged by
deslop-js, and consolidate effect rule coverage into JSON parity fixtures.

Co-authored-by: Cursor <cursoragent@cursor.com>
Hardcoded 60s `OXLINT_SPAWN_TIMEOUT_MS` starves every batch under Vercel
Sandbox microVMs in react-doctor-evals (the native binding is markedly
slower there than on a laptop). Allow the evals harness to bump it via
`REACT_DOCTOR_OXLINT_SPAWN_TIMEOUT_MS` without changing the local
default.

Co-authored-by: Cursor <cursoragent@cursor.com>
`onPartialFailure` always blamed the per-batch oxlint budget even when
files were dropped for an unrelated reason (output-too-large, OOM,
native-binding crash). In the eval pipeline this masked sandbox-specific
issues — every report attributed the missing lint diagnostics to a
timeout that never actually fired.

Now the partial-failure string is rephrased as a generic skip ("failed
to lint and were skipped") and appends the first concrete error
message (e.g. "oxlint was killed by SIGABRT") so consumers can tell
the budget case from a native-binding failure at a glance.

Co-authored-by: Cursor <cursoragent@cursor.com>
- oxlint 1.63.0 → 1.66.0
- oxlint-tsgolint 0.22.1 → 0.23.0
- oxc-parser 0.131.0 → 0.132.0

Picks up the v1.65.0 Linux MUSL fix for the fixed-size allocator
(oxc-project/oxc#22388 — backing fix for the SIGABRT panic at
`crates/oxc_allocator/src/pool/fixed_size.rs:112` we hit in Vercel
Sandbox) and Windows `VirtualAlloc` fix (#22124), plus the new built-in
rules from v1.65/1.66 (no-noninteractive-element-to-interactive-role,
no-noninteractive-element-interactions, control-has-associated-label,
no-implicit-globals, no-implied-eval, id-match,
no-object-type-as-default-prop, no-unstable-nested-components,
import/newline-after-import, jsx-a11y-x support).

All 1404 tests pass.

Co-authored-by: Cursor <cursoragent@cursor.com>
Conflicts (packages/react-doctor/package.json, pnpm-lock.yaml):
- Take main's dep reorganization from #312 (`commander`, `ora` moved
  from `dependencies` → `devDependencies`; `picocolors` dropped — not
  imported by this package).
- Keep our `oxlint ^1.66.0` bump (origin/main still on ^1.63.0).
- Keep our drop of the `eslint-plugin-react-you-might-not-need-an-effect`
  peer + dev dep — the OXC port in this PR (`4d466f05`) replaces the
  upstream plugin with native ports under `react-doctor/*`, so main's
  reintroduction of it as a devDependency in #312 is no longer needed.

Verified post-merge: pnpm build / typecheck / lint clean; 1406 tests
pass.

Co-authored-by: Cursor <cursoragent@cursor.com>
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.

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 6326c0d. Configure here.

Comment thread packages/core/src/is-test-file.ts
Bugbot caught a hole in `stripAboveSourceRoot`: when a path matches
`FIXTURE_PROJECT_PATTERN` but the inner project has no
source-root segment (`src/`, `app/`, `lib/`, …), the function returned
the full path. The outer `tests/` / `e2e/` prefix then still satisfied
`TEST_FILE_DIRECTORY_PATTERN`, so flat fixtures like
`tests/fixtures/my-app/Component.tsx` were misclassified as test files
and their `test-noise`-tagged diagnostics auto-suppressed.

Strip up through the fixture segment in that case so the path passed
to the directory heuristic starts at the fixture itself — fixture
contents are production-shaped code, the fixture project is the unit
under test. Existing `tests/fixtures/<proj>/src/...` behavior
preserved (still strips to the inner source root when present).

Added regression test cases for the flat layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
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