fix(php): an untyped receiver keeps a unique method only when named for it (#2152)

PHP joins the untyped-receiver languages for the unique-name method guess:
`$x->m()` takes the one project method named `m` only when the receiver
shares a word with its owner, or names a class (the whole name, or its last
camel word: `$newRole` → Role) whose ancestry — extends + trait `use`,
namespace-aware — has the method.

A/B (edges removed / added): guzzle -359, BookStack -302, console -124,
Slim -124, monica -53. Removals are PSR-7 / framework / exception calls
bound to a test double or unrelated helper; the deliberate loss is calls
through receivers named after neither (`$loaner->loansAsLoaner()`).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 10:47:28 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 6eb08d68cf
commit e6527a8d34
3 changed files with 118 additions and 3 deletions
+1
View File
@@ -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 `<Using … Static="true"/>`. 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.
+89
View File
@@ -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<string, string> = {
'src/Models/Entity.php': `<?php
namespace App\\Models;
abstract class Entity
{
public function save(): bool { return true; }
}
`,
'src/Models/Page.php': `<?php
namespace App\\Models;
class Page extends Entity
{
}
`,
'src/Helper/ProgressBar.php': `<?php
namespace App\\Helper;
class ProgressBar
{
public function getMessage(): string { return ''; }
}
`,
'src/Uploads/UserAvatars.php': `<?php
namespace App\\Uploads;
class UserAvatars
{
public function assignToUser($user): void {}
}
`,
'src/Controller.php': `<?php
namespace App;
function handle($page, $avatars, $user)
{
try {
$page->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');
});
});
+28 -3
View File
@@ -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<string>; leavesRepo: boolean } {
const qns = new Set<string>();
@@ -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<string> = new Set(['ruby', 'cfml', 'cfscript', 'objc']);
const UNTYPED_RECEIVER_LANGUAGES: ReadonlySet<string> = new Set(['ruby', 'cfml', 'cfscript', 'objc', 'php']);
/**
* Whether a receiver is named after the owner of `method`, case aside: the