@tsfpp/agents 1.3.4 → 1.4.0

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.
@@ -36,6 +36,13 @@ Full coding standard: `node_modules/@tsfpp/standard/spec/CODING_STANDARD.md`
36
36
 
37
37
  ---
38
38
 
39
+ ## Before writing any test
40
+
41
+ Load and apply the `/test-standard` skill. Every test you write must conform to
42
+ all rules in that skill. Do not write a single test before the skill is loaded.
43
+
44
+ ---
45
+
39
46
  ## Session start
40
47
 
41
48
  Infer the layer per task from the user's message:
@@ -153,6 +160,36 @@ All rules from `TEST_CODING_STANDARD.md` apply. The most critical during test au
153
160
 
154
161
  ---
155
162
 
163
+ ## Factories
164
+
165
+ Always use typed factory functions from `tests/factories/` for test data.
166
+ Never write raw object literals inline.
167
+
168
+ ```ts
169
+ // tests/factories/track.factory.ts
170
+ const makeTrack = (overrides: Partial<Track> = {}): Track => ({
171
+ id: mkTrackId('test-track-001'),
172
+ title: 'Default Title',
173
+ artistId: mkArtistId('test-artist-001'),
174
+ ...overrides,
175
+ })
176
+ ```
177
+
178
+ Import and use with overrides for the specific case under test:
179
+
180
+ ```ts
181
+ // Specific case — override only what matters for this test
182
+ const track = makeTrack({ title: 'Blue Flame' })
183
+
184
+ // Default case — the specific values don't matter
185
+ const track = makeTrack()
186
+ ```
187
+
188
+ Never hard-code raw objects like `{ id: 'abc', title: 'Test', artistId: 'xyz' }` in test bodies.
189
+ Never use production or staging IDs in fixtures.
190
+
191
+ ---
192
+
156
193
  ## Layer-specific test patterns
157
194
 
158
195
  ### `core`
