@doubleloop-io/oxlint-plugin-effect 0.1.0-rc.1 → 0.1.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +75 -57
  3. package/RULES.md +520 -395
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.js +5 -4
  6. package/dist/presets/all.json +2 -2
  7. package/dist/rules/adapter-import-boundary.js +5 -12
  8. package/dist/rules/all-errors-handled-at-boundary.js +3 -9
  9. package/dist/rules/anti-slop-no-service-constructor-imports.js +1 -8
  10. package/dist/rules/exported-schema-identifier.js +15 -10
  11. package/dist/rules/no-effect-promise.js +1 -8
  12. package/dist/rules/no-manual-discriminant-comparison.js +3 -9
  13. package/dist/rules/no-manual-discriminant-construction.js +3 -9
  14. package/dist/rules/no-provide-on-definition.js +1 -8
  15. package/dist/rules/no-throw-or-primitive-failure.d.ts +1 -0
  16. package/dist/rules/{no-bare-error-in-effect.js → no-throw-or-primitive-failure.js} +2 -9
  17. package/dist/rules/no-yielded-match.js +9 -2
  18. package/dist/rules/prefer-effect-vitest.js +1 -8
  19. package/dist/rules/provide-named-layer.js +1 -8
  20. package/dist/rules/redacted-secret-names.js +18 -5
  21. package/dist/rules/run-only-in-runner.js +1 -8
  22. package/dist/rules/service-key-matches-name.js +15 -6
  23. package/dist/rules/service-port-shape-only.js +10 -18
  24. package/dist/rules/single-style-per-function.js +1 -8
  25. package/dist/rules/split-success-path-from-error-handling.d.ts +1 -0
  26. package/dist/rules/{error-handling-in-effect-fn-handler.js → split-success-path-from-error-handling.js} +2 -9
  27. package/dist/shared/entry-files.d.ts +1 -1
  28. package/dist/shared/file-globs.d.ts +3 -9
  29. package/dist/shared/file-globs.js +3 -12
  30. package/dist/shared/settings.d.ts +5 -0
  31. package/dist/shared/settings.js +26 -0
  32. package/dist/shared/tagged-values.d.ts +2 -8
  33. package/dist/shared/tagged-values.js +3 -14
  34. package/dist/shared/test-files.d.ts +1 -1
  35. package/package.json +6 -4
  36. package/dist/rules/error-handling-in-effect-fn-handler.d.ts +0 -1
  37. package/dist/rules/no-bare-error-in-effect.d.ts +0 -1
package/RULES.md CHANGED
@@ -1,261 +1,89 @@
1
1
  # `@doubleloop-io/oxlint-plugin-effect` rules
2
2
 
3
- The plugin's 31 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).
3
+ The plugin's 31 rules.
4
4
 
5
5
  ## Rules index
