From 0dfc26e57f7479f52ecef58b560722461d0b3e09 Mon Sep 17 00:00:00 2001 From: Christian Winther Date: Mon, 4 May 2026 23:44:32 +0200 Subject: [PATCH] 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. --- graphify/extract.py | 30 +++++++++++------------ tests/test_import_extension_resolution.py | 14 +++++++++++ 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 3005657..96eed61 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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: .//index.{ts,…} - for idx in _JS_INDEX_FILES: - c = p / idx - if c.is_file(): - return c + # Directory imports: try .//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 diff --git a/tests/test_import_extension_resolution.py b/tests/test_import_extension_resolution.py index c57122e..39051c9 100644 --- a/tests/test_import_extension_resolution.py +++ b/tests/test_import_extension_resolution.py @@ -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")