Skip to content

scanner: TypeScript ESM/NodeNext projects silently lose most import edges (.js specifier -> .ts file) #132

Description

@JordanCoin

Found while running an agent fleet against real repositories. On a TypeScript project using ESM / moduleResolution: NodeNext — the modern default — codemap loses most of its dependency edges and reports status: complete with issues: [].

Reproduction

A correct NodeNext TypeScript project. Under NodeNext the specifier must carry a .js extension even though the file on disk is .ts — this is required by the spec, not a mistake:

package.json   {"type": "module"}
tsconfig.json  {"compilerOptions": {"module": "NodeNext", "moduleResolution": "NodeNext"}}

src/types.ts    export type T = { a: number }
src/helper.ts   export function helper() { return 1 }
src/index.ts    import type { T } from "./types.js"
                import { helper } from "./helper.js"
$ codemap --json --deps .
  index.ts imports: ["./helper.js", "./types.js"]
  coverage: complete | issues: 0

$ codemap --importers src/types.ts
No files import src/types.ts.
   Note: files in the same package never import each other (Go resolves
   imports at package level), so only cross-package importers appear here.

src/types.ts is imported. codemap reports no importers, full confidence, and no issues — then explains itself with a message about Go package semantics on a TypeScript file.

Scale

Measured on a real 268-file TypeScript repo: codemap reports 93 deps and status: complete. The actual figure is roughly 600 edges — about 515 are lost, ~85%. Every one of them silently.

This is not an exotic configuration. "type": "module" with moduleResolution: NodeNext is what tsc --init and most current TS tooling produce, and the .js-specifier rule is mandatory there.

Mechanism

tryExactMatch (scanner/filegraph.go:414) resolves by appending each known extension to the whole specifier:

for _, ext := range extensions {
    candidate := path + ext          // "./types.js" + ".ts" = "./types.js.ts"
    if files, ok := idx.byExact[candidate]; ok { ... }
}

For ./types.js it tries ./types.js.ts, ./types.js.tsx, … and finally the bare ./types.js (the empty-string fallback at scanner/types.go:253). None exists, so the reference is dropped. The candidate it needs — ./types.ts — is never constructed, because that requires replacing the .js suffix rather than appending to it.

The same shape affects ./foo.mjsfoo.mts and ./foo.cjsfoo.cts.

Suggested fix

In the JS/TS resolution path only, before the append loop: when a specifier ends in .js / .mjs / .cjs, also try the corresponding TypeScript source extensions — .ts/.tsx, .mts, .cts — against the stripped stem. TypeScript's own resolver does exactly this substitution, so matching it is well-defined rather than a heuristic.

Worth scoping to JS-family source languages so a genuine .js file elsewhere is unaffected.

Why this one matters more than the edge count

The failure is silent and confident. coverage.status is complete, issues is empty, and --importers answers "No files import X" — indistinguishable from a genuinely unused file. An agent reading that concludes the file is safe to change or delete. That is the exact failure mode this project treats as its most serious class, and it is currently the default experience for modern TypeScript repositories.

Related: #111 (coverage reported at scan granularity, not per query) — a per-query signal would have made this visible at the point of the wrong answer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions