Skip to content

fix(linter): detect floating promises through cross-module generic wrappers#9835

Merged
ematipico merged 3 commits intobiomejs:mainfrom
bmish:fix/no-floating-promises-cross-module-generic-wrappers
Apr 14, 2026
Merged

fix(linter): detect floating promises through cross-module generic wrappers#9835
ematipico merged 3 commits intobiomejs:mainfrom
bmish:fix/no-floating-promises-cross-module-generic-wrappers

Conversation

@bmish
Copy link
Copy Markdown
Contributor

@bmish bmish commented Apr 6, 2026

Summary

noFloatingPromises missed unhandled promises when an async function was wrapped by a generic identity function defined in another module, a common pattern for tracing decorators, instrumentation wrappers, and memoization helpers:

// trace.ts
export function trace<F extends (...args: any) => any>(fn: F): F { return fn; }

// wrapper.ts
import { trace } from "./trace";
async function _doWork(id: string): Promise<void> { /* ... */ }
export const doWork = trace(_doWork);
export const maybeDoWork: typeof doWork | undefined = doWork;

// index.ts
import { doWork, maybeDoWork } from "./wrapper";
async function main() {
  doWork("abc"); // ← floating promise, was not detected
  maybeDoWork?.("xyz"); // ← also not detected (union-typed callee)
}

The root cause was two gaps in type flattening (expressions.rs):

  1. Unhandled TypeofExpression callees. resolve_callee_to_function could resolve Function, InstanceOf, Interface, and Object callees, but not TypeofExpression. When an imported binding's type is an unflattened call expression (e.g. typeof trace(asyncFn)), the callee couldn't be resolved to its underlying function type. Fixed by adding a TypeofExpression arm that eagerly flattens the expression.

  2. TypeofExpression skipped inside union-typed callees. flattened_call explicitly skipped TypeofExpression variants when iterating through a union callee (e.g. typeof doWork | undefined from an optional call maybeDoWork?.(...)). This prevented detection even after fix 1. Fixed by removing the skip so these variants fall through to resolve_callee_to_function.

Known limitations

Nested/chained generic wrappers (memoize(trace(asyncFn))) across modules are still not detected. The inner TypeofCallExpression argument isn't flattened during generic inference in flattened_function_call, so the composed result doesn't resolve to a function type. This is a pre-existing limitation and could be addressed separately.

Related issues

  • Partially addresses #7332

Test plan

  • invalidGenericWrapper/ — three-file test (index -> wrapper -> trace) with generic trace<F>(fn: F): F, verifies diagnostics for Promise<void> and Promise<string> return types, plus an optional call through a union-typed binding (maybeDoWork?.("xyz")).
  • validGenericWrapper/ — same topology, verifies no false positives when calls are properly handled (await, void, .catch()), and that sync functions through the wrapper are not flagged.
  • invalidTwoHopImport/ — two-hop re-export without generics (base -> reexport -> index).
  • 20_invalid.ts — single-file generic wrapper (same-module baseline).
  • All 46 noFloatingPromises tests, 2326 biome_js_analyze spec tests, and 46 biome_module_graph tests pass. Clippy clean.

This PR was written primarily with AI assistance (Claude).

…appers

Two issues prevented `noFloatingPromises` from detecting unhandled promises
when async functions were wrapped by cross-module generic identity functions
(e.g. `export const fn = trace(asyncFn)` where `trace<F>(fn: F): F`).

1. When types are copied from a module's local (Thin) store to the
   project-wide (Full) resolver, inner `TypeReference::Resolved` fields
   inside compound types like `TypeofCallExpression` retained stale
   Thin-level IDs. Add a remapping pass in `ModuleResolver::resolve_all`
   that rewrites these inner references to Full-level IDs.

2. `resolve_callee_to_function` did not handle `TypeofExpression` callees,
   so imported bindings whose types were unflattened call expressions
   (e.g. `typeof trace(asyncFn)`) could not be resolved to their
   underlying function type. Add a `TypeofExpression` arm that eagerly
   flattens the expression.

Made-with: Cursor
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 6, 2026

🦋 Changeset detected

