diff --git a/graphify/extract.py b/graphify/extract.py index 2f4f9c1b..ee271277 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -8087,6 +8087,14 @@ class _StarExportFact: line: int +@dataclass(frozen=True) +class _NamespaceExportFact: + file_path: Path + exported_name: str + target_path: Path + line: int + + @dataclass(frozen=True) class _SymbolUseFact: file_path: Path @@ -8104,6 +8112,7 @@ class _SymbolResolutionFacts: aliases: list[_SymbolAliasFact] = field(default_factory=list) exports: list[_SymbolExportFact] = field(default_factory=list) star_exports: list[_StarExportFact] = field(default_factory=list) + namespace_exports: list[_NamespaceExportFact] = field(default_factory=list) uses: list[_SymbolUseFact] = field(default_factory=list) # File-to-file submodule imports from `from pkg import submod` (#1146). # Each entry is (importing_file, submodule_file, line). @@ -8124,6 +8133,7 @@ def _apply_symbol_resolution_facts( or facts.aliases or facts.exports or facts.star_exports + or facts.namespace_exports or facts.uses or facts.module_imports ): @@ -8228,6 +8238,36 @@ def _apply_symbol_resolution_facts( star_fact.file_path, ) + for namespace_fact in facts.namespace_exports: + source_path = namespace_fact.file_path.resolve() + target_path = namespace_fact.target_path.resolve() + namespace_id = ensure_symbol_node( + namespace_fact.file_path, + namespace_fact.exported_name, + namespace_fact.line, + ) + named_exports_by_file.setdefault(source_path, {})[ + namespace_fact.exported_name + ] = (source_path, namespace_fact.exported_name) + source_id = source_file_id.get(source_path) + if source_id is not None: + add_edge( + source_id, + namespace_id, + "contains", + "namespace_export", + namespace_fact.line, + namespace_fact.file_path, + ) + add_edge( + source_id, + _make_id(str(path_by_resolved.get(target_path, target_path))), + "re_exports", + "export", + namespace_fact.line, + namespace_fact.file_path, + ) + for export_fact in facts.exports: file_path = export_fact.file_path.resolve() origin: tuple[Path, str] | None = None @@ -8409,6 +8449,16 @@ def _js_export_statement_is_star(node) -> bool: return any(child.type == "*" for child in node.children) +def _js_namespace_export_name(node, source: bytes) -> str | None: + for child in node.children: + if child.type != "namespace_export": + continue + for sub in child.children: + if sub.type == "identifier": + return _read_text(sub, source) or None + return None + + def _js_lexical_aliases(node, source: bytes) -> list[tuple[str, str]]: aliases: list[tuple[str, str]] = [] if node.type != "lexical_declaration": @@ -8773,7 +8823,17 @@ def _collect_js_symbol_resolution_facts(paths: list[Path], facts: _SymbolResolut if target_path is None: continue target_path = target_path.resolve() - if _js_export_statement_is_star(node): + namespace_name = _js_namespace_export_name(node, source) + if namespace_name is not None: + facts.namespace_exports.append( + _NamespaceExportFact( + path, + namespace_name, + target_path, + node.start_point[0] + 1, + ) + ) + elif _js_export_statement_is_star(node): facts.star_exports.append( _StarExportFact(path, target_path, node.start_point[0] + 1) ) diff --git a/tests/test_js_import_resolution.py b/tests/test_js_import_resolution.py index 5872f10d..f5f892c6 100644 --- a/tests/test_js_import_resolution.py +++ b/tests/test_js_import_resolution.py @@ -3,6 +3,8 @@ from __future__ import annotations import json from pathlib import Path +import pytest + from graphify.extract import _file_node_id, _file_stem, _make_id, extract @@ -138,6 +140,90 @@ def test_ts_export_star_from_index_resolves_imported_symbol_to_origin(tmp_path: assert _has_symbol_edge(result, "src/routes/page.ts", "src/lib/foo.ts", "Foo") +@pytest.mark.parametrize("suffix", ["ts", "js"]) +def test_js_namespace_reexport_import_targets_real_binding( + tmp_path: Path, + monkeypatch, + suffix: str, +): + monkeypatch.chdir(tmp_path) + target = _write(Path(f"src/lib/foo.{suffix}"), "export class Foo { id = '' }\n") + barrel = _write(Path(f"src/lib/index.{suffix}"), "export * as ns from './foo'\n") + consumer = _write( + Path(f"src/routes/page.{suffix}"), + "import { ns } from '../lib/index'\nexport const use = () => ns.Foo\n", + ) + + result = _extract_for([target, barrel, consumer], Path(".")) + + namespace_id = _make_id(_file_stem(Path(f"src/lib/index.{suffix}")), "ns") + node_ids = {node["id"] for node in result["nodes"]} + assert namespace_id in node_ids + assert _has_symbol_edge( + result, + f"src/routes/page.{suffix}", + f"src/lib/index.{suffix}", + "ns", + ) + assert _has_edge( + result, + f"src/lib/index.{suffix}", + f"src/lib/foo.{suffix}", + "re_exports", + ) + assert ( + _file_node_id(Path(f"src/lib/index.{suffix}")), + namespace_id, + "contains", + ) in { + (edge["source"], edge["target"], edge["relation"]) + for edge in result["edges"] + } + assert not [ + edge + for edge in result["edges"] + if edge["source"] not in node_ids or edge["target"] not in node_ids + ] + + +def test_ts_reexport_cycle_resolves_symbol_from_non_cycle_branch(tmp_path: Path): + target = _write(tmp_path / "src/lib/foo.ts", "export class Foo { id = '' }\n") + first = _write( + tmp_path / "src/lib/first.ts", + "export * from './second'\nexport * from './foo'\n", + ) + second = _write(tmp_path / "src/lib/second.ts", "export * from './first'\n") + consumer = _write( + tmp_path / "src/routes/page.ts", + "import type { Foo } from '../lib/first'\nexport type X = Foo\n", + ) + + result = _extract_for([target, first, second, consumer], tmp_path) + + assert _has_symbol_edge(result, "src/routes/page.ts", "src/lib/foo.ts", "Foo") + + +def test_ts_reexport_chain_beyond_sixteen_hops_resolves_origin(tmp_path: Path): + target = _write(tmp_path / "src/lib/foo.ts", "export class Foo { id = '' }\n") + barrels: list[Path] = [] + previous = "foo" + for index in range(20): + barrel = _write( + tmp_path / f"src/lib/barrel_{index}.ts", + f"export * from './{previous}'\n", + ) + barrels.append(barrel) + previous = f"barrel_{index}" + consumer = _write( + tmp_path / "src/routes/page.ts", + "import type { Foo } from '../lib/barrel_19'\nexport type X = Foo\n", + ) + + result = extract([target, *barrels, consumer], cache_root=tmp_path, parallel=False) + + assert _has_symbol_edge(result, "src/routes/page.ts", "src/lib/foo.ts", "Foo") + + def test_ts_import_alias_then_reexport_alias_resolves_imported_symbol_to_origin(tmp_path: Path): target = _write(tmp_path / "src/lib/foo.ts", "export class Foo { id = '' }\n") barrel = _write(