From 628ed52bb54541965a751356c8e4f927dc469571 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 17:21:31 +0000 Subject: [PATCH 1/4] Avoid crash on missing TASTy class references (scala/scala3#20010) When a transitive dependency is missing from the classpath, the TASTy reader for a TYPEREF can produce a TypeRef whose symbol cannot be resolved. Downstream code in `computeBaseData` then hits an internal `non-class parent` assertion and crashes the compiler. Replace such unresolvable `TypeRef`s with a stub class symbol (similar to what Scala2Unpickler does), so the user sees a `BadSymbolicReference` error on first use instead of a compiler crash. The substitution is limited to references whose prefix is a package or a module, where member lookup is deterministic, to avoid converting transient lookup failures into spurious stubs during incremental completion. Adds an sbt scripted regression test (`sbt-test/tasty-compat/i20010`) that mirrors the original minimisation: three modules, `a`/`b`/`c`, where compiling `c` must not crash when `a` is absent from its classpath. https://claude.ai/code/session_01MmjynkrPu387FLVh175djG --- .../tools/dotc/core/tasty/TreeUnpickler.scala | 25 ++++++++- sbt-test/tasty-compat/i20010/Repro.scala | 1 + .../tasty-compat/i20010/a/ParsingTest.scala | 3 + .../i20010/b/ValidatingTest.scala | 3 + sbt-test/tasty-compat/i20010/build.sbt | 56 +++++++++++++++++++ .../i20010/project/DottyInjectedPlugin.scala | 11 ++++ .../i20010/project/FakePrintWriter.scala | 6 ++ sbt-test/tasty-compat/i20010/test | 10 ++++ 8 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 sbt-test/tasty-compat/i20010/Repro.scala create mode 100644 sbt-test/tasty-compat/i20010/a/ParsingTest.scala create mode 100644 sbt-test/tasty-compat/i20010/b/ValidatingTest.scala create mode 100644 sbt-test/tasty-compat/i20010/build.sbt create mode 100644 sbt-test/tasty-compat/i20010/project/DottyInjectedPlugin.scala create mode 100644 sbt-test/tasty-compat/i20010/project/FakePrintWriter.scala create mode 100644 sbt-test/tasty-compat/i20010/test diff --git a/compiler/src/dotty/tools/dotc/core/tasty/TreeUnpickler.scala b/compiler/src/dotty/tools/dotc/core/tasty/TreeUnpickler.scala index 32a9291be026..1501e4b112f7 100644 --- a/compiler/src/dotty/tools/dotc/core/tasty/TreeUnpickler.scala +++ b/compiler/src/dotty/tools/dotc/core/tasty/TreeUnpickler.scala @@ -478,7 +478,7 @@ class TreeUnpickler(reader: TastyReader, if unpicklingJava && name == tpnme.Object && (pre.termSymbol eq defn.JavaLangPackageVal) then defn.FromJavaObjectType else - TypeRef(pre, name) + ensureResolvable(TypeRef(pre, name), pre, name) case TERMREF => val sname = readName() val prefix = readType() @@ -528,6 +528,25 @@ class TreeUnpickler(reader: TastyReader, } } + /** If `tpe` refers to a type that is not present on the classpath + * (e.g. a transitive dependency was removed), fall back to a stub + * class symbol so that downstream code sees a `ClassSymbol` and + * reports `BadSymbolicReference` on first use, rather than crashing + * with an internal assertion. See scala/scala3#20010. + * + * The replacement is only performed when the prefix is a + * package or a module, for which member lookup is deterministic – + * this avoids converting transient lookup failures into spurious + * stubs during the incremental completion of in-flight symbols. + */ + private def ensureResolvable(tpe: TypeRef, pre: Type, name: TypeName)(using Context): Type = + if tpe.symbol.exists then tpe + else + val preSym = pre.termSymbol + if preSym.is(Package) || preSym.is(Module) then + newStubSymbol(preSym.moduleClass, name).typeRef + else tpe + private def readPackageRef()(using Context): TermSymbol = { val name = readName() if (name == nme.ROOT || name == nme.ROOTPKG) defn.RootPackage @@ -1303,7 +1322,9 @@ class TreeUnpickler(reader: TastyReader, var qualType = qual.tpe.widenIfUnstable val owner = denot.symbol.maybeOwner val tpe0 = name match - case name: TypeName => TypeRef(qualType, name, denot) + case name: TypeName => + val ref = TypeRef(qualType, name, denot) + ensureResolvable(ref, qualType, name) case name: TermName => TermRef(qualType, name, denot) val tpe = tpe0.makePackageObjPrefixExplicit ConstFold.Select(untpd.Select(qual, name).withType(tpe)) diff --git a/sbt-test/tasty-compat/i20010/Repro.scala b/sbt-test/tasty-compat/i20010/Repro.scala new file mode 100644 index 000000000000..392cd009482b --- /dev/null +++ b/sbt-test/tasty-compat/i20010/Repro.scala @@ -0,0 +1 @@ +class MyTest extends child.ValidatingTest diff --git a/sbt-test/tasty-compat/i20010/a/ParsingTest.scala b/sbt-test/tasty-compat/i20010/a/ParsingTest.scala new file mode 100644 index 000000000000..ebddc4634b99 --- /dev/null +++ b/sbt-test/tasty-compat/i20010/a/ParsingTest.scala @@ -0,0 +1,3 @@ +package parent + +trait ParsingTest diff --git a/sbt-test/tasty-compat/i20010/b/ValidatingTest.scala b/sbt-test/tasty-compat/i20010/b/ValidatingTest.scala new file mode 100644 index 000000000000..7bf487d1e313 --- /dev/null +++ b/sbt-test/tasty-compat/i20010/b/ValidatingTest.scala @@ -0,0 +1,3 @@ +package child + +abstract class ValidatingTest extends parent.ParsingTest diff --git a/sbt-test/tasty-compat/i20010/build.sbt b/sbt-test/tasty-compat/i20010/build.sbt new file mode 100644 index 000000000000..d4299b04dbf4 --- /dev/null +++ b/sbt-test/tasty-compat/i20010/build.sbt @@ -0,0 +1,56 @@ +import sbt.internal.util.ConsoleAppender + +// Reproduces https://github.com/scala/scala3/issues/20010 +// +// Three modules: +// a : defines `parent.ParsingTest` +// b : defines `child.ValidatingTest extends parent.ParsingTest`, +// compiled with `a` on the classpath; its output ends up in `c-input` +// c : root project that extends `child.ValidatingTest`, but only sees +// `c-input` on its classpath – `a`'s outputs are deliberately not +// exposed, so loading `ValidatingTest`'s TASTy can no longer resolve +// its `parent.ParsingTest` parent. +// +// Before the fix the compiler crashed with +// `java.lang.AssertionError: class ValidatingTest has non-class parent: ...` +// Now it must emit a clean `Bad symbolic reference` error. + +lazy val assertCleanMissingRefError = taskKey[Unit]( + "checks that compiling c reports `Bad symbolic reference` rather than crashing" +) +lazy val resetMessages = taskKey[Unit]("empties the messages list") + +lazy val a = project.in(file("a")) + .settings( + Compile / classDirectory := (ThisBuild / baseDirectory).value / "a-only" + ) + +lazy val b = project.in(file("b")) + .settings( + Compile / unmanagedClasspath += (ThisBuild / baseDirectory).value / "a-only", + Compile / classDirectory := (ThisBuild / baseDirectory).value / "c-input" + ) + +lazy val c = project.in(file(".")) + .settings( + // Only `b`'s outputs are visible here – `a-only` is *not* on the classpath. + Compile / unmanagedClasspath += (ThisBuild / baseDirectory).value / "c-input", + Compile / classDirectory := (ThisBuild / baseDirectory).value / "c-output", + extraAppenders := { _ => Seq(ConsoleAppender(FakePrintWriter)) }, + resetMessages := { FakePrintWriter.resetMessages }, + assertCleanMissingRefError := { + val msgs = FakePrintWriter.messages + assert( + msgs.exists(_.contains("Bad symbolic reference")), + s"expected 'Bad symbolic reference' in compiler output, got: ${msgs.mkString("\n")}" + ) + assert( + !msgs.exists(_.contains("non-class parent")), + s"compiler crashed with 'non-class parent' assertion; got: ${msgs.mkString("\n")}" + ) + assert( + !msgs.exists(_.contains("java.lang.AssertionError")), + s"compiler crashed with AssertionError; got: ${msgs.mkString("\n")}" + ) + } + ) diff --git a/sbt-test/tasty-compat/i20010/project/DottyInjectedPlugin.scala b/sbt-test/tasty-compat/i20010/project/DottyInjectedPlugin.scala new file mode 100644 index 000000000000..fb946c4b8c61 --- /dev/null +++ b/sbt-test/tasty-compat/i20010/project/DottyInjectedPlugin.scala @@ -0,0 +1,11 @@ +import sbt._ +import Keys._ + +object DottyInjectedPlugin extends AutoPlugin { + override def requires = plugins.JvmPlugin + override def trigger = allRequirements + + override val projectSettings = Seq( + scalaVersion := sys.props("plugin.scalaVersion") + ) +} diff --git a/sbt-test/tasty-compat/i20010/project/FakePrintWriter.scala b/sbt-test/tasty-compat/i20010/project/FakePrintWriter.scala new file mode 100644 index 000000000000..14d3bcdba5b4 --- /dev/null +++ b/sbt-test/tasty-compat/i20010/project/FakePrintWriter.scala @@ -0,0 +1,6 @@ +object FakePrintWriter extends java.io.PrintWriter("fake-print-writer") { + @volatile var messages = List.empty[String] + def resetMessages = messages = List.empty[String] + override def println(x: String): Unit = messages = x :: messages + override def print(x: String): Unit = messages = x :: messages +} diff --git a/sbt-test/tasty-compat/i20010/test b/sbt-test/tasty-compat/i20010/test new file mode 100644 index 000000000000..9d72fe641dd0 --- /dev/null +++ b/sbt-test/tasty-compat/i20010/test @@ -0,0 +1,10 @@ +# compile library a (defines parent.ParsingTest) +> a/compile +# compile library b (defines child.ValidatingTest extends parent.ParsingTest) +> b/compile +# compile c without a on the classpath. +# This used to crash the compiler with an internal `non-class parent` assertion. +# After the fix it must fail with a clean `Bad symbolic reference` error. +> resetMessages +-> c/compile +> assertCleanMissingRefError From b1722c9e54a53395dbeb4668f77a918a8340ca70 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 18:21:55 +0000 Subject: [PATCH 2/4] Move missing-parent fix from TreeUnpickler to computeBaseData The previous TreeUnpickler-based fix substituted a stub class symbol for any unresolvable cross-file `TypeRef` whose prefix is a package or module. That regressed `sbt-test/source-dependencies/missing-annot`: when a class on the classpath references annotation classes that are no longer reachable, the stub now eagerly throws a `BadSymbolicReference` error - even though the original behaviour was to silently tolerate missing annotations. Move the fix to its narrowest possible scope: the `non-class parent` assertion in `ClassDenotation.computeBaseData`. When the assertion would fire because a parent's `TypeRef` could not be resolved (`p.typeSymbol == NoSymbol`), report a `BadSymbolicReference`-style error with the position of the class that failed to resolve and continue. Annotation processing and other paths that legitimately tolerate missing references are unaffected. `computeBaseData` can be invoked multiple times for the same class (e.g. once via `derivesFrom` from `Namer.checkedParentType`, then again via `isValueClass` from `Checking.checkWellFormed`), so a per-denotation `reportedMissingParents` set deduplicates the diagnostic. Also handle the `MatchError` that fires when `computeMemberNames` encounters the same non-class parent: silently skip after the missing reference has already been reported. https://claude.ai/code/session_01MmjynkrPu387FLVh175djG --- .../tools/dotc/core/SymDenotations.scala | 36 ++++++++++++++++++- .../tools/dotc/core/tasty/TreeUnpickler.scala | 25 ++----------- 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/compiler/src/dotty/tools/dotc/core/SymDenotations.scala b/compiler/src/dotty/tools/dotc/core/SymDenotations.scala index 1aabb85f5919..0ac5eaefce20 100644 --- a/compiler/src/dotty/tools/dotc/core/SymDenotations.scala +++ b/compiler/src/dotty/tools/dotc/core/SymDenotations.scala @@ -1879,6 +1879,13 @@ object SymDenotations { private var baseDataCache: BaseData = BaseData.None private var memberNamesCache: MemberNames = MemberNames.None + /** Set of parent types for which a `BadSymbolicReference` has already + * been reported by `computeBaseData`. Used so the same diagnostic is + * not emitted multiple times when `baseData` is recomputed (e.g. from + * a separate `derivesFrom` invocation). See scala/scala3#20010. + */ + private var reportedMissingParents: Set[Type] = Set.empty + private def memberCache(using Context): EqHashMap[Name, PreDenotation] = { if (myMemberCachePeriod != ctx.period) { myMemberCache = EqHashMap() @@ -2061,7 +2068,29 @@ object SymDenotations { case p :: parents1 => p.classSymbol match { case pcls: ClassSymbol => builder.addAll(pcls.baseClasses) - case _ => assert(isRefinementClass || p.isError || ctx.mode.is(Mode.Interactive) || ctx.tolerateErrorsForBestEffort, s"$this has non-class parent: $p") + case _ => + // The parent type couldn't be resolved to a class, e.g. + // because a transitive dependency was removed from the + // classpath. Report a BadSymbolicReference-style error + // rather than crashing with an internal assertion. See + // scala/scala3#20010. + if p.typeSymbol == NoSymbol && !isRefinementClass && !p.isError + && !ctx.mode.is(Mode.Interactive) && !ctx.tolerateErrorsForBestEffort + then + if !reportedMissingParents.contains(p) then + reportedMissingParents = reportedMissingParents + p + val file = symbol.associatedFile + val (location, src) = + if file != null then (i" in $file", file.toString) + else ("", "the signature") + report.error( + em"""Bad symbolic reference. A signature$location + |refers to ${p.show} as a parent of ${symbol.showLocated}, but it is not available. + |It may be completely missing from the current classpath, or the version on + |the classpath might be incompatible with the version used when compiling $src.""", + symbol.srcPos) + else + assert(isRefinementClass || p.isError || ctx.mode.is(Mode.Interactive) || ctx.tolerateErrorsForBestEffort, s"$this has non-class parent: $p") } traverse(parents1) case nil => @@ -2419,6 +2448,11 @@ object SymDenotations { case pcls: ClassSymbol => for name <- pcls.memberNames(keepOnly) do maybeAdd(name) + case _ => + // Parent failed to resolve to a class (the missing + // reference has been reported by computeBaseData). + // Skip here to avoid a secondary MatchError. + // See scala/scala3#20010. val ownSyms = if (keepOnly eq implicitFilter) if (this.is(Package)) Iterator.empty diff --git a/compiler/src/dotty/tools/dotc/core/tasty/TreeUnpickler.scala b/compiler/src/dotty/tools/dotc/core/tasty/TreeUnpickler.scala index 1501e4b112f7..32a9291be026 100644 --- a/compiler/src/dotty/tools/dotc/core/tasty/TreeUnpickler.scala +++ b/compiler/src/dotty/tools/dotc/core/tasty/TreeUnpickler.scala @@ -478,7 +478,7 @@ class TreeUnpickler(reader: TastyReader, if unpicklingJava && name == tpnme.Object && (pre.termSymbol eq defn.JavaLangPackageVal) then defn.FromJavaObjectType else - ensureResolvable(TypeRef(pre, name), pre, name) + TypeRef(pre, name) case TERMREF => val sname = readName() val prefix = readType() @@ -528,25 +528,6 @@ class TreeUnpickler(reader: TastyReader, } } - /** If `tpe` refers to a type that is not present on the classpath - * (e.g. a transitive dependency was removed), fall back to a stub - * class symbol so that downstream code sees a `ClassSymbol` and - * reports `BadSymbolicReference` on first use, rather than crashing - * with an internal assertion. See scala/scala3#20010. - * - * The replacement is only performed when the prefix is a - * package or a module, for which member lookup is deterministic – - * this avoids converting transient lookup failures into spurious - * stubs during the incremental completion of in-flight symbols. - */ - private def ensureResolvable(tpe: TypeRef, pre: Type, name: TypeName)(using Context): Type = - if tpe.symbol.exists then tpe - else - val preSym = pre.termSymbol - if preSym.is(Package) || preSym.is(Module) then - newStubSymbol(preSym.moduleClass, name).typeRef - else tpe - private def readPackageRef()(using Context): TermSymbol = { val name = readName() if (name == nme.ROOT || name == nme.ROOTPKG) defn.RootPackage @@ -1322,9 +1303,7 @@ class TreeUnpickler(reader: TastyReader, var qualType = qual.tpe.widenIfUnstable val owner = denot.symbol.maybeOwner val tpe0 = name match - case name: TypeName => - val ref = TypeRef(qualType, name, denot) - ensureResolvable(ref, qualType, name) + case name: TypeName => TypeRef(qualType, name, denot) case name: TermName => TermRef(qualType, name, denot) val tpe = tpe0.makePackageObjPrefixExplicit ConstFold.Select(untpd.Select(qual, name).withType(tpe)) From 5c2ad1cea4cb9619125843fae6bf6fe157bd6df4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 09:01:22 +0000 Subject: [PATCH 3/4] Address review: reuse BadSymbolicReference, drop dedup field Per @odersky's review on scala/scala3#25908: - Reuse the existing `BadSymbolicReference` message class instead of duplicating its body inline. Build a stub symbol via `newStubSymbol` (same pattern as `StubInfo.complete`) and report `BadSymbolicReference(stub.denot)` directly so the user-visible position points at the class whose parent failed to resolve. - Drop the per-`ClassDenotation` `reportedMissingParents` dedup field. Suppressing duplicates risks losing the only report of an error if Typer backtracks after a provisional report; always report instead. https://claude.ai/code/session_01MmjynkrPu387FLVh175djG --- .../tools/dotc/core/SymDenotations.scala | 44 +++++++------------ 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/compiler/src/dotty/tools/dotc/core/SymDenotations.scala b/compiler/src/dotty/tools/dotc/core/SymDenotations.scala index 0ac5eaefce20..a0a941d137c6 100644 --- a/compiler/src/dotty/tools/dotc/core/SymDenotations.scala +++ b/compiler/src/dotty/tools/dotc/core/SymDenotations.scala @@ -1879,13 +1879,6 @@ object SymDenotations { private var baseDataCache: BaseData = BaseData.None private var memberNamesCache: MemberNames = MemberNames.None - /** Set of parent types for which a `BadSymbolicReference` has already - * been reported by `computeBaseData`. Used so the same diagnostic is - * not emitted multiple times when `baseData` is recomputed (e.g. from - * a separate `derivesFrom` invocation). See scala/scala3#20010. - */ - private var reportedMissingParents: Set[Type] = Set.empty - private def memberCache(using Context): EqHashMap[Name, PreDenotation] = { if (myMemberCachePeriod != ctx.period) { myMemberCache = EqHashMap() @@ -2071,26 +2064,23 @@ object SymDenotations { case _ => // The parent type couldn't be resolved to a class, e.g. // because a transitive dependency was removed from the - // classpath. Report a BadSymbolicReference-style error - // rather than crashing with an internal assertion. See - // scala/scala3#20010. - if p.typeSymbol == NoSymbol && !isRefinementClass && !p.isError - && !ctx.mode.is(Mode.Interactive) && !ctx.tolerateErrorsForBestEffort - then - if !reportedMissingParents.contains(p) then - reportedMissingParents = reportedMissingParents + p - val file = symbol.associatedFile - val (location, src) = - if file != null then (i" in $file", file.toString) - else ("", "the signature") - report.error( - em"""Bad symbolic reference. A signature$location - |refers to ${p.show} as a parent of ${symbol.showLocated}, but it is not available. - |It may be completely missing from the current classpath, or the version on - |the classpath might be incompatible with the version used when compiling $src.""", - symbol.srcPos) - else - assert(isRefinementClass || p.isError || ctx.mode.is(Mode.Interactive) || ctx.tolerateErrorsForBestEffort, s"$this has non-class parent: $p") + // classpath. Report a `BadSymbolicReference` (mirroring the + // pattern used by `StubInfo.complete` above) rather than + // crashing with an internal assertion. See scala/scala3#20010. + p match + case p: TypeRef + if p.symbol == NoSymbol + && !isRefinementClass && !p.isError + && !ctx.mode.is(Mode.Interactive) && !ctx.tolerateErrorsForBestEffort => + val stubOwner = + p.prefix.classSymbol + .orElse(p.prefix.termSymbol.moduleClass) + .orElse(defn.RootClass) + val stub = newStubSymbol(stubOwner, p.name, CompilationUnitInfo(symbol.associatedFile)) + report.error(BadSymbolicReference(stub.denot), symbol.srcPos) + case _ => + assert(isRefinementClass || p.isError || ctx.mode.is(Mode.Interactive) || ctx.tolerateErrorsForBestEffort, + s"$this has non-class parent: $p") } traverse(parents1) case nil => From fec9294ef350a6f2f0a0073c14a0d7e8b321cbd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 15:52:24 +0000 Subject: [PATCH 4/4] Address review: factor out ignoreBadParent helper Per @odersky's review on scala/scala3#25908 (r3189741543): the same condition appeared (negated) in the `case p: TypeRef` guard and (positive) in the `case _ =>` assertion. Pull it into a local `def ignoreBadParent` so both sites read the same predicate. https://claude.ai/code/session_01MmjynkrPu387FLVh175djG --- .../src/dotty/tools/dotc/core/SymDenotations.scala | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/compiler/src/dotty/tools/dotc/core/SymDenotations.scala b/compiler/src/dotty/tools/dotc/core/SymDenotations.scala index a0a941d137c6..97479d069075 100644 --- a/compiler/src/dotty/tools/dotc/core/SymDenotations.scala +++ b/compiler/src/dotty/tools/dotc/core/SymDenotations.scala @@ -2067,11 +2067,11 @@ object SymDenotations { // classpath. Report a `BadSymbolicReference` (mirroring the // pattern used by `StubInfo.complete` above) rather than // crashing with an internal assertion. See scala/scala3#20010. + def ignoreBadParent = + isRefinementClass || p.isError + || ctx.mode.is(Mode.Interactive) || ctx.tolerateErrorsForBestEffort p match - case p: TypeRef - if p.symbol == NoSymbol - && !isRefinementClass && !p.isError - && !ctx.mode.is(Mode.Interactive) && !ctx.tolerateErrorsForBestEffort => + case p: TypeRef if p.symbol == NoSymbol && !ignoreBadParent => val stubOwner = p.prefix.classSymbol .orElse(p.prefix.termSymbol.moduleClass) @@ -2079,8 +2079,7 @@ object SymDenotations { val stub = newStubSymbol(stubOwner, p.name, CompilationUnitInfo(symbol.associatedFile)) report.error(BadSymbolicReference(stub.denot), symbol.srcPos) case _ => - assert(isRefinementClass || p.isError || ctx.mode.is(Mode.Interactive) || ctx.tolerateErrorsForBestEffort, - s"$this has non-class parent: $p") + assert(ignoreBadParent, s"$this has non-class parent: $p") } traverse(parents1) case nil =>