diff --git a/apps/oxlint/fixtures/invalid_config_enum/.oxlintrc.json b/apps/oxlint/fixtures/invalid_config_enum/.oxlintrc.json new file mode 100644 index 0000000000000..35f44658be682 --- /dev/null +++ b/apps/oxlint/fixtures/invalid_config_enum/.oxlintrc.json @@ -0,0 +1,9 @@ +{ + "categories": { + "correctness": "off" + }, + "rules": { + // Invalid config, valid values are "except-parens" and "always". + "eslint/no-return-assign": ["error", "foobar"] + } +} diff --git a/apps/oxlint/fixtures/invalid_config_extra_options/.oxlintrc.json b/apps/oxlint/fixtures/invalid_config_extra_options/.oxlintrc.json new file mode 100644 index 0000000000000..4b28a230387a5 --- /dev/null +++ b/apps/oxlint/fixtures/invalid_config_extra_options/.oxlintrc.json @@ -0,0 +1,10 @@ +{ + "plugins": ["jest"], + "categories": { + "correctness": "off" + }, + "rules": { + // Invalid config, the only valid option is `allow`. + "jest/no-hooks": ["error", { "foo": "bar" }] + } +} diff --git a/apps/oxlint/fixtures/invalid_config_in_override/.oxlintrc.json b/apps/oxlint/fixtures/invalid_config_in_override/.oxlintrc.json new file mode 100644 index 0000000000000..9aeec9f74aae7 --- /dev/null +++ b/apps/oxlint/fixtures/invalid_config_in_override/.oxlintrc.json @@ -0,0 +1,20 @@ +{ + "plugins": ["jest"], + "categories": { + "correctness": "off" + }, + "rules": { + // Valid config + "jest/no-hooks": "error" + }, + "overrides": [ + { + "files": ["**/*.js"], + "rules": { + // Invalid config, `allow` should only have string values. Ensure that the error + // for a rule defined in an override is properly reported. + "jest/no-hooks": ["error", { "allow": [true, false] }] + } + } + ] +} diff --git a/apps/oxlint/fixtures/invalid_config_multiple_rules/.oxlintrc.json b/apps/oxlint/fixtures/invalid_config_multiple_rules/.oxlintrc.json new file mode 100644 index 0000000000000..53aa3791739cf --- /dev/null +++ b/apps/oxlint/fixtures/invalid_config_multiple_rules/.oxlintrc.json @@ -0,0 +1,13 @@ +{ + "plugins": ["jest"], + "categories": { + "correctness": "off" + }, + "rules": { + // First invalid rule: unknown option field `foo` + "jest/no-hooks": ["error", { "foo": "bar" }], + + // Second invalid rule: invalid enum value + "eslint/no-return-assign": ["error", "foobar"] + } +} diff --git a/apps/oxlint/fixtures/invalid_config_type_difference/.oxlintrc.json b/apps/oxlint/fixtures/invalid_config_type_difference/.oxlintrc.json new file mode 100644 index 0000000000000..3eb06cd581401 --- /dev/null +++ b/apps/oxlint/fixtures/invalid_config_type_difference/.oxlintrc.json @@ -0,0 +1,10 @@ +{ + "plugins": ["jest"], + "categories": { + "correctness": "off" + }, + "rules": { + // Invalid config, `allow` should only have string values + "jest/no-hooks": ["error", { "allow": [true, false] }] + } +} diff --git a/apps/oxlint/src/lint.rs b/apps/oxlint/src/lint.rs index 69f5c326f3e90..a62400d25dee0 100644 --- a/apps/oxlint/src/lint.rs +++ b/apps/oxlint/src/lint.rs @@ -1472,4 +1472,33 @@ export { redundant }; &["--type-aware", "-D", "no-unnecessary-type-assertion"], ); } + + #[test] + fn test_invalid_config_invalid_config_enum() { + Tester::new().with_cwd("fixtures/invalid_config_enum".into()).test_and_snapshot(&[]); + } + + #[test] + fn test_invalid_config_invalid_config_extra_options() { + Tester::new() + .with_cwd("fixtures/invalid_config_extra_options".into()) + .test_and_snapshot(&[]); + } + + #[test] + fn test_invalid_config_invalid_config_in_override() { + Tester::new().with_cwd("fixtures/invalid_config_in_override".into()).test_and_snapshot(&[]); + } + + #[test] + fn test_invalid_config_invalid_config_multiple_rules() { + Tester::new().with_cwd("fixtures/invalid_config_in_override".into()).test_and_snapshot(&[]); + } + + #[test] + fn test_invalid_config_invalid_config_type_difference() { + Tester::new() + .with_cwd("fixtures/invalid_config_type_difference".into()) + .test_and_snapshot(&[]); + } } diff --git a/apps/oxlint/src/snapshots/fixtures__invalid_config_enum_@oxlint.snap b/apps/oxlint/src/snapshots/fixtures__invalid_config_enum_@oxlint.snap new file mode 100644 index 0000000000000..b2a8479a8ad9a --- /dev/null +++ b/apps/oxlint/src/snapshots/fixtures__invalid_config_enum_@oxlint.snap @@ -0,0 +1,14 @@ +--- +source: apps/oxlint/src/tester.rs +--- +########## +arguments: +working directory: fixtures/invalid_config_enum +---------- +Failed to parse oxlint configuration file. + + x Invalid configuration for rule `no-return-assign`: unknown variant `foobar`, expected `always` or `except-parens`, received `"foobar"` + +---------- +CLI result: InvalidOptionConfig +---------- diff --git a/apps/oxlint/src/snapshots/fixtures__invalid_config_extra_options_@oxlint.snap b/apps/oxlint/src/snapshots/fixtures__invalid_config_extra_options_@oxlint.snap new file mode 100644 index 0000000000000..745956a1f2c10 --- /dev/null +++ b/apps/oxlint/src/snapshots/fixtures__invalid_config_extra_options_@oxlint.snap @@ -0,0 +1,14 @@ +--- +source: apps/oxlint/src/tester.rs +--- +########## +arguments: +working directory: fixtures/invalid_config_extra_options +---------- +Failed to parse oxlint configuration file. + + x Invalid configuration for rule `jest/no-hooks`: unknown field `foo`, expected `allow`, received `{ "foo": "bar" }` + +---------- +CLI result: InvalidOptionConfig +---------- diff --git a/apps/oxlint/src/snapshots/fixtures__invalid_config_in_override_@oxlint.snap b/apps/oxlint/src/snapshots/fixtures__invalid_config_in_override_@oxlint.snap new file mode 100644 index 0000000000000..c507908bc2102 --- /dev/null +++ b/apps/oxlint/src/snapshots/fixtures__invalid_config_in_override_@oxlint.snap @@ -0,0 +1,14 @@ +--- +source: apps/oxlint/src/tester.rs +--- +########## +arguments: +working directory: fixtures/invalid_config_in_override +---------- +Failed to build configuration. + + x Invalid configuration for rule `jest/no-hooks`: invalid type: boolean `true`, expected a string, received `{ "allow": [ true, false ] }` + +---------- +CLI result: InvalidOptionConfig +---------- diff --git a/apps/oxlint/src/snapshots/fixtures__invalid_config_type_difference_@oxlint.snap b/apps/oxlint/src/snapshots/fixtures__invalid_config_type_difference_@oxlint.snap new file mode 100644 index 0000000000000..4d673467fc47f --- /dev/null +++ b/apps/oxlint/src/snapshots/fixtures__invalid_config_type_difference_@oxlint.snap @@ -0,0 +1,14 @@ +--- +source: apps/oxlint/src/tester.rs +--- +########## +arguments: +working directory: fixtures/invalid_config_type_difference +---------- +Failed to parse oxlint configuration file. + + x Invalid configuration for rule `jest/no-hooks`: invalid type: boolean `true`, expected a string, received `{ "allow": [ true, false ] }` + +---------- +CLI result: InvalidOptionConfig +---------- diff --git a/crates/oxc_linter/src/config/config_builder.rs b/crates/oxc_linter/src/config/config_builder.rs index 88f51342b604b..ce580f2b1681f 100644 --- a/crates/oxc_linter/src/config/config_builder.rs +++ b/crates/oxc_linter/src/config/config_builder.rs @@ -18,9 +18,10 @@ use crate::{ external_plugins::ExternalPluginEntry, overrides::OxlintOverride, plugins::{LintPlugins, is_normal_plugin_name, normalize_plugin_name}, + rules::OverrideRulesError, }, external_linter::ExternalLinter, - external_plugin_store::{ExternalOptionsId, ExternalRuleId, ExternalRuleLookupError}, + external_plugin_store::{ExternalOptionsId, ExternalRuleId}, rules::RULES, }; @@ -227,15 +228,12 @@ impl ConfigStoreBuilder { { let all_rules = builder.get_all_rules(); - oxlintrc - .rules - .override_rules( - &mut builder.rules, - &mut builder.external_rules, - &all_rules, - external_plugin_store, - ) - .map_err(ConfigBuilderError::ExternalRuleLookupError)?; + oxlintrc.rules.override_rules( + &mut builder.rules, + &mut builder.external_rules, + &all_rules, + external_plugin_store, + )?; } Ok(builder) @@ -402,9 +400,7 @@ impl ConfigStoreBuilder { } let overrides = std::mem::take(&mut self.overrides); - let resolved_overrides = self - .resolve_overrides(overrides, external_plugin_store) - .map_err(ConfigBuilderError::ExternalRuleLookupError)?; + let resolved_overrides = self.resolve_overrides(overrides, external_plugin_store)?; let mut rules: Vec<_> = self .rules @@ -432,7 +428,7 @@ impl ConfigStoreBuilder { &self, overrides: OxlintOverrides, external_plugin_store: &mut ExternalPluginStore, - ) -> Result { + ) -> Result> { let resolved = overrides .into_iter() .map(|override_config| { @@ -459,7 +455,7 @@ impl ConfigStoreBuilder { .map(|(rule_id, (options_id, severity))| (rule_id, options_id, severity)), ); - Ok(ResolvedOxlintOverride { + Ok::<_, Vec>(ResolvedOxlintOverride { files: override_config.files, env: override_config.env, globals: override_config.globals, @@ -652,13 +648,17 @@ pub enum ConfigBuilderError { plugin_specifier: String, error: String, }, - ExternalRuleLookupError(ExternalRuleLookupError), NoExternalLinterConfigured { plugin_specifier: String, }, ReservedExternalPluginName { plugin_name: String, }, + /// Multiple errors parsing rule configuration options + RuleConfigurationErrors { + /// The errors that occurred + errors: Vec, + }, } impl Display for ConfigBuilderError { @@ -709,13 +709,27 @@ impl Display for ConfigBuilderError { )?; Ok(()) } - ConfigBuilderError::ExternalRuleLookupError(e) => std::fmt::Display::fmt(&e, f), + ConfigBuilderError::RuleConfigurationErrors { errors } => { + for (i, error) in errors.iter().enumerate() { + if i > 0 { + f.write_str("\n")?; + } + write!(f, "{error}")?; + } + Ok(()) + } } } } impl std::error::Error for ConfigBuilderError {} +impl From> for ConfigBuilderError { + fn from(errors: Vec) -> Self { + ConfigBuilderError::RuleConfigurationErrors { errors } + } +} + #[cfg(test)] mod test { use std::path::PathBuf; diff --git a/crates/oxc_linter/src/config/rules.rs b/crates/oxc_linter/src/config/rules.rs index 397ee28735869..27006c3c09a50 100644 --- a/crates/oxc_linter/src/config/rules.rs +++ b/crates/oxc_linter/src/config/rules.rs @@ -19,6 +19,39 @@ use crate::{ utils::{is_eslint_rule_adapted_to_typescript, is_jest_rule_adapted_to_vitest}, }; +/// Errors that can occur when overriding rules +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OverrideRulesError { + /// Error looking up an external rule + ExternalRuleLookup(ExternalRuleLookupError), + /// Error parsing rule configuration + RuleConfiguration { + /// The fully qualified rule name (e.g., "jest/no-hooks") + rule_name: String, + /// The error message from parsing + message: String, + }, +} + +impl fmt::Display for OverrideRulesError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + OverrideRulesError::ExternalRuleLookup(e) => write!(f, "{e}"), + OverrideRulesError::RuleConfiguration { rule_name, message } => { + write!(f, "Invalid configuration for rule `{rule_name}`: {message}") + } + } + } +} + +impl std::error::Error for OverrideRulesError {} + +impl From for OverrideRulesError { + fn from(e: ExternalRuleLookupError) -> Self { + OverrideRulesError::ExternalRuleLookup(e) + } +} + type RuleSet = FxHashMap; // TS type is `Record` @@ -71,8 +104,9 @@ impl OxlintRules { >, all_rules: &[RuleEnum], external_plugin_store: &mut ExternalPluginStore, - ) -> Result<(), ExternalRuleLookupError> { + ) -> Result<(), Vec> { let mut rules_to_replace = vec![]; + let mut errors = vec![]; let lookup = self.rules.iter().into_group_map_by(|r| r.rule_name.as_str()); @@ -104,11 +138,17 @@ impl OxlintRules { } else { serde_json::Value::Array(rule_config.config.to_vec()) }; - rules_to_replace.push(( - rule.from_configuration(config) - .expect("failed to parse rule configuration"), - severity, - )); + match rule.from_configuration(config) { + Ok(configured_rule) => { + rules_to_replace.push((configured_rule, severity)); + } + Err(e) => { + errors.push(OverrideRulesError::RuleConfiguration { + rule_name: rule_config.full_name().into_owned(), + message: e.to_string(), + }); + } + } } } else { // If JS plugins are disabled (language server), assume plugin name refers to a JS plugin, @@ -119,25 +159,35 @@ impl OxlintRules { // (e.g. typos like `unicon/filename-case`). But we can't avoid this as the name of a JS plugin // can only be known by loading it, which language server can't do at present. if external_plugin_store.is_enabled() { - let external_rule_id = - external_plugin_store.lookup_rule_id(plugin_name, rule_name)?; - - // Add options to store and get options ID - let options_id = external_plugin_store - .add_options(external_rule_id, &rule_config.config); - - external_rules_for_override - .entry(external_rule_id) - .and_modify(|(opts_id, sev)| { - *opts_id = options_id; - *sev = severity; - }) - .or_insert((options_id, severity)); + match external_plugin_store.lookup_rule_id(plugin_name, rule_name) { + Ok(external_rule_id) => { + // Add options to store and get options ID + let options_id = external_plugin_store + .add_options(external_rule_id, &rule_config.config); + + external_rules_for_override + .entry(external_rule_id) + .and_modify(|(opts_id, sev)| { + *opts_id = options_id; + *sev = severity; + }) + .or_insert((options_id, severity)); + } + Err(e) => { + errors.push(OverrideRulesError::ExternalRuleLookup(e)); + } + } } } } } + if !errors.is_empty() { + // Sort by the error message so output is stable + errors.sort_by_key(std::string::ToString::to_string); + return Err(errors); + } + for (rule, severity) in rules_to_replace { let _ = rules_for_override.remove(&rule); rules_for_override.insert(rule, severity); @@ -632,4 +682,95 @@ mod test { assert_eq!(options_id, ExternalOptionsId::NONE, "no options should use reserved id 0"); assert_eq!(severity, AllowWarnDeny::Deny); } + + #[test] + fn test_override_rules_errors_single() { + let rules_config = OxlintRules::deserialize(&json!({ + "jest/no-hooks": ["error", { "foo": "bar" }], + })) + .unwrap(); + + let mut builtin_rules = RuleSet::default(); + let mut external_rules = FxHashMap::default(); + let mut store = ExternalPluginStore::default(); + + match rules_config.override_rules( + &mut builtin_rules, + &mut external_rules, + &RULES, + &mut store, + ) { + Err(errors) => { + assert!(errors.len() == 1, "expected one error, got {errors:#?}"); + assert!(matches!( + &errors[0], + super::OverrideRulesError::RuleConfiguration { rule_name, message } + if rule_name == "jest/no-hooks" && message.contains("unknown field") + )); + } + Ok(()) => panic!("expected errors from invalid config"), + } + } + + #[test] + fn test_override_rules_errors_multiple() { + let rules_config = OxlintRules::deserialize(&json!({ + "jest/no-hooks": ["error", { "foo": "bar" }], + "eslint/no-return-assign": ["error", "foobar"] + })) + .unwrap(); + + let mut builtin_rules = RuleSet::default(); + let mut external_rules = FxHashMap::default(); + let mut store = ExternalPluginStore::default(); + + match rules_config.override_rules( + &mut builtin_rules, + &mut external_rules, + &RULES, + &mut store, + ) { + Err(errors) => { + assert!(errors.len() == 2, "expected two errors, got {errors:#?}"); + assert!(matches!( + &errors[0], + super::OverrideRulesError::RuleConfiguration { rule_name, message } + if rule_name == "jest/no-hooks" && message.contains("unknown field") + )); + assert!(matches!( + &errors[1], + super::OverrideRulesError::RuleConfiguration { rule_name, message } + if rule_name == "no-return-assign" && message.contains("unknown variant `foobar`") + )); + } + Ok(()) => panic!("expected errors from invalid config"), + } + } + + #[test] + fn test_override_rules_errors_sorted() { + let rules_config = OxlintRules::deserialize(&json!({ + "jest/no-hooks": ["error", { "foo": "bar" }], + "eslint/no-return-assign": ["error", "foobar"] + })) + .unwrap(); + + let mut builtin_rules = RuleSet::default(); + let mut external_rules = FxHashMap::default(); + let mut store = ExternalPluginStore::default(); + + match rules_config.override_rules( + &mut builtin_rules, + &mut external_rules, + &RULES, + &mut store, + ) { + Err(errors) => { + let strs: Vec = + errors.iter().map(std::string::ToString::to_string).collect(); + assert!(strs.windows(2).all(|w| w[0] <= w[1]), "errors not sorted: {strs:#?}"); + } + Ok(()) => panic!("expected errors from invalid configs"), + } + } } diff --git a/crates/oxc_linter/src/rule.rs b/crates/oxc_linter/src/rule.rs index 51a3ed42787ae..731000879e175 100644 --- a/crates/oxc_linter/src/rule.rs +++ b/crates/oxc_linter/src/rule.rs @@ -118,12 +118,24 @@ where let value = serde_json::Value::deserialize(deserializer)?; if let serde_json::Value::Array(arr) = value { - let config = arr - .into_iter() - .next() - .and_then(|v| serde_json::from_value(v).ok()) - .unwrap_or_else(T::default); + let config = match arr.into_iter().next() { + Some(v) => T::deserialize(&v).map_err(|e| { + // Try to include the config object in the error message if we can. + // Collapse any whitespace so we emit a single-line message. + if let Ok(value_str) = serde_json::to_string_pretty(&v) { + let compact = value_str.split_whitespace().collect::>().join(" "); + D::Error::custom(format!("{e}, received `{compact}`")) + } else { + D::Error::custom(e) + } + })?, + None => T::default(), + }; + Ok(DefaultRuleConfig(config)) + } else if value == serde_json::Value::Null { + // Missing configuration (null) is treated as default (no rule options provided) + Ok(DefaultRuleConfig(T::default())) } else { Err(D::Error::custom("Expected array for rule configuration")) } diff --git a/crates/oxc_linter/src/rules/eslint/no_return_assign.rs b/crates/oxc_linter/src/rules/eslint/no_return_assign.rs index 8198a13a6ed92..e87fd56f906d0 100644 --- a/crates/oxc_linter/src/rules/eslint/no_return_assign.rs +++ b/crates/oxc_linter/src/rules/eslint/no_return_assign.rs @@ -73,9 +73,7 @@ fn is_sentinel_node(ast_kind: AstKind) -> bool { impl Rule for NoReturnAssign { fn from_configuration(value: Value) -> Result { - Ok(serde_json::from_value::>(value) - .unwrap_or_default() - .into_inner()) + serde_json::from_value::>(value).map(DefaultRuleConfig::into_inner) } fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { @@ -222,3 +220,22 @@ fn test() { Tester::new(NoReturnAssign::NAME, NoReturnAssign::PLUGIN, pass, fail).test_and_snapshot(); } + +#[test] +fn invalid_configs_error_in_from_configuration() { + // An array with an object should produce an error, since the rule only accepts a string. + let invalid = serde_json::json!([{ "foo": "bar" }]); + assert!(NoReturnAssign::from_configuration(invalid).is_err()); + + // String that isn't one of the allowed options should produce an error + let invalid = serde_json::json!(["foobar"]); + assert!(NoReturnAssign::from_configuration(invalid).is_err()); + let invalid = serde_json::json!(["ExceptParens"]); + assert!(NoReturnAssign::from_configuration(invalid).is_err()); + let invalid = serde_json::json!(["Always"]); + assert!(NoReturnAssign::from_configuration(invalid).is_err()); + + // Valid configs should not produce an error + let valid = serde_json::json!(["except-parens"]); + assert!(NoReturnAssign::from_configuration(valid).is_ok()); +} diff --git a/crates/oxc_linter/src/rules/jest/no_hooks.rs b/crates/oxc_linter/src/rules/jest/no_hooks.rs index edf708e1bc353..3599a66773934 100644 --- a/crates/oxc_linter/src/rules/jest/no_hooks.rs +++ b/crates/oxc_linter/src/rules/jest/no_hooks.rs @@ -20,7 +20,7 @@ fn unexpected_hook_diagnostic(span: Span) -> OxcDiagnostic { pub struct NoHooks(Box); #[derive(Debug, Default, Clone, JsonSchema, Deserialize)] -#[serde(rename_all = "camelCase", default)] +#[serde(rename_all = "camelCase", default, deny_unknown_fields)] pub struct NoHooksConfig { /// An array of hook function names that are permitted for use. allow: Vec, @@ -102,9 +102,7 @@ declare_oxc_lint!( impl Rule for NoHooks { fn from_configuration(value: serde_json::Value) -> Result { - Ok(serde_json::from_value::>(value) - .unwrap_or_default() - .into_inner()) + serde_json::from_value::>(value).map(DefaultRuleConfig::into_inner) } fn run_on_jest_node<'a, 'c>( @@ -152,7 +150,6 @@ fn test() { "afterEach(() => {}); afterAll(() => {});", Some(serde_json::json!([{ "allow": ["afterEach", "afterAll"] }])), ), - ("test(\"foo\")", Some(serde_json::json!([{ "allow": "undefined" }]))), ]; let mut fail = vec![ @@ -183,7 +180,6 @@ fn test() { "afterEach(() => {}); afterAll(() => {});", Some(serde_json::json!([{ "allow": ["afterEach", "afterAll"] }])), ), - (r#"test("foo")"#, Some(serde_json::json!([{ "allow": null }]))), ]; let fail_vitest = vec![ @@ -191,6 +187,8 @@ fn test() { ("beforeEach(() => {})", None), ("afterAll(() => {})", None), ("afterEach(() => {})", None), + ("afterEach(() => {})", Some(serde_json::json!([]))), + ("afterEach(() => {})", Some(serde_json::json!([{ "allow": [] }]))), ( "beforeEach(() => {}); afterEach(() => { vi.resetModules() });", Some(serde_json::json!([{ "allow": ["afterEach"] }])), @@ -212,3 +210,17 @@ fn test() { .with_jest_plugin(true) .test_and_snapshot(); } + +#[test] +fn invalid_configs_error_in_from_configuration() { + // An array with an object that has unknown keys should produce an error + let invalid = serde_json::json!([{ "foo": "bar" }]); + assert!(NoHooks::from_configuration(invalid).is_err()); + + // Configs containing `null` or the string "undefined" should be rejected under strict validation + let undefined_allow = serde_json::json!([{ "allow": "undefined" }]); + assert!(NoHooks::from_configuration(undefined_allow).is_err()); + + let null_allow = serde_json::json!([{ "allow": null }]); + assert!(NoHooks::from_configuration(null_allow).is_err()); +} diff --git a/crates/oxc_linter/src/snapshots/jest_no_hooks.snap b/crates/oxc_linter/src/snapshots/jest_no_hooks.snap index 2d475a894e71a..75fb4eb2b073b 100644 --- a/crates/oxc_linter/src/snapshots/jest_no_hooks.snap +++ b/crates/oxc_linter/src/snapshots/jest_no_hooks.snap @@ -63,6 +63,18 @@ source: crates/oxc_linter/src/tester.rs · ───────── ╰──── + ⚠ eslint-plugin-jest(no-hooks): Do not use setup or teardown hooks. + ╭─[no_hooks.tsx:1:1] + 1 │ afterEach(() => {}) + · ───────── + ╰──── + + ⚠ eslint-plugin-jest(no-hooks): Do not use setup or teardown hooks. + ╭─[no_hooks.tsx:1:1] + 1 │ afterEach(() => {}) + · ───────── + ╰──── + ⚠ eslint-plugin-jest(no-hooks): Do not use setup or teardown hooks. ╭─[no_hooks.tsx:1:1] 1 │ beforeEach(() => {}); afterEach(() => { vi.resetModules() });