Skip to content

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

Description

@jippi

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

After #700, #701, #712 (PR #715), and #713 (PR #714), dynamic_import and most static imports from .svelte files now resolve correctly. But the static-import resolver in _import_js still misses three common TypeScript/Svelte 5 import shapes — and these are by far the most common shapes in any real SvelteKit codebase.

Three missed cases

graphify/extract.py::_import_js rewrites .js → .ts and .jsx → .tsx, but does not try:

  1. Bare paths (no extension). Standard TS convention: import { foo } from '$lib/types/type-helpers'. The resolver builds _make_id('src/lib/types/type-helpers') — but the real file node is _make_id('src/lib/types/type-helpers.ts'). ID mismatch → build_from_json drops the edge as external.

  2. .svelte paths that point at Svelte 5 rune files (.svelte.ts). import { isMobile } from '$lib/hooks/is-mobile.svelte' — there's no .svelte file at that path; SvelteKit/Vite resolves it to is-mobile.svelte.ts (a Svelte 5 rune-only TypeScript file). graphify writes _make_id('...is-mobile.svelte'), real node is ..._is_mobile_svelte_ts.

  3. Directory imports / barrel index.ts. import { x } from '$lib/server/queue' resolves to src/lib/server/queue/index.ts. graphify writes _make_id('src/lib/server/queue'), real node is ..._queue_index_ts.

Scope on a real codebase

A 1,873-file SvelteKit app:

Import shape Count Currently resolves?
Bare paths (no extension) 3,576
.svelte paths that are actually .svelte.ts files 288
Total potentially-missed import edges ~3,864

For comparison, #713 was worth ~3,500 edges. This is the same order of magnitude — and it's not theoretical: spot-checked 6 "isolated" .ts files and confirmed each had real importers in the codebase that the graph couldn't see:

  • src/lib/types/type-helpers.ts — 3 importers, all bare-path
  • src/lib/server/middleware/api-headers.ts — 2 importers, bare-path
  • src/lib/server/recommendations/tag-idf.ts — 3 importers, bare-path
  • src/lib/server/auth/email-verification.ts — 2 importers, bare-path
  • src/lib/server/queue/index.ts — 3 importers, all directory-style (from '$lib/server/queue')
  • src/lib/frontend/models/API_V1_Series_Default.ts — 3 importers, bare-path

Reproducer

Pure TS-to-TS:

// src/lib/foo.ts
export const foo = 1

// src/lib/page.ts
import { foo } from './foo'   // bare path — TS convention

After graphify update .:

import json, re
g = json.load(open('graphify-out/graph.json'))
def make_id(p): return re.sub(r"[^a-zA-Z0-9]+", "_", p).strip("_").lower()
fid = make_id('src/lib/page.ts')
out = [e for e in g['links'] if e.get('source') == fid]
print(out)
# → edge target is 'src_lib_foo' (no _ts), but the real node id is 'src_lib_foo_ts'
# → mismatch → edge dropped as external in build_from_json

Root cause

graphify/extract.py::_import_js (~line 274–283):

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\")
    tgt_nid = _make_id(str(resolved))

The .js → .ts and .jsx → .tsx rewrites are unconditional and don't check filesystem state. There's no fallback for the (much more common) bare-path / .svelte.ts / directory cases. The same gap exists in the alias branch and in extract_svelte()'s regex pass for dynamic_import.

Suggested fix

Add a single resolver helper that walks Vite/TS resolver order, used by both _import_js and extract_svelte:

_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 TS-style import path to an actual file on disk.

    Mirrors Vite/TS resolver order:
      1. exact path
      2. .js → .ts, .jsx → .tsx (TS ESM convention)
      3. bare pathtry .ts/.tsx/.svelte/.js/.jsx/.mjs
      4. bare pathtry directory's index.{ts,tsx,js,jsx}
      5. .svelte → .svelte.ts (Svelte 5 rune file at same path)

    Falls back to the original path on no match (keeps current
    behaviourdropped as external by build_from_json).
    \"\"\"
    if p.exists():
        return p
    if p.suffix == \".js\":
        c = p.with_suffix(\".ts\")
        if c.exists(): return c
    if p.suffix == \".jsx\":
        c = p.with_suffix(\".tsx\")
        if c.exists(): return c
    if p.suffix == \"\":
        for ext in _TS_RESOLVE_EXTS:
            c = p.with_suffix(ext)
            if c.exists(): return c
        for idx in _TS_INDEX_FILES:
            c = p / idx
            if c.exists(): 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.
        c = p.parent / (p.name + \".ts\")
        if c.exists(): return c
    return p

Same call sites: _import_js (relative + alias branches), extract_svelte (regex dynamic_import branches). Tiny diff; same shape as #701/#712/#713.

Related

This is the natural next step in the resolver chain. With #714 + #715 merged, the alias + script-slicing layers are correct; this issue is the remaining "file → file" identity problem.

Environment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions