Skip to content

fix(extract): TS bare-path / .svelte.ts / .svelte.js / multi-dot import resolution#717

Open
jippi wants to merge 6 commits intosafishamsi:v7from
jippi:fix/import-extension-resolution
Open

fix(extract): TS bare-path / .svelte.ts / .svelte.js / multi-dot import resolution#717
jippi wants to merge 6 commits intosafishamsi:v7from
jippi:fix/import-extension-resolution

Conversation

@jippi
Copy link
Copy Markdown

@jippi jippi commented May 4, 2026

Code written by Claude Code, reviewed by me (I'm not super familiar with Python, but SWE with +20 years experience).

Fixes #716. Independent of #714 (#713's PR) — applies cleanly with or without it. The two stack additively for SvelteKit projects.

Problem

_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.

Missed shapes — all common in real codebases:

Import Real file Was resolving?
import { foo } from './foo' (bare path, TS convention) foo.ts no
import { x } from './x.svelte' (Svelte 5 rune file, TS) x.svelte.ts no
import { x } from './x.svelte' (Svelte 5 rune file, JS) x.svelte.js no
import { x } from './queue' (directory import) queue/index.ts no
import { x } from './foo.shared' (multi-dot helper) foo.shared.ts no
import { x } from './foo.config' foo.config.ts no
import { x } from './foo.integration' (test helper) foo.integration.ts no
import { x } from './ambient.d' ambient.d.ts no

Fix

A new resolver — _resolve_js_module_path(p) -> Path — mirrors Vite/TypeScript resolver order:

  1. exact path (when it's a real file)
  2. directory → try index.{ts,tsx,js,jsx}
  3. .js→.ts, .jsx→.tsx (existing TS ESM convention, applied before the generic loop so foo.js doesn't match foo.js.ts when foo.ts is the real target)
  4. append each candidate extension to the full filename. This subsumes bare paths, Svelte 5 rune files (both .svelte.ts and .svelte.js), multi-dot helper files, ambient declarations, and config files in one rule.
  5. directory variant: <name>/index.{ts,tsx,js,jsx}

Falls back to the original path on no match — preserves behaviour for genuinely external modules.

Wired into _import_js (relative + alias branches) and extract_svelte's regex pass for import('...') so static and dynamic imports both benefit.

Subtle: filesystem checks distinguish files from directories (.is_file() / .is_dir() rather than .exists()). Treating any existing path as a file would short-circuit directory imports before the index lookup ever ran.

Priority order note: the _JS_RESOLVE_EXTS tuple puts .ts before .js. This deliberately differs from Vite's resolve.extensions default (which has .js first). graphify is a source-code analysis tool, not a runtime resolver — when a project has both foo.ts and foo.js (typically a .ts source plus a .js build artifact), the source is what we want to graph. Documented in test_resolve_svelte_prefers_svelte_ts_over_svelte_js.

Tests (35 new, all passing)

tests/test_import_extension_resolution.py:

  • Resolver unit (19): every branch with positive + negative cases, including TS-vs-JS priority, directory→index priority, .svelte→.svelte.ts and .svelte→.svelte.js rune file variants, hybrid project handling, real .svelte wins over rune-file siblings, multi-dot helper files, ambient .d.ts, partial-name match rejected, dotfile suffix handling.
  • End-to-end (10): TS bare-path imports, directory→index, Svelte rune-file imports, type-only import type with bare path, per-symbol named-import edges target the correct id after resolution, alias + directory composes, alias + bare path, multi-dot via tree-sitter pipeline, alias → bare path → rune-file chain, dynamic-import bare path through the regex pass.
  • Regression guards (6): explicit .ts/.svelte imports still work, external module specifiers unchanged, real .js not rewritten when .ts absent, dir without index returns input, partial-name rejected, dotfile not treated as bare.

Validation on a real codebase

A 1,873-file SvelteKit app:

State Edges vs upstream
upstream (graphifyy v0.7.5) 12,096
with this PR 20,151 +67%

Concrete impact:

  • Isolated .ts files: 107 → 32 (−70%)
  • Of the remaining 32 isolated files: 18 are truly orphan (CLI entry points, root configs, test helpers loaded by name), 11–12 are SvelteKit +page.server.ts / +layout.server.ts files (loaded by the framework's filesystem router, never import-ed), 2–3 are a separate file-collection pipeline bug that I'll file as its own issue.
  • A representative .svelte page went from 4 → 7 outgoing import edges, recovering a type-only import, a Svelte 5 rune-file import, and a per-symbol named-import edge.

Test plan

  • All resolver unit + edge-case + end-to-end + regression tests pass (35/35)
  • Full test suite: 581 pass, 7 pre-existing failures unrelated
  • Smoke test on real 1,873-file SvelteKit project
  • Manual verification of repaired queries
  • Performance: ~9k extra filesystem stat calls per full build, sub-50ms overhead

jippi added 4 commits May 4, 2026 22:26
_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
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.
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.
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.
jippi added 2 commits May 4, 2026 23:44
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Extension resolution gap: bare TypeScript imports, .svelte.ts rune files, and directory/index.ts imports never resolve to file nodes

1 participant