Skip to content

Resolve isset/empty/?? chains from handler-built descriptors - #6144

Merged
ondrejmirtes merged 3 commits into
2.2.xfrom
isset-resolution-rework
Jul 30, 2026
Merged

Resolve isset/empty/?? chains from handler-built descriptors#6144
ondrejmirtes merged 3 commits into
2.2.xfrom
isset-resolution-rework

Conversation

@ondrejmirtes

@ondrejmirtes ondrejmirtes commented Jul 28, 2026

Copy link
Copy Markdown
Member

Rules\IssetCheck was a hand-maintained mirror of MutatingScope::issetCheck()'s chain walk (variable / offset / property / leaf), re-walking the AST and re-resolving types and property reflections with its own drifting copy of the logic (the scope side even carried a // mirrored in ... comment).

This lands the resolve-type-rewrite branch's architecture for the rule side, with MutatingScope untouched:

  • the chain-link handlers (VariableHandler / ArrayDimFetchHandler / PropertyFetchHandler / StaticPropertyFetchHandler) attach an IssetabilityDescriptor to their ExpressionResult, built from the child results they already have in hand;
  • IssetHandler, EmptyHandler, CoalesceHandler and AssignOpHandler (??=) emit virtual nodes (IssetExpressionNode / EmptyExpressionNode / CoalesceExpressionNode) carrying the subject ExpressionResults;
  • IssetRule / EmptyRule / NullCoalesceRule listen on those nodes, and Rules\IssetCheck folds the resolved links of an IssetabilityResolution instead of walking the AST.

IssetabilityDescriptor, IssetabilityLinkInfo, the three virtual nodes and the three rules are byte-identical to the branch; IssetabilityResolution and Rules\IssetCheck differ only by omitting branch-only leftovers (notEmpty() is dead until the branch's EmptyHandler conversion, plus a debug block). For ??=, AssignOpHandler prices the left side once as a Noop-callback read, mirroring CoalesceHandler's left-side processing — that is the branch's own shape for carrying the descriptor to the rule.

The engine side (MutatingScope::issetCheck() and the handlers' resolveType/specifyTypes) is deliberately untouched; it converges with the branch's sweep, at which point issetCheck() is deleted and no duplicated chain-walk logic remains.

Verification:

  • full test suite green with zero expectation churn (17778 tests)
  • --error-format=raw output identical to 2.2.x on an isset-saturated stress file
  • user CPU neutral on both self-analysis and a synthetic file that is 100% isset/??/empty() chains (ABBA pairs, +0.6% on the stress file, within noise)

🤖 Generated with Claude Code

https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7

Rules\IssetCheck was a hand-maintained mirror of MutatingScope::issetCheck's
chain walk (variable / offset / property / leaf), re-walking the AST and
re-resolving types and property reflections with its own drifting copy of
the logic.

The chain-link handlers (Variable / ArrayDimFetch / PropertyFetch /
StaticPropertyFetch) now attach an IssetabilityDescriptor to their
ExpressionResult, built from the child results they already have in hand.
IssetHandler, EmptyHandler, CoalesceHandler and AssignOpHandler (??=) emit
virtual nodes carrying the subject ExpressionResults; IssetRule, EmptyRule
and NullCoalesceRule listen on those nodes, and Rules\IssetCheck folds the
resolved links of an IssetabilityResolution instead of walking the AST.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7
@ondrejmirtes
ondrejmirtes force-pushed the isset-resolution-rework branch from e800ce8 to 6f9986a Compare July 28, 2026 20:15
@ondrejmirtes ondrejmirtes changed the title Resolve isset/empty/?? chains once via IssetabilityResolver Resolve isset/empty/?? chains from handler-built descriptors Jul 28, 2026
… ??= read

processAssignVar owned the whole assignment timeline - target sub-expression
walk, value evaluation, write - so callers had to inject the value evaluation
as a processExprCallback closure invoked once per target-shape branch. The
invocation point is the same in every branch: after the target's
sub-expressions are walked, before the write - PHP's own evaluation order.
Splitting at that boundary gives the caller the timeline: prepareTarget()
walks the target into a PreparedAssignTarget, the caller processes the
assigned value inline on its scope, and applyWrite() performs the write.

prepareTarget() takes an AssignTargetWalkMode; in the coalesce mode it also
prices the whole target as a read with isset() semantics, composed from the
just-walked child results through the chain-link handlers' new
composeResult() methods (the pure tails of their processExpr, which routes
through the same method) - so AssignOpHandler's ??= subject result for
CoalesceExpressionNode no longer needs the separate noop-callback walk of
the target chain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7
@ondrejmirtes

Copy link
Copy Markdown
Member Author

Second commit ports the AssignHandler prepareTarget()/applyWrite() split from the resolve-type-rewrite branch (adapted to mainline's on-demand result model): the processExprCallback closure is gone, callers process the assigned value inline between the two calls, and in the ??= walk mode the target is priced as a read with isset() semantics composed from the just-walked child results via the chain-link handlers' new composeResult() methods — replacing this PR's earlier noop-callback pre-read of the target chain. Full suite zero-churn (17778).

Two leftover noop-callback walks over real AST:

- A dynamic-name read-modify-write target (`$$name op= ...`) priced the whole
  variable through the walk machinery because the name expression was only
  walked later by the write flow. PHP evaluates the name before reading the
  old value, so prepareTarget() now walks it once - with the real node
  callback, at its actual evaluation point - composes the read from the
  result, and threads it on the PreparedAssignTarget; the write flow consumes
  it instead of walking the name again after the value.

- The ExistingArrayDimFetch branch walked the cloned chain so its nodes had
  stored results, but the composition below prices the clones directly
  through the scope - the walks had no observable effect here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7
@ondrejmirtes
ondrejmirtes merged commit c6cbe79 into 2.2.x Jul 30, 2026
742 of 744 checks passed
@ondrejmirtes
ondrejmirtes deleted the isset-resolution-rework branch July 30, 2026 09:18
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