diff --git a/crates/oxc_linter/fixtures/import/named-and-default-export.js b/crates/oxc_linter/fixtures/import/named-and-default-export.js new file mode 100644 index 0000000000000..92356e1a899ca --- /dev/null +++ b/crates/oxc_linter/fixtures/import/named-and-default-export.js @@ -0,0 +1,3 @@ +export default {}; + +export const foo = 10 diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index bc68d2267a987..f2e18d48e0838 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -12,6 +12,7 @@ mod import { pub mod named; pub mod no_amd; pub mod no_cycle; + pub mod no_named_as_default_member; pub mod no_self_import; } @@ -504,6 +505,7 @@ oxc_macros::declare_all_lint_rules! { react::no_unknown_property, react::require_render_return, import::default, + import::no_named_as_default_member, import::named, import::no_cycle, import::no_self_import, diff --git a/crates/oxc_linter/src/rules/import/no_named_as_default_member.rs b/crates/oxc_linter/src/rules/import/no_named_as_default_member.rs new file mode 100644 index 0000000000000..6a7d9eed94388 --- /dev/null +++ b/crates/oxc_linter/src/rules/import/no_named_as_default_member.rs @@ -0,0 +1,175 @@ +#![allow(clippy::significant_drop_tightening)] +use std::collections::HashMap; + +use dashmap::mapref::one::Ref; +use oxc_ast::{ + ast::{BindingPatternKind, Expression, MemberExpression}, + AstKind, +}; +use oxc_diagnostics::{ + miette::{self, Diagnostic}, + thiserror::Error, +}; +use oxc_macros::declare_oxc_lint; +use oxc_span::{Atom, Span}; +use oxc_syntax::module_record::ImportImportName; + +use crate::{context::LintContext, rule::Rule}; + +#[derive(Debug, Error, Diagnostic)] +#[error("eslint-plugin-import(no-named-as-default-member): {1:?} also has a named export {2:?}")] +#[diagnostic(severity(warning), help("Check if you meant to write `import {{{2:}}} from {3:?}`"))] +struct NoNamedAsDefaultMemberDignostic(#[label] pub Span, String, String, String); + +/// +#[derive(Debug, Default, Clone)] +pub struct NoNamedAsDefaultMember; + +declare_oxc_lint!( + /// ### What it does + /// + /// Reports use of an exported name as a property on the default export. + /// + /// ### Example + /// + /// ```javascript + /// // ./bar.js + /// export function bar() { return null } + /// export default () => { return 1 } + /// + /// // ./foo.js + /// import bar from './bar' + /// const bar = foo.bar // trying to access named export via default + /// ``` + NoNamedAsDefaultMember, + nursery +); + +impl Rule for NoNamedAsDefaultMember { + fn run_once(&self, ctx: &LintContext<'_>) { + let module_record = ctx.semantic().module_record(); + + let mut has_members_map: HashMap<&Atom, (Ref<'_, Atom, _, _>, Atom)> = HashMap::default(); + for import_entry in &module_record.import_entries { + let ImportImportName::Default(_) = import_entry.import_name else { + continue; + }; + + let specifier = import_entry.module_request.name(); + let Some(remote_module_record_ref) = module_record.loaded_modules.get(specifier) else { + continue; + }; + + if !remote_module_record_ref.exported_bindings.is_empty() { + has_members_map.insert( + import_entry.local_name.name(), + (remote_module_record_ref, import_entry.module_request.name().to_owned()), + ); + } + } + + if has_members_map.is_empty() { + return; + }; + let get_external_module_name_if_has_entry = |module_name: &Atom, entry_name: &Atom| { + has_members_map.get(&module_name).and_then(|it| { + if it.0.exported_bindings.contains_key(entry_name) { + Some(it.1.to_string()) + } else { + None + } + }) + }; + + let process_member_expr = |member_expr: &MemberExpression| { + let Expression::Identifier(ident) = member_expr.object() else { + return; + }; + let Some(prop_str) = member_expr.static_property_name() else { + return; + }; + if let Some(module_name) = + get_external_module_name_if_has_entry(&ident.name, &Atom::new_inline(prop_str)) + { + ctx.diagnostic(NoNamedAsDefaultMemberDignostic( + match member_expr { + MemberExpression::ComputedMemberExpression(it) => it.span, + MemberExpression::StaticMemberExpression(it) => it.span, + MemberExpression::PrivateFieldExpression(it) => it.span, + }, + ident.name.to_string(), + prop_str.to_string(), + module_name, + )); + }; + }; + + for item in ctx.semantic().nodes().iter() { + match item.kind() { + AstKind::MemberExpression(member_expr) => process_member_expr(member_expr), + AstKind::VariableDeclarator(decl) => { + if let Some(Expression::MemberExpression(member_expr)) = &decl.init { + process_member_expr(member_expr); + return; + } + let Some(Expression::Identifier(ident)) = &decl.init else { + return; + }; + let BindingPatternKind::ObjectPattern(object_pattern) = &decl.id.kind else { + return; + }; + + for prop in &*object_pattern.properties { + let Some(name) = prop.key.static_name() else { return }; + if let Some(module_name) = + get_external_module_name_if_has_entry(&ident.name, &name) + { + ctx.diagnostic(NoNamedAsDefaultMemberDignostic( + decl.span, + ident.name.to_string(), + name.to_string(), + module_name, + )); + } + } + } + _ => {} + } + } + } +} + +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + r#"import baz, {a} from "./named-exports""#, + r#"import baz from "./named-exports"; const jjj = bar.jjj"#, + r#"import {a} from "./named-exports"; const baz = a.baz"#, + r#"import baz from "./default_export_default_property"; const d = baz.default;"#, + r#"import baz, {foo} from "./named-and-default-export"; const d = baz.default;"#, + r"import baz from './named-exports'; + { + const baz = {}; + const a = baz.a; + }", + ]; + + let fail = vec![ + r#"import baz from "./named-exports"; const a = baz.a;"#, + r#"import baz from "./named-exports"; const a = baz["a"];"#, + r#"import baz from "./named-exports"; baz.a();"#, + r"import baz from './named-exports'; + { + const a = baz.a; + }", + r#"import baz, { bar } from "./named-exports"; const {a} = baz"#, + r#"import baz from "./named-and-default-export"; const {foo: _foo} = baz"#, + ]; + + Tester::new(NoNamedAsDefaultMember::NAME, pass, fail) + .change_rule_path("index.js") + .with_import_plugin(true) + .test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/no_named_as_default_member.snap b/crates/oxc_linter/src/snapshots/no_named_as_default_member.snap new file mode 100644 index 0000000000000..efda572e076d6 --- /dev/null +++ b/crates/oxc_linter/src/snapshots/no_named_as_default_member.snap @@ -0,0 +1,49 @@ +--- +source: crates/oxc_linter/src/tester.rs +expression: no_named_as_default_member +--- + ⚠ eslint-plugin-import(no-named-as-default-member): "baz" also has a named export "a" + ╭─[index.js:1:1] + 1 │ import baz from "./named-exports"; const a = baz.a; + · ───── + ╰──── + help: Check if you meant to write `import {a} from "./named-exports"` + + ⚠ eslint-plugin-import(no-named-as-default-member): "baz" also has a named export "a" + ╭─[index.js:1:1] + 1 │ import baz from "./named-exports"; const a = baz["a"]; + · ──────── + ╰──── + help: Check if you meant to write `import {a} from "./named-exports"` + + ⚠ eslint-plugin-import(no-named-as-default-member): "baz" also has a named export "a" + ╭─[index.js:1:1] + 1 │ import baz from "./named-exports"; baz.a(); + · ───── + ╰──── + help: Check if you meant to write `import {a} from "./named-exports"` + + ⚠ eslint-plugin-import(no-named-as-default-member): "baz" also has a named export "a" + ╭─[index.js:2:1] + 2 │ { + 3 │ const a = baz.a; + · ───── + 4 │ } + ╰──── + help: Check if you meant to write `import {a} from "./named-exports"` + + ⚠ eslint-plugin-import(no-named-as-default-member): "baz" also has a named export "a" + ╭─[index.js:1:1] + 1 │ import baz, { bar } from "./named-exports"; const {a} = baz + · ───────── + ╰──── + help: Check if you meant to write `import {a} from "./named-exports"` + + ⚠ eslint-plugin-import(no-named-as-default-member): "baz" also has a named export "foo" + ╭─[index.js:1:1] + 1 │ import baz from "./named-and-default-export"; const {foo: _foo} = baz + · ───────────────── + ╰──── + help: Check if you meant to write `import {foo} from "./named-and-default-export"` + + diff --git a/tasks/coverage/babel b/tasks/coverage/babel index 7c29fbc4db780..98c08853e3518 160000 --- a/tasks/coverage/babel +++ b/tasks/coverage/babel @@ -1 +1 @@ -Subproject commit 7c29fbc4db7809d24789235b0597e7e8dd61c4ae +Subproject commit 98c08853e3518c8e311a0d45f73f5ef4efae5d77 diff --git a/tasks/coverage/test262 b/tasks/coverage/test262 index 467a0fe68f633..a1ba783ca340e 160000 --- a/tasks/coverage/test262 +++ b/tasks/coverage/test262 @@ -1 +1 @@ -Subproject commit 467a0fe68f633fabde77df94d993c45e840627a0 +Subproject commit a1ba783ca340e4bf3d80b5f5e11fa54f2ee5f1ef diff --git a/tasks/coverage/typescript b/tasks/coverage/typescript index b6121e400cf86..cf33fd0cde229 160000 --- a/tasks/coverage/typescript +++ b/tasks/coverage/typescript @@ -1 +1 @@ -Subproject commit b6121e400cf8636760aa8a7da6b7fac14e2e70c7 +Subproject commit cf33fd0cde22905effce371bb02484a9f2009023 diff --git a/tasks/prettier_conformance/prettier b/tasks/prettier_conformance/prettier index 8d64505bd47bd..ff83d55d05e92 160000 --- a/tasks/prettier_conformance/prettier +++ b/tasks/prettier_conformance/prettier @@ -1 +1 @@ -Subproject commit 8d64505bd47bddb5f559f4a1eead64a158d81c5a +Subproject commit ff83d55d05e92ceef10ec0cb1c0272ab894a00a0