Skip to content
Open
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
107 changes: 107 additions & 0 deletions __tests__/js-specifier-ts-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* `.js` specifiers naming an on-disk `.ts` source.
*
* `"moduleResolution": "NodeNext"` REQUIRES the emitted extension on relative
* imports (`./impl.js`), while the file on disk is `./impl.ts`.
* EXTENSION_RESOLUTION only ever APPENDS a suffix, so such a specifier was tried
* as `impl.js.ts`, `impl.js.tsx`, … then bare `impl.js` — never `impl.ts`.
* Import resolution returned null for EVERY relative import in the project and
* the resolver fell through to the name-matcher, which cannot cross a rename,
* so an aliased or default import reported a false 0 callers.
*
* Each fixture below imports under a DIFFERENT local name than the declaration,
* so the name-matcher cannot rescue the edge — the assertion isolates
* import-based resolution.
*/
import { describe, it, expect, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import CodeGraph from '../src/index';

describe('.js specifiers resolve to their TS source', () => {
let cg: CodeGraph;
let dir: string;

afterEach(() => {
if (cg) cg.destroy();
if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
});

const callersOf = (name: string): string[] => {
const target = cg.getNodesByKind('function').find((n) => n.name === name);
expect(target, `fixture symbol ${name} was not indexed`).toBeDefined();
return cg.getCallers(target!.id).map((c) => c.node.name);
};

it('resolves a default import through a `.js` specifier', async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-jsspec-'));
fs.writeFileSync(
path.join(dir, 'impl.ts'),
'export default function realImpl(): number { return 1; }\n'
);
fs.writeFileSync(
path.join(dir, 'consumer.ts'),
"import renamedLocally from './impl.js';\n" +
'export function consumerFn(): number { return renamedLocally(); }\n'
);

cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts'], exclude: [] } });
await cg.indexAll();

expect(callersOf('realImpl')).toContain('consumerFn');
});

it('resolves `.mjs` and `.cjs` specifiers to `.mts` and `.cts`', async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-jsspec-esm-'));
fs.writeFileSync(
path.join(dir, 'esm.mts'),
'export default function esmImpl(): number { return 1; }\n'
);
fs.writeFileSync(
path.join(dir, 'cjs.cts'),
'export default function cjsImpl(): number { return 2; }\n'
);
fs.writeFileSync(
path.join(dir, 'consumer.ts'),
"import esmRenamed from './esm.mjs';\n" +
"import cjsRenamed from './cjs.cjs';\n" +
'export function consumerFn(): number { return esmRenamed() + cjsRenamed(); }\n'
);

cg = CodeGraph.initSync(dir, {
config: { include: ['**/*.ts', '**/*.mts', '**/*.cts'], exclude: [] },
});
await cg.indexAll();

expect(callersOf('esmImpl')).toContain('consumerFn');
expect(callersOf('cjsImpl')).toContain('consumerFn');
});

it('prefers a real emitted `.js` on disk over the rewritten `.ts`', async () => {
// Committed build output beside its source: `./emitted.js` names the JS
// file, which exists, so the rewrite must NOT steal the edge.
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-jsspec-literal-'));
fs.writeFileSync(
path.join(dir, 'emitted.js'),
'export default function fromJs() { return 1; }\n'
);
fs.writeFileSync(
path.join(dir, 'emitted.ts'),
'export default function fromTs(): number { return 1; }\n'
);
fs.writeFileSync(
path.join(dir, 'consumer.ts'),
"import renamedLocally from './emitted.js';\n" +
'export function consumerFn(): number { return renamedLocally(); }\n'
);

cg = CodeGraph.initSync(dir, {
config: { include: ['**/*.ts', '**/*.js'], exclude: [] },
});
await cg.indexAll();

expect(callersOf('fromJs')).toContain('consumerFn');
expect(callersOf('fromTs')).not.toContain('consumerFn');
});
});
48 changes: 48 additions & 0 deletions src/resolution/import-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,44 @@ function isExternalImport(
return false;
}

/**
* TypeScript's ESM/NodeNext rule: a relative specifier carries the EMITTED
* extension while the file on disk is the TS source — `import './x.js'`
* resolves to `x.ts`. `EXTENSION_RESOLUTION` only ever APPENDS a suffix, so a
* specifier that already ends in `.js` is tried as `x.js.ts`, `x.js.tsx`, …
* and then bare `x.js`, none of which exist in a TS project. Import resolution
* then returns null for EVERY relative import in the repo and the resolver
* falls through to the name-matcher, which cannot cross a rename — so an
* aliased or default export silently reports a false 0 callers.
*
* This is not an edge case: `"moduleResolution": "NodeNext"` REQUIRES the `.js`
* suffix, so an entire class of modern TS codebases resolves nothing (a 16k-file
* project measured 32,732 `.js` specifiers and 0 extensionless ones).
*
* Tried only AFTER the literal path, so a real emitted `.js` sitting on disk
* still wins and no currently-resolving import changes target.
*/
const TS_SOURCE_REWRITES: Array<[RegExp, string[]]> = [
[/\.js$/, ['.ts', '.tsx', '.d.ts']],
[/\.jsx$/, ['.tsx', '.jsx', '.d.ts']],
[/\.mjs$/, ['.mts', '.d.mts']],
[/\.cjs$/, ['.cts', '.d.cts']],
];

/** Languages whose specifiers may name emitted JS for an on-disk TS source. */
const TS_SOURCE_LANGUAGES = new Set<string>(['typescript', 'tsx', 'arkts', 'svelte', 'vue', 'astro']);

/** The TS source paths a JS-suffixed specifier may legally denote. */
function tsSourceCandidates(candidatePath: string, language: Language): string[] {
if (!TS_SOURCE_LANGUAGES.has(language)) return [];
for (const [suffix, replacements] of TS_SOURCE_REWRITES) {
if (suffix.test(candidatePath)) {
return replacements.map((ext) => candidatePath.replace(suffix, ext));
}
}
return [];
}

/**
* Resolve a relative import
*/
Expand Down Expand Up @@ -423,6 +461,13 @@ function resolveRelativeImport(
return relativePath;
}

// `./x.js` naming the on-disk `./x.ts` (see TS_SOURCE_REWRITES).
for (const candidate of tsSourceCandidates(relativePath, language)) {
if (context.fileExists(candidate)) {
return candidate;
}
}

return null;
}

Expand Down Expand Up @@ -450,6 +495,9 @@ function resolveAliasedImport(
if (context.fileExists(candidate)) return candidate;
}
if (context.fileExists(basePath)) return basePath;
for (const candidate of tsSourceCandidates(basePath, language)) {
if (context.fileExists(candidate)) return candidate;
}
return null;
};

Expand Down