diff --git a/bleep-bsp-tests/src/scala/bleep/analysis/BspTestHarness.scala b/bleep-bsp-tests/src/scala/bleep/analysis/BspTestHarness.scala index c0239bcca..f60c79905 100644 --- a/bleep-bsp-tests/src/scala/bleep/analysis/BspTestHarness.scala +++ b/bleep-bsp-tests/src/scala/bleep/analysis/BspTestHarness.scala @@ -222,7 +222,8 @@ class BspTestHarness(workspaceRoot: Path, projectConfigs: Option[List[BspTestHar buildCache = new BuildCache(bleep.model.BspServerConfig.default.maxCachedWorkspacesFor(Runtime.getRuntime.maxMemory()), harnessAnalysisCache), analysisCache = harnessAnalysisCache, daemonInfo = DaemonInfo.inProcess(bleep.model.BspServerConfig.default), - connId = 1 + connId = 1, + configOverride = None ) val buildPayload: Option[BspBuildData.Payload] = diff --git a/bleep-bsp-tests/src/scala/bleep/analysis/BuildStateReducerTest.scala b/bleep-bsp-tests/src/scala/bleep/analysis/BuildStateReducerTest.scala index e38e2485f..b31c8200c 100644 --- a/bleep-bsp-tests/src/scala/bleep/analysis/BuildStateReducerTest.scala +++ b/bleep-bsp-tests/src/scala/bleep/analysis/BuildStateReducerTest.scala @@ -214,4 +214,80 @@ class BuildStateReducerTest extends AnyFunSuite with Matchers { state.runningSuites shouldBe empty state.runningTests shouldBe empty } + + // ========================================================================== + // noOp — "did anything actually recompile" + // ========================================================================== + + private def compiled(project: String, reason: bleep.bsp.protocol.CompileReason): Seq[BuildEvent] = + Seq( + BuildEvent.CompileStarted(cpn(project), ts), + BuildEvent.CompilationReason(cpn(project), reason, totalFiles = 1, invalidatedFiles = Nil, changedDependencies = Nil, ts + 1), + BuildEvent.CompileFinished( + cpn(project), + bleep.bsp.protocol.CompileStatus.Success, + durationMs = 5, + timestamp = ts + 2, + diagnostics = Nil, + skippedBecause = None + ) + ) + + private def summaryOf(events: Seq[BuildEvent]): BuildSummary = + events.foldLeft(BuildState.empty)(BuildStateReducer.reduce).toSummary(durationMs = 0, wasCancelled = false) + + test("noOp: every compile up to date") { + val summary = summaryOf(compiled("a", bleep.bsp.protocol.CompileReason.UpToDate) ++ compiled("b", bleep.bsp.protocol.CompileReason.UpToDate)) + summary.upToDateProjects shouldBe List(cpn("a"), cpn("b")) + summary.noOp shouldBe true + } + + test("noOp: one project recompiled is enough to make the run not a no-op") { + // The asymmetry is the point. A caller uses this to decide whether it may skip a deploy, so a single project that really compiled has to outweigh any + // number that did not. + val summary = summaryOf(compiled("a", bleep.bsp.protocol.CompileReason.UpToDate) ++ compiled("b", bleep.bsp.protocol.CompileReason.Incremental)) + summary.upToDateProjects shouldBe List(cpn("a")) + summary.noOp shouldBe false + } + + test("noOp: a run in which nothing compiled is not a no-op") { + // There was no compile to be a no-op about. Answering true here would tell a deploy script it may skip on the strength of a run that never looked. + summaryOf(Nil).noOp shouldBe false + } + // ========================================================================== + // linkedOutputs — where the link put things + // ========================================================================== + + test("linkedOutputs: the linker's own file list is kept, main artifact first") { + // Kept rather than recomputed, because the directory layout under `link-output/` is bleep's and has been renamed before. A caller reconstructing it is + // guessing; this is the linker's own answer. + val summary = summaryOf( + Seq( + BuildEvent.LinkStarted(cpn("frontend"), bleep.bsp.protocol.LinkPlatformName.ScalaJs, ts), + BuildEvent.LinkSucceeded( + cpn("frontend"), + bleep.bsp.protocol.LinkPlatformName.ScalaJs, + durationMs = 12, + generatedFiles = List("/out/js/main.js", "/out/js/main.js.map"), + ts + 1 + ) + ) + ) + + summary.linkedOutputs.map(_.project) shouldBe List(cpn("frontend")) + summary.linkedOutputs.head.mainArtifact shouldBe java.nio.file.Path.of("/out/js/main.js") + summary.linkedOutputs.head.files should have size 2 + } + + test("linkedOutputs: a failed link contributes nothing to report") { + val summary = summaryOf( + Seq( + BuildEvent.LinkStarted(cpn("frontend"), bleep.bsp.protocol.LinkPlatformName.ScalaJs, ts), + BuildEvent.LinkFailed(cpn("frontend"), bleep.bsp.protocol.LinkPlatformName.ScalaJs, durationMs = 3, error = "boom", ts + 1) + ) + ) + + summary.linkedOutputs shouldBe empty + summary.linkFailures should have size 1 + } } diff --git a/bleep-bsp-tests/src/scala/bleep/analysis/CrossPlatformIntegrationTest.scala b/bleep-bsp-tests/src/scala/bleep/analysis/CrossPlatformIntegrationTest.scala index d18f716c9..683438c3e 100644 --- a/bleep-bsp-tests/src/scala/bleep/analysis/CrossPlatformIntegrationTest.scala +++ b/bleep-bsp-tests/src/scala/bleep/analysis/CrossPlatformIntegrationTest.scala @@ -192,12 +192,9 @@ class CrossPlatformIntegrationTest extends AnyFunSuite with Matchers { KotlinJs( moduleKind = Some(KotlinJsModuleKind.ESModule), moduleName = Some("mymodule"), - outputMode = Some(KotlinJsOutputMode.JsExecutable), sourceMap = Some(true), sourceMapPrefix = None, sourceMapEmbedSources = None, - target = Some(KotlinJsTarget.Node), - developmentMode = Some(false), generateDts = Some(true) ) ), diff --git a/bleep-bsp-tests/src/scala/bleep/analysis/KotlinJsIntegrationTest.scala b/bleep-bsp-tests/src/scala/bleep/analysis/KotlinJsIntegrationTest.scala index 4eb3dc5e7..98e14901f 100644 --- a/bleep-bsp-tests/src/scala/bleep/analysis/KotlinJsIntegrationTest.scala +++ b/bleep-bsp-tests/src/scala/bleep/analysis/KotlinJsIntegrationTest.scala @@ -65,7 +65,6 @@ class KotlinJsIntegrationTest extends AnyFunSuite with Matchers { val config = KotlinJsCompilerConfig.ForTests config.kotlinVersion shouldBe bleep.model.Versions.Kotlin23 config.moduleKind shouldBe KotlinJsCompilerConfig.ModuleKind.CommonJS - config.outputMode shouldBe KotlinJsCompilerConfig.OutputMode.JsExecutable config.sourceMap shouldBe true config.developmentMode shouldBe true config.generateDts shouldBe false @@ -79,22 +78,12 @@ class KotlinJsIntegrationTest extends AnyFunSuite with Matchers { KotlinJsCompilerConfig.ModuleKind.ESModule.name shouldBe "es" } - test("KotlinJsCompilerConfig: output modes") { - KotlinJsCompilerConfig.OutputMode.Klib.name shouldBe "klib" - KotlinJsCompilerConfig.OutputMode.JsExecutable.name shouldBe "js" - } - test("KotlinJsCompilerConfig: source map embed sources") { KotlinJsCompilerConfig.SourceMapEmbedSources.Never.name shouldBe "never" KotlinJsCompilerConfig.SourceMapEmbedSources.Always.name shouldBe "always" KotlinJsCompilerConfig.SourceMapEmbedSources.Inlining.name shouldBe "inlining" } - test("KotlinJsCompilerConfig: targets") { - KotlinJsCompilerConfig.Target.Browser.name shouldBe "browser" - KotlinJsCompilerConfig.Target.Node.name shouldBe "nodejs" - } - // ============================================================================ // KotlinJsCompileResult Tests // ============================================================================ @@ -176,8 +165,7 @@ class KotlinJsAdvancedIntegrationTest extends AnyFunSuite with Matchers with Pla ) val config = KotlinJsCompilerConfig.ForTests.copy( - moduleName = "hello", - outputMode = KotlinJsCompilerConfig.OutputMode.JsExecutable + moduleName = "hello" ) val errors = scala.collection.mutable.ListBuffer[CompilerError]() @@ -258,8 +246,7 @@ class KotlinJsAdvancedIntegrationTest extends AnyFunSuite with Matchers with Pla ) val config = KotlinJsCompilerConfig.ForTests.copy( - moduleName = "mylib", - outputMode = KotlinJsCompilerConfig.OutputMode.Klib + moduleName = "mylib" ) val listener = new DiagnosticListener { diff --git a/bleep-bsp-tests/src/scala/bleep/analysis/LinkDagIntegrationTest.scala b/bleep-bsp-tests/src/scala/bleep/analysis/LinkDagIntegrationTest.scala index 24664a0eb..c5ae08a37 100644 --- a/bleep-bsp-tests/src/scala/bleep/analysis/LinkDagIntegrationTest.scala +++ b/bleep-bsp-tests/src/scala/bleep/analysis/LinkDagIntegrationTest.scala @@ -398,7 +398,10 @@ class LinkDagIntegrationTest extends AnyFunSuite with Matchers { LinkPlatform.Jvm shouldBe a[LinkPlatform] LinkPlatform.ScalaJs("1.16.0", "3.3.3", ScalaJsLinkConfig.Debug) shouldBe a[LinkPlatform.ScalaJs] LinkPlatform.ScalaNative("0.5.6", "3.3.3", ScalaNativeLinkConfig.Debug) shouldBe a[LinkPlatform.ScalaNative] - LinkPlatform.KotlinJs("2.0.0", TaskDag.KotlinJsConfig(bleep.model.KotlinJsModuleKind.CommonJS, true, false, java.nio.file.Path.of("."))) shouldBe a[ + LinkPlatform.KotlinJs( + "2.0.0", + TaskDag.KotlinJsConfig(bleep.model.KotlinJsModuleKind.CommonJS, None, true, None, bleep.model.KotlinJsSourceMapEmbedSources.Never, false, false) + ) shouldBe a[ LinkPlatform.KotlinJs ] LinkPlatform.KotlinNative("2.0.0", TaskDag.KotlinNativeConfig("linux-x64", true, false, false)) shouldBe a[LinkPlatform.KotlinNative] diff --git a/bleep-bsp-tests/src/scala/bleep/analysis/PlatformCancellationTest.scala b/bleep-bsp-tests/src/scala/bleep/analysis/PlatformCancellationTest.scala index 1b3b39215..fd18be3b4 100644 --- a/bleep-bsp-tests/src/scala/bleep/analysis/PlatformCancellationTest.scala +++ b/bleep-bsp-tests/src/scala/bleep/analysis/PlatformCancellationTest.scala @@ -60,11 +60,9 @@ class PlatformCancellationTest extends AnyFunSuite with Matchers { kotlinVersion = "2.3.0", moduleName = "test-module", moduleKind = KotlinJsCompilerConfig.ModuleKind.CommonJS, - outputMode = KotlinJsCompilerConfig.OutputMode.JsExecutable, sourceMap = false, sourceMapPrefix = None, sourceMapEmbedSources = KotlinJsCompilerConfig.SourceMapEmbedSources.Never, - target = KotlinJsCompilerConfig.Target.Node, developmentMode = true, generateDts = false, additionalOptions = Seq.empty @@ -111,11 +109,9 @@ class PlatformCancellationTest extends AnyFunSuite with Matchers { kotlinVersion = "2.3.0", moduleName = "test-module", moduleKind = KotlinJsCompilerConfig.ModuleKind.CommonJS, - outputMode = KotlinJsCompilerConfig.OutputMode.JsExecutable, sourceMap = false, sourceMapPrefix = None, sourceMapEmbedSources = KotlinJsCompilerConfig.SourceMapEmbedSources.Never, - target = KotlinJsCompilerConfig.Target.Node, developmentMode = true, generateDts = false, additionalOptions = Seq.empty diff --git a/bleep-bsp-tests/src/scala/bleep/bsp/BleepStatusEndpointTest.scala b/bleep-bsp-tests/src/scala/bleep/bsp/BleepStatusEndpointTest.scala index 73090b98f..f6318c5e5 100644 --- a/bleep-bsp-tests/src/scala/bleep/bsp/BleepStatusEndpointTest.scala +++ b/bleep-bsp-tests/src/scala/bleep/bsp/BleepStatusEndpointTest.scala @@ -52,7 +52,8 @@ class BleepStatusEndpointTest extends AnyFunSuite with Matchers { buildCache = new BuildCache(4, analysisCache), analysisCache = analysisCache, daemonInfo = daemonInfo, - connId = 17 + connId = 17, + configOverride = None ) private val thread = new Thread(() => server.run(), "status-endpoint-test-server") diff --git a/bleep-bsp-tests/src/scala/bleep/bsp/CopyStateEndpointTest.scala b/bleep-bsp-tests/src/scala/bleep/bsp/CopyStateEndpointTest.scala index e1eeed937..cba0684e3 100644 --- a/bleep-bsp-tests/src/scala/bleep/bsp/CopyStateEndpointTest.scala +++ b/bleep-bsp-tests/src/scala/bleep/bsp/CopyStateEndpointTest.scala @@ -91,7 +91,8 @@ class CopyStateEndpointTest extends AnyFunSuite with Matchers { buildCache = new BuildCache(4, analysisCache), analysisCache = analysisCache, daemonInfo = daemonInfo, - connId = 17 + connId = 17, + configOverride = None ) private val thread = new Thread(() => server.run(), "copy-state-endpoint-test-server") diff --git a/bleep-bsp/src/scala/bleep/analysis/KotlinJsCompiler.scala b/bleep-bsp/src/scala/bleep/analysis/KotlinJsCompiler.scala index be2321f65..6ce349f1e 100644 --- a/bleep-bsp/src/scala/bleep/analysis/KotlinJsCompiler.scala +++ b/bleep-bsp/src/scala/bleep/analysis/KotlinJsCompiler.scala @@ -413,17 +413,19 @@ object KotlinJsLinker { klibs: Seq[Path], outputDir: Path, config: KotlinJsCompilerConfig, + projectKlibName: String, diagnosticListener: DiagnosticListener, cancellation: CancellationToken ): IO[bleep.bsp.Outcome.ThreadOutcome[KotlinJsLinkResult]] = bleep.bsp.Outcome.runInFreshThread[KotlinJsLinkResult](name = "kotlin-js-linker", contextClassLoader = None, cancellation = cancellation) { - linkBlocking(klibs, outputDir, config, diagnosticListener, cancellation) + linkBlocking(klibs, outputDir, config, projectKlibName, diagnosticListener, cancellation) } private def linkBlocking( klibs: Seq[Path], outputDir: Path, config: KotlinJsCompilerConfig, + projectKlibName: String, diagnosticListener: DiagnosticListener, cancellation: CancellationToken ): KotlinJsLinkResult = { @@ -448,10 +450,14 @@ object KotlinJsLinker { // For linking, we pass KLIBs via includes field (the main KLIB to link) and libraries (dependencies) // The first KLIB is the main one to link, others are dependencies + // Which of these is the project's own KLIB, matched on the *project's* name rather than on `config.moduleName`. + // + // Those were the same string until `kotlin.js.moduleName` became a setting a build can change, and then they were not: the KLIB on disk is named after + // the project, so a project naming its module something else matched nothing, `includes` was never set, and the link failed with no diagnostic. One + // string was doing two jobs — naming the output, and identifying a file — and only one of them is the user's to choose. val (mainKlib, depKlibs) = klibs.partition { p => - // The main KLIB is typically the one matching the module name - p.getFileName.toString.contains(config.moduleName.replace("_", "-")) || - p.getFileName.toString.contains(config.moduleName) + p.getFileName.toString.contains(projectKlibName.replace("_", "-")) || + p.getFileName.toString.contains(projectKlibName) } // Set main KLIB via includes field (not freeArgs which is interpreted as sources) @@ -550,9 +556,12 @@ object KotlinJsLinker { return KotlinJsLinkResult(outputDir, None, exitCode) } - // Find the JS output file - val jsFile = outputDir.resolve(s"${config.moduleName}.js") - val jsFileOpt = if (Files.exists(jsFile)) Some(jsFile) else None + // Find the JS output file. + // + // `.mjs` as well as `.js`, because Kotlin names its output after the module kind: `moduleKind = es` emits `.mjs`, every other kind emits + // `.js`. Looking only for `.js` meant an ES link that the compiler had completed successfully came back with no file, and the caller reported + // that as "Kotlin/JS linking failed" with no diagnostics — the compiler had nothing to complain about. + val jsFileOpt = List(".js", ".mjs").map(ext => outputDir.resolve(config.moduleName + ext)).find(Files.exists(_)) KotlinJsLinkResult(outputDir, jsFileOpt, exitCode) } catch { diff --git a/bleep-bsp/src/scala/bleep/analysis/KotlinJsCompilerConfig.scala b/bleep-bsp/src/scala/bleep/analysis/KotlinJsCompilerConfig.scala index cc5ae6db5..59ba1814f 100644 --- a/bleep-bsp/src/scala/bleep/analysis/KotlinJsCompilerConfig.scala +++ b/bleep-bsp/src/scala/bleep/analysis/KotlinJsCompilerConfig.scala @@ -10,11 +10,9 @@ case class KotlinJsCompilerConfig( kotlinVersion: String, moduleKind: KotlinJsCompilerConfig.ModuleKind, moduleName: String, - outputMode: KotlinJsCompilerConfig.OutputMode, sourceMap: Boolean, sourceMapPrefix: Option[String], sourceMapEmbedSources: KotlinJsCompilerConfig.SourceMapEmbedSources, - target: KotlinJsCompilerConfig.Target, developmentMode: Boolean, generateDts: Boolean, additionalOptions: Seq[String] @@ -34,15 +32,6 @@ object KotlinJsCompilerConfig { case object ESModule extends ModuleKind { val name = "es" } } - /** Output mode. */ - sealed trait OutputMode { - def name: String - } - object OutputMode { - case object Klib extends OutputMode { val name = "klib" } - case object JsExecutable extends OutputMode { val name = "js" } - } - /** Source map embedding. */ sealed trait SourceMapEmbedSources { def name: String @@ -53,15 +42,6 @@ object KotlinJsCompilerConfig { case object Inlining extends SourceMapEmbedSources { val name = "inlining" } } - /** Target environment. */ - sealed trait Target { - def name: String - } - object Target { - case object Browser extends Target { val name = "browser" } - case object Node extends Target { val name = "nodejs" } - } - /** A ready-made config for tests, not a default anything reads at build time. Production builds this explicitly in `ProjectCompiler` from the project's own * `kotlin.version`; nothing here is consulted. It was called `Default` and sat next to real defaults, which reads as "what a project gets when it says * nothing" — it is not, and that misreading is easy to act on. @@ -70,11 +50,9 @@ object KotlinJsCompilerConfig { kotlinVersion = bleep.model.Versions.Kotlin23, moduleKind = ModuleKind.CommonJS, moduleName = "main", - outputMode = OutputMode.JsExecutable, sourceMap = true, sourceMapPrefix = None, sourceMapEmbedSources = SourceMapEmbedSources.Never, - target = Target.Node, developmentMode = true, generateDts = false, additionalOptions = Seq.empty diff --git a/bleep-bsp/src/scala/bleep/analysis/ProjectCompiler.scala b/bleep-bsp/src/scala/bleep/analysis/ProjectCompiler.scala index 16df1a25c..0458dac49 100644 --- a/bleep-bsp/src/scala/bleep/analysis/ProjectCompiler.scala +++ b/bleep-bsp/src/scala/bleep/analysis/ProjectCompiler.scala @@ -571,13 +571,13 @@ object KotlinJsProjectCompiler extends ProjectCompiler { val jsConfig = KotlinJsCompilerConfig( kotlinVersion = kt.kotlinVersion, + // The compile phase produces a KLIB, not a program: module kind, source-map shape and `.d.ts` generation are decided at link time, where the project's + // `kotlin.js` settings are read. Nothing here is a user-facing choice. moduleKind = KotlinJsCompilerConfig.ModuleKind.CommonJS, moduleName = config.name.replace("-", "_"), - outputMode = KotlinJsCompilerConfig.OutputMode.JsExecutable, sourceMap = true, sourceMapPrefix = None, sourceMapEmbedSources = KotlinJsCompilerConfig.SourceMapEmbedSources.Never, - target = KotlinJsCompilerConfig.Target.Node, developmentMode = true, generateDts = false, additionalOptions = kt.kotlinOptions diff --git a/bleep-bsp/src/scala/bleep/bsp/BspServerDaemon.scala b/bleep-bsp/src/scala/bleep/bsp/BspServerDaemon.scala index 470acfaf7..5dc488dd2 100644 --- a/bleep-bsp/src/scala/bleep/bsp/BspServerDaemon.scala +++ b/bleep-bsp/src/scala/bleep/bsp/BspServerDaemon.scala @@ -547,7 +547,9 @@ object BspServerDaemon { buildCache = buildCache, analysisCache = analysisCache, daemonInfo = daemonInfo, - connId = connId + connId = connId, + // The real daemon re-reads the user config per request on purpose; see `configOverride`. + configOverride = None ) // Run server message loop diff --git a/bleep-bsp/src/scala/bleep/bsp/InProcessBspServer.scala b/bleep-bsp/src/scala/bleep/bsp/InProcessBspServer.scala index 94c7c63da..1ca640f40 100644 --- a/bleep-bsp/src/scala/bleep/bsp/InProcessBspServer.scala +++ b/bleep-bsp/src/scala/bleep/bsp/InProcessBspServer.scala @@ -1,6 +1,8 @@ package bleep.bsp import cats.effect.{IO, Resource} + +import scala.concurrent.duration.* import ryddig.Logger import java.util.concurrent.CompletableFuture @@ -11,7 +13,15 @@ import java.util.concurrent.CompletableFuture */ object InProcessBspServer { - def connect(logger: Logger): Resource[IO, BspConnection] = + /** An in-process server, given the config it should use and a governor it should share. + * + * Both used to be invented here: the config was read from the developer's own `config.yaml` per request, and a `MachineResources` was built `forThisMachine` + * — sized for every core and most of the RAM — once per connection. A test suite running many of these concurrently therefore had as many governors as + * connections, each admitting forks as though it were the machine's only tenant, which is the opposite of what a governor is for. + * + * Callers now supply both, so a suite can share one governor across everything it runs and mean what it says about heaps and parallelism. + */ + def connect(config: bleep.model.BleepConfig, machine: bleep.MachineResources)(logger: Logger): Resource[IO, BspConnection] = Resource.make( IO.blocking { // Two pipes carry the two directions. A megabyte of slack keeps a sourcegen run that logs faster than the @@ -30,8 +40,6 @@ object InProcessBspServer { override def run(): Unit = { var exitCode: java.lang.Integer = 0 try { - val numCores = Runtime.getRuntime.availableProcessors() - val machine = bleep.MachineResources.forThisMachine(totalCpu = numCores, logger = logger) val inProcessAnalysisCache = new bleep.analysis.AnalysisCache // One server per in-process run, so fresh daemon-scoped state is correct here. val server = @@ -45,8 +53,9 @@ object InProcessBspServer { buildCache = new BuildCache(bleep.model.BspServerConfig.default.maxCachedWorkspacesFor(Runtime.getRuntime.maxMemory()), inProcessAnalysisCache), analysisCache = inProcessAnalysisCache, - daemonInfo = DaemonInfo.inProcess(bleep.model.BspServerConfig.default), - connId = 1 + daemonInfo = DaemonInfo.inProcess(config.bspServerConfigOrDefault), + connId = 1, + configOverride = Some(config) ) server.run() } catch { @@ -74,11 +83,23 @@ object InProcessBspServer { exited: CompletableFuture[java.lang.Integer] ) extends BspConnection { def serverExited: IO[Int] = IO.fromCompletableFuture(IO.pure(exited)).map(_.intValue) - def close: IO[Unit] = IO.blocking { - try output.close() - catch { case _: Exception => () } - try input.close() - catch { case _: Exception => () } - } + + /** Close the transport, then wait for the server thread to finish unwinding. + * + * Closing the pipes is what tells `run()` to return, but nothing used to wait for it — so a test could finish, and start the next one, while the previous + * server was still cancelling requests and killing its child processes. The wait is bounded: a server that will not come down must not hang the suite that + * is trying to leave. + */ + def close: IO[Unit] = + IO.blocking { + try output.close() + catch { case _: Exception => () } + try input.close() + catch { case _: Exception => () } + } >> IO + .fromCompletableFuture(IO.pure(exited)) + .timeout(30.seconds) + .void + .handleError(_ => ()) } } diff --git a/bleep-bsp/src/scala/bleep/bsp/LinkExecutor.scala b/bleep-bsp/src/scala/bleep/bsp/LinkExecutor.scala index a2b5c4837..ece88946e 100644 --- a/bleep-bsp/src/scala/bleep/bsp/LinkExecutor.scala +++ b/bleep-bsp/src/scala/bleep/bsp/LinkExecutor.scala @@ -4,7 +4,7 @@ import bleep.analysis._ import bleep.bsp.protocol.KillReason import bleep.bsp.TaskDag._ import bleep.bsp.protocol.ProcessExit -import bleep.model.KotlinJsModuleKind +import bleep.model.{KotlinJsModuleKind, KotlinJsSourceMapEmbedSources} import cats.effect.{Deferred, IO} import java.nio.file.{Files, Path} import scala.jdk.CollectionConverters._ @@ -371,17 +371,22 @@ object LinkExecutor { case KotlinJsModuleKind.Plain => KotlinJsCompilerConfig.ModuleKind.Plain } + val embedSources = platform.config.sourceMapEmbedSources match { + case KotlinJsSourceMapEmbedSources.Never => KotlinJsCompilerConfig.SourceMapEmbedSources.Never + case KotlinJsSourceMapEmbedSources.Always => KotlinJsCompilerConfig.SourceMapEmbedSources.Always + case KotlinJsSourceMapEmbedSources.Inlining => KotlinJsCompilerConfig.SourceMapEmbedSources.Inlining + } + val config = KotlinJsCompilerConfig( kotlinVersion = platform.version, - moduleName = moduleName, + // The project's name for the module when it gave one; otherwise the project's own name, which is what it has always been. + moduleName = platform.config.moduleName.getOrElse(moduleName), moduleKind = moduleKind, - outputMode = KotlinJsCompilerConfig.OutputMode.JsExecutable, sourceMap = platform.config.sourceMap, - sourceMapPrefix = None, - sourceMapEmbedSources = KotlinJsCompilerConfig.SourceMapEmbedSources.Never, - target = KotlinJsCompilerConfig.Target.Node, + sourceMapPrefix = platform.config.sourceMapPrefix, + sourceMapEmbedSources = embedSources, developmentMode = !platform.config.dce, // DCE requires production mode - generateDts = false, + generateDts = platform.config.generateDts, additionalOptions = Seq.empty ) @@ -395,7 +400,7 @@ object LinkExecutor { } KotlinJsLinker - .link(klibs, jsOutputDir, config, diagnosticListener, cancellation) + .link(klibs, jsOutputDir, config, projectKlibName = moduleName, diagnosticListener, cancellation) .map { case Outcome.ThreadOutcome.Completed(result) => logger.debug(s"[LINK] Kotlin/JS link result: isSuccess=${result.isSuccess}, jsFile=${result.jsFile}") @@ -410,8 +415,20 @@ object LinkExecutor { val sourceMap = allFiles.find(_.toString.endsWith(".map")) (TaskResult.Success, LinkResult.JsSuccess(result.jsFile.get, sourceMap, allFiles, wasUpToDate = false)) } else { - logger.error(s"[LINK] Kotlin/JS linking failed") - (TaskResult.Failure("Kotlin/JS linking failed", List.empty), LinkResult.Failure("Linking failed", List.empty)) + // Two different failures used to share one message and one empty diagnostic list. A compiler that reported errors is one thing; a compiler that + // exited 0 while bleep could not find what it wrote is a bleep problem, and saying "linking failed" for it sends the reader to look for a + // compile error that was never emitted. + val reason = + if (result.exitCode != 0) "Kotlin/JS linking failed" + else { + val found = scala.util + .Using(Files.list(jsOutputDir))(_.iterator().asScala.map(_.getFileName.toString).toList.sorted) + .getOrElse(Nil) + val listed = if (found.isEmpty) "the directory is empty" else found.mkString(", ") + s"Kotlin/JS linker reported success but produced no module in $jsOutputDir ($listed)" + } + logger.error(s"[LINK] $reason") + (TaskResult.Failure(reason, List.empty), LinkResult.Failure(reason, List.empty)) } case Outcome.ThreadOutcome.Cancelled(reason) => (TaskResult.Killed(reason), LinkResult.Cancelled) diff --git a/bleep-bsp/src/scala/bleep/bsp/MultiWorkspaceBspServer.scala b/bleep-bsp/src/scala/bleep/bsp/MultiWorkspaceBspServer.scala index b8843b965..6728b4576 100644 --- a/bleep-bsp/src/scala/bleep/bsp/MultiWorkspaceBspServer.scala +++ b/bleep-bsp/src/scala/bleep/bsp/MultiWorkspaceBspServer.scala @@ -69,7 +69,15 @@ class MultiWorkspaceBspServer( buildCache: BuildCache, analysisCache: bleep.analysis.AnalysisCache, daemonInfo: DaemonInfo, - connId: Int + connId: Int, + /** The config to use instead of reading the user's `config.yaml`, when a caller already has one. + * + * Empty for the real daemon, which re-reads the file per request on purpose — `parallelism`, the heaps and the idle timeout are machine settings, and + * picking up an edit without a restart is the point. It is set by the in-process server, whose caller has a config it means: bleep's own integration tests + * were carefully setting `parallelism`, `testRunnerHeap` and `kspRunnerMaxMemory` and having every one of them ignored in favour of whatever the developer + * running the suite happened to have in their own file. + */ + configOverride: Option[model.BleepConfig] ) { import MultiWorkspaceBspServer.DebugLogging @@ -1008,7 +1016,7 @@ class MultiWorkspaceBspServer( "The server does not resolve builds itself, so there is nothing to compile these from." ) ) - bleepConfig <- BleepConfigOps.loadOrDefault(userPaths) + bleepConfig <- configOverride.map(Right(_)).getOrElse(BleepConfigOps.loadOrDefault(userPaths)) } yield { val pre = Prebootstrapped( logger = logger, @@ -1775,9 +1783,9 @@ class MultiWorkspaceBspServer( val recorder = new TranscriptRecorder registerOperation(workspace, taskId, opLabel, projectsToCompile.map(_.value), cancellation, params.originId, recorder) IO.defer { - // Re-read user config fresh before starting (allows runtime config changes) + // Re-read user config fresh before starting (allows runtime config changes), unless a caller handed us one — see `configOverride`. val userPaths = UserPaths.fromAppDirs - val freshConfig = BleepConfigOps.loadOrDefault(userPaths).getOrElse(model.BleepConfig.default) + val freshConfig = configOverride.getOrElse(BleepConfigOps.loadOrDefault(userPaths).getOrElse(model.BleepConfig.default)) val serverConfig = freshConfig.bspServerConfigOrDefault // Sizes are resolved here, once, so a task can declare the same heap the fork is started with. val forkHeaps = TaskDag.ForkHeaps( @@ -1818,20 +1826,13 @@ class MultiWorkspaceBspServer( (platformOpt, isKotlin) match { case (Some(model.PlatformId.Js), true) => // Kotlin/JS - val projectPaths = started.projectPaths(crossName) - val outputDir = projectPaths.targetDir.resolve("link-output").resolve("js") - val moduleKind = linkOpts.moduleKind - .map { - case "esmodule" => model.KotlinJsModuleKind.ESModule - case "nomodule" => model.KotlinJsModuleKind.Plain - case _ => model.KotlinJsModuleKind.CommonJS - } - .getOrElse(model.KotlinJsModuleKind.CommonJS) - val config = TaskDag.KotlinJsConfig( + val moduleKind = kotlinJsModuleKind(project, linkOpts.moduleKind) + val config = kotlinJsConfig( + project, moduleKind = moduleKind, - sourceMap = linkOpts.sourceMaps.getOrElse(!isRelease), - dce = linkOpts.optimize.getOrElse(isRelease), - outputDir = outputDir + sourceMap = linkOpts.sourceMaps, + sourceMapDefault = !isRelease, + dce = linkOpts.optimize.getOrElse(isRelease) ) Some(crossName -> TaskDag.LinkPlatform.KotlinJs(kotlinVersion, config)) @@ -1850,23 +1851,7 @@ class MultiWorkspaceBspServer( emitSourceMaps = linkOpts.sourceMaps.getOrElse(baseConfig.emitSourceMaps), minify = linkOpts.minify.getOrElse(baseConfig.minify), optimizer = linkOpts.optimize.getOrElse(baseConfig.optimizer), - // `--module-kind` first, then the project's own `jsKind`, and only then the constant. - // - // The project was never consulted: a build declaring `jsKind: esmodule` got a CommonJS link and no flag was needed to cause it, because the - // fallback was a hardcoded `CommonJSModule`. That also quietly disarmed the Closure rule below, which skips Closure for ESModule output because - // Scala.js rejects the pairing — a yaml-declared ESModule project reached it looking like CommonJS. - moduleKind = linkOpts.moduleKind - .map { - case "nomodule" => ScalaJsLinkConfig.ModuleKind.NoModule - case "esmodule" => ScalaJsLinkConfig.ModuleKind.ESModule - case _ => ScalaJsLinkConfig.ModuleKind.CommonJSModule - } - .orElse(project.platform.flatMap(_.jsKind).map { - case model.ModuleKindJS.NoModule => ScalaJsLinkConfig.ModuleKind.NoModule - case model.ModuleKindJS.CommonJSModule => ScalaJsLinkConfig.ModuleKind.CommonJSModule - case model.ModuleKindJS.ESModule => ScalaJsLinkConfig.ModuleKind.ESModule - }) - .getOrElse(baseConfig.moduleKind) + moduleKind = scalaJsModuleKind(project, linkOpts.moduleKind, baseConfig.moduleKind) ) Some(crossName -> TaskDag.LinkPlatform.ScalaJs(sjsVersion, scalaVersion, config)) @@ -2337,9 +2322,9 @@ class MultiWorkspaceBspServer( val recorder = new TranscriptRecorder registerOperation(workspace, taskId, "test", testProjects.map(_.value), cancellation, params.originId, recorder) IO.defer { - // Re-read user config fresh before starting (allows runtime config changes) + // Re-read user config fresh before starting (allows runtime config changes), unless a caller handed us one — see `configOverride`. val userPaths = UserPaths.fromAppDirs - val freshConfig = BleepConfigOps.loadOrDefault(userPaths).getOrElse(model.BleepConfig.default) + val freshConfig = configOverride.getOrElse(BleepConfigOps.loadOrDefault(userPaths).getOrElse(model.BleepConfig.default)) val serverConfig = freshConfig.bspServerConfigOrDefault val maxParallelism = serverConfig.effectiveParallelism val forkHeaps = TaskDag.ForkHeaps( @@ -2371,14 +2356,16 @@ class MultiWorkspaceBspServer( (platformOpt, isKotlin) match { case (Some(model.PlatformId.Js), true) => - // Kotlin/JS - don't add "js" here; executeKotlinJs adds it - val projectPaths = started.projectPaths(crossName) - val outputDir = projectPaths.targetDir - val config = TaskDag.KotlinJsConfig( - moduleKind = model.KotlinJsModuleKind.UMD, - sourceMap = false, - dce = false, // Tests run without DCE - outputDir = outputDir + // Kotlin/JS. UMD is not an arbitrary default here, and not the project's to choose: bleep runs Kotlin/JS tests by generating a CommonJS script that + // installs a QUnit mock as a global and then `require`s the linked output. UMD is what satisfies that — an ES module makes `require` throw + // `ERR_REQUIRE_ESM`, and a plain or AMD module does not export what the runner reads. A project declaring one of those is warned rather than + // silently linked as something else; see `kotlinJsTestModuleKind`. + val config = kotlinJsConfig( + project, + moduleKind = kotlinJsTestModuleKind(started, crossName, project), + sourceMap = None, + sourceMapDefault = false, + dce = false // Tests run without DCE ) Some(crossName -> TaskDag.LinkPlatform.KotlinJs(kotlinVersion, config)) @@ -2390,7 +2377,11 @@ class MultiWorkspaceBspServer( .getOrElse(throw new IllegalStateException(s"Scala.js version not found for ${crossName.value}")) val scalaVersion = project.scala.flatMap(_.version).map(_.scalaVersion).getOrElse(throw new IllegalStateException(s"Scala version not found for ${crossName.value}")) - val config = bleep.analysis.ScalaJsLinkConfig.Debug + // Debug semantics for a test link are deliberate — nobody wants their tests run through the optimizer — but the module kind is not a semantics + // choice, it is what the build declared. `Debug` carries `CommonJSModule`, and taking that wholesale meant a project declaring `jsKind: esmodule` + // had its tests linked as CommonJS with nothing to say so. + val base = bleep.analysis.ScalaJsLinkConfig.Debug + val config = base.copy(moduleKind = scalaJsModuleKind(project, None, base.moduleKind)) Some(crossName -> TaskDag.LinkPlatform.ScalaJs(sjsVersion, scalaVersion, config)) case (Some(model.PlatformId.Native), true) => @@ -2594,7 +2585,14 @@ class MultiWorkspaceBspServer( case (Some(model.PlatformId.Js), true) => runKotlinJsTestSuite(started, testTask, linkedArtifactOf(testTask.project, linkResult), testEnv, eventQueue, taskKillSignal) case (Some(model.PlatformId.Js), false) => - runScalaJsTestSuite(started, testTask, classpath, testEnv, linkResult, eventQueue, taskKillSignal) + // Read back off the platform this run's DAG was built with, so the runner is told how the program was emitted rather than deciding for + // itself. A missing entry means a Scala.js test project reached the handler without a link node, which is a broken DAG, not a default. + val moduleKind = platforms.get(testTask.project) match { + case Some(TaskDag.LinkPlatform.ScalaJs(_, _, config)) => config.moduleKind + case other => + throw new IllegalStateException(s"No Scala.js link platform for test project ${testTask.project.value}, got $other") + } + runScalaJsTestSuite(started, testTask, classpath, testEnv, linkResult, moduleKind, eventQueue, taskKillSignal) case (Some(model.PlatformId.Native), true) => runKotlinNativeTestSuite(started, testTask, linkedArtifactOf(testTask.project, linkResult), testEnv, eventQueue, taskKillSignal) case (Some(model.PlatformId.Native), false) => @@ -2644,7 +2642,9 @@ class MultiWorkspaceBspServer( IO.blocking(getTestClasspath(started, linkTask.project)).flatMap { classpath => val projectPaths = started.projectPaths(linkTask.project) val logger = createLinkLogger() - val outputDir = projectPaths.targetDir + // The same base directory the compile/link path uses. These were `targetDir/link-output` and `targetDir`, so `bleep link mytest` and `bleep + // test mytest` linked the same project into two trees, each with its own up-to-date check that could not see the other's output. + val outputDir = projectPaths.targetDir.resolve("link-output") withLinkMetrics(linkTask, started.buildPaths.buildDir.toString) { LinkExecutor.execute(linkTask, classpath.map(_.toAbsolutePath), None, outputDir, logger, killSignal) } @@ -3560,6 +3560,99 @@ class MultiWorkspaceBspServer( private def nodeBinaryFor(started: Started, project: model.Project): String = started.pre.fetchNode(project.platform.flatMap(_.jsNodeVersion).getOrElse(bleep.constants.Node)).toAbsolutePath.toString + /** The module kind a Scala.js link emits for `project`: an explicit `--module-kind` first, then the project's own `jsKind`, and only then the base + * configuration's own default. + * + * One function for the main link and the test link because there were two, and they disagreed. The test path declared `ScalaJsLinkConfig.Debug` and took its + * `CommonJSModule` along with the debug semantics it actually wanted, so a build declaring `jsKind: esmodule` had its tests linked as CommonJS and no flag + * could change it. The test path passes `None` for the flag — `bleep test` accepts no link options — and so always gets what the build declared. + */ + private def scalaJsModuleKind( + project: model.Project, + fromFlag: Option[String], + fallback: ScalaJsLinkConfig.ModuleKind + ): ScalaJsLinkConfig.ModuleKind = + fromFlag + .map { + case "nomodule" => ScalaJsLinkConfig.ModuleKind.NoModule + case "esmodule" => ScalaJsLinkConfig.ModuleKind.ESModule + case _ => ScalaJsLinkConfig.ModuleKind.CommonJSModule + } + .orElse(project.platform.flatMap(_.jsKind).map { + case model.ModuleKindJS.NoModule => ScalaJsLinkConfig.ModuleKind.NoModule + case model.ModuleKindJS.CommonJSModule => ScalaJsLinkConfig.ModuleKind.CommonJSModule + case model.ModuleKindJS.ESModule => ScalaJsLinkConfig.ModuleKind.ESModule + }) + .getOrElse(fallback) + + /** The module kind a Kotlin/JS link emits for `project`: an explicit `--module-kind` first, then the project's own `kotlin.js.moduleKind`, and only then + * CommonJS. + * + * The project was never consulted. `model.KotlinJs.moduleKind` has been in the build model — and in the schema, and presumably in someone's `bleep.yaml` — + * with no reader anywhere in the server, so a build declaring `es` got CommonJS and nothing said otherwise. This is the Kotlin half of the same defect + * `scalaJsModuleKind` fixes on the Scala.js side. + * + * The flag speaks the Scala.js vocabulary because it is one flag across both, so `nomodule` maps to Kotlin's `plain`. + */ + private def kotlinJsModuleKind(project: model.Project, fromFlag: Option[String]): model.KotlinJsModuleKind = + fromFlag + .map { + case "esmodule" => model.KotlinJsModuleKind.ESModule + case "nomodule" => model.KotlinJsModuleKind.Plain + case _ => model.KotlinJsModuleKind.CommonJS + } + .orElse(project.kotlin.flatMap(_.js).flatMap(_.moduleKind)) + .getOrElse(model.KotlinJsModuleKind.CommonJS) + + /** The Kotlin/JS link settings for `project`. + * + * Every one of these has to have a value — bleep sets each on the compiler arguments unconditionally — so the question was never whether to support them but + * which constant to hardcode. They now come from the build where the build says something, in the same order as the module kind: an explicit flag first, + * then the project, then bleep's default. + * + * `sourceMapPrefix` is the one exception, and stays an `Option`: the compiler is only told about it when there is one. + */ + private def kotlinJsConfig( + project: model.Project, + moduleKind: model.KotlinJsModuleKind, + sourceMap: Option[Boolean], + sourceMapDefault: Boolean, + dce: Boolean + ): TaskDag.KotlinJsConfig = { + val js = project.kotlin.flatMap(_.js) + TaskDag.KotlinJsConfig( + moduleKind = moduleKind, + moduleName = js.flatMap(_.moduleName), + sourceMap = sourceMap.orElse(js.flatMap(_.sourceMap)).getOrElse(sourceMapDefault), + sourceMapPrefix = js.flatMap(_.sourceMapPrefix), + sourceMapEmbedSources = js.flatMap(_.sourceMapEmbedSources).getOrElse(model.KotlinJsSourceMapEmbedSources.Never), + generateDts = js.flatMap(_.generateDts).getOrElse(false), + dce = dce + ) + } + + /** UMD, and a warning when the project asked for something else. + * + * Unlike a Scala.js test link — where the adapter picks its `Input` per module kind and all three work — the Kotlin/JS test path has exactly one shape it + * can load. `KotlinTestRunner.Js` generates a CommonJS script that installs a QUnit mock as a global and then `require`s the linked output. `ESModule` makes + * that `require` throw, and `Plain` and `AMD` do not put the tests where the runner reads them. UMD satisfies it and is compatible with the rest. + * + * So the declaration cannot be honoured here, and the honest thing is to say so rather than substitute in silence — which is what the hardcoded constant + * did. A warning rather than a failure: these builds' tests run today, and breaking them to report a limitation of the runner would be a poor trade. + */ + private def kotlinJsTestModuleKind(started: Started, crossName: CrossProjectName, project: model.Project): model.KotlinJsModuleKind = { + project.kotlin.flatMap(_.js).flatMap(_.moduleKind).filterNot(_ == model.KotlinJsModuleKind.UMD).foreach { declared => + started.logger + .withContext("project", crossName.value) + .withContext("declared", declared.value) + .warn( + s"Kotlin/JS tests link as UMD regardless of `kotlin.js.moduleKind: ${declared.value}` — bleep's test runner loads the output with `require`. " + + "The main link honours the declaration; only the test link overrides it." + ) + } + model.KotlinJsModuleKind.UMD + } + /** Run a Scala.js test suite: link → run via Node.js, emit events to DAG queue. */ private def runScalaJsTestSuite( started: Started, @@ -3567,6 +3660,7 @@ class MultiWorkspaceBspServer( classpath: List[Path], testEnv: Map[String, String], linkResult: Option[TaskDag.LinkResult], + moduleKind: ScalaJsLinkConfig.ModuleKind, eventQueue: Queue[IO, Option[TaskDag.DagEvent]], killSignal: Deferred[IO, KillReason] ): IO[TaskDag.TaskResult] = { @@ -3574,8 +3668,8 @@ class MultiWorkspaceBspServer( val sjsVersion = project.platform.flatMap(_.jsVersion).getOrElse { throw new IllegalStateException(s"Scala.js version not found for ${testTask.project.value}") } - // No Scala version needed here any more: it was only ever used to describe the link this function used to run itself. - val linkConfig = bleep.analysis.ScalaJsLinkConfig.Debug + // Taken from the link the DAG ran rather than declared again here. The adapter picks its `Input` from this — a NoModule program loaded as a module, or the + // reverse, fails before any test runs — so the one thing it must never be is a second opinion about how the program was emitted. for { startTs <- IO.realTime.map(_.toMillis) @@ -3598,7 +3692,7 @@ class MultiWorkspaceBspServer( ScalaJsTestRunner .runTests( mainModule, - linkConfig.moduleKind, + moduleKind, suites, eventHandler, ScalaJsTestRunner.NodeEnvironment.Node, diff --git a/bleep-bsp/src/scala/bleep/bsp/TaskDag.scala b/bleep-bsp/src/scala/bleep/bsp/TaskDag.scala index 193e39e73..87dcf6e95 100644 --- a/bleep-bsp/src/scala/bleep/bsp/TaskDag.scala +++ b/bleep-bsp/src/scala/bleep/bsp/TaskDag.scala @@ -4,7 +4,7 @@ import bleep.MachineResources import bleep.bsp.protocol.KillReason import bleep.bsp.protocol.{BleepBspProtocol, LinkPlatformName, OutputChannel, ProcessExit, SuiteOutcome, TestStatus} import bleep.bsp.protocol.BleepBspProtocol.BuildMode -import bleep.model.{CrossProjectName, KotlinJsModuleKind, ScriptDef, SuiteName, TestName} +import bleep.model.{CrossProjectName, KotlinJsModuleKind, KotlinJsSourceMapEmbedSources, ScriptDef, SuiteName, TestName} import cats.effect._ import cats.effect.std.Queue import cats.syntax.all._ @@ -231,12 +231,20 @@ object TaskDag { case object Jvm extends LinkPlatform { val name: LinkPlatformName = LinkPlatformName.Jvm } } - /** Kotlin/JS configuration */ + /** Kotlin/JS configuration. + * + * No `outputDir`: the field that used to be here had no reader. `LinkExecutor.execute` computes the output directory for every platform the same way, from + * the base directory it is handed plus the mode suffix, and Kotlin/JS was no exception — but the two callers filled this field with two *different* + * directories, which made the compile path and the test path look like they disagreed about where a link lands when neither was being consulted. + */ case class KotlinJsConfig( moduleKind: KotlinJsModuleKind, + moduleName: Option[String], sourceMap: Boolean, - dce: Boolean, // Dead Code Elimination - true = smaller output - outputDir: java.nio.file.Path + sourceMapPrefix: Option[String], + sourceMapEmbedSources: KotlinJsSourceMapEmbedSources, + generateDts: Boolean, + dce: Boolean // Dead Code Elimination - true = smaller output ) /** Kotlin/Native configuration */ diff --git a/bleep-cli/src/scala/bleep/Main.scala b/bleep-cli/src/scala/bleep/Main.scala index 9ec259d3a..bb18d6dd0 100644 --- a/bleep-cli/src/scala/bleep/Main.scala +++ b/bleep-cli/src/scala/bleep/Main.scala @@ -941,7 +941,7 @@ object Main { Opts.subcommand("publish-local", "publishes your project locally (deprecated: use 'publish local-ivy')") { ( Opts.option[String]("groupId", "organization you will publish under"), - Opts.option[String]("version", "version you will publish"), + Opts.option[String]("version", "version you will publish (default: from git tags)").orNone, Opts.option[Path]("to", s"publish to a maven repository at given path").orNone, projectNames, watch, @@ -953,7 +953,7 @@ object Main { } val options = commands.PublishLocal.Options( groupId = groupId, - version = version, + version = publishVersion(version), publishTarget = publishTarget, projects, ManifestCreator.default @@ -962,14 +962,6 @@ object Main { } }, Opts.subcommand("publish", "publish artifacts to a named resolver, local-ivy, or sonatype") { - // `dynverSonatypeSnapshots = true` to match the two other places that derive a version from git: - // `GenerateResources` (which bakes `BleepVersion.current` into the client) and `PublishSonatype`. - // Without it a snapshot publishes as `1.0.0-M10+46-abc1234` while the client it was built alongside - // asks coursier for `1.0.0-M10+46-abc1234-SNAPSHOT` — so `bleep publish local-ivy` produced jars no - // client would ever resolve, silently leaving the previously released server in play. - val dynVerFallback: () => String = - () => new bleep.plugin.dynver.DynVerPlugin(baseDirectory = started.buildPaths.buildDir.toFile, dynverSonatypeSnapshots = true).version - def publishOpts(target: commands.Publish.Target): Opts[BleepBuildCommand] = publishOptsWith(Opts.unit.map(_ => target)) @@ -984,7 +976,7 @@ object Main { ).mapN { case (version, assertRel, dryRun, projects, buildOpts, target) => commands.Publish( false, - commands.Publish.Options(version, Some(dynVerFallback), assertRel, dryRun, target, projects, ManifestCreator.default), + commands.Publish.Options(publishVersion(version), assertRel, dryRun, target, projects, ManifestCreator.default), buildOpts ) } @@ -1006,7 +998,7 @@ object Main { commonBuildOpts ).mapN { case (version, assertRel, projects, buildOpts) => commands.PublishSonatype( - commands.PublishSonatype.Options(version, assertRel, projects, ManifestCreator.default), + commands.PublishSonatype.Options(publishVersion(version), assertRel, projects, ManifestCreator.default), buildOpts ) } @@ -1606,12 +1598,25 @@ object Main { } case Right(cmd) => Try(runCommand(cmd)) match { - case Failure(th) => fatal("command failed unexpectedly! This really shouldn't happen. Please report.", logger, th) - case Success(Left(th)) => fatal("command failed", logger, th) + case Failure(th) => failed("command failed unexpectedly! This really shouldn't happen. Please report.", logger, th) + case Success(Left(th)) => failed("command failed", logger, th) case Success(Right(())) => ExitCode.Success } } + /** [[fatal]], except that a program bleep launched on the user's behalf is not a bleep failure. + * + * A script that exits 1 to signal something it has already reported gets one line and its own exit code, so `bleep