name: build-toolchain description: Designs and maintains build tooling — bundlers (Vite, Webpack, Rollup, esbuild, Parcel, tsup), package managers (npm, pnpm, yarn, Pipenv, Poetry, uv), lockfile hygiene, pre-commit hooks, runtime pinning, build cache, module resolution, monorepo workspaces, reproducibility. Audits for non-deterministic builds, lockfile drift, mixed package managers, unpinned runtimes, broken caches, and hooks that mutate without staging. Use when adding a bundler, switching package managers, debugging slow or non-reproducible builds, configuring pre-commit, or laying out a monorepo. Triggers on "build", "bundler", "vite", "webpack", "rollup", "esbuild", "parcel", "tsup", "swc", "babel", "npm", "pnpm", "yarn", "pipenv", "poetry", "uv", "pip-tools", "lockfile", "pre-commit", "husky", "lefthook", "build cache", "slow build", "hmr", "tree shaking", "code splitting", "monorepo", "workspace", "turborepo", "nx", "reproducible build", ".nvmrc", ".python-version", "engines field", "build toolchain".
Build / Toolchain Engineer
Owns the path from source on disk to a runnable artifact. Optimises for reproducibility first, feedback latency second, output size and runtime cost third.
Investigation order
Apply to any build problem before changing the config.
- Reproduce. Pin the exact command, working directory, lockfile state, runtime version, and OS. A build that "works on CI but not locally" is two builds; describe both.
- Measure. Wall time per stage (resolve, compile, bundle, emit), cache hit ratio, peak memory, output size. Profilers:
--profile,--analyze,--metafile,--debug-resolve, language-specific timers. - Hypothesise. Name the suspected cause in one sentence: stale cache, unhashed input, lockfile mismatch, dual transformer, plugin order, runtime drift.
- Verify. Change one variable in isolation — wipe the cache, pin the runtime, regenerate the lockfile in a clean checkout, swap one plugin out. Re-measure.
- Apply. Smallest config diff that proves the fix.
- Re-measure. Cold build, warm rebuild, dev start, and output size all return to or beat baseline before closing.
Skipping step 2 is the most common error — build problems look obvious and almost always aren't.
Mode router
Pick one per invocation. If ambiguous, ask.
| Mode | Use when | Output | |------|----------|--------| | Design | A new bundler, package manager, or monorepo layout is being introduced | Config spec (entry points, outputs, transformers, plugins, cache strategy, lockfile and runtime pinning) | | Audit | Existing config needs review | Tiered findings (Blocker / High / Medium) with the rule each violates | | Diagnose | Build is slow, flaky, non-deterministic, or producing the wrong output | Root cause traced through resolver → transformer → bundler → emitter | | Optimise | Build works but is too slow or too large | Ranked changes by measured impact, smallest first |
This project's build — a published dual-format library
Most of this skill is about building an application. react-basics-ui builds a
package other people install, which changes what "correct" means: the output
is a public contract, not a deploy artifact.
Shape
tsup.config.ts exports an array of two configs:
| Config | Entry | Emits |
|---|---|---|
| JS | src/index.ts | index.js (ESM) · index.cjs · index.d.ts / .d.cts · sourcemaps |
| CSS | src/global.css | index.css (~293 KB, Tailwind + the token system) |
package.json publishes them via the exports map (. and ./styles.css),
ships only dist via files, and marks
sideEffects: ["**/*.css"] so bundlers may tree-shake the JS but never drop the
stylesheet.
Two gotchas that cost real time here
- The stylesheet needs its own config. The declaration pass refuses a
.cssroot file (TS6054), so a combined config fails the whole build. The CSS config also setsclean: false, or it wipes the JS build that ran before it. - The entry key must be exactly
index. Naming it'index.css'producesdist/index.css.css, which the exports map does not point at — and nothing warns you.
Externals — verify, don't assume
tsup externalises dependencies and peerDependencies by default, which is what
you want: bundling react would give consumers two Reacts. Confirm after any
build change:
grep -oE 'from ?"[^"./][^"]*"' dist/index.js | sort -u
Expect exactly: clsx, react, react-dom, react/jsx-runtime,
react-icons/*, tailwind-merge. Anything else appearing means a dependency got
inlined; anything missing means it got bundled. dist/index.js is ~540 KB of
this library's own components — that number should track component count, not
dependency count.
After any build change, check all four
npm run build
ls dist/index.js dist/index.cjs dist/index.d.ts dist/index.css # all four exist
grep -c 'import\.meta' dist/index.cjs # must be 0 — syntax error in CJS
The import.meta check matters: it is valid in ESM and fatal in CommonJS, so a
Vite-style idiom slipping into source breaks only the CJS consumers, and only at
their build time.
Optimisation priority
In order. Never invert.
- Correctness. The output matches source semantics. Tree-shaking must not drop side-effectful imports; minifiers must not rename across module boundaries; transforms must preserve
this, decorators, and async semantics. - Reproducibility. Same inputs → same outputs, on any machine, any day.
- Cold build time. First build from a clean checkout.
- Warm rebuild time. Incremental build during development.
- Dev server start. Time from
startto first interactive request. - Bundle size. Compressed size of shipped artifacts.
- Memory footprint. Peak RSS during build.
A faster build that emits subtly wrong output is a regression, not a win.
Reproducibility
The default question: can this exact artifact be rebuilt in six months from the committed state of the repo alone?
Inputs that must be pinned
- Runtime. Node, Python, Bun, Deno version, declared in a version file (
.nvmrc,.tool-versions,.python-version,enginesfield) and enforced in CI. - Package manager. Version pinned (
packageManagerfield,pipenv --python,poetry env use). Mixingnpm installwith apnpm-lock.yamlcorrupts the lockfile silently. - Direct dependencies. Exact versions in the manifest and a lockfile committed to VCS.
- Transitive dependencies. Resolved versions in the lockfile, with integrity hashes.
- Native toolchain. Compiler, linker, system libraries — declare in a base image or devcontainer when they affect output.
Lockfile rules
- Always committed. A repo with
package.jsonbut nopackage-lock.json/pnpm-lock.yaml/yarn.lockhas no reproducibility guarantee. - Regenerated only by a manifest change. A lockfile churn in a PR that did not touch the manifest is a smell — investigate before merging.
- Frozen in CI. Use the install mode that fails on drift (
npm ci,pnpm install --frozen-lockfile,yarn install --immutable,pipenv install --deploy,poetry install --no-update). Plaininstallmutates the lockfile and hides drift. - One lockfile format per repo. Two installers writing to the same
node_modulesproduces ghost dependencies.
Determinism gotchas
| Source | Symptom | Fix |
|---|---|---|
| Date.now() baked into output | New hash on every build | Inject build date at runtime, not build time |
| Filesystem iteration order | Chunk hashes shuffle across machines | Sort glob results before consumption |
| Parallel emitter race | Source maps mis-aligned with chunks | Pin emitter concurrency or pin output filenames |
| Postinstall scripts touching the network | Build differs by region or DNS state | Vendor or remove the postinstall |
| Untracked env vars (NODE_ENV, CI, locale) | Output diverges by environment | Snapshot the env in the build manifest |
Bundler configuration
Entry, output, target
- Entry: enumerate explicitly. Glob entries hide which files ship.
- Output: content-hash filenames (
[name].[contenthash].js) for cacheable assets; stable names only for the HTML or manifest that points at them. - Target: pin to a concrete set of browsers, a Node version, or a runtime spec.
latestis not a target.
Transformer pipeline
Order matters and is rarely commutative.
- Resolve (path → file)
- Load (file → source string + maps)
- Transform (source → source; runs N times)
- Bundle (graph → chunks)
- Optimise (minify, tree-shake, scope-hoist)
- Emit (chunks → disk)
A common bug: two transformers claim the same extension and run in the wrong order. Symptoms — works in dev, breaks in prod, or vice versa. Make the order explicit in config.
Tree shaking
- Requires ESM sources. CJS is opaque to the bundler.
- Requires
sideEffects: falseinpackage.json(or a precise array) for any package whose modules can be safely dropped when unused. - Breaks silently on:
- Re-export barrels that re-export everything (
export * from './x') withoutsideEffects. - Property access on namespace imports (
import * as X). - Polyfills imported for side effect (must be listed in
sideEffects).
- Re-export barrels that re-export everything (
Code splitting
- Route-level: dynamic
import()at the route boundary. Default for SPAs. - Vendor split: separate chunk for stable third-party deps. Inverts cache invalidation pressure from application changes.
- Shared chunks: extract code referenced by ≥2 entries. Configure the minimum reuse threshold; the default is often too aggressive and produces dozens of tiny chunks.
A bundle with 200 chunks is not split — it is shrapnel. Measure HTTP/2 push, prefetch budget, and parse cost before adding splits.
Source maps
- Dev: inline or
eval-cheap-module-source-mapequivalent — fast, accurate to original line. - Prod:
source-map(separate file), uploaded to the error tracker, not served to clients. A public source map leaks original source.
Dev server vs prod build
These are two different programs sharing a config file. Verify both:
- Dev: HMR works, module graph is correct, no stale modules after edit.
- Prod: tree-shaking applied, minification correct, output runs in the target runtime.
A green dev server is not evidence that prod builds correctly.
Build performance
Cache layers
| Layer | Keyed by | Invalidates when | Storage | |---|---|---|---| | Resolver cache | (specifier, conditions, importer dir) | Manifest or directory listing changes | In-memory per process | | Transform cache | (file content hash, transformer version, options) | File or transformer changes | Disk, per project | | Bundle cache | (module graph, options) | Any input module changes | Disk or runner cache | | Dependency cache | (lockfile hash) | Lockfile changes | Runner cache |
Cache invalidation must depend on every input that affects output. A cache keyed on the file path but not the file content is a correctness bug, not a speed feature.
Parallelism
- Worker pools help CPU-bound transforms (TypeScript, Babel, Sass).
- They hurt I/O-bound steps; the bottleneck becomes disk, not CPU.
- Saturate at
cpu - 1workers. Above that, contention with the main thread wastes time. - Measure before parallelising — a 4× speedup that costs 16× memory is not always worth it.
Incremental builds
Required signals from the watcher:
- File added, removed, content-changed, renamed.
- Directory rescans on case-insensitive filesystems (macOS) when case changes.
A watcher that misses a rename produces a build that disagrees with source until restart. Symptom: tests pass but production fails on the renamed file.
Package managers
Selection criteria
- Match the ecosystem. Node monorepos:
pnpmoryarnwith workspaces. Python projects with native deps:uvorpoetry. Python apps with simple deps:pipenvorpip-tools. - Pick one per repo. Mixed package managers produce shadow dependency graphs that pass tests and fail in prod.
- Pin its version. Without
packageManager(Node) or[tool.poetry] versionconstraint (Python), the runner installs whatever floats — lockfile semantics differ across major versions.
Install modes
| Intent | Command pattern | Effect |
|---|---|---|
| Add a dep | add <pkg> / poetry add / pipenv install | Mutates manifest and lockfile |
| Sync from lockfile (CI) | ci / install --frozen-lockfile / install --deploy / install --no-update | Fails on drift |
| Sync from lockfile (local) | Same as CI, or install if drift is allowed | Local convention |
| Upgrade | update <pkg> / poetry update | Recomputes lockfile within manifest constraints |
The CI install must always be the frozen mode. Plain install in CI silently regenerates the lockfile and ships drifted deps.
Workspace layout
- Hoisting models differ:
npm/yarnflatten by default;pnpmsymlinks. Code that assumes a flatnode_modules(raw path imports, native module probes) breaks underpnpm. Test on the chosen layout. - A package depending on a sibling must declare it. Cross-package imports that work only because hoisting flattened them are accidents.
workspace:*andpath:specifiers preserve local development across versions; replace with concrete versions only at publish time.
Pre-commit orchestration
A pre-commit hook is code that runs on every commit. Treat it as production code on a hot path.
What belongs in pre-commit
- Fast checks: format, lint, trailing whitespace, secret scan.
- Localised checks: each hook reads only the staged files it was passed.
- Idempotent fixers: a second run on the same input is a no-op.
What does not belong
- Full test suites — too slow; push to CI.
- Build steps — too slow and changes output the developer did not intend.
- Hooks that touch unstaged files — silently widens the commit's blast radius.
- Network calls — break offline commits; non-deterministic on flaky networks.
Hook configuration discipline
- Pin every hook to a tagged release (
rev:in.pre-commit-config.yaml, an SHA or version inhusky/lefthookscripts). Floating refs mean two developers run different versions. - Pin
language_versionfor any hook with a compiled language (Python, Node). Otherwise a developer's system runtime decides what runs. - Scope
files:andexclude:per hook. A linter pointed at the entire repo runs N× as long as the staged set. - Auto-fixers must re-stage their writes or fail loudly. A hook that fixes a file without staging produces a commit that does not match what was checked.
Bypass policy
--no-verify is a tool, not a workflow. If developers bypass routinely, the hook is wrong, not the developers. Investigate before tightening.
Current state in this repo — configured but inert
husky is installed and package.json runs prepare: husky, and a lint-staged
block is configured (eslint --fix + prettier --write on *.{ts,tsx}). But
.husky/pre-commit does not exist, so nothing invokes lint-staged and no hook
runs on commit. .husky/ contains only husky's internal _/ directory.
This is dead configuration, and it is the failure mode this section warns about in reverse: not a hook that does too much, but config that reads as protection while providing none. Either wire it:
echo 'npx lint-staged' > .husky/pre-commit && chmod +x .husky/pre-commit
…or drop husky, lint-staged, and the prepare script so the repo does not
claim a guarantee it has not got. Do not leave it as-is — a reader reasonably
assumes staged files are linted.
Monorepo concerns
Task graph
A monorepo build is a DAG of (package, task) nodes. The runner (turborepo, nx, bazel, make) walks the graph and caches per node.
- Declare task inputs precisely. Anything not declared is invisible to the cache and produces stale hits.
- Declare task outputs precisely. Anything not declared is not restored from cache and looks like a miss.
- One node per
(package, task)pair. A task that depends on "the whole repo" defeats the cache.
Affected detection
since=<ref>filters to packages whose inputs changed against the ref.- Reliable only if every package's input set is complete. A missing input (e.g., a shared
tsconfig) makes affected detection silently under-build.
Versioning
- Fixed: all packages share a version; one publishes, all publish. Simple, coarse.
- Independent: each package versions on its own changes. Requires a changeset workflow or commit-message convention.
Pick one per repo. Half-and-half produces tag collisions.
Red flags
Treat as bugs unless justified in writing.
package.jsonwithout a committed lockfile.npm install(notnpm ci) in CI.- Two lockfile formats in the same repo (
package-lock.jsonandpnpm-lock.yaml). - A lockfile diff in a PR that did not touch the manifest.
latest,*, or a floating major (^1,~1) on a runtime, a build tool, or a compiler.- Postinstall scripts that fetch from the network or write outside the package.
- Bundler config that imports application source — config evaluation now depends on app correctness.
- Tree-shaking enabled with no
sideEffectsdeclaration on internal packages. - Source maps emitted to a public output directory in prod.
- Pre-commit hook pinned to
mainor no rev at all. - Pre-commit hook that runs the test suite.
- A watch script that calls a different bundler than the build script.
- A cache key that excludes the transformer's own version.
enginesfield absent from any published package.- A monorepo task whose declared inputs do not include the files it reads.
- Two developers reporting different build outputs from the same commit.
Decision flowchart — "the build is broken"
Build is failing or wrong
│
├─ Fails to resolve a module?
│ ├─ Check the resolver: extensions, conditions, exports field, tsconfig paths
│ ├─ Check the manager: lockfile in sync? frozen install passing? package manager pinned?
│ └─ Check the workspace: sibling declared as a dep? hoisting model assumed?
│
├─ Resolves but transforms wrong?
│ ├─ Two transformers claiming the same extension? Pin order, drop one.
│ ├─ Stale transform cache? Wipe; re-measure.
│ └─ Transformer version drift across machines? Pin runtime + lockfile.
│
├─ Builds locally, fails in CI?
│ ├─ Different runtime? Compare .nvmrc / .python-version vs CI image.
│ ├─ Different install mode? CI must be frozen; local often is not.
│ ├─ Case-sensitive paths? Linux CI on a case-sensitive FS catches what macOS misses.
│ └─ Missing env var? Snapshot the env that affects the build.
│
├─ Builds, but output is wrong?
│ ├─ Tree-shake dropped a side effect? Declare in sideEffects.
│ ├─ Minifier renamed across modules? Disable mangling for boundary symbols.
│ └─ Source map mis-aligned? Pin emitter concurrency.
│
└─ Builds, but is too slow?
├─ Measure stages first. Optimise the slowest, not the most visible.
├─ Cache hit ratio low? Check the key; one wrong input invalidates everything.
└─ Parallelism saturated? More workers will hurt.
Next.js App Router Expert
Development
A skill that turns Claude into a Next.js App Router expert.
README Generator
Development
Creates professional and comprehensive README.md files for your projects.
API Documentation Writer
Development
Generates comprehensive API documentation in OpenAPI/Swagger format.