-
-
Notifications
You must be signed in to change notification settings - Fork 4.8k
fix: resolve TypeScript ESM .js extension imports to .ts source files #1525
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
magyargergo
merged 4 commits into
abhigyanpatwari:main
from
chouzz:fix/esm-js-extension-resolution
May 12, 2026
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
0bf6853
fix: resolve TypeScript ESM .js extension imports to .ts source files
chouzz 999b724
fix: address review findings — normalization, edge-case tests, integr…
chouzz a27f50b
chore(autofix): apply prettier + eslint fixes via /autofix command
github-actions[bot] 048de95
chore: retrigger CI after bot-only tip commit
magyargergo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
62 changes: 62 additions & 0 deletions
62
gitnexus/test/integration/resolvers/typescript-esm-js-extension.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| /** | ||
| * Integration test: TypeScript ESM .js extension imports produce CALLS edges. | ||
| * | ||
| * Verifies the full pipeline: .js import → resolveImportPath strips .js → | ||
| * resolves to .ts → scope-resolver emits CALLS edge. | ||
| */ | ||
| import { describe, it, expect, beforeAll, afterAll } from 'vitest'; | ||
| import path from 'path'; | ||
| import fs from 'node:fs'; | ||
| import os from 'node:os'; | ||
| import { getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js'; | ||
|
|
||
| function writeFixtureRepo(root: string, files: Record<string, string>): void { | ||
| for (const [relPath, content] of Object.entries(files)) { | ||
| const fullPath = path.join(root, relPath); | ||
| fs.mkdirSync(path.dirname(fullPath), { recursive: true }); | ||
| fs.writeFileSync(fullPath, content, 'utf8'); | ||
| } | ||
| } | ||
|
|
||
| describe('TypeScript ESM .js extension → CALLS edges', () => { | ||
| let result: PipelineResult; | ||
| let repoDir: string | undefined; | ||
|
|
||
| beforeAll(async () => { | ||
| repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-ts-esm-js-ext-')); | ||
| writeFixtureRepo(repoDir, { | ||
| 'src/utils.ts': ` | ||
| export function estimateTokens(text: string): number { | ||
| return Math.ceil(text.length / 4); | ||
| } | ||
| `, | ||
| 'src/index.ts': ` | ||
| import { estimateTokens } from './utils.js'; | ||
|
|
||
| export function processText(text: string): number { | ||
| return estimateTokens(text); | ||
| } | ||
| `, | ||
| }); | ||
| result = await runPipelineFromRepo(repoDir, () => {}); | ||
| }, 60000); | ||
|
|
||
| afterAll(() => { | ||
| if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| it('emits CALLS edge from processText → estimateTokens via .js import', () => { | ||
| const calls = getRelationships(result, 'CALLS'); | ||
| const edge = calls.find((c) => c.source === 'processText' && c.target === 'estimateTokens'); | ||
| expect(edge).toBeDefined(); | ||
| expect(edge!.targetFilePath).toBe('src/utils.ts'); | ||
| }); | ||
|
|
||
| it('emits IMPORTS edge from index.ts → utils.ts', () => { | ||
| const imports = getRelationships(result, 'IMPORTS'); | ||
| const edge = imports.find( | ||
| (e) => e.sourceFilePath === 'src/index.ts' && e.targetFilePath === 'src/utils.ts', | ||
| ); | ||
| expect(edge).toBeDefined(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,153 @@ | ||
| /** | ||
| * Unit tests for TypeScript ESM .js extension resolution. | ||
| * | ||
| * TypeScript ESM requires imports to use .js extensions even when source | ||
| * files are .ts. The resolver must map .js → .ts (and .jsx → .tsx, | ||
| * .mjs → .mts, .cjs → .cts) when the literal .js file does not exist. | ||
| */ | ||
|
|
||
| import { describe, it, expect } from 'vitest'; | ||
| import { resolveImportPath } from '../../src/core/ingestion/import-resolvers/standard.js'; | ||
| import { stripJsExtension } from '../../src/core/ingestion/import-resolvers/standard.js'; | ||
| import { buildSuffixIndex } from '../../src/core/ingestion/import-resolvers/utils.js'; | ||
| import { SupportedLanguages } from 'gitnexus-shared'; | ||
|
|
||
| function makeCtx(files: string[]) { | ||
| // Match production normalization: only replace backslashes with forward slashes | ||
| const normalized = files.map((f) => f.replace(/\\/g, '/')); | ||
| const allFilesSet = new Set(files); | ||
| const index = buildSuffixIndex(normalized, files); | ||
| const cache = new Map<string, string | null>(); | ||
| return { files, normalized, allFilesSet, index, cache }; | ||
| } | ||
|
|
||
| function resolve( | ||
| currentFile: string, | ||
| importPath: string, | ||
| language: SupportedLanguages, | ||
| ctx: ReturnType<typeof makeCtx>, | ||
| ): string | null { | ||
| return resolveImportPath( | ||
| currentFile, | ||
| importPath, | ||
| ctx.allFilesSet, | ||
| ctx.files, | ||
| ctx.normalized, | ||
| ctx.cache, | ||
| language, | ||
| null, | ||
| ctx.index, | ||
| ); | ||
| } | ||
|
|
||
| describe('TypeScript ESM .js extension resolution', () => { | ||
| it('resolves ./utils.js to ./utils.ts when .js does not exist', () => { | ||
| const ctx = makeCtx(['src/index.ts', 'src/utils.ts']); | ||
| const result = resolve('src/index.ts', './utils.js', SupportedLanguages.TypeScript, ctx); | ||
| expect(result).toBe('src/utils.ts'); | ||
| }); | ||
|
|
||
| it('resolves ./component.jsx to ./component.tsx', () => { | ||
| const ctx = makeCtx(['src/app.ts', 'src/component.tsx']); | ||
| const result = resolve('src/app.ts', './component.jsx', SupportedLanguages.TypeScript, ctx); | ||
| expect(result).toBe('src/component.tsx'); | ||
| }); | ||
|
|
||
| it('resolves ./config.mjs to ./config.mts', () => { | ||
| const ctx = makeCtx(['src/index.ts', 'src/config.mts']); | ||
| const result = resolve('src/index.ts', './config.mjs', SupportedLanguages.TypeScript, ctx); | ||
| expect(result).toBe('src/config.mts'); | ||
| }); | ||
|
|
||
| it('resolves ./legacy.cjs to ./legacy.cts', () => { | ||
| const ctx = makeCtx(['src/index.ts', 'src/legacy.cts']); | ||
| const result = resolve('src/index.ts', './legacy.cjs', SupportedLanguages.TypeScript, ctx); | ||
| expect(result).toBe('src/legacy.cts'); | ||
| }); | ||
|
|
||
| it('prefers actual .js file when it exists', () => { | ||
| const ctx = makeCtx(['src/index.ts', 'src/utils.js', 'src/utils.ts']); | ||
| const result = resolve('src/index.ts', './utils.js', SupportedLanguages.TypeScript, ctx); | ||
| expect(result).toBe('src/utils.js'); | ||
| }); | ||
|
|
||
| it('resolves relative path with ../ and .js extension', () => { | ||
| const ctx = makeCtx(['src/helpers/token.ts', 'src/core/engine.ts']); | ||
| const result = resolve( | ||
| 'src/core/engine.ts', | ||
| '../helpers/token.js', | ||
| SupportedLanguages.TypeScript, | ||
| ctx, | ||
| ); | ||
| expect(result).toBe('src/helpers/token.ts'); | ||
| }); | ||
|
|
||
| it('works for JavaScript language too', () => { | ||
| const ctx = makeCtx(['src/index.js', 'src/utils.ts']); | ||
| const result = resolve('src/index.js', './utils.js', SupportedLanguages.JavaScript, ctx); | ||
| expect(result).toBe('src/utils.ts'); | ||
| }); | ||
|
|
||
| it('does NOT apply ESM fallback for non-TS/JS languages', () => { | ||
| const ctx = makeCtx(['src/main.py', 'src/utils.ts']); | ||
| const result = resolve('src/main.py', './utils.js', SupportedLanguages.Python, ctx); | ||
| expect(result).toBeNull(); | ||
| }); | ||
|
|
||
| it('returns null when neither .js nor .ts exists', () => { | ||
| const ctx = makeCtx(['src/index.ts']); | ||
| const result = resolve('src/index.ts', './missing.js', SupportedLanguages.TypeScript, ctx); | ||
| expect(result).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('ESM extension resolution — .mjs/.cjs with competing siblings', () => { | ||
| it('resolves ./config.mjs to .ts when only .ts exists (no .mts)', () => { | ||
| const ctx = makeCtx(['src/index.ts', 'src/config.ts']); | ||
| const result = resolve('src/index.ts', './config.mjs', SupportedLanguages.TypeScript, ctx); | ||
| // .ts wins because EXTENSIONS order tries .ts before .mts | ||
| expect(result).toBe('src/config.ts'); | ||
| }); | ||
|
|
||
| it('resolves ./config.mjs to .mts when both .ts and .mts exist', () => { | ||
| // Note: EXTENSIONS order is .tsx, .ts, .mts, .cts — so .ts wins over .mts. | ||
| // This is intentional for a source-analysis tool: we resolve to the first | ||
| // matching source file. In practice, having both config.ts and config.mts | ||
| // in the same directory is extremely rare. | ||
| const ctx = makeCtx(['src/index.ts', 'src/config.ts', 'src/config.mts']); | ||
| const result = resolve('src/index.ts', './config.mjs', SupportedLanguages.TypeScript, ctx); | ||
| expect(result).toBe('src/config.ts'); | ||
| }); | ||
|
|
||
| it('resolves ./config.cjs to .cts when only .cts exists', () => { | ||
| const ctx = makeCtx(['src/index.ts', 'src/config.cts']); | ||
| const result = resolve('src/index.ts', './config.cjs', SupportedLanguages.TypeScript, ctx); | ||
| expect(result).toBe('src/config.cts'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('ESM extension resolution — directory index boundary', () => { | ||
| it('resolves ./dir.js to dir/index.ts when dir/ exists (bundler-mode)', () => { | ||
| // After stripping .js from "dir.js" → "dir", tryResolveWithExtensions probes | ||
| // "/index.ts" suffix. This matches bundler-mode behavior where bare directory | ||
| // imports resolve to index files. Intentional for source-analysis compatibility. | ||
| const ctx = makeCtx(['src/index.ts', 'src/dir/index.ts']); | ||
| const result = resolve('src/index.ts', './dir.js', SupportedLanguages.TypeScript, ctx); | ||
| expect(result).toBe('src/dir/index.ts'); | ||
| }); | ||
|
|
||
| it('resolves ./dir/index.js to dir/index.ts', () => { | ||
| const ctx = makeCtx(['src/index.ts', 'src/dir/index.ts']); | ||
| const result = resolve('src/index.ts', './dir/index.js', SupportedLanguages.TypeScript, ctx); | ||
| expect(result).toBe('src/dir/index.ts'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('stripJsExtension', () => { | ||
| it('strips .js', () => expect(stripJsExtension('foo/bar.js')).toBe('foo/bar')); | ||
| it('strips .jsx', () => expect(stripJsExtension('foo/bar.jsx')).toBe('foo/bar')); | ||
| it('strips .mjs', () => expect(stripJsExtension('foo/bar.mjs')).toBe('foo/bar')); | ||
| it('strips .cjs', () => expect(stripJsExtension('foo/bar.cjs')).toBe('foo/bar')); | ||
| it('returns null for .ts', () => expect(stripJsExtension('foo/bar.ts')).toBeNull()); | ||
| it('returns null for no extension', () => expect(stripJsExtension('foo/bar')).toBeNull()); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please create a ticket for this