@tsfpp/agents 1.2.3 → 1.3.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.
@@ -12,23 +12,33 @@ Recipes: `node_modules/@tsfpp/prelude/RECIPES.md`
12
12
  ```ts
13
13
  import {
14
14
  // ADT constructors
15
- some, none, ok, err,
15
+ some, none, ok, err, unit,
16
16
  // Type guards
17
17
  isSome, isNone, isOk, isErr,
18
18
  // Option combinators
19
- mapO, flatMapO, orElse, getOrElse, fromNullable,
19
+ mapO, flatMapO, orElse, getOrElse, fromNullable, toNullable,
20
20
  // Result combinators
21
- map, flatMap, flatMapAsync, mapErr, tap, tapErr,
21
+ map, flatMap, flatMapAsync, tap, tapErr,
22
22
  // Async adapters
23
23
  tryCatch, tryCatchAsync,
24
24
  // Traversal
25
25
  traverseArray, traverseArrayO, sequenceArrayO,
26
+ // Unknown decoding
27
+ isRecord, fromUnknownString, fromUnknownArray, fromUnknownArrayOf, fromNonEmptyString,
28
+ getStringField, getNumberField, getBooleanField, getTypedField,
29
+ // ReadonlyMap
30
+ intoMap, entriesOfMap, assoc, dissoc, lookup,
31
+ // ReadonlySet
32
+ intoSet, conj, disj, member,
33
+ // List
34
+ fromArray, toArray, cons, nil, isCons, isNil,
26
35
  // Pipe
27
36
  pipe, flow, comp, complement,
28
37
  // Utilities
29
- absurd, unit,
38
+ absurd, unique,
30
39
  // Types
31
40
  type Option, type Result, type Unit, type Brand,
41
+ type UnknownRecord,
32
42
  } from '@tsfpp/prelude'
33
43
  ```
34
44
 
@@ -37,7 +47,6 @@ Never `import from 'ramda'`.
37
47
  ## Option\<A\>
38
48
 
39
49
  ```ts
40
- // Construct
41
50
  const a: Option<number> = some(42)
42
51
  const b: Option<number> = none
43
52
 
@@ -48,17 +57,16 @@ if (isNone(opt)) return ... // early exit
48
57
  // Transform
49
58
  pipe(opt, mapO(n => n + 1))
50
59
  pipe(opt, flatMapO(n => n > 0 ? some(n) : none))
51
- pipe(opt, getOrElse(() => 0))
52
- pipe(opt, orElse(() => some(defaultValue)))
60
+ pipe(opt, getOrElse(() => 0)) // collapse to value
61
+ pipe(opt, orElse(() => some(fallback))) // keep Option context
53
62
 
54
- // Lift from nullable
63
+ // Lift from nullable — never use if (x === null) directly
55
64
  const opt = fromNullable(maybeNull) // null | undefined → Option<T>
56
65
  ```
57
66
 
58
67
  ## Result\<T, E\>
59
68
 
60
69
  ```ts
61
- // Construct
62
70
  const r: Result<number, string> = ok(42)
63
71
  const e: Result<number, string> = err('oops')
64
72
 
@@ -69,73 +77,113 @@ if (isErr(r)) r.error // E
69
77
  // Transform
70
78
  pipe(r, map(v => v + 1))
71
79
  pipe(r, flatMap(v => v > 0 ? ok(v) : err('non-positive')))
72
- pipe(r, mapErr(e => `Wrapped: ${e}`))
73
80
 
74
- // Side effects without breaking the chain
75
- pipe(r, tap(v => log.debug({ v })))
76
- pipe(r, tapErr(e => log.warn({ e })))
77
-
78
- // Async adapter — wraps throwing code
79
- const result = await tryCatchAsync(
80
- () => db.findById(id),
81
- e => mkDbError(e),
81
+ // Side effects never break the pipe chain for logging
82
+ pipe(r,
83
+ tap(v => log.debug({ v })),
84
+ tapErr(e => log.warn({ e })),
82
85
  )
83
86
 
84
- // Sync adapter
87
+ // Wrapping throwing code — never use raw try/catch in core
85
88
  const result = tryCatch(
86
89
  () => JSON.parse(raw),
87
- e => `parse error: ${e}`,
90
+ e => `parse error: ${String(e)}`,
91
+ )
92
+ const result = await tryCatchAsync(
93
+ () => db.findById(id),
94
+ e => mkDbError(e),
88
95
  )
89
96
  ```
90
97
 
91
- ## pipe
98
+ ## Result\<Unit, E\> for no-value success
92
99
 
93
100
  ```ts
94
- const result = pipe(
95
- input,
96
- mapO(transform),
97
- flatMapO(validate),
98
- getOrElse(() => fallback),
99
- )
101
+ // Never Result<void, E>
102
+ const save = (): Result<Unit, DbError> => ok(unit)
100
103
  ```
101
104
 
102
- ## Unit
105
+ ## pipe vs flow
103
106
 
104
107
  ```ts
105
- // Success with no meaningful value use ok(unit), not ok(undefined)
106
- const save = (): Result<Unit, DbError> => ok(unit)
108
+ // pipe value at hand
109
+ const result = pipe(input, mapO(transform), getOrElse(() => fallback))
110
+
111
+ // flow — reusable pipeline, returns a function
112
+ const process = flow(mapO(transform), getOrElse(() => fallback))
113
+ const result = process(input)
107
114
  ```
108
115
 
109
- ## absurd
116
+ ## absurd — exhaustiveness witness
110
117
 
111
118
  ```ts
112
- // Exhaustiveness witness — type error if a union variant is unhandled
113
- default: return absurd(x)
119
+ switch (result._tag) {
120
+ case 'Ok': return result.value
121
+ case 'Err': return result.error
122
+ default: return absurd(result)
123
+ }
114
124
  ```
115
125
 
116
- ## Brand
126
+ ## Unknown record decoding
117
127
 
118
128
  ```ts
119
- type TrackId = Brand<string, 'TrackId'>
129
+ import { isRecord, getStringField, getNumberField, getTypedField } from '@tsfpp/prelude'
130
+
131
+ const decode = (raw: unknown): Result<Foo, string> => {
132
+ if (!isRecord(raw)) return err('not an object')
133
+ const name = getStringField(raw, 'name') // Option<string> — rejects empty/whitespace
134
+ const age = getNumberField(raw, 'age') // Option<number> — rejects NaN/Infinity
135
+ const id = getTypedField(raw, 'id', isFooId) // Option<FooId> — custom guard
136
+ return isSome(name) && isSome(age) && isSome(id)
137
+ ? ok({ name: name.value, age: age.value, id: id.value })
138
+ : err('missing or invalid fields')
139
+ }
140
+ ```
120
141
 
121
- const mkTrackId = brand<string, 'TrackId'>(
122
- s => s.length > 0,
123
- s => `Invalid TrackId: "${s}"`,
124
- )
142
+ ## Array traversal
143
+
144
+ ```ts
145
+ // Fallible map — short-circuits on first Err
146
+ const all = traverseArray(parseFoo)(rawItems) // Result<ReadonlyArray<Foo>, E>
147
+ // Never: rawItems.map(parseFoo) — produces ReadonlyArray<Result<Foo,E>>
148
+
149
+ // Option traversal — None if any element is None
150
+ traverseArrayO(fromNullable)([1, 2, 3]) // Some([1, 2, 3])
151
+ traverseArrayO(fromNullable)([1, null, 3]) // None
152
+
153
+ // Already have ReadonlyArray<Option<A>>?
154
+ sequenceArrayO([some(1), some(2)]) // Some([1, 2])
155
+ sequenceArrayO([some(1), none]) // None
156
+
157
+ // Guard typed arrays from unknown
158
+ const strings = fromUnknownArrayOf(
159
+ (v): v is string => typeof v === 'string'
160
+ )(raw) // Option<ReadonlyArray<string>>
125
161
  ```
126
162
 
127
- ## Traversal
163
+ ## ReadonlyMap
128
164
 
129
165
  ```ts
130
- // ReadonlyArray<A> (A → Result<B, E>) → Result<ReadonlyArray<B>, E>
131
- const results = traverseArray(validate)(items)
166
+ // Never new Map()
167
+ const m = intoMap([['a', 1], ['b', 2]]) // ReadonlyMap<string, number>
168
+ const v = pipe(m, lookup('a')) // Some(1)
169
+ const m2 = pipe(m, assoc('c', 3)) // insert / replace
170
+ const m3 = pipe(m2, dissoc('a')) // remove
171
+ const es = entriesOfMap(m) // ReadonlyArray<readonly [string, number]>
172
+ ```
132
173
 
133
- // ReadonlyArray<A> → (A → Option<B>) → Option<ReadonlyArray<B>>
134
- const options = traverseArrayO(lookup)(items)
174
+ ## ReadonlySet
175
+
176
+ ```ts
177
+ // Never new Set()
178
+ const s = intoSet([1, 2, 2, 3]) // ReadonlySet<number> — {1, 2, 3}
179
+ const s2 = pipe(s, conj(4)) // add
180
+ const s3 = pipe(s2, disj(2)) // remove (no-op when absent)
181
+ const has = pipe(s, member(1)) // true
135
182
  ```
136
183
 
137
184
  ## Discriminant convention
138
185
 
139
- - Prelude ADTs use `_tag` internally **never access it directly**
140
- - Use exported guards: `isSome`, `isNone`, `isOk`, `isErr`
141
- - Domain ADTs use `kind` as discriminant
186
+ | ADT origin | Field | Access |
187
+ |---|---|---|
188
+ | `@tsfpp/prelude` (Result, Option) | `_tag` | **via guards only** — never `x._tag === 'Ok'` |
189
+ | Domain ADTs | `kind` | `switch (x.kind)` with `absurd` |
@@ -5,83 +5,195 @@ applyTo: "**/*.tsx"
5
5
  # TSF++ React rules
6
6
 
7
7
  Full standard: `node_modules/@tsfpp/standard/spec/REACT_CODING_STANDARD.md`
8
- Extends: tsfpp-base.instructions.md (all base rules apply to `.tsx` too)
8
+ Extends: `tsfpp-base.instructions.md` (all base rules apply to `.tsx` too)
9
9
 
10
10
  ## Component shape
11
11
 
12
12
  ```ts
13
- // Props: readonly record, no optional fields use Option<T>
13
+ // Props: type alias, Props suffix, all fields readonly, no optional fields
14
14
  type TrackCardProps = {
15
- readonly track: Track
15
+ readonly track: Track
16
16
  readonly onSelect: Option<(id: TrackId) => void>
17
17
  }
18
18
 
19
- // Component: pure function, explicit return type
20
- const TrackCard = ({ track, onSelect }: TrackCardProps): React.ReactElement => { ... }
19
+ // Component: arrow const, explicit return type
20
+ const TrackCard = ({ track, onSelect }: TrackCardProps): React.ReactElement => (
21
+ <article className="rounded-lg border p-4">
22
+ <h2>{track.title}</h2>
23
+ </article>
24
+ )
21
25
  ```
22
26
 
23
- ## State
27
+ One public exported component per file. `.tsx` only when the file contains JSX.
24
28
 
25
- Model state as a discriminated union — never boolean soup:
29
+ ## State elimination ladder
30
+
31
+ Exhaust top-to-bottom before introducing state:
32
+
33
+ 1. Derivable from props? → compute during render
34
+ 2. Derivable from existing state? → compute during render or `useMemo` if expensive
35
+ 3. Belongs in URL? → router search/path params
36
+ 4. Server data? → TanStack Query
37
+ 5. Form state? → React Hook Form
38
+ 6. Ephemeral UI state, one component? → `useState` / `useReducer`
39
+ 7. Shared between siblings? → lift to nearest common ancestor
40
+ 8. Shared across distant subtrees, low-frequency? → Context
41
+ 9. Shared across distant subtrees, high-frequency? → Zustand / Jotai
42
+
43
+ Model multi-field or state-machine state with `useReducer` over multiple `useState`:
26
44
 
27
45
  ```ts
28
46
  // Yes
29
- type LoadState =
47
+ type EditorState =
30
48
  | { readonly kind: 'idle' }
31
- | { readonly kind: 'loading' }
32
- | { readonly kind: 'success'; readonly data: ReadonlyArray<Track> }
33
- | { readonly kind: 'error'; readonly message: string }
49
+ | { readonly kind: 'saving' }
50
+ | { readonly kind: 'saved'; readonly at: Date }
51
+ | { readonly kind: 'error'; readonly message: string }
34
52
 
35
53
  // No
36
54
  const [isLoading, setIsLoading] = useState(false)
37
- const [hasError, setHasError] = useState(false)
38
- const [data, setData] = useState(null)
55
+ const [hasError, setHasError] = useState(false)
56
+ ```
57
+
58
+ ## Effect discipline
59
+
60
+ `useEffect` is reserved exclusively for synchronising with systems **outside React**: subscriptions, browser APIs, imperative third-party libraries.
61
+
62
+ ```ts
63
+ // Yes — external subscription with cleanup
64
+ useEffect(() => {
65
+ // NOTE(author, date): Syncing to ResizeObserver — no React equivalent
66
+ const observer = new ResizeObserver(onResize)
67
+ observer.observe(ref.current)
68
+ return () => observer.disconnect()
69
+ }, [onResize])
70
+
71
+ // No — use TanStack Query
72
+ useEffect(() => { fetch('/api/tracks').then(setTracks) }, [])
73
+
74
+ // No — use event handler
75
+ useEffect(() => { if (submitted) navigate('/done') }, [submitted])
76
+
77
+ // No — compute during render
78
+ useEffect(() => { setFullName(`${first} ${last}`) }, [first, last])
39
79
  ```
