@tsfpp/agents 1.2.3 → 1.3.1

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.
@@ -1,89 +1,166 @@
1
- ---
2
- applyTo: "{**/routes/**,**/handlers/**,**/api/**}/*.ts"
3
- ---
4
-
5
- # TSF++ API rules
6
-
7
- Full standard: `node_modules/@tsfpp/standard/spec/API_CODING_STANDARD.md`
8
- Boundary API: `node_modules/@tsfpp/boundary/README.md`
9
- Extends: tsfpp-base.instructions.md (all base rules apply)
10
-
11
- ## Handler shape
12
-
13
- Handlers are thin. The only permitted steps are: parse → call use-case → map response.
1
+ # TSF++ coding standard — v1.1.0
2
+
3
+ This repository follows the TSF++ coding standard.
4
+ Canonical source: `node_modules/@tsfpp/standard/spec/CODING_STANDARD.md` — when this file and the standard conflict, the standard wins.
5
+
6
+ ## Axioms (non-negotiable)
7
+
8
+ 1. Referential transparency is the norm; effects are reified as `Promise<Result<T, E>>`.
9
+ 2. Total functions partiality is typed via `Option<A>` or `Result<T, E>`. Never concealed.
10
+ 3. Algebraic data types are the primary modelling language: sum types via tagged discriminated unions, product types via readonly records.
11
+ 4. Compiler first, property tests second, documentation third.
12
+
13
+ ## Never
14
+
15
+ - `class` `this` `new` `instanceof` `namespace` `enum`
16
+ - `interface` without `// DEVIATION(1.4): <reason>`
17
+ - `any` — use `unknown` at I/O boundaries, narrow in scope
18
+ - `as` outside a smart constructor body
19
+ - `!` (non-null assertion)
20
+ - `let` `var`
21
+ - `for` `while` `do..while`
22
+ - `.push` `.pop` `.splice` `.sort` `.reverse` `.fill` `delete` (mutating methods)
23
+ - `throw` in core — return `err(...)` instead
24
+ - `==` `!=` or truthiness checks on non-booleans (`if (str)`, `if (value)`)
25
+ - Optional params `?` — use `Option<T>` or a defaults record
26
+ - `default:` in an exhaustive switch — use `absurd(x)` instead
27
+ - `import from 'ramda'` — use `@tsfpp/prelude`
28
+ - `new Map()` `new Set()` — use `intoMap` / `intoSet` from `@tsfpp/prelude`
29
+ - `if (x === null)` `if (x === undefined)` `x ?? y` — use `fromNullable` / `getOrElse`
30
+ - `try/catch` in core — use `tryCatch` / `tryCatchAsync` from `@tsfpp/prelude`
31
+
32
+ ## Always
33
+
34
+ - `const` for every binding
35
+ - `readonly` on every record field and `ReadonlyArray<T>` for arrays
36
+ - Explicit return type on every exported function
37
+ - Sum-type dispatch via `switch` ending in `default: return absurd(x)`
38
+ - Errors as data: `Result<T, E>` — never `throw` in core
39
+ - Pipelines via `pipe` from `@tsfpp/prelude`
40
+ - JSDoc on every exported symbol (`@param`, `@returns`, `@law` where applicable)
41
+ - `// DEVIATION(N.M): <reason>` immediately before any necessary rule violation
42
+
43
+ ## Size limits
44
+
45
+ | Metric | Limit |
46
+ |--------|-------|
47
+ | Function body | ≤ 40 lines (excl. blank lines and comments) |
48
+ | Cyclomatic complexity | ≤ 10 |
49
+ | Nesting depth | ≤ 4 |
50
+ | Positional arity | ≤ 3 — use a readonly record for ≥ 3 |
51
+
52
+ ## Canonical idioms
14
53
 
