Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion gitnexus/src/core/ingestion/import-resolvers/standard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,21 @@ export const resolveImportPath = (

if (importPath.startsWith('.')) {
const resolved = tryResolveWithExtensions(basePath, allFiles);
return cache(resolved);
if (resolved) return cache(resolved);

// TypeScript ESM: imports use .js/.jsx/.mjs/.cjs but source files are
// .ts/.tsx/.mts/.cts. Strip the JS-family extension and re-resolve.
// NOTE: This fallback only applies to relative imports. Path alias imports
// (e.g. @/utils.js via tsconfig paths) do not yet strip .js extensions —
// that is a known limitation tracked for follow-up.

Copy link
Copy Markdown
Collaborator

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

if (language === SupportedLanguages.TypeScript || language === SupportedLanguages.JavaScript) {
const stripped = stripJsExtension(basePath);
if (stripped !== null) {
return cache(tryResolveWithExtensions(stripped, allFiles));
}
}

return cache(null);
}

// ---- Generic package/absolute import resolution (suffix matching) ----
Expand Down Expand Up @@ -182,3 +196,19 @@ export function resolveStandard(
export function createStandardStrategy(language: SupportedLanguages): ImportResolverStrategy {
return (raw, fp, ctx) => resolveStandard(raw, fp, ctx, language);
}

// ============================================================================
// ESM extension helpers
// ============================================================================

/** JS-family extensions that TypeScript ESM maps to TS equivalents. */
const JS_EXTENSION_PATTERN = /\.(js|jsx|mjs|cjs)$/;

/**
* Strip a JS-family extension from a path, returning the stem.
* Returns `null` if the path does not end with a JS-family extension.
*/
export function stripJsExtension(path: string): string | null {
const match = JS_EXTENSION_PATTERN.exec(path);
return match ? path.slice(0, -match[0].length) : null;
}
4 changes: 4 additions & 0 deletions gitnexus/src/core/ingestion/import-resolvers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ export const EXTENSIONS = [
// TypeScript/JavaScript
'.tsx',
'.ts',
'.mts',
'.cts',
'.jsx',
'.js',
'.mjs',
'.cjs',
'.vue',
'/index.tsx',
'/index.ts',
Expand Down
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();
});
});
153 changes: 153 additions & 0 deletions gitnexus/test/unit/esm-extension-resolution.test.ts
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());
});
Loading