Skip to content

[Bug]: Swift: unannotated class-body property (var a = Type.shared / var c = Type()) never seeds the receiver type map, so calls through it have 0 callers (locals with the same initializer resolve) #2661

Description

@rplotkin

Description

In Swift, a class-body property initialized from a static member or a constructor call without a type annotation (var a = Service.shared, var c = Service()) never seeds the receiver type map, so every method call through that property (a.reset(), self.a.reset()) is dropped. The callee then shows 0 callers in fn-impact / query / context, and the role classifier labels it leaf rather than dead-unresolved, so nothing signals that calls were lost.

The same initializer on a function-local binding (let s = Service.shared; s.reset()) does resolve, so local-variable inference already handles this shape; the gap is specifically class-body property_declaration nodes, which only go through seedSwiftPropertyTypeMap and that function returns early when there is no type_annotation. The chained form Service.shared.reset() is dropped too.

This is the Swift analogue of #2474 (Dart: local-variable constructor-call typing not seeded), one level up: properties rather than locals. The var x = Type.shared singleton idiom is very common in iOS code, so in practice a large share of intra-app method calls vanish from the graph. Both engines behave the same.

Steps to Reproduce

Sources/Service.swift:

import Foundation

public final class Service {
    public static let shared = Service()
    public func reset() { }
}

Sources/Client.swift:

import Foundation

class Client {
    var a = Service.shared                 // static-member initializer, no annotation
    var b: Service = .shared               // annotated
    var c = Service()                      // constructor-call initializer, no annotation
    var d: Service = Service()             // annotated + constructor

    func useA() { a.reset() }
    func useASelf() { self.a.reset() }
    func useB() { b.reset() }
    func useC() { c.reset() }
    func useD() { d.reset() }
    func useDirect() { Service.shared.reset() }
    func useLocalUnannotated() {
        let s = Service.shared
        s.reset()
    }
    func useLocalAnnotated() {
        let s: Service = .shared
        s.reset()
    }
}
codegraph build . --no-incremental
codegraph fn-impact Service.reset

Expected Behavior

All eight use* methods listed as level-1 callers of Service.reset.

Actual Behavior

Function impact: o Service.reset -- Sources/Service.swift:5

  -- Level 1 (4 functions):
      ^ o Client.useB  Sources/Client.swift:11
      ^ o Client.useD  Sources/Client.swift:13
      ^ o Client.useLocalUnannotated  Sources/Client.swift:15
      ^ o Client.useLocalAnnotated  Sources/Client.swift:19

  Total: 4 functions transitively depend on Service.reset
Call shape Receiver declaration Resolved
a.reset() var a = Service.shared (property, unannotated) no
self.a.reset() same no
b.reset() var b: Service = .shared (property, annotated) yes
c.reset() var c = Service() (property, unannotated ctor) no
d.reset() var d: Service = Service() (property, annotated) yes
Service.shared.reset() chained static member no
let s = Service.shared; s.reset() local, unannotated yes
let s: Service = .shared; s.reset() local, annotated yes

Identical output with --engine wasm.

Codegraph Version

3.17.0

Node.js Version

v26.8.1

Operating System

macOS (arm64)

Parser Engine

Both — native and --engine wasm give identical results.

Additional Context

seedSwiftPropertyTypeMap (src/extractors/swift.ts) starts with findChild(node, 'type_annotation') and returns if absent, so an unannotated class-body property contributes nothing to ctx.typeMap. Since the local-binding path already infers let s = Service.shared and let s = Service(), reusing that inference for property_declaration nodes without an annotation (initializer is a navigation_expression whose head is a capitalized simple_identifier, or a call_expression on a capitalized identifier) would close the gap, mirroring the #2474 fix.

Independently of the fix, it would help if the role classifier could mark a callee as dead-unresolved when the graph holds unresolved method-call sites with a matching method name; today the 0-caller result and a genuinely uncalled method are indistinguishable from the graph alone.

Activity

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