diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8d2c9e7b0..f1979b796 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 PHP, a call through a variable nothing types no longer lands on an unrelated class's method just because it's the only method with that name. The variable now needs to be named after the method's class, or after a class that inherits it (`$page->save()` where `Page` extends `Entity`). On guzzle, the tests' `$response->getHeaderLine()` used to land on a test double; on symfony/console, `$e->getMessage()` on a progress bar.
- In C#, a bare name like `TestContext`, `Easing` or `Helper()` now resolves only to a member of the class it's written in, its outer classes, their base classes, or a type brought in with `using static`. That includes a `global using static` and a project file's ``. On eShop, test classes' `TestContext` used to land on one other test class's property; on Newtonsoft.Json, `TypeNameHandling.All` now reaches the enum rather than a same-named property.
- 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.
diff --git a/__tests__/php-receiver-guess.test.ts b/__tests__/php-receiver-guess.test.ts
new file mode 100644
index 000000000..11afdfbcc
--- /dev/null
+++ b/__tests__/php-receiver-guess.test.ts
@@ -0,0 +1,89 @@
+/**
+ * A PHP call through a receiver nothing typed takes the one project method of
+ * that name only when the receiver is named after its class — or after a
+ * class that inherits it (`$page->save()` → Page extends Entity). Guzzle's
+ * tests' PSR-7 `$response->getHeaderLine()` went to a test double's method,
+ * symfony/console's `$e->getMessage()` to a progress bar's.
+ */
+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-php-receiver-'));
+ const files: Record = {
+ 'src/Models/Entity.php': `save();
+ $avatars->assignToUser($user);
+ } catch (\\Exception $e) {
+ return $e->getMessage();
+ }
+}
+`,
+ };
+ 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('PHP calls through an untyped receiver', () => {
+ it('keep a receiver named after the class or one that inherits the method; drop the rest', () => {
+ const ids = cg.getNodesInFile('src/Controller.php').map((n) => n.id);
+ const targets = cg
+ .getOutgoingEdgesFrom(ids)
+ .filter((e) => e.kind === 'calls')
+ .map((e) => cg.getNode(e.target)!.qualifiedName)
+ .sort();
+ expect(targets).toContain('App\\Models::Entity::save');
+ expect(targets).toContain('App\\Uploads::UserAvatars::assignToUser');
+ expect(targets).not.toContain('App\\Helper::ProgressBar::getMessage');
+ });
+});
diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts
index 9202c43ed..ba0db5813 100644
--- a/src/resolution/name-matcher.ts
+++ b/src/resolution/name-matcher.ts
@@ -1984,6 +1984,28 @@ function isPhpMethodInScope(method: Node, ref: UnresolvedRef, via: 'self' | 'par
return up.leavesRepo && context.getNodesByQualifiedName(ownerQn).some((d) => d.kind === 'trait');
}
+/**
+ * Whether a PHP receiver is named after a class that has `method` in its
+ * ancestry: `$page->save()` → Page, which extends Entity; `$newRole->users()`
+ * → Role. BookStack's `$role->save()` is not Entity's (Role is a Model).
+ */
+function phpReceiverReaches(receiver: string, method: Node, context: ResolutionContext): boolean {
+ const cut = method.qualifiedName.lastIndexOf('::');
+ if (cut < 0) return false;
+ const owner = method.qualifiedName.slice(0, cut);
+ const last = receiver.split('.').pop()!.replace(/^\$/, '');
+ if (!last) return false;
+ const words = splitCamelCase(last);
+ const names = new Set([last, words[words.length - 1] ?? last].map((w) => w.charAt(0).toUpperCase() + w.slice(1)));
+ for (const name of names) {
+ for (const decl of context.getNodesByName(name)) {
+ if (decl.language !== 'php' || !PHP_TYPE_KINDS.has(decl.kind)) continue;
+ if (phpAncestry([decl.qualifiedName], context).qns.has(owner)) return true;
+ }
+ }
+ return false;
+}
+
/** Every type `start` reaches through `extends` and trait `use`, and whether it left the repository on the way. */
function phpAncestry(start: readonly string[], context: ResolutionContext): { qns: Set; leavesRepo: boolean } {
const qns = new Set();
@@ -4496,7 +4518,8 @@ export function matchMethodCall(
if (targetMethods.length === 1 && !narrowed && targetMethods[0]!.language === ref.language &&
!(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)))) {
+ !(ref.language === 'objc' && objcReceiverReaches(objectOrClass!, targetMethods[0]!, context)) &&
+ !(ref.language === 'php' && phpReceiverReaches(objectOrClass!, targetMethods[0]!, context)))) {
return {
original: ref,
targetNodeId: targetMethods[0]!.id,
@@ -5302,9 +5325,11 @@ 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`; Objective-C's
- * `image.respondsToSelector:` is NSObject's, not a proxy class's override.
+ * `image.respondsToSelector:` is NSObject's, not a proxy class's override;
+ * PHP's `$request->has()` is the framework request's, not a settings
+ * service's.
*/
-const UNTYPED_RECEIVER_LANGUAGES: ReadonlySet = new Set(['ruby', 'cfml', 'cfscript', 'objc']);
+const UNTYPED_RECEIVER_LANGUAGES: ReadonlySet = new Set(['ruby', 'cfml', 'cfscript', 'objc', 'php']);
/**
* Whether a receiver is named after the owner of `method`, case aside: the