15
54
  ```ts
16
- const createTrackHandler = async (req: Request): Promise<Response> => {
17
- const ctx = extractContext(req) // 1. context
18
- const body = CreateTrackSchema.safeParse(await req.json())
19
- if (!body.success) return fromZodError(body.error, ctx.traceId) // 2. validate
20
-
21
- const result = await createTrack(body.data) // 3. use-case
22
- return pipe(result, fold(apiErrorToResponse, createdResponse)) // 4. map
55
+ // Sum type domain ADT uses `kind`
56
+ type Shape =
57
+ | { readonly kind: 'circle'; readonly radius: number }
58
+ | { readonly kind: 'rect'; readonly width: number; readonly height: number }
59
+
60
+ // Exhaustive match with totality witness
61
+ import { absurd } from '@tsfpp/prelude'
62
+
63
+ const area = (s: Shape): number => {
64
+ switch (s.kind) {
65
+ case 'circle': return Math.PI * s.radius ** 2
66
+ case 'rect': return s.width * s.height
67
+ default: return absurd(s)
68
+ }
23
69
  }
70
+
71
+ // Branded type — smart constructor only; `as` only inside the guard body
72
+ import { type Brand, some, none, type Option } from '@tsfpp/prelude'
73
+
74
+ type UserId = Brand<string, 'UserId'>
75
+ const mkUserId = (raw: string): Option<UserId> =>
76
+ raw.length > 0
77
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- DEVIATION(1.6): smart-constructor body
78
+ ? some(raw as UserId)
79
+ : none
80
+
81
+ // Total function via Option
82
+ const head = <A>(xs: ReadonlyArray<A>): Option<A> =>
83
+ xs.length > 0 ? some(xs[0] as A) : none
84
+
85
+ // Pipeline
86
+ import { pipe, map, flatMap, tap, tapErr } from '@tsfpp/prelude'
87
+
88
+ pipe(
89
+ parseInput(raw),
90
+ flatMap(validate),
91
+ tap(v => log.debug({ v })),
92
+ tapErr(e => log.warn({ e })),
93
+ )
24
94
  ```
25
95
 
26
- ## Boundary imports
96
+ ## Import discipline
27
97
 
28
- All HTTP primitives come from `@tsfpp/boundary`:
98
+ All ADT constructors, combinators, and utilities come from `@tsfpp/prelude`. Never import from `ramda` directly.
29
99
 
30
100
  ```ts
31
101
  import {
32
- extractContext, fromZodError, apiErrorToResponse,
33
- okResponse, createdResponse, noContentResponse, acceptedResponse,
34
- problemResponse, mkProblem,
35
- } from '@tsfpp/boundary'
102
+ some, none, ok, err, unit,
103
+ isSome, isNone, isOk, isErr,
104
+ map, flatMap, flatMapAsync, tap, tapErr,
105
+ mapO, flatMapO, orElse, getOrElse, fromNullable,
106
+ tryCatch, tryCatchAsync,
107
+ traverseArray, traverseArrayO, sequenceArrayO,
108
+ intoMap, assoc, dissoc, lookup, entriesOfMap,
109
+ intoSet, conj, disj, member,
110
+ isRecord, getStringField, getNumberField, getTypedField,
111
+ pipe, flow, absurd,
112
+ type Option, type Result, type Unit, type Brand,
113
+ } from '@tsfpp/prelude'
36
114
  ```
37
115
 
38
- Never construct `new Response(...)` directly in a handler.
116
+ ## Prelude-first
39
117
 
40
- ## Validation
118
+ Before writing any implementation, check if `@tsfpp/prelude` already provides what you need:
41
119
 
42
- All input validated with Zod at the boundary. Schema lives next to the route:
120
+ | If you need… | Use… |
121
+ |---|---|
122
+ | Nullable → Option | `fromNullable` |
123
+ | Fallible operation | `tryCatch` / `tryCatchAsync` |
124
+ | No-value success | `ok(unit)` — never `ok(undefined)` |
125
+ | Map over array that can fail | `traverseArray` |
126
+ | Key/value store | `intoMap`, `lookup`, `assoc`, `dissoc` |
127
+ | Deduplication / membership | `intoSet`, `conj`, `disj`, `member` |
128
+ | Decode unknown record | `isRecord` + `getStringField` / `getNumberField` / `getTypedField` |
129
+
130
+ ## Code markers
43
131
 
44
132
  ```ts
45
- const CreateTrackSchema = z.object({
46
- title: z.string().min(1).max(255),
47
- artistId: z.string().uuid(),
48
- })
133
+ // TODO(author, YYYY-MM-DD[, TICKET]): description
134
+ // FIXME(author, YYYY-MM-DD): description
135
+ // HACK(author, YYYY-MM-DD): description
136
+ // NOTE(author, YYYY-MM-DD): description
137
+ // OPTIMIZE(author, YYYY-MM-DD): description
138
+ // BUG(author, YYYY-MM-DD): description
139
+ // XXX(author, YYYY-MM-DD): description
49
140
  ```
50
141
 
51
- Never pass unvalidated `req.body` or `req.json()` into the domain.
142
+ ## Generation workflow
52
143
 
53
- ## Errors
144
+ 1. **Types first** — model the domain as sum and product types before writing any function.
145
+ 2. **Make it total** — every function returns `Option`/`Result` if it can fail or be absent.
146
+ 3. **Pure vs effectful** — `T` means pure; `Promise<Result<T, E>>` means effectful.
147
+ 4. **Test the laws** — fast-check property tests for every pure function.
54
148
 
55
- ```ts
56
- // Yes — Result propagates; mapped once at the boundary
57
- const result: Result<Track, ApiError> = await createTrack(input)
58
- return pipe(result, fold(apiErrorToResponse, createdResponse))
149
+ ## Editing existing code
59
150
 
60
- // No throw crosses the boundary untyped
61
- throw new Error('not found')
62
- ```
151
+ - Never weaken a signature (e.g. do not replace `Option<T>` with `T | undefined`).
152
+ - Preserve `readonly`-ness transitively.
153
+ - Do not introduce forbidden constructs to fix a type error — rethink the types.
154
+ - Deviation: `// DEVIATION(N.M): <one-line justification>` at the violation site.
63
155
 
64
- ## Context
156
+ ## When a task is ambiguous
65
157
 
66
- ```ts
67
- const { traceId, principalId } = extractContext(req)
68
- // Never: req.headers.get('x-trace-id') in business logic
69
- ```
158
+ Ask one focused question rather than guessing. Do not invent types, error cases, or effect boundaries.
159
+
160
+ ## Agents and tooling
70
161
 
71
- ## Status codes
72
-
73
- | Situation | Code | Builder |
74
- |-----------|------|---------|
75
- | Read success | 200 | `okResponse` |
76
- | Created | 201 | `createdResponse` |
77
- | Accepted (async) | 202 | `acceptedResponse` |
78
- | No content | 204 | `noContentResponse` |
79
- | Validation failure | 422 | `fromZodError` |
80
- | Not found | 404 | `problemResponse(mkProblem(404, ...))` |
81
- | Conflict | 409 | `problemResponse(mkProblem(409, ...))` |
82
- | Server error | 500 | `problemResponse(mkProblem(500, ...))` |
83
-
84
- ## Security
85
-
86
- - All routes require authentication unless explicitly marked `// PUBLIC`
87
- - Never log `principalId`, credentials, or request bodies at `info` level
88
- - Never reflect user input in error messages without sanitisation
89
- - Idempotency keys required on mutating operations — use `withIdempotency`
162
+ - TSF++ compliance audit: `.github/agents/tsfpp-audit.agent.md`
163
+ - Guarded implementation: `.github/agents/tsfpp-guarded-coding.agent.md`
164
+ - Refactoring: `.github/agents/tsfpp-refactor-engineer.agent.md`
165
+ - Annotation: `.github/agents/tsfpp-annotate.agent.md`
166
+ - Trunk workflow: `.github/instructions/trunk.instructions.md`
@@ -6,84 +6,149 @@ applyTo: "{**/routes/**,**/handlers/**,**/api/**}/*.ts"
6
6
 
7
7
  Full standard: `node_modules/@tsfpp/standard/spec/API_CODING_STANDARD.md`
8
8
  Boundary API: `node_modules/@tsfpp/boundary/README.md`
9
- Extends: tsfpp-base.instructions.md (all base rules apply)
9
+ Extends: `tsfpp-base.instructions.md` (all base rules apply)
10
10
 
11
11
  ## Handler shape
12
12
 
13
- Handlers are thin. The only permitted steps are: parse → call use-case → map response.
13
+ Handlers are thin. The only permitted steps are: **parse → call use-case → map response**.
14
14
 
15
15
  ```ts
16
- const createTrackHandler = async (req: Request): Promise<Response> => {
17
- const ctx = extractContext(req) // 1. context
18
- const body = CreateTrackSchema.safeParse(await req.json())
19
- if (!body.success) return fromZodError(body.error, ctx.traceId) // 2. validate
16
+ import {
17
+ extractContext, fromZodError, apiErrorToResponse,
18
+ createdResponse, okResponse, noContentResponse, acceptedResponse,
19
+ withIdempotency, withRequestLog,
20
+ } from '@tsfpp/boundary'
21
+ import { isErr, pipe } from '@tsfpp/prelude'
22
+
23
+ const createTrackHandler: RawHandler = async (req) => {
24
+ const ctx = extractContext(req, '/v1/tracks') // 1. context — always first
20
25
 
21
- const result = await createTrack(body.data) // 3. use-case
22
- return pipe(result, fold(apiErrorToResponse, createdResponse)) // 4. map
26
+ const raw = await req.json().catch(() => null)
27
+ const parsed = CreateTrackSchema.safeParse(raw)
28
+ if (!parsed.success) // 2. validate
29
+ return apiErrorToResponse(fromZodError(parsed.error), ctx)
30
+
31
+ const result = await createTrack(parsed.data) // 3. use-case
32
+ if (isErr(result)) return apiErrorToResponse(result.error, ctx)
33
+
34
+ return createdResponse(result.value, `/v1/tracks/${result.value.id}`, {
35
+ 'X-Request-Id': ctx.traceId,
36
+ }) // 4. respond
23
37
  }
24
38
  ```
25
39
 
26
40
  ## Boundary imports
27
41
 
28
- All HTTP primitives come from `@tsfpp/boundary`:
42
+ All HTTP primitives come from `@tsfpp/boundary`. Never construct `new Response()` directly.
29
43
 
30
44
  ```ts
31
45
  import {
32
- extractContext, fromZodError, apiErrorToResponse,
33
- okResponse, createdResponse, noContentResponse, acceptedResponse,
34
- problemResponse, mkProblem,
46
+ // Context
47
+ extractContext,
48
+ // Validation
49
+ fromZodError, mkValidationError,
50
+ // Error mapping
51
+ apiErrorToResponse, apiErrorToProblem,
52
+ // Response builders
53
+ okResponse, createdResponse, noContentResponse,
54
+ acceptedResponse, redirectResponse, jsonResponse, problemResponse, mkProblem,
55
+ // Pagination
56
+ mkPaginated, parsePaginationQuery, encodeCursor, decodeCursor,
57
+ // LRO
58
+ mkRunningOp, mkSucceededOp, mkFailedOp,
59
+ // Bulk
60
+ bulkResponse, mkBulkOkItem, mkBulkErrorItem,
61
+ // Security
62
+ baselineSecurityHeaders, corsHeaders, rateLimitHeaders,
63
+ // Middleware
64
+ withIdempotency, withRequestLog,
65
+ // Webhooks
66
+ signWebhook, verifyWebhook,
67
+ // Types
68
+ type RawHandler, type HandlerFactory, type RequestContext,
35
69
  } from '@tsfpp/boundary'
36
70
  ```
37
71
 
38
- Never construct `new Response(...)` directly in a handler.
39
-
40
72
  ## Validation
41
73
 
42
- All input validated with Zod at the boundary. Schema lives next to the route:
74
+ All input validated with Zod at the boundary via `safeParse` never `parse` (throws):
43
75
 
