Skip to content

fix(swift): extract file-scope typealias declarations as type nodes - #4639

Open
madhusudanpatnaik wants to merge 8 commits into
Graphify-Labs:v8from
madhusudanpatnaik:fix/swift-typealias
Open

madhusudanpatnaik wants to merge 8 commits into
Graphify-Labs:v8from
madhusudanpatnaik:fix/swift-typealias

Conversation

@madhusudanpatnaik

@madhusudanpatnaik madhusudanpatnaik commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

The Swift extractor emitted no node for a typealias. A field, parameter or return typed with one, even in the declaring file, therefore resolved to a sourceless stub. Also, an extension of an aliased type had no declaration to merge onto, so extension ConstraintInsets (SnapKit) or extension KFCrossPlatformColor (Kingfisher) stayed a separate type node in whichever file extended it. On Kingfisher, all 24 references to KFCrossPlatformColor landed on the extension node in ImageProcessor.swift; they now land on the alias in Kingfisher.swift, and the extension folds into it.

Changes (graphify/extractors/engine.py):

  • A file-scope typealias (typealias Handler = (Int) -> Void, public typealias JSON = [String: Any], typealias Pair<T> = (T, T), attributed or inside #if) becomes a type node contained by its file. It's handled in _swift_extra_walk, which returns False so the walk goes on as before. The id is _make_id(stem, name), the one ensure_named_node and a top-level type use (Swift never populates namespace_stack). So same-file references bind directly, cross-file ones go through the existing rewire, and _merge_swift_extensions folds extensions onto the alias as it does for a struct.

  • The alias node is minted through decl_node_fn, the declaration-id registry v8 added for JS file/class ID collision drops class Js and emits self-contains edges (0.9.87) #4854, as every other file-stem declaration is. It's registered as an alias, as the C++ alias site is. As a type it would share an id with a same-kind declaration differing only in case, so typealias string = [Character] and struct String {} would become one node. Without the registry, an alias that takes its id outside it makes a later declaration of the same name salt itself: on SnapKit, #if canImport(UIKit) / typealias ConstraintLayoutSupport = UILayoutSupport / #else / class ConstraintLayoutSupport {} became the alias, a salted duplicate class node and a sourceless stub, and typealias View beside extension View in Tests.swift gave a salted duplicate View. Through the registry each is one node. An alias whose id spells its file's own id (typealias Swift in Box.swift) also keeps a node of its own and its contains edge.

  • Not declarations here, on purpose:

  • Misparsed files. tree-sitter-swift fails to parse many real files cleanly. Error recovery can lift a type's members to the top level, or wrap a type's header inside a bogus typealias_declaration. Which shapes misparse depends on the grammar version:

    • With 0.7.2, an #if inside a type body is enough: 2 of 64 (Moya) up to 226 of 1,019 (RxSwift) files have errors.
    • With 0.7.4, which v8 now locks, that parses, and the count drops to 0 of 43 (SnapKit) up to 86 of 1,019 (RxSwift). A freestanding #warning("…") or macro expansion in a type body still lifts the member alias after it.
    • pyproject.toml allows both (>=0.7,<0.9).

    A first version that checked only parent.type == "source_file" therefore minted member aliases as file-scope nodes. On RxSwift (0.7.2) one of them then claimed the case-folded id of a method (SectionIndexTitles / sectionIndexTitles()) and absorbed its edges. So _swift_file_scope_aliases decides scope once per file:

    • in a cleanly parsed file, any top-level alias counts;
    • in a misparsed file, an alias counts only if it parsed cleanly itself and the {/} tokens before it balance. Comments and string contents are not brace tokens, and zero-width MISSING tokens are skipped.

    The helper looks only at the root's children and counts braces only in misparsed files that have top-level aliases. Over all 1,019 RxSwift files it takes 7 ms. Across the 6 repos below it rejects 9 lifted member aliases under 0.7.2 (RxSwift 4, swift-argument-parser 5) and none under 0.7.4. Under either grammar, every alias it keeps is file-scope.

  • graphify/cache.py: AST cache schema 34 → 35 (the branch includes v8 at 32e5efe). Cached Swift results lack these nodes.

Type of change

  • Bug fix
  • New feature
  • Documentation
  • Tests or CI
  • Refactor
  • Security fix

Verification & Invariants

Invariant: a source-backed declaration wins over a sourceless stub, without making a previously correct resolution ambiguous, and without losing any edge.

Limitations:

  • An alias's right-hand side is not walked, as in my Go and Rust alias PRs.
  • A file-scope alias in a misparsed file is skipped when the braces before it don't balance (conservative: no node rather than a wrong one).
  • Two files declaring the same alias (e.g. per-platform files) make the name ambiguous, which fails closed, as two same-named structs already do.
  • Pre-existing and unchanged: when a type is also extended in its own file (struct Foo {} + extension Foo: P {} in one file), the extension shares the declaration's id, so _merge_swift_extensions treats the declaration as an extension. Cross-file extensions then don't fold onto it, and references elsewhere stay on a stub. It happens on v8 for a plain struct, and an alias behaves the same way: Kingfisher's KFCrossPlatformImage is declared and extended in Kingfisher.swift, so it doesn't benefit from this PR. That's a separate fix.
  • With typealias Session = Int and func session() in one file, var s: Session binds to the function: the registry's lookup case-folds the name and tries fn before alias. The C++ alias site does exactly the same on v8 today. My C++ alias-lookup PR fixes the lookup for both: a reference binds to the declaration spelled exactly like it. With both PRs, the 6 Swift repos below give exactly the graph this PR gives alone.

How was this tested?

  • New tests/test_swift_typealias.py (18 tests):

    • sourced alias nodes on their lines for every form;
    • contains edges;
    • same-file references binding to the aliases;
    • a cross-file reference rewired onto the alias;
    • an #if/#else pair as one node;
    • member, protocol, extension and local aliases emitting no node;
    • the re-export boundary;
    • four misparse shapes: two that 0.7.2 misparses and 0.7.4 parses, plus a #warning and a macro expansion that both misparse. Each is checked twice: its member alias gets no node, and a real alias after it does;
    • a canary that fails if the installed grammar parses every misparse fixture cleanly, so those tests can't silently stop testing anything;
    • an alias named after the file extension (typealias Swift in Box.swift) keeping its own node;
    • an alias and a struct whose names differ only in case (typealias string + struct String) staying two nodes.

    On v8 (32e5efe, grammar 0.7.4), 12 fail; the other 6 are 5 guards and the canary. Mutations, each run under both 0.7.4 and 0.7.2:

    • The naive parent == source_file scope check fails the #warning and macro tests, plus the two #if tests under 0.7.2.
    • Dropping the brace-depth check fails the same tests, except wrapped-header under 0.7.2.
    • Dropping the has_error check fails wrapped-header under 0.7.2. I found no shape that triggers it under 0.7.4.
    • Rejecting every alias in a misparsed file fails the alias-after-a-misparse tests.
    • Minting with add_node instead of decl_node_fn fails the file-extension test.
    • Registering as a type instead of an alias fails the case test.
  • Real repos, full-graph diff v8 → this branch (v8 at 32e5efe, grammar 0.7.4). Nodes are counted as distinct ids; edges are compared per site (source label, relation, file, line), keeping multiplicity:

    Repo Nodes Sourceless nodes Edges into sourceless stub → real real → stub lost
    Alamofire/Alamofire 3,333 → 3,334 595 → 589 2,957 → 2,937 20 0 0
    onevcat/Kingfisher 3,341 → 3,317 695 → 656 2,387 → 2,259 128 0 0
    ReactiveX/RxSwift 10,443 → 10,451 2,017 → 2,001 11,267 → 11,153 114 0 0
    Moya/Moya 808 → 797 249 → 233 548 → 527 21 0 0
    SnapKit/SnapKit 486 → 474 67 → 58 144 → 131 13 0 0
    apple/swift-argument-parser identical
    • Edges that moved from one real node to another: Kingfisher 31, SnapKit 17, RxSwift 5, Moya 5. I checked every one. Each now lands on the file that declares that typealias (e.g. Kingfisher's 26 onto KFCrossPlatformColor in Kingfisher.swift: its 24 references, a call, and the extending file's contains), because an extension node collapsed onto the alias it extends. That is also why node counts drop.
    • "Lost" counts an edge as kept when its site still has an edge to the same target label. A merged extension changes the source node's file, not the edge.
    • swift-argument-parser's only top-level-looking aliases are members of a nested private struct _Lock that a misparse lifted. They are correctly left out.
  • Determinism (re-checked on v8 32e5efe with this exact code): byte-identical graphs under PYTHONHASHSEED=1 and 777, sequential and parallel extraction, and cold and warm caches (Kingfisher, SnapKit, Moya).

  • Performance:

    • Full extract(), cold cache, 3 interleaved rounds, v8 (0.9.93) vs this branch: RxSwift 15.36/15.46/15.74 s vs 14.87/15.95/15.12 s, Kingfisher 2.91/2.87/3.42 s vs 2.85/2.99/3.04 s, SnapKit 0.40/0.42/0.46 s vs 0.41/0.42/0.57 s; peak RSS identical. Per file, no file is slower beyond noise (flagged files re-timed best-of-15: 0.99–1.03×).
    • Two RxSwift files contain only public typealias TestTime = Int; v8 reports them as yielding no symbols, and this branch extracts the alias.
    • Synthetic 5k/10k/20k file-scope aliases: 0.43/0.80/1.64 s, linear, and misparsed files cost the same as clean ones. v8 takes 0.27/0.54/1.06 s because it emits none of those nodes.
    • Found while testing, pre-existing and unrelated: a Swift file with thousands of declarations that carry type references is quadratic on v8 (2.7 → 10.9 → 45.2 s per doubling). The cost is in _swift_type_parameters_in_scope, not here; my separate type-parameter scope-walk PR (perf(extract): make the Kotlin/Swift/Scala/C++ type-parameter scope walk linear) fixes it.
  • Scenario checks:

    • Kingfisher, SnapKit and RxSwift each produce an identical graph under: a different PYTHONHASHSEED, parallel extraction, a warm cache, and a cache filled by v8 first.
    • The fixtures give the same graph as LF, CRLF, BOM+CRLF and tab-indented.
    • 1,200 mutated Swift files: 0 crashes or hangs, same as v8.
    • Through the CLI on Kingfisher:
      • graphify update over a graph v8 built equals a fresh build.
      • Adding a file and running update again equals a fresh build.
      • The hook-style rebuild differed from a fresh build only in 17 Objective-C category nodes whose ids embedded the absolute checkout path. v8 showed the same difference at the time; fix(extract): keep the checkout path out of ObjC category ids #4642 has since fixed it.
  • Together with my other open PRs: merged onto v8 (32e5efe) with test(watch): script only the watch loop's sleep in drive_watch #4620, fix(extract): variadic parameters reference their type (Go, Java, C#, Scala, PHP) #4638, perf(extract): make the Kotlin/Swift/Scala/C++ type-parameter scope walk linear #4640, fix(dart): name a legacy typedef that returns a function type by its own name #4641 and my C++ alias-lookup PR, only the one-line _AST_CACHE_SCHEMA in cache.py conflicts, and the full suite passes: 3.13: 9424 passed, only the 7 DNS-dependent tests failing.

uv run pytest tests/test_swift_typealias.py tests/test_swift*.py tests/test_languages.py tests/test_multilang.py -q   # 709 passed
# CI matrix: per Python, `uv sync --all-extras --frozen`, then `uv run pytest tests/ -q --tb=short`.
#   this branch on v8 32e5efe9 — 3.13: 9396 passed, plus 1 watch test that failed under load and passes on re-run (v8 itself: 9379)
#   (earlier, on v8 0.9.100 — 3.10: 9375 | 3.13: 9375; on v8 0.9.93 — 3.10: 8473 | 3.12: 8472 | 3.13: 8472 | 3.14: 8472 passed)
#   only the 7 DNS-dependent test_security tests fail (no network here)
#   the watch tests (test_language_override_lifecycle.py, test_pr10_watch_overrides.py) can fail
#   intermittently on v8 as well: a test-harness race, fixed by #4620; a re-run clears it
uv run graphify --help && uv run graphify install   # OK on all four Pythons, in a throwaway HOME
uv run python -m tools.skillgen --check / --audit-coverage / --schema-singleton / --monolith-roundtrip / --always-on-roundtrip   # all OK
uv run python -m tools.skillgen --bless   # 134 artifacts, no changes
uv run ruff check .
uv run pyright                            # 681 errors on v8 (32e5efe9) and on this branch; none new
uv run bandit -r graphify -ll             # 12 findings, identical to v8
GRAPHIFY_TEST_CLI=.venv/bin/graphify bun test tests/pi.test.ts   # 20/20

Graphify-specific checklist

  • I updated generated skill artifacts (uv run python -m tools.skillgen --bless) when changing their source fragments. (Ran --bless: all 134 artifacts regenerated with no changes, and --check passes. No source fragments are touched.)
  • I confirmed that AST/structural extraction remains deterministic (no ambient state dependencies like ENV variables).
  • I reviewed changes for security implications (no unsafe interpolation into shell/Python).
  • I confirmed no API keys or local-only graph data are included.
  • (If applicable) I disclosed AI authorship in my commit messages.

madhusudanpatnaik and others added 2 commits October 20, 2026 09:35
The Swift extractor emitted no node for a `typealias`, so every field,
parameter or return typed with one, even in the declaring file, resolved
to a sourceless stub, and an `extension` of an aliased type had no
declaration to merge onto. A file-scope alias now gets a type node with a
`contains` edge from its file, minted with the id a top-level type gets,
so same-file references bind to it, cross-file ones rewire to it and
_merge_swift_extensions folds extensions onto it.

Member aliases (associated-type witnesses such as `Element`), local
aliases and same-name re-exports (`typealias Field = Core.Field`) get no
node. tree-sitter-swift misparses many real files, and error recovery can
lift a type's members to the top level or wrap its header in a bogus
alias, so in a misparsed file an alias counts only when it parsed cleanly
and the brace tokens before it balance.

Bump the AST cache schema to 18: cached Swift results lack these nodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolves the one-line _AST_CACHE_SCHEMA conflict: upstream is now at
schema 20, so this branch's extraction change moves to 21, with
upstream's history kept as "prior (20)".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Thanks for the pull request, @madhusudanpatnaik. A maintainer will review it soon.

Want to talk it through while it is in review? Come join us on our Discord server. For longer-form discussion there is also GitHub Discussions.

A couple of things that speed up review: make sure the test suite passes on Python 3.10 and 3.13, and that the change keeps extraction deterministic.

@github-actions github-actions Bot added the contributor Pull request from a returning community contributor label Oct 21, 2026

@graphify-labs graphify-labs Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

No issues found by static checks — no coupling regressions or blocking issues in the code graph. That is not the same as safe to merge: see what was not checked.

Not checked: tests were not run; formal verification proved 0 of 2 changed function(s) (2 not verified).

Formal verification. PR-changed functions: 0/2 verified (0 proven, 0 may-equivalent, 0 distinguished) · 2 not verified (2 unsupported).

Not verified on this run: \_extract\_generic (unsupported), \_swift\_extra\_walk (unsupported).


Graphify review — findings

Adds extraction of file-scope Swift typealias declarations as type nodes with a contains edge from the declaring file, so fields, parameters and returns typed with an alias resolve to a sourced node instead of a sourceless stub. Member aliases (in types, protocols, extensions), function-local aliases, and same-name re-exports like typealias Field = Core.Field still get no node. In files that tree-sitter-swift misparses, a top-level alias counts only if it parsed cleanly and the preceding {/} tokens balance, so error recovery can't promote a member alias to file scope. Bumps _AST_CACHE_SCHEMA to 21 so cached Swift extractions re-run.

No blocking issues surfaced. 1 lower-confidence candidate did not survive cross-model review.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 3557 functions depend on the 463 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: extract() — 989 callers, 59 callees
  • worse: _extract_generic() — 20 callers, 47 callees

Verification — 3557 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 2171 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

73 of 437 test file(s) selected (17%) via static blast radius.

  • tests/test_astro_extraction.py — impact
  • tests/test_build.py — impact
  • tests/test_cache.py — impact
  • tests/test_cache_stale_import_target.py — impact
  • tests/test_charmap_encoding.py — impact
  • tests/test_chunking.py — impact
  • tests/test_cjs_module_extension.py — impact
  • tests/test_cpp_nested_and_cli.py — impact
  • tests/test_csharp_partial_classes.py — impact
  • tests/test_definition_file_portability.py — impact
  • tests/test_detect.py — impact
  • tests/test_dotnet.py — impact
  • tests/test_ejs_extractor.py — impact
  • tests/test_ext_tagged_id_clash.py — impact
  • tests/test_extract.py — impact
  • tests/test_extract_cache_location.py — impact
  • tests/test_extract_cli.py — impact
  • tests/test_extract_php_closures.py — impact
  • tests/test_grammar_cache_key.py — impact
  • tests/test_ignore_file_encoding.py — impact
  • tests/test_import_extension_resolution.py — impact
  • tests/test_incremental_mtime_collision.py — impact
  • tests/test_indirect_call_block_scoped_shadow.py — impact
  • tests/test_indirect_dispatch.py — impact
  • tests/test_indirect_dispatch_assign_return.py — impact
  • tests/test_indirect_dispatch_getattr.py — impact
  • tests/test_java_member_calls.py — impact
  • tests/test_js_exported_scalar_bindings.py — impact
  • tests/test_js_member_collisions.py — impact
  • tests/test_language_override_lifecycle.py — impact
  • tests/test_language_overrides.py — impact
  • tests/test_languages.py — impact
  • tests/test_llm_backends.py — impact
  • tests/test_manifest_ingest.py — impact
  • tests/test_multilang.py — impact
  • tests/test_objc_field_table_remap.py — impact
  • tests/test_out_dir_evidence.py — impact
  • tests/test_partial_cache.py — impact
  • tests/test_php_unparsed_casts.py — impact
  • tests/test_pipeline.py — impact
  • tests/test_python_inherited_method_calls.py — impact
  • tests/test_python_property_reads.py — impact
  • tests/test_python_pytest_fixture_receivers.py — impact
  • tests/test_python_underscore_resolution.py — impact
  • tests/test_rationale.py — impact
  • tests/test_ruby_resolution.py — impact
  • tests/test_rust_self_member_calls.py — impact
  • tests/test_rust_use_trees.py — impact
  • tests/test_scala_context_bounds.py — impact
  • tests/test_scala_given_instances.py — impact
  • … and 23 more

Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.

Docs that may be stale (advisory)

…and 10 more.

Formal verification

Could not verify: Could not verify \_extract\_generic.

The verifier did not have enough to check \_extract\_generic, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: no capturable inputs from the test suite; property tier: parameter `config` is annotated `LanguageConfig` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_swift\_extra\_walk.

The verifier did not have enough to check \_swift\_extra\_walk, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: no capturable inputs from the test suite; property tier: signature changed: no shared positional arity

· 2 more finding(s) on lines outside this diff (see the check run).

madhusudanpatnaik and others added 3 commits November 2, 2026 08:55
The only conflict was the one-line _AST_CACHE_SCHEMA in graphify/cache.py:
v8 is at 34, so this branch now bumps it to 35 and keeps v8's 34 entry in
the history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d registry

v8 now assigns file-stem declaration ids through DeclIdRegistry (Graphify-Labs#4854), so a
declaration whose id spells its file's own id gets a salted id instead of
collapsing into the file node. The typealias mint site this branch adds
predates that and called add_node directly: `typealias Swift = Int` in
Box.swift got no node of its own, and `var v: Swift` pointed at a stub. On
SnapKit, an alias minted outside the registry also made the same-named
`class ConstraintLayoutSupport` (the other `#if canImport(UIKit)` branch) and
`extension View` salt themselves into duplicate nodes.

Route it through decl_node_fn as the other file-stem declarations are, and
read whether a node was minted off seen_ids after the call, so a salted alias
still gets its `contains` edge. Register it as an "alias", as the C++ alias
site does: as a "type" it would merge with a same-kind case twin, so
`typealias string` and `struct String` would become one node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…parses

v8's relock moved tree-sitter-swift from 0.7.2 to 0.7.4, which parses an
`#if` inside a type body cleanly, so the two misparse fixtures stopped
exercising the file-scope guard: the naive `parent == source_file` rule
passed them. 0.7.4 still trips on a freestanding `#warning`/macro
expansion in a type body and lifts the member alias after it to the top
level; two fixtures of that shape fail under the naive rule again. The
0.7.2 fixtures stay, since pyproject allows that version. A canary test
fails if no fixture misparses under the installed grammar any more, and
the alias-after-a-misparse test runs on every fixture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
madhusudanpatnaik and others added 3 commits November 2, 2026 16:30
test (3.13) failed on test_watch_config_changes_keep_semantic_work_visible[False]
with FileNotFoundError on graph.json. That is the drive_watch harness race Graphify-Labs#4620
fixes: the harness patches time.sleep on the shared time module, so a sleep in
subprocess's wait loop during the rebuild's `git rev-parse` raises the scripted
KeyboardInterrupt and stops the watcher before it writes graph.json. 3.14 passed
on the same commit. No code change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The only conflict was the one-line _AST_CACHE_SCHEMA in graphify/cache.py:
v8 is at 36, so this branch now bumps it to 37 and keeps v8's 36 entry in
the history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g in case

With the exact-spelling lookup now in v8 (Graphify-Labs#4951), `typealias Session = Int`
beside `func session()` resolves `var s: Session` to the alias. The alias is
registered as an "alias", which the kind-ordered lookup used to try after
"fn"; this pins that the exact spelling now decides. It fails on this
branch's previous head, which predates the lookup change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

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

Labels

contributor Pull request from a returning community contributor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant