Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/Rules/TooWideTypehints/TooWideParameterOutTypeCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ private function processSingleParameter(
$variableExpr = new Variable($parameter->getName());
$variableType = $scope->getType($variableExpr);

// a variadic out type describes one argument - see ParameterOutTypeCheck
if ($parameter->isVariadic()) {
if (!$variableType->isArray()->yes()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

mutating testing suggests this condition is not covered by a test

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.

Right, thanks. The existing case rebinds to 42, and an int has no element type, so the check reported nothing with or without the guard. I added a case that rebinds to new \ArrayIterator(['ok']). Without the guard, it now fails with "never assigns null to &$refs". The same guard in ParameterOutTypeCheck was already covered: removing it fails ParameterOutAssignedTypeRuleTest::testBug15066.

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.

The mutation run on the new head still found one: !isArray()->yes() to isArray()->no(), which only changes the maybe-array case. I added rand(0, 1) === 1 ? ['ok'] : new \ArrayIterator(['ok']). With either mutant, the test now fails with "never assigns null to &$refs".

return [];
}
$variableType = $variableType->getIterableValueType();
}

return $this->tooWideTypeCheck->checkParameterOutType(
$outType,
$variableType,
Expand Down
15 changes: 15 additions & 0 deletions src/Rules/Variables/ParameterOutTypeCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@
* The promise is either an explicit `@param-out` or, in its absence, the parameter's own type.
* Which one it is only shows in the error message, so callers report it via $isParamOutType.
*
* For a variadic parameter the promise describes a single argument, as NodeScopeResolver applies it at
* the call site, while the variable holds the packed array of them, so its element type is compared.
* Once the variable no longer holds an array, rebinding it has discarded the references and nothing
* reaches a caller. A write through an offset, `$refs[0] = ...`, does reach the caller and leaves an
* array, so an array is always compared.
*
* @internal
*/
#[AutowiredService]
Expand All @@ -47,6 +53,8 @@ public function check(
bool $isParamOutType,
): array
{
$isVariadic = $parameter->isVariadic();

$typeResult = $this->ruleLevelHelper->findTypeToCheck(
$scope,
$checkedExpr,
Expand All @@ -58,6 +66,13 @@ public function check(
}

$assignedExprType = $scope->getType($checkedExpr);
if ($isVariadic) {
if (!$assignedExprType->isArray()->yes()) {
return [];
}
$assignedExprType = $assignedExprType->getIterableValueType();
}

if ($outType->isSuperTypeOf($assignedExprType)->yes()) {
return [];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use PHPStan\Rules\Properties\PropertyReflectionFinder;
use PHPStan\Rules\Rule as TRule;
use PHPStan\Testing\RuleTestCase;
use PHPUnit\Framework\Attributes\RequiresPhp;

/**
* @extends RuleTestCase<TooWideFunctionParameterOutTypeRule>
Expand Down Expand Up @@ -50,4 +51,22 @@ public function testNestedTooWideType(): void
]);
}

#[RequiresPhp('>= 8.0.0')]
public function testBug15066(): void
{
$this->analyse([__DIR__ . '/data/bug-15066.php'], [
[
'Function Bug15066\\variadicNeverNull() never assigns null to &$refs so it can be removed from the by-ref type.',
24,
'You can narrow the parameter out type with @param-out PHPDoc tag.',
],
// rebinding the packed variable to a non-array is silent, to an array is not - see the fixture
[
'Function Bug15066\\variadicRebindOnlyString() never assigns null to &$refs so it can be removed from the by-ref type.',
62,
'You can narrow the parameter out type with @param-out PHPDoc tag.',
],
]);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use PHPStan\Rules\Properties\PropertyReflectionFinder;
use PHPStan\Rules\Rule as TRule;
use PHPStan\Testing\RuleTestCase;
use PHPUnit\Framework\Attributes\RequiresPhp;

/**
* @extends RuleTestCase<TooWideMethodParameterOutTypeRule>
Expand Down Expand Up @@ -144,4 +145,16 @@ public function testNestedTooWideType(): void
]);
}

#[RequiresPhp('>= 8.0.0')]
public function testBug15066(): void
{
$this->analyse([__DIR__ . '/data/bug-15066.php'], [
[
'Method Bug15066\\Foo::variadicNeverNull() never assigns null to &$refs so it can be removed from the by-ref type.',
45,
'You can narrow the parameter out type with @param-out PHPDoc tag.',
],
]);
}

}
78 changes: 78 additions & 0 deletions tests/PHPStan/Rules/TooWideTypehints/data/bug-15066.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php declare(strict_types = 1); // lint >= 8.0

namespace Bug15066;

function variadicByRef(string|null &...$refs): void
{
foreach ($refs as &$ref) {
$ref = $ref === null ? null : trim($ref);
}
}

function singleByRef(string|null &$ref): void
{
$ref = $ref === null ? null : trim($ref);
}

function variadicByIndex(string|null &...$refs): void
{
foreach ($refs as $key => $value) {
$refs[$key] = $value === null ? null : trim($value);
}
}

function variadicNeverNull(string|null &...$refs): void
{
foreach ($refs as $key => $value) {
$refs[$key] = 'foo';
}
}

function variadicNeverWritten(string|null &...$refs): void
{
}

class Foo
{

public function variadicByIndex(string|null &...$refs): void
{
foreach ($refs as $key => $value) {
$refs[$key] = $value === null ? null : trim($value);
}
}

public function variadicNeverNull(string|null &...$refs): void
{
foreach ($refs as $key => $value) {
$refs[$key] = 'foo';
}
}

}

// Rebinding the packed variable discards the references it held, so PHP writes nothing back and
// there is no out value left to check. An array is still compared, because a write through an
// offset reaches the caller and leaves the variable as an array too.
function variadicRebindNonArray(string|null &...$refs): void
{
$refs = 42;
}

function variadicRebindOnlyString(string|null &...$refs): void
{
$refs = ['ok'];
}

// An iterable that is not an array still has an element type, but rebinding to it discards the
// references just the same, so it stays silent too.
function variadicRebindTraversable(string|null &...$refs): void
{
$refs = new \ArrayIterator(['ok']);
}

// Maybe an array is not compared either, even when the other types also have an element type.
function variadicRebindMaybeArray(string|null &...$refs): void
{
$refs = rand(0, 1) === 1 ? ['ok'] : new \ArrayIterator(['ok']);
}
23 changes: 23 additions & 0 deletions tests/PHPStan/Rules/Variables/ParameterOutAssignedTypeRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,27 @@ public function testCatchVariable(): void
]);
}

#[RequiresPhp('>= 8.0.0')]
public function testBug15066(): void
{
$this->analyse([__DIR__ . '/data/bug-15066.php'], [
[
'Parameter &$refs by-ref type of function Bug15066Variables\\variadicWrongType() expects string|null, int|string|null given.',
22,
'You can change the parameter out type with @param-out PHPDoc tag.',
],
[
'Parameter &$refs by-ref type of method Bug15066Variables\\Foo::variadicWrongType() expects string|null, int|string|null given.',
52,
'You can change the parameter out type with @param-out PHPDoc tag.',
],
// rebinding the packed variable to a non-array is silent, to an array is not - see the fixture
[
'Parameter &$refs by-ref type of function Bug15066Variables\\variadicRebindWrongArray() expects string|null, int given.',
69,
'You can change the parameter out type with @param-out PHPDoc tag.',
],
]);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleLevelHelper;
use PHPStan\Testing\RuleTestCase;
use PHPUnit\Framework\Attributes\RequiresPhp;

/**
* @extends RuleTestCase<ParameterOutExecutionEndTypeRule>
Expand Down Expand Up @@ -74,4 +75,15 @@ public function testBug12330(): void
$this->analyse([__DIR__ . '/data/bug-12330.php'], []);
}

#[RequiresPhp('>= 8.0.0')]
public function testBug15066(): void
{
$this->analyse([__DIR__ . '/data/bug-15066.php'], [
[
'Parameter &$refs @param-out type of function Bug15066Variables\\variadicParamOutNeverWritten() expects string, string|null given.',
35,
],
]);
}

}
83 changes: 83 additions & 0 deletions tests/PHPStan/Rules/Variables/data/bug-15066.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php declare(strict_types = 1); // lint >= 8.0

namespace Bug15066Variables;

function variadicByRef(string|null &...$refs): void
{
foreach ($refs as &$ref) {
$ref = $ref === null ? null : trim($ref);
}
}

function variadicByIndex(string|null &...$refs): void
{
foreach ($refs as $key => $value) {
$refs[$key] = $value === null ? null : trim($value);
}
}

function variadicWrongType(string|null &...$refs): void
{
foreach ($refs as $key => $value) {
$refs[$key] = 42;
}
}

/** @param-out string|null $refs */
function variadicParamOut(string|null &...$refs): void
{
foreach ($refs as $key => $value) {
$refs[$key] = $value === null ? null : trim($value);
}
}

/** @param-out string $refs */
function variadicParamOutNeverWritten(string|null &...$refs): void
{
}

class Foo
{

public function variadicByIndex(string|null &...$refs): void
{
foreach ($refs as $key => $value) {
$refs[$key] = $value === null ? null : trim($value);
}
}

public function variadicWrongType(string|null &...$refs): void
{
foreach ($refs as $key => $value) {
$refs[$key] = 42;
}
}

}

// Rebinding the packed variable discards the references it held, so PHP writes nothing back to any
// caller. Nothing is reported for a non-array, because no out value is left to check. An array is
// still reported: a write through an offset reaches the caller and leaves the variable as an array
// too, so the two cannot be told apart here, and the offset write is the case that matters.
function variadicRebindNonArray(string|null &...$refs): void
{
$refs = 42;
}

function variadicRebindWrongArray(string|null &...$refs): void
{
$refs = [42];
}

function variadicRebindOkArray(string|null &...$refs): void
{
$refs = ['ok'];
}

// The packed variable may end up only maybe holding an array. There is then no element type to
// speak of, so the comparison is skipped rather than run against a nonexistent one.
function variadicRebindMaybeArray(string|null &...$refs): void
{
$refs = rand(0, 1) === 1 ? [42] : null;
}

Loading