From e6bad95c06fadeb589138b0ccfa10a5c0c4ac89f Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 23 Sep 2026 08:57:28 +0900 Subject: [PATCH 1/4] Do not report assigning a float zero as redundant 0.0 and -0.0 are identical, so ConstantFloatType::equals() does not tell them apart, but the sign stays observable: (string) -0.0 is "-0" and fdiv(1, -0.0) is -INF. `$f === 0.0` also narrows $f to 0.0 while it may still hold -0.0, so a sign-aware comparison would not help either. AssignHandler::redundant() now gives up when the value holds a float zero, directly or inside a constant array; the native twin does the same. --- src/Analyser/ExprHandler/AssignHandler.php | 22 ++++++- .../Rules/DeadCode/UnusedVariableRuleTest.php | 8 +++ .../unused-variable-redundant-signed-zero.php | 60 +++++++++++++++++++ turbo-ext/src/AssignHandler.cpp | 32 ++++++++++ turbo-ext/src/generated/AssignHandler.h | 7 ++- 5 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Rules/DeadCode/data/unused-variable-redundant-signed-zero.php diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index bd62f071ea6..eff214fa3f6 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -3093,11 +3093,31 @@ private static function redundant(ExpressionResult $rhs, Expr $target, Expressio $nativeType = $nativeType->getOffsetValueType($nativeOffset); } $values = $type->getFiniteTypes(); - if (count($values) !== 1 || !$values[0]->equals($rhs->getType())) { + if (count($values) !== 1 || !$values[0]->equals($rhs->getType()) || self::containsFloatZero($values[0])) { return null; } $nativeValues = $nativeType->getFiniteTypes(); return count($nativeValues) === 1 && $nativeValues[0]->equals($rhs->getNativeType()) ? $rhs->getType() : null; } + /** + * 0.0 and -0.0 are identical (`===`) but distinguishable (`(string)`, `fdiv()`), + * and `$f === 0.0` narrows $f to 0.0 while it may still hold -0.0, so a type + * holding a float zero cannot prove that assigning it again changes nothing. + */ + private static function containsFloatZero(Type $type): bool + { + if (in_array(0.0, $type->getConstantScalarValues(), true)) { + return true; + } + foreach ($type->getConstantArrays() as $array) { + foreach ($array->getValueTypes() as $valueType) { + if (self::containsFloatZero($valueType)) { + return true; + } + } + } + return false; + } + } diff --git a/tests/PHPStan/Rules/DeadCode/UnusedVariableRuleTest.php b/tests/PHPStan/Rules/DeadCode/UnusedVariableRuleTest.php index 2ceca6991c7..029867a419a 100644 --- a/tests/PHPStan/Rules/DeadCode/UnusedVariableRuleTest.php +++ b/tests/PHPStan/Rules/DeadCode/UnusedVariableRuleTest.php @@ -206,6 +206,14 @@ public function testRedundantAssignment(): void ]); } + public function testRedundantAssignmentOfSignedZero(): void + { + $this->analyse([__DIR__ . '/data/unused-variable-redundant-signed-zero.php'], [ + ['Value assigned to variable $x is never read before being overwritten.', 12], + ['Variable $x is assigned value 1.5 but it already has that value.', 58], + ]); + } + public function testByRefReturn(): void { $this->analyse([__DIR__ . '/data/unused-variable-by-ref-return.php'], [ diff --git a/tests/PHPStan/Rules/DeadCode/data/unused-variable-redundant-signed-zero.php b/tests/PHPStan/Rules/DeadCode/data/unused-variable-redundant-signed-zero.php new file mode 100644 index 00000000000..ac0a802718b --- /dev/null +++ b/tests/PHPStan/Rules/DeadCode/data/unused-variable-redundant-signed-zero.php @@ -0,0 +1,60 @@ + -0.0]; + sink($a); + $a['k'] = 0.0; + sink($a); +} + +function nonZeroFloatIsStillRedundant(): void +{ + $x = 1.5; + sink($x); + $x = 1.5; + sink($x); +} diff --git a/turbo-ext/src/AssignHandler.cpp b/turbo-ext/src/AssignHandler.cpp index 393a822d2b8..48e0fc3bf64 100644 --- a/turbo-ext/src/AssignHandler.cpp +++ b/turbo-ext/src/AssignHandler.cpp @@ -4810,6 +4810,9 @@ class AssignHandler bool equal; AH_OK(ahEquals(zend_hash_index_find(Z_ARRVAL_P(values.raw()), 0), rhsType.raw(), equal)); if (!equal) return ahNull(); + bool floatZero; + AH_OK(containsFloatZero(zend_hash_index_find(Z_ARRVAL_P(values.raw()), 0), floatZero)); + if (floatZero) return ahNull(); } AH_VAL(nativeValues, ahCall(nativeType.raw(), PT_LC("getfinitetypes"), 0, NULL)); if (zend_hash_num_elements(Z_ARRVAL_P(nativeValues.raw())) != 1) return ahNull(); @@ -4819,6 +4822,35 @@ class AssignHandler if (!equal) return ahNull(); return pt_expression_result_get_type(rhs); } + + /* (twin 3042); false = pending exception */ + static bool containsFloatZero(zval *type, bool &result) + { + result = false; + { + AH_VALB(scalarValues, ahTypeOp(type, PT_OP_GET_CONSTANT_SCALAR_VALUES, 0, NULL)); + for (auto entry : zv::TableRef(Z_ARRVAL_P(scalarValues.raw()))) { + zval *value = entry.value().deref().raw(); + // in_array(0.0, ..., true): -0.0 is identical to 0.0, NAN to nothing + if (Z_TYPE_P(value) == IS_DOUBLE && Z_DVAL_P(value) == 0.0) { + result = true; + return true; + } + } + } + AH_VALB(constantArrays, ahTypeOp(type, PT_OP_GET_CONSTANT_ARRAYS, 0, NULL)); + for (auto arrayEntry : zv::TableRef(Z_ARRVAL_P(constantArrays.raw()))) { + AH_VALB(valueTypes, ahTypeOp(arrayEntry.value().deref().raw(), PT_OP_GET_VALUE_TYPES, 0, NULL)); + for (auto entry : zv::TableRef(Z_ARRVAL_P(valueTypes.raw()))) { + zval *valueType = entry.value().deref().raw(); + bool ok = true; + pt_engine_with_stack([&]() { ok = containsFloatZero(valueType, result); }); + AH_OKB(ok); + if (result) return true; + } + } + return true; + } }; } // namespace phpstanturbo diff --git a/turbo-ext/src/generated/AssignHandler.h b/turbo-ext/src/generated/AssignHandler.h index 5edaadc5cbe..4110afacd9e 100644 --- a/turbo-ext/src/generated/AssignHandler.h +++ b/turbo-ext/src/generated/AssignHandler.h @@ -227,7 +227,9 @@ inline constexpr char strings[] = "array\0" /* 3789 */ "hasArrayReference\0" /* 3795 */ "rhs\0" /* 3813 */ - "redundant"; /* 3817 */ + "redundant\0" /* 3817 */ + "type\0" /* 3827 */ + "containsFloatZero"; /* 3832 */ inline constexpr reg::PackedArg args[] = { reg::packed(0, 0, 25), /* __construct $templateArgumentObserver */ reg::packed(76, 0, 99), /* __construct $varAnnotationProcessor */ @@ -473,6 +475,8 @@ inline constexpr reg::PackedArg args[] = { reg::packed(1975, 0, 1376), /* redundant $target */ reg::packed(1521, 0, 1529), /* redundant $storage */ reg::packed(1396, MAY_BE_NULL, 2038), /* redundant return */ + reg::packed(3827, 0, 2038), /* containsFloatZero $type */ + reg::packed(1396, MAY_BE_BOOL), /* containsFloatZero return */ }; using Sig = reg::Sig; } // namespace sigtab @@ -519,6 +523,7 @@ inline constexpr sigtab::Sig getLiteralArrayKeyType = { { 3654 /* getLiteralArra inline constexpr sigtab::Sig getOriginalPropertyType = { { 3765 /* getOriginalPropertyType */, 3, 234, 3, 237, ZEND_ACC_PRIVATE } }; inline constexpr sigtab::Sig hasArrayReference = { { 3795 /* hasArrayReference */, 1, 238, 1, 239, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; inline constexpr sigtab::Sig redundant = { { 3817 /* redundant */, 3, 240, 3, 243, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig containsFloatZero = { { 3832 /* containsFloatZero */, 1, 244, 1, 245, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; } // namespace sig } // namespace ptdecl::AssignHandler From 62b7d1e328d5b612e7596c64bd920c9508b9e87b Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 23 Sep 2026 10:37:17 +0900 Subject: [PATCH 2/4] Cover nested float zeros and a repeated 0.0 in the redundant assignment test A zero two arrays deep pins the recursion of containsFloatZero(), and `$x = 0.0; $x = 0.0;` pins that a repeated zero is no longer reported: the type cannot tell a literal 0.0 from a narrowed zero of unknown sign. --- .../unused-variable-redundant-signed-zero.php | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/PHPStan/Rules/DeadCode/data/unused-variable-redundant-signed-zero.php b/tests/PHPStan/Rules/DeadCode/data/unused-variable-redundant-signed-zero.php index ac0a802718b..5c1fe264062 100644 --- a/tests/PHPStan/Rules/DeadCode/data/unused-variable-redundant-signed-zero.php +++ b/tests/PHPStan/Rules/DeadCode/data/unused-variable-redundant-signed-zero.php @@ -58,3 +58,28 @@ function nonZeroFloatIsStillRedundant(): void $x = 1.5; sink($x); } + +function zeroInNestedArray(): void +{ + $a = [[-0.0]]; + sink($a); + $a = [[0.0]]; + sink($a); +} + +function zeroAtNestedOffset(): void +{ + $a = ['x' => ['y' => -0.0]]; + sink($a); + $a['x']['y'] = 0.0; + sink($a); +} + +function repeatedPositiveZeroIsNotReportedEither(): void +{ + // the type of $x cannot tell this 0.0 from a narrowed zero of unknown sign + $x = 0.0; + sink($x); + $x = 0.0; + sink($x); +} From 0d60c67265d1cc174e152c8ffc19f580a7d70ee5 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Thu, 1 Oct 2026 02:12:38 +0900 Subject: [PATCH 3/4] Add the 3v4l reproducer of a non-redundant zero assignment to the test https://3v4l.org/GiSbY: `$f === 0.0` holds for -0.0, yet `$f = 0.0` in that branch changes what `echo $f` prints from "-0" to "0". --- .../data/unused-variable-redundant-signed-zero.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/PHPStan/Rules/DeadCode/data/unused-variable-redundant-signed-zero.php b/tests/PHPStan/Rules/DeadCode/data/unused-variable-redundant-signed-zero.php index 5c1fe264062..18323eefe56 100644 --- a/tests/PHPStan/Rules/DeadCode/data/unused-variable-redundant-signed-zero.php +++ b/tests/PHPStan/Rules/DeadCode/data/unused-variable-redundant-signed-zero.php @@ -83,3 +83,14 @@ function repeatedPositiveZeroIsNotReportedEither(): void $x = 0.0; sink($x); } + +// https://3v4l.org/GiSbY +function h(): void { + $f = -0.0; + var_dump($f === 0.0); // bool(true) + echo $f, "\n"; // -0 + if ($f === 0.0) { + $f = 0.0; // not redundant: turns -0.0 into 0.0 + } + echo $f, "\n"; // 0 +} From 121f18283bc289a77f43df5358f88fd45d21f417 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Mon, 2 Nov 2026 05:15:31 +0900 Subject: [PATCH 4/4] Check only the assigned value itself for a float zero Constant arrays holding a zero are no longer searched, so `$a = [-0.0]; $a = [0.0];` and `if ($a === [0.0]) { $a = [0.0]; }` are reported again. Object shapes and general arrays never have a single finite type and do not reach this check. --- src/Analyser/ExprHandler/AssignHandler.php | 27 +++---------- .../Rules/DeadCode/UnusedVariableRuleTest.php | 2 +- .../unused-variable-redundant-signed-zero.php | 16 -------- turbo-ext/src/AssignHandler.cpp | 38 +++---------------- turbo-ext/src/generated/AssignHandler.h | 7 +--- 5 files changed, 14 insertions(+), 76 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index eff214fa3f6..eb4fdd91f3c 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -3093,31 +3093,16 @@ private static function redundant(ExpressionResult $rhs, Expr $target, Expressio $nativeType = $nativeType->getOffsetValueType($nativeOffset); } $values = $type->getFiniteTypes(); - if (count($values) !== 1 || !$values[0]->equals($rhs->getType()) || self::containsFloatZero($values[0])) { + if (count($values) !== 1 || !$values[0]->equals($rhs->getType())) { + return null; + } + // 0.0 and -0.0 are identical (`===`) but distinguishable (`(string)`, `fdiv()`), + // and `$f === 0.0` narrows $f to 0.0 while it may still hold -0.0 + if (in_array(0.0, $values[0]->getConstantScalarValues(), true)) { return null; } $nativeValues = $nativeType->getFiniteTypes(); return count($nativeValues) === 1 && $nativeValues[0]->equals($rhs->getNativeType()) ? $rhs->getType() : null; } - /** - * 0.0 and -0.0 are identical (`===`) but distinguishable (`(string)`, `fdiv()`), - * and `$f === 0.0` narrows $f to 0.0 while it may still hold -0.0, so a type - * holding a float zero cannot prove that assigning it again changes nothing. - */ - private static function containsFloatZero(Type $type): bool - { - if (in_array(0.0, $type->getConstantScalarValues(), true)) { - return true; - } - foreach ($type->getConstantArrays() as $array) { - foreach ($array->getValueTypes() as $valueType) { - if (self::containsFloatZero($valueType)) { - return true; - } - } - } - return false; - } - } diff --git a/tests/PHPStan/Rules/DeadCode/UnusedVariableRuleTest.php b/tests/PHPStan/Rules/DeadCode/UnusedVariableRuleTest.php index 029867a419a..0379c4c06b2 100644 --- a/tests/PHPStan/Rules/DeadCode/UnusedVariableRuleTest.php +++ b/tests/PHPStan/Rules/DeadCode/UnusedVariableRuleTest.php @@ -210,7 +210,7 @@ public function testRedundantAssignmentOfSignedZero(): void { $this->analyse([__DIR__ . '/data/unused-variable-redundant-signed-zero.php'], [ ['Value assigned to variable $x is never read before being overwritten.', 12], - ['Variable $x is assigned value 1.5 but it already has that value.', 58], + ['Variable $x is assigned value 1.5 but it already has that value.', 50], ]); } diff --git a/tests/PHPStan/Rules/DeadCode/data/unused-variable-redundant-signed-zero.php b/tests/PHPStan/Rules/DeadCode/data/unused-variable-redundant-signed-zero.php index 18323eefe56..8d7b53c8911 100644 --- a/tests/PHPStan/Rules/DeadCode/data/unused-variable-redundant-signed-zero.php +++ b/tests/PHPStan/Rules/DeadCode/data/unused-variable-redundant-signed-zero.php @@ -35,14 +35,6 @@ function looseZeroNarrowing(float $f): void sink($f); } -function zeroInArray(): void -{ - $a = [-0.0]; - sink($a); - $a = [0.0]; - sink($a); -} - function zeroAtOffset(): void { $a = ['k' => -0.0]; @@ -59,14 +51,6 @@ function nonZeroFloatIsStillRedundant(): void sink($x); } -function zeroInNestedArray(): void -{ - $a = [[-0.0]]; - sink($a); - $a = [[0.0]]; - sink($a); -} - function zeroAtNestedOffset(): void { $a = ['x' => ['y' => -0.0]]; diff --git a/turbo-ext/src/AssignHandler.cpp b/turbo-ext/src/AssignHandler.cpp index 48e0fc3bf64..a4606b7f285 100644 --- a/turbo-ext/src/AssignHandler.cpp +++ b/turbo-ext/src/AssignHandler.cpp @@ -4810,9 +4810,12 @@ class AssignHandler bool equal; AH_OK(ahEquals(zend_hash_index_find(Z_ARRVAL_P(values.raw()), 0), rhsType.raw(), equal)); if (!equal) return ahNull(); - bool floatZero; - AH_OK(containsFloatZero(zend_hash_index_find(Z_ARRVAL_P(values.raw()), 0), floatZero)); - if (floatZero) return ahNull(); + // in_array(0.0, ..., true): matches -0.0 too, never NAN; the sign of a zero is observable + AH_VAL(scalarValues, ahTypeOp(zend_hash_index_find(Z_ARRVAL_P(values.raw()), 0), PT_OP_GET_CONSTANT_SCALAR_VALUES, 0, NULL)); + for (auto entry : zv::TableRef(Z_ARRVAL_P(scalarValues.raw()))) { + zval *value = entry.value().deref().raw(); + if (Z_TYPE_P(value) == IS_DOUBLE && Z_DVAL_P(value) == 0.0) return ahNull(); + } } AH_VAL(nativeValues, ahCall(nativeType.raw(), PT_LC("getfinitetypes"), 0, NULL)); if (zend_hash_num_elements(Z_ARRVAL_P(nativeValues.raw())) != 1) return ahNull(); @@ -4822,35 +4825,6 @@ class AssignHandler if (!equal) return ahNull(); return pt_expression_result_get_type(rhs); } - - /* (twin 3042); false = pending exception */ - static bool containsFloatZero(zval *type, bool &result) - { - result = false; - { - AH_VALB(scalarValues, ahTypeOp(type, PT_OP_GET_CONSTANT_SCALAR_VALUES, 0, NULL)); - for (auto entry : zv::TableRef(Z_ARRVAL_P(scalarValues.raw()))) { - zval *value = entry.value().deref().raw(); - // in_array(0.0, ..., true): -0.0 is identical to 0.0, NAN to nothing - if (Z_TYPE_P(value) == IS_DOUBLE && Z_DVAL_P(value) == 0.0) { - result = true; - return true; - } - } - } - AH_VALB(constantArrays, ahTypeOp(type, PT_OP_GET_CONSTANT_ARRAYS, 0, NULL)); - for (auto arrayEntry : zv::TableRef(Z_ARRVAL_P(constantArrays.raw()))) { - AH_VALB(valueTypes, ahTypeOp(arrayEntry.value().deref().raw(), PT_OP_GET_VALUE_TYPES, 0, NULL)); - for (auto entry : zv::TableRef(Z_ARRVAL_P(valueTypes.raw()))) { - zval *valueType = entry.value().deref().raw(); - bool ok = true; - pt_engine_with_stack([&]() { ok = containsFloatZero(valueType, result); }); - AH_OKB(ok); - if (result) return true; - } - } - return true; - } }; } // namespace phpstanturbo diff --git a/turbo-ext/src/generated/AssignHandler.h b/turbo-ext/src/generated/AssignHandler.h index 4110afacd9e..5edaadc5cbe 100644 --- a/turbo-ext/src/generated/AssignHandler.h +++ b/turbo-ext/src/generated/AssignHandler.h @@ -227,9 +227,7 @@ inline constexpr char strings[] = "array\0" /* 3789 */ "hasArrayReference\0" /* 3795 */ "rhs\0" /* 3813 */ - "redundant\0" /* 3817 */ - "type\0" /* 3827 */ - "containsFloatZero"; /* 3832 */ + "redundant"; /* 3817 */ inline constexpr reg::PackedArg args[] = { reg::packed(0, 0, 25), /* __construct $templateArgumentObserver */ reg::packed(76, 0, 99), /* __construct $varAnnotationProcessor */ @@ -475,8 +473,6 @@ inline constexpr reg::PackedArg args[] = { reg::packed(1975, 0, 1376), /* redundant $target */ reg::packed(1521, 0, 1529), /* redundant $storage */ reg::packed(1396, MAY_BE_NULL, 2038), /* redundant return */ - reg::packed(3827, 0, 2038), /* containsFloatZero $type */ - reg::packed(1396, MAY_BE_BOOL), /* containsFloatZero return */ }; using Sig = reg::Sig; } // namespace sigtab @@ -523,7 +519,6 @@ inline constexpr sigtab::Sig getLiteralArrayKeyType = { { 3654 /* getLiteralArra inline constexpr sigtab::Sig getOriginalPropertyType = { { 3765 /* getOriginalPropertyType */, 3, 234, 3, 237, ZEND_ACC_PRIVATE } }; inline constexpr sigtab::Sig hasArrayReference = { { 3795 /* hasArrayReference */, 1, 238, 1, 239, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; inline constexpr sigtab::Sig redundant = { { 3817 /* redundant */, 3, 240, 3, 243, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig containsFloatZero = { { 3832 /* containsFloatZero */, 1, 244, 1, 245, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; } // namespace sig } // namespace ptdecl::AssignHandler