Skip to content

Shadow NodeScopeResolver and the narrowing core natively - #6521

Merged
ondrejmirtes merged 16 commits into
2.3.xfrom
turbo-native-engine-foundation
Sep 22, 2026
Merged

ondrejmirtes merged 16 commits into
2.3.xfrom
turbo-native-engine-foundation

Conversation

@ondrejmirtes

@ondrejmirtes ondrejmirtes commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

Makes the core of the analysis engine native: NodeScopeResolver with StatementsHandler, the handler registries, the narrowing machinery (TypeSpecifier, SpecifiedTypes, TypeSpecifierContext and the narrowing helpers), and the value classes a walk passes around. Apart from the #[ShadowedByTurboExtension] / #[ReferencedByTurboExtension] attributes, the PHP twins are unchanged.

The engine-port foundation

Engine.h is what the analysis-engine ports build on:

  • PHPStanTurbo\NativeClosure: a C++ body with its captures inline in one allocation. PHP can call it like the closure it replaces, and native code enters it without a frame.
  • pt_expression_result_create(): calls the native constructor directly for the container-generated factory.
  • Handler entries dispatched through pt_expr_handler_process() / pt_stmt_handler_process().
  • Per-site method and property caches for the PHP collaborators that aren't ported yet.
  • pt_finally(), which runs a finally block with the pending exception set aside.
  • pt_engine_with_stack(): when the C stack runs low, native walk recursion continues on a fresh zend_fiber stack.

ScalarHandler and VariableHandler are the first two handlers on it.

Value classes

  • SpecifiedTypes and TypeSpecifierContext, including the alternative-term algebra of intersectWith()/unionWith().
  • ExpressionContext and StatementContext.
  • ImpurePoint, ThrowPoint, InternalThrowPoint, ArgsResult and IssetabilityDescriptor.
  • The statement results: the internal and public StatementResult, StatementExitPoint and EndStatementResult classes.
  • TemplateArgumentFrame, AssignTargetWalkMode and PreparedAssignTarget.
  • RecordingNodeCallback.

Getters are inline slot readers in AnalyserValues.h: the slot for a native object, the getter by name for anything else.

Services

  • ExprHandlerRegistry and StmtHandlerRegistry: they memoize with the twins' exact cache keys, and a hit allocates nothing.
  • MethodCallReturnTypeHelper, MethodThrowPointHelper and EarlyTerminatingCallHelper.
  • The boolean narrowing helpers, the conditional-expression recipe and the two disjunction augments.
  • TypeSpecifier, DefaultNarrowingHelper and IdenticalNarrowingHelper. Each public method is exported as a direct entry, and the twins' closures are native closures.
  • NodeScopeResolver, StatementsHandler (statement lists, goto convergence and the two-pass body walk) and NonNullabilityHelper. NodeScopeResolver isn't final, so $this calls take the native body only when the object is exactly the native class.

Two collection reads, the expression type resolver extensions (~600K PHP frames per self-analysis) and the asking scope's nativeTypesPromoted, now come straight from slots.

Verification

The ported classes are compared against their twins by smoke.php's differential tests and, for the engine as a whole, by walk-trace.php, which records a full NodeScopeResolver walk over the default corpus with the native classes and with the PHP implementations and requires identical traces (612,019 lines). With the extension loaded and active, make tests, make phpstan, make cs, make lint, make lint-turbo and make sanitize-turbo are clean, and make pgo with the strict flags passes under GCC 11.4 (CI's gnu image) against PHP 8.4 and PHP 8.6.

🤖 Generated with Claude Code

https://claude.ai/code/session_017MvPby652L7wUqGAHEiEcN

ondrejmirtes and others added 16 commits September 22, 2026 11:59
Ports both narrowing value classes over the twins' generated slots: the
with*()/set*() clones, intersectWith()/unionWith() with the alternative-term
algebra on the native TypeCombinator, and the context singletons kept in the
twin's static $registry. emptySpecifyCallback() is one Closure over a
NativeCallback holder cached in the twin's static slot, so invoking it from
native code takes no frame. MutatingScope (applySpecifiedTypes, filterBy*Value)
and ExpressionResult's branch scopes call the new pt_specified_types_* and
pt_type_specifier_context_* entries; the typeSpecifierContext class-map key is
removed. smoke.php compares both classes method by method.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3SmhnNkB3pEeVRTxqbdbu
… natively

ExpressionContext and StatementContext keep their state in the twins'
promoted property slots; native callers get pt_expression_context_* and
pt_statement_context_* direct entries (slots for a native context, the
methods otherwise). ExprHandlerRegistry and StmtHandlerRegistry memoize in
the twins' private static arrays with the exact cache keys (the CallLike
first-class-callable and New_ class discriminators assembled in a stack
buffer, so a hit allocates nothing) and export
pt_expr_handler_registry_resolve() / pt_stmt_handler_registry_resolve();
MutatingScope's static call goes through the former. The exprHandlerRegistry
class-map key is replaced by exprHandler and stmtHandler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3SmhnNkB3pEeVRTxqbdbu
…oundation

Engine.h is what the analysis-engine ports build on: PHPStanTurbo\NativeClosure
(a C++ body with its captures inline in one allocation, callable from PHP like
the closure it replaces and entered without a frame by pt_type_call_callable
and pt_direct_invoke), pt_expression_result_create() (the native constructor
directly for the container-generated factory, whose extensions collection is
learned from its first result; create() with the named arguments otherwise),
handler entries dispatched by pt_expr_handler_process() /
pt_stmt_handler_process(), and per-site method and property caches for the PHP
collaborators not ported yet. ScalarHandler and VariableHandler use all of it
plus the SpecifiedTypes / TypeSpecifierContext direct entries;
composeResult() is exported as pt_variable_handler_compose_result(). New
direct entries: ExpressionResult getters, four MutatingScope reads (the
branch scopes' applySpecifiedTypes() now uses one), VariableFlow::mention() /
all(READ_ALL). Class-map keys: impurePoint, issetabilityDescriptor.
turbo-ext/CLAUDE.md documents the porting conventions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3SmhnNkB3pEeVRTxqbdbu
…setabilityDescriptor natively

Ports the throw/impure point value classes, the argument walk result and
the isset/empty/?? chain descriptor. Their slot getters are inline borrowed
readers in the new AnalyserValues.h (the slot for the native class, the
getter by name otherwise), which Engine.h includes: they replace the
foundation's out-of-line ExpressionResult getters (getScope() /
getThrowPoints() / getImpurePoints() / hasYield() / isAlwaysTerminating(),
plus getBeforeScope() and getExpr()), and VariableFlowBuilder and
VariableHandler read through them. VariableHandler creates impure points and
descriptors through pt_impure_point_new() / pt_issetability_descriptor_variable()
(the class-map keys impurePoint and issetabilityDescriptor are gone).
Factories and behaviourful entries (toPublic(), resolve(), ...) are declared
in support.h, plus MutatingScope entries for hasExpressionType() / getType() /
getNativeType() next to the foundation's zend_string-keyed variable reads.
Mirrors the twins' isset()/?? reads of never initialized properties and the
read order of toPublic(). Differential: tests/analyser-values.php, run from
smoke.php.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3SmhnNkB3pEeVRTxqbdbu
Ports InternalStatementResult, InternalStatementExitPoint and
InternalEndStatementResult with the public StatementResult,
StatementExitPoint and EndStatementResult toPublic() builds. The
constructor joins the exit points' and end statements' inference facts
through new MutatingScope direct entries (get/addTemplateArgumentConstraints,
mergeWith for the loop back edge), reading them through inline slot readers
added to AnalyserValues.h. The exit-point walks both result classes repeat
are written once (StatementResults.h). Mirrors the "Undefined property"
warning of a num read on a statement other than break/continue. Adds the
class-map keys continueStmt and breakStmt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3SmhnNkB3pEeVRTxqbdbu
…Target natively

Ports the template inference frame (isObserving(), returnTypeOfCall(),
resolution through the parents, the unconstrained resolution with native
callback holders for the traversal and the resolver) and the assignment
target walk mode and prepared target. Their getters are inline slot readers
in AnalyserValues.h. MutatingScope calls returnTypeOfCall() directly and
gets direct entries for getCurrentTemplateArgumentFrame() and the
nativeTypesPromoted property. Replaces the class-map key
templateArgumentFrame with resolvedFunctionVariant; withTemplateArgumentFrame()
checks its argument against the shadowing class or the class of that name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3SmhnNkB3pEeVRTxqbdbu
Ports the recording node callback of the convergence passes: PHP invokes
the native __invoke() as a callable object through the engine, and
pt_type_call_callable() - what the native invokers of node callbacks use -
recognizes the class and records through
pt_recording_node_callback_record() without a call. The differential
invokes it every way PHP calls a callable object and through the native and
PHP ClassStatementsGatherer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3SmhnNkB3pEeVRTxqbdbu
…rminatingCallHelper natively

Ports the three call-handler helper services over the twins' generated slots,
with the twins' exact constructor arginfo for autowiring. The scope and Type
work runs natively through new MutatingScope direct entries (filterTypeWithMethod,
getMethodReflection, getStateType, getConditionalExpressions,
getCurrentExpressionResultStorage, resolveTypeByName,
specifyTypesOfNewWorldHandlerNode); pt_mutating_scope_has_expression_type() now
answers the PT_TRI_* value (IssetabilityDescriptor converted). InternalThrowPoint,
ExpressionContext::isInThrow() and TemplateArgumentFrame go through their direct
entries; the dynamic return/throw type extensions and the analyser classes still
PHP are called by name. getAll() of a LazyExtensionsCollection is read from its
memo slot (pt_extensions_collection_get_all). The twin's try/finally around the
primed storage is mirrored with the engine's exception chaining. Class-map keys
added: extensionClassHelper, lazyExtensionsCollection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3SmhnNkB3pEeVRTxqbdbu
Ports BooleanNarrowingHelper, ConditionalExpressionHolderHelper,
ConditionalExpressionHolderRecipe, DisjunctionBranchUnionAugment and
DisjunctionHolderProjectionAugment over the twins' generated slots. The
compositions run on the native SpecifiedTypes/TypeSpecifierContext entries and
construct the recipe/augments directly; MutatingScope::applySpecifiedTypes()
evaluates recipes and the two augments through the new
pt_conditional_expression_holder_recipe_evaluate() /
pt_disjunction_*_augment_evaluate() entries, and the recipe reads its results
through pt_expression_result_get_type_on_scope(). NodeScopeResolver and
DefaultNarrowingHelper stay PHP (by name). Class-map keys added:
booleanAndExpr, logicalAndExpr, booleanOrExpr, logicalOrExpr, parserIssetExpr.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3SmhnNkB3pEeVRTxqbdbu
Ports the narrowing service over the twin's generated slots with its exact
constructor arginfo and @api signatures: create() and the null-safe unwrap
run natively over the MutatingScope, Type, ExprPrinter and SpecifiedTypes
entries (the chain tail calls of createNullsafeTypes() as a loop), the
handler lookup through pt_expr_handler_registry_resolve(), and the per-class
extension lookups merge natively over ExtensionClassHelper's static memo
(pt_extension_class_helper_get_extension_class_names, also used by
EarlyTerminatingCallHelper). MutatingScope::filterByTruthyValue()/filterByFalseyValue()
call pt_type_specifier_specify_types_in_condition(). NullsafeOperatorHelper and
the reflections stay PHP (by name). Class-map keys added:
nullsafeOperatorHelper, coalesceExpr.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3SmhnNkB3pEeVRTxqbdbu
…memo

