diff --git a/modules/build/src/main/scala/scala/build/internal/util/WarningMessages.scala b/modules/build/src/main/scala/scala/build/internal/util/WarningMessages.scala index 8f06ab7837..3f6e17ff08 100644 --- a/modules/build/src/main/scala/scala/build/internal/util/WarningMessages.scala +++ b/modules/build/src/main/scala/scala/build/internal/util/WarningMessages.scala @@ -187,6 +187,16 @@ object WarningMessages { def slothCouldNotPatch(subject: String, reason: String): String = s"Could not patch lazy vals in $subject, using original: $reason" + def slothUnresolvedHierarchy(subject: String, details: String): String = + s"Sloth could not resolve class hierarchies while patching $subject ($details). " + + "Patching continued with widened frames; use --sloth-strict to fail instead." + + def slothUnresolvedHierarchyStrict(subject: String, details: String): String = + s"Sloth could not resolve class hierarchies while patching $subject ($details)." + + val slothStrictRequiresPatching: String = + "--sloth-strict requires --sloth or --sloth-agent; hierarchy checking has no effect without lazy-val patching." + val slothNonStandaloneBootstrapWarning: String = "Could not patch lazy vals in non-standalone bootstrap dependencies; use --standalone for batch patching." } diff --git a/modules/build/src/main/scala/scala/build/postprocessing/SlothAgent.scala b/modules/build/src/main/scala/scala/build/postprocessing/SlothAgent.scala index b10b3ea8ab..a7a754a93a 100644 --- a/modules/build/src/main/scala/scala/build/postprocessing/SlothAgent.scala +++ b/modules/build/src/main/scala/scala/build/postprocessing/SlothAgent.scala @@ -31,6 +31,11 @@ object SlothAgent: ): Unit = if options.notForBloopOptions.sloth && options.notForBloopOptions.slothAgent then logger.message(s"$warnPrefix ${WarningMessages.slothModesMutuallyRedundant}") + if options.notForBloopOptions.slothStrict && + !options.notForBloopOptions.sloth && + !options.notForBloopOptions.slothAgent + then + logger.message(s"$warnPrefix ${WarningMessages.slothStrictRequiresPatching}") private def fetchAgentJar( options: BuildOptions, @@ -57,6 +62,11 @@ object SlothAgent: ): Either[BuildException, os.Path] = val expectedJarName = s"${Constants.slothAgentModuleName}-${Constants.slothAgentVersion}.jar" + val plainJarName = s"${Constants.slothAgentModuleName}.jar" artifacts - .collectFirst { case (_, path) if path.last == expectedJarName => path } + .collectFirst { + // Maven-style: sloth-agent-.jar; Ivy publishLocal often keeps sloth-agent.jar. + case (_, path) + if path.last == expectedJarName || path.last == plainJarName => path + } .toRight(SlothAgentError(s"Could not resolve sloth agent ${Constants.slothAgentVersion}")) diff --git a/modules/build/src/main/scala/scala/build/postprocessing/SlothPatcher.scala b/modules/build/src/main/scala/scala/build/postprocessing/SlothPatcher.scala index 3e53ec38cf..0934b97a90 100644 --- a/modules/build/src/main/scala/scala/build/postprocessing/SlothPatcher.scala +++ b/modules/build/src/main/scala/scala/build/postprocessing/SlothPatcher.scala @@ -11,7 +11,7 @@ import java.util.jar.{Attributes as JarAttributes, JarOutputStream, Manifest as import java.util.zip.{ZipEntry, ZipFile, ZipOutputStream} import scala.build.Ops.EitherIteratorOps -import scala.build.errors.BuildException +import scala.build.errors.{BuildException, SlothHierarchyError} import scala.build.internal.util.WarningMessages import scala.build.internal.{Constants, JarManifests} import scala.build.internals.ConsoleUtils.ScalaCliConsole.warnPrefix @@ -23,6 +23,10 @@ import scala.util.control.NonFatal object SlothPatcher: + /** Whether a classpath entry is the project's own output or a dependency / external artifact. */ + enum SlothSource: + case Project, Dependency + private val cacheDir = Directories.directories.cacheDir / "sloth" private[build] def shouldPatchProjectClasses( @@ -41,29 +45,88 @@ object SlothPatcher: scalaVersions = builds.flatMap(_.scalaParams).map(_.scalaVersion) ) + private def warnIfStrictWithoutPatching(options: BuildOptions, logger: Logger): Unit = + if options.notForBloopOptions.slothStrict && + !options.notForBloopOptions.sloth && + !options.notForBloopOptions.slothAgent + then + logger.message(s"$warnPrefix ${WarningMessages.slothStrictRequiresPatching}") + def transformClassPath( classPath: Seq[os.Path], options: BuildOptions, logger: Logger, - patchProjectClassDirs: Boolean = false + patchProjectClassDirs: Boolean, + projectClassDirs: Set[os.Path] ): Either[BuildException, Seq[os.Path]] = + warnIfStrictWithoutPatching(options, logger) if options.notForBloopOptions.sloth then - Right(captureStdio(logger)(classPath.map(patchClassPathEntry( - _, - patchProjectClassDirs, - logger - )))) + try + Right(captureStdio(logger)(classPath.map(patchClassPathEntry( + _, + patchProjectClassDirs, + projectClassDirs, + classPath, + options.notForBloopOptions.slothStrict, + logger + )))) + catch + case e: SlothHierarchyError => Left(e) else Right(classPath) def patchJarFile( jar: os.Path, options: BuildOptions, - logger: Logger + logger: Logger, + hierarchyClassPath: Seq[os.Path] = Nil, + source: SlothSource = SlothSource.Dependency ): Either[BuildException, os.Path] = + warnIfStrictWithoutPatching(options, logger) if options.notForBloopOptions.sloth then - Right(captureStdio(logger)(patchIfJar(jar, logger))) + try + Right(captureStdio(logger)(patchIfJar( + jar, + hierarchyClassPath, + source, + options.notForBloopOptions.slothStrict, + logger + ))) + catch + case e: SlothHierarchyError => Left(e) else Right(jar) + /** Turn a classpath entry into a JAR suitable for packaging (e.g. bootstrap). Files are patched + * when `--sloth` is on; directories are jarred (and patched when `--sloth` is on). Never mutates + * the original directory. Directory jars are content-addressed under the Sloth cache so + * packaging stays deterministic across runs. + */ + def classPathEntryAsJar( + path: os.Path, + options: BuildOptions, + logger: Logger, + hierarchyClassPath: Seq[os.Path] = Nil, + source: SlothSource = SlothSource.Dependency + ): Either[BuildException, os.Path] = + warnIfStrictWithoutPatching(options, logger) + if os.isDir(path) then + try + Right(captureStdio(logger): + if options.notForBloopOptions.sloth && containsClassFiles(path) then + // Prefer a cached jar of the patched directory + patchClassDir( + path, + hierarchyClassPath, + source, + options.notForBloopOptions.slothStrict, + logger + ) match + case jar if jar != path && os.isFile(jar) => jar + case _ => jarDirectoryCached(path) + else jarDirectoryCached(path)) + catch + case e: SlothHierarchyError => Left(e) + else patchJarFile(path, options, logger, hierarchyClassPath, source) + /** In-process memo of class directories covered by [[patchClassDirInPlace]] in this JVM. * Consulted by callers (e.g. `copyOutput`) to skip a redundant second pass over the same bytes; * does not short-circuit [[patchClassDirInPlace]] itself (needed under `--watch`). @@ -74,22 +137,34 @@ object SlothPatcher: def wasPatchedInThisProcess(dir: os.Path): Boolean = patchedClassDirsInThisProcess.contains(dir) - /** Patch the class directories of `classPath` in place, keeping them as directories, and return - * `classPath` unchanged so it can be chained into [[transformClassPath]]. + /** Patch the project class directories of `classPath` in place, keeping them as directories, and + * return `classPath` unchanged so it can be chained into [[transformClassPath]]. Only entries in + * `projectClassDirs` are considered. */ def patchClassPathDirsInPlace( classPath: Seq[os.Path], options: BuildOptions, logger: Logger, - shouldPatch: Boolean + shouldPatch: Boolean, + projectClassDirs: Set[os.Path] ): Either[BuildException, Seq[os.Path]] = + warnIfStrictWithoutPatching(options, logger) if !options.notForBloopOptions.sloth || !shouldPatch then Right(classPath) else - classPath.iterator - .filter(containsClassFiles) - .map(patchClassDirInPlace(_, options, logger, shouldPatch = true)) - .sequence0 - .map(_ => classPath) + try + classPath.iterator + .filter(p => projectClassDirs.contains(p) && containsClassFiles(p)) + .map(patchClassDirInPlace( + _, + options, + logger, + shouldPatch = true, + hierarchyClassPath = classPath + )) + .sequence0 + .map(_ => classPath) + catch + case e: SlothHierarchyError => Left(e) private def containsClassFiles(path: os.Path): Boolean = os.isDir(path) && os.walk.stream(path).find(p => p.ext == "class" && os.isFile(p)).isDefined @@ -102,35 +177,46 @@ object SlothPatcher: dir: os.Path, options: BuildOptions, logger: Logger, - shouldPatch: Boolean + shouldPatch: Boolean, + hierarchyClassPath: Seq[os.Path] = Nil ): Either[BuildException, Unit] = if !options.notForBloopOptions.sloth || !shouldPatch || !os.isDir(dir) then Right(()) else - Right: - captureStdio(logger): - try - withOriginalFallback(dir.toString, (), logger): - val tmpInput = - os.temp(prefix = "sloth-inplace-", suffix = ".jar", deleteOnExit = false) - val tmpOutput = - os.temp(prefix = "sloth-inplace-out-", suffix = ".jar", deleteOnExit = false) - try - jarDirectory(dir, tmpInput) - runJarProcessor(tmpInput, tmpOutput) match - case Left(errorMsg) => - logger.message( - s"$warnPrefix ${WarningMessages.slothCouldNotPatch(dir.toString, errorMsg)}" - ) - case Right(result) if result.patchedClasses == 0 => - logger.debug(s"No lazy vals to patch in place in $dir") - case Right(_) => - writeBackPatchedClasses(dir, tmpInput, tmpOutput, logger) - logger.debug(s"Patched lazy vals in place in $dir") - finally - if os.exists(tmpInput) then os.remove(tmpInput) - if os.exists(tmpOutput) then os.remove(tmpOutput) - finally - patchedClassDirsInThisProcess.add(dir) + try + Right: + captureStdio(logger): + try + withOriginalFallback(dir.toString, (), logger): + val tmpInput = + os.temp(prefix = "sloth-inplace-", suffix = ".jar", deleteOnExit = false) + val tmpOutput = + os.temp(prefix = "sloth-inplace-out-", suffix = ".jar", deleteOnExit = false) + try + jarDirectory(dir, tmpInput) + runJarProcessor( + tmpInput, + tmpOutput, + hierarchyClassPath, + SlothSource.Project, + strictRequested = options.notForBloopOptions.slothStrict, + logger + ) match + case Left(errorMsg) => + logger.message( + s"$warnPrefix ${WarningMessages.slothCouldNotPatch(dir.toString, errorMsg)}" + ) + case Right(result) if result.patchedClasses == 0 => + logger.debug(s"No lazy vals to patch in place in $dir") + case Right(_) => + writeBackPatchedClasses(dir, tmpInput, tmpOutput, logger) + logger.debug(s"Patched lazy vals in place in $dir") + finally + if os.exists(tmpInput) then os.remove(tmpInput) + if os.exists(tmpOutput) then os.remove(tmpOutput) + finally + patchedClassDirsInThisProcess.add(dir) + catch + case e: SlothHierarchyError => Left(e) private def writeBackPatchedClasses( dir: os.Path, @@ -151,15 +237,25 @@ object SlothPatcher: def patchByteCodeZipEntries( entries: Seq[(ZipEntry, Array[Byte])], options: BuildOptions, - logger: Logger + logger: Logger, + hierarchyClassPath: Seq[os.Path] = Nil ): Either[BuildException, Seq[(ZipEntry, Array[Byte])]] = + warnIfStrictWithoutPatching(options, logger) if !options.notForBloopOptions.sloth || entries.isEmpty then Right(entries) else val tmpJar = os.temp(prefix = "sloth-entries-", suffix = ".jar", deleteOnExit = false) try withOriginalFallback("bytecode zip entries", Right(entries), logger): writeZipEntries(tmpJar, entries) - patchJarFile(tmpJar, options, logger).map(readZipEntries) + patchJarFile( + tmpJar, + options, + logger, + hierarchyClassPath, + SlothSource.Project + ).map(readZipEntries) + catch + case e: SlothHierarchyError => Left(e) finally if os.exists(tmpJar) then os.remove(tmpJar) /** ZIP local-file / empty-archive / spanned signatures. */ @@ -230,11 +326,17 @@ object SlothPatcher: ((bytes(offset + 2) & 0xff).toLong << 16) | ((bytes(offset + 3) & 0xff).toLong << 24) - private def patchIfJar(path: os.Path, logger: Logger): os.Path = + private def patchIfJar( + path: os.Path, + hierarchyClassPath: Seq[os.Path], + source: SlothSource, + strictRequested: Boolean, + logger: Logger + ): os.Path = path.orOriginalOnFailure(logger): zipStartOffset(path) match case Some(offset) => - patchJar(path, offset, logger) + patchJar(path, offset, hierarchyClassPath, source, strictRequested, logger) case None => if os.isFile(path) then logger.message(s"$warnPrefix ${WarningMessages.slothNotAnArchive(path)}") @@ -245,19 +347,116 @@ object SlothPatcher: private def patchClassPathEntry( path: os.Path, patchProjectClassDirs: Boolean, + projectClassDirs: Set[os.Path], + hierarchyClassPath: Seq[os.Path], + strictRequested: Boolean, logger: Logger ): os.Path = path.orOriginalOnFailure(logger): zipStartOffset(path) match case Some(offset) => - patchJar(path, offset, logger) - case None if patchProjectClassDirs && os.isDir(path) => - patchClassDir(path, logger) + val source = + if projectClassDirs.contains(path) then SlothSource.Project + else + SlothSource.Dependency + patchJar(path, offset, hierarchyClassPath, source, strictRequested, logger) + case None if containsClassFiles(path) => + if projectClassDirs.contains(path) then + if patchProjectClassDirs then + patchClassDir( + path, + hierarchyClassPath, + SlothSource.Project, + strictRequested, + logger + ) + else path + else + patchExternalClassDir(path, hierarchyClassPath, strictRequested, logger) case None => logger.debug(s"Sloth skipping classpath entry: $path") path - private def patchClassDir(dir: os.Path, logger: Logger): os.Path = + /** Patch an externally supplied class directory into a cached directory copy. Never mutates the + * user's files; keeps the entry as a directory so test-suite discovery (which only scans + * directory classpath entries) still works. + */ + private def patchExternalClassDir( + dir: os.Path, + hierarchyClassPath: Seq[os.Path], + strictRequested: Boolean, + logger: Logger + ): os.Path = + dir.orOriginalOnFailure(logger): + val dirHash = sha1OfDir(dir) + val cachedDir = cacheDir / Constants.slothVersion / "external-dirs" / dirHash + val cached = cachedDir / dir.last + val unpatchedMarker = cachedDir / unpatchedMarkerName + if os.exists(cached) then cached + else if os.exists(unpatchedMarker) then dir + else + os.makeDir.all(cachedDir) + val tmpInput = + os.temp( + prefix = "sloth-external-", + suffix = ".jar", + dir = cachedDir, + deleteOnExit = false + ) + val tmpOutput = + os.temp( + prefix = "sloth-external-out-", + suffix = ".jar", + dir = cachedDir, + deleteOnExit = false + ) + try + jarDirectory(dir, tmpInput) + runJarProcessor( + tmpInput, + tmpOutput, + hierarchyClassPath, + SlothSource.Dependency, + strictRequested, + logger + ) match + case Left(errorMsg) => + logger.message( + s"$warnPrefix ${WarningMessages.slothCouldNotPatch(dir.toString, errorMsg)}" + ) + dir + case Right(result) if result.patchedClasses == 0 => + os.write(unpatchedMarker, "", createFolders = true) + logger.debug(s"No lazy vals to patch in external class directory $dir") + dir + case Right(_) => + val staging = + cachedDir / s"${dir.last}.staging-${java.util.UUID.randomUUID().toString}" + try + os.copy(dir, staging, createFolders = true) + writeBackPatchedClasses(staging, tmpInput, tmpOutput, logger) + try + os.move(staging, cached, atomicMove = true, replaceExisting = false) + catch + case _: FileAlreadyExistsException | _: AtomicMoveNotSupportedException => + try os.move(staging, cached, replaceExisting = false) + catch case _: FileAlreadyExistsException => () + if os.exists(cached) then + logger.debug(s"Patched lazy vals in external class directory $dir -> $cached") + cached + else dir + finally if os.exists(staging) then os.remove.all(staging) + finally + if os.exists(tmpInput) then os.remove(tmpInput) + if os.exists(tmpOutput) then os.remove(tmpOutput) + + private def patchClassDir( + dir: os.Path, + hierarchyClassPath: Seq[os.Path], + source: SlothSource, + strictRequested: Boolean, + logger: Logger + ): os.Path = dir.orOriginalOnFailure(logger): val dirHash = sha1OfDir(dir) val cachedDir = cacheDir / Constants.slothVersion / "dirs" / dirHash @@ -288,7 +487,15 @@ object SlothPatcher: publishCached( cachedDir, cached, - out => runJarProcessor(tmpInput, out).map(_ => ()) + out => + runJarProcessor( + tmpInput, + out, + hierarchyClassPath, + source, + strictRequested, + logger + ).map(_ => ()) ) match case Right(cachedPath) => logger.debug(s"Patched lazy vals in class directory $dir -> $cachedPath") @@ -317,6 +524,23 @@ object SlothPatcher: jos.write(content) jos.closeEntry() + /** Content-addressed jar of a classpath directory, for deterministic packaging. */ + private def jarDirectoryCached(dir: os.Path): os.Path = + val cachedDir = cacheDir / Constants.slothVersion / "cp-entries" / sha1OfDir(dir) + val cached = cachedDir / s"${dir.last}.jar" + publishCached( + cachedDir, + cached, + out => + try + jarDirectory(dir, out) + Right(()) + catch case NonFatal(e) => Left(Option(e.getMessage).getOrElse(e.toString)) + ).getOrElse: + val dest = os.temp(prefix = "sloth-cp-entry-", suffix = ".jar", deleteOnExit = false) + jarDirectory(dir, dest) + dest + private def sha1OfDir(dir: os.Path): String = val md = MessageDigest.getInstance("SHA-1") val files = os.walk(dir).filter(os.isFile).sorted @@ -431,7 +655,8 @@ object SlothPatcher: private def withOriginalFallback[T](subject: String, original: => T, logger: Logger)(f: => T): T = try f catch - case NonFatal(e) => + case e: SlothHierarchyError => throw e + case NonFatal(e) => logger.message( s"$warnPrefix ${WarningMessages.slothCouldNotPatch( subject, @@ -444,7 +669,14 @@ object SlothPatcher: private def orOriginalOnFailure(logger: Logger)(patch: => os.Path): os.Path = withOriginalFallback(path.toString, path, logger)(patch) - private def patchJar(jar: os.Path, zipOffset: Long, logger: Logger): os.Path = + private def patchJar( + jar: os.Path, + zipOffset: Long, + hierarchyClassPath: Seq[os.Path], + source: SlothSource, + strictRequested: Boolean, + logger: Logger + ): os.Path = jar.orOriginalOnFailure(logger): val jarHash = sha1(jar) val cachedDir = cacheDir / Constants.slothVersion / jarHash @@ -475,7 +707,14 @@ object SlothPatcher: val tmpOutput = os.temp(prefix = "sloth-patch-", suffix = ".tmp", dir = cachedDir, deleteOnExit = false) try - runJarProcessor(payloadJar, tmpOutput) match + runJarProcessor( + payloadJar, + tmpOutput, + hierarchyClassPath, + source, + strictRequested, + logger + ) match case Left(message) => logger.message( s"$warnPrefix ${WarningMessages.slothCouldNotPatch(jar.toString, message)}" @@ -591,17 +830,50 @@ object SlothPatcher: private def runJarProcessor( input: os.Path, - output: os.Path + output: os.Path, + hierarchyClassPath: Seq[os.Path], + source: SlothSource, + strictRequested: Boolean, + logger: Logger ): Either[String, JarProcessor.JarResult] = try - val result = JarProcessor.process(input.toNIO, output.toNIO) - if result.errors.nonEmpty then - Left( - s"Failed to patch lazy vals in $input (${result.failedClasses} failed classes): ${result.errors.mkString("; ")}" + val hierarchyNio = hierarchyClassPath.map(_.toNIO) + val strictResult = + JarProcessor.process( + input.toNIO, + output.toNIO, + hierarchyClasspath = hierarchyNio, + strictHierarchy = true ) - else Right(result) + if strictResult.errors.isEmpty then Right(strictResult) + else + val details = strictResult.errors.mkString("; ") + source match + case SlothSource.Project => + Left(WarningMessages.slothUnresolvedHierarchyStrict(input.toString, details)) + case SlothSource.Dependency if strictRequested => + throw SlothHierarchyError( + WarningMessages.slothUnresolvedHierarchyStrict(input.toString, details) + ) + case SlothSource.Dependency => + logger.message( + s"$warnPrefix ${WarningMessages.slothUnresolvedHierarchy(input.toString, details)}" + ) + val lenientResult = + JarProcessor.process( + input.toNIO, + output.toNIO, + hierarchyClasspath = hierarchyNio, + strictHierarchy = false + ) + if lenientResult.errors.nonEmpty then + Left( + s"Failed to patch lazy vals in $input (${lenientResult.failedClasses} failed classes): ${lenientResult.errors.mkString("; ")}" + ) + else Right(lenientResult) catch - case NonFatal(e) => + case e: SlothHierarchyError => throw e + case NonFatal(e) => Left(s"Failed to patch lazy vals in $input: ${e.getMessage}") private def sha1(path: os.Path): String = diff --git a/modules/build/src/main/scala/scala/build/preprocessing/directives/DirectivesPreprocessingUtils.scala b/modules/build/src/main/scala/scala/build/preprocessing/directives/DirectivesPreprocessingUtils.scala index 861a4882f6..eb4892e6e7 100644 --- a/modules/build/src/main/scala/scala/build/preprocessing/directives/DirectivesPreprocessingUtils.scala +++ b/modules/build/src/main/scala/scala/build/preprocessing/directives/DirectivesPreprocessingUtils.scala @@ -33,6 +33,7 @@ object DirectivesPreprocessingUtils { directives.Sources.handler, directives.Sloth.handler, directives.SlothAgent.handler, + directives.SlothStrict.handler, directives.Watching.handler, directives.Tests.handler, directives.Wasm.handler diff --git a/modules/build/src/test/scala/scala/build/tests/DirectiveTests.scala b/modules/build/src/test/scala/scala/build/tests/DirectiveTests.scala index 6774dfd276..11af6ab131 100644 --- a/modules/build/src/test/scala/scala/build/tests/DirectiveTests.scala +++ b/modules/build/src/test/scala/scala/build/tests/DirectiveTests.scala @@ -584,4 +584,38 @@ class DirectiveTests extends TestUtil.ScalaCliBuildSuite { expect(build.options.notForBloopOptions.slothAgentOpt.contains(false)) } } + + for ( + directive <- Seq( + "slothStrict", + "sloth-strict", + "lazyvalgradeStrict", + "lazyvalgrade-strict", + "lazyValPatchingStrict", + "lazy-val-patching-strict" + ) + ) + test(s"slothStrict directive ($directive)") { + val testInputs = TestInputs( + os.rel / "simple.sc" -> + s"""//> using $directive + |""".stripMargin + ) + testInputs.withBuild(baseOptions, buildThreads, bloopConfigOpt) { (_, _, maybeBuild) => + val build = maybeBuild.orThrow + expect(build.options.notForBloopOptions.slothStrictOpt.contains(true)) + } + } + + test("slothStrict directive false") { + val testInputs = TestInputs( + os.rel / "simple.sc" -> + """//> using slothStrict false + |""".stripMargin + ) + testInputs.withBuild(baseOptions, buildThreads, bloopConfigOpt) { (_, _, maybeBuild) => + val build = maybeBuild.orThrow + expect(build.options.notForBloopOptions.slothStrictOpt.contains(false)) + } + } } diff --git a/modules/build/src/test/scala/scala/build/tests/SlothAgentTests.scala b/modules/build/src/test/scala/scala/build/tests/SlothAgentTests.scala index 8ca1fa25be..09db93753d 100644 --- a/modules/build/src/test/scala/scala/build/tests/SlothAgentTests.scala +++ b/modules/build/src/test/scala/scala/build/tests/SlothAgentTests.scala @@ -16,12 +16,14 @@ class SlothAgentTests extends TestUtil.ScalaCliBuildSuite: private def optionsWith( sloth: Boolean = false, - slothAgent: Boolean = false + slothAgent: Boolean = false, + slothStrict: Boolean = false ): BuildOptions = BuildOptions(notForBloopOptions = PostBuildOptions( slothOpt = Some(sloth).filter(identity), - slothAgentOpt = Some(slothAgent).filter(identity) + slothAgentOpt = Some(slothAgent).filter(identity), + slothStrictOpt = Some(slothStrict).filter(identity) ) ) @@ -38,6 +40,13 @@ class SlothAgentTests extends TestUtil.ScalaCliBuildSuite: assert(result.isRight, s"Expected Right but got $result") assert(result.toOption.get == agentJar, s"Expected $agentJar but got ${result.toOption.get}") + test("selectAgentJar accepts Ivy-style sloth-agent.jar without the version suffix"): + val ivyJar = os.root / "ivy2" / "local" / s"${Constants.slothAgentModuleName}.jar" + val artifacts = Seq((s"file://$ivyJar", ivyJar)) + val result = SlothAgent.selectAgentJar(artifacts) + assert(result.isRight, s"Expected Right but got $result") + assert(result.toOption.get == ivyJar) + test("selectAgentJar returns error when agent jar not found"): val decoyJar = os.root / "cache" / "asm-9.10.1.jar" val otherJar = os.root / "cache" / "some-other-lib-1.0.jar" @@ -66,3 +75,9 @@ class SlothAgentTests extends TestUtil.ScalaCliBuildSuite: SlothAgent.warnIfRedundantWithBatchPatching(optionsWith(sloth = true), logger) SlothAgent.warnIfRedundantWithBatchPatching(optionsWith(slothAgent = true), logger) expect(logger.messages.isEmpty) + + test("warnIfRedundantWithBatchPatching warns when slothStrict is set without patching"): + val logger = RecordingLogger() + SlothAgent.warnIfRedundantWithBatchPatching(optionsWith(slothStrict = true), logger) + expect(logger.messages.exists(_.contains(WarningMessages.slothStrictRequiresPatching))) + expect(logger.messages.exists(_.startsWith(warnPrefix))) diff --git a/modules/build/src/test/scala/scala/build/tests/SlothPatcherTests.scala b/modules/build/src/test/scala/scala/build/tests/SlothPatcherTests.scala index a3158792bd..505a04a810 100644 --- a/modules/build/src/test/scala/scala/build/tests/SlothPatcherTests.scala +++ b/modules/build/src/test/scala/scala/build/tests/SlothPatcherTests.scala @@ -1,9 +1,13 @@ package scala.build.tests +import java.io.File +import java.net.URLClassLoader +import java.nio.file.attribute.FileTime import java.util.concurrent.{Callable, CyclicBarrier, Executors} import java.util.jar.{Attributes as JarAttributes, JarOutputStream, Manifest as JarManifest} import java.util.zip.{ZipEntry, ZipFile} +import scala.build.internal.Constants import scala.build.internal.util.WarningMessages import scala.build.internals.ConsoleUtils.ScalaCliConsole.warnPrefix import scala.build.options.{BuildOptions, PostBuildOptions} @@ -13,14 +17,22 @@ import scala.util.Using class SlothPatcherTests extends TestUtil.ScalaCliBuildSuite: - private def optionsWithSloth(enabled: Boolean): BuildOptions = - BuildOptions(notForBloopOptions = PostBuildOptions(slothOpt = Some(enabled))) + private def optionsWithSloth(enabled: Boolean, strict: Boolean = false): BuildOptions = + BuildOptions(notForBloopOptions = + PostBuildOptions(slothOpt = Some(enabled), slothStrictOpt = Some(strict)) + ) test("transformClassPath returns unchanged when sloth disabled"): val logger = TestLogger() val classPath = Seq(os.pwd / "a.jar", os.pwd / "b.jar") val options = optionsWithSloth(enabled = false) - val result = SlothPatcher.transformClassPath(classPath, options, logger) + val result = SlothPatcher.transformClassPath( + classPath, + options, + logger, + patchProjectClassDirs = false, + projectClassDirs = Set.empty + ) assert(result.isRight) assert(result.toOption.get == classPath) @@ -193,7 +205,13 @@ class SlothPatcherTests extends TestUtil.ScalaCliBuildSuite: val txtFile = root / "readme.txt" os.write(txtFile, "hello") val options = optionsWithSloth(enabled = true) - val result = SlothPatcher.transformClassPath(Seq(txtFile), options, logger) + val result = SlothPatcher.transformClassPath( + Seq(txtFile), + options, + logger, + patchProjectClassDirs = false, + projectClassDirs = Set.empty + ) assert(result.isRight) assert(result.toOption.get == Seq(txtFile)) assert(logger.messages.isEmpty, s"Expected no message-level output, got: ${logger.messages}") @@ -211,7 +229,8 @@ class SlothPatcherTests extends TestUtil.ScalaCliBuildSuite: classPath, options, logger, - patchProjectClassDirs = false + patchProjectClassDirs = false, + projectClassDirs = Set(classDir) ) assert(result.isRight) assert(result.toOption.get == classPath) @@ -221,6 +240,7 @@ class SlothPatcherTests extends TestUtil.ScalaCliBuildSuite: val logger = TestLogger() val classDir = root / "classes" os.makeDir.all(classDir) + writeRealClassFile(classDir) os.write(classDir / "resource.txt", "test content") os.write(classDir / "sub" / "nested.txt", "nested content", createFolders = true) val classPath = Seq(classDir) @@ -229,7 +249,8 @@ class SlothPatcherTests extends TestUtil.ScalaCliBuildSuite: classPath, options, logger, - patchProjectClassDirs = true + patchProjectClassDirs = true, + projectClassDirs = Set(classDir) ) assert(result.isRight) val transformed = result.toOption.get @@ -258,12 +279,14 @@ class SlothPatcherTests extends TestUtil.ScalaCliBuildSuite: |X-Custom: yes |""".stripMargin ) + writeRealClassFile(classDir) val options = optionsWithSloth(enabled = true) val result = SlothPatcher.transformClassPath( Seq(classDir), options, logger, - patchProjectClassDirs = true + patchProjectClassDirs = true, + projectClassDirs = Set(classDir) ) assert(result.isRight, s"Expected Right, got: $result") val patchedPath = result.toOption.get.head @@ -282,6 +305,112 @@ class SlothPatcherTests extends TestUtil.ScalaCliBuildSuite: s"Expected X-Custom=yes in manifest" ) + test("transformClassPath patches external class dirs into cached directory copies"): + TestInputs.withTmpDir("sloth-external-dir-"): root => + val logger = TestLogger() + val externalDir = root / "cp" + val classFile = writeRealClassFile(externalDir) + val before = os.read.bytes(classFile) + val options = optionsWithSloth(enabled = true) + val result = SlothPatcher.transformClassPath( + Seq(externalDir), + options, + logger, + patchProjectClassDirs = false, + projectClassDirs = Set.empty + ) + assert(result.isRight, s"Expected Right, got: $result") + val transformed = result.toOption.get + assert(transformed.size == 1) + val patched = transformed.head + // Either left as-is (nothing to patch) or a cached directory under external-dirs + assert(os.isDir(patched), s"Expected directory, got: $patched") + assert( + java.util.Arrays.equals(os.read.bytes(classFile), before), + s"Expected original $classFile to be left untouched" + ) + if patched != externalDir then + assert( + patched.toString.contains("external-dirs"), + s"Expected external-dirs cache path, got: $patched" + ) + + test("transformClassPath skips resource directories without class files"): + TestInputs.withTmpDir("sloth-resources-"): root => + val logger = TestLogger() + val resourceDir = root / "resources" + os.write(resourceDir / "reference.conf", "answer = 42", createFolders = true) + val options = optionsWithSloth(enabled = true) + val result = SlothPatcher.transformClassPath( + Seq(resourceDir), + options, + logger, + patchProjectClassDirs = true, + projectClassDirs = Set(resourceDir) + ) + assert(result.isRight) + assert(result.toOption.get == Seq(resourceDir)) + + test("patchClassPathDirsInPlace leaves non-project directories alone"): + TestInputs.withTmpDir("sloth-inplace-external-"): root => + val logger = TestLogger() + val projectDir = root / "project" + val externalDir = root / "external" + val projectFile = writeRealClassFile(projectDir) + val externalFile = writeRealClassFile(externalDir) + val before = os.read.bytes(externalFile) + val classPath = Seq(projectDir, externalDir) + val result = SlothPatcher.patchClassPathDirsInPlace( + classPath, + optionsWithSloth(enabled = true), + logger, + shouldPatch = true, + projectClassDirs = Set(projectDir) + ) + assert(result.isRight, s"Expected Right, got: $result") + assert(result.toOption.get == classPath) + assert(SlothPatcher.wasPatchedInThisProcess(projectDir)) + assert(!SlothPatcher.wasPatchedInThisProcess(externalDir)) + assert(java.util.Arrays.equals(os.read.bytes(externalFile), before)) + assert(os.isFile(projectFile)) + + test("classPathEntryAsJar jars directories and patches when sloth is enabled"): + TestInputs.withTmpDir("sloth-as-jar-"): root => + val logger = TestLogger() + val dir = root / "cp" + writeRealClassFile(dir) + val result = SlothPatcher.classPathEntryAsJar(dir, optionsWithSloth(enabled = true), logger) + assert(result.isRight, s"Expected Right, got: $result") + val jar = result.toOption.get + assert(os.isFile(jar) && jar.ext == "jar", s"Expected jar file, got: $jar") + assert(jar.last == "cp.jar", s"Expected stable name cp.jar, got: ${jar.last}") + assert( + !jar.last.startsWith("sloth-cp-entry-"), + s"Expected content-addressed path, not temp name: $jar" + ) + val again = SlothPatcher.classPathEntryAsJar(dir, optionsWithSloth(enabled = true), logger) + assert(again.isRight, s"Expected Right, got: $again") + assert(again.toOption.get == jar, s"Expected same cached path across calls, got: $again") + + test("classPathEntryAsJar jars directories without sloth"): + TestInputs.withTmpDir("sloth-as-jar-off-"): root => + val logger = TestLogger() + val dir = root / "cp" + writeRealClassFile(dir) + val result = SlothPatcher.classPathEntryAsJar(dir, optionsWithSloth(enabled = false), logger) + assert(result.isRight, s"Expected Right, got: $result") + val jar = result.toOption.get + assert(os.isFile(jar) && jar.ext == "jar", s"Expected jar file, got: $jar") + assert(jar.last == "cp.jar", s"Expected stable name cp.jar, got: ${jar.last}") + assert( + !jar.last.startsWith("sloth-cp-entry-"), + s"Expected content-addressed path, not temp name: $jar" + ) + val again = + SlothPatcher.classPathEntryAsJar(dir, optionsWithSloth(enabled = false), logger) + assert(again.isRight, s"Expected Right, got: $again") + assert(again.toOption.get == jar, s"Expected same cached path across calls, got: $again") + /** Bytecode of a class from the test class path, so patching operates on real input. */ private def realClassFileBytes(className: String): Array[Byte] = val resourceName = className.replace('.', '/') + ".class" @@ -303,7 +432,8 @@ class SlothPatcherTests extends TestUtil.ScalaCliBuildSuite: classPath, optionsWithSloth(enabled = true), logger, - shouldPatch = true + shouldPatch = true, + projectClassDirs = Set(classDir) ) assert(result.isRight, s"Expected Right, got: $result") assert(result.toOption.get == classPath, s"Expected $classPath, got: ${result.toOption.get}") @@ -325,7 +455,8 @@ class SlothPatcherTests extends TestUtil.ScalaCliBuildSuite: classPath, optionsWithSloth(enabled = true), logger, - shouldPatch = true + shouldPatch = true, + projectClassDirs = Set.empty ) assert(result.isRight, s"Expected Right, got: $result") assert(result.toOption.get == classPath, s"Expected $classPath, got: ${result.toOption.get}") @@ -344,7 +475,8 @@ class SlothPatcherTests extends TestUtil.ScalaCliBuildSuite: classPath, optionsWithSloth(enabled = true), logger, - shouldPatch = true + shouldPatch = true, + projectClassDirs = Set.empty ) assert(result.isRight, s"Expected Right, got: $result") assert(result.toOption.get == classPath, s"Expected $classPath, got: ${result.toOption.get}") @@ -363,7 +495,8 @@ class SlothPatcherTests extends TestUtil.ScalaCliBuildSuite: classPath, optionsWithSloth(enabled = false), logger, - shouldPatch = true + shouldPatch = true, + projectClassDirs = Set(classDir) ) assert(result.isRight, s"Expected Right, got: $result") assert(result.toOption.get == classPath, s"Expected $classPath, got: ${result.toOption.get}") @@ -382,7 +515,8 @@ class SlothPatcherTests extends TestUtil.ScalaCliBuildSuite: classPath, optionsWithSloth(enabled = true), logger, - shouldPatch = false + shouldPatch = false, + projectClassDirs = Set(classDir) ) assert(result.isRight, s"Expected Right, got: $result") assert(result.toOption.get == classPath, s"Expected $classPath, got: ${result.toOption.get}") @@ -637,7 +771,13 @@ class SlothPatcherTests extends TestUtil.ScalaCliBuildSuite: assert(SlothPatcher.zipStartOffset(corruptJar).contains(0L)) val options = optionsWithSloth(enabled = true) val classPath = Seq(goodJar, corruptJar) - val result = SlothPatcher.transformClassPath(classPath, options, logger) + val result = SlothPatcher.transformClassPath( + classPath, + options, + logger, + patchProjectClassDirs = false, + projectClassDirs = Set.empty + ) assert(result.isRight, s"Expected Right, got: $result") val transformed = result.toOption.get assert(transformed.size == 2, s"Expected 2 entries, got: $transformed") @@ -683,3 +823,279 @@ class SlothPatcherTests extends TestUtil.ScalaCliBuildSuite: (os.isDir(p) && os.list(p).exists(_.last.startsWith("sloth-entries-"))) } assert(leaked.isEmpty, s"Leaked temp entries: $leaked") + + // --- Hierarchy classpath resolution (native-image-safe getCommonSuperClass) --- + + /** Sources for a jar whose types are *not* on the test JVM classpath. lib-b holds Base/SubA/SubB; + * lib-a holds a lazy val whose initializer merges SubA and SubB, forcing ASM COMPUTE_FRAMES to + * call getCommonSuperClass(SubA, SubB) while patching. + */ + private val hierarchyFixtureInputs: TestInputs = TestInputs( + os.rel / "libb" / "Base.scala" -> + """package sloth.hierarchy + | + |class Base + |class SubA extends Base + |class SubB extends Base + |""".stripMargin, + os.rel / "liba" / "Lazies.scala" -> + """package sloth.hierarchy + | + |object Lazies: + | // Lazy val so Sloth rewrites the class (COMPUTE_FRAMES for *all* methods). + | lazy val x: Base = + | if sys.props.contains("sloth.hierarchy.useA") then new SubA else new SubB + | def get: Base = x + | // Non-lazy method returning Base with a SubA/SubB merge into a local (not + | // early-return). When ASM cannot resolve SubA/SubB, getCommonSuperClass + | // falls back to Object and this method VerifyErrors on areturn of Base. + | def pick(useA: Boolean): Base = + | val chosen: Base = if useA then new SubA else new SubB + | chosen + |""".stripMargin + ) + + private lazy val scala33CompilerClasspath: Seq[os.Path] = + val out = os.proc( + "cs", + "fetch", + s"org.scala-lang:scala3-compiler_3:${Constants.scala3Lts}" + ).call().out.trim() + out.split(System.lineSeparator()).toSeq.filter(_.nonEmpty).map(os.Path(_)) + + private def jarDirectory(dir: os.Path, dest: os.Path): Unit = + val manifest = JarManifest() + manifest.getMainAttributes.put(JarAttributes.Name.MANIFEST_VERSION, "1.0") + Using.resource(JarOutputStream(os.write.outputStream(dest), manifest)): jos => + for + path <- os.walk(dir) + if os.isFile(path) + do + val relativePath = path.relativeTo(dir).toString.replace('\\', '/') + val entry = ZipEntry(relativePath) + entry.setLastModifiedTime(FileTime.fromMillis(os.mtime(path))) + val content = os.read.bytes(path) + entry.setSize(content.length) + jos.putNextEntry(entry) + jos.write(content) + jos.closeEntry() + + /** Compile `srcDir` with Scala 3 LTS (pre-3.8 lazy vals) against `extraCp`, jar into `destJar`. + */ + private def compileScala33ToJar( + root: os.Path, + srcDir: os.Path, + destJar: os.Path, + extraCp: Seq[os.Path] = Nil + ): Unit = + val outDir = root / s"${srcDir.last}-classes" + os.makeDir.all(outDir) + val sources = os.walk(srcDir).filter(p => p.ext == "scala" && os.isFile(p)) + assert(sources.nonEmpty, s"Expected sources under $srcDir") + // Compiler boot CP and user -classpath both need scala3-library; extraCp is layered on top. + val compilerCp = scala33CompilerClasspath.mkString(File.pathSeparator) + val userCp = (scala33CompilerClasspath ++ extraCp).mkString(File.pathSeparator) + val res = os.proc( + "java", + "-cp", + compilerCp, + "dotty.tools.dotc.Main", + "-d", + outDir.toString, + "-classpath", + userCp, + sources.map(_.toString) + ).call(cwd = root, check = false, mergeErrIntoOut = true) + assert( + res.exitCode == 0, + s"Failed to compile $srcDir with Scala ${Constants.scala3Lts}:\n${res.out.text()}" + ) + jarDirectory(outDir, destJar) + + /** Build lib-a.jar (lazy vals) and lib-b.jar (hierarchy types) under `root`. */ + private def buildHierarchyFixtureJars(root: os.Path): (os.Path, os.Path) = + val libB = root / "lib-b.jar" + val libA = root / "lib-a.jar" + compileScala33ToJar(root, root / "libb", libB) + compileScala33ToJar(root, root / "liba", libA, extraCp = Seq(libB)) + (libA, libB) + + /** Load `sloth.hierarchy.Lazies$` from the given jars and force lazy-val initialization. */ + private def forceInitLazies(jars: Seq[os.Path]): Unit = + // scala3-library (and scala-library) come from the compiler fetch; platform CL has neither. + val scalaLibs = scala33CompilerClasspath.filter { p => + val n = p.last + n.startsWith("scala3-library") || n.startsWith("scala-library") + } + val urls = (jars ++ scalaLibs).map(_.toNIO.toUri.toURL).toArray + Using.resource(URLClassLoader(urls, ClassLoader.getPlatformClassLoader)): cl => + // Class.forName verifies all methods; pick()'s SubA/SubB merge is the VerifyError trigger + // when those types were invisible to Sloth's COMPUTE_FRAMES. + val cls = Class.forName("sloth.hierarchy.Lazies$", true, cl) + val module = cls.getField("MODULE$").get(null) + cls.getMethod("get").invoke(module) + cls.getMethod("pick", classOf[Boolean]).invoke(module, java.lang.Boolean.FALSE) + () + + test("transformClassPath keeps hierarchy frames valid across jars"): + hierarchyFixtureInputs.fromRoot: root => + val (libA, libB) = buildHierarchyFixtureJars(root) + // Precondition: unpatched jars load and initialize fine. + forceInitLazies(Seq(libA, libB)) + + val logger = TestLogger() + val options = optionsWithSloth(enabled = true) + val result = SlothPatcher.transformClassPath( + Seq(libA, libB), + options, + logger, + patchProjectClassDirs = false, + projectClassDirs = Set.empty + ) + assert(result.isRight, s"Expected Right, got: $result") + val transformed = result.toOption.get + assert(transformed.size == 2, s"Expected 2 entries, got: $transformed") + val patchedA = transformed.head + assert( + patchedA != libA, + s"Expected lib-a.jar to be rewritten (it has lazy vals), got unchanged: $patchedA" + ) + // Loading the patched lib-a against lib-b must not throw VerifyError. Today JarProcessor + // only sees lib-a, so getCommonSuperClass(SubA, SubB) falls back to Object and this fails. + forceInitLazies(Seq(patchedA, libB)) + + test("dependency jar without hierarchy warns and still patches"): + hierarchyFixtureInputs.fromRoot: root => + val (libA, _) = buildHierarchyFixtureJars(root) + val logger = RecordingLogger() + val options = optionsWithSloth(enabled = true) + // Dependency source, hierarchy classpath omits lib-b so SubA/SubB are unresolvable. + val result = SlothPatcher.patchJarFile( + libA, + options, + logger, + hierarchyClassPath = Nil, + source = SlothPatcher.SlothSource.Dependency + ) + assert(result.isRight, s"Expected Right, got: $result") + val patched = result.toOption.get + assert(patched != libA, s"Expected lib-a to be rewritten, got unchanged: $patched") + assert( + logger.messages.exists(_.contains("could not resolve class hierarchies")), + s"Expected hierarchy warning, got: ${logger.messages}" + ) + assert( + logger.messages.exists(_.contains("--sloth-strict")), + s"Expected --sloth-strict hint, got: ${logger.messages}" + ) + + test("dependency jar without hierarchy fails under slothStrict"): + hierarchyFixtureInputs.fromRoot: root => + val (libA, _) = buildHierarchyFixtureJars(root) + val logger = RecordingLogger() + val options = optionsWithSloth(enabled = true, strict = true) + val result = SlothPatcher.patchJarFile( + libA, + options, + logger, + hierarchyClassPath = Nil, + source = SlothPatcher.SlothSource.Dependency + ) + assert(result.isLeft, s"Expected Left under slothStrict, got: $result") + val err = result.swap.toOption.get.getMessage + assert( + err.contains("could not resolve class hierarchies") || err.contains("Cannot resolve class"), + s"Expected hierarchy error message, got: $err" + ) + + test("project bytecode without hierarchy is left unpatched"): + hierarchyFixtureInputs.fromRoot: root => + val (libA, _) = buildHierarchyFixtureJars(root) + val logger = RecordingLogger() + val options = optionsWithSloth(enabled = true) + val result = SlothPatcher.patchJarFile( + libA, + options, + logger, + hierarchyClassPath = Nil, + source = SlothPatcher.SlothSource.Project + ) + assert(result.isRight, s"Expected Right, got: $result") + val patched = result.toOption.get + assert( + patched == libA, + s"Expected project jar to stay unpatched when hierarchy is incomplete, got: $patched" + ) + assert( + logger.messages.exists(m => + m.contains("could not resolve class hierarchies") || m.contains("using original") + ), + s"Expected hierarchy / using-original warning, got: ${logger.messages}" + ) + + test("transformClassPath keeps ZipFile frames in scala3-compiler openZipFile"): + // FileZipArchive.openZipFile merges JarFile and ZipFile; a broken JDK Class.forName path + // under native image collapses that merge to Object and VerifyErrors. Assert the patched + // frames stay ZipFile when hierarchy includes scala3-library. + TestInputs.withTmpDir("sloth-compiler-frames-"): _ => + val compilerJar = scala33CompilerClasspath + .find(_.last.startsWith("scala3-compiler")) + .getOrElse(sys.error("scala3-compiler jar not found on compiler classpath")) + val logger = TestLogger() + val options = optionsWithSloth(enabled = true) + val result = SlothPatcher.transformClassPath( + scala33CompilerClasspath, + options, + logger, + patchProjectClassDirs = false, + projectClassDirs = Set.empty + ) + assert(result.isRight, s"Expected Right, got: $result") + val patchedCompiler = result.toOption.get + .find(_.last.startsWith("scala3-compiler")) + .getOrElse(sys.error(s"No scala3-compiler in ${result.toOption.get}")) + assert( + patchedCompiler != compilerJar, + s"Expected scala3-compiler to be rewritten, got unchanged: $patchedCompiler" + ) + val openZipFrames = stackMapTypesAtMerge( + patchedCompiler, + "dotty/tools/io/FileZipArchive.class", + "openZipFile" + ) + assert( + openZipFrames.exists(_.contains("java/util/zip/ZipFile")), + s"Expected ZipFile on openZipFile stack map frames, got: $openZipFrames" + ) + assert( + !openZipFrames.exists(f => f.contains("java/lang/Object") && !f.contains("ZipFile")), + s"openZipFile frames must not collapse JarFile/ZipFile merge to Object: $openZipFrames" + ) + + /** Stack-map stack types from frames of `methodNameSubstring` inside a class entry of `jar`. */ + private def stackMapTypesAtMerge( + jar: os.Path, + classEntry: String, + methodNameSubstring: String + ): Seq[String] = + import org.objectweb.asm.ClassReader + import org.objectweb.asm.tree.{ClassNode, FrameNode, LabelNode} + Using.resource(ZipFile(jar.toIO)): zf => + val entry = zf.getEntry(classEntry) + assert(entry != null, s"Missing $classEntry in $jar") + val bytes = Using.resource(zf.getInputStream(entry))(_.readAllBytes()) + val cn = ClassNode() + ClassReader(bytes).accept(cn, 0) + val method = cn.methods.asScala + .find(_.name.contains(methodNameSubstring)) + .getOrElse(sys.error(s"No method *$methodNameSubstring* in $classEntry")) + method.instructions.toArray.toSeq.collect { + case f: FrameNode => + Option(f.stack).map(_.asScala.map { + case null => "null" + case s: String => s + case n: Integer => s"int($n)" + case _: LabelNode => "label" + case other => String.valueOf(other) + }.mkString(",")).getOrElse("-") + } diff --git a/modules/cli/src/main/scala/scala/cli/commands/compile/Compile.scala b/modules/cli/src/main/scala/scala/cli/commands/compile/Compile.scala index 6ed64e28a2..8a8fedbae1 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/compile/Compile.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/compile/Compile.scala @@ -96,7 +96,8 @@ object Compile extends ScalaCommand[CompileOptions] with BuildCommandHelpers { s.output, s.options, logger, - shouldPatch = shouldPatchProject + shouldPatch = shouldPatchProject, + hierarchyClassPath = s.fullClassPath ).left ) do logger.exit(ex) @@ -114,7 +115,8 @@ object Compile extends ScalaCommand[CompileOptions] with BuildCommandHelpers { rawCp, s.options, logger, - patchProjectClassDirs = false + patchProjectClassDirs = false, + projectClassDirs = successfulBuilds.map(_.output).toSet ).orExit(logger) .map(_.toString) .mkString(File.pathSeparator) diff --git a/modules/cli/src/main/scala/scala/cli/commands/doc/Doc.scala b/modules/cli/src/main/scala/scala/cli/commands/doc/Doc.scala index aa7cc96898..60648fb405 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/doc/Doc.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/doc/Doc.scala @@ -258,7 +258,8 @@ object Doc extends ScalaCommand[DocOptions] with BuildCommandHelpers { userClassPath0, builds.head.options, logger, - patchProjectClassDirs = SlothPatcher.shouldPatchProjectClasses(builds) + patchProjectClassDirs = SlothPatcher.shouldPatchProjectClasses(builds), + projectClassDirs = builds.map(_.output).toSet )) else userClassPath0 val baseArgs = Seq( diff --git a/modules/cli/src/main/scala/scala/cli/commands/fix/ScalafixRules.scala b/modules/cli/src/main/scala/scala/cli/commands/fix/ScalafixRules.scala index 4800670bf6..8ccb2022a2 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/fix/ScalafixRules.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/fix/ScalafixRules.scala @@ -92,7 +92,8 @@ object ScalafixRules extends CommandHelpers { classPaths0, buildOptions, logger, - patchProjectClassDirs = SlothPatcher.shouldPatchProjectClasses(successfulBuilds) + patchProjectClassDirs = SlothPatcher.shouldPatchProjectClasses(successfulBuilds), + projectClassDirs = successfulBuilds.map(_.output).toSet ) ) val artifacts = diff --git a/modules/cli/src/main/scala/scala/cli/commands/package0/Package.scala b/modules/cli/src/main/scala/scala/cli/commands/package0/Package.scala index 957dcd3428..7c6bf71d62 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/package0/Package.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/package0/Package.scala @@ -459,7 +459,13 @@ object Package extends ScalaCommand[PackageOptions] with BuildCommandHelpers { case PackageType.LibraryJar => val libraryJar0 = Library.libraryJar(builds, mainClassOpt) val libraryJar = value( - SlothPatcher.patchJarFile(libraryJar0, builds.head.options, logger) + SlothPatcher.patchJarFile( + libraryJar0, + builds.head.options, + logger, + hierarchyClassPath = builds.flatMap(_.fullClassPath).distinct, + source = SlothPatcher.SlothSource.Project + ) ) value(alreadyExistsCheck()) if force then os.copy.over(libraryJar, destPath, createFolders = true) @@ -902,8 +908,15 @@ object Package extends ScalaCommand[PackageOptions] with BuildCommandHelpers { byteCodeZipEntries.partition((entry, _) => JarManifests.isManifestEntry(entry.getName)) val baseManifestOpt = manifestEntries.headOption.map(_._2) + val hierarchyCp = builds.flatMap(_.fullClassPath).distinct + val patchedByteCodeZipEntries = value( - SlothPatcher.patchByteCodeZipEntries(nonManifestEntries, options, logger) + SlothPatcher.patchByteCodeZipEntries( + nonManifestEntries, + options, + logger, + hierarchyClassPath = hierarchyCp + ) ) // TODO Generate that in memory @@ -920,12 +933,10 @@ object Package extends ScalaCommand[PackageOptions] with BuildCommandHelpers { builds.flatMap(_.artifacts.artifacts).distinct.map { case (url, path) => if options.notForBloopOptions.packageOptions.isStandalone then - val patchedPath = value(SlothPatcher.patchJarFile(path, options, logger)) - ClassPathEntry.Resource( - patchedPath.last, - os.mtime(patchedPath), - os.read.bytes(patchedPath) + val patchedPath = value( + SlothPatcher.patchJarFile(path, options, logger, hierarchyClassPath = hierarchyCp) ) + classPathResource(path, patchedPath) else if options.notForBloopOptions.sloth then logger.message( @@ -935,12 +946,15 @@ object Package extends ScalaCommand[PackageOptions] with BuildCommandHelpers { } val byteCodeEntry = ClassPathEntry.Resource(s"${destPath.last}-content.jar", 0L, tmpJarContent) val extraClassPath = builds.head.options.classPathOptions.extraClassPath.map { classPath => - val patchedPath = value(SlothPatcher.patchJarFile(classPath, options, logger)) - ClassPathEntry.Resource( - patchedPath.last, - os.mtime(patchedPath), - os.read.bytes(patchedPath) + val patchedPath = value( + SlothPatcher.classPathEntryAsJar( + classPath, + options, + logger, + hierarchyClassPath = hierarchyCp + ) ) + classPathResource(classPath, patchedPath) } val allEntries = Seq(byteCodeEntry) ++ dependencyEntries ++ extraClassPath @@ -985,6 +999,13 @@ object Package extends ScalaCommand[PackageOptions] with BuildCommandHelpers { ProcUtil.maybeUpdatePreamble(destPath) } + /** Resource entry for a bootstrap classpath jar. Name comes from the original entry (with a + * `.jar` suffix for directories); mtime is fixed at 0 for determinism. + */ + private def classPathResource(original: os.Path, patched: os.Path): ClassPathEntry.Resource = + val name = if os.isDir(original) then s"${original.last}.jar" else original.last + ClassPathEntry.Resource(name, 0L, os.read.bytes(patched)) + /** Returns the dependency sub-graph of the provided modules, that is, all their JARs and their * transitive dependencies' JARs. * @@ -1121,7 +1142,15 @@ object Package extends ScalaCommand[PackageOptions] with BuildCommandHelpers { .withPreambleOpt(preambleOpt) value(alreadyExistsCheck()) AssemblyGenerator.generate(params, destPath.toNIO) - val patchedDest = value(SlothPatcher.patchJarFile(destPath, options, logger)) + val patchedDest = value( + SlothPatcher.patchJarFile( + destPath, + options, + logger, + hierarchyClassPath = (destPath +: jars).distinct, + source = SlothPatcher.SlothSource.Project + ) + ) if patchedDest != destPath then os.copy.over(patchedDest, destPath, createFolders = true) ProcUtil.maybeUpdatePreamble(destPath) } diff --git a/modules/cli/src/main/scala/scala/cli/commands/publish/Publish.scala b/modules/cli/src/main/scala/scala/cli/commands/publish/Publish.scala index 279ade5f7e..fdeed4d9f2 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/publish/Publish.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/publish/Publish.scala @@ -518,7 +518,13 @@ object Publish extends ScalaCommand[PublishOptions] with BuildCommandHelpers { logger.debug(s"Retained main class: ${mainClassOpt.getOrElse("(no main class found)")}") val libraryJar0: os.Path = Library.libraryJar(builds, mainClassOpt) val libraryJar: os.Path = value( - SlothPatcher.patchJarFile(libraryJar0, builds.head.options, logger) + SlothPatcher.patchJarFile( + libraryJar0, + builds.head.options, + logger, + hierarchyClassPath = builds.flatMap(_.fullClassPath).distinct, + source = SlothPatcher.SlothSource.Project + ) ) val dest: os.Path = workingDir / org / s"$moduleName-$ver.jar" logger.debug(s"Copying library jar from $libraryJar to $dest...") diff --git a/modules/cli/src/main/scala/scala/cli/commands/repl/Repl.scala b/modules/cli/src/main/scala/scala/cli/commands/repl/Repl.scala index 89def6b072..87c1c93deb 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/repl/Repl.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/repl/Repl.scala @@ -356,7 +356,8 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { classPath, options, logger, - patchProjectClassDirs = SlothPatcher.shouldPatchProjectClasses(successfulBuilds) + patchProjectClassDirs = SlothPatcher.shouldPatchProjectClasses(successfulBuilds), + projectClassDirs = successfulBuilds.map(_.output).toSet )) def maybeRunRepl( diff --git a/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala b/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala index 7643166775..7cf70276b7 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala @@ -720,7 +720,8 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers { classPath0, build.options, logger, - patchProjectClassDirs = SlothPatcher.shouldPatchProjectClasses(builds) + patchProjectClassDirs = SlothPatcher.shouldPatchProjectClasses(builds), + projectClassDirs = builds.map(_.output).toSet ) ) val (pythonJavaProps, pythonExtraEnv) = diff --git a/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala index d3ac9066ae..fbbd7548b2 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala @@ -222,6 +222,13 @@ final case class SharedOptions( @Name("lazyvalgrade-agent") @Name("patch-lazy-vals-with-agent") slothAgent: Option[Boolean] = None, + @HelpMessage( + "Fail when Sloth cannot resolve class hierarchies while patching dependency jars (requires --sloth or --sloth-agent)" + ) + @Tag(tags.experimental) + @Name("lazyvalgrade-strict") + @Name("patch-lazy-vals-strict") + slothStrict: Option[Boolean] = None, @Group(HelpGroup.Scala.toString) @HelpMessage( "Automatically generate BSP configuration in `.bsp/` when running build commands. Enabled by default." @@ -504,7 +511,8 @@ final case class SharedOptions( pythonSetup = sharedPython.pythonSetup, scalaPyVersion = sharedPython.scalaPyVersion, slothOpt = sloth, - slothAgentOpt = slothAgent + slothAgentOpt = slothAgent, + slothStrictOpt = slothStrict ), useBuildServer = compilationServer.server ).orElse(watchOptions.buildOptions()) diff --git a/modules/cli/src/main/scala/scala/cli/commands/test/Test.scala b/modules/cli/src/main/scala/scala/cli/commands/test/Test.scala index 633efc1be0..e4cefcbac0 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/test/Test.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/test/Test.scala @@ -100,6 +100,8 @@ object Test extends ScalaCommand[TestOptions] { val builds0 = optionsKeys.flatMap { optionsKey => builds.map.get(CrossKey(optionsKey, Scope.Test)) } + val projectClassDirs = + builds.all.collect { case s: Build.Successful => s.output }.toSet val buildsLen = builds0.length val printBeforeAfterMessages = buildsLen > 1 && options.shared.logging.verbosity >= 0 @@ -119,7 +121,8 @@ object Test extends ScalaCommand[TestOptions] { args.unparsed, logger, allowExecve = allowExit && buildsLen <= 1, - asJar = options.shared.asJar + asJar = options.shared.asJar, + projectClassDirs = projectClassDirs ) if (printBeforeAfterMessages && idx < buildsLen - 1) System.err.println() @@ -191,7 +194,8 @@ object Test extends ScalaCommand[TestOptions] { args: Seq[String], logger: Logger, asJar: Boolean, - allowExecve: Boolean + allowExecve: Boolean, + projectClassDirs: Set[os.Path] ): Either[BuildException, Int] = either { val predefinedTestFrameworks = build.options.testOptions.frameworks @@ -267,12 +271,14 @@ object Test extends ScalaCommand[TestOptions] { val classPath0 = build.fullClassPathMaybeAsJar(asJar) // The test runner only discovers suites in directory class path entries, so project // classes have to stay directories; only dependency jars may be swapped for patched copies. + // External class directories are patched into cached directory copies by transformClassPath. val classPath1 = value( SlothPatcher.patchClassPathDirsInPlace( classPath0, build.options, logger, - shouldPatch = SlothPatcher.shouldPatchProjectClasses(Seq(build)) + shouldPatch = SlothPatcher.shouldPatchProjectClasses(Seq(build)), + projectClassDirs = projectClassDirs ) ) val classPath = value( @@ -280,7 +286,8 @@ object Test extends ScalaCommand[TestOptions] { classPath1, build.options, logger, - patchProjectClassDirs = false + patchProjectClassDirs = false, + projectClassDirs = projectClassDirs ) ) diff --git a/modules/cli/src/main/scala/scala/cli/commands/util/BuildCommandHelpers.scala b/modules/cli/src/main/scala/scala/cli/commands/util/BuildCommandHelpers.scala index dce6fad6f6..a0861487c7 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/util/BuildCommandHelpers.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/util/BuildCommandHelpers.scala @@ -50,6 +50,7 @@ object BuildCommandHelpers { .orElse(sharedOptions.scalacOptions.getScalacOption("-d")) .filter(_.nonEmpty) .map(os.Path(_, Os.pwd)).foreach { output => + val destExisted = os.exists(output) os.copy( successfulBuild.output, output, @@ -57,7 +58,10 @@ object BuildCommandHelpers { mergeFolders = true, replaceExisting = true ) - if SlothPatcher.wasPatchedInThisProcess(successfulBuild.output) then + // Skip re-patching only when the destination is a fresh copy of an already-patched + // source. If the dest already existed (merge), it may hold older bytecode that still + // needs patching regardless of the project's Scala version. + if !destExisted && SlothPatcher.wasPatchedInThisProcess(successfulBuild.output) then logger.debug( s"Skipping Sloth patch of $output: source ${successfulBuild.output} already patched" ) @@ -67,7 +71,9 @@ object BuildCommandHelpers { output, successfulBuild.options, logger, - shouldPatch = SlothPatcher.shouldPatchProjectClasses(Seq(successfulBuild)) + shouldPatch = + destExisted || SlothPatcher.shouldPatchProjectClasses(Seq(successfulBuild)), + hierarchyClassPath = successfulBuild.fullClassPath ).left do logger.exit(ex) } diff --git a/modules/cli/src/main/scala/scala/cli/commands/util/RunSpark.scala b/modules/cli/src/main/scala/scala/cli/commands/util/RunSpark.scala index 2d8cb9304a..68ea15a6ab 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/util/RunSpark.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/util/RunSpark.scala @@ -36,11 +36,17 @@ object RunSpark { val providedModules = Spark.sparkModules val providedFiles = value(PackageCmd.providedFiles(builds, providedModules, logger)).toSet - val depCp0 = builds.flatMap(_.dependencyClassPath).distinct.filterNot(providedFiles) - val depCp = value(SlothPatcher.transformClassPath(depCp0, builds.head.options, logger)) - val javaHomeInfo = builds.head.options.javaHome().value - val javaOpts = builds.head.options.javaOptions.javaOpts.toSeq.map(_.value.value) - val ext = if Properties.isWin then ".cmd" else "" + val depCp0 = builds.flatMap(_.dependencyClassPath).distinct.filterNot(providedFiles) + val depCp = value(SlothPatcher.transformClassPath( + depCp0, + builds.head.options, + logger, + patchProjectClassDirs = false, + projectClassDirs = Set.empty + )) + val javaHomeInfo = builds.head.options.javaHome().value + val javaOpts = builds.head.options.javaOptions.javaOpts.toSeq.map(_.value.value) + val ext = if Properties.isWin then ".cmd" else "" val submitCommand: String = EnvVar.Spark.sparkHome.valueOpt .map(os.Path(_, os.pwd)) @@ -55,7 +61,13 @@ object RunSpark { scratchDirOpt.foreach(os.makeDir.all(_)) val library = Library.libraryJar(builds) val patchedLibrary = - value(SlothPatcher.patchJarFile(library, builds.head.options, logger)) + value(SlothPatcher.patchJarFile( + library, + builds.head.options, + logger, + hierarchyClassPath = library +: depCp0, + source = SlothPatcher.SlothSource.Project + )) val finalCommand = Seq(submitCommand, "--class", mainClass) ++ @@ -102,14 +114,26 @@ object RunSpark { scratchDirOpt.foreach(os.makeDir.all(_)) val library = Library.libraryJar(builds) val patchedLibrary = - value(SlothPatcher.patchJarFile(library, builds.head.options, logger)) + value(SlothPatcher.patchJarFile( + library, + builds.head.options, + logger, + hierarchyClassPath = builds.flatMap(_.fullClassPath).distinct, + source = SlothPatcher.SlothSource.Project + )) val finalMainClass = "org.apache.spark.deploy.SparkSubmit" val depCp0 = builds.flatMap(_.dependencyClassPath).distinct.filterNot(sparkClassPath.toSet) - val depCp = value(SlothPatcher.transformClassPath(depCp0, builds.head.options, logger)) - val javaHomeInfo = builds.head.options.javaHome().value - val baseJavaOpts = builds.head.options.javaOptions.javaOpts.toSeq.map(_.value.value) + val depCp = value(SlothPatcher.transformClassPath( + depCp0, + builds.head.options, + logger, + patchProjectClassDirs = false, + projectClassDirs = Set.empty + )) + val javaHomeInfo = builds.head.options.javaHome().value + val baseJavaOpts = builds.head.options.javaOptions.javaOpts.toSeq.map(_.value.value) val slothAgentJavaOpts = value(SlothAgent.javaAgentArgs(builds.head.options, logger)) val javaOpts = slothAgentJavaOpts ++ baseJavaOpts diff --git a/modules/cli/src/main/scala/scala/cli/internal/CachedBinary.scala b/modules/cli/src/main/scala/scala/cli/internal/CachedBinary.scala index bba33dd993..5db95c9869 100644 --- a/modules/cli/src/main/scala/scala/cli/internal/CachedBinary.scala +++ b/modules/cli/src/main/scala/scala/cli/internal/CachedBinary.scala @@ -81,6 +81,8 @@ object CachedBinary { for build <- builds do md.update(build.options.notForBloopOptions.sloth.toString.getBytes(charset)) md.update(0: Byte) + md.update(build.options.notForBloopOptions.slothStrict.toString.getBytes(charset)) + md.update(0: Byte) md.update("".getBytes(charset)) for (h <- builds.map(_.options).reduce(_.orElse(_)).hash) { md.update(h.getBytes(charset)) diff --git a/modules/cli/src/main/scala/scala/cli/packaging/NativeImage.scala b/modules/cli/src/main/scala/scala/cli/packaging/NativeImage.scala index 88a0f3fc5b..9699a95dae 100644 --- a/modules/cli/src/main/scala/scala/cli/packaging/NativeImage.scala +++ b/modules/cli/src/main/scala/scala/cli/packaging/NativeImage.scala @@ -100,11 +100,25 @@ object NativeImage { ) if cacheData.changed then { - val mainJar0 = Library.libraryJar(builds) - val mainJar = value(SlothPatcher.patchJarFile(mainJar0, options, logger)) - val deps = builds.flatMap(_.dependencyClassPath).distinct + val mainJar0 = Library.libraryJar(builds) + val deps = builds.flatMap(_.dependencyClassPath).distinct + val mainJar = value( + SlothPatcher.patchJarFile( + mainJar0, + options, + logger, + hierarchyClassPath = mainJar0 +: deps, + source = SlothPatcher.SlothSource.Project + ) + ) val originalClassPath = - mainJar +: value(SlothPatcher.transformClassPath(deps, options, logger)) + mainJar +: value(SlothPatcher.transformClassPath( + deps, + options, + logger, + patchProjectClassDirs = false, + projectClassDirs = Set.empty + )) ManifestJar.maybeWithManifestClassPath( createManifest = Properties.isWin, diff --git a/modules/cli/src/test/scala/cli/commands/tests/RunOptionsTests.scala b/modules/cli/src/test/scala/cli/commands/tests/RunOptionsTests.scala index 9f7c442e71..2e4284f826 100644 --- a/modules/cli/src/test/scala/cli/commands/tests/RunOptionsTests.scala +++ b/modules/cli/src/test/scala/cli/commands/tests/RunOptionsTests.scala @@ -70,4 +70,14 @@ class RunOptionsTests extends munit.FunSuite { val buildOptions = Run.buildOptions(runOptions).value expect(buildOptions.notForBloopOptions.slothAgentOpt.contains(true)) } + + test("sloth-strict option") { + val runOptions = RunOptions( + shared = SharedOptions( + slothStrict = Some(true) + ) + ) + val buildOptions = Run.buildOptions(runOptions).value + expect(buildOptions.notForBloopOptions.slothStrictOpt.contains(true)) + } } diff --git a/modules/cli/src/test/scala/cli/tests/CachedBinaryTests.scala b/modules/cli/src/test/scala/cli/tests/CachedBinaryTests.scala index b33204e9ac..fa4e93bf0b 100644 --- a/modules/cli/src/test/scala/cli/tests/CachedBinaryTests.scala +++ b/modules/cli/src/test/scala/cli/tests/CachedBinaryTests.scala @@ -284,5 +284,51 @@ class CachedBinaryTests extends TestUtil.ScalaCliSuite { expect(cacheAfterSlothToggle.changed) } } + + for { + slothStrictOpt <- Seq(Some(true), Some(false), None) + } + test(s"should rebuild when --sloth-strict changes from $slothStrictOpt ($additionalMessage)") { + inputs.withLoadedBuild( + defaultOptions.copy( + notForBloopOptions = defaultOptions.notForBloopOptions.copy( + slothOpt = Some(true), + slothStrictOpt = slothStrictOpt + ) + ), + buildThreads, + Some(bloopConfig), + fromDirectory + ) { + (_, _, maybeBuild) => + val build = maybeBuild.successfulOpt.get + + val config = build.options.scalaNativeOptions.configCliOptions(resourcesExist = false) + val nativeWorkDir = build.inputs.nativeWorkDir + val destPath = nativeWorkDir / s"main${if (Properties.isWin) ".exe" else ""}" + os.write(destPath, Random.alphanumeric.take(10).mkString(""), createFolders = true) + + val cacheData = + CachedBinary.getCacheData(Seq(build), config, destPath, nativeWorkDir) + CachedBinary.updateProjectAndOutputSha( + destPath, + nativeWorkDir, + cacheData.projectSha + ) + expect(cacheData.changed) + + val updatedBuild = build.copy( + options = build.options.copy( + notForBloopOptions = build.options.notForBloopOptions.copy( + slothStrictOpt = Some(!slothStrictOpt.getOrElse(false)) + ) + ) + ) + + val cacheAfterStrictToggle = + CachedBinary.getCacheData(Seq(updatedBuild), config, destPath, nativeWorkDir) + expect(cacheAfterStrictToggle.changed) + } + } } } diff --git a/modules/core/src/main/scala/scala/build/errors/SlothHierarchyError.scala b/modules/core/src/main/scala/scala/build/errors/SlothHierarchyError.scala new file mode 100644 index 0000000000..8c1b251b4a --- /dev/null +++ b/modules/core/src/main/scala/scala/build/errors/SlothHierarchyError.scala @@ -0,0 +1,7 @@ +package scala.build.errors + +/** Raised when `--sloth-strict` / `//> using slothStrict` is set and Sloth cannot resolve a type + * needed to recompute stack map frames in a dependency jar. + */ +final class SlothHierarchyError(message: String, cause: Throwable = null) + extends BuildException(message, cause = cause) diff --git a/modules/directives/src/main/scala/scala/build/preprocessing/directives/SlothStrict.scala b/modules/directives/src/main/scala/scala/build/preprocessing/directives/SlothStrict.scala new file mode 100644 index 0000000000..cdaec5e9f5 --- /dev/null +++ b/modules/directives/src/main/scala/scala/build/preprocessing/directives/SlothStrict.scala @@ -0,0 +1,27 @@ +package scala.build.preprocessing.directives + +import scala.build.directives.* +import scala.build.errors.BuildException +import scala.build.options.{BuildOptions, PostBuildOptions} +import scala.cli.commands.SpecificationLevel + +@DirectiveExamples("//> using slothStrict") +@DirectiveUsage("//> using slothStrict", "`//> using slothStrict`") +@DirectiveDescription( + "Fail when Sloth cannot resolve class hierarchies while patching dependency jars (requires --sloth or --sloth-agent)" +) +@DirectiveLevel(SpecificationLevel.EXPERIMENTAL) +final case class SlothStrict( + @DirectiveName("lazyvalgradeStrict") + @DirectiveName("lazyValPatchingStrict") + slothStrict: Boolean = false +) extends HasBuildOptions { + def buildOptions: Either[BuildException, BuildOptions] = + Right(BuildOptions( + notForBloopOptions = PostBuildOptions(slothStrictOpt = Some(slothStrict)) + )) +} + +object SlothStrict { + val handler: DirectiveHandler[SlothStrict] = DirectiveHandler.derive +} diff --git a/modules/integration/src/test/scala/scala/cli/integration/CompileTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/CompileTestDefinitions.scala index 9c503f368b..1107f2986d 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/CompileTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/CompileTestDefinitions.scala @@ -1343,4 +1343,90 @@ abstract class CompileTestDefinitions expect(!withSloth.out.trim().contains(slothAgentWarnFragment)) } } + + if isScala38OrNewer then + test( + s"compile --sloth --print-class-path patches external -cp class directory with ${Constants.scala3Lts} lazy vals" + ) { + TestInputs(externalLazyValsInput()).fromRoot { root => + val (classDir, expectedMessage) = compileExternalLazyValClassDir(root) + val printed = os.proc( + TestUtil.cli, + "--power", + "compile", + "--server=false", + "-e", + "def unused = 1", + "-cp", + classDir.toString, + slothOptions, + "--print-class-path", + extraOptions + ).call(cwd = root, stderr = os.Pipe).out.trim() + expect(printed.contains(slothCacheSegment)) + expect(printed.contains("external-dirs")) + + val run = os.proc( + TestUtil.cli, + "--power", + "run", + "--server=false", + "-e", + "println(slothful)", + "-cp", + classDir.toString, + slothOptions, + "--jvm", + Constants.allJavaVersions.max.toString, + extraOptions + ).call(cwd = root, stderr = os.Pipe) + expect(run.out.trim() == expectedMessage) + expect(!run.err.trim().contains("sun.misc.Unsafe")) + } + } + + if isScala38OrNewer then + test( + s"compile --sloth -d patches pre-existing ${Constants.scala3Lts} classes merged into the output dir" + ) { + TestInputs( + externalLazyValsInput(), + os.rel / "project" / "Fresh.scala" -> + """object Fresh { + | def hello: String = "fresh" + |} + |""".stripMargin + ).fromRoot { root => + val (classDir, expectedMessage) = compileExternalLazyValClassDir(root) + // Merge a default-Scala (3.8+) project into the same -d dir that already holds LTS classes. + os.proc( + TestUtil.cli, + "--power", + "compile", + "--server=false", + "project", + slothOptions, + "-d", + classDir.toString, + extraOptions + ).call(cwd = root, stdin = os.Inherit, stdout = os.Inherit) + + val run = os.proc( + TestUtil.cli, + "--power", + "run", + "--server=false", + "-e", + "println(slothful)", + "-cp", + classDir.toString, + // No --sloth here: the -d dest itself must already contain patched bytecode. + "--jvm", + Constants.allJavaVersions.max.toString, + extraOptions + ).call(cwd = root, stderr = os.Pipe) + expect(run.out.trim() == expectedMessage) + expect(!run.err.trim().contains("sun.misc.Unsafe")) + } + } } diff --git a/modules/integration/src/test/scala/scala/cli/integration/DocSlothTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/DocSlothTestDefinitions.scala index 7b255d7ff4..1b3b32bd62 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/DocSlothTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/DocSlothTestDefinitions.scala @@ -2,58 +2,92 @@ package scala.cli.integration import com.eed3si9n.expecty.Expecty.expect -trait DocSlothTestDefinitions extends LazyValTests: +trait DocSlothTestDefinitions extends LazyValTests { this: DocTestDefinitions & TestScalaVersion => - - private val docScalaVersion = Constants.scala3Lts - - private def lazyValProjFile: String = - s"""//> using scala $docScalaVersion - | - |/** A sample with a lazy val. */ + protected def lazyValProjFile: String = + s"""/** A sample with a lazy val. */ |object Main { | lazy val greeting: String = "Hello" | def main(args: Array[String]): Unit = println(greeting) |} |""".stripMargin - private def runDoc( - root: os.Path, - extraArgs: Seq[String], - dest: os.RelPath = os.rel / "doc-out" - ): os.CommandResult = - os.proc( - TestUtil.cli, - "--power", - "doc", - extraOptions, - extraArgs, - ".", - "-o", - dest, - "-v" - ).call(cwd = root, mergeErrIntoOut = true) + if actualScalaVersion.startsWith("3") then { + val scaladocClasspathTestName = + if isScala38OrNewer then "doc --sloth leaves the scaladoc classpath as is when unnecessary" + else "doc --sloth patches the scaladoc classpath" + test(scaladocClasspathTestName) { + TestInputs( + os.rel / "Main.scala" -> lazyValProjFile + ).fromRoot { root => + val dest = os.rel / "doc-out" + val r = + os.proc(TestUtil.cli, "--power", "doc", extraOptions, slothOptions, ".", "-o", dest, "-v") + .call(cwd = root, mergeErrIntoOut = true) + expect(r.exitCode == 0) + expect(os.isDir(root / dest)) + expectScaladocClasspathContains( + r.out.text(), + slothCacheSegment, + shouldContain = !isScala38OrNewer + ) + } + } - test("doc --sloth patches the scaladoc classpath") { - TestInputs( - os.rel / "Main.scala" -> lazyValProjFile - ).fromRoot { root => - val dest = os.rel / "doc-out" - val r = runDoc(root, slothOptions, dest) - expect(r.exitCode == 0) - expect(os.isDir(root / dest)) - expectScaladocClasspathContains(r.out.text(), slothCacheSegment) + test("doc --sloth-agent attaches the sloth java agent to scaladoc") { + TestInputs( + os.rel / "Main.scala" -> lazyValProjFile + ).fromRoot { root => + val dest = os.rel / "doc-out" + val r = + os.proc( + TestUtil.cli, + "--power", + "doc", + extraOptions, + slothAgentOptions, + ".", + "-o", + dest, + "-v" + ).call(cwd = root, mergeErrIntoOut = true) + expect(r.exitCode == 0) + expect(os.isDir(root / dest)) + expect(r.out.text().contains("-javaagent")) + } } - } - test("doc --sloth-agent attaches the sloth java agent to scaladoc") { - TestInputs( - os.rel / "Main.scala" -> lazyValProjFile - ).fromRoot { root => - val dest = os.rel / "doc-out" - val r = runDoc(root, slothAgentOptions, dest) - expect(r.exitCode == 0) - expect(os.isDir(root / dest)) - expect(r.out.text().contains("-javaagent")) + test(s"doc --sloth patches external -cp class directory with ${Constants.scala3Lts} lazy vals") { + TestInputs( + externalLazyValsInput(), + os.rel / "project" / "Main.scala" -> + """/** Docs that reference an external lazy val. */ + |object Main { + | def value: Boolean = slothful + |} + |""".stripMargin + ).fromRoot { root => + val (classDir, _) = + compileExternalLazyValClassDir(workspace = root, scalaVersion = Constants.scala3Lts) + val dest = os.rel / "doc-out" + val r = os.proc( + TestUtil.cli, + "--power", + "doc", + "--server=false", + "-cp", + classDir.toString, + slothOptions, + "project", + "-o", + dest, + "-v", + extraOptions + ).call(cwd = root, mergeErrIntoOut = true) + expect(r.exitCode == 0) + expect(os.isDir(root / dest)) + expectScaladocClasspathContains(r.out.text(), "external-dirs") + } } } +} diff --git a/modules/integration/src/test/scala/scala/cli/integration/DocTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/DocTestDefinitions.scala index ccfaa3f3e0..2f5d35d55d 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/DocTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/DocTestDefinitions.scala @@ -6,7 +6,7 @@ import org.jsoup.* import scala.jdk.CollectionConverters.* abstract class DocTestDefinitions - extends ScalaCliSuite with TestScalaVersionArgs { + extends ScalaCliSuite with TestScalaVersionArgs with DocSlothTestDefinitions { this: TestScalaVersion => protected lazy val extraOptions: Seq[String] = scalaVersionArgs ++ TestUtil.extraOptions diff --git a/modules/integration/src/test/scala/scala/cli/integration/DocTestsDefault.scala b/modules/integration/src/test/scala/scala/cli/integration/DocTestsDefault.scala index ead3fa9679..5c50cc0d6d 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/DocTestsDefault.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/DocTestsDefault.scala @@ -2,7 +2,7 @@ package scala.cli.integration import com.eed3si9n.expecty.Expecty.expect -class DocTestsDefault extends DocTestDefinitions with DocSlothTestDefinitions with TestDefault { +class DocTestsDefault extends DocTestDefinitions with TestDefault { test("javadoc") { val inputs = TestInputs( os.rel / "Foo.java" -> diff --git a/modules/integration/src/test/scala/scala/cli/integration/LazyValTests.scala b/modules/integration/src/test/scala/scala/cli/integration/LazyValTests.scala index 703e9b3347..956447fba7 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/LazyValTests.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/LazyValTests.scala @@ -16,9 +16,12 @@ trait LazyValTests: protected val slothNoOpWarnPrefix: String = "Sloth patching is not applicable to" protected val slothSignatureStrippedWarnFragment: String = "signature files were removed" + protected val slothHierarchyWarnFragment: String = "could not resolve class hierarchies" protected val slothCacheSegment: String = s"${File.separator}sloth${File.separator}" protected val slothOptions: Seq[String] = Seq("--sloth", "--suppress-experimental-feature-warning") + protected val slothStrictOptions: Seq[String] = + Seq("--sloth", "--sloth-strict", "--suppress-experimental-feature-warning") protected val slothAgentOptions: Seq[String] = Seq("--sloth-agent", "--suppress-experimental-feature-warning") @@ -95,10 +98,15 @@ trait LazyValTests: |X-Custom: yes |""".stripMargin - protected def expectScaladocClasspathContains(output: String, fragment: String): Unit = + protected def expectScaladocClasspathContains( + output: String, + fragment: String, + shouldContain: Boolean = true + ): Unit = val marker = "dotty.tools.scaladoc.Main -classpath " val classpathOpt = output.split(marker).lift(1).map(_.takeWhile(c => c != ' ' && c != '\n')) - expect(classpathOpt.exists(_.contains(fragment))) + expect(classpathOpt.isDefined) + expect(classpathOpt.exists(_.contains(fragment)) == shouldContain) protected def classpathEntries(classpath: String): Seq[os.Path] = classpath.split(File.pathSeparator).toSeq.filter(_.nonEmpty).map(os.Path(_)) @@ -122,6 +130,46 @@ trait LazyValTests: expect(isClass) os.read.bytes(path) + /** Compiles a lazy-val source with an older Scala (default: LTS) to an external class directory. + * Returns `(classDir, expectedMessage)`. The class dir mimics a `-cp` / `--extra-jar` directory + * entry produced outside the consuming project. + * + * The source must already be present under `workspace / sourceDirName` (use + * [[externalLazyValsInput]] in the surrounding `TestInputs`). + */ + protected val externalLazyValsDirName: String = "external" + + protected def externalLazyValsSource(expectedMessage: String = "true"): String = + s"""lazy val hah = $expectedMessage + |def slothful: Boolean = hah + |""".stripMargin + + protected def externalLazyValsInput(expectedMessage: String = "true"): (os.RelPath, String) = + os.rel / externalLazyValsDirName / "lazy.scala" -> externalLazyValsSource(expectedMessage) + + protected def compileExternalLazyValClassDir( + workspace: os.Path, + scalaVersion: String = Constants.scala3Lts, + buildJvm: String = "8", + classDirName: String = "cp", + sourceDirName: String = externalLazyValsDirName, + expectedMessage: String = "true" + ): (os.Path, String) = + val classDir = workspace / classDirName + os.proc( + TestUtil.cli, + "compile", + "--server=false", + sourceDirName, + "-S", + scalaVersion, + "--jvm", + buildJvm, + "-d", + classDir.toString + ).call(cwd = workspace, stdin = os.Inherit, stdout = os.Inherit) + (classDir, expectedMessage) + protected def publishLazyValsLib( scalaVersion: String, workspace: os.Path, @@ -218,6 +266,61 @@ trait LazyValTests: os.remove.all(libDir) dest + /** Builds two interdependent JARs for hierarchy-resolution tests: + * - lib-b.jar: `Base` / `SubA` / `SubB` + * - lib-a.jar: lazy val whose initializer merges SubA and SubB (compiled against lib-b) + * + * Both are compiled with [[Constants.scala3Lts]] so they contain pre-3.8 lazy vals. + */ + protected def packageHierarchyFixtureJars( + workspace: os.Path, + scalaVersion: String = Constants.scala3Lts + ): (os.Path, os.Path) = + val libBSrc = workspace / "hier-libb-src" + val libASrc = workspace / "hier-liba-src" + val libBJar = workspace / "hier-lib-b.jar" + val libAJar = workspace / "hier-lib-a.jar" + os.write( + libBSrc / "Base.scala", + s"""//> using scala $scalaVersion + |package hier + |class Base + |class SubA extends Base + |class SubB extends Base + |""".stripMargin, + createFolders = true + ) + os.proc(TestUtil.cli, "--power", "package", "--library", libBSrc, "-o", libBJar) + .call(cwd = workspace, stdin = os.Inherit, stdout = os.Inherit) + os.write( + libASrc / "Lazies.scala", + s"""//> using scala $scalaVersion + |package hier + |object Lazies: + | lazy val x: Base = + | if sys.props.contains("hier.useA") then new SubA else new SubB + | def get: Base = x + | def pick(useA: Boolean): Base = + | val chosen: Base = if useA then new SubA else new SubB + | chosen + |""".stripMargin, + createFolders = true + ) + os.proc( + TestUtil.cli, + "--power", + "package", + "--library", + libASrc, + "-cp", + libBJar.toString, + "-o", + libAJar + ).call(cwd = workspace, stdin = os.Inherit, stdout = os.Inherit) + os.remove.all(libBSrc) + os.remove.all(libASrc) + (libAJar, libBJar) + /** Publishes a library JAR without lazy vals (pure Java or Scala 3.8+) and signs it. Returns the * path to the signed JAR. */ diff --git a/modules/integration/src/test/scala/scala/cli/integration/PackageSlothTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/PackageSlothTestDefinitions.scala index 5178e03c00..a9ec96faf3 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/PackageSlothTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/PackageSlothTestDefinitions.scala @@ -117,7 +117,9 @@ trait PackageSlothTestDefinitions extends LazyValTests: } } - for ver <- assemblyScalaVersions do + // TODO make this work for 3.0.2 + for ver <- assemblyScalaVersions.filter(_.coursierVersion >= Constants.scala3Lts.coursierVersion) + do packageSlothTest( "assembly", Seq("--assembly", "--preamble=false"), @@ -156,6 +158,74 @@ trait PackageSlothTestDefinitions extends LazyValTests: } } + test( + s"package bootstrap --standalone --sloth patches external -cp class directory on JDK $latestJava" + ) { + TestInputs( + externalLazyValsInput(), + os.rel / "project" / "Main.scala" -> + """object Main { + | def main(args: Array[String]): Unit = println(slothful) + |} + |""".stripMargin + ).fromRoot { root => + val (classDir, expectedMsg) = compileExternalLazyValClassDir(root) + val launcher = root / (if Properties.isWin then "app.bat" else "app") + os.proc( + TestUtil.cli, + "--power", + "package", + "--server=false", + "--sloth", + "--suppress-experimental-feature-warning", + "--standalone", + "-cp", + classDir.toString, + os.rel / "project" / "Main.scala", + "-o", + launcher, + extraOptions + ).call(cwd = root, stdin = os.Inherit, stdout = os.Inherit) + + val r = runBootstrapLauncher(root, launcher) + expect(r.out.trim().contains(expectedMsg)) + expect(!r.err.trim().contains("sun.misc.Unsafe")) + } + } + + test( + s"package bootstrap --standalone jars external -cp class directory without --sloth on JDK $latestJava" + ) { + TestInputs( + externalLazyValsInput(), + os.rel / "project" / "Main.scala" -> + """object Main { + | def main(args: Array[String]): Unit = println(slothful) + |} + |""".stripMargin + ).fromRoot { root => + val (classDir, expectedMsg) = compileExternalLazyValClassDir(root) + val launcher = root / (if Properties.isWin then "app.bat" else "app") + os.proc( + TestUtil.cli, + "--power", + "package", + "--server=false", + "--standalone", + "-cp", + classDir.toString, + os.rel / "project" / "Main.scala", + "-o", + launcher, + extraOptions + ).call(cwd = root, stdin = os.Inherit, stdout = os.Inherit) + + // Without --sloth the bootstrap still succeeds (directory is jarred); Unsafe warning is OK. + val r = runBootstrapLauncher(root, launcher) + expect(r.out.trim().contains(expectedMsg)) + } + } + test(s"package library $ltsOnlyScalaVersion --sloth patches lazy vals on JDK $latestJava") { TestInputs( os.rel / "Main.scala" -> lazyValApp(ltsOnlyScalaVersion) diff --git a/modules/integration/src/test/scala/scala/cli/integration/ReplTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/ReplTestDefinitions.scala index 730bfbf9cf..3fc21e1b93 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/ReplTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/ReplTestDefinitions.scala @@ -8,6 +8,7 @@ import scala.util.Properties abstract class ReplTestDefinitions extends ScalaCliSuite with TestScalaVersionArgs with LazyValTests { this: TestScalaVersion => + protected lazy val latestJava = Constants.allJavaVersions.max.toString protected lazy val extraOptions: Seq[String] = scalaVersionArgs ++ TestUtil.extraOptions protected lazy val canRunInRepl: Boolean = @@ -27,13 +28,14 @@ abstract class ReplTestDefinitions extends ScalaCliSuite with TestScalaVersionAr check: Boolean = true, skipScalaVersionArgs: Boolean = false, env: Map[String, String] = Map.empty, - initScriptFromFile: Boolean = false + initScriptFromFile: Boolean = false, + inputArgs: Seq[os.Shellable] = Seq(".") )( runAfterRepl: os.CommandResult => Unit, - runBeforeReplAndGetExtraCliOpts: () => Seq[os.Shellable] = () => Seq.empty + runBeforeReplAndGetExtraCliOpts: os.Path => Seq[os.Shellable] = _ => Seq.empty ): Unit = { testInputs.fromRoot { root => - val potentiallyExtraCliOpts = runBeforeReplAndGetExtraCliOpts() + val potentiallyExtraCliOpts = runBeforeReplAndGetExtraCliOpts(root) val initScriptArgs = if initScriptFromFile then { val initScriptFile = root / ".scala-cli-repl-init.sc" @@ -45,7 +47,7 @@ abstract class ReplTestDefinitions extends ScalaCliSuite with TestScalaVersionAr os.proc( TestUtil.cli, "repl", - ".", + inputArgs, "--repl-quit-after-init", initScriptArgs, if skipScalaVersionArgs then TestUtil.extraOptions else extraOptions, @@ -322,7 +324,7 @@ abstract class ReplTestDefinitions extends ScalaCliSuite with TestScalaVersionAr runInRepl(codeToRunInRepl = """import shapeless._; println("Here's an HList: " + (2 :: true :: "a" :: HNil))""" )( - runBeforeReplAndGetExtraCliOpts = () => + runBeforeReplAndGetExtraCliOpts = _ => val shapelessJar = os.proc(TestUtil.cs, "fetch", "--intransitive", "com.chuusai:shapeless_2.13:2.3.7") .call() @@ -365,5 +367,39 @@ abstract class ReplTestDefinitions extends ScalaCliSuite with TestScalaVersionAr } } + test( + s"$runInReplPrefix --sloth patches external -cp class directory with ${Constants.scala3Lts} lazy vals on JDK $latestJava" + ) { + runInRepl( + codeToRunInRepl = "println(slothful)", + testInputs = TestInputs(externalLazyValsInput()), + cliOptions = Seq("--server=false", "--jvm", latestJava, "--power") ++ slothOptions, + shouldPipeStdErr = true, + inputArgs = Nil + )( + runBeforeReplAndGetExtraCliOpts = root => + val (classDir, _) = compileExternalLazyValClassDir(root) + Seq("-cp", classDir.toString) + , + runAfterRepl = res => + expect(res.out.trim().contains("true")) + expect(!res.err.trim().contains("sun.misc.Unsafe")) + ) + } + // Catches a corrupted *compiler* jar from --sloth even without an external -cp. + // On Scala < 3.8 the REPL classpath includes scala3-compiler which Sloth patches; + // a broken getCommonSuperClass fallback produces VerifyError in ReplDriver. + test(s"$runInReplPrefix --sloth starts the REPL without VerifyError on JDK $latestJava") { + runInRepl( + codeToRunInRepl = "println(1 + 1)", + cliOptions = Seq("--server=false", "--jvm", latestJava, "--power") ++ slothOptions, + shouldPipeStdErr = true, + inputArgs = Nil + ) { res => + expect(res.out.trim().contains("2")) + expect(!res.err.trim().contains("VerifyError")) + expect(!res.err.trim().contains("sun.misc.Unsafe")) + } + } } } diff --git a/modules/integration/src/test/scala/scala/cli/integration/ReplTestsDefault.scala b/modules/integration/src/test/scala/scala/cli/integration/ReplTestsDefault.scala index 8bdf3bb185..24a80b33c8 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/ReplTestsDefault.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/ReplTestsDefault.scala @@ -7,9 +7,6 @@ class ReplTestsDefault extends ReplTestDefinitions with LazyValTests with TestDefault { - // Sloth tests - only in default suite since they use hardcoded Scala versions - private val latestJava = Constants.allJavaVersions.max.toString - private def replNoDepUnsafeTest(slothFlag: String): Unit = test( s"$runInReplPrefix dont warn about sun.misc.Unsafe on JDK $latestJava (no dependency, $slothFlag)" diff --git a/modules/integration/src/test/scala/scala/cli/integration/RunTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/RunTestDefinitions.scala index 858d85eeaa..b58a16a908 100755 --- a/modules/integration/src/test/scala/scala/cli/integration/RunTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/RunTestDefinitions.scala @@ -2829,4 +2829,110 @@ abstract class RunTestDefinitions expect(r.err.trim().contains(slothSignatureStrippedWarnFragment)) } } + + if isScala38OrNewer then { + test( + s"run --sloth patches external -cp class directory with ${Constants.scala3Lts} lazy vals on JDK $latestJava" + ) { + TestInputs(externalLazyValsInput()).fromRoot { root => + val (classDir, expectedMessage) = compileExternalLazyValClassDir(root) + val r = os.proc( + TestUtil.cli, + "--power", + "run", + "--server=false", + "-e", + "println(slothful)", + "-cp", + classDir.toString, + slothOptions, + "--jvm", + latestJava.toString, + extraOptions + ).call(cwd = root, stderr = os.Pipe) + expect(r.out.trim() == expectedMessage) + expect(!r.err.trim().contains("sun.misc.Unsafe")) + } + } + + test( + s"run --sloth-agent patches external -cp class directory with ${Constants.scala3Lts} lazy vals on JDK $latestJava" + ) { + TestInputs(externalLazyValsInput()).fromRoot { root => + val (classDir, expectedMessage) = compileExternalLazyValClassDir(root) + val r = os.proc( + TestUtil.cli, + "--power", + "run", + "--server=false", + "-e", + "println(slothful)", + "-cp", + classDir.toString, + slothAgentOptions, + "--jvm", + latestJava.toString, + extraOptions + ).call(cwd = root, stderr = os.Pipe) + expect(r.out.trim() == expectedMessage) + expect(!r.err.trim().contains("sun.misc.Unsafe")) + } + } + } + + test("run --sloth warns when a dependency jar has an incomplete class hierarchy") { + TestInputs.empty.fromRoot { root => + val (libA, _) = packageHierarchyFixtureJars(root) + // Only lib-a on the classpath: SubA/SubB live in lib-b, so Sloth cannot resolve the + // merge while patching. The snippet itself does not need those types — patching runs + // over the whole -cp before the user code starts. + val r = os.proc( + TestUtil.cli, + "--power", + "run", + "--server=false", + "-e", + """println("ok")""", + "-cp", + libA.toString, + slothOptions, + "--jvm", + latestJava.toString, + extraOptions + ).call(cwd = root, stderr = os.Pipe, check = false) + expect(r.exitCode == 0) + expect(r.out.trim() == "ok") + expect(r.err.trim().contains(slothHierarchyWarnFragment)) + expect(r.err.trim().contains("--sloth-strict")) + } + } + + test( + "run --sloth --sloth-strict fails when a dependency jar has an incomplete class hierarchy" + ) { + TestInputs( + os.rel / "Main.scala" -> + """//> using slothStrict + |@main def main(): Unit = println("ok") + |""".stripMargin + ).fromRoot { root => + val (libA, _) = packageHierarchyFixtureJars(root) + val r = os.proc( + TestUtil.cli, + "--power", + "run", + ".", + "--server=false", + "-cp", + libA.toString, + slothOptions, + "--jvm", + latestJava.toString, + extraOptions + ).call(cwd = root, stderr = os.Pipe, check = false) + expect(r.exitCode != 0) + val err = r.err.trim() + "\n" + r.out.trim() + expect(err.contains(slothHierarchyWarnFragment)) + } + } } diff --git a/modules/integration/src/test/scala/scala/cli/integration/TestTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/TestTestDefinitions.scala index 4f030b8d4f..2f7439f349 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/TestTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/TestTestDefinitions.scala @@ -14,6 +14,7 @@ abstract class TestTestDefinitions extends ScalaCliSuite with TestScalaVersionAr protected lazy val extraOptions: Seq[String] = scalaVersionArgs ++ TestUtil.extraOptions private val utestVersion = "0.8.3" private val zioTestVersion = "2.1.17" + protected val latestJava = Constants.allJavaVersions.max def successfulTestInputs(directivesString: String = s"//> using dep org.scalameta::munit::$munitVersion"): TestInputs = TestInputs( @@ -1317,4 +1318,50 @@ abstract class TestTestDefinitions extends ScalaCliSuite with TestScalaVersionAr expect(err.countOccurrences(expectedWarning) == 1) } } + + if actualScalaVersion.startsWith("3") then + test( + s"test --sloth patches external -cp class directory with ${Constants.scala3Lts} lazy vals on JDK $latestJava" + ) { + val marker = "TEST_BODY_EXECUTED" + val expectedMessage = "true" + TestInputs( + externalLazyValsInput(expectedMessage), + os.rel / "project" / "ExternalCpTests.test.scala" -> + s"""//> using dep org.scalameta::munit::$munitVersion + | + |class ExternalCpTests extends munit.FunSuite { + | test("lazy val from external -cp") { + | println("$marker") + | assertEquals(slothful, $expectedMessage) + | } + |} + |""".stripMargin + ).fromRoot { root => + val (classDir, _) = compileExternalLazyValClassDir(root, expectedMessage = expectedMessage) + val classBytesBefore = os.walk(classDir).filter(_.ext == "class").map { p => + p -> os.read.bytes(p) + }.toMap + val r = os.proc( + TestUtil.cli, + "test", + "--power", + extraOptions, + slothOptions, + "-cp", + classDir.toString, + "--jvm", + latestJava.toString, + "project" + ).call(cwd = root, stderr = os.Pipe) + val out = r.out.trim() + expect(r.exitCode == 0) + expect(out.contains(marker)) + expect(out.contains("1 total")) + expect(!r.err.trim().contains("sun.misc.Unsafe")) + // External class dir must not be mutated in place + for (p, before) <- classBytesBefore do + expect(java.util.Arrays.equals(os.read.bytes(p), before)) + } + } } diff --git a/modules/integration/src/test/scala/scala/cli/integration/TestTestsDefault.scala b/modules/integration/src/test/scala/scala/cli/integration/TestTestsDefault.scala index 74ae6dc89e..87f9307b5c 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/TestTestsDefault.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/TestTestsDefault.scala @@ -8,10 +8,6 @@ import scala.cli.integration.Constants.munitVersion import scala.cli.integration.TestUtil.StringOps class TestTestsDefault extends TestTestDefinitions with LazyValTests with TestDefault { - - // Sloth tests - only in default suite since they use hardcoded Scala versions - private val latestJava = Constants.allJavaVersions.max - private def testLazyValsUnsafe(libScalaVersion: String, slothFlag: String): Unit = test( s"test $libScalaVersion lazy vals dont warn about sun.misc.Unsafe on JDK $latestJava ($slothFlag)" diff --git a/modules/options/src/main/scala/scala/build/options/PostBuildOptions.scala b/modules/options/src/main/scala/scala/build/options/PostBuildOptions.scala index 3c1edcfa6f..c8d4fe60d2 100644 --- a/modules/options/src/main/scala/scala/build/options/PostBuildOptions.scala +++ b/modules/options/src/main/scala/scala/build/options/PostBuildOptions.scala @@ -13,7 +13,8 @@ final case class PostBuildOptions( scalaPyVersion: Option[String] = None, addRunnerDependencyOpt: Option[Boolean] = None, slothOpt: Option[Boolean] = None, - slothAgentOpt: Option[Boolean] = None + slothAgentOpt: Option[Boolean] = None, + slothStrictOpt: Option[Boolean] = None ) { def doSetupPython: Option[Boolean] = @@ -24,6 +25,9 @@ final case class PostBuildOptions( def slothAgent: Boolean = slothAgentOpt.getOrElse(false) + + def slothStrict: Boolean = + slothStrictOpt.getOrElse(false) } object PostBuildOptions: diff --git a/project/deps/package.mill b/project/deps/package.mill index 6979a5c696..99e4ac2e11 100644 --- a/project/deps/package.mill +++ b/project/deps/package.mill @@ -155,7 +155,7 @@ object Deps { def mavenAppGroupId = "com.example" def mavenAppVersion = "0.1-SNAPSHOT" def scalafix = "0.14.7" - def sloth = "0.1.0-M1" + def sloth = "0.1.0-M2" } def slothOrganization = "org.virtuslab" diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index b99e484f4b..626cd375f1 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -1720,6 +1720,12 @@ Aliases: `--lazyvalgrade-agent`, `--patch-lazy-vals-with-agent` Patch Scala 3.0-3.7.x lazy val bytecode at class load time via the sloth Java agent for JDK 26+ compatibility +### `--sloth-strict` + +Aliases: `--lazyvalgrade-strict`, `--patch-lazy-vals-strict` + +Fail when Sloth cannot resolve class hierarchies while patching dependency jars (requires --sloth or --sloth-agent) + ### `--auto-setup-ide` Aliases: `--auto-setup-bsp` diff --git a/website/docs/reference/directives.md b/website/docs/reference/directives.md index f23847712f..bdf2668833 100644 --- a/website/docs/reference/directives.md +++ b/website/docs/reference/directives.md @@ -698,6 +698,15 @@ Patch Scala 3.0-3.7.x lazy val bytecode at class load time via the sloth Java ag #### Examples `//> using slothAgent` +### SlothStrict + +Fail when Sloth cannot resolve class hierarchies while patching dependency jars (requires --sloth or --sloth-agent) + +`//> using slothStrict` + +#### Examples +`//> using slothStrict` + ### Test framework Set the test framework