From 9c1cc8ec6b05b6fdede1fcd091f2ad206bc6d039 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Tue, 7 Apr 2026 15:32:47 -0400 Subject: [PATCH 1/3] fix: rewrite bun interpolator macro for robustness in nested macro contexts The Expr pattern matching approach (.matches, Varargs) broke when bun"..." was used inside another macro's generated code (e.g., utest Tests{}). Rewrite to direct TASTy tree inspection which handles Inlined/Typed/Repeated wrappers from macro expansion. Also fix validation bug: @/bad (empty scope name) was not caught because only the package name after the slash was checked. Co-Authored-By: Claude Opus 4.6 (1M context) --- millbun/src/mill/bun/BunDep.scala | 64 ++++++++++++++++----- millbun/test/src/mill/bun/BunDepTests.scala | 7 +-- 2 files changed, 52 insertions(+), 19 deletions(-) diff --git a/millbun/src/mill/bun/BunDep.scala b/millbun/src/mill/bun/BunDep.scala index 8aca3a5..e4a62b7 100644 --- a/millbun/src/mill/bun/BunDep.scala +++ b/millbun/src/mill/bun/BunDep.scala @@ -24,23 +24,56 @@ private object BunDepMacro: import scala.quoted.* def validateImpl(sc: Expr[StringContext], args: Expr[Seq[Any]])(using Quotes): Expr[String] = - import quotes.reflect.* - - sc match - case '{ StringContext(${ Varargs(parts) }*) } => - // For a no-interpolation literal like bun"react@19.0.0", parts has 1 element - parts match - case Seq(Expr(literal: String)) if args.matches('{ Seq() }) || args.matches('{ Nil }) => - validateLiteral(literal) - Expr(literal) - case _ => - // Has interpolated parts — build at runtime, skip compile-time validation - '{ $sc.s($args*) } + literalParts(sc) match + case Some(Seq(literal)) if isEmptyInterpolation(args) => + validateLiteral(literal) + Expr(literal) case _ => - // Inside another macro (e.g., utest's Tests{}) the pattern may not match. - // Fall back to runtime string construction. + // Has interpolated parts, a non-literal StringContext, or runs inside another + // macro-generated context — build at runtime and skip compile-time validation. '{ $sc.s($args*) } + private def literalParts(sc: Expr[StringContext])(using Quotes): Option[Seq[String]] = + import quotes.reflect.* + + def extractRepeatedStrings(term: Term): Option[Seq[String]] = + term match + case Typed(Repeated(partTerms, _), _) => extractStringTerms(partTerms) + case Repeated(partTerms, _) => extractStringTerms(partTerms) + case Inlined(_, _, inner) => extractRepeatedStrings(inner) + case _ => None + + def extractStringTerms(partTerms: Seq[Term]): Option[Seq[String]] = + partTerms.foldRight(Option(List.empty[String])) { (term, acc) => + val part = term match + case Literal(StringConstant(value)) => Some(value) + case Inlined(_, _, inner) => + inner match + case Literal(StringConstant(value)) => Some(value) + case _ => None + case _ => None + for + values <- acc + value <- part + yield value :: values + } + + sc.asTerm.underlyingArgument match + case Apply(_, List(repeatedParts)) => extractRepeatedStrings(repeatedParts) + case _ => None + + private def isEmptyInterpolation(args: Expr[Seq[Any]])(using Quotes): Boolean = + import quotes.reflect.* + + def extractRepeatedArgs(term: Term): Option[Seq[Term]] = + term match + case Typed(Repeated(argTerms, _), _) => Some(argTerms) + case Repeated(argTerms, _) => Some(argTerms) + case Inlined(_, _, inner) => extractRepeatedArgs(inner) + case _ => None + + extractRepeatedArgs(args.asTerm.underlyingArgument).contains(Nil) + private def validateLiteral(dep: String)(using Quotes): Unit = import quotes.reflect.* if dep.isEmpty then @@ -54,6 +87,9 @@ private object BunDepMacro: s"Invalid scoped package: '$dep'. Expected @scope/name or @scope/name@version" ) val slashIdx = afterScope.indexOf('/') + val scopeName = afterScope.take(slashIdx) + if scopeName.isEmpty then + report.errorAndAbort(s"Invalid scoped package: '$dep'. Scope name is empty.") val afterSlash = afterScope.drop(slashIdx + 1) val nameOnly = if afterSlash.contains('@') then afterSlash.take(afterSlash.indexOf('@')) else afterSlash if nameOnly.isEmpty then diff --git a/millbun/test/src/mill/bun/BunDepTests.scala b/millbun/test/src/mill/bun/BunDepTests.scala index 94ae94a..1ecd98f 100644 --- a/millbun/test/src/mill/bun/BunDepTests.scala +++ b/millbun/test/src/mill/bun/BunDepTests.scala @@ -50,10 +50,7 @@ object BunDepTests extends TestSuite { assert(deps.head.startsWith("@anthropic-ai")) } - // Note: compile-time validation errors can't be tested at runtime. - // The following would fail to compile: - // bun"" // empty - // bun"@/bad" // empty name after scope - // bun"@noSlash" // missing slash in scoped name + // Invalid literal coverage lives in integration tests so the interpolator + // is compiled in a normal build.mill context rather than inside another macro. } } From 991b4b202e8291ab9a62637963ef2b78d907f44a Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Tue, 7 Apr 2026 15:33:10 -0400 Subject: [PATCH 2/3] test: add integration tests for dependency manifests and bun literal validation - invalid-bun-literal: verifies bun"" fails compilation in a real build.mill context (validates macro error reporting end-to-end) - scalajs-dependency-manifests: tests manifest embedding in published JARs, local optional dep flow, and classpath manifest scanning for dev + optional deps - StubBunModule pattern overrides runBun to avoid needing real Bun Co-Authored-By: Claude Opus 4.6 (1M context) --- .../resources/invalid-bun-literal/build.mill | 21 +++++++ .../invalid-bun-literal/src/App.scala | 1 + .../app-local/AppLocal.scala | 1 + .../app-published/AppPublished.scala | 1 + .../scalajs-dependency-manifests/build.mill | 51 +++++++++++++++ .../local-lib/LocalLib.scala | 1 + .../published-lib/PublishedLib.scala | 1 + ...unDependencyManifestIntegrationTests.scala | 62 +++++++++++++++++++ 8 files changed, 139 insertions(+) create mode 100644 millbun/integration/resources/invalid-bun-literal/build.mill create mode 100644 millbun/integration/resources/invalid-bun-literal/src/App.scala create mode 100644 millbun/integration/resources/scalajs-dependency-manifests/app-local/AppLocal.scala create mode 100644 millbun/integration/resources/scalajs-dependency-manifests/app-published/AppPublished.scala create mode 100644 millbun/integration/resources/scalajs-dependency-manifests/build.mill create mode 100644 millbun/integration/resources/scalajs-dependency-manifests/local-lib/LocalLib.scala create mode 100644 millbun/integration/resources/scalajs-dependency-manifests/published-lib/PublishedLib.scala create mode 100644 millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala diff --git a/millbun/integration/resources/invalid-bun-literal/build.mill b/millbun/integration/resources/invalid-bun-literal/build.mill new file mode 100644 index 0000000..d67f057 --- /dev/null +++ b/millbun/integration/resources/invalid-bun-literal/build.mill @@ -0,0 +1,21 @@ +//| mill-version: 1.1.5 +//| mill-jvm-version: system +//| mvnDeps: +//| - com.tjclp::mill-bun_mill1:0.0.0-NIGHTLY + +package build + +import mill.* +import mill.bun.bun +import mill.scalajslib.* +import mill.scalajslib.api.* +import mill.scalajslib.bun.* + +object app extends BunScalaJSModule { + override def moduleDir = build.moduleDir + def scalaVersion = "3.8.2" + + override def moduleKind = Task { ModuleKind.CommonJSModule } + override def sources = Task.Sources(moduleDir / "src") + override def bunDeps = Task { Seq(bun"") } +} diff --git a/millbun/integration/resources/invalid-bun-literal/src/App.scala b/millbun/integration/resources/invalid-bun-literal/src/App.scala new file mode 100644 index 0000000..047460e --- /dev/null +++ b/millbun/integration/resources/invalid-bun-literal/src/App.scala @@ -0,0 +1 @@ +object App diff --git a/millbun/integration/resources/scalajs-dependency-manifests/app-local/AppLocal.scala b/millbun/integration/resources/scalajs-dependency-manifests/app-local/AppLocal.scala new file mode 100644 index 0000000..4d67766 --- /dev/null +++ b/millbun/integration/resources/scalajs-dependency-manifests/app-local/AppLocal.scala @@ -0,0 +1 @@ +object AppLocal diff --git a/millbun/integration/resources/scalajs-dependency-manifests/app-published/AppPublished.scala b/millbun/integration/resources/scalajs-dependency-manifests/app-published/AppPublished.scala new file mode 100644 index 0000000..03f26b6 --- /dev/null +++ b/millbun/integration/resources/scalajs-dependency-manifests/app-published/AppPublished.scala @@ -0,0 +1 @@ +object AppPublished diff --git a/millbun/integration/resources/scalajs-dependency-manifests/build.mill b/millbun/integration/resources/scalajs-dependency-manifests/build.mill new file mode 100644 index 0000000..9b7908a --- /dev/null +++ b/millbun/integration/resources/scalajs-dependency-manifests/build.mill @@ -0,0 +1,51 @@ +//| mill-version: 1.1.5 +//| mill-jvm-version: system +//| mvnDeps: +//| - com.tjclp::mill-bun_mill1:0.0.0-NIGHTLY + +package build + +import mill.* +import mill.api.PathRef +import mill.scalajslib.* +import mill.scalajslib.api.* +import mill.scalajslib.bun.* + +trait StubBunModule extends BunScalaJSModule { + override def moduleDir = build.moduleDir + def scalaVersion = "3.8.2" + override def moduleKind = Task { ModuleKind.CommonJSModule } + override def bunExecutable = Task { "stub-bun" } + + override protected def runBun( + bunExe: String, + args: Seq[String], + cwd: os.Path, + env: Map[String, String] + ): os.CommandResult = { + os.call(Seq("true"), cwd = cwd, env = env) + } +} + +object localLib extends StubBunModule { + override def sources = Task.Sources(moduleDir / "local-lib") + override def bunOptionalDeps = Task { Seq("optional-local@^1.0.0") } +} + +object publishedLib extends StubBunModule with BunPublishModule { + override def sources = Task.Sources(moduleDir / "published-lib") + override def bunDevDeps = Task { Seq("dev-only@^2.0.0") } + override def bunOptionalDeps = Task { Seq("optional-published@^3.0.0") } +} + +object appLocal extends StubBunModule { + override def sources = Task.Sources(moduleDir / "app-local") + override def moduleDeps = Seq(localLib) +} + +object appPublished extends StubBunModule { + override def sources = Task.Sources(moduleDir / "app-published") + override def runClasspath: T[Seq[PathRef]] = Task { + super.runClasspath() ++ Seq(publishedLib.jar()) + } +} diff --git a/millbun/integration/resources/scalajs-dependency-manifests/local-lib/LocalLib.scala b/millbun/integration/resources/scalajs-dependency-manifests/local-lib/LocalLib.scala new file mode 100644 index 0000000..5cdb8a9 --- /dev/null +++ b/millbun/integration/resources/scalajs-dependency-manifests/local-lib/LocalLib.scala @@ -0,0 +1 @@ +object LocalLib diff --git a/millbun/integration/resources/scalajs-dependency-manifests/published-lib/PublishedLib.scala b/millbun/integration/resources/scalajs-dependency-manifests/published-lib/PublishedLib.scala new file mode 100644 index 0000000..f610582 --- /dev/null +++ b/millbun/integration/resources/scalajs-dependency-manifests/published-lib/PublishedLib.scala @@ -0,0 +1 @@ +object PublishedLib diff --git a/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala b/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala new file mode 100644 index 0000000..1171c43 --- /dev/null +++ b/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala @@ -0,0 +1,62 @@ +package mill.bun + +import mill.api.PathRef +import mill.testkit.IntegrationTester +import utest.* + +object BunDependencyManifestIntegrationTests extends TestSuite { + val resourceDir: os.Path = os.Path(sys.env("MILL_WORKSPACE_ROOT")) / "millbun" / "integration" / "resources" + val millExe: os.Path = os.Path(sys.env("MILL_EXECUTABLE_PATH")) + + private def tester(resource: String): IntegrationTester = + new IntegrationTester( + daemonMode = false, + workspaceSourcePath = resourceDir / resource, + millExecutable = millExe, + useInMemory = true + ) + + private def outputPath(tester: IntegrationTester, selector: String): os.Path = + tester.out(selector).value[PathRef].path + + def tests: Tests = Tests { + + test("invalid bun literal fails in build definitions") { + val tester = this.tester("invalid-bun-literal") + val res = tester.eval("app.bunDeps") + assert(!res.isSuccess) + } + + test("published manifests include dev-only modules") { + val tester = this.tester("scalajs-dependency-manifests") + val res = tester.eval("publishedLib.jar") + assert(res.isSuccess) + + val jar = outputPath(tester, "publishedLib.jar") + val manifest = BunManifest.readFromJar(jar) + assert(manifest.isDefined) + assert(manifest.get.dependencies.isEmpty) + assert(manifest.get.devDependencies == Map("dev-only" -> "^2.0.0")) + assert(manifest.get.optionalDependencies == Map("optional-published" -> "^3.0.0")) + } + + test("local optional deps flow into generated package.json") { + val tester = this.tester("scalajs-dependency-manifests") + val res = tester.eval("appLocal.bunInstall") + assert(res.isSuccess) + + val packageJson = ujson.read(os.read(tester.workspacePath / "out" / "appLocal" / "bunInstall.dest" / "package.json")) + assert(packageJson("optionalDependencies").obj("optional-local").str == "^1.0.0") + } + + test("classpath manifests flow dev and optional deps into generated package.json") { + val tester = this.tester("scalajs-dependency-manifests") + val res = tester.eval("appPublished.bunInstall") + assert(res.isSuccess) + + val packageJson = ujson.read(os.read(tester.workspacePath / "out" / "appPublished" / "bunInstall.dest" / "package.json")) + assert(packageJson("devDependencies").obj("dev-only").str == "^2.0.0") + assert(packageJson("optionalDependencies").obj("optional-published").str == "^3.0.0") + } + } +} From 9344593c3ba9aa731e77098bc7e2dda0f3cfe526 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Tue, 7 Apr 2026 15:39:05 -0400 Subject: [PATCH 3/3] feat: project-level lockfile management with bunUpdateLock Developers can now commit bun.lock at the workspace root for deterministic JS dependency resolution across clean builds and CI. - bunWorkspaceLockfile (BunToolchainModule): Task.Input that detects bun.lock at workspace root and triggers re-evaluation on changes - bunInstall/npmInstall: seed from workspace lockfile when present, giving bun a known-good resolution baseline - bunUpdateLock command: runs bun install without --frozen-lockfile, copies generated lockfile back to workspace root for committing - Applied symmetrically to BunScalaJSModule and BunTypeScriptModule (including test module inner npmInstall) Workflow: mill myApp.bunUpdateLock # generate/update bun.lock git add bun.lock && git commit # CI: override bunFrozenLockfile = true to enforce Co-Authored-By: Claude Opus 4.6 (1M context) --- millbun/src/mill/bun/BunToolchainModule.scala | 17 +++++++ .../bun/BunTypeScriptModule.scala | 46 +++++++++++++++++++ .../scalajslib/bun/BunScalaJSModule.scala | 45 ++++++++++++++++++ 3 files changed, 108 insertions(+) diff --git a/millbun/src/mill/bun/BunToolchainModule.scala b/millbun/src/mill/bun/BunToolchainModule.scala index 8551d0f..b72d078 100644 --- a/millbun/src/mill/bun/BunToolchainModule.scala +++ b/millbun/src/mill/bun/BunToolchainModule.scala @@ -108,6 +108,23 @@ trait BunToolchainModule extends Module { .map(PathRef(_)) } + /** + * Workspace-level lockfile committed by the developer. + * + * When present, `bunInstall` / `npmInstall` seed their install directory + * with this lockfile so Bun resolves deterministic versions. In CI, + * combine with `bunFrozenLockfile = true` to reject stale lockfiles. + * + * Declared as `Task.Input` so Mill re-evaluates when the file changes + * on disk and the sandbox checker allows reading from the workspace root. + */ + def bunWorkspaceLockfile: T[Option[PathRef]] = Task.Input { + Seq("bun.lock", "bun.lockb") + .map(name => BuildCtx.workspaceRoot / name) + .find(os.exists) + .map(PathRef(_)) + } + /** * Cross-compilation targets for `bun build --compile`. * Values: "bun-linux-x64", "bun-darwin-arm64", "bun-windows-x64", etc. diff --git a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala index c1983ab..1ca8e04 100644 --- a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala +++ b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala @@ -2,6 +2,7 @@ package mill.javascriptlib package bun import mill.* +import mill.api.BuildCtx import os.* import mill.bun.BunToolchainModule @@ -118,6 +119,11 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out mkBunPackageJson() copyBunWorkspaceConfigs() + // Seed workspace lockfile for deterministic resolution + bunWorkspaceLockfile().foreach { lock => + os.copy.over(lock.path, dest / lock.path.last, createFolders = true) + } + runBun( bunExecutable(), Seq("install") ++ bunInstallArgs() ++ transitiveUnmanagedDeps().map(_.path.toString), @@ -128,6 +134,41 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out PathRef(dest) } + /** Regenerate the workspace lockfile. + * + * Runs `bun install` (without `--frozen-lockfile`) in an ephemeral + * directory, then copies the resulting lockfile back to the workspace + * root so it can be committed for deterministic builds. + * + * {{{ + * mill myApp.bunUpdateLock + * }}} + */ + def bunUpdateLock(): Command[PathRef] = Task.Command { + val dest = Task.dest + os.makeDir.all(dest) + mkBunPackageJson() + copyBunWorkspaceConfigs() + + // Run install WITHOUT --frozen-lockfile to allow fresh resolution + val updateArgs = Seq("--save-text-lockfile", "--linker", bunLinker()) + runBun( + bunExecutable(), + Seq("install") ++ updateArgs ++ transitiveUnmanagedDeps().map(_.path.toString), + cwd = dest, + env = bunToolEnv() + ) + + // Copy generated lockfile back to workspace root + val lockName = bunLockfiles().headOption.getOrElse("bun.lock") + val generated = dest / lockName + if (!os.exists(generated)) + Task.fail(s"Expected lockfile '$lockName' was not generated in $dest") + val target = BuildCtx.workspaceRoot / lockName + os.copy.over(generated, target, createFolders = true) + PathRef(target) + } + /** * Preserve Mill's compile sandbox preparation, but invoke TypeScript through * Bun instead of a Node-shebang script. @@ -327,6 +368,11 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out outer.copyBunWorkspaceConfigs() + // Seed workspace lockfile for deterministic resolution + outer.bunWorkspaceLockfile().foreach { lock => + os.copy.over(lock.path, dest / lock.path.last, createFolders = true) + } + runBun( bunExecutable(), Seq("install") ++ bunInstallArgs() ++ (outer.transitiveUnmanagedDeps() ++ transitiveUnmanagedDeps()).distinct.map(_.path.toString), diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala index 3ac29ff..74305ad 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala @@ -197,6 +197,11 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out os.copy.over(cfg.path, dest / cfg.path.last, createFolders = true) } + // Seed workspace lockfile for deterministic resolution + bunWorkspaceLockfile().foreach { lock => + os.copy.over(lock.path, dest / lock.path.last, createFolders = true) + } + mkBunPackageJson() runBun( @@ -209,6 +214,46 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out PathRef(dest) } + /** Regenerate the workspace lockfile. + * + * Runs `bun install` (without `--frozen-lockfile`) in an ephemeral + * directory, then copies the resulting lockfile back to the workspace + * root so it can be committed for deterministic builds. + * + * {{{ + * mill myApp.bunUpdateLock + * }}} + */ + def bunUpdateLock(): Command[PathRef] = Task.Command { + val dest = Task.dest + os.makeDir.all(dest) + + if (os.exists(npmRc().path)) os.copy.over(npmRc().path, dest / ".npmrc", createFolders = true) + bunfigFiles().foreach { cfg => + os.copy.over(cfg.path, dest / cfg.path.last, createFolders = true) + } + + mkBunPackageJson() + + // Run install WITHOUT --frozen-lockfile to allow fresh resolution + val updateArgs = Seq("--save-text-lockfile", "--linker", bunLinker()) + runBun( + bunExecutable(), + Seq("install") ++ updateArgs ++ transitiveUnmanagedDeps().map(_.path.toString), + cwd = dest, + env = bunEnv() + ) + + // Copy generated lockfile back to workspace root + val lockName = bunLockfiles().headOption.getOrElse("bun.lock") + val generated = dest / lockName + if (!os.exists(generated)) + Task.fail(s"Expected lockfile '$lockName' was not generated in $dest") + val target = BuildCtx.workspaceRoot / lockName + os.copy.over(generated, target, createFolders = true) + PathRef(target) + } + private def resolvedBunConfigs: Task[Seq[PathRef]] = Task.Anon { bunfigFiles() }