From a329df883aeec9ca64d1fd4c5b1cc0222ada3f82 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 21:15:33 +0000 Subject: [PATCH] fix(swift): a call on a typed property is a member of its type (#2205) The declared-member receiver inference #2171 gave C#, Java and Kotlin now reads Swift too: `var cache: ImageCache!`, `let storage: DiskStorage.Backend`, `self.x` as well as `x`, a function's own `let` shadowing the property. The dotted-call path types the receiver from it, and a chained bare call keeps only members of that type or its supertypes / protocols. Kingfisher: `cache.imageCachedType(forKey:)` -> ImageCache's (was a test subclass's override), `storage.store/value/isCached` -> DiskStorage.Backend, `manager.retrieveImage` -> KingfisherManager (was a test double), DispatchQueue `ioQueue.async` no longer a project method. realm-swift: `List::append`, `RealmCollection::sum/index` (were Objective-C RLMArray selectors); vapor: `self.storage.write` -> NIOHTTPBodyWriterStorage. Kingfisher -100 +179, realm-swift -108 +203, Alamofire -16 +54, vapor -15 +10, IceCubesApp -6 +5; Newtonsoft.Json, okhttp unchanged. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/swift-member-receiver.test.ts | 60 +++++++++++++++++++++++++ src/resolution/name-matcher.ts | 15 ++++--- 3 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 __tests__/swift-member-receiver.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2569e6ed..8bc8a415 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- A Swift call on a property declared with a type, like Kingfisher's `var cache: ImageCache!` or `self.storage.write(…)`, now reaches that type's method or one it inherits. It no longer lands on a subclass override in a test, or on another type's method of the same name. - Calls in your own JavaScript no longer link to a same-named function inside a vendored minified script, like a `*.min.js` or a bundle made of a few enormous lines. Those names are mangled. In healthchecks, every jQuery `$(…)` call had gone to a one-letter helper inside `bootstrap-native.min.js`. - A Swift call on a value built on the spot, like `JSONDecoder().decode(…)` or `Realm().create(…)`, now resolves to that type's method, or to one it inherits. It no longer lands on some other type's method of the same name: Vapor's `JSONDecoder().decode` calls had gone to a request's private container. - A bare Lua call like `ipairs(t)`, `error("…")` or busted's `setup()` now means Lua's own function unless the file defines one. It no longer lands on a table method of the same name: telescope's `ipairs` calls had gone to a linked list's `ipairs`, and kong's `error` calls to its response module. A function's own alias of a host table member, like `local get_query = kong.request.get_query`, is now followed as well. diff --git a/__tests__/swift-member-receiver.test.ts b/__tests__/swift-member-receiver.test.ts new file mode 100644 index 00000000..629908aa --- /dev/null +++ b/__tests__/swift-member-receiver.test.ts @@ -0,0 +1,60 @@ +/** + * A Swift call on a property the enclosing type declares with a type — + * Kingfisher's `var cache: ImageCache!` — is a member of that type (or of + * what it inherits): `cache.imageCachedType(forKey:)` is ImageCache's, not a + * test subclass's override (`EvictingPrefetchCache`) that shared its name. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-swift-member-')); + const files: Record = { + 'Sources/Cache/ImageCache.swift': `open class ImageCache { + open func imageCachedType(forKey key: String) -> Int { + return 0 + } +} +`, + 'Tests/KingfisherTests/ImagePrefetcherTests.swift': `class EvictingPrefetchCache: ImageCache { + override func imageCachedType(forKey key: String) -> Int { + return 1 + } +} +`, + 'Tests/KingfisherTests/ImageCacheTests.swift': `class ImageCacheTests { + var cache: ImageCache! + + func testCachedType() { + _ = cache.imageCachedType(forKey: "a") + _ = self.cache.imageCachedType(forKey: "b") + } +} +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('Swift calls on a typed property', () => { + it('reach the declared type’s method, not a subclass override elsewhere', () => { + const ids = cg.getNodesInFile('Tests/KingfisherTests/ImageCacheTests.swift').map((n) => n.id); + const targets = [...new Set(cg.getOutgoingEdgesFrom(ids).filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.target)!.qualifiedName))]; + expect(targets).toEqual(['ImageCache::imageCachedType']); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 2e62d29b..cb7e41f2 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -2643,6 +2643,11 @@ function isSwiftCallTarget(n: Node, shape: SwiftCallShape | null, ref: Unresolve if (shape.constructed && !context.getNodesByName(shape.constructed).some((f) => f.kind === 'function')) { return swiftTypeClosure(shape.constructed, context).has(owner); } + // A property the type around the call declares with a type — Kingfisher's + // `var cache: ImageCache!` — is that type: `cache.imageCachedType(…)` is + // ImageCache's, not a test subclass's override. + const typed = /^(?:self\.)?[A-Za-z_]\w*$/.test(shape.receiver) ? inferMemberReceiverType(shape.receiver, ref, context) : null; + if (typed && /^[A-Z]/.test(typed)) return swiftTypeClosure(typed, context).has(owner); if (shape.receiver === '' || !SWIFT_STD_METHODS.has(n.name)) return true; return sharesReceiverWord(shape.receiver.split('.').pop()!, n) || !swiftDeclOf(owner, context).projectType || swiftDeclaresLabel(n, shape.label, context); @@ -5315,7 +5320,7 @@ function buildLocalReceiverTypePatterns(language: Language, r: string): RegExp[] } /** Languages whose fields and properties declare their type where the class declares them. */ -const MEMBER_TYPED_LANGUAGES: ReadonlySet = new Set(['csharp', 'java', 'kotlin']); +const MEMBER_TYPED_LANGUAGES: ReadonlySet = new Set(['csharp', 'java', 'kotlin', 'swift']); const MEMBER_CLASS_KINDS: ReadonlySet = new Set(['class', 'struct', 'interface', 'enum', 'record']); const MEMBER_TYPE_MEMO = new WeakMap>(); /** Words that can stand where a declaration's type does without being one. */ @@ -5336,7 +5341,7 @@ const MEMBER_TYPE_NON_TYPES: ReadonlySet = new Set([ * TraceJsonWriter went to TraceJsonWriter's own `WriteValue` by name. */ function inferMemberReceiverType(receiver: string, ref: UnresolvedRef, context: ResolutionContext): string | null { - const name = receiver.replace(/^this\./, ''); + const name = receiver.replace(/^(?:this|self)\./, ''); if (!/^[A-Za-z_]\w*$/.test(name)) return null; const inFile = context.getNodesInFile(ref.filePath).filter((n) => n.language === ref.language); let cls: Node | undefined; @@ -5368,7 +5373,7 @@ function bindsNameItself(fn: Node, name: string, context: ResolutionContext): bo const lines = context.getFileLines?.(fn.filePath) ?? context.readFile(fn.filePath)?.split(/\r?\n/) ?? []; const body = lines.slice(fn.startLine - 1, fn.endLine).join('\n'); const r = name.replace(/\$/g, '\\$'); - const binds = new RegExp(`\\b(?:var|val|out\\s+[\\w.<>?]+|foreach\\s*\\(\\s*[\\w.<>?,\\s]+?)\\s+${r}\\b|\\bfor\\s*\\([^;)]*\\s${r}\\s*:|\\b${r}\\s*=>|[(,]\\s*${r}\\s*(?:,[^()]*)?\\)\\s*=>|\\b${r}\\s*(?:,[^{}]*)?->`).test(body); + const binds = new RegExp(`\\b(?:var|val|let|out\\s+[\\w.<>?]+|foreach\\s*\\(\\s*[\\w.<>?,\\s]+?)\\s+${r}\\b|\\bfor\\s*\\([^;)]*\\s${r}\\s*:|\\b${r}\\s*=>|[(,]\\s*${r}\\s*(?:,[^()]*)?\\)\\s*=>|\\b${r}\\s*(?:,[^{}]*)?->`).test(body); memo.set(key, binds); return binds; } @@ -5567,8 +5572,8 @@ function classMemberType(cls: Node, name: string, context: ResolutionContext): s const key = `${cls.id}|${name}`; if (memo.has(key)) return memo.get(key)!; const r = name.replace(/\$/g, '\\$'); - const pattern = cls.language === 'kotlin' - ? new RegExp(`\\b(?:val|var)\\s+${r}\\s*:\\s*([A-Z][\\w.]*)`) + const pattern = cls.language === 'kotlin' || cls.language === 'swift' + ? new RegExp(`\\b(?:val|var|let)\\s+${r}\\s*:\\s*([A-Z][\\w.]*)`) : new RegExp(`(?:^|[\\s(,])([A-Za-z_][\\w.]*)\\s*${TYPE_ARGS}\\??\\s+${r}\\s*(?:[=;,)]|\\{)`); let found: string | null = null; for (const { text, depth } of classMemberLines(cls, context)) {