mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-06-22 10:58:03 +08:00
Merge pull request #359 from tirth8205/fix/import-resolver-nodenext-rewrite
fix(extract-import-map): apply NodeNext .js→.ts rewrite (#294)
This commit is contained in:
@@ -342,6 +342,150 @@ describe('extract-import-map.mjs — TypeScript / JavaScript resolver', () => {
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.output.importMap['src/app.ts']).toContain('lib/thing.ts');
|
||||
});
|
||||
|
||||
// ── #294: NodeNext / ESM TypeScript `.js → .ts` rewrite ────────────────
|
||||
//
|
||||
// Under `moduleResolution: NodeNext`, TypeScript does NOT rewrite import
|
||||
// specifiers during compilation — what you write in the .ts source is
|
||||
// emitted verbatim. Because Node's ESM loader requires explicit file
|
||||
// extensions at runtime, the TS source must already spell the import with
|
||||
// the `.js` extension that will only be correct AFTER compilation:
|
||||
//
|
||||
// import { x } from './config.js'; // on disk: config.ts
|
||||
//
|
||||
// Before the fix, every such import resolved to null, leaving ESM-TS
|
||||
// projects with a near-edgeless knowledge graph.
|
||||
|
||||
it('resolves NodeNext .js → .ts relative imports (the main #294 case)', () => {
|
||||
projectRoot = setupTree({
|
||||
'src/index.ts': `import { resolveBackend } from './llm-backend-selector.js';\nimport { loadConfig } from './config.js';\n`,
|
||||
'src/llm-backend-selector.ts': `export function resolveBackend() {}\n`,
|
||||
'src/config.ts': `export function loadConfig() {}\n`,
|
||||
});
|
||||
|
||||
const result = runScript(projectRoot, {
|
||||
projectRoot,
|
||||
files: [
|
||||
{ path: 'src/index.ts', language: 'typescript', fileCategory: 'code' },
|
||||
{ path: 'src/llm-backend-selector.ts', language: 'typescript', fileCategory: 'code' },
|
||||
{ path: 'src/config.ts', language: 'typescript', fileCategory: 'code' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.output.importMap['src/index.ts']).toEqual([
|
||||
'src/config.ts',
|
||||
'src/llm-backend-selector.ts',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves NodeNext .jsx → .tsx and .mjs → .mts rewrites', () => {
|
||||
projectRoot = setupTree({
|
||||
'src/index.ts': `import Comp from './Comp.jsx';\nimport { fn } from './worker.mjs';\n`,
|
||||
'src/Comp.tsx': `export default function Comp() {}\n`,
|
||||
'src/worker.mts': `export function fn() {}\n`,
|
||||
});
|
||||
|
||||
const result = runScript(projectRoot, {
|
||||
projectRoot,
|
||||
files: [
|
||||
{ path: 'src/index.ts', language: 'typescript', fileCategory: 'code' },
|
||||
{ path: 'src/Comp.tsx', language: 'typescript', fileCategory: 'code' },
|
||||
{ path: 'src/worker.mts', language: 'typescript', fileCategory: 'code' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.output.importMap['src/index.ts']).toEqual([
|
||||
'src/Comp.tsx',
|
||||
'src/worker.mts',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves to the .js when both .ts and .js exist on disk', () => {
|
||||
// Rare but possible during a partial migration: both `config.ts` and
|
||||
// `config.js` exist. An `import './config.js'` is an exact-disk match
|
||||
// and should resolve to that exact file — the NodeNext rewrite only
|
||||
// kicks in when the .js *doesn't* exist on disk. We assert this to
|
||||
// pin the disambiguation and avoid future regressions where the rewrite
|
||||
// accidentally prefers `.ts` over an existing `.js`.
|
||||
projectRoot = setupTree({
|
||||
'src/index.ts': `import { x } from './config.js';\n`,
|
||||
'src/config.ts': `export const x = 1;\n`,
|
||||
'src/config.js': `export const x = 1;\n`,
|
||||
});
|
||||
|
||||
const result = runScript(projectRoot, {
|
||||
projectRoot,
|
||||
files: [
|
||||
{ path: 'src/index.ts', language: 'typescript', fileCategory: 'code' },
|
||||
{ path: 'src/config.ts', language: 'typescript', fileCategory: 'code' },
|
||||
{ path: 'src/config.js', language: 'javascript', fileCategory: 'code' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.output.importMap['src/index.ts']).toEqual(['src/config.js']);
|
||||
});
|
||||
|
||||
it('still resolves traditional .js → .js imports unchanged', () => {
|
||||
// The rewrite must not break the case where `.js` IS the real file on
|
||||
// disk (pure JavaScript projects, untyped libraries).
|
||||
projectRoot = setupTree({
|
||||
'src/index.js': `import { x } from './util.js';\n`,
|
||||
'src/util.js': `export const x = 1;\n`,
|
||||
});
|
||||
|
||||
const result = runScript(projectRoot, {
|
||||
projectRoot,
|
||||
files: [
|
||||
{ path: 'src/index.js', language: 'javascript', fileCategory: 'code' },
|
||||
{ path: 'src/util.js', language: 'javascript', fileCategory: 'code' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.output.importMap['src/index.js']).toEqual(['src/util.js']);
|
||||
});
|
||||
|
||||
it('leaves the historical "no extension" probe behaviour intact', () => {
|
||||
// An import like `./utils` (no extension) must still go through the
|
||||
// append-extensions loop and resolve to `./utils.ts` — the new rewrite
|
||||
// path is only triggered when the import already ends with a compiled
|
||||
// extension.
|
||||
projectRoot = setupTree({
|
||||
'src/index.ts': `import { foo } from './utils';\n`,
|
||||
'src/utils.ts': `export function foo() {}\n`,
|
||||
});
|
||||
|
||||
const result = runScript(projectRoot, {
|
||||
projectRoot,
|
||||
files: [
|
||||
{ path: 'src/index.ts', language: 'typescript', fileCategory: 'code' },
|
||||
{ path: 'src/utils.ts', language: 'typescript', fileCategory: 'code' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.output.importMap['src/index.ts']).toEqual(['src/utils.ts']);
|
||||
});
|
||||
|
||||
it('returns null (no resolution) for a .js import whose .ts source is missing', () => {
|
||||
// The rewrite must NOT silently invent a target when neither the .js nor
|
||||
// the .ts file exists. The old behaviour would also return null for this
|
||||
// case — we're verifying the rewrite path doesn't regress it.
|
||||
projectRoot = setupTree({
|
||||
'src/index.ts': `import './completely-missing.js';\n`,
|
||||
});
|
||||
|
||||
const result = runScript(projectRoot, {
|
||||
projectRoot,
|
||||
files: [{ path: 'src/index.ts', language: 'typescript', fileCategory: 'code' }],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.output.importMap['src/index.ts']).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extract-import-map.mjs — Python resolver', () => {
|
||||
|
||||
@@ -424,15 +424,55 @@ const TS_EXT_PROBES = [
|
||||
'/index.ts', '/index.tsx', '/index.js', '/index.jsx',
|
||||
];
|
||||
|
||||
/**
|
||||
* NodeNext / Node16 / Bundler-with-explicit-extensions ESM TypeScript convention:
|
||||
* TypeScript does NOT rewrite import specifiers during compilation, so source
|
||||
* files import their COMPILED specifier (`./config.js`) even when only
|
||||
* `./config.ts` exists on disk. We map each compiled-output extension to the
|
||||
* TS source extensions that could have produced it, in priority order.
|
||||
*
|
||||
* Without this rewrite, ESM-TS projects (which is now the default for any new
|
||||
* TS project) end up with a near-edgeless knowledge graph because every
|
||||
* project-internal import fails to resolve. (#294)
|
||||
*/
|
||||
const NODENEXT_REWRITES = {
|
||||
'.js': ['.ts', '.tsx', '.js', '.jsx'],
|
||||
'.jsx': ['.tsx', '.jsx'],
|
||||
'.mjs': ['.mts', '.mjs', '.ts'],
|
||||
'.cjs': ['.cts', '.cjs', '.ts'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Try ext probes against the file set for the given base path. Returns the
|
||||
* first matching project-relative path, or null. If the base path already has
|
||||
* a code extension AND exists in the file set, returns it directly.
|
||||
*
|
||||
* For NodeNext-style imports (`./foo.js` where only `./foo.ts` exists), apply
|
||||
* the source-extension rewrite — see NODENEXT_REWRITES above.
|
||||
*/
|
||||
function probeWithExtensions(basePath, fileSet) {
|
||||
if (!basePath) return null;
|
||||
// Exact match (import already had an extension)
|
||||
// Exact match (import already had an extension that resolves on disk)
|
||||
if (fileSet.has(basePath)) return basePath;
|
||||
|
||||
// NodeNext rewrite: if the basePath ends with a compiled-output extension
|
||||
// but no such file exists, try the corresponding source extensions. We do
|
||||
// this BEFORE the legacy "append extensions" loop because for an import
|
||||
// like `./foo.js`, appending `.ts` would produce `foo.js.ts` (always wrong)
|
||||
// while the correct candidate is `foo.ts`.
|
||||
for (const [outExt, srcExts] of Object.entries(NODENEXT_REWRITES)) {
|
||||
if (!basePath.endsWith(outExt)) continue;
|
||||
const stem = basePath.slice(0, -outExt.length);
|
||||
for (const srcExt of srcExts) {
|
||||
const candidate = stem + srcExt;
|
||||
if (fileSet.has(candidate)) return candidate;
|
||||
}
|
||||
// The basePath had an explicit compiled extension — don't fall through
|
||||
// to the "append extensions" loop, which would produce nonsense like
|
||||
// `foo.js.ts`. If NodeNext rewrite didn't find anything, return null.
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const ext of TS_EXT_PROBES) {
|
||||
const candidate = basePath + ext;
|
||||
if (fileSet.has(candidate)) return candidate;
|
||||
|
||||
Reference in New Issue
Block a user