Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions .scalafmt.conf
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>`
dependencies, so local packages install under frozen lockfiles and locks stay portable.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion docs/MIGRATING-0.3.md
Original file line number Diff line number Diff line change
Expand Up @@ -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") }
Expand Down
18 changes: 9 additions & 9 deletions millbun/integration/resources/typescript-env/bun-proxy.cmd
Original file line number Diff line number Diff line change
@@ -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%
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion millbun/src/mill/bun/BunDep.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
29 changes: 19 additions & 10 deletions millbun/src/mill/bun/BunToolchainModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand All @@ -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."
)
)
Expand Down
5 changes: 4 additions & 1 deletion millbun/src/mill/bun/BunWebSupport.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion millbun/src/mill/bun/BunWorkspaceModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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(", ")}")

Expand Down
14 changes: 9 additions & 5 deletions millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand All @@ -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
)

Expand All @@ -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())
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"
Expand Down
18 changes: 13 additions & 5 deletions millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
21 changes: 21 additions & 0 deletions millbun/test/src/mill/bun/BunToolchainTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading