Skip to content

Close soundness holes in closure signature inference - #6622

Merged
ondrejmirtes merged 5 commits into
2.3.xfrom
closure-type-outside-assertions
Sep 27, 2026
Merged

ondrejmirtes merged 5 commits into
2.3.xfrom
closure-type-outside-assertions

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

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) gave Collection<list<int>>. Each value of an unpacked argument is now observed against the parameter it lands in:

  • by position, or by name for a string key;
  • for an array with unknown keys, against every parameter from its position on (every parameter at all when the keys can be strings).

Closures leaving what the observation follows

A closure written where nothing types it is followed through its type. Its value could leave that silently:

function (?Closure $e): void {
	$c = function ($a) { /* $a: 'x', but 5 is passed below */ };
	$c('x');
	$d = $e ?? $c;   // Closure|Closure(m) collapses to Closure
	$d(5);
};
  • Unions absorbing the closure into a wider member (Closure, callable, mixed, Closure(mixed): void). This happens at scope merges, ?:, ??, ??=, match, and arrays written or spread into a list<Closure>. The markers the merged type lost now escape (ClosureSignatureInference::collectAbsorbed()). The check runs only while a frame observes closures.
  • Methods of the closure: __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.
  • Returned closures. 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 locally or escaped. So $c()(5) went unchecked against a body analysed with string. 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-reference foreach or destructuring, static or global, the body's $x no longer shares the reference the closure's use (&$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

  • New tests fail on the base commit: 56 nsrt assertions, the by-ref first-class-callable cases, and three CallCallablesRule false positives.
  • make tests is green with the extension off and loaded (22,407 tests).
  • walk-trace.php shows the PHP and native walks identical (646,836 lines). Side-by-side, signature parity and smoke all pass.
  • Self-analysis reports the identical (empty) error set. User CPU with turbo, 6 interleaved runs per leg: 61.5 s on this branch vs 61.7 s on the base.

Not changed

These are sound, but inconsistent:

  • fn ($v) => new Box($v) gives Box<mixed> where the equivalent closure gives Box<2>. The template site lives in the enclosing frame, since arrow bodies have no frame of their own.
  • A returned closure's type shows its declared parameters, like a directly passed closure.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KFJcH7kubdxiRHYaUSdKhA

ondrejmirtes and others added 5 commits September 27, 2026 22:12
… 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
@ondrejmirtes
ondrejmirtes merged commit dda82d9 into 2.3.x Sep 27, 2026
896 of 911 checks passed
@ondrejmirtes
ondrejmirtes deleted the closure-type-outside-assertions branch September 27, 2026 20:33
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