@@ -164,14 +201,21 @@ import { isSome, isNone, isOk, isErr } from '@tsfpp/prelude'
164
201
  describe('mkTrackId', () => {
165
202
  describe('when the input is a non-empty string', () => {
166
203
  it('returns Some containing a branded TrackId', () => {
167
- const result = mkTrackId('abc')
204
+ const raw = 'abc'
205
+
206
+ const result = mkTrackId(raw)
207
+
168
208
  expect(isSome(result)).toBe(true)
169
209
  })
170
210
  })
171
211
 
172
212
  describe('when the input is empty', () => {
173
213
  it('returns None', () => {
174
- expect(mkTrackId('')).toEqual(none)
214
+ const raw = ''
215
+
216
+ const result = mkTrackId(raw)
217
+
218
+ expect(result).toEqual(none)
175
219
  })
176
220
  })
177
221
 
@@ -200,11 +244,13 @@ describe('POST /v1/tracks', () => {
200
244
  describe('when the request body is valid', () => {
201
245
  it('responds with 201 and a Location header', async () => {
202
246
  const req = new Request('http://localhost/v1/tracks', {
203
- method: 'POST',
204
- body: JSON.stringify({ title: 'Test', artistId: 'a1' }),
247
+ method: 'POST',
248
+ body: JSON.stringify({ title: 'Test', artistId: 'a1' }),
205
249
  headers: { 'Content-Type': 'application/json' },
206
250
  })
251
+
207
252
  const res = await handler(req)
253
+
208
254
  expect(res.status).toBe(201)
209
255
  expect(res.headers.get('Location')).toMatch(/\/v1\/tracks\//)
210
256
  })
@@ -212,12 +258,16 @@ describe('POST /v1/tracks', () => {
212
258
 
213
259
  describe('when title is missing', () => {
214
260
  it('responds with 422', async () => {
261
+ const input = makeCreateTrackInput({ title: undefined }) // override to trigger validation failure
262
+
215
263
  const req = new Request('http://localhost/v1/tracks', {
216
- method: 'POST',
217
- body: JSON.stringify({ artistId: 'a1' }),
264
+ method: 'POST',
265
+ body: JSON.stringify(input),
218
266
  headers: { 'Content-Type': 'application/json' },
219
267
  })
268
+
220
269
  const res = await handler(req)
270
+
221
271
  expect(res.status).toBe(422)
222
272
  })
223
273
  })
@@ -266,7 +316,9 @@ describe('TrackRepository', () => {
266
316
  it('returns Some containing the track', async () => {
267
317
  const track = makeTrack()
268
318
  await repo.save(track)
319
+
269
320
  const result = await repo.findById(track.id)
321
+
270
322
  expect(isSome(result)).toBe(true)
271
323
  })
272
324
  })
@@ -274,6 +326,7 @@ describe('TrackRepository', () => {
274
326
  describe('when the track does not exist', () => {
275
327
  it('returns None', async () => {
276
328
  const result = await repo.findById(mkTrackId('nonexistent'))
329
+
277
330
  expect(isNone(result)).toBe(true) // will fail — findById not implemented
278
331
  })
279
332
  })
@@ -0,0 +1,160 @@
1
+ # Write changelog entry
2
+
3
+ Inspect the current working tree, derive the correct conventional commit message,
4
+ and append a matching entry to the `## [Unreleased]` section of `CHANGELOG.md`.
5
+
6
+ ---
7
+
8
+ ## Step 1 — Inspect changes
9
+
10
+ Run the following to see what has changed:
11
+
12
+ ```bash
13
+ git diff --stat HEAD
14
+ git status --short
15
+ ```
16
+
17
+ For each changed or added file, read enough of its diff to understand **what
18
+ changed and why** — not just which lines moved.
19
+
20
+ ```bash
21
+ git diff HEAD -- <file>
22
+ ```
23
+
24
+ If files are staged but not committed, use:
25
+
26
+ ```bash
27
+ git diff --cached --stat
28
+ git diff --cached -- <file>
29
+ ```
30
+
31
+ ---
32
+
33
+ ## Step 2 — Classify changes
34
+
35
+ Map each change to a Conventional Commits type:
36
+
37
+ | Type | Use when |
38
+ |---|---|
39
+ | `feat` | New behaviour or capability visible to a consumer |
40
+ | `fix` | Corrects incorrect behaviour |
41
+ | `perf` | Improves performance without changing behaviour |
42
+ | `refactor` | Internal restructuring; no behaviour change, no bug fix |
43
+ | `test` | Adds or fixes tests; no production code change |
44
+ | `docs` | Documentation only |
45
+ | `chore` | Tooling, config, dependencies, release machinery |
46
+ | `build` | Build system or external dependency changes |
47
+ | `ci` | CI configuration changes |
48
+
49
+ **Breaking change:** any change that removes or renames a public export, changes
50
+ a function signature, or alters a type in a way that requires consumer updates.
51
+ Mark with `!` after the type (e.g. `feat!`) and add a `BREAKING CHANGE:` footer.
52
+
53
+ **Scope:** the package or module affected — e.g. `prelude`, `boundary`, `agents`,
54
+ `react`, `dal`. Omit if the change is cross-cutting.
55
+
56
+ ---
57
+
58
+ ## Step 3 — Write the commit message
59
+
60
+ Produce a conventional commit message following this format exactly:
61
+
62
+ ```
63
+ <type>(<scope>): <imperative summary in sentence case, ≤ 72 chars>
64
+
65
+ <optional body — what changed and why, not how, wrapped at 72 chars>
66
+
67
+ <optional footers>
68
+ BREAKING CHANGE: <description if applicable>
69
+ Closes #<issue> (if applicable)
70
+ ```
71
+
72
+ Rules:
73
+ - Summary is imperative mood: "add", "fix", "remove" — not "added", "fixes"
74
+ - Summary does not end with a period
75
+ - Body explains the **why**, not the **what** (the diff is the what)
76
+ - One commit per logical change; if the diff contains multiple unrelated changes,
77
+ produce one message per change and say so
78
+
79
+ ---
80
+
81
+ ## Step 4 — Update CHANGELOG.md
82
+
83
+ Find or create the `## [Unreleased]` section at the top of `CHANGELOG.md`.
84
+ If `CHANGELOG.md` does not exist, create it with this header:
85
+
86
+ ```markdown
87
+ # Changelog
88
+
89
+ All notable changes to this project will be documented in this file.
90
+ This file is maintained automatically by [release-please](https://github.com/googleapis/release-please)
91
+ and supplemented during development via the `/trunk-changelog` prompt.
92
+
93
+ <!-- do not remove this comment — release-please uses it as an anchor -->
94
+ <!-- RELEASE-PLEASE-INSERTION-POINT -->
95
+
96
+ ## [Unreleased]
97
+ ```
98
+
99
+ Append the new entry under `## [Unreleased]`, grouped by type in this order:
100
+
101
+ ```markdown
102
+ ## [Unreleased]
103
+
104
+ ### Breaking changes
105
+ - `feat!(boundary)!: remove legacy `fold` export` — consumers must migrate to `map`/`flatMap`
106
+
107
+ ### Features
108
+ - `feat(prelude): add ReadonlyMap combinators` — `intoMap`, `assoc`, `dissoc`, `lookup`, `entriesOfMap`
109
+
110
+ ### Bug fixes
111
+ - `fix(agents): init.mjs fails with ReferenceError when run with --yes`
112
+
113
+ ### Performance
114
+ - ...
115
+
116
+ ### Refactoring
117
+ - ...
118
+
119
+ ### Tests
120
+ - ...
121
+
122
+ ### Documentation
123
+ - ...
124
+
125
+ ### Chores
126
+ - ...
127
+ ```
128
+
129
+ Only include sections that have entries. Do not add empty sections.
130
+
131
+ Each entry is a single line:
132
+ - Backtick-quoted commit summary
133
+ - Em dash
134
+ - One-sentence plain-English explanation of the user-visible impact
135
+
136
+ ---
137
+
138
+ ## Step 5 — Output
139
+
140
+ Print the proposed commit message in a code block so it can be copied directly
141
+ into the terminal or used with `git commit`:
142
+
143
+ ```
144
+ git commit -m "<type>(<scope>): <summary>" \
145
+ -m "<body paragraph if needed>"
146
+ ```
147
+
148
+ If there are multiple logical changes, list each message separately and recommend
149
+ committing them individually with `git add -p` to stage per-change.
150
+
151
+ ---
152
+
153
+ ## Rules
154
+
155
+ - Never invent changes that are not visible in the diff
156
+ - Never write a changelog entry for a change that has no user-visible impact
157
+ (internal renaming, comment edits) — use `chore` or `refactor` in the commit
158
+ message but omit from `## [Unreleased]`
159
+ - Do not modify any section of `CHANGELOG.md` other than `## [Unreleased]`
160
+ - release-please owns every versioned section (`## [1.2.3]`) — never touch those
@@ -0,0 +1,196 @@
1
+ ---
2
+ name: annotation-standard
3
+ description: >
4
+ Normative TSF++ annotation rules for all comments, JSDoc blocks, module
5
+ headers, code markers, and deviation records. Load when writing, reviewing,
6
+ or adding annotations to any TypeScript file: the "why not what" principle,
7
+ JSDoc body content (invariants, rejected alternatives, external contracts,
8
+ accepted imprecision, performance trade-offs), marker taxonomy with format,
9
+ DEVIATION pairing, and what must never be annotated.
10
+ ---
11
+
12
+ # TSF++ annotation standard
13
+
14
+ Full standard: `node_modules/@tsfpp/standard/spec/ANNOTATION_CODING_STANDARD.md`
15
+
16
+ ---
17
+
18
+ ## The single deciding question
19
+
20
+ > Does this tell the reader something they cannot confidently derive from the code and its types?
21
+
22
+ If no — do not add the comment. If yes — write it.
23
+
24
+ ---
25
+
26
+ ## What only a comment can tell you
27
+
28
+ | Category | Example |
29
+ |---|---|
30
+ | **Why this approach** over the natural alternative | Why linear scan instead of `Map` lookup |
31
+ | **Rejected alternatives** | What was considered and why it was ruled out |
32
+ | **Non-obvious invariants** | Preconditions the type cannot express |
33
+ | **Domain knowledge** | Business rules that live in the problem space |
34
+ | **External contracts** | Field names / values dictated by a third party |
35
+ | **Accepted imprecision** | Known limitations that are intentional |
36
+ | **Performance trade-offs** | Why a non-obvious implementation was chosen |
37
+ | **Temporal context** | Why a workaround exists, when to revisit it |
38
+
39
+ ---
40
+
41
+ ## JSDoc body: the why, not the what
42
+
43
+ The first sentence is the purpose. Everything after is the reasoning.
44
+
45
+ ```ts
46
+ /**
47
+ * Constructs a validated `UserId` from a raw string.
48
+ *
49
+ * Returns `None` if the input is empty. The empty case is excluded rather
50
+ * than mapped to an error because an empty ID indicates a caller bug, not
51
+ * a domain error — the type system prevents this at compile time in
52
+ * internal code; this guard exists for boundary inputs only.
53
+ *
54
+ * @param raw - The raw string to validate. Must be non-empty.
55
+ * @returns `Some(UserId)` if valid; `None` if empty.
56
+ *
57
+ * @example
58
+ * mkUserId('usr-00123') // => some(UserId('usr-00123'))
59
+ * mkUserId('') // => none
60
+ */
61
+ ```
62
+
63
+ `@param` describes the domain constraint, not the type. `@returns` describes the meaning, not the type. Both are already in the signature.
64
+
65
+ ---
66
+
67
+ ## Module header
68
+
69
+ Required on every file with public exports:
70
+
71
+ ```ts
72
+ /**
73
+ * @module user-account
74
+ *
75
+ * Domain model for user accounts. Provides the `UserAccount` sum type,
76
+ * its smart constructors, and the combinators for working with account
77
+ * state and identity.
78
+ *
79
+ * All functions are pure and total. Error cases are modelled via
80
+ * `Option<A>` (absent value) or `Result<T, E>` (fallible computation).
81
+ *
82
+ * @packageDocumentation
83
+ */
84
+ ```
85
+
86
+ First sentence: what the module provides. Second paragraph: key design constraints a consumer needs to know. Never describe the implementation.
87
+
88
+ ---
89
+
90
+ ## Inline comment patterns
91
+
92
+ ### Rejected alternative
93
+
94
+ ```ts
95
+ // NOTE(rob, 2026-05-18): Linear scan rather than `ReadonlyMap` lookup.
96
+ // The active session list is always ≤10 items per user; the allocation
97
+ // overhead of a map outweighs the O(1) lookup benefit at this scale.
98
+ const found = sessions.find(s => s.id === id)
99
+ ```
100
+
101
+ ### Non-obvious invariant
102
+
103
+ ```ts
104
+ // Invariant: `handlers` must be registered before this is called.
105
+ // The runtime guarantees registration order; do not call from a module
106
+ // initialiser that may run before the framework bootstraps.
107
+ ```
108
+
109
+ ### External contract
110
+
111
+ ```ts
112
+ // IMPORTANT: The field name `client_id` is specified by OAuth2 RFC 6749
113
+ // and must not be renamed despite the camelCase convention.
114
+ // DEVIATION(1.8): Field name required by external protocol.
115
+ ```
116
+
117
+ ### Accepted imprecision
118
+
119
+ ```ts
120
+ // NOTE(rob, 2026-05-18): Timestamp comparison has ≤1 s imprecision due
121
+ // to clock drift between service instances. Acceptable for audit logs;
122
+ // not acceptable for financial ordering.
123
+ ```
124
+
125
+ ---
126
+
127
+ ## Code markers
128
+
129
+ Required format — no exceptions:
130
+
131
+ ```ts
132
+ // MARKER(author, YYYY-MM-DD[, TICKET]): description
133
+ ```
134
+
135
+ | Marker | Use when | Blocks merge? |
136
+ |---|---|---|
137
+ | `TODO` | Work required before next release | Soft — needs ticket |
138
+ | `FIXME` | Known bug the author is aware of | Yes |
139
+ | `HACK` | Temporary workaround with a deferred correct solution | Yes — needs ticket + revisit condition |
140
+ | `NOTE` | Context a reader needs to understand the code | No |
141
+ | `OPTIMIZE` | Correct but with a known performance concern at scale | No — needs scale threshold |
142
+ | `BUG` | Confirmed bug not yet in a ticket | Yes — convert to FIXME + ticket |
143
+ | `XXX` | Fragile or load-bearing — must not be casually changed | No — use sparingly |
144
+
145
+ ```ts
146
+ // TODO(rjansen, 2026-05-18, ARCH-44): Replace with Result-based validation
147
+ // once the boundary refactor lands in v2.0.
148
+ // HACK(rjansen, 2026-05-18, INFRA-12): Forced cast — third-party type
149
+ // definition is wrong. Fixed upstream in v4.x — remove after upgrade.
150
+ // NOTE(rjansen, 2026-05-18): Rate-limit window resets at midnight UTC,
151
+ // not relative to first request. Contractual requirement — do not change.
152
+ // XXX(rjansen, 2026-05-18): Initialisation order is load-bearing. The
153
+ // store must be hydrated before any handler is registered.
154
+ ```
155
+
156
+ Author = GitHub handle or initials. Never an AI. If unknown, use `unknown`.
157
+
158
+ ---
159
+
160
+ ## DEVIATION format
161
+
162
+ ```ts
163
+ // DEVIATION(N.M): <reason the violation could not be avoided>
164
+ ```
165
+
166
+ The justification explains why no alternative was feasible — not what the violation is.
167
+
168
+ Every `eslint-disable` must be paired:
169
+
170
+ ```ts
171
+ // DEVIATION(1.5): Legacy adapter — raw type narrowed to unknown immediately below.
172
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
173
+ const payload: any = deserialise(raw)
174
+ ```
175
+
176
+ The `as` in a smart constructor body:
177
+
178
+ ```ts
179
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- DEVIATION(1.6): smart-constructor body
180
+ return some(raw as UserId)
181
+ ```
182
+
183
+ ---
184
+
185
+ ## Never annotate
186
+
187
+ | Forbidden | Example |
188
+ |---|---|
189
+ | Paraphrasing the code | `// Check if user is admin` above `if (user.role === 'admin')` |
190
+ | Restating the type | `// Returns a string` on `: string` |
191
+ | Commented-out code | `// const old = legacyParse(raw)` |
192
+ | Section dividers | `// ─────────────` with nothing meaningful |
193
+ | Stale comments | Any comment that no longer matches the code |
194
+ | AI attribution | `// Generated by Claude` |
195
+ | `@throws` on Result functions | Error is in the return type, not thrown |
196
+ | Apologetic comments | `// This is a bit hacky but...` — use `HACK` properly |