@doubleloop-io/oxlint-plugin-effect 0.0.0-stage → 0.1.0-rc.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.
- package/LICENSE +21 -0
- package/LICENSE.anti-slop +21 -0
- package/README.md +210 -2
- package/RULES.md +911 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +71 -0
- package/dist/presets/all.d.ts +7 -0
- package/dist/presets/all.js +9 -0
- package/dist/presets/all.json +37 -0
- package/dist/rules/adapter-import-boundary.d.ts +7 -0
- package/dist/rules/adapter-import-boundary.js +103 -0
- package/dist/rules/anti-slop-no-manual-effect-error-tag.d.ts +1 -0
- package/dist/rules/anti-slop-no-manual-effect-error-tag.js +32 -0
- package/dist/rules/anti-slop-no-service-constructor-imports.d.ts +1 -0
- package/dist/rules/anti-slop-no-service-constructor-imports.js +49 -0
- package/dist/rules/anti-slop-prefer-effect-match.d.ts +1 -0
- package/dist/rules/anti-slop-prefer-effect-match.js +45 -0
- package/dist/rules/boundary-entrypoint-shape.d.ts +1 -0
- package/dist/rules/boundary-entrypoint-shape.js +52 -0
- package/dist/rules/error-handling-in-effect-fn-handler.d.ts +1 -0
- package/dist/rules/error-handling-in-effect-fn-handler.js +66 -0
- package/dist/rules/exported-schema-identifier.d.ts +1 -0
- package/dist/rules/exported-schema-identifier.js +117 -0
- package/dist/rules/no-asymmetric-match-handlers.d.ts +1 -0
- package/dist/rules/no-asymmetric-match-handlers.js +74 -0
- package/dist/rules/no-bare-error-in-effect.d.ts +1 -0
- package/dist/rules/no-bare-error-in-effect.js +34 -0
- package/dist/rules/no-effect-promise.d.ts +1 -0
- package/dist/rules/no-effect-promise.js +57 -0
- package/dist/rules/no-effect-return-type.d.ts +1 -0
- package/dist/rules/no-effect-return-type.js +130 -0
- package/dist/rules/no-inline-schema-codec.d.ts +1 -0
- package/dist/rules/no-inline-schema-codec.js +46 -0
- package/dist/rules/no-inline-yield-star.d.ts +1 -0
- package/dist/rules/no-inline-yield-star.js +230 -0
- package/dist/rules/no-manual-discriminant-comparison.d.ts +2 -0
- package/dist/rules/no-manual-discriminant-comparison.js +58 -0
- package/dist/rules/no-manual-discriminant-construction.d.ts +2 -0
- package/dist/rules/no-manual-discriminant-construction.js +90 -0
- package/dist/rules/no-nested-match.d.ts +1 -0
- package/dist/rules/no-nested-match.js +24 -0
- package/dist/rules/no-parameterless-catch-all.d.ts +1 -0
- package/dist/rules/no-parameterless-catch-all.js +29 -0
- package/dist/rules/no-provide-on-definition.d.ts +1 -0
- package/dist/rules/no-provide-on-definition.js +94 -0
- package/dist/rules/no-transport-types-in-generator.d.ts +1 -0
- package/dist/rules/no-transport-types-in-generator.js +63 -0
- package/dist/rules/no-yielded-match.d.ts +1 -0
- package/dist/rules/no-yielded-match.js +91 -0
- package/dist/rules/prefer-effect-fn.d.ts +1 -0
- package/dist/rules/prefer-effect-fn.js +102 -0
- package/dist/rules/provide-named-layer.d.ts +1 -0
- package/dist/rules/provide-named-layer.js +55 -0
- package/dist/rules/redacted-secret-names.d.ts +1 -0
- package/dist/rules/redacted-secret-names.js +127 -0
- package/dist/rules/run-only-at-entry.d.ts +1 -0
- package/dist/rules/run-only-at-entry.js +50 -0
- package/dist/rules/service-built-with-of.d.ts +1 -0
- package/dist/rules/service-built-with-of.js +74 -0
- package/dist/rules/service-key-matches-name.d.ts +1 -0
- package/dist/rules/service-key-matches-name.js +69 -0
- package/dist/rules/service-port-shape-only.d.ts +1 -0
- package/dist/rules/service-port-shape-only.js +65 -0
- package/dist/rules/single-style-per-function.d.ts +1 -0
- package/dist/rules/single-style-per-function.js +77 -0
- package/dist/rules/try-object-form.d.ts +1 -0
- package/dist/rules/try-object-form.js +26 -0
- package/dist/rules/untraced-recursion.d.ts +1 -0
- package/dist/rules/untraced-recursion.js +48 -0
- package/dist/shared/effect-ast.d.ts +9 -0
- package/dist/shared/effect-ast.js +52 -0
- package/dist/shared/entry-files.d.ts +6 -0
- package/dist/shared/entry-files.js +5 -0
- package/dist/shared/file-globs.d.ts +14 -0
- package/dist/shared/file-globs.js +17 -0
- package/dist/shared/match-ast.d.ts +5 -0
- package/dist/shared/match-ast.js +52 -0
- package/dist/shared/tagged-values.d.ts +17 -0
- package/dist/shared/tagged-values.js +67 -0
- package/dist/shared/test-files.d.ts +6 -0
- package/dist/shared/test-files.js +10 -0
- package/package.json +56 -5
package/RULES.md
ADDED
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
# `@doubleloop-io/oxlint-plugin-effect` rules
|
|
2
|
+
|
|
3
|
+
The plugin's 30 rules. The `Pxx` identifiers come from the origin catalog; the patterns no rule checks are in [`skill/reference/effect-patterns.md`](skill/reference/effect-patterns.md).
|
|
4
|
+
|
|
5
|
+
## Rules index
|
|
6
|
+
|
|
7
|
+
| Rule | Pattern | Severity |
|
|
8
|
+
| --------------------------------------------------------------------------------------- | -------- | -------- |
|
|
9
|
+
| [`service-port-shape-only`](#service-port-shape-only) | P01 | `error` |
|
|
10
|
+
| [`adapter-import-boundary`](#adapter-import-boundary) | P02 | `error` |
|
|
11
|
+
| [`run-only-at-entry`](#run-only-at-entry) | P04, P22 | `error` |
|
|
12
|
+
| [`no-bare-error-in-effect`](#no-bare-error-in-effect) | P05 | `error` |
|
|
13
|
+
| [`try-object-form`](#try-object-form) | P08 | `error` |
|
|
14
|
+
| [`no-effect-promise`](#no-effect-promise) | P08 | `warn` |
|
|
15
|
+
| [`no-parameterless-catch-all`](#no-parameterless-catch-all) | P09 | `error` |
|
|
16
|
+
| [`redacted-secret-names`](#redacted-secret-names) | P13 | `warn` |
|
|
17
|
+
| [`prefer-effect-fn`](#prefer-effect-fn) | P17 | `warn` |
|
|
18
|
+
| [`untraced-recursion`](#untraced-recursion) | P17 | `warn` |
|
|
19
|
+
| [`single-style-per-function`](#single-style-per-function) | P24 | `error` |
|
|
20
|
+
| [`error-handling-in-effect-fn-handler`](#error-handling-in-effect-fn-handler) | P25 | `warn` |
|
|
21
|
+
| [`no-transport-types-in-generator`](#no-transport-types-in-generator) | P26 | `error` |
|
|
22
|
+
| [`boundary-entrypoint-shape`](#boundary-entrypoint-shape) | P27 | `warn` |
|
|
23
|
+
| [`provide-named-layer`](#provide-named-layer) | P28 | `error` |
|
|
24
|
+
| [`no-provide-on-definition`](#no-provide-on-definition) | P28 | `error` |
|
|
25
|
+
| [`no-nested-match`](#no-nested-match) | P30 | `warn` |
|
|
26
|
+
| [`no-inline-yield-star`](#no-inline-yield-star) | P32 | `warn` |
|
|
27
|
+
| [`no-effect-return-type`](#no-effect-return-type) | P33 | `warn` |
|
|
28
|
+
| [`no-asymmetric-match-handlers`](#no-asymmetric-match-handlers) | P34 | `warn` |
|
|
29
|
+
| [`exported-schema-identifier`](#exported-schema-identifier) | P35 | — |
|
|
30
|
+
| [`no-manual-discriminant-comparison`](#no-manual-discriminant-comparison) | P36 | — |
|
|
31
|
+
| [`no-manual-discriminant-construction`](#no-manual-discriminant-construction) | P37 | — |
|
|
32
|
+
| [`service-key-matches-name`](#service-key-matches-name) | P38 | — |
|
|
33
|
+
| [`no-inline-schema-codec`](#no-inline-schema-codec) | P39 | — |
|
|
34
|
+
| [`no-yielded-match`](#no-yielded-match) | P40 | — |
|
|
35
|
+
| [`service-built-with-of`](#service-built-with-of) | P41 | — |
|
|
36
|
+
| [`anti-slop-no-service-constructor-imports`](#anti-slop-no-service-constructor-imports) | — | — |
|
|
37
|
+
| [`anti-slop-no-manual-effect-error-tag`](#anti-slop-no-manual-effect-error-tag) | — | — |
|
|
38
|
+
| [`anti-slop-prefer-effect-match`](#anti-slop-prefer-effect-match) | — | — |
|
|
39
|
+
|
|
40
|
+
The `all` preset enables them all at `warn`. Severity is the one each rule was kept at zero noise with on an Effect 4 codebase: a data point about its stability, not a recommendation. `—` means not yet measured.
|
|
41
|
+
|
|
42
|
+
## Common limits
|
|
43
|
+
|
|
44
|
+
- **Syntax only**: no rule uses type information.
|
|
45
|
+
- **Literal names**: `Effect.gen` is recognized, `import { Effect as E }` or `import * as L from "effect/Layer"` slip through. On code that uses aliases, silence is not compliance.
|
|
46
|
+
- **Effect 4**: the rules also recognize some v3 APIs (`catchAll`, `Effect.Service`, `Context.Tag`), but they are written and tested against v4.
|
|
47
|
+
|
|
48
|
+
## File options
|
|
49
|
+
|
|
50
|
+
When the reason for an exclusion is part of the rule, the rule itself recognizes the files, through an option that **replaces** the defaults (it does not extend them). Globs are matched against the path relative to oxlint's working directory, with `/` on every platform.
|
|
51
|
+
|
|
52
|
+
- **`entryFiles`**, used by [`run-only-at-entry`](#run-only-at-entry) and [`adapter-import-boundary`](#adapter-import-boundary). Default `**/main.ts`, `**/cli.ts`, `**/bin/**`. `index.ts` is excluded on purpose: it is usually a barrel, and exempting it would let adapters reach the rest of the application through re-exports.
|
|
53
|
+
- **`testFiles`**, used by [`adapter-import-boundary`](#adapter-import-boundary), [`no-effect-promise`](#no-effect-promise), [`provide-named-layer`](#provide-named-layer), [`no-provide-on-definition`](#no-provide-on-definition), [`error-handling-in-effect-fn-handler`](#error-handling-in-effect-fn-handler) and [`anti-slop-no-service-constructor-imports`](#anti-slop-no-service-constructor-imports). Default `**/*.test.*`, `**/*.spec.*`, `**/__tests__/**`, `**/test/**`, `**/tests/**`. A helper directory such as `src/testing/` does not count as tests: it is support code.
|
|
54
|
+
|
|
55
|
+
The defaults come from a Node CLI. On another layout (Next.js, a monorepo) they must be reconfigured before reading the numbers, and every rule sharing an option must be reconfigured:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
const entryFiles = ["apps/web/src/app/**/route.ts", "apps/web/src/runtime.ts"]
|
|
59
|
+
|
|
60
|
+
export default defineConfig({
|
|
61
|
+
extends: [dlEffectAll],
|
|
62
|
+
rules: {
|
|
63
|
+
"dl-effect/run-only-at-entry": ["warn", { entryFiles }],
|
|
64
|
+
"dl-effect/adapter-import-boundary": ["warn", { entryFiles }],
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Services and Layers
|
|
70
|
+
|
|
71
|
+
### `service-port-shape-only`
|
|
72
|
+
|
|
73
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
74
|
+
|
|
75
|
+
**Intent**: a port is a shape-only `Context.Service`, with no implementation attached.
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
// no
|
|
79
|
+
class Agent extends Context.Service<Agent, Shape>()("app/Agent", { make: makeAgent }) {}
|
|
80
|
+
|
|
81
|
+
// yes
|
|
82
|
+
export class Agent extends Context.Service<
|
|
83
|
+
Agent,
|
|
84
|
+
{ readonly search: (name: string) => Effect.Effect<RaceCard, AgentError> }
|
|
85
|
+
>()("app/agent/Agent") {}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Trigger**: the second argument of `Context.Service<…>()(id, opts)`; a `static layer` or `static Default` member on the class; the v3 forms `Effect.Service`, `Context.Tag`, `Context.GenericTag`.
|
|
89
|
+
|
|
90
|
+
**Exemptions and blind spots**: a `Context.Service` assigned to a variable is not seen: see `service-not-as-class` (effecttsgo).
|
|
91
|
+
|
|
92
|
+
**Why**: `make` and a static layer tie the port to a default implementation, while the port must stay a contract any adapter can satisfy. It is a convention, not Effect's idiom: Effect's own docs put `static readonly layer` on the service.
|
|
93
|
+
|
|
94
|
+
- **Pattern**: P01 · **Options**: none · **Fix**: none · **Severity**: `error`
|
|
95
|
+
|
|
96
|
+
### `service-key-matches-name`
|
|
97
|
+
|
|
98
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
99
|
+
|
|
100
|
+
**Intent**: a service's key is its class name, alone or after a path prefix.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
// no
|
|
104
|
+
export class ForgerockApis extends Context.Service<ForgerockApis, Shape>()("ForgerockAPIs") {}
|
|
105
|
+
|
|
106
|
+
// yes
|
|
107
|
+
export class ForgerockApis extends Context.Service<ForgerockApis, Shape>()("ForgerockApis") {}
|
|
108
|
+
|
|
109
|
+
// yes
|
|
110
|
+
export class ForgerockApis extends Context.Service<ForgerockApis, Shape>()(
|
|
111
|
+
"app/auth/ForgerockApis",
|
|
112
|
+
) {}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**Trigger**: `class X extends Context.Service<…>()("key")` with a literal key that is neither `"X"` nor ends with `"/X"`.
|
|
116
|
+
|
|
117
|
+
**Exemptions and blind spots**: keys that are not literals (variables, templates with expressions). A `Context.Service` assigned to a variable is not seen: see `service-not-as-class` (effecttsgo). `deterministic-keys` (effecttsgo) with the `default` or `package-identifier` formula produces keys this rule accepts; with `default-hashed` the two contradict each other: enable only one.
|
|
118
|
+
|
|
119
|
+
**Why**: the key is the service's identity in errors, traces and `Context` lookups; a key that drifts from the name, as after a rename, makes it hard to find the class behind a message.
|
|
120
|
+
|
|
121
|
+
- **Pattern**: P38 · **Options**: none · **Fix**: suggestion that replaces the last segment of the key with the class name · **Severity**: —
|
|
122
|
+
|
|
123
|
+
### `service-built-with-of`
|
|
124
|
+
|
|
125
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
126
|
+
|
|
127
|
+
**Intent**: the service a Layer provides is built with `Tag.of({…})`, not a bare object literal.
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
// no
|
|
131
|
+
export const RepoLive = Layer.effect(
|
|
132
|
+
Repo,
|
|
133
|
+
Effect.gen(function* () {
|
|
134
|
+
const db = yield* Database
|
|
135
|
+
return { find: (id: RepoId) => db.get(id) }
|
|
136
|
+
}),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
// yes
|
|
140
|
+
export const RepoLive = Layer.effect(
|
|
141
|
+
Repo,
|
|
142
|
+
Effect.gen(function* () {
|
|
143
|
+
const db = yield* Database
|
|
144
|
+
return Repo.of({ find: (id: RepoId) => db.get(id) })
|
|
145
|
+
}),
|
|
146
|
+
)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
**Trigger**: an object literal passed as the value of `Layer.succeed(T, {…})`, returned by the function of `Layer.sync(T, () => ({…}))`, or returned by a top-level `return {…}` of the generator of `Layer.effect(T, Effect.gen(…))` or `Layer.scoped`; the curried `Layer.succeed(T)(…)` forms too.
|
|
150
|
+
|
|
151
|
+
**Exemptions and blind spots**: a named factory (`Layer.sync(Repo, makeRepo)`) and a variable (`Layer.succeed(Repo, repo)`) are not inspected; `return` inside an `if` or a nested block, `Effect.gen(…).pipe(Layer.effect(T))` and generators other than `Effect.gen` slip through.
|
|
152
|
+
|
|
153
|
+
**Why**: `T.of` names the service the object implements and keeps it checked against the shape if the literal moves out of the Layer; one form for every Layer.
|
|
154
|
+
|
|
155
|
+
- **Pattern**: P41 · **Options**: none · **Fix**: suggestion that wraps the literal in `T.of(…)` · **Severity**: —
|
|
156
|
+
|
|
157
|
+
### `adapter-import-boundary`
|
|
158
|
+
|
|
159
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
160
|
+
|
|
161
|
+
**Intent**: domain code and other adapters do not import an adapter; only entry files choose it.
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
// no: in src/orders/place-order.ts
|
|
165
|
+
import { GoogleCalendar } from "../calendar/google/GoogleCalendar.ts"
|
|
166
|
+
|
|
167
|
+
// yes: depend on the port, main.ts provides GoogleCalendar.layer
|
|
168
|
+
import { Calendar } from "../calendar/Calendar.ts"
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
**Trigger**: a relative import of a file in another directory that is an adapter: one of its `.ts` files (tests excluded) builds a `Layer` with `Layer.effect`, `succeed`, `sync`, `scoped`, `effectContext`, `unwrap`, `mock` and similar. Composition alone (`mergeAll`, `provide`, `merge`) does not make an adapter. It is the only rule that reads the filesystem.
|
|
172
|
+
|
|
173
|
+
**Exemptions and blind spots**: entry files, where adapters are chosen, and test files, which compose the Layers they exercise. Non-relative imports (path aliases such as `@/calendar/google`) and directory imports through an `index.ts` (`"../google"`) slip through. On a layout other than one directory per adapter, observe its behavior before trusting the numbers. Infrastructure requirements in the port's `R` channel need types: see `leaking-requirements` (effecttsgo).
|
|
174
|
+
|
|
175
|
+
**Why**: the adapter can be replaced without touching its consumers.
|
|
176
|
+
|
|
177
|
+
- **Pattern**: P02 · **Options**: `entryFiles`, `testFiles` ([File options](#file-options)) · **Fix**: none · **Severity**: `error`
|
|
178
|
+
|
|
179
|
+
### `anti-slop-no-service-constructor-imports`
|
|
180
|
+
|
|
181
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
182
|
+
|
|
183
|
+
**Intent**: runtime code obtains a service from its Layer, never by calling its `make…` constructor.
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
// no: in src/runtime.ts
|
|
187
|
+
import { makeIssueService } from "./issue-service.ts"
|
|
188
|
+
|
|
189
|
+
// yes
|
|
190
|
+
import { IssueService } from "./issue-service.ts"
|
|
191
|
+
const issues = yield * IssueService
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**Trigger**: a named import of `make` followed by an uppercase letter (`makeIssueService`, also when renamed) from a relative module.
|
|
195
|
+
|
|
196
|
+
**Exemptions and blind spots**: test files, which build services from their constructors to inject fakes. Non-relative imports (packages, path aliases), namespace and default imports, and constructors with another prefix slip through. The same import can also fire [`adapter-import-boundary`](#adapter-import-boundary).
|
|
197
|
+
|
|
198
|
+
**Why**: calling the constructor wires its dependencies by hand, and its requirements stop propagating to the composition root.
|
|
199
|
+
|
|
200
|
+
- **Pattern**: — · **Options**: `testFiles` ([File options](#file-options)) · **Fix**: none · **Severity**: —
|
|
201
|
+
|
|
202
|
+
### `run-only-at-entry`
|
|
203
|
+
|
|
204
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
205
|
+
|
|
206
|
+
**Intent**: Effects run only at the entry point, and tests run them through the test runtime.
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
// no
|
|
210
|
+
export const check = () => Effect.runPromise(pingDb(url))
|
|
211
|
+
|
|
212
|
+
test("…", async () => {
|
|
213
|
+
await Effect.runPromise(authorize({ role: "Manager" }))
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
// yes: in main.ts
|
|
217
|
+
NodeRuntime.runMain(
|
|
218
|
+
run(process.argv.slice(2)).pipe(Effect.provide(NodeServices.layer)) satisfies Effect.Effect<
|
|
219
|
+
void,
|
|
220
|
+
never
|
|
221
|
+
>,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
// yes: in a test, fakes as Layers
|
|
225
|
+
it.effect("…", () => authorize({ role: "Manager" }).pipe(Effect.provide(sheetWith(row))))
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
**Trigger**: every reference, not just calls, to `Effect.run*`, `NodeRuntime.runMain`, `BunRuntime.runMain` and `ManagedRuntime.make` outside entry files.
|
|
229
|
+
|
|
230
|
+
**Exemptions and blind spots**: test files are not exempt; a module-level `ManagedRuntime` in a test is the typical catch. Without `@effect/vitest`, a single test runner module that provides the test Layer goes in `entryFiles`, and each test returns one call to it; the test runtime, `TestClock` included, is lost.
|
|
231
|
+
|
|
232
|
+
**Why**: logger, Layers and defect handling are configured in one place. In tests, the test runtime brings `TestClock` and fakes replace services where the code reads them.
|
|
233
|
+
|
|
234
|
+
- **Pattern**: P04, P22 · **Options**: `entryFiles` ([File options](#file-options)) · **Fix**: none · **Severity**: `error`
|
|
235
|
+
|
|
236
|
+
## Errors
|
|
237
|
+
|
|
238
|
+
### `no-bare-error-in-effect`
|
|
239
|
+
|
|
240
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
241
|
+
|
|
242
|
+
**Intent**: an expected failure is a tagged error, never a thrown or primitive value.
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
// no
|
|
246
|
+
Effect.gen(function* () {
|
|
247
|
+
if (tab === undefined) throw new Error("tab not found")
|
|
248
|
+
})
|
|
249
|
+
Effect.fail("tab not found")
|
|
250
|
+
|
|
251
|
+
// yes
|
|
252
|
+
export class SheetTabNotFoundError extends Schema.TaggedError<SheetTabNotFoundError>()(
|
|
253
|
+
"SheetTabNotFoundError",
|
|
254
|
+
{ spreadsheetId: Schema.String, message: Schema.String },
|
|
255
|
+
) {}
|
|
256
|
+
|
|
257
|
+
Effect.fail(new SheetTabNotFoundError({ spreadsheetId, message: "tab not found" }))
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
**Trigger**: a `throw` directly inside a generator passed to `Effect.gen`/`Effect.fn`; `Effect.fail` with a literal or a template.
|
|
261
|
+
|
|
262
|
+
**Exemptions and blind spots**: a `throw` inside a nested callback. `Effect.fail(new Error(…))` and `class X extends Error`: see `global-error-in-effect-failure` and `extends-native-error` (effecttsgo).
|
|
263
|
+
|
|
264
|
+
**Why**: the typed error channel discriminates only on tags. A `throw` in the generator becomes a defect, outside the error channel.
|
|
265
|
+
|
|
266
|
+
- **Pattern**: P05 · **Options**: none · **Fix**: none · **Severity**: `error`
|
|
267
|
+
|
|
268
|
+
### `try-object-form`
|
|
269
|
+
|
|
270
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
271
|
+
|
|
272
|
+
**Intent**: `Effect.try` and `Effect.tryPromise` take `{ try, catch }`, with `catch` producing a tagged error.
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
// no
|
|
276
|
+
Effect.tryPromise(() => api.spreadsheets.get({ spreadsheetId }))
|
|
277
|
+
|
|
278
|
+
// yes
|
|
279
|
+
Effect.tryPromise({
|
|
280
|
+
try: () => api.spreadsheets.get({ spreadsheetId }),
|
|
281
|
+
catch: toSheetError(spreadsheetId),
|
|
282
|
+
})
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
**Trigger**: `Effect.try` and `Effect.tryPromise` whose argument is not an object literal.
|
|
286
|
+
|
|
287
|
+
**Exemptions and blind spots**: an options object held in a variable is reported too.
|
|
288
|
+
|
|
289
|
+
**Why**: the thunk form types the error as `Cause.UnknownError` (`UnknownException` in v3), which no caller can handle by tag.
|
|
290
|
+
|
|
291
|
+
- **Pattern**: P08 · **Options**: none · **Fix**: none · **Severity**: `error`
|
|
292
|
+
|
|
293
|
+
### `no-effect-promise`
|
|
294
|
+
|
|
295
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
296
|
+
|
|
297
|
+
**Intent**: a promise that can reject is lifted with [`Effect.tryPromise`](#try-object-form), not `Effect.promise`.
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
// no
|
|
301
|
+
Effect.promise(() => client.connect())
|
|
302
|
+
|
|
303
|
+
// yes
|
|
304
|
+
Effect.tryPromise({ try: () => client.connect(), catch: toConnectionError })
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
**Trigger**: `Effect.promise` outside the release step of `acquireRelease`, `acquireUseRelease` and `addFinalizer`.
|
|
308
|
+
|
|
309
|
+
**Exemptions and blind spots**: the release step, where a promise that cannot reject is the normal case. Test files, where a rejection during setup is a broken test and the defect fails it with the cause. Elsewhere, a promise that truly cannot reject must be suppressed by hand.
|
|
310
|
+
|
|
311
|
+
**Why**: `Effect.promise` turns a rejection into a defect.
|
|
312
|
+
|
|
313
|
+
- **Pattern**: P08 · **Options**: `testFiles` ([File options](#file-options)) · **Fix**: none · **Severity**: `warn`
|
|
314
|
+
|
|
315
|
+
### `no-parameterless-catch-all`
|
|
316
|
+
|
|
317
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
318
|
+
|
|
319
|
+
**Intent**: errors are caught by tag; a catch-all never ignores what it catches.
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
// no
|
|
323
|
+
Effect.catch(() => Effect.succeed(fallback))
|
|
324
|
+
|
|
325
|
+
// yes
|
|
326
|
+
Effect.catchTag("AgentTimeout", () => Effect.succeed(skip))
|
|
327
|
+
Effect.catchTag(["ParseError", "ReservedPortError"], () => Effect.succeed(3000))
|
|
328
|
+
Effect.orElseSucceed(() => fallback)
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
**Trigger**: `Effect.catch`, `catchAll`, `catchCause` and `catchEager` with a parameterless handler.
|
|
332
|
+
|
|
333
|
+
**Exemptions and blind spots**: a catch-all that discriminates on `_tag` by hand: see [`anti-slop-no-manual-effect-error-tag`](#anti-slop-no-manual-effect-error-tag), and `catch-all-to-map-error`, `catch-all-tag-dispatch-to-catch-tag` and `catch-to-ignore` (effecttsgo).
|
|
334
|
+
|
|
335
|
+
**Why**: a catch-all that ignores its parameter also swallows the errors added later, which `catchTag` would leave visible in the type.
|
|
336
|
+
|
|
337
|
+
- **Pattern**: P09 · **Options**: none · **Fix**: none · **Severity**: `error`
|
|
338
|
+
|
|
339
|
+
### `anti-slop-no-manual-effect-error-tag`
|
|
340
|
+
|
|
341
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
342
|
+
|
|
343
|
+
**Intent**: a catch handler does not discriminate errors by `_tag`; `catchTag` and `catchReason` do.
|
|
344
|
+
|
|
345
|
+
```ts
|
|
346
|
+
// no
|
|
347
|
+
program.pipe(Effect.catch((error) => (error._tag === "NotFound" ? recover : Effect.fail(error))))
|
|
348
|
+
|
|
349
|
+
// yes
|
|
350
|
+
program.pipe(Effect.catchTag("NotFound", () => recover))
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
**Trigger**: an equality comparison between `_tag` (or `reason._tag`) and a string literal, or a `switch` on `_tag`, whose nearest enclosing function is a handler passed to `Effect.catch`, `catchAll` or `catchIf`. On `reason._tag` the message proposes `Effect.catchReason(s)`.
|
|
354
|
+
|
|
355
|
+
**Exemptions and blind spots**: handlers of other combinators (`catchTag`, `catchCause`, `mapError`), and `_tag` checks in a function nested inside the handler. Outside these handlers the same checks belong to [`no-manual-discriminant-comparison`](#no-manual-discriminant-comparison): the two never fire on the same node.
|
|
356
|
+
|
|
357
|
+
**Why**: a hand-written dispatch keeps the caught errors in the type even when handled, and silently misses tags added later.
|
|
358
|
+
|
|
359
|
+
- **Pattern**: — · **Options**: none · **Fix**: none · **Severity**: —
|
|
360
|
+
|
|
361
|
+
## Tagged values
|
|
362
|
+
|
|
363
|
+
### `no-manual-discriminant-comparison`
|
|
364
|
+
|
|
365
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
366
|
+
|
|
367
|
+
**Intent**: branching on a union's discriminant goes through an exhaustive match, never through comparisons or `switch`.
|
|
368
|
+
|
|
369
|
+
```ts
|
|
370
|
+
// no
|
|
371
|
+
switch (event._tag) {
|
|
372
|
+
case "Created":
|
|
373
|
+
return onCreated(event)
|
|
374
|
+
case "Deleted":
|
|
375
|
+
return onDeleted(event)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// yes
|
|
379
|
+
Match.value(event).pipe(Match.tagsExhaustive({ Created: onCreated, Deleted: onDeleted }))
|
|
380
|
+
|
|
381
|
+
// yes, with `discriminants: ["_tag", "type"]`
|
|
382
|
+
Match.value(payment).pipe(
|
|
383
|
+
Match.discriminatorsExhaustive("type")({ demetra: fromDemetra, fexco: fromFexco }),
|
|
384
|
+
)
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
**Trigger**: an equality comparison between a discriminant (also `["type"]`) and a string literal, or a `switch` on a discriminant. The discriminants are the names in `discriminants`.
|
|
388
|
+
|
|
389
|
+
**Exemptions and blind spots**: comparisons against a variable rather than a literal. On `_tag`, the handlers covered by [`anti-slop-no-manual-effect-error-tag`](#anti-slop-no-manual-effect-error-tag): the two never fire on the same node. Guard clauses such as `if (x.type === "a") return …` fire too; on `_tag` use `Predicate.isTagged`. An exhaustive ts-pattern match is an equally valid remedy. A name added to `discriminants` also fires on objects outside closed unions (DOM events, external payloads): the list is a choice of the codebase.
|
|
390
|
+
|
|
391
|
+
**Why**: `Match.tagsExhaustive`, `Match.discriminatorsExhaustive` and `$match` fail to compile when a variant is added; a comparison or a `switch` does not.
|
|
392
|
+
|
|
393
|
+
- **Pattern**: P36 · **Options**: `discriminants`, replaces the default `["_tag"]`; add the fields your unions discriminate on (`type`, `kind`) · **Fix**: none · **Severity**: —
|
|
394
|
+
|
|
395
|
+
### `no-manual-discriminant-construction`
|
|
396
|
+
|
|
397
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
398
|
+
|
|
399
|
+
**Intent**: a union's variant is built by its constructor, never as an object literal with the discriminant.
|
|
400
|
+
|
|
401
|
+
```ts
|
|
402
|
+
// no
|
|
403
|
+
const error = { _tag: "NotFound", id }
|
|
404
|
+
|
|
405
|
+
// yes
|
|
406
|
+
const error = new NotFound({ id })
|
|
407
|
+
|
|
408
|
+
// no, with `discriminants: ["_tag", "eventType"]`
|
|
409
|
+
const event = { eventType: "PaymentStarted", id, payment }
|
|
410
|
+
|
|
411
|
+
// yes
|
|
412
|
+
const event = PaymentStartedEvent.make({ id, payment })
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
**Trigger**: an object literal with a discriminant property (also `"type"` or `["type"]`) whose value is a string literal. The discriminants are the names in `discriminants`.
|
|
416
|
+
|
|
417
|
+
**Exemptions and blind spots**: values that are not literals (`"Ready" as const` included). Literals that are patterns or raw input, also nested: the arguments of `Match.when`, `Match.not`, of `.with(…)` on a ts-pattern `match(…)` chain, and the input of `Schema.decode*(X)(…)`/`Schema.encode*(X)(…)`. Transport payloads written as literals fire, and a name added to `discriminants` fires on objects outside closed unions: the list is a choice of the codebase. See `schema-struct-with-tag` (effecttsgo) for `Schema.Struct` with a `_tag` field.
|
|
418
|
+
|
|
419
|
+
**Why**: a literal skips the constructor, so it carries none of the class's prototype, equality or validation, and drifts silently when the type changes.
|
|
420
|
+
|
|
421
|
+
- **Pattern**: P37 · **Options**: `discriminants`, shared with [`no-manual-discriminant-comparison`](#no-manual-discriminant-comparison), default `["_tag"]` · **Fix**: none · **Severity**: —
|
|
422
|
+
|
|
423
|
+
## Config
|
|
424
|
+
|
|
425
|
+
### `redacted-secret-names`
|
|
426
|
+
|
|
427
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
428
|
+
|
|
429
|
+
**Intent**: a secret read from config or declared in a schema is `Redacted`.
|
|
430
|
+
|
|
431
|
+
```ts
|
|
432
|
+
// no
|
|
433
|
+
serviceAccount: Config.schema(NonBlank, "GOOGLE_SERVICE_ACCOUNT_JSON")
|
|
434
|
+
const Session = Schema.Struct({ userId: UserId, accessToken: Schema.String })
|
|
435
|
+
|
|
436
|
+
// yes
|
|
437
|
+
serviceAccount: Config.schema(Schema.Redacted(NonBlank), "GOOGLE_SERVICE_ACCOUNT_JSON")
|
|
438
|
+
const Session = Schema.Struct({ userId: UserId, accessToken: Schema.Redacted(Schema.String) })
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
**Trigger**:
|
|
442
|
+
|
|
443
|
+
- a literal name read with `Config.*` that ends with `KEY`, `SECRET`, `TOKEN`, `PASSWORD`, `CREDENTIAL`, `CREDENTIALS` or `_JSON` (service account keys often travel as inline JSON), when it does not go through `Config.redacted` or `Config.schema(Schema.Redacted…(…), name)`;
|
|
444
|
+
- a field of `Schema.Struct({…})` or `Schema.Class<X>("X")({…})` whose name is a `secretFields` entry or ends with it capitalized (`password` matches `dbPassword`), declared with a `Schema.*` constant such as `Schema.String`, also through `.pipe(…)`, `.check(…)`, `.annotate(…)` or `Schema.optional`/`optionalKey`/`NullOr`/`UndefinedOr`/`NullishOr`, with no `Schema.Redacted…` along the way.
|
|
445
|
+
|
|
446
|
+
**Exemptions and blind spots**: a naming heuristic: secrets with other names slip through, non-secrets with these names must be suppressed. Fields declared with a named schema (`accessToken: AccessToken`) are not inspected; other constructors (`Schema.TaggedClass`, `Schema.TaggedStruct`, …) are not seen. `process.env` read outside `Config`: see `process-env` and `process-env-in-effect` (effecttsgo).
|
|
447
|
+
|
|
448
|
+
**Why**: a `Redacted` value does not end up in logs or `toString`.
|
|
449
|
+
|
|
450
|
+
- **Pattern**: P13 · **Options**: `secretFields`, default `["password", "secret", "apiKey", "accessToken", "refreshToken", "idToken"]` (no generic `token`), replaced by the configured list; `Config` names are fixed · **Fix**: none · **Severity**: `warn`
|
|
451
|
+
|
|
452
|
+
## Schema
|
|
453
|
+
|
|
454
|
+
### `exported-schema-identifier`
|
|
455
|
+
|
|
456
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
457
|
+
|
|
458
|
+
**Intent**: an exported schema carries an `identifier` equal to its name.
|
|
459
|
+
|
|
460
|
+
```ts
|
|
461
|
+
// no
|
|
462
|
+
export const EventBusEvents = Schema.Union([Created, Deleted])
|
|
463
|
+
|
|
464
|
+
// yes
|
|
465
|
+
export const EventBusEvents = Schema.Union([Created, Deleted]).annotate({
|
|
466
|
+
identifier: "EventBusEvents",
|
|
467
|
+
})
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
**Trigger**: `export const X = Schema.Struct(…)`, `Schema.Union(…)` or `Schema.Array(…)`, also followed by `.pipe(…)`, `.annotate(…)` or `.check(…)`, with no `identifier` in a `.annotate(…)` or `Schema.annotate(…)` of the chain, or with a literal `identifier` other than `"X"`.
|
|
471
|
+
|
|
472
|
+
**Exemptions and blind spots**: non-exported schemas (fragments reused through `.fields`), and `Schema.Class` and `Schema.TaggedError`, which carry the name already. A computed `identifier` is not compared. Other constructors (`Schema.TaggedStruct`, `Schema.Literals`, …), `export { X }` lists and chains through other methods slip through. An exported fragment reused only through `.fields` fires too, although the annotation does not reach the schemas that spread it: export the plain fields object instead (`export const BaseFields = { … }`).
|
|
473
|
+
|
|
474
|
+
**Why**: without an `identifier`, decoding errors, JSON Schema and arbitraries describe the schema by its structure instead of its name.
|
|
475
|
+
|
|
476
|
+
- **Pattern**: P35 · **Options**: none · **Fix**: suggestion that adds or corrects the `identifier` · **Severity**: —
|
|
477
|
+
|
|
478
|
+
### `no-inline-schema-codec`
|
|
479
|
+
|
|
480
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
481
|
+
|
|
482
|
+
**Intent**: a decoder or encoder is a named module-level constant, not built and applied inline.
|
|
483
|
+
|
|
484
|
+
```ts
|
|
485
|
+
// no
|
|
486
|
+
const toPayment = (raw: unknown) => Schema.decodeUnknownSync(PaymentResponse)(raw)
|
|
487
|
+
|
|
488
|
+
// yes
|
|
489
|
+
const decodePaymentResponse = Schema.decodeUnknownSync(PaymentResponse)
|
|
490
|
+
|
|
491
|
+
const toPayment = (raw: unknown) => decodePaymentResponse(raw)
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
**Trigger**: an immediate application `Schema.decode*(X)(input)` or `Schema.encode*(X)(input)` (the `Effect`, `Exit`, `Option`, `Promise`, `Result` and `Sync` variants, `Unknown` included) inside a function, test callbacks included.
|
|
495
|
+
|
|
496
|
+
**Exemptions and blind spots**: module scope, and the generator of `Layer.effect(T, Effect.gen(…))` or `Layer.effect(T)(Effect.gen(…))` (also `Layer.scoped`), which run once; functions nested in that generator, such as the service methods, are checked. Codecs built inside a function and applied later (`const decode = Schema.decodeUnknownEffect(X)`), codecs passed as callbacks (`Effect.flatMap(Schema.decodeUnknownEffect(X))`) and a Layer built through `.pipe(Layer.effect(T))` slip through. Whether the codec should be synchronous is not this rule's concern: see `schema-sync` (effecttsgo).
|
|
497
|
+
|
|
498
|
+
**Why**: a named codec says what it decodes at the call site and is built once, instead of at every call.
|
|
499
|
+
|
|
500
|
+
- **Pattern**: P39 · **Options**: none · **Fix**: none · **Severity**: —
|
|
501
|
+
|
|
502
|
+
## Effectful functions
|
|
503
|
+
|
|
504
|
+
### `prefer-effect-fn`
|
|
505
|
+
|
|
506
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
507
|
+
|
|
508
|
+
**Intent**: an effectful function is `Effect.fn("name")`, and a named effect value has a span.
|
|
509
|
+
|
|
510
|
+
```ts
|
|
511
|
+
// no
|
|
512
|
+
const outcomeFor = (name: string) => Effect.gen(function* () { … }).pipe(Effect.catch(toFailed))
|
|
513
|
+
const refresh = Effect.gen(function* () { … })
|
|
514
|
+
|
|
515
|
+
// yes: extra combinators as arguments, not .pipe
|
|
516
|
+
const outcomeFor = Effect.fn("outcomeFor")(function* (name: string) { … }, Effect.catch(toFailed))
|
|
517
|
+
const refresh = Effect.gen(function* () { … }).pipe(Effect.withSpan("refresh"))
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
**Trigger**:
|
|
521
|
+
|
|
522
|
+
- a declared function, a variable or an object member that only returns `Effect.gen(…)`, even with `.pipe`;
|
|
523
|
+
- a variable or an object member whose value is `Effect.gen(…)` without `Effect.withSpan` in its `.pipe`;
|
|
524
|
+
- `Effect.fn(body)` without a name;
|
|
525
|
+
- `.pipe` on `Effect.fn(…)(…)`.
|
|
526
|
+
|
|
527
|
+
**Exemptions and blind spots**: `Effect.gen` inline as an argument (`Layer.effect(Tag, Effect.gen(…))`, `Stream.unwrap`, callbacks, `it.effect`). Recursive functions: see [`untraced-recursion`](#untraced-recursion).
|
|
528
|
+
|
|
529
|
+
**Why**: named stack traces and spans without wrapper functions. Combinators passed as arguments run inside the function's span and receive its arguments: `(effect, name) => effect.pipe(…)`.
|
|
530
|
+
|
|
531
|
+
- **Pattern**: P17 · **Options**: none · **Fix**: none · **Severity**: `warn`
|
|
532
|
+
|
|
533
|
+
### `untraced-recursion`
|
|
534
|
+
|
|
535
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
536
|
+
|
|
537
|
+
**Intent**: a recursive effectful function uses `Effect.fnUntraced`; the entry point that starts the recursion is traced.
|
|
538
|
+
|
|
539
|
+
```ts
|
|
540
|
+
// no
|
|
541
|
+
const walk = Effect.fn("walk")(function* (node: Node) {
|
|
542
|
+
for (const child of node.children) yield* walk(child)
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
// yes
|
|
546
|
+
const walk = Effect.fnUntraced(function* (node: Node) {
|
|
547
|
+
for (const child of node.children) yield* walk(child)
|
|
548
|
+
})
|
|
549
|
+
export const scan = Effect.fn("scan")(function* (root: Node) {
|
|
550
|
+
return yield* walk(root)
|
|
551
|
+
})
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
**Trigger**: an `Effect.fn` assigned to a variable that its own body calls again, even by passing it as a callback.
|
|
555
|
+
|
|
556
|
+
**Exemptions and blind spots**: mutual recursion, recursion in pipeables.
|
|
557
|
+
|
|
558
|
+
**Why**: with `Effect.fn` every level opens a span nested in the previous one.
|
|
559
|
+
|
|
560
|
+
- **Pattern**: P17 · **Options**: none · **Fix**: none · **Severity**: `warn`
|
|
561
|
+
|
|
562
|
+
### `single-style-per-function`
|
|
563
|
+
|
|
564
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
565
|
+
|
|
566
|
+
**Intent**: a function is entirely Effect or not Effect at all.
|
|
567
|
+
|
|
568
|
+
```ts
|
|
569
|
+
// no
|
|
570
|
+
export const POST = async (request: Request) => {
|
|
571
|
+
const form = await request.formData()
|
|
572
|
+
const created = await Effect.runPromise(Effect.either(createEvent(form)))
|
|
573
|
+
// …
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// yes: promises lifted with Effect.tryPromise
|
|
577
|
+
const createEvent = Effect.fn("events.create")(function* (request: Request) {
|
|
578
|
+
const form = yield* Effect.tryPromise({
|
|
579
|
+
try: () => request.formData(),
|
|
580
|
+
catch: () => new BadRequest({ message: "invalid form data" }),
|
|
581
|
+
})
|
|
582
|
+
// …
|
|
583
|
+
})
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
**Trigger**: `async`, `await` and `.then(` in a function that builds Effects: an Effect generator, or a function using an `Effect.*` member. In a `test`/`it` callback (`.only`, `.each(…)` and the like included) the mix is reported once, on the callback.
|
|
587
|
+
|
|
588
|
+
**Exemptions and blind spots**: `Effect.run*` alone does not make a function effectful: it is how an async boundary hands over to Effect, and where it may appear is decided by [`run-only-at-entry`](#run-only-at-entry). In tests the fix is a body that is one Effect (`it.effect`, or one call to the test runner), not a `tryPromise` per promise.
|
|
589
|
+
|
|
590
|
+
**Why**: errors travel on two channels, exceptions and Effect, and neither sees them all.
|
|
591
|
+
|
|
592
|
+
- **Pattern**: P24 · **Options**: none · **Fix**: none · **Severity**: `error`
|
|
593
|
+
|
|
594
|
+
### `error-handling-in-effect-fn-handler`
|
|
595
|
+
|
|
596
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
597
|
+
|
|
598
|
+
**Intent**: the generator holds only the happy path; error handling lives in the second argument of `Effect.fn`.
|
|
599
|
+
|
|
600
|
+
```ts
|
|
601
|
+
// no
|
|
602
|
+
function* () {
|
|
603
|
+
const x = yield* fetchThing.pipe(Effect.catchTag("NotFound", () => Effect.succeed(fallback)))
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// yes: naming the error or giving an alternative is not handling
|
|
607
|
+
function* () {
|
|
608
|
+
const x = yield* fetchThing.pipe(Effect.orElseSucceed(() => fallback))
|
|
609
|
+
const y = yield* decode(raw).pipe(Effect.mapError((error) => new BadRequest({ message: error.message })))
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// yes: translation in the second argument, which also receives the original arguments
|
|
613
|
+
const createEvent = Effect.fn("events.create")(
|
|
614
|
+
function* (request: Request) { /* success only */ },
|
|
615
|
+
(effect, request) => effect.pipe(Effect.catchTags({ BadRequest: (e) => refused(400, e.message) })),
|
|
616
|
+
)
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
**Trigger**: inside a generator passed to `Effect.gen`/`Effect.fn`, nested callbacks included, calls to `Effect.catch*` (`catch`, `catchAll`, `catchTag`, `catchTags`, `catchIf`, `catchCause`, `catchDefect`, `catchReason`, …), `either`, `result`, `exit`, `match*` and `sandbox`.
|
|
620
|
+
|
|
621
|
+
**Exemptions and blind spots**: pipeables of a nested `Effect.fn`. Test files, where the failure is the outcome under observation (`yield* Effect.result(op)` then `expect`). A `.pipe(Effect.catch…)` appended to an `Effect.gen` escapes; [`prefer-effect-fn`](#prefer-effect-fn) closes that gap.
|
|
622
|
+
|
|
623
|
+
**Why**: translation becomes a single exhaustive point instead of catches scattered along the flow.
|
|
624
|
+
|
|
625
|
+
- **Pattern**: P25 · **Options**: `testFiles` ([File options](#file-options)) · **Fix**: none · **Severity**: `warn`
|
|
626
|
+
|
|
627
|
+
### `no-transport-types-in-generator`
|
|
628
|
+
|
|
629
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
630
|
+
|
|
631
|
+
**Intent**: the generator returns a domain value; conversion to a transport type happens in the second argument of `Effect.fn`.
|
|
632
|
+
|
|
633
|
+
```ts
|
|
634
|
+
// no: an error branch inside the happy path
|
|
635
|
+
function* (request: Request) {
|
|
636
|
+
const credential = yield* sessionCredential
|
|
637
|
+
if (credential === undefined) return Response.redirect(new URL("/login", origin), 303)
|
|
638
|
+
// …
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// yes
|
|
642
|
+
const createEvent = Effect.fn("events.create")(
|
|
643
|
+
function* (request: Request) {
|
|
644
|
+
const credential = yield* sessionCredential // fails with Unauthenticated
|
|
645
|
+
const submission = yield* form(request)
|
|
646
|
+
return yield* apiCreateEvent(credential, submission)
|
|
647
|
+
},
|
|
648
|
+
(effect, request) =>
|
|
649
|
+
effect.pipe(
|
|
650
|
+
Effect.map((created) => Response.redirect(eventUrl(request, created), 303)),
|
|
651
|
+
Effect.catchTags({ Unauthenticated: () => Effect.succeed(toLogin(request)) }),
|
|
652
|
+
),
|
|
653
|
+
)
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
**Trigger**: `new T(…)`, `T.method(…)` and `T(…)` inside an Effect generator, nested callbacks included, when `T` is a transport type.
|
|
657
|
+
|
|
658
|
+
**Exemptions and blind spots**: pipeables of `Effect.fn`, where the conversion must live. `return yield* new Unauthenticated(…)` is a declared failure. An early `return` of a domain value that actually signals an error is indistinguishable from a success.
|
|
659
|
+
|
|
660
|
+
**Why**: an early `return new Response(…)` on the happy path is an error branch disguised as success, and escapes exhaustive translation.
|
|
661
|
+
|
|
662
|
+
- **Pattern**: P26 · **Options**: `transportTypes`, replaces the default `["Response", "NextResponse"]`; add your own transport DTOs · **Fix**: none · **Severity**: `error`
|
|
663
|
+
|
|
664
|
+
### `boundary-entrypoint-shape`
|
|
665
|
+
|
|
666
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
667
|
+
|
|
668
|
+
**Intent**: the entry point runs an Effect whose error channel is closed to `never`.
|
|
669
|
+
|
|
670
|
+
```ts
|
|
671
|
+
// no
|
|
672
|
+
export const POST = (request: Request) => Effect.runPromise(createEvent(request))
|
|
673
|
+
|
|
674
|
+
// yes: in an entry file
|
|
675
|
+
export const POST = (request: Request) =>
|
|
676
|
+
Effect.runPromise(createEvent(request) satisfies Effect.Effect<Response, never>)
|
|
677
|
+
|
|
678
|
+
// yes: a typed helper
|
|
679
|
+
const runBoundary = (effect: Effect.Effect<Response, never, never>) => Effect.runPromise(effect)
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
**Trigger**: `Effect.runPromise`, `runSync`, `runFork`, `runCallback` and `NodeRuntime.runMain`/`BunRuntime.runMain` whose argument is neither `… satisfies Effect.Effect<A, never>` nor a parameter declared with that type.
|
|
683
|
+
|
|
684
|
+
**Exemptions and blind spots**: the `*Exit` variants, which return failures as values. The rule checks the shape; `tsc` gives the guarantee.
|
|
685
|
+
|
|
686
|
+
**Why**: `Effect.runPromise` turns any error left over into a rejected promise, a generic 500. With `never`, a new untranslated error does not compile, and `R = never` forces all Layers to be provided.
|
|
687
|
+
|
|
688
|
+
- **Pattern**: P27 · **Options**: none · **Fix**: none · **Severity**: `warn`
|
|
689
|
+
|
|
690
|
+
## Wiring and code shape
|
|
691
|
+
|
|
692
|
+
### `provide-named-layer`
|
|
693
|
+
|
|
694
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
695
|
+
|
|
696
|
+
**Intent**: `Effect.provide` receives a named Layer, never an inline composition.
|
|
697
|
+
|
|
698
|
+
```ts
|
|
699
|
+
// no
|
|
700
|
+
printMissing.pipe(Effect.provide(Layer.mergeAll(FileRaceList.layer, GoogleSheet.readerLayer)))
|
|
701
|
+
|
|
702
|
+
// yes: the Layer is named in the entry point, provided in the dispatch
|
|
703
|
+
const missingLayer = Layer.mergeAll(FileRaceList.layer, GoogleSheet.readerLayer)
|
|
704
|
+
Missing: () => printMissing.pipe(Effect.provide(missingLayer)),
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
**Trigger**: an `Effect.provide` argument composed inline: `Layer.*(…)`, a ternary, an `&&`/`||`, a `match`/`$match`.
|
|
708
|
+
|
|
709
|
+
**Exemptions and blind spots**: test files, where different fakes per test are idiomatic. Where `provide` sits (outside the entry point) needs types: see `strict-effect-provide` (effecttsgo).
|
|
710
|
+
|
|
711
|
+
**Why**: an inline Layer cannot be reused and hides which adapters a branch chooses. When branches need different Layers, there is one `provide` per branch, all in the dispatch: a single Layer with every service would build adapters the command does not use.
|
|
712
|
+
|
|
713
|
+
- **Pattern**: P28 · **Options**: `testFiles` ([File options](#file-options)) · **Fix**: none · **Severity**: `error`
|
|
714
|
+
|
|
715
|
+
### `no-provide-on-definition`
|
|
716
|
+
|
|
717
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
718
|
+
|
|
719
|
+
**Intent**: a program does not provide its own Layers; its caller does.
|
|
720
|
+
|
|
721
|
+
```ts
|
|
722
|
+
// no
|
|
723
|
+
const printMissing = Effect.gen(function* () { … }).pipe(Effect.provide(missingLayer))
|
|
724
|
+
|
|
725
|
+
// yes: see provide-named-layer
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
**Trigger**: `Effect.provide` applied to `Effect.gen(…)` or to `Effect.fn(…)(…)`, in all three forms: with `.pipe`, as an `Effect.fn` pipeable, data-first.
|
|
729
|
+
|
|
730
|
+
**Exemptions and blind spots**: test files. A context captured in an enclosing generator with `const ctx = yield* Effect.context<R>()` (also followed by `.pipe(…)`) and handed back to a method of the Layer being built: the caller chose the adapters when it built the Layer. A context obtained through a helper (`yield* contextWithoutSpan()`) is still reported. `provide` on a named program is allowed; `strict-effect-provide` (effecttsgo) reports it too.
|
|
731
|
+
|
|
732
|
+
**Why**: a program that provides its own Layers can no longer be wired to different adapters by its caller.
|
|
733
|
+
|
|
734
|
+
- **Pattern**: P28 · **Options**: `testFiles` ([File options](#file-options)) · **Fix**: none · **Severity**: `error`
|
|
735
|
+
|
|
736
|
+
### `no-nested-match`
|
|
737
|
+
|
|
738
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
739
|
+
|
|
740
|
+
**Intent**: a match handler contains neither another match nor a ternary.
|
|
741
|
+
|
|
742
|
+
```ts
|
|
743
|
+
// no
|
|
744
|
+
const run = (argv: ReadonlyArray<string>) =>
|
|
745
|
+
Result.match(parseArgs(argv), {
|
|
746
|
+
onFailure: usageError,
|
|
747
|
+
onSuccess: (command) =>
|
|
748
|
+
Option.match(command.only, { onNone: () => fillAll, onSome: fillOnly }),
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
// yes, outside a generator: a named handler
|
|
752
|
+
const fill = (command: CliCommand) =>
|
|
753
|
+
Option.match(command.only, { onNone: () => fillAll, onSome: fillOnly })
|
|
754
|
+
|
|
755
|
+
const run = (argv: ReadonlyArray<string>) =>
|
|
756
|
+
Result.match(parseArgs(argv), { onFailure: usageError, onSuccess: fill })
|
|
757
|
+
|
|
758
|
+
// yes, inside a generator: guard clauses
|
|
759
|
+
const run = Effect.fn("run")(function* (argv: ReadonlyArray<string>) {
|
|
760
|
+
const parsed = parseArgs(argv)
|
|
761
|
+
if (Result.isFailure(parsed)) return yield* usageError(parsed.failure)
|
|
762
|
+
|
|
763
|
+
const { only } = parsed.success
|
|
764
|
+
if (Option.isNone(only)) return yield* fillAll
|
|
765
|
+
|
|
766
|
+
return yield* fillOnly(only.value)
|
|
767
|
+
})
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
**Trigger**: a handler of `Option.match`, `Result.match`, `Exit.match`, `Either.match`, `$match` or `Match.valueTags` that is itself a data-last match (also `Match.type().pipe(…)` and `pipe(Match.type(), …)`), or that returns a match, a `Match.value(…).pipe(…)` or `pipe(Match.value(…), …)` chain or a ternary.
|
|
771
|
+
|
|
772
|
+
**Exemptions and blind spots**: `if` inside handlers, and whatever they return from inside an `if` or another nested block; the handlers of an outer `Match.value`/`Match.type` chain; matches nested deeper than the returned value, or assigned to a variable before being returned.
|
|
773
|
+
|
|
774
|
+
**Why**: two nested levels of choice hide which branch is the normal one.
|
|
775
|
+
|
|
776
|
+
- **Pattern**: P30 · **Options**: none · **Fix**: none · **Severity**: `warn`
|
|
777
|
+
|
|
778
|
+
### `no-yielded-match`
|
|
779
|
+
|
|
780
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
781
|
+
|
|
782
|
+
**Intent**: inside an Effect generator, return early on the empty or failed case instead of yielding a match.
|
|
783
|
+
|
|
784
|
+
```ts
|
|
785
|
+
// no
|
|
786
|
+
const save = Effect.fn("save")(function* (id: PolicyId) {
|
|
787
|
+
const found = yield* findPolicy(id)
|
|
788
|
+
return yield* Option.match(found, {
|
|
789
|
+
onNone: () => register(id),
|
|
790
|
+
onSome: (policy) => update(policy),
|
|
791
|
+
})
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
// yes
|
|
795
|
+
const save = Effect.fn("save")(function* (id: PolicyId) {
|
|
796
|
+
const found = yield* findPolicy(id)
|
|
797
|
+
if (Option.isNone(found)) return yield* register(id)
|
|
798
|
+
|
|
799
|
+
return yield* update(found.value)
|
|
800
|
+
})
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
**Trigger**: `yield*` of `Option.match(x, {…})` or `Result.match(x, {…})`, or of a `x.pipe(…)`/`pipe(x, …)` ending in `Option.match({…})`/`Result.match({…})`, in the generator of `Effect.gen` or `Effect.fn`.
|
|
804
|
+
|
|
805
|
+
**Exemptions and blind spots**: an `Option.match` with `onNone` → `Effect.fail(…)` and `onSome` → `Effect.succeed`: see `option-match-to-from-option` (effecttsgo). A handler that `no-nested-match` reports. Matches not yielded directly (assigned first, or inside `Effect.flatMap`) slip through.
|
|
806
|
+
|
|
807
|
+
**Why**: the match splits the generator into two equal-weight branches; a guard clause leaves one main path of `yield*`.
|
|
808
|
+
|
|
809
|
+
- **Pattern**: P40 · **Options**: none · **Fix**: none · **Severity**: —
|
|
810
|
+
|
|
811
|
+
### `no-inline-yield-star`
|
|
812
|
+
|
|
813
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
814
|
+
|
|
815
|
+
**Intent**: a `yield*` result is bound to a `const` before being used.
|
|
816
|
+
|
|
817
|
+
```ts
|
|
818
|
+
// no
|
|
819
|
+
return yield * missingIn(yield * readSheet())
|
|
820
|
+
|
|
821
|
+
// yes
|
|
822
|
+
const sheet = yield * readSheet()
|
|
823
|
+
return yield * missingIn(sheet)
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
**Trigger**: a `yield*` whose result is used inside a larger expression: argument, operand, member, property, template.
|
|
827
|
+
|
|
828
|
+
**Exemptions and blind spots**: a `yield*` that is the whole value of a statement (`yield* x`, `return yield* x`, `if (yield* x)`, `for (… of yield* x)`), a declaration or an assignment. Also the whole branch of a ternary, or the right operand of `&&`/`||`/`??`, in one of those positions (`const a = cached ?? (yield* x)`): binding first would run the effect unconditionally. The autofix extracts every nested `yield*` into `const`s, innermost first, named after the argument's last identifier without a verb prefix (`readSheet()` → `sheet`). There is no autofix when moving the effect could change when or how often it runs: branches of a ternary or `&&`/`||`/`??` inside a larger expression, optional chaining, `while`/`for` conditions, statements without braces, another call evaluated before the `yield*`.
|
|
829
|
+
|
|
830
|
+
**Why**: with several `yield*`s in one expression the order of effects can only be inferred from precedence. One `const` per effect makes the order the reading order and names the intermediate value.
|
|
831
|
+
|
|
832
|
+
- **Pattern**: P32 · **Options**: none · **Fix**: autofix · **Severity**: `warn`
|
|
833
|
+
|
|
834
|
+
### `no-effect-return-type`
|
|
835
|
+
|
|
836
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
837
|
+
|
|
838
|
+
**Intent**: `Effect`, `Stream` and `Layer` return types are inferred, not annotated, except on exported declarations.
|
|
839
|
+
|
|
840
|
+
```ts
|
|
841
|
+
// no
|
|
842
|
+
(effect, name): Effect.Effect<RaceFillResult, never, Agent | SheetReader | SheetWriter> =>
|
|
843
|
+
|
|
844
|
+
// yes
|
|
845
|
+
(effect, name) =>
|
|
846
|
+
|
|
847
|
+
// yes: an exported declaration keeps its annotation as a contract
|
|
848
|
+
export const findPrice = (id: PriceId): Effect.Effect<Price, PriceNotFound> =>
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
**Trigger**: `Effect.Effect`, `Stream.Stream` or `Layer.Layer` in an annotated return type, even nested (`Option.Option<Effect.Effect<…>>`, a function returning an `Effect`).
|
|
852
|
+
|
|
853
|
+
**Exemptions and blind spots**: module-level declarations with `export` (`export const`, `export function`, `export default`), unless `checkExported` is on; methods of an exported object, functions an exported function returns, and `export { f }` lists are still checked. Recursion, and returns of different shapes that need unifying (`Effect.succeed` and `Effect.fail`), recognized from the shape of the `return`s. An `import type { Effect }` used as `Effect<…>` slips through.
|
|
854
|
+
|
|
855
|
+
**Why**: the error and requirement channels collect what comes from everything the function calls; an error or a service added deep down would change every caller's annotation. An exported signature is the module's contract, like a named type: there the annotation makes such a change visible where it is published.
|
|
856
|
+
|
|
857
|
+
- **Pattern**: P33 · **Options**: `checkExported`, default `false`: also checks exported declarations · **Fix**: suggestion that removes the annotation · **Severity**: `warn`
|
|
858
|
+
|
|
859
|
+
### `no-asymmetric-match-handlers`
|
|
860
|
+
|
|
861
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
862
|
+
|
|
863
|
+
**Intent**: if one match handler delegates to a named function, the others do too.
|
|
864
|
+
|
|
865
|
+
```ts
|
|
866
|
+
// no
|
|
867
|
+
Option.match(only, {
|
|
868
|
+
onNone: () => missingIn(sheet),
|
|
869
|
+
onSome: Effect.fn("onlyCandidate")(function* (name: string) { … }),
|
|
870
|
+
})
|
|
871
|
+
|
|
872
|
+
// yes
|
|
873
|
+
Option.match(only, {
|
|
874
|
+
onNone: () => missingIn(sheet),
|
|
875
|
+
onSome: (name) => onlyCandidate(sheet, name),
|
|
876
|
+
})
|
|
877
|
+
```
|
|
878
|
+
|
|
879
|
+
**Trigger**: on the same matches as [`no-nested-match`](#no-nested-match), a handler with an inline body next to one that delegates. A handler delegates if it is a reference, a call, or a function returning only a call; it is inline if it has multiple statements, is a generator, or builds an `Effect.fn`/`Effect.gen`.
|
|
880
|
+
|
|
881
|
+
**Exemptions and blind spots**: returned literals, templates, arrays and objects are values and count on neither side.
|
|
882
|
+
|
|
883
|
+
**Why**: a branch saying _what_ next to one showing _how_ forces the reader to switch level halfway through the choice.
|
|
884
|
+
|
|
885
|
+
- **Pattern**: P34 · **Options**: none · **Fix**: none · **Severity**: `warn`
|
|
886
|
+
|
|
887
|
+
### `anti-slop-prefer-effect-match`
|
|
888
|
+
|
|
889
|
+
<sup>[↑ rules index](#rules-index)</sup>
|
|
890
|
+
|
|
891
|
+
**Intent**: a choice among several literal values of the same expression is a `Match`, not a chain of ternaries.
|
|
892
|
+
|
|
893
|
+
```ts
|
|
894
|
+
// no
|
|
895
|
+
const label = kind === "a" ? "A" : kind === "b" ? "B" : "Other"
|
|
896
|
+
|
|
897
|
+
// yes
|
|
898
|
+
const label = Match.value(kind).pipe(
|
|
899
|
+
Match.when("a", () => "A"),
|
|
900
|
+
Match.when("b", () => "B"),
|
|
901
|
+
Match.orElse(() => "Other"),
|
|
902
|
+
)
|
|
903
|
+
```
|
|
904
|
+
|
|
905
|
+
**Trigger**: a ternary chain of at least two levels where every condition compares the same expression, by source text, with a literal (`==`, `===`, `!=`, `!==`).
|
|
906
|
+
|
|
907
|
+
**Exemptions and blind spots**: a single ternary, chains comparing different expressions, and the same expression written differently (`a.b` vs `a["b"]`). A chain returned by a match handler also fires [`no-nested-match`](#no-nested-match): extract the handler first, since a `Match.value` left inside it fires `no-nested-match` again.
|
|
908
|
+
|
|
909
|
+
**Why**: a ternary chain hides which value is the fallback and cannot be made exhaustive.
|
|
910
|
+
|
|
911
|
+
- **Pattern**: — · **Options**: none · **Fix**: none · **Severity**: —
|