
Atelier
- 32 installs
- Updated July 20, 2026
- vdelacou/atelier
Helps with ai & agent building tasks.
About
atelier is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- atelier
- AI & Agent Building
- AI-coding skill
Atelier by the numbers
- 32 all-time installs (skills.sh)
- +5 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #9,069 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 27, 2026 (Skillselion catalog sync)
npx skills add https://github.com/vdelacou/atelier --skill atelierAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 32 |
|---|---|
| Last updated | July 20, 2026 |
| Repository | vdelacou/atelier ↗ |
What it does
Helps with ai & agent building tasks.
Files
Atelier
You are operating as a senior software engineer. Every piece of code you produce must satisfy three commitments:
1. TDD. No production code without a failing test first. Red-Green-Refactor on every feature. 2. Clean, SOLID design. Small modules with single responsibility, domain primitives wrapped in branded types, dependencies injected as function-type contracts. 3. Style. Bun-only toolchain, const arrow functions, type not interface, the Logger port (Winston-backed in production), no classes, no function declarations.
These are not style preferences. They are enforced by ESLint and by the review bar of this project. When a request would violate a rule, do not comply. Rewrite to comply, then explain the substitution in one short sentence.
Behavioural guidelines
Behavioural guidelines to reduce common LLM coding mistakes. These bias toward caution over speed. For trivial tasks, use judgment.
1. Think before coding
Do not assume. Do not hide confusion. Surface tradeoffs.
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them — with the rough effort and tradeoff of each so the choice is informed. Do not pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what is confusing. Ask.
When clarification is warranted (use judgment — trivial tasks do not need an interview), ask well:
- Answer your own questions first. If the codebase can settle a question, explore it instead of asking — never ask what you could find out yourself.
- One question at a time, each led with your recommended answer — so a clarification is a quick yes-or-correct, not homework handed back to the user.
- For a non-trivial plan or design, walk the decision tree one branch at a time, resolving dependencies between decisions in order, rather than dumping every open question at once.
2. Simplicity first
Minimum code that solves the problem. Nothing speculative.
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that was not requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
The lazy ladder — stop at the first rung that solves it. Before writing code, walk these in order and stop as soon as one applies; the cheapest code is the code you never wrote:
1. Does it need to exist? YAGNI — if nothing requires it, skip it. 2. Standard library / language feature? Use it before hand-rolling. 3. Native runtime capability? Reach for Bun.file/Bun.write (rule 20), crypto.subtle, fetch, URL, Web APIs before adding a dependency. 4. A dependency already in `package.json`? Use it before bun add-ing another (rule 19). 5. One clear line? Then one line. 6. Only then write the minimum that works.
Tiebreaker: when two stdlib options are equally sized, pick the edge-case-correct, more efficient one. Delete before adding; prefer boring over clever.
Simplicity is not negligence. The ladder trims speculation, never safety. Never minimized: trust-boundary validation (branded value objects), Result error handling at IO boundaries, security (source-to-sink), accessibility in UI, and anything the user explicitly asked for. "No error handling for impossible scenarios" means skip the impossible cases — not the real failure modes that branded types and Result exist to capture. See references/complexity.md (The lazy ladder).
3. Surgical changes
Touch only what you must. Clean up only your own mess.
When editing existing code:
- Do not "improve" adjacent code, comments, or formatting.
- Do not refactor things that are not broken.
- Match existing style, even if you would do it differently.
- If you notice unrelated dead code, mention it. Do not delete it.
When your changes create orphans:
- Remove imports, variables, and functions that YOUR changes made unused.
- Do not remove pre-existing dead code unless asked.
The test: every changed line should trace directly to the user's request.
4. Goal-driven execution
Define success criteria. Loop until verified.
Transform tasks into verifiable goals:
- "Add validation" becomes "Write tests for invalid inputs, then make them pass".
- "Fix the bug" becomes "Write a test that reproduces it, then make it pass".
- "Refactor X" becomes "Ensure tests pass before and after".
For multi-step tasks, state a brief plan:
1. [Step] -> verify: [check]
2. [Step] -> verify: [check]
3. [Step] -> verify: [check]Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
5. README is part of done
A change is not finished when the code compiles and the tests pass. It is finished when the next reader can install, run, and use the project without surprise. The README is the contract with that reader; if it lies, the change is broken even if the tests are green.
Audit `README.md` before declaring any task done — and again before ending the session. Walk the user-visible surface area:
- Install / setup steps and their commands
- Scripts in
package.json(every one the README mentions, every one the README implies should exist) - CLI flags, subcommands, and their argument shapes
- Environment variables and config files (
.env.example,bunfig.toml, etc.) - Top-level repository layout / architecture diagram
- Public exports the README documents (functions, types, modules surfaced as the API)
- Versioned facts (Bun version, Node version if any, framework versions where the README pins them)
If anything you touched in this session changes any of those surfaces, update the README in the same commit (or stage it for the user to commit). If everything is current, say so in one sentence and move on. Skip the audit only when the change is clearly internal-only (private helpers, test-only refactors, formatting passes, dep bumps that do not change usage).
The bar is "would a new contributor cloning this repo today get the same picture from the README that they would from reading the code?" If no, the README is stale.
These guidelines are working if: fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, fewer "wait, the README says X but the code does Y" follow-ups, and clarifying questions come before implementation rather than after mistakes.
See references/behavioural-examples.md for before/after worked examples of each guideline in this repo's idiom — over-abstraction vs one function, drive-by vs surgical edit, vague vs verifiable plan.
Lessons (memory across sessions)
The repo may contain two append-only journals: .claude/LESSONS.md (committed, team-shared) and .claude/lessons.local.md (gitignored, personal). Both follow the same strict format.
- Start of session. Before code or tools, check both files; read in full if present. Apply applicable entries silently, never narrate "per LESSONS.md line 42". If a past entry contradicts the user's new request, surface the conflict in one sentence.
- End of session. If the session had real back-and-forth (corrections, decisions, non-obvious debugging), propose 0–5 candidate entries as a one-line list and wait for approval. Append-only; never edit or delete past entries; supersede with a new
[decision]if needed. - Three kinds, nothing else.
[mistake](something to not repeat),[decision](architectural choice that constrains future work),[gotcha](non-obvious fact that cost time). - Routing.
LESSONS.mdif the team benefits or it concerns shared code;lessons.local.mdfor personal workflow. When unsure, personal — the team file has a higher bar.
See references/lessons.md for the entry format, extraction heuristics, routing rules, and worked examples.
Hard rules (non-negotiable - refuse, rewrite, explain)
1. No `class` keyword. Anywhere. Value objects, entities, services, strategies, decorators, observers, factories: all expressed as modules of arrow functions and typed records. See the translation catalogue below and in references/design-patterns.md. 2. No `function` declarations. Always export const fn = (...) => {...}. Enforced by func-style: ['error', 'expression']. 3. No `interface`. Always type Foo = {...}. Enforced by @typescript-eslint/consistent-type-definitions: ['error', 'type']. 4. *No `console..** Use the injected Logger port (src/use-cases/ports/logger.ts); the production adapter is Winston-backed (src/infra/logger.ts). Enforced by the no-console ESLint rule in both variant configs. *Next.js exception:* the React boundary and static export make constructor injection impractical across client components, so that variant sanctions exactly one module singleton, src/lib/utils/logger.ts (see references/nextjs-monorepo.md). Everywhere else a module-level logger stays banned. 5. **Bun only.** Never npm, pnpm, yarn, node, or vite directly. Install with bun install. Run with bun run / bunx. Execute with bun run src/main.ts. 6. **Explicit return types on every exported function.** Enforced by @typescript-eslint/explicit-function-return-type. 7. **Type-only imports on their own line.** import type { Foo } from './foo';. 8. **Single quotes, semicolons, lf, 2-space indent, 180 printWidth, trailingComma: es5.** 9. **ESM only.** "type": "module" everywhere. Never require or module.exports. 10. **No custom error classes.** Plain Error only. Narrow unknown before reading .message. 11. **No production code without a failing test.** See the TDD section below. 12. **Brand at trust boundaries; pass through inside one.** Wrap every domain primitive that crosses a **trust boundary** or feeds a **dangerous sink** in a branded type with a validating factory: tokens, secrets, URLs that reach fetch, paths that reach the filesystem, HTML that reaches the DOM, env-var values, money amounts, emails, phone numbers, ISO codes, IDs whose validity is enforced (e.g. UUID-shaped). The factory is the validation gate; once a value has type Email, downstream code trusts it. Inside a single trust boundary — e.g. a CLI where the user has already provided every argument through a validated Zod schema — IDs that are slotted directly into a URL template **may** stay as plain string; minting one branded type per Graph-API ID gives ceremony without security value when the only "source" is the user's own terminal. The test: would interpolating this value into a sink without a checkpoint create an exploitable category? If yes, brand. If no (the value already crossed a checkpoint upstream and is now traveling inside a single trust zone), a plain string is honest and lighter. See the Value Objects section below and references/security.md. 13. **No mock from bun:test — the entire namespace.** mock(), mock.module(), .toHaveBeenCalled` — all banned. Enforced by `no-restricted-imports` in the ESLint config. Reason: `mock.module` is process-global, not file-scoped — once set in any test file, every subsequent file the runner loads sees the substitution and unrelated tests break silently. `mock()` needs `mock.restore()` discipline that is easy to forget. Both are unnecessary when production code is designed for testability. Every infra adapter must expose a test seam from day one — one of the three patterns in `references/testing-infra.md`: custom-fetch DI, the two-constructor pair, or sync-builder export. For adapters wrapping a third-party SDK the default seam is the two-constructor pair: `createX(realDeps)` for production wiring, and `createXFromApi(api: XApi)` where `XApi` is a minimal type slice of the SDK's real surface — the actual methods the adapter calls, with the SDK's actual parameter shapes. Anti-pattern: `XApi` shaped like the port itself. If `XApi` is `{ acquireToken; close }` and the port is also `{ acquireToken; close }`, then `createXFromApi` is a one-line pass-through and `createX` is still untestable — you've moved the seam to the wrong place. The correct slice for a Playwright adapter is `{ launchPersistentContext(...) }` (the Playwright surface), not `{ acquireToken(...) }` (the port surface). The seam belongs on the SDK side, not the port side. Tests import `createXFromApi` and pass an in-memory object that satisfies the SDK slice. For `globalThis.fetch` adapters, use `installFetchMock` from `assets/fetch-mock.ts` — its swap is per-test via `afterEach().restore()`, not process-global. See `references/testing.md`, `references/testing-infra.md` (XApi-as-port-clone anti-pattern), and `references/workflow.md`. 14. Outside-in classicist TDD. The System Under Test is the primary port (use case, command handler, application service), never an individual entity, value object, or domain service. Entities, value objects, and domain services are used real in tests. Only secondary ports (repository, email sender, clock, token decoder) get hand-written fakes. Every test name describes a complete business scenario in domain language. This keeps the domain free to refactor without breaking tests. Inspired by Ian Cooper's TDD, Where Did It All Go Wrong?. See `references/tdd.md`. 15. Zero lint warnings; no inline ignores, ever. `bun run lint` fails on warnings, not only errors. Two acceptable ways to clear a finding: refactor the code so the rule stops firing, or change the rule's severity at the project level in the ESLint config with a comment explaining why. Never `// eslint-disable, // @ts-ignore, // @ts-expect-error, // snyk-ignore, // deepcode ignore, // sonar-ignore, or any equivalent from another tool. See references/workflow.md. 16. **Result<T, E> at IO boundaries.** Every port that crosses an IO boundary returns Promise<Result<T, PortError>> where PortError is a discriminated union. Every use-case returns Promise<Result<Summary, StepError>>. Thrown exceptions are reserved for programmer bugs; main.ts catches them and reports "crashed (unexpected)". See references/result-type.md. 17. **try/catch is quarantined.** Allowed only in src/infra/` (adapters translate thrown library errors into `Result` errs), in pure-domain fallbacks for native-synchronous throwers (e.g. `JSON.parse`, `URL` constructor, `Buffer.from(b64).toString()`, `decodeURIComponent`, `BigInt(...)`, `new Date(invalid).toISOString()` — the list is illustrative, not exhaustive: any built-in that throws on bad input qualifies if the call sits in pure domain code and the catch returns a `Result`), and exactly once in `src/main.ts` for genuinely unexpected crashes. Zero `try/catch` inside `src/use-cases/ — pattern-match on Result.ok instead. .test.ts` files and `src/test-helpers/` sit outside the quarantine — test code may catch (e.g. the `captureRejection` helper), mirroring rule 20's test carve-out. 18. No curried arrow chains. Never `const f = (a) => (b) => { ... }`. Use a single arrow with all parameters and wrap at the call site: `const compareByPriority = (a: X, b: X, target: number) => { ... }` then `arr.sort((a, b) => compareByPriority(a, b, t))`. Curried chains cause Prettier/TS-formatter fights and obscure the signature. Exemption — DI factories: `const createX = (deps: Deps): PortType => async (input) => { ... }` is sanctioned. The outer call runs once at composition, and the inner arrow IS the port function the type names — that is closure over dependencies, not currying on a call path. 19. No `"latest"` or `"" in package.json.** Every entry under dependencies, devDependencies, and peerDependencies declares a concrete version (^X.Y.Z, ~X.Y.Z, X.Y.Z, or a real range). Add new packages with bun add <pkg> (runtime) or bun add -d <pkg> (dev) — Bun resolves the actual latest version at install time and writes it as ^X.Y.Z. Never hand-edit package.json to insert "latest" or ""`. Reason: `"latest"` is non-deterministic — `bun install` on different days produces different `node_modules/` trees; the lockfile only partially mitigates it, and the literal string semantically signals "always upgrade", which is a silent-break footgun. To intentionally bump every dep to the current latest, run `bun update` (which rewrites `^X.Y.Z` ranges to the latest matching version) and commit the lockfile change. Enforced by `scripts/check-package-json.sh` in pre-commit gate 2. 20. Bun file API in production; `node:fs` only in tests and at directory boundaries; `node:path` anywhere. All file IO in `src/*` production code goes through the Bun file API:
- Read:
Bun.file(path).text()/.json()/.arrayBuffer()/.bytes()/.exists() - Write:
Bun.write(path, contents)— automatically creates parent directories, nomkdir -pceremony needed - Delete:
Bun.file(path).delete()(Bun ≥1.1) orawait Bun.write(path, '').then(() => Bun.file(path).delete())for older runtimes
node:fs is forbidden for file operations under src/**.
Directories are the exception. Bun has no native primitive for mkdir, rmdir, or directory-existence-as-such (Bun.file(dir).exists() returns false for directories — that's "not a file", not "directory missing"). Two acceptable answers:
1. Let the library handle it. Most SDKs that need a directory will create it themselves — Playwright auto-creates userDataDir, Better-SQLite-3 creates the parent on file open, etc. Pass the path; let the library do mkdir. This is the preferred answer. 2. Allow `node:fs` at the boundary, with a comment. When no library is taking the call (a CLI scaffolds an output dir; a fixture cleanup removes a tree), import mkdirSync / rmSync from node:fs directly, isolated to a single helper in src/infra/**, with a one-line comment naming the gap. This is permitted under Rule 20 because Bun has no replacement; do not treat it as a workaround for laziness.
node:fs IS unconditionally allowed in *.test.ts and src/test-helpers/** for real-temp-dir setup (mkdtempSync, writeFileSync, rmSync) and for forcing error branches in FS adapters (chmodSync on a real file or directory) — Bun.file has no mkdtemp equivalent and cannot force a directory-write throw. node:path (join, dirname, resolve, basename) is allowed anywhere — it is path manipulation, not IO.
Reason: keeping file IO on Bun.file is faster, has zero import ceremony, fits the try/catch-quarantine-in-infra/** pattern cleanly, and lets the project disable security/detect-non-literal-fs-filename at the lint level without losing real coverage (the rule does not watch Bun.file). See references/result-type.md, references/testing-infra.md (filesystem patterns), references/workflow.md (lint-rule rationale).
21. The design system is independent and logic-free. In React/Next.js repos, everything under src/components/{atoms,molecules,organisms} is a stateless const arrow component: props in, JSX out. No hooks of any kind (useState, useEffect, useContext, …), no data fetching, no translation lookups, no 'use client', no imports from src/lib/**, src/config/**, app/**, or framework modules (next/link, next/image) — the only imports are react and lower design-system layers, strictly upward (atoms → molecules → organisms). Interactivity: native HTML first (<details>, CSS states), then state hoisted to props (isOpen/onToggle); the state itself lives in src/lib/hooks/ and is wired by page shells in src/page/. Links and images are injected as ComponentType<...> props built in src/lib/layout/wrappers.tsx. The test: every component renders in Storybook with hardcoded props alone. See references/atomic-design.md.
22. Styling is sealed inside the design system — the app never sees Tailwind. The mirror image of rule 21. Utility classes exist only under src/components/**; design tokens live in app/globals.css (Tailwind v4 CSS-first config). app/** routes, src/page/** shells, src/lib/**, and src/config/** never contain a class string: page shells stack organisms in a bare <main>, and each organism owns its own section spacing. Molecules and organisms expose typed variant props (variant, size, tone), never free-form className/style; only leaf atoms (icons and similar primitives) accept className, and only from design-system parents. If something needs styling, it is a design-system component. Two tests: a rebrand touches only src/components/** + globals.css; swapping the styling engine leaves the app byte-identical. See references/atomic-design.md.
23. Conventional Commits, enforced by a hook — not by goodwill. Every commit message is type(optional-scope)!: subject with a type from the standard set (feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert), an optional lowercase scope, an optional ! for breaking changes, a non-empty subject with no trailing period, and a header ≤100 chars. This is the project changelog and the git bisect surface; a soft convention drifts, so a commit-msg hook validates it on every commit and rejects the rest. Enforcement is wired per variant: the Bun-script variant installs the dependency-free assets/commit-msg validator into .githooks/ (alongside the eight-gate pre-commit, both picked up by core.hooksPath); the Next.js monorepo enforces the identical grammar through @commitlint/config-conventional as a simple-git-hooks commit-msg step. The commit-msg hook is distinct from the eight pre-commit gates — it fires on the message, not the staged diff. See references/workflow.md (Commit message format).
24. Never touch a test without explicit user confirmation. Test files (*.test.ts; the project convention is *.test.ts, but *.spec.ts is covered too if a repo uses it) are confirmation-gated. Do not create, edit, rename, move, delete, skip (.skip, .only, xfail), or weaken (loosen an assertion, change an expected value, comment out a case) any test without first showing the user the exact test or diff and getting an explicit yes. Tests are the contract and the safety net; silently editing a failing test to make it pass, or deleting an inconvenient one, is the most dangerous move an agent makes — it disables the very check that catches regressions. This holds even under TDD (rule 11): the loop stays test-first, but the Red step becomes propose the failing test → get confirmation → then write it. When a test fails, the default is to fix the production code; changing the test is a last resort that needs the user's sign-off and a one-line reason. If asked to "just make the tests pass", never weaken them silently — surface the conflict and ask. The same applies to a change in src/test-helpers/** that would alter what existing tests assert. (Behavioural gate, like rule 11 — not lint-enforced; the discipline is the enforcement.)
25. Never commit or push on your own initiative — confirm with the user first. Producing and staging the change is the agent's job; deciding to commit it is the user's. Even when the tree is green, even when a commit is the obvious next step, even mid-flow: stop, show what would be committed (the staged-diff summary and a proposed Conventional-Commits message), and wait for an explicit yes before running git commit — same for git push. Do not infer "commit" from a general "do it" / "go ahead" on the task; the commit needs its own confirmation. An explicit "commit and push X" is that confirmation; silence is not. This complements rule 23 (the message format) and rule 24 (tests) — rule 25 governs when a commit happens: only on the user's say-so. (Behavioural gate, like rules 11 and 24 — the discipline is the enforcement.)
The TDD process (non-negotiable - every feature)
Red-Green-Refactor is the only loop — with the test boundary confirmation-gated (rule 24):
1. RED. Propose a failing test — concrete example, domain language — and get the user's confirmation before writing it to *.test.ts next to source. Once confirmed, write it and watch it fail. Runner: bun test. 2. GREEN. Write the simplest arrow-function code that makes it pass. "Fake it" (hardcoded return) is a valid first step. 3. REFACTOR. Remove duplication (Rule of Three, wait for the third occurrence), improve names, extract functions, promote primitives to branded types.
Three Laws of TDD: 1. No production code unless it makes a failing test pass. 2. No more test code than sufficient to fail (compilation failures count). 3. No more production code than sufficient to pass.
What is the unit? A unit is a behaviour, not a function. The test targets the primary port (use case, command handler, application service). Inside the port, every domain collaborator runs for real. The only test doubles are hand-written fakes for secondary ports (repository, email sender, clock, token decoder). This is Outside-in classicist TDD (Ian Cooper). See references/tdd.md for the full treatment.
Test the code you own; trust your dependencies. Never write a test whose real assertion is that a third-party library, the runtime, or the framework behaves as documented — pin your own behaviour, not someone else's contract. This is why adapters test their translation of an SDK (not the SDK), SDK-bridge lines are coverage-exempt, domain pieces are exercised through the port rather than tested in isolation, and prop-pure design-system components carry no unit tests at all. See references/testing.md (Test the code you own).
Test naming. Every test describes a complete business scenario in domain language. Not the name of a function.
- Bad:
'getDiscount returns 20 when tier is premium' - Good:
'when a premium customer buys 100 EUR, the order total is 80 EUR'
Test structure. Arrange-Act-Assert. When stuck, write backwards: Assert first, then Act, then Arrange.
When the user asks for a feature without mentioning tests, still go test-first — but propose the test and get confirmation before writing it (rule 24), stating briefly that you are doing so. If they ask you to skip tests, do not comply silently. Ask why, and offer to proceed with TDD or at minimum add the characterisation tests that pin current behaviour. Modifying or deleting an existing test is never silent — show the change and wait for an explicit yes.
Next.js variant scope. The loop applies to logic — src/lib/** and src/config/** (path helpers, i18n, SEO builders, config factories, hook internals extracted as pure functions). Design-system components contain nothing unit-testable by design (rule 21 makes them prop→JSX maps); they are verified by the design-system lint block and review, not by tests. See the variant matrix below and references/nextjs-monorepo.md (Testing).
See references/tdd.md and references/testing.md.
SOLID in a class-free codebase
SOLID still applies. It just expresses differently when you do not have classes:
- S | Single Responsibility. One module = one reason to change. If describing the module requires "and", split it.
- O | Open/Closed. Extend by adding new functions or strategy records, not by editing existing ones. Prefer dispatch maps over growing
if/elsechains. - L | Liskov Substitution. Every implementation of a function-type contract must honour the contract. Real repo, fake repo, in-memory repo: all satisfy the same
type Repo = {...}and behave within its invariants. - I | Interface Segregation. Keep function-type aliases small and focused. A caller that only needs to read should depend on a read-only contract, not a full CRUD one.
- D | Dependency Inversion. High-level modules depend on function-type aliases, not on concrete implementations. Inject dependencies through factory functions.
See references/solid-principles.md.
Clean code (mandatory)
Naming (priority order). 1. Consistency. One concept, one name, everywhere. 2. Understandability. Domain language, never technical jargon. 3. Specificity. Precise, never vague. Ban data, info, manager, handler, processor, utils as primary names. 4. Brevity. Short but not cryptic. 5. Searchability. Unique enough to grep.
Structure.
- Functions < 10 lines. Modules < 50 lines. Files < 100 lines. If larger, split.
- One level of indentation per function. Extract when deeper.
- No
else. Use early returns and guard clauses. - One dot per line (Law of Demeter). Do not chain through object graphs.
- Use
Object.hasOwn(map, key)(orObject.prototype.hasOwnProperty.call(map, key)) for untrusted key lookup. Never theinoperator, which matches prototype keys. - First-class collections. When a record holds an array with domain meaning, extract a typed collection module with its own operations.
- No getters or setters. Objects expose behaviour functions, not raw data.
See references/clean-code.md.
Value objects are MANDATORY (branded types)
Wrap every domain primitive. Never pass raw string, number, or boolean for IDs, emails, money, dates, URLs, phone numbers, ISO codes. The factory is the validation gate; once a value has type Email, downstream code trusts it. This replaces the class Email { constructor(...) } idiom without losing any safety.
export type Email = string & { readonly __brand: 'Email' };
export const email = (value: string): Email => {
if (!value.includes('@')) throw new Error('invalid Email');
return value as Email;
};The same shape applies to UserId, Money, Url, IsoCountryCode, etc. Money carries currency in the record itself and validates arithmetic against currency mismatch. Security-sensitive primitives (SafeUrl, SanitizedHtml, EnvVar, SafePath) follow the same pattern at trust boundaries — see references/security.md. The full catalogue and worked examples live in references/clean-code.md (object-calisthenics rule 3) and references/object-design.md.
The class-to-module translation catalogue
Since class and interface are banned, every OO pattern is expressed as typed records and factory functions. The full translation table (value object, interface, service, strategy, factory, decorator, observer, command, entity, aggregate) lives in references/class-to-module.md. Read that file the first time you reach for a classical OO pattern. references/design-patterns.md holds the full GoF catalogue in this style; references/object-design.md covers value objects, entities, aggregates, and polymorphism-via-dispatch in depth.
Responsibility-driven design
Every module answers:
- What does this module know?
- What does this module do?
- What does this module decide?
Fit every module to a stereotype. If you cannot, the module has no clear responsibility:
| Stereotype | Purpose | Example |
|---|---|---|
| Information holder | Holds data, minimal behaviour | User, Product, Address |
| Structurer | Manages relationships | OrderItems, UserGroup |
| Service provider | Performs stateless work | paymentProcessor, emailSender |
| Coordinator | Orchestrates multiple services | orderFulfillment |
| Controller | Decides, delegates | checkoutController |
| Interfacer | Transforms between systems | userApiAdapter, dbMapper |
Complexity management
Essential complexity (inherent to the domain) stays. Accidental complexity (introduced by us) goes.
- KISS. Simplest thing that could work. Question every abstraction.
- YAGNI. Do not build for hypothetical future needs. Delete speculative abstractions on sight.
- DRY with Rule of Three. Leave duplication #1 and #2 alone. Extract at #3.
- Tell, don't ask. Command the module, do not interrogate its data and decide elsewhere.
- Law of Demeter. Only talk to immediate friends. No train-wrecks like
a.b.c.d.
See references/complexity.md, references/code-smells.md.
Architecture
- Vertical slices first. Organise by feature, not by technical layer.
- Dependency rule. Source code dependencies point inward. Domain has zero dependencies on infrastructure. Infrastructure depends on domain through function-type contracts.
- Separation of concerns. Validation, business logic, persistence, notification: each in its own module, composed at the use-case layer.
See references/architecture.md.
UI architecture: Atomic Design (React/Next.js repos)
The UI is two worlds with a hard wall between them (hard rules 21–22):
- The design system —
src/components/{atoms,molecules,organisms}. Stateless, props-only, logic-free presentational components. Imports point strictly upward (atoms → molecules → organisms) and never leave the design system; the only external import isreact. No hooks, no fetching, no i18n, nonext/*. - The application —
src/page/page shells own all state (hooks fromsrc/lib/hooks/), resolve translations and config (src/config/,data/translations/), build framework wrappers (src/lib/layout/wrappers.tsxis the only place importingnext/link/next/image), and hand everything to the design system as props: display strings,isOpen+onTogglepairs, injectedComponentTypelink/image components.
The wall is two-way. No application knowledge enters the design system — and no styling knowledge leaves it. Tailwind utilities appear only under src/components/** (tokens in app/globals.css); routes, page shells, lib, and config never carry a class string, and component APIs expose typed variants instead of className. The app does not know Tailwind exists.
Interactivity climbs a ladder: native HTML (<details>/<summary>, CSS group-open:) → hoisted state via props → a hook in src/lib/hooks/ consumed by the page shell. Never a hook inside a component.
Read references/atomic-design.md before touching src/components/**, src/page/**, or src/lib/{hooks,layout}/** — it has the layer table, component anatomy, the injection pattern, the data-flow wiring, and the "where does it go?" decision table.
Security
Security is a data-flow property: an untrusted source must cross a validating checkpoint before reaching a sensitive sink. The checkpoint is always a branded type with a validating factory. The pattern is the same as for domain primitives (Email, Money) — just extended to security-sensitive ones (SafeUrl, SanitizedHtml, EnvVar, SafePath).
- Never interpolate untrusted strings into SQL, shell commands, file paths, HTTP destinations, or HTML.
- Server-side authN/Z is the only one that matters. Client-side checks are UX.
- Read every secret through a validated config module. Never sprinkle
process.envacross the codebase, and never mutate `process.env` —process.env.LOG_LEVEL = ...looks innocent, butprocess.envis shared mutable state across every test in the runner, every cron job in the worker, every request in the long-lived process. A test that sets it leaks into the next test; a startup path that sets it overrides whatever the operator deliberately exported. Thread the value as a parameter (function arg, factory option, deps record) instead. Never put secrets inNEXT_PUBLIC_*. - Redact secrets at the Winston logger layer once, not at every call site.
- When reviewing code, apply a strict false-positive filter: only report concrete, exploitable issues with a clear attack path. Skip DoS, defence-in-depth hardening, and theoretical concerns.
See references/security.md for the full threat model, category catalogue (injection, authN/Z, crypto, XSS, deserialisation, supply chain), branded-type recipes, the pre-merge checklist, and the adopted false-positive filter.
The four elements of simple design (priority order)
1. Runs all the tests. 2. Expresses intent (readable, reveals purpose). 3. No duplication (after Rule of Three). 4. Minimal (fewest modules and functions possible).
If all four are true, the design is good enough. Stop polishing.
Project type (pick the right variant reference)
Next.js monorepo (read references/nextjs-monorepo.md; for any work on components, pages, or UI sections also read references/atomic-design.md) if:
packages/*with Bun workspaces at the root, ornext.config.tsin a package, orapp/(en)/,app/(fr)/route groups, ortailwindcssin dependencies.
Bun TypeScript script repo (read references/bun-typescript.md) if:
- single
src/main.tsentry with"module": "src/main.ts", or - the
src/{domain,use-cases,infra,presenter,composition,test-helpers}Clean Architecture layout (seereferences/architecture.md), or - no Next.js, no React, no Tailwind. Typically CLIs, batch scripts, Firebase Admin jobs.
If the repo is brand-new, ask which variant the user wants before scaffolding.
What applies where
The hard rules are universal unless this table says otherwise. Gates and tooling differ by variant:
| Concern | Bun script repo | Next.js monorepo |
|---|---|---|
TDD + bun test | Everything (rule 11, full loop) | src/lib/** + src/config/** logic; design-system components are prop-pure (rule 21) — lint + review, not unit tests |
Coverage tiers (check-coverage.ts) | Yes — 100/100/80 | No |
| Stryker mutation | Yes — gates mutate:staged/mutate:changed | No |
| Pre-commit | Eight-gate .githooks/pre-commit | simple-git-hooks: test + lint + commitlint — never install both hook mechanisms |
| Commit message (rule 23) | commit-msg hook: shipped assets/commit-msg validator (zero deps) | commit-msg hook: @commitlint/config-conventional via simple-git-hooks — same grammar |
| Logger | Logger port + src/infra adapter (rule 4) | Sanctioned singleton src/lib/utils/logger.ts (rule 4 exception) |
Result<T, E> (rule 16) | Every IO port | src/lib/** runtime IO; build-time data loaders may throw — a loud failed build is the desired outcome |
| Mock ban (rule 13) | no-restricted-imports in ESLint config | Same rule, added with the test setup |
| Rules 21–22 (design system, styling seal) | n/a (no UI) | Mandatory, lint-enforced (design-system ESLint block) |
Reference files
Toolchain:
references/nextjs-monorepo.md| Next.js 16 + Tailwind v4 + i18n route groups + static export.references/atomic-design.md| the logic-free design system: atoms/molecules/organisms layer rules, stateless props-only components, interactivity ladder (native HTML → hoisted state →src/lib/hooks), injected link/image wrappers, page-shell wiring, "where does it go?" table.references/bun-typescript.md| Bun-script repo bootstrap: tsconfig, ESLint flat config (SonarJS + type-aware rules +no-restricted-imports), Logger port + Winston adapter, secrets discipline, full bootstrap checklist with asset copy steps, optional containerization Dockerfile.
Engineering:
references/tdd.md| Red-Green-Refactor, Three Laws, triangulation, transformation priority, writing tests backwards, why we use fakes not mocks.references/testing.md| Outside-in classicist school, primary-port SUT, the test-the-code-you-own principle (trust your dependencies), fakes (with error-injection knob), the absolute no-mock-from-bun:testrule, test builders, contract tests, common mistakes.references/testing-infra.md| three patterns for infra-adapter tests (custom-fetch DI / two-constructor / sync-builder export), production-wiring smoke test,installFetchMock, global-swap pattern, FS chmod tricks, ordering gotchas.references/solid-principles.md| SRP, OCP, LSP, ISP, DIP expressed as typed records and function contracts.references/clean-code.md| naming priorities, object calisthenics translated to a class-free world, comments, formatting, storytelling.references/object-design.md| RDD, stereotypes, tell-don't-ask, value objects vs entities, aggregates, polymorphism via dispatch.references/code-smells.md| detection catalogue and the refactorings that clean each smell.references/complexity.md| essential vs accidental complexity, YAGNI, the lazy ladder (stop at the first rung), KISS, DRY + Rule of Three, four elements.references/behavioural-examples.md| before/after worked examples (in this repo's idiom) for the four Behavioural Guidelines: think-before-coding, simplicity, surgical changes, goal-driven execution; anti-pattern table.references/architecture.md| vertical slices, dependency rule, hexagonal and clean architecture, walking skeleton, inbound HTTP server archetype.references/design-patterns.md| full GoF catalogue rewritten as modules of arrow functions.references/class-to-module.md| translation table for OO patterns (value object, interface, service, strategy, factory, decorator, observer, command, entity) in this class-free style.
Security:
references/security.md| source-to-sink mental model, vulnerability categories, branded types for trust boundaries, pre-merge checklist, adopted false-positive filter.
Error handling:
references/result-type.md|Result<T, E>and helpers, per-port discriminated-union errors,StepErroraggregation, try/catch quarantine, fan-out batch semantics,retryOnErr, fakes-with-error-injection,captureRejection.
Process:
references/workflow.md| inner-loop checks, zero-warning rule, no-inline-ignore, per-tier coverage gates, SonarJS-at-lint-time, eight-gate pre-commit hook (commit-size + package.json + gitleaks + tests + lint + typecheck + coverage + Stryker mutation), dependency hygiene (no"latest"), periodic test-helpers audit, README consistency check.references/lessons.md| session memory format, triggers, extraction heuristics, entry templates, worked examples.
Workflow when writing or editing code
0. Read .claude/LESSONS.md and .claude/lessons.local.md if they exist. Apply any relevant past lessons silently. 1. Identify the variant. Read the matching variant reference. 2. Identify the feature. If non-trivial, skim references/architecture.md. 3. Propose a failing test in *.test.ts with a concrete example name; get the user's confirmation before writing it, and never modify or delete an existing test without explicit sign-off (rule 24). 4. Write the simplest arrow-function code to make it green. 5. Refactor. Apply object calisthenics. Promote primitives to branded types. Extract on Rule of Three. 6. Never emit class, function declaration, interface, console.*, or npm/pnpm/yarn/node/vite. Refuse and rewrite. 7. Any new dependency uses bun add / bun add -d. 8. Any logging goes through deps.logger (the Logger port). Never console.*, never a module-level singleton. 9. Any commit message follows Conventional Commits — type(scope)!: subject, validated by the commit-msg hook (hard rule 23). Write it that way the first time; do not lean on --no-verify. 10. Work trunk-based: commit to main in small green increments (≤10 files / ≤300 lines per gate 1), not onto long-lived feature branches. Every commit keeps main releasable — that is what the pre-commit gates guarantee. Hide unfinished work behind a flag, not a branch. This is the default and overrides any "branch first" habit. See references/workflow.md (Trunk-based development). 11. If legacy code in the repo uses a forbidden pattern, match the local style in that file only. Flag the drift once and offer to refactor. 12. At session wrap-up, scan for [mistake], [decision], [gotcha] entries worth capturing. Propose a candidate list and append on approval. See references/lessons.md.
Pre-code checklist
1. Do I understand the requirement? Write acceptance criteria. 2. What is the first failing test? (domain-language name, concrete example) 3. What is the simplest solution? Walk the lazy ladder (Behavioural Guideline #2) — skip it / stdlib / native runtime / existing dep / one line / minimal custom, in that order. 4. Am I solving a real need or a hypothetical one?
During-code checklist
1. Is this the simplest thing that could work? 2. Does this module have one reason to change? 3. Am I depending on function-type contracts, not concretions? 4. Is there duplication I should extract? (Rule of Three, not before) 5. Did I write the test first — proposed and confirmed before writing, never silently changed (rule 24)?
Post-code checklist
Inner-loop checks 1–4 run after every code change; check 5 runs before staging (Bun variant — see the variant matrix for what applies in a Next.js repo):
1. bun test — passes. 2. bun run lint — 0 errors AND 0 warnings. No inline ignores added. 3. bun run typecheck — tsc --noEmit, clean. 4. bun run coverage — 100% on src/domain/** and src/use-cases/**, 80% on composition + infra + presenter. 5. Before staging (not after every edit — it costs 1–3 min per file): bun run mutate:changed — domain/use-case files score ≥90% mutation. The pre-commit gate runs mutate:staged regardless; running mutate:changed earlier catches surviving mutants sooner.
Then review:
6. Is there dead code to remove? Are names still accurate? Can conditionals simplify? 7. Does any user input reach a sensitive sink (SQL, shell, filesystem, HTTP, HTML)? If yes, did it cross a branded-type checkpoint? 8. Every new IO port returns Result<T, PortError> and its PortError is a discriminated union. Every new use-case returns Result<Summary, StepError>. try/catch only in infra/, main.ts, or a pure-domain native-API fallback. 9. New src/infra/, src/composition/, or src/presenter/ files added in the same commit as a matching side-effect import in scripts/coverage-preload.ts. 10. The commit is small: ≤10 files AND ≤300 lines (insertions + deletions). The pre-commit gate enforces this; aim well under during iteration. 11. README.md audited against the user-visible surface area (install steps, package.json scripts, CLI flags, env vars, top-level layout, public exports, pinned versions) and updated in the same commit if anything is now stale. See Behavioural Guideline #5. The audit runs twice: once before declaring the task done, and again before ending the session — the same READMEs that are correct at task-done can drift across multiple back-to-back tasks in one session. 12. Would a new team member understand this in six months?
The pre-commit hook runs eight gates in order: commit size → package.json (no "latest" / "*") → gitleaks protect --staged → tests → strict lint → typecheck → coverage → mutation. See references/workflow.md for the full breakdown and the no-bypass rule.
Red flags (stop and rethink)
- Writing production code without a failing test.
- Using
class,functiondeclaration,interface, orconsole.*. - A module longer than 50 lines or a function longer than 10 lines.
- More than one level of indentation in a function.
- Using
elsewhen an early return works. - Hardcoding values that should be configurable.
- Extracting an abstraction before the third duplication.
- Adding a feature "just in case" (YAGNI).
- A module with more than one reason to change.
npm,pnpm,yarn,node, orvitein any script or command.- Accessing an object through more than one dot (
a.b.c). - Passing raw strings or numbers for domain concepts instead of branded types.
- Untrusted input reaching a sensitive sink (SQL, shell, filesystem, HTTP, HTML, redirect) without a branded-type checkpoint between them.
- A secret (token, password, API key, PII) interpolated into a log line, or placed in a
NEXT_PUBLIC_*env var. - Creating, editing, deleting, renaming, or skipping a test file — or weakening an assertion, changing an expected value, or commenting out a case — without first showing the user and getting an explicit yes (rule 24). Weakening a failing test to go green instead of fixing the code is the worst of these; the default for a red test is to fix production code.
- Running
git commitorgit pushwithout the user's explicit confirmation (rule 25). Staging and proposing the commit is the agent's role; pulling the trigger is the user's. "Do it" on a task is not commit approval — show the proposed commit and ask. - Importing anything from the
mocknamespace ofbun:test—mock(),mock.module()— or asserting on.toHaveBeenCalled*. Write a fake, or expose acreateXFromApi(api)factory the test can feed an in-memory object. Enforced byno-restricted-imports. - An infra adapter exported with no test seam at all — no custom-fetch DI, no
createXFromApi(api: XApi)factory, no sync-builder export (references/testing-infra.md). Without a seam, someone will reach formock.moduleon the next test. Expose one from day one, even before the first test exists. - Adding a new
src/infra/*.ts,src/composition/*.ts, orsrc/presenter/*.tsfile without a matching side-effect import inscripts/coverage-preload.ts. Untested infra files are invisible tobun test --coverageunless something imports them; the preload makes them appear at 0% so the gate can fail loudly. coverageThresholdset inbunfig.tomlwhile a per-tier script owns enforcement. Bun exits non-zero on the global threshold before the script can print per-file violations — looks like "coverage failed silently". Remove the global threshold; let the script own it.- An inline suppression of any tool:
// eslint-disable*,// @ts-ignore,// @ts-expect-error,// snyk-ignore,// sonar-ignore,// deepcode ignore,// istanbul ignore. Refactor, or change rule severity at the project level. try/catchanywhere outsidesrc/infra/**,src/main.ts, or a pure-domain native-API fallback (JSON.parse,URL). Use-cases must pattern-match onResult.ok.- A port that returns
Promise<T>instead ofPromise<Result<T, PortError>>for an IO call. Expected failures belong in the type. - A curried arrow chain (
const f = (a) => (b) => { ... }). Use a single arrow with all parameters. - A trailing
!(non-null assertion) or aas Typeassertion that is not a genuine narrowing. Replace with a guard clause (SonarJS S4325). String(err)in a catch block. Use the sharedformatError(err: unknown): stringhelper (SonarJS S6551)..match(re)used to read capture groups. Usere.exec(...)(SonarJS S6594).Record<K, V>when the key set is open. UsePartial<Record<K, V>>so the type tells the truth about missing keys.- Domain-specific data (brand lists, flow slugs, tier rates, tenant names) hardcoded as string-literal unions or records in framework code. Drive from env or config files; keep the framework generic.
- A per-file exclusion in
stryker.conf.jsonfor "the tests are awkward". Skip lists rot. The only structural exclusions are**/*.test.tsand**/ports/**. If a file produces equivalent or flaky mutants, tighten the test or refactor the production code — never add it to a skip list. - A commit exceeding 10 files OR 300 lines (insertions + deletions) without a clear big-bang justification (initial scaffold, mass-rename, generated files). Split into smaller coherent slices. The pre-commit gate enforces this; do not normalise
--no-verify. - A commit message that is not Conventional Commits — no
type:prefix, an unlisted type (wip:,update:), a capitalised type, a trailing period, or a >100-char header. Thecommit-msghook rejects these (hard rule 23); writetype(scope): subjectthe first time rather than reaching for--no-verify. A repo with the eight-gatepre-commitinstalled but nocommit-msghook is half-protected — wire both. - A composition root or wiring file declared "untestable" and skipped. The two ergonomic switches make any composition file 100%-testable: parameterise every state-source (path, env var, clock) and inject every output sink (logger, sender). See
references/architecture.md(Composition root testability). - A
"latest"or"*"version string anywhere inpackage.json. Usebun add <pkg>so the version pins to^X.Y.Zat install time. To bump deliberately, runbun updateand commit the lockfile change in the same commit. Enforced byscripts/check-package-json.sh(pre-commit gate 2). - Closing a session (or declaring a task done) with non-README files modified but the README un-audited. The README is part of the change set — re-read it, update what drifted, or state in one sentence that nothing user-visible changed. See Behavioural Guideline #5.
- A
node:fsimport (readFile,writeFile,readFileSync,fs/promises, etc.) in any file undersrc/**that is not a*.test.ts, undersrc/test-helpers/**, or a single isolated directory-boundary helper insrc/infra/**documented with a one-line comment. Production file IO usesBun.file/Bun.write. Hard rule 20. - An assignment to
process.env.X = ...anywhere outside*.test.ts(and even there, only insidebeforeAll/afterAllwith a saved-and-restored original).process.envis shared mutable state — pass values as parameters instead. See the Security section. - A hook call (
useState,useEffect, anyuse*) insidesrc/components/**. State is hoisted: native HTML first, thenisOpen/onToggleprops wired by the page shell from a hook insrc/lib/hooks/. Hard rule 21. - An import of
src/lib/**,src/config/**,next/link, ornext/imageanywhere undersrc/components/**. Links and images arrive as injectedComponentTypeprops built insrc/lib/layout/wrappers.tsx. - A design-system component that resolves translations, reads
process.env, fetches data, or carries'use client'. Display strings and data arrive as props; the client boundary belongs to the page shell. - A downward import in the design system: an atom importing a molecule, or a molecule importing an organism. Imports point strictly upward.
- A Tailwind utility string in
app/**(anywhere butglobals.css),src/page/**,src/lib/**, orsrc/config/**. Styling is sealed in the design system; the app never sees Tailwind. Hard rule 22. - A molecule or organism exposing free-form
className/stylein its public props, or a page shell passing one in. Visual variation is a typed variant prop — add the variant to the component.
Remember
Code exists to build products for users and customers. Testable, flexible, maintainable code wins because it can be cost-effectively maintained by developers.
Design happens during REFACTORING, not during coding. Let patterns emerge from tests and Rule of Three, never from speculation.
"A little bit of duplication is 10x better than the wrong abstraction."
"Solve today's problem simply, not tomorrow's prematurely." Most over-engineering is not wrong, only mistimed — abstraction added before its need is real.
/*
* Capture a promise rejection and return the caught Error.
*
* Replaces `await expect(p).rejects.toThrow(...)`, which trips SonarJS S4123
* ("unexpected await of a non-Promise value") because the matcher chain is
* not a real Thenable. This helper reads more clearly anyway:
*
* const err = await captureRejection(doSomethingThatThrows());
* expect(err.message).toBe('expected message');
*
* Throws:
* - if the promise resolved (so the test fails loudly instead of silently passing)
* - if the rejection value is not an Error (in atelier codebases, all
* rejections must be Errors — enforced by @typescript-eslint/prefer-promise-reject-errors)
*
* See skills/atelier/references/workflow.md (SonarJS table, S4123) and
* skills/atelier/references/result-type.md (Testing Result-returning code).
*/
const formatNonError = (value: unknown): string => {
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
try {
return JSON.stringify(value);
} catch {
return '[unstringifiable value]';
}
};
export const captureRejection = async (promise: Promise<unknown>): Promise<Error> => {
try {
await promise;
} catch (e) {
if (e instanceof Error) return e;
throw new Error(`captureRejection: rejected with non-Error value: ${formatNonError(e)}`);
}
throw new Error('captureRejection: expected promise to reject, but it resolved');
};
#!/usr/bin/env bash
#
# Block commits exceeding 10 files OR 300 lines (insertions + deletions).
#
# Why these thresholds: small commits are easier to review, revert, and
# bisect. Large commits hide bugs (one slip across 300 lines is hard to
# spot). Every commit on `main` becomes git history that the next engineer
# reads — keep each one a coherent slice.
#
# The thresholds are conservative because they force the discipline.
# Loosening them undermines the rule. Bypass with `git commit --no-verify`
# only for genuine big-bang changes (initial scaffolds, mass-renames,
# generated files); justify every bypass in the commit body.
#
# See skills/atelier/references/workflow.md (Commit size limits).
set -euo pipefail
MAX_FILES=10
MAX_LINES=300
files=$(git diff --cached --name-only --diff-filter=ACMR | grep -c '^' || true)
lines=$(git diff --cached --numstat | awk '{ sum += $1 + $2 } END { print sum + 0 }')
if [ "${files:-0}" -le "$MAX_FILES" ] && [ "${lines:-0}" -le "$MAX_LINES" ]; then
exit 0
fi
cat <<EOF >&2
╳ COMMIT TOO BIG
Files staged: ${files} (max ${MAX_FILES})
Lines staged: ${lines} (max ${MAX_LINES}, insertions + deletions)
Bypass: git commit --no-verify
EOF
exit 1
#!/usr/bin/env bun
/*
* Per-tier coverage gate for atelier Clean Architecture.
*
* Runs `bun test --coverage`, parses the text report, and enforces a
* different threshold for each source tier (domain, use-cases, infra,
* composition, presenter). Exits non-zero on any violation.
*
* Exit codes:
* 0 every non-skipped file meets its tier's threshold
* 1 at least one file is below gate, or `bun test` failed
*
* Tune per-project by editing COVERAGE_RULES and SKIPPED below.
*
* IMPORTANT: bunfig.toml MUST NOT set a global `coverageThreshold` when
* this script owns enforcement. The global threshold would make
* `bun test --coverage` exit non-zero before the script can parse,
* and the per-file violation breakdown never prints. See
* skills/atelier/references/workflow.md.
*/
type Tier = {
readonly name: string;
readonly prefix: string;
readonly threshold: number;
};
type SkipRule = {
readonly name: string;
readonly match: (path: string) => boolean;
};
const COVERAGE_RULES: ReadonlyArray<Tier> = [
{ name: 'domain', prefix: 'src/domain/', threshold: 100 },
{ name: 'use-cases', prefix: 'src/use-cases/', threshold: 100 },
{ name: 'infra', prefix: 'src/infra/', threshold: 80 },
{ name: 'composition', prefix: 'src/composition/', threshold: 80 },
{ name: 'presenter', prefix: 'src/presenter/', threshold: 80 },
];
const SKIPPED: ReadonlyArray<SkipRule> = [
{ name: 'test-helpers', match: (p) => p.startsWith('src/test-helpers/') },
{ name: 'entry point', match: (p) => p === 'src/main.ts' },
];
// NOTE: src/composition/build-deps.ts USED to be skipped here. It is now
// fully unit-testable via the optional `BuildDepsConfig` argument pattern
// (token store path + logger injectable, sensible defaults preserve prod
// behaviour). See references/architecture.md and references/workflow.md.
type FileRow = {
readonly path: string;
readonly funcs: number;
readonly lines: number;
};
type Violation = {
readonly file: FileRow;
readonly tier: string;
readonly threshold: number;
readonly metric: 'funcs' | 'lines';
readonly actual: number;
};
const isSkipped = (path: string): boolean => SKIPPED.some((s) => s.match(path));
const findTier = (path: string): Tier | undefined => COVERAGE_RULES.find((t) => path.startsWith(t.prefix));
const parseRow = (line: string): FileRow | undefined => {
const parts = line.split('|').map((c) => c.trim());
if (parts.length < 3) return undefined;
const layouts: ReadonlyArray<{ path: number; funcs: number; lines: number }> = [
{ path: 0, funcs: 1, lines: 2 },
{ path: 1, funcs: 2, lines: 3 },
];
for (const layout of layouts) {
const path = parts[layout.path];
if (!path) continue;
if (path === 'File' || path === 'All files' || path.startsWith('-')) continue;
if (!path.endsWith('.ts') && !path.endsWith('.tsx')) continue;
const funcs = Number.parseFloat(parts[layout.funcs] ?? '');
const lines = Number.parseFloat(parts[layout.lines] ?? '');
if (Number.isNaN(funcs) || Number.isNaN(lines)) continue;
const normalised = path.startsWith('./') ? path.slice(2) : path;
return { path: normalised, funcs, lines };
}
return undefined;
};
// We pass `--preload ./scripts/coverage-preload.ts` HERE rather than wiring
// the preload via `bunfig.toml`'s `[test] preload = [...]`. The preload
// side-effect-imports every infra/composition/presenter file (so they show
// up in the coverage table at 0% if untested) but it pulls in heavy
// third-party SDKs (whatever the infra adapters wrap) that add 1–2s to
// every plain `bun test` run. Loading the preload only when computing
// coverage keeps the inner-loop tests fast without losing the gate.
const runTestsWithCoverage = async (): Promise<{ readonly status: number; readonly output: string }> => {
const proc = Bun.spawn(
['bun', 'test', '--coverage', '--preload', './scripts/coverage-preload.ts'],
{ stdout: 'pipe', stderr: 'pipe' }
);
const [stdoutText, stderrText] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
process.stdout.write(stdoutText);
process.stderr.write(stderrText);
const status = await proc.exited;
return { status, output: `${stdoutText}\n${stderrText}` };
};
const worstBy = (rows: ReadonlyArray<FileRow>, metric: 'funcs' | 'lines'): FileRow | undefined => {
const [first, ...rest] = rows;
if (!first) return undefined;
return rest.reduce((w, r) => (r[metric] < w[metric] ? r : w), first);
};
const printTierSummary = (rows: ReadonlyArray<FileRow>): void => {
console.log('\ncoverage: tier summary (worst funcs / worst lines):');
for (const tier of COVERAGE_RULES) {
const inTier = rows.filter((r) => !isSkipped(r.path) && r.path.startsWith(tier.prefix));
if (inTier.length === 0) {
console.log(` ${tier.name.padEnd(12)} (>= ${tier.threshold}%) no files`);
continue;
}
const worstFuncs = worstBy(inTier, 'funcs');
const worstLines = worstBy(inTier, 'lines');
if (!worstFuncs || !worstLines) continue;
console.log(
` ${tier.name.padEnd(12)} (>= ${tier.threshold}%) funcs: ${worstFuncs.funcs.toFixed(1)}% (${worstFuncs.path}) lines: ${worstLines.lines.toFixed(1)}% (${worstLines.path})`
);
}
};
const collectViolations = (rows: ReadonlyArray<FileRow>): ReadonlyArray<Violation> => {
const violations: Violation[] = [];
for (const row of rows) {
if (isSkipped(row.path)) continue;
const tier = findTier(row.path);
if (!tier) continue;
if (row.funcs < tier.threshold) {
violations.push({ file: row, tier: tier.name, threshold: tier.threshold, metric: 'funcs', actual: row.funcs });
}
if (row.lines < tier.threshold) {
violations.push({ file: row, tier: tier.name, threshold: tier.threshold, metric: 'lines', actual: row.lines });
}
}
return violations;
};
const printViolations = (violations: ReadonlyArray<Violation>): void => {
console.error('\ncoverage: per-file gate violations:');
for (const v of violations) {
console.error(
` ${v.file.path} [${v.tier}] ${v.metric}=${v.actual.toFixed(1)}% required=${v.threshold}%`
);
}
const word = violations.length === 1 ? 'violation' : 'violations';
console.error(
`\ncoverage: ${violations.length} ${word}. Add tests, or restructure to remove unreachable branches — never lower the threshold.`
);
};
const main = async (): Promise<number> => {
const { status, output } = await runTestsWithCoverage();
if (status !== 0) {
console.error('\ncoverage: `bun test --coverage` exited non-zero; fix test failures first.');
return status;
}
const rows = output
.split('\n')
.map(parseRow)
.filter((r): r is FileRow => r !== undefined);
if (rows.length === 0) {
console.error('\ncoverage: no file rows parsed from the coverage report. Check that `bun test --coverage` is producing a text table.');
return 1;
}
printTierSummary(rows);
const violations = collectViolations(rows);
if (violations.length === 0) {
console.log('\ncoverage: all files meet their tier gate.');
return 0;
}
printViolations(violations);
return 1;
};
process.exit(await main());
#!/usr/bin/env bash
#
# Block commits if package.json declares any version as "latest" or "*".
#
# Why: "latest" / "*" are non-deterministic — `bun install` on different
# days produces different node_modules trees. The lockfile only partially
# helps, and the literal string semantically signals "always upgrade",
# which is a silent-break footgun.
#
# Add new packages with `bun add <pkg>` (runtime) or `bun add -d <pkg>`
# (dev). Bun resolves the actual latest at install time and pins it as
# `^X.Y.Z`. To bump everything to current latest deliberately, run
# `bun update` and commit the lockfile change in the same commit.
#
# See skills/atelier/references/workflow.md (Dependency hygiene) and
# SKILL.md hard rule 19.
set -euo pipefail
if [ ! -f package.json ]; then
exit 0
fi
# Match a VALUE position (after the colon) equal to the bare strings
# "latest", "*", or a bare dist-tag ("beta", "alpha", "next", "canary",
# "rc") — all non-deterministic in exactly the way rule 19 bans.
# Anchoring on the colon keeps package NAMES out of scope (the dependency
# "next" is fine; the version "next" is not).
# Catches: "any-pkg": "latest", "x": "*", "plugin": "beta"
# Permits: "x": "^1.2.3" / "~1.2.3" / ">=1.0.0" / "^4.0.0-beta.0", "next": "16.1.1"
violations=$(grep -nE ':[[:space:]]*"(\*|latest|beta|alpha|next|canary|rc)"' package.json || true)
if [ -z "$violations" ]; then
exit 0
fi
cat <<EOF >&2
╳ package.json contains a forbidden version string ("latest", "*", or a bare dist-tag):
$(echo "$violations" | sed 's/^/ /')
Atelier rule 19: every dependency declares a concrete version or range.
Fix:
- Replace each "latest" / "*" / bare dist-tag with the actual installed
version (a pre-release pin like "^4.0.0-beta.0" is fine; bare "beta" is not).
- For new packages, use \`bun add <pkg>\` (or \`bun add -d <pkg>\`)
instead of hand-editing — Bun pins to ^X.Y.Z automatically.
- To bump everything to current latest, run \`bun update\` and commit
the lockfile change in the same commit.
Bypass (rare): git commit --no-verify, with justification in commit body.
EOF
exit 1
#!/usr/bin/env bash
#
# atelier commit-msg hook — enforce Conventional Commits
#
# Fires after the message is composed (separate from the eight-gate
# pre-commit hook, which inspects the staged diff). git passes the path to
# the message file as $1; this validates the subject line against the
# Conventional Commits grammar:
#
# type(optional-scope)!: subject
#
# Why enforce it: the commit log is the project's changelog and `git bisect`
# surface. A machine-readable type/scope lets tooling derive release notes,
# group history, and flag breaking changes (the `!`). A soft convention
# drifts; a hook keeps every commit on `main` honest.
#
# This is the dependency-free validator for the Bun-script variant, matching
# the hand-rolled style of the other gate scripts. The Next.js monorepo
# variant enforces the identical grammar through @commitlint/config-conventional
# wired as a simple-git-hooks `commit-msg` step — see references/nextjs-monorepo.md.
#
# Installed via `core.hooksPath .githooks` alongside the pre-commit hook:
#
# cp <skill>/assets/commit-msg .githooks/commit-msg && chmod +x .githooks/commit-msg
#
# Bypass with `git commit --no-verify` only when genuinely warranted; the
# message still has to make sense to the next reader.
#
# See skills/atelier/references/workflow.md (Commit message format).
set -euo pipefail
msg_file="$1"
# The header is the first line that is neither a comment nor blank.
header=$(grep -vE '^[[:space:]]*#' "$msg_file" | grep -vE '^[[:space:]]*$' | head -n 1 || true)
# git auto-generates these; Conventional Commits does not apply. Let them pass.
case "$header" in
'Merge '* | 'Revert '* | 'fixup! '* | 'squash! '* | 'amend! '*)
exit 0
;;
esac
# The @commitlint/config-conventional type set — keep in lockstep with the
# Next.js variant so both enforce the same contract.
types='feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert'
max_len=100
# type(scope)?!?: subject (scope and ! optional; subject required)
pattern="^(${types})(\([a-z0-9._/-]+\))?(!)?: .+"
fail() {
cat <<EOF >&2
╳ COMMIT MESSAGE IS NOT CONVENTIONAL
${1}
Offending header:
${header:-(empty)}
Required: type(optional-scope)!: subject
type one of: feat fix docs style refactor perf test build ci chore revert
scope optional, lowercase, in parentheses — e.g. (auth), (api)
! optional, marks a breaking change
subject required, no trailing period, header ≤ ${max_len} chars
Examples:
feat(auth): add refresh-token rotation
fix: guard against empty cart total
refactor(orders)!: drop the legacy status field
Bypass (rare): git commit --no-verify
EOF
exit 1
}
[ -n "$header" ] || fail 'The commit message is empty.'
[ "${#header}" -le "$max_len" ] || fail "Header is ${#header} chars; the limit is ${max_len}."
printf '%s' "$header" | grep -qE "$pattern" || fail 'Header does not match the Conventional Commits grammar.'
# config-conventional's subject-full-stop rule: no trailing period.
case "$header" in
*.) fail 'Subject must not end with a period.' ;;
esac
exit 0
/*
* Coverage preload.
*
* `bun test --coverage` only reports rows for files the runner imports.
* Untested infra, composition, and presenter files are silently absent
* from the table, which makes the per-file gate trivially pass. This
* preload side-effect-imports every such file so they appear at 0% (or
* better) and the gate can fail loudly.
*
* Wired ONLY at coverage time, NOT in bunfig.toml. `scripts/check-coverage.ts`
* spawns `bun test --coverage --preload ./scripts/coverage-preload.ts`. We do
* NOT put `preload = [...]` under `[test]` in bunfig.toml because this file
* pulls in heavy third-party SDKs (HTTP clients, cloud SDKs, AI clients,
* loggers, etc. — whatever the infra adapters wrap) that would slow every
* plain `bun test` by 1–2s.
*
* MAINTENANCE RULE: every new file under
* - src/infra/
* - src/composition/
* - src/presenter/
* must be side-effect-imported here in the SAME commit that adds the file.
* Reviewers check this explicitly. A pre-commit lint could enforce it; not
* done yet, so it is a review obligation.
*
* See skills/atelier/references/workflow.md for the full rationale.
*/
// --- src/infra/ ---
import '../src/infra/logger.ts';
// TODO: add every adapter here as you create it, e.g.:
// import '../src/infra/<your-adapter-1>.ts';
// import '../src/infra/<your-adapter-2>.ts';
// import '../src/infra/<your-adapter-3>.ts';
// --- src/composition/ ---
import '../src/composition/env.ts';
import '../src/composition/build-deps.ts';
// NOTE: build-deps.ts USED to be skipped here. It is now testable end-to-end
// via the optional BuildDepsConfig argument (token-store path + logger as
// optional config; sensible defaults preserve production behaviour). See
// references/architecture.md (Composition root testability).
// --- src/presenter/ ---
import '../src/presenter/cli.ts';
/*
* Fetch-mock test helper for atelier infra adapter tests.
*
* Swaps globalThis.fetch with a handler-driven stub that records every call
* and restores the real fetch in afterEach. Used by adapters that call
* globalThis.fetch directly (Telegram, RSS fetcher, HTTP-based adapters).
*
* Usage:
*
* import { afterEach } from 'bun:test';
* import { installFetchMock } from '../test-helpers/fetch-mock.ts';
*
* let mock: ReturnType<typeof installFetchMock> | undefined;
* afterEach(() => mock?.restore());
*
* mock = installFetchMock([
* { match: (url) => url.endsWith('/sendMessage'),
* respond: () => new Response(JSON.stringify({ ok: true })) },
* ]);
*
* IMPORTANT: handlers are checked in order, first match wins. Put the
* more-specific matcher first (e.g. /api/foo_publish before /api/foo),
* or use url.endsWith(...) for exact path-suffix matching. A broad
* url.includes(...) will match more URLs than you expect.
*
* See skills/atelier/references/testing-infra.md (§ 1. HTTP via globalThis.fetch → installFetchMock).
*/
// FetchInput / FetchInit are derived from the global `fetch` signature so the
// helper compiles without requiring the `DOM` lib in tsconfig.
type FetchInput = Parameters<typeof fetch>[0];
type FetchInit = Parameters<typeof fetch>[1];
export type FetchHandler = {
readonly match: (url: string, init: FetchInit) => boolean;
readonly respond: (url: string, init: FetchInit) => Response | Promise<Response>;
};
export type FetchMockCall = {
readonly url: string;
readonly init: FetchInit;
};
export type FetchMock = {
readonly calls: ReadonlyArray<FetchMockCall>;
readonly restore: () => void;
};
const urlOf = (input: FetchInput): string => {
if (typeof input === 'string') return input;
if (input instanceof URL) return input.toString();
return input.url;
};
export const installFetchMock = (handlers: ReadonlyArray<FetchHandler>): FetchMock => {
const calls: FetchMockCall[] = [];
const original = globalThis.fetch;
globalThis.fetch = (async (input: FetchInput, init: FetchInit): Promise<Response> => {
const url = urlOf(input);
calls.push({ url, init });
const handler = handlers.find((h) => h.match(url, init));
if (!handler) throw new Error(`fetch-mock: no handler matched ${url}`);
return handler.respond(url, init);
}) as typeof fetch;
return {
calls,
restore: (): void => {
globalThis.fetch = original;
},
};
};
/*
* Format an unknown thrown value into a human-readable string.
*
* Replaces `String(err)` in catch blocks. `String(obj)` returns
* "[object Object]" for plain-object throws and loses the message
* entirely (SonarJS S6551).
*
* Use this in EVERY `catch (e)` block in src/infra/** and in any
* pure-domain native-API fallback. Safe on any input.
*
* See skills/atelier/references/workflow.md (SonarJS table, S6551).
*/
export const formatError = (err: unknown): string => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
if (typeof err === 'number' || typeof err === 'boolean') return String(err);
try {
return JSON.stringify(err);
} catch {
return '[unstringifiable error]';
}
};
#!/usr/bin/env bash
#
# Run Stryker mutation testing on files differing from `origin/main` plus
# any uncommitted edits. Used during iteration to catch surviving mutants
# before staging.
#
# Override the base ref with the BASE env var:
#
# BASE=HEAD~3 bun run mutate:changed
#
# See skills/atelier/references/workflow.md (Mutation testing).
set -euo pipefail
BASE="${BASE:-origin/main}"
# Files that differ from BASE plus uncommitted/staged edits, intersected
# with the mutation scope.
files=$( {
git diff --name-only --diff-filter=ACMR "$BASE"...HEAD
git diff --name-only --diff-filter=ACMR HEAD
git diff --cached --name-only --diff-filter=ACMR
} | sort -u \
| grep -E '^src/(domain|use-cases)/' \
| grep -E '\.ts$' \
| grep -vE '\.test\.ts$' \
| grep -vE '/ports/' \
|| true)
if [ -z "$files" ]; then
echo "mutate:changed: no files in mutation scope changed since ${BASE}"
exit 0
fi
count=$(echo "$files" | wc -l | tr -d ' ')
echo "mutate:changed: testing ${count} file(s) (base: ${BASE})"
# Stryker's --mutate takes ONE comma-separated value; repeated flags
# overwrite each other (the CLI keeps only the last one), so join the list.
mutate_arg=$(echo "$files" | paste -sd, -)
bunx stryker run --mutate "$mutate_arg"
#!/usr/bin/env bash
#
# Run Stryker mutation testing on STAGED files in the mutation scope
# (src/domain/** and src/use-cases/**, excluding tests and ports).
#
# Used by the pre-commit hook (gate 8). Skips with exit 0 when no relevant
# files are staged, so commits that touch only docs, tests, or scripts are
# unaffected.
#
# See skills/atelier/references/workflow.md (Mutation testing).
set -euo pipefail
files=$(git diff --cached --name-only --diff-filter=ACMR \
| grep -E '^src/(domain|use-cases)/' \
| grep -E '\.ts$' \
| grep -vE '\.test\.ts$' \
| grep -vE '/ports/' \
|| true)
if [ -z "$files" ]; then
echo "mutate:staged: no staged files in mutation scope, skipping"
exit 0
fi
count=$(echo "$files" | wc -l | tr -d ' ')
echo "mutate:staged: testing ${count} file(s)"
# Stryker's --mutate takes ONE comma-separated value; repeated flags
# overwrite each other (the CLI keeps only the last one), so join the list.
mutate_arg=$(echo "$files" | paste -sd, -)
bunx stryker run --mutate "$mutate_arg"
#!/usr/bin/env bash
#
# atelier pre-commit hook — 8 gates
#
# Order matters: cheap fast-fail gates first, expensive gates last so a slow
# mutation run is only paid when everything else is clean.
#
# 1. commit size — ≤10 files AND ≤300 lines (insertions + deletions)
# 2. package.json — no "latest" / "*" version strings
# 3. secret scan — gitleaks protect --staged (degrades gracefully)
# 4. tests — bun test
# 5. strict lint — bun run lint:strict (0 errors AND 0 warnings)
# 6. typecheck — bun run typecheck
# 7. coverage — bun run coverage (per-tier gates)
# 8. mutation testing — bun run mutate:staged (≥90% on staged domain/use-case files)
#
# Install once per clone:
#
# git config core.hooksPath .githooks
#
# Bypass with `git commit --no-verify` is reserved for genuine big-bang
# changes (initial scaffolds, mass-renames, generated-file updates). Justify
# every bypass in the commit body. Do not normalise bypassing.
set -euo pipefail
if ! command -v bun >/dev/null 2>&1; then
echo "pre-commit: 'bun' is not on PATH. Install Bun from https://bun.sh" >&2
exit 1
fi
echo "pre-commit: running 8 gates..."
echo "[1/8] commit size"
bash scripts/check-commit-size.sh
echo "[2/8] package.json (no latest / *)"
bash scripts/check-package-json.sh
echo "[3/8] gitleaks protect --staged"
if command -v gitleaks >/dev/null 2>&1; then
gitleaks protect --staged --redact --verbose --no-banner
else
echo " warning: 'gitleaks' not on PATH — skipping secret scan." >&2
echo " install: brew install gitleaks (or https://github.com/gitleaks/gitleaks/releases)" >&2
fi
echo "[4/8] bun test"
bun test
echo "[5/8] bun run lint:strict"
bun run lint:strict
echo "[6/8] bun run typecheck"
bun run typecheck
echo "[7/8] bun run coverage"
bun run coverage
echo "[8/8] bun run mutate:staged"
bun run mutate:staged
echo "pre-commit: all gates passed"
#!/usr/bin/env bun
/*
* Regenerate scripts/coverage-preload.ts by globbing every TypeScript file
* under src/infra, src/composition, and src/presenter and emitting a fresh
* preload that side-effect-imports each one.
*
* Why this exists:
*
* `bun test --coverage` only emits rows for files the runner imports.
* Untested files in src/infra / src/composition / src/presenter are
* silently absent from the coverage table, which makes the per-file gate
* trivially pass (a file at 0% is invisible, not failing).
*
* coverage-preload.ts side-effect-imports every such file so they appear
* in the table at 0% if untested, which makes the gate fail loudly.
*
* But that file is a manual chore: every new adapter or wiring file means
* one more line, and forgetting it silently masks a coverage hole. This
* script regenerates the whole preload from the filesystem so the chore
* becomes deterministic.
*
* Usage:
*
* bun run scripts/regenerate-coverage-preload.ts
* # writes scripts/coverage-preload.ts (or `--out <path>` for elsewhere)
*
* bun run scripts/regenerate-coverage-preload.ts --check
* # exits non-zero if the on-disk file is out of sync with the glob;
* # use this in CI or pre-commit so a missing import blocks the merge.
*
* Wire it into pre-commit as an unnumbered pre-flight (the gates stay 1..8):
*
* echo "[pre-flight] coverage-preload sync" >&2
* bun run scripts/regenerate-coverage-preload.ts --check
*
* Tune SCAN_DIRS below if your project's Clean Architecture layout differs.
*
* See skills/atelier/references/workflow.md (Coverage gates).
*/
import { readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
type Args = {
readonly check: boolean;
readonly out: string;
};
const SCAN_DIRS: ReadonlyArray<string> = [
'src/infra',
'src/composition',
'src/presenter',
];
// Files in scan dirs that should NOT be preloaded. Tests are obvious; ports
// are type-only and have no runtime to preload; index.ts files are usually
// barrel exports already pulled in by their siblings.
const EXCLUDE = (relPath: string): boolean =>
relPath.endsWith('.test.ts') ||
relPath.includes('/ports/') ||
relPath.endsWith('/index.ts');
const parseArgs = (argv: ReadonlyArray<string>): Args => {
const check = argv.includes('--check');
const outIdx = argv.indexOf('--out');
const candidate = outIdx >= 0 ? argv[outIdx + 1] : undefined;
const out = candidate ?? 'scripts/coverage-preload.ts';
return { check, out };
};
const walk = (dir: string, repoRoot: string, acc: string[]): void => {
let entries: ReadonlyArray<string>;
try {
entries = readdirSync(dir);
} catch {
return; // dir does not exist; harmless
}
for (const entry of entries) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
walk(full, repoRoot, acc);
} else if (st.isFile() && entry.endsWith('.ts')) {
const rel = relative(repoRoot, full);
if (!EXCLUDE(rel)) acc.push(rel);
}
}
};
const collectFiles = (repoRoot: string): ReadonlyArray<string> => {
const acc: string[] = [];
for (const scan of SCAN_DIRS) walk(join(repoRoot, scan), repoRoot, acc);
return acc.sort();
};
const HEADER = `/*
* Auto-generated by scripts/regenerate-coverage-preload.ts. Do not hand-edit;
* run \`bun run scripts/regenerate-coverage-preload.ts\` to rewrite.
*
* \`bun test --coverage\` only emits rows for files the runner imports. Untested
* src/infra, src/composition, and src/presenter files are silently absent
* from the table — which makes the per-file gate trivially pass. This preload
* side-effect-imports every such file so they appear at 0% (or better) and
* the gate can fail loudly.
*
* Wired ONLY at coverage time, NOT in bunfig.toml. \`scripts/check-coverage.ts\`
* spawns \`bun test --coverage --preload ./scripts/coverage-preload.ts\`.
*
* See skills/atelier/references/workflow.md.
*/
`;
const buildContent = (files: ReadonlyArray<string>, repoRoot: string, outPath: string): string => {
const grouped = new Map<string, string[]>();
for (const f of files) {
const top = SCAN_DIRS.find((d) => f.startsWith(`${d}/`)) ?? 'other';
const list = grouped.get(top) ?? [];
list.push(f);
grouped.set(top, list);
}
// Imports are written relative to the output file's directory.
const outDir = join(repoRoot, outPath, '..');
const lines: string[] = [HEADER];
for (const dir of SCAN_DIRS) {
const list = grouped.get(dir);
if (!list || list.length === 0) continue;
lines.push('', `// --- ${dir}/ ---`);
for (const f of list) {
const fromOut = relative(outDir, join(repoRoot, f));
const importPath = fromOut.startsWith('.') ? fromOut : `./${fromOut}`;
lines.push(`import '${importPath}';`);
}
}
lines.push(''); // trailing newline
return lines.join('\n');
};
const main = async (): Promise<number> => {
const args = parseArgs(process.argv.slice(2));
const repoRoot = process.cwd();
const files = collectFiles(repoRoot);
const content = buildContent(files, repoRoot, args.out);
if (args.check) {
const existing = await Bun.file(args.out).text().catch(() => '');
if (existing.trim() === content.trim()) {
console.log(`coverage-preload: in sync (${files.length} files)`);
return 0;
}
console.error(`coverage-preload: OUT OF SYNC.`);
console.error(` ${args.out} does not match the current set of source files.`);
console.error(` Run: bun run scripts/regenerate-coverage-preload.ts`);
console.error(` Then: git add ${args.out} && commit.`);
return 1;
}
await Bun.write(args.out, content);
console.log(`coverage-preload: wrote ${args.out} (${files.length} files)`);
return 0;
};
process.exit(await main());
{
"$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
"_comment": "atelier mutation testing config. See skills/atelier/references/workflow.md (Mutation testing).",
"_packageManager_comment": "Stryker doesn't fully understand Bun yet; tell it npm so it doesn't probe for yarn/pnpm lockfiles.",
"packageManager": "npm",
"testRunner": "command",
"commandRunner": {
"command": "bun test"
},
"_mutate_comment": "Core only: src/domain + src/use-cases. Test files have no logic to mutate; ports/ files are type-only (zero mutable code).",
"mutate": [
"src/domain/**/*.ts",
"src/use-cases/**/*.ts",
"!**/*.test.ts",
"!**/ports/**"
],
"thresholds": {
"high": 95,
"low": 90,
"break": 90
},
"incremental": true,
"incrementalFile": "reports/stryker-incremental.json",
"concurrency": 4,
"timeoutMS": 30000,
"reporters": ["clear-text", "progress", "html"],
"htmlReporter": {
"fileName": "reports/mutation/index.html"
},
"tempDirName": ".stryker-tmp",
"cleanTempDir": true,
"_ignorePatterns_comment": "Skip non-source dirs from the sandbox copy. .claude/ may contain a symlink Stryker cannot copy (ENOTSUP); the rest are pure noise.",
"ignorePatterns": [
".claude/",
".agents/",
".githooks/",
".vscode/",
".git/",
"docs/",
"prompts/",
"scripts/",
"reports/",
".stryker-tmp/",
"node_modules/",
"coverage/",
"*.md",
"*.toml",
"*.lock",
"*.json"
]
}
Software Architecture
The goal
Enable the team to:
1. Add features with minimal friction. 2. Change existing features safely. 3. Remove features cleanly. 4. Test features in isolation. 5. Deploy independently when possible.
Architectural principles
1. Vertical slices (feature-first)
Organise by feature, not by technical layer.
BAD - layer-first
src/
controllers/
userController.ts
orderController.ts
services/
userService.ts
orderService.ts
repositories/
userRepository.ts
orderRepository.ts
GOOD - feature-first
src/
users/
user-controller.ts
user-service.ts
user-repository.ts
orders/
order-controller.ts
order-service.ts
order-repository.tsWhy. Changes to the "users" feature stay in users/. High cohesion within features, low coupling between them.
2. Horizontal boundaries (layers)
Separate concerns into layers with clear dependencies.
+--------------------------------------+
| Presentation | UI, controllers, CLI entry
+--------------------------------------+
| Application | Use cases, orchestration
+--------------------------------------+
| Domain | Business logic, value objects, entities
+--------------------------------------+
| Infrastructure | Database, APIs, external integrations
+--------------------------------------+3. The dependency rule
Dependencies point INWARD.
Infrastructure -> Application -> Domain
outer middle inner- Inner layers know NOTHING about outer layers.
- Domain has zero dependencies on infrastructure.
- Use function-type contracts to invert dependencies.
// Domain defines the contract (inner)
export type RepoError = { type: 'io'; message: string };
export type UserRepo = {
save: (user: User) => Promise<Result<void, RepoError>>;
findById: (id: UserId) => Promise<Result<User | null, RepoError>>;
};
// Infrastructure implements it (outer)
export const createPostgresUserRepo = (db: Database): UserRepo => ({
save: async (user) => {
/* SQL here, wrapped in ok()/err() */
},
findById: async (id) => {
/* SQL here, wrapped in ok()/err() */
},
});
// Domain use-case depends on the contract, never on the postgres implementation
export const createGetUser = (repo: UserRepo) => async (id: UserId): Promise<Result<User | null, RepoError>> => repo.findById(id);IO ports always return Promise<Result<T, PortError>>, never bare Promise<T> — see references/result-type.md.
4. Contracts
Function-type aliases define boundaries between components.
// The contract
export type PaymentGateway = {
charge: (amount: Money, card: CardDetails) => Promise<ChargeResult>;
refund: (chargeId: ChargeId) => Promise<RefundResult>;
};
// Multiple implementations possible
export const stripeGateway: PaymentGateway = { /* ... */ };
export const payPalGateway: PaymentGateway = { /* ... */ };
export const fakeGateway: PaymentGateway = { /* ... */ }; // in-memory fake for tests5. Cross-cutting concerns
Concerns that span multiple features: logging, auth, validation, error handling.
Options in our style:
- Middleware / interceptors.
- Higher-order functions that wrap other functions.
- Decorator functions (from
references/design-patterns.md).
// Higher-order function wraps a handler with logging.
// The logger is a parameter, not a module-level singleton (hard rule 4).
export type Handler<Req, Res> = (request: Req) => Promise<Res>;
export const withLogging = <Req extends { path: string }, Res extends { status: number }>(
handler: Handler<Req, Res>,
logger: Logger
): Handler<Req, Res> =>
async (request) => {
logger.info('request', { path: request.path });
const response = await handler(request);
logger.info('response', { status: response.status });
return response;
};6. Conway's Law
"Organisations design systems that mirror their communication structure."
Implication. Team structure affects architecture. Align both intentionally.
---
Common architectural styles
Layered architecture
Traditional layers: Presentation -> Business -> Persistence.
Pros. Simple, well-understood. Cons. Can become a "big ball of mud" without discipline. No clear story about dependency direction.
Hexagonal architecture (Ports and Adapters)
Domain at the centre, adapters around the edges.
+---------------------+
| HTTP adapter |
+----------+----------+
|
+------------------v------------------+
| DOMAIN |
| +--------------------------+ |
| | business logic | |
| | use cases | |
| +--------------------------+ |
+------------------+------------------+
|
+----------v----------+
| database adapter |
+---------------------+- Ports | function-type contracts defined by the domain.
- Adapters | concrete implementations that connect to the outside world.
Clean architecture
Similar to hexagonal, with explicit layers:
1. Entities | enterprise business rules. 2. Use cases | application business rules. 3. Interface adapters | controllers, presenters, gateways. 4. Frameworks and drivers | web, DB, external interfaces.
---
Feature-driven structure (frontend)
In the Next.js variant there is no features/ folder. A vertical slice is expressed as one organism per page section plus a page shell per route, with the slice's logic in src/lib/<feature>/ (e.g. src/lib/guides/). Components never live outside the design system (src/components/{atoms,molecules,organisms} — hard rules 21-22), and state lives in src/lib/hooks/, wired by the page shells.
src/
components/
atoms/ | no internal composition
molecules/ | import atoms only
organisms/ | import atoms + molecules; one per page section
page/ | page shells; one per route, wire organisms to lib state
lib/
guides/ | feature logic for the "guides" slice
hooks/ | state, consumed by page shells
i18n/See references/nextjs-monorepo.md for the full layout and rules, and references/atomic-design.md for the component layer rules.
---
Clean Architecture layout (backend, canonical)
For any non-trivial Bun backend — pipelines, batch jobs, CLIs with real integrations — use this strict six-folder layout. Files land where they belong based on what they depend on, not on which feature they serve.
src/
├── domain/ # branded value objects, Zod schemas, pure utilities, FlowConfig builder
│ ├── ids.ts # branded IDs (UserId, OrderId, ...)
│ ├── urls.ts # SafeUrl, canonicalUrl helpers
│ ├── schemas/ # Zod shape definitions (no IO)
│ ├── utilities/ # split-text, retry-on-err, rss-parser, format-error
│ ├── result.ts # Result<T, E> + helpers
│ └── flow.ts # pure FlowConfig builder
├── use-cases/ # coordinators + the port interfaces they depend on
│ ├── ports/ # type-only interfaces for every side-effectful dependency
│ │ ├── sheets.ts
│ │ ├── llm.ts
│ │ ├── telegram.ts
│ │ ├── rss-fetcher.ts
│ │ ├── prompt-loader.ts
│ │ ├── logger.ts
│ │ └── step-error.ts
│ ├── select-news.ts
│ ├── post-telegram.ts
│ └── run-pipeline.ts
├── infra/ # concrete adapters that implement the ports
│ ├── google-auth.ts
│ ├── sheets-google.ts
│ ├── gemini-llm.ts
│ ├── telegram-http.ts
│ ├── rss-fetcher-http.ts
│ ├── prompt-loader-fs.ts
│ ├── http/ # inbound Bun.serve adapter (server archetype)
│ │ ├── server.ts # route table + the one request-level try/catch
│ │ └── to-response.ts # pure Result → Response mapper
│ └── logger.ts
├── presenter/ # CLI argv parsing, usage text, output formatting
│ └── cli.ts
├── composition/ # the composition root: env parser + buildPipelineDeps
│ ├── env.ts
│ └── build-deps.ts # the ONLY place infra/ meets use-cases/
├── test-helpers/ # in-memory fakes for every port + test data builders
│ ├── sheets-fake.ts
│ ├── llm-fake.ts
│ ├── telegram-fake.ts
│ ├── logger-fake.ts
│ ├── capture-rejection.ts
│ └── test-flow.ts
└── main.ts # thin entry: argv → presenter → composition → use-caseDependency rule (strict, inward-only)
| Folder | Depends on |
|---|---|
domain/ | nothing inside src/ |
use-cases/ | domain/ + its own ports/ (types only) |
infra/ | domain/ + the ports it implements (+ the use-case Result/error types an inbound adapter maps) + third-party SDKs |
presenter/ | domain/ only |
composition/ | everything (this is the only place where concrete infra/ meets use-case deps) |
test-helpers/ | domain/ + ports (no production code depends on test-helpers) |
main.ts | composition/ + presenter/ + infra/ (for top-level error notification only) |
Invariants the layout protects:
- The domain is zero-dependency on anything in
src/except shareddomain/*.grep -rn "from '.*infra" src/domain src/use-casesmust return nothing. - Ports are type-only modules: they declare interfaces, never implementations.
- The composition root is the only place where you may import both an adapter and a use-case.
- Tests instantiate fakes; no production code imports from
test-helpers/.
Adding a new external service
1. Define the port under src/use-cases/ports/<service>.ts — type only, returns Promise<Result<T, <Service>Error>> where the error is a discriminated union. 2. Create the in-memory fake under src/test-helpers/<service>-fake.ts with an optional errors config so tests can inject err(...). 3. Implement the real adapter under src/infra/<service>-<protocol>.ts (e.g. sheets-google.ts, tmdb-http.ts). The adapter is the only place try/catch wraps the SDK call. 4. Wire it into PipelineDeps and src/composition/build-deps.ts. 5. Write use-case tests that inject the fake and pattern-match on Result.ok.
Inbound HTTP (server archetype)
The canonical archetype is a CLI/batch job that runs and process.exits. When the entry instead serves HTTP, the server is an `infra/` adapter — the inbound mirror of an outbound one — not a new layer and not a `presenter/` file. An outbound adapter (telegram-http.ts) turns a thrown SDK error into a Result; the inbound adapter turns a use-case Result into a Response. It reuses infra's existing try/catch quarantine slot and 80% coverage tier, and adds no new layer — it lives under infra/, covered by that row's inbound-adapter clause.
Why not presenter/: the Result → Response mapper must read Summary and StepError, which live in use-cases/ports/. The presenter → domain/ only rule forbids that import, so a "presenter mapper" silently breaks the dependency table. The infra/ row explicitly covers the use-case Result/error types an inbound adapter maps — so the mapper belongs there.
// src/infra/http/to-response.ts — pure, total: a use-case Result → an HTTP Response.
import type { Result } from '../../domain/result.ts';
import type { Summary, StepError } from '../../use-cases/ports/step-error.ts';
export const toResponse = (result: Result<Summary, StepError>): Response => {
if (result.ok) return Response.json(result.value, { status: 200 });
const { step, cause, message } = result.error;
// The use-case flatten already stringified the port `kind` into `cause: string`, so there is no
// typed discriminant to switch on here — a use-case failure is a 500 by default. Precise client
// errors (400) are decided upstream at the branded request checkpoint, before this runs.
return Response.json({ step, error: cause, message }, { status: 500 });
};// src/infra/http/server.ts — inbound adapter; the one request-level try/catch lives here.
import type { Result } from '../../domain/result.ts';
import type { OrderInput } from '../../domain/order-input.ts';
import type { Summary, StepError } from '../../use-cases/ports/step-error.ts';
import { parseOrderBody } from '../../domain/order-input.ts'; // branded checkpoint (rule 12)
import { formatError } from '../../domain/utilities/format-error.ts';
import { toResponse } from './to-response.ts';
type HttpDeps = { readonly placeOrder: (input: OrderInput) => Promise<Result<Summary, StepError>> };
export const createHttpServer = (deps: HttpDeps): { readonly fetch: (req: Request) => Promise<Response> } => ({
fetch: async (req) => {
try {
const parsed = parseOrderBody(await req.text());
if (!parsed.ok) return Response.json({ error: parsed.error.message }, { status: 400 });
return toResponse(await deps.placeOrder(parsed.value));
} catch (e) {
return Response.json({ error: formatError(e) }, { status: 500 });
}
},
});src/main.ts stays the single thin entry with its one top-level catch: env → buildPipelineDeps(env) → createHttpServer(deps) → Bun.serve({ port: env.port, fetch: server.fetch }). Do not add a second main-web.ts; if the app is server-shaped, main.ts is the serving entry, and the Dockerfile gains EXPOSE <port> (references/bun-typescript.md § Containerization).
Three rules this archetype leans on:
1. The body is an untrusted source — brand it (rule 12). parseOrderBody is the validating checkpoint; precise 400s are decided here, where the type is still narrow. 2. A use-case error defaults to `500`. The flatten destroys the port kind (see references/result-type.md), so never switch on StepError.cause to fabricate 401/404/429 — that non-exhaustive lookup rots silently. Honoring typed statuses is a real upgrade: carry a status number through the flatten where TS still enforces totality over the kind union — adopt it when a requirement lands, not speculatively. 3. *Register each new `src/infra/http/.ts in scripts/coverage-preload.ts` in the same commit** — or the 80% gate passes trivially on uncovered files.
No router until the third route (Rule of Three) — a switch (new URL(req.url).pathname) covers one or two endpoints. No framework; that choice stays out of scope.
Framework vs configuration
Domain-specific data — brand lists, tenant slugs, feature flags, tier-discount rates, per-environment API endpoints — is configuration, not framework code. It lives in env vars, JSON files, or an external source loaded at runtime. The framework code never contains string-literal unions of brand slugs, hardcoded record maps of brands, or if (brand === 'acme') ... branches.
Signal: if a new tenant requires editing a union type or a switch statement, the code is fused with the data. Refactor to drive the behaviour from config.
Composition root testability (no skip lists)
src/composition/build-deps.ts is not a coverage-skip. It is fully unit-testable when two ergonomic switches are in place:
1. Every "where do I read state from" point — file path, env var, system clock, random source — is parameterisable. 2. Every "what do I write to / log to" sink can be injected as a port (Logger, EmailSender, Clock).
The pattern is an optional BuildDepsConfig argument with sensible defaults that preserve production behaviour:
// src/composition/build-deps.ts
export type BuildDepsConfig = {
readonly tokenStorePath?: string;
readonly logger?: Logger;
};
export const buildPipelineDeps = async (
env: Env,
config: BuildDepsConfig = {}
): Promise<PipelineDeps> => {
const logger = config.logger ?? createWinstonLogger();
const tokenStore = createTokenStoreFs({ path: config.tokenStorePath ?? '.tokens.json' });
// ... rest unchanged
};Production callers (just src/main.ts) call buildPipelineDeps(env) with no second argument; behaviour is identical. Tests pass { tokenStorePath: tmpDir + '/tokens.json', logger: createLoggerFake() }. With the token store empty and staleAfterMs set so refresh paths short-circuit, end-to-end execution is offline and the wiring covers itself.
Also export the otherwise-private helpers (overlayToken, buildEnrichmentPlugin, etc.) so individual branches can be tested in isolation rather than only through the composed buildPipelineDeps call.
The earlier policy that left build-deps.ts in the coverage skip list as "verified live, not via units" was hedging. With the two switches above, the file goes from "skipped" to 100%. The same logic applies to any composition or wiring file that feels untestable: parameterise the inputs, inject the outputs, and the test seam appears.
Feature-driven structure (simpler alternative, for small scripts)
For throwaway scripts, one-off CLIs, or pre-pipeline prototypes, a simpler feature-first layout is fine. Skip the port/adapter split until the repo genuinely needs it.
src/
<feature>/
domain.ts
use-case.ts
infra.ts
utils/
logger.tsGraduate to the Clean Architecture layout above when: the script gains a second external service, needs tests with fakes, or grows past ~500 lines. See references/bun-typescript.md for the small-script tsconfig / eslint setup.
---
The walking skeleton
Start with a minimal end-to-end slice:
1. Thinnest possible feature that touches all layers. 2. Deployable from day one. 3. Proves the architecture works.
Example walking skeleton for e-commerce:
- User can view ONE product (hardcoded).
- User can add it to a cart.
- User can "checkout" (just logs the attempt).
From there, flesh out each feature fully with TDD.
---
Testing architecture
+--------------------------------------------+
| E2E / acceptance tests | few, slow, high confidence
+--------------------------------------------+
| Integration tests | some, medium speed
+--------------------------------------------+
| Unit tests | many, fast, isolated
+--------------------------------------------+Test by layer:
- Domain | unit tests (most tests here).
- Application | integration tests with faked infrastructure.
- Infrastructure | integration tests with real dependencies.
- E2E | critical paths only.
See references/testing.md for the full strategy.
---
Architecture Decision Records (ADRs)
Document significant decisions:
# ADR 001 | Use PostgreSQL for persistence
## Status
Accepted
## Context
We need a database. Options: PostgreSQL, MongoDB, MySQL.
## Decision
PostgreSQL, because:
- ACID compliance
- Team familiarity
- JSON support for flexibility
## Consequences
- Need PostgreSQL expertise.
- Schema migrations required.
- Excellent query capabilities.Store ADRs under docs/adr/ in the repo. One file per decision, numbered.
---
Red flags in architecture
- Circular dependencies between modules.
- Domain depending on infrastructure.
- Framework code in business logic.
- No clear boundaries between features.
- Shared mutable state across modules.
- "utils" or "common" packages that grow forever.
- Database schema driving the domain model (domain should drive the schema, not the other way).
Atomic Design — the logic-free design system
Applies to every repo with React UI (Next.js monorepo variant). Read this before creating or modifying anything under src/components/, src/page/, or src/lib/{hooks,layout}/.
The design system is a standalone catalogue of presentational components. It renders props. It decides nothing, fetches nothing, stores nothing, and imports nothing from the application around it. All intelligence — state, data loading, routing, i18n, analytics — lives outside the design system and arrives through props. And the wall runs both ways: just as no application knowledge enters the design system, no styling knowledge leaves it — Tailwind is invisible outside src/components/**. The Next.js side does not know the project uses Tailwind. This is SKILL.md hard rules 21 and 22, and it is non-negotiable.
Why the hard line:
- Portability. Components that depend only on
reactrender anywhere: any router, Storybook, a marketing microsite, a test with no providers. - Refactor freedom. The app can swap its state management, i18n library, or data source without touching a single component file — and redesign a component without touching behaviour.
- Restyle freedom. Styling lives only in the design system, so a full rebrand touches
src/components/**and the design tokens — never a page, hook, route, or config file. Swapping the styling engine itself leaves the app byte-identical. - Reviewability. A diff under
src/components/**is a pure visual diff. If one PR changes a component and application behaviour, the boundary has leaked.
The five levels, mapped to directories
Brad Frost's hierarchy (atoms → molecules → organisms → templates → pages) maps onto the repo like this:
| Level | Directory | Responsibility | May import |
|---|---|---|---|
| Atoms | src/components/atoms/ | Smallest primitives: button, badge, icons. No internal composition — only HTML elements. | react only |
| Molecules | src/components/molecules/ | Small groupings: article-card, breadcrumbs, section-header, language-switcher. | atoms, react |
| Organisms | src/components/organisms/ | Full page sections: hero, faq, pricing, nav-bar, footer. | atoms, molecules, react |
| Templates | src/lib/layout/ | Layout shells and framework wrappers shared across pages. | anything |
| Pages | src/page/ | Page shells consumed by app/(lang)/page.tsx. Own all state and wiring. | anything |
Imports point strictly upward. An atom never imports a molecule. A molecule never imports an organism. Nothing inside src/components/** imports from src/lib/**, src/config/**, src/page/**, app/**, or any framework module (next/link, next/image, next/navigation). The only allowed imports inside the design system are react (types and JSX runtime) and lower design-system layers.
One component per kebab-case folder, component in index.tsx, PascalCase named export, exported props type, displayName set:
src/components/
├── atoms/
│ ├── button/index.tsx # exports Button, ButtonProps
│ ├── badge/index.tsx
│ └── icons/arrow-right-icon.tsx
├── molecules/
│ └── article-card/index.tsx # exports ArticleCard, ArticleCardProps
└── organisms/
└── pricing/index.tsx # exports Pricing, PricingPropsThe no-logic rule
Every component in src/components/** is a stateless const arrow function. Render output derives from props and nothing else.
Banned inside the design system:
useState,useReducer,useEffect,useContext,useReffor behaviour — any hook that creates state or side effects.- Data fetching,
async, promises, timers. - Translation lookups. Components receive final display strings (
title,label,description) as props; the page shell resolves translations upstream. - Business decisions. A component may map a typed prop to a class string (
variant → classes); it may not decide which variant applies — that decision arrives as a prop. - Imperative DOM access,
window/document, global side effects. dangerouslySetInnerHTMLon raw strings. If a prop is HTML, it crosses aSanitizedHtmlcheckpoint upstream (seereferences/security.md) — the prop type says so.
The test: could this component render in Storybook with nothing but hardcoded props? If anything else is needed — a provider, a router, an env var, a fetch — logic has leaked in.
The interactivity ladder
"No logic" does not mean "no interactivity". Reach for these in order:
1. Native HTML first. Disclosure widgets are <details>/<summary>; styling reacts with CSS (group-open:rotate-180). Hover and focus states are CSS. Zero JavaScript, zero props, accessible by default.
// organisms/faq — an accordion with no state anywhere
<details className="rounded-md border border-primary-200 p-6">
<summary className="flex cursor-pointer list-none items-center justify-between">
<span>{item.question}</span>
<ChevronDownIcon className="transition-transform group-open:rotate-180" />
</summary>
<p className="mt-4">{item.answer}</p>
</details>2. Hoisted state via props. When JS state is genuinely needed (mobile menu, dropdown), the component receives the state and its transitions as props — isOpen: boolean plus onToggle: () => void — and stays pure:
export type LanguageSwitcherProps = {
currentLang: string;
languages: LanguageItem[];
label?: string;
isOpen: boolean; // state lives upstream
onToggle: () => void; // transition lives upstream
};3. The state itself lives in `src/lib/hooks/`, consumed by the page shell — never by a component:
// src/lib/hooks/use-nav-state.ts
export const useNavState = (): NavState => {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [languageSwitcherIsOpen, setLanguageSwitcherIsOpen] = useState(false);
return {
mobileMenuOpen,
languageSwitcherIsOpen,
handleMobileMenuToggle: (): void => setMobileMenuOpen(!mobileMenuOpen),
handleLanguageSwitcherToggle: (): void => setLanguageSwitcherIsOpen(!languageSwitcherIsOpen),
};
};Visibility toggles with classes (hidden, block, conditional class strings), not by mounting/unmounting whole subtrees where a class would do.
Framework pieces are injected, never imported
Routing and image optimisation belong to the framework; the design system must not know which framework. Links and images arrive as component props typed against plain HTML attributes:
// organism props — knows "a link goes here", not "Next.js exists"
export type NavBarProps = {
brandImage: ComponentType<ImgHTMLAttributes<HTMLImageElement>>;
brandLink: ComponentType<AnchorHTMLAttributes<HTMLAnchorElement>>;
navLinkProps: { name: string; href: string }[];
mobileMenuOpen: boolean;
onMobileMenuToggle: () => void;
};The adapters live in src/lib/layout/wrappers.tsx — the one place that imports next/link and next/image:
// src/lib/layout/wrappers.tsx
export const createLinkWrapper = (href: string): ComponentType<AnchorHTMLAttributes<HTMLAnchorElement>> => {
const isExternal = href.startsWith('http://') || href.startsWith('https://');
const LinkWrapper: ComponentType<AnchorHTMLAttributes<HTMLAnchorElement>> = ({ children, ...props }) =>
isExternal ? (
<Link href={href} target="_blank" rel="noopener noreferrer" {...props}>{children}</Link>
) : (
<Link href={href} {...props}>{children}</Link>
);
LinkWrapper.displayName = `LinkWrapper(${href})`;
return LinkWrapper;
};The page shell builds the wrappers and hands them down. Render via composition: <Item.Link>children</Item.Link>. A component that imports createLinkWrapper directly has coupled itself to src/lib — that is the leak this pattern exists to prevent.
Styling is sealed inside the design system
The mirror image of the no-logic rule: the application never styles anything. Tailwind — the utility classes, the responsive grammar, the token scale — exists only under src/components/**, plus the token sheet app/globals.css (Tailwind v4 CSS-first config, the rebrand lever). To the rest of the codebase, "how things look" is not a concept it can express.
- No utility class outside the design system.
app/**routes,src/page/**shells,src/lib/**,src/config/**never contain a Tailwind string. A page shell stacks organisms inside a bare<main>; each organism owns its own section spacing (py-16 lg:py-20), so the page has nothing left to say about layout. - No free-form `className`/`style` in public component APIs. Molecules and organisms expose typed props —
variant,size,tone, content — never a class-string escape hatch. If a caller "needs" to pass a class, the design system is missing a variant: add the variant there instead. - Leaf atoms are the one exception. Icons and similar primitives may accept
classNameso design-system parents can size and position them (<ChevronDownIcon className="h-5 w-5" />). That is internal composition between design-system layers; it never crosses the app boundary. - Copy is plain text. Strings in
src/config/anddata/translations/carry no embedded class names and no styled JSX. - If it needs styling, it is a design-system component. An MDX component map, a styled prose block, a styled link — the styled implementation lives under
src/components/**;src/libcomposes and wires it.
Two tests, applied at review:
1. The rebrand test. A full visual redesign touches src/components/** and the tokens in app/globals.css — nothing else shows up in the diff. 2. The swap test. Migrating Tailwind to another styling engine leaves app/, src/page/, src/lib/, and src/config/ byte-identical.
Why: utility classes scattered through pages are how design drift starts — three slightly different paddings for the same kind of section, a rogue mt-7 nobody can explain. One owner for the visual layer keeps every spacing decision reviewable in one directory, and keeps application diffs about behaviour, never pixels.
Component anatomy
// src/components/atoms/button/index.tsx
import type { ButtonHTMLAttributes, FC, ReactNode } from 'react';
export type ButtonVariant = 'primary' | 'secondary' | 'premium' | 'ghost';
export type ButtonProps = {
children: ReactNode;
variant?: ButtonVariant;
icon?: ReactNode;
} & ButtonHTMLAttributes<HTMLButtonElement>;
const variantStyles: Record<ButtonVariant, string> = {
primary: 'bg-primary-950 text-white hover:bg-primary-900',
secondary: 'bg-transparent text-primary-900 border border-primary-300',
premium: 'bg-accent-800 text-white hover:bg-accent-900',
ghost: 'bg-transparent text-primary-700 hover:text-primary-950',
};
export const Button: FC<ButtonProps> = ({ children, variant = 'primary', icon, className = '', ...props }) => {
const variantClass = ((): string => {
switch (variant) {
case 'secondary': return variantStyles.secondary;
case 'premium': return variantStyles.premium;
case 'ghost': return variantStyles.ghost;
default: return variantStyles.primary;
}
})();
return (
<button className={`inline-flex items-center justify-center gap-x-2 rounded-md ${variantClass} ${className}`} {...props}>
{icon && <span className="shrink-0">{icon}</span>}
<span>{children}</span>
</button>
);
};
Button.displayName = 'Button';Style rules, all enforced at review:
constarrow function typedFC<Props>; never afunctiondeclaration (hard rule 2).- Props type exported next to the component;
type, neverinterface(hard rule 3). - Group related inputs into named prop objects (
authProps,languageSwitcherProps,items) instead of long flat lists. - Destructure props in the parameter list; defaults in the parameter list.
- Variant/size dispatch through a typed
Recordmap; look up viaswitchsoeslint-plugin-security's object-injection rule stays quiet without an inline ignore (hard rule 15). - Semantic elements first:
header,navwitharia-label,section,ul/li— notdivsoup. - Lists render with
.mapand stable keys (id, slug, question text) — never the array index. - Tailwind utilities on the design-token scale; responsive shifts via
md:*/lg:*. - Accessibility is part of the contract: visible focus rings,
aria-expanded/aria-haspopupon disclosure triggers,aria-hidden="true"on decorative SVGs. - Named imports only — no
import * as Xwildcards. displayNameon every component (and on generated wrappers).- The
classNamemerge on this atom is the leaf-atom exception to the styling seal: it exists so design-system parents can size and position the primitive. The application side never passes a class through it.
The wiring: how data reaches the design system
app/(en)/page.tsx server component, build time
loadTranslations('en') ── reads data/translations/en.json
getLandingPageConfig('en') ── src/config: copy + hrefs + image configs, typed
│ JSON-serialisable props
▼
src/page/home-page.tsx 'use client' page shell
useNavState() ── owns ALL state (src/lib/hooks)
createLinkWrapper / createImageWrapper (src/lib/layout/wrappers)
│ props only: data + callbacks + injected components
▼
src/components/organisms → molecules → atoms stateless, logic-freeThe page shell is the composition root of the UI: it is the only 'use client' boundary, the only consumer of hooks, and the only place design-system props get assembled. src/config/ may import design-system prop types (FeaturesProps, TestimonialsProps) to stay in sync with the components it feeds — types flow downward, code never does.
Where does it go?
| You are about to write… | It belongs in |
|---|---|
| A reusable visual primitive (button, badge, icon) | src/components/atoms/ |
| A grouping of atoms with one purpose (card, breadcrumbs) | src/components/molecules/ |
| A full page section (hero, pricing, footer) | src/components/organisms/ |
| A Tailwind utility class | inside a component under src/components/** — nowhere else |
| A design token (colour, spacing scale, font) | app/globals.css (@theme) |
useState / useEffect / any hook | src/lib/hooks/, consumed by src/page/ |
A next/link or next/image usage | src/lib/layout/wrappers.tsx, injected as props |
| Display copy, labels, hrefs | data/translations/ + src/config/, resolved by the route |
| Data loading (MDX, JSON) | server components in app/, at build time |
| Page assembly, state wiring | src/page/<name>-page.tsx |
| SEO/structured data | src/lib/seo/, rendered by the page shell |
When a component seems to "need" something not in this table — a store, a context, a fetch — the need is real but the location is wrong: satisfy it in the page shell and pass the result down.
Red flags (design system)
- A hook call — any
use*— insidesrc/components/**. import ... from '../../lib/...','@/src/config/...','next/link', or'next/image'anywhere undersrc/components/**. Links and images are injected asComponentTypeprops.- A component that resolves translations, reads
process.env, or toucheswindow. 'use client'on a design-system component. The directive belongs to page shells; pure components inherit the boundary.- A
<div onClick>where a<button>or<details>does the job natively. - Conditional
nullreturns to hide content where ahidden/responsive class is the honest tool. - Index keys in a
.map. - A new component folder without an exported props type, without
displayName, or holding two components. - A page shell importing an atom directly to rebuild what an organism already provides — compose at the right level instead.
- A Tailwind utility string in
app/**(anywhere butglobals.css),src/page/**,src/lib/**, orsrc/config/**. Styling lives only undersrc/components/**. - A molecule or organism whose public props include
classNameorstyle, or a page shell passing one in. That is a missing variant — add it to the component. - A class name embedded in config or translation strings.
Class-to-Module Translation Catalogue
Since class and interface are banned in this codebase, classical OO patterns must be expressed as typed records and factory functions. Learn these translations once, apply everywhere.
The references/design-patterns.md file contains the full GoF catalogue in this style. references/object-design.md covers value objects, entities, aggregates, and polymorphism-via-dispatch in depth. This page is the quick lookup table.
Note on examples. Port and use-case signatures in this file are sometimes elided toPromise<T>(or throw on business failure) for brevity where error handling is not the lesson. In real code every IO port returnsPromise<Result<T, PortError>>and every use-case returnsPromise<Result<Summary, StepError>>— hard rule 16, seereferences/result-type.md.
Value object
class Money { ... } becomes a readonly record plus operation functions:
export type Money = { readonly amount: number; readonly currency: string };
export const money = (amount: number, currency: string): Money => {
if (!Number.isFinite(amount)) throw new Error('invalid Money.amount');
return { amount, currency };
};
export const addMoney = (a: Money, b: Money): Money => {
if (a.currency !== b.currency) throw new Error('CurrencyMismatch');
return money(a.amount + b.amount, a.currency);
};
export const moneyEquals = (a: Money, b: Money): boolean =>
a.amount === b.amount && a.currency === b.currency;The factory function (money) is the validation gate. Downstream code trusts anything with type Money without re-checking.
Interface / contract
interface UserRepo { ... } becomes a function-type alias:
export type UserRepo = {
save: (user: User) => Promise<void>;
findById: (id: UserId) => Promise<User | null>;
};Service with injected dependencies
class UserService { constructor(repo) { ... } } becomes a factory that closes over its dependencies:
export type UserService = { getUser: (id: UserId) => Promise<User | null> };
export const createUserService = (repo: UserRepo): UserService => ({
getUser: async (id) => repo.findById(id),
});Strategy
interface ShippingMethod plus multiple class ... implements becomes a contract plus exported records:
export type ShippingMethod = { calculateCost: (orderValue: number) => number };
export const standardShipping: ShippingMethod = { calculateCost: (v) => (v < 50 ? 5 : 0) };
export const expressShipping: ShippingMethod = { calculateCost: () => 15 };
export const overnightShipping: ShippingMethod = { calculateCost: () => 25 };New shipping methods arrive as new exported consts, never as edits to existing ones.
Factory
class NotificationFactory becomes a plain function:
export const createNotification = (kind: NotificationKind): Notification => {
if (kind === 'email') return emailNotification;
if (kind === 'sms') return smsNotification;
return pushNotification;
};Decorator
class SMSDecorator implements Notifier becomes a higher-order function:
export const withSms = (wrapped: Notifier): Notifier => ({
send: async (message) => {
await wrapped.send(message);
await sendSms(message);
},
});Compose decorators with function application: withSlack(withSms(emailNotifier)).
Observer
class EventEmitter becomes a closure factory:
export type Emitter<T> = {
subscribe: (observer: (event: T) => void) => () => void;
emit: (event: T) => void;
};
export const createEmitter = <T>(): Emitter<T> => {
let observers: ((event: T) => void)[] = [];
return {
subscribe: (observer) => {
observers.push(observer);
return (): void => {
observers = observers.filter((x) => x !== observer);
};
},
emit: (event) => observers.forEach((o) => o(event)),
};
};Command
class AddItemCommand implements Command becomes a typed record with action functions:
export type Command = { execute: () => void; undo: () => void };
// addToCart/removeFromCart are immutable (they return new carts), so the
// command closes over a mutable holder; execute/undo swap the current cart.
export const addItemCommand = (cart: Cart, item: Item): Command & { getCart: () => Cart } => {
let current = cart;
return {
execute: () => {
current = addToCart(current, item);
},
undo: () => {
current = removeFromCart(current, item);
},
getCart: () => current,
};
};Entity with state transitions
class Order becomes an immutable record plus transform functions:
export type Order = {
readonly id: OrderId;
readonly items: readonly OrderItem[];
readonly status: OrderStatus;
};
export const createOrder = (id: OrderId): Order => ({ id, items: [], status: 'pending' });
export const addItemToOrder = (order: Order, item: OrderItem): Order => ({
...order,
items: [...order.items, item],
});
export const payOrder = (order: Order): Order => ({ ...order, status: 'paid' });Transformations take the record in, return a new record out, and enforce invariants in between. Aggregate roots follow the same pattern: every mutation goes through a root function that returns a new root.
Quick reference
| OO concept | Class-free expression |
|---|---|
| Value object | Readonly record + validating factory |
| Interface / contract | type Foo = { method: (...) => ... } |
| Service with deps | Factory function returning the contract |
| Strategy | Contract + exported implementation records |
| Factory | Plain function that returns the right variant |
| Decorator | Higher-order function wrapping the contract |
| Observer | Closure factory over an observer array |
| Command | Record with execute / undo functions |
| Entity | Immutable record + transform functions |
| Aggregate root | Root function is the only mutation path |