Summary
_resolve_js_import_path maps a .js import specifier to .ts and stops. TypeScript's documented resolution order for a .js specifier is .ts → .tsx → .d.ts → .js, so every NodeNext/Node16-style import of a .tsx file is dropped from the graph.
On a 1,430-file TypeScript monorepo this loses 171 import edges, and presents as .tsx files appearing unreachable.
Where
graphify/extractors/resolution.py (branch v8, 0.9.58 — also 0.9.50):
# TS ESM convention: imports often spell .js/.jsx while source is .ts/.tsx.
if candidate.suffix == ".js":
ts_candidate = candidate.with_suffix(".ts")
if ts_candidate.is_file():
return ts_candidate
elif candidate.suffix == ".jsx":
tsx_candidate = candidate.with_suffix(".tsx")
if tsx_candidate.is_file():
return tsx_candidate
The comment names the right convention, but .js only ever tries .ts. A .tsx source file is imported as .js, not .jsx — TypeScript emits Foo.tsx to Foo.js, so the specifier the compiler requires is ./Foo.js. ./Foo.jsx is only correct under jsx: preserve, which is the rarer case.
The append-extension loop underneath does not rescue it, because it appends to the full filename rather than replacing the suffix:
for ext in _JS_RESOLVE_EXTS:
with_ext = candidate.parent / f"{candidate.name}{ext}" # MessageBubble.js.tsx
Reproduction
src/Button.tsx export function Button() {}
src/App.ts import { Button } from "./Button.js";
with "moduleResolution": "nodenext". Expected: an import edge App.ts → Button.tsx. Actual: none — Button.tsx has zero inbound edges.
Directly against the resolver:
>>> from graphify.extractors.resolution import _resolve_js_import_path as R
>>> from pathlib import Path
>>> R(Path("src/Button.js")) # Button.tsx exists on disk
PosixPath('src/Button.js') # unresolved
>>> R(Path("src/helper.js")) # helper.ts exists on disk
PosixPath('src/helper.ts') # fine
Measured impact
Real monorepo, 1,430 source files, 10,958 nodes / 19,266 edges:
Relative .js specifiers |
Count |
resolve to .ts |
1,041 |
resolve to .tsx — edge lost |
171 |
The asymmetry is visible in the graph as a large difference in apparent reachability between two extensions under one import convention:
| Non-test source |
Files |
Zero inbound |
Rate |
.ts |
472 |
47 |
10% |
.tsx |
185 |
82 |
44% |
After repairing only this one case out-of-band, .tsx drops to 15% and 54 files stop reading as unreachable.
This matters beyond edge counts: it makes affected, any dead-code pass, and any reachability question quietly wrong for .tsx files, in the direction that is hardest to notice — a missing edge looks like a clean result.
Suggested fix
Replace the branch with a suffix→candidates table, preserving the existing order semantics:
_TS_OUTPUT_EQUIVALENT = {
".js": (".ts", ".tsx", ".d.ts"),
".jsx": (".tsx",),
".mjs": (".mts", ".d.mts"),
".cjs": (".cts", ".d.cts"),
}
for replacement in _TS_OUTPUT_EQUIVALENT.get(candidate.suffix, ()):
sibling = candidate.with_suffix("") # .with_suffix(".d.ts") mangles the stem
rewritten = sibling.parent / f"{sibling.name}{replacement}"
if rewritten.is_file():
return rewritten
Note Path.with_suffix(".d.ts") does not do what it looks like it does on a multi-dot stem, hence building the name by concatenation.
.mjs/.cjs are included for completeness; I have no repro for those, only for .js → .tsx.
Happy to open a PR with this plus a test if that is useful.
Summary
_resolve_js_import_pathmaps a.jsimport specifier to.tsand stops. TypeScript's documented resolution order for a.jsspecifier is.ts→.tsx→.d.ts→.js, so every NodeNext/Node16-style import of a.tsxfile is dropped from the graph.On a 1,430-file TypeScript monorepo this loses 171 import edges, and presents as
.tsxfiles appearing unreachable.Where
graphify/extractors/resolution.py(branchv8, 0.9.58 — also 0.9.50):The comment names the right convention, but
.jsonly ever tries.ts. A.tsxsource file is imported as.js, not.jsx— TypeScript emitsFoo.tsxtoFoo.js, so the specifier the compiler requires is./Foo.js../Foo.jsxis only correct underjsx: preserve, which is the rarer case.The append-extension loop underneath does not rescue it, because it appends to the full filename rather than replacing the suffix:
Reproduction
with
"moduleResolution": "nodenext". Expected: an import edgeApp.ts → Button.tsx. Actual: none —Button.tsxhas zero inbound edges.Directly against the resolver:
Measured impact
Real monorepo, 1,430 source files, 10,958 nodes / 19,266 edges:
.jsspecifiers.ts.tsx— edge lostThe asymmetry is visible in the graph as a large difference in apparent reachability between two extensions under one import convention:
.ts.tsxAfter repairing only this one case out-of-band,
.tsxdrops to 15% and 54 files stop reading as unreachable.This matters beyond edge counts: it makes
affected, any dead-code pass, and any reachability question quietly wrong for.tsxfiles, in the direction that is hardest to notice — a missing edge looks like a clean result.Suggested fix
Replace the branch with a suffix→candidates table, preserving the existing order semantics:
Note
Path.with_suffix(".d.ts")does not do what it looks like it does on a multi-dot stem, hence building the name by concatenation..mjs/.cjsare included for completeness; I have no repro for those, only for.js→.tsx.Happy to open a PR with this plus a test if that is useful.