From 1e12d5ff2c85d9756df6785203433f8fc20b0595 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 21 Aug 2026 05:43:57 +0000 Subject: [PATCH 001/423] feat: add source-patched native Oxlint path --- .changeset/tidy-oxen-compile.md | 5 + .gitattributes | 1 + .github/workflows/native-oxlint.yml | 85 ++++ native/oxlint/README.md | 21 + native/oxlint/react-doctor.patch | 434 ++++++++++++++++++ native/oxlint/upstream.json | 8 + package.json | 3 + packages/core/src/constants.ts | 8 + packages/core/src/run-oxlint.ts | 17 +- packages/core/src/runners/oxlint/config.ts | 18 +- .../core/src/runners/oxlint/parse-output.ts | 6 +- .../core/src/runners/oxlint/resolve-paths.ts | 9 +- .../oxlint/resolve-toolchain-versions.ts | 13 + .../core/src/runners/oxlint/spawn-batches.ts | 2 + .../core/src/runners/oxlint/spawn-oxlint.ts | 11 +- .../core/tests/oxlint-config-settings.test.ts | 19 + .../tests/oxlint-engine-diagnostic.test.ts | 22 + ...resolve-native-oxlint-binding-path.test.ts | 20 + .../tests/resolve-toolchain-versions.test.ts | 18 + .../tests/spawn-oxlint-compile-cache.test.ts | 19 +- .../src/cli/utils/build-run-event.ts | 6 + .../react-doctor/src/cli/utils/constants.ts | 2 + .../tests/build-run-event.test.ts | 7 + scripts/native/build-oxlint-binding.mjs | 125 +++++ scripts/native/verify-oxlint-parity.mjs | 235 ++++++++++ 25 files changed, 1102 insertions(+), 12 deletions(-) create mode 100644 .changeset/tidy-oxen-compile.md create mode 100644 .github/workflows/native-oxlint.yml create mode 100644 native/oxlint/README.md create mode 100644 native/oxlint/react-doctor.patch create mode 100644 native/oxlint/upstream.json create mode 100644 packages/core/tests/resolve-native-oxlint-binding-path.test.ts create mode 100644 scripts/native/build-oxlint-binding.mjs create mode 100644 scripts/native/verify-oxlint-parity.mjs diff --git a/.changeset/tidy-oxen-compile.md b/.changeset/tidy-oxen-compile.md new file mode 100644 index 0000000000..81551d7735 --- /dev/null +++ b/.changeset/tidy-oxen-compile.md @@ -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. diff --git a/.gitattributes b/.gitattributes index 1455cff2cd..7274c38197 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/native-oxlint.yml b/.github/workflows/native-oxlint.yml new file mode 100644 index 0000000000..c90be6dca8 --- /dev/null +++ b/.github/workflows/native-oxlint.yml @@ -0,0 +1,85 @@ +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 + - 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 diff --git a/native/oxlint/README.md b/native/oxlint/README.md new file mode 100644 index 0000000000..62da005740 --- /dev/null +++ b/native/oxlint/README.md @@ -0,0 +1,21 @@ +# 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 first native rule is `no-document-write`. React Doctor enables it 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 the JavaScript rule exactly as before. + +## Build and verify + +```sh +nr native:oxlint:verify +nr native:oxlint:build +REACT_DOCTOR_NATIVE_OXLINT_BINDING_PATH=dist/native-oxlint/.node nr native:oxlint:parity +REACT_DOCTOR_NATIVE_OXLINT_BINDING_PATH=dist/native-oxlint/.node nr native:oxlint:parity --benchmark +REACT_DOCTOR_NATIVE_OXLINT_BINDING_PATH=dist/native-oxlint/.node nr native:oxlint:parity --corpus packages/fuzz/tmp/corpus-repos +``` + +`native:oxlint:verify` clones the pinned tag, checks its commit, and proves the patch still applies. `native:oxlint:build` applies the patch in a temporary checkout, 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. diff --git a/native/oxlint/react-doctor.patch b/native/oxlint/react-doctor.patch new file mode 100644 index 0000000000..a99e1a971c --- /dev/null +++ b/native/oxlint/react-doctor.patch @@ -0,0 +1,434 @@ +From a3405e0ffde271279ce4cc5927fa649a9ae009c9 Mon Sep 17 00:00:00 2001 +From: React Doctor +Date: Fri, 21 Aug 2026 05:21:16 +0000 +Subject: [PATCH] feat(linter): add React Doctor native rules + +--- + crates/oxc_linter/src/config/plugins.rs | 6 ++ + .../src/generated/rule_runner_impls.rs | 6 ++ + crates/oxc_linter/src/generated/rules_enum.rs | 35 ++++++++- + crates/oxc_linter/src/rules.rs | 4 + + .../react_doctor_native/no_document_write.rs | 76 +++++++++++++++++++ + ...react_doctor_native_no_document_write.snap | 51 +++++++++++++ + .../track_linter_timings/linter_timings.snap | 1 + + 7 files changed, 177 insertions(+), 2 deletions(-) + create mode 100644 crates/oxc_linter/src/rules/react_doctor_native/no_document_write.rs + create mode 100644 crates/oxc_linter/src/snapshots/react_doctor_native_no_document_write.snap + +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 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::(); +diff --git a/crates/oxc_linter/src/generated/rule_runner_impls.rs b/crates/oxc_linter/src/generated/rule_runner_impls.rs +index 03d08e7..dc14108 100644 +--- a/crates/oxc_linter/src/generated/rule_runner_impls.rs ++++ b/crates/oxc_linter/src/generated/rule_runner_impls.rs +@@ -8,6 +8,12 @@ use oxc_semantic::AstTypesBitset; + + use crate::rule::{RuleRunFunctionsImplemented, RuleRunner}; + ++impl RuleRunner for crate::rules::react_doctor_native::no_document_write::NoDocumentWrite { ++ const NODE_TYPES: Option<&AstTypesBitset> = ++ Some(&AstTypesBitset::from_types(&[AstType::CallExpression])); ++ const RUN_FUNCTIONS: RuleRunFunctionsImplemented = RuleRunFunctionsImplemented::Run; ++} ++ + impl RuleRunner + for crate::rules::import::consistent_type_specifier_style::ConsistentTypeSpecifierStyle + { +diff --git a/crates/oxc_linter/src/generated/rules_enum.rs b/crates/oxc_linter/src/generated/rules_enum.rs +index c05302a..e538ca1 100644 +--- a/crates/oxc_linter/src/generated/rules_enum.rs ++++ b/crates/oxc_linter/src/generated/rules_enum.rs +@@ -509,6 +509,7 @@ pub use crate::rules::react::unsupported_syntax::UnsupportedSyntax as ReactUnsup + pub use crate::rules::react::use_memo::UseMemo as ReactUseMemo; + pub use crate::rules::react::void_dom_elements_no_children::VoidDomElementsNoChildren as ReactVoidDomElementsNoChildren; + pub use crate::rules::react::void_use_memo::VoidUseMemo as ReactVoidUseMemo; ++pub use crate::rules::react_doctor_native::no_document_write::NoDocumentWrite as ReactDoctorNativeNoDocumentWrite; + pub use crate::rules::react_perf::jsx_no_jsx_as_prop::JsxNoJsxAsProp as ReactPerfJsxNoJsxAsProp; + pub use crate::rules::react_perf::jsx_no_new_array_as_prop::JsxNoNewArrayAsProp as ReactPerfJsxNoNewArrayAsProp; + pub use crate::rules::react_perf::jsx_no_new_function_as_prop::JsxNoNewFunctionAsProp as ReactPerfJsxNoNewFunctionAsProp; +@@ -890,6 +891,7 @@ use crate::{ + use oxc_semantic::AstTypesBitset; + #[derive(Debug, Clone)] + pub enum RuleEnum { ++ ReactDoctorNativeNoDocumentWrite(ReactDoctorNativeNoDocumentWrite), + ImportConsistentTypeSpecifierStyle(ImportConsistentTypeSpecifierStyle), + ImportDefault(ImportDefault), + ImportExport(ImportExport), +@@ -1769,7 +1771,9 @@ pub enum RuleEnum { + VueValidDefineProps(VueValidDefineProps), + VueValidNextTick(VueValidNextTick), + } +-const IMPORT_CONSISTENT_TYPE_SPECIFIER_STYLE_ID: usize = 0usize; ++const REACT_DOCTOR_NATIVE_NO_DOCUMENT_WRITE_ID: usize = 0usize; ++const IMPORT_CONSISTENT_TYPE_SPECIFIER_STYLE_ID: usize = ++ REACT_DOCTOR_NATIVE_NO_DOCUMENT_WRITE_ID + 1usize; + const IMPORT_DEFAULT_ID: usize = IMPORT_CONSISTENT_TYPE_SPECIFIER_STYLE_ID + 1usize; + const IMPORT_EXPORT_ID: usize = IMPORT_DEFAULT_ID + 1usize; + const IMPORT_EXPORTS_LAST_ID: usize = IMPORT_EXPORT_ID + 1usize; +@@ -2748,7 +2752,8 @@ const VUE_VALID_DEFINE_EMITS_ID: usize = VUE_RETURN_IN_EMITS_VALIDATOR_ID + 1usi + const VUE_VALID_DEFINE_OPTIONS_ID: usize = VUE_VALID_DEFINE_EMITS_ID + 1usize; + const VUE_VALID_DEFINE_PROPS_ID: usize = VUE_VALID_DEFINE_OPTIONS_ID + 1usize; + const VUE_VALID_NEXT_TICK_ID: usize = VUE_VALID_DEFINE_PROPS_ID + 1usize; +-static RULE_NAMES: [&str; 870usize] = [ ++static RULE_NAMES: [&str; 871usize] = [ ++ ReactDoctorNativeNoDocumentWrite::NAME, + ImportConsistentTypeSpecifierStyle::NAME, + ImportDefault::NAME, + ImportExport::NAME, +@@ -3623,6 +3628,7 @@ static RULE_NAMES: [&str; 870usize] = [ + impl RuleEnum { + pub fn id(&self) -> usize { + match self { ++ Self::ReactDoctorNativeNoDocumentWrite(_) => REACT_DOCTOR_NATIVE_NO_DOCUMENT_WRITE_ID, + Self::ImportConsistentTypeSpecifierStyle(_) => { + IMPORT_CONSISTENT_TYPE_SPECIFIER_STYLE_ID + } +@@ -4630,6 +4636,7 @@ impl RuleEnum { + } + pub fn category(&self) -> RuleCategory { + match self { ++ Self::ReactDoctorNativeNoDocumentWrite(_) => ReactDoctorNativeNoDocumentWrite::CATEGORY, + Self::ImportConsistentTypeSpecifierStyle(_) => { + ImportConsistentTypeSpecifierStyle::CATEGORY + } +@@ -5679,6 +5686,7 @@ impl RuleEnum { + #[doc = r" This [`Rule`]'s auto-fix capabilities."] + pub fn fix(&self) -> RuleFixMeta { + match self { ++ Self::ReactDoctorNativeNoDocumentWrite(_) => ReactDoctorNativeNoDocumentWrite::FIX, + Self::ImportConsistentTypeSpecifierStyle(_) => ImportConsistentTypeSpecifierStyle::FIX, + Self::ImportDefault(_) => ImportDefault::FIX, + Self::ImportExport(_) => ImportExport::FIX, +@@ -6668,6 +6676,9 @@ impl RuleEnum { + #[cfg(feature = "ruledocs")] + pub fn documentation(&self) -> Option<&'static str> { + match self { ++ Self::ReactDoctorNativeNoDocumentWrite(_) => { ++ ReactDoctorNativeNoDocumentWrite::documentation() ++ } + Self::ImportConsistentTypeSpecifierStyle(_) => { + ImportConsistentTypeSpecifierStyle::documentation() + } +@@ -7934,6 +7945,10 @@ impl RuleEnum { + generator: &mut schemars::SchemaGenerator, + ) -> Option { + match self { ++ Self::ReactDoctorNativeNoDocumentWrite(_) => { ++ ReactDoctorNativeNoDocumentWrite::config_schema(generator) ++ .or_else(|| ReactDoctorNativeNoDocumentWrite::schema(generator)) ++ } + Self::ImportConsistentTypeSpecifierStyle(_) => { + ImportConsistentTypeSpecifierStyle::config_schema(generator) + .or_else(|| ImportConsistentTypeSpecifierStyle::schema(generator)) +@@ -10429,6 +10444,7 @@ impl RuleEnum { + } + pub fn plugin_name(&self) -> &'static str { + match self { ++ Self::ReactDoctorNativeNoDocumentWrite(_) => "react_doctor_native", + Self::ImportConsistentTypeSpecifierStyle(_) => "import", + Self::ImportDefault(_) => "import", + Self::ImportExport(_) => "import", +@@ -12434,6 +12450,7 @@ impl RuleEnum { + #[inline(never)] + fn run_dispatch<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + match self { ++ Self::ReactDoctorNativeNoDocumentWrite(rule) => rule.run(node, ctx), + Self::ImportConsistentTypeSpecifierStyle(rule) => rule.run(node, ctx), + Self::ImportDefault(rule) => rule.run(node, ctx), + Self::ImportExport(rule) => rule.run(node, ctx), +@@ -13321,6 +13338,7 @@ impl RuleEnum { + #[inline(never)] + fn run_once_dispatch(&self, ctx: &LintContext<'_>) { + match self { ++ Self::ReactDoctorNativeNoDocumentWrite(rule) => rule.run_once(ctx), + Self::ImportConsistentTypeSpecifierStyle(rule) => rule.run_once(ctx), + Self::ImportDefault(rule) => rule.run_once(ctx), + Self::ImportExport(rule) => rule.run_once(ctx), +@@ -14211,6 +14229,7 @@ impl RuleEnum { + ctx: &'c LintContext<'a>, + ) { + match self { ++ Self::ReactDoctorNativeNoDocumentWrite(rule) => rule.run_on_jest_node(jest_node, ctx), + Self::ImportConsistentTypeSpecifierStyle(rule) => rule.run_on_jest_node(jest_node, ctx), + Self::ImportDefault(rule) => rule.run_on_jest_node(jest_node, ctx), + Self::ImportExport(rule) => rule.run_on_jest_node(jest_node, ctx), +@@ -15213,6 +15232,7 @@ impl RuleEnum { + } + pub(crate) fn should_run(&self, ctx: &ContextHost) -> bool { + match self { ++ Self::ReactDoctorNativeNoDocumentWrite(rule) => rule.should_run(ctx), + Self::ImportConsistentTypeSpecifierStyle(rule) => rule.should_run(ctx), + Self::ImportDefault(rule) => rule.should_run(ctx), + Self::ImportExport(rule) => rule.should_run(ctx), +@@ -16087,6 +16107,9 @@ impl RuleEnum { + } + pub fn is_tsgolint_rule(&self) -> bool { + match self { ++ Self::ReactDoctorNativeNoDocumentWrite(_) => { ++ ReactDoctorNativeNoDocumentWrite::IS_TSGOLINT_RULE ++ } + Self::ImportConsistentTypeSpecifierStyle(_) => { + ImportConsistentTypeSpecifierStyle::IS_TSGOLINT_RULE + } +@@ -17351,6 +17374,7 @@ impl RuleEnum { + #[cfg(feature = "ruledocs")] + pub fn version(&self) -> &'static str { + match self { ++ Self::ReactDoctorNativeNoDocumentWrite(_) => ReactDoctorNativeNoDocumentWrite::VERSION, + Self::ImportConsistentTypeSpecifierStyle(_) => { + ImportConsistentTypeSpecifierStyle::VERSION + } +@@ -18400,6 +18424,9 @@ impl RuleEnum { + #[doc = r" Whether this rule declares a configuration type."] + pub fn has_config(&self) -> bool { + match self { ++ Self::ReactDoctorNativeNoDocumentWrite(_) => { ++ ReactDoctorNativeNoDocumentWrite::HAS_CONFIG ++ } + Self::ImportConsistentTypeSpecifierStyle(_) => { + ImportConsistentTypeSpecifierStyle::HAS_CONFIG + } +@@ -19490,6 +19517,7 @@ impl RuleEnum { + #[cfg(feature = "ruledocs")] + pub fn info(&self) -> RuleInfo { + match self { ++ Self::ReactDoctorNativeNoDocumentWrite(_) => ReactDoctorNativeNoDocumentWrite::INFO, + Self::ImportConsistentTypeSpecifierStyle(_) => ImportConsistentTypeSpecifierStyle::INFO, + Self::ImportDefault(_) => ImportDefault::INFO, + Self::ImportExport(_) => ImportExport::INFO, +@@ -20483,6 +20511,7 @@ impl RuleEnum { + } + pub fn types_info(&self) -> Option<&'static AstTypesBitset> { + match self { ++ Self::ReactDoctorNativeNoDocumentWrite(rule) => rule.types_info(), + Self::ImportConsistentTypeSpecifierStyle(rule) => rule.types_info(), + Self::ImportDefault(rule) => rule.types_info(), + Self::ImportExport(rule) => rule.types_info(), +@@ -21357,6 +21386,7 @@ impl RuleEnum { + } + pub fn run_info(&self) -> RuleRunFunctionsImplemented { + match self { ++ Self::ReactDoctorNativeNoDocumentWrite(rule) => rule.run_info(), + Self::ImportConsistentTypeSpecifierStyle(rule) => rule.run_info(), + Self::ImportDefault(rule) => rule.run_info(), + Self::ImportExport(rule) => rule.run_info(), +@@ -22253,6 +22283,7 @@ impl PartialOrd for RuleEnum { + } + pub static RULES: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + vec![ ++ RuleEnum::ReactDoctorNativeNoDocumentWrite(ReactDoctorNativeNoDocumentWrite::default()), + RuleEnum::ImportConsistentTypeSpecifierStyle(ImportConsistentTypeSpecifierStyle::default()), + RuleEnum::ImportDefault(ImportDefault::default()), + RuleEnum::ImportExport(ImportExport::default()), +diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs +index baa13c5..6a83da3 100644 +--- a/crates/oxc_linter/src/rules.rs ++++ b/crates/oxc_linter/src/rules.rs +@@ -3,6 +3,10 @@ + //! 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 { ++ pub mod no_document_write; ++} ++ + /// + pub(crate) mod import { + pub mod consistent_type_specifier_style; +diff --git a/crates/oxc_linter/src/rules/react_doctor_native/no_document_write.rs b/crates/oxc_linter/src/rules/react_doctor_native/no_document_write.rs +new file mode 100644 +index 0000000..2eb5f69 +--- /dev/null ++++ b/crates/oxc_linter/src/rules/react_doctor_native/no_document_write.rs +@@ -0,0 +1,76 @@ ++use oxc_ast::{AstKind, ast::Expression}; ++use oxc_diagnostics::OxcDiagnostic; ++use oxc_macros::declare_oxc_lint; ++use oxc_span::Span; ++ ++use crate::{AstNode, context::LintContext, rule::Rule}; ++ ++const MESSAGE: &str = "`document.write()` blocks parsing, is ignored (or wipes the page) after load, and is flagged by browsers as a performance anti-pattern. Build DOM nodes or set `innerHTML`/`textContent` on a target element instead."; ++ ++fn no_document_write_diagnostic(span: Span) -> OxcDiagnostic { ++ OxcDiagnostic::warn(MESSAGE).with_label(span) ++} ++ ++#[derive(Debug, Default, Clone)] ++pub struct NoDocumentWrite; ++ ++declare_oxc_lint!( ++ /// Disallow `document.write()` and `document.writeln()`. ++ NoDocumentWrite, ++ react_doctor_native, ++ perf, ++ version = "0.1.0", ++ short_description = "Disallow document.write and document.writeln.", ++); ++ ++impl Rule for NoDocumentWrite { ++ fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { ++ let AstKind::CallExpression(call_expression) = node.kind() else { ++ return; ++ }; ++ let Some(member_expression) = ++ call_expression.callee.get_inner_expression().get_member_expr() ++ else { ++ return; ++ }; ++ if !matches!(member_expression.static_property_name(), Some("write" | "writeln")) { ++ return; ++ } ++ let Expression::Identifier(document_identifier) = ++ member_expression.object().get_inner_expression() ++ else { ++ return; ++ }; ++ if document_identifier.name != "document" ++ || !ctx.is_reference_to_global_variable(document_identifier) ++ { ++ return; ++ } ++ ctx.diagnostic(no_document_write_diagnostic(call_expression.span)); ++ } ++} ++ ++#[test] ++fn test() { ++ use crate::tester::Tester; ++ ++ let pass = vec![ ++ "document.createElement('div');", ++ "stream.write('chunk');", ++ "document[method]('x');", ++ "const document = { write() {} }; document.write('x');", ++ "const document = { writeln() {} }; document?.writeln('x');", ++ ]; ++ let fail = vec![ ++ "document.write('

hi

');", ++ "document.writeln('x');", ++ "document['write']('x');", ++ "document[`writeln`]('x');", ++ "document?.write('x');", ++ "document!.write('x');", ++ "(document as Document)['write']('x');", ++ "(document satisfies Document).writeln('x');", ++ ]; ++ ++ Tester::new(NoDocumentWrite::NAME, NoDocumentWrite::PLUGIN, pass, fail).test_and_snapshot(); ++} +diff --git a/crates/oxc_linter/src/snapshots/react_doctor_native_no_document_write.snap b/crates/oxc_linter/src/snapshots/react_doctor_native_no_document_write.snap +new file mode 100644 +index 0000000..52ac2a5 +--- /dev/null ++++ b/crates/oxc_linter/src/snapshots/react_doctor_native_no_document_write.snap +@@ -0,0 +1,51 @@ ++--- ++source: crates/oxc_linter/src/tester.rs ++--- ++ ++ ⚠ react-doctor-native(no-document-write): `document.write()` blocks parsing, is ignored (or wipes the page) after load, and is flagged by browsers as a performance anti-pattern. Build DOM nodes or set `innerHTML`/`textContent` on a target element instead. ++ ╭─[no_document_write.tsx:1:1] ++ 1 │ document.write('

hi

'); ++ · ─────────────────────────── ++ ╰──── ++ ++ ⚠ react-doctor-native(no-document-write): `document.write()` blocks parsing, is ignored (or wipes the page) after load, and is flagged by browsers as a performance anti-pattern. Build DOM nodes or set `innerHTML`/`textContent` on a target element instead. ++ ╭─[no_document_write.tsx:1:1] ++ 1 │ document.writeln('x'); ++ · ───────────────────── ++ ╰──── ++ ++ ⚠ react-doctor-native(no-document-write): `document.write()` blocks parsing, is ignored (or wipes the page) after load, and is flagged by browsers as a performance anti-pattern. Build DOM nodes or set `innerHTML`/`textContent` on a target element instead. ++ ╭─[no_document_write.tsx:1:1] ++ 1 │ document['write']('x'); ++ · ────────────────────── ++ ╰──── ++ ++ ⚠ react-doctor-native(no-document-write): `document.write()` blocks parsing, is ignored (or wipes the page) after load, and is flagged by browsers as a performance anti-pattern. Build DOM nodes or set `innerHTML`/`textContent` on a target element instead. ++ ╭─[no_document_write.tsx:1:1] ++ 1 │ document[`writeln`]('x'); ++ · ──────────────────────── ++ ╰──── ++ ++ ⚠ react-doctor-native(no-document-write): `document.write()` blocks parsing, is ignored (or wipes the page) after load, and is flagged by browsers as a performance anti-pattern. Build DOM nodes or set `innerHTML`/`textContent` on a target element instead. ++ ╭─[no_document_write.tsx:1:1] ++ 1 │ document?.write('x'); ++ · ──────────────────── ++ ╰──── ++ ++ ⚠ react-doctor-native(no-document-write): `document.write()` blocks parsing, is ignored (or wipes the page) after load, and is flagged by browsers as a performance anti-pattern. Build DOM nodes or set `innerHTML`/`textContent` on a target element instead. ++ ╭─[no_document_write.tsx:1:1] ++ 1 │ document!.write('x'); ++ · ──────────────────── ++ ╰──── ++ ++ ⚠ react-doctor-native(no-document-write): `document.write()` blocks parsing, is ignored (or wipes the page) after load, and is flagged by browsers as a performance anti-pattern. Build DOM nodes or set `innerHTML`/`textContent` on a target element instead. ++ ╭─[no_document_write.tsx:1:1] ++ 1 │ (document as Document)['write']('x'); ++ · ──────────────────────────────────── ++ ╰──── ++ ++ ⚠ react-doctor-native(no-document-write): `document.write()` blocks parsing, is ignored (or wipes the page) after load, and is flagged by browsers as a performance anti-pattern. Build DOM nodes or set `innerHTML`/`textContent` on a target element instead. ++ ╭─[no_document_write.tsx:1:1] ++ 1 │ (document satisfies Document).writeln('x'); ++ · ────────────────────────────────────────── ++ ╰──── +diff --git a/tasks/track_linter_timings/linter_timings.snap b/tasks/track_linter_timings/linter_timings.snap +index f6ed38d..f3782dd 100644 +--- a/tasks/track_linter_timings/linter_timings.snap ++++ b/tasks/track_linter_timings/linter_timings.snap +@@ -492,6 +492,7 @@ react/unsupported-syntax | 7 + react/use-memo | 7 + react/void-dom-elements-no-children | 7019 + react/void-use-memo | 7 ++react_doctor_native/no-document-write | 62606 + react_perf/jsx-no-jsx-as-prop | 804 + react_perf/jsx-no-new-array-as-prop | 804 + react_perf/jsx-no-new-function-as-prop | 804 +-- +2.47.3 + diff --git a/native/oxlint/upstream.json b/native/oxlint/upstream.json new file mode 100644 index 0000000000..b3bf10c31b --- /dev/null +++ b/native/oxlint/upstream.json @@ -0,0 +1,8 @@ +{ + "repository": "https://github.com/oxc-project/oxc.git", + "tag": "oxlint_v1.79.0", + "commit": "0db127cc16d28b97d84bac4ebeb302caf1a78c7e", + "oxlintVersion": "1.79.0", + "rustToolchain": "1.97.1", + "nativeRules": ["no-document-write"] +} diff --git a/package.json b/package.json index e04d07a9ee..c20f6cb60f 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,9 @@ "performance:profile": "tsx scripts/performance/analyze-cpu-profile.ts", "performance:memory": "tsx scripts/performance/analyze-heap-profile.ts", "performance:rules": "tsx scripts/performance/analyze-oxlint-timings.ts", + "native:oxlint:verify": "node scripts/native/build-oxlint-binding.mjs --check-only", + "native:oxlint:build": "node scripts/native/build-oxlint-binding.mjs", + "native:oxlint:parity": "node scripts/native/verify-oxlint-parity.mjs", "test": "turbo run test --filter=react-doctor --filter=@react-doctor/core --filter=@react-doctor/api --filter=oxlint-plugin-react-doctor --filter=eslint-plugin-react-doctor --filter=@react-doctor/fuzz --filter=@react-doctor/evals", "fuzz": "pnpm --filter @react-doctor/fuzz fuzz", "test:public-react-repos": "REACT_DOCTOR_PUBLIC_REPOS=1 vp test run packages/react-doctor/tests/public-react-repos.test.ts", diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 953d9f46d2..4e3ee57dc0 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -556,6 +556,14 @@ export const SKILL_NAME = "react-doctor"; // pathological JS-plugin rules that emit one diagnostic per AST node. export const OXLINT_OUTPUT_MAX_BYTES = 50 * 1024 * 1024; +export const REACT_DOCTOR_NATIVE_OXLINT_BINDING_ENV = "REACT_DOCTOR_NATIVE_OXLINT_BINDING_PATH"; + +export const OXLINT_NATIVE_LIBRARY_PATH_ENV = "NAPI_RS_NATIVE_LIBRARY_PATH"; + +export const NATIVE_REACT_DOCTOR_PLUGIN_NAME = "react-doctor-native"; + +export const NATIVE_REACT_DOCTOR_RULE_IDS: ReadonlySet = new Set(["no-document-write"]); + // HACK: per-batch wall-clock budget for an oxlint spawn. Each batch // is at most OXLINT_MAX_FILES_PER_BATCH (= 200) files and a healthy // batch finishes in well under a second; 60 s leaves a large safety diff --git a/packages/core/src/run-oxlint.ts b/packages/core/src/run-oxlint.ts index d733c4454b..6a3d4b0ac6 100644 --- a/packages/core/src/run-oxlint.ts +++ b/packages/core/src/run-oxlint.ts @@ -9,7 +9,7 @@ import { } from "oxlint-plugin-react-doctor/core"; import type { Diagnostic } from "./types/index.js"; import { batchIncludePaths } from "./batch-include-paths.js"; -import { COOPERATIVE_YIELD_BUDGET_MS } from "./constants.js"; +import { COOPERATIVE_YIELD_BUDGET_MS, NATIVE_REACT_DOCTOR_RULE_IDS } from "./constants.js"; import { buildRuleSeverityControls } from "./build-rule-severity-controls.js"; import { canOxlintExtendConfig } from "./can-oxlint-extend-config.js"; import { collectIgnorePatterns } from "./collect-ignore-patterns.js"; @@ -32,6 +32,7 @@ import { resolveUserPlugins } from "./runners/oxlint/plugin-resolution.js"; import { resolveOxlintToolchainVersions } from "./runners/oxlint/resolve-toolchain-versions.js"; import { resolveOxlintBinary, + resolveNativeOxlintBindingPath, resolvePluginPath, resolveTsConfigRelativePath, } from "./runners/oxlint/resolve-paths.js"; @@ -350,6 +351,7 @@ export const runOxlint = async (options: RunOxlintOptions): Promise; + nativeRuleIds?: ReadonlySet; } const resolveSettingsRootDirectory = (rootDirectory: string): string => { @@ -138,6 +143,7 @@ export const createOxlintConfig = ({ disableReactHooksJsPlugin = false, ruleSelection, sidecarRuleIdFilter, + nativeRuleIds = new Set(), }: OxlintConfigOptions) => { const hasIncludedTags = includedTags.size > 0; // The sidecar carries only cross-file react-doctor rules — the React @@ -177,6 +183,7 @@ export const createOxlintConfig = ({ noMultiCompOverride !== undefined && multiComponentFileOverride === undefined; const enabledReactDoctorRules: Record = {}; + let didEnableNativeReactDoctorRule = false; for (const registryEntry of REACT_DOCTOR_RULES) { const rule = REACT_DOCTOR_RULE_REGISTRY[registryEntry.id]; if (!rule) continue; @@ -241,7 +248,12 @@ export const createOxlintConfig = ({ resolveCompilerCleanupBucketSeverity(registryEntry.key, severityControls) ?? rule.severity; if (severity === "off") continue; - enabledReactDoctorRules[registryEntry.key] = severity; + if (nativeRuleIds.has(registryEntry.id)) { + enabledReactDoctorRules[`${NATIVE_REACT_DOCTOR_PLUGIN_NAME}/${registryEntry.id}`] = severity; + didEnableNativeReactDoctorRule = true; + } else { + enabledReactDoctorRules[registryEntry.key] = severity; + } } // Fold every user-declared plugin's enabled rules + add its @@ -278,7 +290,7 @@ export const createOxlintConfig = ({ // from our codegen-built registry plus configured npm-shipped // plugins (react-hooks-js for the React Compiler frontend etc.) // and any user-declared plugins from `config.plugins`. - plugins: [], + plugins: didEnableNativeReactDoctorRule ? [NATIVE_REACT_DOCTOR_PLUGIN_NAME] : [], jsPlugins: [...jsPlugins, pluginPath], settings: { "react-doctor": { diff --git a/packages/core/src/runners/oxlint/parse-output.ts b/packages/core/src/runners/oxlint/parse-output.ts index 20c8f62518..f2112bd191 100644 --- a/packages/core/src/runners/oxlint/parse-output.ts +++ b/packages/core/src/runners/oxlint/parse-output.ts @@ -243,7 +243,11 @@ const parseRuleCode = (code: unknown): { plugin: string; rule: string } => { } const match = code.match(/^(.+)\((.+)\)$/); if (!match) return { plugin: "unknown", rule: code }; - return { plugin: match[1].replace(/^eslint-plugin-/, ""), rule: match[2] }; + const parsedPlugin = match[1].replace(/^eslint-plugin-/, ""); + return { + plugin: parsedPlugin === "react-doctor-native" ? "react-doctor" : parsedPlugin, + rule: match[2], + }; }; const resolveDiagnosticCategory = (plugin: string, rule: string): string => { diff --git a/packages/core/src/runners/oxlint/resolve-paths.ts b/packages/core/src/runners/oxlint/resolve-paths.ts index 3d264e8dec..a673527747 100644 --- a/packages/core/src/runners/oxlint/resolve-paths.ts +++ b/packages/core/src/runners/oxlint/resolve-paths.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs"; import { createRequire } from "node:module"; import * as path from "node:path"; -import { TSCONFIG_FILENAMES } from "../../constants.js"; +import { REACT_DOCTOR_NATIVE_OXLINT_BINDING_ENV, TSCONFIG_FILENAMES } from "../../constants.js"; const esmRequire = createRequire(import.meta.url); @@ -18,6 +18,13 @@ export const resolveOxlintBinary = (): string => { // (node_modules/.pnpm/...), and from pnpm dlx / npx temp directories. export const resolvePluginPath = (): string => esmRequire.resolve("oxlint-plugin-react-doctor"); +export const resolveNativeOxlintBindingPath = ( + environment: NodeJS.ProcessEnv = process.env, +): string | null => { + const configuredPath = environment[REACT_DOCTOR_NATIVE_OXLINT_BINDING_ENV]?.trim(); + return configuredPath ? path.resolve(configuredPath) : null; +}; + export const resolveTsConfigRelativePath = (rootDirectory: string): string | null => { for (const filename of TSCONFIG_FILENAMES) { if (fs.existsSync(path.join(rootDirectory, filename))) { diff --git a/packages/core/src/runners/oxlint/resolve-toolchain-versions.ts b/packages/core/src/runners/oxlint/resolve-toolchain-versions.ts index 345e60e25c..8c2e47ece2 100644 --- a/packages/core/src/runners/oxlint/resolve-toolchain-versions.ts +++ b/packages/core/src/runners/oxlint/resolve-toolchain-versions.ts @@ -68,6 +68,7 @@ const resolvePluginFingerprint = (): string => { // marker rather than throwing, so the hash stays deterministic. export const resolveOxlintToolchainVersions = ( nodeBinaryPath: string = process.execPath, + nativeBindingPath?: string, ): ReadonlyArray => { const versions: string[] = [`node=${resolveChildNodeVersion(nodeBinaryPath)}`]; for (const specifier of TOOLCHAIN_PACKAGE_SPECIFIERS) { @@ -80,5 +81,17 @@ export const resolveOxlintToolchainVersions = ( } } versions.push(`oxlint-plugin-react-doctor#fingerprint=${resolvePluginFingerprint()}`); + let nativeBindingFingerprint = "stock"; + if (nativeBindingPath !== undefined) { + try { + nativeBindingFingerprint = fingerprintFileContents( + nativeBindingPath, + PLUGIN_FINGERPRINT_LENGTH_CHARS, + ); + } catch { + nativeBindingFingerprint = "unresolved"; + } + } + versions.push(`oxlint-native-binding#fingerprint=${nativeBindingFingerprint}`); return versions; }; diff --git a/packages/core/src/runners/oxlint/spawn-batches.ts b/packages/core/src/runners/oxlint/spawn-batches.ts index aec5893d87..bf8710f5e3 100644 --- a/packages/core/src/runners/oxlint/spawn-batches.ts +++ b/packages/core/src/runners/oxlint/spawn-batches.ts @@ -47,6 +47,7 @@ export interface SpawnLintBatchesInput { readonly fileBatches: ReadonlyArray; readonly rootDirectory: string; readonly nodeBinaryPath: string; + readonly nativeBindingPath?: string; readonly project: ProjectInfo; readonly sourcePathByLintPath?: ReadonlyMap; readonly sourceMapByLintPath?: ReadonlyMap; @@ -268,6 +269,7 @@ export const spawnLintBatches = async (input: SpawnLintBatchesInput): Promise void, + nativeBindingPath?: string, ): Promise => new Promise((resolve, reject) => { if (abortSignal?.aborted) { @@ -56,6 +58,9 @@ export const spawnOxlint = ( return; } onSpawn?.(); + const childEnvironment = nativeBindingPath + ? { ...SANITIZED_ENV, [OXLINT_NATIVE_LIBRARY_PATH_ENV]: nativeBindingPath } + : SANITIZED_ENV; const child = spawn( nodeBinaryPath, buildProfiledNodeArguments({ @@ -65,7 +70,7 @@ export const spawnOxlint = ( }), { cwd: rootDirectory, - env: SANITIZED_ENV, + env: childEnvironment, // HACK: oxlint's cli.js sets process.stdin._handle.setBlocking(true) // when stdout is not a TTY. This initializes and refs the child's stdin // handle, and since the parent never closes the pipe the child's event @@ -184,14 +189,14 @@ export const spawnOxlint = ( return; } } - const timingDirectory = SANITIZED_ENV.REACT_DOCTOR_OXLINT_TIMINGS_DIR; + const timingDirectory = childEnvironment.REACT_DOCTOR_OXLINT_TIMINGS_DIR; if (timingDirectory === undefined) { resolve(output); return; } captureOxlintRuleTimings({ argumentsList: args, - environment: SANITIZED_ENV, + environment: childEnvironment, nodeBinaryPath, rootDirectory, timingDirectory, diff --git a/packages/core/tests/oxlint-config-settings.test.ts b/packages/core/tests/oxlint-config-settings.test.ts index 5fd1d36c10..02e6fc3751 100644 --- a/packages/core/tests/oxlint-config-settings.test.ts +++ b/packages/core/tests/oxlint-config-settings.test.ts @@ -50,6 +50,25 @@ const tailwindViteWebProject = buildProject({ }); describe("createOxlintConfig settings", () => { + it("moves selected React Doctor rules into the native plugin", () => { + const stockConfig = createOxlintConfig({ + pluginPath: "/tmp/plugin.js", + project: viteWebProject, + }); + const nativeConfig = createOxlintConfig({ + pluginPath: "/tmp/plugin.js", + project: viteWebProject, + nativeRuleIds: new Set(["no-document-write"]), + }); + + expect(stockConfig.rules["react-doctor/no-document-write"]).toBe("warn"); + expect(stockConfig.plugins).toEqual([]); + expect(nativeConfig.rules).not.toHaveProperty("react-doctor/no-document-write"); + expect(nativeConfig.rules["react-doctor-native/no-document-write"]).toBe("warn"); + expect(nativeConfig.plugins).toEqual(["react-doctor-native"]); + expect(nativeConfig.jsPlugins).toContain("/tmp/plugin.js"); + }); + it("uses curated behavior for faithfully ported rules", () => { const config = createOxlintConfig({ pluginPath: "/tmp/plugin.js", diff --git a/packages/core/tests/oxlint-engine-diagnostic.test.ts b/packages/core/tests/oxlint-engine-diagnostic.test.ts index a0d997c4df..2b95eff9d1 100644 --- a/packages/core/tests/oxlint-engine-diagnostic.test.ts +++ b/packages/core/tests/oxlint-engine-diagnostic.test.ts @@ -181,4 +181,26 @@ describe("parseOxlintOutput engine diagnostics", () => { expect(diagnostics).toHaveLength(1); expect(diagnostics[0]).toMatchObject({ plugin: "TS", rule: "1039" }); }); + + it("normalizes native React Doctor diagnostics to the public plugin identity", () => { + const diagnostics = parseOxlintOutput( + buildOutput([ + { + ...HEALTHY_DIAGNOSTIC, + code: "react-doctor-native(no-document-write)", + message: "Native document write diagnostic", + }, + ]), + buildProject(), + TEST_ROOT_DIRECTORY, + ); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + plugin: "react-doctor", + rule: "no-document-write", + category: "Performance", + title: "document.write/writeln", + }); + }); }); diff --git a/packages/core/tests/resolve-native-oxlint-binding-path.test.ts b/packages/core/tests/resolve-native-oxlint-binding-path.test.ts new file mode 100644 index 0000000000..e9c79fe18a --- /dev/null +++ b/packages/core/tests/resolve-native-oxlint-binding-path.test.ts @@ -0,0 +1,20 @@ +import * as path from "node:path"; +import { describe, expect, it } from "vite-plus/test"; +import { resolveNativeOxlintBindingPath } from "../src/runners/oxlint/resolve-paths.js"; + +describe("resolveNativeOxlintBindingPath", () => { + it("keeps stock Oxlint unless an explicit binding path is configured", () => { + expect(resolveNativeOxlintBindingPath({})).toBeNull(); + expect(resolveNativeOxlintBindingPath({ REACT_DOCTOR_NATIVE_OXLINT_BINDING_PATH: " " })).toBe( + null, + ); + }); + + it("resolves a configured binding path from the current working directory", () => { + expect( + resolveNativeOxlintBindingPath({ + REACT_DOCTOR_NATIVE_OXLINT_BINDING_PATH: "./dist/oxlint.node", + }), + ).toBe(path.resolve("./dist/oxlint.node")); + }); +}); diff --git a/packages/core/tests/resolve-toolchain-versions.test.ts b/packages/core/tests/resolve-toolchain-versions.test.ts index 1eb5e201f9..f484c2fa7f 100644 --- a/packages/core/tests/resolve-toolchain-versions.test.ts +++ b/packages/core/tests/resolve-toolchain-versions.test.ts @@ -1,3 +1,6 @@ +import * as fs from "node:fs"; +import os from "node:os"; +import * as path from "node:path"; import { describe, expect, it } from "vite-plus/test"; import { resolveOxlintToolchainVersions } from "../src/runners/oxlint/resolve-toolchain-versions.js"; @@ -23,4 +26,19 @@ describe("resolveOxlintToolchainVersions", () => { versions.some((entry) => entry.startsWith("oxlint-plugin-react-doctor/package.json=")), ).toBe(true); }); + + it("fingerprints a custom native binding independently from stock Oxlint", () => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-binding-")); + const nativeBindingPath = path.join(temporaryDirectory, "oxlint.node"); + try { + fs.writeFileSync(nativeBindingPath, "native-binding"); + const stockVersions = resolveOxlintToolchainVersions(); + const nativeVersions = resolveOxlintToolchainVersions(process.execPath, nativeBindingPath); + + expect(stockVersions).toContain("oxlint-native-binding#fingerprint=stock"); + expect(nativeVersions).toContain("oxlint-native-binding#fingerprint=f90b6a879ed766d3"); + } finally { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + } + }); }); diff --git a/packages/core/tests/spawn-oxlint-compile-cache.test.ts b/packages/core/tests/spawn-oxlint-compile-cache.test.ts index cb3ca32490..9baad2b38d 100644 --- a/packages/core/tests/spawn-oxlint-compile-cache.test.ts +++ b/packages/core/tests/spawn-oxlint-compile-cache.test.ts @@ -1,7 +1,7 @@ import os from "node:os"; import * as path from "node:path"; import { describe, expect, it } from "vite-plus/test"; -import { NODE_COMPILE_CACHE_DIR_NAME } from "../src/constants.js"; +import { NODE_COMPILE_CACHE_DIR_NAME, OXLINT_NATIVE_LIBRARY_PATH_ENV } from "../src/constants.js"; import { spawnOxlint } from "../src/runners/oxlint/spawn-oxlint.js"; // spawn-oxlint.ts captures SANITIZED_ENV from the live process.env at module @@ -26,4 +26,21 @@ describe("spawnOxlint propagates the V8 compile cache to children", () => { expect(stdout).toBe(path.join(os.tmpdir(), NODE_COMPILE_CACHE_DIR_NAME)); }, ); + + it("passes an explicit native binding only to the child", async () => { + const nativeBindingPath = "/tmp/react-doctor-oxlint.node"; + const stdout = await spawnOxlint( + ["-e", `process.stdout.write(process.env.${OXLINT_NATIVE_LIBRARY_PATH_ENV} ?? 'unset')`], + process.cwd(), + process.execPath, + 5_000, + undefined, + undefined, + undefined, + nativeBindingPath, + ); + + expect(stdout).toBe(nativeBindingPath); + expect(process.env[OXLINT_NATIVE_LIBRARY_PATH_ENV]).not.toBe(nativeBindingPath); + }); }); diff --git a/packages/react-doctor/src/cli/utils/build-run-event.ts b/packages/react-doctor/src/cli/utils/build-run-event.ts index 650e147a60..5dbfda9778 100644 --- a/packages/react-doctor/src/cli/utils/build-run-event.ts +++ b/packages/react-doctor/src/cli/utils/build-run-event.ts @@ -15,6 +15,7 @@ import type { SuppressedRuleCount, } from "@react-doctor/core"; import { buildRuleBlastRadii } from "./diagnostic-grouping.js"; +import { REACT_DOCTOR_NATIVE_OXLINT_BINDING_ENV } from "./constants.js"; import { hasLintHardFailure } from "./has-lint-hard-failure.js"; import { isInspectResultComplete } from "./is-inspect-result-complete.js"; import { ACTION_INPUT_ENVIRONMENT_VARIABLES, detectRunnerOs } from "./is-ci-environment.js"; @@ -527,6 +528,11 @@ export const buildRunEventAttributes = ( toSpanAttributes({ ...buildScanAttributes(input), ...buildActionAttributes(), + ...withNamespace("lint", { + engine: process.env[REACT_DOCTOR_NATIVE_OXLINT_BINDING_ENV]?.trim() + ? "native-patch" + : "stock", + }), ...buildOutcomeAttributes(input), ...buildCacheAttributes(input), }); diff --git a/packages/react-doctor/src/cli/utils/constants.ts b/packages/react-doctor/src/cli/utils/constants.ts index b2aa358400..3eacff513d 100644 --- a/packages/react-doctor/src/cli/utils/constants.ts +++ b/packages/react-doctor/src/cli/utils/constants.ts @@ -19,6 +19,8 @@ export const NODE_ARGUMENT_COUNT = 2; // once-per-repo prompt decisions (CI pitch, action upgrade). export const REACT_DOCTOR_CONFIG_PROJECT_NAME = "react-doctor"; +export const REACT_DOCTOR_NATIVE_OXLINT_BINDING_ENV = "REACT_DOCTOR_NATIVE_OXLINT_BINDING_PATH"; + export const STAGED_FILES_TEMP_DIR_PREFIX = "react-doctor-staged-"; export const STAGED_SNAPSHOT_ADDITIONAL_CONFIG_FILENAMES = [ ".babelrc", diff --git a/packages/react-doctor/tests/build-run-event.test.ts b/packages/react-doctor/tests/build-run-event.test.ts index c1f879d3c3..476d75f335 100644 --- a/packages/react-doctor/tests/build-run-event.test.ts +++ b/packages/react-doctor/tests/build-run-event.test.ts @@ -13,6 +13,7 @@ const ENV_VARS = [ "RUNNER_OS", "REACT_DOCTOR_GITHUB_ACTION", "REACT_DOCTOR_NO_CACHE", + "REACT_DOCTOR_NATIVE_OXLINT_BINDING_PATH", ...Object.values(ACTION_INPUT_ENVIRONMENT_VARIABLES), ] as const; @@ -113,6 +114,12 @@ describe("buildRunEventAttributes", () => { } }); + it("records whether lint used stock Oxlint or a native source patch", () => { + expect(buildRunEventAttributes(baseInput())["lint.engine"]).toBe("stock"); + process.env.REACT_DOCTOR_NATIVE_OXLINT_BINDING_PATH = "/tmp/oxlint.node"; + expect(buildRunEventAttributes(baseInput())["lint.engine"]).toBe("native-patch"); + }); + it("records how many surfaced diagnostics span multiple lines", () => { const attributes = buildRunEventAttributes( baseInput({ diff --git a/scripts/native/build-oxlint-binding.mjs b/scripts/native/build-oxlint-binding.mjs new file mode 100644 index 0000000000..74e4af94c8 --- /dev/null +++ b/scripts/native/build-oxlint-binding.mjs @@ -0,0 +1,125 @@ +import { execFileSync } from "node:child_process"; +import crypto from "node:crypto"; +import * as fs from "node:fs"; +import { createRequire } from "node:module"; +import os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const requireFromScript = createRequire(import.meta.url); +const nativeDirectory = path.join(repositoryRoot, "native", "oxlint"); +const upstream = JSON.parse(fs.readFileSync(path.join(nativeDirectory, "upstream.json"), "utf8")); +const patchPath = path.join(nativeDirectory, "react-doctor.patch"); + +const argumentsList = process.argv.slice(2); +const readOption = (name) => { + const optionIndex = argumentsList.indexOf(name); + if (optionIndex === -1) return null; + const optionValue = argumentsList[optionIndex + 1]; + if (!optionValue || optionValue.startsWith("--")) throw new Error(`${name} requires a value`); + return optionValue; +}; + +const sourcePath = readOption("--source"); +const outputDirectory = path.resolve( + readOption("--output") ?? path.join(repositoryRoot, "dist", "native-oxlint"), +); +const shouldCheckOnly = argumentsList.includes("--check-only"); +const shouldUseAllocator = !argumentsList.includes("--no-allocator"); +const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-oxc-")); +const checkoutDirectory = path.join(temporaryDirectory, "oxc"); + +const run = (command, commandArguments, options = {}) => + execFileSync(command, commandArguments, { + cwd: options.cwd ?? repositoryRoot, + env: options.env ?? process.env, + stdio: "inherit", + }); + +try { + if (sourcePath) { + run("git", ["clone", "--no-checkout", path.resolve(sourcePath), checkoutDirectory]); + } else { + run("git", [ + "clone", + "--filter=blob:none", + "--no-checkout", + "--branch", + upstream.tag, + "--depth=1", + upstream.repository, + checkoutDirectory, + ]); + } + + run("git", ["checkout", "--detach", upstream.commit], { cwd: checkoutDirectory }); + const resolvedCommit = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: checkoutDirectory, + encoding: "utf8", + }).trim(); + if (resolvedCommit !== upstream.commit) { + throw new Error(`expected upstream commit ${upstream.commit}, received ${resolvedCommit}`); + } + + run("git", ["apply", "--check", patchPath], { cwd: checkoutDirectory }); + if (shouldCheckOnly) { + process.stdout.write(`Patch applies to ${upstream.tag} (${upstream.commit}).\n`); + process.exitCode = 0; + } else { + run("git", ["apply", patchPath], { cwd: checkoutDirectory }); + const targetDirectory = path.resolve( + process.env.CARGO_TARGET_DIR ?? path.join(temporaryDirectory, "target"), + ); + const cargoArguments = ["build", "--locked", "-p", "oxlint", "--release"]; + if (shouldUseAllocator) cargoArguments.push("--features", "allocator"); + run("cargo", cargoArguments, { + cwd: checkoutDirectory, + env: { ...process.env, CARGO_TARGET_DIR: targetDirectory }, + }); + + const libraryName = + process.platform === "win32" + ? "oxlint.dll" + : process.platform === "darwin" + ? "liboxlint.dylib" + : "liboxlint.so"; + const platformSuffix = + process.platform === "linux" + ? `${process.platform}-${process.arch}-gnu` + : `${process.platform}-${process.arch}`; + const bindingFileName = `oxlint-react-doctor.${platformSuffix}.node`; + const builtLibraryPath = path.join(targetDirectory, "release", libraryName); + const outputBindingPath = path.join(outputDirectory, bindingFileName); + fs.mkdirSync(outputDirectory, { recursive: true }); + fs.copyFileSync(builtLibraryPath, outputBindingPath); + const nativeBinding = requireFromScript(outputBindingPath); + if (typeof nativeBinding.lint !== "function") { + throw new Error(`built binding does not export lint: ${outputBindingPath}`); + } + + const sha256 = (filePath) => + crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); + fs.writeFileSync( + path.join(outputDirectory, `${bindingFileName}.json`), + `${JSON.stringify( + { + upstreamRepository: upstream.repository, + upstreamTag: upstream.tag, + upstreamCommit: upstream.commit, + oxlintVersion: upstream.oxlintVersion, + rustToolchain: upstream.rustToolchain, + nativeRules: upstream.nativeRules, + bindingFile: bindingFileName, + bindingSha256: sha256(outputBindingPath), + patchSha256: sha256(patchPath), + }, + null, + 2, + )}\n`, + ); + process.stdout.write(`Built ${outputBindingPath}\n`); + } +} finally { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); +} diff --git a/scripts/native/verify-oxlint-parity.mjs b/scripts/native/verify-oxlint-parity.mjs new file mode 100644 index 0000000000..12831b86ac --- /dev/null +++ b/scripts/native/verify-oxlint-parity.mjs @@ -0,0 +1,235 @@ +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import { createRequire } from "node:module"; +import os from "node:os"; +import * as path from "node:path"; +import { performance } from "node:perf_hooks"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const requireFromRepository = createRequire(path.join(repositoryRoot, "package.json")); +const argumentsList = process.argv.slice(2); +const readOption = (name) => { + const optionIndex = argumentsList.indexOf(name); + if (optionIndex === -1) return null; + const optionValue = argumentsList[optionIndex + 1]; + if (!optionValue || optionValue.startsWith("--")) throw new Error(`${name} requires a value`); + return optionValue; +}; +const bindingDirectory = readOption("--directory"); +const corpusDirectory = readOption("--corpus"); +const configuredBindingPath = + readOption("--binding") ?? process.env.REACT_DOCTOR_NATIVE_OXLINT_BINDING_PATH; +const nativeBindingCandidates = bindingDirectory + ? fs + .readdirSync(path.resolve(bindingDirectory)) + .filter((fileName) => fileName.endsWith(".node")) + .map((fileName) => path.join(path.resolve(bindingDirectory), fileName)) + : configuredBindingPath + ? [configuredBindingPath] + : []; +if (nativeBindingCandidates.length > 1) { + throw new Error(`expected one native binding, received ${nativeBindingCandidates.length}`); +} +const nativeBindingPath = nativeBindingCandidates[0]; +if (!nativeBindingPath) + throw new Error("pass --binding, --directory, or set the native binding env"); +if (!fs.existsSync(nativeBindingPath)) + throw new Error(`native binding not found: ${nativeBindingPath}`); + +const oxlintMainPath = requireFromRepository.resolve("oxlint"); +const oxlintBinaryPath = path.join( + path.resolve(path.dirname(oxlintMainPath), ".."), + "bin", + "oxlint", +); +const pluginPath = requireFromRepository.resolve("oxlint-plugin-react-doctor"); +const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-native-parity-")); +const fixturePath = path.join(temporaryDirectory, "fixture.ts"); +const stockConfigPath = path.join(temporaryDirectory, "stock.json"); +const nativeConfigPath = path.join(temporaryDirectory, "native.json"); +const EXPECTED_DIAGNOSTIC_COUNT = 8; +const BENCHMARK_FILE_COUNT = 100; +const BENCHMARK_CALL_COUNT_PER_FILE = 500; +const BENCHMARK_SAMPLE_COUNT = 5; +const DISABLED_RULE_CATEGORIES = { + correctness: "off", + nursery: "off", + pedantic: "off", + perf: "off", + restriction: "off", + style: "off", + suspicious: "off", +}; +const shouldBenchmark = argumentsList.includes("--benchmark"); +const fixture = ` +document.write("a"); +document.writeln("b"); +document["write"]("c"); +document[\`writeln\`]("d"); +document?.write("e"); +document!.write("f"); +(document as Document)["write"]("g"); +(document satisfies Document).writeln("h"); +document[method]("safe"); +stream.write("safe"); +{ const document = { write() {} }; document.write("safe"); } +`; + +const normalizeDiagnostics = (diagnostics) => + diagnostics + .filter( + (diagnostic) => + typeof diagnostic.code === "string" && diagnostic.code.includes("no-document-write"), + ) + .map((diagnostic) => ({ + code: diagnostic.code.replace("react-doctor-native", "react-doctor"), + filename: path.relative(repositoryRoot, path.resolve(repositoryRoot, diagnostic.filename)), + message: diagnostic.message, + severity: diagnostic.severity, + labels: diagnostic.labels, + })) + .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))); + +const runOxlint = (configPath, environment, targetPath = fixturePath) => { + const startedAt = performance.now(); + const result = spawnSync( + process.execPath, + [oxlintBinaryPath, "-c", configPath, "--format", "json", targetPath], + { cwd: repositoryRoot, env: environment, encoding: "utf8" }, + ); + if (result.error) throw result.error; + if (!result.stdout) { + throw new Error(result.stderr || `oxlint exited with status ${result.status}`); + } + const parsed = JSON.parse(result.stdout); + if (result.status !== 0 && result.status !== 1) { + throw new Error(result.stderr || `oxlint exited with status ${result.status}`); + } + return { + durationMs: performance.now() - startedAt, + diagnostics: normalizeDiagnostics(parsed.diagnostics), + }; +}; + +try { + fs.writeFileSync(fixturePath, fixture); + fs.writeFileSync( + stockConfigPath, + JSON.stringify({ + categories: DISABLED_RULE_CATEGORIES, + plugins: [], + jsPlugins: [pluginPath], + rules: { "react-doctor/no-document-write": "warn" }, + }), + ); + fs.writeFileSync( + nativeConfigPath, + JSON.stringify({ + categories: DISABLED_RULE_CATEGORIES, + plugins: ["react-doctor-native"], + jsPlugins: [pluginPath], + rules: { "react-doctor-native/no-document-write": "warn" }, + }), + ); + const stockDiagnostics = runOxlint(stockConfigPath, process.env).diagnostics; + const nativeEnvironment = { + ...process.env, + NAPI_RS_NATIVE_LIBRARY_PATH: path.resolve(nativeBindingPath), + }; + const nativeDiagnostics = runOxlint(nativeConfigPath, nativeEnvironment).diagnostics; + if (stockDiagnostics.length !== EXPECTED_DIAGNOSTIC_COUNT) { + throw new Error( + `expected ${EXPECTED_DIAGNOSTIC_COUNT} JavaScript diagnostics, received ${stockDiagnostics.length}`, + ); + } + if (JSON.stringify(nativeDiagnostics) !== JSON.stringify(stockDiagnostics)) { + throw new Error( + `native parity failed\nstock=${JSON.stringify(stockDiagnostics, null, 2)}\nnative=${JSON.stringify(nativeDiagnostics, null, 2)}`, + ); + } + process.stdout.write(`Native parity passed for ${stockDiagnostics.length} diagnostics.\n`); + + if (corpusDirectory) { + const resolvedCorpusDirectory = path.resolve(corpusDirectory); + const corpusRepositories = fs + .readdirSync(resolvedCorpusDirectory, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) + .map((entry) => entry.name) + .sort(); + if (corpusRepositories.length === 0) { + throw new Error(`no repositories found in corpus: ${resolvedCorpusDirectory}`); + } + let corpusDiagnosticCount = 0; + for (const repositoryName of corpusRepositories) { + const repositoryPath = path.join(resolvedCorpusDirectory, repositoryName); + const repositoryStockDiagnostics = runOxlint( + stockConfigPath, + process.env, + repositoryPath, + ).diagnostics; + const repositoryNativeDiagnostics = runOxlint( + nativeConfigPath, + nativeEnvironment, + repositoryPath, + ).diagnostics; + if ( + JSON.stringify(repositoryNativeDiagnostics) !== JSON.stringify(repositoryStockDiagnostics) + ) { + throw new Error( + `native corpus parity failed for ${repositoryName}\nstock=${JSON.stringify(repositoryStockDiagnostics, null, 2)}\nnative=${JSON.stringify(repositoryNativeDiagnostics, null, 2)}`, + ); + } + corpusDiagnosticCount += repositoryStockDiagnostics.length; + } + process.stdout.write( + `Native corpus parity passed for ${corpusRepositories.length} repositories and ${corpusDiagnosticCount} diagnostics.\n`, + ); + } + + if (shouldBenchmark) { + const benchmarkDirectory = path.join(temporaryDirectory, "benchmark"); + fs.mkdirSync(benchmarkDirectory); + const benchmarkSource = `${Array.from( + { length: BENCHMARK_CALL_COUNT_PER_FILE }, + (_unused, index) => `stream.write(value${index});`, + ).join("\n")}\n`; + for (let fileIndex = 0; fileIndex < BENCHMARK_FILE_COUNT; fileIndex += 1) { + fs.writeFileSync(path.join(benchmarkDirectory, `fixture-${fileIndex}.ts`), benchmarkSource); + } + runOxlint(stockConfigPath, process.env, benchmarkDirectory); + runOxlint(nativeConfigPath, nativeEnvironment, benchmarkDirectory); + const stockDurationsMs = []; + const nativeDurationsMs = []; + for (let sampleIndex = 0; sampleIndex < BENCHMARK_SAMPLE_COUNT; sampleIndex += 1) { + const shouldRunNativeFirst = sampleIndex % 2 === 1; + if (shouldRunNativeFirst) { + nativeDurationsMs.push( + runOxlint(nativeConfigPath, nativeEnvironment, benchmarkDirectory).durationMs, + ); + stockDurationsMs.push( + runOxlint(stockConfigPath, process.env, benchmarkDirectory).durationMs, + ); + } else { + stockDurationsMs.push( + runOxlint(stockConfigPath, process.env, benchmarkDirectory).durationMs, + ); + nativeDurationsMs.push( + runOxlint(nativeConfigPath, nativeEnvironment, benchmarkDirectory).durationMs, + ); + } + } + const median = (values) => { + const sortedValues = [...values].sort((left, right) => left - right); + return sortedValues[Math.floor(sortedValues.length / 2)]; + }; + const stockMedianMs = median(stockDurationsMs); + const nativeMedianMs = median(nativeDurationsMs); + const speedupPercent = ((stockMedianMs - nativeMedianMs) / stockMedianMs) * 100; + process.stdout.write( + `Benchmark p50: JavaScript ${stockMedianMs.toFixed(1)} ms, native ${nativeMedianMs.toFixed(1)} ms, ${speedupPercent.toFixed(1)}% faster.\n`, + ); + } +} finally { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); +} From be8e2add3e9bf3baa10fb7dd76aa5a940023a5ec Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 21 Aug 2026 12:16:23 +0000 Subject: [PATCH 002/423] perf(native): port 16 rules to Rust --- native/oxlint/README.md | 5 +- native/oxlint/react-doctor.patch | 403 ++---------------- native/oxlint/rules/for-each-named-import.rs | 15 + native/oxlint/rules/for-each-value-import.rs | 20 + native/oxlint/rules/is-non-production-file.rs | 200 +++++++++ native/oxlint/rules/is-type-only-import.rs | 16 + native/oxlint/rules/jsx-no-duplicate-props.rs | 47 ++ .../rules/nextjs-no-vercel-og-import.rs | 34 ++ native/oxlint/rules/no-children-prop.rs | 55 +++ native/oxlint/rules/no-danger.rs | 66 +++ native/oxlint/rules/no-document-write.rs | 56 +++ native/oxlint/rules/no-moment.rs | 34 ++ native/oxlint/rules/no-namespace.rs | 58 +++ native/oxlint/rules/no-react-children.rs | 57 +++ .../rules/preact-no-react-hooks-import.rs | 67 +++ .../rules/rn-bottom-sheet-prefer-native.rs | 52 +++ .../oxlint/rules/rn-no-deprecated-modules.rs | 63 +++ .../rules/rn-no-legacy-expo-packages.rs | 54 +++ native/oxlint/rules/rn-no-panresponder.rs | 39 ++ native/oxlint/rules/rn-prefer-pressable.rs | 54 +++ native/oxlint/rules/rn-prefer-reanimated.rs | 45 ++ native/oxlint/rules/use-lazy-motion.rs | 46 ++ native/oxlint/upstream.json | 19 +- package.json | 1 + packages/core/src/constants.ts | 19 +- scripts/native/build-oxlint-binding.mjs | 156 ++++--- scripts/native/verify-oxlint-parity.mjs | 102 ++++- 27 files changed, 1342 insertions(+), 441 deletions(-) create mode 100644 native/oxlint/rules/for-each-named-import.rs create mode 100644 native/oxlint/rules/for-each-value-import.rs create mode 100644 native/oxlint/rules/is-non-production-file.rs create mode 100644 native/oxlint/rules/is-type-only-import.rs create mode 100644 native/oxlint/rules/jsx-no-duplicate-props.rs create mode 100644 native/oxlint/rules/nextjs-no-vercel-og-import.rs create mode 100644 native/oxlint/rules/no-children-prop.rs create mode 100644 native/oxlint/rules/no-danger.rs create mode 100644 native/oxlint/rules/no-document-write.rs create mode 100644 native/oxlint/rules/no-moment.rs create mode 100644 native/oxlint/rules/no-namespace.rs create mode 100644 native/oxlint/rules/no-react-children.rs create mode 100644 native/oxlint/rules/preact-no-react-hooks-import.rs create mode 100644 native/oxlint/rules/rn-bottom-sheet-prefer-native.rs create mode 100644 native/oxlint/rules/rn-no-deprecated-modules.rs create mode 100644 native/oxlint/rules/rn-no-legacy-expo-packages.rs create mode 100644 native/oxlint/rules/rn-no-panresponder.rs create mode 100644 native/oxlint/rules/rn-prefer-pressable.rs create mode 100644 native/oxlint/rules/rn-prefer-reanimated.rs create mode 100644 native/oxlint/rules/use-lazy-motion.rs diff --git a/native/oxlint/README.md b/native/oxlint/README.md index 62da005740..a34bb82647 100644 --- a/native/oxlint/README.md +++ b/native/oxlint/README.md @@ -2,19 +2,20 @@ 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 first native rule is `no-document-write`. React Doctor enables it 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 the JavaScript rule exactly as before. +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/.node nr native:oxlint:parity REACT_DOCTOR_NATIVE_OXLINT_BINDING_PATH=dist/native-oxlint/.node nr native:oxlint:parity --benchmark REACT_DOCTOR_NATIVE_OXLINT_BINDING_PATH=dist/native-oxlint/.node nr native:oxlint:parity --corpus packages/fuzz/tmp/corpus-repos ``` -`native:oxlint:verify` clones the pinned tag, checks its commit, and proves the patch still applies. `native:oxlint:build` applies the patch in a temporary checkout, compiles and loads the N-API binding, and writes the binding plus provenance and SHA-256 hashes to `dist/native-oxlint`. +`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. diff --git a/native/oxlint/react-doctor.patch b/native/oxlint/react-doctor.patch index a99e1a971c..3e4afe7dab 100644 --- a/native/oxlint/react-doctor.patch +++ b/native/oxlint/react-doctor.patch @@ -1,20 +1,3 @@ -From a3405e0ffde271279ce4cc5927fa649a9ae009c9 Mon Sep 17 00:00:00 2001 -From: React Doctor -Date: Fri, 21 Aug 2026 05:21:16 +0000 -Subject: [PATCH] feat(linter): add React Doctor native rules - ---- - crates/oxc_linter/src/config/plugins.rs | 6 ++ - .../src/generated/rule_runner_impls.rs | 6 ++ - crates/oxc_linter/src/generated/rules_enum.rs | 35 ++++++++- - crates/oxc_linter/src/rules.rs | 4 + - .../react_doctor_native/no_document_write.rs | 76 +++++++++++++++++++ - ...react_doctor_native_no_document_write.snap | 51 +++++++++++++ - .../track_linter_timings/linter_timings.snap | 1 + - 7 files changed, 177 insertions(+), 2 deletions(-) - create mode 100644 crates/oxc_linter/src/rules/react_doctor_native/no_document_write.rs - create mode 100644 crates/oxc_linter/src/snapshots/react_doctor_native_no_document_write.snap - 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 @@ -60,375 +43,37 @@ index e4e346c..6ecbfc1 100644 } let enum_schema = r#gen.subschema_for::(); -diff --git a/crates/oxc_linter/src/generated/rule_runner_impls.rs b/crates/oxc_linter/src/generated/rule_runner_impls.rs -index 03d08e7..dc14108 100644 ---- a/crates/oxc_linter/src/generated/rule_runner_impls.rs -+++ b/crates/oxc_linter/src/generated/rule_runner_impls.rs -@@ -8,6 +8,12 @@ use oxc_semantic::AstTypesBitset; - - use crate::rule::{RuleRunFunctionsImplemented, RuleRunner}; - -+impl RuleRunner for crate::rules::react_doctor_native::no_document_write::NoDocumentWrite { -+ const NODE_TYPES: Option<&AstTypesBitset> = -+ Some(&AstTypesBitset::from_types(&[AstType::CallExpression])); -+ const RUN_FUNCTIONS: RuleRunFunctionsImplemented = RuleRunFunctionsImplemented::Run; -+} -+ - impl RuleRunner - for crate::rules::import::consistent_type_specifier_style::ConsistentTypeSpecifierStyle - { -diff --git a/crates/oxc_linter/src/generated/rules_enum.rs b/crates/oxc_linter/src/generated/rules_enum.rs -index c05302a..e538ca1 100644 ---- a/crates/oxc_linter/src/generated/rules_enum.rs -+++ b/crates/oxc_linter/src/generated/rules_enum.rs -@@ -509,6 +509,7 @@ pub use crate::rules::react::unsupported_syntax::UnsupportedSyntax as ReactUnsup - pub use crate::rules::react::use_memo::UseMemo as ReactUseMemo; - pub use crate::rules::react::void_dom_elements_no_children::VoidDomElementsNoChildren as ReactVoidDomElementsNoChildren; - pub use crate::rules::react::void_use_memo::VoidUseMemo as ReactVoidUseMemo; -+pub use crate::rules::react_doctor_native::no_document_write::NoDocumentWrite as ReactDoctorNativeNoDocumentWrite; - pub use crate::rules::react_perf::jsx_no_jsx_as_prop::JsxNoJsxAsProp as ReactPerfJsxNoJsxAsProp; - pub use crate::rules::react_perf::jsx_no_new_array_as_prop::JsxNoNewArrayAsProp as ReactPerfJsxNoNewArrayAsProp; - pub use crate::rules::react_perf::jsx_no_new_function_as_prop::JsxNoNewFunctionAsProp as ReactPerfJsxNoNewFunctionAsProp; -@@ -890,6 +891,7 @@ use crate::{ - use oxc_semantic::AstTypesBitset; - #[derive(Debug, Clone)] - pub enum RuleEnum { -+ ReactDoctorNativeNoDocumentWrite(ReactDoctorNativeNoDocumentWrite), - ImportConsistentTypeSpecifierStyle(ImportConsistentTypeSpecifierStyle), - ImportDefault(ImportDefault), - ImportExport(ImportExport), -@@ -1769,7 +1771,9 @@ pub enum RuleEnum { - VueValidDefineProps(VueValidDefineProps), - VueValidNextTick(VueValidNextTick), - } --const IMPORT_CONSISTENT_TYPE_SPECIFIER_STYLE_ID: usize = 0usize; -+const REACT_DOCTOR_NATIVE_NO_DOCUMENT_WRITE_ID: usize = 0usize; -+const IMPORT_CONSISTENT_TYPE_SPECIFIER_STYLE_ID: usize = -+ REACT_DOCTOR_NATIVE_NO_DOCUMENT_WRITE_ID + 1usize; - const IMPORT_DEFAULT_ID: usize = IMPORT_CONSISTENT_TYPE_SPECIFIER_STYLE_ID + 1usize; - const IMPORT_EXPORT_ID: usize = IMPORT_DEFAULT_ID + 1usize; - const IMPORT_EXPORTS_LAST_ID: usize = IMPORT_EXPORT_ID + 1usize; -@@ -2748,7 +2752,8 @@ const VUE_VALID_DEFINE_EMITS_ID: usize = VUE_RETURN_IN_EMITS_VALIDATOR_ID + 1usi - const VUE_VALID_DEFINE_OPTIONS_ID: usize = VUE_VALID_DEFINE_EMITS_ID + 1usize; - const VUE_VALID_DEFINE_PROPS_ID: usize = VUE_VALID_DEFINE_OPTIONS_ID + 1usize; - const VUE_VALID_NEXT_TICK_ID: usize = VUE_VALID_DEFINE_PROPS_ID + 1usize; --static RULE_NAMES: [&str; 870usize] = [ -+static RULE_NAMES: [&str; 871usize] = [ -+ ReactDoctorNativeNoDocumentWrite::NAME, - ImportConsistentTypeSpecifierStyle::NAME, - ImportDefault::NAME, - ImportExport::NAME, -@@ -3623,6 +3628,7 @@ static RULE_NAMES: [&str; 870usize] = [ - impl RuleEnum { - pub fn id(&self) -> usize { - match self { -+ Self::ReactDoctorNativeNoDocumentWrite(_) => REACT_DOCTOR_NATIVE_NO_DOCUMENT_WRITE_ID, - Self::ImportConsistentTypeSpecifierStyle(_) => { - IMPORT_CONSISTENT_TYPE_SPECIFIER_STYLE_ID - } -@@ -4630,6 +4636,7 @@ impl RuleEnum { - } - pub fn category(&self) -> RuleCategory { - match self { -+ Self::ReactDoctorNativeNoDocumentWrite(_) => ReactDoctorNativeNoDocumentWrite::CATEGORY, - Self::ImportConsistentTypeSpecifierStyle(_) => { - ImportConsistentTypeSpecifierStyle::CATEGORY - } -@@ -5679,6 +5686,7 @@ impl RuleEnum { - #[doc = r" This [`Rule`]'s auto-fix capabilities."] - pub fn fix(&self) -> RuleFixMeta { - match self { -+ Self::ReactDoctorNativeNoDocumentWrite(_) => ReactDoctorNativeNoDocumentWrite::FIX, - Self::ImportConsistentTypeSpecifierStyle(_) => ImportConsistentTypeSpecifierStyle::FIX, - Self::ImportDefault(_) => ImportDefault::FIX, - Self::ImportExport(_) => ImportExport::FIX, -@@ -6668,6 +6676,9 @@ impl RuleEnum { - #[cfg(feature = "ruledocs")] - pub fn documentation(&self) -> Option<&'static str> { - match self { -+ Self::ReactDoctorNativeNoDocumentWrite(_) => { -+ ReactDoctorNativeNoDocumentWrite::documentation() -+ } - Self::ImportConsistentTypeSpecifierStyle(_) => { - ImportConsistentTypeSpecifierStyle::documentation() - } -@@ -7934,6 +7945,10 @@ impl RuleEnum { - generator: &mut schemars::SchemaGenerator, - ) -> Option { - match self { -+ Self::ReactDoctorNativeNoDocumentWrite(_) => { -+ ReactDoctorNativeNoDocumentWrite::config_schema(generator) -+ .or_else(|| ReactDoctorNativeNoDocumentWrite::schema(generator)) -+ } - Self::ImportConsistentTypeSpecifierStyle(_) => { - ImportConsistentTypeSpecifierStyle::config_schema(generator) - .or_else(|| ImportConsistentTypeSpecifierStyle::schema(generator)) -@@ -10429,6 +10444,7 @@ impl RuleEnum { - } - pub fn plugin_name(&self) -> &'static str { - match self { -+ Self::ReactDoctorNativeNoDocumentWrite(_) => "react_doctor_native", - Self::ImportConsistentTypeSpecifierStyle(_) => "import", - Self::ImportDefault(_) => "import", - Self::ImportExport(_) => "import", -@@ -12434,6 +12450,7 @@ impl RuleEnum { - #[inline(never)] - fn run_dispatch<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { - match self { -+ Self::ReactDoctorNativeNoDocumentWrite(rule) => rule.run(node, ctx), - Self::ImportConsistentTypeSpecifierStyle(rule) => rule.run(node, ctx), - Self::ImportDefault(rule) => rule.run(node, ctx), - Self::ImportExport(rule) => rule.run(node, ctx), -@@ -13321,6 +13338,7 @@ impl RuleEnum { - #[inline(never)] - fn run_once_dispatch(&self, ctx: &LintContext<'_>) { - match self { -+ Self::ReactDoctorNativeNoDocumentWrite(rule) => rule.run_once(ctx), - Self::ImportConsistentTypeSpecifierStyle(rule) => rule.run_once(ctx), - Self::ImportDefault(rule) => rule.run_once(ctx), - Self::ImportExport(rule) => rule.run_once(ctx), -@@ -14211,6 +14229,7 @@ impl RuleEnum { - ctx: &'c LintContext<'a>, - ) { - match self { -+ Self::ReactDoctorNativeNoDocumentWrite(rule) => rule.run_on_jest_node(jest_node, ctx), - Self::ImportConsistentTypeSpecifierStyle(rule) => rule.run_on_jest_node(jest_node, ctx), - Self::ImportDefault(rule) => rule.run_on_jest_node(jest_node, ctx), - Self::ImportExport(rule) => rule.run_on_jest_node(jest_node, ctx), -@@ -15213,6 +15232,7 @@ impl RuleEnum { - } - pub(crate) fn should_run(&self, ctx: &ContextHost) -> bool { - match self { -+ Self::ReactDoctorNativeNoDocumentWrite(rule) => rule.should_run(ctx), - Self::ImportConsistentTypeSpecifierStyle(rule) => rule.should_run(ctx), - Self::ImportDefault(rule) => rule.should_run(ctx), - Self::ImportExport(rule) => rule.should_run(ctx), -@@ -16087,6 +16107,9 @@ impl RuleEnum { - } - pub fn is_tsgolint_rule(&self) -> bool { - match self { -+ Self::ReactDoctorNativeNoDocumentWrite(_) => { -+ ReactDoctorNativeNoDocumentWrite::IS_TSGOLINT_RULE -+ } - Self::ImportConsistentTypeSpecifierStyle(_) => { - ImportConsistentTypeSpecifierStyle::IS_TSGOLINT_RULE - } -@@ -17351,6 +17374,7 @@ impl RuleEnum { - #[cfg(feature = "ruledocs")] - pub fn version(&self) -> &'static str { - match self { -+ Self::ReactDoctorNativeNoDocumentWrite(_) => ReactDoctorNativeNoDocumentWrite::VERSION, - Self::ImportConsistentTypeSpecifierStyle(_) => { - ImportConsistentTypeSpecifierStyle::VERSION - } -@@ -18400,6 +18424,9 @@ impl RuleEnum { - #[doc = r" Whether this rule declares a configuration type."] - pub fn has_config(&self) -> bool { - match self { -+ Self::ReactDoctorNativeNoDocumentWrite(_) => { -+ ReactDoctorNativeNoDocumentWrite::HAS_CONFIG -+ } - Self::ImportConsistentTypeSpecifierStyle(_) => { - ImportConsistentTypeSpecifierStyle::HAS_CONFIG - } -@@ -19490,6 +19517,7 @@ impl RuleEnum { - #[cfg(feature = "ruledocs")] - pub fn info(&self) -> RuleInfo { - match self { -+ Self::ReactDoctorNativeNoDocumentWrite(_) => ReactDoctorNativeNoDocumentWrite::INFO, - Self::ImportConsistentTypeSpecifierStyle(_) => ImportConsistentTypeSpecifierStyle::INFO, - Self::ImportDefault(_) => ImportDefault::INFO, - Self::ImportExport(_) => ImportExport::INFO, -@@ -20483,6 +20511,7 @@ impl RuleEnum { - } - pub fn types_info(&self) -> Option<&'static AstTypesBitset> { - match self { -+ Self::ReactDoctorNativeNoDocumentWrite(rule) => rule.types_info(), - Self::ImportConsistentTypeSpecifierStyle(rule) => rule.types_info(), - Self::ImportDefault(rule) => rule.types_info(), - Self::ImportExport(rule) => rule.types_info(), -@@ -21357,6 +21386,7 @@ impl RuleEnum { - } - pub fn run_info(&self) -> RuleRunFunctionsImplemented { - match self { -+ Self::ReactDoctorNativeNoDocumentWrite(rule) => rule.run_info(), - Self::ImportConsistentTypeSpecifierStyle(rule) => rule.run_info(), - Self::ImportDefault(rule) => rule.run_info(), - Self::ImportExport(rule) => rule.run_info(), -@@ -22253,6 +22283,7 @@ impl PartialOrd for RuleEnum { - } - pub static RULES: std::sync::LazyLock> = std::sync::LazyLock::new(|| { - vec![ -+ RuleEnum::ReactDoctorNativeNoDocumentWrite(ReactDoctorNativeNoDocumentWrite::default()), - RuleEnum::ImportConsistentTypeSpecifierStyle(ImportConsistentTypeSpecifierStyle::default()), - RuleEnum::ImportDefault(ImportDefault::default()), - RuleEnum::ImportExport(ImportExport::default()), +diff --git a/crates/oxc_linter/src/context/mod.rs b/crates/oxc_linter/src/context/mod.rs +index 152f919..544fbc7 100644 +--- a/crates/oxc_linter/src/context/mod.rs ++++ b/crates/oxc_linter/src/context/mod.rs +@@ -264,7 +264,15 @@ 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) { ++ 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/rules.rs b/crates/oxc_linter/src/rules.rs -index baa13c5..6a83da3 100644 +index baa13c5..7c21b9d 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs -@@ -3,6 +3,10 @@ +@@ -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 { -+ pub mod no_document_write; -+} ++pub(crate) mod react_doctor_native; + /// pub(crate) mod import { pub mod consistent_type_specifier_style; -diff --git a/crates/oxc_linter/src/rules/react_doctor_native/no_document_write.rs b/crates/oxc_linter/src/rules/react_doctor_native/no_document_write.rs -new file mode 100644 -index 0000000..2eb5f69 ---- /dev/null -+++ b/crates/oxc_linter/src/rules/react_doctor_native/no_document_write.rs -@@ -0,0 +1,76 @@ -+use oxc_ast::{AstKind, ast::Expression}; -+use oxc_diagnostics::OxcDiagnostic; -+use oxc_macros::declare_oxc_lint; -+use oxc_span::Span; -+ -+use crate::{AstNode, context::LintContext, rule::Rule}; -+ -+const MESSAGE: &str = "`document.write()` blocks parsing, is ignored (or wipes the page) after load, and is flagged by browsers as a performance anti-pattern. Build DOM nodes or set `innerHTML`/`textContent` on a target element instead."; -+ -+fn no_document_write_diagnostic(span: Span) -> OxcDiagnostic { -+ OxcDiagnostic::warn(MESSAGE).with_label(span) -+} -+ -+#[derive(Debug, Default, Clone)] -+pub struct NoDocumentWrite; -+ -+declare_oxc_lint!( -+ /// Disallow `document.write()` and `document.writeln()`. -+ NoDocumentWrite, -+ react_doctor_native, -+ perf, -+ version = "0.1.0", -+ short_description = "Disallow document.write and document.writeln.", -+); -+ -+impl Rule for NoDocumentWrite { -+ fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { -+ let AstKind::CallExpression(call_expression) = node.kind() else { -+ return; -+ }; -+ let Some(member_expression) = -+ call_expression.callee.get_inner_expression().get_member_expr() -+ else { -+ return; -+ }; -+ if !matches!(member_expression.static_property_name(), Some("write" | "writeln")) { -+ return; -+ } -+ let Expression::Identifier(document_identifier) = -+ member_expression.object().get_inner_expression() -+ else { -+ return; -+ }; -+ if document_identifier.name != "document" -+ || !ctx.is_reference_to_global_variable(document_identifier) -+ { -+ return; -+ } -+ ctx.diagnostic(no_document_write_diagnostic(call_expression.span)); -+ } -+} -+ -+#[test] -+fn test() { -+ use crate::tester::Tester; -+ -+ let pass = vec![ -+ "document.createElement('div');", -+ "stream.write('chunk');", -+ "document[method]('x');", -+ "const document = { write() {} }; document.write('x');", -+ "const document = { writeln() {} }; document?.writeln('x');", -+ ]; -+ let fail = vec![ -+ "document.write('

hi

');", -+ "document.writeln('x');", -+ "document['write']('x');", -+ "document[`writeln`]('x');", -+ "document?.write('x');", -+ "document!.write('x');", -+ "(document as Document)['write']('x');", -+ "(document satisfies Document).writeln('x');", -+ ]; -+ -+ Tester::new(NoDocumentWrite::NAME, NoDocumentWrite::PLUGIN, pass, fail).test_and_snapshot(); -+} -diff --git a/crates/oxc_linter/src/snapshots/react_doctor_native_no_document_write.snap b/crates/oxc_linter/src/snapshots/react_doctor_native_no_document_write.snap -new file mode 100644 -index 0000000..52ac2a5 ---- /dev/null -+++ b/crates/oxc_linter/src/snapshots/react_doctor_native_no_document_write.snap -@@ -0,0 +1,51 @@ -+--- -+source: crates/oxc_linter/src/tester.rs -+--- -+ -+ ⚠ react-doctor-native(no-document-write): `document.write()` blocks parsing, is ignored (or wipes the page) after load, and is flagged by browsers as a performance anti-pattern. Build DOM nodes or set `innerHTML`/`textContent` on a target element instead. -+ ╭─[no_document_write.tsx:1:1] -+ 1 │ document.write('

hi

'); -+ · ─────────────────────────── -+ ╰──── -+ -+ ⚠ react-doctor-native(no-document-write): `document.write()` blocks parsing, is ignored (or wipes the page) after load, and is flagged by browsers as a performance anti-pattern. Build DOM nodes or set `innerHTML`/`textContent` on a target element instead. -+ ╭─[no_document_write.tsx:1:1] -+ 1 │ document.writeln('x'); -+ · ───────────────────── -+ ╰──── -+ -+ ⚠ react-doctor-native(no-document-write): `document.write()` blocks parsing, is ignored (or wipes the page) after load, and is flagged by browsers as a performance anti-pattern. Build DOM nodes or set `innerHTML`/`textContent` on a target element instead. -+ ╭─[no_document_write.tsx:1:1] -+ 1 │ document['write']('x'); -+ · ────────────────────── -+ ╰──── -+ -+ ⚠ react-doctor-native(no-document-write): `document.write()` blocks parsing, is ignored (or wipes the page) after load, and is flagged by browsers as a performance anti-pattern. Build DOM nodes or set `innerHTML`/`textContent` on a target element instead. -+ ╭─[no_document_write.tsx:1:1] -+ 1 │ document[`writeln`]('x'); -+ · ──────────────────────── -+ ╰──── -+ -+ ⚠ react-doctor-native(no-document-write): `document.write()` blocks parsing, is ignored (or wipes the page) after load, and is flagged by browsers as a performance anti-pattern. Build DOM nodes or set `innerHTML`/`textContent` on a target element instead. -+ ╭─[no_document_write.tsx:1:1] -+ 1 │ document?.write('x'); -+ · ──────────────────── -+ ╰──── -+ -+ ⚠ react-doctor-native(no-document-write): `document.write()` blocks parsing, is ignored (or wipes the page) after load, and is flagged by browsers as a performance anti-pattern. Build DOM nodes or set `innerHTML`/`textContent` on a target element instead. -+ ╭─[no_document_write.tsx:1:1] -+ 1 │ document!.write('x'); -+ · ──────────────────── -+ ╰──── -+ -+ ⚠ react-doctor-native(no-document-write): `document.write()` blocks parsing, is ignored (or wipes the page) after load, and is flagged by browsers as a performance anti-pattern. Build DOM nodes or set `innerHTML`/`textContent` on a target element instead. -+ ╭─[no_document_write.tsx:1:1] -+ 1 │ (document as Document)['write']('x'); -+ · ──────────────────────────────────── -+ ╰──── -+ -+ ⚠ react-doctor-native(no-document-write): `document.write()` blocks parsing, is ignored (or wipes the page) after load, and is flagged by browsers as a performance anti-pattern. Build DOM nodes or set `innerHTML`/`textContent` on a target element instead. -+ ╭─[no_document_write.tsx:1:1] -+ 1 │ (document satisfies Document).writeln('x'); -+ · ────────────────────────────────────────── -+ ╰──── -diff --git a/tasks/track_linter_timings/linter_timings.snap b/tasks/track_linter_timings/linter_timings.snap -index f6ed38d..f3782dd 100644 ---- a/tasks/track_linter_timings/linter_timings.snap -+++ b/tasks/track_linter_timings/linter_timings.snap -@@ -492,6 +492,7 @@ react/unsupported-syntax | 7 - react/use-memo | 7 - react/void-dom-elements-no-children | 7019 - react/void-use-memo | 7 -+react_doctor_native/no-document-write | 62606 - react_perf/jsx-no-jsx-as-prop | 804 - react_perf/jsx-no-new-array-as-prop | 804 - react_perf/jsx-no-new-function-as-prop | 804 --- -2.47.3 - diff --git a/native/oxlint/rules/for-each-named-import.rs b/native/oxlint/rules/for-each-named-import.rs new file mode 100644 index 0000000000..c71483fb76 --- /dev/null +++ b/native/oxlint/rules/for-each-named-import.rs @@ -0,0 +1,15 @@ +pub fn for_each_named_import( + import_declaration: &oxc_ast::ast::ImportDeclaration<'_>, + mut visit: impl FnMut(&oxc_ast::ast::ImportSpecifier<'_>), +) { + let Some(specifiers) = &import_declaration.specifiers else { + return; + }; + for specifier in specifiers { + if let oxc_ast::ast::ImportDeclarationSpecifier::ImportSpecifier(import_specifier) = + specifier + { + visit(import_specifier); + } + } +} diff --git a/native/oxlint/rules/for-each-value-import.rs b/native/oxlint/rules/for-each-value-import.rs new file mode 100644 index 0000000000..4b4e9ec1c0 --- /dev/null +++ b/native/oxlint/rules/for-each-value-import.rs @@ -0,0 +1,20 @@ +pub fn for_each_value_import( + import_declaration: &oxc_ast::ast::ImportDeclaration<'_>, + mut visit: impl FnMut(&oxc_ast::ast::ImportSpecifier<'_>), +) { + if import_declaration.import_kind.is_type() { + return; + } + let Some(specifiers) = &import_declaration.specifiers else { + return; + }; + for specifier in specifiers { + let oxc_ast::ast::ImportDeclarationSpecifier::ImportSpecifier(import_specifier) = specifier + else { + continue; + }; + if import_specifier.import_kind.is_value() { + visit(import_specifier); + } + } +} diff --git a/native/oxlint/rules/is-non-production-file.rs b/native/oxlint/rules/is-non-production-file.rs new file mode 100644 index 0000000000..2f776eceaf --- /dev/null +++ b/native/oxlint/rules/is-non-production-file.rs @@ -0,0 +1,200 @@ +const NON_PRODUCTION_PATH_SEGMENTS: &[&str] = &[ + "/test/", + "/tests/", + "/testing/", + "/__tests__/", + "/__test__/", + "/__fixtures__/", + "/fixtures/", + "/__mocks__/", + "/mocks/", + "/testUtils/", + "/test-utils/", + "/test-stubs/", + "/testutils/", + "/cypress/", + "/playwright/", + "/.storybook/", + "/.dumi/", + "/stories/", + "/__stories__/", + "/playground/", + "/playgrounds/", + "/examples/", + "/example/", + "/demo/", + "/demos/", + "/sandbox/", + "/sandboxes/", + "/e2e/", + "/e2e-tests/", + "/specs/", + "/spec/", + "/integration-tests/", + "/integration/", + "/it/", + "/benchmarks/", + "/benchmark/", + "/__benchmarks__/", + "/perf/", + "/perf-tests/", + "/scripts/", + "/cli/", + "/bin/", + "/tooling/", + "/tools/", + "/codemods/", + "/codemod/", + "/migrations/", + "/migration/", + "/generators/", + "/generator/", + "/runbooks/", + "/devtools/", + "/internal-tools/", + "/seeds/", + "/seed/", + "/dev-seeder/", +]; + +const NON_PRODUCTION_FILENAME_SUFFIXES: &[&str] = &[ + ".test.", + ".spec.", + ".cy.", + ".stories.", + ".story.", + ".bench.", + ".benchmark.", + ".e2e.", + ".integration-spec.", + ".int-spec.", + ".mock.", + ".mocks.", + ".fixture.", +]; + +const NON_PRODUCTION_BASENAMES: &[&str] = &[ + "setuptests.js", + "setuptests.ts", + "setuptests.jsx", + "setuptests.tsx", + "setupvitest.js", + "setupvitest.ts", + "setupvitest.jsx", + "setupvitest.tsx", + "setupjest.js", + "setupjest.ts", + "vitest.setup.js", + "vitest.setup.ts", + "vitest.setup.mjs", + "vitest.config.ts", + "vitest.config.js", + "vitest.config.mts", + "vitest.config.mjs", + "jest.setup.js", + "jest.setup.ts", + "jest.setup.jsx", + "jest.setup.tsx", + "jest.config.js", + "jest.config.ts", + "jest.config.mjs", + "playwright.config.ts", + "playwright.config.js", + "cypress.config.ts", + "cypress.config.js", + "karma.conf.js", + "karma.conf.ts", + "vite.config.ts", + "vite.config.js", + "vite.config.mts", + "vite.config.mjs", + "webpack.config.ts", + "webpack.config.js", + "webpack.config.mjs", + "rollup.config.ts", + "rollup.config.js", + "rollup.config.mjs", + "esbuild.config.ts", + "esbuild.config.js", + "esbuild.config.mjs", + "tsup.config.ts", + "tsup.config.js", + "tsup.config.mjs", + "rsbuild.config.ts", + "rsbuild.config.js", + "rspack.config.ts", + "rspack.config.js", + "next.config.ts", + "next.config.js", + "next.config.mjs", + "remix.config.js", + "remix.config.ts", + "astro.config.ts", + "astro.config.js", + "astro.config.mjs", + "tailwind.config.ts", + "tailwind.config.js", + "tailwind.config.mjs", + "postcss.config.ts", + "postcss.config.js", + "postcss.config.mjs", + "biome.config.ts", + "biome.config.js", + "drizzle.config.ts", + "drizzle.config.js", + "prisma.config.ts", + "prisma.config.js", + "knip.config.ts", + "knip.config.js", + "knip.config.mjs", + "lint-staged.config.js", + "lint-staged.config.mjs", +]; + +const SOURCE_ROOT_SEGMENTS: &[&str] = &[ + "/src/", + "/app/", + "/lib/", + "/components/", + "/pages/", + "/features/", + "/modules/", + "/packages/", + "/apps/", + "/frontend/", + "/client/", +]; + +fn is_non_production_file(ctx: &crate::context::ContextHost) -> bool { + let filename = ctx.file_path().to_string_lossy().replace('\\', "/"); + let basename = filename.rsplit('/').next().unwrap_or(filename.as_ref()); + let lowercase_basename = basename.to_lowercase(); + if NON_PRODUCTION_BASENAMES.contains(&lowercase_basename.as_str()) + || NON_PRODUCTION_FILENAME_SUFFIXES + .iter() + .any(|suffix| basename.contains(suffix)) + { + return true; + } + if ["/.storybook/", "/.dumi/"] + .iter() + .any(|segment| filename.contains(segment)) + { + return true; + } + let scoped_filename = SOURCE_ROOT_SEGMENTS + .iter() + .filter_map(|segment| filename.rfind(segment)) + .max() + .map_or(filename.as_ref(), |source_root_index| { + &filename[source_root_index..] + }); + NON_PRODUCTION_PATH_SEGMENTS.iter().any(|segment| { + let haystack = if segment.starts_with("/.") { + filename.as_ref() + } else { + scoped_filename + }; + haystack.contains(segment) + }) +} diff --git a/native/oxlint/rules/is-type-only-import.rs b/native/oxlint/rules/is-type-only-import.rs new file mode 100644 index 0000000000..ab7d849bec --- /dev/null +++ b/native/oxlint/rules/is-type-only-import.rs @@ -0,0 +1,16 @@ +pub fn is_type_only_import(import_declaration: &oxc_ast::ast::ImportDeclaration<'_>) -> bool { + if import_declaration.import_kind.is_type() { + return true; + } + let Some(specifiers) = &import_declaration.specifiers else { + return false; + }; + !specifiers.is_empty() + && specifiers.iter().all(|specifier| { + matches!( + specifier, + oxc_ast::ast::ImportDeclarationSpecifier::ImportSpecifier(import_specifier) + if import_specifier.import_kind.is_type() + ) + }) +} diff --git a/native/oxlint/rules/jsx-no-duplicate-props.rs b/native/oxlint/rules/jsx-no-duplicate-props.rs new file mode 100644 index 0000000000..4e248c0257 --- /dev/null +++ b/native/oxlint/rules/jsx-no-duplicate-props.rs @@ -0,0 +1,47 @@ +use oxc_ast::{ + AstKind, + ast::{JSXAttributeItem, JSXAttributeName}, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use rustc_hash::FxHashSet; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +#[derive(Debug, Default, Clone)] +pub struct JsxNoDuplicateProps; + +declare_oxc_lint!( + /// Disallow duplicate JSX props. + JsxNoDuplicateProps, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow duplicate JSX props.", +); + +impl Rule for JsxNoDuplicateProps { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXOpeningElement(opening_element) = node.kind() else { + return; + }; + let mut seen_prop_names = FxHashSet::default(); + for attribute in &opening_element.attributes { + let JSXAttributeItem::Attribute(attribute) = attribute else { + continue; + }; + let JSXAttributeName::Identifier(identifier) = &attribute.name else { + continue; + }; + if !seen_prop_names.insert(identifier.name) { + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "Your users can get the wrong value because React keeps only the last \"{}\" & drops the first.", + identifier.name + )) + .with_label(attribute.span), + ); + } + } + } +} diff --git a/native/oxlint/rules/nextjs-no-vercel-og-import.rs b/native/oxlint/rules/nextjs-no-vercel-og-import.rs new file mode 100644 index 0000000000..351f298b0d --- /dev/null +++ b/native/oxlint/rules/nextjs-no-vercel-og-import.rs @@ -0,0 +1,34 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +const MESSAGE: &str = "@vercel/og is bundled into Next.js. Import from \"next/og\" instead to avoid duplicate code and version mismatch."; + +#[derive(Debug, Default, Clone)] +pub struct NextjsNoVercelOgImport; + +declare_oxc_lint!( + /// Prefer the Next.js entry point for Vercel OG. + NextjsNoVercelOgImport, + react_doctor_native, + perf, + version = "0.1.0", + short_description = "Prefer next/og over @vercel/og.", +); + +impl Rule for NextjsNoVercelOgImport { + fn should_run(&self, ctx: &crate::context::ContextHost) -> bool { + !is_non_production_file(ctx) + } + + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::ImportDeclaration(import_declaration) = node.kind() else { + return; + }; + if import_declaration.source.value == "@vercel/og" { + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(import_declaration.span)); + } + } +} diff --git a/native/oxlint/rules/no-children-prop.rs b/native/oxlint/rules/no-children-prop.rs new file mode 100644 index 0000000000..1588f5f6e8 --- /dev/null +++ b/native/oxlint/rules/no-children-prop.rs @@ -0,0 +1,55 @@ +use oxc_ast::{ + AstKind, + ast::{Argument, JSXAttributeName, ObjectPropertyKind}, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::GetSpan; + +use crate::{AstNode, context::LintContext, rule::Rule, utils::is_create_element_call}; + +const MESSAGE: &str = "A `children` prop can override or hide nested children, so the component may render different content than the JSX shows."; + +#[derive(Debug, Default, Clone)] +pub struct NoChildrenProp; + +declare_oxc_lint!( + /// Disallow passing React children through a prop. + NoChildrenProp, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow passing React children through a prop.", +); + +impl Rule for NoChildrenProp { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + match node.kind() { + AstKind::JSXAttribute(attribute) => { + let JSXAttributeName::Identifier(identifier) = &attribute.name else { + return; + }; + if identifier.name == "children" { + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(identifier.span)); + } + } + AstKind::CallExpression(call_expression) if is_create_element_call(call_expression) => { + let Some(Argument::ObjectExpression(properties)) = call_expression.arguments.get(1) + else { + return; + }; + for property in &properties.properties { + let ObjectPropertyKind::ObjectProperty(property) = property else { + continue; + }; + if property.key.is_specific_static_name("children") { + ctx.diagnostic( + OxcDiagnostic::warn(MESSAGE).with_label(property.key.span()), + ); + } + } + } + _ => {} + } + } +} diff --git a/native/oxlint/rules/no-danger.rs b/native/oxlint/rules/no-danger.rs new file mode 100644 index 0000000000..6c4b1365cb --- /dev/null +++ b/native/oxlint/rules/no-danger.rs @@ -0,0 +1,66 @@ +use oxc_ast::{ + AstKind, + ast::{Argument, JSXAttributeItem, ObjectPropertyKind}, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::GetSpan; + +use crate::{AstNode, context::LintContext, rule::Rule, utils::is_create_element_call}; + +const MESSAGE: &str = "`dangerouslySetInnerHTML` is an XSS hole that runs attacker-controlled HTML in your users' browsers."; + +#[derive(Debug, Default, Clone)] +pub struct NoDanger; + +declare_oxc_lint!( + /// Disallow raw HTML injection through React props. + NoDanger, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow raw HTML injection through React props.", +); + +impl Rule for NoDanger { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + match node.kind() { + AstKind::JSXOpeningElement(opening_element) => { + for attribute in &opening_element.attributes { + let JSXAttributeItem::Attribute(attribute) = attribute else { + continue; + }; + if attribute + .name + .as_identifier() + .is_some_and(|identifier| identifier.name == "dangerouslySetInnerHTML") + { + ctx.diagnostic( + OxcDiagnostic::warn(MESSAGE).with_label(attribute.name.span()), + ); + } + } + } + AstKind::CallExpression(call_expression) if is_create_element_call(call_expression) => { + let Some(Argument::ObjectExpression(properties)) = call_expression.arguments.get(1) + else { + return; + }; + for property in &properties.properties { + let ObjectPropertyKind::ObjectProperty(property) = property else { + continue; + }; + if property + .key + .is_specific_static_name("dangerouslySetInnerHTML") + { + ctx.diagnostic( + OxcDiagnostic::warn(MESSAGE).with_label(property.key.span()), + ); + } + } + } + _ => {} + } + } +} diff --git a/native/oxlint/rules/no-document-write.rs b/native/oxlint/rules/no-document-write.rs new file mode 100644 index 0000000000..383c6364bf --- /dev/null +++ b/native/oxlint/rules/no-document-write.rs @@ -0,0 +1,56 @@ +use oxc_ast::{AstKind, ast::Expression}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::Span; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +const MESSAGE: &str = "`document.write()` blocks parsing, is ignored (or wipes the page) after load, and is flagged by browsers as a performance anti-pattern. Build DOM nodes or set `innerHTML`/`textContent` on a target element instead."; + +fn no_document_write_diagnostic(span: Span) -> OxcDiagnostic { + OxcDiagnostic::warn(MESSAGE).with_label(span) +} + +#[derive(Debug, Default, Clone)] +pub struct NoDocumentWrite; + +declare_oxc_lint!( + /// Disallow `document.write()` and `document.writeln()`. + NoDocumentWrite, + react_doctor_native, + perf, + version = "0.1.0", + short_description = "Disallow document.write and document.writeln.", +); + +impl Rule for NoDocumentWrite { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::CallExpression(call_expression) = node.kind() else { + return; + }; + let Some(member_expression) = call_expression + .callee + .get_inner_expression() + .get_member_expr() + else { + return; + }; + if !matches!( + member_expression.static_property_name(), + Some("write" | "writeln") + ) { + return; + } + let Expression::Identifier(document_identifier) = + member_expression.object().get_inner_expression() + else { + return; + }; + if document_identifier.name != "document" + || !ctx.is_reference_to_global_variable(document_identifier) + { + return; + } + ctx.diagnostic(no_document_write_diagnostic(call_expression.span)); + } +} diff --git a/native/oxlint/rules/no-moment.rs b/native/oxlint/rules/no-moment.rs new file mode 100644 index 0000000000..083bc0b0a6 --- /dev/null +++ b/native/oxlint/rules/no-moment.rs @@ -0,0 +1,34 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +const MESSAGE: &str = "moment.js ships 300 kb+ to your users & slows page load. Use \"date-fns\" or \"dayjs\" instead."; + +#[derive(Debug, Default, Clone)] +pub struct NoMoment; + +declare_oxc_lint!( + /// Disallow runtime imports of moment.js. + NoMoment, + react_doctor_native, + perf, + version = "0.1.0", + short_description = "Disallow runtime imports of moment.js.", +); + +impl Rule for NoMoment { + fn should_run(&self, ctx: &crate::context::ContextHost) -> bool { + !is_non_production_file(ctx) + } + + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::ImportDeclaration(import_declaration) = node.kind() else { + return; + }; + if import_declaration.source.value == "moment" && !is_type_only_import(import_declaration) { + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(import_declaration.span)); + } + } +} diff --git a/native/oxlint/rules/no-namespace.rs b/native/oxlint/rules/no-namespace.rs new file mode 100644 index 0000000000..f37c021391 --- /dev/null +++ b/native/oxlint/rules/no-namespace.rs @@ -0,0 +1,58 @@ +use oxc_ast::{ + AstKind, + ast::{Argument, JSXElementName}, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::GetSpan; + +use crate::{AstNode, context::LintContext, rule::Rule, utils::is_create_element_call}; + +#[derive(Debug, Default, Clone)] +pub struct NoNamespace; + +declare_oxc_lint!( + /// Disallow namespaced React element names. + NoNamespace, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow namespaced React element names.", +); + +impl Rule for NoNamespace { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let (component_name, span) = match node.kind() { + AstKind::JSXOpeningElement(opening_element) => { + let JSXElementName::NamespacedName(namespaced_name) = &opening_element.name else { + return; + }; + ( + format!( + "{}:{}", + namespaced_name.namespace.name, namespaced_name.name.name + ), + namespaced_name.span, + ) + } + AstKind::CallExpression(call_expression) if is_create_element_call(call_expression) => { + let Some(Argument::StringLiteral(component_name)) = + call_expression.arguments.first() + else { + return; + }; + if !component_name.value.contains(':') { + return; + } + (component_name.value.to_string(), component_name.span()) + } + _ => return, + }; + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "React can't render namespaced names like `{component_name}`." + )) + .with_label(span), + ); + } +} diff --git a/native/oxlint/rules/no-react-children.rs b/native/oxlint/rules/no-react-children.rs new file mode 100644 index 0000000000..26e5d8f404 --- /dev/null +++ b/native/oxlint/rules/no-react-children.rs @@ -0,0 +1,57 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::GetSpan; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +const MESSAGE: &str = "`React.Children` traversal depends on the runtime child shape, so wrapping or unwrapping a child can silently change what gets visited."; + +#[derive(Debug, Default, Clone)] +pub struct NoReactChildren; + +declare_oxc_lint!( + /// Disallow traversal through React.Children. + NoReactChildren, + react_doctor_native, + restriction, + version = "0.1.0", + short_description = "Disallow traversal through React.Children.", +); + +impl Rule for NoReactChildren { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::CallExpression(call_expression) = node.kind() else { + return; + }; + let Some(member_expression) = call_expression.callee.get_member_expr() else { + return; + }; + let object = member_expression.object().get_inner_expression(); + + if let Some(identifier) = object.get_identifier_reference() + && identifier.name == "Children" + && is_imported_from_react(identifier.name.as_str(), ctx) + { + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(member_expression.span())); + return; + } + + if let Some(inner_member) = object.as_member_expression() + && inner_member.static_property_name() == Some("Children") + && let Some(identifier) = inner_member + .object() + .get_inner_expression() + .get_identifier_reference() + && is_imported_from_react(identifier.name.as_str(), ctx) + { + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(member_expression.span())); + } + } +} + +fn is_imported_from_react(local_name: &str, ctx: &LintContext) -> bool { + ctx.module_record().import_entries.iter().any(|entry| { + entry.module_request.name() == "react" && entry.local_name.name() == local_name + }) +} diff --git a/native/oxlint/rules/preact-no-react-hooks-import.rs b/native/oxlint/rules/preact-no-react-hooks-import.rs new file mode 100644 index 0000000000..13affb01d5 --- /dev/null +++ b/native/oxlint/rules/preact-no-react-hooks-import.rs @@ -0,0 +1,67 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +const REACT_HOOK_NAMES: [&str; 15] = [ + "useCallback", + "useContext", + "useDebugValue", + "useDeferredValue", + "useEffect", + "useId", + "useImperativeHandle", + "useInsertionEffect", + "useLayoutEffect", + "useMemo", + "useReducer", + "useRef", + "useState", + "useSyncExternalStore", + "useTransition", +]; + +#[derive(Debug, Default, Clone)] +pub struct PreactNoReactHooksImport; + +declare_oxc_lint!( + /// Disallow React hook imports in pure Preact projects. + PreactNoReactHooksImport, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow React hook imports in pure Preact projects.", +); + +impl Rule for PreactNoReactHooksImport { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::ImportDeclaration(import_declaration) = node.kind() else { + return; + }; + if import_declaration.source.value != "react" { + return; + } + let mut imported_names = Vec::new(); + for_each_named_import(import_declaration, |import_specifier| { + let imported_name = import_specifier.imported.name(); + if REACT_HOOK_NAMES.contains(&imported_name.as_str()) { + imported_names.push(imported_name.to_string()); + } + }); + if imported_names.is_empty() { + return; + } + let formatted_names = imported_names + .iter() + .map(|imported_name| format!("`{imported_name}`")) + .collect::>() + .join(", "); + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "Your users hit `__H` undefined errors because importing {formatted_names} from `react` in a pure-Preact project loads a second copy of the hook state, so import from `preact/hooks` (or `preact/compat`) instead." + )) + .with_label(import_declaration.span), + ); + } +} diff --git a/native/oxlint/rules/rn-bottom-sheet-prefer-native.rs b/native/oxlint/rules/rn-bottom-sheet-prefer-native.rs new file mode 100644 index 0000000000..eecad06e04 --- /dev/null +++ b/native/oxlint/rules/rn-bottom-sheet-prefer-native.rs @@ -0,0 +1,52 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +const JS_BOTTOM_SHEET_PACKAGES: [&str; 7] = [ + "react-native-bottom-sheet", + "react-native-modal-bottom-sheet", + "react-native-raw-bottom-sheet", + "react-native-modalize", + "react-native-actions-sheet", + "react-native-bottomsheet-reanimated", + "@discord/bottom-sheet", +]; + +#[derive(Debug, Default, Clone)] +pub struct RnBottomSheetPreferNative; + +declare_oxc_lint!( + /// Prefer native bottom-sheet presentation where it fits the design. + RnBottomSheetPreferNative, + react_doctor_native, + perf, + version = "0.1.0", + short_description = "Prefer native bottom-sheet presentation.", +); + +impl Rule for RnBottomSheetPreferNative { + fn should_run(&self, ctx: &crate::context::ContextHost) -> bool { + !is_non_production_file(ctx) + } + + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::ImportDeclaration(import_declaration) = node.kind() else { + return; + }; + if is_type_only_import(import_declaration) { + return; + } + let source = import_declaration.source.value.as_str(); + if !JS_BOTTOM_SHEET_PACKAGES.contains(&source) { + return; + } + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "Users get JS-driven sheet gestures and presentation with {source}, instead of the platform-native formSheet behavior." + )) + .with_label(import_declaration.span), + ); + } +} diff --git a/native/oxlint/rules/rn-no-deprecated-modules.rs b/native/oxlint/rules/rn-no-deprecated-modules.rs new file mode 100644 index 0000000000..deac016daa --- /dev/null +++ b/native/oxlint/rules/rn-no-deprecated-modules.rs @@ -0,0 +1,63 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +const DEPRECATED_MODULES: [&str; 16] = [ + "AsyncStorage", + "Picker", + "PickerIOS", + "DatePickerIOS", + "DatePickerAndroid", + "ProgressBarAndroid", + "ProgressViewIOS", + "SafeAreaView", + "Slider", + "ViewPagerAndroid", + "WebView", + "NetInfo", + "CameraRoll", + "Clipboard", + "ImageEditor", + "MaskedViewIOS", +]; + +#[derive(Debug, Default, Clone)] +pub struct RnNoDeprecatedModules; + +declare_oxc_lint!( + /// Disallow modules removed from React Native core. + RnNoDeprecatedModules, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow modules removed from React Native core.", +); + +impl Rule for RnNoDeprecatedModules { + fn should_run(&self, ctx: &crate::context::ContextHost) -> bool { + !is_non_production_file(ctx) + } + + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::ImportDeclaration(import_declaration) = node.kind() else { + return; + }; + if import_declaration.source.value != "react-native" { + return; + } + for_each_value_import(import_declaration, |import_specifier| { + let imported_name = import_specifier.imported.name(); + if !DEPRECATED_MODULES.contains(&imported_name.as_str()) { + return; + } + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "Your users hit a crash from \"{imported_name}\", which was removed from react-native." + )) + .with_label(import_specifier.span), + ); + }); + } +} diff --git a/native/oxlint/rules/rn-no-legacy-expo-packages.rs b/native/oxlint/rules/rn-no-legacy-expo-packages.rs new file mode 100644 index 0000000000..1ebf105508 --- /dev/null +++ b/native/oxlint/rules/rn-no-legacy-expo-packages.rs @@ -0,0 +1,54 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +const LEGACY_EXPO_PACKAGES: [&str; 4] = [ + "expo-av", + "expo-permissions", + "expo-app-loading", + "react-native-fast-image", +]; + +#[derive(Debug, Default, Clone)] +pub struct RnNoLegacyExpoPackages; + +declare_oxc_lint!( + /// Disallow unmaintained legacy Expo packages. + RnNoLegacyExpoPackages, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow unmaintained legacy Expo packages.", +); + +impl Rule for RnNoLegacyExpoPackages { + fn should_run(&self, ctx: &crate::context::ContextHost) -> bool { + !is_non_production_file(ctx) + } + + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::ImportDeclaration(import_declaration) = node.kind() else { + return; + }; + if is_type_only_import(import_declaration) { + return; + } + let source = import_declaration.source.value.as_str(); + let Some(package_name) = LEGACY_EXPO_PACKAGES.iter().find(|package_name| { + source == **package_name + || source + .strip_prefix(**package_name) + .is_some_and(|suffix| suffix.starts_with('/')) + }) else { + return; + }; + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "Your users are exposed to unfixed bugs when \"{package_name}\" is no longer maintained." + )) + .with_label(import_declaration.span), + ); + } +} diff --git a/native/oxlint/rules/rn-no-panresponder.rs b/native/oxlint/rules/rn-no-panresponder.rs new file mode 100644 index 0000000000..b716e868de --- /dev/null +++ b/native/oxlint/rules/rn-no-panresponder.rs @@ -0,0 +1,39 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +const MESSAGE: &str = "PanResponder runs gesture handling on the JS thread, which stutters under load. Use react-native-gesture-handler (`Gesture.Pan()`) so gestures run on the native UI thread."; + +#[derive(Debug, Default, Clone)] +pub struct RnNoPanresponder; + +declare_oxc_lint!( + /// Prefer native-thread gesture handling over PanResponder. + RnNoPanresponder, + react_doctor_native, + perf, + version = "0.1.0", + short_description = "Prefer native-thread gesture handling over PanResponder.", +); + +impl Rule for RnNoPanresponder { + fn should_run(&self, ctx: &crate::context::ContextHost) -> bool { + !is_non_production_file(ctx) + } + + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::ImportDeclaration(import_declaration) = node.kind() else { + return; + }; + if import_declaration.source.value != "react-native" { + return; + } + for_each_value_import(import_declaration, |import_specifier| { + if import_specifier.imported.name() == "PanResponder" { + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(import_specifier.span)); + } + }); + } +} diff --git a/native/oxlint/rules/rn-prefer-pressable.rs b/native/oxlint/rules/rn-prefer-pressable.rs new file mode 100644 index 0000000000..d58834716d --- /dev/null +++ b/native/oxlint/rules/rn-prefer-pressable.rs @@ -0,0 +1,54 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +const TOUCHABLE_COMPONENTS: [&str; 4] = [ + "TouchableOpacity", + "TouchableHighlight", + "TouchableWithoutFeedback", + "TouchableNativeFeedback", +]; + +#[derive(Debug, Default, Clone)] +pub struct RnPreferPressable; + +declare_oxc_lint!( + /// Prefer Pressable over frozen Touchable components. + RnPreferPressable, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Prefer Pressable over frozen Touchable components.", +); + +impl Rule for RnPreferPressable { + fn should_run(&self, ctx: &crate::context::ContextHost) -> bool { + !is_non_production_file(ctx) + } + + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::ImportDeclaration(import_declaration) = node.kind() else { + return; + }; + if !matches!( + import_declaration.source.value.as_str(), + "react-native" | "react-native-gesture-handler" + ) { + return; + } + for_each_value_import(import_declaration, |import_specifier| { + let imported_name = import_specifier.imported.name(); + if !TOUCHABLE_COMPONENTS.contains(&imported_name.as_str()) { + return; + } + ctx.diagnostic( + OxcDiagnostic::warn(format!( + "Your users miss 's flexible press feedback when you use {imported_name}, which is old & frozen." + )) + .with_label(import_specifier.span), + ); + }); + } +} diff --git a/native/oxlint/rules/rn-prefer-reanimated.rs b/native/oxlint/rules/rn-prefer-reanimated.rs new file mode 100644 index 0000000000..b29ec53967 --- /dev/null +++ b/native/oxlint/rules/rn-prefer-reanimated.rs @@ -0,0 +1,45 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +#[derive(Debug, Default, Clone)] +pub struct RnPreferReanimated; + +declare_oxc_lint!( + /// Prefer Reanimated over React Native JS-thread animation APIs. + RnPreferReanimated, + react_doctor_native, + perf, + version = "0.1.0", + short_description = "Prefer Reanimated over JS-thread animation APIs.", +); + +impl Rule for RnPreferReanimated { + fn should_run(&self, ctx: &crate::context::ContextHost) -> bool { + !is_non_production_file(ctx) + } + + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::ImportDeclaration(import_declaration) = node.kind() else { + return; + }; + if import_declaration.source.value != "react-native" { + return; + } + for_each_value_import(import_declaration, |import_specifier| { + let imported_name = import_specifier.imported.name(); + let message = match imported_name.as_str() { + "LayoutAnimation" => { + "Your users see stutter when LayoutAnimation runs on the JS thread." + } + "Animated" => { + "Your users see stutter when Animated from react-native runs on the JS thread." + } + _ => return, + }; + ctx.diagnostic(OxcDiagnostic::warn(message).with_label(import_specifier.span)); + }); + } +} diff --git a/native/oxlint/rules/use-lazy-motion.rs b/native/oxlint/rules/use-lazy-motion.rs new file mode 100644 index 0000000000..4deb216d55 --- /dev/null +++ b/native/oxlint/rules/use-lazy-motion.rs @@ -0,0 +1,46 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +const MESSAGE: &str = "Importing \"motion\" ships about 30 kb of extra code and slows page load. Use \"m\" with LazyMotion instead."; + +#[derive(Debug, Default, Clone)] +pub struct UseLazyMotion; + +declare_oxc_lint!( + /// Prefer LazyMotion over the full Motion component bundle. + UseLazyMotion, + react_doctor_native, + perf, + version = "0.1.0", + short_description = "Prefer LazyMotion over the full Motion component bundle.", +); + +impl Rule for UseLazyMotion { + fn should_run(&self, ctx: &crate::context::ContextHost) -> bool { + !is_non_production_file(ctx) + } + + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::ImportDeclaration(import_declaration) = node.kind() else { + return; + }; + if !matches!( + import_declaration.source.value.as_str(), + "framer-motion" | "motion/react" + ) { + return; + } + let mut has_full_motion_import = false; + for_each_value_import(import_declaration, |import_specifier| { + if import_specifier.imported.name() == "motion" { + has_full_motion_import = true; + } + }); + if has_full_motion_import { + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(import_declaration.span)); + } + } +} diff --git a/native/oxlint/upstream.json b/native/oxlint/upstream.json index b3bf10c31b..096cebfa41 100644 --- a/native/oxlint/upstream.json +++ b/native/oxlint/upstream.json @@ -4,5 +4,22 @@ "commit": "0db127cc16d28b97d84bac4ebeb302caf1a78c7e", "oxlintVersion": "1.79.0", "rustToolchain": "1.97.1", - "nativeRules": ["no-document-write"] + "nativeRules": [ + "jsx-no-duplicate-props", + "nextjs-no-vercel-og-import", + "no-children-prop", + "no-danger", + "no-document-write", + "no-moment", + "no-namespace", + "no-react-children", + "preact-no-react-hooks-import", + "rn-bottom-sheet-prefer-native", + "rn-no-deprecated-modules", + "rn-no-legacy-expo-packages", + "rn-no-panresponder", + "rn-prefer-pressable", + "rn-prefer-reanimated", + "use-lazy-motion" + ] } diff --git a/package.json b/package.json index c20f6cb60f..91339068de 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "performance:memory": "tsx scripts/performance/analyze-heap-profile.ts", "performance:rules": "tsx scripts/performance/analyze-oxlint-timings.ts", "native:oxlint:verify": "node scripts/native/build-oxlint-binding.mjs --check-only", + "native:oxlint:check": "node scripts/native/build-oxlint-binding.mjs --compile-check", "native:oxlint:build": "node scripts/native/build-oxlint-binding.mjs", "native:oxlint:parity": "node scripts/native/verify-oxlint-parity.mjs", "test": "turbo run test --filter=react-doctor --filter=@react-doctor/core --filter=@react-doctor/api --filter=oxlint-plugin-react-doctor --filter=eslint-plugin-react-doctor --filter=@react-doctor/fuzz --filter=@react-doctor/evals", diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 4e3ee57dc0..35a4b8efb9 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -562,7 +562,24 @@ export const OXLINT_NATIVE_LIBRARY_PATH_ENV = "NAPI_RS_NATIVE_LIBRARY_PATH"; export const NATIVE_REACT_DOCTOR_PLUGIN_NAME = "react-doctor-native"; -export const NATIVE_REACT_DOCTOR_RULE_IDS: ReadonlySet = new Set(["no-document-write"]); +export const NATIVE_REACT_DOCTOR_RULE_IDS: ReadonlySet = new Set([ + "jsx-no-duplicate-props", + "nextjs-no-vercel-og-import", + "no-children-prop", + "no-danger", + "no-document-write", + "no-moment", + "no-namespace", + "no-react-children", + "preact-no-react-hooks-import", + "rn-bottom-sheet-prefer-native", + "rn-no-deprecated-modules", + "rn-no-legacy-expo-packages", + "rn-no-panresponder", + "rn-prefer-pressable", + "rn-prefer-reanimated", + "use-lazy-motion", +]); // HACK: per-batch wall-clock budget for an oxlint spawn. Each batch // is at most OXLINT_MAX_FILES_PER_BATCH (= 200) files and a healthy diff --git a/scripts/native/build-oxlint-binding.mjs b/scripts/native/build-oxlint-binding.mjs index 74e4af94c8..258dbe081a 100644 --- a/scripts/native/build-oxlint-binding.mjs +++ b/scripts/native/build-oxlint-binding.mjs @@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url"; const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const requireFromScript = createRequire(import.meta.url); const nativeDirectory = path.join(repositoryRoot, "native", "oxlint"); +const nativeRulesDirectory = path.join(nativeDirectory, "rules"); const upstream = JSON.parse(fs.readFileSync(path.join(nativeDirectory, "upstream.json"), "utf8")); const patchPath = path.join(nativeDirectory, "react-doctor.patch"); @@ -26,6 +27,7 @@ const outputDirectory = path.resolve( readOption("--output") ?? path.join(repositoryRoot, "dist", "native-oxlint"), ); const shouldCheckOnly = argumentsList.includes("--check-only"); +const shouldCompileCheck = argumentsList.includes("--compile-check"); const shouldUseAllocator = !argumentsList.includes("--no-allocator"); const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-oxc-")); const checkoutDirectory = path.join(temporaryDirectory, "oxc"); @@ -68,57 +70,115 @@ try { process.exitCode = 0; } else { run("git", ["apply", patchPath], { cwd: checkoutDirectory }); - const targetDirectory = path.resolve( - process.env.CARGO_TARGET_DIR ?? path.join(temporaryDirectory, "target"), + const upstreamRulesDirectory = path.join( + checkoutDirectory, + "crates", + "oxc_linter", + "src", + "rules", + "react_doctor_native", ); - const cargoArguments = ["build", "--locked", "-p", "oxlint", "--release"]; - if (shouldUseAllocator) cargoArguments.push("--features", "allocator"); - run("cargo", cargoArguments, { - cwd: checkoutDirectory, - env: { ...process.env, CARGO_TARGET_DIR: targetDirectory }, - }); - - const libraryName = - process.platform === "win32" - ? "oxlint.dll" - : process.platform === "darwin" - ? "liboxlint.dylib" - : "liboxlint.so"; - const platformSuffix = - process.platform === "linux" - ? `${process.platform}-${process.arch}-gnu` - : `${process.platform}-${process.arch}`; - const bindingFileName = `oxlint-react-doctor.${platformSuffix}.node`; - const builtLibraryPath = path.join(targetDirectory, "release", libraryName); - const outputBindingPath = path.join(outputDirectory, bindingFileName); - fs.mkdirSync(outputDirectory, { recursive: true }); - fs.copyFileSync(builtLibraryPath, outputBindingPath); - const nativeBinding = requireFromScript(outputBindingPath); - if (typeof nativeBinding.lint !== "function") { - throw new Error(`built binding does not export lint: ${outputBindingPath}`); + fs.mkdirSync(upstreamRulesDirectory, { recursive: true }); + const nativeUtilitySources = new Map( + [ + "is-non-production-file", + "is-type-only-import", + "for-each-named-import", + "for-each-value-import", + ].map((utilityName) => [ + utilityName.replaceAll("-", "_"), + fs.readFileSync(path.join(nativeRulesDirectory, `${utilityName}.rs`), "utf8").trim(), + ]), + ); + for (const nativeRuleId of upstream.nativeRules) { + const nativeRuleSource = fs.readFileSync( + path.join(nativeRulesDirectory, `${nativeRuleId}.rs`), + "utf8", + ); + const requiredUtilities = [...nativeUtilitySources] + .filter(([utilityName]) => nativeRuleSource.includes(`${utilityName}(`)) + .map(([, utilitySource]) => utilitySource) + .join("\n\n"); + fs.writeFileSync( + path.join(upstreamRulesDirectory, `${nativeRuleId.replaceAll("-", "_")}.rs`), + requiredUtilities ? `${requiredUtilities}\n\n${nativeRuleSource}` : nativeRuleSource, + ); } - - const sha256 = (filePath) => - crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); - fs.writeFileSync( - path.join(outputDirectory, `${bindingFileName}.json`), - `${JSON.stringify( - { - upstreamRepository: upstream.repository, - upstreamTag: upstream.tag, - upstreamCommit: upstream.commit, - oxlintVersion: upstream.oxlintVersion, - rustToolchain: upstream.rustToolchain, - nativeRules: upstream.nativeRules, - bindingFile: bindingFileName, - bindingSha256: sha256(outputBindingPath), - patchSha256: sha256(patchPath), - }, - null, - 2, - )}\n`, + const rulesRegistryPath = path.join( + checkoutDirectory, + "crates", + "oxc_linter", + "src", + "rules.rs", ); - process.stdout.write(`Built ${outputBindingPath}\n`); + const nativeModuleDeclarations = upstream.nativeRules + .map((nativeRuleId) => ` pub mod ${nativeRuleId.replaceAll("-", "_")};`) + .join("\n"); + const rulesRegistry = fs + .readFileSync(rulesRegistryPath, "utf8") + .replace( + "pub(crate) mod react_doctor_native;", + `pub(crate) mod react_doctor_native {\n${nativeModuleDeclarations}\n}`, + ); + fs.writeFileSync(rulesRegistryPath, rulesRegistry); + run("cargo", ["lintgen"], { cwd: checkoutDirectory }); + if (shouldCompileCheck) { + run("cargo", ["check", "--locked", "-p", "oxc_linter"], { cwd: checkoutDirectory }); + process.stdout.write(`Native rules compile against ${upstream.tag} (${upstream.commit}).\n`); + process.exitCode = 0; + } else { + const targetDirectory = path.resolve( + process.env.CARGO_TARGET_DIR ?? path.join(temporaryDirectory, "target"), + ); + const cargoArguments = ["build", "--locked", "-p", "oxlint", "--release", "--lib"]; + if (shouldUseAllocator) cargoArguments.push("--features", "allocator"); + run("cargo", cargoArguments, { + cwd: checkoutDirectory, + env: { ...process.env, CARGO_TARGET_DIR: targetDirectory }, + }); + + const libraryName = + process.platform === "win32" + ? "oxlint.dll" + : process.platform === "darwin" + ? "liboxlint.dylib" + : "liboxlint.so"; + const platformSuffix = + process.platform === "linux" + ? `${process.platform}-${process.arch}-gnu` + : `${process.platform}-${process.arch}`; + const bindingFileName = `oxlint-react-doctor.${platformSuffix}.node`; + const builtLibraryPath = path.join(targetDirectory, "release", libraryName); + const outputBindingPath = path.join(outputDirectory, bindingFileName); + fs.mkdirSync(outputDirectory, { recursive: true }); + fs.copyFileSync(builtLibraryPath, outputBindingPath); + const nativeBinding = requireFromScript(outputBindingPath); + if (typeof nativeBinding.lint !== "function") { + throw new Error(`built binding does not export lint: ${outputBindingPath}`); + } + + const sha256 = (filePath) => + crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); + fs.writeFileSync( + path.join(outputDirectory, `${bindingFileName}.json`), + `${JSON.stringify( + { + upstreamRepository: upstream.repository, + upstreamTag: upstream.tag, + upstreamCommit: upstream.commit, + oxlintVersion: upstream.oxlintVersion, + rustToolchain: upstream.rustToolchain, + nativeRules: upstream.nativeRules, + bindingFile: bindingFileName, + bindingSha256: sha256(outputBindingPath), + patchSha256: sha256(patchPath), + }, + null, + 2, + )}\n`, + ); + process.stdout.write(`Built ${outputBindingPath}\n`); + } } } finally { fs.rmSync(temporaryDirectory, { recursive: true, force: true }); diff --git a/scripts/native/verify-oxlint-parity.mjs b/scripts/native/verify-oxlint-parity.mjs index 12831b86ac..52e6b60426 100644 --- a/scripts/native/verify-oxlint-parity.mjs +++ b/scripts/native/verify-oxlint-parity.mjs @@ -8,6 +8,9 @@ import { fileURLToPath } from "node:url"; const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const requireFromRepository = createRequire(path.join(repositoryRoot, "package.json")); +const nativeRules = JSON.parse( + fs.readFileSync(path.join(repositoryRoot, "native", "oxlint", "upstream.json"), "utf8"), +).nativeRules; const argumentsList = process.argv.slice(2); const readOption = (name) => { const optionIndex = argumentsList.indexOf(name); @@ -45,13 +48,31 @@ const oxlintBinaryPath = path.join( ); const pluginPath = requireFromRepository.resolve("oxlint-plugin-react-doctor"); const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-native-parity-")); -const fixturePath = path.join(temporaryDirectory, "fixture.ts"); +const fixturePath = path.join(temporaryDirectory, "fixture.tsx"); const stockConfigPath = path.join(temporaryDirectory, "stock.json"); const nativeConfigPath = path.join(temporaryDirectory, "native.json"); -const EXPECTED_DIAGNOSTIC_COUNT = 8; +const EXPECTED_DIAGNOSTIC_COUNTS = { + "jsx-no-duplicate-props": 1, + "nextjs-no-vercel-og-import": 1, + "no-children-prop": 2, + "no-danger": 3, + "no-document-write": 8, + "no-moment": 1, + "no-namespace": 2, + "no-react-children": 2, + "preact-no-react-hooks-import": 2, + "rn-bottom-sheet-prefer-native": 1, + "rn-no-deprecated-modules": 1, + "rn-no-legacy-expo-packages": 1, + "rn-no-panresponder": 1, + "rn-prefer-pressable": 1, + "rn-prefer-reanimated": 2, + "use-lazy-motion": 1, +}; const BENCHMARK_FILE_COUNT = 100; const BENCHMARK_CALL_COUNT_PER_FILE = 500; const BENCHMARK_SAMPLE_COUNT = 5; +const OXLINT_OUTPUT_MAX_BYTES = 256 * 1024 * 1024; const DISABLED_RULE_CATEGORIES = { correctness: "off", nursery: "off", @@ -63,6 +84,22 @@ const DISABLED_RULE_CATEGORIES = { }; const shouldBenchmark = argumentsList.includes("--benchmark"); const fixture = ` +import moment from "moment"; +import type { Moment } from "moment"; +import { ImageResponse } from "@vercel/og"; +import React, { Children, useEffect, useState, Component } from "react"; +import type { useMemo as PreactTypeOnlyHook } from "react"; +import RawBottomSheet from "react-native-raw-bottom-sheet"; +import { Audio } from "expo-av/build/Audio"; +import { + Animated, + AsyncStorage, + LayoutAnimation, + PanResponder as PR, + TouchableOpacity, + type WebView, +} from "react-native"; +import { motion, type MotionConfig } from "framer-motion"; document.write("a"); document.writeln("b"); document["write"]("c"); @@ -74,13 +111,29 @@ document!.write("f"); document[method]("safe"); stream.write("safe"); { const document = { write() {} }; document.write("safe"); } +const duplicateProps = ; +const namespaced = ; +React.createElement("svg:path"); +const danger =
; +React.createElement("div", { dangerouslySetInnerHTML: { __html: markup } }); +const suppressedOnlyForReact = + // eslint-disable-next-line react/no-danger +
; +const suppressedReactDoctor = + // eslint-disable-next-line react-doctor/no-danger +
; +const childrenProp = ; +React.createElement(Widget, { children: "hidden" }); +Children.map(children, child => child); +React.Children.only(children); `; const normalizeDiagnostics = (diagnostics) => diagnostics .filter( (diagnostic) => - typeof diagnostic.code === "string" && diagnostic.code.includes("no-document-write"), + typeof diagnostic.code === "string" && + nativeRules.some((nativeRuleId) => diagnostic.code.includes(`(${nativeRuleId})`)), ) .map((diagnostic) => ({ code: diagnostic.code.replace("react-doctor-native", "react-doctor"), @@ -91,12 +144,28 @@ const normalizeDiagnostics = (diagnostics) => })) .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))); +const countDiagnosticsByRule = (diagnostics) => { + const counts = Object.fromEntries(nativeRules.map((nativeRuleId) => [nativeRuleId, 0])); + for (const diagnostic of diagnostics) { + const ruleId = nativeRules.find((candidateRuleId) => + diagnostic.code.includes(`(${candidateRuleId})`), + ); + if (ruleId) counts[ruleId] += 1; + } + return counts; +}; + const runOxlint = (configPath, environment, targetPath = fixturePath) => { const startedAt = performance.now(); const result = spawnSync( process.execPath, [oxlintBinaryPath, "-c", configPath, "--format", "json", targetPath], - { cwd: repositoryRoot, env: environment, encoding: "utf8" }, + { + cwd: repositoryRoot, + env: environment, + encoding: "utf8", + maxBuffer: OXLINT_OUTPUT_MAX_BYTES, + }, ); if (result.error) throw result.error; if (!result.stdout) { @@ -120,7 +189,9 @@ try { categories: DISABLED_RULE_CATEGORIES, plugins: [], jsPlugins: [pluginPath], - rules: { "react-doctor/no-document-write": "warn" }, + rules: Object.fromEntries( + nativeRules.map((nativeRuleId) => [`react-doctor/${nativeRuleId}`, "warn"]), + ), }), ); fs.writeFileSync( @@ -128,8 +199,10 @@ try { JSON.stringify({ categories: DISABLED_RULE_CATEGORIES, plugins: ["react-doctor-native"], - jsPlugins: [pluginPath], - rules: { "react-doctor-native/no-document-write": "warn" }, + jsPlugins: [], + rules: Object.fromEntries( + nativeRules.map((nativeRuleId) => [`react-doctor-native/${nativeRuleId}`, "warn"]), + ), }), ); const stockDiagnostics = runOxlint(stockConfigPath, process.env).diagnostics; @@ -138,9 +211,10 @@ try { NAPI_RS_NATIVE_LIBRARY_PATH: path.resolve(nativeBindingPath), }; const nativeDiagnostics = runOxlint(nativeConfigPath, nativeEnvironment).diagnostics; - if (stockDiagnostics.length !== EXPECTED_DIAGNOSTIC_COUNT) { + const stockDiagnosticCounts = countDiagnosticsByRule(stockDiagnostics); + if (JSON.stringify(stockDiagnosticCounts) !== JSON.stringify(EXPECTED_DIAGNOSTIC_COUNTS)) { throw new Error( - `expected ${EXPECTED_DIAGNOSTIC_COUNT} JavaScript diagnostics, received ${stockDiagnostics.length}`, + `unexpected JavaScript diagnostic coverage\nexpected=${JSON.stringify(EXPECTED_DIAGNOSTIC_COUNTS, null, 2)}\nreceived=${JSON.stringify(stockDiagnosticCounts, null, 2)}`, ); } if (JSON.stringify(nativeDiagnostics) !== JSON.stringify(stockDiagnostics)) { @@ -176,8 +250,16 @@ try { if ( JSON.stringify(repositoryNativeDiagnostics) !== JSON.stringify(repositoryStockDiagnostics) ) { + const nativeDiagnosticKeys = new Set(repositoryNativeDiagnostics.map(JSON.stringify)); + const stockDiagnosticKeys = new Set(repositoryStockDiagnostics.map(JSON.stringify)); + const stockOnlyDiagnostic = repositoryStockDiagnostics.find( + (diagnostic) => !nativeDiagnosticKeys.has(JSON.stringify(diagnostic)), + ); + const nativeOnlyDiagnostic = repositoryNativeDiagnostics.find( + (diagnostic) => !stockDiagnosticKeys.has(JSON.stringify(diagnostic)), + ); throw new Error( - `native corpus parity failed for ${repositoryName}\nstock=${JSON.stringify(repositoryStockDiagnostics, null, 2)}\nnative=${JSON.stringify(repositoryNativeDiagnostics, null, 2)}`, + `native corpus parity failed for ${repositoryName}\nstock count=${repositoryStockDiagnostics.length}\nnative count=${repositoryNativeDiagnostics.length}\nstock only=${JSON.stringify(stockOnlyDiagnostic, null, 2)}\nnative only=${JSON.stringify(nativeOnlyDiagnostic, null, 2)}`, ); } corpusDiagnosticCount += repositoryStockDiagnostics.length; From ecba44a29f0633bc32a337d283d21dd10fa67962 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 21 Aug 2026 12:21:02 +0000 Subject: [PATCH 003/423] fix(native): install rustfmt for registry generation --- .github/workflows/native-oxlint.yml | 2 ++ scripts/native/build-oxlint-binding.mjs | 1 + 2 files changed, 3 insertions(+) diff --git a/.github/workflows/native-oxlint.yml b/.github/workflows/native-oxlint.yml index c90be6dca8..64120ac258 100644 --- a/.github/workflows/native-oxlint.yml +++ b/.github/workflows/native-oxlint.yml @@ -53,6 +53,8 @@ jobs: 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 }} diff --git a/scripts/native/build-oxlint-binding.mjs b/scripts/native/build-oxlint-binding.mjs index 258dbe081a..7630f46c00 100644 --- a/scripts/native/build-oxlint-binding.mjs +++ b/scripts/native/build-oxlint-binding.mjs @@ -121,6 +121,7 @@ try { `pub(crate) mod react_doctor_native {\n${nativeModuleDeclarations}\n}`, ); fs.writeFileSync(rulesRegistryPath, rulesRegistry); + run("rustfmt", ["--version"], { cwd: checkoutDirectory }); run("cargo", ["lintgen"], { cwd: checkoutDirectory }); if (shouldCompileCheck) { run("cargo", ["check", "--locked", "-p", "oxc_linter"], { cwd: checkoutDirectory }); From dbc6ed1cf80e66790b347a3b93b2dd6debb9783d Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 22 Aug 2026 01:46:43 +0000 Subject: [PATCH 004/423] perf(native): port nine parity-safe Oxc rules --- native/oxlint/react-doctor.patch | 146 +++++++++++++++++- native/oxlint/rules/no-is-mounted.rs | 44 ++++++ native/oxlint/rules/no-render-return-value.rs | 52 +++++++ native/oxlint/upstream.json | 58 ++++++- packages/core/src/constants.ts | 9 ++ scripts/native/build-oxlint-binding.mjs | 57 ++++++- scripts/native/verify-oxlint-parity.mjs | 129 +++++++++++++++- 7 files changed, 486 insertions(+), 9 deletions(-) create mode 100644 native/oxlint/rules/no-is-mounted.rs create mode 100644 native/oxlint/rules/no-render-return-value.rs diff --git a/native/oxlint/react-doctor.patch b/native/oxlint/react-doctor.patch index 3e4afe7dab..9879c80c1f 100644 --- a/native/oxlint/react-doctor.patch +++ b/native/oxlint/react-doctor.patch @@ -44,14 +44,126 @@ index e4e346c..6ecbfc1 100644 let enum_schema = r#gen.subschema_for::(); diff --git a/crates/oxc_linter/src/context/mod.rs b/crates/oxc_linter/src/context/mod.rs -index 152f919..544fbc7 100644 +index 152f919..7e451af 100644 --- a/crates/oxc_linter/src/context/mod.rs +++ b/crates/oxc_linter/src/context/mod.rs -@@ -264,7 +264,15 @@ impl<'a> LintContext<'a> { +@@ -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>, + } + +@@ -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), @@ -64,6 +176,36 @@ index 152f919..544fbc7 100644 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) -> 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 diff --git a/native/oxlint/rules/no-is-mounted.rs b/native/oxlint/rules/no-is-mounted.rs new file mode 100644 index 0000000000..eba98efb60 --- /dev/null +++ b/native/oxlint/rules/no-is-mounted.rs @@ -0,0 +1,44 @@ +use oxc_ast::{AstKind, ast::Expression}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::GetSpan; + +use crate::{AstNode, context::LintContext, rule::Rule, utils::get_parent_component}; + +const MESSAGE: &str = + "`isMounted` is unreliable in modern React, so async callbacks can update state after unmount."; + +#[derive(Debug, Default, Clone)] +pub struct NoIsMounted; + +declare_oxc_lint!( + /// Disallow `this.isMounted()` inside React components. + NoIsMounted, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow this.isMounted calls inside React components.", +); + +impl Rule for NoIsMounted { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::CallExpression(call_expression) = node.kind() else { + return; + }; + let Expression::StaticMemberExpression(member_expression) = + call_expression.callee.get_inner_expression() + else { + return; + }; + if member_expression.property.name != "isMounted" + || !matches!( + member_expression.object.get_inner_expression(), + Expression::ThisExpression(_) + ) + || get_parent_component(node, ctx).is_none() + { + return; + } + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(call_expression.span())); + } +} diff --git a/native/oxlint/rules/no-render-return-value.rs b/native/oxlint/rules/no-render-return-value.rs new file mode 100644 index 0000000000..9e6e87ef9a --- /dev/null +++ b/native/oxlint/rules/no-render-return-value.rs @@ -0,0 +1,52 @@ +use oxc_ast::{AstKind, ast::Expression}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::GetSpan; + +use crate::{AstNode, context::LintContext, rule::Rule}; + +const MESSAGE: &str = "Your app breaks in React 19 because `ReactDOM.render` returns nothing there."; + +#[derive(Debug, Default, Clone)] +pub struct NoRenderReturnValue; + +declare_oxc_lint!( + /// Disallow using the return value of `ReactDOM.render()`. + NoRenderReturnValue, + react_doctor_native, + correctness, + version = "0.1.0", + short_description = "Disallow using the ReactDOM.render return value.", +); + +impl Rule for NoRenderReturnValue { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::CallExpression(call_expression) = node.kind() else { + return; + }; + let Expression::StaticMemberExpression(member_expression) = + call_expression.callee.get_inner_expression() + else { + return; + }; + let Expression::Identifier(receiver) = member_expression.object.get_inner_expression() + else { + return; + }; + if receiver.name != "ReactDOM" || member_expression.property.name != "render" { + return; + } + let parent = ctx.nodes().parent_node(node.id()); + if !matches!( + parent.kind(), + AstKind::VariableDeclarator(_) + | AstKind::ObjectProperty(_) + | AstKind::ReturnStatement(_) + | AstKind::AssignmentExpression(_) + | AstKind::ArrowFunctionExpression(_) + ) { + return; + } + ctx.diagnostic(OxcDiagnostic::warn(MESSAGE).with_label(call_expression.callee.span())); + } +} diff --git a/native/oxlint/upstream.json b/native/oxlint/upstream.json index 096cebfa41..fa3ddfe2e3 100644 --- a/native/oxlint/upstream.json +++ b/native/oxlint/upstream.json @@ -20,6 +20,62 @@ "rn-no-panresponder", "rn-prefer-pressable", "rn-prefer-reanimated", - "use-lazy-motion" + "use-lazy-motion", + "html-has-lang", + "no-access-key", + "no-clone-element", + "no-is-mounted", + "no-render-return-value", + "no-will-update-set-state", + "self-closing-comp", + "no-distracting-elements", + "require-render-return" + ], + "delegatedRules": [ + { + "id": "html-has-lang", + "module": "jsx_a11y::html_has_lang", + "struct": "HtmlHasLang", + "category": "correctness" + }, + { + "id": "no-access-key", + "module": "jsx_a11y::no_access_key", + "struct": "NoAccessKey", + "category": "correctness", + "fix": "suggestion", + "skipNonProduction": true + }, + { + "id": "no-clone-element", + "module": "react::no_clone_element", + "struct": "NoCloneElement", + "category": "correctness" + }, + { + "id": "no-will-update-set-state", + "module": "react::no_will_update_set_state", + "struct": "NoWillUpdateSetState", + "category": "correctness" + }, + { + "id": "self-closing-comp", + "module": "react::self_closing_comp", + "struct": "SelfClosingComp", + "category": "style", + "fix": "fix" + }, + { + "id": "no-distracting-elements", + "module": "jsx_a11y::no_distracting_elements", + "struct": "NoDistractingElements", + "category": "correctness" + }, + { + "id": "require-render-return", + "module": "react::require_render_return", + "struct": "RequireRenderReturn", + "category": "nursery" + } ] } diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 35a4b8efb9..d280cd368f 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -579,6 +579,15 @@ export const NATIVE_REACT_DOCTOR_RULE_IDS: ReadonlySet = new Set([ "rn-prefer-pressable", "rn-prefer-reanimated", "use-lazy-motion", + "html-has-lang", + "no-access-key", + "no-clone-element", + "no-is-mounted", + "no-render-return-value", + "no-will-update-set-state", + "self-closing-comp", + "no-distracting-elements", + "require-render-return", ]); // HACK: per-batch wall-clock budget for an oxlint spawn. Each batch diff --git a/scripts/native/build-oxlint-binding.mjs b/scripts/native/build-oxlint-binding.mjs index 7630f46c00..a4847e4329 100644 --- a/scripts/native/build-oxlint-binding.mjs +++ b/scripts/native/build-oxlint-binding.mjs @@ -12,6 +12,9 @@ const nativeDirectory = path.join(repositoryRoot, "native", "oxlint"); const nativeRulesDirectory = path.join(nativeDirectory, "rules"); const upstream = JSON.parse(fs.readFileSync(path.join(nativeDirectory, "upstream.json"), "utf8")); const patchPath = path.join(nativeDirectory, "react-doctor.patch"); +const delegatedRules = new Map( + (upstream.delegatedRules ?? []).map((delegatedRule) => [delegatedRule.id, delegatedRule]), +); const argumentsList = process.argv.slice(2); const readOption = (name) => { @@ -79,6 +82,52 @@ try { "react_doctor_native", ); fs.mkdirSync(upstreamRulesDirectory, { recursive: true }); + const buildDelegatedRuleSource = (delegatedRule) => { + const fixCapability = delegatedRule.fix ? `\n ${delegatedRule.fix},` : ""; + const shouldRun = delegatedRule.skipNonProduction + ? "!is_non_production_file(ctx) && self.upstream_rule.should_run(ctx)" + : "self.upstream_rule.should_run(ctx)"; + return `use crate::{ + AstNode, + context::{ContextHost, LintContext}, + rule::Rule, + rules::${delegatedRule.module}::${delegatedRule.struct} as UpstreamRule, +}; +use oxc_macros::declare_oxc_lint; + +#[derive(Debug, Default, Clone)] +pub struct ${delegatedRule.struct} { + upstream_rule: UpstreamRule, +} + +declare_oxc_lint!( + /// React Doctor adapter for Oxc's native detector. + ${delegatedRule.struct}, + react_doctor_native, + ${delegatedRule.category},${fixCapability} + version = "0.1.0", + short_description = "Runs the parity-matched native Oxc detector with React Doctor diagnostics.", +); + +impl Rule for ${delegatedRule.struct} { + fn from_configuration(value: serde_json::Value) -> Result { + UpstreamRule::from_configuration(value).map(|upstream_rule| Self { upstream_rule }) + } + + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + self.upstream_rule.run(node, ctx); + } + + fn run_once(&self, ctx: &LintContext) { + self.upstream_rule.run_once(ctx); + } + + fn should_run(&self, ctx: &ContextHost) -> bool { + ${shouldRun} + } +} +`; + }; const nativeUtilitySources = new Map( [ "is-non-production-file", @@ -91,10 +140,10 @@ try { ]), ); for (const nativeRuleId of upstream.nativeRules) { - const nativeRuleSource = fs.readFileSync( - path.join(nativeRulesDirectory, `${nativeRuleId}.rs`), - "utf8", - ); + const delegatedRule = delegatedRules.get(nativeRuleId); + const nativeRuleSource = delegatedRule + ? buildDelegatedRuleSource(delegatedRule) + : fs.readFileSync(path.join(nativeRulesDirectory, `${nativeRuleId}.rs`), "utf8"); const requiredUtilities = [...nativeUtilitySources] .filter(([utilityName]) => nativeRuleSource.includes(`${utilityName}(`)) .map(([, utilitySource]) => utilitySource) diff --git a/scripts/native/verify-oxlint-parity.mjs b/scripts/native/verify-oxlint-parity.mjs index 52e6b60426..3a83e826e8 100644 --- a/scripts/native/verify-oxlint-parity.mjs +++ b/scripts/native/verify-oxlint-parity.mjs @@ -49,12 +49,13 @@ const oxlintBinaryPath = path.join( const pluginPath = requireFromRepository.resolve("oxlint-plugin-react-doctor"); const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-native-parity-")); const fixturePath = path.join(temporaryDirectory, "fixture.tsx"); +const nonProductionFixturePath = path.join(temporaryDirectory, "fixture.test.tsx"); const stockConfigPath = path.join(temporaryDirectory, "stock.json"); const nativeConfigPath = path.join(temporaryDirectory, "native.json"); const EXPECTED_DIAGNOSTIC_COUNTS = { "jsx-no-duplicate-props": 1, "nextjs-no-vercel-og-import": 1, - "no-children-prop": 2, + "no-children-prop": 3, "no-danger": 3, "no-document-write": 8, "no-moment": 1, @@ -68,9 +69,19 @@ const EXPECTED_DIAGNOSTIC_COUNTS = { "rn-prefer-pressable": 1, "rn-prefer-reanimated": 2, "use-lazy-motion": 1, + "html-has-lang": 1, + "no-access-key": 1, + "no-clone-element": 1, + "no-is-mounted": 1, + "no-render-return-value": 2, + "no-will-update-set-state": 1, + "self-closing-comp": 1, + "no-distracting-elements": 1, + "require-render-return": 2, }; const BENCHMARK_FILE_COUNT = 100; const BENCHMARK_CALL_COUNT_PER_FILE = 500; +const BENCHMARK_FINDING_COUNT_PER_FILE = 500; const BENCHMARK_SAMPLE_COUNT = 5; const OXLINT_OUTPUT_MAX_BYTES = 256 * 1024 * 1024; const DISABLED_RULE_CATEGORIES = { @@ -82,12 +93,21 @@ const DISABLED_RULE_CATEGORIES = { style: "off", suspicious: "off", }; +const REACT_DOCTOR_SETTINGS = { + "react-doctor": { + portedRuleMode: "curated", + framework: "unknown", + rootDirectory: repositoryRoot, + capabilities: ["react"], + }, +}; const shouldBenchmark = argumentsList.includes("--benchmark"); const fixture = ` import moment from "moment"; import type { Moment } from "moment"; import { ImageResponse } from "@vercel/og"; import React, { Children, useEffect, useState, Component } from "react"; +import ReactDOM from "react-dom"; import type { useMemo as PreactTypeOnlyHook } from "react"; import RawBottomSheet from "react-native-raw-bottom-sheet"; import { Audio } from "expo-av/build/Audio"; @@ -126,6 +146,39 @@ const childrenProp = ; React.createElement(Widget, { children: "hidden" }); Children.map(children, child => child); React.Children.only(children); +const forwardedWithoutRef = React.forwardRef((props) =>
{props.label}
); +const page = ; +const untitledFrame =