fix: resolve Python ClassName.method() qualified calls to class-method nodes (#1446)

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) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-06-24 09:09:48 +01:00
co-authored by Claude Opus 4.8
parent 2c7cbb6530
commit c390456c6c
3 changed files with 235 additions and 2 deletions
+1
View File
@@ -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)
+121 -2
View File
@@ -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")
+113
View File
@@ -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