create-warlock 4.1.10 → 4.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/esm/features/features-map.mjs +0 -12
- package/esm/features/features-map.mjs.map +1 -1
- package/esm/helpers/app.mjs +15 -3
- package/esm/helpers/app.mjs.map +1 -1
- package/package.json +1 -1
- package/templates/warlock/skills/api-design/SKILL.md +461 -0
- package/templates/warlock/skills/code-standards/SKILL.md +595 -0
- package/templates/warlock/skills/data-and-persistence/SKILL.md +330 -0
- package/templates/warlock/skills/git-workflow/SKILL.md +282 -0
- package/templates/warlock/skills/module-boundaries/SKILL.md +283 -0
- package/templates/warlock/skills/observability-and-resilience/SKILL.md +306 -0
- package/templates/warlock/skills/security-baseline/SKILL.md +352 -0
- package/templates/warlock/skills/testing-strategy/SKILL.md +323 -0
- package/templates/warlock/src/app/auth/services/forgot-password.service.ts +3 -5
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: code-standards
|
|
3
|
+
description: 'Project-level TypeScript code standards for apps built on Warlock.js — `interface` vs `type`, access modifiers, no `any`, no inline-duplicated unions, file-naming suffixes (`.contract.ts` / `.type.ts` / `.service.ts` / `.model.ts` / `.resource.ts` / `.spec.ts`), single-responsibility files, FP-factory + internal-class pattern, brace-every-control-block readability with blank lines between consecutive blocks, JSDoc on public surface and non-obvious logic, error classes extending framework errors, async/await + `Promise.all`, `undefined` over `null`, Vitest co-located, ESLint + Prettier. Triggers: writing/editing any `.ts` / `.tsx` file in this project; user asks "what are the code standards / style rules / conventions"; spawning a sub-agent that will write code; reviewing a diff for style issues; deciding `interface` vs `type`, when to use a class, where helpers live, how to name a file, or whether something needs JSDoc. Skip: pure-Markdown / JSON / YAML edits with no code change; runtime / behavior questions about a specific framework primitive — load the relevant `@warlock.js/<pkg>` skill instead; package-publishing or CI workflow questions.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Code Standards
|
|
7
|
+
|
|
8
|
+
**Status:** Stable
|
|
9
|
+
**Applies to:** All TypeScript in this project — services, models, resources, modules, app code, scripts.
|
|
10
|
+
|
|
11
|
+
> **Sub-agent rule:** Before writing any code, read this file end-to-end and apply every rule. When spawning a sub-agent that will write code, prepend: _"First, read `skills/code-standards/SKILL.md` and apply every rule."_
|
|
12
|
+
|
|
13
|
+
The bar is **senior-level clean code**. Not "it compiles" — code that the next person on this codebase can read in one pass.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 1. TypeScript
|
|
18
|
+
|
|
19
|
+
### 1.1 `interface` for contracts, `type` for data
|
|
20
|
+
|
|
21
|
+
- `interface` describes a **contract** — anything with methods, anything a class implements, anything that holds actions.
|
|
22
|
+
- `type` describes a **data shape** — plain objects, unions, tuples, mapped types, discriminated unions.
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
// ✅ contract — has behaviour
|
|
26
|
+
export interface PaymentGateway {
|
|
27
|
+
readonly name: string;
|
|
28
|
+
charge(amount: number, token: string): Promise<ChargeResult>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ✅ data shape
|
|
32
|
+
export type ChargeResult = {
|
|
33
|
+
id: string;
|
|
34
|
+
status: "succeeded" | "failed";
|
|
35
|
+
amount: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// ❌ don't use interface for plain data
|
|
39
|
+
export interface ChargeResult {
|
|
40
|
+
id: string;
|
|
41
|
+
status: "succeeded" | "failed";
|
|
42
|
+
amount: number;
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### 1.2 Access modifiers on every class member
|
|
47
|
+
|
|
48
|
+
Never omit `public` / `private` / `protected`. Explicit is faster to read than inferring.
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
// ✅
|
|
52
|
+
export class CartService {
|
|
53
|
+
public readonly currency: string;
|
|
54
|
+
private items: CartItem[] = [];
|
|
55
|
+
public add(item: CartItem) { /* ... */ }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ❌
|
|
59
|
+
export class CartService {
|
|
60
|
+
readonly currency: string; // missing public
|
|
61
|
+
items: CartItem[] = []; // missing private
|
|
62
|
+
add(item: CartItem) {} // missing public
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### 1.3 No `any`
|
|
67
|
+
|
|
68
|
+
Never. Use `unknown` when the type is genuinely unknown and narrow at the boundary. If you reach for `any`, you've skipped a design decision.
|
|
69
|
+
|
|
70
|
+
### 1.4 No inline-duplicated unions or literals
|
|
71
|
+
|
|
72
|
+
If a union appears in more than one place, extract it to a named exported type once.
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
// ✅
|
|
76
|
+
export type OrderStatus = "pending" | "paid" | "shipped" | "cancelled";
|
|
77
|
+
|
|
78
|
+
export type Order = { status: OrderStatus; /* ... */ };
|
|
79
|
+
export type OrderEvent = { status: OrderStatus; /* ... */ };
|
|
80
|
+
|
|
81
|
+
// ❌ inline duplication — changing the set means N edits and N chances to miss one
|
|
82
|
+
export type Order = { status: "pending" | "paid" | "shipped" | "cancelled"; /* ... */ };
|
|
83
|
+
export type OrderEvent = { status: "pending" | "paid" | "shipped" | "cancelled"; /* ... */ };
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### 1.5 Constrain generics
|
|
87
|
+
|
|
88
|
+
Open generics are almost always wrong. If `T` has a contract, declare it.
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
// ✅
|
|
92
|
+
export function publish<T extends DomainEvent>(event: T): void { /* ... */ }
|
|
93
|
+
|
|
94
|
+
// ❌
|
|
95
|
+
export function publish<T>(event: T): void { /* ... */ }
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### 1.6 Prefer `undefined` for "absent"
|
|
99
|
+
|
|
100
|
+
Use `undefined` everywhere — optional properties, optional parameters, "not found" returns. Optional chaining (`?.`) and default values (`??`) compose naturally with `undefined`. Use `null` **only** when an external API hands you `null` (e.g. a database driver, a third-party SDK) and convert at the boundary.
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
// ✅
|
|
104
|
+
export type User = { id: string; phone?: string };
|
|
105
|
+
function find(id: string): User | undefined { /* ... */ }
|
|
106
|
+
|
|
107
|
+
// ❌ mixed
|
|
108
|
+
export type User = { id: string; phone: string | null };
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## 2. Imports
|
|
114
|
+
|
|
115
|
+
### 2.1 Use the project's path aliases, not deep relatives
|
|
116
|
+
|
|
117
|
+
The project ships a `tsconfig.json` path alias for the app root — `app/*` → `src/app/*`. Use it for every cross-module import. If you see `../../../../something` in an import, you're bypassing it.
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
// ✅
|
|
121
|
+
import { listUsersService } from "app/users/services";
|
|
122
|
+
import { OrderStatus } from "app/orders/types/order.type";
|
|
123
|
+
|
|
124
|
+
// ❌
|
|
125
|
+
import { listUsersService } from "../../users/services";
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### 2.2 Barrel files — sub-folder level, not module level
|
|
129
|
+
|
|
130
|
+
Barrels (`index.ts`) make a module easier to consume **at the sub-folder level** — `services/`, `utils/`, `types/`. They become a problem when you put one at the **module root** and turn it into a giant re-export of everything inside.
|
|
131
|
+
|
|
132
|
+
The right shape:
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
✅ app/users/services/index.ts ← barrels each service file
|
|
136
|
+
app/users/services/list-users.service.ts
|
|
137
|
+
app/users/services/create-user.service.ts
|
|
138
|
+
app/users/utils/index.ts ← barrels each util
|
|
139
|
+
app/users/types/index.ts ← barrels each type
|
|
140
|
+
|
|
141
|
+
❌ app/users/index.ts ← module-root barrel re-exporting everything
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Imports stay short and intent-revealing:
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
// ✅ targeted — reader knows it's a service
|
|
148
|
+
import { listUsersService } from "app/users/services";
|
|
149
|
+
import { formatUserName } from "app/users/utils";
|
|
150
|
+
|
|
151
|
+
// ❌ everything-from-everywhere
|
|
152
|
+
import { listUsersService, formatUserName, User } from "app/users";
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The module-root barrel is the one that causes circular imports and tree-shaking misses. Sub-folder barrels are safe because they only group siblings of the same role.
|
|
156
|
+
|
|
157
|
+
### 2.3 No file extension in import paths
|
|
158
|
+
|
|
159
|
+
Always bare: `from "./order.service"`, never `from "./order.service.ts"` or `.js`.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## 3. File naming
|
|
164
|
+
|
|
165
|
+
| Suffix | Purpose | Example |
|
|
166
|
+
| -------------- | ------------------------------------ | ---------------------- |
|
|
167
|
+
| `.contract.ts` | Interface only (blueprint) | `payment.contract.ts` |
|
|
168
|
+
| `.type.ts` | Types only (data shapes) | `order.type.ts` |
|
|
169
|
+
| `.service.ts` | Service class | `cart.service.ts` |
|
|
170
|
+
| `.model.ts` | Domain model class | `user.model.ts` |
|
|
171
|
+
| `.resource.ts` | Output mapper (model → wire format) | `order.resource.ts` |
|
|
172
|
+
| `.spec.ts` | Co-located test for a sibling `.ts` | `cart.service.spec.ts` |
|
|
173
|
+
|
|
174
|
+
**One primary export per file.** If a `.contract.ts` also defines closely-related data types used only by that contract, that's fine — but the primary export is the interface.
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## 4. Code organization
|
|
179
|
+
|
|
180
|
+
### 4.1 One responsibility per file, class, and function
|
|
181
|
+
|
|
182
|
+
If the file's description needs an "and", split it. The same goes for classes and functions. A 300-line service that handles orders **and** sends emails **and** writes audit logs is three files.
|
|
183
|
+
|
|
184
|
+
**File-level SRP.** If a file has more than one top-level helper that isn't a direct implementation detail of the main export, extract helpers into a `utils/` folder — one helper per file. The main file imports from leaf paths.
|
|
185
|
+
|
|
186
|
+
```
|
|
187
|
+
❌ cart.service.ts holds CartService + calculateTax + formatLineItem + applyDiscount
|
|
188
|
+
✅ cart.service.ts holds CartService only
|
|
189
|
+
utils/calculate-tax.ts
|
|
190
|
+
utils/format-line-item.ts
|
|
191
|
+
utils/apply-discount.ts
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### 4.2 Public API is functional, classes are an implementation detail
|
|
195
|
+
|
|
196
|
+
The default shape of a public primitive is a **factory function returning a plain object**. Users of your service never call `new`.
|
|
197
|
+
|
|
198
|
+
Reach for a `class` in exactly three cases:
|
|
199
|
+
|
|
200
|
+
1. **Long-lived state across calls.** A client, a cache, a connection pool. Example: a `PaymentClient` that owns an HTTP connection and an auth token for its lifetime.
|
|
201
|
+
2. **Per-call state across phases.** A single operation has 4+ phases that share mutable state (intermediate results, accumulated errors, events). Instantiate fresh per call inside the factory; keep the class unexported.
|
|
202
|
+
3. **Immutable builder pattern.** A value type whose methods each return a new snapshot of itself. The class gives you prototype method sharing and `instanceof` narrowing. Still front it with a factory so callers never see `new`.
|
|
203
|
+
|
|
204
|
+
If state fits in local variables of a single function, **don't make a class** — a closure is cleaner.
|
|
205
|
+
|
|
206
|
+
```typescript
|
|
207
|
+
// ✅ public factory, internal class for per-call phases
|
|
208
|
+
export function placeOrder(config: OrderConfig) {
|
|
209
|
+
return {
|
|
210
|
+
async execute(input: OrderInput) {
|
|
211
|
+
return new PlacementRun(config, input).run();
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
class PlacementRun {
|
|
217
|
+
private items: ResolvedItem[] = [];
|
|
218
|
+
private total = 0;
|
|
219
|
+
public async run(): Promise<OrderResult> { /* ... */ }
|
|
220
|
+
private async validate(): Promise<void> { /* ... */ }
|
|
221
|
+
private async charge(): Promise<void> { /* ... */ }
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### 4.3 No single-file folders
|
|
226
|
+
|
|
227
|
+
Never create a folder to hold one file and an `index.ts` that re-exports it. Put the file in the parent.
|
|
228
|
+
|
|
229
|
+
```
|
|
230
|
+
❌ src/services/cart/cart.service.ts
|
|
231
|
+
src/services/cart/index.ts ← only re-exports
|
|
232
|
+
✅ src/services/cart.service.ts
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
A folder earns its keep at **two or more related files**.
|
|
236
|
+
|
|
237
|
+
### 4.4 Readability beats terseness
|
|
238
|
+
|
|
239
|
+
Code is read far more than it's written. Optimize for the reader.
|
|
240
|
+
|
|
241
|
+
- **Always brace.** Never single-line `if (x) doIt();`, never braceless bodies, never inline for-loop bodies. The one exception: a guard clause that ends the function — `if (!user) return;` is fine on a single line.
|
|
242
|
+
- **Full, descriptive identifiers.** No cryptic abbreviations. Write `systemPrompt`, `tripIndex`, `request`, `response`, `error`, `message`, `tool`. Not `sp`, `i`, `req`, `res`, `err`, `msg`, `t`. This includes arrow-function parameters — `.find((tool) => ...)`, never `.find((t) => ...)`. Local scope isn't a license to abbreviate — the reader has the **least** context locally.
|
|
243
|
+
- **One statement per line.** No chaining three actions onto a single `if`. No packing two branches into a ternary when `if/else` reads clearer.
|
|
244
|
+
- **Avoid deeply nested ternaries.** If a ternary wraps or has three branches, rewrite as `if/else`.
|
|
245
|
+
- **One class per file.** Always.
|
|
246
|
+
|
|
247
|
+
### 4.5 Blank lines separate consecutive logical blocks
|
|
248
|
+
|
|
249
|
+
Consecutive control structures (`if`, `for`, `while`, `try`, `switch`) **are not a wall** — each one is its own logical step. Put a blank line **between** them.
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
// ❌ wall — hard to scan
|
|
253
|
+
if (!user) {
|
|
254
|
+
return notFound();
|
|
255
|
+
}
|
|
256
|
+
if (!user.verified) {
|
|
257
|
+
return forbidden();
|
|
258
|
+
}
|
|
259
|
+
for (const item of items) {
|
|
260
|
+
await process(item);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ✅ each block breathes
|
|
264
|
+
if (!user) {
|
|
265
|
+
return notFound();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (!user.verified) {
|
|
269
|
+
return forbidden();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
for (const item of items) {
|
|
273
|
+
await process(item);
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Same rule inside a function — put a blank line before a `return` and between phases that "finish a thought" (e.g. a `.push()` that ends an accumulation step before the next phase begins). Don't over-apply it inside a single block — code inside one `if` body usually doesn't need internal blank lines.
|
|
278
|
+
|
|
279
|
+
### 4.6 Comments — default to none
|
|
280
|
+
|
|
281
|
+
Identifiers carry the meaning. Add a comment only when the **why** is non-obvious: a hidden constraint, a subtle invariant, a workaround for a bug elsewhere. **Never** describe what the code does — if the reader can't see it from the code, the code is the problem.
|
|
282
|
+
|
|
283
|
+
```typescript
|
|
284
|
+
// ❌ noise
|
|
285
|
+
// Loop over items
|
|
286
|
+
for (const item of items) { /* ... */ }
|
|
287
|
+
|
|
288
|
+
// ✅ the WHY isn't obvious from the code
|
|
289
|
+
// Stripe returns idempotency_key on retry but not on first call —
|
|
290
|
+
// fall back to the order id so downstream dedup still works.
|
|
291
|
+
const key = response.idempotency_key ?? order.id;
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### 4.7 Pure helpers live outside classes
|
|
295
|
+
|
|
296
|
+
If a function doesn't touch the class's state and isn't part of the class's interface, it's not a method. Move it to a sibling utility file. Methods are for behaviour that operates on the instance.
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
## 5. Errors
|
|
301
|
+
|
|
302
|
+
### 5.1 Extend framework error classes
|
|
303
|
+
|
|
304
|
+
Never throw bare `Error`. Every project error extends a framework error so the dispatcher, logger, and HTTP layer can classify it correctly. Match the closest base — `HttpError` for request-failure cases, a domain root for business-rule violations.
|
|
305
|
+
|
|
306
|
+
Define the class once, then throw it from the call site by name — the type itself carries the meaning.
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
309
|
+
// ✅ define
|
|
310
|
+
import { HttpError } from "@warlock.js/core";
|
|
311
|
+
|
|
312
|
+
export class CartLockedError extends HttpError {
|
|
313
|
+
public readonly statusCode = 409;
|
|
314
|
+
public constructor(public readonly cartId: string) {
|
|
315
|
+
super(`Cart ${cartId} is locked`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ✅ throw — the class name is the error code, the message stays useful
|
|
320
|
+
import { CartLockedError } from "app/cart/errors/cart-locked.error";
|
|
321
|
+
|
|
322
|
+
if (cart.isLocked) {
|
|
323
|
+
throw new CartLockedError(cart.id);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ❌ bare Error — no status code, no type to catch, no classification
|
|
327
|
+
throw new Error("cart locked");
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### 5.2 Handle at boundaries, not in the middle
|
|
331
|
+
|
|
332
|
+
Wrap a `try / catch` only at system boundaries — an HTTP handler, a queue consumer, a CLI entry point — or when you're **enriching** the error with context before re-throwing. Internal function calls should let errors propagate; the boundary catches once and decides the response.
|
|
333
|
+
|
|
334
|
+
### 5.3 Never swallow
|
|
335
|
+
|
|
336
|
+
A bare `catch {}` or `catch (e) { console.log(e); }` is a bug, not error handling. If you can't recover, re-throw. If you can recover, log the cause and continue with an explicit fallback value.
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
## 6. Async
|
|
341
|
+
|
|
342
|
+
### 6.1 Parallelize independent awaits
|
|
343
|
+
|
|
344
|
+
Two independent async calls in a row is two round-trips. Use `Promise.all`.
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
// ❌ serial — twice the latency for no reason
|
|
348
|
+
const user = await loadUser(id);
|
|
349
|
+
const orders = await loadOrders(id);
|
|
350
|
+
|
|
351
|
+
// ✅ parallel
|
|
352
|
+
const [user, orders] = await Promise.all([loadUser(id), loadOrders(id)]);
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### 6.2 Pass `AbortSignal` where supported
|
|
356
|
+
|
|
357
|
+
If a framework call (HTTP, AI, queue) accepts a signal, plumb one through. Long-running operations that ignore cancellation leak resources on user disconnect.
|
|
358
|
+
|
|
359
|
+
### 6.3 No floating promises
|
|
360
|
+
|
|
361
|
+
Every promise is either awaited, returned, or explicitly `.catch()`'d. A "fire and forget" call that throws will crash the process or vanish silently — both bad.
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
// ❌
|
|
365
|
+
sendWelcomeEmail(user); // floating — if it rejects, you'll never know
|
|
366
|
+
|
|
367
|
+
// ✅
|
|
368
|
+
await sendWelcomeEmail(user);
|
|
369
|
+
|
|
370
|
+
// ✅ deliberate fire-and-forget with logged failure
|
|
371
|
+
sendWelcomeEmail(user).catch((error) => logger.error("welcome-email-failed", error));
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
---
|
|
375
|
+
|
|
376
|
+
## 7. Documentation (JSDoc)
|
|
377
|
+
|
|
378
|
+
Project-level JSDoc is **lighter** than framework-package JSDoc — the audience already knows the codebase. JSDoc earns its place when it tells the reader something the code can't.
|
|
379
|
+
|
|
380
|
+
### 7.1 Required
|
|
381
|
+
|
|
382
|
+
- **Every exported function, class, and type that crosses a module boundary.** One-line purpose, plus `@example` if the call shape isn't obvious.
|
|
383
|
+
- **Every class with more than one public method.** A class-level block stating the role and what it owns / doesn't own. Don't make the next reader infer the architecture from method bodies.
|
|
384
|
+
- **Any function whose logic isn't obvious from its name.** Regex, state machines, multi-branch business rules, performance-driven code, anything with a non-obvious constraint.
|
|
385
|
+
|
|
386
|
+
### 7.2 Not required
|
|
387
|
+
|
|
388
|
+
- Private and protected methods, unless the **why** is non-obvious.
|
|
389
|
+
- Trivial getters, setters, and one-line helpers.
|
|
390
|
+
- Internal functions whose name and signature already say everything.
|
|
391
|
+
|
|
392
|
+
### 7.3 Purpose, not restatement
|
|
393
|
+
|
|
394
|
+
The first line states the **role** — what this thing is for, not what its name already says.
|
|
395
|
+
|
|
396
|
+
```typescript
|
|
397
|
+
// ❌ adds nothing
|
|
398
|
+
/** Builds the message list. */
|
|
399
|
+
private buildMessages(): void { /* ... */ }
|
|
400
|
+
|
|
401
|
+
// ✅ explains the role and the position in the flow
|
|
402
|
+
/**
|
|
403
|
+
* Resolve the system prompt, prepend history, append the user input.
|
|
404
|
+
* Produces the initial messages array sent on the first trip. Runs once
|
|
405
|
+
* per execution before the trip loop starts.
|
|
406
|
+
*/
|
|
407
|
+
private buildMessages(): void { /* ... */ }
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
---
|
|
411
|
+
|
|
412
|
+
## 8. Logging
|
|
413
|
+
|
|
414
|
+
Use the `log` singleton from `@warlock.js/logger`. Never `console.log` in committed code.
|
|
415
|
+
|
|
416
|
+
Every call has the same shape — **`module`**, **`action`**, **`message`**, optional **`context`**:
|
|
417
|
+
|
|
418
|
+
```typescript
|
|
419
|
+
import { log } from "@warlock.js/logger";
|
|
420
|
+
|
|
421
|
+
// ✅ four arguments — module, action, message, context
|
|
422
|
+
log.success("orders", "order-placed", "Order has been created successfully", {
|
|
423
|
+
orderId,
|
|
424
|
+
userId,
|
|
425
|
+
total,
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
log.error("payments", "charge", new Error("Card declined"), { userId, attemptId });
|
|
429
|
+
|
|
430
|
+
// ❌ interpolated string — unstructured, can't be queried
|
|
431
|
+
log.info(`Order ${orderId} placed for user ${userId} totaling ${total}`);
|
|
432
|
+
|
|
433
|
+
// ❌ wrong arity — missing module/action
|
|
434
|
+
log.info("order-placed", { orderId, userId, total });
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
**Five levels** — `debug`, `info`, `warn`, `error`, `success`. Pick the one that matches the event semantics. `success` is its own level (not a flavour of `info`) — use it for explicit completions a human cares about.
|
|
438
|
+
|
|
439
|
+
**Other rules:**
|
|
440
|
+
|
|
441
|
+
- Log at boundaries (request in, request out, error caught) — not every internal step.
|
|
442
|
+
- **Redact at the source.** Tokens, passwords, credit card numbers, personal identifiers never appear in log context. Configure redaction once via `log.configure({ redact: ... })` — see `@warlock.js/logger/redact-sensitive-log-fields/SKILL.md`.
|
|
443
|
+
- `module` is the area of the app (`"orders"`, `"auth"`, `"payments"`), `action` is the verb-noun event (`"order-placed"`, `"login-failed"`, `"refund-issued"`). Both are searchable indexes — keep them consistent across the codebase.
|
|
444
|
+
|
|
445
|
+
---
|
|
446
|
+
|
|
447
|
+
## 9. Non-negotiable defaults
|
|
448
|
+
|
|
449
|
+
Senior-team baseline rules. Each one prevents a real class of bug or incident. Each one is short because each one is unconditional. Deeper coverage lives in sibling skills — `skills/security-baseline/`, `skills/data-and-persistence/`, `skills/api-design/`, `skills/observability-and-resilience/`.
|
|
450
|
+
|
|
451
|
+
### 9.1 Validate untrusted input at the boundary
|
|
452
|
+
|
|
453
|
+
Every input from outside the process — request body, query params, headers, file uploads, env vars, queue messages, webhook payloads — is validated against a schema before any code consumes it. TypeScript types are runtime lies; only a validator gives you actual guarantees.
|
|
454
|
+
|
|
455
|
+
Use `@warlock.js/seal`. Schemas live in the module's `schema/` folder, exported alongside the inferred type.
|
|
456
|
+
|
|
457
|
+
```typescript
|
|
458
|
+
// ✅ schema + inferred type — colocated, single source
|
|
459
|
+
import { v, type Infer } from "@warlock.js/seal";
|
|
460
|
+
|
|
461
|
+
export const createOrderSchema = v.object({
|
|
462
|
+
cartId: v.string(),
|
|
463
|
+
paymentToken: v.string(),
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
export type CreateOrderSchema = Infer<typeof createOrderSchema>;
|
|
467
|
+
|
|
468
|
+
// ❌ trust the type — no runtime check, any payload passes
|
|
469
|
+
const input = request.body as CreateOrderSchema;
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
Validation runs once at the route boundary; everything inward consumes the typed result. See `skills/security-baseline/SKILL.md` for the full input-trust model.
|
|
473
|
+
|
|
474
|
+
### 9.2 Money is integer minor units, time is UTC at rest
|
|
475
|
+
|
|
476
|
+
- **Money:** integer cents (or the currency's smallest unit). Never a `number` field for prices, totals, balances, refunds. Floats break the first time you sum them.
|
|
477
|
+
- **Time:** store UTC in the database, work in UTC across services, convert to the user's timezone **only** at the response layer.
|
|
478
|
+
|
|
479
|
+
```typescript
|
|
480
|
+
// ✅
|
|
481
|
+
type Order = { totalCents: number; placedAtUtc: Date };
|
|
482
|
+
|
|
483
|
+
// ❌
|
|
484
|
+
type Order = { total: number; placedAt: string }; // float money, ambiguous tz
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
See `skills/data-and-persistence/SKILL.md`.
|
|
488
|
+
|
|
489
|
+
### 9.3 IDs in URLs are opaque
|
|
490
|
+
|
|
491
|
+
Use UUIDs or nanoids for any identifier that crosses a process boundary — URLs, response bodies, log lines, webhooks. Never expose sequential primary keys: they leak business volume (anyone can tell `/orders/1042` means you've placed 1042 orders) and invite enumeration attacks.
|
|
492
|
+
|
|
493
|
+
Sequential keys are fine as internal database primary keys. They just don't leave the database.
|
|
494
|
+
|
|
495
|
+
### 9.4 Every external call has a timeout
|
|
496
|
+
|
|
497
|
+
HTTP, AI, queue, database driver — every call to something outside this process has an explicit timeout. No timeout = a slow dependency takes down the whole service.
|
|
498
|
+
|
|
499
|
+
`@warlock.js/http` accepts `timeout` per request and `timeout` per `Http` instance. Set a default at the instance level; override per request when a specific endpoint needs more.
|
|
500
|
+
|
|
501
|
+
See `skills/observability-and-resilience/SKILL.md`.
|
|
502
|
+
|
|
503
|
+
### 9.5 Idempotency keys for external side effects
|
|
504
|
+
|
|
505
|
+
Payments, emails, webhook deliveries, third-party charges — any operation whose accidental retry could double-charge a customer or send a duplicate notice needs an idempotency key. Generate the key at the call site, store the result keyed by the idempotency key, return the stored result on retry.
|
|
506
|
+
|
|
507
|
+
This is the difference between "the network blipped" and "the customer got billed twice".
|
|
508
|
+
|
|
509
|
+
### 9.6 One response envelope across the project
|
|
510
|
+
|
|
511
|
+
Every HTTP response in the project uses the same shape — success and failure both. Pick once, document in `skills/api-design/SKILL.md`, use everywhere. The moment this rule lands, the frontend stops writing per-endpoint special cases.
|
|
512
|
+
|
|
513
|
+
### 9.7 Config validated at boot, no scattered `process.env`
|
|
514
|
+
|
|
515
|
+
Every environment variable is read through `env()` inside a `src/config/*.ts` file — one typed config object per concern (`app`, `database`, `mail`, …), each with a sensible default. App code never touches `process.env.X`; it reads resolved values through the `config` accessor from `@warlock.js/core`.
|
|
516
|
+
|
|
517
|
+
```typescript
|
|
518
|
+
// ✅ src/config/app.ts — env() with defaults, typed config object
|
|
519
|
+
import { env, type AppConfigurations } from "@warlock.js/core";
|
|
520
|
+
|
|
521
|
+
const appConfig: AppConfigurations = {
|
|
522
|
+
appName: env("APP_NAME", "Warlock"),
|
|
523
|
+
baseUrl: env("BASE_URL", "http://localhost:2030"),
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
export default appConfig;
|
|
527
|
+
|
|
528
|
+
// ✅ read a resolved value anywhere via the config accessor
|
|
529
|
+
import { config } from "@warlock.js/core";
|
|
530
|
+
|
|
531
|
+
const appName = config.key<string>("app.appName");
|
|
532
|
+
const database = config.get("database"); // whole typed group
|
|
533
|
+
|
|
534
|
+
// ❌ scattered, unvalidated process.env reads in app code
|
|
535
|
+
const url = process.env.BASE_URL;
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
A secret with no safe default (a payment key, a signing secret) should fail loudly at boot — give the config file an explicit guard rather than letting a missing value surface three hours in on the first request.
|
|
539
|
+
|
|
540
|
+
---
|
|
541
|
+
|
|
542
|
+
## 10. Testing
|
|
543
|
+
|
|
544
|
+
- **Vitest**, co-located: `cart.service.ts` → `cart.service.spec.ts` in the same folder.
|
|
545
|
+
- **Test the public surface.** If a private method needs testing, the public method that calls it does — test through it. If you can't reach the behaviour from outside, it shouldn't exist.
|
|
546
|
+
- **Arrange / Act / Assert** structure per test. Blank lines between phases. One assertion target per test where practical.
|
|
547
|
+
- **Mock at the boundary.** Use `MockSDK` from `@warlock.js/ai` for AI calls. Mock HTTP at the HTTP client level, not deep inside services.
|
|
548
|
+
- **Don't snapshot large objects.** Assert specific fields. A snapshot diff with 200 lines of noise hides the regression you actually care about.
|
|
549
|
+
|
|
550
|
+
---
|
|
551
|
+
|
|
552
|
+
## 11. Tooling
|
|
553
|
+
|
|
554
|
+
Ship a `.eslintrc` with at least these rules on:
|
|
555
|
+
|
|
556
|
+
- `@typescript-eslint/explicit-member-accessibility` — enforces § 1.2
|
|
557
|
+
- `@typescript-eslint/no-explicit-any` — enforces § 1.3
|
|
558
|
+
- `@typescript-eslint/consistent-type-definitions` — supports § 1.1
|
|
559
|
+
- `@typescript-eslint/no-floating-promises` — enforces § 6.3
|
|
560
|
+
- `@typescript-eslint/await-thenable`
|
|
561
|
+
- `curly` — enforces braces (§ 4.4)
|
|
562
|
+
- `no-console` — enforces § 8
|
|
563
|
+
|
|
564
|
+
Match the project's `.prettierrc` exactly. If two configs are present and disagree, the one closer to the file wins (Prettier's resolution order). Don't fight the formatter — configure it, then trust it.
|
|
565
|
+
|
|
566
|
+
---
|
|
567
|
+
|
|
568
|
+
## 12. Review checklist
|
|
569
|
+
|
|
570
|
+
Before any change ships:
|
|
571
|
+
|
|
572
|
+
**Craft**
|
|
573
|
+
- [ ] All class members have access modifiers
|
|
574
|
+
- [ ] No `any`
|
|
575
|
+
- [ ] No inline-duplicated unions or literal sets
|
|
576
|
+
- [ ] `interface` for contracts, `type` for data
|
|
577
|
+
- [ ] File suffix matches content
|
|
578
|
+
- [ ] Imports use project path aliases, not deep relatives
|
|
579
|
+
- [ ] No module-root barrels, no single-file folders
|
|
580
|
+
- [ ] Blank lines separate consecutive control blocks
|
|
581
|
+
- [ ] No floating promises, no swallowed catches
|
|
582
|
+
- [ ] Errors extend framework error classes
|
|
583
|
+
- [ ] JSDoc on every public export and every multi-method class
|
|
584
|
+
- [ ] No `console.log`, `log.<level>(module, action, message, context)` signature used correctly
|
|
585
|
+
- [ ] Tests co-located, public-surface-driven
|
|
586
|
+
- [ ] `tsc --noEmit` and `eslint` pass clean
|
|
587
|
+
|
|
588
|
+
**Non-negotiable defaults**
|
|
589
|
+
- [ ] Every untrusted input has a `@warlock.js/seal` schema at the boundary
|
|
590
|
+
- [ ] Money fields are integer minor units, time stored as UTC
|
|
591
|
+
- [ ] IDs exposed in URLs are opaque (UUID / nanoid)
|
|
592
|
+
- [ ] Every external call (HTTP, AI, queue, DB) has an explicit timeout
|
|
593
|
+
- [ ] External side effects (payments, emails, webhooks) use idempotency keys
|
|
594
|
+
- [ ] Response shape matches the project's canonical envelope
|
|
595
|
+
- [ ] No `process.env.X` outside the config module
|