From cd9fbdb1d8d577cf985f219754397e5a4a1957be Mon Sep 17 00:00:00 2001 From: rajanpanth Date: Sat, 15 Aug 2026 10:01:03 +0545 Subject: [PATCH] fix(js): extract factory object methods --- graphify/extractors/engine.py | 52 +++++++++++++++++++++++++---------- tests/test_extract.py | 28 +++++++++++++++++++ 2 files changed, 65 insertions(+), 15 deletions(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index b91463c320..b967fc8d54 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -1966,9 +1966,11 @@ def _js_member_assignment_target(left, source: bytes): module.exports.foo = fn → ("exports", None, "foo") Foo.prototype.bar = fn → ("prototype", "Foo", "bar") - Any other shape (an arbitrary `obj.x = fn`) returns None and is skipped — - capturing those would reintroduce the bare-named / phantom-god-node class - of bug the module-level scope guard (#1077) exists to prevent. + An arbitrary identifier receiver is returned as ``("object", name, member)``. + It is only materialized after the caller proves that the identifier is a + direct object-literal binding in the enclosing function. Keeping that scope + check at the caller avoids the bare-named / phantom-god-node failure mode + that the module-level guard (#1077) prevents. """ if left is None or left.type != "member_expression": return None @@ -1986,7 +1988,7 @@ def _js_member_assignment_target(left, source: bytes): if obj.type == "identifier": if _read_text(obj, source) == "exports": return ("exports", None, member_name) - return None + return ("object", _read_text(obj, source), member_name) if obj.type == "member_expression": # module.exports.X or Foo.prototype.X inner_obj = obj.child_by_field_name("object") @@ -4157,17 +4159,27 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: line, context=ctx) body = _find_body(node, config) - # JS/TS: capture `this.X = () => {}` / `this.X = function(){}` - # assigned directly in this function/constructor body. They live - # inside the body (otherwise only walked for calls), so without this - # they are never emitted — the dominant miss on constructor-style - # ("function Foo(){ this.bar = () => {} }") and many CommonJS repos. - # Owner is the enclosing class when present (a constructor's methods - # belong to the class), else the function itself. + # JS/TS: capture callable members assigned directly in a function + # body. Besides constructor-style `this.X = fn`, factories commonly + # create an object literal and assign its public surface with + # `api.X = fn`. These statements otherwise live only in a body that + # is walked for calls, so their symbols vanish from the graph. if body is not None and config.ts_module in ( "tree_sitter_javascript", "tree_sitter_typescript" ): - this_owner_nid = parent_class_nid if parent_class_nid else func_nid + function_owner_nid = parent_class_nid if parent_class_nid else func_nid + object_bindings: dict[str, object] = {} + for stmt in body.children: + if stmt.type not in ("lexical_declaration", "variable_declaration"): + continue + for declarator in stmt.children: + if declarator.type != "variable_declarator": + continue + name = declarator.child_by_field_name("name") + value = declarator.child_by_field_name("value") + if name is not None and name.type == "identifier" \ + and value is not None and value.type == "object": + object_bindings[_read_text(name, source)] = declarator for stmt in body.children: if stmt.type != "expression_statement": continue @@ -4180,13 +4192,23 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: continue tgt = _js_member_assignment_target( assign.child_by_field_name("left"), source) - if tgt is None or tgt[0] != "this": + if tgt is None: + continue + if tgt[0] == "this": + owner_nid = function_owner_nid + elif tgt[0] == "object" and tgt[1] in object_bindings: + object_name = tgt[1] + owner_nid = _make_id(function_owner_nid, object_name) + owner_line = object_bindings[object_name].start_point[0] + 1 + add_node(owner_nid, object_name, owner_line) + add_edge(function_owner_nid, owner_nid, "contains", owner_line) + else: continue m_name = tgt[2] m_line = stmt.start_point[0] + 1 - m_nid = _make_id(this_owner_nid, m_name) + m_nid = _make_id(owner_nid, m_name) add_node(m_nid, f".{m_name}()", m_line) - add_edge(this_owner_nid, m_nid, "method", m_line) + add_edge(owner_nid, m_nid, "method", m_line) m_body = val.child_by_field_name("body") if m_body: function_bodies.append((m_nid, m_body)) diff --git a/tests/test_extract.py b/tests/test_extract.py index 2dbda9f8e2..bfb48dcec7 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -821,6 +821,34 @@ def test_extract_js_this_assigned_methods(tmp_path): assert (owner, ".getUser()") in method_edges +def test_extract_js_factory_object_assigned_methods(tmp_path): + """Methods assigned to a local object-literal factory API remain visible.""" + from graphify.extract import extract_js + f = tmp_path / "factory.js" + f.write_text( + "function createApi(deps) {\n" + " const api = {};\n" + " api.sourceClips = async function sourceClips(topic) { return deps.fetch(topic); };\n" + " api.renderVideo = function renderVideo(clips) { return api.sourceClips(clips); };\n" + " return api;\n" + "}\n" + ) + + result = extract_js(f) + by_label = {n["label"]: n for n in result["nodes"]} + assert {"createApi()", "api", ".sourceClips()", ".renderVideo()"} <= set(by_label) + + factory_nid = by_label["createApi()"]["id"] + api_nid = by_label["api"]["id"] + source_clips_nid = by_label[".sourceClips()"]["id"] + render_video_nid = by_label[".renderVideo()"]["id"] + edges = {(e["source"], e["relation"], e["target"]) for e in result["edges"]} + assert (factory_nid, "contains", api_nid) in edges + assert (api_nid, "method", source_clips_nid) in edges + assert (api_nid, "method", render_video_nid) in edges + assert (render_video_nid, "calls", source_clips_nid) in edges + + def test_extract_js_commonjs_exports_assignment(tmp_path): """`exports.X = fn` and `module.exports.X = fn` must produce function nodes.""" from graphify.extract import extract_js