From 51fc00a30ff5161e392037ff634fe394d315d14b Mon Sep 17 00:00:00 2001 From: safishamsi Date: Mon, 29 Jun 2026 14:49:42 +0100 Subject: [PATCH] fix(swift): type-qualified static calls resolve as EXTRACTED, not INFERRED (#1533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A type-qualified Swift call (`Type.staticMethod()`, `Singleton.shared.method()`) names the receiver type explicitly in source, so the resolved edge is an exact reference — now emitted as EXTRACTED (1.0), matching the Python qualified-class-method pass (_resolve_python_member_calls). Instance calls whose receiver type comes from local inference (`obj.method()`) stay INFERRED (0.8). Resolution and the single-definition god-node guard are unchanged. This addresses the actionable part of #1533's "static calls" report: the edge was always produced (graphify models calls as method->method), it was just under-confident. Updated the confidence test to assert the instance/type-qualified split. Co-Authored-By: Claude Opus 4.8 (1M context) --- graphify/extract.py | 17 +++++++++---- tests/test_swift_cross_file_calls.py | 36 ++++++++++++++++++---------- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index d1b655a0..7d45a632 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -9492,9 +9492,10 @@ def _resolve_swift_member_calls( (#543/#1219). Swift extractors record the receiver of each member call and a per-file ``name -> type`` table (``swift_type_table``); this pass uses them to type the receiver, then emits an edge ONLY when that type name resolves to - exactly one definition. Everything it adds is INFERRED (type inference, not an - explicit import), and the line-12503 drop stays intact: this is purely - additive and fires only on receiver-typed Swift calls. + exactly one definition. A type-qualified call (``Type.staticMethod()``) is + EXTRACTED (the type is named explicitly in source); an instance call typed via + local inference (``obj.method()``) is INFERRED. The shared-pass member-call drop + stays intact: this is purely additive and fires only on receiver-typed Swift calls. Must run after id-disambiguation so node ids and caller_nids are final. """ @@ -9551,8 +9552,10 @@ def _resolve_swift_member_calls( # declaring file's local type table. if receiver[:1].isupper(): type_name = receiver + type_qualified = True else: type_name = type_table_by_file.get(rc.get("source_file", ""), {}).get(receiver) + type_qualified = False if not type_name: continue type_defs = type_def_nids.get(_key(type_name), []) @@ -9568,13 +9571,17 @@ def _resolve_swift_member_calls( if target == caller or (caller, target) in existing_pairs: continue existing_pairs.add((caller, target)) + # A type-qualified call (`Type.staticMethod()`) names the receiver type + # explicitly in source, so it is an exact reference — EXTRACTED, matching + # the Python qualified-class-method pass (#1533). An instance call whose + # receiver type came from local inference (`obj.method()`) stays INFERRED. all_edges.append({ "source": caller, "target": target, "relation": relation, "context": "call", - "confidence": "INFERRED", - "confidence_score": 0.8, + "confidence": "EXTRACTED" if type_qualified else "INFERRED", + "confidence_score": 1.0 if type_qualified else 0.8, "source_file": rc.get("source_file", ""), "source_location": rc.get("source_location"), "weight": 1.0, diff --git a/tests/test_swift_cross_file_calls.py b/tests/test_swift_cross_file_calls.py index 464fc5ce..55258199 100644 --- a/tests/test_swift_cross_file_calls.py +++ b/tests/test_swift_cross_file_calls.py @@ -70,32 +70,42 @@ def test_swift_cross_file_member_calls_resolve(tmp_path: Path): assert (".go()", "calls", ".method()") in edges # Singleton.shared.method() -def test_swift_cross_file_member_calls_are_inferred_and_resolve_to_real_nodes(tmp_path: Path): - # The new edges must be INFERRED (type inference, not an explicit import) and - # land on real definition nodes so build_from_json keeps them. +def test_swift_cross_file_member_calls_have_correct_confidence_and_resolve(tmp_path: Path): + # Instance calls typed via local inference (vm.update(), self.svc.fetch()) are + # INFERRED; type-qualified static calls (SessionType.staticMethod(), + # Singleton.shared.method()) name the receiver type explicitly in source, so + # they are EXTRACTED, matching the Python qualified-class-method pass (#1533). + # All must land on real definition nodes so build_from_json keeps them. files = _issue_fixture(tmp_path / "src") result = extract(files, cache_root=tmp_path / "cache") node_ids = {n["id"] for n in result["nodes"]} src_by_id = {n["id"]: n.get("source_file") for n in result["nodes"]} - member_targets = {".update()", ".fetch()", ".staticMethod()", ".method()"} - seen_targets: set[str] = set() + inferred_targets = {".update()", ".fetch()"} + extracted_targets = {".staticMethod()", ".method()"} + seen_inferred: set[str] = set() + seen_extracted: set[str] = set() for e in result["edges"]: tgt_label = _label(result, e["target"]) - if e.get("relation") == "calls" and tgt_label in member_targets: - assert e["confidence"] == "INFERRED" - assert e["confidence_score"] == 0.8 - assert e["target"] in node_ids - assert src_by_id.get(e["target"]) # resolved to a real, source-backed def - seen_targets.add(tgt_label) - assert seen_targets == member_targets + if e.get("relation") != "calls": + continue + if tgt_label in inferred_targets: + assert e["confidence"] == "INFERRED" and e["confidence_score"] == 0.8 + assert e["target"] in node_ids and src_by_id.get(e["target"]) + seen_inferred.add(tgt_label) + elif tgt_label in extracted_targets: + assert e["confidence"] == "EXTRACTED" and e["confidence_score"] == 1.0 + assert e["target"] in node_ids and src_by_id.get(e["target"]) + seen_extracted.add(tgt_label) + assert seen_inferred == inferred_targets + assert seen_extracted == extracted_targets # Edges survive graph construction (no dangling targets pruned). g = build_from_json(result) surviving = sum( 1 for _, _, d in g.edges(data=True) - if d.get("confidence") == "INFERRED" and d.get("relation") == "calls" + if d.get("relation") == "calls" and d.get("confidence") in ("INFERRED", "EXTRACTED") ) assert surviving >= 5