fix(js): dedup factory-object contains edge; cover arrow + bare-receiver (#2745)

The factory-object owner path emitted the `contains` edge inside the per-member
loop; add_node dedups on id but add_edge does not, so N methods assigned to one
object flooded the graph with N identical contains edges. Emit it once per owner.

Tests: assert a single contains edge across four methods, cover arrow-function
assignment (the dominant modern factory shape), and lock the negative case that a
non-object-literal receiver (`external.handler = fn`) is not captured.
This commit is contained in:
safishamsi
2026-08-18 22:32:41 +01:00
parent 61228be167
commit d0f0ac1c8d
2 changed files with 77 additions and 1 deletions
+8 -1
View File
@@ -4400,6 +4400,11 @@ def _extract_generic(
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
# A factory object gets one owner node and one `contains` edge no
# matter how many methods hang off it. add_node dedups on id, but
# add_edge does not, so without this guard N assigned methods would
# emit N identical `contains` edges (the flood #1077 warns against).
contained_owners: set[str] = set()
for stmt in body.children:
if stmt.type != "expression_statement":
continue
@@ -4421,7 +4426,9 @@ def _extract_generic(
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)
if owner_nid not in contained_owners:
contained_owners.add(owner_nid)
add_edge(function_owner_nid, owner_nid, "contains", owner_line)
else:
continue
m_name = tgt[2]
+69
View File
@@ -906,6 +906,75 @@ def test_extract_js_factory_object_assigned_methods(tmp_path):
assert (render_video_nid, "calls", source_clips_nid) in edges
def test_extract_js_factory_object_contains_edge_not_duplicated(tmp_path):
"""The factory-to-object `contains` edge is emitted once regardless of how
many methods hang off the object. add_edge does not dedup, so a per-method
emission would flood the graph with N identical `contains` edges."""
from graphify.extract import extract_js
f = tmp_path / "many.js"
f.write_text(
"function build() {\n"
" const api = {};\n"
" api.a = () => 1;\n"
" api.b = () => 2;\n"
" api.c = () => 3;\n"
" api.d = () => 4;\n"
" return api;\n"
"}\n"
)
result = extract_js(f)
api_nid = next(n["id"] for n in result["nodes"] if n["label"] == "api")
contains = [
e for e in result["edges"]
if e["relation"] == "contains" and e["target"] == api_nid
]
assert len(contains) == 1, f"expected one contains edge, got {len(contains)}"
methods = [e for e in result["edges"]
if e["relation"] == "method" and e["source"] == api_nid]
assert len(methods) == 4
def test_extract_js_factory_object_arrow_assigned_methods(tmp_path):
"""Arrow functions assigned to a factory object are captured just like
function expressions (the dominant modern factory shape)."""
from graphify.extract import extract_js
f = tmp_path / "arrow_factory.js"
f.write_text(
"function makeStore() {\n"
" const store = {};\n"
" store.get = (k) => k;\n"
" store.set = (k, v) => store.get(k);\n"
" return store;\n"
"}\n"
)
result = extract_js(f)
by_label = {n["label"]: n for n in result["nodes"]}
assert {"makeStore()", "store", ".get()", ".set()"} <= set(by_label)
store_nid = by_label["store"]["id"]
edges = {(e["source"], e["relation"], e["target"]) for e in result["edges"]}
assert (store_nid, "method", by_label[".get()"]["id"]) in edges
assert (store_nid, "method", by_label[".set()"]["id"]) in edges
assert (by_label[".set()"]["id"], "calls", by_label[".get()"]["id"]) in edges
def test_extract_js_bare_object_member_assignment_not_captured(tmp_path):
"""An `obj.x = fn` where `obj` is NOT a local object-literal binding must be
skipped — capturing arbitrary receivers reintroduces the #1077 phantom-owner
flood the scope check exists to prevent."""
from graphify.extract import extract_js
f = tmp_path / "bare.js"
f.write_text(
"function wire(external) {\n"
" external.handler = () => 1;\n"
" return external;\n"
"}\n"
)
result = extract_js(f)
labels = {n["label"] for n in result["nodes"]}
assert "external" not in labels
assert ".handler()" not in labels
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