6
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-in-runner`](#run-only-in-runner) | P04 | `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
- | [`prefer-effect-vitest`](#prefer-effect-vitest) | P22 | — |
20
- | [`single-style-per-function`](#single-style-per-function) | P24 | `error` |
21
- | [`error-handling-in-effect-fn-handler`](#error-handling-in-effect-fn-handler) | P25 | `warn` |
22
- | [`no-transport-types-in-generator`](#no-transport-types-in-generator) | P26 | `error` |
23
- | [`all-errors-handled-at-boundary`](#all-errors-handled-at-boundary) | P27 | `warn` |
24
- | [`provide-named-layer`](#provide-named-layer) | P28 | `error` |
25
- | [`no-provide-on-definition`](#no-provide-on-definition) | P28 | `error` |
26
- | [`no-nested-match`](#no-nested-match) | P30 | `warn` |
27
- | [`no-inline-yield-star`](#no-inline-yield-star) | P32 | `warn` |
28
- | [`no-effect-return-type`](#no-effect-return-type) | P33 | `warn` |
29
- | [`no-asymmetric-match-handlers`](#no-asymmetric-match-handlers) | P34 | `warn` |
30
- | [`exported-schema-identifier`](#exported-schema-identifier) | P35 | — |
31
- | [`no-manual-discriminant-comparison`](#no-manual-discriminant-comparison) | P36 | — |
32
- | [`no-manual-discriminant-construction`](#no-manual-discriminant-construction) | P37 | — |
33
- | [`service-key-matches-name`](#service-key-matches-name) | P38 | — |
34
- | [`no-inline-schema-codec`](#no-inline-schema-codec) | P39 | — |
35
- | [`no-yielded-match`](#no-yielded-match) | P40 | — |
36
- | [`service-built-with-of`](#service-built-with-of) | P41 | — |
37
- | [`anti-slop-no-service-constructor-imports`](#anti-slop-no-service-constructor-imports) | — | — |
38
- | [`anti-slop-no-manual-effect-error-tag`](#anti-slop-no-manual-effect-error-tag) | — | — |
39
- | [`anti-slop-prefer-effect-match`](#anti-slop-prefer-effect-match) | — | — |
40
-
41
- 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.
7
+ | Group | Rule |
8
+ | --------------------------------------------------- | --------------------------------------------------------------------------------------- |
9
+ | [Error channel](#error-channel) | [`no-throw-or-primitive-failure`](#no-throw-or-primitive-failure) |
10
+ | | [`try-object-form`](#try-object-form) |
11
+ | | [`no-effect-promise`](#no-effect-promise) |
12
+ | | [`no-parameterless-catch-all`](#no-parameterless-catch-all) |
13
+ | | [`split-success-path-from-error-handling`](#split-success-path-from-error-handling) |
14
+ | | [`anti-slop-no-manual-effect-error-tag`](#anti-slop-no-manual-effect-error-tag) |
15
+ | [Tagged values](#tagged-values) | [`no-manual-discriminant-comparison`](#no-manual-discriminant-comparison) |
16
+ | | [`no-manual-discriminant-construction`](#no-manual-discriminant-construction) |
17
+ | [Services and Layers](#services-and-layers) | [`service-port-shape-only`](#service-port-shape-only) |
18
+ | | [`service-key-matches-name`](#service-key-matches-name) |
19
+ | | [`service-built-with-of`](#service-built-with-of) |
20
+ | | [`provide-named-layer`](#provide-named-layer) |
21
+ | | [`no-provide-on-definition`](#no-provide-on-definition) |
22
+ | | [`anti-slop-no-service-constructor-imports`](#anti-slop-no-service-constructor-imports) |
23
+ | [Runtime and entry point](#runtime-and-entry-point) | [`run-only-in-runner`](#run-only-in-runner) |
24
+ | | [`prefer-effect-vitest`](#prefer-effect-vitest) |
25
+ | | [`all-errors-handled-at-boundary`](#all-errors-handled-at-boundary) |
26
+ | [Design and boundaries](#design-and-boundaries) | [`adapter-import-boundary`](#adapter-import-boundary) |
27
+ | | [`no-transport-types-in-generator`](#no-transport-types-in-generator) |
28
+ | [Effectful functions](#effectful-functions) | [`prefer-effect-fn`](#prefer-effect-fn) |
29
+ | | [`untraced-recursion`](#untraced-recursion) |
30
+ | | [`single-style-per-function`](#single-style-per-function) |
31
+ | [Code shape](#code-shape) | [`no-nested-match`](#no-nested-match) |
32
+ | | [`no-yielded-match`](#no-yielded-match) |
33
+ | | [`no-inline-yield-star`](#no-inline-yield-star) |
34
+ | | [`no-effect-return-type`](#no-effect-return-type) |
35
+ | | [`no-asymmetric-match-handlers`](#no-asymmetric-match-handlers) |
36
+ | | [`anti-slop-prefer-effect-match`](#anti-slop-prefer-effect-match) |
37
+ | [Schema](#schema) | [`exported-schema-identifier`](#exported-schema-identifier) |
38
+ | | [`no-inline-schema-codec`](#no-inline-schema-codec) |
39
+ | [Config and secrets](#config-and-secrets) | [`redacted-secret-names`](#redacted-secret-names) |
40
+
41
+ The `all` preset enables them all at `warn`.
42
42
 
43
43
  ## Common limits
44
44
 
45
- - **Syntax only**: no rule uses type information, and only [`prefer-effect-vitest`](#prefer-effect-vitest) looks past the file, to find whether `@effect/vitest` is installed.
45
+ - **Syntax only**: no rule uses type information. Only two rules look past the file: [`adapter-import-boundary`](#adapter-import-boundary), to find which directories are adapters, and [`prefer-effect-vitest`](#prefer-effect-vitest), to find whether `@effect/vitest` is installed.
46
46
  - **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.
47
- - **Effect 4**: the rules also recognize some v3 APIs (`catchAll`, `Effect.Service`, `Context.Tag`), but they are written and tested against v4.
48
-
49
- ## File options
50
-
51
- 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.
52
-
53
- - **`entryFiles`**, used by [`run-only-in-runner`](#run-only-in-runner) 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.
54
- - **`testFiles`**, used by [`run-only-in-runner`](#run-only-in-runner), [`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), [`no-bare-error-in-effect`](#no-bare-error-in-effect), [`single-style-per-function`](#single-style-per-function), [`all-errors-handled-at-boundary`](#all-errors-handled-at-boundary) and [`anti-slop-no-service-constructor-imports`](#anti-slop-no-service-constructor-imports). Those rules skip test files, except `no-bare-error-in-effect`, which skips only primitive failures there, and `single-style-per-function`, which treats their helpers like tests; [`prefer-effect-vitest`](#prefer-effect-vitest) checks only them. Default `**/*.test.*`, `**/*.spec.*`, `**/__tests__/**`, `**/test/**`, `**/tests/**`. A helper directory such as `src/testing/` does not count as tests: it is support code.
55
-
56
- 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:
47
+ - **Effect 4**: the rules are written against v4. Where a v3 API has the same intent, they recognize its v3 name too (`catchAll`, `Either`, `Context.Tag`, `Config.redacted`, …), and the Trigger names it.
48
+
49
+ ## Shared settings
50
+
51
+ What describes the project rather than a rule is declared once, under `settings["dl-effect"]` in the oxlint config, and read by every rule that needs it. A value **replaces** the default (it does not extend it). An unknown key or a value that is not an array of strings stops the lint with an error. File globs are matched against the path relative to oxlint's working directory, with `/` on every platform.
52
+
53
+ - **`entryFiles`**
54
+ - used by:
55
+ - [`run-only-in-runner`](#run-only-in-runner)
56
+ - [`adapter-import-boundary`](#adapter-import-boundary)
57
+ - 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.
58
+ - **`testFiles`**
59
+ - used by:
60
+ - [`run-only-in-runner`](#run-only-in-runner), [`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), [`split-success-path-from-error-handling`](#split-success-path-from-error-handling), [`all-errors-handled-at-boundary`](#all-errors-handled-at-boundary), [`anti-slop-no-service-constructor-imports`](#anti-slop-no-service-constructor-imports): skip test files
61
+ - [`no-throw-or-primitive-failure`](#no-throw-or-primitive-failure): skips only primitive failures there
62
+ - [`single-style-per-function`](#single-style-per-function): treats their helpers like tests
63
+ - [`prefer-effect-vitest`](#prefer-effect-vitest): checks only them
64
+ - default: `**/*.test.*`, `**/*.spec.*`, `**/__tests__/**`, `**/test/**`, `**/tests/**`. A helper directory such as `src/testing/` does not count as tests: it is support code.
65
+ - **`discriminants`**
66
+ - used by:
67
+ - [`no-manual-discriminant-comparison`](#no-manual-discriminant-comparison)
68
+ - [`no-manual-discriminant-construction`](#no-manual-discriminant-construction)
69
+ - default: `_tag`. Add the fields your unions discriminate on (`type`, `kind`).
70
+
71
+ The file defaults come from a Node CLI. On another layout (Next.js, a monorepo) they must be reconfigured before reading the warnings:
57
72
 
58
73
  ```ts
59
- const entryFiles = ["apps/web/src/app/**/route.ts", "apps/web/src/runtime.ts"]
60
-
61
74
  export default defineConfig({
62
75
  extends: [dlEffectAll],
63
- rules: {
64
- "dl-effect/run-only-in-runner": ["warn", { entryFiles }],
65
- "dl-effect/adapter-import-boundary": ["warn", { entryFiles }],
76
+ settings: {
77
+ "dl-effect": { entryFiles: ["apps/web/src/app/**/route.ts", "apps/web/src/runtime.ts"] },
66
78
  },
67
79
  })
68
80
  ```
69
81
 
70
- ## Services and Layers
71
-
72
- ### `service-port-shape-only`
73
-
74
- <sup>[↑ rules index](#rules-index)</sup>
75
-
76
- **Intent**: a port is a shape-only `Context.Service`, with no implementation attached.
77
-
78
- ```ts
79
- // no
80
- class Agent extends Context.Service<Agent, Shape>()("app/Agent", { make: makeAgent }) {}
81
-
82
- // yes
83
- export class Agent extends Context.Service<
84
- Agent,
85
- { readonly search: (name: string) => Effect.Effect<RaceCard, AgentError> }
86
- >()("app/agent/Agent") {}
87
- ```
88
-
89
- **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`.
90
-
91
- **Exemptions and blind spots**: a `Context.Service` assigned to a variable is not seen: see `service-not-as-class` (effecttsgo).
92
-
93
- **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.
94
-
95
- - **Pattern**: P01 · **Options**: none · **Fix**: none · **Severity**: `error`
96
-
97
- ### `service-key-matches-name`
98
-
99
- <sup>[↑ rules index](#rules-index)</sup>
100
-
101
- **Intent**: a service's key is its class name, alone or after a path prefix.
102
-
103
- ```ts
104
- // no
105
- export class ForgerockApis extends Context.Service<ForgerockApis, Shape>()("ForgerockAPIs") {}
106
-
107
- // yes
108
- export class ForgerockApis extends Context.Service<ForgerockApis, Shape>()("ForgerockApis") {}
109
-
110
- // yes
111
- export class ForgerockApis extends Context.Service<ForgerockApis, Shape>()(
112
- "app/auth/ForgerockApis",
113
- ) {}
114
- ```
115
-
116
- **Trigger**: `class X extends Context.Service<…>()("key")` with a literal key that is neither `"X"` nor ends with `"/X"`.
117
-
118
- **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.
119
-
120
- **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.
121
-
122
- - **Pattern**: P38 · **Options**: none · **Fix**: suggestion that replaces the last segment of the key with the class name · **Severity**: —
123
-
124
- ### `service-built-with-of`
125
-
126
- <sup>[↑ rules index](#rules-index)</sup>
127
-
128
- **Intent**: the service a Layer provides is built with `Tag.of({…})`, not a bare object literal.
129
-
130
- ```ts
131
- // no
132
- export const RepoLive = Layer.effect(
133
- Repo,
134
- Effect.gen(function* () {
135
- const db = yield* Database
136
- return { find: (id: RepoId) => db.get(id) }
137
- }),
138
- )
139
-
140
- // yes
141
- export const RepoLive = Layer.effect(
142
- Repo,
143
- Effect.gen(function* () {
144
- const db = yield* Database
145
- return Repo.of({ find: (id: RepoId) => db.get(id) })
146
- }),
147
- )
148
- ```
149
-
150
- **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.
151
-
152
- **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.
153
-
154
- **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.
155
-
156
- - **Pattern**: P41 · **Options**: none · **Fix**: suggestion that wraps the literal in `T.of(…)` · **Severity**: —
157
-
158
- ### `adapter-import-boundary`
159
-
160
- <sup>[↑ rules index](#rules-index)</sup>
161
-
162
- **Intent**: domain code and other adapters do not import an adapter; only entry files choose it.
163
-
164
- ```ts
165
- // no: in src/orders/place-order.ts
166
- import { GoogleCalendar } from "../calendar/google/GoogleCalendar.ts"
167
-
168
- // yes: depend on the port, main.ts provides GoogleCalendar.layer
169
- import { Calendar } from "../calendar/Calendar.ts"
170
- ```
171
-
172
- **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.
173
-
174
- **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).
175
-
176
- **Why**: the adapter can be replaced without touching its consumers.
177
-
178
- - **Pattern**: P02 · **Options**: `entryFiles`, `testFiles` ([File options](#file-options)) · **Fix**: none · **Severity**: `error`
179
-
180
- ### `anti-slop-no-service-constructor-imports`
181
-
182
- <sup>[↑ rules index](#rules-index)</sup>
183
-
184
- **Intent**: runtime code obtains a service from its Layer, never by calling its `make…` constructor.
185
-
186
- ```ts
187
- // no: in src/runtime.ts
188
- import { makeIssueService } from "./issue-service.ts"
189
-
190
- // yes
191
- import { IssueService } from "./issue-service.ts"
192
- const issues = yield * IssueService
193
- ```
194
-
195
- **Trigger**: a named import of `make` followed by an uppercase letter (`makeIssueService`, also when renamed) from a relative module.
196
-
197
- **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).
198
-
199
- **Why**: calling the constructor wires its dependencies by hand, and its requirements stop propagating to the composition root.
200
-
201
- - **Pattern**: — · **Options**: `testFiles` ([File options](#file-options)) · **Fix**: none · **Severity**: —
202
-
203
- ### `run-only-in-runner`
204
-
205
- <sup>[↑ rules index](#rules-index)</sup>
206
-
207
- **Intent**: Effects run only in the program's runner, the entry point.
208
-
209
- ```ts
210
- // no
211
- export const check = () => Effect.runPromise(pingDb(url))
212
-
213
- // yes: in main.ts
214
- NodeRuntime.runMain(
215
- run(process.argv.slice(2)).pipe(Effect.provide(NodeServices.layer)) satisfies Effect.Effect<
216
- void,
217
- never
218
- >,
219
- )
220
- ```
221
-
222
- **Trigger**: every reference, not just calls, to `Effect.run*`, `NodeRuntime.runMain`, `BunRuntime.runMain` and `ManagedRuntime.make` outside entry files.
223
-
224
- **Exemptions and blind spots**: test files, checked by [`prefer-effect-vitest`](#prefer-effect-vitest) instead.
225
-
226
- **Why**: logger, Layers and defect handling are configured in one place.
227
-
228
- - **Pattern**: P04 · **Options**: `entryFiles`, `testFiles` ([File options](#file-options)) · **Fix**: none · **Severity**: `error`
229
-
230
- ### `prefer-effect-vitest`
231
-
232
- <sup>[↑ rules index](#rules-index)</sup>
233
-
234
- **Intent**: tests hand their Effects to `@effect/vitest` instead of running them.
235
-
236
- ```ts
237
- // no
238
- test("…", async () => {
239
- await Effect.runPromise(authorize({ role: "Manager" }))
240
- })
241
-
242
- // yes: fakes as Layers
243
- import { effect } from "@effect/vitest"
244
-
245
- effect("…", () => authorize({ role: "Manager" }).pipe(Effect.provide(sheetWith(row))))
246
- ```
247
-
248
- **Trigger**: the same references as [`run-only-in-runner`](#run-only-in-runner), inside test files only.
249
-
250
- **Exemptions and blind spots**: projects where `@effect/vitest` cannot be found in a `node_modules` from the test file up, since the remedy is out of reach there; scattered runs in their tests go unreported. Every file outside `testFiles`, test helpers such as `src/testing/` included.
251
-
252
- **Why**: the test runtime brings `TestClock` and a scope per test, and fixtures provided as Layers replace services where the code reads them.
253
-
254
- - **Pattern**: P22 · **Options**: `testFiles` ([File options](#file-options)) · **Fix**: none · **Severity**: —
82
+ Settings are not inherited from a preset in `extends` and cannot go in `overrides`: for a value that differs by group of files, override the rules instead.
255
83
 
256
- ## Errors
84
+ ## Error channel
257
85
 
258
- ### `no-bare-error-in-effect`
86
+ ### `no-throw-or-primitive-failure`
259
87
 
260
88
  <sup>[↑ rules index](#rules-index)</sup>
261
89
 
@@ -283,7 +111,11 @@ Effect.fail(new SheetTabNotFoundError({ spreadsheetId, message: "tab not found"
283
111
 
284
112
  **Why**: the typed error channel discriminates only on tags. A `throw` in the generator becomes a defect, outside the error channel.
285
113
 
286
- - **Pattern**: P05 · **Options**: `testFiles` ([File options](#file-options)) · **Fix**: none · **Severity**: `error`
114
+ **Options**:
115
+
116
+ - `testFiles`: shared setting, see [Shared settings](#shared-settings).
117
+
118
+ **Fix**: none
287
119
 
288
120
  ### `try-object-form`
289
121
 
@@ -308,7 +140,9 @@ Effect.tryPromise({
308
140
 
309
141
  **Why**: the thunk form types the error as `Cause.UnknownError` (`UnknownException` in v3), which no caller can handle by tag.
310
142
 
311
- - **Pattern**: P08 · **Options**: none · **Fix**: none · **Severity**: `error`
143
+ **Options**: none
144
+
145
+ **Fix**: none
312
146
 
313
147
  ### `no-effect-promise`
314
148
 
@@ -330,13 +164,17 @@ Effect.tryPromise({ try: () => client.connect(), catch: toConnectionError })
330
164
 
331
165
  **Why**: `Effect.promise` turns a rejection into a defect.
332
166
 
333
- - **Pattern**: P08 · **Options**: `testFiles` ([File options](#file-options)) · **Fix**: none · **Severity**: `warn`
167
+ **Options**:
168
+
169
+ - `testFiles`: shared setting, see [Shared settings](#shared-settings).
170
+
171
+ **Fix**: none
334
172
 
335
173
  ### `no-parameterless-catch-all`
336
174
 
337
175
  <sup>[↑ rules index](#rules-index)</sup>
338
176
 
339
- **Intent**: errors are caught by tag; a catch-all never ignores what it catches.
177
+ **Intent**: errors are caught by tag; a fallback that ignores the error is spelled `orElseSucceed`/`orElse`, never a catch-all with an ignored parameter.
340
178
 
341
179
  ```ts
342
180
  // no
@@ -345,6 +183,8 @@ Effect.catch(() => Effect.succeed(fallback))
345
183
  // yes
346
184
  Effect.catchTag("AgentTimeout", () => Effect.succeed(skip))
347
185
  Effect.catchTag(["ParseError", "ReservedPortError"], () => Effect.succeed(3000))
186
+
187
+ // yes: every failure truly means the same fallback
348
188
  Effect.orElseSucceed(() => fallback)
349
189
  ```
350
190
 
@@ -354,7 +194,46 @@ Effect.orElseSucceed(() => fallback)
354
194
 
355
195
  **Why**: a catch-all that ignores its parameter also swallows the errors added later, which `catchTag` would leave visible in the type.
356
196
 
357
- - **Pattern**: P09 · **Options**: none · **Fix**: none · **Severity**: `error`
197
+ **Options**: none
198
+
199
+ **Fix**: none
200
+
201
+ ### `split-success-path-from-error-handling`
202
+
203
+ <sup>[↑ rules index](#rules-index)</sup>
204
+
205
+ **Intent**: the generator holds only the happy path; error handling lives in the second argument of `Effect.fn`.
206
+
207
+ ```ts
208
+ // no
209
+ function* () {
210
+ const x = yield* fetchThing.pipe(Effect.catchTag("NotFound", () => Effect.succeed(fallback)))
211
+ }
212
+
213
+ // yes: naming the error or giving an alternative is not handling
214
+ function* () {
215
+ const x = yield* fetchThing.pipe(Effect.orElseSucceed(() => fallback))
216
+ const y = yield* decode(raw).pipe(Effect.mapError((error) => new BadRequest({ message: error.message })))
217
+ }
218
+
219
+ // yes: translation in the second argument, which also receives the original arguments
220
+ const createEvent = Effect.fn("events.create")(
221
+ function* (request: Request) { /* success only */ },
222
+ (effect, request) => effect.pipe(Effect.catchTags({ BadRequest: (e) => refused(400, e.message) })),
223
+ )
224
+ ```
225
+
226
+ **Trigger**: inside a generator passed to `Effect.gen`/`Effect.fn`, callbacks passed to `Effect.*` combinators included, calls to `Effect.catch*` (`catch`, `catchAll`, `catchTag`, `catchTags`, `catchIf`, `catchCause`, `catchDefect`, `catchReason`, …), `either`, `result`, `exit`, `match*` and `sandbox`.
227
+
228
+ **Exemptions and blind spots**: pipeables of a nested `Effect.fn`. Handlers inside the callback of an iteration (`forEach`, `filter`, `reduce`, `partition`, `validate`, `findFirst`, …): they handle each element on its own, and in the pipeables the first failure would stop the rest. Functions the generator only defines, such as an `HttpApiBuilder` handler or a service method: they are programs of their own. Test files, where the failure is the outcome under observation (`yield* Effect.result(op)` then `expect`). A `catch`, `catchTag`, `catchTags`, `catchIf` or `catchFilter` whose handlers only die (`Effect.die` passed directly or as the sole return): it is a selective `orDie`, declaring the case untracked rather than handling it; a die behind a wrapper or a condition is still reported. A `.pipe(Effect.catch…)` appended to an `Effect.gen` escapes; [`prefer-effect-fn`](#prefer-effect-fn) closes that gap.
229
+
230
+ **Why**: translation becomes a single exhaustive point instead of catches scattered along the flow.
231
+
232
+ **Options**:
233
+
234
+ - `testFiles`: shared setting, see [Shared settings](#shared-settings).
235
+
236
+ **Fix**: none
358
237
 
359
238
  ### `anti-slop-no-manual-effect-error-tag`
360
239
 
@@ -376,7 +255,9 @@ program.pipe(Effect.catchTag("NotFound", () => recover))
376
255
 
377
256
  **Why**: a hand-written dispatch keeps the caught errors in the type even when handled, and silently misses tags added later.
378
257
 
379
- - **Pattern**: — · **Options**: none · **Fix**: none · **Severity**: —
258
+ **Options**: none
259
+
260
+ **Fix**: none
380
261
 
381
262
  ## Tagged values
382
263
 
@@ -400,7 +281,7 @@ Match.value(event).pipe(Match.tagsExhaustive({ Created: onCreated, Deleted: onDe
400
281
 
401
282
  // yes, with `discriminants: ["_tag", "type"]`
402
283
  Match.value(payment).pipe(
403
- Match.discriminatorsExhaustive("type")({ demetra: fromDemetra, fexco: fromFexco }),
284
+ Match.discriminatorsExhaustive("type")({ card: fromCard, transfer: fromTransfer }),
404
285
  )
405
286
  ```
406
287
 
@@ -410,7 +291,11 @@ Match.value(payment).pipe(
410
291
 
411
292
  **Why**: `Match.tagsExhaustive`, `Match.discriminatorsExhaustive` and `$match` fail to compile when a variant is added; a comparison or a `switch` does not.
412
293
 
413
- - **Pattern**: P36 · **Options**: `discriminants`, replaces the default `["_tag"]`; add the fields your unions discriminate on (`type`, `kind`) · **Fix**: none · **Severity**: —
294
+ **Options**:
295
+
296
+ - `discriminants`: shared setting, see [Shared settings](#shared-settings).
297
+
298
+ **Fix**: none
414
299
 
415
300
  ### `no-manual-discriminant-construction`
416
301
 
@@ -438,211 +323,312 @@ const event = PaymentStartedEvent.make({ id, payment })
438
323
 
439
324
  **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.
440
325
 
441
- - **Pattern**: P37 · **Options**: `discriminants`, shared with [`no-manual-discriminant-comparison`](#no-manual-discriminant-comparison), default `["_tag"]` · **Fix**: none · **Severity**: —
326
+ **Options**:
442
327
 
443
- ## Config
328
+ - `discriminants`: shared setting, see [Shared settings](#shared-settings).
444
329
 
445
- ### `redacted-secret-names`
330
+ **Fix**: none
331
+
332
+ ## Services and Layers
333
+
334
+ ### `service-port-shape-only`
446
335
 
447
336
  <sup>[↑ rules index](#rules-index)</sup>
448
337
 
449
- **Intent**: a secret read from config or declared in a schema is `Redacted`.
338
+ **Intent**: a port is a shape-only `Context.Service` (v3: `Context.Tag`), with no implementation attached.
450
339
 
451
340
  ```ts
452
341
  // no
453
- serviceAccount: Config.schema(NonBlank, "GOOGLE_SERVICE_ACCOUNT_JSON")
454
- const Session = Schema.Struct({ userId: UserId, accessToken: Schema.String })
342
+ class Agent extends Context.Service<Agent, Shape>()("app/Agent", { make: makeAgent }) {}
455
343
 
456
344
  // yes
457
- serviceAccount: Config.schema(Schema.Redacted(NonBlank), "GOOGLE_SERVICE_ACCOUNT_JSON")
458
- const Session = Schema.Struct({ userId: UserId, accessToken: Schema.Redacted(Schema.String) })
345
+ export class Agent extends Context.Service<
346
+ Agent,
347
+ { readonly search: (name: string) => Effect.Effect<RaceCard, AgentError> }
348
+ >()("app/agent/Agent") {}
459
349
  ```
460
350
 
461
- **Trigger**:
351
+ **Trigger**: the second argument of `Context.Service<…>()(id, opts)`; a `static layer`, `static Default` or `static Live` member on a `Context.Service` or v3 `Context.Tag(id)<…>()` class; any `Effect.Service`.
462
352
 
463
- - 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)`;
464
- - 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.
353
+ **Exemptions and blind spots**: a `Context.Service` or v3 `Context.GenericTag` assigned to a variable is not seen: see `service-not-as-class` (effecttsgo). Statics with other names slip through.
465
354
 
466
- **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).
355
+ **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.
467
356
 
468
- **Why**: a `Redacted` value does not end up in logs or `toString`.
357
+ **Options**: none
469
358
 
470
- - **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`
359
+ **Fix**: none
471
360
 
472
- ## Schema
361
+ ### `service-key-matches-name`
473
362
 
474
- ### `exported-schema-identifier`
363
+ <sup>[↑ rules index](#rules-index)</sup>
364
+
365
+ **Intent**: a service's key is its class name, alone or after a path prefix.
366
+
367
+ ```ts
368
+ // no
369
+ export class GeocodingApis extends Context.Service<GeocodingApis, Shape>()("GeocodingAPIs") {}
370
+
371
+ // yes
372
+ export class GeocodingApis extends Context.Service<GeocodingApis, Shape>()("GeocodingApis") {}
373
+
374
+ // yes
375
+ export class GeocodingApis extends Context.Service<GeocodingApis, Shape>()(
376
+ "app/geo/GeocodingApis",
377
+ ) {}
378
+ ```
379
+
380
+ **Trigger**: `class X extends Context.Service<…>()("key")`, or the v3 `Context.Tag("key")<…>()` and `Effect.Service<X>()("key", …)`, with a literal key that is neither `"X"` nor ends with `"/X"`.
381
+
382
+ **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.
383
+
384
+ **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.
385
+
386
+ **Options**: none
387
+
388
+ **Fix**: suggestion that replaces the last segment of the key with the class name
389
+
390
+ ### `service-built-with-of`
475
391
 
476
392
  <sup>[↑ rules index](#rules-index)</sup>
477
393
 
478
- **Intent**: an exported schema carries an `identifier` equal to its name.
394
+ **Intent**: the service a Layer provides is built with `Tag.of({…})`, not a bare object literal.
479
395
 
480
396
  ```ts
481
397
  // no
482
- export const EventBusEvents = Schema.Union([Created, Deleted])
398
+ export const RepoLive = Layer.effect(
399
+ Repo,
400
+ Effect.gen(function* () {
401
+ const db = yield* Database
402
+ return { find: (id: RepoId) => db.get(id) }
403
+ }),
404
+ )
483
405
 
484
406
  // yes
485
- export const EventBusEvents = Schema.Union([Created, Deleted]).annotate({
486
- identifier: "EventBusEvents",
487
- })
407
+ export const RepoLive = Layer.effect(
408
+ Repo,
409
+ Effect.gen(function* () {
410
+ const db = yield* Database
411
+ return Repo.of({ find: (id: RepoId) => db.get(id) })
412
+ }),
413
+ )
488
414
  ```
489
415
 
490
- **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"`.
416
+ **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.
491
417
 
492
- **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 = { … }`).
418
+ **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.
493
419
 
494
- **Why**: without an `identifier`, decoding errors, JSON Schema and arbitraries describe the schema by its structure instead of its name.
420
+ **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.
495
421
 
496
- - **Pattern**: P35 · **Options**: none · **Fix**: suggestion that adds or corrects the `identifier` · **Severity**: —
422
+ **Options**: none
497
423
 
498
- ### `no-inline-schema-codec`
424
+ **Fix**: suggestion that wraps the literal in `T.of(…)`
425
+
426
+ ### `provide-named-layer`
499
427
 
500
428
  <sup>[↑ rules index](#rules-index)</sup>
501
429
 
502
- **Intent**: a decoder or encoder is a named module-level constant, not built and applied inline.
430
+ **Intent**: `Effect.provide` receives a named Layer, never an inline composition.
503
431
 
504
432
  ```ts
505
433
  // no
506
- const toPayment = (raw: unknown) => Schema.decodeUnknownSync(PaymentResponse)(raw)
434
+ printMissing.pipe(Effect.provide(Layer.mergeAll(FileRaceList.layer, GoogleSheet.readerLayer)))
435
+
436
+ // yes: the Layer is named in the entry point, provided in the dispatch
437
+ const missingLayer = Layer.mergeAll(FileRaceList.layer, GoogleSheet.readerLayer)
438
+ Missing: () => printMissing.pipe(Effect.provide(missingLayer)),
439
+ ```
440
+
441
+ **Trigger**: an `Effect.provide` argument composed inline: `Layer.*(…)`, a ternary, an `&&`/`||`, a `match`/`$match`.
442
+
443
+ **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).
444
+
445
+ **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.
446
+
447
+ **Options**:
448
+
449
+ - `testFiles`: shared setting, see [Shared settings](#shared-settings).
450
+
451
+ **Fix**: none
452
+
453
+ ### `no-provide-on-definition`
454
+
455
+ <sup>[↑ rules index](#rules-index)</sup>
456
+
457
+ **Intent**: a program does not provide its own Layers; its caller does.
458
+
459
+ ```ts
460
+ // no
461
+ const printMissing = Effect.gen(function* () { … }).pipe(Effect.provide(missingLayer))
462
+
463
+ // yes: the program stays unprovided, its caller provides
464
+ const printMissing = Effect.gen(function* () { … })
465
+ Missing: () => printMissing.pipe(Effect.provide(missingLayer)),
466
+ ```
467
+
468
+ **Trigger**: `Effect.provide` applied to `Effect.gen(…)` or to `Effect.fn(…)(…)`, in all three forms: with `.pipe`, as an `Effect.fn` pipeable, data-first.
469
+
470
+ **Exemptions and blind spots**: test files. A program passed directly to `Effect.run*`, also through `.pipe(…)` or data-first `Effect.*` combinators (`Effect.runPromise(Effect.gen(…).pipe(Effect.provide(layer), Effect.result))`): it is already being dispatched; through any other function it is still reported. 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.
471
+
472
+ **Why**: a program that provides its own Layers can no longer be wired to different adapters by its caller.
473
+
474
+ **Options**:
475
+
476
+ - `testFiles`: shared setting, see [Shared settings](#shared-settings).
477
+
478
+ **Fix**: none
479
+
480
+ ### `anti-slop-no-service-constructor-imports`
481
+
482
+ <sup>[↑ rules index](#rules-index)</sup>
483
+
484
+ **Intent**: runtime code obtains a service from its Layer, never by calling its `make…` constructor.
485
+
486
+ ```ts
487
+ // no: in src/runtime.ts
488
+ import { makeIssueService } from "./issue-service.ts"
507
489
 
508
490
  // yes
509
- const decodePaymentResponse = Schema.decodeUnknownSync(PaymentResponse)
491
+ import { IssueService } from "./issue-service.ts"
510
492
 
511
- const toPayment = (raw: unknown) => decodePaymentResponse(raw)
493
+ Effect.gen(function* () {
494
+ const issues = yield* IssueService
495
+ })
512
496
  ```
513
497
 
514
- **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.
498
+ **Trigger**: a named import of `make` followed by an uppercase letter (`makeIssueService`, also when renamed) from a relative module.
515
499
 
516
- **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).
500
+ **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).
517
501
 
518
- **Why**: a named codec says what it decodes at the call site and is built once, instead of at every call.
502
+ **Why**: calling the constructor wires its dependencies by hand, and its requirements stop propagating to the composition root.
519
503
 
520
- - **Pattern**: P39 · **Options**: none · **Fix**: none · **Severity**: —
504
+ **Options**:
521
505
 
522
- ## Effectful functions
506
+ - `testFiles`: shared setting, see [Shared settings](#shared-settings).
523
507
 
524
- ### `prefer-effect-fn`
508
+ **Fix**: none
509
+
510
+ ## Runtime and entry point
511
+
512
+ ### `run-only-in-runner`
525
513
 
526
514
  <sup>[↑ rules index](#rules-index)</sup>
527
515
 
528
- **Intent**: an effectful function is `Effect.fn("name")`, and a named effect value has a span.
516
+ **Intent**: Effects run only in the program's runner, the entry point.
529
517
 
530
518
  ```ts
531
519
  // no
532
- const outcomeFor = (name: string) => Effect.gen(function* () { … }).pipe(Effect.catch(toFailed))
533
- const refresh = Effect.gen(function* () { … })
520
+ export const check = () => Effect.runPromise(pingDb(url))
534
521
 
535
- // yes: extra combinators as arguments, not .pipe
536
- const outcomeFor = Effect.fn("outcomeFor")(function* (name: string) { … }, Effect.catch(toFailed))
537
- const refresh = Effect.gen(function* () { … }).pipe(Effect.withSpan("refresh"))
522
+ // yes: in main.ts
523
+ NodeRuntime.runMain(
524
+ run(process.argv.slice(2)).pipe(Effect.provide(NodeServices.layer)) satisfies Effect.Effect<
525
+ void,
526
+ never
527
+ >,
528
+ )
538
529
  ```
539
530
 
540
- **Trigger**:
531
+ **Trigger**: every reference, not just calls, to `Effect.run*`, `NodeRuntime.runMain`, `BunRuntime.runMain` and `ManagedRuntime.make` outside entry files.
541
532
 
542
- - a declared function, a variable or an object member that only returns `Effect.gen(…)`, even with `.pipe`;
543
- - a variable or an object member whose value is `Effect.gen(…)` without `Effect.withSpan` in its `.pipe`;
544
- - `Effect.fn(body)` without a name;
545
- - `.pipe` on `Effect.fn(…)(…)`.
533
+ **Exemptions and blind spots**: test files, checked by [`prefer-effect-vitest`](#prefer-effect-vitest) instead.
534
+
535
+ **Why**: logger, Layers and defect handling are configured in one place.
546
536
 
547
- **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).
537
+ **Options**:
548
538
 
549
- **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(…)`.
539
+ - `entryFiles`: shared setting, see [Shared settings](#shared-settings).
540
+ - `testFiles`: shared setting, see [Shared settings](#shared-settings).
550
541
 
551
- - **Pattern**: P17 · **Options**: none · **Fix**: none · **Severity**: `warn`
542
+ **Fix**: none
552
543
 
553
- ### `untraced-recursion`
544
+ ### `prefer-effect-vitest`
554
545
 
555
546
  <sup>[↑ rules index](#rules-index)</sup>
556
547
 
557
- **Intent**: a recursive effectful function uses `Effect.fnUntraced`; the entry point that starts the recursion is traced.
548
+ **Intent**: tests hand their Effects to `@effect/vitest` instead of running them.
558
549
 
559
550
  ```ts
560
551
  // no
561
- const walk = Effect.fn("walk")(function* (node: Node) {
562
- for (const child of node.children) yield* walk(child)
552
+ test("…", async () => {
553
+ await Effect.runPromise(authorize({ role: "Manager" }))
563
554
  })
564
555
 
565
- // yes
566
- const walk = Effect.fnUntraced(function* (node: Node) {
567
- for (const child of node.children) yield* walk(child)
568
- })
569
- export const scan = Effect.fn("scan")(function* (root: Node) {
570
- return yield* walk(root)
571
- })
556
+ // yes: fakes as Layers
557
+ import { effect } from "@effect/vitest"
558
+
559
+ effect("…", () => authorize({ role: "Manager" }).pipe(Effect.provide(sheetWith(row))))
572
560
  ```
573
561
 
574
- **Trigger**: an `Effect.fn` assigned to a variable that its own body calls again, even by passing it as a callback.
562
+ **Trigger**: the same references as [`run-only-in-runner`](#run-only-in-runner), inside test files only.
575
563
 
576
- **Exemptions and blind spots**: mutual recursion, recursion in pipeables.
564
+ **Exemptions and blind spots**: projects where `@effect/vitest` cannot be found in a `node_modules` from the test file up, since the remedy is out of reach there; scattered runs in their tests go unreported. Every file outside `testFiles`, test helpers such as `src/testing/` included.
577
565
 
578
- **Why**: with `Effect.fn` every level opens a span nested in the previous one.
566
+ **Why**: the test runtime brings `TestClock` and a scope per test, and fixtures provided as Layers replace services where the code reads them.
579
567
 
580
- - **Pattern**: P17 · **Options**: none · **Fix**: none · **Severity**: `warn`
568
+ **Options**:
581
569
 
582
- ### `single-style-per-function`
570
+ - `testFiles`: shared setting, see [Shared settings](#shared-settings).
571
+
572
+ **Fix**: none
573
+
574
+ ### `all-errors-handled-at-boundary`
583
575
 
584
576
  <sup>[↑ rules index](#rules-index)</sup>
585
577
 
586
- **Intent**: a function is entirely Effect or not Effect at all.
578
+ **Intent**: the entry point runs an Effect whose error channel is closed to `never`.
587
579
 
588
580
  ```ts
589
581
  // no
590
- export const POST = async (request: Request) => {
591
- const form = await request.formData()
592
- const created = await Effect.runPromise(Effect.either(createEvent(form)))
593
- // …
594
- }
582
+ export const POST = (request: Request) => Effect.runPromise(createEvent(request))
595
583
 
596
- // yes: promises lifted with Effect.tryPromise
597
- const createEvent = Effect.fn("events.create")(function* (request: Request) {
598
- const form = yield* Effect.tryPromise({
599
- try: () => request.formData(),
600
- catch: () => new BadRequest({ message: "invalid form data" }),
601
- })
602
- // …
603
- })
584
+ // yes: in an entry file
585
+ export const POST = (request: Request) =>
586
+ Effect.runPromise(createEvent(request) satisfies Effect.Effect<Response, never>)
587
+
588
+ // yes: a typed helper
589
+ const runBoundary = (effect: Effect.Effect<Response, never, never>) => Effect.runPromise(effect)
604
590
  ```
605
591
 
606
- **Trigger**: `async`, `await` and `.then(` in a function that builds Effects: an Effect generator, or a function using an `Effect.*` member. A `test`/`it` callback (`.only`, `.each(…)` and the like included) or a `beforeAll`/`afterAll`/`beforeEach`/`afterEach` hook, and in test files any other function except an Effect generator, is reported once, on the function, when more than one of its `await`s, or any `.then(`, involves Effect: has an `Effect.*` member in its expression, nested callbacks and `Effect.run*` included.
592
+ **Trigger**: `Effect.runPromise`, `runSync`, `runFork` and `runCallback` whose argument is not `… satisfies Effect.Effect<A, never>`, a parameter declared with that type, or a program ending in `Effect.result`/`Effect.exit`, or the v3 `Effect.either` (`Effect.result(program)`, `program.pipe(…, Effect.result)`).
607
593
 
608
- **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-in-runner`](#run-only-in-runner). An `async` function that renders JSX itself, not only in nested callbacks, is a Server Component: how it turns failures into UI is left to the application. A test, hook or test helper may await promise-based fixtures (a container, an identity provider), then one Effect program, and assert on its result. A step that reaches Effect only through a helper (`await run(seed)`) is not counted.
594
+ **Exemptions and blind spots**: the `*Exit` variants, which return failures as values; `NodeRuntime.runMain`/`BunRuntime.runMain`, which log the Cause and exit with code 1, the right outcome for a process that cannot start; runners inside an `async` function that renders JSX itself, a Server Component, which hands failures to React rather than to an HTTP response; test files, where a rejected run fails the test with the error. The rule checks the shape; `tsc` gives the guarantee.
609
595
 
610
- **Why**: errors travel on two channels, exceptions and Effect, and neither sees them all.
596
+ **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.
597
+
598
+ **Options**:
599
+
600
+ - `testFiles`: shared setting, see [Shared settings](#shared-settings).
601
+
602
+ **Fix**: none
611
603
 
612
- - **Pattern**: P24 · **Options**: `testFiles` ([File options](#file-options)) · **Fix**: none · **Severity**: `error`
604
+ ## Design and boundaries
613
605
 
614
- ### `error-handling-in-effect-fn-handler`
606
+ ### `adapter-import-boundary`
615
607
 
616
608
  <sup>[↑ rules index](#rules-index)</sup>
617
609
 
618
- **Intent**: the generator holds only the happy path; error handling lives in the second argument of `Effect.fn`.
610
+ **Intent**: domain code and other adapters do not import an adapter; only entry files choose it.
619
611
 
620
612
  ```ts
621
- // no
622
- function* () {
623
- const x = yield* fetchThing.pipe(Effect.catchTag("NotFound", () => Effect.succeed(fallback)))
624
- }
625
-
626
- // yes: naming the error or giving an alternative is not handling
627
- function* () {
628
- const x = yield* fetchThing.pipe(Effect.orElseSucceed(() => fallback))
629
- const y = yield* decode(raw).pipe(Effect.mapError((error) => new BadRequest({ message: error.message })))
630
- }
613
+ // no: in src/orders/place-order.ts
614
+ import { GoogleCalendar } from "../calendar/google/GoogleCalendar.ts"
631
615
 
632
- // yes: translation in the second argument, which also receives the original arguments
633
- const createEvent = Effect.fn("events.create")(
634
- function* (request: Request) { /* success only */ },
635
- (effect, request) => effect.pipe(Effect.catchTags({ BadRequest: (e) => refused(400, e.message) })),
636
- )
616
+ // yes: depend on the port, main.ts provides GoogleCalendar.layer
617
+ import { Calendar } from "../calendar/Calendar.ts"
637
618
  ```
638
619
 
639
- **Trigger**: inside a generator passed to `Effect.gen`/`Effect.fn`, callbacks passed to `Effect.*` combinators included, calls to `Effect.catch*` (`catch`, `catchAll`, `catchTag`, `catchTags`, `catchIf`, `catchCause`, `catchDefect`, `catchReason`, …), `either`, `result`, `exit`, `match*` and `sandbox`.
620
+ **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, or declares a v3 `Effect.Service`, which always carries its implementation. Composition alone (`mergeAll`, `provide`, `merge`) does not make an adapter.
640
621
 
641
- **Exemptions and blind spots**: pipeables of a nested `Effect.fn`. Handlers inside the callback of an iteration (`forEach`, `filter`, `reduce`, `partition`, `validate`, `findFirst`, …): they handle each element on its own, and in the pipeables the first failure would stop the rest. Functions the generator only defines, such as an `HttpApiBuilder` handler or a service method: they are programs of their own. Test files, where the failure is the outcome under observation (`yield* Effect.result(op)` then `expect`). A `catch`, `catchTag`, `catchTags`, `catchIf` or `catchFilter` whose handlers only die (`Effect.die` passed directly or as the sole return): it is a selective `orDie`, declaring the case untracked rather than handling it; a die behind a wrapper or a condition is still reported. A `.pipe(Effect.catch…)` appended to an `Effect.gen` escapes; [`prefer-effect-fn`](#prefer-effect-fn) closes that gap.
622
+ **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, check what it reports before trusting it. Infrastructure requirements in the port's `R` channel need types: see `leaking-requirements` (effecttsgo).
642
623
 
643
- **Why**: translation becomes a single exhaustive point instead of catches scattered along the flow.
624
+ **Why**: the adapter can be replaced without touching its consumers.
625
+
626
+ **Options**:
627
+
628
+ - `entryFiles`: shared setting, see [Shared settings](#shared-settings).
629
+ - `testFiles`: shared setting, see [Shared settings](#shared-settings).
644
630
 
645
- - **Pattern**: P25 · **Options**: `testFiles` ([File options](#file-options)) · **Fix**: none · **Severity**: `warn`
631
+ **Fix**: none
646
632
 
647
633
  ### `no-transport-types-in-generator`
648
634
 
@@ -679,79 +665,113 @@ const createEvent = Effect.fn("events.create")(
679
665
 
680
666
  **Why**: an early `return new Response(…)` on the happy path is an error branch disguised as success, and escapes exhaustive translation.
681
667
 
682
- - **Pattern**: P26 · **Options**: `transportTypes`, replaces the default `["Response", "NextResponse"]`; add your own transport DTOs · **Fix**: none · **Severity**: `error`
668
+ **Options**:
683
669
 
684
- ### `all-errors-handled-at-boundary`
670
+ - `transportTypes`: default `["Response", "NextResponse"]`, replaced by the configured list. Add your own transport DTOs.
671
+
672
+ **Fix**: none
673
+
674
+ ## Effectful functions
675
+
676
+ ### `prefer-effect-fn`
685
677
 
686
678
  <sup>[↑ rules index](#rules-index)</sup>
687
679
 
688
- **Intent**: the entry point runs an Effect whose error channel is closed to `never`.
680
+ **Intent**: an effectful function is `Effect.fn("name")`, and a named effect value has a span.
689
681
 
690
682
  ```ts
691
683
  // no
692
- export const POST = (request: Request) => Effect.runPromise(createEvent(request))
693
-
694
- // yes: in an entry file
695
- export const POST = (request: Request) =>
696
- Effect.runPromise(createEvent(request) satisfies Effect.Effect<Response, never>)
684
+ const outcomeFor = (name: string) => Effect.gen(function* () { … }).pipe(Effect.catch(toFailed))
685
+ const refresh = Effect.gen(function* () { … })
697
686
 
698
- // yes: a typed helper
699
- const runBoundary = (effect: Effect.Effect<Response, never, never>) => Effect.runPromise(effect)
687
+ // yes: extra combinators as arguments, not .pipe
688
+ const outcomeFor = Effect.fn("outcomeFor")(function* (name: string) { … }, Effect.catch(toFailed))
689
+ const refresh = Effect.gen(function* () { … }).pipe(Effect.withSpan("refresh"))
700
690
  ```
701
691
 
702
- **Trigger**: `Effect.runPromise`, `runSync`, `runFork` and `runCallback` whose argument is not `… satisfies Effect.Effect<A, never>`, a parameter declared with that type, or a program ending in `Effect.result`/`Effect.exit` (`Effect.result(program)`, `program.pipe(…, Effect.result)`).
692
+ **Trigger**:
703
693
 
704
- **Exemptions and blind spots**: the `*Exit` variants, which return failures as values; `NodeRuntime.runMain`/`BunRuntime.runMain`, which log the Cause and exit with code 1, the right outcome for a process that cannot start; runners inside an `async` function that renders JSX itself, a Server Component, which hands failures to React rather than to an HTTP response; test files, where a rejected run fails the test with the error. The rule checks the shape; `tsc` gives the guarantee.
694
+ - a declared function, a variable or an object member that only returns `Effect.gen(…)`, even with `.pipe`;
695
+ - a variable or an object member whose value is `Effect.gen(…)` without `Effect.withSpan` in its `.pipe`;
696
+ - `Effect.fn(body)` without a name;
697
+ - `.pipe` on `Effect.fn(…)(…)`.
705
698
 
706
- **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.
699
+ **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).
707
700
 
708
- - **Pattern**: P27 · **Options**: `testFiles` ([File options](#file-options)) · **Fix**: none · **Severity**: `warn`
701
+ **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(…)`.
709
702
 
710
- ## Wiring and code shape
703
+ **Options**: none
711
704
 
712
- ### `provide-named-layer`
705
+ **Fix**: none
706
+
707
+ ### `untraced-recursion`
713
708
 
714
709
  <sup>[↑ rules index](#rules-index)</sup>
715
710
 
716
- **Intent**: `Effect.provide` receives a named Layer, never an inline composition.
711
+ **Intent**: a recursive effectful function uses `Effect.fnUntraced`; the entry point that starts the recursion is traced.
717
712
 
718
713
  ```ts
719
714
  // no
720
- printMissing.pipe(Effect.provide(Layer.mergeAll(FileRaceList.layer, GoogleSheet.readerLayer)))
715
+ const walk = Effect.fn("walk")(function* (node: Node) {
716
+ for (const child of node.children) yield* walk(child)
717
+ })
721
718
 
722
- // yes: the Layer is named in the entry point, provided in the dispatch
723
- const missingLayer = Layer.mergeAll(FileRaceList.layer, GoogleSheet.readerLayer)
724
- Missing: () => printMissing.pipe(Effect.provide(missingLayer)),
719
+ // yes
720
+ const walk = Effect.fnUntraced(function* (node: Node) {
721
+ for (const child of node.children) yield* walk(child)
722
+ })
723
+ export const scan = Effect.fn("scan")(function* (root: Node) {
724
+ return yield* walk(root)
725
+ })
725
726
  ```
726
727
 
727
- **Trigger**: an `Effect.provide` argument composed inline: `Layer.*(…)`, a ternary, an `&&`/`||`, a `match`/`$match`.
728
+ **Trigger**: an `Effect.fn` assigned to a variable that its own body calls again, even by passing it as a callback.
728
729
 
729
- **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).
730
+ **Exemptions and blind spots**: mutual recursion, recursion in pipeables.
730
731
 
731
- **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.
732
+ **Why**: with `Effect.fn` every level opens a span nested in the previous one.
732
733
 
733
- - **Pattern**: P28 · **Options**: `testFiles` ([File options](#file-options)) · **Fix**: none · **Severity**: `error`
734
+ **Options**: none
734
735
 
735
- ### `no-provide-on-definition`
736
+ **Fix**: none
737
+
738
+ ### `single-style-per-function`
736
739
 
737
740
  <sup>[↑ rules index](#rules-index)</sup>
738
741
 
739
- **Intent**: a program does not provide its own Layers; its caller does.
742
+ **Intent**: a function is entirely Effect or not Effect at all.
740
743
 
741
744
  ```ts
742
745
  // no
743
- const printMissing = Effect.gen(function* () { … }).pipe(Effect.provide(missingLayer))
746
+ export const POST = async (request: Request) => {
747
+ const form = await request.formData()
748
+ const created = await Effect.runPromise(Effect.result(createEvent(form)))
749
+ // …
750
+ }
744
751
 
745
- // yes: see provide-named-layer
752
+ // yes: promises lifted with Effect.tryPromise
753
+ const createEvent = Effect.fn("events.create")(function* (request: Request) {
754
+ const form = yield* Effect.tryPromise({
755
+ try: () => request.formData(),
756
+ catch: () => new BadRequest({ message: "invalid form data" }),
757
+ })
758
+ // …
759
+ })
746
760
  ```
747
761
 
748
- **Trigger**: `Effect.provide` applied to `Effect.gen(…)` or to `Effect.fn(…)(…)`, in all three forms: with `.pipe`, as an `Effect.fn` pipeable, data-first.
762
+ **Trigger**: `async`, `await` and `.then(` in a function that builds Effects: an Effect generator, or a function using an `Effect.*` member. A `test`/`it` callback (`.only`, `.each(…)` and the like included) or a `beforeAll`/`afterAll`/`beforeEach`/`afterEach` hook, and in test files any other function except an Effect generator, is reported once, on the function, when more than one of its `await`s, or any `.then(`, involves Effect: has an `Effect.*` member in its expression, nested callbacks and `Effect.run*` included.
749
763
 
750
- **Exemptions and blind spots**: test files. A program passed directly to `Effect.run*`, also through `.pipe(…)` or data-first `Effect.*` combinators (`Effect.runPromise(Effect.gen(…).pipe(Effect.provide(layer), Effect.result))`): it is already being dispatched; through any other function it is still reported. 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.
764
+ **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-in-runner`](#run-only-in-runner). An `async` function that renders JSX itself, not only in nested callbacks, is a Server Component: how it turns failures into UI is left to the application. A test, hook or test helper may await promise-based fixtures (a container, an identity provider), then one Effect program, and assert on its result. A step that reaches Effect only through a helper (`await run(seed)`) is not counted.
751
765
 
752
- **Why**: a program that provides its own Layers can no longer be wired to different adapters by its caller.
766
+ **Why**: errors travel on two channels, exceptions and Effect, and neither sees them all.
767
+
768
+ **Options**:
769
+
770
+ - `testFiles`: shared setting, see [Shared settings](#shared-settings).
753
771
 
754
- - **Pattern**: P28 · **Options**: `testFiles` ([File options](#file-options)) · **Fix**: none · **Severity**: `error`
772
+ **Fix**: none
773
+
774
+ ## Code shape
755
775
 
756
776
  ### `no-nested-match`
757
777
 
@@ -793,7 +813,9 @@ const run = Effect.fn("run")(function* (argv: ReadonlyArray<string>) {
793
813
 
794
814
  **Why**: two nested levels of choice hide which branch is the normal one.
795
815
 
796
- - **Pattern**: P30 · **Options**: none · **Fix**: none · **Severity**: `warn`
816
+ **Options**: none
817
+
818
+ **Fix**: none
797
819
 
798
820
  ### `no-yielded-match`
799
821
 
@@ -820,13 +842,15 @@ const save = Effect.fn("save")(function* (id: PolicyId) {
820
842
  })
821
843
  ```
822
844
 
823
- **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`.
845
+ **Trigger**: `yield*` of `Option.match(x, {…})` or `Result.match(x, {…})` (v3: `Either.match`), or of a `x.pipe(…)`/`pipe(x, …)` ending in one of them, in the generator of `Effect.gen` or `Effect.fn`.
824
846
 
825
847
  **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.
826
848
 
827
849
  **Why**: the match splits the generator into two equal-weight branches; a guard clause leaves one main path of `yield*`.
828
850
 
829
- - **Pattern**: P40 · **Options**: none · **Fix**: none · **Severity**: —
851
+ **Options**: none
852
+
853
+ **Fix**: none
830
854
 
831
855
  ### `no-inline-yield-star`
832
856
 
@@ -836,11 +860,15 @@ const save = Effect.fn("save")(function* (id: PolicyId) {
836
860
 
837
861
  ```ts
838
862
  // no
839
- return yield * missingIn(yield * readSheet())
863
+ function* () {
864
+ return yield* missingIn(yield* readSheet())
865
+ }
840
866
 
841
867
  // yes
842
- const sheet = yield * readSheet()
843
- return yield * missingIn(sheet)
868
+ function* () {
869
+ const sheet = yield* readSheet()
870
+ return yield* missingIn(sheet)
871
+ }
844
872
  ```
845
873
 
846
874
  **Trigger**: a `yield*` whose result is used inside a larger expression: argument, operand, member, property, template.
@@ -849,7 +877,9 @@ return yield * missingIn(sheet)
849
877
 
850
878
  **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.
851
879
 
852
- - **Pattern**: P32 · **Options**: none · **Fix**: autofix · **Severity**: `warn`
880
+ **Options**: none
881
+
882
+ **Fix**: autofix
853
883
 
854
884
  ### `no-effect-return-type`
855
885
 
@@ -874,7 +904,11 @@ export const findPrice = (id: PriceId): Effect.Effect<Price, PriceNotFound> =>
874
904
 
875
905
  **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. To check a returned object against a named type, annotate the callback that builds it (`Effect.map(CurrentSession, (session): OrganizationContext => ({ … }))`) or use `satisfies`.
876
906
 
877
- - **Pattern**: P33 · **Options**: `checkExported`, default `false`: also checks exported declarations · **Fix**: suggestion that removes the annotation · **Severity**: `warn`
907
+ **Options**:
908
+
909
+ - `checkExported`: default `false`. Also checks exported declarations.
910
+
911
+ **Fix**: suggestion that removes the annotation
878
912
 
879
913
  ### `no-asymmetric-match-handlers`
880
914
 
@@ -902,7 +936,9 @@ Option.match(only, {
902
936
 
903
937
  **Why**: a branch saying _what_ next to one showing _how_ forces the reader to switch level halfway through the choice.
904
938
 
905
- - **Pattern**: P34 · **Options**: none · **Fix**: none · **Severity**: `warn`
939
+ **Options**: none
940
+
941
+ **Fix**: none
906
942
 
907
943
  ### `anti-slop-prefer-effect-match`
908
944
 
@@ -928,4 +964,93 @@ const label = Match.value(kind).pipe(
928
964
 
929
965
  **Why**: a ternary chain hides which value is the fallback and cannot be made exhaustive.
930
966
 
931
- - **Pattern**: — · **Options**: none · **Fix**: none · **Severity**: —
967
+ **Options**: none
968
+
969
+ **Fix**: none
970
+
971
+ ## Schema
972
+
973
+ ### `exported-schema-identifier`
974
+
975
+ <sup>[↑ rules index](#rules-index)</sup>
976
+
977
+ **Intent**: an exported schema carries an `identifier` equal to its name.
978
+
979
+ ```ts
980
+ // no
981
+ export const EventBusEvents = Schema.Union([Created, Deleted])
982
+
983
+ // yes
984
+ export const EventBusEvents = Schema.Union([Created, Deleted]).annotate({
985
+ identifier: "EventBusEvents",
986
+ })
987
+ ```
988
+
989
+ **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 (v3: `annotations`), or with a literal `identifier` other than `"X"`.
990
+
991
+ **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 = { … }`).
992
+
993
+ **Why**: without an `identifier`, decoding errors, JSON Schema and arbitraries describe the schema by its structure instead of its name.
994
+
995
+ **Options**: none
996
+
997
+ **Fix**: suggestion that corrects the `identifier`, or two that add it, `v3:` with `.annotations(…)` and `v4:` with `.annotate(…)`
998
+
999
+ ### `no-inline-schema-codec`
1000
+
1001
+ <sup>[↑ rules index](#rules-index)</sup>
1002
+
1003
+ **Intent**: a decoder or encoder is a named module-level constant, not built and applied inline.
1004
+
1005
+ ```ts
1006
+ // no
1007
+ const toPayment = (raw: unknown) => Schema.decodeUnknownSync(PaymentResponse)(raw)
1008
+
1009
+ // yes
1010
+ const decodePaymentResponse = Schema.decodeUnknownSync(PaymentResponse)
1011
+
1012
+ const toPayment = (raw: unknown) => decodePaymentResponse(raw)
1013
+ ```
1014
+
1015
+ **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.
1016
+
1017
+ **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).
1018
+
1019
+ **Why**: a named codec says what it decodes at the call site and is built once, instead of at every call.
1020
+
1021
+ **Options**: none
1022
+
1023
+ **Fix**: none
1024
+
1025
+ ## Config and secrets
1026
+
1027
+ ### `redacted-secret-names`
1028
+
1029
+ <sup>[↑ rules index](#rules-index)</sup>
1030
+
1031
+ **Intent**: a secret read from config or declared in a schema is `Redacted`.
1032
+
1033
+ ```ts
1034
+ // no
1035
+ serviceAccount: Config.schema(NonBlank, "GOOGLE_SERVICE_ACCOUNT_JSON")
1036
+ const Session = Schema.Struct({ userId: UserId, accessToken: Schema.String })
1037
+
1038
+ // yes
1039
+ serviceAccount: Config.schema(Schema.Redacted(NonBlank), "GOOGLE_SERVICE_ACCOUNT_JSON")
1040
+ const Session = Schema.Struct({ userId: UserId, accessToken: Schema.Redacted(Schema.String) })
1041
+ ```
1042
+
1043
+ **Trigger**:
1044
+
1045
+ - 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`, the v3 `Config.redacted` (also wrapping a config, `Config.redacted(Config.string(name))`), or `Config.schema(Schema.Redacted…(…), name)`;
1046
+ - 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(…)`/`.annotations(…)` or `Schema.optional`/`optionalKey`/`optionalWith`/`NullOr`/`UndefinedOr`/`NullishOr`, with no `Schema.Redacted…` along the way.
1047
+
1048
+ **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).
1049
+
1050
+ **Why**: a `Redacted` value does not end up in logs or `toString`.
1051
+
1052
+ **Options**:
1053
+
1054
+ - `secretFields`: default `["password", "secret", "apiKey", "accessToken", "refreshToken", "idToken"]` (no generic `token`), replaced by the configured list. `Config` names are fixed.
1055
+
1056
+ **Fix**: none