From 5b20640e4ed9d87632ba142680dc2abcd9a96078 Mon Sep 17 00:00:00 2001 From: Luke Seeber Date: Thu, 30 Jul 2026 15:31:32 +0200 Subject: [PATCH] fix(resolution): resolve `.js` import specifiers to their TS source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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: #1482 --- __tests__/js-specifier-ts-resolution.test.ts | 107 +++++++++++++++++++ src/resolution/import-resolver.ts | 48 +++++++++ 2 files changed, 155 insertions(+) create mode 100644 __tests__/js-specifier-ts-resolution.test.ts diff --git a/__tests__/js-specifier-ts-resolution.test.ts b/__tests__/js-specifier-ts-resolution.test.ts new file mode 100644 index 000000000..ecc3de1ba --- /dev/null +++ b/__tests__/js-specifier-ts-resolution.test.ts @@ -0,0 +1,107 @@ +/** + * `.js` specifiers naming an on-disk `.ts` source. + * + * `"moduleResolution": "NodeNext"` REQUIRES the emitted extension on relative + * imports (`./impl.js`), while the file on disk is `./impl.ts`. + * EXTENSION_RESOLUTION only ever APPENDS a suffix, so such a specifier was tried + * as `impl.js.ts`, `impl.js.tsx`, … then bare `impl.js` — never `impl.ts`. + * Import resolution returned null for EVERY relative import in the project and + * the resolver fell through to the name-matcher, which cannot cross a rename, + * so an aliased or default import reported a false 0 callers. + * + * Each fixture below imports under a DIFFERENT local name than the declaration, + * so the name-matcher cannot rescue the edge — the assertion isolates + * import-based resolution. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src/index'; + +describe('.js specifiers resolve to their TS source', () => { + let cg: CodeGraph; + let dir: string; + + afterEach(() => { + if (cg) cg.destroy(); + if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); + }); + + const callersOf = (name: string): string[] => { + const target = cg.getNodesByKind('function').find((n) => n.name === name); + expect(target, `fixture symbol ${name} was not indexed`).toBeDefined(); + return cg.getCallers(target!.id).map((c) => c.node.name); + }; + + it('resolves a default import through a `.js` specifier', async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-jsspec-')); + fs.writeFileSync( + path.join(dir, 'impl.ts'), + 'export default function realImpl(): number { return 1; }\n' + ); + fs.writeFileSync( + path.join(dir, 'consumer.ts'), + "import renamedLocally from './impl.js';\n" + + 'export function consumerFn(): number { return renamedLocally(); }\n' + ); + + cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts'], exclude: [] } }); + await cg.indexAll(); + + expect(callersOf('realImpl')).toContain('consumerFn'); + }); + + it('resolves `.mjs` and `.cjs` specifiers to `.mts` and `.cts`', async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-jsspec-esm-')); + fs.writeFileSync( + path.join(dir, 'esm.mts'), + 'export default function esmImpl(): number { return 1; }\n' + ); + fs.writeFileSync( + path.join(dir, 'cjs.cts'), + 'export default function cjsImpl(): number { return 2; }\n' + ); + fs.writeFileSync( + path.join(dir, 'consumer.ts'), + "import esmRenamed from './esm.mjs';\n" + + "import cjsRenamed from './cjs.cjs';\n" + + 'export function consumerFn(): number { return esmRenamed() + cjsRenamed(); }\n' + ); + + cg = CodeGraph.initSync(dir, { + config: { include: ['**/*.ts', '**/*.mts', '**/*.cts'], exclude: [] }, + }); + await cg.indexAll(); + + expect(callersOf('esmImpl')).toContain('consumerFn'); + expect(callersOf('cjsImpl')).toContain('consumerFn'); + }); + + it('prefers a real emitted `.js` on disk over the rewritten `.ts`', async () => { + // Committed build output beside its source: `./emitted.js` names the JS + // file, which exists, so the rewrite must NOT steal the edge. + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-jsspec-literal-')); + fs.writeFileSync( + path.join(dir, 'emitted.js'), + 'export default function fromJs() { return 1; }\n' + ); + fs.writeFileSync( + path.join(dir, 'emitted.ts'), + 'export default function fromTs(): number { return 1; }\n' + ); + fs.writeFileSync( + path.join(dir, 'consumer.ts'), + "import renamedLocally from './emitted.js';\n" + + 'export function consumerFn(): number { return renamedLocally(); }\n' + ); + + cg = CodeGraph.initSync(dir, { + config: { include: ['**/*.ts', '**/*.js'], exclude: [] }, + }); + await cg.indexAll(); + + expect(callersOf('fromJs')).toContain('consumerFn'); + expect(callersOf('fromTs')).not.toContain('consumerFn'); + }); +}); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 07f18cbb1..8e4cd2633 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -376,6 +376,44 @@ function isExternalImport( return false; } +/** + * TypeScript's ESM/NodeNext rule: a relative specifier carries the EMITTED + * extension while the file on disk is the TS source — `import './x.js'` + * resolves to `x.ts`. `EXTENSION_RESOLUTION` only ever APPENDS a suffix, so a + * specifier that already ends in `.js` is tried as `x.js.ts`, `x.js.tsx`, … + * and then bare `x.js`, none of which exist in a TS project. Import resolution + * then returns null for EVERY relative import in the repo and the resolver + * falls through to the name-matcher, which cannot cross a rename — so an + * aliased or default export silently reports a false 0 callers. + * + * This is not an edge case: `"moduleResolution": "NodeNext"` REQUIRES the `.js` + * suffix, so an entire class of modern TS codebases resolves nothing (a 16k-file + * project measured 32,732 `.js` specifiers and 0 extensionless ones). + * + * Tried only AFTER the literal path, so a real emitted `.js` sitting on disk + * still wins and no currently-resolving import changes target. + */ +const TS_SOURCE_REWRITES: Array<[RegExp, string[]]> = [ + [/\.js$/, ['.ts', '.tsx', '.d.ts']], + [/\.jsx$/, ['.tsx', '.jsx', '.d.ts']], + [/\.mjs$/, ['.mts', '.d.mts']], + [/\.cjs$/, ['.cts', '.d.cts']], +]; + +/** Languages whose specifiers may name emitted JS for an on-disk TS source. */ +const TS_SOURCE_LANGUAGES = new Set(['typescript', 'tsx', 'arkts', 'svelte', 'vue', 'astro']); + +/** The TS source paths a JS-suffixed specifier may legally denote. */ +function tsSourceCandidates(candidatePath: string, language: Language): string[] { + if (!TS_SOURCE_LANGUAGES.has(language)) return []; + for (const [suffix, replacements] of TS_SOURCE_REWRITES) { + if (suffix.test(candidatePath)) { + return replacements.map((ext) => candidatePath.replace(suffix, ext)); + } + } + return []; +} + /** * Resolve a relative import */ @@ -423,6 +461,13 @@ function resolveRelativeImport( return relativePath; } + // `./x.js` naming the on-disk `./x.ts` (see TS_SOURCE_REWRITES). + for (const candidate of tsSourceCandidates(relativePath, language)) { + if (context.fileExists(candidate)) { + return candidate; + } + } + return null; } @@ -450,6 +495,9 @@ function resolveAliasedImport( if (context.fileExists(candidate)) return candidate; } if (context.fileExists(basePath)) return basePath; + for (const candidate of tsSourceCandidates(basePath, language)) { + if (context.fileExists(candidate)) return candidate; + } return null; };