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
13 changes: 11 additions & 2 deletions src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,7 @@ public function createIssetSingleSubjectNonTrueTypes(
$typesToRemove = [];
$hasOptionalNonNullableOffset = false;
$hasPossiblyNullOffsetValue = false;
$hasUnsealedOptionalOffset = false;
foreach ($constantArrays as $constantArray) {
$hasOffset = $constantArray->hasOffsetValueType($dimType);
if ($hasOffset->no()) {
Expand All @@ -744,13 +745,21 @@ public function createIssetSingleSubjectNonTrueTypes(
continue;
}

// Removing the key from an unsealed shape re-admits it through
// the unsealed extras, widening its value type instead.
if ($constantArray->isUnsealed()->yes()) {
$hasUnsealedOptionalOffset = true;
continue;
}

$hasOptionalNonNullableOffset = true;
}

// !isset() on an optional key with a non-nullable value means the
// key is absent - but only when no member can hold null at that
// offset (the removal distributes over every union member).
if ($hasOptionalNonNullableOffset && !$hasPossiblyNullOffsetValue) {
// offset and no member is unsealed (the removal distributes over
// every union member).
if ($hasOptionalNonNullableOffset && !$hasPossiblyNullOffsetValue && !$hasUnsealedOptionalOffset) {
$typesToRemove[] = new HasOffsetType($dimType);
}

Expand Down
2 changes: 1 addition & 1 deletion src/Turbo/TurboExtensionEnabler.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
final class TurboExtensionEnabler
{

public const EXPECTED_EXTENSION_VERSION = '28c29b8';
public const EXPECTED_EXTENSION_VERSION = '6c18d79';

private static bool $active = false;

Expand Down
33 changes: 33 additions & 0 deletions tests/PHPStan/Analyser/Bug15428Test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php declare(strict_types = 1);

namespace PHPStan\Analyser;

use PHPStan\Testing\TypeInferenceTestCase;
use PHPUnit\Framework\Attributes\DataProvider;

/**
* Runs without bleedingEdge.neon, where a sealed PHPDoc shape carries no
* sealedness information and reports isUnsealed() as maybe.
*/
class Bug15428Test extends TypeInferenceTestCase
{

public static function dataFileAsserts(): iterable
{
yield from self::gatherAssertTypes(__DIR__ . '/data/bug-15428.php');
}

/**
* @param mixed ...$args
*/
#[DataProvider('dataFileAsserts')]
public function testFileAsserts(
string $assertType,
string $file,
...$args,
): void
{
$this->assertFileAsserts($assertType, $file, ...$args);
}

}
24 changes: 24 additions & 0 deletions tests/PHPStan/Analyser/data/bug-15428.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php declare(strict_types = 1);

namespace Bug15428Data;

use function PHPStan\Testing\assertType;

/**
* @param array{a?: string, ...} $c
*/
function unsealed(array $c): void
{
$x = $c['a'] ?? '';
assertType('array{a?: string, ...}', $c);
}

/**
* @param array{a?: string} $c
*/
function sealed(array $c): void
{
if (!isset($c['a'])) {
assertType('array{}', $c);
}
}
48 changes: 48 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-15428.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php declare(strict_types = 1);

namespace Bug15428;

use function PHPStan\Testing\assertType;

/**
* @param array{a?: string, ...} $c
*/
function repro(array &$c): void
{
$x = $c['a'] ?? ''; // this causes $c to lose it's type
assertType('array{a?: string, ...}', $c);
$c['a'] = '1';
}

/**
* @param array{a?: string, ...} $c
*/
function ifIsset(array $c): void
{
if (!isset($c['a'])) {
assertType('array{a?: string, ...}', $c);
}
assertType('array{a?: string, ...}', $c);
}

/**
* @param array{a?: string, ...<string, int>} $c
*/
function typedUnsealed(array $c): void
{
if (!isset($c['a'])) {
assertType('array{a?: string, ...<string, int>}', $c);
}
assertType('array{a?: string, ...<string, int>}', $c);
}

/**
* @param array{a?: string} $c
*/
function sealed(array $c): void
{
if (!isset($c['a'])) {
assertType('array{}', $c);
}
assertType('array{}|array{a: string}', $c);
}
47 changes: 47 additions & 0 deletions tests/PHPStan/Rules/Variables/IssetRuleWithoutBleedingEdgeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Variables;

use PHPStan\Rules\Comparison\ConstantConditionInTraitHelper;
use PHPStan\Rules\IssetCheck;
use PHPStan\Rules\Properties\PropertyDescriptor;
use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;

/**
* Runs without bleedingEdge.neon, where a sealed PHPDoc shape carries no
* sealedness information and reports isUnsealed() as maybe.
*
* @extends RuleTestCase<IssetRule>
*/
class IssetRuleWithoutBleedingEdgeTest extends RuleTestCase
{

protected function getRule(): Rule
{
return new IssetRule(
new IssetCheck(
new PropertyDescriptor(),
true,
true,
),
self::getContainer()->getByType(ConstantConditionInTraitHelper::class),
);
}

public function testBug15428(): void
{
$this->analyse([__DIR__ . '/data/bug-15428.php'], [
[
'Offset \'a\' on array{} in isset() does not exist.',
15,
],
]);
}

public static function getAdditionalConfigFiles(): array
{
return [];
}

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

namespace Bug15428Rule;

/**
* @param array{a?: string, ...} $c
* @param array{a?: string} $d
*/
function repro(array $c, array $d): void
{
if (!isset($c['a'])) {
isset($c['a']);
}
if (!isset($d['a'])) {
isset($d['a']);
}
}
15 changes: 13 additions & 2 deletions turbo-ext/src/DefaultNarrowingHelper.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1586,6 +1586,7 @@ class DefaultNarrowingHelper
zv::Arr typesToRemove = zv::Arr::empty();
bool hasOptionalNonNullableOffset = false;
bool hasPossiblyNullOffsetValue = false;
bool hasUnsealedOptionalOffset = false;
if (Z_TYPE_P(constantArrays.raw()) == IS_ARRAY) {
for (zv::ArrayEntry entry : zv::ArrRef(constantArrays.raw())) {
zval *constantArray = entry.value().deref().raw();
Expand All @@ -1607,14 +1608,24 @@ class DefaultNarrowingHelper
continue;
}

// Removing the key from an unsealed shape re-admits it through
// the unsealed extras, widening its value type instead.
zend_long isUnsealed = pt_type_op_trinary(Z_OBJ_P(constantArray), PT_OP_IS_UNSEALED, 0, NULL);
if (UNEXPECTED(isUnsealed < 0)) return zv::Val();
if (isUnsealed == PT_TRI_YES) {
hasUnsealedOptionalOffset = true;
continue;
}

hasOptionalNonNullableOffset = true;
}
}

// !isset() on an optional key with a non-nullable value means the
// key is absent - but only when no member can hold null at that
// offset (the removal distributes over every union member).
if (hasOptionalNonNullableOffset && !hasPossiblyNullOffsetValue) {
// offset and no member is unsealed (the removal distributes over
// every union member).
if (hasOptionalNonNullableOffset && !hasPossiblyNullOffsetValue && !hasUnsealedOptionalOffset) {
zval hasOffsetZv;
if (UNEXPECTED(!pt_has_offset_type_new(&hasOffsetZv, dimType.raw()))) return zv::Val();
typesToRemove.push(zv::Val::adopt(hasOffsetZv));
Expand Down
Loading