diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..db500b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Deterministic working trees on every platform: sources check out as LF everywhere, so the +# scalafmt CI check compares identical bytes on Windows runners (whose git defaults to +# autocrlf=true and would otherwise rewrite all 28 Scala sources to CRLF at checkout). +* text=auto eol=lf +# cmd.exe mis-parses some batch constructs without CRLF, and these only ever run on Windows. +*.bat text eol=crlf +*.cmd text eol=crlf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 950aa79..a5149ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,9 @@ jobs: with: bun-version: 1.4.0 + - name: Check formatting + run: $MILL --no-server mill.scalalib.scalafmt.ScalafmtModule/checkFormatAll __.sources + - name: Compile run: $MILL --no-server millbun.compile diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..212de3d --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,10 @@ +version = "3.8.3" +runner.dialect = scala3 + +# Deliberately conservative: the tree mixes brace and significant-indentation styles, and this +# config normalizes spacing and wrapping without converting between them. +maxColumn = 120 +rewrite.scala3.removeOptionalBraces = false +rewrite.scala3.insertEndMarkerMinLines = 0 +docstrings.style = keep +newlines.source = keep diff --git a/CHANGELOG.md b/CHANGELOG.md index 96fcc66..40c801f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Scala.js linking delegates to Mill's standard linker hooks; applications now choose `scalaJSVersion` explicitly. - Development dependencies are local tooling inputs and are no longer published transitively. - `bunPackageJsonExtras` rejects dependency fields now represented by typed settings. -- Missing dependency versions are represented as `latest` instead of an empty package.json value. +- Missing dependency versions are represented as `latest` instead of an empty package.json value, + and an explicit specifier wins over `latest` when the same package is declared both ways — + two different explicit specifiers still fail deterministically. - TypeScript `bunBundleFormat` is `Option[String]`, matching Scala.js; `None` lets `bun build` infer. - `unmanagedDeps` entries are staged into `vendor/` and declared as `file:./vendor/` dependencies, so local packages install under frozen lockfiles and locks stay portable. diff --git a/README.md b/README.md index f2ddb4e..aaa881f 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ The dependency model is shared across Scala.js and TypeScript: | `npmOverrides` | Explicit resolution for otherwise conflicting declarations | | `bunPackageJsonExtras` | Unmodeled fields such as `scripts`; typed dependency fields are rejected here | -The `bun"pkg@specifier"` interpolator is an optional compile-time validator for dependency strings. Unversioned dependencies resolve explicitly to `latest`; contradictory requirements fail unless selected by `npmOverrides`. +The `bun"pkg@specifier"` interpolator is an optional compile-time validator for dependency strings. Unversioned dependencies resolve explicitly to `latest`, and an explicit specifier wins over `latest` for the same package; contradictory explicit requirements fail unless selected by `npmOverrides`. ## Managed Bun diff --git a/docs/MIGRATING-0.3.md b/docs/MIGRATING-0.3.md index b6cb44d..ed9fac6 100644 --- a/docs/MIGRATING-0.3.md +++ b/docs/MIGRATING-0.3.md @@ -46,7 +46,9 @@ override def bunBundleFormat = Task { Some("esm") } 0.2 resolved the same package declared with different specifiers last-wins. 0.3 fails deterministically for **all** generated package.json files — direct declarations, module-graph -aggregation, and classpath manifests alike. Resolve with `npmOverrides`: +aggregation, and classpath manifests alike. Unversioned declarations are the one exception: they +resolve to `latest`, and an explicit specifier for the same package wins over `latest` instead +of conflicting. Resolve genuine conflicts with `npmOverrides`: ```scala override def npmOverrides = Task { Map("react" -> "^19.0.0") } diff --git a/millbun/integration/resources/typescript-env/bun-proxy.cmd b/millbun/integration/resources/typescript-env/bun-proxy.cmd index 3e105e3..0f70b59 100644 --- a/millbun/integration/resources/typescript-env/bun-proxy.cmd +++ b/millbun/integration/resources/typescript-env/bun-proxy.cmd @@ -1,9 +1,9 @@ -@echo off -setlocal -set "FIRST=%~1" -if "%FIRST%"=="" set "FIRST=unknown" -set "MARKER=%BUN_PROXY_MARKER%" -if "%MARKER%"=="" set "MARKER=missing" ->> "%CD%\.bun-env-log" echo %FIRST%:%MARKER% -bun %* -exit /b %ERRORLEVEL% +@echo off +setlocal +set "FIRST=%~1" +if "%FIRST%"=="" set "FIRST=unknown" +set "MARKER=%BUN_PROXY_MARKER%" +if "%MARKER%"=="" set "MARKER=missing" +>> "%CD%\.bun-env-log" echo %FIRST%:%MARKER% +bun %* +exit /b %ERRORLEVEL% diff --git a/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala b/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala index b966b50..75a7645 100644 --- a/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala @@ -60,7 +60,8 @@ object BunDependencyManifestIntegrationTests extends BunIntegrationSuite { val res = tester.eval("appLocal.bunInstall") assert(res.isSuccess) - val packageJson = ujson.read(os.read(tester.workspacePath / "out" / "appLocal" / "bunInstall.dest" / "package.json")) + val packageJson = + ujson.read(os.read(tester.workspacePath / "out" / "appLocal" / "bunInstall.dest" / "package.json")) assert(packageJson("optionalDependencies").obj("optional-local").str == "^1.0.0") } @@ -69,7 +70,8 @@ object BunDependencyManifestIntegrationTests extends BunIntegrationSuite { val res = tester.eval("appPublished.bunInstall") assert(res.isSuccess) - val packageJson = ujson.read(os.read(tester.workspacePath / "out" / "appPublished" / "bunInstall.dest" / "package.json")) + val packageJson = + ujson.read(os.read(tester.workspacePath / "out" / "appPublished" / "bunInstall.dest" / "package.json")) assert(!packageJson("devDependencies").obj.contains("dev-only")) assert(packageJson("optionalDependencies").obj("optional-published").str == "^3.0.0") assert(packageJson("peerDependencies").obj("peer-published").str == "^4.0.0") diff --git a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala index a06b2ad..7407015 100644 --- a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala @@ -224,7 +224,8 @@ object BunTypeScriptIntegrationTests extends BunIntegrationSuite { // Test module should have is-odd in devDependencies (not dependencies) val testRes = tester.eval("app.test.bunInstall") assert(testRes.isSuccess) - val testPkg = ujson.read(os.read(tester.workspacePath / "out" / "app" / "test" / "bunInstall.dest" / "package.json")) + val testPkg = + ujson.read(os.read(tester.workspacePath / "out" / "app" / "test" / "bunInstall.dest" / "package.json")) assert(testPkg("devDependencies").obj.contains("is-odd")) assert(!testPkg("dependencies").obj.contains("is-odd")) assert(!testPkg("devDependencies").obj.contains("is-even")) diff --git a/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala b/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala index 023cce6..887b097 100644 --- a/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala @@ -33,7 +33,9 @@ object BunWorkspaceIntegrationTests extends BunIntegrationSuite: // otherwise a rename satisfies Mill's duplicate guard while bun still sees collisions. assert(scalaJson("name").str == "scala-app-renamed") assert(scalaJson("dependencies").obj("shared-local").str == "file:./vendor/shared-local") - assert(os.exists(workspaceInstall / "packages" / "scala-app-renamed" / "vendor" / "shared-local" / "package.json")) + assert( + os.exists(workspaceInstall / "packages" / "scala-app-renamed" / "vendor" / "shared-local" / "package.json") + ) assert(!os.read(workspaceInstall / ".workspace-installed").contains("shared-local")) val scalaResult = tester.eval("scalaApp.bunInstall") diff --git a/millbun/src/mill/bun/BunDep.scala b/millbun/src/mill/bun/BunDep.scala index 450e194..587c4bf 100644 --- a/millbun/src/mill/bun/BunDep.scala +++ b/millbun/src/mill/bun/BunDep.scala @@ -29,7 +29,7 @@ object BunDep: */ def validate(dep: String): String = BunToolchainModule.parseDependency(dep) match - case Right(_) => dep + case Right(_) => dep case Left(message) => throw new IllegalArgumentException(s"Invalid bun dependency: $message") private object BunDepMacro: diff --git a/millbun/src/mill/bun/BunToolchainModule.scala b/millbun/src/mill/bun/BunToolchainModule.scala index 6728a70..b9f68e0 100644 --- a/millbun/src/mill/bun/BunToolchainModule.scala +++ b/millbun/src/mill/bun/BunToolchainModule.scala @@ -119,7 +119,7 @@ object BunToolchainModule { case name if name.contains("mac") || name.contains("darwin") => Right("darwin") case name if name.contains("linux") => Right("linux") case name if name.contains("windows") => Right("windows") - case other => Left(s"Unsupported operating system '$other'") + case other => Left(s"Unsupported operating system '$other'") } val archPart = architecture.toLowerCase match { case "aarch64" | "arm64" => Right("aarch64") @@ -233,7 +233,7 @@ object BunToolchainModule { ) cached catch - case _: java.nio.file.AtomicMoveNotSupportedException => publishViaCopy(staged, cached) + case _: java.nio.file.AtomicMoveNotSupportedException => publishViaCopy(staged, cached) case scala.util.control.NonFatal(_) if os.exists(cached) => // Lost the publish race. The path is keyed by the verified checksum, so the winner's // bytes are the right bytes. Windows reports this as a sharing violation rather than @@ -300,7 +300,7 @@ object BunToolchainModule { /** Parse a dependency for compatibility with the existing public helper. */ def splitDep(input: String): (String, ujson.Str) = parseDependency(input) match { - case Right(dep) => dep.name -> ujson.Str(dep.specifier) + case Right(dep) => dep.name -> ujson.Str(dep.specifier) case Left(message) => throw new IllegalArgumentException(message) } @@ -309,18 +309,27 @@ object BunToolchainModule { inputs: Seq[String], overrides: Map[String, String] = Map.empty ): Seq[(String, ujson.Str)] = { - val parsed = inputs.map(input => parseDependency(input).fold( - message => throw new IllegalArgumentException(message), - identity - )) + val parsed = inputs.map(input => + parseDependency(input).fold( + message => throw new IllegalArgumentException(message), + identity + ) + ) parsed.groupBy(_.name).toSeq.sortBy(_._1).map { case (name, entries) => val specifiers = entries.map(_.specifier).distinct - val resolved = overrides.get(name).orElse(specifiers match { + // "latest" is only the placeholder for an unversioned declaration, so any explicit + // specifier wins over it: without this, one unversioned dep in a published library would + // hard-conflict with every consumer that pins a version. Two DIFFERENT explicit + // specifiers (even semantically overlapping ones like ^19 and ^19.0.0) still fail + // deterministically rather than resolving by declaration order. + val explicit = specifiers.filterNot(_ == "latest") + val candidates = if (explicit.nonEmpty) explicit else specifiers + val resolved = overrides.get(name).orElse(candidates match { case Seq(specifier) => Some(specifier) - case _ => None + case _ => None }).getOrElse( throw new IllegalArgumentException( - s"Conflicting npm dependency '$name': ${specifiers.sorted.mkString(", ")}. " + + s"Conflicting npm dependency '$name': ${explicit.sorted.mkString(", ")}. " + "Declare npmOverrides to select one specifier." ) ) diff --git a/millbun/src/mill/bun/BunWebSupport.scala b/millbun/src/mill/bun/BunWebSupport.scala index 0b779c1..4f90856 100644 --- a/millbun/src/mill/bun/BunWebSupport.scala +++ b/millbun/src/mill/bun/BunWebSupport.scala @@ -129,7 +129,10 @@ private[mill] object BunWebSupport: os.walk(source).foreach { path => val destination = target / path.relativeTo(source) if os.isDir(path) then os.makeDir.all(destination) - else if !os.exists(destination) || os.mtime(path) != os.mtime(destination) || os.size(path) != os.size(destination) then + else if !os.exists(destination) || os.mtime(path) != os.mtime(destination) || os.size(path) != os.size( + destination + ) + then os.copy.over(path, destination, createFolders = true) } else if !os.exists(target) || os.mtime(source) != os.mtime(target) || os.size(source) != os.size(target) then diff --git a/millbun/src/mill/bun/BunWorkspaceModule.scala b/millbun/src/mill/bun/BunWorkspaceModule.scala index f9eb152..6da8ecd 100644 --- a/millbun/src/mill/bun/BunWorkspaceModule.scala +++ b/millbun/src/mill/bun/BunWorkspaceModule.scala @@ -56,7 +56,8 @@ trait BunWorkspaceModule extends BunToolchainModule: /** Generated root and member package.json files before installation. */ def bunWorkspaceLayout: T[PathRef] = Task { val packages = resolvedPackages() - val duplicateNames = packages.groupBy(_._1).collect { case (name, entries) if entries.size > 1 => name }.toSeq.sorted + val duplicateNames = + packages.groupBy(_._1).collect { case (name, entries) if entries.size > 1 => name }.toSeq.sorted if duplicateNames.nonEmpty then Task.fail(s"Duplicate Bun workspace package names: ${duplicateNames.mkString(", ")}") diff --git a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala index 2e33a8e..fbafe14 100644 --- a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala +++ b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala @@ -73,9 +73,9 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with /** Ambient runtime types aligned to the configured Bun target. */ protected def ambientTypeDeps: T[Seq[String]] = Task { bunBundleTarget() match { - case "bun" => Seq(s"@types/bun@${bunTypesVersion()}") + case "bun" => Seq(s"@types/bun@${bunTypesVersion()}") case "node" => Seq(s"@types/node@${nodeTypesVersion()}") - case _ => Seq.empty + case _ => Seq.empty } } @@ -99,7 +99,8 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with BunToolchainModule.dependencyPairs(transitiveNpmDeps(), overrides), transitiveUnmanagedDeps() )), - devDependencies = ujson.Obj.from(BunToolchainModule.dependencyPairs(transitiveNpmDevDeps() ++ tsDeps(), overrides)) + devDependencies = + ujson.Obj.from(BunToolchainModule.dependencyPairs(transitiveNpmDevDeps() ++ tsDeps(), overrides)) ).cleanJson.obj.toSeq ) @@ -108,7 +109,8 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with if optional.nonEmpty then resolved("optionalDependencies") = ujson.Obj.from(optional) if peers.nonEmpty then resolved("peerDependencies") = ujson.Obj.from(peers) if overrides.nonEmpty then - resolved("overrides") = ujson.Obj.from(overrides.toSeq.sortBy(_._1).map((name, value) => name -> ujson.Str(value))) + resolved("overrides") = + ujson.Obj.from(overrides.toSeq.sortBy(_._1).map((name, value) => name -> ujson.Str(value))) BunToolchainModule.mergePackageJson(resolved, bunPackageJsonExtras()) } @@ -217,6 +219,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with os.copy.over(generated, sourceLock, createFolders = true) PathRef(sourceLock) } + /** * Preserve Mill's compile sandbox preparation, but invoke TypeScript through * Bun instead of a Node-shebang script. @@ -369,7 +372,8 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with */ def compileExecutables: T[Map[String, PathRef]] = Task { val targets = bunCompileTargets() - if (targets.isEmpty) Task.fail("bunCompileTargets is empty. Set targets like Seq(\"bun-linux-x64\", \"bun-darwin-arm64\").") + if (targets.isEmpty) + Task.fail("bunCompileTargets is empty. Set targets like Seq(\"bun-linux-x64\", \"bun-darwin-arm64\").") val compileDir = compile().path val buildDir = Task.dest / "workspace" diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala index 0c03165..2add628 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala @@ -183,9 +183,11 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac @deprecated("Use transitiveNpmOptionalDeps", "0.3.0") def transitiveBunOptionalDeps: T[Seq[String]] = Task { - val moduleOptional = Task.traverse(recursiveInstallBunModuleDeps)(module => Task.Anon { - module.npmOptionalDeps() ++ module.bunOptionalDeps() - })().flatten + val moduleOptional = Task.traverse(recursiveInstallBunModuleDeps)(module => + Task.Anon { + module.npmOptionalDeps() ++ module.bunOptionalDeps() + } + )().flatten moduleOptional ++ classpathBunOptionalDeps() ++ npmOptionalDeps() ++ bunOptionalDeps() } @@ -349,7 +351,12 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac bunfigFiles() } - private def ensureLinkedWorkspace(report: Report, installDir: os.Path, lockfiles: Seq[String], bunConfigs: Seq[PathRef]): Unit = { + private def ensureLinkedWorkspace( + report: Report, + installDir: os.Path, + lockfiles: Seq[String], + bunConfigs: Seq[PathRef] + ): Unit = { val linkedDir = report.dest.path os.copy.over(installDir / "package.json", linkedDir / "package.json", createFolders = true) @@ -521,7 +528,8 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac */ def compileExecutables: T[Map[String, PathRef]] = Task { val targets = bunCompileTargets() - if (targets.isEmpty) Task.fail("bunCompileTargets is empty. Set targets like Seq(\"bun-linux-x64\", \"bun-darwin-arm64\").") + if (targets.isEmpty) + Task.fail("bunCompileTargets is empty. Set targets like Seq(\"bun-linux-x64\", \"bun-darwin-arm64\").") val linked = fullLinkJS() bunInstall() diff --git a/millbun/test/src/mill/bun/BunToolchainTests.scala b/millbun/test/src/mill/bun/BunToolchainTests.scala index df23cb5..3393669 100644 --- a/millbun/test/src/mill/bun/BunToolchainTests.scala +++ b/millbun/test/src/mill/bun/BunToolchainTests.scala @@ -261,6 +261,27 @@ object BunToolchainTests extends TestSuite: ) assert(pairs.map((name, version) => name -> version.str) == Seq("react" -> "19.1.1")) + test("an explicit specifier wins over an unversioned declaration"): + // "latest" is only the placeholder for a missing version. Without this yield, one + // unversioned dep in a published library conflicts with every consumer that pins. + val pairs = BunToolchainModule.dependencyPairs(Seq("react", "react@^19.0.0")) + assert(pairs.map((name, version) => name -> version.str) == Seq("react" -> "^19.0.0")) + + // Order-independent, and multiple latests collapse into the one explicit winner. + val reversed = BunToolchainModule.dependencyPairs(Seq("react@^19.0.0", "react", "react")) + assert(reversed.map((name, version) => name -> version.str) == Seq("react" -> "^19.0.0")) + + // Only-unversioned still resolves to latest. + val bare = BunToolchainModule.dependencyPairs(Seq("react", "react")) + assert(bare.map((name, version) => name -> version.str) == Seq("react" -> "latest")) + + // Two different explicit specifiers still conflict, with latest absent from the message. + val error = intercept[IllegalArgumentException]( + BunToolchainModule.dependencyPairs(Seq("react", "react@^18", "react@^19")) + ) + assert(error.getMessage.contains("Conflicting npm dependency 'react'")) + assert(!error.getMessage.contains("latest")) + test("malformed dependency declarations fail clearly"): Seq("", "react@", "@types", "@types/bun@").foreach: input => intercept[IllegalArgumentException](BunToolchainModule.splitDep(input)) diff --git a/millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala b/millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala index 949afd3..f7cf028 100644 --- a/millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala +++ b/millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala @@ -169,7 +169,7 @@ object BunVendoredNodeModulesTests extends TestSuite { private def parentDirectories(path: String): Seq[String] = path.split('/').dropRight(1).scanLeft("") { - case ("", segment) => s"$segment/" + case ("", segment) => s"$segment/" case (acc, segment) => s"$acc$segment/" }.drop(1) }