@deslop/coding-standards 0.0.0-stage → 0.1.761
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/dist/MutableRef-DT4DAKeI.mjs +5851 -0
- package/dist/install.d.ts +1 -0
- package/dist/install.js +24769 -0
- package/dist/oxlint.d.ts +4669 -0
- package/dist/oxlint.js +3076 -0
- package/package.json +62 -3
- package/skills/design/SKILL.md +107 -0
- package/skills/engineering/SKILL.md +562 -0
- package/skills/testing/SKILL.md +63 -0
- package/src/css-properties.d.ts +9 -0
- package/tsconfig.json +34 -0
- package/README.md +0 -3
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: engineering
|
|
3
|
+
description: 'Engineering rules for product code, paired with the shared lint. Use before designing or editing code, writing tests, or reviewing a diff.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
**Scope.** Apply these rules and repository-specific CODING_STANDARDS.md. CLI-installed copies are read-only; improve their package source, then refresh through the CLI.
|
|
7
|
+
|
|
8
|
+
**Sources.** Match manifest/lock versions; inspect cloned upstream and nearest usage, never node_modules. Library APIs do not define coding policy.
|
|
9
|
+
|
|
10
|
+
**Diagnostics.** Use the shown idiom, not casts, weakened rules or a rewrite trading one diagnostic for another.
|
|
11
|
+
|
|
12
|
+
## Simplicity
|
|
13
|
+
|
|
14
|
+
**Clarity.** Plain, explicit, idiomatic code, names and files; read top to bottom without commentary or cleverness.
|
|
15
|
+
|
|
16
|
+
- **Requested only.** Build the smallest thing that does the requested job well and touch only what the request needs; an unrelated improvement is listed for the user. A refactor or cleanup request asks for depth instead: every rule applies to every line of the owned files.
|
|
17
|
+
- **Inward layers.** Domain and service code never import HTTP, RPC, or other transport types.
|
|
18
|
+
- **Building blocks.** Offer services, Layers, and functions the caller composes like any Effect module, never a wrapper that bundles them behind one call such as `serve(app)`.
|
|
19
|
+
- **No machinery.** Behave correctly instead of building hooks, guards, or generators to enforce behavior.
|
|
20
|
+
- **Behavior kept.** A refactor keeps behavior at every usage point. Two differences are accepted and reported with the change: one no usage point observes that makes the code simpler, and one a rule here causes, such as an error keeping its cause or sorted keys, unless a usage point parses it; printed diagnostics are not a contract. Any other behavior change is the user's decision, except a reachable bug's fix.
|
|
21
|
+
- **Reachable bugs.** A bug a real input from a usage point triggers is fixed and reported, with every consumer of the changed output checked. Handling for input no caller produces is deleted; anything that looks intentional or that other code relies on is kept and reported as possibly intentional.
|
|
22
|
+
- **Performance.** Improve performance in the touched code where you know how; measure on a realistic input only when a change claims speed or keeps a slower-looking form. Diagnose at the layer that owns the symptom: distributed spans for RPC/backend dependencies, React commits for rerenders, a browser performance profile for main-thread work. Correlate one real action before changing code, then repeat it on the fix; neither a screenshot nor a service name proves latency or trace coverage. Use the repository's instrumentation and the tool's help; profiling is not a ritual on every change.
|
|
23
|
+
- **Type safety.** A fix never weakens types to make a symptom go away: no widened or erased type, cast, dropped generic, or loosened exported type.
|
|
24
|
+
- **Domain.** Implement the definition the domain uses, such as a cycle for recursion, never the nearest syntactic proxy.
|
|
25
|
+
- **Reread.** Before finishing, reread the diff and delete every line the outcome does not require.
|
|
26
|
+
|
|
27
|
+
**Happy path.** Failures flow through the error channel: no catch, retry, fallback, backup, or defensive check unless the request or an existing contract needs it.
|
|
28
|
+
|
|
29
|
+
**Trusted data.** Validate and transform once, at the boundary, with Effect Schema; inside, carry narrowed values forward and never re-check what the schema, the declared type, an earlier filter, or every caller guarantees.
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
// good
|
|
33
|
+
const add = Effect.fn('Ledger.add')(function* (draft: LedgerDraft) {
|
|
34
|
+
yield* Ref.update(entries, Array.append(draft))
|
|
35
|
+
})
|
|
36
|
+
// bad
|
|
37
|
+
Schema.decodeEffect(LedgerDraft)(draft) // draft is already a LedgerDraft
|
|
38
|
+
variable.init?.type === 'TSSatisfiesExpression' // init was already narrowed to non-null
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Effect first.** Search matching cloned Effect modules before hand-writing traversal, state, configuration or platform operations. Replace dependencies covered by Effect or its platform adapters.
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
// good
|
|
45
|
+
const cycles = Graph.stronglyConnectedComponents(graph)
|
|
46
|
+
BigDecimal.format(amount)
|
|
47
|
+
Duration.parts(DateTime.distance(oldest.timestamp, newest.timestamp)).days
|
|
48
|
+
fs.glob('**/.env*.example', {exclude: ['**/node_modules'], root: config.cwd})
|
|
49
|
+
// bad
|
|
50
|
+
const digits = BigDecimal.scale(BigDecimal.abs(amount), 2).value.toString().padStart(3, '0')
|
|
51
|
+
Math.floor(Duration.toDays(DateTime.distance(oldest.timestamp, newest.timestamp)))
|
|
52
|
+
import {glob} from 'glob' // a dependency Effect's FileSystem replaces
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Whole values.** Pass whole values or spread them. No parameter/callback/loop destructuring except useState and component ref; project fields only to exclude data the recipient must not receive.
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
// good
|
|
59
|
+
server => ({server: {...server, forwardConsole: true, warmup}})
|
|
60
|
+
for (const schema of schemas) report(schema.name)
|
|
61
|
+
Record.map(groups, entries => entries.length)
|
|
62
|
+
upload({scope: input.scope, type: input.type}) // input also holds the bearer token
|
|
63
|
+
// bad
|
|
64
|
+
({host, port}) => ({server: {forwardConsole: true, host, port, warmup}})
|
|
65
|
+
server: {forwardConsole: true, host: config.host, port: config.port, warmup}
|
|
66
|
+
for (const {name} of schemas) report(name)
|
|
67
|
+
Array.map(Record.toEntries(groups), ([key, entries]) => entries.length)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Nearest sibling.** Extend the nearest implementation; match permissions, errors, refresh and tests. Reuse its helpers and change only affected state.
|
|
71
|
+
|
|
72
|
+
**Once.** Extract repeated logic once; one fact has one owner.
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
// good
|
|
76
|
+
const sandboxGone = Effect.mapError(cause => SandboxError.make({cause, message: 'The sandbox is gone'}))
|
|
77
|
+
// bad — the same mapping written in three handlers
|
|
78
|
+
pipe(
|
|
79
|
+
sandbox.exec(input),
|
|
80
|
+
Effect.mapError(cause => SandboxError.make({cause, message: 'The sandbox is gone'}))
|
|
81
|
+
)
|
|
82
|
+
pipe(
|
|
83
|
+
sandbox.read(path),
|
|
84
|
+
Effect.mapError(cause => SandboxError.make({cause, message: 'The sandbox is gone'}))
|
|
85
|
+
)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Canonical data.** Store/send canonical data, derive display values, expose each state once and remove states no caller produces.
|
|
89
|
+
|
|
90
|
+
**One way.** Replace superseded paths; no duplicate/subset/superset fields, methods, options or exports.
|
|
91
|
+
|
|
92
|
+
**No compatibility.** Rename the shape and every caller together. No alias, fallback, deprecated path or flag unless a documented/user-named contract requires it.
|
|
93
|
+
|
|
94
|
+
**Present needs.** No speculative option, abstraction, layer, export, file or check. Configure only what callers vary.
|
|
95
|
+
|
|
96
|
+
**Dead code.** Every superseded or unused file, export, type, branch, config, validation, doc, test, and dependency goes in the same change.
|
|
97
|
+
|
|
98
|
+
## Lint pairs
|
|
99
|
+
|
|
100
|
+
The forms below are the ones lint enforces without an autofix, and the single form that satisfies every rule involved.
|
|
101
|
+
|
|
102
|
+
**Fallback.** A default is `??`; never a ternary on the same value.
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
// good
|
|
106
|
+
const root = options?.root ?? '.'
|
|
107
|
+
// bad — no-negated-condition flips it, then prefer-nullish-coalescing flags it
|
|
108
|
+
const root = options?.root !== undefined ? options.root : '.'
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Conditions.** A boolean is tested bare; `=== true` only on a value that may be undefined.
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
// good
|
|
115
|
+
if (enabled) return
|
|
116
|
+
aria-current={props.selected === true ? 'page' : undefined}
|
|
117
|
+
// bad
|
|
118
|
+
if (enabled === true) return // enabled is a boolean
|
|
119
|
+
if (props.selected) return // props.selected may be undefined
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
**Flat.** Use early-return statements in Effect.fn. No nested ternaries or combinators deeper than equivalent statements. Match/combinators serve single expressions; Boolean.match selects a value, except one-line JSX-attribute ternaries.
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
// good
|
|
126
|
+
const kill = Effect.fn('Sandbox.kill')(function* (sandbox?: Sandbox) {
|
|
127
|
+
if (sandbox?.status !== 'running') return
|
|
128
|
+
|
|
129
|
+
yield* sandbox.kill
|
|
130
|
+
})
|
|
131
|
+
Boolean.match(ignoreCase, {onFalse: () => undefined, onTrue: () => 'i'})
|
|
132
|
+
// bad
|
|
133
|
+
const type = event.type === 'text-delta' ? 'text' : event.type === 'reasoning-delta' ? 'reasoning' : 'unknown'
|
|
134
|
+
const flags = ignoreCase ? 'i' : undefined // "instead of a ternary the Boolean.match would be cleaner"
|
|
135
|
+
// bad
|
|
136
|
+
Array.appendAll(
|
|
137
|
+
base,
|
|
138
|
+
Boolean.match(hasTools, {
|
|
139
|
+
onFalse: () => [],
|
|
140
|
+
onTrue: () =>
|
|
141
|
+
pipe(
|
|
142
|
+
tools,
|
|
143
|
+
Array.map(tool => pipe(tool, Option.toArray))
|
|
144
|
+
)
|
|
145
|
+
})
|
|
146
|
+
)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
**Choices.** `Match` for a union, never `switch`.
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
// good
|
|
153
|
+
pipe(
|
|
154
|
+
Match.value(usage.kind),
|
|
155
|
+
Match.when('input', () => 'in'),
|
|
156
|
+
Match.when('output', () => 'out'),
|
|
157
|
+
Match.exhaustive
|
|
158
|
+
)
|
|
159
|
+
// bad
|
|
160
|
+
switch (usage.kind) {
|
|
161
|
+
case 'input':
|
|
162
|
+
return 'in'
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
**Options.** Use Option operators, never .value. Use Option where it composes; keep plain optional values otherwise. This is our convention, not a claim narrowing is unsafe.
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
// good
|
|
170
|
+
Option.map(Number.parse(event.target.value), field.handleChange)
|
|
171
|
+
Option.map(Array.head(parts), part => part.text)
|
|
172
|
+
// bad
|
|
173
|
+
if (Option.isSome(open)) open.value.append(event.delta)
|
|
174
|
+
const parsed = Option.getOrUndefined(Number.parse(event.target.value))
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
**Handlers.** A handler used once is an arrow in place; a handler used twice is a `function`.
|
|
178
|
+
|
|
179
|
+
**Pairs.** A tuple stays whole, read by index.
|
|
180
|
+
|
|
181
|
+
**Accumulators.** No `let`: a fold or an Effect collection operation that says what it selects.
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
// good
|
|
185
|
+
const total = Array.reduce(values, 0, Number.sum)
|
|
186
|
+
pipe(teamId, String.split(','), Array.map(String.trim), Array.findFirst(String.isNonEmpty))
|
|
187
|
+
Array.filterMap(items, item => (supports(item) ? Result.succeed(item.value) : Result.failVoid))
|
|
188
|
+
// bad
|
|
189
|
+
let total = 0
|
|
190
|
+
for (const value of values) total += value
|
|
191
|
+
const [first] = teamId
|
|
192
|
+
.split(',')
|
|
193
|
+
.map(value => value.trim())
|
|
194
|
+
.filter(value => value.length > 0)
|
|
195
|
+
items.filter(item => supports(item)).map(item => item.value)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
**Natives.** Use Effect modules instead of globals/native methods. Third-party-owned methods stay, with an inline diagnostic disable and reason when needed.
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
// good
|
|
202
|
+
Number.parse(input.count)
|
|
203
|
+
Predicate.isString(value)
|
|
204
|
+
Predicate.isNotNullish(annotation)
|
|
205
|
+
Array.isReadonlyArrayEmpty(node.arguments)
|
|
206
|
+
String.isNonEmpty(event.delta)
|
|
207
|
+
Array.get(items, 0)
|
|
208
|
+
const serve = Effect.gen(function* () {
|
|
209
|
+
const start = yield* Clock.currentTimeMillis
|
|
210
|
+
const host = yield* pipe(Config.String('HOST'), Config.withDefault('0.0.0.0'))
|
|
211
|
+
const password = yield* Config.Redacted('SMTP_PASS')
|
|
212
|
+
yield* Effect.sleep(Duration.millis(delay))
|
|
213
|
+
return {host, password, start}
|
|
214
|
+
})
|
|
215
|
+
// bad
|
|
216
|
+
parseInt(input.count)
|
|
217
|
+
typeof value === 'string'
|
|
218
|
+
annotation !== null && annotation !== undefined
|
|
219
|
+
node.arguments.length === 0
|
|
220
|
+
Effect.promise(() => new Promise(resolve => setTimeout(resolve, delay)))
|
|
221
|
+
process.env.HOST ?? '0.0.0.0'
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
**Pipe.** A standalone `pipe`, nested at most three deep; never the `.pipe` method.
|
|
225
|
+
|
|
226
|
+
**Failures.** In a generator a failure is `return yield* E.make(...)`; outside one, `Effect.fail`.
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
// good
|
|
230
|
+
const text = Effect.fn('Ai.text')(function* (response: AiResponse) {
|
|
231
|
+
if (response.status === 'refused') return yield* AiError.make({message: 'The model refused the prompt'})
|
|
232
|
+
|
|
233
|
+
return response.text
|
|
234
|
+
})
|
|
235
|
+
// bad
|
|
236
|
+
const text = Effect.fn('Ai.text')(function* (response: AiResponse) {
|
|
237
|
+
if (response.status === 'refused') yield* Effect.fail(AiError.make({message: 'The model refused the prompt'}))
|
|
238
|
+
if (response.status === 'refused') throw new Error('The model refused the prompt')
|
|
239
|
+
return response.text
|
|
240
|
+
})
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
**Casts.** No `as`, `any`, or `unknown` annotation: decode unknown data once with a schema at the boundary; `satisfies` and `as const` are the only assertions.
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
// good
|
|
247
|
+
const params = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown))(part.params)
|
|
248
|
+
return Effect.succeed([{text: part.text, type: 'text'} satisfies TextContent])
|
|
249
|
+
// bad — a cast satisfies one rule and the next flags it
|
|
250
|
+
const params = part.params as Record<string, unknown>
|
|
251
|
+
create: Effect.fnUntraced(function* (input: unknown) {
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
**Decoders.** `Schema.decodeUnknownEffect` only for `unknown` input; typed input uses `Schema.decodeEffect`, and JSON text goes through `Schema.fromJsonString`.
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
// good
|
|
258
|
+
const load = Effect.fn('Plugin.load')(function* (path: string) {
|
|
259
|
+
const endpoint = yield* Schema.decodeEffect(Endpoint)(config.endpoint)
|
|
260
|
+
const manifest = yield* Schema.decodeEffect(PackageManifest)(yield* fs.readFileString(path))
|
|
261
|
+
return {endpoint, manifest}
|
|
262
|
+
})
|
|
263
|
+
// bad
|
|
264
|
+
Schema.decodeUnknownEffect(Endpoint)(config.endpoint) // config.endpoint is a string
|
|
265
|
+
JSON.parse(text)
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
**Imports.** Keep module names; only generated shadcn aliases differ. Separate type imports; packages, blank line, then # aliases. Every export has a consumer.
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
// good
|
|
272
|
+
import {Effect, Schema} from 'effect'
|
|
273
|
+
import type {Plugin} from 'vite'
|
|
274
|
+
|
|
275
|
+
import type {PortfolioState} from '#rpcs/contracts.ts'
|
|
276
|
+
// bad — each line is a diagnostic
|
|
277
|
+
import {Schema as S} from 'effect'
|
|
278
|
+
import {type Plugin, createServer} from 'vite'
|
|
279
|
+
export const EntryKind = Schema.Literals(['income', 'expense']) // no importer
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
**Readonly.** Every property of a service shape is readonly, as in Effect's services, and so may class members be; readonly appears nowhere else, except on a `Schema.suspend` cycle's hand-written types.
|
|
283
|
+
|
|
284
|
+
**Disables.** Fix the idiom or reproduce a rule defect. Inline disables require an unavoidable conflict and -- reason; never disable ordering or weaken file configuration. Report dependency-only diagnostics; rename filename violations.
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
// good
|
|
288
|
+
// oxlint-disable-next-line typescript/consistent-type-definitions -- TanStack Router augments this interface by name.
|
|
289
|
+
// @effect-diagnostics-next-line nodeBuiltinImport:off -- Vite's plugin API hands over a Node server.
|
|
290
|
+
// bad
|
|
291
|
+
"exactOptionalPropertyTypes": false
|
|
292
|
+
// oxlint-disable-next-line typescript/consistent-type-assertions
|
|
293
|
+
// oxlint-disable-next-line sort-keys -- keeps the printed order
|
|
294
|
+
// bad
|
|
295
|
+
files: ['packages/components/src/components/agent-browser.tsx', 'packages/components/src/components/form.tsx'],
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
## Effect
|
|
299
|
+
|
|
300
|
+
**Functions.** Traced argument boundary: Effect.fn('Name.method'). Internal helper: Effect.fnUntraced. Argument-free multi-statement effect: Effect.gen; one statement: the effect. Annotated return: Effect.fn.Return.
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
// good
|
|
304
|
+
const prompt = Effect.fn('Ai.prompt')(function* (message: Prompt.UserMessage): Effect.fn.Return<void, AiError> {
|
|
305
|
+
const input = yield* piUserMessage(message)
|
|
306
|
+
yield* send(input)
|
|
307
|
+
})
|
|
308
|
+
const decodePart = Effect.fnUntraced(function* (part: Part) {
|
|
309
|
+
return yield* Schema.decodeEffect(Event)(part)
|
|
310
|
+
})
|
|
311
|
+
const stop = Effect.gen(function* () {
|
|
312
|
+
yield* setStatus('stopping')
|
|
313
|
+
yield* kill
|
|
314
|
+
})
|
|
315
|
+
const restart = setStatus('starting')
|
|
316
|
+
// bad
|
|
317
|
+
fetch: url => Effect.tryPromise(() => fetch(url))
|
|
318
|
+
const decodePart = Effect.fn('decodePart')(function* (part: Part) {}) // a span on an internal helper
|
|
319
|
+
const stop = Effect.gen(function* () {
|
|
320
|
+
yield* setStatus('stopping')
|
|
321
|
+
}) // one statement
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
**Errors.** One domain error per service; preserve cause, redact secrets, map in Effect.fn's pipeline argument. Map SDK unknown errors at the adapter. No catch/retry/fallback without a required contract.
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
// good
|
|
328
|
+
export class AiError extends Schema.TaggedError<AiError>()('AiError', {
|
|
329
|
+
cause: Schema.optional(Schema.Defect()),
|
|
330
|
+
message: Schema.String
|
|
331
|
+
}) {}
|
|
332
|
+
const prompt = Effect.fn('Ai.prompt')(
|
|
333
|
+
function* (message: Prompt.UserMessage) {
|
|
334
|
+
yield* send(message)
|
|
335
|
+
},
|
|
336
|
+
Effect.mapError(cause => AiError.make({cause, message: 'Cannot prompt the agent'}))
|
|
337
|
+
)
|
|
338
|
+
const pages = Effect.tryPromise({
|
|
339
|
+
catch: cause => NotionError.make({cause, message: 'Cannot list pages'}),
|
|
340
|
+
try: () => client.pages.list()
|
|
341
|
+
})
|
|
342
|
+
Effect.mapError(cause => WorkspaceError.make({cause: Redacted.make(cause), operation: 'exec input'}))
|
|
343
|
+
Effect.tapError(() => cleanup)
|
|
344
|
+
// bad
|
|
345
|
+
pipe(
|
|
346
|
+
fs.readFileString(target),
|
|
347
|
+
Effect.catch(() => Effect.succeed(''))
|
|
348
|
+
)
|
|
349
|
+
pipe(loadConfig, Effect.retry(Schedule.recurs(3)))
|
|
350
|
+
Effect.mapError(failure => new LedgerError({reason: failure.message})) // cause dropped, new
|
|
351
|
+
Effect.catch(error => pipe(cleanup, Effect.andThen(Effect.fail(error)))) // fails again with the error it caught
|
|
352
|
+
load: (path: string) => Effect.Effect<void, PlatformError | Schema.SchemaError> // library failures leak from the service
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
**Services.** Inline a sole implementation in its Layer; name layers for multiple backends. Defaults use Context.Reference. Implement with Service.of or satisfies; service methods are readonly properties.
|
|
356
|
+
|
|
357
|
+
```ts
|
|
358
|
+
// good
|
|
359
|
+
export class Ledger extends Context.Service<Ledger, Ledger.Shape>()('@app/ledger/service/Ledger') {
|
|
360
|
+
static readonly layer = Layer.effect(
|
|
361
|
+
this,
|
|
362
|
+
Effect.gen(function* () {
|
|
363
|
+
const entries = yield* Ref.make(Array.empty<LedgerEntry>())
|
|
364
|
+
return Ledger.of({add: add(entries), balanceByTag: balanceByTag(entries)})
|
|
365
|
+
})
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
export class Ai extends Context.Service<Ai, Ai.Agent>()('@app/ai/service/Ai') {
|
|
369
|
+
static layerPi(config: Pi.Config) {
|
|
370
|
+
return Layer.effect(this, makePi(config))
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
export const Verbose = Context.Reference<boolean>('@app/ai/Verbose', {defaultValue: () => false})
|
|
374
|
+
// bad
|
|
375
|
+
class Logger extends Context.Service<Logger>()('Logger', {make: Effect.succeed(service)}) {}
|
|
376
|
+
static readonly layer = Layer.effect(Ledger, makeLedger) // a make with one caller
|
|
377
|
+
query(sql: string): Effect.Effect<Rows> // a method signature instead of a readonly property
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
**Primitives.** Use Effect's primitives directly; rpcs and services already trace, and one scope owns each lifetime.
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
// good
|
|
384
|
+
const connect = Effect.gen(function* () {
|
|
385
|
+
const state = yield* SubscriptionRef.make(PortfolioState.make({}))
|
|
386
|
+
const socket = yield* Effect.acquireRelease(openSocket, closeSocket)
|
|
387
|
+
return {socket, state}
|
|
388
|
+
})
|
|
389
|
+
// bad
|
|
390
|
+
class State {
|
|
391
|
+
update(f: (s: PortfolioState) => PortfolioState) {
|
|
392
|
+
return SubscriptionRef.update(this.ref, f)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const traced = Effect.withSpan('Notes.create')(notes.create(input))
|
|
396
|
+
Effect.addFinalizer(() => closeSocket(socket)) // acquisition and release with different owners
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
**Runtime.** The owned runtime runs the program; a Promise an external contract demands is bridged once, where it is returned.
|
|
400
|
+
|
|
401
|
+
```ts
|
|
402
|
+
// good
|
|
403
|
+
NodeRuntime.runMain(program)
|
|
404
|
+
health: flow(rpc.health, Effect.runPromiseWith(Context.empty())),
|
|
405
|
+
// bad
|
|
406
|
+
const runtime = ManagedRuntime.make(AppLayer)
|
|
407
|
+
const run = <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromise(effect)
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
**Sorting.** `Order` for sorting, `Result` for a fallible pure value.
|
|
411
|
+
|
|
412
|
+
```ts
|
|
413
|
+
// good
|
|
414
|
+
Array.sort(
|
|
415
|
+
entries,
|
|
416
|
+
Order.mapInput(Order.Number, entry => entry.tokens)
|
|
417
|
+
)
|
|
418
|
+
Result.fromOption(Array.head(parts), () => AiError.make({message: 'The model returned no parts'}))
|
|
419
|
+
// bad
|
|
420
|
+
entries.sort((left, right) => left.tokens - right.tokens)
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
## Types and Schema
|
|
424
|
+
|
|
425
|
+
**Inference.** Infer types. Annotate recursion or branches TypeScript cannot unite; use schema types/indexed members in signatures. Keep intentional widening, not repeated inferred generics.
|
|
426
|
+
|
|
427
|
+
```ts
|
|
428
|
+
// good
|
|
429
|
+
const balances = Array.reduce(decoded, HashMap.empty<Tag, BigDecimal.BigDecimal>(), sumByTag)
|
|
430
|
+
const label = MutableRef.make('') // the initializer infers string, not just ''
|
|
431
|
+
const [status, setStatus] = useState<'idle' | 'running'>('idle') // intentional widening
|
|
432
|
+
function visit(node: Node): Result {
|
|
433
|
+
return visit(node.parent)
|
|
434
|
+
}
|
|
435
|
+
function settle(amount: LedgerEntry['amount']) {
|
|
436
|
+
return BigDecimal.format(amount)
|
|
437
|
+
}
|
|
438
|
+
export type Sandbox = ReturnType<typeof fromSdk>
|
|
439
|
+
// bad
|
|
440
|
+
const Input = Schema.Struct({value: Schema.String}) satisfies Schema.Schema<Input>
|
|
441
|
+
const label = MutableRef.make<string>('') // repeats the inferred type
|
|
442
|
+
export type SandboxLike = {id: string; renew: (seconds: number) => Promise<unknown>} // a copy of an inferred shape
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
**Schema pairs.** Type pair immediately before every schema. Structs use Schema.Struct, never Schema.Class; reuse fields by spreading. Call schema.make directly for validation/defaults.
|
|
446
|
+
|
|
447
|
+
```ts
|
|
448
|
+
// good
|
|
449
|
+
export type LedgerDraft = typeof LedgerDraft.Type
|
|
450
|
+
export const LedgerDraft = Schema.Struct({...LedgerEntry.fields, id: Schema.optionalKey(Schema.NonEmptyString)})
|
|
451
|
+
RunEventCursor.make(value.toString())
|
|
452
|
+
trails: pipe(Schema.Array(PortfolioTrail), Schema.withConstructorDefault(Effect.succeed([])))
|
|
453
|
+
// bad
|
|
454
|
+
export type EntryDraft = {amount: EntryAmount; id?: EntryId; tag: string} // a hand-written shape beside the schema
|
|
455
|
+
// bad
|
|
456
|
+
new AiError({message: 'The model returned no text'}) // the schema has make
|
|
457
|
+
function cursorFromBigInt(value: bigint) {
|
|
458
|
+
return RunEventCursor.make(value.toString())
|
|
459
|
+
}
|
|
460
|
+
payload: Schema.Struct(pipe(PortfolioVisitor.fields, Struct.pick(['color', 'id', 'name', 'x', 'y']))) // the schema itself fits
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
**Cycles.** Handwrite each recursive-cycle type; annotate only Schema.suspend thunks. Noncycle types stay inferred; unions follow members.
|
|
464
|
+
|
|
465
|
+
```ts
|
|
466
|
+
// good
|
|
467
|
+
export type CodeStep = typeof CodeStep.Type
|
|
468
|
+
export const CodeStep = Schema.Struct({code: Schema.String, kind: Schema.Literal('code')})
|
|
469
|
+
export type CallStep = {readonly do: readonly Step[]; readonly kind: 'call'}
|
|
470
|
+
export const CallStep = Schema.Struct({
|
|
471
|
+
do: Schema.Array(Schema.suspend((): Schema.Codec<Step> => Step)),
|
|
472
|
+
kind: Schema.Literal('call')
|
|
473
|
+
})
|
|
474
|
+
export type Step = CallStep | CodeStep
|
|
475
|
+
export const Step = Schema.Union([CallStep, CodeStep])
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
**Error predicates.** A predicate is a static member of its tagged error; a struct is checked inline with `Schema.is`.
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
// good
|
|
482
|
+
class PathNotFound extends Schema.TaggedError<PathNotFound>()('PathNotFound', {
|
|
483
|
+
cause: Schema.optional(Schema.Defect())
|
|
484
|
+
}) {
|
|
485
|
+
static matches = (error: Cause.UnknownError) => Schema.is(FileNotFound)(error.cause)
|
|
486
|
+
}
|
|
487
|
+
// bad
|
|
488
|
+
function isPathNotFound(error: unknown) {
|
|
489
|
+
return Schema.is(FileNotFound)(error)
|
|
490
|
+
}
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
## Shape
|
|
494
|
+
|
|
495
|
+
**Inline.** Inline forwarding wrappers and single-use helpers/temporaries/constants. Argument-free functions are named values.
|
|
496
|
+
|
|
497
|
+
```ts
|
|
498
|
+
// good
|
|
499
|
+
function randomIndex(length: number) {
|
|
500
|
+
return Random.Random.defaultValue().nextIntUnsafe() % length
|
|
501
|
+
}
|
|
502
|
+
const isApiUrl = Predicate.compose(String.isString, Predicate.or(Equal.equals('/api'), String.startsWith('/api/'))) // a predicate, reused
|
|
503
|
+
static generateText = generateTextPi
|
|
504
|
+
const notFound = HttpServerResponse.empty({status: 404})
|
|
505
|
+
// bad
|
|
506
|
+
const random = Random.Random.defaultValue()
|
|
507
|
+
function isBackendRequest(request: IncomingMessage) {
|
|
508
|
+
return isApiUrl(request.url)
|
|
509
|
+
} // "this is just a wrapper / factory function"
|
|
510
|
+
Ref.set(entries, Array.copy(decoded)) // decoded is already an array
|
|
511
|
+
const runPromise = Effect.runPromiseWith(Context.empty()) // "wrapper function, tmp variable ... that can be inlined"
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
**Linked state.** Linked state moves in one pure updater, never a setter that calls another.
|
|
515
|
+
|
|
516
|
+
```ts
|
|
517
|
+
// good
|
|
518
|
+
setState(current => {
|
|
519
|
+
const draft = {...current.draft, files}
|
|
520
|
+
return {draft, owner: ownerAfter(draft)}
|
|
521
|
+
})
|
|
522
|
+
// bad
|
|
523
|
+
setDraft(current => {
|
|
524
|
+
setOwner(ownerAfter(current))
|
|
525
|
+
return {...current, files}
|
|
526
|
+
})
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
**File tree.** Use explicit kebab-case role names and one naming scheme. Prefer one substantive file to near-empty fragments; colocate <name>.test.ts; no barrels.
|
|
530
|
+
|
|
531
|
+
**Package surface.** Explicit exports for imported subpaths; imports aliases for internal paths. Shared lint preset plus generated ignores only.
|
|
532
|
+
|
|
533
|
+
```ts
|
|
534
|
+
// good
|
|
535
|
+
"exports": {"./plugin": "./src/generated/plugin.ts"}
|
|
536
|
+
export {oxlint as default} from '@deslop/coding-standards'
|
|
537
|
+
// bad — consumers import two names from a barrel of everything
|
|
538
|
+
export * from './generated/Client.ts'
|
|
539
|
+
const warned = ['sort-keys', 'typescript/no-restricted-types'] // a package rule list
|
|
540
|
+
"typecheck": "tsc --noEmit" // type-aware lint already checks types
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
**Generated code.** A generator emits code that follows these rules as closely as it can; lint ignores its output.
|
|
544
|
+
|
|
545
|
+
## React
|
|
546
|
+
|
|
547
|
+
**Components.** React Compiler memoizes; state is destructured at its declaration; refs are props; logic lives in atoms.
|
|
548
|
+
|
|
549
|
+
```tsx
|
|
550
|
+
// good
|
|
551
|
+
const editorRef = useRef<Lexical.LexicalEditor>(null)
|
|
552
|
+
const [showShortcuts, setShowShortcuts] = useState(false)
|
|
553
|
+
const moveRpc = useAtomSet(RpcClient.mutation('portfolio.move'))
|
|
554
|
+
// bad
|
|
555
|
+
const handle = useCallback(() => setOpen(true), [])
|
|
556
|
+
const state = useState(false)
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
- **Pure render.** Derive display values from props and subscribed state during render; never mutate them or perform an RPC, write or subscription there. Event handlers own user-triggered actions; Effects synchronize external systems, not copied state or calculations. Reuse the existing router/atom integration rather than fetching in a new Effect.
|
|
560
|
+
- **State identity.** Keep one owner for each value, stable domain keys for list items and deliberate reset boundaries. Compiler memoization is a performance optimization, never a correctness or lifetime guarantee. Do not add manual memoization, compiler escape directives or weakened hook rules to hide an ownership problem.
|
|
561
|
+
|
|
562
|
+
Testing judgment, fixtures, assertions and verification belong to the testing skill; these engineering rules still apply to test code.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: testing
|
|
3
|
+
description: 'Curated black-box tests and trustworthy verification for Effect applications and tools. Read before writing, reviewing or running tests; apply engineering to the test code.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Testing
|
|
7
|
+
|
|
8
|
+
**Scope.** Guard owned behavior, not coverage. CLI-installed skills are read-only; change their source and refresh via CLI.
|
|
9
|
+
|
|
10
|
+
- **Worth it.** Test owned branching, computation, parsing/state transitions a plausible regression breaks; add missing valuable coverage before refactoring.
|
|
11
|
+
- **Seam.** Test the exported layer, service, or function of a package or an app's service as a black box, so rewriting the implementation leaves every test green; the browser proves UI components.
|
|
12
|
+
- **Redundant.** Delete touched cases merely asserting types/schema rules, Effect/dependency/platform internals, wiring, constants/counts, wording, removed/unreachable behavior or an already-covered input.
|
|
13
|
+
- **Independent.** Expected values come from the contract/worked example, not implementation recomputation. Use the smallest reachable discriminating fixture.
|
|
14
|
+
- **Bugs.** Reproduce a bug before fixing it; its failing case stays only when the fixed logic meets this bar, in the existing test that covers the fixed code.
|
|
15
|
+
- **Throwaway.** Settle a worry with a throwaway test or prototype and delete it once answered.
|
|
16
|
+
- **Honest.** No wrong expectations or rule workarounds. Change assertions only for recorded behavior changes; preserve every still-relevant value.
|
|
17
|
+
|
|
18
|
+
**Assertions.** One representative input per behavior in its existing case; helpers serve every case. Use @effect/vitest assert for returned/flagged input or structured error identity. Doubles use Layers/public parameters, never vi/global/module mocks. Compose once; isolate state at its owning fixture.
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
// good — "test files only for the services/packages public interfaces"
|
|
22
|
+
it.layer(Layer.provideMerge(Ledger.layer, NodeServices.layer))(test => {
|
|
23
|
+
test.effect('keeps the previous entries when the file is malformed', () => run(program))
|
|
24
|
+
})
|
|
25
|
+
assert.deepStrictEqual(customCodes(result.stdout), ['@deslop/coding-standards(no-typeof)'])
|
|
26
|
+
assert.containsSubset(error, {_tag: 'SandboxUnavailable', cause: killed})
|
|
27
|
+
Layer.succeed(OpenSandbox, openSandbox)
|
|
28
|
+
// bad — "1 userful test is better than 1000 useless ones"
|
|
29
|
+
it.effect('rejects an empty tag', () => run(ledger.add({tag: ''}))) // Schema.isNonEmpty already does
|
|
30
|
+
// bad — "this test is useless, it doesn't test that the agent is working"
|
|
31
|
+
it.effect('forwards the prompt', () => run(agent.prompt(message))) // wiring that only shows a call reaching a double
|
|
32
|
+
additional?: {name: string; source: string}[] // a helper option one case needs
|
|
33
|
+
it.effect('loads', () => pipe(program, Effect.provide(Ledger.layer))) // no owned behavior asserted
|
|
34
|
+
assert.isTrue(Array.some(diagnostics, d => d.code === 'typescript(TS2456)')) // another tool's output
|
|
35
|
+
expect(error).toMatchObject({_tag: 'SandboxUnavailable'}) // Vitest's expect
|
|
36
|
+
assert.strictEqual(Array.flatMap(attioProvider.groups, group => group.actions).length, 75) // a pinned count
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Choose cases
|
|
40
|
+
|
|
41
|
+
- **Contract.** Reachable input → observable output; a plausible wrong implementation must fail. Use the nearest public seam, not private helpers/new harnesses.
|
|
42
|
+
- **Branches.** Exercise reachable empty/duplicate/concurrent/dependency-failure distinctions; no Cartesian product or random/repeated happy paths without a hypothesis.
|
|
43
|
+
- **Failure.** Effect.flip for expected failure; Effect.exit for both outcomes. Assert domain identity and preserved state/resources: no partial write, lost value, extra retry or leak. Wording/rejection alone is insufficient; no incidental order assertions.
|
|
44
|
+
- **Control.** Run against the unfixed implementation: the intended assertion fails, not setup/import/compilation. For refactoring, add missing coverage on old behavior first. Keep durable regressions, remove settled probes.
|
|
45
|
+
|
|
46
|
+
## Effect and fixtures
|
|
47
|
+
|
|
48
|
+
- **Entrypoint.** Plain it: sync/Promise public functions. it.effect(name, () => effect): virtual services; it.live: real services. Return Effect from a callback, no runner wrapper. it.layer caches its block context and exposes effect, not live; shared live layers use {excludeTestServices: true}. Verify matched cloned-source APIs/imports.
|
|
49
|
+
- **Lifetime.** it.effect/live supply case Scope; it.layer acquisition belongs to its block. Use scoped platform resources, no redundant whole-case Effect.scoped. To assert cleanup, close the smaller operation scope before inspection.
|
|
50
|
+
- **Isolation.** Shared layers share mutable services/TestClock/TestConsole; Layer.fresh does not reset cached context. Distinct keys, public reset or case-local fixtures; cases pass alone/together, never depend on earlier output.
|
|
51
|
+
- **Clock.** TestClock/TestConsole from effect/testing. Fork, await a dependency-boundary Deferred carrying the actual key/path, adjust time, join; forking alone is not readiness. JavaScript timers/sockets/processes need live services, not TestClock.
|
|
52
|
+
- **Concurrency.** Dependency entered/release pair: await entry, start competing action, release, assert contract. Set Effect.all concurrency for overlap. Cancellation: await acquisition, interrupt and await completion; unrelated outer Scope stays open. Don't test primitives/incidental ordering.
|
|
53
|
+
- **Boundary.** Double only dependencies outside tested logic, via Layer/public parameter. SDK/process/filesystem/HTTP/browser/package integration claims require the real boundary, not matching mocks/spies.
|
|
54
|
+
- **Artifacts.** Public-tool proof uses real packing/declared dependencies, not source imports/stubs. Generate fixtures with the real generator after one representative input settles; never alter expected output to fit a bug.
|
|
55
|
+
- **Platform.** FileSystem.layerNoop is not memory storage; HttpServer.layerServices supplies no filesystem. Use platform Layer/scoped paths. RpcTest bypasses serialization; HttpApiTest bypasses sockets while testing routing/encoding/middleware/decoding. Neither proves deployment/external integration.
|
|
56
|
+
|
|
57
|
+
## Run and reconcile
|
|
58
|
+
|
|
59
|
+
- **Run.** Use root check/fix commands and documented test selectors; confirm intended collection/execution. Zero tests/setup-only/exit0 alone is not proof; no invented final suite.
|
|
60
|
+
- **Inventory.** Collect independent failures once instead of cancelling the suite at the first failure. Distinguish separate cases from repeated observations. A timeout receives one isolated rerun of that same test; after a genuine pass, stop investigating it. Do not call a bypassed selector or a skipped operation flaky.
|
|
61
|
+
- **Evidence.** Preserve the first failure, exact command, execution count and exit status. Separate cause from hypothesis, product defect from setup failure, and a skipped criterion from a passing one. A source read or overall green command never substitutes for a missing behavior check.
|
|
62
|
+
- **Stable inputs.** No writer changes a running check's source, dependencies, configuration or build inputs. A relevant edit invalidates its prior result; rerun affected criteria and reuse unaffected evidence. Independent research or browser journeys may continue without mutating those inputs.
|
|
63
|
+
- **Final proof.** Reconcile every required behavior on the final code. Screenshots/video prove visible outcomes; public-seam execution proves nonvisual behavior; fresh agents prove changed agent instructions. Keep the result and useful evidence, not disposable drivers, fixture installs, reports or preview services.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export {}
|
|
2
|
+
|
|
3
|
+
declare module 'react' {
|
|
4
|
+
// oxlint-disable-next-line typescript/consistent-type-definitions -- React accepts CSS custom properties in style only through this interface augmentation.
|
|
5
|
+
interface CSSProperties {
|
|
6
|
+
// oxlint-disable-next-line typescript/consistent-indexed-object-style -- the augmentation merges this member into React's interface, which a Record type cannot do.
|
|
7
|
+
[property: `--${string}`]: string
|
|
8
|
+
}
|
|
9
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"strict": true,
|
|
4
|
+
"noUnusedLocals": true,
|
|
5
|
+
"noImplicitReturns": true,
|
|
6
|
+
"noErrorTruncation": true,
|
|
7
|
+
"allowUnusedLabels": false,
|
|
8
|
+
"noUnusedParameters": true,
|
|
9
|
+
"noImplicitOverride": true,
|
|
10
|
+
"allowUnreachableCode": false,
|
|
11
|
+
"noUncheckedIndexedAccess": true,
|
|
12
|
+
"noFallthroughCasesInSwitch": true,
|
|
13
|
+
"noPropertyAccessFromIndexSignature": true,
|
|
14
|
+
|
|
15
|
+
"target": "ESNext",
|
|
16
|
+
"module": "ESNext",
|
|
17
|
+
"moduleDetection": "force",
|
|
18
|
+
"moduleResolution": "bundler",
|
|
19
|
+
|
|
20
|
+
"skipLibCheck": true,
|
|
21
|
+
"esModuleInterop": true,
|
|
22
|
+
"isolatedModules": true,
|
|
23
|
+
"resolveJsonModule": true,
|
|
24
|
+
"erasableSyntaxOnly": true,
|
|
25
|
+
"verbatimModuleSyntax": true,
|
|
26
|
+
"allowImportingTsExtensions": true,
|
|
27
|
+
"noUncheckedSideEffectImports": true,
|
|
28
|
+
|
|
29
|
+
"noEmit": true,
|
|
30
|
+
"incremental": false,
|
|
31
|
+
|
|
32
|
+
"plugins": [{"name": "@effect/language-service", "effectFn": ["span", "untraced"]}]
|
|
33
|
+
}
|
|
34
|
+
}
|