fix: emit symbol edges for default imports/exports

JS/TS symbol resolution only handled named imports. A default-exported
symbol (`export default class Foo`) imported as `import Foo from './foo'`
produced only a file→file `imports_from` edge; the class node received no
incoming symbol edge. On codebases that default-export most classes
(NestJS services/helpers/models, etc.) those symbols looked like isolated
leaf nodes, and `graphify affected "<Class>"` / `explain` reported no
callers.

Record default imports with imported_name="default", register a "default"
export for `export default <class|function|identifier>`, and let the
existing exported-origin resolver wire the `imports` edge — which also
resolves calls made through the local binding, even when it is renamed
(`import Bar from './foo'; new Bar()`). `export { X as default }` was
already handled via the export-clause path; anonymous defaults
(`export default class {}`) have no resolvable symbol and stay file-level.

Adds regression tests for default-export class/function/identifier import
resolution and renamed-binding call resolution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jamie Evans
2026-06-11 15:23:16 -04:00
committed by Safi
parent d276a5aa57
commit 6dc23db90f
3 changed files with 119 additions and 1 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## Unreleased
- Fix: tsconfig `paths` aliases are now resolved relative to `baseUrl`. `_read_tsconfig_aliases` previously joined alias targets onto the tsconfig's directory and ignored `compilerOptions.baseUrl`, so the common monorepo / NestJS layout (`baseUrl: "./src"` with `"@services/*": ["services/*"]`) resolved to `<dir>/services` instead of `<dir>/src/services` and every aliased import edge was silently dropped — leaving cross-file caller graphs nearly empty on alias-heavy TypeScript codebases. Resolution now joins `paths` onto `baseUrl` (defaulting to `.`, preserving prior behavior for configs without `baseUrl`).
- Fix: default imports/exports now produce symbol-level edges. JS/TS symbol resolution only handled named imports, so a `export default class Foo` imported as `import Foo from './foo'` got just a file→file `imports_from` edge — the class node received no incoming symbol edge. On codebases that default-export most classes (NestJS services/helpers/models, etc.) this left those symbols looking like isolated leaf nodes and made `graphify affected "<Class>"` / `explain` report no callers. Default imports are now recorded with `imported_name="default"`, `export default <class|function|identifier>` registers a `"default"` export, and the existing resolver wires the `imports` edge (and resolves calls through the local binding, even when renamed). Anonymous defaults (`export default class {}`) remain file-level only.
## 0.8.37 (2026-06-10)
+63
View File
@@ -7507,6 +7507,43 @@ def _js_exported_declaration_names(node, source: bytes) -> list[str]:
return names
def _js_default_import_name(node, source: bytes) -> str | None:
"""Local binding of a default import: the `Foo` in `import Foo from './x'`.
The default binding is a bare identifier child of the import_clause (named
imports live in a `named_imports` node, namespace imports in a
`namespace_import` node), so it is also picked up from the mixed form
`import Foo, { Bar } from './x'`.
"""
for child in node.children:
if child.type == "import_clause":
for sub in child.children:
if sub.type == "identifier":
return _read_text(sub, source)
return None
def _js_default_export_name(node, source: bytes) -> str | None:
"""Local name of a default export, or None for anonymous defaults.
Handles `export default class Foo {}`, `export default function foo() {}`,
`export default abstract class Foo {}` (name on the `declaration` field) and
`export default Foo` (an identifier on the `value` field). Anonymous defaults
(`export default class {}`, `export default {...}`) have no resolvable symbol
and return None.
"""
if not any(child.type == "default" for child in node.children):
return None
declaration = node.child_by_field_name("declaration")
if declaration is not None:
name_node = declaration.child_by_field_name("name")
return _read_text(name_node, source) if name_node is not None else None
value = node.child_by_field_name("value")
if value is not None and value.type == "identifier":
return _read_text(value, source)
return None
def _js_top_level_function_bodies(path: Path, root_node, source: bytes) -> list[tuple[str, object]]:
bodies: list[tuple[str, object]] = []
stem = _file_stem(path)
@@ -7757,6 +7794,17 @@ def _collect_js_symbol_resolution_facts(paths: list[Path], facts: _SymbolResolut
node.start_point[0] + 1,
)
)
default_local = _js_default_import_name(node, source)
if default_local is not None:
facts.imports.append(
_SymbolImportFact(
path,
default_local,
target_path,
"default",
node.start_point[0] + 1,
)
)
for node in _walk_js_tree(root_node):
for alias, target in _js_lexical_aliases(node, source):
@@ -7825,6 +7873,21 @@ def _collect_js_symbol_resolution_facts(paths: list[Path], facts: _SymbolResolut
)
)
# `export default class Foo {}` / `export default foo` exposes the
# symbol under the name "default"; record that so a default import
# (imported_name="default") resolves to it. `export { X as default }`
# is already handled via the export_clause path above.
default_name = _js_default_export_name(node, source)
if default_name is not None:
facts.exports.append(
_SymbolExportFact(
path,
"default",
node.start_point[0] + 1,
local_name=default_name,
)
)
for path in js_paths:
resolved_path = path.resolve()
parsed = trees.get(resolved_path)
+55
View File
@@ -381,6 +381,61 @@ def test_tsconfig_array_extends_alias_resolves_existing_ts_file(tmp_path: Path):
assert _has_edge(result, "src/routes/page.ts", "src/lib/types/type-helpers.ts")
def test_default_import_resolves_to_default_exported_class(tmp_path: Path):
target = _write(tmp_path / "src/lib/foo.ts", "export default class Foo { id = '' }\n")
importer = _write(
tmp_path / "src/routes/page.ts",
"import Foo from '../lib/foo'\nnew Foo()\n",
)
result = _extract_for([target, importer], tmp_path)
assert _has_symbol_edge(result, "src/routes/page.ts", "src/lib/foo.ts", "Foo")
def test_default_import_with_renamed_binding_resolves_to_origin(tmp_path: Path):
# The local binding may differ from the exported symbol name; the edge must
# still target the origin symbol, not the local binding.
target = _write(tmp_path / "src/lib/foo.ts", "export default class Foo { id = '' }\n")
importer = _write(
tmp_path / "src/routes/page.ts",
"import Renamed from '../lib/foo'\nnew Renamed()\n",
)
result = _extract_for([target, importer], tmp_path)
assert _has_symbol_edge(result, "src/routes/page.ts", "src/lib/foo.ts", "Foo")
def test_export_default_identifier_resolves_default_import(tmp_path: Path):
target = _write(tmp_path / "src/lib/foo.ts", "class Foo { id = '' }\nexport default Foo\n")
importer = _write(
tmp_path / "src/routes/page.ts",
"import Foo from '../lib/foo'\nnew Foo()\n",
)
result = _extract_for([target, importer], tmp_path)
assert _has_symbol_edge(result, "src/routes/page.ts", "src/lib/foo.ts", "Foo")
def test_default_import_call_resolves_to_default_exported_function(tmp_path: Path):
# Binding a default import also lets calls through it resolve to the origin.
# The local binding (`mk`) deliberately differs from the exported name so the
# edge can only come from the default-import alias, not global-label matching.
target = _write(tmp_path / "src/lib/foo.ts", "export default function makeFoo() { return 1 }\n")
importer = _write(
tmp_path / "src/routes/page.ts",
"import mk from '../lib/foo'\nconst X = () => mk()\n",
)
result = _extract_for([target, importer], tmp_path)
assert _has_symbol_to_symbol_edge(
result, "src/routes/page.ts", "X", "src/lib/foo.ts", "makeFoo", "calls"
)
def test_pnpm_workspace_package_import_resolves_package_entry(tmp_path: Path):
_write(
tmp_path / "pnpm-workspace.yaml",