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:
co-authored by
Claude Opus 4.8
parent
2c7cbb6530
commit
c390456c6c
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user