ExpressionResult::consultExpressionTypeResolverExtensions() and
MutatingScope::resolveType() asked the injected ExtensionsCollection for its
list by name on every type resolution (~600K PHP frames per self-analysis);
they now go through pt_extensions_collection_get_all(), which reads a
LazyExtensionsCollection's $extensions memo slot once it is filled and calls
getAll() only before that or for any other collection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3SmhnNkB3pEeVRTxqbdbu
Ports the helper whole: every public method is exported as a
pt_default_narrowing_helper_* direct entry; VariableHandler's
specifyTypesCallback and the two disjunction augments now call it through
them. The twin's closures are native closures (the chain type reader handed
out as a Closure, the issetability callbacks, both TypeTraverser callbacks of
specifyTypesFromAsserts(), the template one keeping its by-reference capture);
captureChainResults() walks its chain with an explicit stack in the twin's
pre-order. New direct entries: ExpressionResult containsNullsafe/
getCreatedTypesForScope/getSpecifiedTypesForScope, MutatingScope toWalkScope,
pt_expr_printer_print(), pt_specified_types_new_with_root_expr(), and the
ImpurePoint getNode/isCertain slot readers in AnalyserValues.h. Class-map keys:
typeExpr, identicalExpr.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3SmhnNkB3pEeVRTxqbdbu
Ports the === / == / against-type narrowing whole, including the function
families, the decided-comparison guard and specifyEqual()'s identical-type
callback as a native closure. The public methods are exported as
pt_identical_narrowing_helper_* direct entries; VariableHandler's
captureFirstArgResult() and specifyIdentical() calls use them, and every
DefaultNarrowingHelper call goes through its direct entries. The twin's
receiver-then-argument evaluation of `$a->unionWith($b)` is sequenced
explicitly. CountNarrowingHelper, RicherScopeGetTypeHelper and
ClassReflection::asFinal() stay PHP behind cached method sites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3SmhnNkB3pEeVRTxqbdbu
…onResult

ExpressionResult's getTypeOnScope() / getSpecifiedTypesForScope() /
getCreatedTypesForScope() and the other scope-flavoured reads looked the
public property up by name through zend_read_property() on every call; they
now take pt_mutating_scope_native_types_promoted(), which reads the slot of a
MutatingScope and the property table of any other scope object with the same
warning and uninitialized-read Error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3SmhnNkB3pEeVRTxqbdbu
…natively

The walk hub runs natively: processExprNode/processStmtNode/processStmtNodesInternal, the on-demand walks and callNodeCallback reach handlers through pt_expr_handler_process/pt_stmt_handler_process and the native registries; StatementsHandler (with StatementListWalkState) walks statement lists, goto convergence and the two-pass body walk over the native statement results, TemplateArgumentFrame and RecordingNodeCallback; NonNullabilityHelper answers applyPendingEnsure from its slots.
Direct entries: pt_node_scope_resolver_*, pt_statements_handler_*, pt_statement_list_walk_state_*, pt_non_nullability_helper_*, plus new MutatingScope, ExpressionResult, storage/stack and VariableFlow entries; the narrowing helpers' requireScopeStateType calls use the NodeScopeResolver entry.
Engine.h adds pt_finally() (finally blocks run with the pending exception set aside), pt_engine_call_node_callback() (native closures, the native gatherer/recording callbacks and object callables without a by-name resolve), php-parser attribute reads, and pt_engine_with_stack(): native walk recursion continues on a fresh zend_fiber C stack when the stack runs low.
NodeScopeResolver is not final: $this-calls take the native body only for exactly the native class. Its static guard properties stay real statics; MutatingScope reads them from the native class and calls processExprOnDemand through the direct entry.
Class map: nodeScopeResolver dropped; keys added for the php-parser statement/expression classes, NodeFinder, NodeAbstract, the virtual nodes, PhpMethodReflection, NoopNodeCallback and the PHP template-argument/ensured-non-nullability classes the hub still calls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3SmhnNkB3pEeVRTxqbdbu
@ondrejmirtes
ondrejmirtes force-pushed the turbo-native-engine-foundation branch from ef6001c to 65a3b1a Compare September 22, 2026 09:59
@ondrejmirtes
ondrejmirtes merged commit 65a3b1a into 2.3.x Sep 22, 2026
623 of 645 checks passed
@ondrejmirtes
ondrejmirtes deleted the turbo-native-engine-foundation branch September 22, 2026 10:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant