Skip to content

fix(js,ts): default imports follow a module's imported default; calls never land on types - #2800

Open
colbymchenry wants to merge 1 commit into
mainfrom
claude/js-default-exports-call-kinds
Open

colbymchenry wants to merge 1 commit into
mainfrom
claude/js-default-exports-call-kinds

Conversation

@colbymchenry

@colbymchenry colbymchenry commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

Summary

Two resolver bugs that land an import on the wrong kind of symbol. Both are older than #2799 and were already on main, but #2799 made them common, because packages imported by name now resolve. Its A/B showed them in jest, webdriverio and memfs, and they need no workspace to reproduce.

  • A default import of a module that hands on a default it doesn't declare. jest-resolve's index is import Resolver from './resolver'; export * from './utils'; export default Resolver. A default import of it walked the export * and took the first function utils.ts exports, so 23 jest edges such as Resolver.findNodeModule(), new Resolver() and Resolver types landed on resolveTestEnvironment. A module that default-exports an import of its own beside its own functions had the same problem: insomnia's main/log.ts (import log from 'electron-log/main' … export default log) and outline's logging/sentry.ts sent their importers' log.info() and Sentry.captureException() calls to whichever function the module exported first.
  • A call or new landing on a TypeScript interface or type alias. memfs's result.ts declares interface Ok<T> and function Ok<T>(), and Ok(…) through an import reached the interface. A new of a global that a project only types (new Response(), new Worker(), new Event()) was linked by name to the project's interface Response or type Event.

Root cause

findExportedSymbolWalk (import-resolver.ts) answers a default import from the module's own export default statement (#2432). It missed two things.

  • A statement naming a binding the module only imports found no node, so it fell back to the old guess: the first function or class the module exports.
  • When that found nothing, step 3 walked export * from re-exports for the default too. An ES module's export * never forwards default.

The resolver has no rule on what a call may target. Whichever strategy found the name (import, then name match) chose among same-named nodes by order, and nothing stopped it on a type.

Fix

