From bdc81e2a6eeebf03f995ff3fea3b8b40a519e83b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Raddum=20Berg?= Date: Mon, 25 May 2026 17:16:03 +0200 Subject: [PATCH] Warn when sources sit under sbt/Maven layout that bleep won't pick up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bleep's default source layout resolves to src// because sbt-scope defaults to "". Files placed under src/main/scala, src/main/java, src/main/kotlin, or the src/test/ equivalents are silently invisible to the build — the project compiles as a no-op and you only notice via a runtime ClassNotFoundException (or never, if the no-op project is downstream of nothing). This bit our own KotlinIT, which was a 4-test silent no-op for months. Adds a best-effort heuristic at bootstrap: for each project without an explicit `sources:` or `sbt-scope:`, check whether any of the six suspect directories exists AND contains at least one matching source file AND is not already in the project's resolved sourcesDirs. If so, emit one warn per offender pointing at the file and listing three fixes (move, set sources:, or set sbt-scope:). Costs at most 6 Files.isDirectory probes per project and one Files.walk (short-circuited via anyMatch) per existing suspect dir. Zero false positives on bleep's own multi-project build. Co-Authored-By: Claude Opus 4.7 (1M context) --- bleep-core/src/scala/bleep/bootstrap.scala | 2 + .../internal/CheckMisplacedSources.scala | 53 +++++++ .../bleep/CheckMisplacedSourcesTest.scala | 137 ++++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 bleep-core/src/scala/bleep/internal/CheckMisplacedSources.scala create mode 100644 bleep-tests/src/scala/bleep/CheckMisplacedSourcesTest.scala diff --git a/bleep-core/src/scala/bleep/bootstrap.scala b/bleep-core/src/scala/bleep/bootstrap.scala index e78372966..f9d528ed3 100644 --- a/bleep-core/src/scala/bleep/bootstrap.scala +++ b/bleep-core/src/scala/bleep/bootstrap.scala @@ -85,6 +85,8 @@ object bootstrap { Some(chosen).filter(_.nonEmpty) } + internal.CheckMisplacedSources(pre.logger, pre.buildPaths, finalBuild) + val td = System.currentTimeMillis() - t0 pre.logger.info(s"bootstrapped in $td ms") diff --git a/bleep-core/src/scala/bleep/internal/CheckMisplacedSources.scala b/bleep-core/src/scala/bleep/internal/CheckMisplacedSources.scala new file mode 100644 index 000000000..fe4d69b40 --- /dev/null +++ b/bleep-core/src/scala/bleep/internal/CheckMisplacedSources.scala @@ -0,0 +1,53 @@ +package bleep +package internal + +import ryddig.Logger + +import java.nio.file.{Files, Path} + +/** Warns when a project has source files under sbt/Maven-style `src/{main,test}/{scala,java,kotlin}/` paths that bleep is not picking up. + * + * Bleep's default source layout resolves to `src//` (because `sbt-scope` defaults to empty), so files placed under `src/main//` or + * `src/test//` are silently invisible to the build unless the user explicitly sets `sources:` or `sbt-scope:`. This check surfaces that situation early + * with an actionable message instead of letting the user discover it via a missing-class error at runtime. + */ +object CheckMisplacedSources { + + private case class Suspect(scope: String, lang: String, extension: String) + + private val suspects: List[Suspect] = for { + scope <- List("main", "test") + (lang, ext) <- List("scala" -> ".scala", "java" -> ".java", "kotlin" -> ".kt") + } yield Suspect(scope, lang, ext) + + def apply(logger: Logger, buildPaths: BuildPaths, build: model.Build): Unit = + build.explodedProjects.foreach { case (crossName, project) => + val projectPaths = buildPaths.project(crossName, project) + val coveredDirs: Set[Path] = projectPaths.sourcesDirs.all.toSet ++ projectPaths.resourcesDirs.all.toSet + + // A project that has either an explicit `sources:` list OR an explicit `sbt-scope:` has opted into custom layout — assume the user knows what they're + // doing and skip the heuristic entirely. + val hasUserOverride = project.sources.values.nonEmpty || project.`sbt-scope`.isDefined + if (!hasUserOverride) { + suspects.foreach { suspect => + val candidate = projectPaths.dir / "src" / suspect.scope / suspect.lang + if (Files.isDirectory(candidate) && !coveredDirs.contains(candidate) && containsSourceFile(candidate, suspect.extension)) { + logger + .withContext("project", crossName.value) + .withContext("path", candidate.toString) + .warn( + s"found ${suspect.lang} sources under sbt/Maven-style 'src/${suspect.scope}/${suspect.lang}/' that bleep is NOT picking up. " + + s"Either move them to 'src/${suspect.lang}/' (bleep's default), set `sources: ./src/${suspect.scope}/${suspect.lang}` " + + s"on this project, or set `sbt-scope: ${suspect.scope}` to opt into the sbt-style layout." + ) + } + } + } + } + + private def containsSourceFile(dir: Path, extension: String): Boolean = { + val stream = Files.walk(dir) + try stream.anyMatch(p => Files.isRegularFile(p) && p.toString.endsWith(extension)) + finally stream.close() + } +} diff --git a/bleep-tests/src/scala/bleep/CheckMisplacedSourcesTest.scala b/bleep-tests/src/scala/bleep/CheckMisplacedSourcesTest.scala new file mode 100644 index 000000000..51c72d0f6 --- /dev/null +++ b/bleep-tests/src/scala/bleep/CheckMisplacedSourcesTest.scala @@ -0,0 +1,137 @@ +package bleep + +import bleep.internal.{CheckMisplacedSources, FileUtils} +import org.scalatest.funsuite.AnyFunSuite +import ryddig.LogLevel + +import java.nio.file.{Files, Path, Paths} + +class CheckMisplacedSourcesTest extends AnyFunSuite { + + private val prelude = + """$schema: https://raw.githubusercontent.com/oyvindberg/bleep/master/schema.json + |$version: dev + |""".stripMargin + + private def runCheck(yaml: String, files: List[String]): List[String] = { + val tempDir = Files.createTempDirectory("bleep-misplaced-") + try { + Files.writeString(tempDir.resolve("bleep.yaml"), prelude ++ yaml) + files.foreach { rel => + val target = tempDir.resolve(rel) + Files.createDirectories(target.getParent) + Files.writeString(target, "") + } + + val existing = BuildLoader.find(tempDir).existing.orThrow + val build = model.Build.FileBacked(existing.buildFile.forceGet.orThrow) + val buildPaths = BuildPaths(cwd = tempDir, existing, model.BuildVariant.Normal) + + val logger = ThreadSafeStoringLogger().withMinLogLevel(LogLevel.debug) + CheckMisplacedSources(logger, buildPaths, build) + logger.underlying.toList.filter(_.metadata.logLevel == LogLevel.warn).map(_.message.plainText) + } finally FileUtils.deleteDirectory(tempDir) + } + + private val kotlinYaml = + """projects: + | myapp: + | source-layout: kotlin + | kotlin: + | version: 2.1.20 + | platform: + | name: jvm + |""".stripMargin + + test("kotlin source under src/main/kotlin warns") { + val warns = runCheck(kotlinYaml, List("myapp/src/main/kotlin/test/Foo.kt")) + assert(warns.size === 1, warns) + assert(warns.head.contains("kotlin")) + assert(warns.head.contains("src/main/kotlin")) + } + + test("kotlin source at the correct src/kotlin does not warn") { + val warns = runCheck(kotlinYaml, List("myapp/src/kotlin/test/Foo.kt")) + assert(warns === Nil) + } + + test("empty src/main/kotlin directory does not warn") { + val tempDir = Files.createTempDirectory("bleep-misplaced-empty-") + try { + Files.createDirectories(tempDir.resolve("myapp/src/main/kotlin/test")) + Files.writeString(tempDir.resolve("bleep.yaml"), prelude ++ kotlinYaml) + val existing = BuildLoader.find(tempDir).existing.orThrow + val build = model.Build.FileBacked(existing.buildFile.forceGet.orThrow) + val buildPaths = BuildPaths(cwd = tempDir, existing, model.BuildVariant.Normal) + val logger = ThreadSafeStoringLogger().withMinLogLevel(LogLevel.debug) + CheckMisplacedSources(logger, buildPaths, build) + val warns = logger.underlying.toList.filter(_.metadata.logLevel == LogLevel.warn) + assert(warns === Nil) + } finally FileUtils.deleteDirectory(tempDir) + } + + test("project with explicit sources: override does not warn") { + val yaml = + """projects: + | myapp: + | source-layout: kotlin + | kotlin: + | version: 2.1.20 + | platform: + | name: jvm + | sources: ./src/main/kotlin + |""".stripMargin + val warns = runCheck(yaml, List("myapp/src/main/kotlin/test/Foo.kt")) + assert(warns === Nil) + } + + test("project with sbt-scope: main does not warn") { + val yaml = + """projects: + | myapp: + | source-layout: kotlin + | sbt-scope: main + | kotlin: + | version: 2.1.20 + | platform: + | name: jvm + |""".stripMargin + val warns = runCheck(yaml, List("myapp/src/main/kotlin/test/Foo.kt")) + assert(warns === Nil) + } + + test("java source under src/main/java warns") { + val yaml = + """projects: + | myapp: + | java: {} + | platform: + | name: jvm + |""".stripMargin + val warns = runCheck(yaml, List("myapp/src/main/java/test/Foo.java")) + assert(warns.size === 1, warns) + assert(warns.head.contains("java")) + assert(warns.head.contains("src/main/java")) + } + + test("scala source under src/main/scala warns") { + val yaml = + """projects: + | myapp: + | scala: + | version: 3.3.3 + | platform: + | name: jvm + |""".stripMargin + val warns = runCheck(yaml, List("myapp/src/main/scala/test/Foo.scala")) + assert(warns.size === 1, warns) + assert(warns.head.contains("scala")) + assert(warns.head.contains("src/main/scala")) + } + + test("src/test/ is also caught") { + val warns = runCheck(kotlinYaml, List("myapp/src/test/kotlin/test/Foo.kt")) + assert(warns.size === 1, warns) + assert(warns.head.contains("src/test/kotlin")) + } +}