Skip to content

[Bug]: Swift: an extension of a type outside the corpus (private extension String) captures every reference to that type #4574

Open

Description

@makinggainz

Pre-flight checks

  • I have checked the Troubleshooting section in the README

What happened?

A Swift extension of a type that is not defined in the corpus (here private extension String) becomes the node that every other String in the corpus resolves to. A struct field let name: String in another file gets a references edge to the extension node in Helpers.swift.

Expected: references to String should not land on an extension that happens to live in the repo. Leaving them unresolved, or pointing them at an external String node, would both be fine.

Impact on a mid-sized SwiftUI app: one private extension String collected about 940 edges. It became the top cross-community bridge in GRAPH_REPORT.md, and the first suggested question was about it.

Steps to reproduce

mkdir ext-repro && cd ext-repro && git init -q
cat > Helpers.swift <<'EOF'
private extension String {
    var trimmed: String { trimmingCharacters(in: .whitespaces) }
}
EOF
cat > Models.swift <<'EOF'
struct Person {
    let name: String
}
EOF
graphify update .
python3 -c "
import json; g = json.load(open('graphify-out/graph.json')); L = g.get('links', g.get('edges'))
lab = {n['id']: n['label'] for n in g['nodes']}
print([(n['id'], n.get('source_file')) for n in g['nodes'] if n['label'] == 'String'])
print(sorted((lab.get(e['source']), e['relation'], lab.get(e['target'])) for e in L if 'String' in (lab.get(e['source']), lab.get(e['target']))))
"

Error output or graph output

[('helpers_string', 'Helpers.swift')]
[('Helpers.swift', 'contains', 'String'), ('Person', 'references', 'String'), ('String', 'method', '.trimmed')]

The only String node is the extension in Helpers.swift, and Person (in Models.swift) references it.

Graphify version

0.9.92

Operating System

macOS 26.5.2 (Apple silicon)

Python Version

3.12.13

Installation Method

uv tool install graphifyy

Additional Environment Details

No provider API keys set. Reproduced in a clean directory with only the two files above. tree-sitter 0.25.2, tree-sitter-swift 0.7.4.

Additional context

The docstring of _merge_swift_extensions says extensions of types outside the corpus are left untouched, which is right. But the untouched extension node keeps the label String, so the later by-label type-reference resolution seems to treat it as the only String definition. I didn't trace the resolver itself. A possible fix: never resolve a reference to a node that only exists as an extension, or skip _LANGUAGE_BUILTIN_GLOBALS names in that resolution the way the extension merge already does.

Related: #2538 (cross-file extension Foo handling).

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 20, 2026

    @github-actions

    Thanks for opening this issue, @makinggainz. 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.

  2. makinggainz commented on Oct 20, 2026

    @makinggainz
    Author

    I tracked down the cause and have a fix working locally.

    Cause. _rewire_unique_stub_nodes builds real_by_label from every sourced, type-like node. _merge_swift_extensions rightly leaves an extension of an out-of-corpus type alone. But that node still has a source_file and the label String, so it becomes the one "real" String.

    A second bug with the same cause. A file that declares struct Money and also extends it uses one id for both. _merge_swift_extensions skips every id in extension_nids when it looks for canonical types, so that node can't be the canonical Money. As a result, an extension Money in another file stays a second Money node. A reference from a third file then has two candidates and stays on a stub:

    // Money.swift
    struct Money { let cents: Int }
    extension Money { static var zero: Money { Money(cents: 0) } }
    // MoneyFormat.swift
    extension Money { var text: String { "" } }
    // Wallet.swift
    struct Wallet { let balance: Money }

    This yields two sourced Money nodes, and Wallet references a sourceless stub.

    The fix I tested has three small parts:

    1. In the generic class walk, record which Swift ids the file actually declares: class, struct, enum, actor or protocol, i.e. not extension. Add "declared": True to each swift_extensions record whose id the same file also declares.
    2. In _merge_swift_extensions, a declared id counts as a canonical declaration. Exclude only extension-only ids when building label_to_canonical, and never remap declared ids. Extension-only ids that stay unmerged get a node marker, for example _swift_extension_only = True.
    3. In _rewire_unique_stub_nodes, skip nodes with that marker when building real_by_label. Add the marker to the resolution-context marker lists in watch.py and cli.py, so graphify update keeps it for unchanged files. Bump _AST_CACHE_SCHEMA, since the per-file records change.

    Result. On a mid-sized SwiftUI app, the private extension String node went from about 940 edges to 2, and an extension Date node went from 72 to 3. Their references now behave like Int and Bool already do, as sourceless stubs.

    Tests. These regression tests fail on 0.9.92 and pass with the change:

    • the repro from this issue;
    • the Money case above;
    • a type next to its own extension still resolving;
    • cross-file merging of corpus types unchanged;
    • an incremental _rebuild_code run, where the extension file arrives only as resolution-context nodes.

    The full suite shows no new failures.

    I have this on a local branch and can open a PR if that would help.

    Written with the help of an AI assistant; the steps and numbers above come from running the change.

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