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:
oleksii-tumanov
2026-06-30 09:42:49 +01:00
committed by safishamsi
parent 57469641cd
commit c8c604d08c
2 changed files with 147 additions and 1 deletions
+61 -1
View File
@@ -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)
)
+86
View File
@@ -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(