diff --git a/graphify/extract.py b/graphify/extract.py index d76fdc2..3005657 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 a420172..e81c35c 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):