From be047b86f4b70b0a79636920e03ccc64be8e3d88 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 31 Jul 2026 19:49:44 +0200 Subject: [PATCH 1/6] Specify expression types in place on an unpublished working copy specifyExpressionType() derived one whole scope per specification - and one per array-dim level of the parent-intersection cascade. It now opens a single unpublished copy (openSpecificationScope) and writes holders straight into its maps (specifyExpressionTypeInPlace), resetting the lazily-derived views (resolvedTypes, truthy/falsey scopes, promoted-native scope) after each write so every read answers exactly as it did on a per-specification fresh scope. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7 --- src/Analyser/MutatingScope.php | 99 +++++++++++++++++++++++----------- 1 file changed, 68 insertions(+), 31 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 257ccc4de9..a6bfd07d1a 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2989,14 +2989,43 @@ private function unsetExpression(Expr $expr): self public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, TrinaryLogic $certainty): self { - if ($expr instanceof Scalar) { + if ($this->isSpecifyExpressionTypeNoop($expr, $type)) { return $this; } + $scope = $this->openSpecificationScope(); + $scope->specifyExpressionTypeInPlace($expr, $type, $nativeType, $certainty); + + return $scope; + } + + /** An unpublished copy of this scope that in-place specification may mutate. */ + private function openSpecificationScope(): self + { + /** @var static */ + return ScopeOps::scopeWith( + $this, + $this->expressionTypes, + $this->nativeExpressionTypes, + $this->conditionalExpressions, + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + $this->inFunctionCallsStack, + $this->inFirstLevelStatement, + $this->afterExtractCall, + ); + } + + private function isSpecifyExpressionTypeNoop(Expr $expr, Type $type): bool + { + if ($expr instanceof Scalar) { + return true; + } + if ($expr instanceof ConstFetch) { $loweredConstName = strtolower($expr->name->toString()); if (in_array($loweredConstName, ['true', 'false', 'null'], true)) { - return $this; + return true; } } @@ -3007,11 +3036,25 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, 'is_file', 'file_exists', ], true)) { - return $this; + return true; } } - $scope = $this; + return false; + } + + /** + * The body of specifyExpressionType() writing straight into this scope's + * holder maps - only to be called on an unpublished scope (see + * openSpecificationScope()). Batching callers avoid one whole-map copy and + * scope construction per specification (and per array-dim level). + */ + private function specifyExpressionTypeInPlace(Expr $expr, Type $type, Type $nativeType, TrinaryLogic $certainty): void + { + if ($this->isSpecifyExpressionTypeNoop($expr, $type)) { + return; + } + if ( $expr instanceof Expr\ArrayDimFetch && $expr->dim !== null @@ -3020,9 +3063,9 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, && !$expr->dim instanceof Expr\PostDec && !$expr->dim instanceof Expr\PostInc ) { - $dimType = $scope->getType($expr->dim)->toArrayKey(); + $dimType = $this->getType($expr->dim)->toArrayKey(); if ($dimType->isInteger()->yes() || $dimType->isString()->yes()) { - $exprVarType = $scope->getType($expr->var); + $exprVarType = $this->getType($expr->var); $isArray = $exprVarType->isArray(); if (!$exprVarType instanceof MixedType && !$isArray->no()) { $varType = $exprVarType; @@ -3043,10 +3086,10 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, } } - $scope = $scope->specifyExpressionType( + $this->specifyExpressionTypeInPlace( $expr->var, $varType, - $scope->getNativeType($expr->var), + $this->getNativeType($expr->var), $certainty, ); } @@ -3058,29 +3101,23 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, } $exprString = $this->getNodeKey($expr); - $expressionTypes = $scope->expressionTypes; - $expressionTypes[$exprString] = new ExpressionTypeHolder($expr, $type, $certainty); - $nativeTypes = $scope->nativeExpressionTypes; - $nativeTypes[$exprString] = new ExpressionTypeHolder($expr, $nativeType, $certainty); - - /** @var static $scope */ - $scope = ScopeOps::scopeWith( - $this, - $expressionTypes, - $nativeTypes, - $this->conditionalExpressions, - $this->currentlyAssignedExpressions, - $this->currentlyAllowedUndefinedExpressions, - $this->inFunctionCallsStack, - $this->inFirstLevelStatement, - $this->afterExtractCall, - ); - - if ($expr instanceof AlwaysRememberedExpr) { - return $scope->specifyExpressionType($expr->expr, $type, $nativeType, $certainty); - } - - return $scope; + $this->expressionTypes[$exprString] = new ExpressionTypeHolder($expr, $type, $certainty); + $this->nativeExpressionTypes[$exprString] = new ExpressionTypeHolder($expr, $nativeType, $certainty); + // the writes invalidate every lazily-derived view of this scope; reset + // them to their fresh-constructor defaults, exactly as deriving a new + // scope per specification did + $this->resolvedTypes = []; + $this->truthyScopes = []; + $this->falseyScopes = []; + $this->fiberScope = null; + $this->scopeOutOfFirstLevelStatement = null; + $this->scopeWithPromotedNativeTypes = null; + + if (!($expr instanceof AlwaysRememberedExpr)) { + return; + } + + $this->specifyExpressionTypeInPlace($expr->expr, $type, $nativeType, $certainty); } public function assignExpression(Expr $expr, Type $type, Type $nativeType): self From 6d7b2ca2e3a34b7699894a545ca96be83ff66b0b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 31 Jul 2026 19:52:51 +0200 Subject: [PATCH 2/6] Batch specified-type application on one working copy filterBySpecifiedTypes() chained addTypeToExpression()/removeTypeFromExpression(), deriving a whole scope per specification. The application loop now inlines their math and writes every consecutive specification into a single unpublished working copy; operations that go through other scope derivations (isset certainty, overwriting assignment) publish it and the next specification opens a fresh one. The conditional-expressions tail moves to processConditionalExpressionsAfterSpecifying(), mirroring the eventual applySpecifiedTypes() shape. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7 --- src/Analyser/MutatingScope.php | 116 ++++++++++++++++++++++----------- 1 file changed, 79 insertions(+), 37 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index a6bfd07d1a..8078d199a0 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3411,6 +3411,10 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self } $scope = $this; + // one unpublished working copy takes all in-place specifications of the + // batch; operations that go through other scope derivations publish it + // and a fresh copy opens on the next specification + $scopeIsWorkingCopy = false; $specifiedExpressions = []; foreach ($typeSpecifications as $typeSpecification) { $expr = $typeSpecification['expr']; @@ -3427,6 +3431,7 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self } else { $scope = $scope->unsetExpression($expr); } + $scopeIsWorkingCopy = false; continue; } @@ -3459,12 +3464,15 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $originalExprType = $scope->getType($expr); if (!$scope->isComplexUnionType($originalExprType)) { $nativeType = $scope->getNativeType($expr); - $scope = $scope->specifyExpressionType( - $expr, - TypeCombinator::intersect($evaluate($originalExprType), $originalExprType), - TypeCombinator::intersect($evaluate($nativeType), $nativeType), - TrinaryLogic::createYes(), - ); + $newType = TypeCombinator::intersect($evaluate($originalExprType), $originalExprType); + $newNativeType = TypeCombinator::intersect($evaluate($nativeType), $nativeType); + if (!$this->isSpecifyExpressionTypeNoop($expr, $newType)) { + if (!$scopeIsWorkingCopy) { + $scope = $scope->openSpecificationScope(); + $scopeIsWorkingCopy = true; + } + $scope->specifyExpressionTypeInPlace($expr, $newType, $newNativeType, TrinaryLogic::createYes()); + } $specifiedExpressions[$typeSpecification['exprString']] = ExpressionTypeHolder::createYes($expr, $scope->getScopeType($expr)); } @@ -3475,26 +3483,82 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self if ($typeSpecification['sure']) { if ($specifiedTypes->shouldOverwrite()) { $scope = $scope->assignExpression($expr, $type, $type); + $scopeIsWorkingCopy = false; } else { - $scope = $scope->addTypeToExpression($expr, $type); + // addTypeToExpression(), writing into the working copy + $originalExprType = $scope->getType($expr); + if (!$scope->isComplexUnionType($originalExprType)) { + $nativeType = $scope->getNativeType($expr); + $newType = TypeCombinator::intersect($type, $originalExprType); + $newNativeType = $originalExprType->equals($nativeType) ? $newType : TypeCombinator::intersect($type, $nativeType); + if (!$this->isSpecifyExpressionTypeNoop($expr, $newType)) { + if (!$scopeIsWorkingCopy) { + $scope = $scope->openSpecificationScope(); + $scopeIsWorkingCopy = true; + } + $scope->specifyExpressionTypeInPlace($expr, $newType, $newNativeType, TrinaryLogic::createYes()); + } + } + } + } elseif (!$type instanceof NeverType) { + // removeTypeFromExpression(), writing into the working copy + $exprType = $scope->getType($expr); + if (!$exprType instanceof NeverType && !$scope->isComplexUnionType($exprType)) { + $newType = TypeCombinator::remove($exprType, $type); + $newNativeType = TypeCombinator::remove($scope->getNativeType($expr), $type); + if (!$this->isSpecifyExpressionTypeNoop($expr, $newType)) { + if (!$scopeIsWorkingCopy) { + $scope = $scope->openSpecificationScope(); + $scopeIsWorkingCopy = true; + } + $scope->specifyExpressionTypeInPlace($expr, $newType, $newNativeType, TrinaryLogic::createYes()); + } } - } else { - $scope = $scope->removeTypeFromExpression($expr, $type); } $specifiedExpressions[$typeSpecification['exprString']] = ExpressionTypeHolder::createYes($expr, $scope->getScopeType($expr)); } - [$conditions] = ScopeOps::matchConditionalExpressions($scope->conditionalExpressions, $specifiedExpressions); + $scope = $scope->processConditionalExpressionsAfterSpecifying($specifiedExpressions); - return $this->applyFilteredConditions($scope, $conditions, $specifiedTypes); + $newConditionalExpressionHolders = $specifiedTypes->getNewConditionalExpressionHolders(); + foreach ($specifiedTypes->getConditionalExpressionHolderRecipes() as $recipe) { + // the recipes' state-dependent math runs here, against this scope's + // pre-application state - the application point of the narrowing + foreach ($recipe->evaluate($this) as $recipeExprString => $recipeHolders) { + foreach ($recipeHolders as $key => $holder) { + $newConditionalExpressionHolders[$recipeExprString][$key] = $holder; + } + } + } + + /** @var static */ + return ScopeOps::scopeWith( + $scope, + $scope->expressionTypes, + $scope->nativeExpressionTypes, + $this->mergeConditionalExpressions($newConditionalExpressionHolders, $scope->conditionalExpressions), + $scope->currentlyAssignedExpressions, + $scope->currentlyAllowedUndefinedExpressions, + $scope->inFunctionCallsStack, + $scope->inFirstLevelStatement, + $scope->afterExtractCall, + ); } /** - * @param array $conditions - * @return static + * Matches already-registered conditional expressions against the just-specified + * expression type holders and applies the matching consequences. + * + * Mutates and returns $this - only to be called on an intermediate scope + * that is about to be rebuilt through the scope factory. + * + * @param array $specifiedExpressions */ - private function applyFilteredConditions(self $scope, array $conditions, SpecifiedTypes $specifiedTypes): self + private function processConditionalExpressionsAfterSpecifying(array $specifiedExpressions): self { + $scope = $this; + [$conditions] = ScopeOps::matchConditionalExpressions($scope->conditionalExpressions, $specifiedExpressions); + foreach ($conditions as $conditionalExprString => $expressions) { $certainty = TrinaryLogic::lazyExtremeIdentity($expressions, static fn (ConditionalExpressionHolder $holder) => $holder->getTypeHolder()->getCertainty()); if ($certainty->no()) { @@ -3517,29 +3581,7 @@ private function applyFilteredConditions(self $scope, array $conditions, Specifi } } - $newConditionalExpressionHolders = $specifiedTypes->getNewConditionalExpressionHolders(); - foreach ($specifiedTypes->getConditionalExpressionHolderRecipes() as $recipe) { - // the recipes' state-dependent math runs here, against this scope's - // pre-application state - the application point of the narrowing - foreach ($recipe->evaluate($this) as $recipeExprString => $recipeHolders) { - foreach ($recipeHolders as $key => $holder) { - $newConditionalExpressionHolders[$recipeExprString][$key] = $holder; - } - } - } - - /** @var static */ - return ScopeOps::scopeWith( - $scope, - $scope->expressionTypes, - $scope->nativeExpressionTypes, - $this->mergeConditionalExpressions($newConditionalExpressionHolders, $scope->conditionalExpressions), - $scope->currentlyAssignedExpressions, - $scope->currentlyAllowedUndefinedExpressions, - $scope->inFunctionCallsStack, - $scope->inFirstLevelStatement, - $scope->afterExtractCall, - ); + return $scope; } /** From 127ffe870d92c051c4a70eb4c360bfb7a97c89af Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 31 Jul 2026 19:55:57 +0200 Subject: [PATCH 3/6] Build the type-specification list inline, sorting alternative-form entries in The alternative-form entries of a SpecifiedTypes batch were appended after the sorted sure/sure-not list; sorting all three kinds together restores the shortest-expression-first guarantee (parents specified before their children) for them too. With the building inlined at its only caller, ScopeOps::buildTypeSpecifications() and its native mirror are removed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7 --- phpstan-baseline.neon | 2 +- src/Analyser/MutatingScope.php | 47 ++++++++--- src/Analyser/ScopeOps.php | 47 ----------- turbo-ext/src/ScopeOps.cpp | 138 --------------------------------- turbo-ext/tests/smoke.php | 30 ------- 5 files changed, 38 insertions(+), 226 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 3578073227..ca35341dc9 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -69,7 +69,7 @@ parameters: - rawMessage: Casting to string something that's already string. identifier: cast.useless - count: 2 + count: 1 path: src/Analyser/MutatingScope.php - diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 8078d199a0..9707f19f7e 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -130,6 +130,7 @@ use function strtolower; use function substr; use function uksort; +use function usort; use const PHP_INT_MAX; use const PHP_INT_MIN; use const PHP_VERSION_ID; @@ -3392,24 +3393,50 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $specifiedTypes = $specifiedTypes->unionWith($augmentTypes); } - $typeSpecifications = ScopeOps::buildTypeSpecifications($specifiedTypes->getSureTypes(), $specifiedTypes->getSureNotTypes()); - - foreach ($specifiedTypes->getAlternativeTypes() as $exprString => [$alternativeExpr, $terms]) { - if ( - $alternativeExpr instanceof Node\Scalar - || $alternativeExpr instanceof Expr\Array_ - || ($alternativeExpr instanceof Expr\UnaryMinus && $alternativeExpr->expr instanceof Node\Scalar) - ) { + $typeSpecifications = []; + foreach ($specifiedTypes->getSureTypes() as $exprString => [$expr, $type]) { + if ($expr instanceof Node\Scalar || $expr instanceof Expr\Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { continue; } $typeSpecifications[] = [ 'sure' => true, - 'exprString' => (string) $exprString, - 'expr' => $alternativeExpr, + 'exprString' => $exprString, + 'expr' => $expr, + 'type' => $type, + ]; + } + foreach ($specifiedTypes->getSureNotTypes() as $exprString => [$expr, $type]) { + if ($expr instanceof Node\Scalar || $expr instanceof Expr\Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { + continue; + } + $typeSpecifications[] = [ + 'sure' => false, + 'exprString' => $exprString, + 'expr' => $expr, + 'type' => $type, + ]; + } + foreach ($specifiedTypes->getAlternativeTypes() as $exprString => [$expr, $terms]) { + if ($expr instanceof Node\Scalar || $expr instanceof Expr\Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { + continue; + } + $typeSpecifications[] = [ + 'sure' => true, + 'exprString' => $exprString, + 'expr' => $expr, 'terms' => $terms, ]; } + usort($typeSpecifications, static function (array $a, array $b): int { + $length = strlen($a['exprString']) - strlen($b['exprString']); + if ($length !== 0) { + return $length; + } + + return $b['sure'] - $a['sure']; // @phpstan-ignore minus.leftNonNumeric, minus.rightNonNumeric + }); + $scope = $this; // one unpublished working copy takes all in-place specifications of the // batch; operations that go through other scope derivations publish it diff --git a/src/Analyser/ScopeOps.php b/src/Analyser/ScopeOps.php index 0a52507740..39b7d9a02f 100644 --- a/src/Analyser/ScopeOps.php +++ b/src/Analyser/ScopeOps.php @@ -4,7 +4,6 @@ use PhpParser\Node; use PhpParser\Node\Expr; -use PhpParser\Node\Expr\Array_; use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\PropertyFetch; @@ -37,7 +36,6 @@ use function strlen; use function strpos; use function substr_compare; -use function usort; /** * Hot scope-table operations extracted from MutatingScope. @@ -891,51 +889,6 @@ public static function getIntertwinedRefRootVariableName(Expr $expr): ?string return null; } - /** - * The sorted type-specification list of MutatingScope::filterBySpecifiedTypes(). - * - * @param array $sureTypes - * @param array $sureNotTypes - * @return list - */ - public static function buildTypeSpecifications(array $sureTypes, array $sureNotTypes): array - { - $typeSpecifications = []; - foreach ($sureTypes as $exprString => [$expr, $type]) { - if ($expr instanceof Node\Scalar || $expr instanceof Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { - continue; - } - $typeSpecifications[] = [ - 'sure' => true, - 'exprString' => (string) $exprString, - 'expr' => $expr, - 'type' => $type, - ]; - } - foreach ($sureNotTypes as $exprString => [$expr, $type]) { - if ($expr instanceof Node\Scalar || $expr instanceof Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { - continue; - } - $typeSpecifications[] = [ - 'sure' => false, - 'exprString' => (string) $exprString, - 'expr' => $expr, - 'type' => $type, - ]; - } - - usort($typeSpecifications, static function (array $a, array $b): int { - $length = strlen($a['exprString']) - strlen($b['exprString']); - if ($length !== 0) { - return $length; - } - - return $b['sure'] - $a['sure']; // @phpstan-ignore minus.leftNonNumeric, minus.rightNonNumeric - }); - - return $typeSpecifications; - } - /** * The conditional-expressions fixed-point matching of * MutatingScope::filterBySpecifiedTypes(). diff --git a/turbo-ext/src/ScopeOps.cpp b/turbo-ext/src/ScopeOps.cpp index 4ae5517fb9..e7f434ae5d 100644 --- a/turbo-ext/src/ScopeOps.cpp +++ b/turbo-ext/src/ScopeOps.cpp @@ -1087,47 +1087,6 @@ class ScopeOps return zv::Val::null(); } - /* - * Mirrors ScopeOps::buildTypeSpecifications(); the usort becomes a qsort - * over a flat working set, kept stable via a sequence number. - */ - static zv::Val buildTypeSpecifications(zv::TableRef sureTypes, zv::TableRef sureNotTypes) - { - uint32_t capacity = sureTypes.size() + sureNotTypes.size(); - TypeSpec *specs = capacity > 0 ? (TypeSpec *) emalloc(capacity * sizeof(TypeSpec)) : NULL; - uint32_t count = 0; - - if (UNEXPECTED(!collectTypeSpecifications(sureTypes, true, specs, &count)) - || UNEXPECTED(!collectTypeSpecifications(sureNotTypes, false, specs, &count))) { - for (uint32_t i = 0; i < count; i++) { - zend_string_release(specs[i].exprString); - } - if (specs != NULL) { - efree(specs); - } - return zv::Val(); - } - - if (count > 1) { - qsort(specs, count, sizeof(TypeSpec), compareTypeSpecifications); - } - - zv::Arr result = zv::Arr::create(count); - for (uint32_t i = 0; i < count; i++) { - zv::Arr item = zv::Arr::create(4); - item.set("sure", zv::Val::boolean(specs[i].sure)); - item.set("exprString", zv::Val::adoptString(specs[i].exprString)); - item.set("expr", zv::Val::copyOf(zv::Ref(&specs[i].expr))); - item.set("type", zv::Val::copyOf(zv::Ref(&specs[i].type))); - result.push(std::move(item)); - } - - if (specs != NULL) { - efree(specs); - } - return zv::Val(std::move(result)); - } - /* * Mirrors ScopeOps::matchConditionalExpressions(): the fixed-point loop * with an exact-match pass and a supertype-match pass per expression. @@ -1281,16 +1240,6 @@ class ScopeOps bool isThis; }; - /* One row of buildTypeSpecifications()' sortable working set. */ - struct TypeSpec - { - zend_string *exprString; /* owned */ - zval expr; /* borrowed */ - zval type; /* borrowed */ - bool sure; - uint32_t seq; - }; - static zv::Val trinarySingleton(zend_long value) { return zv::Val::copyOf(zv::Ref(pt_trinary_singleton(value))); @@ -1980,80 +1929,6 @@ class ScopeOps return true; } - /* Collects one sureTypes/sureNotTypes table into the sortable working set. */ - static bool collectTypeSpecifications(zv::TableRef input, bool sure, TypeSpec *specs, uint32_t *count) - { - zend_class_entry *scalarCe = pt_class(PT_CLASS_SCALAR); - zend_class_entry *arrayExprCe = pt_class(PT_CLASS_ARRAY_EXPR); - zend_class_entry *unaryMinusCe = pt_class(PT_CLASS_UNARY_MINUS); - - if (UNEXPECTED(scalarCe == NULL || arrayExprCe == NULL || unaryMinusCe == NULL)) { - return false; - } - - for (auto entry : input) { - zv::Ref pair = entry.value().deref(); - if (UNEXPECTED(!pair.isArray())) { - zend_throw_error(NULL, "phpstan_turbo: sure type entry is not an array"); - return false; - } - zval *exprSlot = zend_hash_index_find(pair.asArrayTable(), 0); - zval *typeSlot = zend_hash_index_find(pair.asArrayTable(), 1); - if (UNEXPECTED(exprSlot == NULL || typeSlot == NULL)) { - zend_throw_error(NULL, "phpstan_turbo: malformed sure type entry"); - return false; - } - zv::Ref expr = zv::Ref(exprSlot).deref(); - zv::Ref type = zv::Ref(typeSlot).deref(); - if (UNEXPECTED(!expr.isObject() || !type.isObject())) { - zend_throw_error(NULL, "phpstan_turbo: malformed sure type entry"); - return false; - } - - zend_class_entry *exprCe = expr.asObject()->ce; - if (instanceof_function(exprCe, scalarCe) || instanceof_function(exprCe, arrayExprCe)) { - continue; - } - if (instanceof_function(exprCe, unaryMinusCe)) { - int32_t subOffset = pt_instance_prop_offset(exprCe, "expr", sizeof("expr") - 1); - if (subOffset >= 0) { - zv::Ref sub = zv::ObjRef(expr.asObject()).propAtOffset((uint32_t) subOffset).deref(); - if (sub.instanceOf(scalarCe)) { - continue; - } - } - } - - TypeSpec *spec = &specs[*count]; - zend_string *key = entry.stringKeyOrNull(); - spec->exprString = key != NULL ? zend_string_copy(key) : zend_long_to_str((zend_long) entry.indexKey()); - ZVAL_COPY_VALUE(&spec->expr, expr.raw()); - ZVAL_COPY_VALUE(&spec->type, type.raw()); - spec->sure = sure; - spec->seq = *count; - (*count)++; - } - - return true; - } - - /* buildTypeSpecifications()'s usort comparator (stable via seq) */ - static int compareTypeSpecifications(const void *a, const void *b) - { - const TypeSpec *specA = (const TypeSpec *) a; - const TypeSpec *specB = (const TypeSpec *) b; - size_t lengthA = ZSTR_LEN(specA->exprString); - size_t lengthB = ZSTR_LEN(specB->exprString); - - if (lengthA != lengthB) { - return lengthA < lengthB ? -1 : 1; - } - if (specA->sure != specB->sure) { - return specB->sure - specA->sure; /* sure=true first */ - } - return specA->seq < specB->seq ? -1 : (specA->seq > specB->seq ? 1 : 0); - } - /* * matchConditionalExpressions()' shared tail of both passes: * $conditions[$exprString][] = $conditionalExpression and @@ -2216,19 +2091,6 @@ void pt_register_scope_ops() result.intoReturnValue(return_value); }); - cls.method("buildTypeSpecifications", reg::PublicStatic, 2, { reg::arrayArg("sureTypes"), reg::arrayArg("sureNotTypes") }, [](INTERNAL_FUNCTION_PARAMETERS) { - HashTable *sure_types, *sure_not_types; - ZEND_PARSE_PARAMETERS_START(2, 2) - Z_PARAM_ARRAY_HT(sure_types) - Z_PARAM_ARRAY_HT(sure_not_types) - ZEND_PARSE_PARAMETERS_END(); - zv::Val result = ScopeOps::buildTypeSpecifications(zv::TableRef(sure_types), zv::TableRef(sure_not_types)); - if (UNEXPECTED(result.isUndef())) { - RETURN_THROWS(); - } - result.intoReturnValue(return_value); - }); - cls.method("matchConditionalExpressions", reg::PublicStatic, 2, { reg::arrayArg("conditionalExpressions"), reg::arrayArg("specifiedExpressions") }, [](INTERNAL_FUNCTION_PARAMETERS) { HashTable *conditional, *specified_input; ZEND_PARSE_PARAMETERS_START(2, 2) diff --git a/turbo-ext/tests/smoke.php b/turbo-ext/tests/smoke.php index e668d30344..a5b825ac31 100644 --- a/turbo-ext/tests/smoke.php +++ b/turbo-ext/tests/smoke.php @@ -565,36 +565,6 @@ public function enterNode(\PhpParser\Node $node) } check($mergeResults['php'] === $mergeResults['native'], 'ScopeOps mergeVariableHolders: merged keys, certainties and types'); -// buildTypeSpecifications — pure, so both sides can share the inputs; the -// scalar/array/unary-minus entries must be dropped and the result ordered by -// expression-string length with sure-before-not tie-breaking -$specInt = new \PHPStan\Type\IntegerType(); -$specString = new \PHPStan\Type\StringType(); -$specSure = [ - '$bb' => [new \PhpParser\Node\Expr\Variable('bb'), $specInt], - '$a' => [new \PhpParser\Node\Expr\Variable('a'), $specInt], - "'lit'" => [new \PhpParser\Node\Scalar\String_('lit'), $specString], - '-5' => [new \PhpParser\Node\Expr\UnaryMinus(new \PhpParser\Node\Scalar\Int_(5)), $specInt], - '[]' => [new \PhpParser\Node\Expr\Array_([]), $specInt], -]; -$specSureNot = [ - '$a' => [new \PhpParser\Node\Expr\Variable('a'), $specString], - '$o->p' => [new \PhpParser\Node\Expr\PropertyFetch(new \PhpParser\Node\Expr\Variable('o'), 'p'), $specString], -]; -$specResults = []; -foreach ($scopeOpsClasses as $side => $scopeOpsClass) { - $specResults[$side] = array_map( - static fn (array $specification): array => [ - $specification['sure'], - $specification['exprString'], - spl_object_id($specification['expr']), - $specification['type']->describe(\PHPStan\Type\VerbosityLevel::precise()), - ], - $scopeOpsClass::buildTypeSpecifications($specSure, $specSureNot), - ); -} -check($specResults['php'] === $specResults['native'], 'ScopeOps buildTypeSpecifications: filtering, ordering and tie-breaking'); - // matchConditionalExpressions — a holder whose conditions are all among the // specified expressions must resolve, transitively (fixed point); '$c' // resolves only after '$b' did, '$unmatched' never does From a1ebc5e21762f2622d6ec9e2f462c15d0acc670d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 31 Jul 2026 19:57:47 +0200 Subject: [PATCH 4/6] Rename filterBySpecifiedTypes() to applySpecifiedTypes() The method does not filter - it applies computed narrowing to the scope. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7 --- src/Analyser/ConditionalExpressionHolderRecipe.php | 2 +- src/Analyser/ExprHandler/AssignHandler.php | 4 ++-- .../Helper/ConditionalExpressionHolderHelper.php | 4 ++-- src/Analyser/MutatingScope.php | 8 +++++--- src/Analyser/NodeScopeResolver.php | 2 +- src/Analyser/ScopeOps.php | 2 +- src/Analyser/SpecifiedTypes.php | 4 ++-- tests/PHPStan/Analyser/TypeSpecifierTest.php | 2 +- 8 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/Analyser/ConditionalExpressionHolderRecipe.php b/src/Analyser/ConditionalExpressionHolderRecipe.php index 2abb015ece..4a6aa4e3f7 100644 --- a/src/Analyser/ConditionalExpressionHolderRecipe.php +++ b/src/Analyser/ConditionalExpressionHolderRecipe.php @@ -15,7 +15,7 @@ * composed. The state-dependent math - the condition complements against the * current type, the holder target types, the vacuity checks - runs in * evaluate() against the scope the narrowing is applied to - * (MutatingScope::filterBySpecifiedTypes()), never the scope the composition ran + * (MutatingScope::applySpecifiedTypes()), never the scope the composition ran * on. */ final class ConditionalExpressionHolderRecipe diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index ba29dbf325..c0dade166a 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -877,8 +877,8 @@ public function applyWrite( $condScope = $nodeScopeResolver->processExprNode($stmt, $assignedExpr->cond, $scope, $storage->duplicate(), new NoopNodeCallback(), ExpressionContext::createDeep())->getScope(); $truthySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($condScope, $assignedExpr->cond, TypeSpecifierContext::createTruthy()); $falseySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($condScope, $assignedExpr->cond, TypeSpecifierContext::createFalsey()); - $truthyScope = $condScope->filterBySpecifiedTypes($truthySpecifiedTypes); - $falsyScope = $condScope->filterBySpecifiedTypes($falseySpecifiedTypes); + $truthyScope = $condScope->applySpecifiedTypes($truthySpecifiedTypes); + $falsyScope = $condScope->applySpecifiedTypes($falseySpecifiedTypes); $truthyType = $truthyScope->getType($if); $falseyType = $falsyScope->getType($assignedExpr->else); diff --git a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php index bc615d90ac..1e23004949 100644 --- a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php +++ b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php @@ -34,7 +34,7 @@ public function __construct( * Captures the either-branch union recovery as a deferred augment: the * branch types are read from the operand-walk filtered scopes here at * compose time, while the does-it-actually-narrow gates run against the - * applying scope when MutatingScope::filterBySpecifiedTypes() evaluates it. + * applying scope when MutatingScope::applySpecifiedTypes() evaluates it. * * The filtered scopes are thunks resolved only when there are candidate * expressions - deriving them per level of a deep boolean chain is @@ -111,7 +111,7 @@ public function buildBranchUnionAugment( /** * Captures the raw entries of a boolean-decomposition holder pair as a * recipe; the state-dependent complement/target math runs against the - * applying scope when MutatingScope::filterBySpecifiedTypes() evaluates it. + * applying scope when MutatingScope::applySpecifiedTypes() evaluates it. * * The condition side asserts that its sub-expression evaluates truthy. * When that sub-expression is itself a compound boolean (e.g. `$a && $b`), diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 9707f19f7e..81d6e3c789 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3349,7 +3349,7 @@ public function filterByTruthyValue(Expr $expr): self } $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createTruthy()); - $scope = $this->filterBySpecifiedTypes($specifiedTypes); + $scope = $this->applySpecifiedTypes($specifiedTypes); $this->truthyScopes[$exprString] = $scope; return $scope; @@ -3366,16 +3366,18 @@ public function filterByFalseyValue(Expr $expr): self } $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createFalsey()); - $scope = $this->filterBySpecifiedTypes($specifiedTypes); + $scope = $this->applySpecifiedTypes($specifiedTypes); $this->falseyScopes[$exprString] = $scope; return $scope; } /** + * Applies computed narrowing to this scope. + * * @return static */ - public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self + public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self { // deferred augments see this scope's pre-application state - the // application point of the narrowing; their entries join this batch diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index aefe7850be..bc8d819950 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1167,7 +1167,7 @@ public function processStmtNode( $this->callNodeCallback($nodeCallback, new NoopExpressionNode($stmt->expr, $hasAssign), $scope, $storage); } $scope = $result->getScope(); - $scope = $scope->filterBySpecifiedTypes($this->typeSpecifier->specifyTypesInCondition( + $scope = $scope->applySpecifiedTypes($this->typeSpecifier->specifyTypesInCondition( $scope, $stmt->expr, TypeSpecifierContext::createNull(), diff --git a/src/Analyser/ScopeOps.php b/src/Analyser/ScopeOps.php index 39b7d9a02f..69eb65a4a9 100644 --- a/src/Analyser/ScopeOps.php +++ b/src/Analyser/ScopeOps.php @@ -891,7 +891,7 @@ public static function getIntertwinedRefRootVariableName(Expr $expr): ?string /** * The conditional-expressions fixed-point matching of - * MutatingScope::filterBySpecifiedTypes(). + * MutatingScope::applySpecifiedTypes(). * * @param array $conditionalExpressions * @param array $specifiedExpressions diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index b48b56da95..ed8e09a771 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -26,7 +26,7 @@ final class SpecifiedTypes /** * Deferred boolean-decomposition holders, evaluated against the applying - * scope by MutatingScope::filterBySpecifiedTypes(). + * scope by MutatingScope::applySpecifiedTypes(). * * @var list */ @@ -34,7 +34,7 @@ final class SpecifiedTypes /** * State-dependent augmentations evaluated against the applying scope by - * MutatingScope::filterBySpecifiedTypes(); their entries join the applied + * MutatingScope::applySpecifiedTypes(); their entries join the applied * batch. * * @var list diff --git a/tests/PHPStan/Analyser/TypeSpecifierTest.php b/tests/PHPStan/Analyser/TypeSpecifierTest.php index 8fedca4dfa..8444c43e6f 100644 --- a/tests/PHPStan/Analyser/TypeSpecifierTest.php +++ b/tests/PHPStan/Analyser/TypeSpecifierTest.php @@ -1391,7 +1391,7 @@ private function toReadableResult(SpecifiedTypes $specifiedTypes): array foreach ($specifiedTypes->getAlternativeTypes() as $exprString => [$exprNode, $terms]) { // evaluate the alternative-form entry against the test scope, the - // same way filterBySpecifiedTypes() evaluates it at the application + // same way applySpecifiedTypes() evaluates it at the application // point - the readable result matches the old eager normalize form $parts = []; foreach ($terms as [$sure, $subtract]) { From 1059d9e44625f5683c0117f7b230ddbf34ff2450 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 31 Jul 2026 20:00:05 +0200 Subject: [PATCH 5/6] Keep the held type when only changing an expression's certainty The isset-certainty path of applySpecifiedTypes() re-read the expression's type via getType() before re-specifying it with Maybe certainty. getType() only reports the type of Yes-certainty holders, so for a maybe-defined expression it broadens to the original type - which would overwrite a co-applied narrowing (e.g. isset's $a -> null in the else branch). Read the held type from the holder maps instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7 --- src/Analyser/MutatingScope.php | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 81d6e3c789..0f33ced2e3 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3248,18 +3248,28 @@ private function invalidateMethodsOnExpression(Expr $expressionToInvalidate): se ); } - private function setExpressionCertainty(Expr $expr, TrinaryLogic $certainty): self + /** + * Certainty change for applySpecifiedTypes(): + * it keeps the type already held for the expression instead of re-reading it + * via getType(). getType() only reports the type of Yes-certainty holders, so + * for a maybe-defined variable it broadens to the original type - which would + * overwrite a co-applied narrowing (e.g. isset's $a -> null in the else branch). + */ + private function setExpressionCertaintyKeepingType(Expr $expr, TrinaryLogic $certainty): self { - if ($this->hasExpressionType($expr)->no()) { + $exprString = $this->getNodeKey($expr); + if (!array_key_exists($exprString, $this->expressionTypes)) { throw new ShouldNotHappenException(); } - $originalExprType = $this->getType($expr); - $nativeType = $this->getNativeType($expr); + $exprType = $this->expressionTypes[$exprString]->getType(); + $nativeType = array_key_exists($exprString, $this->nativeExpressionTypes) + ? $this->nativeExpressionTypes[$exprString]->getType() + : $exprType; return $this->specifyExpressionType( $expr, - $originalExprType, + $exprType, $nativeType, $certainty, ); @@ -3453,7 +3463,7 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $expr = $issetExpr->getExpr(); if ($typeSpecification['sure']) { - $scope = $scope->setExpressionCertainty( + $scope = $scope->setExpressionCertaintyKeepingType( $expr, TrinaryLogic::createMaybe(), ); From 0e53dd0424e376379c63ab5ba1fcf2f36eaba827 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 3 Aug 2026 09:31:42 +0200 Subject: [PATCH 6/6] 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 26a5f31c39..f6ecb4c970 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -20,7 +20,7 @@ final class TurboExtensionEnabler * version is the short SHA of the last commit touching turbo-ext/src/, * enforced by the phar.yml turbo-version job. */ - public const EXPECTED_EXTENSION_VERSION = '1ecf6e0'; + public const EXPECTED_EXTENSION_VERSION = '127ffe8'; private static bool $typeCombinatorCacheEnabled = false;