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:
179
AGENTS.md
179
AGENTS.md
@@ -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, R1–R5)
|
||||||
|
|
||||||
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, R7–R12)
|
||||||
|
|
||||||
|
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, R13–R17)
|
||||||
|
|
||||||
|
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, R18–R21)
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user