Skip to content

fix(parser): resolve relative TS/JS imports by appending, not replacing, the extension - #831

Open
lavaxun wants to merge 1 commit into
tirth8205:mainfrom
lavaxun:fix/relative-import-dotted-stems
Open

fix(parser): resolve relative TS/JS imports by appending, not replacing, the extension#831
lavaxun wants to merge 1 commit into
tirth8205:mainfrom
lavaxun:fix/relative-import-dotted-stems

Conversation

@lavaxun

@lavaxun lavaxun commented Aug 7, 2026

Copy link
Copy Markdown

The bug

_do_resolve_module's relative-import branch for JS/TS/TSX/Vue (code_review_graph/parser.py, around line 13578 on main) resolves an extensionless relative import like ./outlet.entity by trying base.with_suffix(ext) for each candidate extension. Path.with_suffix replaces the final suffix rather than appending to it, and Path("outlet.entity").suffix is .entity — so the probe becomes outlet.ts, not outlet.entity.ts.

Why this matters

Dotted stems are the dominant NestJS file-naming convention: *.entity.ts, *.service.ts, *.controller.ts, *.module.ts, *.guard.ts, and so on. With the replace-based probe, almost every relative import between files following this convention either:

  • resolves to the wrong file, if a same-directory decoy with the truncated name happens to exist (e.g. outlet.ts next to outlet.entity.ts), or
  • fails to resolve at all, falling back to the raw unresolved module string.

Measured in a large real-world NestJS monorepo: 13,013 unresolved relative imports dropped to 13 after applying this fix. That's a large fraction of a typical NestJS codebase's internal import graph silently missing from callers_of, get_impact_radius, and every other graph query that depends on relative-import edges resolving correctly.

_probe_path in tsconfig_resolver.py already gets this right for path-alias imports (it appends when the base already has a suffix); this fix mirrors that logic in the relative-import branch, which had drifted from it.

The fix

  1. In the extensions loop, replace target = base.with_suffix(ext) with target = Path(str(base) + ext), so the candidate extension is appended rather than replacing whatever the stem's own suffix looks like.
  2. Add a narrow fallback after that loop: if the base already ends in .js, .jsx, .mjs, or .cjs, retry with base.with_suffix(".ts") / .with_suffix(".tsx"). This covers ESM/NodeNext output, which writes ./foo.js in source for a module that is actually foo.ts on disk — the one case where replacing the suffix is the correct behavior, since appending would probe the nonsensical foo.js.ts.

Checked the rest of parser.py for the same pattern: the Dart relative-import branch also calls base.with_suffix(".dart"), but Dart stems essentially never contain dots, so it isn't exposed to this bug and was intentionally left alone. _probe_path in tsconfig_resolver.py was re-checked against current main and is already correct (append-not-replace) for path-alias resolution.

Tests

Added four tests to tests/test_commonjs_imports.py, following the file's existing tmp_path + CodeParser().parse_file + IMPORTS_FROM-edge style:

  • test_dotted_stem_relative_import_resolves_to_the_dotted_file_not_a_decoy./outlet.entity in a directory containing both outlet.entity.ts and a decoy outlet.ts must resolve to the dotted file. This is the case the old code got wrong (it resolved to the decoy).
  • test_dotted_stem_relative_import_resolves_without_a_decoy_present — same dotted-stem import with no decoy present, plain regression.
  • test_esm_js_extension_relative_import_still_resolves_to_the_ts_source./helper.js still resolves to helper.ts on disk (the fallback added in step 2).
  • test_plain_single_word_stem_relative_import_still_resolves — a non-dotted stem (./dependency) is unaffected.

All four were run against the pre-fix source first. The two dotted-stem tests fail without the fix (one resolves to the decoy, the other fails to resolve at all); the ESM and plain-stem tests happen to already pass pre-fix, since with_suffix incidentally does the right thing for single-dot stems — they're included as regression guards against the fix itself, not as bug demonstrations.

Full targeted run after the fix: tests/test_commonjs_imports.py, tests/test_tsconfig_resolver.py, tests/test_parser.py — 183 passed. Full suite: 2401 passed, 5 skipped, 2 xpassed, 1 pre-existing failure unrelated to this change (test_embedding_initialization.py::test_windows_server_still_prewarms_before_mcp_run, a Python 3.14 asyncio.WindowsSelectorEventLoopPolicy removal issue, reproduced identically on an unpatched checkout).

…ng, the extension

_do_resolve_module's relative-import branch probed extensionless imports
with base.with_suffix(ext), which replaces the stem's final suffix
instead of appending to it. A dotted stem like ./outlet.entity therefore
probed outlet.ts rather than outlet.entity.ts, and dotted stems are the
dominant NestJS naming convention (*.entity.ts, *.service.ts,
*.controller.ts, *.module.ts) — measured in a large NestJS monorepo,
this dropped 13,013 relative imports as unresolved (or silently
misresolved onto a same-directory decoy).

Switch the extension loop to append (Path(str(base) + ext)), mirroring
_probe_path in tsconfig_resolver.py, which already gets this right for
alias imports. Add a narrow fallback for ESM/NodeNext output, which
writes ./foo.js for a module that is foo.ts on disk — the one case
where replacing the suffix is actually correct.

Adds regression coverage in test_commonjs_imports.py: a dotted stem
resolving correctly in the presence of a same-directory decoy, a
dotted stem with no decoy, the ESM .js-to-.ts fallback, and the plain
single-word-stem case that must keep working unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant