mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-02 02:11:29 +00:00
Merge branch 'main' into lang/php-migration-v2
This commit is contained in:
commit
713aa92bec
4 changed files with 250 additions and 1 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,12 @@ export const EXTENSIONS = [
|
|||
// TypeScript/JavaScript
|
||||
'.tsx',
|
||||
'.ts',
|
||||
'.mts',
|
||||
'.cts',
|
||||
'.jsx',
|
||||
'.js',
|
||||
'.mjs',
|
||||
'.cjs',
|
||||
'.vue',
|
||||
'/index.tsx',
|
||||
'/index.ts',
|
||||
|
|
|
|||
|
|
@ -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
gitnexus/test/unit/esm-extension-resolution.test.ts
Normal file
153
gitnexus/test/unit/esm-extension-resolution.test.ts
Normal 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());
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue