From e5d7391cbe340e05a943761ca60808e0c1293b65 Mon Sep 17 00:00:00 2001 From: Jason Zaugg Date: Mon, 8 Jun 2026 23:30:46 +1000 Subject: [PATCH 1/2] Add failing test for undercompilation when case class field type changes When a case class field type is changed, files that pattern-match on it are not recompiled by Zinc, leading to NoSuchMethodError at runtime. Root cause: Scala 3 generates `def unapply(x: C): C = x` whose signature is stable regardless of field types. ExtractDependencies records `unapply` as a used name but never `_1`, which is the method the generated bytecode actually calls. Since `unapply`'s name hash never changes, Zinc skips recompilation of the dependent file. The three new tests document: - `_1` API hash changes when field type changes (expected) - `unapply` API hash does NOT change (the stable signature hiding the change) - `_1` is absent from the used names recorded for a pattern-matching file (the bug) Fixes https://github.com/scala/scala3/issues/26231 Co-Authored-By: Claude Sonnet 4.6 --- .../test/xsbt/ExtractAPISpecification.scala | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/sbt-bridge/test/xsbt/ExtractAPISpecification.scala b/sbt-bridge/test/xsbt/ExtractAPISpecification.scala index e85cf8989b0f..b51ab337aa2f 100644 --- a/sbt-bridge/test/xsbt/ExtractAPISpecification.scala +++ b/sbt-bridge/test/xsbt/ExtractAPISpecification.scala @@ -1,5 +1,6 @@ package xsbt +import xsbti.UseScope import xsbti.api._ import xsbt.api.SameAPI @@ -168,6 +169,100 @@ class ExtractAPISpecification { assertNotEquals(apis("A.AA"), apis("B.AA")) } + // Tests for https://github.com/scala/scala3/issues/26231 + // Undercompilation when a case class field type changes and another file pattern-matches on it. + + /** Returns the declared members of the API for a named class/module in a given source. */ + private def declaredMembers(src: String, className: String, defType: DefinitionType): Array[ClassDefinition] = { + val apis = new ScalaCompilerForUnitTesting().extractApiFromSrc(src) + val cls = apis.find(c => c.name() == className && c.definitionType() == defType).get + cls.structure().declared() + } + + @Test + def caseClassSelectorMethodApiChangesWhenFieldTypeChanges = { + // `_1` is the product selector called at the bytecode level after pattern matching. + // Its return type should change when the case class field type changes. + val withNumber = declaredMembers( + "case class Customer2(state: Number = java.lang.Integer.valueOf(1))", + "Customer2", DefinitionType.ClassDef + ) + val withString = declaredMembers( + """case class Customer2(state: String = "")""", + "Customer2", DefinitionType.ClassDef + ) + + val selector1WithNumber = withNumber.find(_.name() == "_1").get + val selector1WithString = withString.find(_.name() == "_1").get + + assertFalse( + "_1 API must differ between Number and String field type", + SameAPI(selector1WithNumber, selector1WithString) + ) + } + + @Test + def companionUnapplyApiUnchangedWhenCaseClassFieldTypeChanges = { + // In Scala 3, the compiler generates `def unapply(x: Customer2): Customer2 = x` for + // case classes. Its signature is always `(Customer2): Customer2` regardless of field + // types, so its API hash never changes when a field type changes. + // + // This is one half of the undercompilation bug: the dependent file (Test.scala) records + // a used-name dependency on `unapply`, but `unapply`'s hash doesn't change, so Zinc + // doesn't recompile Test.scala even though _1's return type changed. + val unapplyWithNumber = declaredMembers( + "case class Customer2(state: Number = java.lang.Integer.valueOf(1))", + "Customer2", DefinitionType.Module + ).find(_.name() == "unapply").get + + val unapplyWithString = declaredMembers( + """case class Customer2(state: String = "")""", + "Customer2", DefinitionType.Module + ).find(_.name() == "unapply").get + + assertTrue( + "unapply API is unchanged between Number and String field types (the stable signature that hides the change from Zinc)", + SameAPI(unapplyWithNumber, unapplyWithString) + ) + } + + @Test + def caseClassPatternMatchRecordsSelector1AsUsedName = { + // The other half of the undercompilation bug: when Test.scala does + // case Customer2(x) => x + // the compiler emits a call to Customer2._1() in the bytecode. But + // ExtractDependencies only records `unapply` as a used name (from the + // UnApply tree node), never `_1`. Since `unapply`'s hash doesn't change + // when field types change, Zinc won't recompile Test.scala. + // + // This test asserts the DESIRED (fixed) behaviour: `_1` must appear in the + // used names so that a change in _1's return type triggers recompilation. + // It is expected to FAIL on an unfixed compiler, documenting the bug. + val caseClassSrc = + "case class Customer2(state: Number = java.lang.Integer.valueOf(1))" + val patternMatchSrc = + """|object Test { + | def test(c: Option[Customer2]): Any = c match { + | case Some(Customer2(x)) => x + | case None => null + | } + |}""".stripMargin + + // compileSrcs returns used names for all classes across all source files + val output = new ScalaCompilerForUnitTesting() + .compileSrcs(caseClassSrc, patternMatchSrc) + val usedNames = output.analysis.usedNames + + // `unapply` is recorded because the typed UnApply tree references Customer2.unapply. + assertTrue("unapply must be a used name in Test", + usedNames("Test").contains("unapply")) + + // `_1` must also be recorded because it is what the generated bytecode calls. + // TODO: this currently FAILS, documenting the undercompilation bug. + assertTrue("_1 must be a used name in Test (currently missing — undercompilation bug)", + usedNames("Test").contains("_1")) + } + @Test def handlePackageObjectsAndTypeCompanions = { val src = From b9fafe3d9d771b7938366ff8e237c7a36d17ade3 Mon Sep 17 00:00:00 2001 From: Jason Zaugg Date: Mon, 8 Jun 2026 23:45:00 +1000 Subject: [PATCH 2/2] Fix undercompilation when case class field type changes (issue #26231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Scala 3, the compiler generates `def unapply(x: C): C = x` for case classes. Its signature is always `(C): C` regardless of field types, so its API hash never changes when a field is renamed or retyped. The PatternMatcher phase (which runs after ExtractDependencies) lowers `case C(x)` to a direct call to the product selector `_1()`, `_2()`, etc. Because ExtractDependencies ran before PatternMatcher, those selector calls were never in the tree, so `_1` was never recorded as a used name in the dependent file. Zinc therefore saw no reason to recompile the file when the field type changed, producing a NoSuchMethodError at runtime. Fix: in `AbstractExtractDependenciesCollector.recordTree`, add a case for `UnApply` that records each product selector (`_1`, `_2`, …) found on the unapply's result type as a member-reference used name. When the selector's return type changes (because the field type changed) its name hash changes and Zinc correctly invalidates the dependent file. The key detail is that `fun.tpe` in an `UnApply` node is a `TermRef`; we must call `.widen.finalResultType` to reach the underlying case-class type before asking `Applications.productSelectors` for the `_N` members. Co-Authored-By: Claude Sonnet 4.6 --- .../tools/dotc/sbt/ExtractDependencies.scala | 20 ++++++++++++++----- .../test/xsbt/ExtractAPISpecification.scala | 3 +-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/compiler/src/dotty/tools/dotc/sbt/ExtractDependencies.scala b/compiler/src/dotty/tools/dotc/sbt/ExtractDependencies.scala index c86d8f93eef9..1fac620fd9fc 100644 --- a/compiler/src/dotty/tools/dotc/sbt/ExtractDependencies.scala +++ b/compiler/src/dotty/tools/dotc/sbt/ExtractDependencies.scala @@ -4,7 +4,6 @@ package sbt import java.io.File import java.nio.file.Path import java.util.EnumSet - import dotty.tools.dotc.ast.tpd import dotty.tools.dotc.classpath.FileUtils.{hasClassExtension, hasTastyExtension} import dotty.tools.dotc.core.Contexts.* @@ -16,16 +15,15 @@ import dotty.tools.dotc.core.Phases.* import dotty.tools.dotc.core.Symbols.* import dotty.tools.dotc.core.Denotations.StaleSymbol import dotty.tools.dotc.core.Types.* - -import dotty.tools.dotc.util.{SrcPos, NoSourcePosition} +import dotty.tools.dotc.typer.Applications.* +import dotty.tools.dotc.util.{NoSourcePosition, SrcPos} import dotty.tools.io -import dotty.tools.io.{AbstractFile, PlainFile, ZipArchive, NoAbstractFile, FileExtension} +import dotty.tools.io.{AbstractFile, FileExtension, NoAbstractFile, PlainFile, ZipArchive} import xsbti.UseScope import xsbti.api.DependencyContext import xsbti.api.DependencyContext.* import scala.jdk.CollectionConverters.* - import scala.collection.{Set, mutable} import scala.compiletime.uninitialized @@ -265,6 +263,18 @@ trait AbstractExtractDependenciesCollector(rec: DependencyRecorder) extends tpd. addInheritanceDependencies(t) case t: Template => addInheritanceDependencies(t) + case UnApply(fun, implicits, patterns) => + // For a case-class unapply of the form `def unapply(x: C): C = x`, the + // compiler emits calls to the product selector methods `_1`, `_2`, … in + // the generated bytecode (via PatternMatcher, which runs after this phase). + // Those selectors are never explicit in the typed tree, so they would not + // normally be recorded as used names. We record them here so that Zinc + // knows to recompile this file if the return type of any selector changes. + // + // fun.tpe is a TermRef; widen first to reach the underlying MethodType, + // then take finalResultType to get the case class type (e.g. Customer2). + val selectors = productSelectors(fun.tpe.widen.finalResultType) + selectors.foreach(addMemberRefDependency) case _ => () /**Reused EqHashSet, safe to use as each TypeDependencyTraverser is used atomically diff --git a/sbt-bridge/test/xsbt/ExtractAPISpecification.scala b/sbt-bridge/test/xsbt/ExtractAPISpecification.scala index b51ab337aa2f..034d8a36fcce 100644 --- a/sbt-bridge/test/xsbt/ExtractAPISpecification.scala +++ b/sbt-bridge/test/xsbt/ExtractAPISpecification.scala @@ -258,8 +258,7 @@ class ExtractAPISpecification { usedNames("Test").contains("unapply")) // `_1` must also be recorded because it is what the generated bytecode calls. - // TODO: this currently FAILS, documenting the undercompilation bug. - assertTrue("_1 must be a used name in Test (currently missing — undercompilation bug)", + assertTrue("_1 must be a used name in Test", usedNames("Test").contains("_1")) }