Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 45 additions & 3 deletions compiler/src/dotty/tools/dotc/core/TypeComparer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -1823,6 +1823,31 @@ class TypeComparer(@constructorOnly initctx: Context) extends ConstraintHandling
def paramBounds(tparam: Symbol): TypeBounds =
tparam.info.substApprox(tparams2.asInstanceOf[List[Symbol]], args2).bounds

lazy val allTparamSyms: List[Symbol] = tparams2.collect { case tparam: Symbol => tparam }

/** Which parameters have declared bounds that recursively refer to themselves?
*
* Capture conversion and existential widening below can both approximate
* wildcard arguments from their own bounds. That is sound for independent
* parameter bounds, but for recursive bounds it can accept a surface
* subtyping that frozen re-checks cannot justify. Detect direct TypeRefs
* to the parameter, and treat LazyRefs conservatively to avoid forcing
* recursive class-header initialization. Keep those cases on the old
* conservative path.
*/
lazy val recursiveParamBounds: List[Symbol] =
allTparamSyms.filter: tparam =>
val acc = new TypeAccumulator[Boolean]:
def apply(x: Boolean, tp: Type): Boolean =
x || (tp match
case _: LazyRef => true
case tp: TypeRef => tp.symbol eq tparam
case _ => foldOver(x, tp))
acc(false, tparam.info)

def hasRecursiveParamBounds(tparam: Symbol): Boolean =
recursiveParamBounds.exists(_ eq tparam)

/** Test all arguments. Incomplete argument tests (according to isIncomplete) are deferred in
* the first run and picked up in the second.
*/
Expand Down Expand Up @@ -1880,7 +1905,9 @@ class TypeComparer(@constructorOnly initctx: Context) extends ConstraintHandling
def compareCaptured(arg1: TypeBounds, arg2: Type) = tparam match {
case tparam: Symbol =>
val leftr = leftRoot.nn
if (leftr.isStable || ctx.isAfterTyper || ctx.mode.is(Mode.TypevarsMissContext))
if (hasRecursiveParamBounds(tparam))
false
else if (leftr.isStable || ctx.isAfterTyper || ctx.mode.is(Mode.TypevarsMissContext))
&& leftr.isValueType
&& leftr.member(tparam.name).exists
then
Expand All @@ -1890,10 +1917,25 @@ class TypeComparer(@constructorOnly initctx: Context) extends ConstraintHandling
// The captured reference could be illegal and cause a
// TypeError to be thrown in argDenot
false
// Existential widening for wildcard arguments (issue #16018):
// `arg1` is the bounds carried by a `? >: lo <: hi` wildcard. For
// `arg2` to be a (covariant / contravariant) supertype of `arg1`,
// we only need the *wildcard's own* hi/lo to conform — the
// declared parameter bounds (`paramBounds(tparam)`) only ever
// *constrain* the wildcard further, never relax it. The earlier
// formulation used `paramBounds(tparam).hi`, which collapses to
// `Any` for unconstrained type parameters and rejects valid
// subtypings such as `F[? <: M] <: F[M]` for covariant `F`
// (mathematically: ⨆{F[X] | X <: M} = F[M] by covariance, since
// the supremum is attained at X = M). Intersecting with
// `paramBounds(tparam)` keeps soundness in the rare case where
// the wildcard's bound is *wider* than what `tparam` permits.
else if (v > 0)
isSubType(paramBounds(tparam).hi, arg2)
val effectiveHi = arg1.hi & paramBounds(tparam).hi
isSubType(effectiveHi, arg2)
else if (v < 0)
isSubType(arg2, paramBounds(tparam).lo)
val effectiveLo = arg1.lo | paramBounds(tparam).lo
isSubType(arg2, effectiveLo)
else
false
case _ =>
Expand Down
49 changes: 49 additions & 0 deletions tests/neg/i16018c.check
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
-- [E007] Type Mismatch Error: tests/neg/i16018c.scala:18:50 -----------------------------------------------------------
18 | def inv_upper_bad[M](xs: Inv[? <: M]): Inv[M] = xs // error
| ^^
| Found: (xs : Test.Inv[? <: M])
| Required: Test.Inv[M]
|
| longer explanation available when compiling with `-explain`
-- [E007] Type Mismatch Error: tests/neg/i16018c.scala:19:50 -----------------------------------------------------------
19 | def inv_lower_bad[M](xs: Inv[? >: M]): Inv[M] = xs // error
| ^^
| Found: (xs : Test.Inv[? >: M])
| Required: Test.Inv[M]
|
| longer explanation available when compiling with `-explain`
-- [E007] Type Mismatch Error: tests/neg/i16018c.scala:26:47 -----------------------------------------------------------
26 | def co_lower_bad[M](xs: Co[? >: M]): Co[M] = xs // error
| ^^
| Found: (xs : Test.Co[? >: M])
| Required: Test.Co[M]
|
| longer explanation available when compiling with `-explain`
-- [E007] Type Mismatch Error: tests/neg/i16018c.scala:32:59 -----------------------------------------------------------
32 | def contra_upper_bad[M](xs: Contra[? <: M]): Contra[M] = xs // error
| ^^
| Found: (xs : Test.Contra[? <: M])
| Required: Test.Contra[M]
|
| longer explanation available when compiling with `-explain`
-- [E007] Type Mismatch Error: tests/neg/i16018c.scala:40:50 -----------------------------------------------------------
40 | def co_too_wide(xs: Co[? <: Any]): Co[Holder] = xs // error
| ^^
| Found: (xs : Test.Co[?])
| Required: Test.Co[Test.Holder]
|
| longer explanation available when compiling with `-explain`
-- [E007] Type Mismatch Error: tests/neg/i16018c.scala:56:73 -----------------------------------------------------------
56 | def co_guard_upper_boundary(xs: CoBounded[? <: Any]): CoBounded[Sub] = xs // error
| ^^
| Found: (xs : Test.CoBounded[?])
| Required: Test.CoBounded[Test.Sub]
|
| longer explanation available when compiling with `-explain`
-- [E007] Type Mismatch Error: tests/neg/i16018c.scala:68:91 -----------------------------------------------------------
68 | def contra_guard_lower_boundary(xs: ContraBounded[? >: Nothing]): ContraBounded[Super] = xs // error
| ^^
| Found: (xs : Test.ContraBounded[?])
| Required: Test.ContraBounded[Test.Super]
|
| longer explanation available when compiling with `-explain`
68 changes: 68 additions & 0 deletions tests/neg/i16018c.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Negative half of the directional matrix for the fix to
// `TypeComparer.compareCaptured` (#16018 / Existential widening for wildcard
// arguments).
//
// These shapes must remain *rejected* after the fix: the fix only adds
// widening that is justified by variance — covariant + upper bound,
// contravariant + lower bound. Any other combination is unsound and must
// keep the same diagnostic it had before. If any of these `//<space>error`
// markers stops firing, the fix has overreached.

object Test:

class Co[+T]
class Contra[-T]
class Inv[T]

// ---- A. Invariant containers never widen the wildcard ---------------
def inv_upper_bad[M](xs: Inv[? <: M]): Inv[M] = xs // error
def inv_lower_bad[M](xs: Inv[? >: M]): Inv[M] = xs // error

// ---- B. Covariant with a *lower*-bounded wildcard does not widen ----
//
// For covariant F, `F[? >: lo]` is the type of F[X] for some X >: lo.
// That has no useful relationship to F[lo] — picking X = Any is
// permitted yet gives F[Any], not F[lo].
def co_lower_bad[M](xs: Co[? >: M]): Co[M] = xs // error

// ---- C. Contravariant with an *upper*-bounded wildcard does not widen
//
// Dual to (B): for contravariant G, `G[? <: hi]` makes no commitment
// that the picked X is `hi` itself; it could be `Nothing`.
def contra_upper_bad[M](xs: Contra[? <: M]): Contra[M] = xs // error

// ---- D. The required type's hi is strictly tighter than the wildcard
//
// `Co[? <: Any]` is the trivial widening shape (any T). It must not be
// accepted at `Co[Holder]` because the wildcard's hi (`Any`) is wider
// than the target.
trait Holder
def co_too_wide(xs: Co[? <: Any]): Co[Holder] = xs // error

// ---- E. Upper-boundary check, covariant ------------------------------
//
// Boundary companion to `soundness_guard_co` in tests/pos/i16018c.scala.
// For `CoBounded[+T <: Holder]` fed `CoBounded[? <: Any]`, the effective
// wildcard hi computed by the fix is `Any & Holder = Holder`. The
// *positive* counterpart pins conformance at `CoBounded[Holder]`
// (admits THE FIX, rejects NAIVE). This negative pins that conformance
// stops there: `CoBounded[Sub]` for `Sub <: Holder` must still be
// rejected. Note that all three of OLD / NAIVE / THE FIX reject this
// case (each computes a different LHS but each LHS is ⊄ Sub), so this
// case is a *boundary* test, not a distinguishing one — read the pos
// counterpart for the implementation-distinguishing check.
class Sub extends Holder
class CoBounded[+T <: Holder]
def co_guard_upper_boundary(xs: CoBounded[? <: Any]): CoBounded[Sub] = xs // error

// ---- F. Lower-boundary check, contravariant --------------------------
//
// Dual to (E). The positive counterpart `soundness_guard_contra` pins
// conformance at `ContraBounded[Holder]`. This negative pins that a
// *strict supertype* of `Holder` is not reached (variance flips the
// direction). Same caveat as (E): all three implementations reject,
// so this is a boundary check.
class Super
class HolderS extends Super
class ContraBounded[-T >: HolderS]
def contra_guard_lower_boundary(xs: ContraBounded[? >: Nothing]): ContraBounded[Super] = xs // error
17 changes: 17 additions & 0 deletions tests/neg/i16018d.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Additional negative coverage for the fix to `TypeComparer.compareCaptured`
// (#16018). The companion `pos/i16018d.scala` widens coverage to
// HKT-nested Function1, intersection, path-dependent, and acyclic dependent
// bound shapes. This file pins that recursive parameter bounds stay on the
// old conservative path: the surface widening would otherwise accept a
// relation that frozen Ycheck cannot justify.

object Test:

// ---- A. F-bounded parameter: recursive bound guard -------------------
//
// The guard rejects this at compile time. Without it, the surface widening
// `(M & FBounded[M]) <: M` succeeds but produces a typed tree that fails
// frozen Ycheck:all with assertions like `M <:< xs.T`.

class FBounded[+T <: FBounded[T]]
def f_bounded_guard[M <: FBounded[M]](xs: FBounded[? <: M]): FBounded[M] = xs // error
32 changes: 32 additions & 0 deletions tests/neg/i16018e.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// HKT-nested negative coverage for `TypeComparer.compareCaptured` (#16018).
//
// The flat negative matrix in i16018c.scala pins the basic variance
// directions. These cases pin that the same per-position variance checks keep
// firing when the wildcard is nested under an outer covariant container.

object Test:

class Co[+T]
class Inv[T]

// ---- A. Function1: contravariant slot + upper-bounded wildcard ------
//
// Outer `Co` covariant descends to `Function1[? <: M, R] <: Function1[M, R]`.
// The T1 slot has v < 0, so contravariant + upper must not widen.

def fn_contra_upper_in_co[M, R](xs: Co[Function1[? <: M, R]])
: Co[Function1[M, R]] = xs // error

// ---- B. Function1: covariant slot + lower-bounded wildcard ----------
//
// Dual to (A). The R slot has v > 0, so covariant + lower must not widen.

def fn_co_lower_in_co[M, T1](xs: Co[Function1[T1, ? >: M]])
: Co[Function1[T1, M]] = xs // error

// ---- C. Invariant slot nested under covariant container -------------
//
// Nesting inside a covariant outer container must not smuggle widening
// past an invariant inner slot.

def inv_in_co[M](xs: Co[Inv[? <: M]]): Co[Inv[M]] = xs // error
39 changes: 39 additions & 0 deletions tests/pos/i16018-orig.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// The original akka-minimized reproduction from #16018 (verbatim, modulo
// `_` → `?` syntax migration). Posted by @He-Pin on 2022-09-17:
//
// https://github.com/scala/scala3/issues/16018#issuecomment-1250066489
//
// Pre-fix this rejected with
//
// Found: (other : ?1.CAP)
// Required: Main.Source[T, M] & ?1.CAP
// where: ?1 is an unknown value of type
// scala.runtime.TypeBox[Nothing, Main.Graph[Main.SourceShape[T], ? <: M]]
//
// The directional minimizations of this same shape live in
// tests/pos/i16018b.scala (the matrix); this file pins the exact
// reproduction so that the original ticket cannot regress in either
// direction.

import scala.collection.immutable

object Main:

class Source[+Out, +Mat] extends Graph[SourceShape[Out], Mat]

class Shape
class SourceShape[+T] extends Shape
class Graph[+S <: Shape, +M]

def combine[T, U, M](sources: java.util.List[? <: Graph[SourceShape[T], ? <: M]])
: Source[U, java.util.List[M]] =
val seq: immutable.Seq[Graph[SourceShape[T], M]] =
if sources != null then
immutableSeq(sources).collect {
case source: Source[T, M] @unchecked => source
case other => other
}
else immutable.Seq()
???

def immutableSeq[T](iterable: java.lang.Iterable[T]): immutable.Seq[T] = ???
58 changes: 58 additions & 0 deletions tests/pos/i16018.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Regression test for #16018: type inference with `_ <: T` wildcards in
// pattern matching over capture-converted scrutinees.
//
// Coverage axes for the positive matrix:
// 1. The mbovel minimization (single wildcard via List.map) — was fixed
// since 3.2.0, kept here as a pin.
// 2. Pure-Scala collections — never trigger the capture-conversion path.
// 3. Identity / generic Scala helpers over the wildcard-bearing collection.
//
// The dual cases reached through Java-generic interop (java.lang.Iterable /
// java.util.List), which were the original akka regression, are in
// tests/pos/i16018b.scala.

object Test:

class Box[T](val value: T)
class Container[+S, +M]
class SubContainer[+S, +M] extends Container[S, M]

// ---- 1. mbovel's minimized case from #16018 ------------------------
def f1[T](l: List[Box[? <: T]]): List[T] = l.map(_.value)

// 1b. Variant: returning the wildcard-bearing element preserved.
def f1b[T](l: List[Box[? <: T]]): List[Box[? <: T]] = l.map(identity)

// ---- 2. Pure-Scala scrutinee — never goes through capture conversion -
def f2[M](xs: scala.collection.immutable.Seq[Container[Any, ? <: M]])
: scala.collection.immutable.Seq[Container[Any, M]] =
xs.collect {
case g: SubContainer[Any, M] @unchecked => g
case other => other
}

// 2b. Variant: `.map` instead of `.collect`.
def f2b[M](xs: scala.collection.immutable.Seq[Container[Any, ? <: M]])
: scala.collection.immutable.Seq[Container[Any, M]] =
xs.map {
case g: SubContainer[Any, M] @unchecked => g
case other => other
}

// 2c. Variant: List instead of Seq.
def f2c[M](xs: List[Container[Any, ? <: M]])
: List[Container[Any, M]] =
xs.collect {
case g: SubContainer[Any, M] @unchecked => g
case other => other
}

// ---- 3. Generic helper that stays pure-Scala does not skolemize ----
def idSeq[T](it: Iterable[T]): scala.collection.immutable.Seq[T] = ???

def f3[M](xs: List[Container[Any, ? <: M]])
: scala.collection.immutable.Seq[Container[Any, M]] =
idSeq(xs).collect {
case g: SubContainer[Any, M] @unchecked => g
case other => other
}
47 changes: 47 additions & 0 deletions tests/pos/i16018b.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Regression test for #16018: the akka-derived shapes.
//
// These exercise the path that motivated the original ticket: a value of
// type `J[? <: G[? <: M]]` (J a Java generic) flows through a method that
// re-projects it as a Scala collection, and is then `collect`/`map`-ed with
// a subtype pattern + a fallback `case other`. The combination triggers
// (i) `Inferencing.captureWildcards` — the Java-generic argument becomes
// a TypeBox.CAP skolem `?N.CAP`, and
// (ii) `Match` typing — pattern narrowing turns the first case body into
// `pat ∩ ?N.CAP`, while `case other` keeps the raw `?N.CAP`.
//
// Until the fix in `TypeComparer.compareCaptured` for wildcard-bound
// existential widening, the inferred result type could not escape the
// skolem cleanly. See:
// - tests/pos/i16018.scala — companion pos matrix (pure-Scala)
// - scala/scala3 #16018 — original bug report

object Test:

class Container[+S, +M]
class SubContainer[+S, +M] extends Container[S, M]

def seqOf[T](it: java.lang.Iterable[T]): scala.collection.immutable.Seq[T] = ???

// ---- f1: trigger via java.lang.Iterable ----------------------------
def f1[M](xs: java.lang.Iterable[? <: Container[Any, ? <: M]])
: scala.collection.immutable.Seq[Container[Any, M]] =
seqOf(xs).collect {
case g: SubContainer[Any, M] @unchecked => g
case other => other
}

// ---- f2: same trigger via java.util.List ---------------------------
def f2[M](xs: java.util.List[? <: Container[Any, ? <: M]])
: scala.collection.immutable.Seq[Container[Any, M]] =
seqOf(xs).collect {
case g: SubContainer[Any, M] @unchecked => g
case other => other
}

// ---- f3: same trigger via .map instead of .collect -----------------
def f3[M](xs: java.lang.Iterable[? <: Container[Any, ? <: M]])
: scala.collection.immutable.Seq[Container[Any, M]] =
seqOf(xs).map {
case g: SubContainer[Any, M] @unchecked => g
case other => other
}
Loading