40
80
 
41
- ## Data fetching
81
+ Never disable `react-hooks/exhaustive-deps`. Every subscribing effect returns a cleanup.
42
82
 
43
- Use TanStack Query. Never `useEffect` for fetching:
83
+ ## Data fetching TanStack Query
44
84
 
45
85
  ```ts
46
86
  // Yes
47
- const { data, isPending, isError } = useQuery({ queryKey: ['tracks'], queryFn: fetchTracks })
87
+ const { data, isPending, isError } = useQuery({
88
+ queryKey: trackKeys.byId(id),
89
+ queryFn: () => fetchTrack(id),
90
+ })
91
+
92
+ // Query key factory — typed, never inline string arrays
93
+ const trackKeys = {
94
+ all: ['tracks'] as const,
95
+ byId: (id: TrackId) => [...trackKeys.all, id] as const,
96
+ }
97
+ ```
48
98
 
49
- // No
50
- useEffect(() => { fetch('/api/tracks').then(...) }, [])
99
+ ## Forms — React Hook Form + Zod
100
+
101
+ ```ts
102
+ const schema = z.object({ title: z.string().min(1), artistId: z.string().uuid() })
103
+ type FormData = z.infer<typeof schema>
104
+
105
+ const form = useForm<FormData>({ resolver: zodResolver(schema) })
106
+
107
+ // Submit returns Result<T, E> — never throw
108
+ const onSubmit = async (data: FormData): Promise<Result<Track, ApiError>> => { ... }
51
109
  ```
52
110
 
53
- ## useEffect
111
+ ## Routing — TanStack Router
54
112
 
55
- Allowed only for genuine external synchronisation (DOM events, third-party library lifecycle, WebSocket). Must include a comment explaining why `useEffect` is the only option:
113
+ ```ts
114
+ // Yes — typed navigate
115
+ navigate({ to: '/tracks/$id', params: { id } })
116
+
117
+ // No — hand-built URL string
118
+ navigate(`/tracks/${id}`)
119
+ ```
120
+
121
+ Search params validated by Zod at the route definition.
122
+
123
+ ## Global state — Zustand / Jotai
56
124
 
57
125
  ```ts
58
- useEffect(() => {
59
- // NOTE(author, date): Syncing to external ResizeObserver — no React equivalent
60
- const observer = new ResizeObserver(...)
61
- return () => observer.disconnect()
62
- }, [ref])
126
+ // Yes — narrow selection
127
+ const title = useTrackStore((s) => s.track.title)
128
+
129
+ // No whole-store selection
130
+ const store = useTrackStore((s) => s)
63
131
  ```
64
132
 
65
- ## Forbidden in React
133
+ Store actions are pure `(state, payload) => state` — no I/O inside.
134
+
135
+ ## Styling — Tailwind + cva
136
+
137
+ ```ts
138
+ // Variants via cva — never if/else string concatenation
139
+ const buttonVariants = cva('rounded-lg px-3 py-2', {
140
+ variants: {
141
+ variant: {
142
+ primary: 'bg-primary text-primary-foreground',
143
+ destructive: 'bg-destructive text-destructive-foreground',
144
+ },
145
+ },
146
+ })
147
+
148
+ // Conditional classes via cn
149
+ const cls = cn('rounded-lg p-4', active && 'ring-2', disabled && 'opacity-50')
150
+
151
+ // Design tokens only — no hex codes or magic pixel values
152
+ // Good: className="bg-background text-foreground gap-4"
153
+ // Bad: className="bg-[#0a0a0a] gap-[17px]"
154
+ ```
66
155
 
