Skip to content

[Bug]: Swift: types extended across files, qualified nested paths and constructor calls do not resolve #4754

Open

Description

@idachev

Pre-flight checks

  • I have checked the Troubleshooting section in the README

What happened?

In a Swift corpus, many type references never reach the declaration they name. This report covers three cases that #4574 and #4575 do not:

  1. A type declared next to its own extension, and also extended in another file. The declaration, the cross-file extension and one stub per referencing file all stay separate Account nodes. References from other files land on sourceless stubs (store_swift_account), not on the declaration.
  2. Qualified nested type paths (Catalog.Item, Catalog.Item.ID) written in another file. Only the first segment is kept, so both references become Viewer -> Catalog. No edge reaches Item or ID.
  3. Constructor calls Widget(...) from another file. If the calling file also names Widget in a type position, the call produces no calls edge. The same call in a file that does not name the type in a type position does get one.

Expected:

  1. One Account node (the declaration). The extension in AccountDefaults.swift merges into it, and Store and .save() reference it.
  2. Viewer references Item and ID, the nested declarations, not Catalog. A dotted path must not fall back to a name made by joining its segments either: Catalog.Item.ID is not ItemID, which is a separate struct in the repro. v8 gets that part right today, and a fix should keep it.
  3. Factory.build() calls Widget, the same way Builder.build() already does.

Impact on a mid-sized SwiftUI app (about 570 Swift files): over 1,300 references edges from Swift files point at a sourceless stub whose label is a type that the corpus declares. About 600 of them come from case 1 alone, spread over 8 types. One struct with 17 references from other files had none of them on its declaration. graphify affected <name> printed No unique node match, and graphify affected <declaration id> listed only the type's own method callers.

Steps to reproduce

# Case 1: declaration + same-file extension + extension in another file
mkdir ext-xfile && cd ext-xfile && git init -q
cat > Account.swift <<'EOF'
struct Account {
    let name: String
}

extension Account {
    var isEmpty: Bool { name.isEmpty }
}
EOF
cat > AccountDefaults.swift <<'EOF'
extension Account {
    static var guest: Account { Account(name: "guest") }
}
EOF
cat > Store.swift <<'EOF'
final class Store {
    var current: Account?
    func save(_ account: Account) {}
}
EOF
graphify update .
cd ..

# Case 2: qualified nested type paths from another file
mkdir nested-path && cd nested-path && git init -q
cat > Catalog.swift <<'EOF'
struct Catalog {
    struct Item {
        struct ID: Hashable { let raw: String }
        let id: ID
    }
}
EOF
cat > Viewer.swift <<'EOF'
struct Viewer {
    var item: Catalog.Item
    var selected: Catalog.Item.ID?
}
EOF
cat > ItemID.swift <<'EOF'
struct ItemID {
    let raw: String
}
EOF
graphify update .
cd ..

# Case 3: constructor calls from another file
mkdir ctor-xfile && cd ctor-xfile && git init -q
cat > Widget.swift <<'EOF'
struct Widget {
    let size: Int
}
EOF
cat > Factory.swift <<'EOF'
enum Factory {
    static func store(_ widget: Widget) {}
    static func build() {
        let widget = Widget(size: 2)
        store(widget)
    }
}
EOF
cat > Builder.swift <<'EOF'
enum Builder {
    static func build() -> Int {
        let widget = Widget(size: 3)
        return widget.size
    }
}
EOF
graphify update .
cd ..

# Dump the relevant nodes and edges (run inside each directory; set WATCH per case:
# "Account" / "Catalog Item ID ItemID" / "Widget")
WATCH="Account" python3 -c "
import json, os; g = json.load(open('graphify-out/graph.json')); L = g.get('links', g.get('edges'))
w = set(os.environ['WATCH'].split()); lab = {n['id']: n['label'] for n in g['nodes']}
print('nodes:', sorted((n['id'], n.get('source_file') or '') for n in g['nodes'] if n['label'] in w))
for e in sorted(L, key=lambda e: (e['source'], e['target'])):
    if e['relation'] != 'contains' and (lab.get(e['source']) in w or lab.get(e['target']) in w):
        print(' ', e['source'], e['relation'], e['target'], e.get('confidence', ''))
"

Error output or graph output

# Case 1
nodes: [('account_account', 'Account.swift'), ('accountdefaults_account', 'AccountDefaults.swift'), ('store_swift_account', '')]
  account_account method account_account_isempty EXTRACTED
  account_account references bool EXTRACTED
  account_account references string EXTRACTED
  accountdefaults_account method accountdefaults_account_guest EXTRACTED
  store_store references store_swift_account EXTRACTED
  store_store_save references store_swift_account EXTRACTED

# Case 2
nodes: [('catalog_catalog', 'Catalog.swift'), ('catalog_id', 'Catalog.swift'), ('catalog_item', 'Catalog.swift'), ('itemid_itemid', 'ItemID.swift')]
  catalog_id references catalog_swift_string EXTRACTED
  catalog_id implements hashable EXTRACTED
  itemid_itemid references itemid_swift_string EXTRACTED
  viewer_viewer references catalog_catalog EXTRACTED
  (no edge from viewer_viewer to catalog_item or catalog_id)

# Case 3
nodes: [('widget_widget', 'Widget.swift')]
  builder_builder_build calls widget_widget INFERRED
  factory_factory_store references widget_widget EXTRACTED
  widget_widget references widget_swift_int EXTRACTED
  (no edge from factory_factory_build to widget_widget)

Case 1 controls, same dump:

# same-file `extension Account` removed
nodes: [('account_account', 'Account.swift')]
  account_account method accountdefaults_account_guest EXTRACTED
  store_store references account_account EXTRACTED
  store_store_save references account_account EXTRACTED

# AccountDefaults.swift removed
nodes: [('account_account', 'Account.swift')]
  store_store references account_account EXTRACTED
  store_store_save references account_account EXTRACTED

With either control, every reference resolves to account_account, and the first control also merges the AccountDefaults.swift extension into it. Only the same-file extension and the cross-file extension together break it. (Controls trimmed to the Account edges from other files.)

Graphify version

0.9.95 (v8 at 92741d16)

Operating System

macOS 26.6.2 (Apple silicon)

Python Version

3.13.15

Installation Method

built from source (git clone)

Additional Environment Details

No provider API keys set. Each case was reproduced in a clean directory that holds only the files above. tree-sitter 0.25.2, tree-sitter-swift 0.7.2.

Additional context

What I found in the source (I hope the pointers help; I may have missed something):

  • Case 1. Swift node ids are {file_stem}_{name}, so struct Account and extension Account in one file get the same id. The extractor records the declaration's id as an extension id because the node has an extension child. _merge_swift_extensions skips every extension id when it collects canonical candidates, so the real declaration is never a candidate. The cross-file extension then finds no target and stays a second sourced Account node. _rewire_unique_stub_nodes sees two type-like Account definitions, treats the label as ambiguous and keeps every stub. That fail-closed guard is right, but its input is wrong. The <file>_swift_account ids come from the later collision disambiguation, so they are a symptom, not the cause.
  • Case 2. _swift_collect_type_refs (and _swift_user_type_name for inheritance clauses) keeps only the first type_identifier of a user_type. Catalog.Item.ID therefore becomes Catalog, and the same head-only name goes into the Swift type table. Nested types are labelled with their bare name, and an extension of a nested type is labelled with its dotted name (extension Catalog.Item becomes Catalog.Item), so such extensions never match the declaration either.
  • Case 3. When the calling file also names Widget in a type position, Widget(size: 2) is recorded as a raw call with via_stub. The shared call pass accepts such a call only with import evidence (or a C# namespace hit). A Swift file never imports a type from its own module, so the call is always dropped. Builder.swift never names Widget as a type, so its call takes the other path and resolves.

Go (package-wide merge plus import-path type resolution), Java/C#/PHP (namespace or using evidence before the generic rewire) and Rust (a declaration marker that tells struct from impl) each handle the equivalent of this. Swift relies only on the two label-based passes.

Related:

I have written an RFC for a Swift module-level declaration marker, a qualified type-reference pass and constructor-call binding with module-scope evidence. A PR that implements it follows.

This report and its reproduction were prepared with the help of an AI assistant and checked by running the steps above.

Activity

  1. github-actions commented on Oct 25, 2026

    @github-actions

    Thanks for opening this issue, @idachev. A maintainer will take a look soon.

    If you would like to discuss it in real time, come say hi on our Discord server. For longer-form questions and ideas there is also GitHub Discussions.

    To help us triage, please make sure the report includes what you expected, what actually happened, and the steps (and a small sample) to reproduce it.

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