Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
77 commits
Select commit Hold shift + click to select a range
1e12d5f
feat: add source-patched native Oxlint path
aidenybai Aug 21, 2026
be8e2ad
perf(native): port 16 rules to Rust
aidenybai Aug 21, 2026
ecba44a
fix(native): install rustfmt for registry generation
aidenybai Aug 21, 2026
dbc6ed1
perf(native): port nine parity-safe Oxc rules
aidenybai Aug 22, 2026
6241656
perf(native): port curated JSX rules to Rust
aidenybai Aug 22, 2026
93f98b0
perf(native): port forward ref provenance to Rust
aidenybai Aug 22, 2026
b28de10
perf(native): port seven exact rules to Rust
aidenybai Aug 22, 2026
07e6e63
perf(native): port seven more exact rules to Rust
aidenybai Aug 23, 2026
64f9a22
perf(native): port three more exact rules to Rust
aidenybai Aug 23, 2026
993cc52
perf(native): port three class rules to Rust
aidenybai Aug 23, 2026
25201d4
perf(native): port three JSX rules to Rust
aidenybai Aug 23, 2026
3c06763
perf(native): tighten three Rust rule ports
aidenybai Aug 23, 2026
70a422f
perf(native): port three more JSX rules to Rust
aidenybai Aug 23, 2026
094b499
perf(native): port three accessibility rules to Rust
aidenybai Aug 23, 2026
6c41d00
perf(native): port three framework rules to Rust
aidenybai Aug 23, 2026
ac21ca4
perf(native): port three ecosystem rules to Rust
aidenybai Aug 23, 2026
d602c54
fix(native): preserve test-file rule gating
aidenybai Aug 23, 2026
25585c1
perf(native): port three application rules to Rust
aidenybai Aug 23, 2026
d077413
perf(native): port three static JSX rules to Rust
aidenybai Aug 23, 2026
5be4252
perf(native): port three static accessibility rules to Rust
aidenybai Aug 23, 2026
5b273fd
feat(native): port nextjs route rules
aidenybai Aug 23, 2026
bdeaa97
feat(native): port nextjs asset rules
aidenybai Aug 23, 2026
d85612d
perf(native): port Motion scope rules to Rust
aidenybai Aug 23, 2026
776804a
feat(native): port dialog and viewport rules
aidenybai Aug 23, 2026
61ae842
feat(native): port React Native import rules
aidenybai Aug 23, 2026
ad38785
feat(native): port retired rules
aidenybai Aug 23, 2026
acfb367
feat(native): port React Native list and image rules
aidenybai Aug 23, 2026
946e3d0
perf(native): port render side-effect rules to Rust
aidenybai Aug 23, 2026
d99512c
perf(native): port scope-aware framework rules to Rust
aidenybai Aug 23, 2026
d68de6e
feat(native): port JSX attribute and link rules
aidenybai Aug 23, 2026
aafc1fc
feat(native): port JSX content rules to Rust
aidenybai Aug 23, 2026
38db003
feat(native): port accessibility semantics rules to Rust
aidenybai Aug 23, 2026
00a96fb
feat(native): register project analysis rules in Rust
aidenybai Aug 23, 2026
df69478
perf(native): port migration and router rules to Rust
aidenybai Aug 23, 2026
251021a
feat(native): port Remotion and Tailwind rules to Rust
aidenybai Aug 23, 2026
b69ce5c
feat(native): port Tailwind interaction rules to Rust
aidenybai Aug 23, 2026
b2b63f2
perf(native): port context and class rules to Rust
aidenybai Aug 23, 2026
21a8248
perf(native): port accessibility role rules to Rust
aidenybai Aug 23, 2026
de8c41e
perf(native): port JSX depth and unsafe lifecycle rules
aidenybai Aug 23, 2026
707549f
perf(native): port R3F and WebGPU rules to Rust
aidenybai Aug 23, 2026
287b570
perf(native): port nested router rule to Rust
aidenybai Aug 23, 2026
5c1367a
perf(native): port viewport and text style rules to Rust
aidenybai Aug 23, 2026
839f0b7
perf(native): port static style rules to Rust
aidenybai Aug 23, 2026
8fa5c79
perf(native): port static page rules to Rust
aidenybai Aug 23, 2026
c5e95b5
perf(native): port static text rules to Rust
aidenybai Aug 23, 2026
e166b3e
perf(native): port general performance rules to Rust
aidenybai Aug 23, 2026
50e83c8
perf(native): port static control rules to Rust
aidenybai Aug 23, 2026
b32e3eb
perf(native): port static typography rules to Rust
aidenybai Aug 23, 2026
358dc3e
perf(native): port static presentation rules to Rust
aidenybai Aug 23, 2026
572c5e7
perf(native): port rerender rules to Rust
aidenybai Aug 23, 2026
87c6e6c
perf(native): port static inline rules to Rust
aidenybai Aug 23, 2026
eda5df4
perf(native): port repeated section shells to Rust
aidenybai Aug 23, 2026
3453e5c
perf(native): port eager state initializer rules to Rust
aidenybai Aug 23, 2026
5dffa81
perf(native): port oversized long headings to Rust
aidenybai Aug 23, 2026
53ed5fc
perf(native): port flat page type scale to Rust
aidenybai Aug 23, 2026
5e32198
perf(native): port small form control text to Rust
aidenybai Aug 23, 2026
4d846b2
perf(native): port simple useMemo rule to Rust
aidenybai Aug 23, 2026
31eff0a
perf(native): port JSX em dash rule to Rust
aidenybai Aug 23, 2026
0ec6dce
perf(native): port redundant axis rules to Rust
aidenybai Aug 23, 2026
50cf1c9
perf(native): port spacing and ellipsis rules to Rust
aidenybai Aug 23, 2026
cefb3e6
perf(native): port vague button label to Rust
aidenybai Aug 23, 2026
24838f4
perf(native): port immutable sorting rule to Rust
aidenybai Aug 23, 2026
72fd97b
perf(native): port functional setState rule to Rust
aidenybai Aug 23, 2026
d213ba2
perf(native): port storage cache rule to Rust
aidenybai Aug 23, 2026
2b6a6db
perf(native): port render state setter rule to Rust
aidenybai Aug 23, 2026
31fdd92
perf(native): port property access cache rule to Rust
aidenybai Aug 23, 2026
1f0c9c1
perf(native): port Effect Event dependency rule to Rust
aidenybai Aug 23, 2026
662bac8
perf(native): port async reduce accumulator rule to Rust
aidenybai Aug 24, 2026
d7d29a4
perf(native): port invalid route splat rule to Rust
aidenybai Aug 24, 2026
52301a5
perf(native): port absolute child route rule to Rust
aidenybai Aug 24, 2026
d1ab655
perf(native): port empty leaf route rule to Rust
aidenybai Aug 24, 2026
2c37670
perf(native): port React Router route contracts to Rust
aidenybai Aug 24, 2026
b1528de
perf(native): port removed Router v8 flags rule to Rust
aidenybai Aug 24, 2026
ac67a97
perf(native): port duplicate route ID rule to Rust
aidenybai Aug 24, 2026
1237395
perf(native): port Ink render side-effect rules to Rust
aidenybai Aug 24, 2026
9473dbc
perf(native): port Ink terminal and layout rules to Rust
aidenybai Aug 24, 2026
0287abb
perf(native): port event trigger state rule to Rust
aidenybai Aug 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
7 changes: 7 additions & 0 deletions .changeset/functional-state-rust.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"oxlint-plugin-react-doctor": patch
"eslint-plugin-react-doctor": patch
"react-doctor": patch
---

Run `rerender-functional-setstate` natively in the patched Oxlint binding and avoid reporting a synchronous state update when the only other call to the same setter does not read that state.
5 changes: 5 additions & 0 deletions .changeset/tidy-oxen-compile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-doctor": patch
---

Add an opt-in, source-patched Oxlint binding path with native rule parity, cache isolation, provenance metadata, and reproducible cross-platform build tooling.
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
packages/fuzz/corpus/react-bench-0.9.7-audit/** -text whitespace=-trailing-space
packages/fuzz/corpus/dummy-threejs-v14-audit/** -text whitespace=-trailing-space
native/oxlint/*.patch -text whitespace=-trailing-space
87 changes: 87 additions & 0 deletions .github/workflows/native-oxlint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
name: Native Oxlint

on:
push:
branches: [main]
paths:
- "native/oxlint/**"
- "scripts/native/**"
- "package.json"
- "packages/core/src/constants.ts"
- "packages/core/src/run-oxlint.ts"
- "packages/core/src/runners/oxlint/**"
- ".github/workflows/native-oxlint.yml"
pull_request:
branches: [main]
paths:
- "native/oxlint/**"
- "scripts/native/**"
- "package.json"
- "packages/core/src/constants.ts"
- "packages/core/src/run-oxlint.ts"
- "packages/core/src/runners/oxlint/**"
- ".github/workflows/native-oxlint.yml"

permissions:
contents: read

jobs:
verify-patch:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- run: node scripts/native/build-oxlint-binding.mjs --check-only

build:
needs: verify-patch
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
runner:
- ubuntu-24.04
- ubuntu-24.04-arm
- macos-15-intel
- macos-15
- windows-2025
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@1.97.1
with:
components: rustfmt
- uses: Swatinem/rust-cache@v2
with:
shared-key: native-oxlint-${{ matrix.runner }}
- run: node scripts/native/build-oxlint-binding.mjs --output dist/native-oxlint
- uses: actions/upload-artifact@v7
with:
name: native-oxlint-${{ matrix.runner }}
path: dist/native-oxlint/*
if-no-files-found: error

parity:
needs: build
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v5
with:
node-version: "22.18.0"
cache: pnpm
- run: pnpm install --frozen-lockfile --prefer-offline
- run: pnpm --filter oxlint-plugin-react-doctor build
- uses: actions/download-artifact@v8
with:
name: native-oxlint-ubuntu-24.04
path: dist/native-oxlint
- run: node scripts/native/verify-oxlint-parity.mjs --directory dist/native-oxlint
24 changes: 24 additions & 0 deletions native/oxlint/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Source-patched Oxlint

This directory contains a patch against one exact Oxc commit. It adds a `react-doctor-native` plugin while leaving the stock Oxlint JavaScript launcher and all unported React Doctor rules unchanged.

The native rule cohort is listed in `upstream.json`, with one Rust source file per rule in `rules/`. React Doctor enables those rules only when `REACT_DOCTOR_NATIVE_OXLINT_BINDING_PATH` points to a built `.node` binding. Without that environment variable, scans use the published Oxlint binding and JavaScript rules exactly as before.

## Build and verify

```sh
nr native:oxlint:verify
nr native:oxlint:check
nr native:oxlint:build
REACT_DOCTOR_NATIVE_OXLINT_BINDING_PATH=dist/native-oxlint/<binding>.node nr native:oxlint:parity
REACT_DOCTOR_NATIVE_OXLINT_BINDING_PATH=dist/native-oxlint/<binding>.node nr native:oxlint:parity --benchmark
REACT_DOCTOR_NATIVE_OXLINT_BINDING_PATH=dist/native-oxlint/<binding>.node nr native:oxlint:parity --corpus packages/fuzz/tmp/corpus-repos
```

Set `CARGO_BUILD_JOBS=2` for the compile-check and release build on memory-constrained builders.

`native:oxlint:verify` clones the pinned tag, checks its commit, and proves the patch still applies. `native:oxlint:check` overlays every native rule, regenerates Oxc's rule registry, and compile-checks the linter. `native:oxlint:build` performs the same source assembly, compiles and loads the N-API binding, and writes the binding plus provenance and SHA-256 hashes to `dist/native-oxlint`.

The parity check runs the JavaScript and native implementations over the same adversarial TypeScript fixture and compares normalized diagnostics. Pass `--corpus` with a directory of repositories to compare every repository independently. A native rule should not be added to `nativeRules` or `NATIVE_REACT_DOCTOR_RULE_IDS` until both checks pass.

The workflow builds artifacts for Linux x64/arm64, macOS x64/arm64, and Windows x64. It does not publish them. Shipping or making the native patch the default should happen only after corpus parity shows no diagnostic drift and benchmarks show at least a 15% p50 lint improvement.
221 changes: 221 additions & 0 deletions native/oxlint/react-doctor.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
diff --git a/crates/oxc_linter/src/config/plugins.rs b/crates/oxc_linter/src/config/plugins.rs
index e4e346c..6ecbfc1 100644
--- a/crates/oxc_linter/src/config/plugins.rs
+++ b/crates/oxc_linter/src/config/plugins.rs
@@ -68,6 +68,7 @@ pub fn plugin_display_name(plugin_name: &str) -> &str {
match plugin_name {
"jsx_a11y" => "jsx-a11y",
"react_perf" => "react-perf",
+ "react_doctor_native" => "react-doctor-native",
"nextjs" => "next",
_ => plugin_name,
}
@@ -121,6 +122,8 @@ bitflags! {
const NODE = 1 << 12;
/// `eslint-plugin-vue`
const VUE = 1 << 13;
+ /// Native React Doctor rules
+ const REACT_DOCTOR_NATIVE = 1 << 14;
}
}

@@ -186,6 +189,7 @@ impl TryFrom<&str> for LintPlugins {
"promise" => Ok(LintPlugins::PROMISE),
"node" => Ok(LintPlugins::NODE),
"vue" => Ok(LintPlugins::VUE),
+ "react-doctor-native" | "react_doctor_native" => Ok(LintPlugins::REACT_DOCTOR_NATIVE),
// "eslint" is not really a plugin, so it's 'empty'. This has the added benefit of
// making it the default value.
"eslint" => Ok(LintPlugins::ESLINT),
@@ -211,6 +215,7 @@ impl From<LintPlugins> for &'static str {
LintPlugins::PROMISE => "promise",
LintPlugins::NODE => "node",
LintPlugins::VUE => "vue",
+ LintPlugins::REACT_DOCTOR_NATIVE => "react-doctor-native",
_ => "",
}
}
@@ -282,6 +287,7 @@ impl JsonSchema for LintPlugins {
Promise,
Node,
Vue,
+ ReactDoctorNative,
}

let enum_schema = r#gen.subschema_for::<LintPluginOptionsSchema>();
diff --git a/crates/oxc_linter/src/context/mod.rs b/crates/oxc_linter/src/context/mod.rs
index 152f919..7e451af 100644
--- a/crates/oxc_linter/src/context/mod.rs
+++ b/crates/oxc_linter/src/context/mod.rs
@@ -1,15 +1,23 @@
#![expect(rustdoc::private_intra_doc_links)] // useful for intellisense

-use std::{ffi::OsStr, ops::Deref, path::Path, rc::Rc};
+use std::{
+ borrow::Cow,
+ cell::OnceCell,
+ collections::HashMap,
+ ffi::OsStr,
+ ops::Deref,
+ path::Path,
+ rc::Rc,
+};

use javascript_globals::{GLOBALS, GLOBALS_BUILTIN, GLOBALS_ES2026};

use oxc_allocator::Allocator;
-use oxc_ast::ast::IdentifierReference;
+use oxc_ast::{AstKind, ast::IdentifierReference};
use oxc_cfg::ControlFlowGraph;
use oxc_diagnostics::{OxcDiagnostic, Severity};
use oxc_semantic::{IsGlobalReference, Semantic};
-use oxc_span::Span;
+use oxc_span::{GetSpan, LabeledSpan, Span};

#[cfg(debug_assertions)]
use crate::rule::RuleFixMeta;
@@ -25,6 +33,74 @@ use crate::{
mod host;
pub use host::{ContextHost, ContextSubHost, ContextSubHostOptions};

+fn apply_react_doctor_diagnostic_override(
+ context: &LintContext,
+ rule_name: &str,
+ message: &mut Message,
+) {
+ let upstream_message = message.error.message.as_ref();
+ let diagnostic_message: Cow<'static, str> = match rule_name {
+ "html-has-lang" => {
+ Cow::Borrowed("Screen readers may mispronounce this page because it doesn't declare a language, so add a `lang` attribute like `en`.")
+ }
+ "no-access-key" => {
+ Cow::Borrowed("Screen reader users can lose their shortcuts because `accessKey` clashes with them, so remove it.")
+ }
+ "no-clone-element" => {
+ Cow::Borrowed("`React.cloneElement` couples the parent to the child's prop shape, so child prop changes can silently break injected behavior.")
+ }
+ "no-is-mounted" => {
+ Cow::Borrowed("`isMounted` is unreliable in modern React, so async callbacks can update state after unmount.")
+ }
+ "no-render-return-value" => {
+ Cow::Borrowed("Your app breaks in React 19 because `ReactDOM.render` returns nothing there.")
+ }
+ "no-will-update-set-state" => {
+ Cow::Borrowed("Calling setState in componentWillUpdate can trigger another update immediately, loop forever, and freeze the component.")
+ }
+ "self-closing-comp" => {
+ Cow::Borrowed("This tag has no children, so the closing tag adds noise without changing output.")
+ }
+ "no-distracting-elements" => {
+ let element = upstream_message
+ .split("`<")
+ .nth(1)
+ .and_then(|message| message.split_once('>'))
+ .map_or("element", |(element, _)| element);
+ Cow::Owned(format!("Users with attention or motion sensitivity struggle because `<{element}>` animates on its own, so use normal, accessible markup instead."))
+ }
+ "require-render-return" => Cow::Borrowed(
+ "Your users see nothing because this `render` method returns nothing.",
+ ),
+ _ => return,
+ };
+ if rule_name == "self-closing-comp" {
+ let diagnostic_spans = context.react_doctor_diagnostic_spans.get_or_init(|| {
+ context
+ .nodes()
+ .iter()
+ .filter_map(|node| match (rule_name, node.kind()) {
+ ("self-closing-comp", AstKind::JSXElement(element)) => element
+ .closing_element
+ .as_ref()
+ .map(|closing_element| {
+ (closing_element.span().start, element.opening_element.span())
+ }),
+ _ => None,
+ })
+ .collect()
+ });
+ if let Some(diagnostic_span) = diagnostic_spans.get(&message.span.start) {
+ message.span = *diagnostic_span;
+ }
+ }
+ message.error.message = diagnostic_message;
+ message.error.help = None;
+ message.error.note = None;
+ message.error.labels.clear();
+ message.error.labels.push(LabeledSpan::underline(message.span));
+}
+
/// Contains all of the state and context specific to this lint rule.
///
/// Includes information like the rule name, plugin name, and severity of the rule.
@@ -59,5 +127,6 @@ pub struct LintContext<'a> {
/// }
/// ```
severity: Severity,
+ react_doctor_diagnostic_spans: OnceCell<HashMap<u32, Span>>,
}

@@ -264,7 +332,18 @@ impl<'a> LintContext<'a> {
/// Add a diagnostic message to the list of diagnostics. Outputs a diagnostic with the current rule
/// name, severity, and a link to the rule's documentation URL.
fn add_diagnostic(&self, mut message: Message) {
- if self.parent.disable_directives().contains(self.current_rule_name, message.span) {
+ if self.current_plugin_name == "react_doctor_native" {
+ apply_react_doctor_diagnostic_override(self, self.current_rule_name, &mut message);
+ }
+ let is_disabled = if self.current_plugin_name == "react_doctor_native" {
+ self.parent.disable_directives().contains(
+ &format!("react-doctor/{}", self.current_rule_name),
+ message.span,
+ )
+ } else {
+ self.parent.disable_directives().contains(self.current_rule_name, message.span)
+ };
+ if is_disabled {
return;
}
message.error = message
diff --git a/crates/oxc_linter/src/context/host.rs b/crates/oxc_linter/src/context/host.rs
--- a/crates/oxc_linter/src/context/host.rs
+++ b/crates/oxc_linter/src/context/host.rs
@@ -493,24 +493,26 @@ impl<'a> ContextHost<'a> {
LintContext {
parent: self,
+ react_doctor_diagnostic_spans: OnceCell::new(),
current_rule_name: rule_name,
current_plugin_name: plugin_name,
current_plugin_display_name: plugin_display_name(plugin_name),
#[cfg(debug_assertions)]
current_rule_fix_capabilities: rule.fix(),
severity: severity.into(),
}
}

/// Creates a new [`LintContext`] for testing purposes only.
#[cfg(test)]
pub(crate) fn spawn_for_test(self: Rc<Self>) -> LintContext<'a> {
LintContext {
parent: Rc::clone(&self),
+ react_doctor_diagnostic_spans: OnceCell::new(),
current_rule_name: "",
current_plugin_name: "eslint",
current_plugin_display_name: "eslint",
#[cfg(debug_assertions)]
current_rule_fix_capabilities: crate::rule::RuleFixMeta::None,
severity: oxc_diagnostics::Severity::Warning,
}
}
diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs
index baa13c5..7c21b9d 100644
--- a/crates/oxc_linter/src/rules.rs
+++ b/crates/oxc_linter/src/rules.rs
@@ -3,6 +3,8 @@
//! New rules need to be added to these `mod` statements.
//! Then run `cargo lintgen` to regenerate the RuleEnum and RuleRunnerImpls.

+pub(crate) mod react_doctor_native;
+
/// <https://github.com/import-js/eslint-plugin-import>
pub(crate) mod import {
pub mod consistent_type_specifier_style;
Loading
Loading