fix(extract-import-map): apply NodeNext .js→.ts rewrite (#294)

Fixes the silent near-edgeless-graph regression on any modern ESM
TypeScript project. Reported in #294 with full repro + root-cause
analysis.

### Why this matters

Under `moduleResolution: NodeNext` (or `Node16` / `Bundler` with
explicit extensions — the default for new TS-ESM projects since 2023),
TypeScript does NOT rewrite import specifiers during compilation:

  // src/index.ts — real, idiomatic NodeNext source
  import { x } from './config.js';   // on disk: config.ts

Before this fix, `probeWithExtensions` only tried APPENDING extensions
to the import specifier:

  './config.js' → not in fileSet
  './config.js.ts', './config.js.tsx', './config.js.js', ... → all miss
  → returns null → edge dropped at merge as dangling

Net result on the reporter's repro: a knowledge graph with hundreds of
file nodes and almost no `imports` edges between them — silently
removing exactly the dependency structure the graph is meant to show.

### Fix

New `NODENEXT_REWRITES` table maps each compiled-output extension to
the TypeScript source extensions that could have produced it:

  .js   → [.ts, .tsx, .js, .jsx]
  .jsx  → [.tsx, .jsx]
  .mjs  → [.mts, .mjs, .ts]
  .cjs  → [.cts, .cjs, .ts]

`probeWithExtensions` now applies the rewrite when the import already
ends with one of these extensions and no such file exists on disk. The
rewrite runs BEFORE the legacy append-extensions loop — otherwise
`./foo.js` would generate the nonsense candidate `foo.js.ts` and the
append loop would never reach the actual `foo.ts`.

### Disambiguation

If both `config.ts` and `config.js` exist on disk (rare, but possible
during a partial migration), `import './config.js'` still resolves to
the .js — that's an exact-disk match and what NodeNext compilation
actually does. The rewrite only kicks in when the .js doesn't exist.

### Tests

6 new tests in `test_extract_import_map.test.mjs`:
- The main #294 case (`.js → .ts`)
- `.jsx → .tsx` and `.mjs → .mts` rewrites
- Disambiguation when both `.ts` and `.js` exist on disk
- Pure-JS projects still work (real `.js → .js` imports)
- Historical no-extension probes unaffected
- Missing files still return null (rewrite can't invent targets)

Total: 202 tests passing (was 196).

Closes #294
This commit is contained in:
Tirth Kanani
2026-05-31 22:31:23 +01:00
Unverified
parent 470cc01dc5
commit a6c653e36b
2 changed files with 185 additions and 1 deletions
@@ -237,6 +237,150 @@ describe('extract-import-map.mjs — TypeScript / JavaScript resolver', () => {
'packages/foo/src/y.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', () => {