mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-12 10:27:11 +00:00
feat(js): resolve namespace re-export bindings (export * as ns from) (#1552)
`export * as ns from './mod'` now creates a real symbol node for the
namespace binding `ns`, registers it as a named export (so a downstream
`import { ns }` resolves to it), and emits a file-level `re_exports` edge
to the target module. The binding is treated as a single opaque symbol —
`ns.member` accesses are deliberately NOT expanded into per-member
name-matching, avoiding the over-linking that would fan false edges.
Includes re-export cycle and deep-chain recursion guards.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
safishamsi
parent
57469641cd
commit
c8c604d08c
+61
-1
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user