Repository navigation
Swift: a cross-file extension Foo drops every static/singleton call edge into Foo (defeats #1533) #2538
Description
Swift: a cross-file extension Foo drops every static/singleton call edge into Foo (defeats #1533)
Summary
On a Swift corpus extracted with absolute input paths, adding one extension Foo {} in a separate file removes every calls edge into Foo and its methods. Foo also fragments into several nodes, so graphify affected Foo reports No unique node match and explain Foo shows a fraction of its real neighbourhood.
This silently defeats the static/singleton resolution added in #1533. Cross-file extensions are idiomatic in essentially every real Swift codebase, so the Swift call graph is largely empty in practice while small single-file test corpora look correct.
graphifyy 0.9.35, Python 3.14, macOS.
Reproduction
Three files:
Foo.swift
struct Foo {
static let shared = Foo()
func bar() { }
}Caller.swift
final class Caller {
func go() {
Foo.shared.bar()
}
}FooExt.swift an empty extension is enough
extension Foo {
func baz() { }
}graphify extract <dir> --code-only --no-cluster --force --out <out>Count calls edges whose target is foo_foo_bar:
| corpus | calls edges into .bar() |
|---|---|
Foo.swift + Caller.swift |
1 caller_caller_go -> foo_foo_bar |
the same + FooExt.swift |
0 |
the same + a file containing let x: Foo |
0 |
Root cause
swift_extensions[].nid is recorded during per-file AST extraction, before the id_remap / sym_remap passes rewrite absolute-path ids to repo-relative ones (extract.py ~L5122-5211). Those passes rewrite node ids, edge endpoints and raw_calls[].caller_nid, but not swift_extensions[].nid.
Note cache.py:656 already lists raw_calls[].caller_nid and swift_extensions[].nid side by side as the id-carrying payload fields the cache path must remap the in-process path covers only the first.
Consequently the merge's behaviour depends on the form of the paths handed to extract():
- relative input (
extract([Path("Foo.swift"), ...]))_file_stemalready yields the post-remap slug, ids match, the merge works. - absolute input the recorded nids are stale, every lookup in
_merge_swift_extensions(L2015) misses, and the merge no-ops.
(The bug is easy to miss in tests for this reason: extract(paths) without root= never triggers the remap. test_swift_extension_across_files_merges_into_canonical_type in test_languages.py passes because of this, and because it asserts only on method edges with two files it never observes the call-edge loss that a third, calling file exposes.)
With the merge skipped, Foo keeps two definition nodes, so the single-definition guard in _resolve_swift_member_calls bails:
# extract.py ~L2338
type_defs = type_def_nids.get(_key(type_name), [])
if len(type_defs) != 1: # ambiguous -> bail
continue_rewire_unique_stub_nodes (L1947) needs the same uniqueness and also bails, leaving the per-file stubs orphaned. Receiver extraction itself is fine _swift_receiver_name (engine.py:878-900) correctly yields Foo for Foo.shared.bar(). The resolver is just starved of a unique target.
A second, independent defect blocks the merge even once the nid is correct: _merge_swift_extensions builds label_to_canonical from all non-extension nodes with the label (L2040-2047) and bails when len != 1. A bare annotation like let x: Foo mints a sourceless stub labelled Foo (engine.py:2489-2511, source_file=""), so one annotation anywhere makes the label look ambiguous. Every sibling pass pairs _is_type_like_definition with an id in contained check (L2322, 2568, 2674, 2804, 3093) for exactly this reason; this one doesn't.
Two hazards a naive fix exposes
Worth flagging, because simply remapping the nid activates a merge pass that has been mostly dormant, and its matching rule is unsafe as written. Both measured:
1. No language gate. label_to_canonical is filtered only by _is_type_like_definition, which accepts any file_type == "code". Label equality is the only other condition. With the merge running:
web/src/Store.ts export class Store { save() { return 1; } }
ios/StoreExt.swift extension Store { func reset() { } }
ios/VM.swift final class VM { let store: Store = Store(); func f() { store.save() } }
produces
calls ios_vm_vm_f -> web_src_store_store_save [INFERRED]
method web_src_store_store -> ios_storeext_store_reset
contains ios_storeext -> web_src_store_store
A Swift call resolves into a TypeScript method; the TS class owns a Swift method. Swift extends Data, Date, Result, Store, Router constantly, and those are common class names in TS/Python/Kotlin any ios/ + web/ monorepo hits this immediately. Reproduced with Python (extension Data onto class Data) and with a JS re-export symbol node.
Note the gate must permit Swift↔Objective-C folding, which is intended and covered by test_swift_extension_folds_onto_objc_class. _LANG_FAMILY_BY_EXT's "native" family already expresses exactly this.
2. The post-merge edge rebuild is global. Once remap is non-empty, _merge_swift_extensions rebuilds the entire all_edges list and de-duplicates on (source, target, relation, source_file, source_location) a key that ignores confidence, confidence_score, weight, context. Measured on graphify's own graphify/ (80 .py files) plus two trivial Swift files: 166 legitimate Python edges silently deleted. The corpus genuinely contains 113 duplicate-key groups (e.g. def g(a: Thing, b: Thing) -> Thing emits three same-key references edges), and a Swift extension anywhere now prunes them.
Fix
I have a patch and will open a PR against v8 referencing this issue. It:
- remaps
swift_extensions[].nidin both theid_remapandsym_remappasses, alongside the existingraw_calls[].caller_nidrewrite (exact mapping, no heuristics); - restricts merge candidates to Swift's own
_LANG_FAMILY_BY_EXTfamily and skips_LANGUAGE_BUILTIN_GLOBALS, preserving Swift↔ObjC folding; - adds the
id in containedgate so sourceless stubs can't make a label ambiguous; - scopes the edge rebuild to edges the merge actually remapped.
Four tests in tests/test_swift_cross_file_calls.py: two fail without (1)+(3), two fail against a fix that omits (2)+(4). Full suite shows no regressions against baseline.
Residual (separate, likely by design)
_swift_receiver_name is depth-1 (engine.py:900), so Foo.shared.baz.bar() yields no edge. Rare in practice; noting it only so it isn't conflated with the above.
Activity
fix(swift): cross-file extension no longer drops static/singleton cal…
safishamsi commentedon Aug 7, 2026
Fixed in v0.9.36. A cross-file
extension Foonow merges onto its base type before call resolution (the extension node id is remapped consistently in the in-process passes, not just the cache path), so staticFoo.bar()and singletonFoo.shared.bar()calls resolve again. The merge is gated so it never absorbs a same-named type from another language. Thanks for the fix.Merge upstream/v8 (v0.9.37) into chore/sync-upstream-0937
fix(swift): cross-file extension no longer drops static/singleton cal…
merge: backport upstream Swift extras (Graphify-Labs#2181 Graphify-La…
fix(extract): keep recursive calls when merging C# partial classes
fix(extract): keep recursive calls when merging C# partial classes