← All results

A star projection in one union arm must not unbind T in the others

Group: Cross-analyzer regressions · Category: regression · File: regressions_star_projection_union_template.php

PHPStan writes Pair<T, *> (and Laravel packages write Relation<T, *, *>) when only some type arguments matter. The weaker honest reading is to treat * as "unspecified" so T still binds from Box<T>. Full star-projection semantics are phpdoc_advanced_phpstan_star_projection. Psalm 7 rejects * as InvalidDocblock, discards the whole param tag, and leaves T unbound so a Box<T> return degrades to Box<never> — callers then see dead-code diagnostics (vimeo/psalm#11931). Source lead: vimeo/psalm#11931.

Analyzer results

AnalyzerVersionResultDiagnostics
phan 6.0.7Phan 6.0.7 php-ast version 1.1.3 PHP version used to run Phan: 8.5.9 Unrecognized
regressions_star_projection_union_template.php:66: Saw possibly unextractable annotation for a fragment of comment '* @param Box<T>|Pair<T, *> $subject': after Box<T>|Pair, did not see an element name (will guess based on comment order) [PhanUnextractableAnnotationElementName]
regressions_star_projection_union_template.php:66: Saw a token Phan may have failed to parse after '* @param Box<T>|Pair<T, *> $subject': after Box<T>|Pair, saw '<' [PhanUnextractableAnnotationSuffix]
regressions_star_projection_union_template.php:69: Template type T not declared in parameters of function/method make() (or Phan can't extract template types for this use case) [PhanTemplateTypeNotDeclaredInFunctionParams]
  • Recognition: spelling not resolved — reported on declaration line(s) 66, 69
  • Enforcement: 1/1 of the expected violations reported — incidental, since the spelling was not resolved

Notes: Cannot parse `*` in Pair<T, *>, but T still binds from the Box arm and the caller is not marked dead.

phpstan 2.2.15PHPStan - PHP Static Analysis Tool 2.2.15 Enforced

No diagnostics reported.

  • Recognition: spelling resolved
  • Enforcement: 1/1 of the expected violations reported
psalm 6.18.0Psalm 6.18.0@536dd6236b39a5115385b0307b42d6d1ad431a02
next: 7.0.0-beta22
Unrecognized (pzoom≠)
regressions_star_projection_union_template.php:66: Invalid type 'Conformance\Tests\RegressionsStarProjectionUnionTemplate\*' in docblock for Conformance\Tests\RegressionsStarProjectionUnionTemplate\Factory::make [InvalidDocblock]
regressions_star_projection_union_template.php:100: All possible types for this argument were invalidated - This may be dead code [NoValue]
pzoom (Psalm port)

No diagnostics from pzoom.

  • Recognition: spelling not resolved — reported on declaration line(s) 66
  • Enforcement: 1/1 of the expected violations reported — incidental, since the spelling was not resolved
Expectation diff
Line 100: Expected valid value to be accepted, got ["All possible types for this argument were invalidated - This may be dead code [NoValue]"]

Notes: InvalidDocblock on `*` discards the `@param`, T never binds, and `@return Box<T>` degrades to Box<never>. Factory::make($box)->get() is then NoValue. The echo after that stays quiet in this shape; the cascade is the never return, not a later UnevaluatedCode. vimeo/psalm#11931.

mago 1.50.0mago 1.50.0 Enforced

No diagnostics reported.

  • Recognition: spelling resolved
  • Enforcement: 1/1 of the expected violations reported
mir 0.78.0mir 0.78.0 Enforced

No diagnostics reported.

  • Recognition: spelling resolved
  • Enforcement: 1/1 of the expected violations reported
phpantom 0.10.0phpantom_lsp 0.10.0 Enforced

No diagnostics reported.

  • Recognition: spelling resolved
  • Enforcement: 1/1 of the expected violations reported
intelephense 1.18.5intelephense 1.18.5 Enforced

No diagnostics reported.

  • Recognition: spelling resolved
  • Enforcement: 1/1 of the expected violations reported
phpactor 2026.06.23.0Phpactor 2026.06.23.0 Enforced

No diagnostics reported.

  • Recognition: spelling resolved
  • Enforcement: 1/1 of the expected violations reported
phpy 1.0.19274phpy 1.0.19274 Enforced

No diagnostics reported.

  • Recognition: spelling resolved
  • Enforcement: 1/1 of the expected violations reported
qodana 262.9437.196Qodana 262.9437.196 Enforced

No diagnostics reported.

  • Recognition: spelling resolved
  • Enforcement: 1/1 of the expected violations reported

Notes: Silent on Pair<T, *>: T still binds from the Box arm and the caller is not marked dead.

noverify 0.5.5NoVerify, version 0.5.5: built on: 2025.04.22 14:36:46 OS: r-kuchinskas arm64 Commit: 4774b82f8adc53fbef38f95e97af4b953b4d1529 Enforced ⚠ 10 false positives
regressions_star_projection_union_template.php:28: Class or interface named \Conformance\Tests\RegressionsStarProjectionUnionTemplate\TValue does not exist [undefinedClass]
regressions_star_projection_union_template.php:31: param $value miss matched with phpdoc type <<TValue>> [funcParamTypeMissMatch]
regressions_star_projection_union_template.php:36: Class or interface named \Conformance\Tests\RegressionsStarProjectionUnionTemplate\TValue does not exist [undefinedClass]
regressions_star_projection_union_template.php:51: Class or interface named \Conformance\Tests\RegressionsStarProjectionUnionTemplate\TFirst does not exist [undefinedClass]
regressions_star_projection_union_template.php:52: Class or interface named \Conformance\Tests\RegressionsStarProjectionUnionTemplate\TSecond does not exist [undefinedClass]
regressions_star_projection_union_template.php:55: param $first miss matched with phpdoc type <<TFirst>> [funcParamTypeMissMatch]
regressions_star_projection_union_template.php:56: param $second miss matched with phpdoc type <<TSecond>> [funcParamTypeMissMatch]
regressions_star_projection_union_template.php:84: param $subject miss matched with phpdoc type <<Box<T>>> [funcParamTypeMissMatch]
regressions_star_projection_union_template.php:97: param $box miss matched with phpdoc type <<Box<int>>> [funcParamTypeMissMatch]
regressions_star_projection_union_template.php:107: param $box miss matched with phpdoc type <<Box<int>>> [funcParamTypeMissMatch]
  • Recognition: spelling resolved
  • Enforcement: 1/1 of the expected violations reported
  • False positives: line(s) 28, 31, 36, 51, 52, 55, 56, 84, 97, 107
Expectation diff
Line 28: Unexpected errors ["Class or interface named \\Conformance\\Tests\\RegressionsStarProjectionUnionTemplate\\TValue does not exist [undefinedClass]"]
Line 31: Unexpected errors ["param $value miss matched with phpdoc type <<TValue>> [funcParamTypeMissMatch]"]
Line 36: Unexpected errors ["Class or interface named \\Conformance\\Tests\\RegressionsStarProjectionUnionTemplate\\TValue does not exist [undefinedClass]"]
Line 51: Unexpected errors ["Class or interface named \\Conformance\\Tests\\RegressionsStarProjectionUnionTemplate\\TFirst does not exist [undefinedClass]"]
Line 52: Unexpected errors ["Class or interface named \\Conformance\\Tests\\RegressionsStarProjectionUnionTemplate\\TSecond does not exist [undefinedClass]"]
Line 55: Unexpected errors ["param $first miss matched with phpdoc type <<TFirst>> [funcParamTypeMissMatch]"]
Line 56: Unexpected errors ["param $second miss matched with phpdoc type <<TSecond>> [funcParamTypeMissMatch]"]
Line 84: Unexpected errors ["param $subject miss matched with phpdoc type <<Box<T>>> [funcParamTypeMissMatch]"]
Line 97: Unexpected errors ["param $box miss matched with phpdoc type <<Box<int>>> [funcParamTypeMissMatch]"]
Line 107: Unexpected errors ["param $box miss matched with phpdoc type <<Box<int>>> [funcParamTypeMissMatch]"]
steins 0.1.8steins 0.1.8 (2026-09-21 revision 76306a9) Enforced

No diagnostics reported.

  • Recognition: spelling resolved
  • Enforcement: 1/1 of the expected violations reported
phpstan-strict 2.2.15PHPStan - PHP Static Analysis Tool 2.2.15 Enforced

No diagnostics reported.

  • Recognition: spelling resolved
  • Enforcement: 1/1 of the expected violations reported
psalm-next 7.0.0-beta22Psalm 7.0.0-beta22@de27e5724ee5997a1e3baee4d394ca6f4e46eba0 Unrecognized
regressions_star_projection_union_template.php:66: Invalid type 'Conformance\Tests\RegressionsStarProjectionUnionTemplate\*' in docblock for Conformance\Tests\RegressionsStarProjectionUnionTemplate\Factory::make [InvalidDocblock]
regressions_star_projection_union_template.php:100: All possible types for this argument were invalidated - This may be dead code [NoValue]
  • Recognition: spelling not resolved — reported on declaration line(s) 66
  • Enforcement: 1/1 of the expected violations reported — incidental, since the spelling was not resolved
Expectation diff
Line 100: Expected valid value to be accepted, got ["All possible types for this argument were invalidated - This may be dead code [NoValue]"]

Notes: Same as Psalm 6/7: InvalidDocblock on `*` leaves T unbound, get() is never, takesInt is NoValue. vimeo/psalm#11931.

Source

<?php

declare(strict_types=1);

namespace Conformance\Tests\RegressionsStarProjectionUnionTemplate;

/**
 * A star projection in one union arm must not unbind T in the others.
 *
 * PHPStan writes `Pair<T, *>` (and Laravel packages write `Relation<T, *, *>`)
 * when only some type arguments matter. The weaker honest reading is to treat
 * `*` as "unspecified" so T still binds from `Box<T>`. Full star-projection
 * semantics are `phpdoc_advanced_phpstan_star_projection`.
 *
 * Psalm 7 rejects `*` as `InvalidDocblock`, discards the whole param tag, and
 * leaves T unbound so a `Box<T>` return degrades to `Box<never>` — callers then
 * see dead-code diagnostics (vimeo/psalm#11931).
 *
 * Source lead: vimeo/psalm#11931.
 */

/**
 * @template TValue
 */
final class Box
{
    /**
     * @param TValue $value
     */
    public function __construct(
        public mixed $value,
    ) {
    }

    /**
     * @return TValue
     */
    public function get(): mixed
    {
        return $this->value;
    }
}

/**
 * @template TFirst
 * @template TSecond
 */
final class Pair
{
    /**
     * @param TFirst $first
     * @param TSecond $second
     */
    public function __construct(
        public mixed $first,
        public mixed $second,
    ) {
    }
}

final class Factory
{
    /**
     * @template T
     *
     * @param Box<T>|Pair<T, *> $subject
     * @return Box<T>
     */
    public static function make(Box|Pair $subject): Box // T: Pair<T, *>
    {
        if ($subject instanceof Box) {
            return $subject; // V
        }

        throw new \InvalidArgumentException('not a Box');
    }

    /**
     * @template T
     *
     * @param Box<T> $subject
     * @return Box<T>
     */
    public static function makeBoxOnly(Box $subject): Box
    {
        return $subject; // V
    }
}

function takesInt(int $value): void
{
}

/**
 * @param Box<int> $box
 */
function consumeStar(Box $box): void
{
    $result = Factory::make($box); // V: T binds from Box<int> regardless of the Pair arm
    takesInt($result->get()); // V
    echo 'reachable after star'; // Q: Box<never> must not mark the rest of the caller dead
}

/**
 * @param Box<int> $box
 */
function consumePlain(Box $box): void
{
    $result = Factory::makeBoxOnly($box); // V: the same call without `*` in the union
    takesInt($result->get()); // V
    echo 'reachable without star'; // V
}

consumeStar(new Box(1)); // V
consumePlain(new Box(1)); // V