44
76
  ```ts
45
- const CreateTrackSchema = z.object({
46
- title: z.string().min(1).max(255),
47
- artistId: z.string().uuid(),
48
- })
77
+ const parsed = CreateTrackSchema.safeParse(raw)
78
+ if (!parsed.success) return apiErrorToResponse(fromZodError(parsed.error), ctx)
49
79
  ```
50
80
 
51
- Never pass unvalidated `req.body` or `req.json()` into the domain.
81
+ Never pass unvalidated `req.json()` into the domain.
52
82
 
53
- ## Errors
83
+ ## Error mapping
54
84
 
55
85
  ```ts
56
86
  // Yes — Result propagates; mapped once at the boundary
57
- const result: Result<Track, ApiError> = await createTrack(input)
58
- return pipe(result, fold(apiErrorToResponse, createdResponse))
87
+ const result = await createTrack(input)
88
+ if (isErr(result)) return apiErrorToResponse(result.error, ctx)
59
89
 
60
90
  // No — throw crosses the boundary untyped
61
91
  throw new Error('not found')
62
92
  ```
63
93
 
64
- ## Context
94
+ `dependency` and `internal` ApiError variants contain a `cause` — **log `cause` before calling `apiErrorToResponse`**, it is stripped from the response.
95
+
96
+ ## Middleware composition
97
+
98
+ Compose via `pipe`, outermost-last. `withRequestLog` must always be outermost:
65
99
 
66
100
  ```ts
67
- const { traceId, principalId } = extractContext(req)
68
- // Never: req.headers.get('x-trace-id') in business logic
101
+ const handler: RawHandler = pipe(
102
+ createTrackHandler, // business logic
103
+ withIdempotency(store), // replay / in-flight guard
104
+ withRequestLog(logger, '/v1/tracks'), // outermost — logs every outcome
105
+ )
69
106
  ```
70
107
 
71
- ## Status codes
108
+ ## Pagination
72
109
 
73
- | Situation | Code | Builder |
74
- |-----------|------|---------|
75
- | Read success | 200 | `okResponse` |
76
- | Created | 201 | `createdResponse` |
77
- | Accepted (async) | 202 | `acceptedResponse` |
78
- | No content | 204 | `noContentResponse` |
79
- | Validation failure | 422 | `fromZodError` |
80
- | Not found | 404 | `problemResponse(mkProblem(404, ...))` |
81
- | Conflict | 409 | `problemResponse(mkProblem(409, ...))` |
82
- | Server error | 500 | `problemResponse(mkProblem(500, ...))` |
110
+ ```ts
111
+ const pageQuery = parsePaginationQuery(req.url, 100) // Result<PageQuery, ValidationError>
112
+ if (isErr(pageQuery)) return apiErrorToResponse(pageQuery.error, ctx)
113
+
114
+ const page = mkPaginated(items, nextCursor) // totalCount omitted unless precomputed
115
+ return okResponse(page)
116
+ ```
117
+
118
+ ## Rate limiting
119
+
120
+ Attach `rateLimitHeaders` to **all** responses on rate-limited endpoints, not only 429s:
121
+
122
+ ```ts
123
+ return okResponse(body, rateLimitHeaders(state))
124
+ ```
83
125
 
84
126
  ## Security
85
127
 
128
+ ```ts
129
+ // Merge baseline headers into every response
130
+ return okResponse(body, { ...baselineSecurityHeaders, ...rateLimitHeaders(state) })
131
+
132
+ // CORS — never reflect Origin blindly; allowedOrigins from config only
133
+ return okResponse(body, corsHeaders(allowedOrigins, requestOrigin))
134
+ ```
135
+
86
136
  - All routes require authentication unless explicitly marked `// PUBLIC`
87
- - Never log `principalId`, credentials, or request bodies at `info` level
137
+ - Never log `principalId`, credentials, or full request bodies at `info` level
88
138
  - Never reflect user input in error messages without sanitisation
