Repository navigation
feat(graph): full-fidelity Swift extraction - #219
Conversation
Move Swift (.swift) from the generic breadth tier — whose tags query had no call captures, so Swift repos indexed symbols with zero wiring — to a hand-written tree-sitter extractor, following Kotlin's promotion (trailhq#130). tree-sitter-swift folds class/struct/enum/actor/extension into one class_declaration node type, told apart by their own keyword; describeSwift rekinds them (actor → class), protocols become interfaces, typealias a type, and top-level let/var variables. An extension takes the extended type's name so its members mint as that type's methods, but the node itself is kind "module" — a second same-named class would make every initializer call and heritage target ambiguous and drop. init becomes a method named after its type, like a Java constructor. Calls resolve via call_expression with navigation/self/super receivers. A bare call inside a type body may be an implicit-`self` member call, so it may also match a method (R Phase 4's widening; unique matches only). An initializer call — an ordinary call node, no `new` — falls back to class/struct/enum targets once functions find nothing (Python's constructor-fallback shape). The `:` inheritance clause yields extends edges, import declarations yield module-path import edges, and only private/fileprivate hide a symbol: Swift's default `internal` is module-wide, which for a one-module repo is the API surface. Dependency: tree-sitter-swift ^0.7.1 — the first release whose install compiles the shipped parser instead of regenerating it via tree-sitter-cli, which broke under npm's hoisting. Its tree-sitter peer wants ^0.22, so a root `overrides` entry pins it to the repo's 0.21 runtime for dev installs; transitive (end-user) installs resolve cleanly without it (verified). Known gap, shared with Kotlin: untyped receivers (`a.run()`) have no bindings collector yet, so those member calls stay unresolved rather than guessed.
Close the untyped-receiver gap the Swift promotion shipped with: a collectBindings pass for Swift, from the confident, syntax-local clues the other languages already trust. Typed parameters (`func feed(animal: Animal)`, the last simple_identifier before the `:` is the local name — the first may be an external argument label), typed properties (`var d: Doctor`, one level of optional unwrapped), and initializer-call assignments (`let vet = Vet()` — an UpperCamelCase callee names the constructed type, the same convention trust as Go's `NewX`). A property directly inside a type body is a field, bound at the type's scope both bare and `self.`-prefixed, like Java's fields. calleeName gains the `self.repo.save()` shape (one hop off self binds like TS's `this.x`) and now emits member calls with unknowable receivers instead of dropping them at extract time — recvType stays unset and resolve drops them, the same contract as Java's and TS's unknown receivers. A type-member call (`Animal.census()`) resolves via an uppercase-receiver fallback in resolveRecvType, mirroring PHP's scoped-call rule; a shadowing local binding is tried first. swiftDefName mirrors describeSwift's scope segments (extensions named after the extended type, inits after their enclosing type) so the two scope stacks stay in lockstep, per bindings.ts's existing no-value-import contract.
…lobal name uniqueness
Dogfooding the Swift extractor on Alamofire (108 files) and
swift-composable-architecture (796 files) caught two false-positive classes
in the bare-call kind widening:
- An UpperCamelCase bare call is an initializer, and matching it against
methods bound every `Text("hi")` (SwiftUI's own init) to an unrelated
in-repo `extension Text { init(template:) }` that happened to be the
repo's only method named Text.
- A stdlib call inside an extension (`contains(element)` in `extension
Set`) bound to an unrelated type's only in-repo `contains`.
Replace the widening: a bare lowercase call in a type body now emits a
second intent — viaMember, typed to the enclosing class — which resolves
only through the owner-qualified method index and the class's in-repo
ancestor chain, and drops otherwise. The plain intent still resolves
genuine free-function calls, and initializer calls take the class/struct/
enum fallback, which extension nodes (kind "module") can never false-match.
On the two corpora after the fix: both false-positive classes are gone,
280 inherited `stored(...)` calls in Alamofire's tests and 144
`clearLogs`/`assertLogs` calls in TCA's resolve to their base-class
methods via the chain, both graphs build 100% of files, and `graft check`
reports OK (deterministic) on both.
|
Tested against two substantial real-world Swift codebases before review:
Results on both: 100% of files parsed with zero failures, Sampling cross-file |
…resolution Address three adversarial-review findings against the Swift promotion: 1. Bare-call double edges. A lowercase bare call in a type body emitted two independent intents (member + free function), and when BOTH targets existed the graph kept both edges — Swift resolves inner-scope-first, to the member alone. Now it is ONE edge: the member reading (viaMember + recvType = enclosing type), with a new RawEdge.implicitSelf flag letting resolve.ts take the free-function reading only when the whole owner chain has no such member. An ambiguous member set still drops without falling through — a member exists, so the function reading is wrong. 2. Overloads resolved to the first declaration. With no arity data, several same-owner candidates hit the same-file tiebreak, which picks whichever overload appears first and stamps it `extracted`. Swift callables now carry arity (required-parameter count; defaults/variadics mark it a minimum) and Swift call sites carry argCount (value_arguments + trailing closure) — Java's exact mechanism — so distinct-arity overloads resolve precisely, and a set arity can't split (`save(Int)` vs `save(String)`) drops rather than guessing by source order. 3. super.method() never resolved. resolveRecvType maps `super` through ctx.rSuperClass, which only R populated. Swift class declarations now set it from their own `:` clause's first entry (the grammar puts a superclass before any protocol), so `super.ping()` in an override resolves to the PARENT's method, climbing past the current class's own. Four new tests pin member-vs-global precedence (inside and outside the type), equal-arity drop, distinct-arity resolution, and super dispatch. Re-dogfooded on Alamofire and swift-composable-architecture: both graphs build 100% of files, `graft check` OK, duplicate-reading and first-overload edges gone.
|
An adversarial review pass (Codex) against this branch surfaced three dispatch-precision findings; commit c3e4c21 addresses all three:
Four new tests pin member-vs-global precedence, equal-arity drop, distinct-arity resolution, and super dispatch (Swift suite now 20 tests; full suite 935/935). Re-dogfooded on Alamofire and swift-composable-architecture: 100% parse, |
🌱 graft blast radius1 area changed → 5 areas can be affected. 16 dependent symbols, depth 2. flowchart TB
A0(("build.ts<br/>9 symbols"))
A1(("cli.ts<br/>3 symbols"))
A2(("review.ts<br/>2 symbols"))
A3(("enrich.ts<br/>1 symbol"))
A4(("tools.ts<br/>1 symbol"))
classDef reached fill:#D9EDF3,stroke:#3AA7C9,stroke-width:1.5px,color:#0E313C;
class A0,A1,A2,A3,A4 reached;
All 16 dependent symbols, grouped by areabuild.ts — 9 symbols in 7 files
cli.ts — 3 symbols in 2 files
review.ts — 2 symbols in 1 file
enrich.ts — 1 symbol in 1 file
tools.ts — 1 symbol in 1 file
Test signal per changed area — 1 ⚠Reached = a node under a test path has a resolved edge into the changed symbol. It undercounts anything called indirectly — through a CLI, a spawned process or a dynamic import — so read a low ratio as “look here”, never as a coverage gate.
32 test suites also reference this code40 symbols, kept out of the diagram and the table so they cannot crowd out the areas a reviewer has to look at.
Open the interactive graph → — click an area to see the code that changed, and the line that reaches it. |
# Conflicts: # package-lock.json
# Conflicts: # CHANGELOG.md # package.json
|
Verified locally on top of current Depth-tier Swift (native |
* feat(graph): full-fidelity Swift extraction Move Swift (.swift) from the generic breadth tier — whose tags query had no call captures, so Swift repos indexed symbols with zero wiring — to a hand-written tree-sitter extractor, following Kotlin's promotion (trailhq#130). tree-sitter-swift folds class/struct/enum/actor/extension into one class_declaration node type, told apart by their own keyword; describeSwift rekinds them (actor → class), protocols become interfaces, typealias a type, and top-level let/var variables. An extension takes the extended type's name so its members mint as that type's methods, but the node itself is kind "module" — a second same-named class would make every initializer call and heritage target ambiguous and drop. init becomes a method named after its type, like a Java constructor. Calls resolve via call_expression with navigation/self/super receivers. A bare call inside a type body may be an implicit-`self` member call, so it may also match a method (R Phase 4's widening; unique matches only). An initializer call — an ordinary call node, no `new` — falls back to class/struct/enum targets once functions find nothing (Python's constructor-fallback shape). The `:` inheritance clause yields extends edges, import declarations yield module-path import edges, and only private/fileprivate hide a symbol: Swift's default `internal` is module-wide, which for a one-module repo is the API surface. Dependency: tree-sitter-swift ^0.7.1 — the first release whose install compiles the shipped parser instead of regenerating it via tree-sitter-cli, which broke under npm's hoisting. Its tree-sitter peer wants ^0.22, so a root `overrides` entry pins it to the repo's 0.21 runtime for dev installs; transitive (end-user) installs resolve cleanly without it (verified). Known gap, shared with Kotlin: untyped receivers (`a.run()`) have no bindings collector yet, so those member calls stay unresolved rather than guessed. * feat(graph): Swift receiver-type bindings Close the untyped-receiver gap the Swift promotion shipped with: a collectBindings pass for Swift, from the confident, syntax-local clues the other languages already trust. Typed parameters (`func feed(animal: Animal)`, the last simple_identifier before the `:` is the local name — the first may be an external argument label), typed properties (`var d: Doctor`, one level of optional unwrapped), and initializer-call assignments (`let vet = Vet()` — an UpperCamelCase callee names the constructed type, the same convention trust as Go's `NewX`). A property directly inside a type body is a field, bound at the type's scope both bare and `self.`-prefixed, like Java's fields. calleeName gains the `self.repo.save()` shape (one hop off self binds like TS's `this.x`) and now emits member calls with unknowable receivers instead of dropping them at extract time — recvType stays unset and resolve drops them, the same contract as Java's and TS's unknown receivers. A type-member call (`Animal.census()`) resolves via an uppercase-receiver fallback in resolveRecvType, mirroring PHP's scoped-call rule; a shadowing local binding is tried first. swiftDefName mirrors describeSwift's scope segments (extensions named after the extended type, inits after their enclosing type) so the two scope stacks stay in lockstep, per bindings.ts's existing no-value-import contract. * fix(swift): resolve implicit-self calls via the ancestor chain, not global name uniqueness Dogfooding the Swift extractor on Alamofire (108 files) and swift-composable-architecture (796 files) caught two false-positive classes in the bare-call kind widening: - An UpperCamelCase bare call is an initializer, and matching it against methods bound every `Text("hi")` (SwiftUI's own init) to an unrelated in-repo `extension Text { init(template:) }` that happened to be the repo's only method named Text. - A stdlib call inside an extension (`contains(element)` in `extension Set`) bound to an unrelated type's only in-repo `contains`. Replace the widening: a bare lowercase call in a type body now emits a second intent — viaMember, typed to the enclosing class — which resolves only through the owner-qualified method index and the class's in-repo ancestor chain, and drops otherwise. The plain intent still resolves genuine free-function calls, and initializer calls take the class/struct/ enum fallback, which extension nodes (kind "module") can never false-match. On the two corpora after the fix: both false-positive classes are gone, 280 inherited `stored(...)` calls in Alamofire's tests and 144 `clearLogs`/`assertLogs` calls in TCA's resolve to their base-class methods via the chain, both graphs build 100% of files, and `graft check` reports OK (deterministic) on both. * fix(swift): member-first bare-call precedence, overload arity, super resolution Address three adversarial-review findings against the Swift promotion: 1. Bare-call double edges. A lowercase bare call in a type body emitted two independent intents (member + free function), and when BOTH targets existed the graph kept both edges — Swift resolves inner-scope-first, to the member alone. Now it is ONE edge: the member reading (viaMember + recvType = enclosing type), with a new RawEdge.implicitSelf flag letting resolve.ts take the free-function reading only when the whole owner chain has no such member. An ambiguous member set still drops without falling through — a member exists, so the function reading is wrong. 2. Overloads resolved to the first declaration. With no arity data, several same-owner candidates hit the same-file tiebreak, which picks whichever overload appears first and stamps it `extracted`. Swift callables now carry arity (required-parameter count; defaults/variadics mark it a minimum) and Swift call sites carry argCount (value_arguments + trailing closure) — Java's exact mechanism — so distinct-arity overloads resolve precisely, and a set arity can't split (`save(Int)` vs `save(String)`) drops rather than guessing by source order. 3. super.method() never resolved. resolveRecvType maps `super` through ctx.rSuperClass, which only R populated. Swift class declarations now set it from their own `:` clause's first entry (the grammar puts a superclass before any protocol), so `super.ping()` in an override resolves to the PARENT's method, climbing past the current class's own. Four new tests pin member-vs-global precedence (inside and outside the type), equal-arity drop, distinct-arity resolution, and super dispatch. Re-dogfooded on Alamofire and swift-composable-architecture: both graphs build 100% of files, `graft check` OK, duplicate-reading and first-overload edges gone. --------- Co-authored-by: Shrish Dwivedi <shrish@nanonets.com>
What
Promotes Swift (
.swift) from the generic breadth tier to a hand-written depth-tier extractor, following the Kotlin promotion (#130). Today the breadth tier'sswift.scmhas no@reference.callcaptures at all, so a Swift repo gets symbols and zero wiring — nocallers, no blast radius, no coupling-ranked retrieval.How
Extractor (
src/graph/extract.ts, mirroringdescribeKotlin):class_declarationnode coversclass/struct/enum/actor/extension, told apart by their own keyword token →class,struct,enum,class, and the extension case below.protocol→ interface,typealias→ type, top-levellet/var→ variable.extension Pointtakes the extended type's name, so its members mint asPointmethods and member calls on a Point receiver resolve to them. The extension node itself is kindmodule, not a second class namedPoint— that duplicate made everyPoint()initializer call and: Pointheritage target ambiguous (resolve never guesses), and a test pins the regression.init→ a method named after its type (like Java'sconstructor_declaration);func→ method inside any type, function elsewhere.call_expressionwithnavigation_expression/self/superreceivers;:inheritance clause →extendsedges (bare names — Swift can't say syntactically which specifier is the superclass);import→ module-path import edges.private/fileprivatehide a symbol. Swift's defaultinternalis module-wide, and a repo is typically one module, so it counts as API surface.Resolution (
src/graph/resolve.ts, two existing precedents reused):clearLogs()in a test subclass finds the base class's method), then the free-function reading only when no member exists on the chain — a name defined as both yields the member edge alone, as Swift dispatches it.super.method()resolves against the declaration's own superclass (the first:entry — Swift's grammar puts the superclass before any protocol), climbing past the current class's override.save(Int)vssave(String)) drops rather than taking the same-file first-declaration tiebreak.Animal(legs: 4)is an ordinary call node with nonew; it falls back to class/struct/enum targets once functions find nothing — Python's constructor-fallback shape.Dependency:
tree-sitter-swift@^0.7.1— the first release whose install compiles the shippedparser.cinstead of regenerating it via tree-sitter-cli (0.6.0's gyp action breaks under npm hoisting:No rule to make target node_modules/tree-sitter-cli). Its peer wantstree-sitter@^0.22; a rootoverridesentry pins it to the repo's 0.21 runtime for dev installs, and I verified a transitive (end-user) install resolves cleanly without any override, with the compiled grammar loading and parsing fine on the 0.21 runtime (ABI 14).Receiver-type bindings
A second commit adds the Swift
collectBindingspass, so untyped receivers now resolve too. Bound clues, all syntax-local: typed parameters (argument labels handled), typed properties (one optional level unwrapped), initializer-call assignments (let vet = Vet()— an UpperCamelCase callee names the constructed type, the same convention trust as Go'sNewX), and fields bound both bare andself.-prefixed like Java's.self.repo.save()binds one hop off self like TS'sthis.x, andAnimal.census()resolves via an uppercase-receiver rule mirroring PHP's scoped calls. A receiver with no local clue (a chained call's result) stays unresolved rather than guessed.Dogfooded on two real Swift codebases
Alamofire (108 files, ~43k lines) and swift-composable-architecture (796 files, ~79k lines): both build 100% of files with zero parse failures,
graft checkreports OK (deterministic) on both, and the graphs come out at 3,067 nodes / 6,530 edges and 6,127 nodes / 12,433 edges respectively.Sampling cross-file
inferredmethod-target call edges caught two false-positive classes in the first implementation of the implicit-self rule (then a bare-namekindswidening):Text("hi")— SwiftUI's own init — bound to an unrelated in-repoextension Text { init(template:) }, andcontains(element)insideextension Set— a stdlib call — bound to an unrelated type's only in-repocontains. The third commit replaces the widening with the ancestor-chain intent described above; after it, both classes are gone, while 280 inheritedstored(...)calls in Alamofire's tests and 144clearLogs/assertLogscalls in TCA's still resolve to their base-class methods through the chain.Testing
test/graph-swift.test.ts: 20 tests — kind mapping for all fiveclass_declarationkeywords, extension member ownership + the ambiguity regression, initializer methods, call shapes (bare / member / self / super / trailing closure), heritage, imports, visibility incl.private(set), and resolution-level pins throughbuildGraph(implicit-self, constructor edges, ambiguous-drops, receiver-typed member calls incl. extension methods).test/graph-languages.test.ts/test/supported-extensions.test.tsupdated for the tier move.extends Animal -> Greeter,run -> walk(extension → class),walk -> greet(implicit self),makeAnimal -> Animal(initializer, inferred), cross-file bare calls — all resolving.README's supported-languages section is also brought current (Kotlin and PHP were still listed as breadth-tier; Nix was missing).