Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -2753,6 +2753,75 @@ def _js_scan_member_assignments(
function_bodies.append((m_nid, m_body))


def _js_find_exported_objects(program_node, source: bytes) -> set[str]:
"""Find names of identifiers exported from a JS module (CJS or ESM)."""
if program_node is None or program_node.type != "program":
return set()
exported = set()
for child in program_node.children:
if child.type == "export_statement":
for c in child.children:
if c.type == "identifier":
exported.add(_read_text(c, source))
elif c.type == "export_clause":
for sc in c.children:
if sc.type == "export_specifier":
name_node = sc.child_by_field_name("name")
if name_node:
exported.add(_read_text(name_node, source))
elif c.type in ("variable_declaration", "lexical_declaration"):
for decl in c.children:
if decl.type == "variable_declarator":
name_node = decl.child_by_field_name("name")
if name_node and name_node.type == "identifier":
exported.add(_read_text(name_node, source))
elif child.type in ("variable_declaration", "lexical_declaration"):
for decl in child.children:
if decl.type != "variable_declarator":
continue
name_node = decl.child_by_field_name("name")
val_node = decl.child_by_field_name("value")
if name_node and name_node.type == "identifier" and val_node:
name_str = _read_text(name_node, source)
curr = val_node
while curr and curr.type == "assignment_expression":
left = curr.child_by_field_name("left")
if left:
left_str = _read_text(left, source)
if left_str in ("module.exports", "exports") or left_str.startswith(("module.exports.", "exports.")):
exported.add(name_str)
break
curr = curr.child_by_field_name("right")
if curr:
val_str = _read_text(curr, source)
if val_str in ("module.exports", "exports"):
exported.add(name_str)
elif child.type == "expression_statement":
assign = next((c for c in child.children if c.type == "assignment_expression"), None)
if assign:
left = assign.child_by_field_name("left")
right = assign.child_by_field_name("right")
if left:
left_str = _read_text(left, source)
if left_str in ("module.exports", "exports") or left_str.startswith(("module.exports.", "exports.")):
curr = right
while curr and curr.type == "assignment_expression":
l = curr.child_by_field_name("left")
if l and l.type == "identifier":
exported.add(_read_text(l, source))
curr = curr.child_by_field_name("right")
if curr and curr.type == "identifier":
exported.add(_read_text(curr, source))
elif curr and curr.type == "object":
for prop in curr.children:
if prop.type == "shorthand_property_identifier":
exported.add(_read_text(prop, source))
elif prop.type == "pair":
v = prop.child_by_field_name("value")
if v and v.type == "identifier":
exported.add(_read_text(v, source))
return exported

def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str,
Comment thread
harshaygadekar marked this conversation as resolved.
nodes: list, edges: list, seen_ids: set, function_bodies: list,
parent_class_nid: str | None, add_node_fn, add_edge_fn,
Expand Down Expand Up @@ -2828,6 +2897,20 @@ def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str,
add_node_fn(nid, f".{member_name}()", line)
add_edge_fn(owner_nid, nid, "method", line)
handled = True
elif kind == "object":
is_exported = False
if node.parent is not None and node.parent.type == "program":
exported_names = _js_find_exported_objects(node.parent, source)
is_exported = owner_name in exported_names
if is_exported:
owner_nid = _make_id(stem, owner_name)
nid = _make_id(owner_nid, member_name)
if owner_nid not in seen_ids:
add_node_fn(owner_nid, owner_name, line)
add_edge_fn(file_nid, owner_nid, "contains", line)
add_node_fn(nid, f".{member_name}()", line)
add_edge_fn(owner_nid, nid, "method", line)
handled = True
if handled:
if callable_def_nids is not None:
callable_def_nids.add(nid) # CJS/prototype fn is callable
Expand Down
47 changes: 47 additions & 0 deletions tests/test_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -1187,6 +1187,53 @@ def test_extract_js_arbitrary_member_assignment_not_captured(tmp_path):
assert ".whatever()" not in labels



def test_extract_js_exported_object_member_assignment_and_calls(tmp_path):
"""#3778: Member functions assigned to exported objects (like Express res.format = fn)
are captured as methods and their call expressions resolved."""
from graphify.extract import extract
utils = tmp_path / "utils.js"
utils.write_text(
"exports.normalizeType = function(val) { return val; };\n"
)
response = tmp_path / "response.js"
response.write_text(
"var normalizeType = require('./utils').normalizeType;\n"
"var res = Object.create(null);\n"
"module.exports = res;\n"
"res.format = function(obj) {\n"
" return normalizeType(obj);\n"
"};\n"
)
result = extract([utils, response], root=tmp_path)
labels = {n["label"] for n in result["nodes"]}
assert ".format()" in labels
assert "res" in labels

edges = [(e["source"], e["target"], e["relation"]) for e in result["edges"]]
calls = [e for e in edges if e[2] == "calls"]
matching_calls = [
(s, t) for (s, t, r) in calls
if s.endswith("res_format") and t.endswith("normalizetype")
]
assert len(matching_calls) == 1


def test_extract_js_esm_exported_object_member_assignment(tmp_path):
"""#3778: ESM exported object declarations (export const app = {}) capture member assignments."""
from graphify.extract import extract
app = tmp_path / "app.js"
app.write_text(
"export const app = {};\n"
"app.use = function(middleware) {\n"
" return middleware;\n"
"};\n"
)
result = extract([app], root=tmp_path)
labels = {n["label"] for n in result["nodes"]}
assert ".use()" in labels
assert "app" in labels

def test_extract_js_nested_function_declarations(tmp_path):
"""#2653: function declarations nested inside another function emit nodes,
source contains edges from the enclosing function, and attribute call edges correctly."""
Expand Down
Loading