Shadow MutatingScope, ClassReflection and ExpressionResult natively - #6509
Merged
Merged
Conversation
ondrejmirtes
force-pushed
the
turbo-native-scope
branch
3 times, most recently
from
September 22, 2026 08:47
06160e7 to
fe72499
Compare
…leFlowBuilder, VariableLivenessResolver Shadow VolatileExpressionHelper, VariableFlow and VariableFlowBuilder natively VolatileExpressionHelper works on the scope's by-reference expression tables in place, separating a table only when an entry is actually removed (an eager zpp separation would duplicate the whole table on every no-op call). VariableFlow is declared abstract (new reg::Class::abstract_()) with the readonly $kind slot its PHP subclasses fill through the protected constructor; its factories instantiate the PHP flow classes through the class map (new keys: the four flow classes, VariableWrite, VariableWriteOffset, List_, StaticPropertyFetch). VariableFlowBuilder reads the node structure from the node properties and takes stored results from the native ExpressionResultStorage directly (pt_expression_result_storage_find; the PHP collaborators ExpressionResult, ArgsResult, MutatingScope and VariableWriteOffset stay engine calls). reg.h gains by-ref array, nullable variadic object, nullable bool and public string / private array constant builders. Shadow VariableLivenessResolver natively The resolver's only public entry point is the static resolve(): the native class keeps the twin's per-call state on the C stack as PHP arrays in the twin's exact shapes (so every set union, key order and insertion order is the twin's), walks the PHP flow classes through their property slots, reads VariableWrite through its slots (pt_variable_write_slots_of; the getters for anything else) and answers the catch-clause Type queries through the Type ops. The PHP VariableWritesNode is instantiated through the class map (new key variableWritesNode). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MShHKdUB19w38vboJLPKXy
Ports PHPStan\Analyser\ExpressionResult (29 methods) to turbo-ext/src/ ExpressionResult.cpp. The state lives in the twin's property slots in declaration order and the constructor keeps the twin's exact arginfo, so Nette still generates ExpressionResultFactory from it; the type/specify/ create callbacks, MutatingScope and the extensions collection are called through the engine, while the Type queries, late-resolvable resolution and the void->null projection go through the native Type ops and TypeTraverser. VariableFlowBuilder now reads a result's flow through the new pt_expression_result_variable_flow() slot reader instead of a method call. Adds the class-map keys typeSpecifierContext, voidToNullTraverser, issetabilityResolution, issetabilityLinkInfo and nullsafeMethodCall, and lets reg::callableArg() be nullable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MShHKdUB19w38vboJLPKXy
Ports every method of PHPStan\Reflection\ClassReflection to turbo-ext/src/ClassReflection.cpp, in the twin's file order: the class layout (54 instance slots in the twin's declaration order plus the static $resolvingTypeAliasImports; the `string|false|null = false` memo slots are TypedFalse properties), the 19-parameter constructor with the twin's exact service class names, direct C++ $this dispatch (the twin is final; the final flag joins the plan with the flip), the ancestors, the enum methods, traits, the constants family, the type-alias machinery, deprecation/internal/immutable/consistent-constructor/named-arguments, the attribute-class flags, the generics machinery (template type maps, call-site variance maps, withTypes/withVariances/asFinal), the resolved PHPDoc and its tag getters, resolved mixin types and allowed subtypes. The Type kernel is reached natively, with instanceof against a shadowed Type class accepting the twin under the prefixed harness. The VerbosityLevel / TemplateTypeVariance singletons and the two statics that classify a Type by its class entry, TemplateTypeScope::createWithClass() and TemplateTypeFactory::fromTemplateTag(), go through the classes' real names (kernelSingleton() / kernelStatic()), which under the prefixed differential activation are the PHP ones. TypehintHelper:: decideTypeFromReflection() gained a native export so the twin's ClassReflection parameter type never sees the native $this. Twelve class-map keys support the port: initializerExprContext, the Extended*/Wrapped* method and property reflections, enumCaseReflection, reflectionEnumBackedCase, realClassClassConstantReflection, typeAlias, circularTypeAliasDefinitionException, argumentsNormalizer and arg. The plan is differential-only: reg::Class::shadowDifferentialOnly(), a new ShadowPlan flag Shadow.cpp honours, declares the class only under the prefixed activation of the differential tests, never under the real name, so production keeps the PHP twin and the ClassReflectionAccess.cpp slot readers until the flip. side-by-side.php skips such a class, as no attribute names it yet. The generator renders declarations for shadowed classes only, so the port's generated declarations are kept next to it in turbo-ext/src/ClassReflectionDeclarations.h. tests/reflection-family.php rebuilds real class reflections (fixture + stubbed built-ins + withTypes/withVariances/asFinal derivations) as twin and native over duck-typed collaborators - the class reflection factory, the PHPDoc inheritance resolver and stand-ins for the class-map classes that receive $this - and compares 41,140 observations over 68 samples, memo slots included; the fixture covers type aliases, documented/ templated/deprecated constants, mixin, sealed, property/method tags and attribute classes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MShHKdUB19w38vboJLPKXy Claude-Session: https://claude.ai/code/session_017MvPby652L7wUqGAHEiEcN
PHPStan\Reflection\ClassReflection is the native class in a production run now: declareClass() (final, like the twin) + shadow(), ClassReflectionDeclarations.h to the generator's generated/ClassReflection.h, and the PT_CLASS_CLASS_REFLECTION class-map key gone. ClassReflectionAccess.cpp is deleted with it. Its seven readers used to answer from the twin's memo slots by name; they are re-exported from ClassReflection.cpp as a direct call into the native body (the PHP method for a foreign object), so the 44 Type-kernel call sites keep their spelling. The two MutatingScope scope readers and their slot cache moved to ScopeContext.cpp, where pt_class_reflection_access_rinit() became pt_scope_access_rinit(). typeGetClassReflection() shortcut through pt_object_type_get_class_reflection() ran ObjectType's body for a GenericObjectType, which overrides it; only the flip made that reachable. It dispatches through the receiver's own PT_OP_GET_CLASS_REFLECTION entry now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MShHKdUB19w38vboJLPKXy
PHPStan\Reflection\Php\PhpClassReflectionExtension is the member factory behind every ClassReflection::has*()/get*() call: hasMethod() 807K, hasNativeMethod() 746K, touchMemberCacheKey() 399K, getNativeMethod() 292K, hasProperty() 156K and getNativeProperty() 78K times in a self-analysis of src/Analyser, src/Rules and src/Type. The whole class is ported (the nine public methods natively, the member construction below them as C++), and its hot path is absorbed on both sides: the service's own bodies and self-calls, ClassReflection::getCacheKey() and ::getNativeReflection() through the pt_class_reflection_*() direct calls, the shared member LRU natively, and new readers of the BetterReflection adapter's member memos, which answer hasMethod()/hasProperty()/getMethod() out of ReflectionClass::$cachedMethods/$cachedProperties instead of three PHP frames (lazy population, the empty name and a missing method stay the library's, through its own methods). Values that reach a PHP collaborator's concrete typed parameter or an instanceof it performs - TrinaryLogic, TemplateTypeMap, TemplateTypeVariance, MixedType and what TypehintHelper and TypeCombinator build - are created through the classes' real names, which are the native classes in a production run and the PHP twins under the prefixed differential activation. tests/php-class-reflection-family.php builds both sides from the container's own collaborators with named arguments and compares every public method over inherited, trait, magic, promoted, hooked, attributed, enum, interface and signature-mapped members, in and out of class scope, plus the member-cache memo and its eviction. Interleaved A/B on src/Analyser + src/Rules + src/Type, user CPU, 8 ABBA pairs: 41.03s -> 40.55s, -1.16% (median pair delta -1.22%); analysis output byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MShHKdUB19w38vboJLPKXy
Ports every method PHPStan\Analyser\MutatingScope declares to turbo-ext/src/MutatingScope.cpp, in the twin's file order: - the class layout (37 declared slots in the twin's order), the 33-parameter constructor and Z_OBJCE-polymorphic $this dispatch, with named handlers for the methods NodeCallbackScope overrides so a $this-dispatch can identify the native body; - the type resolution core: getType() with the ScopeOps memo and tracked-holder direct entries and the NodeScopeResolver guard statics, resolveType() and the handler-node resolution, the ExpressionResultStorage stack accessors, the template-argument frame and constraints, getNativeType() / getKeepVoidType() / promoteNativeTypes(), resolveName() / resolveTypeByName() and getTypeFromValue(); - the in-function-call stack and the enter* families (classes, traits, class methods, property hooks, functions, namespaces, anonymous and arrow functions, closure binding); - the assignment and invalidation family and the specification machinery, where the twin starts chaining scopes: foreign-scope helpers read another scope's tables by slot when it is the native class and by name otherwise; - the narrowing application (applySpecifiedTypes() with its deferred augments and conditional-holder recipes) and the scope merges; - the closure and loop scopes, the loop fixed-point (generalizeWith() and generalizeType()'s seven buckets), the scope comparison, the visibility queries, debug(), the union-filtering member lookups and the node-callback entries. ScopeOps gains direct entries for the memo, the invalidation, the merges and the conditional-expression bookkeeping, StaticTypeFactory for the offset-accessible types, TypeUtils for resolveLateResolvableTypes(), and ExpressionResultStorage for duplicate(); UnionType::filterTypes() gets its PHP callable from one pt_type_native_callback() holder. The class-map keys the port needs are added, the PHP classes among them carrying #[ReferencedByTurboExtension]. The plan is differential-only (reg::Class::shadowDifferentialOnly()): the class is declared only by the prefixed activation of the differential tests, so production keeps the PHP twin until the flip. The generator renders declarations for shadowed classes only, so the port's generated declarations are kept next to it in turbo-ext/src/MutatingScopeDeclarations.h. tests/scope-family.php rebuilds real walk scopes on both sides over a recording scope factory - the enter* producers' create() arguments, a digest of the scope a chaining method answered, adjacent per-side scopes whose tables and conditional expressions are built per side for the merges and the generalization, and a Base/Child/Sibling hierarchy for the visibility queries - and compares 64,669 observations over 80 scopes. Mutation-checked with 39 deliberate native bugs: 37 caught, and the other two sit in arms the twin cannot reach either. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MShHKdUB19w38vboJLPKXy Claude-Session: https://claude.ai/code/session_017MvPby652L7wUqGAHEiEcN
PHPStan\Analyser\MutatingScope is the native class in a production run now: declareClass() (implementing Scope, NodeCallbackInvoker, CollectedDataEmitter) + shadow() instead of shadowDifferentialOnly(), #[ShadowedByTurboExtension] on the twin, the declarations moving from MutatingScopeDeclarations.h to the generator's generated/MutatingScope.h, and the PT_CLASS_MUTATING_SCOPE class-map key gone. Its only user, ScopeContext.cpp's scopeClassReflectionSlot(), reads pt_ce_mutating_scope directly; the class map was the only thing there that could throw, so the helper's `bool &error` out-parameter and the two UNEXPECTED(error) arms of pt_scope_is_in_class() / pt_scope_get_class_reflection() go with it. scope-family.php keeps comparing both sides under the prefix — the type barrier the prefix creates is unchanged by the flip — but its reflection-driven delegate generation is gone: every public method of the twin is native, so it produced nothing. smoke.php registers the class in $covered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MShHKdUB19w38vboJLPKXy
Four PHP frames stood between the native MutatingScope / ClassReflection and values they already owned, each reached per call through the pt_type_call path: - ExpressionResultStorageStack::getCurrent() (1.0M calls per self-analysis run, plus push/pop) - the class is small and final, so it is now shadowed natively, and MutatingScope::currentStorage() reads the top of the stack out of the list property instead of calling the method. - LazyClassReflectionExtensionRegistryProvider::getRegistry() (1.3M) followed by a ClassReflectionExtensionRegistry getter (1.3M) - the provider builds its registry once and keeps it in $registry for the rest of the run (it drops the container reference right after), and the registry is a final value class whose slots only its constructor writes. ReflectionAccess.cpp therefore reads both hops out of the property slots, exactly as it already does for the memoizing reflection provider: the methods still answer while the memo is null, for a provider or registry of any other class, and for a slot the constructor never wrote. - LazyInternalScopeFactory::create() (1.45M) - once its `??=` service memos are filled, create() is those slots plus a `new`, which MutatingScope::factoryCreate() now does itself. The method answers for the first create() of each factory, for any other InternalScopeFactory implementation, for arguments its signature would reject, and whenever the classes it would instantiate are not the native ones - which is what the prefixed differential activation sets up, so the tests keep exercising the twin. Hit rates over a src/Analyser + src/Reflection run: 499,649 native vs 351 method calls for create(), 299,999 vs 1 for the registry members, 400,000 vs 0 for the storage stack. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MShHKdUB19w38vboJLPKXy
ondrejmirtes
force-pushed
the
turbo-native-scope
branch
from
September 22, 2026 09:05
fe72499 to
cd47e80
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Makes
MutatingScope,ClassReflection,ExpressionResult,PhpClassReflectionExtensionand the variable-flow helpers native classes in a production run. Apart from the#[ShadowedByTurboExtension]/#[ReferencedByTurboExtension]attributes, the PHP twins are unchanged.MutatingScope and ClassReflection: port, then flip
Each of these two classes lands in two commits. The first ports every method, in the twin's file order, and registers the class with the new
reg::Class::shadowDifferentialOnly(): the class is declared only under the prefixed activation of the differential tests, never under the real name, so production keeps the PHP twin. The second commit flips it toshadow(): it adds the#[ShadowedByTurboExtension]attribute and moves the declarations from the hand-kept*Declarations.hinto the generated header.side-by-side.phpskips differential-only classes, since no attribute names them yet.Each port is checked differentially against its twin:
tests/scope-family.phprebuilds real walk scopes on both sides and compares 64,669 observations over 80 scopes. It was mutation-checked with 39 deliberate native bugs; 37 were caught, and the other two sit in arms the twin cannot reach either.tests/reflection-family.phpbuilds 68 class reflections (fixture classes, stubbed built-ins, andwithTypes/withVariances/asFinalderivations) as twin and native, and compares 41,140 observations, memo slots included.tests/php-class-reflection-family.phpcoversPhpClassReflectionExtension: every public method over inherited, trait, magic, promoted, hooked, attributed, enum, interface and signature-mapped members, in and out of class scope, plus the member-cache memo and its eviction.ClassReflectionAccess.cppis deleted. Its slot readers are re-exported fromClassReflection.cppas direct calls into the native bodies, so the Type-kernel call sites keep their spelling. The flip also exposed a latent bug: thetypeGetClassReflection()shortcut ranObjectType's body for aGenericObjectType, which overrides it. It now dispatches through the receiver's own op entry.Other native classes
ExpressionResult(29 methods). Its state lives in the twin's property slots and its constructor keeps the twin's exact arginfo, so Nette still generatesExpressionResultFactoryfrom it.VolatileExpressionHelper,VariableFlow,VariableFlowBuilderandVariableLivenessResolver. The resolver keeps its per-call state in the twin's exact array shapes, so every set union and every key and insertion order matches the twin's.PhpClassReflectionExtension, the member factory behind everyClassReflection::has*()/get*()call (hundreds of thousands of calls per self-analysis). Its hot path answershasMethod()/hasProperty()/getMethod()from the BetterReflection adapter's member memos instead of three PHP frames. In 8 interleaved ABBA pairs this is −1.16% user CPU, with byte-identical output.ExpressionResultStorageStack.Accessor hops
Several PHP frames stood between the native scope and values it already owns. They are now read from property slots:
ExpressionResultStorageStack::getCurrent(): 1.0M calls per self-analysis.LazyClassReflectionExtensionRegistryProvider::getRegistry()and the registry getter behind it: 1.3M calls each.LazyInternalScopeFactory::create(): 1.45M calls.The PHP methods still answer while a memo is unset, for any other implementation, and under the prefixed differential activation.
Verification
Each commit builds under
-Wall -Wextra -Werror, keeps the generated declarations current, and passesside-by-side.php,signature-parity.phpandsmoke.php(including the differential scope and reflection families) on its own. On the last commit, with the extension loaded and active,make tests,make phpstan,make csandmake lintare clean, and a strict profile-guided g++-15 build passes.🤖 Generated with Claude Code
https://claude.ai/code/session_017MvPby652L7wUqGAHEiEcN