Skip to content

Keep unresolved template arguments of different sites apart, solve covariant ones like Hack - #6650

Merged
ondrejmirtes merged 2 commits into
2.3.xfrom
issue-15331
Oct 2, 2026
Merged

ondrejmirtes merged 2 commits into
2.3.xfrom
issue-15331

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

A template slot fed by several unresolved sites — Box::of(1)->zip(Box::of(2), Box::of(3)) with @return self<T|U> — made a union of their markers. Unions treated a marker as its delegate, so generalizing unresolved(2)|unresolved(3) (for the display name of an impure method call) collapsed it into one marker and tripped The initial type of an unresolved template argument is never itself unresolved.; marker|int silently lost the marker too.

I compared what other checkers do with one type variable fed by several unresolved ones (Hack, Kotlin, Java, Scala 3, Rust, OCaml, Haskell, Swift, TypeScript, Flow, mypy, pyright — all run). Hack, the closest precedent to the two-pass design, keeps distinct type variables apart in unions, eagerly solves a covariant-only variable to the union of its lower bounds, and splits (#1 | #2) <: int into upper bounds on each member. This PR does the same:

  • TypeCombinator::union() keeps template-argument markers opaque: only the same site's marker merges, into the wider inferred type (closure-signature markers keep their old behaviour).
  • A template occurring only covariantly in a call's return type, or a covariant class template of new, gets no marker when something was inferred — the result carries the union of what it was inferred from (Box<m1|m2|m3>). A send of the result puts the declared argument as an upper bound on every marker in it.
  • An upper bound of a site also bounds the sites its inferred argument was made of (collect(Box<U>, Box<U>): Coll<U> sent to Coll<int>).
  • The covariant fallback of a site with nothing inferred prefers concrete upper bounds over another site's variable, fixing Box<mixed> false positives for objects passed through generic functions (joinBoxes(new Box(), new Box()) sent to Box<int>: expects Box<int>, Box<mixed> given).
  • TemplateArgumentObserver::replaceInferableTemplates() relied on the old absorption to turn T|null into the marker; it now absorbs the covered siblings itself.

Turbo mirrors are ported (with differential coverage in type-family.php); the full suite passes with and without the extension, and self-analysis output is identical either way.

Tests: AnalyserIntegrationTest::testBug15331 (the issue's snippet) and nsrt/template-argument-multiple-sites.php covering covariant/invariant/contravariant combinations, nothing-inferred sites, loop generalization and covariant new.

Closes phpstan/phpstan#15331

🤖 Generated with Claude Code

https://claude.ai/code/session_0159PawMjk3w72qmhaGSqSH9

ondrejmirtes and others added 2 commits October 2, 2026 09:34
…variant ones like Hack

A template slot fed by several unresolved sites (zip(of(1), of(2))) made
a union of their markers. Unions treated a marker as its delegate, so
generalizing unresolved(2)|unresolved(3) collapsed it into one marker and
tripped the "never itself unresolved" invariant; marker|int silently lost
the marker.

- TypeCombinator::union() keeps template-argument markers opaque: only the
  same site's marker merges, into the wider inferred type.
- A template occurring only covariantly in a call's return type, or a
  covariant class template of `new`, gets no marker when something was
  inferred: it is the union of what it was inferred from (Hack's eager
  solution of covariant-only type variables). A send of the result splits
  across the markers in that union as upper bounds.
- An upper bound of a site also bounds the sites its inferred argument was
  made of (collect(of(1), of(2)) returning Coll<U>).
- The covariant fallback of a site with nothing inferred prefers concrete
  upper bounds over another site's variable, fixing Box<mixed> false
  positives for objects passed through generic functions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159PawMjk3w72qmhaGSqSH9
@ondrejmirtes
ondrejmirtes merged commit ca8452c into 2.3.x Oct 2, 2026
@ondrejmirtes
ondrejmirtes deleted the issue-15331 branch October 2, 2026 07:35
@staabm

staabm commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

this PR was merged with "Turbo Lint" errors

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.

2.3.x-dev: internal error "The initial type of an unresolved template argument is never itself unresolved" (unresolvedTemplateArguments)

2 participants