diff --git a/.changeset/small-words-show.md b/.changeset/small-words-show.md new file mode 100644 index 000000000000..c284e3cf6399 --- /dev/null +++ b/.changeset/small-words-show.md @@ -0,0 +1,5 @@ +--- +'@biomejs/biome': patch +--- + +Fixed [#7657](https://github.com/biomejs/biome/issues/7657): Added the new rule [`no-unknown-property`](https://biomejs.dev/linter/rules/no-unknown-property/) from ESLint diff --git a/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs b/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs index 12aecbf76c75..fa0233f24bb8 100644 --- a/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs +++ b/crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs @@ -2441,6 +2441,18 @@ pub(crate) fn migrate_eslint_any_rule( .get_or_insert(Default::default()); rule.set_level(rule.level().max(rule_severity.into())); } + "react/no-unknown-property" => { + if !options.include_nursery { + results.add(eslint_name, eslint_to_biome::RuleMigrationResult::Nursery); + return false; + } + let group = rules.nursery.get_or_insert_with(Default::default); + let rule = group + .unwrap_group_as_mut() + .no_unknown_attribute + .get_or_insert(Default::default()); + rule.set_level(rule.level().max(rule_severity.into())); + } "react/void-dom-elements-no-children" => { let group = rules.correctness.get_or_insert_with(Default::default); let rule = group diff --git a/crates/biome_configuration/src/analyzer/linter/rules.rs b/crates/biome_configuration/src/analyzer/linter/rules.rs index 017b3e4c23b4..b0d4f5d024ad 100644 --- a/crates/biome_configuration/src/analyzer/linter/rules.rs +++ b/crates/biome_configuration/src/analyzer/linter/rules.rs @@ -276,6 +276,7 @@ pub enum RuleName { NoUndeclaredDependencies, NoUndeclaredVariables, NoUnknownAtRules, + NoUnknownAttribute, NoUnknownFunction, NoUnknownMediaFeatureName, NoUnknownProperty, @@ -648,6 +649,7 @@ impl RuleName { Self::NoUndeclaredDependencies => "noUndeclaredDependencies", Self::NoUndeclaredVariables => "noUndeclaredVariables", Self::NoUnknownAtRules => "noUnknownAtRules", + Self::NoUnknownAttribute => "noUnknownAttribute", Self::NoUnknownFunction => "noUnknownFunction", Self::NoUnknownMediaFeatureName => "noUnknownMediaFeatureName", Self::NoUnknownProperty => "noUnknownProperty", @@ -1016,6 +1018,7 @@ impl RuleName { Self::NoUndeclaredDependencies => RuleGroup::Correctness, Self::NoUndeclaredVariables => RuleGroup::Correctness, Self::NoUnknownAtRules => RuleGroup::Suspicious, + Self::NoUnknownAttribute => RuleGroup::Nursery, Self::NoUnknownFunction => RuleGroup::Correctness, Self::NoUnknownMediaFeatureName => RuleGroup::Correctness, Self::NoUnknownProperty => RuleGroup::Correctness, @@ -1393,6 +1396,7 @@ impl std::str::FromStr for RuleName { "noUndeclaredDependencies" => Ok(Self::NoUndeclaredDependencies), "noUndeclaredVariables" => Ok(Self::NoUndeclaredVariables), "noUnknownAtRules" => Ok(Self::NoUnknownAtRules), + "noUnknownAttribute" => Ok(Self::NoUnknownAttribute), "noUnknownFunction" => Ok(Self::NoUnknownFunction), "noUnknownMediaFeatureName" => Ok(Self::NoUnknownMediaFeatureName), "noUnknownProperty" => Ok(Self::NoUnknownProperty), @@ -4704,7 +4708,7 @@ impl From for Correctness { #[cfg_attr(feature = "schema", derive(JsonSchema))] #[serde(rename_all = "camelCase", default, deny_unknown_fields)] #[doc = r" A list of rules that belong to this group"] -pub struct Nursery { # [doc = r" Enables the recommended rules for this group"] # [serde (skip_serializing_if = "Option::is_none")] pub recommended : Option < bool > , # [doc = "Restrict imports of deprecated exports."] # [serde (skip_serializing_if = "Option::is_none")] pub no_deprecated_imports : Option < RuleConfiguration < biome_rule_options :: no_deprecated_imports :: NoDeprecatedImportsOptions >> , # [doc = "Prevent the listing of duplicate dependencies. The rule supports the following dependency groups: \"bundledDependencies\", \"bundleDependencies\", \"dependencies\", \"devDependencies\", \"overrides\", \"optionalDependencies\", and \"peerDependencies\"."] # [serde (skip_serializing_if = "Option::is_none")] pub no_duplicate_dependencies : Option < RuleConfiguration < biome_rule_options :: no_duplicate_dependencies :: NoDuplicateDependenciesOptions >> , # [doc = "Disallow empty sources."] # [serde (skip_serializing_if = "Option::is_none")] pub no_empty_source : Option < RuleConfiguration < biome_rule_options :: no_empty_source :: NoEmptySourceOptions >> , # [doc = "Require Promise-like statements to be handled appropriately."] # [serde (skip_serializing_if = "Option::is_none")] pub no_floating_promises : Option < RuleFixConfiguration < biome_rule_options :: no_floating_promises :: NoFloatingPromisesOptions >> , # [doc = "Prevent import cycles."] # [serde (skip_serializing_if = "Option::is_none")] pub no_import_cycles : Option < RuleConfiguration < biome_rule_options :: no_import_cycles :: NoImportCyclesOptions >> , # [doc = "Disallows the usage of the unary operators ++ and --."] # [serde (skip_serializing_if = "Option::is_none")] pub no_increment_decrement : Option < RuleConfiguration < biome_rule_options :: no_increment_decrement :: NoIncrementDecrementOptions >> , # [doc = "Disallow string literals inside JSX elements."] # [serde (skip_serializing_if = "Option::is_none")] pub no_jsx_literals : Option < RuleConfiguration < biome_rule_options :: no_jsx_literals :: NoJsxLiteralsOptions >> , # [doc = "Disallow Promises to be used in places where they are almost certainly a mistake."] # [serde (skip_serializing_if = "Option::is_none")] pub no_misused_promises : Option < RuleFixConfiguration < biome_rule_options :: no_misused_promises :: NoMisusedPromisesOptions >> , # [doc = "Prevent client components from being async functions."] # [serde (skip_serializing_if = "Option::is_none")] pub no_next_async_client_component : Option < RuleConfiguration < biome_rule_options :: no_next_async_client_component :: NoNextAsyncClientComponentOptions >> , # [doc = "Disallow function parameters that are only used in recursive calls."] # [serde (skip_serializing_if = "Option::is_none")] pub no_parameters_only_used_in_recursion : Option < RuleFixConfiguration < biome_rule_options :: no_parameters_only_used_in_recursion :: NoParametersOnlyUsedInRecursionOptions >> , # [doc = "Replaces usages of forwardRef with passing ref as a prop."] # [serde (skip_serializing_if = "Option::is_none")] pub no_react_forward_ref : Option < RuleFixConfiguration < biome_rule_options :: no_react_forward_ref :: NoReactForwardRefOptions >> , # [doc = "Disallow variable declarations from shadowing variables declared in the outer scope."] # [serde (skip_serializing_if = "Option::is_none")] pub no_shadow : Option < RuleConfiguration < biome_rule_options :: no_shadow :: NoShadowOptions >> , # [doc = "Disallow unnecessary type-based conditions that can be statically determined as redundant."] # [serde (skip_serializing_if = "Option::is_none")] pub no_unnecessary_conditions : Option < RuleConfiguration < biome_rule_options :: no_unnecessary_conditions :: NoUnnecessaryConditionsOptions >> , # [doc = "Warn when importing non-existing exports."] # [serde (skip_serializing_if = "Option::is_none")] pub no_unresolved_imports : Option < RuleConfiguration < biome_rule_options :: no_unresolved_imports :: NoUnresolvedImportsOptions >> , # [doc = "Disallow expression statements that are neither a function call nor an assignment."] # [serde (skip_serializing_if = "Option::is_none")] pub no_unused_expressions : Option < RuleConfiguration < biome_rule_options :: no_unused_expressions :: NoUnusedExpressionsOptions >> , # [doc = "Disallow unused catch bindings."] # [serde (skip_serializing_if = "Option::is_none")] pub no_useless_catch_binding : Option < RuleFixConfiguration < biome_rule_options :: no_useless_catch_binding :: NoUselessCatchBindingOptions >> , # [doc = "Disallow the use of useless undefined."] # [serde (skip_serializing_if = "Option::is_none")] pub no_useless_undefined : Option < RuleFixConfiguration < biome_rule_options :: no_useless_undefined :: NoUselessUndefinedOptions >> , # [doc = "Enforce that Vue component data options are declared as functions."] # [serde (skip_serializing_if = "Option::is_none")] pub no_vue_data_object_declaration : Option < RuleFixConfiguration < biome_rule_options :: no_vue_data_object_declaration :: NoVueDataObjectDeclarationOptions >> , # [doc = "Disallow duplicate keys in Vue component data, methods, computed properties, and other options."] # [serde (skip_serializing_if = "Option::is_none")] pub no_vue_duplicate_keys : Option < RuleConfiguration < biome_rule_options :: no_vue_duplicate_keys :: NoVueDuplicateKeysOptions >> , # [doc = "Disallow reserved keys in Vue component data and computed properties."] # [serde (skip_serializing_if = "Option::is_none")] pub no_vue_reserved_keys : Option < RuleConfiguration < biome_rule_options :: no_vue_reserved_keys :: NoVueReservedKeysOptions >> , # [doc = "Disallow reserved names to be used as props."] # [serde (skip_serializing_if = "Option::is_none")] pub no_vue_reserved_props : Option < RuleConfiguration < biome_rule_options :: no_vue_reserved_props :: NoVueReservedPropsOptions >> , # [doc = "Enforce consistent arrow function bodies."] # [serde (skip_serializing_if = "Option::is_none")] pub use_consistent_arrow_return : Option < RuleFixConfiguration < biome_rule_options :: use_consistent_arrow_return :: UseConsistentArrowReturnOptions >> , # [doc = "Require the @deprecated directive to specify a deletion date."] # [serde (skip_serializing_if = "Option::is_none")] pub use_deprecated_date : Option < RuleConfiguration < biome_rule_options :: use_deprecated_date :: UseDeprecatedDateOptions >> , # [doc = "Require switch-case statements to be exhaustive."] # [serde (skip_serializing_if = "Option::is_none")] pub use_exhaustive_switch_cases : Option < RuleFixConfiguration < biome_rule_options :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCasesOptions >> , # [doc = "Enforce types in functions, methods, variables, and parameters."] # [serde (skip_serializing_if = "Option::is_none")] pub use_explicit_type : Option < RuleConfiguration < biome_rule_options :: use_explicit_type :: UseExplicitTypeOptions >> , # [doc = "Enforce a maximum number of parameters in function definitions."] # [serde (skip_serializing_if = "Option::is_none")] pub use_max_params : Option < RuleConfiguration < biome_rule_options :: use_max_params :: UseMaxParamsOptions >> , # [doc = "Disallow use* hooks outside of component$ or other use* hooks in Qwik applications."] # [serde (skip_serializing_if = "Option::is_none")] pub use_qwik_method_usage : Option < RuleConfiguration < biome_rule_options :: use_qwik_method_usage :: UseQwikMethodUsageOptions >> , # [doc = "Disallow unserializable expressions in Qwik dollar ($) scopes."] # [serde (skip_serializing_if = "Option::is_none")] pub use_qwik_valid_lexical_scope : Option < RuleConfiguration < biome_rule_options :: use_qwik_valid_lexical_scope :: UseQwikValidLexicalScopeOptions >> , # [doc = "Enforce the sorting of CSS utility classes."] # [serde (skip_serializing_if = "Option::is_none")] pub use_sorted_classes : Option < RuleFixConfiguration < biome_rule_options :: use_sorted_classes :: UseSortedClassesOptions >> , # [doc = "Enforce specific order of Vue compiler macros."] # [serde (skip_serializing_if = "Option::is_none")] pub use_vue_define_macros_order : Option < RuleFixConfiguration < biome_rule_options :: use_vue_define_macros_order :: UseVueDefineMacrosOrderOptions >> , # [doc = "Enforce multi-word component names in Vue components."] # [serde (skip_serializing_if = "Option::is_none")] pub use_vue_multi_word_component_names : Option < RuleConfiguration < biome_rule_options :: use_vue_multi_word_component_names :: UseVueMultiWordComponentNamesOptions >> } +pub struct Nursery { # [doc = r" Enables the recommended rules for this group"] # [serde (skip_serializing_if = "Option::is_none")] pub recommended : Option < bool > , # [doc = "Restrict imports of deprecated exports."] # [serde (skip_serializing_if = "Option::is_none")] pub no_deprecated_imports : Option < RuleConfiguration < biome_rule_options :: no_deprecated_imports :: NoDeprecatedImportsOptions >> , # [doc = "Prevent the listing of duplicate dependencies. The rule supports the following dependency groups: \"bundledDependencies\", \"bundleDependencies\", \"dependencies\", \"devDependencies\", \"overrides\", \"optionalDependencies\", and \"peerDependencies\"."] # [serde (skip_serializing_if = "Option::is_none")] pub no_duplicate_dependencies : Option < RuleConfiguration < biome_rule_options :: no_duplicate_dependencies :: NoDuplicateDependenciesOptions >> , # [doc = "Disallow empty sources."] # [serde (skip_serializing_if = "Option::is_none")] pub no_empty_source : Option < RuleConfiguration < biome_rule_options :: no_empty_source :: NoEmptySourceOptions >> , # [doc = "Require Promise-like statements to be handled appropriately."] # [serde (skip_serializing_if = "Option::is_none")] pub no_floating_promises : Option < RuleFixConfiguration < biome_rule_options :: no_floating_promises :: NoFloatingPromisesOptions >> , # [doc = "Prevent import cycles."] # [serde (skip_serializing_if = "Option::is_none")] pub no_import_cycles : Option < RuleConfiguration < biome_rule_options :: no_import_cycles :: NoImportCyclesOptions >> , # [doc = "Disallows the usage of the unary operators ++ and --."] # [serde (skip_serializing_if = "Option::is_none")] pub no_increment_decrement : Option < RuleConfiguration < biome_rule_options :: no_increment_decrement :: NoIncrementDecrementOptions >> , # [doc = "Disallow string literals inside JSX elements."] # [serde (skip_serializing_if = "Option::is_none")] pub no_jsx_literals : Option < RuleConfiguration < biome_rule_options :: no_jsx_literals :: NoJsxLiteralsOptions >> , # [doc = "Disallow Promises to be used in places where they are almost certainly a mistake."] # [serde (skip_serializing_if = "Option::is_none")] pub no_misused_promises : Option < RuleFixConfiguration < biome_rule_options :: no_misused_promises :: NoMisusedPromisesOptions >> , # [doc = "Prevent client components from being async functions."] # [serde (skip_serializing_if = "Option::is_none")] pub no_next_async_client_component : Option < RuleConfiguration < biome_rule_options :: no_next_async_client_component :: NoNextAsyncClientComponentOptions >> , # [doc = "Disallow function parameters that are only used in recursive calls."] # [serde (skip_serializing_if = "Option::is_none")] pub no_parameters_only_used_in_recursion : Option < RuleFixConfiguration < biome_rule_options :: no_parameters_only_used_in_recursion :: NoParametersOnlyUsedInRecursionOptions >> , # [doc = "Replaces usages of forwardRef with passing ref as a prop."] # [serde (skip_serializing_if = "Option::is_none")] pub no_react_forward_ref : Option < RuleFixConfiguration < biome_rule_options :: no_react_forward_ref :: NoReactForwardRefOptions >> , # [doc = "Disallow variable declarations from shadowing variables declared in the outer scope."] # [serde (skip_serializing_if = "Option::is_none")] pub no_shadow : Option < RuleConfiguration < biome_rule_options :: no_shadow :: NoShadowOptions >> , # [doc = "Disallow unknown DOM properties."] # [serde (skip_serializing_if = "Option::is_none")] pub no_unknown_attribute : Option < RuleConfiguration < biome_rule_options :: no_unknown_attribute :: NoUnknownAttributeOptions >> , # [doc = "Disallow unnecessary type-based conditions that can be statically determined as redundant."] # [serde (skip_serializing_if = "Option::is_none")] pub no_unnecessary_conditions : Option < RuleConfiguration < biome_rule_options :: no_unnecessary_conditions :: NoUnnecessaryConditionsOptions >> , # [doc = "Warn when importing non-existing exports."] # [serde (skip_serializing_if = "Option::is_none")] pub no_unresolved_imports : Option < RuleConfiguration < biome_rule_options :: no_unresolved_imports :: NoUnresolvedImportsOptions >> , # [doc = "Disallow expression statements that are neither a function call nor an assignment."] # [serde (skip_serializing_if = "Option::is_none")] pub no_unused_expressions : Option < RuleConfiguration < biome_rule_options :: no_unused_expressions :: NoUnusedExpressionsOptions >> , # [doc = "Disallow unused catch bindings."] # [serde (skip_serializing_if = "Option::is_none")] pub no_useless_catch_binding : Option < RuleFixConfiguration < biome_rule_options :: no_useless_catch_binding :: NoUselessCatchBindingOptions >> , # [doc = "Disallow the use of useless undefined."] # [serde (skip_serializing_if = "Option::is_none")] pub no_useless_undefined : Option < RuleFixConfiguration < biome_rule_options :: no_useless_undefined :: NoUselessUndefinedOptions >> , # [doc = "Enforce that Vue component data options are declared as functions."] # [serde (skip_serializing_if = "Option::is_none")] pub no_vue_data_object_declaration : Option < RuleFixConfiguration < biome_rule_options :: no_vue_data_object_declaration :: NoVueDataObjectDeclarationOptions >> , # [doc = "Disallow duplicate keys in Vue component data, methods, computed properties, and other options."] # [serde (skip_serializing_if = "Option::is_none")] pub no_vue_duplicate_keys : Option < RuleConfiguration < biome_rule_options :: no_vue_duplicate_keys :: NoVueDuplicateKeysOptions >> , # [doc = "Disallow reserved keys in Vue component data and computed properties."] # [serde (skip_serializing_if = "Option::is_none")] pub no_vue_reserved_keys : Option < RuleConfiguration < biome_rule_options :: no_vue_reserved_keys :: NoVueReservedKeysOptions >> , # [doc = "Disallow reserved names to be used as props."] # [serde (skip_serializing_if = "Option::is_none")] pub no_vue_reserved_props : Option < RuleConfiguration < biome_rule_options :: no_vue_reserved_props :: NoVueReservedPropsOptions >> , # [doc = "Enforce consistent arrow function bodies."] # [serde (skip_serializing_if = "Option::is_none")] pub use_consistent_arrow_return : Option < RuleFixConfiguration < biome_rule_options :: use_consistent_arrow_return :: UseConsistentArrowReturnOptions >> , # [doc = "Require the @deprecated directive to specify a deletion date."] # [serde (skip_serializing_if = "Option::is_none")] pub use_deprecated_date : Option < RuleConfiguration < biome_rule_options :: use_deprecated_date :: UseDeprecatedDateOptions >> , # [doc = "Require switch-case statements to be exhaustive."] # [serde (skip_serializing_if = "Option::is_none")] pub use_exhaustive_switch_cases : Option < RuleFixConfiguration < biome_rule_options :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCasesOptions >> , # [doc = "Enforce types in functions, methods, variables, and parameters."] # [serde (skip_serializing_if = "Option::is_none")] pub use_explicit_type : Option < RuleConfiguration < biome_rule_options :: use_explicit_type :: UseExplicitTypeOptions >> , # [doc = "Enforce a maximum number of parameters in function definitions."] # [serde (skip_serializing_if = "Option::is_none")] pub use_max_params : Option < RuleConfiguration < biome_rule_options :: use_max_params :: UseMaxParamsOptions >> , # [doc = "Disallow use* hooks outside of component$ or other use* hooks in Qwik applications."] # [serde (skip_serializing_if = "Option::is_none")] pub use_qwik_method_usage : Option < RuleConfiguration < biome_rule_options :: use_qwik_method_usage :: UseQwikMethodUsageOptions >> , # [doc = "Disallow unserializable expressions in Qwik dollar ($) scopes."] # [serde (skip_serializing_if = "Option::is_none")] pub use_qwik_valid_lexical_scope : Option < RuleConfiguration < biome_rule_options :: use_qwik_valid_lexical_scope :: UseQwikValidLexicalScopeOptions >> , # [doc = "Enforce the sorting of CSS utility classes."] # [serde (skip_serializing_if = "Option::is_none")] pub use_sorted_classes : Option < RuleFixConfiguration < biome_rule_options :: use_sorted_classes :: UseSortedClassesOptions >> , # [doc = "Enforce specific order of Vue compiler macros."] # [serde (skip_serializing_if = "Option::is_none")] pub use_vue_define_macros_order : Option < RuleFixConfiguration < biome_rule_options :: use_vue_define_macros_order :: UseVueDefineMacrosOrderOptions >> , # [doc = "Enforce multi-word component names in Vue components."] # [serde (skip_serializing_if = "Option::is_none")] pub use_vue_multi_word_component_names : Option < RuleConfiguration < biome_rule_options :: use_vue_multi_word_component_names :: UseVueMultiWordComponentNamesOptions >> } impl Nursery { const GROUP_NAME: &'static str = "nursery"; pub(crate) const GROUP_RULES: &'static [&'static str] = &[ @@ -4720,6 +4724,7 @@ impl Nursery { "noParametersOnlyUsedInRecursion", "noReactForwardRef", "noShadow", + "noUnknownAttribute", "noUnnecessaryConditions", "noUnresolvedImports", "noUnusedExpressions", @@ -4773,6 +4778,7 @@ impl Nursery { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[28]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[29]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[30]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[31]), ]; } impl RuleGroupExt for Nursery { @@ -4844,101 +4850,106 @@ impl RuleGroupExt for Nursery { { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[11])); } - if let Some(rule) = self.no_unnecessary_conditions.as_ref() + if let Some(rule) = self.no_unknown_attribute.as_ref() && rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[12])); } - if let Some(rule) = self.no_unresolved_imports.as_ref() + if let Some(rule) = self.no_unnecessary_conditions.as_ref() && rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[13])); } - if let Some(rule) = self.no_unused_expressions.as_ref() + if let Some(rule) = self.no_unresolved_imports.as_ref() && rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[14])); } - if let Some(rule) = self.no_useless_catch_binding.as_ref() + if let Some(rule) = self.no_unused_expressions.as_ref() && rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[15])); } - if let Some(rule) = self.no_useless_undefined.as_ref() + if let Some(rule) = self.no_useless_catch_binding.as_ref() && rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[16])); } - if let Some(rule) = self.no_vue_data_object_declaration.as_ref() + if let Some(rule) = self.no_useless_undefined.as_ref() && rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[17])); } - if let Some(rule) = self.no_vue_duplicate_keys.as_ref() + if let Some(rule) = self.no_vue_data_object_declaration.as_ref() && rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[18])); } - if let Some(rule) = self.no_vue_reserved_keys.as_ref() + if let Some(rule) = self.no_vue_duplicate_keys.as_ref() && rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[19])); } - if let Some(rule) = self.no_vue_reserved_props.as_ref() + if let Some(rule) = self.no_vue_reserved_keys.as_ref() && rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[20])); } - if let Some(rule) = self.use_consistent_arrow_return.as_ref() + if let Some(rule) = self.no_vue_reserved_props.as_ref() && rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[21])); } - if let Some(rule) = self.use_deprecated_date.as_ref() + if let Some(rule) = self.use_consistent_arrow_return.as_ref() && rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[22])); } - if let Some(rule) = self.use_exhaustive_switch_cases.as_ref() + if let Some(rule) = self.use_deprecated_date.as_ref() && rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[23])); } - if let Some(rule) = self.use_explicit_type.as_ref() + if let Some(rule) = self.use_exhaustive_switch_cases.as_ref() && rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[24])); } - if let Some(rule) = self.use_max_params.as_ref() + if let Some(rule) = self.use_explicit_type.as_ref() && rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[25])); } - if let Some(rule) = self.use_qwik_method_usage.as_ref() + if let Some(rule) = self.use_max_params.as_ref() && rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[26])); } - if let Some(rule) = self.use_qwik_valid_lexical_scope.as_ref() + if let Some(rule) = self.use_qwik_method_usage.as_ref() && rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[27])); } - if let Some(rule) = self.use_sorted_classes.as_ref() + if let Some(rule) = self.use_qwik_valid_lexical_scope.as_ref() && rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[28])); } - if let Some(rule) = self.use_vue_define_macros_order.as_ref() + if let Some(rule) = self.use_sorted_classes.as_ref() && rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[29])); } - if let Some(rule) = self.use_vue_multi_word_component_names.as_ref() + if let Some(rule) = self.use_vue_define_macros_order.as_ref() && rule.is_enabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[30])); } + if let Some(rule) = self.use_vue_multi_word_component_names.as_ref() + && rule.is_enabled() + { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[31])); + } index_set } fn get_disabled_rules(&self) -> FxHashSet> { @@ -5003,101 +5014,106 @@ impl RuleGroupExt for Nursery { { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[11])); } - if let Some(rule) = self.no_unnecessary_conditions.as_ref() + if let Some(rule) = self.no_unknown_attribute.as_ref() && rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[12])); } - if let Some(rule) = self.no_unresolved_imports.as_ref() + if let Some(rule) = self.no_unnecessary_conditions.as_ref() && rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[13])); } - if let Some(rule) = self.no_unused_expressions.as_ref() + if let Some(rule) = self.no_unresolved_imports.as_ref() && rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[14])); } - if let Some(rule) = self.no_useless_catch_binding.as_ref() + if let Some(rule) = self.no_unused_expressions.as_ref() && rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[15])); } - if let Some(rule) = self.no_useless_undefined.as_ref() + if let Some(rule) = self.no_useless_catch_binding.as_ref() && rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[16])); } - if let Some(rule) = self.no_vue_data_object_declaration.as_ref() + if let Some(rule) = self.no_useless_undefined.as_ref() && rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[17])); } - if let Some(rule) = self.no_vue_duplicate_keys.as_ref() + if let Some(rule) = self.no_vue_data_object_declaration.as_ref() && rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[18])); } - if let Some(rule) = self.no_vue_reserved_keys.as_ref() + if let Some(rule) = self.no_vue_duplicate_keys.as_ref() && rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[19])); } - if let Some(rule) = self.no_vue_reserved_props.as_ref() + if let Some(rule) = self.no_vue_reserved_keys.as_ref() && rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[20])); } - if let Some(rule) = self.use_consistent_arrow_return.as_ref() + if let Some(rule) = self.no_vue_reserved_props.as_ref() && rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[21])); } - if let Some(rule) = self.use_deprecated_date.as_ref() + if let Some(rule) = self.use_consistent_arrow_return.as_ref() && rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[22])); } - if let Some(rule) = self.use_exhaustive_switch_cases.as_ref() + if let Some(rule) = self.use_deprecated_date.as_ref() && rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[23])); } - if let Some(rule) = self.use_explicit_type.as_ref() + if let Some(rule) = self.use_exhaustive_switch_cases.as_ref() && rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[24])); } - if let Some(rule) = self.use_max_params.as_ref() + if let Some(rule) = self.use_explicit_type.as_ref() && rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[25])); } - if let Some(rule) = self.use_qwik_method_usage.as_ref() + if let Some(rule) = self.use_max_params.as_ref() && rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[26])); } - if let Some(rule) = self.use_qwik_valid_lexical_scope.as_ref() + if let Some(rule) = self.use_qwik_method_usage.as_ref() && rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[27])); } - if let Some(rule) = self.use_sorted_classes.as_ref() + if let Some(rule) = self.use_qwik_valid_lexical_scope.as_ref() && rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[28])); } - if let Some(rule) = self.use_vue_define_macros_order.as_ref() + if let Some(rule) = self.use_sorted_classes.as_ref() && rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[29])); } - if let Some(rule) = self.use_vue_multi_word_component_names.as_ref() + if let Some(rule) = self.use_vue_define_macros_order.as_ref() && rule.is_disabled() { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[30])); } + if let Some(rule) = self.use_vue_multi_word_component_names.as_ref() + && rule.is_disabled() + { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[31])); + } index_set } #[doc = r" Checks if, given a rule name, matches one of the rules contained in this category"] @@ -5176,6 +5192,10 @@ impl RuleGroupExt for Nursery { .no_shadow .as_ref() .map(|conf| (conf.level(), conf.get_options())), + "noUnknownAttribute" => self + .no_unknown_attribute + .as_ref() + .map(|conf| (conf.level(), conf.get_options())), "noUnnecessaryConditions" => self .no_unnecessary_conditions .as_ref() @@ -5272,6 +5292,7 @@ impl From for Nursery { no_parameters_only_used_in_recursion: Some(value.into()), no_react_forward_ref: Some(value.into()), no_shadow: Some(value.into()), + no_unknown_attribute: Some(value.into()), no_unnecessary_conditions: Some(value.into()), no_unresolved_imports: Some(value.into()), no_unused_expressions: Some(value.into()), diff --git a/crates/biome_configuration/src/generated/domain_selector.rs b/crates/biome_configuration/src/generated/domain_selector.rs index 4526c79824ae..48d2b01aa562 100644 --- a/crates/biome_configuration/src/generated/domain_selector.rs +++ b/crates/biome_configuration/src/generated/domain_selector.rs @@ -53,6 +53,7 @@ static REACT_FILTERS: LazyLock>> = LazyLock::new(|| { RuleFilter::Rule("correctness", "useJsxKeyInIterable"), RuleFilter::Rule("correctness", "useUniqueElementIds"), RuleFilter::Rule("nursery", "noReactForwardRef"), + RuleFilter::Rule("nursery", "noUnknownAttribute"), RuleFilter::Rule("security", "noDangerouslySetInnerHtml"), RuleFilter::Rule("security", "noDangerouslySetInnerHtmlWithChildren"), RuleFilter::Rule("style", "useComponentExportOnlyModules"), diff --git a/crates/biome_diagnostics_categories/src/categories.rs b/crates/biome_diagnostics_categories/src/categories.rs index b2d9446cab37..de4b6dab3b31 100644 --- a/crates/biome_diagnostics_categories/src/categories.rs +++ b/crates/biome_diagnostics_categories/src/categories.rs @@ -179,6 +179,7 @@ define_categories! { "lint/nursery/noParametersOnlyUsedInRecursion": "https://biomejs.dev/linter/rules/no-parameters-only-used-in-recursion", "lint/nursery/noReactForwardRef": "https://biomejs.dev/linter/rules/no-react-forward-ref", "lint/nursery/noShadow": "https://biomejs.dev/linter/rules/no-shadow", + "lint/nursery/noUnknownAttribute": "https://biomejs.dev/linter/rules/no-unknown-attribute", "lint/nursery/noUnnecessaryConditions": "https://biomejs.dev/linter/rules/no-unnecessary-conditions", "lint/nursery/noUnresolvedImports": "https://biomejs.dev/linter/rules/no-unresolved-imports", "lint/nursery/noUnusedExpressions": "https://biomejs.dev/linter/rules/no-unused-expressions", diff --git a/crates/biome_js_analyze/src/lint/nursery.rs b/crates/biome_js_analyze/src/lint/nursery.rs index f6ce17dd4abf..3601414342ea 100644 --- a/crates/biome_js_analyze/src/lint/nursery.rs +++ b/crates/biome_js_analyze/src/lint/nursery.rs @@ -14,6 +14,7 @@ pub mod no_next_async_client_component; pub mod no_parameters_only_used_in_recursion; pub mod no_react_forward_ref; pub mod no_shadow; +pub mod no_unknown_attribute; pub mod no_unnecessary_conditions; pub mod no_unresolved_imports; pub mod no_unused_expressions; @@ -32,4 +33,4 @@ pub mod use_qwik_valid_lexical_scope; pub mod use_sorted_classes; pub mod use_vue_define_macros_order; pub mod use_vue_multi_word_component_names; -declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_deprecated_imports :: NoDeprecatedImports , self :: no_empty_source :: NoEmptySource , self :: no_floating_promises :: NoFloatingPromises , self :: no_import_cycles :: NoImportCycles , self :: no_increment_decrement :: NoIncrementDecrement , self :: no_jsx_literals :: NoJsxLiterals , self :: no_misused_promises :: NoMisusedPromises , self :: no_next_async_client_component :: NoNextAsyncClientComponent , self :: no_parameters_only_used_in_recursion :: NoParametersOnlyUsedInRecursion , self :: no_react_forward_ref :: NoReactForwardRef , self :: no_shadow :: NoShadow , self :: no_unnecessary_conditions :: NoUnnecessaryConditions , self :: no_unresolved_imports :: NoUnresolvedImports , self :: no_unused_expressions :: NoUnusedExpressions , self :: no_useless_catch_binding :: NoUselessCatchBinding , self :: no_useless_undefined :: NoUselessUndefined , self :: no_vue_data_object_declaration :: NoVueDataObjectDeclaration , self :: no_vue_duplicate_keys :: NoVueDuplicateKeys , self :: no_vue_reserved_keys :: NoVueReservedKeys , self :: no_vue_reserved_props :: NoVueReservedProps , self :: use_consistent_arrow_return :: UseConsistentArrowReturn , self :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCases , self :: use_explicit_type :: UseExplicitType , self :: use_max_params :: UseMaxParams , self :: use_qwik_method_usage :: UseQwikMethodUsage , self :: use_qwik_valid_lexical_scope :: UseQwikValidLexicalScope , self :: use_sorted_classes :: UseSortedClasses , self :: use_vue_define_macros_order :: UseVueDefineMacrosOrder , self :: use_vue_multi_word_component_names :: UseVueMultiWordComponentNames ,] } } +declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_deprecated_imports :: NoDeprecatedImports , self :: no_empty_source :: NoEmptySource , self :: no_floating_promises :: NoFloatingPromises , self :: no_import_cycles :: NoImportCycles , self :: no_increment_decrement :: NoIncrementDecrement , self :: no_jsx_literals :: NoJsxLiterals , self :: no_misused_promises :: NoMisusedPromises , self :: no_next_async_client_component :: NoNextAsyncClientComponent , self :: no_parameters_only_used_in_recursion :: NoParametersOnlyUsedInRecursion , self :: no_react_forward_ref :: NoReactForwardRef , self :: no_shadow :: NoShadow , self :: no_unknown_attribute :: NoUnknownAttribute , self :: no_unnecessary_conditions :: NoUnnecessaryConditions , self :: no_unresolved_imports :: NoUnresolvedImports , self :: no_unused_expressions :: NoUnusedExpressions , self :: no_useless_catch_binding :: NoUselessCatchBinding , self :: no_useless_undefined :: NoUselessUndefined , self :: no_vue_data_object_declaration :: NoVueDataObjectDeclaration , self :: no_vue_duplicate_keys :: NoVueDuplicateKeys , self :: no_vue_reserved_keys :: NoVueReservedKeys , self :: no_vue_reserved_props :: NoVueReservedProps , self :: use_consistent_arrow_return :: UseConsistentArrowReturn , self :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCases , self :: use_explicit_type :: UseExplicitType , self :: use_max_params :: UseMaxParams , self :: use_qwik_method_usage :: UseQwikMethodUsage , self :: use_qwik_valid_lexical_scope :: UseQwikValidLexicalScope , self :: use_sorted_classes :: UseSortedClasses , self :: use_vue_define_macros_order :: UseVueDefineMacrosOrder , self :: use_vue_multi_word_component_names :: UseVueMultiWordComponentNames ,] } } diff --git a/crates/biome_js_analyze/src/lint/nursery/no_unknown_attribute.rs b/crates/biome_js_analyze/src/lint/nursery/no_unknown_attribute.rs new file mode 100644 index 000000000000..167680f552bf --- /dev/null +++ b/crates/biome_js_analyze/src/lint/nursery/no_unknown_attribute.rs @@ -0,0 +1,1188 @@ +use biome_analyze::{Rule, RuleDiagnostic, context::RuleContext, declare_lint_rule}; +use biome_analyze::{RuleDomain, RuleSource}; +use biome_console::markup; +use biome_js_syntax::AnyJsxAttributeName; +use biome_js_syntax::{AnyJsxElementName, JsxAttribute, jsx_ext::AnyJsxElement}; +use biome_package::PackageJson; +use biome_rowan::{AstNode, TokenText}; +use biome_rule_options::no_unknown_attribute::NoUnknownAttributeOptions; +use camino::Utf8PathBuf; +use rustc_hash::FxHashMap; +use std::sync::{Arc, LazyLock}; + +use crate::services::manifest::Manifest; + +declare_lint_rule! { + /// Disallow unknown DOM properties. + /// + /// In JSX, most DOM properties and attributes should be camelCased to be consistent with standard JavaScript style. + /// This can be a possible source of error if you are used to writing plain HTML. + /// Only `data-*` and `aria-*` attributes are allowed to use hyphens and lowercase letters in JSX. + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```jsx,expect_diagnostic + ///
+ /// ``` + /// + /// ```jsx,expect_diagnostic + ///
{}} /> + /// ``` + /// + /// ```jsx,expect_diagnostic + ///
+ /// ``` + /// + /// ### Valid + /// + /// ```jsx + ///
+ /// ``` + /// + /// ```jsx + ///
{}} /> + /// ``` + /// + /// ```jsx + ///
+ /// ``` + /// + /// ```jsx + ///
+ /// ``` + /// + /// ```jsx + ///
+ /// ``` + /// + /// ## Options + /// + /// ### `ignore` + /// + /// An array of property and attribute names to ignore during validation. + /// + /// ```json + /// { + /// "noUnknownAttribute": { + /// "options": { + /// "ignore": ["custom-attribute", "non-standard-prop"] + /// } + /// } + /// } + /// ``` + pub NoUnknownAttribute { + version: "next", + name: "noUnknownAttribute", + language: "jsx", + domains: &[RuleDomain::React], + sources: &[ + RuleSource::EslintReact("no-unknown-property").same(), + ], + recommended: false, + } +} + +/** + * Popover API properties added in React 19 + */ +const POPOVER_API_PROPS: &[&str] = &[ + "onBeforeToggle", + "popover", + "popoverTarget", + "popoverTargetAction", +]; + +const POPOVER_API_PROPS_LOWERCASE: &[&str] = &[ + "onbeforetoggle", + "popover", + "popovertarget", + "popovertargetaction", +]; +const ATTRIBUTE_TAGS_MAP: &[(&str, &[&str])] = &[ + ("abbr", &["th", "td"]), + ( + "align", + &[ + "applet", "caption", "col", "colgroup", "hr", "iframe", "img", "table", "tbody", "td", + "tfoot", "th", "thead", "tr", + ], + ), + ("allowFullScreen", &["iframe", "video"]), + ("as", &["link"]), + ("autoPictureInPicture", &["video"]), + ("charset", &["meta"]), + ("checked", &["input"]), + ("controls", &["audio", "video"]), + ("controlsList", &["audio", "video"]), + ( + "crossOrigin", + &["script", "img", "video", "audio", "link", "image"], + ), + ("disablePictureInPicture", &["video"]), + ("disableRemotePlayback", &["audio", "video"]), + ("displaystyle", &["math"]), + ("download", &["a", "area"]), + ( + "fill", + &[ + "altGlyph", + "circle", + "ellipse", + "g", + "line", + "marker", + "mask", + "path", + "polygon", + "polyline", + "rect", + "svg", + "symbol", + "text", + "textPath", + "tref", + "tspan", + "use", + "animate", + "animateColor", + "animateMotion", + "animateTransform", + "set", + ], + ), + ("focusable", &["svg"]), + ("imageSizes", &["link"]), + ("imageSrcSet", &["link"]), + ("loop", &["audio", "video"]), + ("mozAllowFullScreen", &["iframe", "video"]), + ("muted", &["audio", "video"]), + ("noModule", &["script"]), + ("onAbort", &["audio", "video"]), + ("onCanPlay", &["audio", "video"]), + ("onCanPlayThrough", &["audio", "video"]), + ("onCancel", &["dialog"]), + ("onClose", &["dialog"]), + ("onDurationChange", &["audio", "video"]), + ("onEmptied", &["audio", "video"]), + ("onEncrypted", &["audio", "video"]), + ("onEnded", &["audio", "video"]), + ( + "onError", + &[ + "audio", "video", "img", "link", "source", "script", "picture", "iframe", + ], + ), + ( + "onLoad", + &[ + "script", "img", "link", "picture", "iframe", "object", "source", + ], + ), + ("onLoadStart", &["audio", "video"]), + ("onLoadedData", &["audio", "video"]), + ("onLoadedMetadata", &["audio", "video"]), + ("onPause", &["audio", "video"]), + ("onPlay", &["audio", "video"]), + ("onPlaying", &["audio", "video"]), + ("onProgress", &["audio", "video"]), + ("onRateChange", &["audio", "video"]), + ("onResize", &["audio", "video"]), + ("onSeeked", &["audio", "video"]), + ("onSeeking", &["audio", "video"]), + ("onStalled", &["audio", "video"]), + ("onSuspend", &["audio", "video"]), + ("onTimeUpdate", &["audio", "video"]), + ("onVolumeChange", &["audio", "video"]), + ("onWaiting", &["audio", "video"]), + ("playsInline", &["video"]), + ("poster", &["video"]), + ("preload", &["audio", "video"]), + ("property", &["meta"]), + ("returnValue", &["dialog"]), + ("scrolling", &["iframe"]), + ( + "valign", + &[ + "tr", "td", "th", "thead", "tbody", "tfoot", "colgroup", "col", + ], + ), + ("viewBox", &["marker", "pattern", "svg", "symbol", "view"]), + ("webkitAllowFullScreen", &["iframe", "video"]), + ("webkitDirectory", &["input"]), +]; + +static ATTRIBUTE_TAGS_LOOKUP: LazyLock> = + LazyLock::new(|| ATTRIBUTE_TAGS_MAP.iter().copied().collect()); + +const ARIA_PROPERTIES: [&str; 53] = [ + // See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes + // Global attributes + "aria-atomic", + "aria-braillelabel", + "aria-brailleroledescription", + "aria-busy", + "aria-controls", + "aria-current", + "aria-describedby", + "aria-description", + "aria-details", + "aria-disabled", + "aria-dropeffect", + "aria-errormessage", + "aria-flowto", + "aria-grabbed", + "aria-haspopup", + "aria-hidden", + "aria-invalid", + "aria-keyshortcuts", + "aria-label", + "aria-labelledby", + "aria-live", + "aria-owns", + "aria-relevant", + "aria-roledescription", + // Widget attributes + "aria-autocomplete", + "aria-checked", + "aria-expanded", + "aria-level", + "aria-modal", + "aria-multiline", + "aria-multiselectable", + "aria-orientation", + "aria-placeholder", + "aria-pressed", + "aria-readonly", + "aria-required", + "aria-selected", + "aria-sort", + "aria-valuemax", + "aria-valuemin", + "aria-valuenow", + "aria-valuetext", + // Relationship attributes + "aria-activedescendant", + "aria-colcount", + "aria-colindex", + "aria-colindextext", + "aria-colspan", + "aria-posinset", + "aria-rowcount", + "aria-rowindex", + "aria-rowindextext", + "aria-rowspan", + "aria-setsize", +]; + +const DOM_PROPERTIES_IGNORE_CASE: [&str; 5] = [ + "allowFullScreen", + "charset", + "mozAllowFullScreen", + "webkitAllowFullScreen", + "webkitDirectory", +]; + +const DOM_ATTRIBUTE_NAMES: &[(&str, &str)] = &[ + ("accept-charset", "acceptCharset"), + ("accent-height", "accentHeight"), + ("alignment-baseline", "alignmentBaseline"), + ("arabic-form", "arabicForm"), + ("baseline-shift", "baselineShift"), + ("cap-height", "capHeight"), + ("class", "className"), + ("clip-path", "clipPath"), + ("clip-rule", "clipRule"), + ("color-interpolation", "colorInterpolation"), + ("color-interpolation-filters", "colorInterpolationFilters"), + ("color-profile", "colorProfile"), + ("color-rendering", "colorRendering"), + ("crossorigin", "crossOrigin"), + ("dominant-baseline", "dominantBaseline"), + ("enable-background", "enableBackground"), + ("fill-opacity", "fillOpacity"), + ("fill-rule", "fillRule"), + ("flood-color", "floodColor"), + ("flood-opacity", "floodOpacity"), + ("font-family", "fontFamily"), + ("font-size", "fontSize"), + ("font-size-adjust", "fontSizeAdjust"), + ("font-stretch", "fontStretch"), + ("font-style", "fontStyle"), + ("font-variant", "fontVariant"), + ("font-weight", "fontWeight"), + ("for", "htmlFor"), + ("glyph-name", "glyphName"), + ("glyph-orientation-horizontal", "glyphOrientationHorizontal"), + ("glyph-orientation-vertical", "glyphOrientationVertical"), + ("horiz-adv-x", "horizAdvX"), + ("horiz-origin-x", "horizOriginX"), + ("http-equiv", "httpEquiv"), + ("image-rendering", "imageRendering"), + ("letter-spacing", "letterSpacing"), + ("lighting-color", "lightingColor"), + ("marker-end", "markerEnd"), + ("marker-mid", "markerMid"), + ("marker-start", "markerStart"), + ("nomodule", "noModule"), + ("overline-position", "overlinePosition"), + ("overline-thickness", "overlineThickness"), + ("paint-order", "paintOrder"), + ("panose-1", "panose1"), + ("pointer-events", "pointerEvents"), + ("rendering-intent", "renderingIntent"), + ("shape-rendering", "shapeRendering"), + ("stop-color", "stopColor"), + ("stop-opacity", "stopOpacity"), + ("strikethrough-position", "strikethroughPosition"), + ("strikethrough-thickness", "strikethroughThickness"), + ("stroke-dasharray", "strokeDasharray"), + ("stroke-dashoffset", "strokeDashoffset"), + ("stroke-linecap", "strokeLinecap"), + ("stroke-linejoin", "strokeLinejoin"), + ("stroke-miterlimit", "strokeMiterlimit"), + ("stroke-opacity", "strokeOpacity"), + ("stroke-width", "strokeWidth"), + ("text-anchor", "textAnchor"), + ("text-decoration", "textDecoration"), + ("text-rendering", "textRendering"), + ("underline-position", "underlinePosition"), + ("underline-thickness", "underlineThickness"), + ("unicode-bidi", "unicodeBidi"), + ("unicode-range", "unicodeRange"), + ("units-per-em", "unitsPerEm"), + ("v-alphabetic", "vAlphabetic"), + ("v-hanging", "vHanging"), + ("v-ideographic", "vIdeographic"), + ("v-mathematical", "vMathematical"), + ("vector-effect", "vectorEffect"), + ("vert-adv-y", "vertAdvY"), + ("vert-origin-x", "vertOriginX"), + ("vert-origin-y", "vertOriginY"), + ("word-spacing", "wordSpacing"), + ("writing-mode", "writingMode"), + ("x-height", "xHeight"), + ("xlink:actuate", "xlinkActuate"), + ("xlink:arcrole", "xlinkArcrole"), + ("xlink:href", "xlinkHref"), + ("xlink:role", "xlinkRole"), + ("xlink:show", "xlinkShow"), + ("xlink:title", "xlinkTitle"), + ("xlink:type", "xlinkType"), + ("xml:base", "xmlBase"), + ("xml:lang", "xmlLang"), + ("xml:space", "xmlSpace"), +]; + +static DOM_ATTRIBUTE_LOOKUP: LazyLock> = + LazyLock::new(|| DOM_ATTRIBUTE_NAMES.iter().copied().collect()); + +const DOM_PROPERTY_NAMES: &[&str] = &[ + // Single word properties + "accept", + "action", + "accumulate", + "additive", + "allow", + "alphabetic", + "alt", + "amplitude", + "as", + "ascent", + "async", + "azimuth", + "bbox", + "begin", + "bias", + "buffered", + "by", + "capture", + "challenge", + "children", + "cite", + "clip", + "code", + "cols", + "content", + "controls", + "coords", + "csp", + "cursor", + "cx", + "cy", + "d", + "data", + "decelerate", + "decoding", + "default", + "defer", + "descent", + "dir", + "direction", + "disabled", + "display", + "divisor", + "draggable", + "dur", + "dx", + "dy", + "elevation", + "end", + "exponent", + "fill", + "filter", + "form", + "format", + "fr", + "from", + "fx", + "fy", + "g1", + "g2", + "hanging", + "headers", + "height", + "hidden", + "high", + "href", + "hreflang", + "icon", + "id", + "ideographic", + "importance", + "in", + "in2", + "inert", + "integrity", + "intercept", + "k", + "k1", + "k2", + "k3", + "k4", + "kerning", + "key", + "kind", + "label", + "lang", + "language", + "list", + "loading", + "local", + "loop", + "low", + "manifest", + "mask", + "max", + "media", + "method", + "min", + "mode", + "multiple", + "muted", + "name", + "nonce", + "offset", + "open", + "operator", + "optimum", + "order", + "orient", + "orientation", + "origin", + "overflow", + "part", + "path", + "pattern", + "ping", + "placeholder", + "points", + "poster", + "preload", + "profile", + "property", + "r", + "radius", + "ref", + "rel", + "required", + "results", + "restart", + "reversed", + "role", + "rows", + "rx", + "ry", + "sandbox", + "scale", + "scope", + "seamless", + "security", + "seed", + "selected", + "shape", + "size", + "sizes", + "slot", + "slope", + "span", + "spacing", + "speed", + "src", + "start", + "stemh", + "stemv", + "step", + "string", + "stroke", + "style", + "summary", + "target", + "title", + "to", + "transform", + "translate", + "type", + "u1", + "u2", + "unicode", + "value", + "values", + "version", + "visibility", + "width", + "widths", + "wmode", + "wrap", + "x", + "x1", + "x2", + "xmlns", + "y", + "y1", + "y2", + "z", + // Two word properties + "acceptCharset", + "accessKey", + "accentHeight", + "alignmentBaseline", + "arabicForm", + "attributeName", + "attributeType", + "autoCapitalize", + "autoComplete", + "autoCorrect", + "autoFocus", + "autoPictureInPicture", + "autoPlay", + "autoSave", + "baseFrequency", + "baseProfile", + "baselineShift", + "border", + "calcMode", + "capHeight", + "cellPadding", + "cellSpacing", + "classID", + "className", + "clipPath", + "clipPathUnits", + "clipRule", + "codeBase", + "colSpan", + "colorInterpolation", + "colorInterpolationFilters", + "colorProfile", + "colorRendering", + "contentEditable", + "contentScriptType", + "contentStyleType", + "contextMenu", + "controlsList", + "crossOrigin", + "dangerouslySetInnerHTML", + "dateTime", + "defaultChecked", + "defaultValue", + "diffuseConstant", + "disablePictureInPicture", + "disableRemotePlayback", + "dominantBaseline", + "edgeMode", + "enableBackground", + "encType", + "enterKeyHint", + "exportParts", + "fetchPriority", + "fillOpacity", + "fillRule", + "filterRes", + "filterUnits", + "floodColor", + "floodOpacity", + "fontFamily", + "fontSize", + "fontSizeAdjust", + "fontStretch", + "fontStyle", + "fontVariant", + "fontWeight", + "formAction", + "formEncType", + "formMethod", + "formNoValidate", + "formTarget", + "frameBorder", + "glyphName", + "glyphOrientationHorizontal", + "glyphOrientationVertical", + "glyphRef", + "gradientTransform", + "gradientUnits", + "horizAdvX", + "horizOriginX", + "hrefLang", + "htmlFor", + "httpEquiv", + "imageRendering", + "imageSizes", + "imageSrcSet", + "inputMode", + "isMap", + "itemID", + "itemProp", + "itemRef", + "itemScope", + "itemType", + "kernelMatrix", + "kernelUnitLength", + "keyParams", + "keyPoints", + "keySplines", + "keyTimes", + "keyType", + "lengthAdjust", + "letterSpacing", + "lightingColor", + "limitingConeAngle", + "marginHeight", + "marginWidth", + "markerEnd", + "markerHeight", + "markerMid", + "markerStart", + "markerUnits", + "markerWidth", + "maskContentUnits", + "maskUnits", + "mathematical", + "maxLength", + "mediaGroup", + "minLength", + "noValidate", + "numOctaves", + "onAbort", + "onAbortCapture", + "onAnimationEnd", + "onAnimationEndCapture", + "onAnimationIteration", + "onAnimationStart", + "onAnimationStartCapture", + "onAuxClick", + "onAuxClickCapture", + "onBeforeInput", + "onBeforeInputCapture", + "onBlur", + "onBlurCapture", + "onCanPlay", + "onCanPlayCapture", + "onCanPlayThrough", + "onCanPlayThroughCapture", + "onChange", + "onChangeCapture", + "onClick", + "onClickCapture", + "onCompositionEnd", + "onCompositionEndCapture", + "onCompositionStart", + "onCompositionStartCapture", + "onCompositionUpdate", + "onCompositionUpdateCapture", + "onContextMenu", + "onContextMenuCapture", + "onCopy", + "onCopyCapture", + "onCut", + "onCutCapture", + "onDoubleClick", + "onDoubleClickCapture", + "onDrag", + "onDragCapture", + "onDragEnd", + "onDragEndCapture", + "onDragEnter", + "onDragEnterCapture", + "onDragExit", + "onDragExitCapture", + "onDragLeave", + "onDragLeaveCapture", + "onDragOver", + "onDragOverCapture", + "onDragStart", + "onDragStartCapture", + "onDrop", + "onDropCapture", + "onDurationChange", + "onDurationChangeCapture", + "onEmptied", + "onEmptiedCapture", + "onEncrypted", + "onEncryptedCapture", + "onEnded", + "onEndedCapture", + "onError", + "onErrorCapture", + "onFocus", + "onFocusCapture", + "onGotPointerCapture", + "onGotPointerCaptureCapture", + "onInput", + "onInputCapture", + "onInvalid", + "onInvalidCapture", + "onKeyDown", + "onKeyDownCapture", + "onKeyPress", + "onKeyPressCapture", + "onKeyUp", + "onKeyUpCapture", + "onLoad", + "onLoadCapture", + "onLoadedData", + "onLoadedDataCapture", + "onLoadedMetadata", + "onLoadedMetadataCapture", + "onLoadStart", + "onLoadStartCapture", + "onLostPointerCapture", + "onLostPointerCaptureCapture", + "onMouseDown", + "onMouseDownCapture", + "onMouseEnter", + "onMouseLeave", + "onMouseMove", + "onMouseMoveCapture", + "onMouseOut", + "onMouseOutCapture", + "onMouseOver", + "onMouseOverCapture", + "onMouseUp", + "onMouseUpCapture", + "onPaste", + "onPasteCapture", + "onPause", + "onPauseCapture", + "onPlay", + "onPlayCapture", + "onPlaying", + "onPlayingCapture", + "onPointerCancel", + "onPointerCancelCapture", + "onPointerDown", + "onPointerDownCapture", + "onPointerEnter", + "onPointerEnterCapture", + "onPointerLeave", + "onPointerLeaveCapture", + "onPointerMove", + "onPointerMoveCapture", + "onPointerOut", + "onPointerOutCapture", + "onPointerOver", + "onPointerOverCapture", + "onPointerUp", + "onPointerUpCapture", + "onProgress", + "onProgressCapture", + "onRateChange", + "onRateChangeCapture", + "onReset", + "onResetCapture", + "onResize", + "onScroll", + "onScrollCapture", + "onSeeked", + "onSeekedCapture", + "onSeeking", + "onSeekingCapture", + "onSelect", + "onSelectCapture", + "onStalled", + "onStalledCapture", + "onSubmit", + "onSubmitCapture", + "onSuspend", + "onSuspendCapture", + "onTimeUpdate", + "onTimeUpdateCapture", + "onToggle", + "onTouchCancel", + "onTouchCancelCapture", + "onTouchEnd", + "onTouchEndCapture", + "onTouchMove", + "onTouchMoveCapture", + "onTouchStart", + "onTouchStartCapture", + "onTransitionEnd", + "onTransitionEndCapture", + "onVolumeChange", + "onVolumeChangeCapture", + "onWaiting", + "onWaitingCapture", + "onWheel", + "onWheelCapture", + "overlinePosition", + "overlineThickness", + "paintOrder", + "panose1", + "pathLength", + "patternContentUnits", + "patternTransform", + "patternUnits", + "pointerEvents", + "pointsAtX", + "pointsAtY", + "pointsAtZ", + "popover", + "preserveAlpha", + "preserveAspectRatio", + "primitiveUnits", + "radioGroup", + "readOnly", + "referrerPolicy", + "refX", + "refY", + "rendering-intent", + "repeatCount", + "repeatDur", + "requiredExtensions", + "requiredFeatures", + "rowSpan", + "shapeRendering", + "specularConstant", + "specularExponent", + "spellCheck", + "spreadMethod", + "srcDoc", + "srcLang", + "srcSet", + "startOffset", + "stdDeviation", + "stitchTiles", + "stopColor", + "stopOpacity", + "strikethroughPosition", + "strikethroughThickness", + "strokeDasharray", + "strokeDashoffset", + "strokeLinecap", + "strokeLinejoin", + "strokeMiterlimit", + "strokeOpacity", + "strokeWidth", + "suppressContentEditableWarning", + "suppressHydrationWarning", + "surfaceScale", + "systemLanguage", + "tabIndex", + "tableValues", + "targetX", + "targetY", + "textAnchor", + "textDecoration", + "textLength", + "textRendering", + "transformOrigin", + "underlinePosition", + "underlineThickness", + "unicodeBidi", + "unicodeRange", + "unitsPerEm", + "useMap", + "vAlphabetic", + "vHanging", + "vIdeographic", + "vMathematical", + "vectorEffect", + "vertAdvY", + "vertOriginX", + "vertOriginY", + "viewBox", + "viewTarget", + "wordSpacing", + "writingMode", + "xChannelSelector", + "xHeight", + "xlinkActuate", + "xlinkArcrole", + "xlinkHref", + "xlinkRole", + "xlinkShow", + "xlinkTitle", + "xlinkType", + "xmlBase", + "xmlLang", + "xmlnsXlink", + "xmlSpace", + "yChannelSelector", + "zoomAndPan", +]; + +fn is_valid_data_attribute(name: &str) -> bool { + use biome_string_case::StrOnlyExtension; + if !name.starts_with("data-") { + return false; + } + + if name.to_lowercase_cow().starts_with("data-xml") { + return false; + } + + let data_name = &name["data-".len()..]; + + if data_name.is_empty() { + return false; + } + + data_name.chars().all(|c| c != ':') +} + +fn is_valid_aria_attribute(name: &str) -> bool { + ARIA_PROPERTIES.contains(&name) +} + +fn is_valid_html_tag_in_jsx(node: &AnyJsxElement, tag_name: &str) -> bool { + let matches_tag_convention = tag_name.char_indices().all(|(i, c)| { + if i == 0 { + c.is_ascii_lowercase() + } else { + c != '-' + } + }); + + if matches_tag_convention { + return node.attributes().find_by_name("is").is_none(); + } + + false +} + +fn tag_name_has_dot(node: &AnyJsxElement) -> Option { + Some(matches!( + node.name().ok()?, + AnyJsxElementName::JsxMemberName(_) + )) +} + +pub enum NoUnknownAttributeState { + UnknownProp { + name: Box, + }, + UnknownPropWithStandardName { + name: Box, + standard_name: Box, + }, + InvalidPropOnTag { + name: Box, + tag_name: TokenText, + allowed_tags: &'static [&'static str], + }, +} + +fn get_standard_name(ctx: &RuleContext, name: &str) -> Option<&'static str> { + if let Some(&standard_name) = DOM_ATTRIBUTE_LOOKUP.get(name) { + return Some(standard_name); + } + let is_react_19_or_later = ctx + .get_service::)>>() + .and_then(|manifest| { + manifest + .as_ref() + .map(|(_, package_json)| package_json.matches_dependency("react", ">=19.0.0")) + }) + .unwrap_or(false); + + if is_react_19_or_later { + if let Some(&prop) = POPOVER_API_PROPS + .iter() + .find(|&&element| element.eq_ignore_ascii_case(name)) + { + return Some(prop); + } + } else if let Some(&prop) = POPOVER_API_PROPS_LOWERCASE + .iter() + .find(|&&element| element.eq_ignore_ascii_case(name)) + { + return Some(prop); + } + + DOM_PROPERTY_NAMES + .iter() + .find(|&&element| element.eq_ignore_ascii_case(name)) + .copied() +} + +impl Rule for NoUnknownAttribute { + type Query = Manifest; + type State = NoUnknownAttributeState; + type Signals = Option; + type Options = NoUnknownAttributeOptions; + + fn run(ctx: &RuleContext) -> Self::Signals { + let node = ctx.query(); + + let options = ctx.options(); + + let node_name = match node.name().ok()? { + AnyJsxAttributeName::JsxName(name) => name.syntax().text_trimmed(), + AnyJsxAttributeName::JsxNamespaceName(name) => name.syntax().text_trimmed(), + }; + + let node_name = node_name.into_text(); + + if options + .ignore + .iter() + .any(|ignored| ignored == node_name.text()) + { + return None; + } + let name = if let Some(element) = DOM_PROPERTIES_IGNORE_CASE + .iter() + .find(|element| element.eq_ignore_ascii_case(&node_name)) + { + element + } else { + &node_name.text() + }; + + let parent = node.syntax().parent()?.parent()?; + let element = AnyJsxElement::cast_ref(&parent)?; + + // Ignore tags like + if tag_name_has_dot(&element)? { + return None; + } + + // Handle data-* attributes + if is_valid_data_attribute(name) { + return None; + } + + // Handle aria-* attributes + if is_valid_aria_attribute(name) { + return None; + } + + let tag_name = element.name_value_token().ok()?.token_text_trimmed(); + + // Special case for fbt/fbs nodes + if tag_name == "fbt" || tag_name == "fbs" { + return None; + } + + // Only validate HTML/DOM elements, not React components + if !is_valid_html_tag_in_jsx(&element, &tag_name) { + return None; + } + + let allowed_tags = ATTRIBUTE_TAGS_LOOKUP.get(name); + + if let Some(allowed_tags) = allowed_tags { + if !allowed_tags.contains(&tag_name.trim()) { + return Some(NoUnknownAttributeState::InvalidPropOnTag { + name: (*name).into(), + tag_name, + allowed_tags, + }); + } + return None; + } + + if let Some(standard_name) = get_standard_name(ctx, name) { + if standard_name != *name { + return Some(NoUnknownAttributeState::UnknownPropWithStandardName { + name: (*name).into(), + standard_name: standard_name.into(), + }); + } + return None; + } + + Some(NoUnknownAttributeState::UnknownProp { + name: (*name).into(), + }) + } + + fn diagnostic(ctx: &RuleContext, state: &Self::State) -> Option { + let node = ctx.query(); + match state { + NoUnknownAttributeState::UnknownProp { name } => Some( + RuleDiagnostic::new( + rule_category!(), + node.range(), + markup! { + "The property '"{name}"' is not a valid DOM attribute." + }, + ) + .note(markup! { + "This property is not recognized as a valid HTML/DOM attribute or React prop." + }) + .note(markup! { + "Check the spelling or consider using a valid data-* attribute for custom properties." + }), + ), + NoUnknownAttributeState::UnknownPropWithStandardName { + name, + standard_name, + } => Some( + RuleDiagnostic::new( + rule_category!(), + node.range(), + markup! { + "Property '"{name}"' is not a valid React prop name." + }, + ) + .note(markup! { + "React uses camelCased props, while HTML uses kebab-cased attributes." + }) + .note(markup! { + "Use '"{standard_name}"' instead of '"{name}"' for React components." + }), + ), + NoUnknownAttributeState::InvalidPropOnTag { + name, + tag_name, + allowed_tags, + } => Some( + RuleDiagnostic::new( + rule_category!(), + node.range(), + markup! { + "Property '" {name} "' is not valid on a <" {tag_name.text()} "> element." + }, + ) + .note(markup! { + "This attribute is restricted and cannot be used on this HTML element" + }) + .note(markup! { + "This attribute is only allowed on: "{allowed_tags.join(",")} + }), + ), + } + } +} diff --git a/crates/biome_js_analyze/tests/specs/nursery/noUnknownAttribute/ignore/valid.jsx b/crates/biome_js_analyze/tests/specs/nursery/noUnknownAttribute/ignore/valid.jsx new file mode 100644 index 000000000000..6c173cb92e73 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noUnknownAttribute/ignore/valid.jsx @@ -0,0 +1,6 @@ +/* should not generate diagnostics */ +<> +
+
+
+; diff --git a/crates/biome_js_analyze/tests/specs/nursery/noUnknownAttribute/ignore/valid.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/noUnknownAttribute/ignore/valid.jsx.snap new file mode 100644 index 000000000000..bb35b99b37d0 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noUnknownAttribute/ignore/valid.jsx.snap @@ -0,0 +1,14 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: valid.jsx +--- +# Input +```jsx +/* should not generate diagnostics */ +<> +
+
+
+; + +``` diff --git a/crates/biome_js_analyze/tests/specs/nursery/noUnknownAttribute/ignore/valid.options.json b/crates/biome_js_analyze/tests/specs/nursery/noUnknownAttribute/ignore/valid.options.json new file mode 100644 index 000000000000..682bc3d8aa8d --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noUnknownAttribute/ignore/valid.options.json @@ -0,0 +1,16 @@ +{ + "$schema": "../../../../../../../packages/@biomejs/biome/configuration_schema.json", + "linter": { + "enabled": true, + "rules": { + "nursery": { + "noUnknownAttribute": { + "level": "error", + "options": { + "ignore": ["class", "someProp", "css"] + } + } + } + } + } +} diff --git a/crates/biome_js_analyze/tests/specs/nursery/noUnknownAttribute/invalid.jsx b/crates/biome_js_analyze/tests/specs/nursery/noUnknownAttribute/invalid.jsx new file mode 100644 index 000000000000..a9dcd45752a3 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/noUnknownAttribute/invalid.jsx @@ -0,0 +1,53 @@ +<> +
+
+
+
+
+
+
+
+
+
+
+
+
+ + +