Skip to content
Open
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
52 changes: 37 additions & 15 deletions graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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))
Expand Down
28 changes: 28 additions & 0 deletions tests/test_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down