fix(php): preserve qualified form on TypeRef.rawName in normalizePhpType

Stop collapsing `\App\Models\User` to `User` in normalizePhpType (step 6).
Canonicalize the leading backslash off and preserve the qualified path
on TypeRef.rawName so downstream PHP receiver resolution can distinguish
the FQN target from a same-simple-name class reachable via `use`.

- Step 6 rewritten: `\App\Models\User` → `App\Models\User`,
  `App\Models\User` → unchanged, `User` → unchanged.
- Final validation regex relaxed from /^\w+$/ to /^\w+(?:\\w+)*$/ to
  accept qualified PHP identifiers while still rejecting empty segments,
  trailing backslashes, and non-identifier characters.
- Step 5 (single-arg generic strip) already passes qualified inner types
  through via its existing /^\w[\w\]*/ pattern — no change needed.

U3 will wire the qualified-name lookup path; this commit alone is a
no-op for resolution (the lookup chain still keys on simple names).
PHP suite remains green at 177 passed; the only failing test is the
intentional FQN regression from U1.

Plan: docs/plans/2026-05-11-002-fix-php-fqn-and-overload-codex-findings-plan.md (U2)
This commit is contained in:
Gergo Magyar 2026-05-11 17:05:22 +01:00
parent 0e5e54f2d3
commit 1d69b3f5fa

View file

@ -155,8 +155,15 @@ export function interpretPhpTypeBinding(captures: CaptureMatch): ParsedTypeBindi
* 3. Take first part of `&` intersection
* 4. Strip array suffix `[]`
* 5. Strip generic wrapper `Collection<User>` → `User`
* 6. Strip backslash namespace qualifier `\App\Models\User` → `User`
* 6. Canonicalize leading backslash off: `\App\Models\User` → `App\Models\User`
* 7. Reject PHP primitive / pseudo types
*
* The qualified form is preserved on `TypeRef.rawName` so downstream PHP
* receiver resolution can distinguish `\App\Other\User` from a same-simple-name
* `User` reachable via `use`. Without this, fully-qualified type hints collapse
* to ambiguous simple names and resolve against the caller's scope chain
* instead of the explicit target the source named (Codex PR #1497 review,
* finding 1).
*/
export function normalizePhpType(raw: string): string | null {
// 1. Strip nullable prefix
@ -183,22 +190,28 @@ export function normalizePhpType(raw: string): string | null {
if (type.endsWith('[]')) type = type.slice(0, -2).trim();
// 5. Strip single-arg generic wrapper: Collection<User> → User
// Qualified inner types (Collection<\App\Models\User>) survive — the
// capture group preserves whatever the writer named.
const genericMatch = type.match(/^\w[\w\\]*\s*<([^,<>]+)>$/);
if (genericMatch) {
type = genericMatch[1].trim();
}
// 6. Strip backslash namespace qualifier: \App\Models\User → User
if (type.includes('\\')) {
const segments = type.split('\\').filter(Boolean);
type = segments[segments.length - 1] ?? type;
}
// 6. Canonicalize leading backslash off — keep the qualified path intact.
// `\App\Models\User` → `App\Models\User`. `App\Models\User` → unchanged.
// Unqualified `User` stays as `User`. The qualified form is the lookup
// key into the workspace QualifiedNameIndex (PHP defs are indexed by
// namespace-joined qualifiedName); the leading-backslash distinction in
// source is only an "absolute path" anchor, not part of the canonical key.
if (type.startsWith('\\')) type = type.replace(/^\\+/, '');
// 7. Reject primitives / pseudo-types
if (isPrimitiveOrPseudo(type)) return null;
// Must be a simple identifier
if (!/^\w+$/.test(type)) return null;
// Must be a (possibly qualified) PHP identifier — segments of word chars
// separated by single backslashes. Empty segments (consecutive backslashes,
// trailing backslash) are rejected.
if (!/^\w+(?:\\\w+)*$/.test(type)) return null;
return type;
}