|
| 1 | +# AGENTS.md |
| 2 | + |
| 3 | +Guidance for AI coding agents working in this repository. See |
| 4 | +<https://agents.md/> for the file convention. |
| 5 | + |
| 6 | +## Coding Guidelines |
| 7 | + |
| 8 | +TypeScript conventions for this project. Rules are normative: **MUST** = enforced, **PREFER** = default unless there's a reason. Each rule has a minimal example. Consistency with existing code beats any rule below. |
| 9 | + |
| 10 | +## Type safety |
| 11 | + |
| 12 | +- **MUST** enable `strict` in tsconfig, plus `noUncheckedIndexedAccess` and `noImplicitReturns`. |
| 13 | +- **MUST NOT** use `any`. Use `unknown` at boundaries, then narrow. |
| 14 | + ```ts |
| 15 | + // bad |
| 16 | + function parse(s: string): any { return JSON.parse(s); } |
| 17 | + // good |
| 18 | + function parse(s: string): unknown { return JSON.parse(s); } |
| 19 | + ``` |
| 20 | +- **MUST** annotate exported function signatures and public boundaries explicitly. Let inference handle locals. |
| 21 | +- **MUST** validate all external input (args, env, config, network, `JSON.parse`) at the boundary with a runtime schema (e.g. Zod), so static types match reality. |
| 22 | + ```ts |
| 23 | + const User = z.object({ id: z.string(), name: z.string() }); |
| 24 | + type User = z.infer<typeof User>; |
| 25 | + const user = User.parse(await res.json()); // throws on bad shape |
| 26 | + ``` |
| 27 | + |
| 28 | +## Modeling data — make illegal states unrepresentable |
| 29 | + |
| 30 | +- **PREFER** discriminated unions over objects with many optional fields. Tag with a literal field. |
| 31 | + ```ts |
| 32 | + // bad: { status: string; data?: T; error?: string } |
| 33 | + type Req<T> = |
| 34 | + | { status: "loading" } |
| 35 | + | { status: "success"; data: T } |
| 36 | + | { status: "error"; error: string }; |
| 37 | + ``` |
| 38 | +- **MUST** use exhaustive `switch` with a `never` guard on unions, so adding a variant is a compile error. |
| 39 | + ```ts |
| 40 | + default: return assertNever(x); // assertNever(x: never): never |
| 41 | + ``` |
| 42 | +- **PREFER** precise literal unions over open primitives: `"admin" | "guest"`, not `string`. |
| 43 | +- **PREFER** `readonly` fields and `as const` by default; mutate deliberately. |
| 44 | +- **PREFER** branded types for values that share a runtime type but not a meaning (IDs, units). |
| 45 | + ```ts |
| 46 | + type UserId = string & { readonly __brand: "UserId" }; |
| 47 | + ``` |
| 48 | + |
| 49 | +## Errors and results |
| 50 | + |
| 51 | +- **PREFER** returning a `Result` for *expected* failures; reserve `throw` for programmer error and unrecoverable states. |
| 52 | + ```ts |
| 53 | + type Result<T, E = Error> = |
| 54 | + | { ok: true; value: T } |
| 55 | + | { ok: false; error: E }; |
| 56 | + ``` |
| 57 | +- **MUST** model a function's failure modes in its return type when they're part of the domain (not found, invalid, over-limit). |
| 58 | + |
| 59 | +## Functions |
| 60 | + |
| 61 | +- **MUST** keep functions single-responsibility and side-effect-free where possible. Push I/O and mutation to the edges; keep a pure core. |
| 62 | +- **MUST** give exported functions explicit return types. |
| 63 | +- **PREFER** a union parameter over overloads when the body is shared. |
| 64 | + |
| 65 | +## Classes — use only when justified |
| 66 | + |
| 67 | +- **MUST NOT** use a class when a function or type suffices. A class needs **identity + mutable state + an invariant to protect + behavior**. No state → function. No behavior → type. |
| 68 | + ```ts |
| 69 | + // bad: class Point { constructor(public x: number, public y: number) {} } |
| 70 | + type Point = { x: number; y: number }; |
| 71 | + ``` |
| 72 | +- **MUST** justify every class by an invariant. Ask "what can't happen to an instance?" If the answer is "nothing," it shouldn't be a class. Getters+setters that just wrap a field are not encapsulation. |
| 73 | +- **MUST** validate in a static factory when construction can fail; keep the constructor private and do no I/O in it. |
| 74 | + ```ts |
| 75 | + class Email { |
| 76 | + private constructor(readonly value: string) {} |
| 77 | + static create(s: string): Result<Email, "invalid"> { |
| 78 | + return s.includes("@") ? ok(new Email(s)) : err("invalid"); |
| 79 | + } |
| 80 | + } |
| 81 | + ``` |
| 82 | +- **MUST NOT** do work in constructors (no I/O, no network, no throwing for expected conditions). Use named async factories: `Config.load()`, `User.fromDatabase()`. |
| 83 | +- **MUST** use `#private` fields to protect invariants; expose read via getters, never a bare setter. |
| 84 | +- **PREFER** "tell, don't ask": give the object an intent, let it enforce its own rules — don't pull data out to decide, then push a change back in. |
| 85 | + ```ts |
| 86 | + cart.checkout(); // returns Result — not: if (cart.total < limit) cart.status = ... |
| 87 | + ``` |
| 88 | +- **MUST** keep the public surface minimal. Default methods to private; promote only when a caller needs them. |
| 89 | +- **PREFER** composition over inheritance. Reserve inheritance for genuinely different *behavior*; represent "kinds of" as a discriminated field built by different factories. |
| 90 | +- **PREFER** `readonly` fields; keep the few mutable ones obvious. |
| 91 | + |
| 92 | +## Dependency injection |
| 93 | + |
| 94 | +- **MUST** inject dependencies (clock, store, logger) rather than constructing them internally, so logic is testable with fakes. |
| 95 | +- **MUST** define interfaces from the *consumer's* view, listing only the methods it uses — not the full capability of what's passed in. |
| 96 | + ```ts |
| 97 | + interface SessionStore { save(id: string, exp: number): Promise<void>; } |
| 98 | + // not: constructor(private db: Database) // 40 methods, uses one |
| 99 | + ``` |
| 100 | + |
| 101 | +## File & project structure |
| 102 | + |
| 103 | +- **MUST** keep one primary export per file, and name the file after it: `Email.ts` → `Email`, `parseConfig.ts` → `parseConfig`. |
| 104 | +- **MUST NOT** create `utils.ts` / `helpers.ts` / `misc/` grab-bags. If you can't name the file after its contents, the contents don't belong together. |
| 105 | +- **MUST** enforce a one-directional dependency rule: delivery layer (CLI commands, HTTP routes) → core logic → nothing. Core **MUST NOT** import from I/O or delivery layers. |
| 106 | +- **PREFER** casing that signals the export kind, applied consistently: `PascalCase` for files exporting a class/type, `camelCase` for files exporting functions. (Uniform `kebab-case` is also acceptable — pick one project-wide.) |
| 107 | +- **PREFER** naming by domain role, not redundant technical suffix. Drop `Model`/`Impl`/`Class`; keep suffixes only when they disambiguate real roles (`User.ts`, `UserRepository.ts`, `UserSchema.ts`). |
| 108 | +- **MUST** keep `index.ts` as a re-export doorway only — no logic. |
| 109 | +- **MUST** avoid folder/file stutter: `config/loader.ts`, not `config/configLoader.ts`. |
| 110 | +- **MUST** colocate tests with a matching name: `planner.ts` → `planner.test.ts`. |
| 111 | + |
| 112 | +## Meta |
| 113 | + |
| 114 | +- **MUST** match an existing convention over introducing a "better" second one. Predictability is the point; a codebase with two conventions is worse than either alone. |
0 commit comments