Repository navigation
Description
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:
- 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 separateAccountnodes. References from other files land on sourceless stubs (store_swift_account), not on the declaration. - Qualified nested type paths (
Catalog.Item,Catalog.Item.ID) written in another file. Only the first segment is kept, so both references becomeViewer -> Catalog. No edge reachesItemorID. - Constructor calls
Widget(...)from another file. If the calling file also namesWidgetin a type position, the call produces nocallsedge. The same call in a file that does not name the type in a type position does get one.
Expected:
- One
Accountnode (the declaration). The extension inAccountDefaults.swiftmerges into it, andStoreand.save()reference it. ViewerreferencesItemandID, the nested declarations, notCatalog. A dotted path must not fall back to a name made by joining its segments either:Catalog.Item.IDis notItemID, which is a separate struct in the repro. v8 gets that part right today, and a fix should keep it.Factory.build()callsWidget, the same wayBuilder.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}, sostruct Accountandextension Accountin one file get the same id. The extractor records the declaration's id as an extension id because the node has anextensionchild._merge_swift_extensionsskips 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 sourcedAccountnode._rewire_unique_stub_nodessees two type-likeAccountdefinitions, treats the label as ambiguous and keeps every stub. That fail-closed guard is right, but its input is wrong. The<file>_swift_accountids come from the later collision disambiguation, so they are a symptom, not the cause. - Case 2.
_swift_collect_type_refs(and_swift_user_type_namefor inheritance clauses) keeps only the firsttype_identifierof auser_type.Catalog.Item.IDtherefore becomesCatalog, 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.ItembecomesCatalog.Item), so such extensions never match the declaration either. - Case 3. When the calling file also names
Widgetin a type position,Widget(size: 2)is recorded as a raw call withvia_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.swiftnever namesWidgetas 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:
- [Bug]: Swift: an extension of a type outside the corpus (
private extension String) captures every reference to that type #4574: an extension of a type outside the corpus captures every reference to it. That is a separate cause, but the fix for case 1 has to keep it working. - [Bug]: Swift: same-named nested types in one file collapse into one node (e.g. two
CodingKeysenums) #4575: same-named nested types in one file collapse into one node. Case 2 is the cross-file, qualified-path side of the nested-type problem. - fix(swift): stop an extension of an outside type absorbing every same-named reference #4600: as far as I can tell, it marks the declaration id itself as an extension whenever the declaration has a same-file extension. In the case 1 layout that would hide the only real declaration from the rewire.
- fix(swift): keep same-named nested types under different outers apart #4599: parent-qualified ids for nested types. That fixes identity, but
Catalog.Itemwritten in another file still resolves toCatalog. - fix(swift): extract file-scope typealias declarations as type nodes #4639: file-scope
typealiasnodes. This is complementary, and references to aliases would go through the same resolution.
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
github-actions commentedon Oct 25, 2026
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.