From 9f634070b16bb08d0bce4eba81ecbeb284d4bdb9 Mon Sep 17 00:00:00 2001 From: drewmt Date: Tue, 6 Oct 2026 23:12:28 +0300 Subject: [PATCH 1/3] Read the updated value of consumed prefix mutations Closes https://github.com/phpstan/phpstan/issues/15411 --- src/Analyser/ExprHandler/PreDecHandler.php | 6 ++- src/Analyser/ExprHandler/PreIncHandler.php | 6 ++- .../Rules/DeadCode/UnusedVariableRuleTest.php | 5 +++ .../PHPStan/Rules/DeadCode/data/bug-15411.php | 39 +++++++++++++++++++ turbo-ext/src/PreDecHandler.cpp | 2 +- turbo-ext/src/PreIncHandler.cpp | 2 +- turbo-ext/src/SimpleExprHandlers.h | 14 +++---- 7 files changed, 63 insertions(+), 11 deletions(-) create mode 100644 tests/PHPStan/Rules/DeadCode/data/bug-15411.php diff --git a/src/Analyser/ExprHandler/PreDecHandler.php b/src/Analyser/ExprHandler/PreDecHandler.php index efaf6a7b44d..b7bc7515ffa 100644 --- a/src/Analyser/ExprHandler/PreDecHandler.php +++ b/src/Analyser/ExprHandler/PreDecHandler.php @@ -90,7 +90,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $assignedScope, beforeScope: $scope, expr: $expr, - variableFlow: VariableFlow::sequence($varResult->getVariableFlow(), $valueFlowWrite !== null && $context->isValueConsumed() ? VariableFlow::inputs($valueFlowWrite->getId(), $context->getValueFlowTarget() !== null ? $context->getValueFlowTarget()->getId() : null) : null, VariableFlowBuilder::targetWrite($expr->var, VariableWrite::KIND_PRE_DEC, $assignedScope, $storage)), + variableFlow: VariableFlow::sequence( + $varResult->getVariableFlow(), + VariableFlowBuilder::targetWrite($expr->var, VariableWrite::KIND_PRE_DEC, $assignedScope, $storage), + $valueFlowWrite !== null && $context->isValueConsumed() ? VariableFlowBuilder::targetRead($expr->var, $storage, true, $context->getValueFlowTarget() !== null ? $context->getValueFlowTarget()->getId() : null) : null, + ), hasYield: $varResult->hasYield(), isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), diff --git a/src/Analyser/ExprHandler/PreIncHandler.php b/src/Analyser/ExprHandler/PreIncHandler.php index 9d7e02bf780..854627dc21c 100644 --- a/src/Analyser/ExprHandler/PreIncHandler.php +++ b/src/Analyser/ExprHandler/PreIncHandler.php @@ -90,7 +90,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $assignedScope, beforeScope: $scope, expr: $expr, - variableFlow: VariableFlow::sequence($varResult->getVariableFlow(), $valueFlowWrite !== null && $context->isValueConsumed() ? VariableFlow::inputs($valueFlowWrite->getId(), $context->getValueFlowTarget() !== null ? $context->getValueFlowTarget()->getId() : null) : null, VariableFlowBuilder::targetWrite($expr->var, VariableWrite::KIND_PRE_INC, $assignedScope, $storage)), + variableFlow: VariableFlow::sequence( + $varResult->getVariableFlow(), + VariableFlowBuilder::targetWrite($expr->var, VariableWrite::KIND_PRE_INC, $assignedScope, $storage), + $valueFlowWrite !== null && $context->isValueConsumed() ? VariableFlowBuilder::targetRead($expr->var, $storage, true, $context->getValueFlowTarget() !== null ? $context->getValueFlowTarget()->getId() : null) : null, + ), hasYield: $varResult->hasYield(), isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), diff --git a/tests/PHPStan/Rules/DeadCode/UnusedVariableRuleTest.php b/tests/PHPStan/Rules/DeadCode/UnusedVariableRuleTest.php index a16a5965174..2ceca6991c7 100644 --- a/tests/PHPStan/Rules/DeadCode/UnusedVariableRuleTest.php +++ b/tests/PHPStan/Rules/DeadCode/UnusedVariableRuleTest.php @@ -18,6 +18,11 @@ protected function getRule(): Rule return new UnusedVariableRule(self::getContainer()->getByType(ExprPrinter::class)); } + public function testBug15411(): void + { + $this->analyse([__DIR__ . '/data/bug-15411.php'], []); + } + public function testThrowableCatchAfterDocumentedException(): void { $this->analyse([__DIR__ . '/data/unused-variable-throwable-catch.php'], []); diff --git a/tests/PHPStan/Rules/DeadCode/data/bug-15411.php b/tests/PHPStan/Rules/DeadCode/data/bug-15411.php new file mode 100644 index 00000000000..f76c8cc1a95 --- /dev/null +++ b/tests/PHPStan/Rules/DeadCode/data/bug-15411.php @@ -0,0 +1,39 @@ +getVariableFlow(), $valueFlowWrite !== - * null && $context->isValueConsumed() ? VariableFlow::inputs($valueFlowWrite->getId(), - * $context->getValueFlowTarget() !== null ? $context->getValueFlowTarget()->getId() : null) - * : null, VariableFlowBuilder::targetWrite($var, $kind, $assignedScope, $storage)) */ -inline zv::Val incDecFlow(zval *varFlow, zval *valueFlowWrite, zval *context, zval *var, zend_long kind, zval *assignedScope, zval *storage) +/* Post-inc/dec consumes the inputs of the write; pre-inc/dec consumes the + * newly written value, so its target read must follow the target write. */ +inline zv::Val incDecFlow(zval *varFlow, zval *valueFlowWrite, zval *context, zval *var, zend_long kind, zval *assignedScope, zval *storage, bool readsNewValue = false) { zv::Val inputsFlow = zv::Val::null(); if (Z_TYPE_P(valueFlowWrite) != IS_NULL) { @@ -154,13 +152,15 @@ inline zv::Val incDecFlow(zval *varFlow, zval *valueFlowWrite, zval *context, zv targetId = variableWriteId(target.raw()); if (UNEXPECTED(targetId.isUndef())) return zv::Val(); } - inputsFlow = pt_variable_flow_inputs(zval_get_long(writeId.raw()), targetId.raw()); + inputsFlow = readsNewValue + ? pt_variable_flow_builder_target_read(var, storage, true, targetId.raw()) + : pt_variable_flow_inputs(zval_get_long(writeId.raw()), targetId.raw()); if (UNEXPECTED(inputsFlow.isUndef())) return zv::Val(); } } zv::Val targetWriteFlow = pt_variable_flow_builder_target_write(var, kind, assignedScope, storage, NULL); if (UNEXPECTED(targetWriteFlow.isUndef())) return zv::Val(); - zv::Args flows{varFlow, inputsFlow.raw(), targetWriteFlow.raw()}; + zv::Args flows{varFlow, readsNewValue ? targetWriteFlow.raw() : inputsFlow.raw(), readsNewValue ? inputsFlow.raw() : targetWriteFlow.raw()}; return pt_variable_flow_sequence(3, flows); } From ad373c1109398baa666ad5c23c8b5daae6190f06 Mon Sep 17 00:00:00 2001 From: drewmt Date: Tue, 6 Oct 2026 23:12:28 +0300 Subject: [PATCH 2/3] Bump expected turbo version --- src/Turbo/TurboExtensionEnabler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turbo/TurboExtensionEnabler.php b/src/Turbo/TurboExtensionEnabler.php index 03b1e80d4d7..b61ebc4e69a 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -33,7 +33,7 @@ final class TurboExtensionEnabler { - public const EXPECTED_EXTENSION_VERSION = '12acf37'; + public const EXPECTED_EXTENSION_VERSION = '9f63407'; private static bool $active = false; From f4a22bc216c856fbfe89b1aa05389fadc11b729e Mon Sep 17 00:00:00 2001 From: drewmt Date: Tue, 6 Oct 2026 23:16:27 +0300 Subject: [PATCH 3/3] Specify integer offsets in prefix mutation regression --- tests/PHPStan/Rules/DeadCode/data/bug-15411.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/PHPStan/Rules/DeadCode/data/bug-15411.php b/tests/PHPStan/Rules/DeadCode/data/bug-15411.php index f76c8cc1a95..380edf7633a 100644 --- a/tests/PHPStan/Rules/DeadCode/data/bug-15411.php +++ b/tests/PHPStan/Rules/DeadCode/data/bug-15411.php @@ -28,11 +28,13 @@ function previousAssignedValue(int $count): int return $value; } +/** @param array{count: int} $counts */ function nextOffsetValue(array $counts): int { return ++$counts['count']; } +/** @param array{count: int} $counts */ function previousOffsetValue(array $counts): int { return --$counts['count'];