mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-28 17:26:48 +00:00
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:
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user