Skip to content

Single-pass expression analysis groundwork - answer type questions from ExpressionResults#5857

Open
ondrejmirtes wants to merge 378 commits into
2.2.xfrom
resolve-type-rewrite-2
Open

Single-pass expression analysis groundwork - answer type questions from ExpressionResults#5857
ondrejmirtes wants to merge 378 commits into
2.2.xfrom
resolve-type-rewrite-2

Conversation

@ondrejmirtes

@ondrejmirtes ondrejmirtes commented Jun 12, 2026

Copy link
Copy Markdown
Member

Groundwork for the "new world" where an expression is traversed once: after processExpr, its ExpressionResult knows the before/after scopes, the type (typeCallback) and the narrowing (specifyTypesCallback), composed from child results instead of re-walking subtrees. Handlers then stop implementing TypeResolvingExprHandler; the old entry points (MutatingScope::resolveType, the TypeSpecifier dispatcher) are guarded behind NewWorld::disableOldWorld() and get mass-deleted in PHPStan 3.0.

What's on the branch, bottom up:

  • Guards + ExpressionResultFactory: old-world type resolution entry points throw when NewWorld::disableOldWorld() is flipped (the migration meter); all ExpressionResult construction goes through a generated factory.
  • ExpressionResult carries beforeScope, expr, typeCallback, specifyTypesCallback and is stored per node in ExpressionResultStorage (layered O(1) duplicate()), replacing the stored before-Scope.
  • ExprHandler / TypeResolvingExprHandler split: resolveType/specifyTypes move to the sub-interface so handlers can shed them one by one.
  • ExpressionResultStorageStack: old-world consumers (TypeSpecifier dispatcher, extensions, rules below PHP 8.1, unconverted handlers' resolveType) keep working for converted handlers' nodes. Every scope shares the stack created by its internal scope factory; NodeScopeResolver pushes the storage of the analysis in progress through MutatingScope::pushExpressionResultStorage() (always popped in finally, throwing on imbalance), and MutatingScope answers from the stored result - or processes a synthetic node on demand. Scopes never reference a storage directly, so nothing pins the result graph with the cycle collector disabled in bin/phpstan. Also adds MutatingScope::applySpecifiedTypes - filterBySpecifiedTypes without Scope::getType().
  • First two migrations: ScalarHandler and ArrayHandler no longer implement TypeResolvingExprHandler. The array migration is a precision win the old world cannot reach: each item type is captured at its own evaluation point, so [$b = 1, $b + 1, $c = $b, $c + 2, $c++, $c] infers array{1, 2, 1, 3, 1, 2}.

Verified: full test suite green, make phpstan clean, and analysis memory back at baseline (no leak from the result graph despite gc_disable()).

Closes phpstan/phpstan#13944
Closes phpstan/phpstan#12207
Closes phpstan/phpstan#7155

Closes phpstan/phpstan#2032

Closes phpstan/phpstan#10786

Closes phpstan/phpstan#13253
Closes phpstan/phpstan#14396
Closes phpstan/phpstan#11953
Closes phpstan/phpstan#13802
Closes phpstan/phpstan#13789
Closes phpstan/phpstan#12780
Closes phpstan/phpstan#14914
Closes phpstan/phpstan#14908

🤖 Generated with Claude Code

return $this->withFlavor(false);
}

private function withFlavor(bool $fiber): self

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this read withFiber?

…vingExprHandler is gone"

This reverts commit 72d3f6eb6a8e0821b20e152a465fd404cebf9c0f.
…back

Wire a createTypesCallback on AlwaysRememberedExprHandler that constrains both
the remembered wrapper key and the inner expression by composing the inner's
child result - the inside-out equivalent of TypeSpecifier::create()'s
AlwaysRememberedExpr fan-out. Additive: raw-Expr callers still go through
create()->createForExpr; nothing composes a remembered child result yet, so
behaviour is unchanged. A foundation for migrating the equality narrowing off
the raw create() path.
Wire createTypesCallback on NullsafePropertyFetchHandler and
NullsafeMethodCallHandler: the plain property fetch / method call narrowed by
the constraint, unioned with "receiver is not null" - the inside-out
equivalent of TypeSpecifier::createNullsafeTypes(). A deeper ?-> in the
receiver surfaces through the parent handler composing the var result, not by
walking the chain here. Additive: the raw-Expr create()/createNullsafeTypes()
path is intact and nothing composes a nullsafe child result yet, so behaviour
is unchanged.
Wire createTypesCallback on MethodCallHandler and StaticCallHandler: a
narrowable (non-side-effecting) call narrows itself; an impure one narrows to
nothing - the inside-out equivalent of createForExpr's MethodCall/StaticCall
purity gate. Reuses the existing isMethodCallNarrowable/isStaticCallNarrowable
predicates (which read the receiver/class child result, not Scope::getType).
Unlike the no-op foundations, a call result is already composed in coalesce /
instanceof / assignment contexts, so this applies the purity gate there too
(matching createForExpr); the suite stays green.
Wire createTypesCallback on FuncCallHandler: a narrowable (non-side-effecting,
non-first-class) function call narrows itself; an impure one narrows to
nothing - the inside-out equivalent of createForExpr's FuncCall purity gate.
Reuses the existing isFuncCallNarrowable predicate (reads the name child
result, not Scope::getType). Mirrors the method/static call foundation.
Complete the nullsafe createTypesCallback to mirror createForExpr's `?->`
handling instead of only its !containsNull branch: compute containsNull from
the constraint type and the ?->'s own type (read inside-out via the extracted
$nullsafeTypeCallback, not Scope::getType). When the constraint permits the
short-circuit's null (containsNull), narrow the ?-> node itself; otherwise emit
the plain-chain narrowing, the original ?-> double-key, and "receiver is not
null". This removes the createForExpr Scope::getType from the composed path.
Switch specifyTypesForNormalizedIdentical's operand narrowing from raw
TypeSpecifier::create($operand,...) to DefaultNarrowingHelper::createSubjectTypes
with the operand's stored result, so an assignment / remembered wrapper / call /
nullsafe operand fans out through its own createTypesCallback. This deletes the
two manual AlwaysRememberedExpr unwraps the helper carried.

createSubjectTypes now falls back to create() when there is no composable result
(a synthetic node reached via specifyTypesInCondition, or an operand not in the
current storage) instead of emitting a bare single entry - the create-side analog
of getChildSpecifiedTypes' specifyTypesInCondition bridge, transitional until
synthetic-node narrowing is removed.
…orSubject

Replace every remaining raw TypeSpecifier::create($subject, ...) call in the
helper with a createForSubject() that composes the subject inside-out from its
stored result (falling back to create() for synthetic subjects). The helper no
longer constructs narrowing for operands / call args / constants via the raw
create() path; each subject fans out through its own createTypesCallback.
Unifies the createSubjectTypes calls introduced last commit onto the same helper
and drops the now-unused $getResult closure.
…orSubject

Promote createForSubject to DefaultNarrowingHelper (same signature as
TypeSpecifier::create) and switch the remaining raw create() callers to it:
BinaryOpHandler (count/strlen/comparison narrowing), IssetHandler, and
ConditionalExpressionHolderHelper. Each subject now narrows through its own
stored result's createTypesCallback, falling back to create() only for
synthetic subjects. EqualityTypeSpecifyingHelper delegates to the shared method
instead of a local copy; ConditionalExpressionHolderHelper no longer needs the
TypeSpecifier dependency.
getChildSpecifiedTypes' fallback (a child with no wired specifyTypesCallback, or
a synthetic node with no result) now processes the child on demand and asks its
result for the narrowing - $s->specifyTypesOfNewWorldHandlerNode(...) ?? default
- the same path specifyTypesInCondition already routes handler-supported nodes
through, minus the old-world dispatcher. The create-side fallback deliberately
stays on create(): its handlers' createTypesCallbacks self-reference their own
node, so on-demand routing there would re-enter infinitely.
Extract the on-demand narrowing of getChildSpecifiedTypes into a reusable
DefaultNarrowingHelper::specifyTypesForNode(scope, node, context), and route
BinaryOpHandler's synthetic re-dispatches (NotIdentical->Identical,
NotEqual->Equal, inverse comparisons, count===) through it instead of the
old-world specifyTypesInCondition. Each synthetic is a different node type than
the one being processed, so on-demand processing cannot self-cycle.
Route EqualityTypeSpecifyingHelper's re-dispatched conditions (==null/true/false,
Equal->Identical, ->Instanceof_, preg_match/operand dispatch) through
DefaultNarrowingHelper::specifyTypesForNode instead of the old-world
specifyTypesInCondition. specifyTypesInCondition already funnelled these synthetic
nodes through specifyTypesOfNewWorldHandlerNode/processExprOnDemand, so the path
and its termination are unchanged; none rebuilds an Identical with the same
operands, so there is no self-cycle.
empty($x) narrowing builds a synthetic `!isset($x) || !$x` BooleanOr; route it
through DefaultNarrowingHelper::specifyTypesForNode instead of the old-world
specifyTypesInCondition. Its BooleanNot leaves now resolve through the on-demand
getChildSpecifiedTypes path; nothing in that chain re-synthesizes the empty()
node, so it terminates. EmptyHandler no longer needs the TypeSpecifier dependency.
BooleanNotHandler narrows its operand by composing directly from the operand's
own ExpressionResult (getChildSpecifiedTypes with the captured result) instead of
re-resolving the node - the natural inside-out flow, since the operand was just
processed above. IssetHandler rewrites multi-var isset() into a synthetic
BooleanAnd chain of single-var issets (no captured result for the chain) and
resolves it on demand via specifyTypesForNode; the single-var path builds no
further chain so it terminates. BooleanNotHandler no longer needs TypeSpecifier.
BinaryOpHandler hands EqualityTypeSpecifyingHelper a $resultFor closure mapping
each operand node to the ExpressionResult it already produced. The helper's
existing-operand specify dispatches (==false/==true, preg_match ===) now compose
the operand's narrowing from that captured result via getChildSpecifiedTypes
rather than re-resolving the node through specifyTypesForNode. Behaviour is
unchanged - the on-demand path resolved the same stored result - but the common
operands are now composed inside-out directly; a non-operand or unwrapped node
maps to null and still falls back to on-demand resolution.
… unwraps

createForSubject takes an optional $resultFor lookup so a parent handler that
already produced the operand's result composes from it directly; an
AlwaysRememberedExpr operand then fans out to wrapper + inner through its own
createTypesCallback. Thread it through EqualityTypeSpecifyingHelper's operand
createForSubject calls (and finish the specify side in
specifyTypesForConstantBinaryExpression), then delete the manual
"if ($x instanceof AlwaysRememberedExpr) narrow the unwrapped inner" blocks in
the never and finite-type branches - the composed path covers them (it also
narrows the wrapper key, matching create()'s double-key). The remaining
AlwaysRememberedExpr instanceof checks are structural: they unwrap to inspect the
inner node's syntax (count/preg_match/get_class/::class), not to narrow.
ondrejmirtes and others added 28 commits July 16, 2026 16:06
The foreach handling re-priced its iteratee with two on-demand walks per
processing: once on the truthy-narrowed scope (the iteratee narrowed to a
non-empty array) and once on the post-loop scope (the body may have
modified it). Both narrowing and modification are tracked by the scope, so
getTypeOnScope()'s authoritative read answers without a walk; the new
ExpressionResult::answersOnScope() gates the rare remainder - an untracked
iteratee whose inputs the loop body changed - onto the reprocessing path.

In nested loops inside closures these walks multiplied with the
convergence passes (91 of the 237 walks of the census's hottest
non-trait node came from these two sites).
…result

Every foreach processing narrowed the body scope by a synthetic
NotIdentical($iteratee, []) walked through the on-demand dispatcher - at
three sites, one of them inside the convergence loop, so the iteratee and
the sentinel were re-priced up to LOOP_SCOPE_ITERATIONS + 2 times per
processing. The walked synthetic delegates to
IdenticalNarrowingHelper::specifyIdenticalAgainstType anyway; call it
directly with the iteratee's result and the empty-array sentinel, compute
the narrowed scope once, and reuse it at all three merge points. The walk
remains as the composition's miss seam (FuncCall families, dynamic
class-constant subjects).
…scope

The switch handling narrowed each case's branch scope by walking a
synthetic Equal(subject, case) through the on-demand dispatcher; the walk
delegates to IdenticalNarrowingHelper::specifyEqual anyway, so call it
directly with the subject's and the case's results (the walk stays as the
composition's miss seam).

The match and switch exhaustiveness checks re-walked their subject on the
"no arm/case matched" scope; that narrowing is tracked by the scope, so
getTypeOnScope's authoritative read answers without a walk, gated by
answersOnScope like the foreach iteratee.
…alking

CoalesceCompositionHelper's left-is-set branch re-walked the left
expression on the isset-narrowed scope for every ?? / ??= type ask. The
chain narrowing is tracked by the scope (getTypeOnScope's authoritative
read), so the walk only remains for an untracked left side, gated by
answersOnScope.
…alking

Same pattern as the foreach iteratee and match/switch subjects: the truthy
narrowing is tracked by the scope, so getTypeOnScope's authoritative read
answers without a walk; answersOnScope gates the untracked remainder onto
the reprocessing path.
Rules ask about the same (usually synthetic) expression repeatedly across
statement boundaries; each parked fiber was resolved by a fresh on-demand
walk on a duplicated, discarded storage, so every subsequent ask re-walked
the node and its whole subtree. The channel-aggregated census put this at
~31% of all corpus reprocessings (357k flush walks, 217k of them re-asks
of an already-priced expression, 99.9% answerable from the last result).

The last flush result is now kept per expression (WeakMap - entries die
with the AST) and reused when nothing the expression reads changed since
the walk, on both flavours; the resumed fiber reads the result's own
walk-position type, so scope-authoritative answers deliberately do not
qualify. State mismatches re-walk as before.
The memo's CPU win is real (-23% self-analysis user CPU, ahead of 2.2.x by
~15%) but retaining the flush-walk results pins their scope and callback
graphs: +1.3GB, which nondeterministically breaks make phpstan's 450M
per-worker limit. Every slim variant measured (eager types, native
re-walk, downgrade generations) loses the CPU win - the lazily memoized
native flavour on the retained result is what answers repeated asks for
free. Re-land after ExpressionResult retention shrinks
(callback-scope-removal) or with a cheaper native strategy.
A closure/arrow function passed as a call argument paid up to four full
body walks: the faithful-return gather walk, the real walk, a second
walk on the promoted scope for the native flavour of the stored result,
and (arrows) an on-demand walk when the invalidate-expressions read
asked Scope::getType() before the result was stored.

- The native flavour is now built from the same gathered
  returns/yields, reading the stored native types off the walk's scopes
  (buildClosureTypeForClosure(native: true), mirroring what
  buildClosureTypeForArrowFunction() and the plain handlers already do).
- The builders now compute the closure-type cache key on the
  flavour-correct scope and callable parameters: the native build no
  longer clobbers the phpdoc cache slot (previously
  buildClosureTypeForArrowFunction(native: true) cached the native
  return type under the phpdoc key), and a later getClosureType() ask on
  the promoted scope answers from the build instead of re-walking.
- The arrow branch builds the ClosureType once, stores the result
  first, and reads the invalidate expressions off the built type.
- The gather walk only runs when acceptor selection is type-driven
  (multiple variants, named-argument variants, or templates/conditionals
  to resolve); otherwise the signature-only shallow type keeps the
  count/name bookkeeping correct without a body walk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s consumed

For a multi-variant or templated callee, every argument re-ran the full
type-driven acceptor selection (intrinsic overrides + template inference
over all args padded to the full count) - O(args^2) with a full generic
resolution per argument.

Only a closure/arrow function consumes the generic-RESOLVED parameter
type: its parameters and body scope are typed from the resolved
callable(T), directly or through the in-function-call stack when nested
inside the argument. Every other argument reads variant-stable facts off
its parameter (by-ref flag, callable bookkeeping), so a single all-mixed
count-stable selection now serves all of them; the per-argument
gathered-so-far selection remains for arguments containing a
closure/arrow function.

Cuts acceptor selections by ~73% on a templated-call corpus with
byte-identical output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ults

Loop-convergence passes and expression-level loop processing wrote into
throwaway storage duplicates that never reached the scope's storage
stack, so every in-pass ask (applySpecifiedTypes pricing, stored-result
reads) missed the pass's own results and re-priced real nodes on demand
each iteration.

- The conditional storage push moves from processStmtNodesInternal into
  processStmtNodesInternalWithoutFlushingPendingFibers, covering the
  closure by-ref convergence passes that call it directly.
- While/Do/For condition walks, For loop-expression walks, and foreach
  enterForeach virtual assigns now push their pass storage for the
  pass's duration (branch-scope derivations forced inside the region).
- The While post-loop falsey narrowing and the foreach iteratee
  re-pricing walk on a duplicate of the live storage instead of a fresh
  one, so subresults whose state did not change answer from the stored
  results.
- Do/For read the always-iterates check off the condition's processed
  result instead of a scope-based read that was a guaranteed storage
  miss (the condition was only ever stored into discarded convergence
  duplicates) followed by processing the condition a second time.

Cuts on-demand walks by 31% on a loop-heavy corpus and 15% on
TypeCombinator.php self-analysis with byte-identical output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
isGenerator() recursively scanned the whole function body on every call,
and reflections for the same node are recreated per ask - the yield scan
ran 1.3M times on the src/Analyser+src/Rules corpus. The answer is a
property of the AST node, so it is memoized on the node itself (WeakMap).
Self-analysis user CPU: -5%.
FiberScope::getType()/getNativeType() suspended on every ask; for an
already-stored expression the round-trip through the fiber machinery hands
back the very result the scope's current storage holds. The new
findSettledExpressionResult() (the find path's provisional gate, now
shared) answers those asks in place under exactly the resumed fast path's
conditions; misses and filtered/promoted asks suspend as before.
…keys

shouldInvalidateExpression() re-scanned the holder's whole subtree with a
NodeFinder on every invalidation (after a substring pre-filter on the
printed keys, whose compositional-printer invariant needed carve-outs for
virtual nodes and key suffixes). The scan establishes a property of the
holder's expression alone, so each holder now lazily indexes the node keys
of its sub-expressions once - holders are shared across scope copies, so
the single subtree scan amortizes over every later check - and the check
becomes one array lookup, exact by construction.

'$this' invalidations keep the finder: they also match self/static/parent
and the current class name, and that name resolution depends on the asking
scope. invalidateExpression() was 7.8% of the src/Analyser+src/Rules run;
make phpstan user CPU: -2.5%.
Rules ask about the same (usually synthetic) expression repeatedly across
statement boundaries; each parked fiber was resolved by a fresh on-demand
walk on a duplicated, discarded storage, so every subsequent ask re-walked
the node and its subtree - the single largest reprocessing channel in the
corpus census (31% of reprocessing walks, 357k flush walks per run, 99.9%
of re-asks answerable from the previous pricing).

Unlike the reverted first attempt (436b282a0f), the memo retains no walk
results: only the read-variable state snapshot (ReadVariableStateSnapshot,
the retained half of askScopeVariableStateMatches) and the two materialized
types per expression. A hit fabricates an effect-free eager result at the
ask position - exactly where a fresh walk's result would sit - so no scope
or callback graph outlives the walk and memory stays at parity. Both
flavours are materialized at store time; with the walk contexts hot this
costs far less than the re-walks it replaces.

make phpstan user CPU: -3.5%, closing the gap to 2.2.x to about +1%.
createConditionalExpressions() scanned every holder map in full - twice
per merge - although each of its loops only ever selects entries whose
our/their/merged holders differ: guards require the merged type to differ
from ours, targets likewise, and the exclusion list is only consulted for
such entries. mergeVariableHolders() knows exactly which keys those are
(everything that is not one shared holder on both sides), so it now
reports them and the conditional creation iterates just that diff -
typically a handful of keys instead of hundreds of holders.

createConditionalExpressions was the largest self-time item in the
branch's SPX profile (5.8s of a 3x-inflated scoped run); make phpstan
user CPU: -1%.
Loop convergence re-walked the body until the exit scope stabilized; 94%
of converging loops converge exactly one pass after the last change, so
that final re-walk is pure verification. Walking is deterministic in the
entry scope: when a pass's merged entry equals the previous pass's entry,
the exit is the previous exit and the walk is skipped (27.5% of
convergence re-walks on the scoped corpus). Measured CPU-neutral on make
phpstan - the skipped bodies are small - but the wasted walks are gone
and larger loop bodies benefit proportionally.
specifyExpressionType() copied both holder maps and constructed a scope
per specification - and per array-dim level through its parent recursion.
The body now writes in place on an unpublished working copy
(specifyExpressionTypeInPlace, same contract as
processConditionalExpressionsAfterSpecifying), single callers open one
copy, and applySpecifiedTypes() shares one working copy across its whole
batch, publishing only when another scope derivation interleaves.

