fix(php): inject FQN-keyed module-scope bindings (Codex #1497 finding 1)

Extend populatePhpNamespaceSiblings with Step 3b: for every PHP file's
Module scope, inject a binding entry keyed by the fully-qualified
class name (`App\Models\User`) for every class-like def in the
workspace. This routes FQN-receivers like `\App\Other\User` to the
exact namespace-qualified class regardless of which simple-name `User`
the caller's `use` imports shadowed.

Why module-scope bindingAugmentations instead of mutating def.qualifiedName:
the shared QualifiedNameIndex consumes def.qualifiedName at finalize time,
but PHP class defs need to remain keyed by simple name throughout the rest
of the pipeline (MRO, method-dispatch-index, namespace-siblings step 3).
An earlier attempt to rewrite def.qualifiedName to namespace-prefixed form
cascaded into 32 unrelated test failures across receiver-binding, MRO, and
heritage. The bindingAugmentations channel is purpose-built for adding
post-finalize visibility without mutating shared semantic state, and
`findClassBindingInScope`'s scope-chain walk already consumes it via
`lookupBindingsAt` — wiring is zero-touch.

Cost: O(PHP files × class-like defs) augmentation entries. Typical PHP
project: hundreds × hundreds = bounded.

Verified locally:
- Registry-primary: 178/178 PHP tests pass (including the new FQN regression).
- Legacy DAG: 174 passed, 3 existing skips, 1 failure (the FQN test, expected
  — legacy DAG has no namespace augmentation channel; U5 registers it in
  LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.php).
- Cross-language: 794/794 (C#, Python, TypeScript, Go, C) pass. The PHP-only
  augmentation does not touch shared resolution code.
- tsc clean.

Plan: docs/plans/2026-05-11-002-fix-php-fqn-and-overload-codex-findings-plan.md (U3)
This commit is contained in:
Gergo Magyar 2026-05-11 18:12:35 +01:00
parent 1d69b3f5fa
commit 4ffe5449ac

View file

@ -117,8 +117,8 @@ const namespaceByFilePath = new Map<string, string>();
/**
* Read the cached PHP namespace for a given filePath. Returns `''` (global)
* when the file has no namespace_definition or hasn't been processed yet.
* Callers should only consult this AFTER `populatePhpNamespaceSiblings` has
* run for the current resolution.
* Callers should only consult this AFTER either `populatePhpClassQualifiedNames`
* or `populatePhpNamespaceSiblings` has run for the current resolution.
*/
export function getPhpNamespaceForFile(filePath: string): string {
return namespaceByFilePath.get(filePath) ?? '';
@ -240,6 +240,38 @@ export function populatePhpNamespaceSiblings(
}
}
// Step 3b: Inject fully-qualified-name bindings into every PHP file's
// Module scope. PHP `\App\Models\User` (leading-backslash FQN) and
// `App\Models\User` (already-qualified relative) on a parameter or
// typed receiver must resolve to the exact namespace-qualified class
// regardless of which simple-name `User` the caller's `use` imports
// shadowed. The shared `findClassBindingInScope` scope-chain walk
// consumes these augmentations via `lookupBindingsAt`, so adding the
// qualified key on every file's module scope routes FQN-receivers to
// the right def. Codex PR #1497 review, finding 1.
//
// Cost: O(PHP files × class-like defs in the workspace) augmentation
// entries. Bounded and acceptable in practice — typical PHP projects
// have hundreds of files and classes, not tens of thousands.
for (const parsed of parsedFiles) {
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
if (moduleScope === undefined) continue;
const moduleScopeId = moduleScope.id;
for (const [ns, bucket] of buckets) {
if (ns === '') continue; // global-namespace classes have no qualified form to register
for (const def of bucket.classDefs) {
const q = def.qualifiedName ?? '';
const simpleName = q.includes('\\') ? q.slice(q.lastIndexOf('\\') + 1) : q;
if (simpleName === '') continue;
const fqn = `${ns}\\${simpleName}`;
const arr = getAugmentationBucket(augmentations, moduleScopeId, fqn);
if (arr.some((b) => b.def.nodeId === def.nodeId)) continue;
arr.push({ def, origin: 'namespace' });
}
}
}
// Step 4: Mirror return-type bindings from same-namespace sibling files.
// This enables chain-follow like `$c->greet()->save()` where `greet()`
// returns `Greeting` (declared in A.php, same namespace) and `Greeting`