mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-28 09:17:02 +00:00
feat: track JS/TS barrel re-exports as explicit graph edges
- Add 'export_statement' to import_types for JS/TS/TSX configs
- Extend _import_js to detect 'export { X } from ./mod' re-exports
- Emit 're_exports' edges linking barrel files to source symbols
- Preserve walk-through for 'export function/const' declarations
- Add 're_exports' to clean_edges allowlist for cross-file edges
Tested on a 976-file Next.js codebase: detects 162 re_exports edges
and 5760 symbol-level imports (previously 0 for both).
This commit is contained in:
+70
-24
@@ -457,6 +457,17 @@ def _resolve_js_import_target(raw: str, str_path: str) -> "tuple[str, Path | Non
|
||||
|
||||
|
||||
def _import_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str) -> None:
|
||||
is_reexport = node.type == "export_statement"
|
||||
# Only handle export_statement if it has a `from` clause (re-export).
|
||||
# Pure exports like `export const x = 1` or `export { localVar }` have no source module.
|
||||
if is_reexport:
|
||||
has_from = any(child.type == "from" or (_read_text(child, source) == "from") for child in node.children if child.type in ("from", "identifier"))
|
||||
if not has_from:
|
||||
# Check for string child (source path) as a more reliable indicator
|
||||
has_from = any(child.type == "string" for child in node.children)
|
||||
if not has_from:
|
||||
return
|
||||
|
||||
resolved_path: "Path | None" = None
|
||||
for child in node.children:
|
||||
if child.type == "string":
|
||||
@@ -469,7 +480,7 @@ def _import_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_p
|
||||
"source": file_nid,
|
||||
"target": tgt_nid,
|
||||
"relation": "imports_from",
|
||||
"context": "import",
|
||||
"context": "re-export" if is_reexport else "import",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{node.start_point[0] + 1}",
|
||||
@@ -477,32 +488,59 @@ def _import_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_p
|
||||
})
|
||||
break
|
||||
|
||||
# Emit symbol-level edges for named imports from local/aliased files.
|
||||
# Emit symbol-level edges for named imports/re-exports from local/aliased files.
|
||||
# e.g. `import { Foo, type Bar } from './bar'` → file → Foo, file → Bar (EXTRACTED)
|
||||
# e.g. `export { Foo } from './bar'` → file → Foo (re_exports edge)
|
||||
# Uses the same _make_id(target_stem, name) key that _extract_generic emits when
|
||||
# defining the symbol, so these edges wire importers directly to existing symbol nodes.
|
||||
if resolved_path is not None:
|
||||
target_stem = _file_stem(resolved_path)
|
||||
line = node.start_point[0] + 1
|
||||
for child in node.children:
|
||||
if child.type == "import_clause":
|
||||
for sub in child.children:
|
||||
if sub.type == "named_imports":
|
||||
for spec in sub.children:
|
||||
if spec.type == "import_specifier":
|
||||
name_node = spec.child_by_field_name("name")
|
||||
if name_node:
|
||||
sym = _read_text(name_node, source)
|
||||
edges.append({
|
||||
"source": file_nid,
|
||||
"target": _make_id(target_stem, sym),
|
||||
"relation": "imports",
|
||||
"context": "import",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{line}",
|
||||
"weight": 1.0,
|
||||
})
|
||||
|
||||
if is_reexport:
|
||||
# Handle: export { foo, bar } from './module'
|
||||
# export { default as baz } from './module'
|
||||
for child in node.children:
|
||||
if child.type == "export_clause":
|
||||
for spec in child.children:
|
||||
if spec.type == "export_specifier":
|
||||
# The exported name is the local name from the source module
|
||||
name_node = spec.child_by_field_name("name")
|
||||
if name_node:
|
||||
sym = _read_text(name_node, source)
|
||||
if sym == "default":
|
||||
continue # skip default re-exports for ID matching
|
||||
edges.append({
|
||||
"source": file_nid,
|
||||
"target": _make_id(target_stem, sym),
|
||||
"relation": "re_exports",
|
||||
"context": "re-export",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{line}",
|
||||
"weight": 1.0,
|
||||
})
|
||||
else:
|
||||
# Handle: import { Foo, type Bar } from './bar'
|
||||
for child in node.children:
|
||||
if child.type == "import_clause":
|
||||
for sub in child.children:
|
||||
if sub.type == "named_imports":
|
||||
for spec in sub.children:
|
||||
if spec.type == "import_specifier":
|
||||
name_node = spec.child_by_field_name("name")
|
||||
if name_node:
|
||||
sym = _read_text(name_node, source)
|
||||
edges.append({
|
||||
"source": file_nid,
|
||||
"target": _make_id(target_stem, sym),
|
||||
"relation": "imports",
|
||||
"context": "import",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{line}",
|
||||
"weight": 1.0,
|
||||
})
|
||||
|
||||
|
||||
def _dynamic_import_js(node, source: bytes, caller_nid: str, str_path: str, edges: list,
|
||||
@@ -995,7 +1033,7 @@ _JS_CONFIG = LanguageConfig(
|
||||
ts_module="tree_sitter_javascript",
|
||||
class_types=frozenset({"class_declaration"}),
|
||||
function_types=frozenset({"function_declaration", "method_definition"}),
|
||||
import_types=frozenset({"import_statement"}),
|
||||
import_types=frozenset({"import_statement", "export_statement"}),
|
||||
call_types=frozenset({"call_expression", "new_expression"}),
|
||||
call_function_field="function",
|
||||
call_accessor_node_types=frozenset({"member_expression"}),
|
||||
@@ -1014,7 +1052,7 @@ _TS_CONFIG = LanguageConfig(
|
||||
"type_alias_declaration", # named type aliases
|
||||
}),
|
||||
function_types=frozenset({"function_declaration", "method_definition"}),
|
||||
import_types=frozenset({"import_statement"}),
|
||||
import_types=frozenset({"import_statement", "export_statement"}),
|
||||
call_types=frozenset({"call_expression", "new_expression"}),
|
||||
call_function_field="function",
|
||||
call_accessor_node_types=frozenset({"member_expression"}),
|
||||
@@ -1362,6 +1400,14 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
|
||||
if t in config.import_types:
|
||||
if config.import_handler:
|
||||
config.import_handler(node, source, file_nid, stem, edges, str_path)
|
||||
# For export_statement: only return (skip children) if it's a re-export
|
||||
# (has a `from` source). Otherwise fall through to walk children which may
|
||||
# contain function_declaration, class_declaration, etc.
|
||||
if t == "export_statement":
|
||||
has_source = any(c.type == "string" for c in node.children)
|
||||
if not has_source:
|
||||
for child in node.children:
|
||||
walk(child, parent_class_nid)
|
||||
return
|
||||
|
||||
# Class types
|
||||
@@ -2029,7 +2075,7 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
|
||||
clean_edges = []
|
||||
for edge in edges:
|
||||
src, tgt = edge["source"], edge["target"]
|
||||
if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from")):
|
||||
if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from", "re_exports")):
|
||||
clean_edges.append(edge)
|
||||
|
||||
result = {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls}
|
||||
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
// Barrel file that re-exports from submodules
|
||||
export { readCookie, writeCookie } from "./cookieHelpers";
|
||||
export * from "./storageHelpers";
|
||||
export { basePathRewrite, getFullUrl } from "./urlHelpers";
|
||||
|
||||
// Also has local exports (should still be extracted as nodes)
|
||||
export function localHelper() {
|
||||
return "local";
|
||||
}
|
||||
|
||||
export const LOCAL_CONST = 42;
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
export function readCookie(name: string): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
export function writeCookie(name: string, value: string): void {}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
export function getFromStorage(key: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setInStorage(key: string, value: string): void {}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
export function getFullUrl(path: string): string {
|
||||
return "https://example.com" + path;
|
||||
}
|
||||
|
||||
export function basePathRewrite(url: string): string {
|
||||
return url;
|
||||
}
|
||||
@@ -930,3 +930,72 @@ def test_extract_bash_node_metadata_is_sanitized():
|
||||
if isinstance(value, str):
|
||||
assert "<" not in value
|
||||
assert "\x00" not in value
|
||||
|
||||
|
||||
# ── Barrel re-export tests ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_barrel_reexport_emits_re_exports_edges():
|
||||
"""export { X } from './mod' must emit re_exports edges for each named specifier."""
|
||||
from graphify.extract import extract_js
|
||||
result = extract_js(FIXTURES / "barrel_reexport.ts")
|
||||
reexports = [e for e in result["edges"] if e["relation"] == "re_exports"]
|
||||
targets = [e["target"] for e in reexports]
|
||||
# Should find re_exports for readCookie, writeCookie, getFullUrl, basePathRewrite
|
||||
assert len(reexports) >= 4, f"Expected >=4 re_exports, got {len(reexports)}: {targets}"
|
||||
assert any("readcookie" in t for t in targets)
|
||||
assert any("writecookie" in t for t in targets)
|
||||
assert any("getfullurl" in t for t in targets)
|
||||
assert any("basepathrewrite" in t for t in targets)
|
||||
|
||||
|
||||
def test_barrel_reexport_emits_imports_from():
|
||||
"""Barrel file must emit file-level imports_from edges to source modules."""
|
||||
from graphify.extract import extract_js
|
||||
result = extract_js(FIXTURES / "barrel_reexport.ts")
|
||||
imports_from = [e for e in result["edges"] if e["relation"] == "imports_from"]
|
||||
targets = [e["target"] for e in imports_from]
|
||||
assert any("cookiehelpers" in t for t in targets)
|
||||
assert any("urlhelpers" in t for t in targets)
|
||||
assert any("storagehelpers" in t for t in targets)
|
||||
|
||||
|
||||
def test_barrel_reexport_context_tagged():
|
||||
"""re_exports edges should have context='re-export'."""
|
||||
from graphify.extract import extract_js
|
||||
result = extract_js(FIXTURES / "barrel_reexport.ts")
|
||||
reexports = [e for e in result["edges"] if e["relation"] == "re_exports"]
|
||||
for e in reexports:
|
||||
assert e.get("context") == "re-export"
|
||||
|
||||
|
||||
def test_barrel_local_exports_still_extracted():
|
||||
"""export function/const in a barrel file must still create nodes."""
|
||||
from graphify.extract import extract_js
|
||||
result = extract_js(FIXTURES / "barrel_reexport.ts")
|
||||
labels = [n["label"] for n in result["nodes"]]
|
||||
assert "localHelper()" in labels or "localHelper" in labels
|
||||
# File node should also exist
|
||||
assert any("barrel_reexport" in n["label"] for n in result["nodes"])
|
||||
|
||||
|
||||
def test_barrel_reexport_confidence_extracted():
|
||||
"""All re_exports edges should have confidence=EXTRACTED."""
|
||||
from graphify.extract import extract_js
|
||||
result = extract_js(FIXTURES / "barrel_reexport.ts")
|
||||
reexports = [e for e in result["edges"] if e["relation"] == "re_exports"]
|
||||
for e in reexports:
|
||||
assert e["confidence"] == "EXTRACTED"
|
||||
|
||||
|
||||
def test_pure_export_no_from_not_treated_as_reexport():
|
||||
"""export { localVar } without 'from' should NOT create re_exports edges."""
|
||||
from graphify.extract import extract_js
|
||||
import tempfile
|
||||
code = b"const x = 1;\nexport { x };\n"
|
||||
with tempfile.NamedTemporaryFile(suffix=".ts", delete=False) as f:
|
||||
f.write(code)
|
||||
f.flush()
|
||||
result = extract_js(Path(f.name))
|
||||
reexports = [e for e in result["edges"] if e["relation"] == "re_exports"]
|
||||
assert reexports == [], f"Pure export should not create re_exports: {reexports}"
|
||||
|
||||
Reference in New Issue
Block a user