From 0267cd789fec7950b440655e167d2ed1930ad13f Mon Sep 17 00:00:00 2001 From: Christian Winther Date: Mon, 4 May 2026 22:33:29 +0200 Subject: [PATCH] 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 ──────────