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.mjs → foo.mts and ./foo.cjs → foo.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.
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 reportsstatus: completewithissues: [].Reproduction
A correct NodeNext TypeScript project. Under NodeNext the specifier must carry a
.jsextension even though the file on disk is.ts— this is required by the spec, not a mistake:src/types.tsis 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 depsandstatus: 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"withmoduleResolution: NodeNextis whattsc --initand 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
./types.jsit tries./types.js.ts,./types.js.tsx, … and finally the bare./types.js(the empty-string fallback atscanner/types.go:253). None exists, so the reference is dropped. The candidate it needs —./types.ts— is never constructed, because that requires replacing the.jssuffix rather than appending to it.The same shape affects
./foo.mjs→foo.mtsand./foo.cjs→foo.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
.jsfile elsewhere is unaffected.Why this one matters more than the edge count
The failure is silent and confident.
coverage.statusiscomplete,issuesis empty, and--importersanswers "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.