From de74c050bc78b38cd586dde5d90b5009aaa3cca8 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 17:04:59 +0000 Subject: [PATCH] fix(objc): a message to super reaches the superclass (#2179) `[super init]` was judged like a self-send, so the sending class's own override passed (SDDiskCache's [super init] came back to itself), and where tree-sitter-objc lost an @implementation's range no hierarchy was read at all, so any class's init passed (NSObject subclasses' [super init] -> SDDiskCache::init). - a super-send excludes the class it is written in - a lost @implementation's class is read from the enclosing method's qualified name (SDWebImageDownloaderDecryptor::initWithBlock:) Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/objc-super-send.test.ts | 84 +++++++++++++++++++++++++++++++ src/resolution/name-matcher.ts | 35 +++++++++---- 3 files changed, 109 insertions(+), 11 deletions(-) create mode 100644 __tests__/objc-super-send.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e2fad42..3dee2195 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 +- In Objective-C, `[super init]` and other messages to `super` now reach the superclass's method, never the sending class's own override. This also works where the index lost the `@implementation`'s range. On SDWebImage, `NSObject` subclasses' `[super init]` used to land on `SDDiskCache`'s `init`, and `[self class]` in tests on a proxy class's `class`. On AFNetworking, a serializer's `[super encodeWithCoder:]` now reaches its superclass. Re-index after upgrading. - In Solidity, a call with no receiver now reaches a function of the contract around it or of one it inherits. On OpenZeppelin, `_msgSender()` in contracts built on `Context` used to land on `ERC2771Context`'s override 83 times. In Erlang, a call to another module's function now follows the file's `-import(Mod, [f/N])`. On cowboy, `-import(req_SUITE, [do_get/3])` calls used to land on another suite's `do_get/3`. A bare call with no such import no longer links to some other module. Re-index after upgrading. - Calls on a library's own types and modules no longer land on a project method that happens to share the name. In Rust, `Vec::new()`, `String::from(…)` and module paths like `task::spawn(…)` stay the standard library's. A later link of a multi-line Rust or Go chain now counts as a chained call, named after the chain's first line. In Go, `w.Header().Get(…)` is net/http's. In R, a plain call like `range(x)` is a function, not an object's method. In JavaScript, `response.text()` / `.json()` is fetch's. On tokio, `task::spawn(…)` used to land on `TaskTracker::spawn` 300 times. On ggplot2, `range(…)` used to land on a coordinate system's `range` method. Re-index after upgrading. - In C and C++, a qualified name like `fmt::format` now reaches a declaration inside a namespace the library opens with a macro, like fmt's `FMT_BEGIN_NAMESPACE`, pybind11's `PYBIND11_NAMESPACE_BEGIN(…)` or rapidjson's and spdlog's `…_NAMESPACE_BEGIN`. It also works through a namespace alias like `namespace py = pybind11;`. On fmt, 1,728 `fmt::format(…)` calls used to link to nothing, and so did about 1,400 `py::…` calls on pybind11. diff --git a/__tests__/objc-super-send.test.ts b/__tests__/objc-super-send.test.ts new file mode 100644 index 00000000..3a6ba691 --- /dev/null +++ b/__tests__/objc-super-send.test.ts @@ -0,0 +1,84 @@ +/** + * `[super init]` goes past the class it is written in: to the superclass's + * `init`, or — for an NSObject subclass — to no project method at all. + * SDWebImage's NSObject subclasses sent `[super init]` to SDDiskCache's + * `init` (their `@implementation` range was lost, so no hierarchy was read + * and any `init` passed), and SDDiskCache's own `[super init]` came back to + * itself. + */ +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-objc-super-')); + const files: Record = { + 'Base.h': `#import +@interface Base : NSObject +- (instancetype)init; +@end +`, + 'Base.m': `#import "Base.h" +@implementation Base +- (instancetype)init { + self = [super init]; + return self; +} +@end +`, + 'Child.h': `#import "Base.h" +@interface Child : Base +@end +`, + 'Child.m': `#import "Child.h" +@implementation Child +- (instancetype)init { + self = [super init]; + return self; +} +@end +`, + 'Other.m': `#import +@interface Other : NSObject +@end +@implementation Other +- (instancetype)init { + self = [super init]; + return self; +} +@end +`, + }; + for (const [rel, content] of Object.entries(files)) { + 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 }); +}); + +/** `Owner::member` of every call edge out of a file. */ +function callsFrom(file: string): string[] { + const ids = cg.getNodesInFile(file).map((n) => n.id); + return cg + .getOutgoingEdgesFrom(ids) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.target)!.qualifiedName) + .sort(); +} + +describe('Objective-C messages to super', () => { + it('reach the superclass, never the sender itself or an unrelated class', () => { + expect(callsFrom('Child.m')).toEqual(['Base::init']); + expect(callsFrom('Base.m')).toEqual([]); + expect(callsFrom('Other.m')).toEqual([]); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 817abebe..72c46245 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -2590,19 +2590,20 @@ const OBJC_MEMBER_KINDS: ReadonlySet = new Set(['method', 'property', 'f * never a method), `self-send` for a message to `self` / `super` / * `[self class]`, whose receiver the extractor drops; null otherwise. */ -function objcCallShape(ref: UnresolvedRef, context: ResolutionContext): 'c-call' | 'self-send' | null { +function objcCallShape(ref: UnresolvedRef, context: ResolutionContext): 'c-call' | 'self-send' | 'super-send' | null { const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split('\n')[ref.line - 1]; if (line === undefined) return null; const name = ref.referenceName.split(':')[0]!; if (!name) return null; const at = new RegExp(`(? | null { - const here = context - .getNodesInFile(ref.filePath) + const inFile = context.getNodesInFile(ref.filePath); + let here = inFile .filter((c) => c.kind === 'class' && c.startLine <= ref.line && c.endLine >= ref.line) - .sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0]; + .sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0]?.name; + // An `@implementation` whose range the index lost still names its class in + // its methods: `SDWebImageDownloaderDecryptor::initWithBlock:`. + if (!here) { + const method = inFile.find((c) => c.kind === 'method' && c.startLine <= ref.line && c.endLine >= ref.line && c.qualifiedName.includes('::')); + here = method?.qualifiedName.slice(0, method.qualifiedName.lastIndexOf('::')); + } if (!here) return null; const seen = new Set(); - const queue = [here.name]; + const queue = [here]; while (queue.length > 0 && seen.size < 30) { const name = queue.shift()!; if (seen.has(name)) continue; @@ -2639,15 +2646,19 @@ function objcHierarchyAt(ref: UnresolvedRef, context: ResolutionContext): Set