import-resolver.ts

  • statedDefaultExport: a default that names a binding the module imports and doesn't declare is now that import ({ imported }), read from the module's import mappings.
  • esmDefaultExport follows that import into the module it comes from, as a named or default import of that module. Nothing comes back for a namespace import or a package outside the project, and no guess is made.
  • findExportedSymbolWalk:
    • stops after a default that the module imports, found or not;
    • no longer walks export * for a default (Python's star imports are untouched).

index.ts (gateTargetKind): for the JS family, a calls or instantiates result whose target is an interface or a type_alias moves to the value the target's file declares under the same name (a function, class, component, constant or variable, first by position). That covers function Ok beside interface Ok, and a class merged with an interface. With no such value, the result is dropped, so new Response() links nothing.

Overlap with #2754, which is open and held: following a default that names an import is one of the things #2754 does, implemented here minimally ({ imported } in StatedDefault, one follow). #2754's broader reading (wrappers, new X(), export { X as default }, forwarded named exports) is untouched, and would supersede this part when it lands. The export * rule and the call/new gate are not in #2754.

Validation

Tests: __tests__/default-import-and-value-targets.test.ts indexes real temp repositories (8 tests).

  • Defaults:
    • a default that names an import, beside export * (jest-resolve's shape);
    • the same followed past a CommonJS namesake (webdriver's node.ts);
    • an expression default beside export *;
    • a default import of a package (electron-log, a @sentry/node namespace) beside the module's own functions;
    • the control, a declared default.
  • Value targets:
    • Ok(…) reaches function Ok beside interface Ok, directly and through a barrel, while a type position still names the interface;
    • new Response() and new Event() link nothing when only project types share the names;
    • new Store() reaches the class an interface merges with.

Results:

  • RED on main: 6 of 8 fail. The 2 that pass pin behaviour main already gets right: the declared default, and the webdriver shape, which main reached only through the export * walk.
  • GREEN on the kernel. The change is resolver-only; the kernel only extracts.
  • Mutations: 7 rules removed one at a time, and each removal fails at least one test. The rules are: export * and defaults, the imported default, following it, the call/new gate, the value beside the type, new gated, and a class beside an interface.
  • 90 related test files pass (2,177 tests): import, default, sync, resolution, React Router, JSX, frameworks and others.

Sync equals a fresh index: CLI codegraph sync against a fresh init, in 9 scenarios:

  • a barrel's default becomes an import, or becomes declared;
  • a barrel gains export *;
  • a module gains its own function beside an imported default;
  • a forwarded default moves in its module, or the forwarding import changes source;
  • the value beside a type is added or removed;
  • a project type for a global appears.

A/B: fresh init, both arms with the native kernel, site-paired edge diff, main e8ddc4e.

repo result
jest 61050e93 23 retargets, all right. Resolver.findNodeModule() and the other static calls, new Resolver() and Resolver types now reach jest-resolve's Resolver class through the import. They had been on resolveTestEnvironment; before #2799 main had them right, but only by name match. 8 removed: new Event(), new Module() and a new Reporter() of a local variable, which had landed on type aliases and interfaces, and write() on type write.
memfs adae41a5 11 Ok(…)/Err(…) calls move from interface Ok/Err to the functions. new ReadableStream() no longer lands on a project interface.
webdriverio c6f450ee 2 allure.addFeature()/addDescription() calls move from api.ts's first function to AllureReporter's methods. 55 new SauceService() keep their target, now through the import. 26 new of globals are dropped (Event, Response, Request, mocha's Context and Suite).
insomnia 1a5a33aa 47 new Response(), new Request(), new Headers() and new Event() are dropped; they had linked to Postman importer types. 2 log.info() calls on electron-log's logger move from initializeLogging to a method-name guess, Console.info. That is wrong both before and after (see below).
outline 45bf9e29 2 Sentry.withScope()/captureException() calls no longer land on requestErrorHandler.
social-app 00ff89e3 2 BackgroundNotificationHandler.setBadgeCountAsync() calls now link the constant the module's default forwards, as a named import of it already does, instead of the type's method guessed by name. 2 new BroadcastChannel() keep their target, now through the import.
vite 4260c756 27 new Worker()/new Event() dropped; they had linked to .d.ts interfaces.
nuxt d296167e 57 new Response() dropped; it had linked to a type Response in a build stub.
vben bd7b04d2, t3-turbo 8f945b7b, nitro da176d5f, vue3-antdv-admin 64c03db7, pathe bc7477a0, c12 c42af029 byte-identical
flask d086db85 (Python), gin 0f09c3a9 (Go): controls byte-identical

I checked every removed new by grouping on name and target. Each names a global (Worker, Event, Response, Request, Headers, ReadableStream, Node's Module, mocha's Context/Suite) or a local variable, never a class of the project.

Cost: fresh index, median of 3, run back to back, alternating which arm goes first. All three are within noise:

repo main this PR
webdriverio 13.88 s 13.93 s
insomnia 15.28 s 15.05 s
nuxt 11.64 s 11.03 s

The added work is one import-mapping lookup per default statement that names a binding, and one same-file lookup when a call target is a type.

Full suite on 48f0d88 (CODEGRAPH_KERNEL_EXPECT=1, 4 workers): 725 files (719 passed, 6 skipped), 8,693 tests: 8,611 passed, 0 failed, 82 skipped. No file needed a rerun.

Not in this PR

  • Alias constants. export const ModuleMap = HasteModuleMap as … and export const TestEnvironment = JSDOMEnvironment are still not followed to the class. jest's 26 ModuleMap.create() calls link the constant, and 6 extends require('jest-environment-jsdom').TestEnvironment stay unlinked. Following a constant's initializer to a class, through casts, is its own change.
  • A default that forwards a package's import, used as a receiver. import log from './main/log', where log.ts forwards electron-log's default, then log.info(): the call no longer reaches a guessed function of log.ts. It is still guessed by method name elsewhere (insomnia: Console.info), because the receiver isn't known to come from an outside package. A namespace default like outline's Sentry links nothing.

🤖 Generated with Claude Code

… never land on types

Two resolver bugs that land an import on the wrong kind of symbol, on main
before PR #2799 made them common (its A/B found them in jest, webdriverio
and memfs):
- A default import of a module whose `export default X` names a binding
  the module only imports fell back to a guess: the first function the
  module exports, or, through `export * from`, the first function of a
  forwarded module. jest-resolve (`import Resolver from './resolver';
  export * from './utils'; export default Resolver`) sent 23 edges such as
  `Resolver.findNodeModule()` to `resolveTestEnvironment`.
- A call or `new` reached a TypeScript interface or type alias: memfs's
  `Ok(...)` reached `interface Ok` beside `function Ok`, and `new
  Response()` / `new Worker()` linked to project types for those globals.

Fix:
- import-resolver.ts: a default naming an imported binding is that import,
  followed into its module (a namespace or outside package gives nothing,
  never a guess), and `export *` is never walked for a default, as ES
  modules never forward one.
- gateTargetKind: a JS-family `calls`/`instantiates` result on an interface
  or type alias moves to the value its file declares under that name
  (function, class, component, constant, variable), else it is dropped.

Validation:
- __tests__/default-import-and-value-targets.test.ts (8 tests): 6 fail on
  main; the 2 that pass pin behaviour main gets right. All pass on the
  kernel. Each of 7 rules removed in turn fails at least one test.
- Sync equals a fresh index (CLI) in 9 scenarios: defaults becoming
  imported or declared, a barrel gaining `export *`, a forwarded default
  moving, the value beside a type added or removed.
- A/B on 14 TS/JS repos and 2 controls: jest 23 edges corrected to
  jest-resolve's Resolver; memfs 11 Ok/Err calls to their functions;
  webdriverio 2 allure calls to AllureReporter's methods; outline's 2
  Sentry calls off a guessed function; 165 `new` of globals dropped (vite
  27, nuxt 57, webdriverio 26, insomnia 47, jest 7, memfs 1) plus jest's
  call to `type write`. vben, t3-turbo, nitro, vue3-antdv-admin, pathe, c12,
  flask and gin byte-identical.
- Cost: fresh index within noise (median of 3, interleaved).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@colbymchenry

Copy link
Copy Markdown
Owner Author

Held for Colby, not merged: the default-export half of this PR overlaps #2754, which is waiting on his review (it replaces #2451, which he asked to keep open and unmerged). Specifically, following an export default X whose X is an import, and never walking export * for a default. The call/new-onto-an-interface-or-type-alias half doesn't overlap, so it's being landed separately in its own PR. When #2754 is decided, this one can be merged or closed with it.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant