Skip to content

fix: resolve docblock types of anonymous classes against their lexical namespace - #825

Merged
romm merged 2 commits into
CuyZ:masterfrom
lunetics:fix/anonymous-class-namespace-resolution
Jul 28, 2026
Merged

fix: resolve docblock types of anonymous classes against their lexical namespace#825
romm merged 2 commits into
CuyZ:masterfrom
lunetics:fix/anonymous-class-namespace-resolution

Conversation

@lunetics

@lunetics lunetics commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Builds on #824. Its reproduction test is the first commit here, authorship preserved.

Root cause

The namespace used to resolve short docblock type names comes from the class name (ReflectionClass::getNamespaceName() / inNamespace()). For an anonymous class that name is synthetic. It carries no namespace at all, or the namespace of the extended/implemented type. It does not reliably carry the lexical namespace of the file the class was written in.

This breaks both resolution layers. The use statements table comes back empty, because TokenParser::parseUseStatements() never matches the file's namespace block, so imported symbols fail. And the same-namespace fallback is never entered; for parent-prefixed synthetic names, resolution is attempted against the parent type's namespace instead.

A partial-fix experiment separates the two: with only the second layer fixed, the original tests of this PR turn green while a test using an imported symbol stays red. Each layer therefore has its own regression test in this PR.

The fix

For anonymous classes, the lexical namespace is recovered from the tokenized source, behind an isAnonymous() check. Closures already worked this way, and they are anonymous in the same sense, so both now share one code path. Named classes and named functions keep byte-identical behaviour. That includes named eval()'d classes: no source is available for them, but their name still carries the namespace, and a dedicated test pins that. PhpParser::parseNamespace() now accepts classes and memoizes per file/line, mirroring parseUseStatements().

BC note

For anonymous classes extending or implementing a namespaced type, short-name resolution was previously attempted against the parent type's namespace, and could only succeed where a class of the same name exists there. It now resolves against the declaring file's namespace, which is how PHP itself resolves type hints at the declaration site.

Pre-existing issues noticed while testing (out of scope)

  • An eval()'d anonymous class cannot be mapped by its class name at all; its synthetic name does not survive the type-signature parser (InvalidMappingTypeSignature).
  • Compiling the definition of an eval()'d closure with an unresolvable docblock type does not round-trip through the filesystem cache (CorruptedCompiledPhpCacheFile). Whether the trigger is the missing source or the unresolvable type was not isolated.

Validation

  • Full suite green on PHP 8.2, 8.3, 8.4 and 8.5 (2708 tests); mutation tests at 100% MSI on the changed lines.
  • The new regression tests fail on master without the src/ changes; the FQCN control passes.
  • An escaped mutant in the first CI round exposed a missing guard: nothing pinned that named eval()'d classes must keep resolving through their name. test_named_class_declared_without_source_file_can_use_type_from_its_own_namespace covers that now.
  • PHPStan, Psalm and PHP-CS-Fixer are clean.

if ($reflection instanceof ReflectionClass && $reflection->isAnonymous()) {
// An anonymous class name carries either no namespace or the
// extended/implemented type's namespace, not reliably the lexical one.
$namespace = PhpParser::parseNamespace($reflection);

@lunetics lunetics Jul 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An alternative here would be to always recover the namespace from the source:

$namespace = PhpParser::parseNamespace($reflection);

Two problems with that. Named eval()'d classes would regress: their name carries the correct namespace today, but no source is available to parse, so the lookup would come back empty. And it adds file I/O plus tokenization for every named class where none exists today. The isAnonymous() check limits the new path to the only case where the reflection name is unreliable.

This invariant is pinned by test_named_class_declared_without_source_file_can_use_type_from_its_own_namespace, added after an Infection mutant escaped by breaking exactly this case.

Comment on lines +77 to 80
if ($reflection instanceof ReflectionFunction || $reflection->isAnonymous()) {
// An anonymous class name carries either no namespace or the
// extended/implemented type's namespace, not reliably the lexical one.
$namespaceName = $tokenParser->getNamespace();

@lunetics lunetics Jul 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An alternative here would be to derive the namespace from the token stream for all reflections, named classes included, since the content is already tokenized at this point. The whole conditional (including the ReflectionMethod reassignment above) would collapse to:

        $namespaceName = $tokenParser->getNamespace();

Both variants pass the full test suite (2708 tests) on this branch. The uniform variant stops relying on reflection names entirely, but it changes the code path for every named class to fix a bug that only affects anonymous ones, and it widens the mutation-testing surface under --min-msi=100. The first CI round of this PR demonstrated that mechanism: a LogicalAnd mutant escaped on the sibling condition until a dedicated test pinned it. Cost is one extra token walk per class-definition parse; the content is already read and tokenized, and the result is cached per file/line.

The PR keeps the isAnonymous() branch so named classes stay byte-identical. Switching to the uniform variant is a small change if preferred.

@lunetics
lunetics marked this pull request as draft July 18, 2026 21:37
@lunetics
lunetics force-pushed the fix/anonymous-class-namespace-resolution branch from 319c06f to 535b84b Compare July 18, 2026 21:43
@lunetics

Copy link
Copy Markdown
Contributor Author

AI (SLOP) Disclaimer, i'm currently building a gated, harnessed llm loop bug scout, so this is also just an experiment i took on a quick glance because @Ocramius mentioned this bug. Feel free to just toss it into the bin, no hard feelings :)

@lunetics
lunetics marked this pull request as ready for review July 18, 2026 22:17
@romm
romm force-pushed the fix/anonymous-class-namespace-resolution branch from 535b84b to bbf1ada Compare July 28, 2026 20:29
Ocramius and others added 2 commits July 28, 2026 22:30
Why the old code was problematic: the namespace used to resolve short
type names in docblocks was derived from the class name
(`ReflectionClass::getNamespaceName()` / `inNamespace()`). The name of
an anonymous class is a synthetic value that carries either no
namespace, or — when the class extends or implements another type —
that type's namespace, never the namespace the docblocks were lexically
written in. Both resolution layers therefore failed: the `use`
statements table came back empty, and the same-namespace fallback was
never entered (or qualified symbols against the wrong namespace).

Trigger scenario: mapping to an anonymous class whose constructor
parameters or properties reference types by their short (imported or
same-namespace) name in a docblock, e.g. `@param DummyPaginable<User>`;
also closures scoped inside anonymous classes.

New behaviour: for anonymous classes the lexical namespace is recovered
from the tokenized source file — the same mechanism already used for
functions. Named classes keep the exact previous behaviour, including
named classes declared via `eval()` (no source available: short names
remain unresolved as before, fully-qualified names keep working).
`PhpParser::parseNamespace()` is widened to accept classes and now
memoizes per file/line, mirroring `parseUseStatements()`.
@romm
romm force-pushed the fix/anonymous-class-namespace-resolution branch from bbf1ada to 1d1599b Compare July 28, 2026 20:30
@romm

romm commented Jul 28, 2026

Copy link
Copy Markdown
Member

Thanks for the honesty, I still prefer to use your work as I lack time to work on OSS lately.

Thanks to both of you @Ocramius, @lunetics!

@romm
romm merged commit 7fc4822 into CuyZ:master Jul 28, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants