development-monorepo-pnpm skill: initial import

Full-stack pnpm + Turborepo monorepo skill, independent of the separate
backend and frontend monorepo skills it draws on.

Repository shape is split four ways at the top level: applications-backend,
applications-frontend, packages-backend, packages-frontend — so a path
states which runtime a file ends up in.

Defaults: NestJS for backend services, React with IBM Carbon Design for
browser applications; the project's stated architecture overrides both.

Covers workspace globs, the Turborepo pipeline, filtered validation,
blast-radius checks, Module Federation, turbo prune image builds, and Helm.
Documents one sanctioned cross-boundary dependency: API contract types owned
by the backend, imported type-only by the frontend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 23:49:02 +03:00
co-authored by Claude Opus 5
commit ff69bcca2f
14 changed files with 1250 additions and 0 deletions
+176
View File
@@ -0,0 +1,176 @@
# Backend Applications
Everything under `applications-backend/*`: NestJS service layout, configuration, ports and
base paths, health checks, and the service container image.
Read this when adding a service, adding an endpoint to one, changing configuration or
environment variables, assigning a port or base path, or debugging a service that will not
start.
Default framework: **NestJS**. If the project architecture specifies otherwise, follow the
architecture — the layout rules below that are framework-neutral still apply.
---
## 1. Service workspace layout
```text
applications-backend/<service-name>/
├── src/
│ ├── main.ts # bootstrap — the only file that binds a port
│ ├── app.module.ts # root module, imports feature modules
│ ├── config/
│ │ ├── configuration.ts # typed config factory, reads process.env once
│ │ └── validation.ts # schema; process exits if the env is invalid
│ ├── health/ # liveness and readiness endpoints
│ └── <feature>/
│ ├── <feature>.module.ts
│ ├── <feature>.controller.ts # HTTP surface only
│ ├── <feature>.service.ts # business logic
│ ├── dto/ # request/response shapes
│ └── entities/
├── test/
├── Dockerfile
├── nest-cli.json
├── package.json
├── tsconfig.json
└── tsconfig.build.json
```
One responsibility per layer, and the boundary is enforceable by review:
| Layer | Holds | Never holds |
|---|---|---|
| `*.controller.ts` | routing, param binding, response shape | business rules, direct DB access |
| `*.service.ts` | business logic, orchestration | HTTP concerns, `@Res()`, status codes |
| `dto/` | validated request/response shapes | logic |
| `entities/` | persistence models | HTTP or transport concerns |
A controller that reaches past its service into a repository is the most common drift in this
layout, and it is invisible until you try to reuse the logic from a queue consumer or a cron
job and discover it only exists inside an HTTP handler.
## 2. Configuration and environment
Read `process.env` in **one** place — the config factory — and inject typed config everywhere
else. Scattered `process.env` reads make it impossible to know a service's full input surface,
which is exactly what you need when writing the Helm values.
```ts
// src/config/configuration.ts
export default () => ({
port: parseInt(process.env.PORT ?? "3000", 10),
basePath: process.env.API_BASE_PATH ?? "/api/<service-name>",
database: { url: process.env.DATABASE_URL },
});
```
Validate at startup and **fail fast**. A service that boots with a missing variable and fails
on the first request that needs it turns a config error into a production incident, and the
stack trace points at the wrong place.
Every variable added here must also be added to the Helm values and the service's documented
env surface — see `references/containers-and-helm.md`.
## 3. Ports and base paths
Both are **cross-cutting contracts**, not local settings:
- The port appears in `main.ts`, the Dockerfile `EXPOSE`, the Helm values, and the service's
Kubernetes Service definition.
- The base path appears in `main.ts` (`app.setGlobalPrefix()`), the gateway or ingress routing,
and every frontend caller.
Assign a port from the repository's documented range and record it in the paired code-agent
`memory-bank/` — that record is what stops the next service from colliding with it. Changing
either value is a blast-radius change (SKILL.md §8).
```ts
// src/main.ts
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix(config.basePath);
app.enableCors({ origin: config.corsOrigins });
await app.listen(config.port);
```
## 4. Health checks
Expose **two** endpoints, and keep them different:
- **Liveness** — is the process alive? No dependency checks. A liveness probe that checks the
database restarts a healthy service every time the database blips, turning a partial outage
into a total one.
- **Readiness** — can it serve traffic now? Checks dependencies it genuinely needs.
## 5. Consuming shared packages
Backend services consume `packages-backend/*` with `workspace:*`. They must never import from
`packages-frontend/*`.
A service that defines an API contract publishes it as `packages-backend/<domain>-contracts`,
types only, so frontend applications can import it type-only — see SKILL.md §3. Adding a
runtime dependency to a contract package breaks that guarantee for every frontend consumer.
## 6. Container image
Multi-stage build using `turbo prune` to produce a minimal snapshot of just this service and
its workspace dependencies. Pruning is what stops an unrelated workspace change from
invalidating this image's layers.
```dockerfile
FROM node:22-alpine AS pruner
WORKDIR /app
RUN corepack enable
COPY . .
RUN pnpm dlx turbo prune --scope=@<scope>/<service> --docker
FROM node:22-alpine AS builder
WORKDIR /app
RUN corepack enable
COPY --from=pruner /app/out/json/ .
RUN pnpm install --frozen-lockfile
COPY --from=pruner /app/out/full/ .
RUN pnpm turbo run build --filter=@<scope>/<service>
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -S app && adduser -S app -G app
COPY --from=builder --chown=app:app /app .
USER app
EXPOSE <port>
CMD ["node", "applications-backend/<service>/dist/main.js"]
```
Points that matter:
- `out/json/` is copied and installed **before** `out/full/` so dependency installation caches
independently of source changes.
- `--frozen-lockfile` makes the build fail rather than silently resolve different versions.
- Run as a non-root user. The default is root, and nothing warns you.
## 7. Validation
```bash
pnpm check-types --filter="@<scope>/<service>"
pnpm lint --filter="@<scope>/<service>"
pnpm build --filter="@<scope>/<service>"
pnpm dev --filter="@<scope>/<service>" # it must actually boot
curl -s localhost:<port>/<basePath>/health | jq .
```
A build that passes proves the types line up; only starting the service proves the config
validation and port binding do.
## 8. Mistakes to avoid
- Reading `process.env` outside the config factory.
- Booting successfully with invalid configuration instead of failing fast.
- A liveness probe that checks dependencies.
- Business logic in a controller.
- Importing from `packages-frontend/*`.
- Giving a contract package a runtime dependency.
- Changing a port or base path without updating Helm values, gateway routing, and callers.
- Hardcoding a base path string in a frontend caller instead of deriving it from the contract.
- Running the container as root.
+202
View File
@@ -0,0 +1,202 @@
# Frontend Applications
Everything under `applications-frontend/*`: React application layout, IBM Carbon Design setup,
Module Federation wiring, and the nginx container image.
Read this when adding an application, a page, a component or a hook; theming or styling with
Carbon; wiring a host to a remote; or debugging a runtime module-loading failure.
Default stack: **React** with **IBM Carbon Design** (`@carbon/react`). If the project
architecture specifies otherwise, follow the architecture — the layout, federation, and image
rules below remain framework-neutral.
---
## 1. Application workspace layout
```text
applications-frontend/<app-name>/
├── src/
│ ├── index.tsx # entry — mounts React, imports Carbon styles
│ ├── bootstrap.tsx # async boundary, required when federated (§4)
│ ├── App.tsx
│ ├── pages/<Page>/
│ ├── components/<Component>/ # app-local components only
│ ├── hooks/
│ ├── api/ # typed clients; imports contract types type-only
│ └── styles/
│ └── _carbon.scss # single Carbon import + theme configuration
├── public/
├── Dockerfile
├── nginx.conf
├── package.json
├── tsconfig.json
└── vite.config.ts | webpack.config.cjs # webpack when Module Federation is used
```
A component belongs in `packages-frontend/*` only when a **second** application needs it and
its API has settled. Until then it stays app-local — a shared component that is still changing
imposes its churn on every consumer.
## 2. IBM Carbon Design
Install `@carbon/react` in the application workspace, never at the root.
Import Carbon styles **once**, in one SCSS entry point. Importing them per-component
multiplies bundle size and produces unpredictable cascade order:
```scss
// src/styles/_carbon.scss
@use "@carbon/react" with (
$font-path: "@ibm/plex"
);
```
Rules that keep a Carbon application coherent:
- **Use Carbon components before writing your own.** A hand-rolled button or modal loses
Carbon's keyboard handling, focus management, and ARIA wiring — accessibility work that is
already done and easy to get wrong.
- **Theme with Carbon tokens, never raw hex.** `$background`, `$text-primary`, `$layer-01`.
A raw colour is invisible to theme switching and breaks the moment someone enables dark mode.
- **Use the Carbon grid** (`Grid`, `Column`) rather than ad-hoc flexbox for page structure, so
breakpoints match the rest of the system.
- **Never mix in a second component library.** Two design systems in one app means two type
scales, two grids, and tokens that fight each other.
- Follow Carbon's spacing scale (`$spacing-01` … `$spacing-13`) instead of arbitrary pixels.
Theme selection belongs at the application root via Carbon's `Theme` component, so a switch
propagates to every component without per-component overrides.
## 3. Talking to backend services
API clients live in `src/api/`. Import contract types **type-only** from the owning backend
package — a value import pulls server code into the browser bundle and nothing in the build
will flag it (SKILL.md §3):
```ts
import type { UserDto } from "@<scope>/user-contracts";
export async function fetchUser(id: string): Promise<UserDto> {
const res = await fetch(`${BASE_PATH}/users/${id}`);
if (!res.ok) throw new Error(`fetchUser failed: ${res.status}`);
return res.json() as Promise<UserDto>;
}
```
Base paths come from configuration, never hardcoded per call site — when a service's base path
changes, one constant should move, not twenty fetch calls.
## 4. Module Federation
Used when applications are composed at runtime: a **host** loads **remotes** over HTTP. Both
are built and deployed independently, so **nothing catches a mismatch at compile time**. The
contract has three parts, and all three must agree:
1. the remote's URL and its `remoteEntry.js` filename,
2. the exact keys in the remote's `exposes`, matched by the host's imports,
3. the shared-dependency singleton settings on **both** sides.
```js
// remote — webpack.config.cjs
new ModuleFederationPlugin({
name: "remoteApp",
filename: "remoteEntry.js",
exposes: { "./Widget": "./src/components/Widget" },
shared: { react: { singleton: true, requiredVersion: deps.react },
"react-dom": { singleton: true, requiredVersion: deps["react-dom"] } },
});
// host — webpack.config.cjs
new ModuleFederationPlugin({
name: "hostApp",
remotes: { remoteApp: "remoteApp@https://<host>/remoteEntry.js" },
shared: { react: { singleton: true, requiredVersion: deps.react },
"react-dom": { singleton: true, requiredVersion: deps["react-dom"] } },
});
```
**The async bootstrap is mandatory.** `src/index.tsx` must do nothing but
`import("./bootstrap")`. Federated modules resolve asynchronously; importing React directly in
the entry file evaluates it before the shared scope is initialised, and you get two React
copies with hook errors that look nothing like a configuration problem.
Failure modes and what they actually mean:
| Symptom | Cause |
|---|---|
| `Shared module is not available for eager consumption` | missing async bootstrap |
| `Invalid hook call` / two React copies | `singleton: true` missing on one side |
| `ScriptExternalLoadError` | wrong remote URL, or the remote is not deployed |
| Host builds, blank page at runtime | `exposes` key renamed on the remote only |
Changing any part of the contract is a breaking change for the other side (SKILL.md §8).
## 5. Container image
Build with `turbo prune`, then serve the static output from nginx:
```dockerfile
FROM node:22-alpine AS pruner
WORKDIR /app
RUN corepack enable
COPY . .
RUN pnpm dlx turbo prune --scope=@<scope>/<app> --docker
FROM node:22-alpine AS builder
WORKDIR /app
RUN corepack enable
COPY --from=pruner /app/out/json/ .
RUN pnpm install --frozen-lockfile
COPY --from=pruner /app/out/full/ .
RUN pnpm turbo run build --filter=@<scope>/<app>
FROM nginx:alpine AS runner
COPY --from=builder /app/applications-frontend/<app>/dist /usr/share/nginx/html
COPY applications-frontend/<app>/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080
```
`nginx.conf` must route unknown paths back to `index.html`, or a client-side route will 404 on
refresh and deep links will not work:
```nginx
location / {
try_files $uri $uri/ /index.html;
}
```
Anything baked in at build time is public. Build-time environment variables are compiled into
the bundle — never put a secret in one.
## 6. Validation
```bash
pnpm check-types --filter="@<scope>/<app>"
pnpm lint --filter="@<scope>/<app>"
pnpm build --filter="@<scope>/<app>"
```
Federated applications additionally require host and remote **running together** — separate
green builds prove nothing about a runtime contract:
```bash
pnpm dev --filter="@<scope>/<remote>"
pnpm dev --filter="@<scope>/<host>"
```
Then load the host and confirm the remote's component actually renders, with a clean console.
## 7. Mistakes to avoid
- Importing backend contract types with a value import instead of `import type`.
- Importing Carbon styles in more than one place.
- Raw hex colours or arbitrary pixel spacing instead of Carbon tokens and the spacing scale.
- Hand-rolling a component Carbon already provides.
- Mixing a second component library into a Carbon application.
- Omitting the async bootstrap in a federated application.
- Changing an `exposes` key, remote name, or remote URL on one side only.
- Setting `singleton: true` on one side of a shared dependency but not the other.
- Missing the `try_files` fallback in `nginx.conf`.
- Baking a secret into a build-time environment variable.
- Promoting an app-local component into `packages-frontend/*` before a second consumer exists.
+128
View File
@@ -0,0 +1,128 @@
# Containers and Helm
Building images for both sides of the monorepo, and the Helm assets that deploy them.
Read this when editing a Dockerfile, adding a chart, wiring an image tag, changing values or
secrets, or propagating a port or base-path change into deployment.
---
## 1. `turbo prune` is what makes image builds sane
Both sides build the same way: prune the monorepo to one workspace and its dependencies,
install, build, then copy the result into a minimal runtime image.
```bash
turbo prune --scope=@<scope>/<workspace> --docker
```
This produces `out/json/` (package manifests plus the lockfile) and `out/full/` (source).
Copying and installing `out/json/` **before** `out/full/` is the whole point: dependency
installation caches on manifests alone, so editing source does not reinstall, and an unrelated
workspace's change does not invalidate this image at all.
Without pruning, every image rebuilds on any change anywhere in the repository.
## 2. The two runtime shapes
| | backend | frontend |
|---|---|---|
| Runtime stage | `node:22-alpine` | `nginx:alpine` |
| Ships | compiled server + `node_modules` | static assets only |
| Serves | the Node process | nginx |
| Needs `try_files` fallback | no | **yes** |
| Runtime env vars | yes — read at startup | **no** — baked in at build time |
That last row is the one that causes incidents. A frontend build inlines its environment
variables into the bundle, so they are public and fixed at build time. **A secret in a frontend
build argument is a published secret.** Anything genuinely per-environment on the frontend must
be fetched at runtime from an endpoint, not baked in.
Full Dockerfiles: `references/applications-backend.md` §6 and
`references/applications-frontend.md` §5.
## 3. Chart layout
```text
helm-charts/<chart-name>/
├── Chart.yaml
├── values.yaml # defaults — never environment-specific secrets
├── values-<env>.yaml # per-environment overrides
└── templates/
├── deployment.yaml
├── service.yaml
├── ingress.yaml
├── configmap.yaml
└── _helpers.tpl
```
Rules:
- **Never hardcode an image tag in a template.** It comes from values, supplied by the
pipeline. A hardcoded tag makes every environment deploy whatever was current when someone
last edited the chart.
- Ports and base paths in values must match the service's actual configuration (§5).
- Resource requests and limits are set for every container. A container without them competes
unboundedly with everything else on the node.
- Liveness and readiness probes point at the service's two distinct health endpoints — see
`references/applications-backend.md` §4.
## 4. Secrets
- **No secret values in `values.yaml`, `values-<env>.yaml`, templates, or the repository.**
- Reference an existing Kubernetes `Secret` by name, or use an external secrets provider that
materialises it in-cluster.
- A base64 string in a committed manifest is encoding, not encryption — treat it as plaintext.
- Never log a secret's value from a container's startup path.
## 5. Port and base-path propagation
Changing a service's port or base path touches, at minimum:
1. the service's own config (`main.ts`, config factory),
2. the Dockerfile `EXPOSE`,
3. the chart's values and `service.yaml`,
4. ingress or gateway routing,
5. every frontend caller,
6. the paired code-agent `memory-bank/` record.
Miss any one and the failure appears at runtime, in a different component from the one you
edited. Treat it as a blast-radius change (SKILL.md §8) and update all six in the same change.
## 6. Validation
```bash
helm lint helm-charts/<chart-name>
helm template <release> helm-charts/<chart-name> -f helm-charts/<chart-name>/values-<env>.yaml
# what would actually change in the cluster
helm diff upgrade <release> helm-charts/<chart-name> -f values-<env>.yaml
```
`helm lint` checks structure, not intent — a chart that lints cleanly can still point at the
wrong port. Always read the rendered output for the values that matter: image tag, port, base
path, probe paths, resource limits.
Verify an image locally before shipping it:
```bash
docker build -f applications-backend/<service>/Dockerfile -t <service>:local .
docker run --rm -p <port>:<port> <service>:local
```
Build from the **repository root** — the Dockerfile's `COPY . .` expects the whole monorepo as
context, because `turbo prune` runs inside the image.
## 7. Mistakes to avoid
- Building an image without `turbo prune`.
- Copying `out/full/` before installing from `out/json/`, losing all dependency-layer caching.
- Running a container as root.
- A secret in a frontend build argument or any committed values file.
- A hardcoded image tag in a template.
- Missing `try_files` in the frontend nginx config.
- Containers with no resource requests or limits.
- A liveness probe that checks dependencies.
- Changing a port or base path in fewer than all six places in §5.
- Building the image from the workspace directory instead of the repository root.
- Editing rendered manifests instead of the chart that produces them.
+137
View File
@@ -0,0 +1,137 @@
# Shared Packages
Everything under `packages-backend/*` and `packages-frontend/*`: which root a package belongs
in, package tiers, public-API safety, and changesets publishing.
Read this when creating a shared package, changing one's exports or dependencies, deciding
where a package belongs, or cutting a release.
---
## 1. Choosing the root
**A package lives in the root that matches its consumers, never its author.** A utility
extracted from a service but used only by browser applications belongs in
`packages-frontend/*`.
| Consumed by | Root |
|---|---|
| backend services only | `packages-backend/*` |
| browser applications only | `packages-frontend/*` |
| both — API contract types | `packages-backend/<domain>-contracts`, types only (SKILL.md §3) |
| both — genuine runtime code | see below |
Truly shared **runtime** code is rarer than it looks. Before creating one, check that it is
free of Node built-ins (`fs`, `path`, `crypto`), free of DOM APIs, and free of anything
environment-specific. If it is not, you have two packages, not one — and forcing it into a
single package pulls one environment's assumptions into the other.
When such a package is justified, place it under the root of its **primary** consumer, keep it
dependency-free, and document the constraint at the top of its README. A dependency added
later is what breaks the other side.
## 2. Package tiers
| Tier | Purpose | Examples |
|---|---|---|
| Configuration presets | shared build/lint/type config | `eslint-config`, `typescript-config` |
| Contracts | API types shared across the boundary | `<domain>-contracts` |
| Domain libraries | business logic for one domain | `billing-core`, `user-core` |
| Utilities | domain-independent helpers | `date-utils`, `result` |
Configuration presets are shared from day one — that is their job. Every other tier earns its
existence only after a second consumer appears.
## 3. Package anatomy
```text
packages-<side>/<package-name>/
├── src/
│ ├── index.ts # barrel — the entire public API
│ └── <module>.ts
├── package.json
├── tsconfig.json
├── .eslintrc.cjs | eslint.config.js
└── README.md # what it is, who consumes it, constraints
```
```json
{
"name": "@<scope>/<package-name>",
"version": "0.1.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } },
"scripts": {
"build": "tsc",
"check-types": "tsc --noEmit",
"lint": "eslint src"
}
}
```
**`src/index.ts` is the public API.** Anything not exported from the barrel is internal and may
change freely; anything exported is a contract with every consumer. Deep imports into
`dist/` or `src/` from a consumer defeat this — treat one as a bug in the consumer.
Script names must match the tasks in `turbo.json`, or Turborepo silently skips the workspace.
## 4. Changing a public API
Adding an export is safe. Changing or removing one is not.
1. Search every consumer across all four roots before editing:
```bash
grep -Rn "from '@<scope>/<package>'" applications-backend applications-frontend \
packages-backend packages-frontend
```
2. Decide explicitly whether the change is backward-compatible.
3. If it is not: update consumers in the same change, or add the new export alongside the old
one, migrate, then remove.
4. Build every affected consumer with its own `--filter`.
A shared package's dependencies propagate to every consumer, so weigh each one twice. This
matters most in `packages-frontend/*`, where a dependency lands in a browser bundle that ships
to users.
## 5. Configuration presets
`typescript-config` and `eslint-config` exist so rules live in one place. Every workspace
extends them rather than restating `compilerOptions` or lint rules locally.
Their blast radius is the **whole repository** — a rule added to the shared ESLint preset can
fail the build in workspaces you never opened. Change one, then run an unfiltered
`pnpm lint && pnpm check-types` before committing.
Each side may need its own preset variant (a DOM `lib` for frontend, Node types for backend).
Keep the shared base shared and put only the genuine differences in the variant.
## 6. Changesets and publishing
```bash
pnpm changeset # describe the change, pick the bump
pnpm publish-packages # build:ci + lint, then version and publish
```
- One changeset per meaningful change, written for a consumer deciding whether to upgrade —
not a restatement of the commit message.
- A breaking change to an exported API is a **major** bump, even if every current consumer
lives in this repository.
- Never hand-edit versions or `CHANGELOG.md`; changesets owns both.
- Internal-only packages are marked `"private": true` and are never published.
## 7. Mistakes to avoid
- Putting a package in the root matching its author instead of its consumers.
- Creating a "shared" runtime package that imports Node built-ins or DOM APIs.
- Giving a contract package a runtime dependency.
- Exporting something from the barrel that was meant to be internal.
- Deep-importing another package's `src/` or `dist/` from a consumer.
- Changing an exported API without searching consumers first.
- Adding a dependency to a frontend package without considering bundle size.
- Restating `compilerOptions` or lint rules locally instead of changing the shared preset.
- Editing versions or changelogs by hand.
- Promoting logic into a shared package before a second consumer exists.
+143
View File
@@ -0,0 +1,143 @@
# Workspace and Tooling
Root-level configuration: workspace globs, the Turborepo pipeline and its caching, root
scripts, Prettier, dependency overrides, and lockfile discipline.
Read this when adding a workspace, changing `turbo.json` or root scripts, debugging a task
that does not run or a cache that never hits, or touching `pnpm.overrides`.
---
## 1. Workspace globs
```yaml
# pnpm-workspace.yaml
packages:
- "applications-backend/*"
- "applications-frontend/*"
- "packages-backend/*"
- "packages-frontend/*"
```
Exactly one level of nesting under each root. A directory that no glob matches is invisible to
pnpm and to Turborepo — it will not install dependencies, will not build, and will not fail
loudly. The symptom is usually "my new package isn't found by its consumer".
After adding a workspace, run `pnpm install` so the workspace links are created.
## 2. Root `package.json`
```json
{
"packageManager": "pnpm@10.17.1",
"engines": { "node": ">=22", "pnpm": ">=10" },
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"check-types": "turbo run check-types",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md,scss}\"",
"publish-packages": "turbo run build:ci lint && changeset version && changeset publish",
"prepush": "turbo build --filter=...[HEAD^]"
}
}
```
- `packageManager` pins the exact pnpm version; corepack reads it, so every contributor and CI
runner resolves dependencies identically.
- Root scripts **always** delegate to Turborepo, never to a single workspace — calling a
workspace script directly bypasses the dependency graph and builds stale upstream code.
- Root dependencies are tooling only: turbo, prettier, husky, changesets, typescript.
## 3. Turborepo pipeline
| Task | Depends on | Cached outputs |
|---|---|---|
| `build` | `^build`, `check-types`, `lint` | `dist/**`, `.next/**` |
| `build:ci` | `^build:ci` | `dist/**`, `dist-internal/**` |
| `lint` | `^lint` | — |
| `check-types` | `^check-types` | — |
| `dev` | — | — (persistent, never cached) |
`^task` means the task must complete in every upstream workspace dependency first. That is
what makes `--filter` safe: filtering to one workspace still builds what it depends on.
Two consequences worth internalising:
- **`build` does not run until `check-types` and `lint` pass.** A type or lint error is a build
failure for every downstream workspace, not a warning in one.
- **Every output directory a workspace produces must be listed in `outputs`.** An unlisted
directory is not restored from cache, so a "cache hit" silently yields a missing build
artifact — which surfaces later as a container image built from nothing.
Task names in a workspace's `package.json` must match the task names in `turbo.json`. A
mismatch is not an error; Turborepo just skips that workspace, and the task appears to pass.
Cache inputs include `$TURBO_DEFAULT$` and `.env*`.
## 4. Dependency rules
- Add a dependency to the **smallest** workspace that needs it.
- Inter-workspace dependencies use `workspace:*`.
- Run `pnpm install` after every `package.json` edit; commit the `pnpm-lock.yaml` change
together with the edit that caused it. A lockfile committed separately is unreviewable.
- Never hand-edit `pnpm-lock.yaml`.
### `pnpm.overrides` — security pins
```json
{ "pnpm": { "overrides": { "undici": "^6.21.1", "ws": "^8.17.1" } } }
```
Each entry exists because a **transitive** dependency shipped a vulnerability its direct
parent had not yet picked up. Removing an entry silently reinstates the vulnerable version and
nothing in the build complains — no error, no warning, a green pipeline.
Remove one only after confirming the direct dependency now resolves to a fixed version, and
record that verification in the change.
## 5. Formatting
Root `.prettierrc` is the single authority for the whole repository. Do not add per-package
Prettier config: two configs mean every file formatted from one side produces churn diffs when
touched from the other.
```bash
pnpm format
```
## 6. `Taskfile.yml`
The root `Taskfile.yml` is a **thin delegator** — it only `includes:` module Taskfiles under
`.scripts/`. Never add inline commands to it. New automation becomes a `.scripts/<module>/`
with its own Taskfile, which is then included.
## 7. Diagnosing common failures
| Symptom | Likely cause |
|---|---|
| New workspace not found by its consumer | no matching glob, or `pnpm install` not re-run |
| Task appears to pass but does nothing | script name does not match a `turbo.json` task |
| Cache hit but missing build artifact | output directory not listed in `outputs` |
| Lockfile churn on every install | `packageManager` not pinned, or pnpm version drift |
| Build fails only in CI | `--frozen-lockfile` exposing an uncommitted lockfile change |
| Vulnerability reappears after an install | an overrides entry was removed |
```bash
pnpm list --depth 0
pnpm why <package>
turbo run build --dry-run # what would run, and in what order
turbo run build --filter=...[HEAD^]
```
## 8. Mistakes to avoid
- Adding a workspace outside the four roots, or without updating the globs.
- Calling a workspace script directly instead of going through Turborepo.
- Adding a root dependency for a need local to one workspace.
- Omitting an output directory from a task's `outputs`.
- A workspace script name that does not match its `turbo.json` task.
- Removing a `pnpm.overrides` entry without a security review.
- Per-package Prettier configuration.
- Inline commands in the root `Taskfile.yml`.
- Committing a lockfile change apart from the `package.json` edit that caused it.