diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f0ccb5b..ba582373 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 `
/services` instead of `/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 ""` / `explain` report no callers. Default imports are now recorded with `imported_name="default"`, `export default ` 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)
diff --git a/graphify/extract.py b/graphify/extract.py
index a857bd97..f08188da 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -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)
diff --git a/tests/test_js_import_resolution.py b/tests/test_js_import_resolution.py
index 6cf084c5..9a6d8742 100644
--- a/tests/test_js_import_resolution.py
+++ b/tests/test_js_import_resolution.py
@@ -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",