Skip to content

Swift: a cross-file extension Foo drops every static/singleton call edge into Foo (defeats #1533) #2538

Closed

Description

@pawelo446

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_stem already 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:

  1. remaps swift_extensions[].nid in both the id_remap and sym_remap passes, alongside the existing raw_calls[].caller_nid rewrite (exact mapping, no heuristics);
  2. restricts merge candidates to Swift's own _LANG_FAMILY_BY_EXT family and skips _LANGUAGE_BUILTIN_GLOBALS, preserving Swift↔ObjC folding;
  3. adds the id in contained gate so sourceless stubs can't make a label ambiguous;
  4. 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

  1. safishamsi commented on Aug 7, 2026

    @safishamsi
    Member

    Fixed in v0.9.36. A cross-file extension Foo now 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 static Foo.bar() and singleton Foo.shared.bar() calls resolve again. The merge is gated so it never absorbs a same-named type from another language. Thanks for the fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions