Skip to content
Open
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
11 changes: 11 additions & 0 deletions src/Type/IntersectionType.php
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,17 @@
static fn (Type $innerType) => $acceptingType->accepts($innerType, $strictTypes),
);

// lazyMaxMin can short-circuit to Yes when array<mixed> (inside e.g. array&callable
// or array&hasOffsetValue) is accepted by a specific array type like array<int>,
// because MixedType::isAcceptedBy() always returns Yes. The isSuperTypeOf check
// considers the intersection holistically and catches these false positives.
if ($result->yes()) {

Check warning on line 311 in src/Type/IntersectionType.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ // or array&hasOffsetValue) is accepted by a specific array type like array<int>, // because MixedType::isAcceptedBy() always returns Yes. The isSuperTypeOf check // considers the intersection holistically and catches these false positives. - if ($result->yes()) { + if (!$result->no()) { $isSuperType = $acceptingType->isSuperTypeOf($this); if ($isSuperType->no()) { return $isSuperType->toAcceptsResult();

Check warning on line 311 in src/Type/IntersectionType.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ // or array&hasOffsetValue) is accepted by a specific array type like array<int>, // because MixedType::isAcceptedBy() always returns Yes. The isSuperTypeOf check // considers the intersection holistically and catches these false positives. - if ($result->yes()) { + if (!$result->no()) { $isSuperType = $acceptingType->isSuperTypeOf($this); if ($isSuperType->no()) { return $isSuperType->toAcceptsResult();
$isSuperType = $acceptingType->isSuperTypeOf($this);
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.

Add a quick comment to explain which situations is this about

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added a 4-line comment above the isSuperTypeOf guard in IntersectionType::isAcceptedBy() explaining that it catches false positives from lazyMaxMin short-circuiting when MixedType::isAcceptedBy() returns Yes for intersection types like array<mixed>&callable or array<mixed>&hasOffsetValue against specific array types. All 12,013 tests pass and make phpstan reports no errors.

if ($isSuperType->no()) {
return $isSuperType->toAcceptsResult();
}
}

if ($this->isOversizedArray()->yes()) {
if (!$result->no()) {
return AcceptsResult::createYes();
Expand Down
37 changes: 37 additions & 0 deletions tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4025,4 +4025,41 @@ public function testBug13272(): void
$this->analyse([__DIR__ . '/data/bug-13272.php'], []);
}

public function testBug14549(): void
{
$this->checkThisOnly = false;
$this->checkNullables = true;
$this->checkUnionTypes = true;
$this->analyse([__DIR__ . '/data/bug-14549-bis.php'], [
[
'Parameter #1 $param of method Bug14549Bis\Foo::callArrayInt() expects array<int>, array&callable given.',
33,
],
[
'Parameter #1 $param of method Bug14549Bis\Foo::callConstantArrayStringString() expects array{string, string}, array&callable(): mixed given.',
34,
],
[
'Parameter #1 $param of method Bug14549Bis\Foo::callConstantArrayObjectOrStringStringString() expects array{object|string, string, string}, array&callable(): mixed given.',
36,
],
[
'Parameter #1 $param of method Bug14549Bis\Foo::callArrayInt() expects array<int>, array&callable given.',
44,
],
[
'Parameter #1 $param of method Bug14549Bis\Foo::callConstantArrayStringString() expects array{string, string}, array&callable(): mixed given.',
45,
],
[
'Parameter #1 $param of method Bug14549Bis\Foo::callConstantArrayObjectOrStringStringString() expects array{object|string, string, string}, array&callable(): mixed given.',
47,
],
[
'Parameter #1 $param of method Bug14549Bis\Foo::callArrayString() expects array<string>, array given.',
58,
],
]);
}

}
63 changes: 63 additions & 0 deletions tests/PHPStan/Rules/Methods/data/bug-14549-bis.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

namespace Bug14549Bis;

class Foo
{

/** @param array<int> $param */
public function callArrayInt(array $param): void
{
}

/** @param array{string, string} $param */
public function callConstantArrayStringString(array $param): void
{
}

/** @param array{object|string, string} $param */
public function callConstantArrayObjectOrStringString(array $param): void
{
}

/** @param array{object|string, string, string} $param */
public function callConstantArrayObjectOrStringStringString(array $param): void
{
}

/**
* @param callable-array $task
*/
public function doCallWithCallableArray(array $task): void
{
$this->callArrayInt($task);
$this->callConstantArrayStringString($task);
$this->callConstantArrayObjectOrStringString($task);
$this->callConstantArrayObjectOrStringStringString($task);
}

/**
* @param callable&array $task
*/
public function doCallWithCallableAndArray(array $task): void
{
$this->callArrayInt($task);
$this->callConstantArrayStringString($task);
$this->callConstantArrayObjectOrStringString($task);
$this->callConstantArrayObjectOrStringStringString($task);
}

/** @param array<string> $param */
public function callArrayString(array $param): void
{
}

public function doCallWithHasOffsetValue(array $arr): void
{
if (isset($arr[1]) && $arr[1] === 1) {
$this->callArrayString($arr);
$this->callArrayInt($arr);
}
}

}
159 changes: 159 additions & 0 deletions tests/PHPStan/Type/IntersectionTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,118 @@ public function testAccepts(Type $type, Type $otherType, TrinaryLogic $expectedR
);
}

/**
* @return Iterator<int, array{Type, Type, TrinaryLogic}>
*/
public static function dataIsAcceptedBy(): Iterator
{
// array&callable isAcceptedBy array - success
yield [
new IntersectionType([new ArrayType(new MixedType(), new MixedType()), new CallableType()]),
new ArrayType(new MixedType(), new MixedType()),
TrinaryLogic::createYes(),
];

// array&callable isAcceptedBy array<int> - failure
yield [
new IntersectionType([new ArrayType(new MixedType(), new MixedType()), new CallableType()]),
new ArrayType(new MixedType(), new IntegerType()),
TrinaryLogic::createNo(),
];

// array&callable isAcceptedBy constantArray{stdClass, string} - maybe
yield [
new IntersectionType([new ArrayType(new MixedType(), new MixedType()), new CallableType()]),
new ConstantArrayType(
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
[new UnionType([new ObjectType('stdClass'), new StringType()]), new StringType()],
),
TrinaryLogic::createMaybe(),
];

// array&callable isAcceptedBy constantArray{string, string} - maybe
yield [
new IntersectionType([new ArrayType(new MixedType(), new MixedType()), new CallableType()]),
new ConstantArrayType(
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
[new StringType(), new StringType()],
),
TrinaryLogic::createMaybe(),
];

// array&hasOffsetValue isAcceptedBy array - success
yield [
new IntersectionType([
new ArrayType(new MixedType(), new MixedType()),
new NonEmptyArrayType(),
new HasOffsetValueType(new ConstantIntegerType(3), new IntegerType()),
]),
new ArrayType(new MixedType(), new MixedType()),
TrinaryLogic::createYes(),
];

// array&hasOffsetValue isAcceptedBy array - failure
yield [
new IntersectionType([
new ArrayType(new MixedType(), new MixedType()),
new NonEmptyArrayType(),
new HasOffsetValueType(new ConstantIntegerType(3), new IntegerType()),
]),
new ArrayType(new MixedType(), new StringType()),
TrinaryLogic::createNo(),
];

// array&hasOffsetValue isAcceptedBy array<int> - success (matching value type)
yield [
new IntersectionType([
new ArrayType(new MixedType(), new MixedType()),
new NonEmptyArrayType(),
new HasOffsetValueType(new ConstantIntegerType(3), new IntegerType()),
]),
new ArrayType(new MixedType(), new IntegerType()),
TrinaryLogic::createYes(),
];

// array&hasOffsetValue isAcceptedBy constantArray{int, int} - success
yield [
new IntersectionType([
new ArrayType(new MixedType(), new MixedType()),
new NonEmptyArrayType(),
new HasOffsetValueType(new ConstantIntegerType(0), new IntegerType()),
]),
new ConstantArrayType(
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
[new IntegerType(), new IntegerType()],
),
TrinaryLogic::createMaybe(),
];

// array&hasOffsetValue isAcceptedBy constantArray{string, string} - failure
yield [
new IntersectionType([
new ArrayType(new MixedType(), new MixedType()),
new NonEmptyArrayType(),
new HasOffsetValueType(new ConstantIntegerType(0), new IntegerType()),
]),
new ConstantArrayType(
[new ConstantIntegerType(0), new ConstantIntegerType(1)],
[new StringType(), new StringType()],
),
TrinaryLogic::createNo(),
];
}

#[DataProvider('dataIsAcceptedBy')]
public function testIsAcceptedBy(Type $type, Type $acceptingType, TrinaryLogic $expectedResult): void
{
$actualResult = $acceptingType->accepts($type, true)->result;
$this->assertSame(
$expectedResult->describe(),
$actualResult->describe(),
sprintf('%s -> isAcceptedBy(%s)', $type->describe(VerbosityLevel::precise()), $acceptingType->describe(VerbosityLevel::precise())),
);
}

public static function dataIsCallable(): array
{
return [
Expand Down Expand Up @@ -362,6 +474,53 @@ public static function dataIsSubTypeOf(): Iterator
]),
TrinaryLogic::createYes(),
];

// array&callable isSubTypeOf array - success
yield [
new IntersectionType([new ArrayType(new MixedType(), new MixedType()), new CallableType()]),
new ArrayType(new MixedType(), new MixedType()),
TrinaryLogic::createYes(),
];

// array&callable isSubTypeOf array<int> - failure
yield [
new IntersectionType([new ArrayType(new MixedType(), new MixedType()), new CallableType()]),
new ArrayType(new MixedType(), new IntegerType()),
TrinaryLogic::createNo(),
];

// array&hasOffsetValue isSubTypeOf array - success
yield [
new IntersectionType([
new ArrayType(new MixedType(), new MixedType()),
new NonEmptyArrayType(),
new HasOffsetValueType(new ConstantIntegerType(3), new IntegerType()),
]),
new ArrayType(new MixedType(), new MixedType()),
TrinaryLogic::createYes(),
];

// array&hasOffsetValue isSubTypeOf array<int> - maybe
yield [
new IntersectionType([
new ArrayType(new MixedType(), new MixedType()),
new NonEmptyArrayType(),
new HasOffsetValueType(new ConstantIntegerType(3), new IntegerType()),
]),
new ArrayType(new MixedType(), new IntegerType()),
TrinaryLogic::createMaybe(),
];

// array&hasOffsetValue isSubTypeOf array<string> - failure
yield [
new IntersectionType([
new ArrayType(new MixedType(), new MixedType()),
new NonEmptyArrayType(),
new HasOffsetValueType(new ConstantIntegerType(3), new IntegerType()),
]),
new ArrayType(new MixedType(), new StringType()),
TrinaryLogic::createNo(),
];
}

#[DataProvider('dataIsSubTypeOf')]
Expand Down
Loading