From 8ca44335029de258b40f8e08d32e0c86e1fdceb4 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Thu, 1 Oct 2026 03:45:49 +0000 Subject: [PATCH] fix(objc,swift): a class conforming to its namesake protocol implements the protocol (#2242) The SwiftUI/UIKit/Vapor resolvers apply to Swift refs only; the shared name pick never returns the declaring type for an inheritance ref; and the supertype re-choice prefers the declaring file, then a protocol/interface for `implements`. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/objc-same-name-protocol.test.ts | 57 +++++++++++++++++++++ src/resolution/frameworks/name-heuristic.ts | 3 ++ src/resolution/frameworks/swift.ts | 10 +++- src/resolution/name-matcher.ts | 13 ++++- 5 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 __tests__/objc-same-name-protocol.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b11ab4010..43abf835b 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 +- An Objective-C class that conforms to a protocol of its own name now links to that protocol instead of to itself. Swift's naming conventions for views, models and controllers also no longer apply to Objective-C code. Before, every one of SDWebImage's `@interface SDDiskCache : NSObject `-style classes implemented itself. - In Solidity, a call to an overloaded function now reaches the overload whose parameter count it fits, as in the other languages with overloading. Before, OpenZeppelin's `_checkRole(role, _msgSender())` inside the one-argument `_checkRole(bytes32 role)` was linked to itself. - In JavaScript and TypeScript, the React conventions for hooks (`use…`) and contexts (`…Context`, `…Provider`) now pick only from the reference's own file. Another file's hook or context is reached only through an import. An optional call like `onSuccess?.(…)` now gets the same scope rules as a plain call. Before, trpc's WebSocket adapter's `createContext?.(…)` option linked to an example app's `createContext`, zod's `schemas.ParseContext` linked to the v3 copy instead of v4's, and bulletproof-react's apps linked `useUser()` to one another's. - In R, a call like `c(1, 2)` is no longer linked to a data variable that happens to share a base function's name, since R skips non-function bindings when it looks up a call. Before, ggplot2's facet tests bound `c <- data_frame(b = 3)`, and every `c(…)` call in those files pointed at that data frame. Functions built by a factory, like ggplot2's `geom_point <- make_constructor(…)`, still resolve. diff --git a/__tests__/objc-same-name-protocol.test.ts b/__tests__/objc-same-name-protocol.test.ts new file mode 100644 index 000000000..df471e353 --- /dev/null +++ b/__tests__/objc-same-name-protocol.test.ts @@ -0,0 +1,57 @@ +/** + * An Objective-C class that conforms to a protocol of its own name — + * SDWebImage's `@interface SDDiskCache : NSObject ` — implements + * the protocol, not itself. The SwiftUI / UIKit / Vapor conventions are + * Swift's (they took the ObjC ref), and among the protocol's copies the + * declaring header's own is the one. + */ +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; + +const header = `#import + +@protocol SDDiskCache +- (nullable NSData *)dataForKey:(nonnull NSString *)key; +@end + +@interface SDDiskCache : NSObject +@end +`; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-objc-protocol-')); + const files: Record = { + 'SDWebImage/Core/SDDiskCache.h': header, + 'SDWebImage/include/SDWebImage/SDDiskCache.h': header, + 'Examples/SwiftUIDemo/ContentView.swift': `import SwiftUI + +struct ContentView: View { + var body: some View { Text("x") } +} +`, + }; + 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('an Objective-C class conforming to its namesake protocol', () => { + it('implements the protocol in its own header', () => { + const cls = cg.getNodesInFile('SDWebImage/Core/SDDiskCache.h').find((n) => n.kind === 'class')!; + const targets = cg.getOutgoingEdgesFrom([cls.id]).filter((e) => e.kind === 'implements').map((e) => cg.getNode(e.target)!); + expect(targets.map((t) => `${t.kind} ${t.filePath}`)).toEqual(['protocol SDWebImage/Core/SDDiskCache.h']); + }); +}); diff --git a/src/resolution/frameworks/name-heuristic.ts b/src/resolution/frameworks/name-heuristic.ts index 9b06843a2..078dd26e1 100644 --- a/src/resolution/frameworks/name-heuristic.ts +++ b/src/resolution/frameworks/name-heuristic.ts @@ -44,8 +44,11 @@ export function pickByNameAndKind( context: ResolutionContext, options: NamePickOptions = {}, ): string | null { + const inheritance = ref.referenceKind === 'extends' || ref.referenceKind === 'implements'; const candidates = context.getNodesByName(ref.referenceName).filter((n) => kinds.has(n.kind) && + // A type never inherits from itself. + !(inheritance && n.id === ref.fromNodeId) && isLexicallyReachable(n, ref, context) && (n.filePath === ref.filePath || (!isNestedType(n, context) && isVisibleAcrossFiles(n, ref, context))) && (!options.accept || options.accept(n))); diff --git a/src/resolution/frameworks/swift.ts b/src/resolution/frameworks/swift.ts index 5702aef4a..241953cb0 100644 --- a/src/resolution/frameworks/swift.ts +++ b/src/resolution/frameworks/swift.ts @@ -40,6 +40,9 @@ export const swiftUIResolver: FrameworkResolver = { }, resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + // Swift's conventions, for Swift's refs: an Objective-C `@interface SDDiskCache + // : NSObject ` is no SwiftUI view or model (languages gates only extraction). + if (ref.language !== 'swift') return null; // Pattern 1: View references (SwiftUI views are PascalCase ending in View) if (ref.referenceName.endsWith('View') && /^[A-Z]/.test(ref.referenceName)) { const result = resolveByNameAndKind(ref, VIEW_KINDS, VIEW_DIRS, context); @@ -106,6 +109,9 @@ export const uikitResolver: FrameworkResolver = { }, resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + // Swift's conventions, for Swift's refs: an Objective-C `@interface SDDiskCache + // : NSObject ` is no SwiftUI view or model (languages gates only extraction). + if (ref.language !== 'swift') return null; // Pattern 1: ViewController references if (ref.referenceName.endsWith('ViewController')) { const result = resolveByNameAndKind(ref, CLASS_KINDS, VC_DIRS, context); @@ -194,13 +200,15 @@ export const vaporResolver: FrameworkResolver = { }, resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + // Swift's conventions, for Swift's refs: an Objective-C `@interface SDDiskCache + // : NSObject ` is no SwiftUI view or model (languages gates only extraction). + if (ref.language !== 'swift') return null; // Pattern 0: a route's handler — `use: SearchController.show` arrives as // `SearchController@show`, `use: self.index` / `use: index` as `@index`. // Resolved on the type the route names, never by the method's name alone: // every controller has a `show`. No match, or two, is left unresolved. const handler = VAPOR_HANDLER.exec(ref.referenceName); if (handler) { - if (ref.language !== 'swift') return null; const target = resolveVaporHandler(handler[1] ?? null, handler[2]!, ref, context); return target ? { original: ref, targetNodeId: target, confidence: 0.9, resolvedBy: 'framework' } : null; } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index b2c635e9c..16609dd72 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -8923,7 +8923,18 @@ function otherSupertypeNamed(ref: UnresolvedRef, context: ResolutionContext): Re const candidates = context.getNodesByName(name).filter((n) => n.id !== ref.fromNodeId && isSupertypeTarget(n) && sameLanguageFamily(n.language, ref.language) && (qualifier === '' || ownerPathOf(n, context) === qualifier || ownerPathOf(n, context).endsWith(`.${qualifier}`))); - return candidates.length === 1 ? { original: ref, targetNodeId: candidates[0]!.id, confidence: 0.8, resolvedBy: 'qualified-name' } : null; + // Among several, the declaring file's own, then (for `implements`) a protocol / interface / trait: + // SDWebImage's `@interface SDWebImageCacheKeyFilter : NSObject `. + let pool = candidates; + if (pool.length > 1) { + const sameFile = pool.filter((n) => n.filePath === ref.filePath); + if (sameFile.length > 0) pool = sameFile; + } + if (pool.length > 1 && ref.referenceKind === 'implements') { + const conformable = pool.filter((n) => n.kind === 'protocol' || n.kind === 'interface' || n.kind === 'trait'); + if (conformable.length > 0) pool = conformable; + } + return pool.length === 1 ? { original: ref, targetNodeId: pool[0]!.id, confidence: 0.8, resolvedBy: 'qualified-name' } : null; } /** A declaration's dotted owner path — its file's package clauses, then its enclosing types (`cats.kernel.instances`, `cats.Bifoldable`). */