89
- - Idempotency keys required on mutating operations — use `withIdempotency`
139
+ - Idempotency keys required on state-mutating operations — use `withIdempotency`
140
+
141
+ ## Status codes
142
+
143
+ | Situation | Code | Builder |
144
+ |---|---|---|
145
+ | Read success | 200 | `okResponse` |
146
+ | Created | 201 | `createdResponse(body, location)` |
147
+ | Accepted (async / LRO) | 202 | `acceptedResponse(operation, pollUrl)` |
148
+ | No content | 204 | `noContentResponse` |
149
+ | Multi-status (bulk) | 207 | `bulkResponse(items)` |
150
+ | Validation failure | 422 | `apiErrorToResponse(fromZodError(e), ctx)` |
151
+ | Not found | 404 | `apiErrorToResponse({ kind: 'not_found', ... }, ctx)` |
152
+ | Conflict | 409 | `apiErrorToResponse({ kind: 'conflict', ... }, ctx)` |
153
+ | Rate limited | 429 | `apiErrorToResponse({ kind: 'rate_limit', ... }, ctx)` |
154
+ | Server error | 500 | `apiErrorToResponse({ kind: 'internal', cause }, ctx)` |
@@ -21,6 +21,9 @@ Full standard: `node_modules/@tsfpp/standard/spec/CODING_STANDARD.md`
21
21
  - Optional params `?` — use `Option<T>` or a defaults record
22
22
  - `default:` in an exhaustive switch — use `absurd(x)` instead
23
23
  - `import from 'ramda'` — use `@tsfpp/prelude`
24
+ - `new Map()` `new Set()` — use `intoMap` / `intoSet` from `@tsfpp/prelude`
25
+ - `if (x === null)` `if (x === undefined)` `x ?? y` — use `fromNullable` / `getOrElse`
26
+ - `try/catch` in core — use `tryCatch` / `tryCatchAsync` from `@tsfpp/prelude`
24
27
 
25
28
  ## Always
26
29
 
@@ -46,7 +49,7 @@ Full standard: `node_modules/@tsfpp/standard/spec/CODING_STANDARD.md`
46
49
  ## ADT patterns
47
50
 
48
51
  ```ts
49
- // Sum type
52
+ // Sum type — domain ADTs use `kind`
50
53
  type Shape =
51
54
  | { readonly kind: 'circle'; readonly radius: number }
52
55
  | { readonly kind: 'rect'; readonly width: number; readonly height: number }
@@ -58,21 +61,29 @@ switch (shape.kind) {
58
61
  default: return absurd(shape)
59
62
  }
60
63
 
61
- // Branded type
64
+ // Branded type — `as` only inside the smart constructor guard body
62
65
  type UserId = Brand<string, 'UserId'>
63
- const mkUserId = brand<string, 'UserId'>(
64
- s => /^[a-z0-9-]+$/.test(s),
65
- s => `Invalid UserId: ${s}`,
66
- )
66
+ const mkUserId = (raw: string): Option<UserId> =>
67
+ raw.length > 0
68
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- DEVIATION(1.6): smart-constructor body
69
+ ? some(raw as UserId)
70
+ : none
67
71
 
68
72
  // Total function
69
73
  const head = <A>(xs: ReadonlyArray<A>): Option<A> =>
70
74
  xs.length > 0 ? some(xs[0] as A) : none
71
75
  ```
72
76
 
77
+ ## Discriminant convention
78
+
79
+ | ADT origin | Field | Example |
80
+ |---|---|---|
81
+ | `@tsfpp/prelude` (Result, Option) | `_tag` | accessed via guards only — never `x._tag === 'Ok'` |
82
+ | Domain ADTs | `kind` | `{ kind: 'pending'; ... }` |
83
+
73
84
  ## Imports
74
85
 
75
- All ADT constructors (`some`, `none`, `ok`, `err`), combinators (`map`, `flatMap`, `pipe`, `prop`, …), and Ramda re-exports come from `@tsfpp/prelude`. Never import from `ramda` directly.
86
+ All ADT constructors, combinators, and utilities come from `@tsfpp/prelude`. Never import from `ramda` directly.
76
87
 
77
88
  ## Markers
78
89