Merge branch 'main' into dependabot/npm_and_yarn/gitnexus-web/npm_and_yarn-40d86643b0

This commit is contained in:
Gergő Magyar 2026-05-12 16:31:02 +01:00 • committed by GitHub
commit 3218780811
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 250 additions and 1 deletions

View file

@ -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.
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) ----
@ -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;
}

View file

@ -9,8 +9,12 @@ export const EXTENSIONS = [
// TypeScript/JavaScript
'.tsx',
'.ts',
'.mts',
'.cts',
'.jsx',
'.js',
'.mjs',
'.cjs',
'.vue',
'/index.tsx',
'/index.ts',

View file

@ -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();
});
});

View file

@ -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());
});