docs(agents): root AGENTS.md updated for Plan 8 + Plan 9 conventions

Per-Package Conventions now reflect post-Plan-9 reality:
- Source-file relative-import example uses entities/models/<x> path
- New subsections for use-case schemas (R1–R5), controller presenter
  (R7–R12), feature-scoped tRPC error mapping (R13–R17), and the
  per-feature public-API split (R18–R21)
- bindProduction* + repository class examples align with the post-Plan-8
  naming (no Payload prefix, dropped .js extensions in import samples)

Refactor log doc-update checklist: AGENTS.md (root) item ticked.
This commit is contained in:
2026-05-06 16:45:29 +02:00
parent ef2b8e300e
commit 9c9d01b9f6

179
AGENTS.md
View File

@@ -107,17 +107,25 @@ pnpm test --filter @repo/blog # Only blog unit/integration tests
## Per-Package Conventions ## 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 @/) ### Source files use RELATIVE imports (not @/)
Inside `src/` files, import from sibling layers using relative paths: Inside `src/` files, import from sibling layers using relative paths (no `.js` extension — modern Node/Vitest resolves without it):
```typescript ```typescript
// packages/blog/src/application/use-cases/get-article.use-case.ts // packages/blog/src/application/use-cases/get-articles.use-case.ts
import type { IArticlesRepository } from "../repositories/articles.repository.interface.js"; import type { IArticlesRepository } from "../repositories/articles.repository.interface";
import { ARTICLES_REPOSITORY } from "../../di/symbols.js"; import { BLOG_SYMBOLS } from "../../di/symbols";
import type { Article } from "../../entities/article.js"; 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. This keeps source code portable and avoids circular alias issues.
### Test files use @/ alias ### Test files use @/ alias
@@ -125,8 +133,8 @@ This keeps source code portable and avoids circular alias issues.
Test files (`*.test.ts`) use the `@/` alias to import from `src/`: Test files (`*.test.ts`) use the `@/` alias to import from `src/`:
```typescript ```typescript
// packages/blog/src/application/use-cases/get-article.use-case.test.ts // packages/blog/src/application/use-cases/get-articles.use-case.test.ts
import { getArticleUseCase } from "@/application/use-cases/get-article.use-case.js"; import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case";
``` ```
### vitest.config.ts MUST declare @/ alias ### vitest.config.ts MUST declare @/ alias
@@ -163,40 +171,165 @@ TypeScript configs must set `"rootDir": "."` to allow both `src/` and test files
} }
``` ```
### Payload-backed features use constructor injection ### Use cases own input + output schemas (Plan 9, R1R5)
Feature packages that need Payload (e.g., `@repo/blog/infrastructure/repositories/payload-articles.repository.ts`) receive the Payload config via constructor, not via `@repo/core-cms` dependency: 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 ```typescript
export class PayloadArticlesRepository implements IArticlesRepository { // packages/blog/src/application/use-cases/get-articles.use-case.ts
constructor(private config: Config) {} import { z } from "zod";
import { articleSchema } from "../../entities/models/article";
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
async getById(id: string): Promise<Article | null> { // ── 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 | App packages only |
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 }); const payload = await getPayload({ config: this.config });
return payload.findByID({ collection: "articles", id }); // ...
} }
} }
``` ```
The config comes from the app at boot time (see below). Class names carry no `Payload` prefix — `ArticlesRepository`, `PagesRepository`, `HeaderRepository`, etc. The config comes from the app at boot time (see below).
### Apps call `bindProduction*()` per feature at boot ### Apps call `bindProduction*()` per feature at boot
Each app (`web-next`, `web-tanstack`, `cms`) imports feature containers and binds production Payload repos at startup: Each app (`web-next`, `web-tanstack`, `cms`) imports feature `bindProduction*` functions and calls them at startup to swap mock implementations for Payload-backed ones:
```typescript ```typescript
// apps/web-next/src/app/layout.tsx (Next.js) // apps/web-next/src/server/bind-production.ts
import { bindProductionArticles } from "@repo/blog/di/bind-production"; import { bindProductionBlog } from "@repo/blog/di/bind-production";
import { bindProductionUsers } from "@repo/auth/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 { bindProductionMedia } from "@repo/media/di/bind-production";
import { payloadConfig } from "@repo/core-cms"; import config from "@repo/core-cms";
// At app boot: export async function bindAllProduction(): Promise<void> {
await bindProductionArticles(blogContainer, payloadConfig); const resolvedConfig = await config;
await bindProductionUsers(authContainer, payloadConfig); bindProductionAuth(resolvedConfig);
// ... etc for each feature bindProductionBlog(resolvedConfig);
bindProductionMarketingPages(resolvedConfig);
bindProductionNavigation(resolvedConfig);
bindProductionMedia(resolvedConfig);
}
``` ```
Actual function names: `bindProductionAuth`, `bindProductionBlog`, `bindProductionMarketingPages`, `bindProductionNavigation`, `bindProductionMedia`.
--- ---
## Specification & Guides ## Specification & Guides