diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 3d8e4b78..77c4672b 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -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] diff --git a/tests/test_extract.py b/tests/test_extract.py index 6b1ed117..e8d32ab1 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -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