Skip to content

fix(resolution): resolve .js import specifiers to their TS source - #1484

Open
valkyriweb wants to merge 1 commit into
colbymchenry:mainfrom
valkyriweb:fix/js-specifier-ts-resolution
Open

fix(resolution): resolve .js import specifiers to their TS source#1484
valkyriweb wants to merge 1 commit into
colbymchenry:mainfrom
valkyriweb:fix/js-specifier-ts-resolution

Conversation

@valkyriweb

Copy link
Copy Markdown

Fixes the first of the two root causes behind #1482.

The bug

EXTENSION_RESOLUTION only ever appends a suffix. So a NodeNext-style relative specifier is tried as:

./x.js  ->  x.js.ts   x.js.tsx   x.js.d.ts   ...   x.js

x.ts is never tried. resolveRelativeImport returns null.

That matters more than it looks, because "moduleResolution": "NodeNext" requires the emitted extension on relative imports. In such a project no relative import resolves at all — every reference falls through to Strategy 3, the name-matcher. Name-matching cannot cross a rename, so a default import or an aliased import silently reports 0 callers with full confidence.

One 16k-file TypeScript project I measured has 32,732 .js specifiers and 0 extensionless ones. For that repo, import-based resolution was effectively off.

The fix

A small rewrite table, tried only after the literal path:

specifier candidates
.js .ts, .tsx, .d.ts
.jsx .tsx, .jsx, .d.ts
.mjs .mts, .d.mts
.cjs .cts, .d.cts

Applied in resolveRelativeImport and in resolveAliasedImport's tryWithExt, gated to TS-family languages.

Ordering is the safety property: a real emitted .js sitting on disk beside its source still wins, so no currently-resolving import changes target. There is a test for exactly that.

Tests

__tests__/js-specifier-ts-resolution.test.ts, 3 cases:

  1. default import through ./impl.js — resolves to impl.ts
  2. .mjs / .cjs -> .mts / .cts
  3. ./emitted.js where both emitted.js and emitted.ts exist — the real .js wins

Each fixture imports under a different local name than the declaration, so the name-matcher cannot rescue the edge; the assertion isolates import resolution.

On main cases 1 and 2 fail and case 3 passes; with the fix all three pass.

Verification

  • full suite on this branch: 2,533 passed, 0 failed, 175 skipped
  • the 175 skips are the kernel-*-parity files, expected without a native kernel build (CODEGRAPH_KERNEL=0)
  • real-world: a 18.4k-file TS/polyglot repo re-indexed in 1m30s, edges 1,262,985 -> 1,268,309 (+5,324), and a callers query that previously returned nothing now returns both true call sites

Not covered

I did not build the Rust kernel, so the kernel path for this change is unvalidated. Both changed functions live in the TS resolution layer above it, but you'll want to confirm that.


Companion PR for the second root cause (alias bindings) is independent of this one and can land in either order; both touch import-resolver.ts in different regions, so whichever lands second may need a trivial rebase.

`EXTENSION_RESOLUTION` only ever appends a suffix, so a NodeNext-style
specifier `./x.js` was tried as `x.js.ts`, `x.js.tsx`, ... and finally bare
`x.js` — never `x.ts`. `resolveRelativeImport` therefore returned null.

Because `"moduleResolution": "NodeNext"` requires the emitted extension on
relative imports, an entire class of TypeScript projects resolved no relative
import at all: every reference fell through to the name-matcher, which cannot
cross a rename, so aliased and default imports reported a false 0 callers.
One 16k-file project measured 32,732 `.js` specifiers and 0 extensionless.

Adds a rewrite table (`.js` -> `.ts`/`.tsx`/`.d.ts`, `.mjs` -> `.mts`,
`.cjs` -> `.cts`) applied in `resolveRelativeImport` and `resolveAliasedImport`,
tried only AFTER the literal path so a real emitted `.js` on disk still wins
and no already-resolving import changes target.

Each fixture imports under a different local name than the declaration, so the
name-matcher cannot rescue the edge and the test isolates import resolution.

Refs: colbymchenry#1482
@valkyriweb

Copy link
Copy Markdown
Author

Companion: #1485 (alias bindings). Independent — either order; whichever lands second may need a trivial rebase of import-resolver.ts.

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