Skip to content

Additional PHPStan Divergences #74

Description

@klunejko

Some more bugs we discovered and this probably closes out the vast majority of our false positives. Thanks again for your quick followup.

A method-level @template T used as a type argument of a generic parameter is replaced by its bound

interface PBase {}
final class PA implements PBase {}

/** @template T of PBase */
final class PBox
{
    /** @param T $value */
    public function __construct(public PBase $value) {}
}

final class P1MethodTemplate
{
    /**
     * @template T of PBase
     * @param PBox<T> $box
     * @return T
     */
    public function unwrap(PBox $box): PBase { return $box->value; }
}

(new P1MethodTemplate())->unwrap(new PBox(new PA()));

Actual:

P1MethodTemplate::unwrap(): Argument $box expects PBox<invariant PBase>, but PBox<PA> was given

The same happens when the consumer class carries unrelated class-level templates (P1b, the AdminRestApi shape) and when a class-level template sits inside an array parameter of the constructor (P8, @param array<array-key,PBox<T>> $items on @template T — EntityBatchResponse::__construct()).

Expected: T is inferred as PA from the argument, as PHPStan does; the call is accepted. Note for the item 7 follow-up: converting the repository constructors to method-level generics is blocked by this bug until it is fixed.

Static methods ignore the @extends binding of the called subclass

/** @template TEntity of PBase */
abstract class P2IdBase
{
    /** @param non-empty-string|TEntity|P2IdBase<TEntity> $value */
    public static function from(string|PBase|P2IdBase $value): static { return new static(); }

    /** @param TEntity $entity */
    public static function fromEntity(PBase $entity): static { return new static(); }
}

/** @extends P2IdBase<PA> */
final class P2AId extends P2IdBase {}

/** @extends P2IdBase<PBase> */
final class P2AnyId extends P2IdBase {}

P2AId::from(P2AId::from('x'));   // (a)
P2AnyId::fromEntity(new PA());   // (b) accepted
P2AnyId::fromEntity(new PB());   // (b) rejected

Actual:

(a) P2AId::from(): Argument $value must be of type (non-empty-string | PBase | P2IdBase<PBase>), P2AId given
(b) P2AnyId::fromEntity(): Argument $entity (template TEntity = PA) must be of type PA, PB given

Two symptoms of the same gap: on a static call the class template is not taken from the called class's @extends. In (a) the bound is substituted and the union member P2IdBase<PBase> then rejects the class's own instance by invariance. In (b) the template is bound from the first call and kept for every later static call, although @extends P2IdBase<PBase> already fixes it.

Expected: TEntity = PA in (a) and TEntity = PBase in (b), as PHPStan resolves @extends; all three calls are accepted.

An interface's template bound is resolved in the namespace of the trait that supplies the method

namespace TypephpProbe;

/** @template TRoute of P3RouteInterface */
interface P3RouteInterface
{
    /** @return TRoute */
    public static function fromName(string $name): self;
}

/** @implements P3RouteInterface<self> */
enum P3Route: string implements P3RouteInterface
{
    use \TypephpProbe\Sub\P3HasRoute;
    case A = 'a';
}

// other file
namespace TypephpProbe\Sub;

trait P3HasRoute
{
    public static function fromName(string $name): self { return self::from($name); }
}

P3Route::fromName('a');

Actual:

P3Route::fromName(): Return value must be of type P3RouteInterface, TypephpProbe\P3Route returned

The expected type is printed unqualified: the bound P3RouteInterface from the interface's docblock was resolved in the trait's namespace (TypephpProbe\Sub), where the name does not exist. The same enum passes with the static method written inline (P3b) or with the trait declared in the interface's namespace (P3c). Same family as item 4, on the interface-docblock-through-trait path.

Expected: the interface's docblock is resolved in the interface's namespace (or the @implements P3RouteInterface<self> binding is used); the return is accepted.

A Closure parameter read in the method body is replaced by a wrapper, so closure identity is lost

final class P5dStoredClosure
{
    public Closure $factory;

    /** @param Closure(?PA=): PBase $factory */
    public function __construct(Closure $factory) { $this->factory = $factory; }
}

final class P5ClosureIdentity
{
    /** @param Closure(?PA=): PBase $factory */
    public function __construct(public Closure $factory) {}
}

$closure = static fn (?PA $a = null): PBase => new PA();
(new P5dStoredClosure($closure))->factory === $closure;   // false — wrapper from src/Internal/Wrapper/CallableWrapper.php
(new P5ClosureIdentity($closure))->factory === $closure;  // true

FunctionContractInjector injects a wrapCallable() dispatcher for the parameter local, so anything the body does with the parameter (store, forward, compare) sees a different Closure object; a promoted property keeps the original. The README does not mention the wrapping, and the two spellings behave differently. Possibly by design — filed as a behavior question: either document the identity change and make promotion consistent with body reads, or return the original closure after validation.

Expected: the stored closure is the object that was passed in, in both spellings.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions