Close soundness holes in closure signature inference - #6622
Merged
Merged
Conversation
… bodies Every closure or arrow function a fixture of the closure signature inference stores in a variable now also has its type asserted where it is used, so the inferred signature is checked against the types its body was analysed with. Arrow function twins cover the same sends, invocations and escapes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KFJcH7kubdxiRHYaUSdKhA
`$c(...$args)` and `$coll->add(...$ints)` put the unpacked array itself as a lower bound on the parameter at its position: a closure written where nothing types it got `'x'|list<int>` for its first parameter, reported every call with an int against it, and a generic receiver was inferred as `Collection<list<int>>`. Each value of the unpacked argument is now observed against the parameter it lands in - by position, by name for a string key - and the values of an array with unknown keys against every parameter they can reach. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KFJcH7kubdxiRHYaUSdKhA
…owed A closure written where nothing types it is followed through its type. Its value could leave that without the observation noticing, and the body was then analysed with parameter types some invocations do not pass: - A union absorbed it into a wider member - a scope merge with `?Closure`, a ternary with `callable`, `?:` with `mixed`, `??`, `??=`, `match`, an array written into or spread into a `list<Closure>`. The markers the merged type lost now escape (ClosureSignatureInference::collectAbsorbed()). - A method of the closure ran it (`__invoke()`, `call()`, a dynamic name, `?->`, the callable of `$c->__invoke(...)`), or an array callable `[$c, '__invoke']` carried it. These escape as well. - `$c(...)` built a new callable type without the markers. The first-class callable of a closure object is the object itself, so it now keeps its type and its invocations are followed like any other. - A closure returned by a stored closure was typed by the return types of the callables the outer one is sent to even when the outer one was also invoked here or escaped - `$c()(5)` went unchecked against a body analysed with `string`. The return marker now resolves to no expected type then. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KFJcH7kubdxiRHYaUSdKhA
A closure's `use (&$x)` holds the reference `$x` had when the closure was created. After `unset($x)`, `$x = &$y`, a by-reference foreach or destructuring, `static $x` or `global $x`, the variable of the body no longer shares it: invoking the closure changes nothing the body can see, and the body sees what the reference held, not what `$x` holds at the invocation. Following such a closure to its invocations was unsound, so a by-ref use of a variable the enclosing body rebinds keeps the creation-time fixpoint. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KFJcH7kubdxiRHYaUSdKhA
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.
Follow-up to #6604 and #6610. The fixtures of both now also assert the type of the stored closure outside its body, and arrow-function twins cover the same sends, invocations and escapes. Checking those against the types the bodies were analysed with found several places where an inferred signature was unsound.
Unpacked arguments
$c(...$args)put the unpacked array itself as the lower bound of the parameter at its position:Closure('x'|list<int>), and every call with an int reported against it. The template-argument observation from #6332 had the same bug:$coll->add(...$ints)gaveCollection<list<int>>. Each value of an unpacked argument is now observed against the parameter it lands in:Closures leaving what the observation follows
A closure written where nothing types it is followed through its type. Its value could leave that silently:
Closure,callable,mixed,Closure(mixed): void). This happens at scope merges,?:,??,??=,match, and arrays written or spread into alist<Closure>. The markers the merged type lost now escape (ClosureSignatureInference::collectAbsorbed()). The check runs only while a frame observes closures.__invoke(),call(), a dynamic name,?->and->__invoke(...). Array callables such as[$c, '__invoke']. These escape too.$c(...)built a new callable type without the markers. The first-class callable of a closure object is the object itself ($c(...) === $c), so it now keeps the callee's type, and invocations through it are followed. That includes by-ref effects.$c()(5)went unchecked against a body analysed withstring. An escaped or locally invoked return marker now resolves to no expected type.By-ref uses of rebound variables
After
unset($x),$x = &$y, a by-referenceforeachor destructuring,staticorglobal, the body's$xno longer shares the reference the closure'suse (&$x)holds. Invoking the closure then changes nothing the body sees. #6610 applied its writes anyway (unset($x); $c();made$x'a'; PHP leaves it undefined). A by-ref use of a variable the enclosing body rebinds now keeps the creation-time fixpoint.Verification
CallCallablesRulefalse positives.make testsis green with the extension off and loaded (22,407 tests).walk-trace.phpshows the PHP and native walks identical (646,836 lines). Side-by-side, signature parity and smoke all pass.Not changed
These are sound, but inconsistent:
fn ($v) => new Box($v)givesBox<mixed>where the equivalent closure givesBox<2>. The template site lives in the enclosing frame, since arrow bodies have no frame of their own.🤖 Generated with Claude Code
https://claude.ai/code/session_01KFJcH7kubdxiRHYaUSdKhA