67
- - `useEffect` for data fetching or derived state
68
- - Prop drilling > 2 levels — lift state or use context/Jotai
69
- - `any` in prop types or event handlers
70
- - Mutable refs as state (`useRef` for values that drive rendering)
71
- - Inline object/array literals in JSX props without `useMemo`
156
+ ## Memoization
72
157
 
73
- ## Event handlers
158
+ Add memoization only after a profiler measurement identifies re-renders as the bottleneck. Document the reason inline.
74
159
 
75
160
  ```ts
76
- // Yes named, explicit type
77
- const handleSelect = (id: TrackId): void => { ... }
161
+ // Only when passed to a memoized consumer or genuinely expensive
162
+ const sorted = useMemo(() => sortTracks(tracks), [tracks])
163
+ // Reason: passed to memoized VirtualList
78
164
 
79
- // No inline arrow in JSX prop recreated every render
80
- <Button onClick={() => doSomething(id)} />
165
+ // Only when passed to memoized component or in another hook's dep array
166
+ const handleSelect = useCallback((id: TrackId) => onSelect(id), [onSelect])
81
167
  ```
82
168
 
83
- ## Memoisation
169
+ Never add speculative `useMemo` / `useCallback` / `React.memo`.
170
+
171
+ ## Accessibility
172
+
173
+ - Interactive elements use semantic HTML — never `div` with `onClick`
174
+ - Forms: associated `label`; icon-buttons: `aria-label`; images: `alt`
175
+ - Keyboard navigation complete; focus order logical and visible
176
+
177
+ ## Testing
178
+
179
+ ```ts
180
+ // Yes — query by accessible role
181
+ screen.getByRole('button', { name: /save/i })
182
+
183
+ // No — data-testid
184
+ screen.getByTestId('save-button')
185
+ ```
186
+
187
+ Network mocked with MSW — never stub `fetch` directly.
188
+
189
+ ## Forbidden in React
84
190
 
85
- - Wrap expensive computations in `useMemo`
86
- - Wrap callbacks passed to child components in `useCallback`
87
- - Do not memoize everything — only when a profiler or render trace shows it matters
191
+ - `useEffect` for data fetching, derived state, or user-event reactions
192
+ - Prop drilling beyond 2 levels
193
+ - `useState` for server state
194
+ - `useStore((s) => s)` whole-store selection
195
+ - Inline `if/else` string concatenation for Tailwind variants — use `cva`
196
+ - `div` with `onClick` for an action — use `button`
197
+ - `data-testid` queries in tests
198
+ - Per-field `useState` in forms — use React Hook Form
199
+ - Hand-built URL strings for navigation — use typed router API
@@ -0,0 +1,154 @@
1
+ ---
2
+ applyTo: "**/*.{test,spec}.{ts,tsx}"
3
+ ---
4
+
5
+ # TSF++ test rules
6
+
7
+ Full standard: `node_modules/@tsfpp/standard/spec/TEST_CODING_STANDARD.md`
8
+ Extends: `tsfpp-base.instructions.md` (all base TSF++ rules apply to test code)
9
+
10
+ ## Toolchain
11
+
12
+ | Layer | Runner | Property tests | Network | DB |
13
+ |---|---|---|---|---|
14
+ | Core | Vitest | fast-check | — | — |
15
+ | Use-case | Vitest | fast-check (optional) | — | In-memory stub |
16
+ | API / handler | Vitest | — | MSW | In-memory stub |
17
+ | DAL | Vitest | — | — | Real / containerised |
18
+ | React | Vitest + RTL | — | MSW | — |
19
+
20
+ ## Test structure — AAA
21
+
22
+ ```ts
23
+ it('returns None when the input string is empty', () => {
24
+ const raw = '' // Arrange
25
+
26
+ const result = mkTrackId(raw) // Act
27
+
28
+ expect(result).toEqual(none) // Assert
29
+ })
30
+ ```
31
+
32
+ One blank line between phases. One logical assertion per test. No branching or loops in test bodies.
33
+
34
+ ## Describe block structure
35
+
36
+ ```ts
37
+ describe('mkTrackId', () => {
38
+ describe('when the input is valid', () => {
39
+ it('returns Some containing a branded TrackId', () => { ... })
40
+ })
41
+ describe('when the input is empty', () => {
42
+ it('returns None', () => { ... })
43
+ })
44
+ })
45
+ ```
46
+
47
+ Max two levels of nesting. Test descriptions are full sentences describing behaviour.
48
+
49
+ ## Property-based tests — fast-check
50
+
51
+ Required for every pure function and combinator. Laws in `@law` JSDoc must have a corresponding property test.
52
+
53
+ ```ts
54
+ import * as fc from 'fast-check'
55
+
56
+ it('satisfies the identity law: map(id) ≡ id', () => {
57
+ fc.assert(
58
+ fc.property(fc.integer(), (n) => {
59
+ expect(pipe(ok(n), map(x => x))).toEqual(ok(n))
60
+ }),
61
+ )
62
+ })
63
+ ```
64
+
65
+ ## React components — RTL
66
+
67
+ ```ts
68
+ import { render, screen } from '@testing-library/react'
69
+ import userEvent from '@testing-library/user-event'
70
+
71
+ it('calls onSelect with the track id when the row is clicked', async () => {
72
+ const onSelect = vi.fn()
73
+ render(<TrackRow track={makeTrack()} onSelect={onSelect} />)
74
+
75
+ await userEvent.click(screen.getByRole('row', { name: /test track/i }))
76
+
77
+ expect(onSelect).toHaveBeenCalledWith(expect.any(String))
78
+ })
79
+ ```
80
+
81
+ Query hierarchy (use the first that works):
82
+ 1. `getByRole` — always preferred
83
+ 2. `getByLabelText`
84
+ 3. `getByText`
85
+ 4. `getByPlaceholderText`
86
+
87
+ Never `getByTestId`.
88
+
89
+ ## Network mocking — MSW
90
+
91
+ ```ts
92
+ import { http, HttpResponse } from 'msw'
93
+ import { setupServer } from 'msw/node'
94
+
95
+ const server = setupServer(
96
+ http.get('/api/tracks', () => HttpResponse.json(trackFixtures)),
97
+ )
98
+
99
+ beforeAll(() => server.listen())
100
+ afterEach(() => server.resetHandlers())
101
+ afterAll(() => server.close())
102
+ ```
103
+
104
+ Never stub `fetch`, `axios`, or any HTTP client directly.
105
+
106
+ ## Port stubs — in-memory implementations
107
+
108
+ ```ts
109
+ // Good — typed in-memory implementation of the port
110
+ const repo = mkInMemoryTrackRepository()
111
+
112
+ // Bad — partial vi.fn() mock
113
+ const repo = { findById: vi.fn().mockResolvedValue(track) }
114
+ ```
115
+
116
+ `vi.fn()` is permitted only for standalone callbacks (`onClose`, `onSelect`, etc.).
117
+
118
+ ## Factories
119
+
120
+ ```ts
121
+ // tests/factories/track.factory.ts
122
+ const makeTrack = (overrides: Partial<Track> = {}): Track => ({
123
+ id: mkTrackId('test-track-001'),
124
+ title: 'Default Title',
125
+ artistId: mkArtistId('test-artist-001'),
126
+ ...overrides,
127
+ })
128
+ ```
129
+
130
+ - Factories live in `tests/factories/` — never inline raw object literals
131
+ - Fixture IDs are deterministic strings that cannot collide with real data
132
+ - Never copy IDs from production or staging environments
133
+
134
+ ## What to cover
135
+
136
+ | Layer | Must cover |
137
+ |---|---|
138
+ | Core | Every export · every smart constructor boundary · every error path · laws via fast-check |
139
+ | Use-case | Success path · each distinct `Err` variant · any enforced invariant |
140
+ | API handler | Each missing required field (422) · success status + headers · each `ApiError` variant · auth failure |
141
+ | DAL | Insert+read round-trip · not-found → `None` · constraint violation → typed `DataError` |
142
+ | React | Renders · user interactions · loading state · error state · keyboard/ARIA |
143
+
144
+ ## Never
145
+
146
+ - `data-testid` queries
147
+ - `vi.fn()` to implement a port interface
148
+ - Assert that an internal function was called — assert on the observable outcome
149
+ - `any` in test code
150
+ - `setTimeout` delays — use `waitFor` or `findBy*`
151
+ - `beforeAll` for state that mutates between tests
152
+ - Snapshot tests for component structure or API response shape
153
+ - Direct access to non-exported symbols or internal state
154
+ - `shallow` rendering
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tsfpp/agents",
3
- "version": "1.2.3",
3
+ "version": "1.3.0",
4
4
  "description": "Workspace AI tooling for TSF++ projects: scoped instructions, coding agents, and reusable prompts",
5
5
  "keywords": [
6
6
  "tsfpp",