Latest commit: 1335923

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 13 packages
Name Type
@biomejs/biome Patch
@biomejs/cli-win32-x64 Patch
@biomejs/cli-win32-arm64 Patch
@biomejs/cli-darwin-x64 Patch
@biomejs/cli-darwin-arm64 Patch
@biomejs/cli-linux-x64 Patch
@biomejs/cli-linux-arm64 Patch
@biomejs/cli-linux-x64-musl Patch
@biomejs/cli-linux-arm64-musl Patch
@biomejs/wasm-web Patch
@biomejs/wasm-bundler Patch
@biomejs/wasm-nodejs Patch
@biomejs/backend-jsonrpc Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions Bot added A-Project Area: project A-Linter Area: linter L-JavaScript Language: JavaScript and super languages A-Type-Inference Area: type inference labels Apr 6, 2026
@bmish bmish marked this pull request as ready for review April 6, 2026 20:18
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 6, 2026

Walkthrough

Updates enable the noFloatingPromises linter rule to detect floating promises when promise-returning functions are wrapped by cross-module generic wrappers. New TypeScript test fixtures cover valid and invalid generic-wrapper and two-hop import scenarios. Implementation changes traverse TypeofExpression during callee-to-function resolution and add a post-processing remap of module-level TypeReference::Resolved entries to full resolved type IDs during module resolution.

