fix(php): a bare class name is its namespace's class or the imported one (#2235)

isPhpClassVisible: an unqualified PHP class name means the current
namespace's class or the one a `use` imports (none in the project for a
library's); applied before ranking and as a cross-file veto.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-01 02:22:47 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent d9d457d528
commit 3df191b6b9
3 changed files with 111 additions and 0 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- In PHP, an unqualified class name now resolves to the current namespace's class or the one a `use` imports, as PHP does. If the imported class comes from a library, it resolves to nothing in the project. Before, koel's form requests linked `extends Request` to whichever `Request` class was indexed first, and BookStack's controllers linked `Request $request` (Laravel's, by import) to BookStack's own `Request` subclass.
- A call in C code is no longer linked to a C++ class's method, since C has no methods and can't call one. Before, redis's POSIX `read(fd, buf, n)` calls and hiredis's `c->funcs->read(…)` function pointer pointed at a Qt adapter's `read` method.
- In Go, a name written through an imported package from outside the module, like `context.Context`, `http.ResponseWriter` or testify's `require.Contains`, is no longer linked to a project symbol of that name. Before, fiber's `context.Context` parameters pointed at a method `Stream.Context`, its `require.Contains(…)` assertions at an extractor's `Contains`, and gin's `http.ResponseWriter` at its own `ResponseWriter` interface.
- Bare calls in Gradle Kotlin scripts (`build.gradle.kts`), like `plugins { }`, `dependencies { }` or `api(…)`, are no longer linked to a method or property of one of the project's own classes. Build logic's extension functions on Gradle's types still resolve. Before, nowinandroid's build scripts pointed every `plugins { }` and `dependencies { }` block at its build logic's `Graph` class, and every `api(…)` at a lint registry's `api` property.
@@ -0,0 +1,85 @@
/**
* An unqualified PHP class name is the current namespace's class or the one
* a `use` imports — PHP never falls back to another namespace for classes.
* koel's `extends Request` (in `App\Http\Requests\API`, under `use
* App\Http\Requests\API\Request;`, or under `use Saloon\Http\Request;`) all
* went to the first `Request` indexed, and BookStack's controllers'
* `Request $request` (`use Illuminate\Http\Request;`) to its own subclass.
*/
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-ns-'));
const files: Record<string, string> = {
'app/Http/Requests/API/Interaction/Request.php': `<?php
namespace App\\Http\\Requests\\API\\Interaction;
abstract class Request {}
`,
'app/Http/Requests/API/Request.php': `<?php
namespace App\\Http\\Requests\\API;
abstract class Request {}
`,
'app/Http/Requests/API/DeleteSongsRequest.php': `<?php
namespace App\\Http\\Requests\\API;
class DeleteSongsRequest extends Request {}
`,
'app/Http/Requests/API/Playlist/PlaylistSongUpdateRequest.php': `<?php
namespace App\\Http\\Requests\\API\\Playlist;
use App\\Http\\Requests\\API\\Request;
class PlaylistSongUpdateRequest extends Request {}
`,
'app/Http/Integrations/SearchRequest.php': `<?php
namespace App\\Http\\Integrations;
use Saloon\\Http\\Request;
class SearchRequest extends Request {}
`,
};
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 });
});
const supertypesOf = (file: string) => {
const ids = cg.getNodesInFile(file).map((n) => n.id);
return cg.getOutgoingEdgesFrom(ids).filter((e) => e.kind === 'extends').map((e) => cg.getNode(e.target)!.qualifiedName);
};
describe('bare PHP class names', () => {
it('are the namespace’s own class', () => {
expect(supertypesOf('app/Http/Requests/API/DeleteSongsRequest.php')).toEqual(['App\\Http\\Requests\\API::Request']);
});
it('or the one a use imports', () => {
expect(supertypesOf('app/Http/Requests/API/Playlist/PlaylistSongUpdateRequest.php')).toEqual(['App\\Http\\Requests\\API::Request']);
});
it('and nothing in the project when the import is a library’s', () => {
expect(supertypesOf('app/Http/Integrations/SearchRequest.php')).toEqual([]);
});
});
+25
View File
@@ -1336,6 +1336,25 @@ function isGoExternalQualified(ref: UnresolvedRef, context: ResolutionContext):
return external;
}
const PHP_CLASS_KINDS: ReadonlySet<string> = new Set(['class', 'interface', 'trait', 'enum']);
/**
* Whether a bare PHP class name at `ref` can mean `candidate`. An unqualified
* class name is the current namespace's class or the one a `use` imports —
* PHP never falls back to another namespace for classes. koel's `extends
* Request` (under `use Saloon\Http\Request;`, `use App\Http\Requests\API\Request;`,
* or in `App\Http\Requests\API` itself) all went to the first `Request` indexed.
*/
function isPhpClassVisible(candidate: Node, ref: UnresolvedRef, context: ResolutionContext): boolean {
if (ref.language !== 'php' || candidate.language !== 'php' || !PHP_CLASS_KINDS.has(candidate.kind)) return true;
const name = ref.referenceName;
if (!/^[A-Za-z_]\w*$/.test(name) || /^(?:self|static|parent)$/i.test(name)) return true;
const fqn = candidate.qualifiedName.replace(/::/g, '\\');
const scope = phpFileScope(ref.filePath, context);
const imported = scope.uses.get(name);
if (imported !== undefined) return imported.toLowerCase() === fqn.toLowerCase();
return fqn.toLowerCase() === (scope.namespace ? `${scope.namespace}\\${name}` : name).toLowerCase();
}
const SCALA_OBJECT_PACKAGES = new WeakMap<ResolutionContext, Map<string, string | null>>();
/** The full package a Scala file's `package object X` opens (`algebra`, `cats.syntax`), or null for none. */
@@ -1464,6 +1483,8 @@ export function isVisibleAcrossFiles(candidate: Node, ref: UnresolvedRef, contex
// private to the component: shadcn-svelte's 838 `<Item.Root>` (a namespace
// import) went to a `type Item` one example component declares for itself.
if (isSfcPrivate(candidate, context)) return false;
// A bare PHP class name is its namespace's class, or the one a `use` names.
if (!isPhpClassVisible(candidate, ref, context)) return false;
// A Scala package object's member is in scope in its package and those under
// it, or through an import: cats.laws' `Eq` is the `cats` package object's
// alias, not the `algebra` one's (752 refs went there).
@@ -4814,6 +4835,10 @@ export function matchByExactName(
// A C# type name is a type its namespaces can see — ahead of the ranking,
// so a visible namesake wins where the veto after it would drop the
// ref: eShop's `WebhookType.OrderPaid` under `using Webhooks.API.Model;`.
// A bare PHP class name, only its namespace's or the imported one — ahead of
// the ranking, so koel's `extends Request` under `use App\Http\Requests\API\Request;`
// is that class, not the first `Request` indexed.
isPhpClassVisible(n, ref, context) &&
// A Scala package object's member, only where it is in scope — ahead of
// the ranking, so cats.laws' `Eq` can be the `cats` package object's.
!(ref.language === 'scala' && n.language === 'scala' && n.filePath !== ref.filePath &&