Preserve symlinks when staging, and link node_modules into web builds - #15
Merged
arcaputo3 merged 3 commits intoAug 26, 2026
Merged
Conversation
`BunScalaJSWebModule` could not resolve any npm dependency. Every tree copy in the plugin walked with `os.walk` (followLinks=false) but branched on `os.isDir`/`os.copy` (followLinks=true), so the `node_modules` symlink that `ensureLinkedWorkspace` plants in the link report was misclassified: - `BunWebSupport.copyContents` turned it into an empty directory, so `bun build index.html` had nothing to resolve imports against. - `BunToolchainModule.copyWorkspace` deep-copied the entire resolved tree -- O(node_modules) on every compileExecutable, bundle, and test workspace -- and threw outright on any broken link, routine for the .bin shims of skipped optional dependencies. Add `BunToolchainModule.copyTree`, which recreates links instead of resolving them, and route copyWorkspace, copyPathRefs, and both BunWebSupport copiers through it. `BunScalaJSWebModule` now stages `node_modules` explicitly, mirroring `BunTypeScriptWebModule.prepareWebStage`, rather than hoping the link survives the copy. Staging tasks that consume the linked tree now declare the install they symlink into. Mill's filesystem checker rejects reading a dest the task does not depend on, and the previous deep-copy only slipped past it because `os.copy` checks the source path rather than the resolved target -- the dependency was always real, just undeclared. Split `htmlEntries` into a pure resolver and `materializeHtmlEntries`, so `dev()` and `bundle` stop writing `index.html` into a staging task's already-cached output. Collapse `BunTypeScriptWebModule`'s two byte-identical staging tasks into one `webStage`. The scalajs-web fixture had no npm dependencies, which is exactly why CI was blind to this. It now imports lodash through @jsimport; on the parent commit that fixture fails with a bun build error, and both web tests fail. Unit 67 passing (was 57), integration 37 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`BunTypeScriptTests.npmInstall` builds a package.json that is a strict
superset of the outer module's, but `bunLockfile` is not a member of Mill's
`TypeScriptTests`, so the unqualified reference resolved through Scala's
outer scope to the *enclosing* module's bun.lock. Installing a superset
package.json against that lock under --frozen-lockfile fails with:
error: lockfile had changes, but lockfile is frozen
and there was no `test.bunLock` to generate a matching one. Any TypeScript
test module declaring a dependency the outer module lacks was broken under
the shipped default.
- `bunTestPackageJson` is now one task shared by `npmInstall` and `bunLock`,
so the install and the lockfile cannot describe different dependency sets.
- The test trait declares its own `bunLockfile` at `<test module>/bun.lock`
and its own `bunLock` command. Defined directly rather than by mixing in
BunToolchainModule, which would fork bunVersion/bunExecutable into separate
task instances and re-download managed Bun per test module.
- A test module that adds nothing now delegates to the outer install instead
of demanding a second lockfile, so bare test modules are unaffected.
- Every Bun member in the test trait is qualified `outer.` or `this.`. The
outer-vs-inherited split is what caused this, and only `bunLockfile` was
ever wrong -- `transitiveUnmanagedDeps` and friends are declared by Mill's
TypeScriptTests and correctly bind to the test module.
- `requireBunLockfile` takes the expected path, so the error names the test
module's lockfile rather than the outer one.
The typescript-test-deps fixture (outer is-even, test is-odd) now pins
bunRequireLockfile itself, following the typescript-lock precedent, so the
strict path is exercised regardless of the suite's env. On the parent commit
both new tests fail.
Unit 67 passing, integration 39 passing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Give TypeScript test modules their own lockfile identity
arcaputo3
changed the base branch from
agent/bun-140-toolchain
to
agent/idiomatic-scalajs-mill-workflows
August 26, 2026 22:15
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stack 2/N. Base is
agent/bun-140-toolchain(#14), notmain.The bug
BunScalaJSWebModulecould not resolve any npm dependency.Every tree copy in the plugin walks with
os.walk(followLinks = false) but branches onos.isDir/os.copy(followLinks = true). Anode_modulessymlink — whichensureLinkedWorkspaceplants in the link report — is therefore misclassified as a directory:BunWebSupport.copyContentscalledos.makeDir.all, producing an emptynode_modulesin the staged web build.bun build index.htmlthen had nothing to resolve@JSImported packages against.BunToolchainModule.copyWorkspacecalledos.copy.over, which follows the link and deep-copies the entire resolved tree — O(node_modules) on everycompileExecutable,bundle, and test workspace — and throws outright on any broken link, which is routine for the.binshims of skipped optional dependencies.Both verified with standalone os-lib repros before fixing.
The fix
BunToolchainModule.copyTreerecreates symlinks instead of resolving them, and dispatches onos.isLinkbeforeos.isDir(followLinks = false).copyWorkspace,copyPathRefs, and bothBunWebSupportcopiers route through it.BunScalaJSWebModulenow stagesnode_modulesexplicitly — mirroringBunTypeScriptWebModule.prepareWebStage, which already did this and was correct — rather than hoping the link survives a copy.Staging tasks now declare the install they link into. Mill's filesystem checker rejects reading a dest the task doesn't depend on, so recreating the symlink surfaced a dependency that was always real but never declared. The old deep-copy slipped past the checker only because
os.copychecks the source path (inside the link report, a legal read) rather than the resolved target. Affected:BunScalaJSModule.compileExecutable/compileExecutables,BunTypeScriptModule.bundle/compileExecutable/compileExecutables,BunWorkersModule.bundleWorkers.Also in scope, same files:
htmlEntriesis split into a pure resolver andmaterializeHtmlEntries.dev()andbundlecalled the writing version against a staging task's already-cached dest.BunTypeScriptWebModule.webDevelopmentStageandwebProductionStagewere byte-for-byte identical; collapsed into onewebStage, so a production build no longer runs the same staging work twice.Proof the test catches it
scalajs-webhad no npm dependencies — which is precisely why CI was blind. It now importslodashthrough@JSImport, so the linked output carries a real npm import.Verified by stashing the source fix and running the fixture against the parent commit:
With the fix, both pass,
stage/node_modulesis a link, andlodashis inlined into the bundle rather than left as a bare import.Verification
CopyTreeTestscovers symlinked directories, broken links, exclusion, symlinked files, and empty directories;BunWebSupportTestscovershtmlEntriespurity and single-materialization.🤖 Generated with Claude Code