Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cool-lobsters-invent.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/* should not generate diagnostics */
import { useState } from "./export-equals-react";

export const state = useState;
Original file line number Diff line number Diff line change
@@ -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;

```
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
declare namespace ReactExportEquals {
function useState(): void;
}

export = ReactExportEquals;
Original file line number Diff line number Diff line change
@@ -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;

```
164 changes: 139 additions & 25 deletions crates/biome_module_graph/src/js_module_info/collector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TokenText>,
},
Reexport {
/// Name under which the import will be re-exported.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
}
Expand All @@ -877,7 +879,10 @@ impl JsModuleInfoCollector {
}
}

fn collect_exports(&mut self) -> IndexMap<Text, JsExport> {
fn collect_exports(
&mut self,
semantic_model: &biome_js_semantic::SemanticModel,
) -> IndexMap<Text, JsExport> {
let mut finalised_exports = IndexMap::new();

let exports = std::mem::take(&mut self.exports);
Expand All @@ -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 {
Expand All @@ -953,6 +958,50 @@ impl JsModuleInfoCollector {
finalised_exports
}

fn collect_member_exports_for_resolved_type(
&mut self,
mut resolved: ResolvedTypeId,
finalised_exports: &mut IndexMap<Text, JsExport>,
) {
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<JsOwnExport> {
let binding_ref = self.scopes[0].bindings_by_name.get(&local_name)?;

Expand Down Expand Up @@ -1000,6 +1049,71 @@ impl JsModuleInfoCollector {

Some(export)
}

fn collect_namespace_exports_for_local_name(
&self,
local_name: &TokenText,
finalised_exports: &mut IndexMap<Text, JsExport>,
) {
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<ResolvedTypeId> {
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 {
Expand Down
11 changes: 8 additions & 3 deletions crates/biome_module_graph/src/js_module_info/visitor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down
Loading
Loading