From 44c0a5e33c7011813dcebf1a8850c1c6005bf500 Mon Sep 17 00:00:00 2001 From: safishamsi Date: Thu, 2 Jul 2026 11:46:22 +0100 Subject: [PATCH] fix(swift): resolve calls on a singleton cached into a local var (#1604) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `let manager = NetworkManager.shared` followed by `manager.fetchData()` on a later line produced zero call edges. Two gaps: (1) local let/var bindings inside method bodies were never typed (only class-level properties and function params populated the per-file type table), and (2) a static-member initializer (`Type.shared`, a navigation_expression) wasn't recognized as typing the local even in the class-property path — only constructor calls (`Type()`) were. Add _swift_local_var_types (mirrors _cpp_local_var_types): walk each function body and type a local from a constructor OR a `Type.staticProp` access whose head is upper-cased. `x.method()` then resolves to the receiver type through the existing single-definition god-node guard. The class-property path also learns the Type.shared shape. Reported on a 23k-file iOS corpus where this idiom is the median call pattern. Verified: the repro resolves (loadIfNeeded -> fetchData/isLoading); constructor locals still resolve; a lowercase-head init (`other.child`) does NOT falsely type; and an ambiguous method name resolves to the receiver-typed class only. Swift + full suite 2789 passed. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + graphify/extract.py | 58 ++++++++++++++++++++++++++++ tests/test_swift_cross_file_calls.py | 25 ++++++++++++ 3 files changed, 84 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2196548..1255f61 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: Swift singleton cached into a local var now resolves later calls (#1604, thanks @jerryliurui). `let x = NetworkManager.shared` followed by `x.fetchData()` on a subsequent line produced zero call edges — local `let`/`var` bindings inside method bodies weren't typed (only class-level properties and params were), and a static-member init (`Type.shared`, a navigation expression) wasn't recognized even where locals were typed. Method-body locals are now typed from both constructor (`Type()`) and static-member (`Type.shared`) initializers, so `x.method()` resolves to the receiver type via the existing single-definition guard. This singleton-into-local idiom is one of the most common Swift call patterns. - Fix: the skill's Python-interpreter detection now accepts Homebrew `python@3.x` paths (#1586, thanks @SUDARSHANCHAUDHARI). The shebang allowlist rejected any path with a character outside `[a-zA-Z0-9/_.-]`, but Homebrew installs versioned Python under `python@3.13`, so a valid interpreter containing `@` was skipped and detection fell through to a bare `python3` that lacked graphify (every step then failed with `ModuleNotFoundError`). `@` is now allowed across all skill variants (matching the #473 hooks.py fix); injection characters are still rejected. - Fix: `graphify merge-graphs` no longer crashes on inputs that disagree on graph type (#1606, thanks @AdrianRusan). Per-repo `graph.json` files don't always share the same `directed` / `multigraph` flags, and `compose` requires one uniform type, so a mixed set raised an unhandled `NetworkXError`. All inputs are now normalized to a plain undirected graph (which the cross-repo merged view already is) before composing. - Fix: type-reference / inheritance edge gaps closed across seven languages (all thanks @Synvoya): diff --git a/graphify/extract.py b/graphify/extract.py index 4a99636..698e192 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -2221,6 +2221,45 @@ def _cpp_local_var_types(body_node, source: bytes, table: dict[str, str]) -> Non stack.append(c) +def _swift_local_var_types(body_node, source: bytes, table: dict[str, str]) -> None: + """Collect ``var -> Type`` from local ``let``/``var`` bindings in a Swift + function body, so a member call on the local (``x.method()``) resolves to Type + in the cross-file member-call pass (#1604). + + Two initializer shapes are recorded, PRECISION over recall: + - a constructor call ``let x = Type()`` (``_swift_constructor_type``); + - a static-member access ``let x = Type.shared`` (a navigation_expression + with an upper-cased head) — the singleton-cached-into-a-local idiom, one + of the most common Swift call patterns and previously resolved to nothing. + Nested function declarations are not descended into (their locals are scoped + away); the first binding for a name wins, so a class property of the same name + already in the table is not overwritten. + """ + stack = [body_node] + while stack: + n = stack.pop() + if n.type == "function_declaration" and n is not body_node: + continue + if n.type == "property_declaration": + prop_type: str | None = None + for child in n.children: + if child.type == "call_expression": + prop_type = _swift_constructor_type(child, source) + break + if child.type == "navigation_expression": + head = child.children[0] if child.children else None + if head is not None and head.type == "simple_identifier": + htext = _read_text(head, source) + if htext and htext[:1].isupper(): + prop_type = htext + break + name = _swift_property_name(n, source) + if name and prop_type and name not in table: + table[name] = prop_type + for c in n.children: + stack.append(c) + + def _objc_local_var_types(body_node, source: bytes, table: dict[str, str]) -> None: """Collect ``var -> ClassName`` from ObjC local declarations (``Foo *f = ...;``) in a method body, for receiver typing in the cross-file message-send pass @@ -3932,6 +3971,18 @@ def _extract_generic( ctor = _swift_constructor_type(child, source) if ctor is not None: prop_type = ctor + # #1604 Stage 2b: `let x = Type.shared` (or any `Type.staticProp`) + # binds x to Type via a static-member access, which is a + # navigation_expression, not a constructor call. Infer x's type from + # the uppercase head so later `x.method()` calls resolve to Type. This + # is the singleton idiom (`Type.shared`) cached into a local var and + # called on a subsequent line — extremely common in Swift. + elif child.type == "navigation_expression" and prop_type is None: + head = child.children[0] if child.children else None + if head is not None and head.type == "simple_identifier": + htext = _read_text(head, source) + if htext and htext[:1].isupper(): + prop_type = htext prop_name = _swift_property_name(node, source) if prop_name and prop_type: type_table[prop_name] = prop_type @@ -5040,6 +5091,13 @@ def _extract_generic( for _caller_nid, body_node in function_bodies: _cpp_local_var_types(body_node, source, type_table) + # Swift: type local `let x = Type()` / `let x = Type.shared` bindings inside + # method bodies so `x.method()` on a later line resolves — class-level + # properties are typed in the walk, but method-body locals were not (#1604). + if config.ts_module == "tree_sitter_swift": + for _caller_nid, body_node in function_bodies: + _swift_local_var_types(body_node, source, type_table) + for caller_nid, body_node in function_bodies: walk_calls(body_node, caller_nid) diff --git a/tests/test_swift_cross_file_calls.py b/tests/test_swift_cross_file_calls.py index 5525819..e021dc4 100644 --- a/tests/test_swift_cross_file_calls.py +++ b/tests/test_swift_cross_file_calls.py @@ -152,3 +152,28 @@ def test_swift_unknown_receiver_emits_no_edge(tmp_path: Path): edges = _edge_labels(result, relations=("calls",)) assert (".run()", "calls", ".help()") not in edges + + +def test_deferred_singleton_local_var_resolves(tmp_path): + """#1604: `let x = Type.shared` cached into a local var, then `x.method()` on a + later line, must resolve to Type's method. This static-member (navigation) init + was previously untyped, so the singleton-into-local idiom produced zero edges. + The constructor form `let x = Type()` is exercised alongside it.""" + base = tmp_path / "src" + _write(base / "NetworkManager.swift", + "class NetworkManager {\n static let shared = NetworkManager()\n" + " func fetchData() { }\n func isLoading() -> Bool { return false }\n}\n") + _write(base / "ViewController.swift", + "class ViewControllerA {\n func loadIfNeeded() {\n" + " let manager = NetworkManager.shared\n" + " if manager.isLoading() { return }\n" + " manager.fetchData()\n }\n" + " func makeFresh() {\n let m = NetworkManager()\n m.fetchData()\n }\n}\n") + result = extract(sorted(base.glob("*.swift")), cache_root=tmp_path / "cache", parallel=False) + calls = {(s, t) for s, r, t in _edge_labels(result, ("calls",))} + # deferred singleton local var -> both later member calls resolve (method + # labels carry a leading dot, e.g. ".loadIfNeeded()") + assert any("loadIfNeeded" in s and "fetchData" in t for s, t in calls) + assert any("loadIfNeeded" in s and "isLoading" in t for s, t in calls) + # constructor-into-local still resolves + assert any("makeFresh" in s and "fetchData" in t for s, t in calls)