Files
agentic-dev/AGENTS.md
Danijel Martinek 8f34daca36 docs(dev-seed): canonical doc updates + refactor-log entry
- CLAUDE.md Key Conventions: 'App bootstrap' rule rewritten as 'Three
  binding modes per feature' — describes USE_DEV_SEED + NODE_ENV
  resolution order and the new ./di/bind-dev-seed export.
- AGENTS.md (root): exports list now mentions ./ui + ./di/bind-dev-seed;
  Per-feature public-API surface table gains a row; Apps section shows
  the bindAll() dispatcher with three-rule logic.
- docs/architecture/vertical-feature-spec.md §6: file shape now
  includes bind-dev-seed.ts, bind-dev-seed.test.ts, __seeds__/dev.ts;
  package.json exports list updated to include ./di/bind-dev-seed.
- docs/architecture/data-flow-explainer.html: anatomy tree gains
  __seeds__/ row; LAYERS.di description updated with new binders +
  cross-link to di-explainer.html; new LAYERS.seeds entry; public-
  surface card expanded to six subpaths.
- docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md
  §7: new 'Post-Plan-9: dev-seed binders' entry summarizing the rollout
  (commits, per-feature additions, app wiring, tests, turbo, docs).
- bind-production.test.ts: dispatcher tests use vi.stubEnv (typesafe
  way to test process.env in TypeScript 5+ with @types/node read-only
  process.env types). 4 dispatcher tests + 2 bindAllProduction tests
  = 7 tests total.
2026-05-06 19:49:58 +02:00

367 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# AGENTS.md — Vertical Feature Monorepo
This is a **Turborepo + pnpm monorepo** organized by vertical features. Each feature package owns its own Clean Architecture layers (entities, application, infrastructure, interface-adapters) and integrations (CMS collections, tRPC routers, UI components). Core packages provide foundation: primitives, design system, CMS composition, API aggregation, and tRPC client platform.
---
## Package Map
| Package | Tag | Purpose |
|---|---|---|
| `@repo/core-shared` | core | Generic primitives (Zod, env, Payload hooks/fields/blocks, tRPC init/context) |
| `@repo/core-ui` | core | Design system (atoms, molecules, generic organisms, templates) |
| `@repo/core-api` | core-composition | tRPC router aggregator — imports `@repo/<feature>/api` only |
| `@repo/core-cms` | core-composition | Payload config aggregator — imports `@repo/<feature>/cms` only |
| `@repo/core-trpc` | core-composition | Frontend tRPC client + framework-specific providers (Next.js, TanStack) |
| `@repo/auth` | feature | Users collection + sign-in/up/out |
| `@repo/blog` | feature | Articles collection + article use-cases |
| `@repo/media` | feature | Media collection + upload helpers |
| `@repo/marketing-pages` | feature | Pages collection + SiteSettings global |
| `@repo/navigation` | feature | Header global |
| `@repo/core-eslint` | tooling | Shared ESLint 9 flat configs (base, next, react-internal, boundaries) |
| `@repo/core-typescript` | tooling | Shared TypeScript base configs + Vitest base |
| `@repo/core-testing` | tooling | Shared test utilities (defineFactory, defineContractSuite, renderWithProviders, payload mocks) |
---
## Boundary Rules
### Five tags
- **app** (4 packages) — `apps/web-next`, `apps/web-tanstack`, `apps/cms`, `apps/storybook`
- **core-composition** (3 packages) — `packages/core-api`, `core-cms`, `core-trpc`
- **core** (2 packages) — `packages/core-shared`, `core-ui`
- **feature** (5 packages) — `packages/auth`, `blog`, `media`, `marketing-pages`, `navigation`
- **tooling** (3 packages) — `packages/core-eslint`, `core-typescript`, `core-testing`
### Allowed dependency directions
| Tag | May depend on |
|---|---|
| app | app, core, core-composition, feature, tooling |
| core-composition | core, core-composition, feature, tooling |
| core | core, core-composition, tooling |
| feature | core, tooling |
| tooling | tooling |
### Composition exceptions
1. **`core-api`** may import `@repo/<feature>/api` subpath exports only (to compose tRPC routers).
2. **`core-cms`** may import `@repo/<feature>/cms` subpath exports only (to compose Payload collections).
3. **`core-trpc`** reaches features transitively through `core-api`'s `AppRouter` type.
No other cross-package boundary deviations are permitted.
### Four enforcement layers
1. **`package.json` dependencies** — only allowed deps are declared; illegal imports fail at install time.
2. **`exports` maps** — feature packages expose `.`, `./ui`, `./cms`, `./api`, `./di/bind-production`, `./di/bind-dev-seed` only; no deep source paths exist.
3. **ESLint `eslint-plugin-boundaries`** (lint-time) — configured in `packages/core-eslint/`:
- Enforces the five-tag rules at linting
- Feature packages may import from `core` and tooling only.
- `core-shared`, `core-ui` may not import any feature.
- `core-api` restricted to `@repo/<feature>/api` imports.
- `core-cms` restricted to `@repo/<feature>/cms` imports.
- No `../../../` cross-package relative imports.
4. **Turborepo `boundaries`** (build-graph time) — configured in root `turbo.json`:
- Validates the entire workspace dependency graph, including transitive dependencies
- Catches issues ESLint might miss (e.g., transitive feature reaches through composition packages)
- Run with `pnpm turbo boundaries`
---
## Adding a Feature
Start with `docs/guides/adding-a-feature.md` for a step-by-step walkthrough covering:
- New feature scaffold (folder structure, `package.json`, `tsconfig.json`, `vitest.config.ts`)
- Clean Architecture layers (entities, use cases, repositories, DI container, controllers)
- Payload integration (collections, hooks, Payload repository binding)
- tRPC integration (routers, procedure binding)
- Core wiring (`core-api`, `core-cms`, path aliases, app bootstrap)
- Testing and lint validation
---
## Key Commands
```bash
pnpm install # Install all dependencies
pnpm dev # Start all dev servers (Next.js :3000, CMS :3001, Storybook :6006)
pnpm typecheck # Type-check all packages
pnpm lint # Lint all packages (ESLint boundaries enforced)
pnpm turbo boundaries # Validate workspace dependency graph (Turbo boundaries)
pnpm test # Run all unit + integration tests (Vitest)
pnpm test:e2e # Run e2e tests (Playwright across both apps)
pnpm build # Build all packages (Turborepo)
docker compose up -d # Start PostgreSQL
# Filtered commands
pnpm dev --filter @repo/web-next # Only Next.js app
pnpm dev --filter @repo/cms # Only CMS admin
pnpm dev --filter @repo/storybook # Only Storybook
pnpm typecheck --filter @repo/blog # Only blog feature
pnpm test --filter @repo/blog # Only blog unit/integration tests
```
---
## Per-Package Conventions
> These conventions reflect the post-Plan-8 (Lazar conformance) and post-Plan-9 (input/output unification) state.
> Canonical summary: `CLAUDE.md` § Key Conventions.
> Refactor logs: `docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md` (Plan 8) and `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md` (Plan 9).
> Decision records: `docs/decisions/adr-012-lazar-conformance.md` and `docs/decisions/adr-013-input-output-unification.md`.
### Source files use RELATIVE imports (not @/)
Inside `src/` files, import from sibling layers using relative paths (no `.js` extension — modern Node/Vitest resolves without it):
```typescript
// packages/blog/src/application/use-cases/get-articles.use-case.ts
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
import { BLOG_SYMBOLS } from "../../di/symbols";
import type { Article } from "../../entities/models/article";
```
Entity models live at `entities/models/<x>.ts`; domain errors at `entities/errors/<domain>.ts`; the shared `InputParseError` at `entities/errors/common.ts`.
Mock siblings use the `.mock.ts` suffix (`<x>.repository.mock.ts`); real repository impls drop the `Payload` prefix (`articles.repository.ts`); interface filenames are dot-separated (`articles.repository.interface.ts`).
This keeps source code portable and avoids circular alias issues.
### Test files use @/ alias
Test files (`*.test.ts`) use the `@/` alias to import from `src/`:
```typescript
// packages/blog/src/application/use-cases/get-articles.use-case.test.ts
import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case";
```
### vitest.config.ts MUST declare @/ alias
Every package's `vitest.config.ts` must define the alias:
```typescript
import path from "path";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { environment: "node", globals: true },
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});
```
### tsconfig.json rootDir = "."
TypeScript configs must set `"rootDir": "."` to allow both `src/` and test files to coexist:
```json
{
"extends": "@repo/core-typescript/base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "dist"
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}
```
### Use cases own input + output schemas (Plan 9, R1R5)
Every use-case file exports its Zod schemas and inferred types. The use case body validates its output before returning — a misbehaving repository fails loudly at the layer that owns the contract.
```typescript
// packages/blog/src/application/use-cases/get-articles.use-case.ts
import { z } from "zod";
import { articleSchema } from "../../entities/models/article";
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
// ── Input ────────────────────────────────────────────────────────────────
export const getArticlesInputSchema = z
.object({ status: z.string().optional(), limit: z.number().int().optional() })
.strict();
export type GetArticlesInput = z.infer<typeof getArticlesInputSchema>;
// ── Output ───────────────────────────────────────────────────────────────
export const getArticlesOutputSchema = z.array(articleSchema);
export type GetArticlesOutput = z.infer<typeof getArticlesOutputSchema>;
// ── Use case ─────────────────────────────────────────────────────────────
export type IGetArticlesUseCase = ReturnType<typeof getArticlesUseCase>;
export const getArticlesUseCase =
(articlesRepository: IArticlesRepository) =>
async (input: GetArticlesInput): Promise<GetArticlesOutput> => {
const result = await articlesRepository.getArticles(input);
return getArticlesOutputSchema.parse(result);
};
```
Void-input use cases use `z.object({}).strict()` and accept `_input: XInput`. Void-output use cases (e.g. `signOutUseCase`, `deleteMediaUseCase`) export only `xInputSchema` — no `xOutputSchema`.
Tests inject mocks directly — no container rebinding:
```typescript
const repo = new MockArticlesRepository([]);
const useCase = getArticlesUseCase(repo);
const articles = await useCase({ status: "published" });
```
### Controllers receive `unknown` + presenter (Plan 9, R7R12)
Controllers `safeParse(xInputSchema)` from the use-case file and throw `InputParseError` on failure. Every non-void controller defines a top-level `function presenter(value: XOutput)` and returns `Promise<ReturnType<typeof presenter>>`. Identity is fine — `return value` — but the function form is always present so adding a transform later is a one-line edit.
```typescript
// packages/blog/src/interface-adapters/controllers/get-articles.controller.ts
import { InputParseError } from "../../entities/errors/common";
import {
getArticlesInputSchema,
type GetArticlesOutput,
type IGetArticlesUseCase,
} from "../../application/use-cases/get-articles.use-case";
function presenter(value: GetArticlesOutput) {
return value;
}
export type IGetArticlesController = ReturnType<typeof getArticlesController>;
export const getArticlesController =
(getArticlesUseCase: IGetArticlesUseCase) =>
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = getArticlesInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid input", { cause: parsed.error });
}
return presenter(await getArticlesUseCase(parsed.data));
};
```
Void controllers (e.g. `signOutController`, `deleteMediaController`) return `Promise<void>` and skip the presenter entirely. One controller file per use case — no multi-method controller files.
DI binds each factory with `.toDynamicValue()`:
```typescript
bind<IGetArticlesUseCase>(BLOG_SYMBOLS.IGetArticlesUseCase)
.toDynamicValue((ctx) => getArticlesUseCase(ctx.container.get(BLOG_SYMBOLS.IArticlesRepository)));
```
### Feature-scoped tRPC error mapping (Plan 9, R13R17)
Each feature owns `integrations/api/procedures.ts` that wires domain errors to tRPC codes. `core-shared` provides the `defineErrorMiddleware` factory but never enumerates feature error classes.
```typescript
// packages/blog/src/integrations/api/procedures.ts
import { t } from "@repo/core-shared/trpc/init";
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
import { ArticleNotFoundError } from "../../entities/errors/article";
import { InputParseError } from "../../entities/errors/common";
export const blogProcedure = t.procedure.use(
defineErrorMiddleware([
[InputParseError, "BAD_REQUEST"],
[ArticleNotFoundError, "NOT_FOUND"],
]),
);
```
The router then uses `blogProcedure.input(xInputSchema)` for every procedure — schemas are imported from the use-case file, never redefined inline. Unmapped errors still surface as `TRPCError(code: INTERNAL_SERVER_ERROR)`; the original domain error is preserved as `.cause`.
### Per-feature public-API surface (Plan 9, R18R21)
Each feature package exposes exactly these subpath exports:
| Subpath | What it exports | Who consumes |
|---|---|---|
| `.` (root) | Contracts only: types, errors, schemas, `IUseCase` / `IController` aliases, router type, constants | Any consumer |
| `./ui` | Query builders (`queryOptions`), UI components | App packages |
| `./api` | tRPC router (`xRouter` + `XRouter` type) | `@repo/core-api` only |
| `./cms` | Payload collections | `@repo/core-cms` only |
| `./di/bind-production` | App boot side-effect — swaps mock for real Payload impl | App packages only |
| `./di/bind-dev-seed` | App boot side-effect — swaps empty mock for populated mock | App packages, storybook |
Apps import schemas/types from `@repo/<feature>` (root) and React Query builders from `@repo/<feature>/ui`. Deep source paths are not accessible — the `exports` map enforces this.
### Payload-backed features use constructor injection
Feature packages that need Payload receive the `SanitizedConfig` via constructor, not via `@repo/core-cms` dependency:
```typescript
// packages/blog/src/infrastructure/repositories/articles.repository.ts
@injectable()
export class ArticlesRepository implements IArticlesRepository {
constructor(private config: SanitizedConfig) {}
async getArticles(options?: { status?: string; limit?: number }): Promise<Article[]> {
const payload = await getPayload({ config: this.config });
// ...
}
}
```
Class names carry no `Payload` prefix — `ArticlesRepository`, `PagesRepository`, `HeaderRepository`, etc. The config comes from the app at boot time (see below).
### Apps call `bindAll()` per feature at boot
Each app (`web-next`, `web-tanstack`, `cms`) imports both binders per feature and uses a small dispatcher (`bindAll()`) that picks based on environment:
- `USE_DEV_SEED === "true"` → dev seed (explicit override; works in any `NODE_ENV`)
- `NODE_ENV === "production"` → production (real Payload)
- otherwise → dev seed (developer default; `pnpm dev` boots without Payload)
```typescript
// apps/web-next/src/server/bind-production.ts
import { bindProductionBlog } from "@repo/blog/di/bind-production";
import { bindProductionAuth } from "@repo/auth/di/bind-production";
import { bindProductionMarketingPages } from "@repo/marketing-pages/di/bind-production";
import { bindProductionNavigation } from "@repo/navigation/di/bind-production";
import { bindProductionMedia } from "@repo/media/di/bind-production";
import { bindDevSeedBlog } from "@repo/blog/di/bind-dev-seed";
import { bindDevSeedAuth } from "@repo/auth/di/bind-dev-seed";
import { bindDevSeedMarketingPages } from "@repo/marketing-pages/di/bind-dev-seed";
import { bindDevSeedNavigation } from "@repo/navigation/di/bind-dev-seed";
import { bindDevSeedMedia } from "@repo/media/di/bind-dev-seed";
import config from "@repo/core-cms";
export async function bindAll(): Promise<void> {
if (process.env.USE_DEV_SEED === "true") return bindAllDevSeed();
if (process.env.NODE_ENV === "production") return bindAllProduction();
return bindAllDevSeed();
}
export async function bindAllProduction(): Promise<void> {
const resolvedConfig = await config;
bindProductionAuth(resolvedConfig);
bindProductionBlog(resolvedConfig);
bindProductionMarketingPages(resolvedConfig);
bindProductionNavigation(resolvedConfig);
bindProductionMedia(resolvedConfig);
}
```
Actual function names: `bindProductionAuth`, `bindProductionBlog`, `bindProductionMarketingPages`, `bindProductionNavigation`, `bindProductionMedia`.
---
## Specification & Guides
- **Vertical Feature Spec** — `docs/architecture/vertical-feature-spec.md` — full design, rationale, decision log
- **Architecture Overview** — `docs/architecture/overview.md` — package responsibilities, data flow
- **Dependency Flow** — `docs/architecture/dependency-flow.md` — allowed directions and composition pattern
- **Adding a Feature Guide** — `docs/guides/adding-a-feature.md` — step-by-step new feature walkthrough
- **Testing Strategy** — `docs/guides/testing-strategy.md` — test placement, Vitest per-package, Playwright e2e
- **TDD Workflow** — `docs/guides/tdd-workflow.md` — red-green-refactor cycle, mocking decision tree, coverage targets
Per-package documentation lives in each `AGENTS.md`:
- `packages/core-shared/AGENTS.md`
- `packages/core-api/AGENTS.md`, `core-cms/AGENTS.md`, `core-trpc/AGENTS.md`
- `packages/core-ui/AGENTS.md`
- `packages/auth/AGENTS.md`, `blog/AGENTS.md`, `media/AGENTS.md`, `marketing-pages/AGENTS.md`, `navigation/AGENTS.md`
- `packages/core-eslint/AGENTS.md`, `core-typescript/AGENTS.md`, `core-testing/AGENTS.md`
- `apps/cms/AGENTS.md`, `web-next/AGENTS.md`, `web-tanstack/AGENTS.md`, `storybook/AGENTS.md`