Suggested reviewers

  • dyc3
  • ematipico
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarises the core change: detecting floating promises through cross-module generic wrappers, which is the primary objective of the PR.
Description check ✅ Passed The PR description clearly explains the bug (floating promises missed through cross-module generic wrappers), provides concrete examples, identifies root causes, details the fixes implemented, and documents test coverage.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.changeset/fix-no-floating-promises-generic-wrappers.md:
- Line 5: Update the changeset description so it begins with the repository
bug-fix prefix; replace the existing first sentence ("The `noFloatingPromises`
rule now detects floating promises through cross-module generic wrapper
functions...") with a line that starts with the bug-fix format, e.g. "Fixed
[`#7332`](https://github.com/biomejs/biome/issues/7332): The `noFloatingPromises`
rule now detects floating promises through cross-module generic wrapper
functions..." to match the required `.changeset/*.md` convention and ensure
release notes parse correctly; edit the file named
.changeset/fix-no-floating-promises-generic-wrappers.md and update the existing
description line accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4ee854c6-99bb-475d-88ac-d6b94c6a453c

📥 Commits

Reviewing files that changed from the base of the PR and between 3884c04 and de7645e.

⛔ Files ignored due to path filters (18)
  • crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/20_invalid.ts.snap is excluded by !**/*.snap and included by **
  • crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/invalidGenericWrapper/index.ts.snap is excluded by !**/*.snap and included by **
  • crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/invalidGenericWrapper/trace.ts.snap is excluded by !**/*.snap and included by **
  • crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/invalidGenericWrapper/wrapper.ts.snap is excluded by !**/*.snap and included by **
  • crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/invalidTwoHopImport/base.ts.snap is excluded by !**/*.snap and included by **
  • crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/invalidTwoHopImport/index.ts.snap is excluded by !**/*.snap and included by **
  • crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/invalidTwoHopImport/reexport.ts.snap is excluded by !**/*.snap and included by **
  • crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/validGenericWrapper/index.ts.snap is excluded by !**/*.snap and included by **
  • crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/validGenericWrapper/trace.ts.snap is excluded by !**/*.snap and included by **
  • crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/validGenericWrapper/wrapper.ts.snap is excluded by !**/*.snap and included by **
  • crates/biome_module_graph/tests/snapshots/test_resolve_generic_mapped_value.snap is excluded by !**/*.snap and included by **
  • crates/biome_module_graph/tests/snapshots/test_resolve_generic_return_value.snap is excluded by !**/*.snap and included by **
  • crates/biome_module_graph/tests/snapshots/test_resolve_generic_return_value_with_multiple_modules.snap is excluded by !**/*.snap and included by **
  • crates/biome_module_graph/tests/snapshots/test_resolve_nested_function_call_with_namespace_in_return_type.snap is excluded by !**/*.snap and included by **
  • crates/biome_module_graph/tests/snapshots/test_resolve_type_of_destructured_field_of_intersection_of_interfaces.snap is excluded by !**/*.snap and included by **
  • crates/biome_module_graph/tests/snapshots/test_resolve_type_of_intersection_of_interfaces.snap is excluded by !**/*.snap and included by **
  • crates/biome_module_graph/tests/snapshots/test_widening_via_assignment.snap is excluded by !**/*.snap and included by **
  • crates/biome_module_graph/tests/snapshots/test_widening_via_assignment_multiple_values.snap is excluded by !**/*.snap and included by **
📒 Files selected for processing (13)
  • .changeset/fix-no-floating-promises-generic-wrappers.md
  • crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/20_invalid.ts
  • crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/invalidGenericWrapper/index.ts
  • crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/invalidGenericWrapper/trace.ts
  • crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/invalidGenericWrapper/wrapper.ts
  • crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/invalidTwoHopImport/base.ts
  • crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/invalidTwoHopImport/index.ts
  • crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/invalidTwoHopImport/reexport.ts
  • crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/validGenericWrapper/index.ts
  • crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/validGenericWrapper/trace.ts
  • crates/biome_js_analyze/tests/specs/nursery/noFloatingPromises/validGenericWrapper/wrapper.ts
  • crates/biome_js_type_info/src/flattening/expressions.rs
  • crates/biome_module_graph/src/js_module_info/module_resolver.rs

Comment thread .changeset/fix-no-floating-promises-generic-wrappers.md
… generic wrappers

TypeofExpression callees inside union types (e.g. optional calls via `?.`)
were being skipped in `flattened_call`, preventing detection. Remove the
skip so they fall through to `resolve_callee_to_function` which already
handles TypeofExpression flattening.

Made-with: Cursor
@ematipico
Copy link
Copy Markdown
Member

These stale references caused lookups to resolve to the wrong type (e.g. ImportNamespace instead of the actual function binding).

Can you actually explain it with actual code? Are there tests you can point to?

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Apr 7, 2026

Merging this PR will not alter performance

✅ 58 untouched benchmarks
⏩ 196 skipped benchmarks1


Comparing bmish:fix/no-floating-promises-cross-module-generic-wrappers (1335923) with main (82b9c4a)

Open in CodSpeed

Footnotes

  1. 196 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@bmish
Copy link
Copy Markdown
Contributor Author

bmish commented Apr 7, 2026

These stale references caused lookups to resolve to the wrong type (e.g. ImportNamespace instead of the actual function binding).

Can you actually explain it with actual code? Are there tests you can point to?

The test for this is invalidGenericWrapper/. In wrapper.ts, export const doWork = trace(_doWork) gets stored as a TypeofCallExpression. The problem is that when resolve_all copies types into the Full store, the inner references inside that expression (callee, args) keep their old Module(0) IDs, so they point at the wrong things. The module-graph snapshot diffs (e.g. test_resolve_generic_return_value.snap) show returns: Module(0) TypeId(0) becoming returns: Full TypeId(0).

Comment thread crates/biome_module_graph/src/js_module_info/module_resolver.rs Outdated
Comment thread crates/biome_module_graph/src/js_module_info/module_resolver.rs Outdated
Comment thread crates/biome_module_graph/src/js_module_info/module_resolver.rs Outdated
The Thin→Full inner reference remapping is not required for the
floating promise detection fix. Revert the module_resolver.rs changes
and associated snapshot updates as requested.

Made-with: Cursor
@github-actions github-actions Bot removed the A-Project Area: project label Apr 12, 2026
@ematipico ematipico merged commit f8d49d9 into biomejs:main Apr 14, 2026
19 checks passed
@github-actions github-actions Bot mentioned this pull request Apr 14, 2026
@arendjr
Copy link
Copy Markdown
Contributor

arendjr commented Apr 15, 2026

This is a great improvement, thanks both!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-Linter Area: linter A-Type-Inference Area: type inference L-JavaScript Language: JavaScript and super languages

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants