From 1ecf6e02d4517b04d123597b1f5f11dc5df6d83f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 30 Jul 2026 19:56:11 +0200 Subject: [PATCH 1/2] Let the compositional-key shortcut clear virtual-node keys on the invalidated side The invalidation pre-filters treated every '__phpstan' occurrence in the INVALIDATED expression's key as non-compositional: invalidating a virtual-keyed expression disabled the substring shortcut and swept the scope's whole holder population through the per-holder containment work. The holder side already consulted keyMayHideSubExpressions(); the invalidated side now does too, and ForeachValueByRef, IntertwinedVariableByReference and PropertyInitialization join COMPOSITIONAL_VIRTUAL_KEY_PREFIXES - their printers emit every walked sub-node (or the node walks none). The foreach and parameter original-value markers stay out deliberately: they hide a synthesized Variable child that containment-based invalidation must keep finding when the variable is reassigned. src/Analyser+src/Rules run: shouldInvalidateExpression() calls 759k -> 433k, containment scans 629k -> 297k. Mirrored in the native twin. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7 --- src/Analyser/ScopeOps.php | 13 +++++++++---- turbo-ext/src/ScopeOps.cpp | 13 ++++++++++--- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/Analyser/ScopeOps.php b/src/Analyser/ScopeOps.php index e5fb684ead..0a52507740 100644 --- a/src/Analyser/ScopeOps.php +++ b/src/Analyser/ScopeOps.php @@ -49,7 +49,12 @@ final class ScopeOps private const CONTAINS_SUPER_GLOBAL_ATTRIBUTE_NAME = 'containsSuperGlobal'; /** Virtual-node key prefixes whose printers include all children verbatim. */ - private const COMPOSITIONAL_VIRTUAL_KEY_PREFIXES = ['__phpstanPossiblyImpure(', '__phpstanRemembered(']; + // A prefix may be listed only when the printer emits every getSubNodeNames() + // sub-node verbatim (or the node walks no sub-nodes at all). The foreach/ + // parameter original-value markers (__phpstanOriginalForeachKey etc.) hide a + // synthesized Variable child on purpose - reassigning the variable must + // invalidate them through containment - so they can never be listed here. + private const COMPOSITIONAL_VIRTUAL_KEY_PREFIXES = ['__phpstanForeachValueByRef(', '__phpstanIntertwinedVariableByReference(', '__phpstanPossiblyImpure(', '__phpstanPropertyInitialization(', '__phpstanRemembered(']; /** * Mirrors MutatingScope::getNodeKey(). @@ -611,7 +616,7 @@ public static function invalidateExpressionEntries( // a substring cannot belong to an expression containing the invalidated one, so // the much more expensive per-expression check can be skipped without being called. $canUseKeyPrefilter = $exprStringToInvalidate !== '$this' - && !str_contains($exprStringToInvalidate, '__phpstan') + && !self::keyMayHideSubExpressions($exprStringToInvalidate) && !str_contains($exprStringToInvalidate, '/*'); foreach ($expressionTypes as $exprString => $exprTypeHolder) { @@ -735,7 +740,7 @@ public static function invalidateMethodsOnExpression( // call's key embeds its receiver's key verbatim, so when the invalidated key // does not occur in the entry's key, the receiver cannot match and the entry // can be kept without re-printing the receiver. - $canUseKeyPrefilter = !str_contains($exprStringToInvalidate, '__phpstan') + $canUseKeyPrefilter = !self::keyMayHideSubExpressions($exprStringToInvalidate) && !str_contains($exprStringToInvalidate, '/*'); foreach ($expressionTypes as $exprString => $exprTypeHolder) { @@ -844,7 +849,7 @@ public static function shouldInvalidateExpression(MutatingScope $scope, ExprPrin // - keys carrying a getNodeKey() suffix ('/*…*/') are not plain substrings. if ( $exprStringToInvalidate !== '$this' - && !str_contains($exprStringToInvalidate, '__phpstan') + && !self::keyMayHideSubExpressions($exprStringToInvalidate) && !str_contains($exprStringToInvalidate, '/*') && !str_contains($exprString, $exprStringToInvalidate) && !self::keyMayHideSubExpressions($exprString) diff --git a/turbo-ext/src/ScopeOps.cpp b/turbo-ext/src/ScopeOps.cpp index 307f228b36..4ae5517fb9 100644 --- a/turbo-ext/src/ScopeOps.cpp +++ b/turbo-ext/src/ScopeOps.cpp @@ -795,7 +795,7 @@ class ScopeOps * containing the invalidated one, so the much more expensive * per-expression check can be skipped without being called. */ const bool canUseKeyPrefilter = !query.isThis - && !strContains(exprStringToInvalidate, "__phpstan", sizeof("__phpstan") - 1) + && !keyMayHideSubExpressions(exprStringToInvalidate) && !strContains(exprStringToInvalidate, "/*", 2); bool invalidated = false; @@ -1006,7 +1006,7 @@ class ScopeOps * method call's key embeds its receiver's key verbatim, so when the * invalidated key does not occur in the entry's key, the receiver cannot * match and the entry can be kept without re-printing the receiver. */ - const bool canUseKeyPrefilter = !strContains(exprStringToInvalidate, "__phpstan", sizeof("__phpstan") - 1) + const bool canUseKeyPrefilter = !keyMayHideSubExpressions(exprStringToInvalidate) && !strContains(exprStringToInvalidate, "/*", 2); for (auto entry : expressionTypes) { @@ -1657,8 +1657,15 @@ class ScopeOps */ static bool keyMayHideSubExpressions(zend_string *key) { + /* Mirror of ScopeOps::COMPOSITIONAL_VIRTUAL_KEY_PREFIXES - a prefix may + * be listed only when the printer emits every getSubNodeNames() sub-node + * verbatim (or the node walks no sub-nodes at all); the foreach/parameter + * original-value markers hide a synthesized Variable child on purpose. */ static const struct { const char *prefix; size_t len; } compositionalPrefixes[] = { + { "__phpstanForeachValueByRef(", sizeof("__phpstanForeachValueByRef(") - 1 }, + { "__phpstanIntertwinedVariableByReference(", sizeof("__phpstanIntertwinedVariableByReference(") - 1 }, { "__phpstanPossiblyImpure(", sizeof("__phpstanPossiblyImpure(") - 1 }, + { "__phpstanPropertyInitialization(", sizeof("__phpstanPropertyInitialization(") - 1 }, { "__phpstanRemembered(", sizeof("__phpstanRemembered(") - 1 }, }; @@ -1901,7 +1908,7 @@ class ScopeOps /* Compositional-key substring gate */ if (!query.isThis - && !strContains(query.exprStringToInvalidate, "__phpstan", sizeof("__phpstan") - 1) + && !keyMayHideSubExpressions(query.exprStringToInvalidate) && !strContains(query.exprStringToInvalidate, "/*", 2) && !strContainsStr(exprString, query.exprStringToInvalidate) && !keyMayHideSubExpressions(exprString)) { From 410436a0e46a5fa3cc46f0572d5603fb2996a5a4 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 30 Jul 2026 19:56:14 +0200 Subject: [PATCH 2/2] 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 adeb565965..26a5f31c39 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 = '86312b7'; + public const EXPECTED_EXTENSION_VERSION = '1ecf6e0'; private static bool $typeCombinatorCacheEnabled = false;