From b2ebd76ffb32e862b0b6a9f349755433aee5a3e8 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Wed, 5 Aug 2026 11:35:11 +0200 Subject: [PATCH 1/2] [Php71] Skip AssignArrayToStringRector on a variable re-assigned as string PHPStan 2.2.8 infers a possibly undefined variable as ErrorType, where it used to be a union of its possible types. AssignArrayToStringRector leaned on that union to skip: if (empty($where)) { $where = ''; } else { $where = 'WHERE ' . implode(' AND ', $where); } With ErrorType, neither the isArray() nor the UnionType guard matches, and the empty string turned into an array, breaking the concat that follows. The variable being filled as a string later on is what makes it a string here, so that is now checked directly. An empty string assign does not count, as it is a candidate for the very same re-type. --- .../Assign/AssignArrayToStringRector.php | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/rules/Php71/Rector/Assign/AssignArrayToStringRector.php b/rules/Php71/Rector/Assign/AssignArrayToStringRector.php index 7e69acbbb2d..e69ed434db8 100644 --- a/rules/Php71/Rector/Assign/AssignArrayToStringRector.php +++ b/rules/Php71/Rector/Assign/AssignArrayToStringRector.php @@ -237,6 +237,61 @@ private function isReAssignedAsArray(Assign $assign, string $variableName, Varia return false; } + /** + * The variable is filled as a string later on, so it has to stay a string here as well + */ + private function isReAssignedAsString( + Variable $variable, + Namespace_|FileNode|ClassMethod|Function_|Closure $node + ): bool { + if ($node->stmts === null) { + return false; + } + + $variableName = $this->getName($variable); + if ($variableName === null) { + return false; + } + + $isReAssignedAsString = false; + + $this->traverseNodesWithCallable($node->stmts, function (Node $node) use ( + $variable, + $variableName, + &$isReAssignedAsString + ): ?int { + if (! $node instanceof Assign) { + return null; + } + + if (! $node->var instanceof Variable) { + return null; + } + + if (! $this->isName($node->var, $variableName)) { + return null; + } + + if ($node->var->getStartTokenPos() <= $variable->getStartTokenPos()) { + return null; + } + + // an empty string assign is a candidate for the very same re-type, so it is no proof of a string + if ($this->isEmptyString($node->expr)) { + return null; + } + + if (! $this->nodeTypeResolver->getNativeType($node->expr)->isString()->yes()) { + return null; + } + + $isReAssignedAsString = true; + return NodeVisitor::STOP_TRAVERSAL; + }); + + return $isReAssignedAsString; + } + private function refactorAssign( Assign $assign, Namespace_|FileNode|ClassMethod|Function_|Closure $node @@ -258,6 +313,10 @@ private function refactorAssign( return null; } + if ($this->isReAssignedAsString($assign->var, $node)) { + return null; + } + $variableAssignArrayDimFetches = $this->findSameNamedVariableAssigns($assign->var, $node); $shouldRetype = false; From c6978392cee5d22bb8824e97971bc0cd95e58ee2 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Wed, 5 Aug 2026 12:00:20 +0200 Subject: [PATCH 2/2] [Sets] Update SetManagerTest to the single Twig composer-based set rectorphp/rector-symfony#1010 trims TwigSetProvider to its composer-based trigger, as every rule of the per-version Twig sets is already in it, bound to the twig/twig version it needs. --- tests/Set/SetManager/SetManagerTest.php | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/tests/Set/SetManager/SetManagerTest.php b/tests/Set/SetManager/SetManagerTest.php index 55edca9c1d6..9a29ff4f510 100644 --- a/tests/Set/SetManager/SetManagerTest.php +++ b/tests/Set/SetManager/SetManagerTest.php @@ -20,7 +20,7 @@ public function testMatchComposerTriggered(): void $setManager = $this->createSetManagerWithProjectDirectory(getcwd()); $twigComposerTriggeredSet = $setManager->matchComposerTriggered(SetGroup::TWIG); - $this->assertCount(8, $twigComposerTriggeredSet); + $this->assertCount(1, $twigComposerTriggeredSet); } /** @@ -42,21 +42,13 @@ public function testByVersion(string $projectDirectory, array $expectedSets): vo */ public static function provideInstalledTwigData(): Iterator { - // here we cannot used features coming up in 2.4, as we only have 2.0 - yield [ - __DIR__ . '/Fixture/project-twig-20', - [realpath(TwigSetList::COMPOSER_BASED), realpath(TwigSetList::TWIG_20)], - ]; + // the composer-based set covers every Twig version, as each rule inside it is bound + // to the exact twig/twig version it needs + yield [__DIR__ . '/Fixture/project-twig-20', [realpath(TwigSetList::COMPOSER_BASED)]]; - yield [ - __DIR__ . '/Fixture/project-twig-24', - [realpath(TwigSetList::COMPOSER_BASED), realpath(TwigSetList::TWIG_20), realpath(TwigSetList::TWIG_24)], - ]; + yield [__DIR__ . '/Fixture/project-twig-24', [realpath(TwigSetList::COMPOSER_BASED)]]; - yield [ - __DIR__ . '/Fixture/project-twig-127', - [realpath(TwigSetList::COMPOSER_BASED), realpath(TwigSetList::TWIG_112), realpath(TwigSetList::TWIG_127)], - ]; + yield [__DIR__ . '/Fixture/project-twig-127', [realpath(TwigSetList::COMPOSER_BASED)]]; } private function createSetManagerWithProjectDirectory(string $projectDirectory): SetManager