diff --git a/.changeset/cool-lobsters-invent.md b/.changeset/cool-lobsters-invent.md new file mode 100644 index 000000000000..682064de7f8a --- /dev/null +++ b/.changeset/cool-lobsters-invent.md @@ -0,0 +1,5 @@ +--- +"@biomejs/biome": patch +--- + +Fixed [#9626](https://github.com/biomejs/biome/issues/9626): `noUnresolvedImports` now keeps namespace members reachable from `export =` type definitions during project scans, including no-inference scans used by the CLI. diff --git a/crates/biome_js_analyze/tests/specs/correctness/noUnresolvedImports/export-equals-namespace.ts b/crates/biome_js_analyze/tests/specs/correctness/noUnresolvedImports/export-equals-namespace.ts new file mode 100644 index 000000000000..8110069925d2 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/correctness/noUnresolvedImports/export-equals-namespace.ts @@ -0,0 +1,4 @@ +/* should not generate diagnostics */ +import { useState } from "./export-equals-react"; + +export const state = useState; diff --git a/crates/biome_js_analyze/tests/specs/correctness/noUnresolvedImports/export-equals-namespace.ts.snap b/crates/biome_js_analyze/tests/specs/correctness/noUnresolvedImports/export-equals-namespace.ts.snap new file mode 100644 index 000000000000..faf677b711d3 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/correctness/noUnresolvedImports/export-equals-namespace.ts.snap @@ -0,0 +1,13 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +assertion_line: 149 +expression: export-equals-namespace.ts +--- +# Input +```ts +/* should not generate diagnostics */ +import { useState } from "./export-equals-react"; + +export const state = useState; + +``` diff --git a/crates/biome_js_analyze/tests/specs/correctness/noUnresolvedImports/export-equals-react.d.ts b/crates/biome_js_analyze/tests/specs/correctness/noUnresolvedImports/export-equals-react.d.ts new file mode 100644 index 000000000000..646cee65c9c0 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/correctness/noUnresolvedImports/export-equals-react.d.ts @@ -0,0 +1,5 @@ +declare namespace ReactExportEquals { + function useState(): void; +} + +export = ReactExportEquals; diff --git a/crates/biome_js_analyze/tests/specs/correctness/noUnresolvedImports/export-equals-react.d.ts.snap b/crates/biome_js_analyze/tests/specs/correctness/noUnresolvedImports/export-equals-react.d.ts.snap new file mode 100644 index 000000000000..dfce11fb7bab --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/correctness/noUnresolvedImports/export-equals-react.d.ts.snap @@ -0,0 +1,14 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +assertion_line: 149 +expression: export-equals-react.d.ts +--- +# Input +```ts +declare namespace ReactExportEquals { + function useState(): void; +} + +export = ReactExportEquals; + +``` diff --git a/crates/biome_js_analyze/tests/specs/correctness/noUnresolvedImports/export-equals-shared-binding.d.ts b/crates/biome_js_analyze/tests/specs/correctness/noUnresolvedImports/export-equals-shared-binding.d.ts new file mode 100644 index 000000000000..9ee2af6e5d7e --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/correctness/noUnresolvedImports/export-equals-shared-binding.d.ts @@ -0,0 +1,9 @@ +declare namespace shared { + type State = string; +} + +declare const shared: { + useState(): shared.State; +}; + +export = shared; diff --git a/crates/biome_js_analyze/tests/specs/correctness/noUnresolvedImports/export-equals-shared-binding.d.ts.snap b/crates/biome_js_analyze/tests/specs/correctness/noUnresolvedImports/export-equals-shared-binding.d.ts.snap new file mode 100644 index 000000000000..f996daa945af --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/correctness/noUnresolvedImports/export-equals-shared-binding.d.ts.snap @@ -0,0 +1,18 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +assertion_line: 149 +expression: export-equals-shared-binding.d.ts +--- +# Input +```ts +declare namespace shared { + type State = string; +} + +declare const shared: { + useState(): shared.State; +}; + +export = shared; + +``` diff --git a/crates/biome_js_analyze/tests/specs/correctness/noUnresolvedImports/export-equals-shared.ts b/crates/biome_js_analyze/tests/specs/correctness/noUnresolvedImports/export-equals-shared.ts new file mode 100644 index 000000000000..79ec6a685835 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/correctness/noUnresolvedImports/export-equals-shared.ts @@ -0,0 +1,4 @@ +/* should not generate diagnostics */ +import { useState } from "./export-equals-shared-binding"; + +export const state = useState; diff --git a/crates/biome_js_analyze/tests/specs/correctness/noUnresolvedImports/export-equals-shared.ts.snap b/crates/biome_js_analyze/tests/specs/correctness/noUnresolvedImports/export-equals-shared.ts.snap new file mode 100644 index 000000000000..497e3e72d99b --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/correctness/noUnresolvedImports/export-equals-shared.ts.snap @@ -0,0 +1,13 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +assertion_line: 149 +expression: export-equals-shared.ts +--- +# Input +```ts +/* should not generate diagnostics */ +import { useState } from "./export-equals-shared-binding"; + +export const state = useState; + +``` diff --git a/crates/biome_module_graph/src/js_module_info/collector.rs b/crates/biome_module_graph/src/js_module_info/collector.rs index 29c3e1c1e24a..01782a1ccf06 100644 --- a/crates/biome_module_graph/src/js_module_info/collector.rs +++ b/crates/biome_module_graph/src/js_module_info/collector.rs @@ -122,6 +122,8 @@ pub(super) enum JsCollectedExport { ExportDefaultAssignment { /// Reference to the type assigned to the export. ty: TypeReference, + /// Local identifier assigned to the export, if the expression is a plain identifier. + local_name: Option, }, Reexport { /// Name under which the import will be re-exported. @@ -552,7 +554,7 @@ impl JsModuleInfoCollector { self.purge_redundant_types(); } - let exports = self.collect_exports(); + let exports = self.collect_exports(semantic_model); let binding_type_data = self.build_binding_type_data(semantic_model); (exports, binding_type_data) @@ -861,7 +863,7 @@ impl JsModuleInfoCollector { for collected_export in &mut self.exports { match collected_export { JsCollectedExport::ExportDefault { ty } - | JsCollectedExport::ExportDefaultAssignment { ty } => { + | JsCollectedExport::ExportDefaultAssignment { ty, .. } => { if let TypeReference::Resolved(resolved_id) = ty { update_resolved_id(resolved_id); } @@ -877,7 +879,10 @@ impl JsModuleInfoCollector { } } - fn collect_exports(&mut self) -> IndexMap { + fn collect_exports( + &mut self, + semantic_model: &biome_js_semantic::SemanticModel, + ) -> IndexMap { let mut finalised_exports = IndexMap::new(); let exports = std::mem::take(&mut self.exports); @@ -903,31 +908,31 @@ impl JsModuleInfoCollector { let export = JsExport::Own(JsOwnExport::Type(resolved)); finalised_exports.insert(Text::new_static("default"), export); } - JsCollectedExport::ExportDefaultAssignment { ty } => { + JsCollectedExport::ExportDefaultAssignment { ty, local_name } => { let resolved = self.resolve_reference(&ty).unwrap_or(GLOBAL_UNKNOWN_ID); - - if let Some(data) = self.get_by_resolved_id(resolved) { - for member in data.as_raw_data().own_members() { - let Some(name) = member.name() else { - continue; - }; - - // DANGER: Normally, when resolving a type reference - // retrieved through `as_raw_data()`, we - // should call - // `apply_module_id_to_reference()` on the - // reference first. But because we know we - // are resolving inside the collector, - // before any module IDs _could_ be applied, - // we can omit this here. - if let Some(resolved_member) = self.resolve_reference(&member.ty) { - let export = JsExport::Own(JsOwnExport::Type(resolved_member)); - finalised_exports.insert(name, export); - } - } + let local_resolved = local_name.as_ref().and_then(|local_name| { + self.resolve_local_value_binding(local_name, semantic_model) + }); + + if let Some(local_name) = local_name.as_ref() { + self.collect_namespace_exports_for_local_name( + local_name, + &mut finalised_exports, + ); } - let export = JsExport::Own(JsOwnExport::Type(resolved)); + self.collect_member_exports_for_resolved_type( + local_resolved.unwrap_or(resolved), + &mut finalised_exports, + ); + + let export = local_name + .as_ref() + .and_then(|local_name| { + self.get_export_for_local_name(local_name.clone()) + .map(JsExport::Own) + }) + .unwrap_or_else(|| JsExport::Own(JsOwnExport::Type(resolved))); finalised_exports.insert(Text::new_static("default"), export); } JsCollectedExport::Reexport { @@ -953,6 +958,50 @@ impl JsModuleInfoCollector { finalised_exports } + fn collect_member_exports_for_resolved_type( + &self, + mut resolved: ResolvedTypeId, + finalised_exports: &mut IndexMap, + ) { + let mut seen_types = Vec::new(); + + loop { + if seen_types.contains(&resolved) { + return; + } + seen_types.push(resolved); + + let Some(data) = self.get_by_resolved_id(resolved) else { + return; + }; + + match data.as_raw_data() { + TypeData::InstanceOf(instance) => { + let instance_ty = data.apply_module_id_to_reference(&instance.ty); + let Some(next) = self.resolve_reference(&instance_ty) else { + return; + }; + resolved = next; + } + _ => { + for member in data.as_raw_data().own_members() { + let Some(name) = member.name() else { + continue; + }; + + let member_ty = data.apply_module_id_to_reference(&member.ty); + if let Some(resolved_member) = self.resolve_reference(&member_ty) { + let export = JsExport::Own(JsOwnExport::Type(resolved_member)); + finalised_exports.insert(name, export); + } + } + + return; + } + } + } + } + fn get_export_for_local_name(&mut self, local_name: TokenText) -> Option { let binding_ref = self.scopes[0].bindings_by_name.get(&local_name)?; @@ -1000,6 +1049,71 @@ impl JsModuleInfoCollector { Some(export) } + + fn collect_namespace_exports_for_local_name( + &self, + local_name: &TokenText, + finalised_exports: &mut IndexMap, + ) { + let Some(binding_ref) = self.scopes[0].bindings_by_name.get(local_name).copied() else { + return; + }; + let Some(namespace_binding_id) = binding_ref.namespace_ty_or_ty() else { + return; + }; + let namespace_binding = &self.bindings[namespace_binding_id.index()]; + let namespace_scope_id = namespace_binding.scope_id; + + for binding in &self.bindings { + if binding.name.text().is_empty() { + continue; + } + + let scope = &self.scopes[binding.scope_id.index()]; + if scope.parent != Some(namespace_scope_id) { + continue; + } + + finalised_exports + .entry(binding.name.clone()) + .or_insert_with(|| JsExport::Own(JsOwnExport::Binding(binding.range))); + } + } + + fn resolve_local_value_binding( + &mut self, + local_name: &TokenText, + semantic_model: &biome_js_semantic::SemanticModel, + ) -> Option { + let binding_ref = self.scopes[0].bindings_by_name.get(local_name).copied()?; + let binding_id = binding_ref.value_ty_or_ty(); + + self.ensure_binding_type(binding_id, semantic_model); + self.resolve_reference(&self.bindings[binding_id.index()].ty) + } + + fn ensure_binding_type( + &mut self, + binding_id: BindingId, + semantic_model: &biome_js_semantic::SemanticModel, + ) { + if self.bindings[binding_id.index()].ty.is_known() { + return; + } + + let binding = self.bindings[binding_id.index()].clone(); + let Some(node) = self + .binding_node_by_start + .get(&binding.range.start()) + .cloned() + else { + return; + }; + + let scope_id = semantic_model.scope_for_range(binding.range).id(); + let ty = self.infer_type(&node, binding, scope_id, semantic_model); + self.bindings[binding_id.index()].ty = ty; + } } impl TypeResolver for JsModuleInfoCollector { diff --git a/crates/biome_module_graph/src/js_module_info/visitor.rs b/crates/biome_module_graph/src/js_module_info/visitor.rs index e5f8a39fdca9..a9f3cfe9bfe9 100644 --- a/crates/biome_module_graph/src/js_module_info/visitor.rs +++ b/crates/biome_module_graph/src/js_module_info/visitor.rs @@ -168,10 +168,15 @@ impl<'a> JsModuleVisitor<'a> { node: TsExportAssignmentClause, collector: &mut JsModuleInfoCollector, ) -> Option<()> { - let type_data = - TypeData::from_any_js_expression(collector, ScopeId::GLOBAL, &node.expression().ok()?); + let expression = node.expression().ok()?; + let local_name = expression + .as_js_identifier_expression() + .and_then(|ident| ident.name().ok()) + .and_then(|name| name.value_token().ok()) + .map(|token| token.token_text_trimmed()); + let type_data = TypeData::from_any_js_expression(collector, ScopeId::GLOBAL, &expression); let ty = TypeReference::from(collector.register_and_resolve(type_data)); - collector.register_export(JsCollectedExport::ExportDefaultAssignment { ty }); + collector.register_export(JsCollectedExport::ExportDefaultAssignment { ty, local_name }); Some(()) } diff --git a/crates/biome_module_graph/tests/spec_tests.rs b/crates/biome_module_graph/tests/spec_tests.rs index ac2c0134b031..008ce2b31c43 100644 --- a/crates/biome_module_graph/tests/spec_tests.rs +++ b/crates/biome_module_graph/tests/spec_tests.rs @@ -1598,6 +1598,16 @@ fn test_resolve_react_types() { let module_graph = Arc::new(ModuleGraph::default()); module_graph.update_graph_for_js_paths(&fs, &project_layout, &added_paths, true); + let react_module = module_graph + .js_module_info_for_path(Utf8Path::new("/node_modules/@types/react/index.d.ts")) + .expect("react typings module must exist"); + assert!( + react_module + .find_js_exported_symbol(module_graph.as_ref(), "useCallback") + .is_some(), + "`useCallback` should be visible as a named export from `export = React` typings" + ); + let index_module = module_graph .js_module_info_for_path(Utf8Path::new("/src/index.ts")) .expect("module must exist"); @@ -1619,6 +1629,129 @@ fn test_resolve_react_types() { assert!(promise_ty.is_promise_instance()); } +#[test] +fn test_react_named_exports_are_visible_without_type_inference() { + let fs = MemoryFileSystem::default(); + fs.insert( + "/node_modules/@types/react/index.d.ts".into(), + include_bytes!("../../biome_resolver/tests/fixtures/resolver_cases_5/node_modules/@types/react/index.d.ts") + ); + fs.insert( + "/src/index.ts".into(), + r#"import { useState } from "react"; + + export const x = useState; + "#, + ); + + let added_paths = [ + BiomePath::new("/node_modules/@types/react/index.d.ts"), + BiomePath::new("/src/index.ts"), + ]; + let added_paths = get_added_js_paths(&fs, &added_paths); + + let project_layout = ProjectLayout::default(); + project_layout.insert_node_manifest( + "/".into(), + PackageJson::new("frontend") + .with_version("0.0.0") + .with_dependencies(Dependencies(Box::new([("react".into(), "19.0.0".into())]))), + ); + + let tsconfig_json = parse_json(r#"{}"#, JsonParserOptions::default()); + project_layout + .insert_serialized_tsconfig("/".into(), &tsconfig_json.syntax().as_send().unwrap()); + + let module_graph = ModuleGraph::default(); + module_graph.update_graph_for_js_paths(&fs, &project_layout, &added_paths, false); + + let react_module = module_graph + .js_module_info_for_path(Utf8Path::new("/node_modules/@types/react/index.d.ts")) + .expect("react typings module must exist"); + assert!( + react_module + .find_js_exported_symbol(&module_graph, "useState") + .is_some(), + "`useState` should stay visible to noUnresolvedImports during project scans without type inference" + ); +} + +#[test] +fn test_export_equals_named_exports_are_visible_without_type_inference() { + let fs = MemoryFileSystem::default(); + fs.insert( + "/node_modules/shared/dist/index.js".into(), + r#" + export function useState() {} + "#, + ); + fs.insert( + "/node_modules/shared/dist/index.d.ts".into(), + r#" + declare namespace shared { + type State = string; + } + + declare const shared: { + useState(): shared.State; + } + + export = shared; + "#, + ); + fs.insert( + "/src/index.ts".into(), + r#"import { useState } from "shared"; + + export const state = useState; + "#, + ); + + let added_paths = [ + BiomePath::new("/node_modules/shared/dist/index.js"), + BiomePath::new("/node_modules/shared/dist/index.d.ts"), + BiomePath::new("/src/index.ts"), + ]; + let added_paths = get_added_js_paths(&fs, &added_paths); + + let project_layout = ProjectLayout::default(); + project_layout.insert_node_manifest( + "/".into(), + PackageJson::new("frontend") + .with_version("0.0.0") + .with_dependencies(Dependencies(Box::new([( + "shared".into(), + "link:./node_modules/shared".into(), + )]))), + ); + project_layout.insert_node_manifest( + "/node_modules/shared".into(), + PackageJson::new("shared") + .with_exports(JsonObject::from([ + ("types".into(), JsonString::from("./dist/index.d.ts").into()), + ("default".into(), JsonString::from("./dist/index.js").into()), + ])) + .with_version("0.0.1"), + ); + + let tsconfig_json = parse_json(r#"{}"#, JsonParserOptions::default()); + project_layout + .insert_serialized_tsconfig("/".into(), &tsconfig_json.syntax().as_send().unwrap()); + + let module_graph = ModuleGraph::default(); + module_graph.update_graph_for_js_paths(&fs, &project_layout, &added_paths, false); + + let export_equals_module = module_graph + .js_module_info_for_path(Utf8Path::new("/node_modules/shared/dist/index.d.ts")) + .expect("export-equals typings module must exist"); + assert!( + export_equals_module + .find_js_exported_symbol(&module_graph, "useState") + .is_some(), + "`useState` should stay visible for identifier-based `export =` bindings during project scans without type inference" + ); +} + #[test] fn test_resolve_redis_commander_types() { let fs = MemoryFileSystem::default();