mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 18:43:44 +00:00
feat(linter): add allow and allowPattern options to react/capitalized-calls
Original PR: #25960
This commit is contained in:
1 parent
d24b61c108
commit
419c1b0fd0
14 files changed
+249
-15
No files matched your search
Generated
+1
@@ -2524,6 +2524,7 @@ dependencies = [
|
||||
"cow-utils",
|
||||
"indexmap",
|
||||
"insta",
|
||||
"lazy-regex",
|
||||
"oxc_allocator",
|
||||
"oxc_ast",
|
||||
"oxc_ast_visit",
|
||||
|
||||
@@ -1323,7 +1323,7 @@ export interface DummyRuleMap {
|
||||
"react-perf/jsx-no-new-function-as-prop"?: RuleNoConfig | [AllowWarnDeny, ReactPerfConfig];
|
||||
"react-perf/jsx-no-new-object-as-prop"?: RuleNoConfig | [AllowWarnDeny, ReactPerfConfig];
|
||||
"react/button-has-type"?: RuleNoConfig | [AllowWarnDeny, ButtonHasType];
|
||||
"react/capitalized-calls"?: RuleNoConfig;
|
||||
"react/capitalized-calls"?: RuleNoConfig | [AllowWarnDeny, CapitalizedCallsConfig];
|
||||
"react/checked-requires-onchange-or-readonly"?: RuleNoConfig | [AllowWarnDeny, CheckedRequiresOnchangeOrReadonly];
|
||||
"react/display-name"?: RuleNoConfig | [AllowWarnDeny, DisplayNameConfig];
|
||||
"react/error-boundaries"?: RuleNoConfig;
|
||||
@@ -1997,6 +1997,7 @@ export interface DummyRuleMap {
|
||||
| [AllowWarnDeny, RadixType]
|
||||
| [AllowWarnDeny, ReactPerfConfig]
|
||||
| [AllowWarnDeny, ButtonHasType]
|
||||
| [AllowWarnDeny, CapitalizedCallsConfig]
|
||||
| [AllowWarnDeny, CheckedRequiresOnchangeOrReadonly]
|
||||
| [AllowWarnDeny, DisplayNameConfig]
|
||||
| [AllowWarnDeny, ExhaustiveDepsConfig]
|
||||
@@ -4971,6 +4972,21 @@ export interface ButtonHasType {
|
||||
*/
|
||||
submit?: boolean;
|
||||
}
|
||||
export interface CapitalizedCallsConfig {
|
||||
/**
|
||||
* Exact names of capitalized functions that may be called directly.
|
||||
* Forwarded to the React Compiler's `validateNoCapitalizedCalls`
|
||||
* environment option.
|
||||
*/
|
||||
allow?: string[];
|
||||
/**
|
||||
* A regex pattern; capitalized functions whose name matches may be called
|
||||
* directly, checked alongside `allow`. Useful when a codebase has a
|
||||
* naming convention for capitalized non-component factories, such as
|
||||
* generated event or schema builders (e.g. `"Event$"`).
|
||||
*/
|
||||
allowPattern?: string;
|
||||
}
|
||||
export interface CheckedRequiresOnchangeOrReadonly {
|
||||
/**
|
||||
* Ignore the restriction that `checked` and `defaultChecked` should not be used together.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"categories": { "correctness": "off" },
|
||||
"plugins": ["react"],
|
||||
"rules": {
|
||||
"react/capitalized-calls": ["error", { "allow": ["Child"], "allowPattern": "Event$" }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import Child from "./Child";
|
||||
import Other from "./Other";
|
||||
import { ButtonClickEvent, EventChild } from "./events";
|
||||
import myModule from "./MyModule";
|
||||
|
||||
// Allowed by `allow`.
|
||||
export function AllowedName() {
|
||||
return <div>{Child()}</div>;
|
||||
}
|
||||
|
||||
// Allowed by `allowPattern`.
|
||||
export function AllowedPattern() {
|
||||
const event = ButtonClickEvent();
|
||||
return <button onClick={() => console.log(event)} />;
|
||||
}
|
||||
|
||||
// Not in `allow` and does not match `allowPattern`.
|
||||
export function NotAllowed() {
|
||||
return <div>{Other()}</div>;
|
||||
}
|
||||
|
||||
// `allowPattern` is a regex, so `Event$` only matches names ending in `Event`.
|
||||
export function PatternDoesNotMatch() {
|
||||
return <div>{EventChild()}</div>;
|
||||
}
|
||||
|
||||
// Like the compiler's `validateNoCapitalizedCalls`, the allowlist only applies to global loads.
|
||||
export function MethodCall() {
|
||||
return <div>{myModule.Child()}</div>;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# Exit code
|
||||
1
|
||||
|
||||
# stdout
|
||||
```
|
||||
x react(capitalized-calls): Capitalized function called without JSX
|
||||
,-[files/index.jsx:19:16]
|
||||
18 | export function NotAllowed() {
|
||||
19 | return <div>{Other()}</div>;
|
||||
: ^^|^^
|
||||
: `-- `Other` may be a component
|
||||
20 | }
|
||||
`----
|
||||
help: Render `Other` with JSX if it is a component; otherwise rename it to start with a lowercase letter or allowlist it in the compiler configuration
|
||||
note: `Other` is treated as a component because it begins with an uppercase letter; React Compiler skipped optimizing this component or hook. Additional guidance: https://react.dev/reference/
|
||||
eslint-plugin-react-hooks/lints/static-components
|
||||
|
||||
x react(capitalized-calls): Capitalized function called without JSX
|
||||
,-[files/index.jsx:24:16]
|
||||
23 | export function PatternDoesNotMatch() {
|
||||
24 | return <div>{EventChild()}</div>;
|
||||
: ^^^^^|^^^^
|
||||
: `-- `EventChild` may be a component
|
||||
25 | }
|
||||
`----
|
||||
help: Render `EventChild` with JSX if it is a component; otherwise rename it to start with a lowercase letter or allowlist it in the compiler configuration
|
||||
note: `EventChild` is treated as a component because it begins with an uppercase letter; React Compiler skipped optimizing this component or hook. Additional guidance: https://react.dev/
|
||||
reference/eslint-plugin-react-hooks/lints/static-components
|
||||
|
||||
x react(capitalized-calls): Capitalized function called without JSX
|
||||
,-[files/index.jsx:29:25]
|
||||
28 | export function MethodCall() {
|
||||
29 | return <div>{myModule.Child()}</div>;
|
||||
: ^^|^^
|
||||
: `-- `Child` may be a component
|
||||
30 | }
|
||||
`----
|
||||
help: Render `Child` with JSX if it is a component; otherwise rename it to start with a lowercase letter or allowlist it in the compiler configuration
|
||||
note: `Child` is treated as a component because it begins with an uppercase letter; React Compiler skipped optimizing this component or hook. Additional guidance: https://react.dev/reference/
|
||||
eslint-plugin-react-hooks/lints/static-components
|
||||
|
||||
Found 0 warnings and 3 errors.
|
||||
Finished in Xms on 1 file with 1 rules using X threads.
|
||||
```
|
||||
|
||||
# stderr
|
||||
```
|
||||
```
|
||||
@@ -25,7 +25,7 @@ use crate::{
|
||||
module_record::ModuleRecord,
|
||||
options::LintOptions,
|
||||
rules::RuleEnum,
|
||||
utils::ReactCompilerResults,
|
||||
utils::{ReactCompilerEnvOptions, ReactCompilerResults},
|
||||
};
|
||||
|
||||
#[cfg(not(test))]
|
||||
@@ -185,6 +185,9 @@ 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>,
|
||||
/// Config-derived environment additions for that shared run (from the
|
||||
/// `react/capitalized-calls` rule's options).
|
||||
react_compiler_env_options: ReactCompilerEnvOptions,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ContextHost<'_> {
|
||||
@@ -225,10 +228,24 @@ impl<'a> ContextHost<'a> {
|
||||
frameworks: options.framework_hints,
|
||||
with_ignore_fixes: options.with_ignore_fixes,
|
||||
react_compiler_results: OnceCell::new(),
|
||||
react_compiler_env_options: ReactCompilerEnvOptions::default(),
|
||||
}
|
||||
.sniff_for_frameworks()
|
||||
}
|
||||
|
||||
/// Set the config-derived environment additions for the shared React
|
||||
/// Compiler run. Must happen before the first React Compiler family rule
|
||||
/// runs on this file.
|
||||
pub fn with_react_compiler_env_options(mut self, env_options: ReactCompilerEnvOptions) -> Self {
|
||||
self.react_compiler_env_options = env_options;
|
||||
self
|
||||
}
|
||||
|
||||
/// Config-derived environment additions for the shared React Compiler run.
|
||||
pub(crate) fn react_compiler_env_options(&self) -> &ReactCompilerEnvOptions {
|
||||
&self.react_compiler_env_options
|
||||
}
|
||||
|
||||
/// The current [`ContextSubHost`]
|
||||
pub fn current_sub_host(&self) -> &ContextSubHost<'a> {
|
||||
&self.sub_hosts[self.current_sub_host_index.get()]
|
||||
|
||||
+3
@@ -11853,6 +11853,9 @@ impl RuleEnum {
|
||||
Self::ReactButtonHasType(_) => {
|
||||
Ok(Self::ReactButtonHasType(ReactButtonHasType::from_configuration(value)?))
|
||||
}
|
||||
Self::ReactCapitalizedCalls(_) => {
|
||||
Ok(Self::ReactCapitalizedCalls(ReactCapitalizedCalls::from_configuration(value)?))
|
||||
}
|
||||
Self::ReactCheckedRequiresOnchangeOrReadonly(_) => {
|
||||
Ok(Self::ReactCheckedRequiresOnchangeOrReadonly(
|
||||
ReactCheckedRequiresOnchangeOrReadonly::from_configuration(value)?,
|
||||
|
||||
@@ -346,8 +346,20 @@ impl Linter {
|
||||
let ResolvedLinterState { rules, config, external_rules } = self.config.resolve(path);
|
||||
let mut timing_recorder = TIMINGS.then(|| RuleTimingRecorder::with_capacity(rules.len()));
|
||||
|
||||
let mut ctx_host =
|
||||
Rc::new(ContextHost::new(path, context_sub_hosts, allocator, self.options, config));
|
||||
// The shared React Compiler run takes its environment additions from the
|
||||
// `react/capitalized-calls` rule's resolved options.
|
||||
let react_compiler_env_options = rules
|
||||
.iter()
|
||||
.find_map(|(rule, _)| match rule {
|
||||
RuleEnum::ReactCapitalizedCalls(rule) => Some(rule.react_compiler_env_options()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut ctx_host = Rc::new(
|
||||
ContextHost::new(path, context_sub_hosts, allocator, self.options, config)
|
||||
.with_react_compiler_env_options(react_compiler_env_options),
|
||||
);
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
let mut current_diagnostic_index = 0;
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
use lazy_regex::Regex;
|
||||
use oxc_react_compiler::ErrorCategory;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
context::{ContextHost, LintContext},
|
||||
rule::Rule,
|
||||
utils::{run_react_compiler_rule, should_run_react_compiler},
|
||||
rule::{DefaultRuleConfig, Rule},
|
||||
utils::{
|
||||
ReactCompilerEnvOptions, deserialize_regex_option, run_react_compiler_rule,
|
||||
should_run_react_compiler,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase", default, deny_unknown_fields)]
|
||||
pub struct CapitalizedCallsConfig {
|
||||
/// Exact names of capitalized functions that may be called directly.
|
||||
/// Forwarded to the React Compiler's `validateNoCapitalizedCalls`
|
||||
/// environment option.
|
||||
allow: Vec<String>,
|
||||
/// A regex pattern; capitalized functions whose name matches may be called
|
||||
/// directly, checked alongside `allow`. Useful when a codebase has a
|
||||
/// naming convention for capitalized non-component factories, such as
|
||||
/// generated event or schema builders (e.g. `"Event$"`).
|
||||
#[serde(deserialize_with = "deserialize_regex_option")]
|
||||
allow_pattern: Option<Regex>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct CapitalizedCalls;
|
||||
pub struct CapitalizedCalls(Box<CapitalizedCallsConfig>);
|
||||
|
||||
declare_react_compiler_lint!(
|
||||
/// ### What it does
|
||||
@@ -44,11 +65,17 @@ declare_react_compiler_lint!(
|
||||
CapitalizedCalls,
|
||||
react,
|
||||
suspicious,
|
||||
config = CapitalizedCallsConfig,
|
||||
version = "1.79.0",
|
||||
short_description = "Disallow calling capitalized functions and methods instead of using JSX.",
|
||||
);
|
||||
|
||||
impl Rule for CapitalizedCalls {
|
||||
fn from_configuration(value: serde_json::Value) -> Result<Self, serde_json::error::Error> {
|
||||
DefaultRuleConfig::<CapitalizedCallsConfig>::from_value(value)
|
||||
.map(|config| Self(Box::new(config.into_inner())))
|
||||
}
|
||||
|
||||
fn run_once(&self, ctx: &LintContext) {
|
||||
run_react_compiler_rule(ctx, ErrorCategory::CapitalizedCalls);
|
||||
}
|
||||
@@ -58,6 +85,17 @@ impl Rule for CapitalizedCalls {
|
||||
}
|
||||
}
|
||||
|
||||
impl CapitalizedCalls {
|
||||
/// The environment additions this rule's options contribute to the shared
|
||||
/// React Compiler run.
|
||||
pub(crate) fn react_compiler_env_options(&self) -> ReactCompilerEnvOptions {
|
||||
ReactCompilerEnvOptions {
|
||||
capitalized_calls_allow: self.0.allow.clone(),
|
||||
capitalized_calls_allow_pattern: self.0.allow_pattern.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test() {
|
||||
use crate::tester::Tester;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use lazy_regex::Regex;
|
||||
use oxc_diagnostics::OxcCode;
|
||||
use oxc_react_compiler::{
|
||||
CompilerOutputMode, EnvironmentConfig, ErrorCategory, ExhaustiveEffectDepsMode, LintDiagnostic,
|
||||
@@ -11,16 +12,29 @@ use crate::{
|
||||
loader::LINT_PARTIAL_LOADER_EXTENSIONS,
|
||||
};
|
||||
|
||||
/// Environment additions to the shared React Compiler run that come from rule
|
||||
/// configuration, extracted once per resolved config in `Linter::run` (from the
|
||||
/// `react/capitalized-calls` rule's options). Config-derived rather than
|
||||
/// per-run rule state, so the single shared run stays valid for any
|
||||
/// combination of enabled rules.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct ReactCompilerEnvOptions {
|
||||
/// Extra allowed names for `validateNoCapitalizedCalls`.
|
||||
pub capitalized_calls_allow: Vec<String>,
|
||||
/// Allowed-name pattern for `validateNoCapitalizedCalls`.
|
||||
pub capitalized_calls_allow_pattern: Option<Regex>,
|
||||
}
|
||||
|
||||
/// The compiler options the React Compiler family of rules lints with — `lint`
|
||||
/// output mode plus validations that are off by default in the compiler.
|
||||
/// Mirrors `COMPILER_OPTIONS` in `eslint-plugin-react-hooks`'s
|
||||
/// `src/shared/RunReactCompiler.ts`.
|
||||
///
|
||||
/// The options are a fixed superset shared by every rule in the family: which
|
||||
/// rules are enabled only routes categories to reporters, it never changes
|
||||
/// what the compiler analyzes. That keeps the single shared run valid for any
|
||||
/// combination of enabled rules.
|
||||
fn react_compiler_plugin_options() -> PluginOptions {
|
||||
/// The options are a superset shared by every rule in the family: which rules
|
||||
/// are enabled only routes categories to reporters, and the only per-config
|
||||
/// variation is [`ReactCompilerEnvOptions`]. That keeps the single shared run
|
||||
/// valid for any combination of enabled rules.
|
||||
fn react_compiler_plugin_options(env_options: &ReactCompilerEnvOptions) -> PluginOptions {
|
||||
PluginOptions {
|
||||
output_mode: Some(CompilerOutputMode::Lint),
|
||||
// Oxlint does not parse Flow files.
|
||||
@@ -44,7 +58,10 @@ fn react_compiler_plugin_options() -> PluginOptions {
|
||||
validate_static_components: true,
|
||||
validate_no_freezing_known_mutable_functions: true,
|
||||
validate_no_void_use_memo: true,
|
||||
validate_no_capitalized_calls: Some(vec![]),
|
||||
validate_no_capitalized_calls: Some(env_options.capitalized_calls_allow.clone()),
|
||||
validate_no_capitalized_calls_pattern: env_options
|
||||
.capitalized_calls_allow_pattern
|
||||
.clone(),
|
||||
validate_hooks_usage: true,
|
||||
validate_no_derived_computations_in_effects: true,
|
||||
validate_exhaustive_memoization_dependencies: true,
|
||||
@@ -82,7 +99,7 @@ pub fn build_react_compiler_results(host: &ContextHost) -> ReactCompilerResults
|
||||
program,
|
||||
semantic,
|
||||
host.allocator(),
|
||||
react_compiler_plugin_options(),
|
||||
react_compiler_plugin_options(host.react_compiler_env_options()),
|
||||
);
|
||||
|
||||
ReactCompilerResults { diagnostics: result.diagnostics }
|
||||
|
||||
@@ -28,6 +28,7 @@ include = ["src/**/*", "examples/**/*", "LICENSE", "README.md", "CHANGELOG.md"]
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
lazy-regex = { workspace = true }
|
||||
oxc_allocator = { workspace = true }
|
||||
oxc_ast = { workspace = true }
|
||||
oxc_ast_visit = { workspace = true }
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
//! Contains feature flags and custom hook definitions that control compiler behavior.
|
||||
|
||||
use crate::react_compiler_utils::FxIndexMap;
|
||||
use lazy_regex::Regex;
|
||||
use rustc_hash::FxHashMap;
|
||||
|
||||
use crate::react_compiler_hir::Effect;
|
||||
@@ -89,6 +90,9 @@ pub struct EnvironmentConfig {
|
||||
pub validate_no_jsx_in_try_statements: bool,
|
||||
pub validate_static_components: bool,
|
||||
pub validate_no_capitalized_calls: Option<Vec<String>>,
|
||||
/// Oxlint extension to `validate_no_capitalized_calls`: names matching the
|
||||
/// pattern are allowed, in addition to the exact entries above.
|
||||
pub validate_no_capitalized_calls_pattern: Option<Regex>,
|
||||
pub validate_blocklisted_imports: Option<Vec<String>>,
|
||||
pub validate_source_locations: bool,
|
||||
pub validate_no_impure_functions_in_render: bool,
|
||||
@@ -141,6 +145,7 @@ impl Default for EnvironmentConfig {
|
||||
validate_no_jsx_in_try_statements: false,
|
||||
validate_static_components: false,
|
||||
validate_no_capitalized_calls: None,
|
||||
validate_no_capitalized_calls_pattern: None,
|
||||
validate_blocklisted_imports: None,
|
||||
validate_source_locations: false,
|
||||
validate_no_impure_functions_in_render: false,
|
||||
|
||||
+2
@@ -23,6 +23,7 @@ pub fn validate_no_capitalized_calls(
|
||||
allow_list.insert(entry.clone());
|
||||
}
|
||||
}
|
||||
let allow_pattern = env.config.validate_no_capitalized_calls_pattern.clone();
|
||||
|
||||
let mut capital_load_globals: FxHashMap<IdentifierId, Ident> = FxHashMap::default();
|
||||
let mut capitalized_properties: FxHashMap<IdentifierId, (Ident, Option<Span>)> =
|
||||
@@ -42,6 +43,7 @@ pub fn validate_no_capitalized_calls(
|
||||
// We don't want to flag CONSTANTS()
|
||||
&& name.as_str() != name.cow_to_uppercase()
|
||||
&& !allow_list.contains(name.as_str())
|
||||
&& !allow_pattern.as_ref().is_some_and(|pattern| pattern.is_match(name.as_str()))
|
||||
{
|
||||
capital_load_globals.insert(lvalue_id, name);
|
||||
}
|
||||
|
||||
@@ -1173,6 +1173,26 @@
|
||||
},
|
||||
"markdownDescription": "The rule takes a single option - an array of possible callback names - which may include object methods. The default callback names are `callback`, `cb`, `next`."
|
||||
},
|
||||
"CapitalizedCallsConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"allow": {
|
||||
"description": "Exact names of capitalized functions that may be called directly.\nForwarded to the React Compiler's `validateNoCapitalizedCalls`\nenvironment option.",
|
||||
"default": [],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"markdownDescription": "Exact names of capitalized functions that may be called directly.\nForwarded to the React Compiler's `validateNoCapitalizedCalls`\nenvironment option."
|
||||
},
|
||||
"allowPattern": {
|
||||
"description": "A regex pattern; capitalized functions whose name matches may be called\ndirectly, checked alongside `allow`. Useful when a codebase has a\nnaming convention for capitalized non-component factories, such as\ngenerated event or schema builders (e.g. `\"Event$\"`).",
|
||||
"type": "string",
|
||||
"markdownDescription": "A regex pattern; capitalized functions whose name matches may be called\ndirectly, checked alongside `allow`. Useful when a codebase has a\nnaming convention for capitalized non-component factories, such as\ngenerated event or schema builders (e.g. `\"Event$\"`)."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"CapitalizedCommentsOptions": {
|
||||
"description": "Configuration for the capitalized-comments rule.\n\nThe first element specifies whether comments should `\"always\"` or `\"never\"`\nbegin with a capital letter. The second element is an optional object\ncontaining additional options.",
|
||||
"type": "array",
|
||||
@@ -6580,7 +6600,24 @@
|
||||
]
|
||||
},
|
||||
"react/capitalized-calls": {
|
||||
"$ref": "#/definitions/RuleNoConfig"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/RuleNoConfig"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": [
|
||||
{
|
||||
"$ref": "#/definitions/AllowWarnDeny"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/CapitalizedCallsConfig"
|
||||
}
|
||||
],
|
||||
"maxItems": 2,
|
||||
"minItems": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
"react/checked-requires-onchange-or-readonly": {
|
||||
"anyOf": [
|
||||
|
||||
Reference in new issue
Block a user