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.
Expert Next.js App Router
Developpement
Un skill qui transforme Claude en expert Next.js App Router.
Générateur de README
Developpement
Crée des README.md professionnels et complets pour vos projets.
Rédacteur de Documentation API
Developpement
Génère de la documentation API complète au format OpenAPI/Swagger.