fix(objc): a message reached by name alone must be in reach (#2150)

* fix(objc): a message reached by name alone must be in reach

- `[self m]` / `[super m]` / `[[self class] m]` (receiver dropped by the
  extractor) reach only methods of the sender's hierarchy, read from
  `@interface Name : Super` plus a small UIKit/AppKit/MapKit superclass table
  for categories on system classes. When tree-sitter-objc loses an
  @implementation and its methods become functions, a same-file function,
  one in the file named after a hierarchy class, or any when the sender's
  class itself was lost, still counts; `[super init]` never lands on another
  file's function.
- C call syntax (`completionBlock()`) never means a method or property.
- Local receivers are typed from ObjC declarations (`FMResultSet *rs`,
  `(nullable FMResultSet *)rs`, block params), and objc joins the untyped-
  receiver languages for the unique-name guess (`weakSelf` / `strongSelf`
  count as self). Shared receiver words must be 3+ letters so class prefixes
  (`SD`, `NS`) don't match; Ruby/CFML unchanged but one wrong lobsters edge.

A/B (edges removed / added): SDWebImage -321/+47, fmdb -28/+85,
CocoaLumberjack -19/+10, AFNetworking -13/+1, MBProgressHUD 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(objc): a class or typed-property receiver keeps its inherited method

`[AllTypesObject objectsInRealm:…]` (a class method inherited from RLMObject)
and `[managed.anyDataObj containsObject:]` (a property declared
`RLMSet<…> *anyDataObj`) now keep the unique-name match when the receiver's
class — or one of the property's declared types — has the method's owner in
its hierarchy. realm-swift goes from -2562/+588 to -504/+529 vs main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 10:33:32 +00:00
committed by GitHub
parent 889b6a3526
commit b238e240e2
3 changed files with 289 additions and 7 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- In Objective-C, a message now reaches a method only when it could really be sent there. A message to `self` or `super` reaches only the sender's class and its superclasses, including categories on UIKit and AppKit views. A C-style call like `completionBlock()` is never taken for a method or property. A message to a local variable uses the variable's declared type (`FMResultSet *rs`). On SDWebImage, `[self class]`, `respondsToSelector:` and `isKindOfClass:` used to land on a proxy class's overrides over 150 times, and on fmdb `[rs close]` now reaches the result set.
- In VB.NET, a call through a receiver like `Me.Panel.Controls.Add(…)` or `New System.Drawing.Size(…)` no longer lands on an unrelated class's member with the same name. It now resolves only through `Me`, `MyBase` or `MyClass`, through the member's own class or module (`Logger.Log(…)`), or through a service lookup's type (`GetService(Of Notifier).Notify()`). On SCrawler, the Windows Forms designer code used to link over 700 `Size` calls to a nested enum's case and over 500 `Controls.Add` calls to a collection class.
- In CFML, a call no longer lands on an unrelated component's method just because the name matches. A call with no receiver inside a component now reaches only that component's methods and those of the components it extends, so built-ins like `now()` stay built-ins. A call on an untyped variable now needs the variable to be named after the component, so `server.keyExists(…)` is no longer taken for some component's `keyExists`. On coldbox, `now()` used to land on a date helper over 200 times, and TestBox's `fail(…)` in specs now reaches the spec base class.
- In Ruby, a call no longer lands on an unrelated class's method just because it's the only method with that name. A call on an untyped receiver now needs the receiver to be named after the method's class, like `web_push_request.legacy_encrypt`. A call with no receiver inside a class now reaches only that class's methods, its superclasses' methods, and those of the modules it includes, extends or prepends. On rubocop, `node.loc` on a syntax node used to land on the project's one `loc` method over 1,200 times, and Kernel's `format(…)` on the language server's `format`. On mastodon, `render` in controllers used to land on an inline renderer, and `authorize` now reaches the authorization concern.
+118
View File
@@ -0,0 +1,118 @@
/**
* An Objective-C message reached by name alone must be in reach:
*
* - a message to `self` / `super` (the extractor drops the receiver) is a
* method of the sender's class hierarchy — SDWebImage's `[self class]`
* went to SDWeakProxy's `class` 71 times;
* - C call syntax (`completionBlock()`) calls a block, a function or a
* function pointer, never a method or property;
* - a message to an untyped local needs its declaration (`FMResultSet *rs`)
* or a receiver named after the class — `[image respondsToSelector:]` is
* NSObject's, not the one proxy class that overrides it.
*/
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-scope-'));
const files: Record<string, string> = {
'Core/SDWeakProxy.h': `#import <Foundation/Foundation.h>
@interface SDWeakProxy : NSProxy
- (Class)class;
- (BOOL)respondsToSelector:(SEL)aSelector;
@end
`,
'Core/SDWeakProxy.m': `#import "SDWeakProxy.h"
@implementation SDWeakProxy
- (Class)class {
return nil;
}
- (BOOL)respondsToSelector:(SEL)aSelector {
return NO;
}
@end
`,
'Core/SDPrefetchToken.h': `#import <Foundation/Foundation.h>
@interface SDPrefetchToken : NSObject
@property (nonatomic, copy) void (^completionBlock)(void);
@end
`,
'Core/FMResultSet.h': `#import <Foundation/Foundation.h>
@interface FMResultSet : NSObject
- (BOOL)next;
@end
`,
'Core/FMResultSet.m': `#import "FMResultSet.h"
@implementation FMResultSet
- (BOOL)next {
return NO;
}
@end
`,
'Core/SDBase.h': `#import <Foundation/Foundation.h>
@interface SDBase : NSObject
- (void)prepare;
@end
`,
'Core/SDBase.m': `#import "SDBase.h"
@implementation SDBase
- (void)prepare {
}
@end
`,
'Core/SDCache.h': `#import "SDBase.h"
@interface SDCache : SDBase
- (void)storeWithCompletion:(void (^)(void))completionBlock image:(id)image;
@end
`,
'Core/SDCache.m': `#import "SDCache.h"
#import "FMResultSet.h"
@implementation SDCache
- (void)storeWithCompletion:(void (^)(void))completionBlock image:(id)image {
Class cls = [self class];
[super prepare];
[self helper];
FMResultSet *rs = [self query];
while ([rs next]) {
}
if ([image respondsToSelector:@selector(copy)]) {
}
completionBlock();
}
- (void)helper {
}
- (FMResultSet *)query {
return nil;
}
@end
`,
};
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('Objective-C messages reached by name alone', () => {
it('reach the sender’s hierarchy and typed locals; never a proxy override or a property through C call syntax', () => {
const method = cg.getNodesInFile('Core/SDCache.m').find((n) => n.name.startsWith('storeWithCompletion'))!;
const targets = cg
.getOutgoingEdges(method.id)
.filter((e) => e.kind === 'calls')
.map((e) => cg.getNode(e.target)!.qualifiedName)
.sort();
expect(targets).toEqual(['FMResultSet::next', 'SDBase::prepare', 'SDCache::helper', 'SDCache::query']);
});
});
+170 -7
View File
@@ -1431,6 +1431,150 @@ function dartHeadOf(decl: Node, context: ResolutionContext): { supers: string[];
};
}
const OBJC_SUPERS = new WeakMap<ResolutionContext, Map<string, string[]>>();
const OBJC_MEMBER_KINDS: ReadonlySet<string> = new Set(['method', 'property', 'field']);
/**
* How a bare Objective-C name is written at its site: `c-call` for C call
* syntax (`completionBlock()` — a function, a block or a function pointer,
* 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 {
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(`(?<![\\w.])${name}\\b`, 'g');
let m: RegExpExecArray | null;
let shape: 'c-call' | 'self-send' | null = null;
while ((m = at.exec(line))) {
const before = line.slice(0, m.index);
const after = line.slice(m.index + name.length);
if (/^\s*\(/.test(after) && !/\[\s*[\w.]+\s+$/.test(before)) shape ??= 'c-call';
else if (/\[\s*(?:self|super|\[\s*self\s+class\s*\])\s+$/.test(before)) return 'self-send';
}
return shape;
}
/**
* The classes a message to `self` / `super` can reach: the class it is
* written in and every class that one inherits from (read from its
* `@interface Name : Super` declarations; category methods are indexed under
* the class they extend). Null outside any class.
*/
function objcHierarchyAt(ref: UnresolvedRef, context: ResolutionContext): Set<string> | null {
const here = context
.getNodesInFile(ref.filePath)
.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];
if (!here) return null;
const seen = new Set<string>();
const queue = [here.name];
while (queue.length > 0 && seen.size < 30) {
const name = queue.shift()!;
if (seen.has(name)) continue;
seen.add(name);
queue.push(...objcSupertypesOf(name, context));
}
return seen;
}
/**
* Whether a message to `self` / `super` can mean `n`: a method of a class in
* the sender's hierarchy — SDWebImage's `[self class]` went to SDWeakProxy's
* `class` 71 times. When tree-sitter-objc loses an `@implementation`
* (AFNetworking's AFURLSessionManager) its methods are indexed as functions:
* one in the sender's own file, or in the file named after a class of its
* hierarchy, still counts (as does any, when the sender's own class was lost
* too). A function elsewhere is never what `[super init]` sends to.
*/
function isObjcSelfSendTarget(n: Node, ref: UnresolvedRef, context: ResolutionContext): boolean {
const hierarchy = objcHierarchyAt(ref, context);
if (OBJC_MEMBER_KINDS.has(n.kind)) {
const cut = n.qualifiedName.lastIndexOf('::');
return cut < 0 || hierarchy === null || hierarchy.has(n.qualifiedName.slice(0, cut));
}
if (n.filePath === ref.filePath || hierarchy === null) return true;
const base = n.filePath.slice(n.filePath.lastIndexOf('/') + 1).replace(/\.\w+$/, '');
return hierarchy.has(base);
}
/**
* UIKit / AppKit superclasses, for a category on a system class: an
* `UIImageView (WebCache)` method sending `[self sd_internalSetImageWithURL:…]`
* reaches the `UIView (WebCache)` category.
*/
const OBJC_SYSTEM_SUPERS: Readonly<Record<string, string>> = {
UIResponder: 'NSObject', UIView: 'UIResponder', UIViewController: 'UIResponder', UIWindow: 'UIView',
UIControl: 'UIView', UIButton: 'UIControl', UITextField: 'UIControl', UISwitch: 'UIControl', UISlider: 'UIControl',
UIImageView: 'UIView', UILabel: 'UIView', UIScrollView: 'UIView', UITableView: 'UIScrollView',
UICollectionView: 'UIScrollView', UITextView: 'UIScrollView', UITableViewCell: 'UIView',
UICollectionReusableView: 'UIView', UICollectionViewCell: 'UICollectionReusableView',
MKAnnotationView: 'UIView', MKMapView: 'UIView',
NSResponder: 'NSObject', NSView: 'NSResponder', NSViewController: 'NSResponder', NSWindow: 'NSResponder',
NSControl: 'NSView', NSImageView: 'NSControl', NSButton: 'NSControl', NSTextField: 'NSControl', NSTableView: 'NSControl',
};
/**
* Whether an Objective-C receiver's type owns `method`, in its hierarchy: the
* receiver names a class (`[AllTypesObject objectsInRealm:…]` — a class
* method inherited from RLMObject), or is a property one of whose
* `@property … Type *name` declarations gives such a type
* (`managed.anyDataObj` → RLMSet, for `containsObject:`).
*/
function objcReceiverReaches(receiver: string, method: Node, context: ResolutionContext): boolean {
const cut = method.qualifiedName.lastIndexOf('::');
if (cut < 0) return false;
const owner = method.qualifiedName.slice(0, cut);
let types: string[] = [];
if (/^[A-Z]\w*$/.test(receiver)) {
if (context.getNodesByName(receiver).some((n) => n.kind === 'class' && n.language === 'objc')) types = [receiver];
} else if (receiver.includes('.')) types = objcPropertyTypes(receiver.split('.').pop()!, context);
const seen = new Set<string>();
const queue = [...types];
while (queue.length > 0 && seen.size < 40) {
const name = queue.shift()!;
if (seen.has(name)) continue;
seen.add(name);
if (name === owner) return true;
queue.push(...objcSupertypesOf(name, context));
}
return false;
}
/** The classes the `@property … Type *name` declarations of `name` give. */
function objcPropertyTypes(name: string, context: ResolutionContext): string[] {
const types = new Set<string>();
const decl = new RegExp(`@property\\s*(?:\\([^)]*\\)\\s*)?([A-Z]\\w*)\\s*(?:<[^;]*>\\s*)?\\*\\s*(?:_Nullable\\s+|_Nonnull\\s+)?${name}\\b`);
for (const n of context.getNodesByName(name)) {
if (n.kind !== 'property' || n.language !== 'objc') continue;
const line = context.getFileLines?.(n.filePath)?.[n.startLine - 1] ?? context.readFile(n.filePath)?.split('\n')[n.startLine - 1] ?? '';
const t = decl.exec(line)?.[1];
if (t) types.add(t);
}
return [...types];
}
/** The superclasses an Objective-C class's `@interface` declarations name. */
function objcSupertypesOf(name: string, context: ResolutionContext): string[] {
let memo = OBJC_SUPERS.get(context);
if (!memo) OBJC_SUPERS.set(context, (memo = new Map()));
const hit = memo.get(name);
if (hit) return hit;
const supers: string[] = [];
for (const decl of context.getNodesByName(name)) {
if (decl.kind !== 'class' || decl.language !== 'objc') continue;
const line = context.getFileLines?.(decl.filePath)?.[decl.startLine - 1] ?? context.readFile(decl.filePath)?.split('\n')[decl.startLine - 1] ?? '';
const sup = /@interface\s+\w+\s*:\s*(\w+)/.exec(line)?.[1];
if (sup && !supers.includes(sup)) supers.push(sup);
}
const system = OBJC_SYSTEM_SUPERS[name];
if (supers.length === 0 && system) supers.push(system);
memo.set(name, supers);
return supers;
}
const VB_MEMBER_KINDS: ReadonlySet<string> = new Set(['method', 'property', 'field', 'enum_member', 'constant', 'variable']);
/**
@@ -2104,8 +2248,12 @@ export function matchByExactName(
const cfmlBare = (ref.language === 'cfml' || ref.language === 'cfscript') && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*$/.test(ref.referenceName);
const vbReceiver = ref.language === 'vbnet' && (ref.referenceKind === 'calls' || ref.referenceKind === 'instantiates') && /^\w+$/.test(ref.referenceName)
? vbReceiverOf(ref, context) : null;
const objcShape = ref.language === 'objc' && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*:*(?:\w+:)*$/.test(ref.referenceName)
? objcCallShape(ref, context) : null;
const phpSelf = phpSelfReceiver(ref, context);
const filtered = sameName.filter((n) =>
!(objcShape === 'c-call' && OBJC_MEMBER_KINDS.has(n.kind)) &&
!(objcShape === 'self-send' && !isObjcSelfSendTarget(n, ref, context)) &&
!(vbReceiver !== null && !isVbMemberReachable(n, vbReceiver)) &&
!(rubyBare && n.kind === 'method' && !isRubyMethodInScope(n, ref, context)) &&
!(cfmlBare && n.kind === 'method' && !isCfmlMethodInScope(n, ref, context)) &&
@@ -3227,6 +3375,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void {
KOTLIN_FILE_SCOPES.delete(context);
RUBY_ANCESTRY.delete(context);
CFML_CHAINS.delete(context);
OBJC_SUPERS.delete(context);
LUA_LOCALS.delete(context);
PHP_FILE_SCOPES.delete(context);
JAVA_STATIC_IMPORTS.delete(context);
@@ -3302,6 +3451,13 @@ function buildLocalReceiverTypePatterns(language: Language, r: string): RegExp[]
new RegExp(`\\b${r}\\b\\s*=\\s*new\\s+([A-Za-z_][\\w.]*)`), // = new Logger()
new RegExp(`\\b([A-Z][\\w.]*)\\s+${r}\\b\\s*[=;,)]`), // Logger lg; / param
];
case 'objc':
return [
// FMResultSet *rs = … / NSString * _Nullable name; / a block's ^(FMResultSet *rs)
new RegExp(`\\b([A-Z]\\w*)\\s*(?:<[^<>;]*>\\s*)?\\*\\s*(?:(?:_Nullable|_Nonnull|__strong|__weak|__unsafe_unretained|const)\\s+)*${r}\\b(?!\\s*\\()`),
// a method parameter: - (void)read:(nullable FMResultSet *)rs
new RegExp(`\\(\\s*(?:(?:nullable|nonnull|__kindof)\\s+)*([A-Z]\\w*)\\s*(?:<[^<>)]*>\\s*)?\\*[^)]*\\)\\s*${r}\\b`),
];
case 'swift':
return [
new RegExp(`\\b${r}\\b\\s*=\\s*([A-Z][\\w.]*)\\s*\\(`), // let lg = Logger(...)
@@ -4216,8 +4372,9 @@ export function matchMethodCall(
// `node.loc` on a rubocop-ast node went to the one `loc` in the project
// 1,201 times; lobsters' `value.to_s` to a short-id class's.
if (targetMethods.length === 1 && !narrowed && targetMethods[0]!.language === ref.language &&
!(UNTYPED_RECEIVER_LANGUAGES.has(ref.language) && !/^(?:self|self\.class|this|super)$/i.test(objectOrClass!) &&
!sharesReceiverWord(objectOrClass!, targetMethods[0]!))) {
!(UNTYPED_RECEIVER_LANGUAGES.has(ref.language) && !/^(?:self|self\.class|this|super|weak_?self|strong_?self)$/i.test(objectOrClass!) &&
!sharesReceiverWord(objectOrClass!, targetMethods[0]!) &&
!(ref.language === 'objc' && objcReceiverReaches(objectOrClass!, targetMethods[0]!, context)))) {
return {
original: ref,
targetNodeId: targetMethods[0]!.id,
@@ -5022,15 +5179,16 @@ function hasParameterBinding(code: string, escapedName: string): boolean {
/**
* Languages whose receivers nothing types, where a unique method name alone
* is no evidence: CFML's `server.keyExists()` is the struct member function,
* not the one component method named `keyExists`.
* not the one component method named `keyExists`; Objective-C's
* `image.respondsToSelector:` is NSObject's, not a proxy class's override.
*/
const UNTYPED_RECEIVER_LANGUAGES: ReadonlySet<string> = new Set(['ruby', 'cfml', 'cfscript']);
const UNTYPED_RECEIVER_LANGUAGES: ReadonlySet<string> = new Set(['ruby', 'cfml', 'cfscript', 'objc']);
/**
* Whether a receiver is named after the owner of `method`, case aside: the
* receiver's last segment is the owner's name (`cbsecurity` → CBSecurity), or
* they share a word (`web_push_request` → WebPushRequest, `executor1` →
* Executor).
* they share a word of three letters or more (`web_push_request` →
* WebPushRequest, `executor1` → Executor, `decodedImage` → UIImage).
*/
function sharesReceiverWord(receiver: string, method: Node): boolean {
const cut = method.qualifiedName.lastIndexOf('::');
@@ -5038,7 +5196,8 @@ function sharesReceiverWord(receiver: string, method: Node): boolean {
const ownerQn = method.qualifiedName.slice(0, cut);
const flat = (w: string) => w.replace(/[^A-Za-z0-9]/g, '').replace(/\d+$/, '').toLowerCase();
if (flat(receiver.split('.').pop()!) === flat(ownerQn.split(/::|\./).pop()!)) return true;
const owner = new Set(splitCamelCase(ownerQn).map(flat));
// Two-letter words are class prefixes (`SD`, `NS`, `UI`), not names.
const owner = new Set(splitCamelCase(ownerQn).map(flat).filter((w) => w.length > 2));
return splitCamelCase(receiver).some((w) => owner.has(flat(w)));
}
@@ -5216,6 +5375,8 @@ export function matchFuzzy(
const cfmlBare = (ref.language === 'cfml' || ref.language === 'cfscript') && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*$/.test(ref.referenceName);
const vbReceiver = ref.language === 'vbnet' && (ref.referenceKind === 'calls' || ref.referenceKind === 'instantiates') && /^\w+$/.test(ref.referenceName)
? vbReceiverOf(ref, context) : null;
const objcShape = ref.language === 'objc' && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*:*(?:\w+:)*$/.test(ref.referenceName)
? objcCallShape(ref, context) : null;
const phpSelf = phpSelfReceiver(ref, context);
// Names are case-sensitive in every language but a handful: Rust's
// `Bytes` is not the method `bytes`, Python's builtin `dir(…)` not a class
@@ -5235,6 +5396,8 @@ export function matchFuzzy(
!(rubyBare && n.kind === 'method' && !isRubyMethodInScope(n, ref, context)) &&
!(cfmlBare && n.kind === 'method' && !isCfmlMethodInScope(n, ref, context)) &&
!(vbReceiver !== null && !isVbMemberReachable(n, vbReceiver)) &&
!(objcShape === 'c-call' && OBJC_MEMBER_KINDS.has(n.kind)) &&
!(objcShape === 'self-send' && !isObjcSelfSendTarget(n, ref, context)) &&
!(phpSelf && (n.kind !== 'method' || !isPhpMethodInScope(n, ref, phpSelf, context))))
.filter((n) => (ref.referenceKind !== 'references' && ref.referenceKind !== 'function_ref') ||
sameLanguageFamily(n.language, ref.language));