Measured CPU-neutral on make phpstan - the whole-map COW copies turn out
cheaper than modeled at typical holder-map sizes - but one copy per batch
replaces one per specification-and-dim-level, and the in-place seam is
what future batching builds on.
…ion position

Body walks pair a gathering closure (impure points, invalidations,
execution ends, return statements) with the rule-facing node callback and
read the gathered arrays as soon as the walk returns. With the whole
composite running inside a fiber, a rule parking on an unsettled
expression deferred the gathering past that read - a closure assigning
static::$prop lost its propertyAssign impure point whenever a rule
suspended on the PropertyAssignNode, so the invocation was reported as
having no side effects.

GatheringNodeCallback makes the pairing explicit: FiberNodeScopeResolver
unwraps it and runs the gatherer at the emission position, deferring only
the rule-facing remainder to a fiber.
…of native-typed properties

Three connected changes to the isset/empty/?? verdict machinery:

The maybe-uninitialized gate in IssetabilityResolution::isSet() drops the
nativeHasDefaultValue conjunct - the old-world MutatingScope::issetCheck()
never had it, only the rule side does (the two deliberately diverge
upstream). In its place the gate learns to read conditional-expression
entries about the fetch as initialization evidence: such entries exist
only when the fetch was narrowed in an evaluated condition, and evaluating
a condition reads the fetch, which would have thrown on an uninitialized
typed property. A typed-property read witnesses initialization.

IssetHandler evaluates its verdict and narrowing callbacks on the
post-revert scope instead of the scope before the subjects were processed:
revertNonNullability() leaves an originally-untracked nullable chain link
tracked at its original type, and that state is what the old world's
narrowing evaluation saw.

The falsey narrowing of a single isset() subject pins a native-typed
property to null even when the verdict is maybe, as long as the inner
chain is fully set: the maybe can only mean a nullable value or a possibly
uninitialized property, and reading an uninitialized typed property throws
instead of yielding a value - so in the !isset() branch any read that
completes yields null.
… askers

The raw target fetch of a property assignment is emitted to node callbacks
at the top of processAssignVar() (rules and DependencyResolver listen on
it and ask its type), but the assign flow never processed it as a read -
under the fiber engine those askers parked forever and were flushed
through the on-demand path, tripping the PHPSTAN_GUARD_NW diagnostic on
~320 suite tests. The property branches now price the target once at the
pre-assign position, consuming the receiver's and name's stored results;
storing it resumes the parked fibers with the pre-assign type.

The pending-fiber guard also learns the same processed-node check the
other three guards already have: a node whose per-body storage was
released since it was stored (class-level rules asking about gathered
method-body expressions) legitimately re-prices through the on-demand
rule bridge and must not be flagged.
A parameter default is a constant expression - resolve it through
InitializerExprTypeResolver like parameter defaults elsewhere, instead of
Scope::getType() on a node the single pass has not walked.
…ssed

The side-effect flip parameters (print_r's $return, var_export's, ...)
read an argument's type to decide whether the call has side effects. At
the pre-args position that argument is not walked yet; after processArgs()
its result is stored and the read is answered from it.
…traction

Upstream removed the then-unused import from MutatingScope; the branch's
applySpecifiedTypes still guards array literals with instanceof Array_,
which silently matched the nonexistent PHPStan\Analyser\Array_ and let
array literals into the scope's tracked expressions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VktvX3FRdnSsL66xGtiUh8
array-merge2: restore the key order upstream moved when getSortedTypes()
stopped sorting in place. preg_replace_callback: the flags-refined closure
parameter makes the arrow body's return type precise, so returning $m[0]
under PREG_OFFSET_CAPTURE is correctly reported as returning an array where
a string is required.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VktvX3FRdnSsL66xGtiUh8
The branch answers invalidation checks from the per-holder index of
contained node keys in MutatingScope, so the extracted
invalidateExpressionEntries/shouldInvalidateExpression/
containsExpressionToInvalidate/buildTypeSpecifications helpers have no
callers left. Also drop the baseline entries for the unused-use closure
errors that upstream's include/require handling (#6056) resolved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VktvX3FRdnSsL66xGtiUh8
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment