From bf5718db75082ead712318d7be07c76f8b624887 Mon Sep 17 00:00:00 2001 From: Jozef Koval Date: Thu, 18 Jun 2026 14:02:55 +0200 Subject: [PATCH] Track Java->Java dependencies on inlined constants (#145) --- .../internal/inc/classfile/JavaAnalyze.scala | 14 +- .../sbt/internal/inc/javac/LocalJava.scala | 214 ++++++++++++++++- .../inc/javac/JavaConstantDepsSpec.scala | 222 ++++++++++++++++++ .../inc/javac/AnalyzingJavaCompiler.scala | 23 +- .../java-constants/build.json | 15 ++ .../java-constants/changes/B2.java | 5 + .../java-constants/dep/B.java | 3 + .../java-constants/dep/incOptions.properties | 1 + .../source-dependencies/java-constants/test | 25 ++ .../java-constants/use/A.java | 14 ++ .../java-constants/use/incOptions.properties | 1 + 11 files changed, 520 insertions(+), 17 deletions(-) create mode 100644 internal/zinc-compile-core/src/test/scala/sbt/internal/inc/javac/JavaConstantDepsSpec.scala create mode 100644 zinc/src/sbt-test/source-dependencies/java-constants/build.json create mode 100644 zinc/src/sbt-test/source-dependencies/java-constants/changes/B2.java create mode 100644 zinc/src/sbt-test/source-dependencies/java-constants/dep/B.java create mode 100644 zinc/src/sbt-test/source-dependencies/java-constants/dep/incOptions.properties create mode 100644 zinc/src/sbt-test/source-dependencies/java-constants/test create mode 100644 zinc/src/sbt-test/source-dependencies/java-constants/use/A.java create mode 100644 zinc/src/sbt-test/source-dependencies/java-constants/use/incOptions.properties diff --git a/internal/zinc-classfile/src/main/scala/sbt/internal/inc/classfile/JavaAnalyze.scala b/internal/zinc-classfile/src/main/scala/sbt/internal/inc/classfile/JavaAnalyze.scala index 0fb9aee80e..899c2e2cc6 100644 --- a/internal/zinc-classfile/src/main/scala/sbt/internal/inc/classfile/JavaAnalyze.scala +++ b/internal/zinc-classfile/src/main/scala/sbt/internal/inc/classfile/JavaAnalyze.scala @@ -38,7 +38,10 @@ private[sbt] object JavaAnalyze { )( analysis: xsbti.AnalysisCallback, loader: ClassLoader, - readAPI: (VirtualFileRef, Seq[Class[?]]) => Set[(String, String)] + readAPI: (VirtualFileRef, Seq[Class[?]]) => Set[(String, String)], + // sbt/zinc#145: extra member-ref edges for inlined `static final` constants that javac erases + // from the bytecode, recovered from the attributed AST. Keyed `fromBinaryName -> onBinaryNames`. + constantDeps: Map[String, Set[String]] = Map.empty ): Unit = { val sourceMap = sources .toSet[VirtualFile] @@ -182,6 +185,15 @@ private[sbt] object JavaAnalyze { processDependencies(binaryClassNameDeps, DependencyByMemberRef, binaryClassName) } + // sbt/zinc#145: javac inlines `static final` constants, erasing the reference to the declaring + // class from this class's bytecode. The AST-derived `constantDeps` restore those member-ref + // edges. Process them here (inside the per-source loop) so the classpath-origin branch of + // `processDependency` resolves against the right `source`. + for { + binaryClassName <- typesInSource.keysIterator + onBinaryName <- constantDeps.getOrElse(binaryClassName, Set.empty) + } processDependency(onBinaryName, DependencyByMemberRef, binaryClassName) + def readInheritanceDependencies(classes: Seq[Class[?]]) = { val api = readAPI(source, classes) // avoid .mapValues(...) because of its viewness (scala/bug#10919) diff --git a/internal/zinc-compile-core/src/main/scala/sbt/internal/inc/javac/LocalJava.scala b/internal/zinc-compile-core/src/main/scala/sbt/internal/inc/javac/LocalJava.scala index 3e8feaa2fa..a7749da28c 100644 --- a/internal/zinc-compile-core/src/main/scala/sbt/internal/inc/javac/LocalJava.scala +++ b/internal/zinc-compile-core/src/main/scala/sbt/internal/inc/javac/LocalJava.scala @@ -21,7 +21,16 @@ import java.util.Locale import java.nio.{ ByteBuffer, CharBuffer } import java.nio.charset.{ Charset, CodingErrorAction } import java.nio.file.{ Files, FileSystems, Path, Paths } -import javax.lang.model.element.{ Modifier, NestingKind } +import javax.lang.model.element.{ Modifier, NestingKind, TypeElement, VariableElement } +import javax.lang.model.util.Elements +import com.sun.source.tree.{ + ClassTree, + CompilationUnitTree, + IdentifierTree, + MemberSelectTree, + Tree +} +import com.sun.source.util.{ JavacTask, TaskEvent, TaskListener, TreePath, TreePathScanner, Trees } import javax.tools.JavaFileManager.Location import javax.tools.JavaFileObject.Kind import javax.tools.{ @@ -287,7 +296,24 @@ final class LocalJavaCompiler(compiler: javax.tools.JavaCompiler) extends XJavaC incToolOptions: IncToolOptions, reporter: Reporter, log0: XLogger - ): Boolean = { + ): Boolean = + runWithConstantDeps(sources, options, output, incToolOptions, reporter, log0)._1 + + /** + * Like [[run]], but also returns the Java->Java dependencies on inlined `static final` constants + * recovered from javac's attributed AST (sbt/zinc#145). javac inlines such constants and erases + * the reference to the declaring class from the using class's bytecode, so they can only be + * observed here. The map is keyed `usingClassBinaryName -> ownerBinaryNames`. Returned (rather + * than stashed on the instance) so a shared compiler can serve concurrent compiles safely. + */ + private[sbt] def runWithConstantDeps( + sources: Array[VirtualFile], + options: Array[String], + output: Output, + incToolOptions: IncToolOptions, + reporter: Reporter, + log0: XLogger + ): (Boolean, Map[String, Set[String]]) = { val log: Logger = log0 val logger = new LoggerWriter(log) val logWriter = new PrintWriter(logger) @@ -332,17 +358,28 @@ final class LocalJavaCompiler(compiler: javax.tools.JavaCompiler) extends XJavaC } var compileSuccess = false + var constantDeps: Map[String, Set[String]] = Map.empty try { - val success = compiler - .getTask( - logWriter, - customizedFileManager, - diagnostics, - javacOptions.toList.asJava, - null, - jfiles.asJava - ) - .call() + // sbt/zinc#145: collect Java->Java dependencies on inlined `static final` constants from + // javac's attributed AST, which retains the reference that the emitted bytecode erases. + val deps = new JavaConstantDeps + val task = compiler.getTask( + logWriter, + customizedFileManager, + diagnostics, + javacOptions.toList.asJava, + null, + jfiles.asJava + ) + try task match { + case jt: JavacTask => jt.addTaskListener(new ConstantDepListener(jt, deps)) + case _ => () // not the system javac; constant deps are simply not tracked + } + catch { + case NonFatal(e) => log.debug("Could not install constant-dependency listener: " + e) + } + val success = task.call() + constantDeps = deps.result /* Double check success variables for the Java compiler. * The local compiler may report successful compilations even though @@ -353,7 +390,7 @@ final class LocalJavaCompiler(compiler: javax.tools.JavaCompiler) extends XJavaC customizedFileManager.close() logger.flushLines(if (compileSuccess) Level.Warn else Level.Error) } - compileSuccess + (compileSuccess, constantDeps) } /** @@ -394,6 +431,157 @@ final class SameFileFixFileManager(underlying: JavaFileManager) override def isSameFile(a: FileObject, b: FileObject): Boolean = LocalJava.isSameFile(a, b) } +/** + * Mutable accumulator for Java->Java dependencies on inlined `static final` constants (sbt/zinc#145). + * Keys and values are JVM binary names (e.g. `pkg.Outer$Inner`), matching what `JavaAnalyze` records. + */ +private[sbt] final class JavaConstantDeps { + private val deps = + scala.collection.mutable.Map.empty[String, scala.collection.mutable.Set[String]] + def add(from: String, on: String): Unit = + deps.getOrElseUpdate(from, scala.collection.mutable.Set.empty[String]) += on + def result: Map[String, Set[String]] = + deps.iterator.map { case (k, v) => k -> v.toSet }.toMap +} + +/** + * Registers [[ConstantDepScanner]] on each top-level class once it has been attributed (the + * `ANALYZE` task event), when resolved symbols — including references to inlined constants — are + * available. Failures are swallowed so analysis never fails the compile. + */ +private[sbt] final class ConstantDepListener(task: JavacTask, sink: JavaConstantDeps) + extends TaskListener { + private val trees = Trees.instance(task) + private val elements = task.getElements + + override def started(e: TaskEvent): Unit = () + + override def finished(e: TaskEvent): Unit = + if (e.getKind == TaskEvent.Kind.ANALYZE) { + val te = e.getTypeElement + if (te != null) + try { + val path = trees.getPath(te) + if (path != null) new ConstantDepScanner(trees, elements, sink).scan(path, null) + } catch { case NonFatal(_) => () } + } +} + +/** + * Walks an attributed Java AST and records, for every reference that resolves to a compile-time + * constant field (`VariableElement.getConstantValue != null`), an edge from the enclosing class to + * the class that declares the constant (the value source, taken from the resolved element so + * `import static` references are captured too) and, for a qualified reference, to the qualifier type + * when it differs from the declaring class. + */ +private[sbt] final class ConstantDepScanner( + trees: Trees, + elements: Elements, + sink: JavaConstantDeps +) extends TreePathScanner[Void, Void] { + + private def record(qualifier: Option[Tree]): Unit = + try { + val path = getCurrentPath + trees.getElement(path) match { + case v: VariableElement if v.getConstantValue != null => + enclosingTypeBinaryName(path).foreach { from => + // the class that declares the constant — a change to its value must recompile `from` + v.getEnclosingElement match { + case owner: TypeElement => addEdge(from, owner) + case _ => () + } + // sbt/zinc#145: when the constant is named through a subtype rather than its declaring + // class (`Sub.K`, or a bare `K` brought in by `import static p.Sub.K` / `p.Sub.*`, where + // K is inherited from Base), javac erases both Base and Sub from the bytecode. Record the + // named (qualifier) type(s) too, so a change to one (it gains its own K, or changes its + // inheritance) recompiles `from` as well. + namedTypes(path, qualifier, v).foreach(addEdge(from, _)) + } + case _ => () + } + } catch { case NonFatal(_) => () } + + /** The type(s) named at the reference site: a member-select qualifier, or static-import types. */ + private def namedTypes( + path: TreePath, + qualifier: Option[Tree], + field: VariableElement + ): List[TypeElement] = + qualifier match { + case Some(q) => + trees.getElement(new TreePath(path, q)) match { + case te: TypeElement => te :: Nil + case _ => Nil + } + case None => staticImportTypes(path.getCompilationUnit, field) + } + + /** + * Every statically-imported type that actually contributes `field` — covering both explicit + * (`import static p.Sub.K`) and on-demand (`import static p.Sub.*`) imports. The import must name + * the field (or be a wildcard) AND the type must actually have it (`getAllMembers`, which includes + * inherited members): the name guard rejects an unrelated `import static p.Sub.M` (which would + * otherwise match just because `Sub` also inherits `K`), and the membership guard rejects a + * same-named import of a *different* constant. + */ + private def staticImportTypes( + cu: CompilationUnitTree, + field: VariableElement + ): List[TypeElement] = { + val cuPath = new TreePath(cu) + cu.getImports.asScala.iterator + .filter(_.isStatic) + .flatMap { imp => + // both `import static p.Sub.K` and `import static p.Sub.*` name the type in `getExpression` + imp.getQualifiedIdentifier match { + case ms: MemberSelectTree + if ms.getIdentifier.contentEquals("*") || + ms.getIdentifier.contentEquals(field.getSimpleName) => + val typePath = + new TreePath(new TreePath(new TreePath(cuPath, imp), ms), ms.getExpression) + trees.getElement(typePath) match { + case te: TypeElement if elements.getAllMembers(te).contains(field) => Some(te) + case _ => None + } + case _ => None + } + } + .toList + } + + private def addEdge(from: String, to: TypeElement): Unit = { + val on = elements.getBinaryName(to).toString + if (from != on) sink.add(from, on) + } + + private def enclosingTypeBinaryName(path: TreePath): Option[String] = { + var p = path + while (p != null) { + p.getLeaf match { + case _: ClassTree => + return trees.getElement(p) match { + case te: TypeElement => Some(elements.getBinaryName(te).toString) + case _ => None + } + case _ => () + } + p = p.getParentPath + } + None + } + + override def visitIdentifier(node: IdentifierTree, p: Void): Void = { + record(None) + super.visitIdentifier(node, p) + } + + override def visitMemberSelect(node: MemberSelectTree, p: Void): Void = { + record(Some(node.getExpression)) + super.visitMemberSelect(node, p) + } +} + /** * Track write calls through customized file manager. * diff --git a/internal/zinc-compile-core/src/test/scala/sbt/internal/inc/javac/JavaConstantDepsSpec.scala b/internal/zinc-compile-core/src/test/scala/sbt/internal/inc/javac/JavaConstantDepsSpec.scala new file mode 100644 index 0000000000..98e22a9aed --- /dev/null +++ b/internal/zinc-compile-core/src/test/scala/sbt/internal/inc/javac/JavaConstantDepsSpec.scala @@ -0,0 +1,222 @@ +/* + * Zinc - The incremental compiler for Scala. + * Copyright Scala Center, Lightbend, and Mark Harrah + * + * Licensed under Apache License 2.0 + * SPDX-License-Identifier: Apache-2.0 + * + * See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + */ + +package sbt +package internal +package inc +package javac + +import java.nio.file.Path + +import xsbti.compile.IncToolOptionsUtil +import sbt.io.IO +import sbt.util.LoggerContext + +/** + * Tests the javac-AST based recovery of Java->Java dependencies on inlined `static final` + * constants (sbt/zinc#145). javac inlines these constants and erases the reference to the declaring + * class from the using class's bytecode, so they can only be recovered from the attributed AST. + */ +class JavaConstantDepsSpec extends UnitSpec { + + "Local javac constant analysis" should "record a member-select constant dependency" in deps { + d => assert(d.getOrElse("p.A", Set.empty).contains("p.B")) + } + + it should "record a statically-imported constant dependency (owner from the element)" in deps { + d => assert(d.getOrElse("p.C", Set.empty).contains("p.B")) + } + + it should "use the declaring class's binary name for a nested-class constant" in deps { d => + assert(d.getOrElse("p.E", Set.empty).contains("p.Outer$Inner")) + } + + it should "not record dependencies on non-constant fields" in deps { d => + assert(!d.getOrElse("p.D", Set.empty).contains("p.B")) + } + + // The cases that matter for sbt/zinc#145: javac erases the owner class entirely from the + // bytecode (verified with javap), so these can ONLY be recovered from the AST. + it should "record a constant used as an annotation value (owner erased from bytecode)" in deps { + d => assert(d.getOrElse("p.AnnoUse", Set.empty).contains("p.B")) + } + + it should "record a constant used as a switch-case label (owner erased from bytecode)" in deps { + d => assert(d.getOrElse("p.SwitchUse", Set.empty).contains("p.B")) + } + + // When an inherited constant is selected through a subtype (`Sub.K`), javac erases BOTH the + // declaring class and the named subtype. Record both so a later change to either recompiles. + it should "record the declaring owner of a constant inherited through a subtype" in deps { d => + assert(d.getOrElse("p.SubUse", Set.empty).contains("p.Base")) + } + + it should "record the qualifier subtype of an inherited-constant reference" in deps { d => + assert(d.getOrElse("p.SubUse", Set.empty).contains("p.Sub")) + } + + // Same as above but the subtype is named by a `import static` rather than at the use site. + it should "record the declaring owner of a statically-imported inherited constant" in deps { d => + assert(d.getOrElse("p.StaticImportSub", Set.empty).contains("p.Base")) + } + + it should "record the static-import subtype of an inherited constant" in deps { d => + assert(d.getOrElse("p.StaticImportSub", Set.empty).contains("p.Sub")) + } + + // On-demand (wildcard) static import: the type is recovered via getAllMembers, not a name match. + it should "record the owner of a wildcard-static-imported inherited constant" in deps { d => + assert(d.getOrElse("p.WildcardImportSub", Set.empty).contains("p.Base")) + } + + it should "record the subtype of a wildcard-static-imported inherited constant" in deps { d => + assert(d.getOrElse("p.WildcardImportSub", Set.empty).contains("p.Sub")) + } + + it should "not create a dependency from an unrelated explicit static import" in deps { d => + assert(d.getOrElse("p.NegImport", Set.empty).contains("p.Base")) // K's declaring owner + assert( + !d.getOrElse("p.NegImport", Set.empty).contains("p.Sub") + ) // unrelated `import static Sub.M` + } + + /** Compile the fixture set once and run the given assertion on the collected dependencies. */ + private def deps(check: Map[String, Set[String]] => Unit): Unit = + IO.withTemporaryDirectory { tmp => + check(compileAndCollect(tmp.toPath)) + } + + private val fixtures: Seq[(String, String)] = Seq( + "B.java" -> + """package p; + |public class B { + | public static final int MAX = 1; + | public int nonConst = 2; + |} + |""".stripMargin, + "Outer.java" -> + """package p; + |public class Outer { + | public static class Inner { + | public static final int K = 7; + | } + |} + |""".stripMargin, + "A.java" -> + """package p; + |public class A { + | int useConst() { return B.MAX; } + |} + |""".stripMargin, + "C.java" -> + """package p; + |import static p.B.MAX; + |public class C { + | int useImport() { return MAX; } + |} + |""".stripMargin, + "D.java" -> + """package p; + |public class D { + | int useNonConst(B b) { return b.nonConst; } + |} + |""".stripMargin, + "E.java" -> + """package p; + |public class E { + | int useNested() { return Outer.Inner.K; } + |} + |""".stripMargin, + "Anno.java" -> + """package p; + |public @interface Anno { int value(); } + |""".stripMargin, + "AnnoUse.java" -> + """package p; + |@Anno(B.MAX) + |public class AnnoUse {} + |""".stripMargin, + "SwitchUse.java" -> + """package p; + |public class SwitchUse { + | int g(int x) { switch (x) { case B.MAX: return 1; default: return 0; } } + |} + |""".stripMargin, + "Base.java" -> + """package p; + |public class Base { public static final int K = 1; } + |""".stripMargin, + "Sub.java" -> + """package p; + |public class Sub extends Base { public static final int M = 2; } + |""".stripMargin, + "SubUse.java" -> + """package p; + |public class SubUse { + | int h(int x) { switch (x) { case Sub.K: return 1; default: return 0; } } + |} + |""".stripMargin, + "StaticImportSub.java" -> + """package p; + |import static p.Sub.K; + |public class StaticImportSub { + | int h(int x) { switch (x) { case K: return 1; default: return 0; } } + |} + |""".stripMargin, + "WildcardImportSub.java" -> + """package p; + |import static p.Sub.*; + |public class WildcardImportSub { + | int h(int x) { switch (x) { case K: return 1; default: return 0; } } + |} + |""".stripMargin, + // Uses inherited K (extends Base) and has an UNRELATED explicit static import of Sub.M. The + // import does not bring in K, so it must not create a dependency on Sub. + "NegImport.java" -> + """package p; + |import static p.Sub.M; + |public class NegImport extends Base { + | int h(int x) { switch (x) { case K: return 1; default: return 0; } } + |} + |""".stripMargin + ) + + private def compileAndCollect(tmp: Path): Map[String, Set[String]] = { + val compiler = new LocalJavaCompiler( + Option(javax.tools.ToolProvider.getSystemJavaCompiler) + .getOrElse(sys.error("This test requires a JDK, not a JRE.")) + ) + val srcDir = tmp.resolve("src") + val sources: Seq[Path] = fixtures.map { + case (name, content) => + val f = srcDir.resolve(name) + IO.write(f.toFile, content) + f + } + val outDir = tmp.resolve("classes") + IO.createDirectory(outDir.toFile) + + val log = LoggerContext.globalContext.logger("JavaConstantDepsSpec", None, None) + val reporter = new ManagedLoggedReporter(10, log) + val options = + if (scala.util.Properties.isJavaAtLeast("21")) Array("-proc:none") else Array.empty[String] + val (success, deps) = compiler.runWithConstantDeps( + sources.map(PlainVirtualFile(_)).toArray, + options, + CompileOutput(outDir), + IncToolOptionsUtil.defaultIncToolOptions(), + reporter, + log + ) + assert(success, "javac compilation of the fixtures failed") + deps + } +} diff --git a/zinc/src/main/scala/sbt/internal/inc/javac/AnalyzingJavaCompiler.scala b/zinc/src/main/scala/sbt/internal/inc/javac/AnalyzingJavaCompiler.scala index cf7b867c74..7a4a03d690 100644 --- a/zinc/src/main/scala/sbt/internal/inc/javac/AnalyzingJavaCompiler.scala +++ b/zinc/src/main/scala/sbt/internal/inc/javac/AnalyzingJavaCompiler.scala @@ -158,6 +158,10 @@ final class AnalyzingJavaCompiler private[sbt] ( progress.advance(0, 2, somePhase, javaCompilationPhase) } + // sbt/zinc#145: dependencies on inlined `static final` constants, keyed + // `usingClassBinaryName -> ownerBinaryNames`, populated by the Java compilation below. + var constantDeps: Map[String, Set[String]] = Map.empty + timed(javaCompilationPhase, log) { val args = sbt.internal.inc.javac.JavaCompiler.commandArguments( absClasspath, @@ -170,8 +174,20 @@ final class AnalyzingJavaCompiler private[sbt] ( sources.sortBy(_.id).toArray // TODO: https://github.com/sbt/sbt/issues/7883 // log.debug(InterfaceUtil.toSupplier(prettyPrintCompilationArguments(args))) - val success = - javac.run(javaSources, args, output, incToolOptions, reporter, log) + // sbt/zinc#145: the local compiler also returns dependencies on inlined `static final` + // constants recovered from javac's attributed AST (the bytecode erases them). This only + // covers in-process javac; a forked compiler can't be hooked, so those rare cross-module + // constant-only dependencies (annotation values / switch-case labels referencing a constant + // from a separately-compiled class) are not tracked under forked javac. + val success = javac match { + case ljc: LocalJavaCompiler => + val (ok, deps) = + ljc.runWithConstantDeps(javaSources, args, output, incToolOptions, reporter, log) + constantDeps = deps + ok + case _ => + javac.run(javaSources, args, output, incToolOptions, reporter, log) + } if (!success) { /* Assume that no Scalac problems are reported for a Javac-related * reporter. This relies on the incremental compiler will not run @@ -214,7 +230,8 @@ final class AnalyzingJavaCompiler private[sbt] ( JavaAnalyze(newClasses.toSeq, srcs, log, output, finalJarOutput)( callback, loader, - readAPI + readAPI, + constantDeps ) } finally classes.close() } diff --git a/zinc/src/sbt-test/source-dependencies/java-constants/build.json b/zinc/src/sbt-test/source-dependencies/java-constants/build.json new file mode 100644 index 0000000000..bb03af4c9c --- /dev/null +++ b/zinc/src/sbt-test/source-dependencies/java-constants/build.json @@ -0,0 +1,15 @@ +{ + "projects": [ + { + "name": "use", + "dependsOn": [ + "dep" + ], + "scalaVersion": "2.13.x" + }, + { + "name": "dep", + "scalaVersion": "2.13.x" + } + ] +} diff --git a/zinc/src/sbt-test/source-dependencies/java-constants/changes/B2.java b/zinc/src/sbt-test/source-dependencies/java-constants/changes/B2.java new file mode 100644 index 0000000000..dce179e90b --- /dev/null +++ b/zinc/src/sbt-test/source-dependencies/java-constants/changes/B2.java @@ -0,0 +1,5 @@ +public class B { + // Changing MAX's type from int to String makes A's `case B.MAX` fail to compile *iff* A is + // recompiled, which happens only when the cross-module A->B constant dependency was recorded. + public static final String MAX = "3"; +} diff --git a/zinc/src/sbt-test/source-dependencies/java-constants/dep/B.java b/zinc/src/sbt-test/source-dependencies/java-constants/dep/B.java new file mode 100644 index 0000000000..0e2aba90e9 --- /dev/null +++ b/zinc/src/sbt-test/source-dependencies/java-constants/dep/B.java @@ -0,0 +1,3 @@ +public class B { + public static final int MAX = 3; +} diff --git a/zinc/src/sbt-test/source-dependencies/java-constants/dep/incOptions.properties b/zinc/src/sbt-test/source-dependencies/java-constants/dep/incOptions.properties new file mode 100644 index 0000000000..754ac248e6 --- /dev/null +++ b/zinc/src/sbt-test/source-dependencies/java-constants/dep/incOptions.properties @@ -0,0 +1 @@ +pipelining = false diff --git a/zinc/src/sbt-test/source-dependencies/java-constants/test b/zinc/src/sbt-test/source-dependencies/java-constants/test new file mode 100644 index 0000000000..6d4922aa26 --- /dev/null +++ b/zinc/src/sbt-test/source-dependencies/java-constants/test @@ -0,0 +1,25 @@ +# sbt/zinc#145: `use` depends on `dep` ONLY through dep's inlined static-final constant B.MAX, +# referenced as a switch-case label. javac inlines the value and erases every reference to B from +# A.class (verified with javap), so the cross-module A->B dependency must be recovered from javac's +# attributed AST or this incremental compile goes stale. +# This must be cross-module: within a single module zinc recompiles all Java sources together +# (compileAllJava), so the missing edge would be masked. Ordinary constant uses (field/method +# bodies) keep B as a CONSTANT_Class entry and are already tracked; only annotation values and +# switch labels actually erase the owner, hence the switch-case fixture. Pipelining is disabled +# (incOptions.properties) because it recompiles all Java sources every cycle, which also masks the +# missing edge. + +> use/compile + +# done this way because last modified times often have ~1s resolution +$ sleep 2000 + +# change B.MAX from int to String: A's `case B.MAX` no longer compiles IF A is recompiled, which +# happens only when the cross-module A->B constant dependency was recorded. +$ copy-file changes/B2.java dep/B.java +-> use/compile + +# verify the change really is a compile error by doing a full recompilation +> use/clean +> dep/clean +-> use/compile diff --git a/zinc/src/sbt-test/source-dependencies/java-constants/use/A.java b/zinc/src/sbt-test/source-dependencies/java-constants/use/A.java new file mode 100644 index 0000000000..4886988132 --- /dev/null +++ b/zinc/src/sbt-test/source-dependencies/java-constants/use/A.java @@ -0,0 +1,14 @@ +// `use` depends on `dep` ONLY through dep's inlined static-final constant B.MAX, used as a +// switch-case label. javac inlines the value AND erases every reference to B from A.class (verified +// with javap), so the cross-module A->B dependency can be recovered only from javac's attributed +// AST (sbt/zinc#145). Without it, changing B.MAX would not recompile A and A would go stale. +public class A { + public int g(int x) { + switch (x) { + case B.MAX: + return 1; + default: + return 0; + } + } +} diff --git a/zinc/src/sbt-test/source-dependencies/java-constants/use/incOptions.properties b/zinc/src/sbt-test/source-dependencies/java-constants/use/incOptions.properties new file mode 100644 index 0000000000..754ac248e6 --- /dev/null +++ b/zinc/src/sbt-test/source-dependencies/java-constants/use/incOptions.properties @@ -0,0 +1 @@ +pipelining = false