mirror of
https://github.com/Graphify-Labs/graphify.git
synced 2026-09-14 19:34:09 +08:00
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:
+1
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user