From 2b1efe8f08247bbbd118e5464b2b231bc2a94857 Mon Sep 17 00:00:00 2001 From: Christian Winther Date: Mon, 4 May 2026 22:26:48 +0200 Subject: [PATCH 1/6] fix(extract): TS bare-path / .svelte.ts / index.ts import resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _import_js previously only rewrote .js→.ts and .jsx→.tsx, leaving every other common TypeScript / SvelteKit / Vite import shape unresolved. The resulting node id wouldn't match the target file's own _make_id, so build_from_json dropped the edge as external. Three missed shapes: 1. Bare paths (no extension) — TS convention: `import { foo } from './foo'` → real file is foo.ts 2. .svelte → .svelte.ts (Svelte 5 rune-only files): `import { x } from './x.svelte'` → real file is x.svelte.ts 3. Directory imports / barrel index files: `import { x } from './queue'` → real file is queue/index.ts Fix --- New helper _resolve_with_extensions(p: Path) -> Path mirrors Vite/TS resolver order: 1. exact path (file) 2. .js→.ts, .jsx→.tsx (existing TS-ESM convention) 3. bare path → .ts/.tsx/.svelte/.js/.jsx/.mjs 4. bare path → directory's index.{ts,tsx,js,jsx} 5. .svelte → .svelte.ts (Svelte 5 rune file) Falls back to the original path on no match — preserves pre-fix behaviour for genuinely external modules (build_from_json drops them as phantoms). Wired into _import_js (relative + alias branches) and extract_svelte's regex pass for dynamic_import so static and dynamic imports both benefit. Subtle: uses .is_file() / .is_dir() rather than .exists(). When the import is a directory, .exists() returns True and would short-circuit before the index.ts lookup ever ran. Tests ----- 20 new tests in tests/test_import_extension_resolution.py: Resolver unit tests (12): - existing path returned unchanged - bare path → .ts / .tsx / .svelte - .ts wins over .svelte for ambiguous bare paths (Vite order) - directory → index.ts - directory prefers index.ts over index.js - .svelte → .svelte.ts (Svelte 5 rune file) - .js → .ts (TS ESM convention) - .jsx → .tsx - real .js stays .js when .ts doesn't exist - unresolvable returns input unchanged End-to-end (8): - bare-path import resolves in TS file - directory import resolves to index.ts - .svelte import resolves to .svelte.ts rune file - explicit .ts/.svelte imports still work (regression guard) - external module specifiers unchanged - alias + bare path resolves - dynamic_import bare path resolves --- graphify/extract.py | 87 +++++++- tests/test_import_extension_resolution.py | 258 ++++++++++++++++++++++ 2 files changed, 335 insertions(+), 10 deletions(-) create mode 100644 tests/test_import_extension_resolution.py diff --git a/graphify/extract.py b/graphify/extract.py index 42fd78f7..d76fdc2d 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -188,6 +188,72 @@ class LanguageConfig: # ── Generic helpers ─────────────────────────────────────────────────────────── +# Vite/TS resolver order. Used by _resolve_with_extensions() to map TypeScript +# bare-path imports onto real files on disk, so the resulting node id matches +# the one _extract_generic creates for the target file (#716). +_TS_RESOLVE_EXTS = (".ts", ".tsx", ".svelte", ".js", ".jsx", ".mjs") +_TS_INDEX_FILES = ("index.ts", "index.tsx", "index.js", "index.jsx") + + +def _resolve_with_extensions(p: Path) -> Path: + """Resolve a TypeScript-style import path to an actual file on disk. + + TS / SvelteKit / Vite let you write imports without a file extension and + auto-resolve via a fixed extension order. The pre-existing .js→.ts and + .jsx→.tsx rewrites only covered the TS-ESM-via-.js convention; everything + else dropped to a phantom node id and the edge was lost in build_from_json. + + Order, mirroring Vite's resolver: + 1. exact path (if it exists) + 2. .js → .ts (TS ESM convention; written as .js, file is .ts) + 3. .jsx → .tsx + 4. bare path → try .ts/.tsx/.svelte/.js/.jsx/.mjs + 5. bare path → try directory's index.{ts,tsx,js,jsx} + 6. .svelte path that isn't a real .svelte file → try the same name + with .ts appended (Svelte 5 rune-only files like foo.svelte.ts — + imports are written as './foo.svelte' but the file is .svelte.ts) + + Falls back to the original path on no match — the edge will be dropped + as external by build_from_json, matching pre-#716 behaviour for cases + we genuinely can't resolve (truly external modules). + """ + # Existing FILE wins — directory matches must fall through to index lookup, + # otherwise `from './queue'` (where queue/ is a real directory) would + # short-circuit and never resolve to queue/index.ts. + if p.is_file(): + return p + # Directory imports: try index.{ts,tsx,js,jsx} + if p.is_dir(): + for idx in _TS_INDEX_FILES: + c = p / idx + if c.is_file(): + return c + return p + if p.suffix == ".js": + c = p.with_suffix(".ts") + if c.is_file(): + return c + if p.suffix == ".jsx": + c = p.with_suffix(".tsx") + if c.is_file(): + return c + if p.suffix == "": + for ext in _TS_RESOLVE_EXTS: + c = p.with_suffix(ext) + if c.is_file(): + return c + if p.suffix == ".svelte": + # SvelteKit imports written as `from './foo.svelte'` may actually point + # at `foo.svelte.ts` (a Svelte 5 rune file). Append .ts to the FULL + # filename rather than swapping the suffix — `with_suffix(".svelte.ts")` + # would replace `.svelte` with `.svelte.ts`, but `with_suffix` only + # replaces the final segment. + c = p.parent / (p.name + ".ts") + if c.is_file(): + return c + return p + + def _read_text(node, source: bytes) -> str: return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace") @@ -275,11 +341,9 @@ def _import_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_p # Relative import - resolve to full path so IDs match file node IDs # normpath removes ".." segments so the ID matches the target file's own node ID resolved = Path(os.path.normpath(Path(str_path).parent / raw)) - # TypeScript ESM: imports written as .js but actual file is .ts/.tsx - if resolved.suffix == ".js": - resolved = resolved.with_suffix(".ts") - elif resolved.suffix == ".jsx": - resolved = resolved.with_suffix(".tsx") + # TS / SvelteKit resolver: try .ts/.tsx/.svelte/.svelte.ts/index.{ts,…} + # so bare-path and Svelte-5-rune imports land on the right node id (#716) + resolved = _resolve_with_extensions(resolved) tgt_nid = _make_id(str(resolved)) resolved_path = resolved else: @@ -292,6 +356,9 @@ def _import_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_p resolved_alias = Path(os.path.normpath(Path(alias_base) / rest)) break if resolved_alias is not None: + # Same resolver fixups as the relative branch — alias targets + # are equally likely to be bare paths / .svelte.ts / index.ts (#716) + resolved_alias = _resolve_with_extensions(resolved_alias) tgt_nid = _make_id(str(resolved_alias)) resolved_path = resolved_alias else: @@ -1761,11 +1828,10 @@ def extract_svelte(path: Path) -> dict: if raw.startswith("."): # Relative import - resolve to full path so IDs match file node IDs. resolved = Path(os.path.normpath(path.parent / raw)) - # TypeScript ESM: imports written as .js but actual file is .ts/.tsx - if resolved.suffix == ".js": - resolved = resolved.with_suffix(".ts") - elif resolved.suffix == ".jsx": - resolved = resolved.with_suffix(".tsx") + # Apply same TS/Svelte resolver fixups as static imports so dynamic + # imports of bare paths and .svelte.ts rune files land on real + # file nodes instead of phantom ids (#716). + resolved = _resolve_with_extensions(resolved) node_id = _make_id(str(resolved)) else: # Check tsconfig.json path aliases (e.g. "$lib/" -> "src/lib/", "@/" -> "src/") @@ -1778,6 +1844,7 @@ def extract_svelte(path: Path) -> dict: resolved_alias = Path(os.path.normpath(Path(alias_base) / rest)) break if resolved_alias is not None: + resolved_alias = _resolve_with_extensions(resolved_alias) node_id = _make_id(str(resolved_alias)) else: # Bare/scoped import (node_modules) - use last segment; diff --git a/tests/test_import_extension_resolution.py b/tests/test_import_extension_resolution.py new file mode 100644 index 00000000..aacf2fd2 --- /dev/null +++ b/tests/test_import_extension_resolution.py @@ -0,0 +1,258 @@ +"""Tests for #716 — TypeScript bare-path imports, Svelte 5 rune file imports +(`from './foo.svelte'` for a `.svelte.ts` file), and directory/index.ts +imports must resolve to the actual file's node id, not a phantom. + +Before #716, `_import_js` only rewrote `.js → .ts` and `.jsx → .tsx`. Every +other shape (bare path, `.svelte → .svelte.ts`, `./foo` directory imports) +produced an id like `..._foo` while the real file's node id was `..._foo_ts`, +so `build_from_json` dropped the edge as external. +""" + +from pathlib import Path + +from graphify.extract import ( + _make_id, + _resolve_with_extensions, + extract_js, + extract_svelte, +) + + +def _write(path: Path, body: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + return path + + +def _import_targets(result: dict) -> set[str]: + return {str(e.get("target") or "") for e in result["edges"] + if e.get("relation") in ("imports", "imports_from")} + + +# ── _resolve_with_extensions unit tests ────────────────────────────────────── + + +def test_resolve_returns_existing_path_unchanged(tmp_path): + p = _write(tmp_path / "foo.ts", "export const x = 1") + assert _resolve_with_extensions(p) == p + + +def test_resolve_bare_path_to_ts(tmp_path): + target = _write(tmp_path / "foo.ts", "export const x = 1") + bare = tmp_path / "foo" + assert _resolve_with_extensions(bare) == target + + +def test_resolve_bare_path_to_tsx(tmp_path): + target = _write(tmp_path / "Component.tsx", "export const x = 1") + bare = tmp_path / "Component" + assert _resolve_with_extensions(bare) == target + + +def test_resolve_bare_path_to_svelte(tmp_path): + target = _write(tmp_path / "Card.svelte", "
") + bare = tmp_path / "Card" + assert _resolve_with_extensions(bare) == target + + +def test_resolve_prefers_ts_over_svelte_when_both_exist(tmp_path): + """Vite resolver order: .ts wins over .svelte for ambiguous bare paths.""" + ts_target = _write(tmp_path / "foo.ts", "export const x = 1") + _write(tmp_path / "foo.svelte", "
") + bare = tmp_path / "foo" + assert _resolve_with_extensions(bare) == ts_target + + +def test_resolve_directory_to_index_ts(tmp_path): + pkg = tmp_path / "queue" + target = _write(pkg / "index.ts", "export const x = 1") + assert _resolve_with_extensions(pkg) == target + + +def test_resolve_directory_prefers_index_ts_over_index_js(tmp_path): + pkg = tmp_path / "queue" + target = _write(pkg / "index.ts", "export const x = 1") + _write(pkg / "index.js", "module.exports = {}") + assert _resolve_with_extensions(pkg) == target + + +def test_resolve_svelte_to_svelte_ts_for_rune_files(tmp_path): + """Svelte 5: `from './foo.svelte'` may actually point at `foo.svelte.ts` + (a rune-only TypeScript file with no .svelte file). The resolver must + APPEND .ts to the full filename, not swap suffixes.""" + target = _write(tmp_path / "is-mobile.svelte.ts", + "export const isMobile = () => true") + written_as = tmp_path / "is-mobile.svelte" + resolved = _resolve_with_extensions(written_as) + assert resolved == target, ( + f"Expected resolution to is-mobile.svelte.ts; got {resolved}" + ) + + +def test_resolve_js_to_ts_when_real_file_is_ts(tmp_path): + """TS ESM convention: imports written as .js but the actual file is .ts.""" + target = _write(tmp_path / "foo.ts", "export const x = 1") + written_as = tmp_path / "foo.js" + assert _resolve_with_extensions(written_as) == target + + +def test_resolve_jsx_to_tsx_when_real_file_is_tsx(tmp_path): + target = _write(tmp_path / "Component.tsx", "export const x = 1") + written_as = tmp_path / "Component.jsx" + assert _resolve_with_extensions(written_as) == target + + +def test_resolve_returns_unchanged_when_nothing_matches(tmp_path): + """External / truly missing paths fall back to the input — preserves + pre-#716 behavior of becoming an external phantom edge.""" + nothing = tmp_path / "does_not_exist" + assert _resolve_with_extensions(nothing) == nothing + + +def test_resolve_real_js_stays_js_when_ts_does_not_exist(tmp_path): + """If `.js` exists and `.ts` does not, keep the `.js` rewrite from + triggering — return the existing file.""" + target = _write(tmp_path / "foo.js", "module.exports = 1") + assert _resolve_with_extensions(target) == target + + +# ── End-to-end: bare-path imports in pure TS files ─────────────────────────── + + +def test_bare_path_import_resolves_in_ts_file(tmp_path): + """The #716 reproducer: TS file imports a sibling without an extension.""" + target = _write(tmp_path / "type-helpers.ts", + "export type GetNestedType = T") + importer = _write(tmp_path / "page.ts", + "import type { GetNestedType } from './type-helpers'\n") + result = extract_js(importer) + expected = _make_id(str(target)) + assert expected in _import_targets(result), ( + f"Bare-path .ts import must resolve to target node id; " + f"expected {expected}; got {_import_targets(result)}" + ) + + +def test_directory_import_resolves_to_index_ts(tmp_path): + """`from './queue'` must resolve to `./queue/index.ts`.""" + target = _write(tmp_path / "queue" / "index.ts", + "export const enqueue = () => {}") + importer = _write(tmp_path / "page.ts", + "import { enqueue } from './queue'\n") + result = extract_js(importer) + expected = _make_id(str(target)) + assert expected in _import_targets(result), ( + f"Directory import must resolve to ./queue/index.ts; " + f"expected {expected}; got {_import_targets(result)}" + ) + + +# ── End-to-end: .svelte → .svelte.ts (Svelte 5 rune files) ─────────────────── + + +def test_dot_svelte_import_resolves_to_dot_svelte_ts(tmp_path): + """Svelte 5 rune file: import written as .svelte, real file is .svelte.ts.""" + target = _write(tmp_path / "is-mobile.svelte.ts", + "export const isMobile = () => true") + importer = _write(tmp_path / "page.ts", + "import { isMobile } from './is-mobile.svelte'\n") + result = extract_js(importer) + expected = _make_id(str(target)) + assert expected in _import_targets(result), ( + f".svelte → .svelte.ts resolution failed; " + f"expected {expected}; got {_import_targets(result)}" + ) + + +# ── Regression guards: existing behavior preserved ─────────────────────────── + + +def test_explicit_ts_import_still_works(tmp_path): + """The most common case — import with explicit .ts extension — must + continue to work after the resolver change.""" + target = _write(tmp_path / "foo.ts", "export const x = 1") + importer = _write(tmp_path / "page.ts", + "import { x } from './foo.ts'\n") + result = extract_js(importer) + expected = _make_id(str(target)) + assert expected in _import_targets(result), ( + f"Explicit .ts imports must still resolve; " + f"expected {expected}; got {_import_targets(result)}" + ) + + +def test_explicit_svelte_import_still_works(tmp_path): + """Real .svelte file imports must still resolve when the .svelte file + exists (i.e. don't accidentally redirect to a non-existent .svelte.ts).""" + target = _write(tmp_path / "Card.svelte", "
") + importer = _write(tmp_path / "page.ts", + "import Card from './Card.svelte'\n") + result = extract_js(importer) + expected = _make_id(str(target)) + assert expected in _import_targets(result), ( + f"Existing .svelte imports must resolve to the .svelte node, " + f"not get redirected; expected {expected}; " + f"got {_import_targets(result)}" + ) + + +def test_external_module_unchanged(tmp_path): + """Bare module specifiers (no leading dot, no alias match) must still + fall through to the external/last-segment path — don't accidentally + treat 'lodash' as a relative path.""" + importer = _write(tmp_path / "page.ts", + "import _ from 'lodash-es'\n") + result = extract_js(importer) + targets = _import_targets(result) + # The target should be the bare module name, not a resolved file path + assert "lodash_es" in targets or any("lodash" in t for t in targets), ( + f"External module specifier should still produce an external " + f"reference; got {targets}" + ) + + +# ── End-to-end: alias-resolved imports go through the same resolver ───────── + + +def test_alias_import_with_bare_path_resolves(tmp_path): + """`$lib/foo` (alias + bare path) — both layers must work together.""" + src = tmp_path / "src" + target = _write(src / "lib" / "type-helpers.ts", + "export type X = string") + _write(tmp_path / "tsconfig.json", + '{"compilerOptions":{"paths":{"$lib":["./src/lib"],' + '"$lib/*":["./src/lib/*"]}}}') + importer_dir = src / "routes" + importer = _write(importer_dir / "page.ts", + "import type { X } from '$lib/type-helpers'\n") + result = extract_js(importer) + expected = _make_id(str(target)) + assert expected in _import_targets(result), ( + f"Alias + bare-path resolution failed; " + f"expected {expected}; got {_import_targets(result)}" + ) + + +# ── End-to-end: dynamic_import in .svelte regex pass uses resolver ────────── + + +def test_dynamic_import_bare_path_resolves(tmp_path): + """The regex pass for `import('...')` in .svelte files must also use + the new resolver — otherwise dynamic imports of bare paths still + produce phantom edges.""" + target = _write(tmp_path / "Heavy.svelte.ts", + "export const heavy = () => 1") + importer = _write(tmp_path / "page.svelte", """\ + +""") + result = extract_svelte(importer) + dyn_targets = {str(e.get("target") or "") for e in result["edges"] + if e.get("relation") == "dynamic_import"} + expected = _make_id(str(target)) + assert expected in dyn_targets, ( + f"dynamic_import of .svelte that's actually .svelte.ts must " + f"resolve through the new resolver; " + f"expected {expected}; got {dyn_targets}" + ) From 0267cd789fec7950b440655e167d2ed1930ad13f Mon Sep 17 00:00:00 2001 From: Christian Winther Date: Mon, 4 May 2026 22:33:29 +0200 Subject: [PATCH 2/6] test(extract): exhaustive coverage for extension resolution edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 8 tests covering import shapes that came up during real-codebase validation against a 1,873-file SvelteKit project: - test_type_only_import_with_bare_path_resolves `import type { X } from './foo'` — type-only imports must go through the same resolver. Common pattern in TS codebases. - test_named_imports_emit_symbol_edges_after_resolution `import { foo, bar } from './module'` — verifies the per-symbol `imports` edges (file → module.foo, file → module.bar) target the correct stem after resolution. The symbol target_stem comes from _file_stem(resolved), so resolution must happen first. - test_alias_directory_import_resolves_to_index_ts `from '$lib/queue'` — alias + directory composes correctly. - test_resolve_does_not_match_partial_directory_name Regression guard: `from './foo'` where only `foo-extra.ts` exists must NOT accidentally resolve to it. - test_resolve_directory_without_index_returns_unchanged A directory with no index.* must fall through, not pick a random .ts inside. - test_resolve_handles_subpath_into_directory_with_index `./foo/sub` where `./foo/sub/index.ts` exists. - test_resolve_does_not_treat_dotfile_as_extension Path('.env-types.ts').suffix is '.ts' (correct), but worth pinning. - test_resolve_chain_alias_and_extension_compose Two-layer resolution: alias → bare path → .svelte.ts. Verifies the full chain works end-to-end for the Svelte 5 rune-file case. Also expanded test_named_imports_emit_symbol_edges_after_resolution to catch a subtle regression class: per-symbol import edges (line 319-340 in _import_js) build their target id from _file_stem(resolved). If resolution fails or returns the wrong path, the symbol edges silently target a different stem and downstream "where is X used?" queries miss real callers. --- tests/test_import_extension_resolution.py | 121 ++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/tests/test_import_extension_resolution.py b/tests/test_import_extension_resolution.py index aacf2fd2..a420172b 100644 --- a/tests/test_import_extension_resolution.py +++ b/tests/test_import_extension_resolution.py @@ -233,6 +233,127 @@ def test_alias_import_with_bare_path_resolves(tmp_path): ) +# ── Edge cases — exhaustiveness ────────────────────────────────────────────── + + +def test_type_only_import_with_bare_path_resolves(tmp_path): + """`import type { X } from './foo'` — type-only imports must go through + the same resolution path as regular imports. Common in TS codebases + that separate types into their own module.""" + target = _write(tmp_path / "type-helpers.ts", + "export type GetNestedType = T") + importer = _write(tmp_path / "page.ts", + "import type { GetNestedType } from './type-helpers'\n") + result = extract_js(importer) + expected = _make_id(str(target)) + assert expected in _import_targets(result), ( + f"Type-only import with bare path failed to resolve; " + f"expected {expected}; got {_import_targets(result)}" + ) + + +def test_named_imports_emit_symbol_edges_after_resolution(tmp_path): + """`import { foo, bar } from './module'` should emit per-symbol `imports` + edges to `module.foo` and `module.bar`, not just the file-level + `imports_from`. The symbol-edge target_stem comes from _file_stem(resolved), + which depends on resolution succeeding first.""" + _write(tmp_path / "utils.ts", "export const foo = 1\nexport const bar = 2") + importer = _write(tmp_path / "page.ts", + "import { foo, bar } from './utils'\n") + result = extract_js(importer) + sym_edges = [e for e in result["edges"] if e.get("relation") == "imports"] + targets = {str(e.get("target") or "") for e in sym_edges} + # Target ids look like "_utils_foo" — substring-match the symbol names + assert any("_foo" in t for t in targets), ( + f"Per-symbol `imports` edge for `foo` missing; got {targets}" + ) + assert any("_bar" in t for t in targets), ( + f"Per-symbol `imports` edge for `bar` missing; got {targets}" + ) + + +def test_alias_directory_import_resolves_to_index_ts(tmp_path): + """`from '$lib/queue'` where queue/ is a directory under src/lib/.""" + src = tmp_path / "src" + target = _write(src / "lib" / "queue" / "index.ts", + "export const enqueue = () => {}") + _write(tmp_path / "tsconfig.json", + '{"compilerOptions":{"paths":{"$lib":["./src/lib"],' + '"$lib/*":["./src/lib/*"]}}}') + importer = _write(src / "routes" / "page.ts", + "import { enqueue } from '$lib/queue'\n") + result = extract_js(importer) + expected = _make_id(str(target)) + assert expected in _import_targets(result), ( + f"Alias + directory resolution failed; " + f"expected {expected}; got {_import_targets(result)}" + ) + + +def test_resolve_does_not_match_partial_directory_name(tmp_path): + """Regression guard: `from './foo'` where './foo' doesn't exist but + './foo-extra.ts' does must NOT accidentally resolve to the latter. + `.with_suffix(".ts")` on 'foo' produces 'foo.ts' — not 'foo-extra.ts', + but worth pinning down.""" + _write(tmp_path / "foo-extra.ts", "export const x = 1") + bare = tmp_path / "foo" + resolved = _resolve_with_extensions(bare) + # Not a real file → nothing matches → returns input unchanged + assert resolved == bare, ( + f"Partial-name match must not happen; got {resolved}" + ) + + +def test_resolve_directory_without_index_returns_unchanged(tmp_path): + """A directory with no index file should fall through to the + \"return as-is\" path, not pick a non-index file from inside.""" + pkg = tmp_path / "pkg" + _write(pkg / "not-index.ts", "export const x = 1") + resolved = _resolve_with_extensions(pkg) + assert resolved == pkg, ( + f"Directory without index.* must return unchanged; got {resolved}" + ) + + +def test_resolve_handles_subpath_into_directory_with_index(tmp_path): + """`./foo/sub` where ./foo/sub/index.ts exists — nested subpath. + Common pattern for sub-modules inside a package.""" + target = _write(tmp_path / "foo" / "sub" / "index.ts", + "export const x = 1") + sub = tmp_path / "foo" / "sub" + assert _resolve_with_extensions(sub) == target + + +def test_resolve_does_not_treat_dotfile_as_extension(tmp_path): + """Edge case: `.eslintrc` and similar dotfiles. Path('.eslintrc').suffix + returns '' on Python 3.x for files starting with `.`. Make sure we + don't accidentally treat a real file as bare and try to append .ts.""" + target = _write(tmp_path / ".env-types.ts", + "export const x = 1") + # Path('.env-types.ts').suffix is '.ts' — not a problem + assert _resolve_with_extensions(target) == target + + +def test_resolve_chain_alias_and_extension_compose(tmp_path): + """Alias → bare path → .svelte.ts. Two layers of resolution must + compose correctly: tsconfig alias maps `$lib/...` to a real dir, + then extension resolution finds the actual file.""" + src = tmp_path / "src" + target = _write(src / "lib" / "hooks" / "is-mobile.svelte.ts", + "export const isMobile = () => true") + _write(tmp_path / "tsconfig.json", + '{"compilerOptions":{"paths":{"$lib":["./src/lib"],' + '"$lib/*":["./src/lib/*"]}}}') + importer = _write(src / "routes" / "page.ts", + "import { isMobile } from '$lib/hooks/is-mobile.svelte'\n") + result = extract_js(importer) + expected = _make_id(str(target)) + assert expected in _import_targets(result), ( + f"Alias + .svelte→.svelte.ts chain failed to compose; " + f"expected {expected}; got {_import_targets(result)}" + ) + + # ── End-to-end: dynamic_import in .svelte regex pass uses resolver ────────── From 49c3b50b5f94c3a77f0562e2748c9aacf58accd5 Mon Sep 17 00:00:00 2001 From: Christian Winther Date: Mon, 4 May 2026 22:41:50 +0200 Subject: [PATCH 3/6] fix(extract): generalize resolver to multi-dot filenames + rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that landed together because they share the same code path: 1. Generalize the bare-path append to handle multi-dot filenames The previous resolver only appended extensions when path.suffix == "" (truly bare paths). Real codebases use a lot of multi-dot patterns: foo.shared.ts ← imported as './foo.shared' foo.config.ts ← imported as './foo.config' foo.compile.ts ← imported as './foo.compile' foo.integration.ts ← imported as './foo.integration' (test helper) foo.triggers.ts ← imported as './foo.triggers' (test helper) foo.svelte.ts ← imported as './foo.svelte' (Svelte 5 rune) foo.d.ts ← imported as './foo.d' (ambient types) For all of these, .suffix is the meaningful middle segment (.shared, .config, .integration, etc.) — not in the .js/.jsx/.svelte handled list, so the resolver fell through and the import dropped to a phantom. The fix unifies the bare-path and .svelte→.svelte.ts cases into a single rule: append each candidate extension to the FULL filename, not to the stripped stem. This subsumes: bare path: foo → foo.ts Svelte rune file: foo.svelte → foo.svelte.ts multi-dot helper: foo.shared → foo.shared.ts ambient declaration: foo.d → foo.d.ts No behaviour change for paths that DO exist (.is_file() short-circuit) or for the .js→.ts / .jsx→.tsx convention (handled before the append loop so we don't accidentally match foo.js → foo.js.ts when foo.ts is the real file). 2. Rename _resolve_with_extensions → _resolve_js_module_path The function is JS/TS/Svelte-specific (Vite resolver order, mirrors the convention used by _import_js, _JS_CONFIG, _TS_CONFIG). The original name suggested it was a generic path utility. Renamed to make scope explicit and align with the existing _import_js / _JS_CONFIG naming pattern. Constants renamed to match: _JS_RESOLVE_EXTS, _JS_INDEX_FILES. Tests ----- 4 new tests in tests/test_import_extension_resolution.py: - test_resolve_multi_dot_helper_file: foo.shared → foo.shared.ts - test_resolve_multi_dot_with_explicit_extension_still_works: foo.shared.ts (explicit) still wins - test_resolve_ambient_d_ts_via_bare_path: foo.d → foo.d.ts - test_end_to_end_multi_dot_import_resolves: tree-sitter pipeline sanity check via extract_js Existing 28 tests updated for the rename. 32/32 pass; 7 pre-existing unrelated failures elsewhere in the suite. Validation ---------- On a 1,873-file SvelteKit codebase, applying both rules over the v0.7.5 baseline: baseline: 12,096 edges with the resolver fix: 20,151 edges (+8,055 = +67%) The +2,652 over the previous version of this branch is attributable entirely to multi-dot filename recovery, primarily test helper imports ('*.integration.ts', '*.triggers.ts'), domain-shared modules ('*.shared.ts'), and config files. --- graphify/extract.py | 91 ++++++++++++----------- tests/test_import_extension_resolution.py | 78 ++++++++++++++----- 2 files changed, 108 insertions(+), 61 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index d76fdc2d..30056571 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -188,47 +188,55 @@ class LanguageConfig: # ── Generic helpers ─────────────────────────────────────────────────────────── -# Vite/TS resolver order. Used by _resolve_with_extensions() to map TypeScript -# bare-path imports onto real files on disk, so the resulting node id matches -# the one _extract_generic creates for the target file (#716). -_TS_RESOLVE_EXTS = (".ts", ".tsx", ".svelte", ".js", ".jsx", ".mjs") -_TS_INDEX_FILES = ("index.ts", "index.tsx", "index.js", "index.jsx") +# Vite / TypeScript resolver extensions. Used by _resolve_js_module_path() +# to map import specifiers onto real files on disk, so the resulting node +# id matches the one _extract_generic creates for the target file. +_JS_RESOLVE_EXTS = (".ts", ".tsx", ".svelte", ".js", ".jsx", ".mjs") +_JS_INDEX_FILES = ("index.ts", "index.tsx", "index.js", "index.jsx") -def _resolve_with_extensions(p: Path) -> Path: - """Resolve a TypeScript-style import path to an actual file on disk. +def _resolve_js_module_path(p: Path) -> Path: + """Resolve a JS/TS-style import specifier path to an actual file on disk. - TS / SvelteKit / Vite let you write imports without a file extension and - auto-resolve via a fixed extension order. The pre-existing .js→.ts and - .jsx→.tsx rewrites only covered the TS-ESM-via-.js convention; everything - else dropped to a phantom node id and the edge was lost in build_from_json. + TypeScript / SvelteKit / Vite let you write imports without a file + extension and auto-resolve via a fixed extension order. The pre-existing + .js→.ts and .jsx→.tsx rewrites only covered the TS-ESM-via-.js convention; + every other shape produced a phantom node id and the edge was lost in + build_from_json. Order, mirroring Vite's resolver: - 1. exact path (if it exists) - 2. .js → .ts (TS ESM convention; written as .js, file is .ts) - 3. .jsx → .tsx - 4. bare path → try .ts/.tsx/.svelte/.js/.jsx/.mjs - 5. bare path → try directory's index.{ts,tsx,js,jsx} - 6. .svelte path that isn't a real .svelte file → try the same name - with .ts appended (Svelte 5 rune-only files like foo.svelte.ts — - imports are written as './foo.svelte' but the file is .svelte.ts) - Falls back to the original path on no match — the edge will be dropped - as external by build_from_json, matching pre-#716 behaviour for cases - we genuinely can't resolve (truly external modules). + 1. exact path, when it's a real file on disk + 2. directory → try index.{ts,tsx,js,jsx} + 3. .js → .ts (TS ESM convention; written as .js, file is .ts) + .jsx → .tsx + 4. append .ts/.tsx/.svelte/.js/.jsx/.mjs to the FULL filename — not + a suffix-swap. This handles, in one rule: + - bare paths: foo → foo.ts + - Svelte 5 rune files: foo.svelte → foo.svelte.ts + - multi-dot helper files: foo.shared → foo.shared.ts + - config files: foo.config → foo.config.ts + - test helper files: foo.spec → foo.spec.ts + 5. directory variant: try .//index.{ts,tsx,js,jsx} + + Falls back to the original path on no match — preserves pre-fix behaviour + for genuinely external modules (the edge gets dropped as external by + build_from_json). """ - # Existing FILE wins — directory matches must fall through to index lookup, - # otherwise `from './queue'` (where queue/ is a real directory) would - # short-circuit and never resolve to queue/index.ts. if p.is_file(): return p - # Directory imports: try index.{ts,tsx,js,jsx} + # 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 _TS_INDEX_FILES: + 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. if p.suffix == ".js": c = p.with_suffix(".ts") if c.is_file(): @@ -237,18 +245,15 @@ def _resolve_with_extensions(p: Path) -> Path: c = p.with_suffix(".tsx") if c.is_file(): return c - if p.suffix == "": - for ext in _TS_RESOLVE_EXTS: - c = p.with_suffix(ext) - if c.is_file(): - return c - if p.suffix == ".svelte": - # SvelteKit imports written as `from './foo.svelte'` may actually point - # at `foo.svelte.ts` (a Svelte 5 rune file). Append .ts to the FULL - # filename rather than swapping the suffix — `with_suffix(".svelte.ts")` - # would replace `.svelte` with `.svelte.ts`, but `with_suffix` only - # replaces the final segment. - c = p.parent / (p.name + ".ts") + # Try appending extensions to the FULL filename. Covers bare paths, + # multi-dot helper files, Svelte 5 rune files, config files, etc. + 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 return p @@ -343,7 +348,7 @@ def _import_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_p resolved = Path(os.path.normpath(Path(str_path).parent / raw)) # TS / SvelteKit resolver: try .ts/.tsx/.svelte/.svelte.ts/index.{ts,…} # so bare-path and Svelte-5-rune imports land on the right node id (#716) - resolved = _resolve_with_extensions(resolved) + resolved = _resolve_js_module_path(resolved) tgt_nid = _make_id(str(resolved)) resolved_path = resolved else: @@ -358,7 +363,7 @@ def _import_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_p if resolved_alias is not None: # Same resolver fixups as the relative branch — alias targets # are equally likely to be bare paths / .svelte.ts / index.ts (#716) - resolved_alias = _resolve_with_extensions(resolved_alias) + resolved_alias = _resolve_js_module_path(resolved_alias) tgt_nid = _make_id(str(resolved_alias)) resolved_path = resolved_alias else: @@ -1831,7 +1836,7 @@ def extract_svelte(path: Path) -> dict: # Apply same TS/Svelte resolver fixups as static imports so dynamic # imports of bare paths and .svelte.ts rune files land on real # file nodes instead of phantom ids (#716). - resolved = _resolve_with_extensions(resolved) + resolved = _resolve_js_module_path(resolved) node_id = _make_id(str(resolved)) else: # Check tsconfig.json path aliases (e.g. "$lib/" -> "src/lib/", "@/" -> "src/") @@ -1844,7 +1849,7 @@ def extract_svelte(path: Path) -> dict: resolved_alias = Path(os.path.normpath(Path(alias_base) / rest)) break if resolved_alias is not None: - resolved_alias = _resolve_with_extensions(resolved_alias) + resolved_alias = _resolve_js_module_path(resolved_alias) node_id = _make_id(str(resolved_alias)) else: # Bare/scoped import (node_modules) - use last segment; diff --git a/tests/test_import_extension_resolution.py b/tests/test_import_extension_resolution.py index a420172b..e81c35cd 100644 --- a/tests/test_import_extension_resolution.py +++ b/tests/test_import_extension_resolution.py @@ -12,7 +12,7 @@ from pathlib import Path from graphify.extract import ( _make_id, - _resolve_with_extensions, + _resolve_js_module_path, extract_js, extract_svelte, ) @@ -29,30 +29,30 @@ def _import_targets(result: dict) -> set[str]: if e.get("relation") in ("imports", "imports_from")} -# ── _resolve_with_extensions unit tests ────────────────────────────────────── +# ── _resolve_js_module_path unit tests ────────────────────────────────────── def test_resolve_returns_existing_path_unchanged(tmp_path): p = _write(tmp_path / "foo.ts", "export const x = 1") - assert _resolve_with_extensions(p) == p + assert _resolve_js_module_path(p) == p def test_resolve_bare_path_to_ts(tmp_path): target = _write(tmp_path / "foo.ts", "export const x = 1") bare = tmp_path / "foo" - assert _resolve_with_extensions(bare) == target + assert _resolve_js_module_path(bare) == target def test_resolve_bare_path_to_tsx(tmp_path): target = _write(tmp_path / "Component.tsx", "export const x = 1") bare = tmp_path / "Component" - assert _resolve_with_extensions(bare) == target + assert _resolve_js_module_path(bare) == target def test_resolve_bare_path_to_svelte(tmp_path): target = _write(tmp_path / "Card.svelte", "
") bare = tmp_path / "Card" - assert _resolve_with_extensions(bare) == target + assert _resolve_js_module_path(bare) == target def test_resolve_prefers_ts_over_svelte_when_both_exist(tmp_path): @@ -60,20 +60,20 @@ def test_resolve_prefers_ts_over_svelte_when_both_exist(tmp_path): ts_target = _write(tmp_path / "foo.ts", "export const x = 1") _write(tmp_path / "foo.svelte", "
") bare = tmp_path / "foo" - assert _resolve_with_extensions(bare) == ts_target + assert _resolve_js_module_path(bare) == ts_target def test_resolve_directory_to_index_ts(tmp_path): pkg = tmp_path / "queue" target = _write(pkg / "index.ts", "export const x = 1") - assert _resolve_with_extensions(pkg) == target + assert _resolve_js_module_path(pkg) == target def test_resolve_directory_prefers_index_ts_over_index_js(tmp_path): pkg = tmp_path / "queue" target = _write(pkg / "index.ts", "export const x = 1") _write(pkg / "index.js", "module.exports = {}") - assert _resolve_with_extensions(pkg) == target + assert _resolve_js_module_path(pkg) == target def test_resolve_svelte_to_svelte_ts_for_rune_files(tmp_path): @@ -83,7 +83,7 @@ def test_resolve_svelte_to_svelte_ts_for_rune_files(tmp_path): target = _write(tmp_path / "is-mobile.svelte.ts", "export const isMobile = () => true") written_as = tmp_path / "is-mobile.svelte" - resolved = _resolve_with_extensions(written_as) + resolved = _resolve_js_module_path(written_as) assert resolved == target, ( f"Expected resolution to is-mobile.svelte.ts; got {resolved}" ) @@ -93,27 +93,27 @@ def test_resolve_js_to_ts_when_real_file_is_ts(tmp_path): """TS ESM convention: imports written as .js but the actual file is .ts.""" target = _write(tmp_path / "foo.ts", "export const x = 1") written_as = tmp_path / "foo.js" - assert _resolve_with_extensions(written_as) == target + assert _resolve_js_module_path(written_as) == target def test_resolve_jsx_to_tsx_when_real_file_is_tsx(tmp_path): target = _write(tmp_path / "Component.tsx", "export const x = 1") written_as = tmp_path / "Component.jsx" - assert _resolve_with_extensions(written_as) == target + assert _resolve_js_module_path(written_as) == target def test_resolve_returns_unchanged_when_nothing_matches(tmp_path): """External / truly missing paths fall back to the input — preserves pre-#716 behavior of becoming an external phantom edge.""" nothing = tmp_path / "does_not_exist" - assert _resolve_with_extensions(nothing) == nothing + assert _resolve_js_module_path(nothing) == nothing def test_resolve_real_js_stays_js_when_ts_does_not_exist(tmp_path): """If `.js` exists and `.ts` does not, keep the `.js` rewrite from triggering — return the existing file.""" target = _write(tmp_path / "foo.js", "module.exports = 1") - assert _resolve_with_extensions(target) == target + assert _resolve_js_module_path(target) == target # ── End-to-end: bare-path imports in pure TS files ─────────────────────────── @@ -297,7 +297,7 @@ def test_resolve_does_not_match_partial_directory_name(tmp_path): but worth pinning down.""" _write(tmp_path / "foo-extra.ts", "export const x = 1") bare = tmp_path / "foo" - resolved = _resolve_with_extensions(bare) + resolved = _resolve_js_module_path(bare) # Not a real file → nothing matches → returns input unchanged assert resolved == bare, ( f"Partial-name match must not happen; got {resolved}" @@ -309,7 +309,7 @@ def test_resolve_directory_without_index_returns_unchanged(tmp_path): \"return as-is\" path, not pick a non-index file from inside.""" pkg = tmp_path / "pkg" _write(pkg / "not-index.ts", "export const x = 1") - resolved = _resolve_with_extensions(pkg) + resolved = _resolve_js_module_path(pkg) assert resolved == pkg, ( f"Directory without index.* must return unchanged; got {resolved}" ) @@ -321,7 +321,7 @@ def test_resolve_handles_subpath_into_directory_with_index(tmp_path): target = _write(tmp_path / "foo" / "sub" / "index.ts", "export const x = 1") sub = tmp_path / "foo" / "sub" - assert _resolve_with_extensions(sub) == target + assert _resolve_js_module_path(sub) == target def test_resolve_does_not_treat_dotfile_as_extension(tmp_path): @@ -331,7 +331,49 @@ def test_resolve_does_not_treat_dotfile_as_extension(tmp_path): target = _write(tmp_path / ".env-types.ts", "export const x = 1") # Path('.env-types.ts').suffix is '.ts' — not a problem - assert _resolve_with_extensions(target) == target + assert _resolve_js_module_path(target) == target + + +def test_resolve_multi_dot_helper_file(tmp_path): + """Common patterns: foo.shared.ts, foo.config.ts, foo.compile.ts, + foo.integration.ts, foo.triggers.ts. Imports written as + `from './foo.shared'` (preserving the meaningful suffix) must resolve + to foo.shared.ts. + + Before this rule, .suffix was '.shared' so neither the bare-path branch + nor the .js/.jsx branches matched, and the import dropped to a phantom.""" + target = _write(tmp_path / "tag-action.shared.ts", + "export const apply = () => {}") + written_as = tmp_path / "tag-action.shared" + assert _resolve_js_module_path(written_as) == target + + +def test_resolve_multi_dot_with_explicit_extension_still_works(tmp_path): + """Sanity: `from './foo.shared.ts'` (explicit) still wins over implicit.""" + target = _write(tmp_path / "foo.shared.ts", "export const x = 1") + assert _resolve_js_module_path(target) == target + + +def test_resolve_ambient_d_ts_via_bare_path(tmp_path): + """Ambient TS declaration files (foo.d.ts) — bare import `./foo.d` + should resolve to `./foo.d.ts` because `name + '.ts'` gives `foo.d.ts`.""" + target = _write(tmp_path / "ambient.d.ts", "declare const X: string") + written_as = tmp_path / "ambient.d" + assert _resolve_js_module_path(written_as) == target + + +def test_end_to_end_multi_dot_import_resolves(tmp_path): + """End-to-end sanity for the multi-dot pattern via the import handler.""" + target = _write(tmp_path / "tag-action.shared.ts", + "export const apply = () => {}") + importer = _write(tmp_path / "page.ts", + "import { apply } from './tag-action.shared'\n") + result = extract_js(importer) + expected = _make_id(str(target)) + assert expected in _import_targets(result), ( + f"Multi-dot import failed end-to-end; " + f"expected {expected}; got {_import_targets(result)}" + ) def test_resolve_chain_alias_and_extension_compose(tmp_path): From 5f5b59309c617949de46b8064d8d64b8aca55861 Mon Sep 17 00:00:00 2001 From: Christian Winther Date: Mon, 4 May 2026 22:47:29 +0200 Subject: [PATCH 4/6] test(extract): cover .svelte.js + hybrid TS/JS Svelte 5 rune files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generalized resolver already handles .svelte.js because the append loop iterates _JS_RESOLVE_EXTS = (.ts, .tsx, .svelte, .js, .jsx, .mjs). Adds three explicit tests to pin the behaviour and document the priority choice: - test_resolve_svelte_to_svelte_js_for_javascript_rune_files JS-only Svelte 5 project: .svelte → .svelte.js works the same way as .svelte.ts in TS projects. No special-casing needed — the generalized append loop covers both. - test_resolve_svelte_prefers_svelte_ts_over_svelte_js Hybrid case (both files exist, e.g. .svelte.ts source plus .svelte.js build artifact): .ts wins. Documents the deliberate source-first priority — graphify is a source-code tool, not a runtime resolver, so we differ from Vite's default JS-first order. - test_resolve_real_svelte_file_wins_over_svelte_ts_sibling Existence check short-circuits before any extension append, so a real .svelte file always wins over a .svelte.ts sibling. --- tests/test_import_extension_resolution.py | 45 +++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/test_import_extension_resolution.py b/tests/test_import_extension_resolution.py index e81c35cd..c57122e3 100644 --- a/tests/test_import_extension_resolution.py +++ b/tests/test_import_extension_resolution.py @@ -89,6 +89,51 @@ def test_resolve_svelte_to_svelte_ts_for_rune_files(tmp_path): ) +def test_resolve_svelte_to_svelte_js_for_javascript_rune_files(tmp_path): + """JS variant of the rune file pattern: a `.svelte.js` file (used in + JavaScript-only Svelte 5 projects, no TypeScript). `from './foo.svelte'` + must resolve to `foo.svelte.js` when no `.ts` variant exists. + + Same code path as the .svelte.ts case — the generalized resolver tries + every extension in priority order, so JS-only and TS-only projects + both work without special-casing.""" + target = _write(tmp_path / "store.svelte.js", + "export const count = $state(0)") + written_as = tmp_path / "store.svelte" + resolved = _resolve_js_module_path(written_as) + assert resolved == target + + +def test_resolve_svelte_prefers_svelte_ts_over_svelte_js(tmp_path): + """When both `.svelte.ts` and `.svelte.js` exist (hybrid project mid- + migration, or a build artifact alongside the source), `.ts` wins — + matching the resolver's stated TypeScript-first priority order. + + Note: Vite's default `resolve.extensions` puts `.js` before `.ts`, but + in practice TypeScript codebases that emit `.svelte.js` build artifacts + expect tooling to read the `.svelte.ts` source. graphify is a source- + code tool, not a runtime resolver, so source-first ordering is correct + for our use case.""" + ts_target = _write(tmp_path / "store.svelte.ts", + "export const count = $state(0)") + _write(tmp_path / "store.svelte.js", + "export const count = 0 // build artifact") + written_as = tmp_path / "store.svelte" + resolved = _resolve_js_module_path(written_as) + assert resolved == ts_target + + +def test_resolve_real_svelte_file_wins_over_svelte_ts_sibling(tmp_path): + """If `foo.svelte` IS a real markup file, importing `./foo.svelte` + must resolve to that — not get hijacked to a sibling `foo.svelte.ts` + rune file. The existence-check short-circuits before any append.""" + real = _write(tmp_path / "Card.svelte", "
card markup
") + _write(tmp_path / "Card.svelte.ts", + "export const helpers = {} // rune sibling, not the import target") + resolved = _resolve_js_module_path(real) + assert resolved == real + + def test_resolve_js_to_ts_when_real_file_is_ts(tmp_path): """TS ESM convention: imports written as .js but the actual file is .ts.""" target = _write(tmp_path / "foo.ts", "export const x = 1") From 0dfc26e57f7479f52ecef58b560722461d0b3e09 Mon Sep 17 00:00:00 2001 From: Christian Winther Date: Mon, 4 May 2026 23:44:32 +0200 Subject: [PATCH 5/6] 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 30056571..96eed616 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 c57122e3..39051c9c 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") From b68ec63494ded5848710bc5db667ac05dda4d8b1 Mon Sep 17 00:00:00 2001 From: Christian Winther Date: Tue, 5 May 2026 00:07:50 +0200 Subject: [PATCH 6/6] fix(extract): apply resolver fixups to JS/TS dynamic_import handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third call site that re-implemented the same .js→.ts rewrite in isolation. Previously only handled the explicit .js→.ts case; bare paths, multi-dot helper files, and alias-resolved dynamic imports all dropped silently. Now uses _resolve_js_module_path on both branches (relative and alias) — same shape as the static-import and Svelte regex paths. Real-world impact: TS files using `await import('./foo')` patterns for code splitting (e.g. lazy-loading a profanity check) now produce edges to the resolved target. --- graphify/extract.py | 10 +++-- tests/test_import_extension_resolution.py | 49 +++++++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 96eed616..2a4e0eef 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -453,10 +453,11 @@ def _dynamic_import_js(node, source: bytes, caller_nid: str, str_path: str, edge # Resolve path using the same logic as static imports if raw.startswith("."): resolved = Path(os.path.normpath(Path(str_path).parent / raw)) - if resolved.suffix == ".js": - resolved = resolved.with_suffix(".ts") - elif resolved.suffix == ".jsx": - resolved = resolved.with_suffix(".tsx") + # Same TS/SvelteKit resolver fixups static imports use, so + # `await import('./foo')` (bare path), `import('./bar.shared')` + # (multi-dot helper), and Svelte 5 rune-file dynamic imports + # all land on real file nodes. + resolved = _resolve_js_module_path(resolved) tgt_nid = _make_id(str(resolved)) else: aliases = _load_tsconfig_aliases(Path(str_path).parent) @@ -467,6 +468,7 @@ def _dynamic_import_js(node, source: bytes, caller_nid: str, str_path: str, edge resolved_alias = Path(os.path.normpath(Path(alias_base) / rest)) break if resolved_alias is not None: + resolved_alias = _resolve_js_module_path(resolved_alias) tgt_nid = _make_id(str(resolved_alias)) else: module_name = raw.split("/")[-1] diff --git a/tests/test_import_extension_resolution.py b/tests/test_import_extension_resolution.py index 39051c9c..0d1222c0 100644 --- a/tests/test_import_extension_resolution.py +++ b/tests/test_import_extension_resolution.py @@ -458,6 +458,55 @@ def test_resolve_chain_alias_and_extension_compose(tmp_path): # ── End-to-end: dynamic_import in .svelte regex pass uses resolver ────────── +def test_ts_dynamic_import_bare_path_resolves(tmp_path): + """Real-world repro: a TS file uses `await import('./foo')` (no extension) + to lazy-load a sibling module. The dynamic-import handler in JS/TS files + has its own copy of the resolution logic — distinct from the static-import + handler and from the Svelte regex pass — and was missing the bare-path + extension append, silently dropping every such edge.""" + target = _write(tmp_path / "profanity.ts", + "export const hasProfanity = (s: string) => false") + importer = _write(tmp_path / "auth-validators.ts", """\ +export async function validate(name: string) { + const { hasProfanity } = await import('./profanity') + return hasProfanity(name) +} +""") + result = extract_js(importer) + expected = _make_id(str(target)) + targets = {str(e.get("target") or "") for e in result["edges"] + if e.get("relation") in ("imports", "imports_from")} + assert expected in targets, ( + f"Bare-path TS dynamic import failed to resolve; " + f"expected {expected}; got {targets}" + ) + + +def test_ts_dynamic_import_alias_with_bare_path_resolves(tmp_path): + """The other branch of the dynamic-import handler — alias resolution — + also needs the same fixups. `import('$lib/foo')` should resolve to + `$lib/foo.ts` after both alias substitution and extension append.""" + src = tmp_path / "src" + target = _write(src / "lib" / "lazy-module.ts", "export const x = 1") + _write(tmp_path / "tsconfig.json", + '{"compilerOptions":{"paths":{"$lib":["./src/lib"],' + '"$lib/*":["./src/lib/*"]}}}') + importer = _write(src / "routes" / "page.ts", """\ +export async function load() { + const m = await import('$lib/lazy-module') + return m.x +} +""") + result = extract_js(importer) + expected = _make_id(str(target)) + targets = {str(e.get("target") or "") for e in result["edges"] + if e.get("relation") in ("imports", "imports_from")} + assert expected in targets, ( + f"Alias + bare-path dynamic import failed to resolve; " + f"expected {expected}; got {targets}" + ) + + def test_dynamic_import_bare_path_resolves(tmp_path): """The regex pass for `import('...')` in .svelte files must also use the new resolver — otherwise dynamic imports of bare paths still