fix(extract): generalize resolver to multi-dot filenames + rename
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.
This commit is contained in:
+48
-43
@@ -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 ./<name>/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: ./<name>/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;
|
||||
|
||||
@@ -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", "<div></div>")
|
||||
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", "<div></div>")
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user