fix(swift): resolve calls on a singleton cached into a local var (#1604)

`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) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-07-02 11:46:22 +01:00
co-authored by Claude Opus 4.8
parent b70a6d7126
commit 44c0a5e33c
3 changed files with 84 additions and 0 deletions
+1
View File
@@ -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):
+58
View File
@@ -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)
+25
View File
@@ -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)