Skip to content

Commit 693fe98

Browse files
fix(js): extract methods on exported objects to resolve CommonJS calls (#3778)
1 parent 35adf43 commit 693fe98

2 files changed

Lines changed: 108 additions & 0 deletions

File tree

‎graphify/extractors/engine.py‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2753,6 +2753,69 @@ def _js_scan_member_assignments(
27532753
function_bodies.append((m_nid, m_body))
27542754

27552755

2756+
def _js_find_exported_objects(program_node, source: bytes) -> set[str]:
2757+
"""Find names of identifiers exported from a JS module (CJS or ESM)."""
2758+
if program_node is None or program_node.type != "program":
2759+
return set()
2760+
exported = set()
2761+
for child in program_node.children:
2762+
if child.type == "export_statement":
2763+
for c in child.children:
2764+
if c.type == "identifier":
2765+
exported.add(_read_text(c, source))
2766+
elif c.type == "export_clause":
2767+
for sc in c.children:
2768+
if sc.type == "export_specifier":
2769+
name_node = sc.child_by_field_name("name")
2770+
if name_node:
2771+
exported.add(_read_text(name_node, source))
2772+
elif child.type in ("variable_declaration", "lexical_declaration"):
2773+
for decl in child.children:
2774+
if decl.type != "variable_declarator":
2775+
continue
2776+
name_node = decl.child_by_field_name("name")
2777+
val_node = decl.child_by_field_name("value")
2778+
if name_node and name_node.type == "identifier" and val_node:
2779+
name_str = _read_text(name_node, source)
2780+
curr = val_node
2781+
while curr and curr.type == "assignment_expression":
2782+
left = curr.child_by_field_name("left")
2783+
if left:
2784+
left_str = _read_text(left, source)
2785+
if left_str in ("module.exports", "exports") or left_str.startswith(("module.exports.", "exports.")):
2786+
exported.add(name_str)
2787+
break
2788+
curr = curr.child_by_field_name("right")
2789+
if curr:
2790+
val_str = _read_text(curr, source)
2791+
if val_str in ("module.exports", "exports"):
2792+
exported.add(name_str)
2793+
elif child.type == "expression_statement":
2794+
assign = next((c for c in child.children if c.type == "assignment_expression"), None)
2795+
if assign:
2796+
left = assign.child_by_field_name("left")
2797+
right = assign.child_by_field_name("right")
2798+
if left:
2799+
left_str = _read_text(left, source)
2800+
if left_str in ("module.exports", "exports") or left_str.startswith(("module.exports.", "exports.")):
2801+
curr = right
2802+
while curr and curr.type == "assignment_expression":
2803+
l = curr.child_by_field_name("left")
2804+
if l and l.type == "identifier":
2805+
exported.add(_read_text(l, source))
2806+
curr = curr.child_by_field_name("right")
2807+
if curr and curr.type == "identifier":
2808+
exported.add(_read_text(curr, source))
2809+
elif curr and curr.type == "object":
2810+
for prop in curr.children:
2811+
if prop.type == "shorthand_property_identifier":
2812+
exported.add(_read_text(prop, source))
2813+
elif prop.type == "pair":
2814+
v = prop.child_by_field_name("value")
2815+
if v and v.type == "identifier":
2816+
exported.add(_read_text(v, source))
2817+
return exported
2818+
27562819
def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str,
27572820
nodes: list, edges: list, seen_ids: set, function_bodies: list,
27582821
parent_class_nid: str | None, add_node_fn, add_edge_fn,
@@ -2828,6 +2891,20 @@ def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str,
28282891
add_node_fn(nid, f".{member_name}()", line)
28292892
add_edge_fn(owner_nid, nid, "method", line)
28302893
handled = True
2894+
elif kind == "object":
2895+
is_exported = False
2896+
if node.parent is not None and node.parent.type == "program":
2897+
exported_names = _js_find_exported_objects(node.parent, source)
2898+
is_exported = owner_name in exported_names
2899+
if is_exported:
2900+
owner_nid = _make_id(stem, owner_name)
2901+
nid = _make_id(owner_nid, member_name)
2902+
if owner_nid not in seen_ids:
2903+
add_node_fn(owner_nid, owner_name, line)
2904+
add_edge_fn(file_nid, owner_nid, "contains", line)
2905+
add_node_fn(nid, f".{member_name}()", line)
2906+
add_edge_fn(owner_nid, nid, "method", line)
2907+
handled = True
28312908
if handled:
28322909
if callable_def_nids is not None:
28332910
callable_def_nids.add(nid) # CJS/prototype fn is callable

‎tests/test_extract.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1187,6 +1187,37 @@ def test_extract_js_arbitrary_member_assignment_not_captured(tmp_path):
11871187
assert ".whatever()" not in labels
11881188

11891189

1190+
1191+
def test_extract_js_exported_object_member_assignment_and_calls(tmp_path):
1192+
"""#3778: Member functions assigned to exported objects (like Express res.format = fn)
1193+
are captured as methods and their call expressions resolved."""
1194+
from graphify.extract import extract
1195+
utils = tmp_path / "utils.js"
1196+
utils.write_text(
1197+
"exports.normalizeType = function(val) { return val; };\n"
1198+
)
1199+
response = tmp_path / "response.js"
1200+
response.write_text(
1201+
"var normalizeType = require('./utils').normalizeType;\n"
1202+
"var res = Object.create(null);\n"
1203+
"module.exports = res;\n"
1204+
"res.format = function(obj) {\n"
1205+
" return normalizeType(obj);\n"
1206+
"};\n"
1207+
)
1208+
result = extract([utils, response], root=tmp_path)
1209+
labels = {n["label"] for n in result["nodes"]}
1210+
assert ".format()" in labels
1211+
assert "res" in labels
1212+
1213+
edges = [(e["source"], e["target"], e["relation"]) for e in result["edges"]]
1214+
calls = [e for e in edges if e[2] == "calls"]
1215+
matching_calls = [
1216+
(s, t) for (s, t, r) in calls
1217+
if s.endswith("res_format") and t.endswith("normalizetype")
1218+
]
1219+
assert len(matching_calls) == 1
1220+
11901221
def test_extract_js_nested_function_declarations(tmp_path):
11911222
"""#2653: function declarations nested inside another function emit nodes,
11921223
source contains edges from the enclosing function, and attribute call edges correctly."""

0 commit comments

Comments
 (0)