20 KiB
Project Engineering Standards (Generic CLAUDE.md)
A reusable engineering rulebook for AI coding agents and human contributors. Drop into any TypeScript monorepo; replace bracketed <placeholders> with project-specific values. Paired with GENERIC-OPENSPEC-AGENTS.md for the change-management workflow.
1. Stack baseline (adjust to your project)
- Monorepo orchestrator: Turborepo (or Nx / Lerna / Moon).
- Package manager / runtime: Bun (or pnpm / npm / yarn).
- Lint / format: Biome (or ESLint + Prettier).
- Unit tests: Vitest (or Bun test / Jest).
- E2E: Playwright.
- Language: TypeScript, strict.
- Registry: private (Verdaccio / GitHub Packages) or public npm.
Pin every tool version in the root manifest; never accept "latest" implicitly.
2. Change workflow
All non-trivial changes go through the OpenSpec process (see GENERIC-OPENSPEC-AGENTS.md). Before coding:
- Read the project's spec/agents docs.
- Create
openspec/changes/<change-id>/withproposal.md,tasks.md, and spec deltas underspecs/<capability>/spec.mdusing## ADDED/## MODIFIED/## REMOVED Requirementsheadings. - On ship: fold the delta into
openspec/specs/<capability>/spec.mdand move the folder toopenspec/archive/<yyyy-mm-dd>-<change-id>/.
3. Critical rules (never compromise)
- Never run
tscdirectly. Use your bundler (Vite/tsup/esbuild) via your task runner. Native TS runtimes (Bun, ts-node) execute.tsfiles without compilation. - Never use mocks for things you can run for real. Prefer real services (testcontainers, in-memory adapters, local servers) over
vi.mock/jest.mock. - Pin exact versions in dependency overrides. No drift.
- Never re-export external deps for convenience — consumers import from the upstream source.
- Never auto-commit/push without user approval. Never use
--no-verify. - Always use absolute paths in tool calls.
- Read files before editing them — understand context first.
- Testing-infrastructure packages must not depend on the project's runtime packages. Only external deps (and a zero-runtime types package, if you have one).
- Never write static-analysis suppression comments (
// biome-ignore,// eslint-disable*,// ts-ignore,// ts-expect-error) in new or migrated code. Stale rule names break the lint gate; comments that no longer apply produce "has no effect" warnings. To exercise a deliberate violation, use a pattern the linter cannot statically reject (see §10b). - Never write
export *in any non-root barrel. Feature-module barrels MUST use explicit named re-exports. Exempt: generated forwarder files, single-line variant forwarders, and namespace re-exports (export * as ns from "..."). Migrateexport *→ named exports inside the same commit, never as a follow-up. - Never import test-runner primitives directly from your runner. If you support both Bun and Vitest (or want to keep the option open), route all test imports through a single neutral wrapper module that re-exports from the active runner via conditional exports. Allowlist only the wrapper packages.
4. Git safety (non-negotiable)
Before ANY git operation: run git status in every worktree (git worktree list) and confirm each is clean or its state is understood.
Never run these without explicit per-use user approval:
git filter-repo/git filter-branch— rewrites history, destroys staged + working tree.git push --force/--force-with-lease— only after the user explicitly says "force push".git reset --hard,git clean -fd,git rebase,git stash drop/clear,git gc/prune,git checkout -- <file>.
Large-file push errors: stop. Do NOT rewrite history. Run git status / git stash list in all worktrees, then ask the user (.gitignore + new commit, Git LFS, or manual removal).
Staged changes are irreplaceable — treat them as more precious than committed history.
5. Workflow rules
- Create a task list for multi-step work (>3 steps).
- Complete all implementations. No stubs, no placeholders, no TODOs, no mocks in production. Missing dep? Add it and wire the real implementation.
- Build feature-rich, production-grade packages. Prefer comprehensive over MVP. Every package ships at full functional completeness on first landing — never defer a feature its name reasonably implies to a follow-up proposal.
- Professional honesty. No marketing language. No fake metrics.
- Preserve the full public surface during rewrites. Trim only when duplicated in-repo or replaceable by an existing in-repo library.
6. Package layout
6.1 Directory structure
Domain-first organization, with per-feature folders containing five co-located files:
src/<category>/
<feature>/
index.ts # Barrel — explicit named re-exports
<feature>.ts # Implementation
<feature>.test.ts # Unit tests
<feature>.types.ts # Type definitions (optional)
<feature>.md # Co-located documentation (mandatory for impl files)
Special directories:
types/— shared types, flatutils/— flat with tests + docsconstants/index.ts— simple exports (no tests/docs needed)
6.2 Naming rules
- No package-name prefix inside package folders. Inside
packages/<pkg>/, child folders/files use the feature name only — never<pkg>-<feature>. The package name is already in the path. - No redundant ancestor names. A child name must not contain the singular or plural form of any ancestor folder. If parent is
adapters/, children must not bememory-adapter/; usememory/. Apply to:strategies,providers,builders,plugins,handlers,resolvers,factories,parsers,services, etc. - One
README.mdper package root only. No category-level READMEs insidesrc/<category>/.
6.3 Subpath exports
Every feature directory must have its own entry in package.json exports so consumers can tree-shake:
"./retry/retry": {
"import": { "types": "./dist/retry/retry.d.ts", "default": "./dist/retry/retry.js" }
}
A single "." barrel is insufficient. The count of subpath exports should match the count of feature modules.
6.4 Barrel rules (explicit named exports only)
// CORRECT — feature barrel: explicit named exports
export {
decryptStream,
type DecryptStreamOptions,
encryptStream,
type EncryptStreamOptions,
} from "./streaming.js";
// WRONG — leaks every new export silently
export * from "./streaming.js";
// CORRECT — top-level namespace re-export (recommended for primitives with shared names)
export * as ed25519 from "./primitives/ed25519/ed25519.js";
Why: the barrel is the contract; export * makes the contract whatever the source happens to declare today. Tree-shaking is equivalent under "sideEffects": false.
7. Dependency conventions
-
External deps use catalog refs (or workspace-level pinning) — never bare versions like
"^1.2.3". -
Internal deps use
workspace:^. -
"sideEffects": falseon all pure library packages. -
After changing
overrides, nuke and reinstall:rm -rf node_modules <lockfile> && find . -type d -name "node_modules" -exec rm -rf {} + && <install> -
For browser/React packages: verify only one React version exists after install:
find . -path "*/node_modules/react/package.json" | head -5
8. Banned patterns in src/
Before closing any task, scan for zero matches:
grep -rn "@deprecated\|TODO\|FIXME\|ts-ignore\|ts-expect-error\|\.skip\|\.only\|\bany\b\|console\.log" \
packages/<pkg>/src/ --include="*.ts"
@deprecatedJSDoc → replace with prose: "Backwards-compatibility alias for X."any→ useunknown(narrow viainstanceof/ type guards) or a proper generic. Never substituteobject,{}, or a redundant union.console.log→ use a real logger (injectableLoggerLikeinterface preferred).
8.1 No stubs, placeholders, or "WIP" code
Concretely banned in any commit:
- Stub functions —
throw new Error("not implemented"), identity pass-throughs that pretend to do work, no-op factories. - Placeholder types —
type Foo = unknown, emptyinterface Foo {},as neverhiding incomplete data flow. - TODO / FIXME / XXX comments in source AND docs.
- Deprecation aliases without a working replacement.
- "Phase 1 / Phase 2" splits where Phase 2 is unwritten and Phase 1 ships a function that doesn't do the thing its name claims.
- Marketing language masking incomplete work ("comprehensive", "fully-featured" applied to code with unimplemented branches).
- Tautological tests —
it.todo,it.skip,expect(true).toBe(true),expect(result).toBeDefined()standing alone, tests that assert against the exact hardcoded value the implementation returns. Everyit(...)MUST verify a behavior the implementation could plausibly get wrong. - Stub docs —
.mdfiles that say "TODO", empty Features/API sections, or fail word-count gates. - Empty test files kept only to keep coverage thresholds passing.
- Silently dropped spec requirements — see §2c of the OpenSpec doc. If a requirement is in the spec delta, it ships.
If the work isn't ready: don't merge it. The default branch is the product, not a scratchpad.
8.2 Never delete spec elements to satisfy a gate
When a gate fails because a registered element (error code, schema field, event topic) is declared but unused, the failure is telling you the implementation hasn't caught up to the proposal. Wire it in, do NOT delete it. Deletion is the path of least resistance and silently strips features the proposal committed to.
If the element genuinely crept in by mistake, remove it with a one-line note under ### Deferred or ### Removed in proposal.md so the deletion is auditable.
8.3 Never delete code that serves a purpose to make tests pass
When a coverage report flags a function, branch, or guard as uncovered, write a test that exercises real behavior — do NOT delete the code that's hard to reach.
Concretely banned:
- Deleting defensive guards because current call sites don't trigger them.
- Deleting rollback/cleanup helpers because the synchronous code path can't reach them.
- Replacing
instanceof Error ? cause : undefinedwithcause as Errorto "simplify." - Deleting branches inside
try/catchbecause the library doesn't currently throw.
Instead, in priority order:
- Write a test that exercises the path — inject a controlled failure (custom storage that throws, custom Clock that returns a chosen instant, AbortSignal scheduled via
queueMicrotask). - Restructure to make the path reachable without changing semantics (e.g.
await Promise.resolve()between iterations so a caller-side abort can land). - If genuinely unreachable, annotate with
/* c8 ignore next */and a comment naming the unreachability proof.
8.4 Suppression workaround pattern
When a deliberate violation is required (e.g. testing a defensive non-Error branch), use a pattern the linter can't statically reject:
// BANNED — suppression rots when rule renames.
// biome-ignore lint/suspicious/noThrowLiterals: ...
throw "literal";
// REQUIRED — linter can't prove the value isn't Error-like.
const failure: unknown = "literal";
throw failure;
9. File-size budgets
- Source files: 300 lines hard / 250 soft.
- Test files: 500 lines max.
- Functions: 50 lines soft — extract helpers at natural responsibility boundaries.
find packages/<pkg>/src -name "*.ts" ! -name "*.test.ts" | \
xargs awk 'END { if (NR > 300) print FILENAME, NR " lines" }'
Split at natural boundaries. Preserve all public exports.
10. Quality gates
10.1 Coverage
100 / 100 / 100 / 100 for every package, no exceptions:
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 }
The pre-publish gate MUST hard-fail below 100%. Mutation / property / fuzz testing layer on top — they do not substitute.
10.2 Tests
-
Use Arrange-Act-Assert.
-
Name tests after the behavior, not the implementation:
// CORRECT it('returns empty array when no results match', ...) it('throws ValidationError when required field is missing', ...) // WRONG it('test1', ...) it('works correctly', ...) it('handleSearch', ...) -
File suffixes:
.test.tsfor unit,.spec.tsfor E2E. -
Always pass a timeout:
it(name, { timeout: 5000 }, fn)(30000 for async-heavy). -
Root-cause every failure — never
.skipor.onlyto ship. -
Every UI component needs
.stories.tsx(default + playground + variants).
10.3 Pre-publish gate
<task-runner> lint typecheck test --filter=<pkg>
<task-runner> test:coverage --filter=<pkg>
<task-runner> build --filter=<pkg>
All must be green. Single-file test runs are not a substitute for the full-package gate.
11. Documentation
11.1 Sibling .md per implementation file
Every .ts/.tsx MUST have a sibling .md. Exclusions: .test.ts, .test.tsx, .spec.ts, .types.ts (inside a feature dir), .stories.tsx, barrel index.ts, constants/index.ts.
Required sections (≥200 words excl. code):
- Purpose (≥2 sentences — what and why)
- Features (bullet list)
- Usage: Basic Example
- Usage: Advanced Example
- API Reference (every exported symbol — params, return type, throws)
- Implementation Notes (design decisions, limits, edge cases)
Hard constraints: both simple AND advanced examples mandatory; code must compile with real imports; no marketing/placeholder text; update docs when code changes.
11.2 Three-pass writing workflow
Never one-shot:
- Read — full
.tsfile; note every exported symbol, param, thrown error. - Structure — Purpose + Features + API Reference first.
- Examples — Basic (simplest happy path), then Advanced (real composition / edge case).
11.3 Doc-drift gate
When you modify any <feature>.ts, you MUST update its sibling <feature>.md in the same change if the modification affects:
- public API surface (added/removed/renamed export, changed types, changed throws)
- documented behavior (error codes, side effects, semantics)
- examples in the
.md(must still compile)
Editing .ts without touching .md is treated as a defect, identical in severity to a failing test.
# For every changed .ts, confirm the sibling .md is also touched
git diff --name-only HEAD | awk '
/\.ts$/ && !/\.test\.ts$/ && !/\.spec\.ts$/ && !/\.types\.ts$/ && !/\/index\.ts$/ {
md = $0; sub(/\.ts$/, ".md", md)
cmd = "git diff --name-only HEAD -- " md
cmd | getline touched; close(cmd)
if (touched == "") print "DOC DRIFT: " md " not updated alongside " $0
}
'
11.4 Root README.md
Every package needs README.md at the root with:
- Package name (H1)
- One-paragraph description
- Install command
- Quick-start code example
- Modules — links to every co-located
.mdinsrc/via relative paths
12. Type design
12.1 No nested object literals in interfaces
Every nested object shape must be a named interface or type.
// WRONG
interface CreateUserOptions {
profile: { displayName: string; avatarUrl: string }
notifications: { email: boolean; push: boolean }
}
// CORRECT
interface UserProfile { displayName: string; avatarUrl: string }
interface NotificationPreferences { email: boolean; push: boolean }
interface CreateUserOptions { profile: UserProfile; notifications: NotificationPreferences }
12.2 No inline shapes in function signatures
// WRONG
function createSession(user: { id: string }, opts: { ttl: number }): { token: string } { ... }
// CORRECT
function createSession(user: SessionUser, opts: SessionOptions): SessionResult { ... }
12.3 Shared types belong in a zero-dep types package
If a type is referenced by ≥2 packages, define it in a shared types/contracts package with zero runtime code and zero workspace deps. This prevents circular dependencies and makes the public contract single-sourced.
13. Error handling
Use a centralized error system (defineCodes("<NAMESPACE>", ["REASON_A", "REASON_B"] as const) style) rather than hand-written string literals. Rules:
- In code: throw
new XxxError(CODES.REASON, …)using the exported constant. Never type the raw"NAMESPACE.REASON"string by hand. - In tests: assert against the constant (
expect(err.code).toBe(CODES.REASON)), never against a hand-typed literal — those rot silently. - In specs: when a spec references a code, use the exact namespaced form. Spec values must match the code-generated value verbatim.
- On mismatch: fix the spec to match the convention-correct value — do not bend the code's namespace to match a mistaken literal.
Every registered error code MUST be reachable from a real call site exercised by a test. A registered-but-never-used code is a defect.
14. Web app / page verification
When generating client-side pages, verification is NOT optional and a 200 response is NOT proof of correctness:
- Staging environment — mirror production before declaring done (Docker / preview deploy).
- Full Playwright E2E sweep — every page, every component, every navigation.
- Never trust HTTP 200 alone — verify rendered DOM contains expected content.
- Inspect browser console — any unexpected error or warning fails the check.
- Inspect network activity — failed requests, missing assets, CORS, mixed content.
- Zero-defect to live — fix root cause first.
15. Vitest / WSL2 specifics (optional, if applicable)
- Use a shared
createTestConfig()helper. For configs needing aliases/plugins, write standalone withpool: 'forks'+ a polyfills setup file. - Never
mergeConfigwith a base config that may omit polyfills — causescrypto is not definedand similar at runtime. vmThreadsandthreadspools fail in Bun on WSL2 — use defaultforks.fileParallelism: falserequired for browser tests.
16. Code-tier organization
Encode the runtime tier in package.json ("browser" field, peer deps, engines), not in the folder path.
- Universal: ZERO browser/React/Node-only deps.
- Browser: React/DOM ok.
- Server: Node.js ok.
Co-locate tests with source; sibling .md docs per implementation file.
17. Package lifecycle
- New packages: add to manifest, regenerate dep graph index.
- Editing: every package starts at
0.1.0; bump only at1.0.0graduation. - Pre-publish gate (non-negotiable): lint + typecheck + test + 100% coverage on touched modules + build all green.
- Foundation packages: when changing a widely-consumed package, run the dependent sweep (
<task-runner> test --filter=...<pkg>) before declaring done. Green local tests do not prove you didn't break consumers.
18. Quick reference commands
# Test one package
<task-runner> test --filter=<pkg>
# Coverage for one package
<task-runner> test:coverage --filter=<pkg>
# Lint + typecheck + tests
<task-runner> lint typecheck test --filter=<pkg>
# Pre-publish gate
<task-runner> lint typecheck test build --filter=<pkg> && <task-runner> test:coverage --filter=<pkg>
# Dependent sweep (foundation packages)
<task-runner> test --filter=...<pkg>
# Changed since main
<task-runner> test --filter=...[main]
19. Behavior-change propagation
When a change introduces consumer-visible behavior changes (new error code, renamed metric, method that now throws, default that flipped, return shape change), do all three:
- Self-doc: update sibling
.md+README.mdFeatures section. - Dependent surface scan:
grep -rln "<old-name>" packages/ | grep -v "/dist/\|/<own-pkg>/". Every hit needs updating in the same change set. - Migration note: add
## Migrationtoproposal.mdlisting old behavior, new behavior, search command for consumers, recommended replacement.