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<Data>`,
`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 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 21:15:33 +00:00
committed by GitHub
parent e7be363f95
commit a329df883a
3 changed files with 71 additions and 5 deletions
+1
View File
@@ -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.
+60
View File
@@ -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<string, string> = {
'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']);
});
});
+10 -5
View File
@@ -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<string> = new Set(['csharp', 'java', 'kotlin']);
const MEMBER_TYPED_LANGUAGES: ReadonlySet<string> = new Set(['csharp', 'java', 'kotlin', 'swift']);
const MEMBER_CLASS_KINDS: ReadonlySet<string> = new Set(['class', 'struct', 'interface', 'enum', 'record']);
const MEMBER_TYPE_MEMO = new WeakMap<ResolutionContext, Map<string, string | null>>();
/** Words that can stand where a declaration's type does without being one. */
@@ -5336,7 +5341,7 @@ const MEMBER_TYPE_NON_TYPES: ReadonlySet<string> = 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)) {