From c390456c6cab3aaa32a34c470153d299260baef7 Mon Sep 17 00:00:00 2001 From: safishamsi Date: Wed, 24 Jun 2026 09:09:48 +0100 Subject: [PATCH] fix: resolve Python ClassName.method() qualified calls to class-method nodes (#1446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-class qualified static calls like `CustomerTaskActions.approve(...)` did not produce an EXTRACTED `calls` edge. Two compounding causes: 1. The shared cross-file pass skips all member calls (the #543/#1219 god-node guard against bare `obj.method()` name collisions), and there was no Python receiver-based resolver to recover the qualified ones. 2. When the called method shared its name with an in-file node — e.g. a viewset action `approve()` delegating to a service `Service.approve()` — the in-file bare-name lookup matched the caller's own node (tgt == caller), so the call was silently dropped before any raw_call was recorded. Fix: capture a simple-identifier receiver in the call walk (new `call_accessor_object_field`, set to `object` for Python), defer capitalized- receiver member calls to a new `_resolve_python_member_calls` pass (mirroring the Swift resolver), and emit an EXTRACTED edge only when the receiver resolves to exactly one class that owns the method (single-definition god-node guard). Instance/module calls (`self.x()`, `obj.x()`, lowercase receivers) are unaffected. Tests: cross-class resolution, the same-method-name collision shape from the issue, instance-call non-over-connection, and the ambiguous-class guard. Full suite 2337 passed; skillgen --check clean; ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + graphify/extract.py | 123 +++++++++++++++++++++++++++++++++++++++++- tests/test_extract.py | 113 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 235 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bff4de5..7c087c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## Unreleased +- Fix: Python qualified class-method calls (`ClassName.method(...)`) now produce an EXTRACTED `calls` edge to the class-qualified method node (#1446). Previously these cross-class static/qualified calls were dropped: the shared cross-file pass skips all member calls (the #543/#1219 god-node guard against bare `obj.method()` collisions), and when the called method shared its name with an in-file node — e.g. a viewset action `approve()` delegating to a service `Service.approve()` — the bare-name lookup matched the caller's own node and silently dropped it. The Python extractor now captures a simple-identifier receiver, defers capitalized-receiver member calls to a new receiver-based resolver (`_resolve_python_member_calls`, mirroring the Swift pass), and emits the edge only when the receiver resolves to exactly one class that owns the method (single-definition god-node guard); instance/module calls (`self.x()`, `obj.x()`, lowercase receivers) are unaffected. - Feat: new first-class `agents` platform installs the skill to the generic cross-framework Agent-Skills locations. `graphify install --platform agents` (alias `--platform skills`) writes the spec's user-global `~/.agents/skills/graphify/SKILL.md` — the directory `npx skills` and spec-compliant frameworks read — and `--project` writes `./.agents/skills/graphify/SKILL.md`; `graphify uninstall` removes them. Previously that user-global location was only reachable as an accidental side effect of the gemini-on-Windows branch. The skill bundle re-homes amp's agents-md body (registered in `tools/skillgen/platforms.toml`, rendered through the skillgen drift/coverage guards); the body is identical to amp's, and only the on-demand hooks reference differs — it points at `graphify agents install`, which (as the amp-twin subcommand) wires the skill plus an AGENTS.md always-on section. Bare `graphify install` is unchanged — still single-platform (claude/windows) (#1432, closes #1405). ## 0.8.46 (2026-06-23) diff --git a/graphify/extract.py b/graphify/extract.py index 6f5c200..968c0c5 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -483,6 +483,7 @@ class LanguageConfig: call_function_field: str = "function" # field on call node for callee call_accessor_node_types: frozenset = frozenset() # member/attribute nodes call_accessor_field: str = "attribute" # field on accessor for method name + call_accessor_object_field: str = "" # field on accessor for the receiver/object # Stop recursion at these types in walk_calls function_boundary_types: frozenset = frozenset() @@ -2032,6 +2033,7 @@ _PYTHON_CONFIG = LanguageConfig( call_function_field="function", call_accessor_node_types=frozenset({"attribute"}), call_accessor_field="attribute", + call_accessor_object_field="object", function_boundary_types=frozenset({"function_definition"}), import_handler=_import_python, ) @@ -3501,6 +3503,7 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict: callee_name: str | None = None is_member_call: bool = False swift_receiver: str | None = None + member_receiver: str | None = None # Special handling per language if config.ts_module == "tree_sitter_swift": @@ -3617,12 +3620,29 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict: attr = func_node.child_by_field_name(config.call_accessor_field) if attr: callee_name = _read_text(attr, source) + if config.call_accessor_object_field: + # Capture a simple-identifier receiver (e.g. `ClassName` + # in `ClassName.method()`) so cross-file member-call + # resolution can resolve qualified class-method calls + # (#1446). Chained receivers (`a.b.method()`) are skipped. + obj = func_node.child_by_field_name(config.call_accessor_object_field) + if obj is not None and obj.type == "identifier": + member_receiver = _read_text(obj, source) else: # Try reading the node directly (e.g. Java name field is the callee) callee_name = _read_text(func_node, source) if callee_name and callee_name not in _LANGUAGE_BUILTIN_GLOBALS: - tgt_nid = label_to_nid.get(callee_name) + # A capitalized-receiver member call (`ClassName.method()`) must defer + # to receiver-based cross-file resolution: the bare method name can + # collide with an in-file node — even the calling method itself, when a + # viewset action delegates to a same-named service action — which would + # match `tgt_nid == caller_nid` and silently drop the call (#1446). The + # captured receiver is resolved later in _resolve_python_member_calls. + if is_member_call and member_receiver and member_receiver[:1].isupper(): + tgt_nid = None + else: + tgt_nid = label_to_nid.get(callee_name) if tgt_nid and tgt_nid != caller_nid: pair = (caller_nid, tgt_nid) if pair not in seen_call_pairs: @@ -3646,7 +3666,7 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict: "is_member_call": is_member_call, "source_file": str_path, "source_location": f"L{node.start_point[0] + 1}", - "receiver": swift_receiver, + "receiver": swift_receiver or member_receiver, }) # Helper function calls: config('foo.bar') → uses_config edge to "foo" @@ -9325,6 +9345,95 @@ def _resolve_swift_member_calls( }) +def _resolve_python_member_calls( + per_file: list[dict], + all_nodes: list[dict], + all_edges: list[dict], +) -> None: + """Resolve cross-file Python qualified class-method calls (``ClassName.method()``) + to the class-qualified method node (#1446). + + The shared cross-file call pass drops every ``is_member_call`` because a bare + method name (``log``) collides across the corpus and inflates god-nodes + (#543/#1219). That guard is right for *instance* calls (``obj.method()``) but + misses *class-qualified* calls (``ClassName.method()``), where the receiver is + an explicitly-named class — an exact, unambiguous reference. This pass uses the + receiver captured by the extractor, and when it is a capitalized name resolving + to exactly one class node that owns the called method, emits an EXTRACTED + ``calls`` edge. Purely additive (only member calls the shared pass skipped), + with a single-definition god-node guard. + + Must run after id-disambiguation so node ids and caller_nids are final. + """ + def _key(label: str) -> str: + return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower() + + node_by_id: dict[str, dict] = {n.get("id"): n for n in all_nodes} + + # A class owns methods: it is the source of one or more `method` edges. Index + # class label -> owning class node ids (len != 1 is the god-node guard), and + # (class_node_id, method_key) -> method_node_id. + class_def_nids: dict[str, list[str]] = {} + method_index: dict[tuple[str, str], str] = {} + for e in all_edges: + if e.get("relation") != "method": + continue + src, tgt = e.get("source"), e.get("target") + cnode = node_by_id.get(src) + if cnode is not None: + class_def_nids.setdefault(_key(cnode.get("label", "")), []).append(src) + tnode = node_by_id.get(tgt) + if tnode is not None: + method_index[(src, _key(tnode.get("label", "")))] = tgt + if not class_def_nids: + return + # A class with N methods produced N entries; collapse to a unique set. + for k in list(class_def_nids): + class_def_nids[k] = sorted(set(class_def_nids[k])) + + all_raw_calls: list[dict] = [] + for result in per_file: + all_raw_calls.extend(result.get("raw_calls", [])) + + existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges} + for rc in all_raw_calls: + if not rc.get("is_member_call"): + continue + receiver = rc.get("receiver") + callee = rc.get("callee") + caller = rc.get("caller_nid") + if not receiver or not callee or not caller: + continue + # Only a capitalized receiver is treated as a class reference, so an + # instance/module (`self`, `obj`, `config`) never collides with a + # same-spelled class via the case-folding key. + if not receiver[:1].isupper(): + continue + class_nids = class_def_nids.get(_key(receiver), []) + if len(class_nids) != 1: # absent or ambiguous -> bail (god-node guard) + continue + method_nid = method_index.get((class_nids[0], _key(callee))) + if not method_nid or method_nid == caller: + continue + if (caller, method_nid) in existing_pairs: + continue + existing_pairs.add((caller, method_nid)) + # EXTRACTED: a qualified `ClassName.method()` is an explicit, unambiguous + # static reference (unlike a bare instance member call), and the class + # resolved to exactly one definition that owns the method. + all_edges.append({ + "source": caller, + "target": method_nid, + "relation": "calls", + "context": "call", + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "source_file": rc.get("source_file", ""), + "source_location": rc.get("source_location"), + "weight": 1.0, + }) + + def extract_objc(path: Path) -> dict: """Extract interfaces, implementations, protocols, methods, and imports from .m/.mm/.h files.""" try: @@ -12979,6 +13088,16 @@ def extract( import logging logging.getLogger(__name__).warning("Swift member-call resolution failed, skipping: %s", exc) + # Cross-file Python qualified class-method resolution (#1446). Same shape as the + # Swift pass: additive, runs after id-disambiguation, single-definition guard. + py_paths = [p for p in paths if p.suffix == ".py"] + if py_paths: + try: + _resolve_python_member_calls(per_file, all_nodes, all_edges) + except Exception as exc: + import logging + logging.getLogger(__name__).warning("Python member-call resolution failed, skipping: %s", exc) + # Relativize source_file fields so paths are portable across machines (#555) for item in all_nodes + all_edges: sf = item.get("source_file") diff --git a/tests/test_extract.py b/tests/test_extract.py index 062e05e..90e597b 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -691,6 +691,119 @@ def test_cross_file_call_remains_inferred_without_import_evidence(tmp_path): assert call_edges[0]["confidence"] == "INFERRED" +def test_python_qualified_class_method_call_resolves_extracted(tmp_path): + """`ClassName.method()` across files resolves to the class-qualified method + node with an EXTRACTED `calls` edge (#1446).""" + actions = tmp_path / "actions.py" + viewset = tmp_path / "viewset.py" + actions.write_text( + "class TaskActions:\n" + " @staticmethod\n" + " def approve(pk):\n" + " return pk\n" + ) + viewset.write_text( + "from actions import TaskActions\n\n" + "class TaskViewSet:\n" + " def handle(self, request):\n" + " return TaskActions.approve(request)\n" + ) + result = extract([viewset, actions], cache_root=tmp_path) + nodes = {n["id"]: n for n in result["nodes"]} + call_edges = [ + e for e in result["edges"] + if e["relation"] == "calls" + and "handle" in nodes[e["source"]]["label"] + and "approve" in nodes[e["target"]]["label"] + and "actions.py" in (nodes[e["target"]].get("source_file") or "") + ] + assert len(call_edges) == 1, f"expected one handle->approve edge, got {call_edges}" + assert call_edges[0]["confidence"] == "EXTRACTED" + + +def test_python_qualified_call_resolves_when_method_name_collides_with_caller(tmp_path): + """The real #1446 shape: a viewset action `approve()` delegates to a SERVICE + action of the SAME name via `Service.approve()`. The bare-name in-file lookup + would match the caller's own node (tgt == caller) and silently drop the call; + the qualified receiver must still resolve it cross-file to the service method.""" + actions = tmp_path / "actions.py" + viewset = tmp_path / "viewset.py" + actions.write_text( + "class TaskActions:\n" + " @staticmethod\n" + " def approve(pk):\n" + " return pk\n" + ) + viewset.write_text( + "from actions import TaskActions\n\n" + "class TaskViewSet:\n" + " def approve(self, request):\n" # same name as the callee + " return TaskActions.approve(request)\n" + ) + result = extract([viewset, actions], cache_root=tmp_path) + nodes = {n["id"]: n for n in result["nodes"]} + cross = [ + e for e in result["edges"] + if e["relation"] == "calls" + and "viewset.py" in (nodes[e["source"]].get("source_file") or "") + and "actions.py" in (nodes[e["target"]].get("source_file") or "") + and "approve" in nodes[e["target"]]["label"] + ] + assert len(cross) == 1, f"expected viewset->service approve edge, got {cross}" + assert cross[0]["confidence"] == "EXTRACTED" + + +def test_python_instance_member_call_not_overconnected(tmp_path): + """A lowercase-receiver member call (`obj.run()`, `self.run()`) must NOT be + resolved cross-file — the #543/#1219 god-node guard stays intact (#1446).""" + svc = tmp_path / "svc.py" + worker = tmp_path / "worker.py" + svc.write_text( + "class Service:\n" + " def run(self):\n" + " return 1\n" + ) + worker.write_text( + "class Worker:\n" + " def go(self, obj):\n" + " return obj.run()\n" + ) + result = extract([worker, svc], cache_root=tmp_path) + nodes = {n["id"]: n for n in result["nodes"]} + bad = [ + e for e in result["edges"] + if e["relation"] == "calls" + and "go" in nodes[e["source"]]["label"] + and "run" in nodes[e["target"]]["label"] + ] + assert bad == [], f"instance member call must not connect cross-file: {bad}" + + +def test_python_qualified_call_ambiguous_class_bails(tmp_path): + """When the class name is defined in 2+ files, the qualified call must not + resolve — single-definition god-node guard (#1446).""" + a = tmp_path / "a.py" + b = tmp_path / "b.py" + caller = tmp_path / "caller.py" + a.write_text("class Helper:\n def do(self):\n return 1\n") + b.write_text("class Helper:\n def do(self):\n return 2\n") + caller.write_text( + "from a import Helper\n\n" + "class C:\n" + " def f(self):\n" + " return Helper.do(self)\n" + ) + result = extract([caller, a, b], cache_root=tmp_path) + nodes = {n["id"]: n for n in result["nodes"]} + resolved = [ + e for e in result["edges"] + if e["relation"] == "calls" + and "f" == nodes[e["source"]]["label"].strip("().") + and "do" in nodes[e["target"]]["label"] + ] + assert resolved == [], f"ambiguous class name must not resolve: {resolved}" + + # ── TSX (JSX-aware) parsing ────────────────────────────────────────────────── # .tsx files require tree-sitter-typescript's `language_tsx`, not the plain # `language_typescript` grammar. Parsing JSX with the wrong grammar produces