From 1ca0d55c245c963ad0617f4f2becb7e94f2b2734 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Tue, 26 May 2026 10:09:00 +0200 Subject: [PATCH 1/2] Remove dead code in ScriptPreprocessor --- .../scala/scala/build/preprocessing/ScriptPreprocessor.scala | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/build/src/main/scala/scala/build/preprocessing/ScriptPreprocessor.scala b/modules/build/src/main/scala/scala/build/preprocessing/ScriptPreprocessor.scala index 9a17792868..7afd52ecba 100644 --- a/modules/build/src/main/scala/scala/build/preprocessing/ScriptPreprocessor.scala +++ b/modules/build/src/main/scala/scala/build/preprocessing/ScriptPreprocessor.scala @@ -105,7 +105,6 @@ case object ScriptPreprocessor extends Preprocessor { inputArgPath.getOrElse(subPath.toString) ) - (pkg :+ wrapper).map(_.raw).mkString(".") val relPath = os.rel / (subPath / os.up) / s"${subPath.last.stripSuffix(".sc")}.scala" val file = PreprocessedSource.UnwrappedScript( From 034a8e4de071e93215aa34c25d880935407f2e68 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Tue, 26 May 2026 11:02:38 +0200 Subject: [PATCH 2/2] Add a diagnostic for when a script's wrapper name shadows a dependency package --- .../src/main/scala/scala/build/Build.scala | 15 +++++- .../src/main/scala/scala/build/Sources.scala | 11 +++- .../scala/scala/build/internal/JarUtils.scala | 32 ++++++++++++ .../scala/build/internal/MainClass.scala | 21 +------- .../scala/build/internal/ScriptUtils.scala | 41 +++++++++++++++ .../build/internal/util/WarningMessages.scala | 12 +++++ .../RunScriptTestDefinitions.scala | 50 +++++++++++++++++++ 7 files changed, 161 insertions(+), 21 deletions(-) create mode 100644 modules/build/src/main/scala/scala/build/internal/JarUtils.scala create mode 100644 modules/build/src/main/scala/scala/build/internal/ScriptUtils.scala diff --git a/modules/build/src/main/scala/scala/build/Build.scala b/modules/build/src/main/scala/scala/build/Build.scala index f868b60dd6..cb0ae718c8 100644 --- a/modules/build/src/main/scala/scala/build/Build.scala +++ b/modules/build/src/main/scala/scala/build/Build.scala @@ -16,7 +16,8 @@ import scala.build.compiler.{ScalaCompiler, ScalaCompilerMaker} import scala.build.errors.* import scala.build.input.* import scala.build.internal.resource.ResourceMapper -import scala.build.internal.{Constants, MainClass, Name, Util} +import scala.build.internal.util.WarningMessages +import scala.build.internal.{Constants, MainClass, Name, ScriptUtils, Util} import scala.build.internals.ConsoleUtils.ScalaCliConsole.warnPrefix import scala.build.options.* import scala.build.options.validation.ValidationException @@ -1129,6 +1130,18 @@ object Build { val artifacts = value(options0.artifacts(logger, scope, maybeRecoverOnError)) + for shadowed <- ScriptUtils.findShadowedDependencyPackages( + sources = sources, + classPath = artifacts.compileClassPath, + logger = logger + ) + do + logger.diagnostic( + message = WarningMessages.scriptNameShadowsDependencyPackage(shadowed), + severity = Severity.Warning, + positions = Seq(Position.File(Right(shadowed.filePath), (0, 0), (0, 0))) + ) + value(validate(logger, options0)) val project = value { diff --git a/modules/build/src/main/scala/scala/build/Sources.scala b/modules/build/src/main/scala/scala/build/Sources.scala index a9fd4c196d..4c2fb5092a 100644 --- a/modules/build/src/main/scala/scala/build/Sources.scala +++ b/modules/build/src/main/scala/scala/build/Sources.scala @@ -6,7 +6,8 @@ import coursier.util.Task import java.nio.charset.StandardCharsets import scala.build.input.Inputs -import scala.build.internal.{CodeWrapper, WrapperParams} +import scala.build.internal.ScriptUtils.ScriptDescriptor +import scala.build.internal.{AmmUtil, CodeWrapper, WrapperParams} import scala.build.options.{BuildOptions, Scope} import scala.build.preprocessing.* @@ -76,6 +77,14 @@ final case class Sources( lazy val hasScala = (paths.iterator.map(_._1.last) ++ inMemory.iterator.map(_.generatedRelPath.last)) .exists(_.endsWith(".scala")) + + def scriptTopLevelNames: Seq[ScriptDescriptor] = + inMemory.collect { + case Sources.InMemory(Right((subPath, filePath)), _, _, Some(_)) => + val (pkg, wrapper) = AmmUtil.pathToPackageWrapper(subPath) + val topLevelName = pkg.headOption.map(_.raw).getOrElse(wrapper.raw) + ScriptDescriptor(topLevelName, subPath, filePath) + } } object Sources { diff --git a/modules/build/src/main/scala/scala/build/internal/JarUtils.scala b/modules/build/src/main/scala/scala/build/internal/JarUtils.scala new file mode 100644 index 0000000000..9e88ea3dae --- /dev/null +++ b/modules/build/src/main/scala/scala/build/internal/JarUtils.scala @@ -0,0 +1,32 @@ +package scala.build.internal + +import java.io.ByteArrayInputStream +import java.nio.file.NoSuchFileException + +import scala.build.internal.zip.WrappedZipInputStream +import scala.build.{Logger, retry} + +object JarUtils { + + /** Walk `.class` entries in a JAR */ + def walkClassEntries[A](jar: os.Path, logger: Logger)( + extract: (String, () => Array[Byte]) => Iterator[A] + ): Iterator[A] = + try + retry()(logger) { + val content = os.read.bytes(jar) + val zip = WrappedZipInputStream.create(new ByteArrayInputStream(content)) + zip.entries().flatMap { ent => + if !ent.isDirectory && ent.getName.endsWith(".class") then + extract(ent.getName, () => zip.readAllBytes()) + else Iterator.empty + } + } + catch { + case e: NoSuchFileException => + logger.debugStackTrace(e) + logger.debug(s"JAR file $jar not found: $e, skipping.") + Iterator.empty + } + +} diff --git a/modules/build/src/main/scala/scala/build/internal/MainClass.scala b/modules/build/src/main/scala/scala/build/internal/MainClass.scala index 33bd5067cf..4c694082d5 100644 --- a/modules/build/src/main/scala/scala/build/internal/MainClass.scala +++ b/modules/build/src/main/scala/scala/build/internal/MainClass.scala @@ -7,7 +7,6 @@ import java.io.{ByteArrayInputStream, InputStream} import java.nio.file.NoSuchFileException import java.util.jar.{Attributes, JarFile} -import scala.build.internal.zip.WrappedZipInputStream import scala.build.{Logger, retry} object MainClass { @@ -73,24 +72,8 @@ object MainClass { finally is.close() private def findInJar(path: os.Path, logger: Logger): Iterator[String] = - try retry()(logger) { - val content = os.read.bytes(path) - val jarInputStream = WrappedZipInputStream.create(new ByteArrayInputStream(content)) - jarInputStream.entries().flatMap(ent => - if !ent.isDirectory && ent.getName.endsWith(".class") then { - val content = jarInputStream.readAllBytes() - val inputStream = new ByteArrayInputStream(content) - findInClass(inputStream, logger) - } - else Iterator.empty - ) - } - catch { - case e: NoSuchFileException => - logger.debugStackTrace(e) - logger.log(s"JAR file $path not found: $e, trying to recover...") - logger.log("Are you trying to run too many builds at once? Trying to recover...") - Iterator.empty + JarUtils.walkClassEntries(path, logger) { (_, bytes) => + findInClass(new ByteArrayInputStream(bytes()), logger) } def findInDependency(jar: os.Path): Option[String] = diff --git a/modules/build/src/main/scala/scala/build/internal/ScriptUtils.scala b/modules/build/src/main/scala/scala/build/internal/ScriptUtils.scala new file mode 100644 index 0000000000..5741277548 --- /dev/null +++ b/modules/build/src/main/scala/scala/build/internal/ScriptUtils.scala @@ -0,0 +1,41 @@ +package scala.build.internal + +import scala.build.{Logger, Sources} + +object ScriptUtils { + private val ignoredPackageRoots = Set("scala", "java", "javax", "META-INF") + + final case class ScriptDescriptor( + name: String, + subPath: os.SubPath, + filePath: os.Path, + shadowedDependencyJars: Seq[String] = Nil + ) + + def findShadowedDependencyPackages( + sources: Sources, + classPath: Seq[os.Path], + logger: Logger + ): Seq[ScriptDescriptor] = { + val scripts = sources.scriptTopLevelNames.filterNot(s => ignoredPackageRoots(s.name)) + if scripts.isEmpty then Nil + else + val packageRoots = + classPath + .filter(_.last.endsWith(".jar")) + .flatMap(path => + JarUtils.walkClassEntries(path, logger) { (name, _) => + val slashIdx = name.indexOf('/') + if slashIdx > 0 then Iterator.single(name.take(slashIdx)) + else Iterator.empty + }.toSet.map(_ -> path) + ) + .groupMap((root, _) => root)((_, path) => path) + .view.mapValues(_.toSet).toMap + scripts.flatMap { script => + packageRoots.get(script.name).map { jars => + script.copy(shadowedDependencyJars = jars.toSeq.map(_.last).sorted) + } + } + } +} 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 996a9bb241..1338c3c80f 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 @@ -2,6 +2,7 @@ package scala.build.internal.util import scala.build.input.ScalaCliInvokeData import scala.build.internal.Constants +import scala.build.internal.ScriptUtils.ScriptDescriptor import scala.build.internals.FeatureType import scala.build.preprocessing.directives.{DirectiveHandler, ScopedDirective} import scala.cli.commands.SpecificationLevel @@ -130,6 +131,17 @@ object WarningMessages { val mainScriptNameClashesWithAppWrapper = "Script file named 'main.sc' detected, keep in mind that accessing it from other scripts is impossible due to a clash of `main` symbols" + def scriptNameShadowsDependencyPackage(shadowed: ScriptDescriptor): String = + val name = shadowed.name + val depsClause = shadowed.shadowedDependencyJars match { + case Nil => "from an unknown dependency" + case Seq(j) => s"from dependency '$j'" + case js => s"from dependencies: ${js.map(j => s"'$j'").mkString(", ")}" + } + s"""Script '${shadowed.subPath}' generates a top-level symbol '$name' that shadows the '$name' package $depsClause. + |Imports of '$name.*' from the dependency will not resolve. + |Consider renaming the script (e.g. to '${name}1.sc') or moving it into a subdirectory.""".stripMargin + private val deprecationNote = "Deprecated features may be removed in a future version." diff --git a/modules/integration/src/test/scala/scala/cli/integration/RunScriptTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/RunScriptTestDefinitions.scala index 67b8d6d5d5..a5f7d6acdd 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/RunScriptTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/RunScriptTestDefinitions.scala @@ -270,6 +270,56 @@ trait RunScriptTestDefinitions { this: RunTestDefinitions => } } + test("warn when script name shadows a dependency top-level package") { + val inputs = TestInputs( + os.rel / "os.sc" -> + s"""//> using dep com.lihaoyi::os-lib:0.11.8 + |println("hi") + |""".stripMargin + ) + inputs.fromRoot { root => + val res = os.proc(TestUtil.cli, extraOptions, "os.sc") + .call(cwd = root, check = false, mergeErrIntoOut = true) + val output = res.out.trim() + expect(output.contains("shadows the 'os' package")) + expect(res.exitCode == 0) + } + } + + test("warn with multiple JARs when script name shadows a shared package root") { + val inputs = TestInputs( + os.rel / "cats.sc" -> + s"""//> using dep org.typelevel::cats-core:2.12.0 + |println("hi") + |""".stripMargin + ) + inputs.fromRoot { root => + val res = os.proc(TestUtil.cli, extraOptions, "cats.sc") + .call(cwd = root, check = false, mergeErrIntoOut = true) + val output = res.out.trim() + expect(output.contains("shadows the 'cats' package from dependencies:")) + expect(output.contains("cats-core")) + expect(output.contains("cats-kernel")) + expect(res.exitCode == 0) + } + } + + test("do not warn when script name does not shadow a dependency top-level package") { + val inputs = TestInputs( + os.rel / "safe.sc" -> + s"""//> using dep com.lihaoyi::os-lib:0.11.8 + |println("hi") + |""".stripMargin + ) + inputs.fromRoot { root => + val res = os.proc(TestUtil.cli, extraOptions, "safe.sc") + .call(cwd = root, check = false, mergeErrIntoOut = true) + val output = res.out.trim() + expect(!output.contains("shadows the 'os' package")) + expect(res.exitCode == 0) + } + } + test("Directory") { val message = "Hello" val inputs = TestInputs(