Files
millionco__react-doctor/native/oxlint/react-doctor.patch

395 lines
15 KiB
Diff

diff --git a/crates/oxc_linter/src/config/plugins.rs b/crates/oxc_linter/src/config/plugins.rs
index e4e346c..6ecbfc1 100644
--- a/crates/oxc_linter/src/config/plugins.rs
+++ b/crates/oxc_linter/src/config/plugins.rs
@@ -68,6 +68,7 @@ pub fn plugin_display_name(plugin_name: &str) -> &str {
match plugin_name {
"jsx_a11y" => "jsx-a11y",
"react_perf" => "react-perf",
+ "react_doctor_native" => "react-doctor-native",
"nextjs" => "next",
_ => plugin_name,
}
@@ -121,6 +122,8 @@ bitflags! {
const NODE = 1 << 12;
/// `eslint-plugin-vue`
const VUE = 1 << 13;
+ /// Native React Doctor rules
+ const REACT_DOCTOR_NATIVE = 1 << 14;
}
}
@@ -186,6 +189,7 @@ impl TryFrom<&str> for LintPlugins {
"promise" => Ok(LintPlugins::PROMISE),
"node" => Ok(LintPlugins::NODE),
"vue" => Ok(LintPlugins::VUE),
+ "react-doctor-native" | "react_doctor_native" => Ok(LintPlugins::REACT_DOCTOR_NATIVE),
// "eslint" is not really a plugin, so it's 'empty'. This has the added benefit of
// making it the default value.
"eslint" => Ok(LintPlugins::ESLINT),
@@ -211,6 +215,7 @@ impl From<LintPlugins> for &'static str {
LintPlugins::PROMISE => "promise",
LintPlugins::NODE => "node",
LintPlugins::VUE => "vue",
+ LintPlugins::REACT_DOCTOR_NATIVE => "react-doctor-native",
_ => "",
}
}
@@ -282,6 +287,7 @@ impl JsonSchema for LintPlugins {
Promise,
Node,
Vue,
+ ReactDoctorNative,
}
let enum_schema = r#gen.subschema_for::<LintPluginOptionsSchema>();
diff --git a/crates/oxc_linter/src/context/mod.rs b/crates/oxc_linter/src/context/mod.rs
index 152f919..7e451af 100644
--- a/crates/oxc_linter/src/context/mod.rs
+++ b/crates/oxc_linter/src/context/mod.rs
@@ -1,15 +1,23 @@
#![expect(rustdoc::private_intra_doc_links)] // useful for intellisense
-use std::{ffi::OsStr, ops::Deref, path::Path, rc::Rc};
+use std::{
+ borrow::Cow,
+ cell::OnceCell,
+ collections::HashMap,
+ ffi::OsStr,
+ ops::Deref,
+ path::Path,
+ rc::Rc,
+};
use javascript_globals::{GLOBALS, GLOBALS_BUILTIN, GLOBALS_ES2026};
use oxc_allocator::Allocator;
-use oxc_ast::ast::IdentifierReference;
+use oxc_ast::{AstKind, ast::IdentifierReference};
use oxc_cfg::ControlFlowGraph;
use oxc_diagnostics::{OxcDiagnostic, Severity};
use oxc_semantic::{IsGlobalReference, Semantic};
-use oxc_span::Span;
+use oxc_span::{GetSpan, LabeledSpan, Span};
#[cfg(debug_assertions)]
use crate::rule::RuleFixMeta;
@@ -25,6 +33,74 @@ use crate::{
mod host;
pub use host::{ContextHost, ContextSubHost, ContextSubHostOptions};
+fn apply_react_doctor_diagnostic_override(
+ context: &LintContext,
+ rule_name: &str,
+ message: &mut Message,
+) {
+ let upstream_message = message.error.message.as_ref();
+ let diagnostic_message: Cow<'static, str> = match rule_name {
+ "html-has-lang" => {
+ Cow::Borrowed("Screen readers may mispronounce this page because it doesn't declare a language, so add a `lang` attribute like `en`.")
+ }
+ "no-access-key" => {
+ Cow::Borrowed("Screen reader users can lose their shortcuts because `accessKey` clashes with them, so remove it.")
+ }
+ "no-clone-element" => {
+ Cow::Borrowed("`React.cloneElement` couples the parent to the child's prop shape, so child prop changes can silently break injected behavior.")
+ }
+ "no-is-mounted" => {
+ Cow::Borrowed("`isMounted` is unreliable in modern React, so async callbacks can update state after unmount.")
+ }
+ "no-render-return-value" => {
+ Cow::Borrowed("Your app breaks in React 19 because `ReactDOM.render` returns nothing there.")
+ }
+ "no-will-update-set-state" => {
+ Cow::Borrowed("Calling setState in componentWillUpdate can trigger another update immediately, loop forever, and freeze the component.")
+ }
+ "self-closing-comp" => {
+ Cow::Borrowed("This tag has no children, so the closing tag adds noise without changing output.")
+ }
+ "no-distracting-elements" => {
+ let element = upstream_message
+ .split("`<")
+ .nth(1)
+ .and_then(|message| message.split_once('>'))
+ .map_or("element", |(element, _)| element);
+ Cow::Owned(format!("Users with attention or motion sensitivity struggle because `<{element}>` animates on its own, so use normal, accessible markup instead."))
+ }
+ "require-render-return" => Cow::Borrowed(
+ "Your users see nothing because this `render` method returns nothing.",
+ ),
+ _ => return,
+ };
+ if rule_name == "self-closing-comp" {
+ let diagnostic_spans = context.react_doctor_diagnostic_spans.get_or_init(|| {
+ context
+ .nodes()
+ .iter()
+ .filter_map(|node| match (rule_name, node.kind()) {
+ ("self-closing-comp", AstKind::JSXElement(element)) => element
+ .closing_element
+ .as_ref()
+ .map(|closing_element| {
+ (closing_element.span().start, element.opening_element.span())
+ }),
+ _ => None,
+ })
+ .collect()
+ });
+ if let Some(diagnostic_span) = diagnostic_spans.get(&message.span.start) {
+ message.span = *diagnostic_span;
+ }
+ }
+ message.error.message = diagnostic_message;
+ message.error.help = None;
+ message.error.note = None;
+ message.error.labels.clear();
+ message.error.labels.push(LabeledSpan::underline(message.span));
+}
+
/// Contains all of the state and context specific to this lint rule.
///
/// Includes information like the rule name, plugin name, and severity of the rule.
@@ -59,5 +127,6 @@ pub struct LintContext<'a> {
/// }
/// ```
severity: Severity,
+ react_doctor_diagnostic_spans: OnceCell<HashMap<u32, Span>>,
}
@@ -73,6 +150,10 @@ impl<'a> Deref for LintContext<'a> {
impl<'a> LintContext<'a> {
+ pub(crate) fn react_doctor_next_file_active(&self) -> &OnceCell<bool> {
+ &self.parent.react_doctor_next_file_active
+ }
+
/// Get information such as the control flow graph, bound symbols, AST, etc.
/// for the file being linted.
///
/// Refer to [`Semantic`]'s documentation for more information.
@@ -264,7 +345,18 @@ impl<'a> LintContext<'a> {
/// Add a diagnostic message to the list of diagnostics. Outputs a diagnostic with the current rule
/// name, severity, and a link to the rule's documentation URL.
fn add_diagnostic(&self, mut message: Message) {
- if self.parent.disable_directives().contains(self.current_rule_name, message.span) {
+ if self.current_plugin_name == "react_doctor_native" {
+ apply_react_doctor_diagnostic_override(self, self.current_rule_name, &mut message);
+ }
+ let is_disabled = if self.current_plugin_name == "react_doctor_native" {
+ self.parent.disable_directives().contains(
+ &format!("react-doctor/{}", self.current_rule_name),
+ message.span,
+ )
+ } else {
+ self.parent.disable_directives().contains(self.current_rule_name, message.span)
+ };
+ if is_disabled {
return;
}
message.error = message
diff --git a/crates/oxc_linter/src/context/host.rs b/crates/oxc_linter/src/context/host.rs
--- a/crates/oxc_linter/src/context/host.rs
+++ b/crates/oxc_linter/src/context/host.rs
@@ -190,6 +190,7 @@ pub struct ContextHost<'a> {
/// every rule in the React Compiler family (`react/hooks`, `react/refs`, …).
/// Stays empty until the first such rule runs on this file.
pub(super) react_compiler_results: OnceCell<ReactCompilerResults>,
+ pub(crate) react_doctor_next_file_active: OnceCell<bool>,
}
impl std::fmt::Debug for ContextHost<'_> {
@@ -230,6 +231,7 @@ impl<'a> ContextHost<'a> {
frameworks: options.framework_hints,
with_ignore_fixes: options.with_ignore_fixes,
react_compiler_results: OnceCell::new(),
+ react_doctor_next_file_active: OnceCell::new(),
}
.sniff_for_frameworks()
}
@@ -493,24 +493,26 @@ impl<'a> ContextHost<'a> {
LintContext {
parent: self,
+ react_doctor_diagnostic_spans: OnceCell::new(),
current_rule_name: rule_name,
current_plugin_name: plugin_name,
current_plugin_display_name: plugin_display_name(plugin_name),
#[cfg(debug_assertions)]
current_rule_fix_capabilities: rule.fix(),
severity: severity.into(),
}
}
/// Creates a new [`LintContext`] for testing purposes only.
#[cfg(test)]
pub(crate) fn spawn_for_test(self: Rc<Self>) -> LintContext<'a> {
LintContext {
parent: Rc::clone(&self),
+ react_doctor_diagnostic_spans: OnceCell::new(),
current_rule_name: "",
current_plugin_name: "eslint",
current_plugin_display_name: "eslint",
#[cfg(debug_assertions)]
current_rule_fix_capabilities: crate::rule::RuleFixMeta::None,
severity: oxc_diagnostics::Severity::Warning,
}
}
diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs
index baa13c5..7c21b9d 100644
--- a/crates/oxc_linter/src/rules.rs
+++ b/crates/oxc_linter/src/rules.rs
@@ -3,6 +3,8 @@
//! New rules need to be added to these `mod` statements.
//! Then run `cargo lintgen` to regenerate the RuleEnum and RuleRunnerImpls.
+pub(crate) mod react_doctor_native;
+
/// <https://github.com/import-js/eslint-plugin-import>
pub(crate) mod import {
pub mod consistent_type_specifier_style;
diff --git a/crates/oxc_linter/src/lib.rs b/crates/oxc_linter/src/lib.rs
--- a/crates/oxc_linter/src/lib.rs
+++ b/crates/oxc_linter/src/lib.rs
@@ -47,6 +47,8 @@ mod tsgolint;
mod utils;
pub mod loader;
+pub mod react_doctor_scan;
+pub mod react_doctor_project_analysis;
pub mod rules;
pub mod table;
diff --git a/apps/oxlint/src/run.rs b/apps/oxlint/src/run.rs
--- a/apps/oxlint/src/run.rs
+++ b/apps/oxlint/src/run.rs
@@ -239,6 +239,108 @@ async fn lint_impl(
cli_runner.run(&mut stdout)
}
+#[napi]
+pub fn analyze_react_doctor_reduced_motion(input_json: String) -> napi::Result<String> {
+ let sources = serde_json::from_str::<
+ Vec<oxc_linter::react_doctor_project_analysis::ReducedMotionSourceInput>,
+ >(&input_json)
+ .map_err(|error| {
+ napi::Error::from_reason(format!("Invalid React Doctor reduced motion input: {error}"))
+ })?;
+ let evidence = oxc_linter::react_doctor_project_analysis::analyze_reduced_motion(sources);
+ serde_json::to_string(&evidence).map_err(|error| {
+ napi::Error::from_reason(format!("Failed to serialize React Doctor reduced motion evidence: {error}"))
+ })
+}
+
+#[napi]
+pub fn extract_react_doctor_jsx_subtree_candidates(
+ file_name: String,
+ source_text: String,
+ maximum_candidate_count: f64,
+) -> napi::Result<String> {
+ let result =
+ oxc_linter::react_doctor_project_analysis::extract_jsx_subtree_candidates(
+ &file_name, &source_text, maximum_candidate_count,
+ );
+ serde_json::to_string(&result).map_err(|error| {
+ napi::Error::from_reason(format!("Failed to serialize React Doctor JSX candidates: {error}"))
+ })
+}
+
+#[napi]
+pub fn analyze_react_doctor_duplicate_jsx(input_json: String) -> napi::Result<String> {
+ let input = serde_json::from_str::<
+ oxc_linter::react_doctor_project_analysis::DuplicateJsxAnalysisInput,
+ >(&input_json)
+ .map_err(|error| {
+ napi::Error::from_reason(format!("Invalid React Doctor duplicate JSX input: {error}"))
+ })?;
+ let findings = oxc_linter::react_doctor_project_analysis::analyze_duplicate_jsx(&input);
+ serde_json::to_string(&findings).map_err(|error| {
+ napi::Error::from_reason(format!("Failed to serialize React Doctor duplicate JSX findings: {error}"))
+ })
+}
+
+#[napi]
+pub fn react_doctor_native_project_rule_ids() -> Vec<String> {
+ oxc_linter::react_doctor_project_analysis::native_project_rule_ids()
+}
+
+#[napi]
+pub fn analyze_react_doctor_project_graph(input_json: String) -> napi::Result<String> {
+ let input = serde_json::from_str::<
+ oxc_linter::react_doctor_project_analysis::ProjectAnalysisGraphInput,
+ >(&input_json)
+ .map_err(|error| {
+ napi::Error::from_reason(format!("Invalid React Doctor project graph input: {error}"))
+ })?;
+ let findings = oxc_linter::react_doctor_project_analysis::analyze_project_graph(&input);
+ serde_json::to_string(&findings).map_err(|error| {
+ napi::Error::from_reason(format!("Failed to serialize React Doctor project findings: {error}"))
+ })
+}
+
+#[napi]
+pub fn react_doctor_native_scan_rule_ids() -> Vec<String> {
+ oxc_linter::react_doctor_scan::native_scan_rule_ids()
+}
+
+#[napi]
+pub fn scan_react_doctor_file(input_json: String) -> napi::Result<String> {
+ let input = serde_json::from_str::<oxc_linter::react_doctor_scan::ScanFileInput>(&input_json)
+ .map_err(|error| {
+ napi::Error::from_reason(format!("Invalid React Doctor scan input: {error}"))
+ })?;
+ scan_react_doctor_file_input(input)
+}
+
+#[napi]
+pub fn scan_react_doctor_file_source(
+ absolute_path: String,
+ relative_path: String,
+ content: String,
+ is_generated_bundle: bool,
+ rule_ids: Vec<String>,
+) -> napi::Result<String> {
+ scan_react_doctor_file_input(oxc_linter::react_doctor_scan::ScanFileInput {
+ absolute_path,
+ relative_path,
+ content,
+ is_generated_bundle,
+ rule_ids,
+ })
+}
+
+fn scan_react_doctor_file_input(
+ input: oxc_linter::react_doctor_scan::ScanFileInput,
+) -> napi::Result<String> {
+ let findings = oxc_linter::react_doctor_scan::scan_file(&input);
+ serde_json::to_string(&findings).map_err(|error| {
+ napi::Error::from_reason(format!("Failed to serialize React Doctor scan findings: {error}"))
+ })
+}
+
#[cfg(all(target_pointer_width = "64", target_endian = "little"))]
pub use crate::js_plugins::parse::{get_buffer_offset, parse_raw_sync};
diff --git a/crates/oxc_linter/Cargo.toml b/crates/oxc_linter/Cargo.toml
--- a/crates/oxc_linter/Cargo.toml
+++ b/crates/oxc_linter/Cargo.toml
@@ -74,6 +74,7 @@ serde_json = { workspace = true, features = [
"preserve_order",
] } # preserve_order: print config with ordered keys.
+sha2 = "=0.10.9"
simdutf8 = { workspace = true }
smallvec = { workspace = true }
unicode-segmentation = { workspace = true }
url = { workspace = true }
diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2298,6 +2298,7 @@ name = "oxc_linter"
"self_cell",
"serde",
"serde_json",
+ "sha2",
"simdutf8",
"smallvec",
"unicode-segmentation",