From 6dc23db90f1bc5e2241b2eba6e90b19223c97a0b Mon Sep 17 00:00:00 2001 From: Jamie Evans Date: Thu, 11 Jun 2026 15:23:16 -0400 Subject: [PATCH] fix: emit symbol edges for default imports/exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ""` / `explain` reported no callers. Record default imports with imported_name="default", register a "default" export for `export default `, 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 --- CHANGELOG.md | 2 +- graphify/extract.py | 63 ++++++++++++++++++++++++++++++ tests/test_js_import_resolution.py | 55 ++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) 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",