Skip to content

fix(parser): resolve dotted-stem relative imports in JS/TS and Dart - #845

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

fix(parser): resolve dotted-stem relative imports in JS/TS and Dart#845
lavaxun wants to merge 1 commit into
tirth8205:mainfrom
lavaxun:fix/dotted-stem-relative-import-resolution

Conversation

@lavaxun

@lavaxun lavaxun commented Aug 13, 2026

Copy link
Copy Markdown

The bug

The JS/TS/TSX/Vue relative-import resolver in code_review_graph/parser.py
used Path.with_suffix(ext) to try candidate extensions:

for ext in extensions:
    target = base.with_suffix(ext)

with_suffix() replaces the final path suffix, it doesn't append one. So
a relative import like ./outlet.entity gets probed as outlet.ts (the
.entity segment is mistaken for an existing suffix) and the resolver never
finds the real file, outlet.entity.ts.

Dotted stems are the dominant NestJS convention — *.entity.ts,
*.service.ts, *.controller.ts, *.guard.ts, *.module.ts — so relative
imports between files that follow this convention silently produce no
IMPORTS_FROM edge. Downstream, importers_of returns a confident, wrong
0 for the real target file: it looks like the file has no importers when
in fact the resolver just never reached it.

The fix

Switch the primary probe to extension-append (Path(str(base) + ext)),
matching the idiom tsconfig_resolver.py's _probe_path already used for
alias imports — that path is right, this one was wrong.

This can't be a blind swap though: NodeNext/ESM TypeScript writes ./foo.js
in import statements for a source file that is actually foo.ts on disk.
That's the one case where replacing the suffix is correct, so a fallback
is kept for it, gated on the import literally ending in
.js/.jsx/.mjs/.cjs:

for ext in extensions:
    target = Path(str(base) + ext)
    if target.is_file():
        return str(target.resolve())
if base.suffix in (".js", ".jsx", ".mjs", ".cjs"):
    for ext in (".ts", ".tsx"):
        target = base.with_suffix(ext)
        if target.is_file():
            return str(target.resolve())

The Dart sibling

_do_resolve_module's Dart branch had the exact same bug shape:
target = base.with_suffix(".dart"). Dart imports normally already carry
the .dart extension (caught by the base.is_file() check just above), so
this only bites when an extension is omitted on a dotted stem — e.g.
./thing.model where thing.model.dart exists on disk. Fixed the same way
(append, don't replace).

What I did NOT touch

I surveyed the other relative/dotted-path resolvers in the same function
(Python and Rust) for the same shape. Both also call with_suffix, but
their candidate paths are built from module-name segments that can't
contain a literal dot (Python: module.split("."); Rust: identifier path
parts), so there's no dotted-stem collision possible there and with_suffix
is correct as-is. Left them alone.

Tests

Added three tests to tests/test_parser.py, calling
CodeParser._resolve_module_to_file directly against real files in a temp
dir:

  1. test_relative_import_dotted_stem_resolves_to_full_filename — the actual
    bug: ./outlet.entity must resolve to outlet.entity.ts, with a decoy
    outlet.ts present to prove a wrong resolution (not just a crash) is
    what happens today. This is RED against main (fails as
    .../outlet.ts instead of .../outlet.entity.ts) and green after the
    fix.
  2. test_relative_import_nodenext_js_extension_resolves_ts_source — the
    NodeNext ./foo.js -> foo.ts case. This already passes on main
    (with_suffix happens to work when the import already ends in .js), and
    must keep passing after the fix — it's a regression guard so a future
    "simplify this" pass that drops the ESM fallback goes red instead of
    silently reintroducing the class of bug this PR fixes.
  3. test_relative_import_dart_dotted_stem_resolves_to_full_filename — same
    shape as (1), for the Dart fallback.
$ uv run pytest tests/ -k relative_import -v
...
tests/test_parser.py::TestCodeParser::test_relative_import_dotted_stem_resolves_to_full_filename PASSED
tests/test_parser.py::TestCodeParser::test_relative_import_nodenext_js_extension_resolves_ts_source PASSED
tests/test_parser.py::TestCodeParser::test_relative_import_dart_dotted_stem_resolves_to_full_filename PASSED

$ uv run pytest tests/
2401 passed, 5 skipped, 2 xpassed

$ uv run ruff check code_review_graph/
All checks passed!

Path.with_suffix() replaces the final path suffix rather than appending
one, so the JS/TS/TSX/Vue relative-import resolver mistook a dotted
stem for an existing extension: `./outlet.entity` probed `outlet.ts`
and never found `outlet.entity.ts`. Dotted stems are the dominant
NestJS convention (*.entity.ts, *.service.ts, *.controller.ts,
*.guard.ts, *.module.ts), so relative imports between such files
silently produced no IMPORTS_FROM edge, and importers_of returned a
confident, wrong zero for the real target.

Switch the primary probe to extension-append (Path(str(base) + ext)),
matching the idiom tsconfig_resolver.py already used for alias
imports. Keep a `with_suffix` fallback, but only when the import
already ends in .js/.jsx/.mjs/.cjs — NodeNext/ESM TypeScript writes
`./foo.js` for a file that is `foo.ts` on disk, which is the one case
where replacing the suffix is correct.

The Dart resolver's `.dart`-append fallback had the same bug shape
(`base.with_suffix(".dart")`); fixed the same way. Surveyed the other
relative/dotted-path resolvers in the same function (Python, Rust) —
both build their candidate path from module-name segments that can't
contain a literal dot, so `with_suffix` is safe there and was left
alone.

Adds three tests: the dotted-stem regression (red against the
unpatched resolver), a NodeNext .js->.ts case that must keep passing
so a future simplification that drops the ESM fallback goes red, and
the Dart sibling.
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