Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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;

```
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
declare namespace shared {
type State = string;
}

declare const shared: {
useState(): shared.State;
};

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

```
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/* should not generate diagnostics */
import { useState } from "./export-equals-shared-binding";

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-shared.ts
---
# Input
```ts
/* should not generate diagnostics */
import { useState } from "./export-equals-shared-binding";

export const state = useState;

```
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(
&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