From 3df191b6b91ac3fda8e337d1ecfccd6c1f80b85c Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Thu, 1 Oct 2026 02:22:47 +0000 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + .../php-namespace-class-visibility.test.ts | 85 +++++++++++++++++++ src/resolution/name-matcher.ts | 25 ++++++ 3 files changed, 111 insertions(+) create mode 100644 __tests__/php-namespace-class-visibility.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 23a25e17..dd5ec633 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, 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. diff --git a/__tests__/php-namespace-class-visibility.test.ts b/__tests__/php-namespace-class-visibility.test.ts new file mode 100644 index 00000000..04a313ab --- /dev/null +++ b/__tests__/php-namespace-class-visibility.test.ts @@ -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 = { + 'app/Http/Requests/API/Interaction/Request.php': ` { + 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([]); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index f631dc4a..608f7975 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1336,6 +1336,25 @@ function isGoExternalQualified(ref: UnresolvedRef, context: ResolutionContext): return external; } +const PHP_CLASS_KINDS: ReadonlySet = 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>(); /** 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 `` (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 &&