fix(js): extract methods assigned to factory object APIs (#2745)

Preserve callable members assigned to a local object-literal factory API
(`const api = {}; api.foo = fn`), modeling the API object beneath its factory
and attaching the assigned functions as methods. The lazy owner-node minting
(only for identifiers proven to be object-literal bindings in the enclosing
function) avoids the #1077 config-object flood.

Partially addresses #2524 (the object-literal-method shorthand form
`return { foo() {} }` remains out of scope).
This commit is contained in:
rajanpanth
2026-08-18 22:29:47 +01:00
committed by safishamsi
parent 120c7d861f
commit 61228be167
2 changed files with 65 additions and 15 deletions
+37 -15
View File
@@ -2092,9 +2092,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
@@ -2112,7 +2114,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")
@@ -4377,17 +4379,27 @@ def _extract_generic(
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
@@ -4400,13 +4412,23 @@ def _extract_generic(
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))
+28
View File
@@ -878,6 +878,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