fix: prefer file matches over directory matches in resolver

When both a file (foo.ts) and a directory (foo/) exist at the same path,
both TypeScript and Vite prefer the file. The previous ordering checked
directory first and fell through unchanged when the directory had no
index, silently dropping every import like 'from ./auth' when an
auth/ subdirectory existed alongside auth.ts.
This commit is contained in:
Christian Winther
2026-05-04 23:44:32 +02:00
parent 5f5b59309c
commit 0dfc26e57f
2 changed files with 28 additions and 16 deletions
+14 -16
View File
@@ -225,15 +225,6 @@ def _resolve_js_module_path(p: Path) -> Path:
"""
if p.is_file():
return p
# Directory imports must be handled before any suffix logic, otherwise
# `from './queue'` (where queue/ is a real directory) would short-circuit
# on .is_file() = False and never reach the index lookup.
if p.is_dir():
for idx in _JS_INDEX_FILES:
c = p / idx
if c.is_file():
return c
return p
# TS ESM convention: import path written with .js but the real file is .ts.
# Apply BEFORE the generic append loop so we don't accidentally match
# foo.js → foo.js.ts when the real file is foo.ts.
@@ -245,17 +236,24 @@ def _resolve_js_module_path(p: Path) -> Path:
c = p.with_suffix(".tsx")
if c.is_file():
return c
# Try appending extensions to the FULL filename. Covers bare paths,
# multi-dot helper files, Svelte 5 rune files, config files, etc.
# Try appending extensions to the FULL filename BEFORE checking for a
# directory import. Both TypeScript and Vite resolvers prefer a file
# match over a directory match — projects routinely have a `foo.ts`
# file living alongside a `foo/` directory of sub-modules (e.g.
# `auth.ts` next to `auth/`). If we checked the directory first, those
# file imports would silently lose to a directory with no `index.*`.
for ext in _JS_RESOLVE_EXTS:
c = p.parent / (p.name + ext)
if c.is_file():
return c
# Treat as a not-yet-existing directory import: ./<name>/index.{ts,…}
for idx in _JS_INDEX_FILES:
c = p / idx
if c.is_file():
return c
# Directory imports: try ./<name>/index.{ts,tsx,js,jsx}. Reached only
# after every file-extension candidate has been ruled out, matching the
# resolver fallback chain.
if p.is_dir():
for idx in _JS_INDEX_FILES:
c = p / idx
if c.is_file():
return c
return p
+14
View File
@@ -63,6 +63,20 @@ def test_resolve_prefers_ts_over_svelte_when_both_exist(tmp_path):
assert _resolve_js_module_path(bare) == ts_target
def test_resolve_file_wins_over_sibling_directory(tmp_path):
"""Real-world repro: a project has both `auth.ts` (file) and `auth/`
(directory of sub-modules) at the same path. Both TypeScript and Vite
prefer the file match. If the resolver checks the directory first and
falls back on a missing index, every `from './auth'` import silently
drops because the directory has no index.{ts,…}."""
file_target = _write(tmp_path / "auth.ts", "export const x = 1")
sibling_dir = tmp_path / "auth"
sibling_dir.mkdir()
_write(sibling_dir / "helpers.ts", "export const y = 2")
bare = tmp_path / "auth"
assert _resolve_js_module_path(bare) == file_target
def test_resolve_directory_to_index_ts(tmp_path):
pkg = tmp_path / "queue"
target = _write(pkg / "index.ts", "export const x = 1")