@doubleloop-io/oxlint-plugin-effect 0.1.0-rc.0 → 0.1.0-rc.2

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 (48) hide show
  1. package/README.md +106 -82
  2. package/RULES.md +527 -382
  3. package/dist/index.d.ts +3 -0
  4. package/dist/index.js +13 -8
  5. package/dist/presets/all.json +5 -4
  6. package/dist/rules/adapter-import-boundary.js +5 -12
  7. package/dist/rules/all-errors-handled-at-boundary.d.ts +1 -0
  8. package/dist/rules/all-errors-handled-at-boundary.js +105 -0
  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 +36 -19
  13. package/dist/rules/no-manual-discriminant-construction.js +50 -14
  14. package/dist/rules/no-provide-on-definition.js +31 -9
  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} +11 -3
  17. package/dist/rules/no-yielded-match.js +9 -2
  18. package/dist/rules/prefer-effect-vitest.d.ts +1 -0
  19. package/dist/rules/prefer-effect-vitest.js +45 -0
  20. package/dist/rules/provide-named-layer.js +1 -8
  21. package/dist/rules/redacted-secret-names.js +18 -7
  22. package/dist/rules/run-only-in-runner.d.ts +1 -0
  23. package/dist/rules/run-only-in-runner.js +33 -0
  24. package/dist/rules/service-key-matches-name.js +15 -6
  25. package/dist/rules/service-port-shape-only.js +10 -18
  26. package/dist/rules/single-style-per-function.js +82 -21
  27. package/dist/rules/split-success-path-from-error-handling.d.ts +1 -0
  28. package/dist/rules/split-success-path-from-error-handling.js +137 -0
  29. package/dist/shared/effect-ast.d.ts +1 -0
  30. package/dist/shared/effect-ast.js +15 -4
  31. package/dist/shared/effect-runners.d.ts +2 -0
  32. package/dist/shared/effect-runners.js +14 -0
  33. package/dist/shared/entry-files.d.ts +2 -2
  34. package/dist/shared/file-globs.d.ts +3 -9
  35. package/dist/shared/file-globs.js +3 -12
  36. package/dist/shared/settings.d.ts +5 -0
  37. package/dist/shared/settings.js +26 -0
  38. package/dist/shared/tagged-values.d.ts +2 -8
  39. package/dist/shared/tagged-values.js +3 -14
  40. package/dist/shared/test-files.d.ts +2 -2
  41. package/package.json +61 -54
  42. package/dist/rules/boundary-entrypoint-shape.d.ts +0 -1
  43. package/dist/rules/boundary-entrypoint-shape.js +0 -52
  44. package/dist/rules/error-handling-in-effect-fn-handler.d.ts +0 -1
  45. package/dist/rules/error-handling-in-effect-fn-handler.js +0 -66
  46. package/dist/rules/no-bare-error-in-effect.d.ts +0 -1
  47. package/dist/rules/run-only-at-entry.d.ts +0 -1
  48. package/dist/rules/run-only-at-entry.js +0 -50
package/RULES.md CHANGED
@@ -1,241 +1,89 @@
1
1
  # `@doubleloop-io/oxlint-plugin-effect` rules
2
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).
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-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.
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`.
41
42
 
42
43
  ## Common limits
43
44
 
44
- - **Syntax only**: no rule uses type information.
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.
45
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.
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:
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:
56
72
 
57
73
  ```ts
58
- const entryFiles = ["apps/web/src/app/**/route.ts", "apps/web/src/runtime.ts"]
59
-
60
74
  export default defineConfig({
61
75
  extends: [dlEffectAll],
62
- rules: {
63
- "dl-effect/run-only-at-entry": ["warn", { entryFiles }],
64
- "dl-effect/adapter-import-boundary": ["warn", { entryFiles }],
76
+ settings: {
77
+ "dl-effect": { entryFiles: ["apps/web/src/app/**/route.ts", "apps/web/src/runtime.ts"] },
65
78
  },
66
79
  })
67
80
  ```
68
81
 
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.
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.
233
83
 
234
- - **Pattern**: P04, P22 · **Options**: `entryFiles` ([File options](#file-options)) · **Fix**: none · **Severity**: `error`
84
+ ## Error channel
235
85
 
236
- ## Errors
237
-
238
- ### `no-bare-error-in-effect`
86
+ ### `no-throw-or-primitive-failure`
239
87
 
240
88
  <sup>[↑ rules index](#rules-index)</sup>
241
89
 
@@ -259,11 +107,15 @@ Effect.fail(new SheetTabNotFoundError({ spreadsheetId, message: "tab not found"
259
107
 
260
108
  **Trigger**: a `throw` directly inside a generator passed to `Effect.gen`/`Effect.fn`; `Effect.fail` with a literal or a template.
261
109
 
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).
110
+ **Exemptions and blind spots**: `Effect.fail` with a literal in test files, where a synthetic failure feeds code generic over the error; a `throw` in a test generator is still reported. 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
111
 
264
112
  **Why**: the typed error channel discriminates only on tags. A `throw` in the generator becomes a defect, outside the error channel.
265
113
 
266
- - **Pattern**: P05 · **Options**: none · **Fix**: none · **Severity**: `error`
114
+ **Options**:
115
+
116
+ - `testFiles`: shared setting, see [Shared settings](#shared-settings).
117
+
118
+ **Fix**: none
267
119
 
268
120
  ### `try-object-form`
269
121
 
@@ -288,7 +140,9 @@ Effect.tryPromise({
288
140
 
289
141
  **Why**: the thunk form types the error as `Cause.UnknownError` (`UnknownException` in v3), which no caller can handle by tag.
290
142
 
291
- - **Pattern**: P08 · **Options**: none · **Fix**: none · **Severity**: `error`
143
+ **Options**: none
144
+
145
+ **Fix**: none
292
146
 
293
147
  ### `no-effect-promise`
294
148
 
@@ -310,13 +164,17 @@ Effect.tryPromise({ try: () => client.connect(), catch: toConnectionError })
310
164
 
311
165
  **Why**: `Effect.promise` turns a rejection into a defect.
312
166
 
313
- - **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
314
172
 
315
173
  ### `no-parameterless-catch-all`
316
174
 
317
175
  <sup>[↑ rules index](#rules-index)</sup>
318
176
 
319
- **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.
320
178
 
321
179
  ```ts
322
180
  // no
@@ -325,6 +183,8 @@ Effect.catch(() => Effect.succeed(fallback))
325
183
  // yes
326
184
  Effect.catchTag("AgentTimeout", () => Effect.succeed(skip))
327
185
  Effect.catchTag(["ParseError", "ReservedPortError"], () => Effect.succeed(3000))
186
+
187
+ // yes: every failure truly means the same fallback
328
188
  Effect.orElseSucceed(() => fallback)
329
189
  ```
330
190
 
@@ -334,7 +194,46 @@ Effect.orElseSucceed(() => fallback)
334
194
 
335
195
  **Why**: a catch-all that ignores its parameter also swallows the errors added later, which `catchTag` would leave visible in the type.
336
196
 
337
- - **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
338
237
 
339
238
  ### `anti-slop-no-manual-effect-error-tag`
340
239
 
@@ -356,7 +255,9 @@ program.pipe(Effect.catchTag("NotFound", () => recover))
356
255
 
357
256
  **Why**: a hand-written dispatch keeps the caught errors in the type even when handled, and silently misses tags added later.
358
257
 
359
- - **Pattern**: — · **Options**: none · **Fix**: none · **Severity**: —
258
+ **Options**: none
259
+
260
+ **Fix**: none
360
261
 
361
262
  ## Tagged values
362
263
 
@@ -380,17 +281,21 @@ Match.value(event).pipe(Match.tagsExhaustive({ Created: onCreated, Deleted: onDe
380
281
 
381
282
  // yes, with `discriminants: ["_tag", "type"]`
382
283
  Match.value(payment).pipe(
383
- Match.discriminatorsExhaustive("type")({ demetra: fromDemetra, fexco: fromFexco }),
284
+ Match.discriminatorsExhaustive("type")({ card: fromCard, transfer: fromTransfer }),
384
285
  )
385
286
  ```
386
287
 
387
288
  **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
289
 
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.
290
+ **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`. A `_tag` compared with a variant of Effect's own types (`Success`/`Failure` of `Result` and `Exit`, `Left`/`Right`, `Some`/`None`) is reported with their refinement as the remedy, such as `Result.isFailure`: those types cannot gain a variant; a `switch` is reported as usual. 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
291
 
391
292
  **Why**: `Match.tagsExhaustive`, `Match.discriminatorsExhaustive` and `$match` fail to compile when a variant is added; a comparison or a `switch` does not.
392
293
 
393
- - **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
394
299
 
395
300
  ### `no-manual-discriminant-construction`
396
301
 
@@ -414,215 +319,316 @@ const event = PaymentStartedEvent.make({ id, payment })
414
319
 
415
320
  **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
321
 
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.
322
+ **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, the expected value of an `expect(…)` matcher or `expect.*` helper, the input of `Schema.decode*(X)(…)`/`Schema.encode*(X)(…)`, the argument of `JSON.stringify`, and an argument whose parameter is annotated `unknown` in a function declared in the same file (a fake API's raw body). Transport payloads written as literals fire, as does raw data passed to an imported function or through a variable, 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
323
 
419
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.
420
325
 
421
- - **Pattern**: P37 · **Options**: `discriminants`, shared with [`no-manual-discriminant-comparison`](#no-manual-discriminant-comparison), default `["_tag"]` · **Fix**: none · **Severity**: —
326
+ **Options**:
422
327
 
423
- ## Config
328
+ - `discriminants`: shared setting, see [Shared settings](#shared-settings).
424
329
 
425
- ### `redacted-secret-names`
330
+ **Fix**: none
331
+
332
+ ## Services and Layers
333
+
334
+ ### `service-port-shape-only`
426
335
 
427
336
  <sup>[↑ rules index](#rules-index)</sup>
428
337
 
429
- **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.
430
339
 
431
340
  ```ts
432
341
  // no
433
- serviceAccount: Config.schema(NonBlank, "GOOGLE_SERVICE_ACCOUNT_JSON")
434
- const Session = Schema.Struct({ userId: UserId, accessToken: Schema.String })
342
+ class Agent extends Context.Service<Agent, Shape>()("app/Agent", { make: makeAgent }) {}
435
343
 
436
344
  // yes
437
- serviceAccount: Config.schema(Schema.Redacted(NonBlank), "GOOGLE_SERVICE_ACCOUNT_JSON")
438
- 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") {}
439
349
  ```
440
350
 
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.
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`.
445
352
 
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).
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.
447
354
 
448
- **Why**: a `Redacted` value does not end up in logs or `toString`.
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.
449
356
 
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`
357
+ **Options**: none
451
358
 
452
- ## Schema
359
+ **Fix**: none
453
360
 
454
- ### `exported-schema-identifier`
361
+ ### `service-key-matches-name`
455
362
 
456
363
  <sup>[↑ rules index](#rules-index)</sup>
457
364
 
458
- **Intent**: an exported schema carries an `identifier` equal to its name.
365
+ **Intent**: a service's key is its class name, alone or after a path prefix.
459
366
 
460
367
  ```ts
461
368
  // no
462
- export const EventBusEvents = Schema.Union([Created, Deleted])
369
+ export class GeocodingApis extends Context.Service<GeocodingApis, Shape>()("GeocodingAPIs") {}
463
370
 
464
371
  // yes
465
- export const EventBusEvents = Schema.Union([Created, Deleted]).annotate({
466
- identifier: "EventBusEvents",
467
- })
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
+ ) {}
468
378
  ```
469
379
 
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"`.
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"`.
471
381
 
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 = { … }`).
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.
473
383
 
474
- **Why**: without an `identifier`, decoding errors, JSON Schema and arbitraries describe the schema by its structure instead of its name.
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.
475
385
 
476
- - **Pattern**: P35 · **Options**: none · **Fix**: suggestion that adds or corrects the `identifier` · **Severity**: —
386
+ **Options**: none
477
387
 
478
- ### `no-inline-schema-codec`
388
+ **Fix**: suggestion that replaces the last segment of the key with the class name
389
+
390
+ ### `service-built-with-of`
479
391
 
480
392
  <sup>[↑ rules index](#rules-index)</sup>
481
393
 
482
- **Intent**: a decoder or encoder is a named module-level constant, not built and applied inline.
394
+ **Intent**: the service a Layer provides is built with `Tag.of({…})`, not a bare object literal.
483
395
 
484
396
  ```ts
485
397
  // no
486
- const toPayment = (raw: unknown) => Schema.decodeUnknownSync(PaymentResponse)(raw)
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
+ )
487
405
 
488
406
  // yes
489
- const decodePaymentResponse = Schema.decodeUnknownSync(PaymentResponse)
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
+ )
414
+ ```
490
415
 
491
- const toPayment = (raw: unknown) => decodePaymentResponse(raw)
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.
417
+
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.
419
+
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.
421
+
422
+ **Options**: none
423
+
424
+ **Fix**: suggestion that wraps the literal in `T.of(…)`
425
+
426
+ ### `provide-named-layer`
427
+
428
+ <sup>[↑ rules index](#rules-index)</sup>
429
+
430
+ **Intent**: `Effect.provide` receives a named Layer, never an inline composition.
431
+
432
+ ```ts
433
+ // no
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)),
492
439
  ```
493
440
 
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.
441
+ **Trigger**: an `Effect.provide` argument composed inline: `Layer.*(…)`, a ternary, an `&&`/`||`, a `match`/`$match`.
495
442
 
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).
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).
497
444
 
498
- **Why**: a named codec says what it decodes at the call site and is built once, instead of at every call.
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.
499
446
 
500
- - **Pattern**: P39 · **Options**: none · **Fix**: none · **Severity**: —
447
+ **Options**:
501
448
 
502
- ## Effectful functions
449
+ - `testFiles`: shared setting, see [Shared settings](#shared-settings).
503
450
 
504
- ### `prefer-effect-fn`
451
+ **Fix**: none
452
+
453
+ ### `no-provide-on-definition`
505
454
 
506
455
  <sup>[↑ rules index](#rules-index)</sup>
507
456
 
508
- **Intent**: an effectful function is `Effect.fn("name")`, and a named effect value has a span.
457
+ **Intent**: a program does not provide its own Layers; its caller does.
509
458
 
510
459
  ```ts
511
460
  // no
512
- const outcomeFor = (name: string) => Effect.gen(function* () { … }).pipe(Effect.catch(toFailed))
513
- const refresh = Effect.gen(function* () { … })
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"
489
+
490
+ // yes
491
+ import { IssueService } from "./issue-service.ts"
492
+
493
+ Effect.gen(function* () {
494
+ const issues = yield* IssueService
495
+ })
496
+ ```
497
+
498
+ **Trigger**: a named import of `make` followed by an uppercase letter (`makeIssueService`, also when renamed) from a relative module.
499
+
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).
501
+
502
+ **Why**: calling the constructor wires its dependencies by hand, and its requirements stop propagating to the composition root.
503
+
504
+ **Options**:
505
+
506
+ - `testFiles`: shared setting, see [Shared settings](#shared-settings).
507
+
508
+ **Fix**: none
509
+
510
+ ## Runtime and entry point
511
+
512
+ ### `run-only-in-runner`
513
+
514
+ <sup>[↑ rules index](#rules-index)</sup>
515
+
516
+ **Intent**: Effects run only in the program's runner, the entry point.
517
+
518
+ ```ts
519
+ // no
520
+ export const check = () => Effect.runPromise(pingDb(url))
514
521
 
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"))
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
+ )
518
529
  ```
519
530
 
520
- **Trigger**:
531
+ **Trigger**: every reference, not just calls, to `Effect.run*`, `NodeRuntime.runMain`, `BunRuntime.runMain` and `ManagedRuntime.make` outside entry files.
521
532
 
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(…)(…)`.
533
+ **Exemptions and blind spots**: test files, checked by [`prefer-effect-vitest`](#prefer-effect-vitest) instead.
526
534
 
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).
535
+ **Why**: logger, Layers and defect handling are configured in one place.
528
536
 
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(…)`.
537
+ **Options**:
530
538
 
531
- - **Pattern**: P17 · **Options**: none · **Fix**: none · **Severity**: `warn`
539
+ - `entryFiles`: shared setting, see [Shared settings](#shared-settings).
540
+ - `testFiles`: shared setting, see [Shared settings](#shared-settings).
532
541
 
533
- ### `untraced-recursion`
542
+ **Fix**: none
543
+
544
+ ### `prefer-effect-vitest`
534
545
 
535
546
  <sup>[↑ rules index](#rules-index)</sup>
536
547
 
537
- **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.
538
549
 
539
550
  ```ts
540
551
  // no
541
- const walk = Effect.fn("walk")(function* (node: Node) {
542
- for (const child of node.children) yield* walk(child)
552
+ test("…", async () => {
553
+ await Effect.runPromise(authorize({ role: "Manager" }))
543
554
  })
544
555
 
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
- })
556
+ // yes: fakes as Layers
557
+ import { effect } from "@effect/vitest"
558
+
559
+ effect("…", () => authorize({ role: "Manager" }).pipe(Effect.provide(sheetWith(row))))
552
560
  ```
553
561
 
554
- **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.
555
563
 
556
- **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.
557
565
 
558
- **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.
559
567
 
560
- - **Pattern**: P17 · **Options**: none · **Fix**: none · **Severity**: `warn`
568
+ **Options**:
561
569
 
562
- ### `single-style-per-function`
570
+ - `testFiles`: shared setting, see [Shared settings](#shared-settings).
571
+
572
+ **Fix**: none
573
+
574
+ ### `all-errors-handled-at-boundary`
563
575
 
564
576
  <sup>[↑ rules index](#rules-index)</sup>
565
577
 
566
- **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`.
567
579
 
568
580
  ```ts
569
581
  // 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
- }
582
+ export const POST = (request: Request) => Effect.runPromise(createEvent(request))
575
583
 
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
+ // 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)
584
590
  ```
585
591
 
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.
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)`).
587
593
 
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.
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.
589
595
 
590
- **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.
591
597
 
592
- - **Pattern**: P24 · **Options**: none · **Fix**: none · **Severity**: `error`
598
+ **Options**:
593
599
 
594
- ### `error-handling-in-effect-fn-handler`
600
+ - `testFiles`: shared setting, see [Shared settings](#shared-settings).
601
+
602
+ **Fix**: none
603
+
604
+ ## Design and boundaries
605
+
606
+ ### `adapter-import-boundary`
595
607
 
596
608
  <sup>[↑ rules index](#rules-index)</sup>
597
609
 
598
- **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.
599
611
 
600
612
  ```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
- }
613
+ // no: in src/orders/place-order.ts
614
+ import { GoogleCalendar } from "../calendar/google/GoogleCalendar.ts"
611
615
 
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
- )
616
+ // yes: depend on the port, main.ts provides GoogleCalendar.layer
617
+ import { Calendar } from "../calendar/Calendar.ts"
617
618
  ```
618
619
 
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
+ **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.
620
621
 
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
+ **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).
622
623
 
623
- **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**:
624
627
 
625
- - **Pattern**: P25 · **Options**: `testFiles` ([File options](#file-options)) · **Fix**: none · **Severity**: `warn`
628
+ - `entryFiles`: shared setting, see [Shared settings](#shared-settings).
629
+ - `testFiles`: shared setting, see [Shared settings](#shared-settings).
630
+
631
+ **Fix**: none
626
632
 
627
633
  ### `no-transport-types-in-generator`
628
634
 
@@ -653,85 +659,119 @@ const createEvent = Effect.fn("events.create")(
653
659
  )
654
660
  ```
655
661
 
656
- **Trigger**: `new T(…)`, `T.method(…)` and `T(…)` inside an Effect generator, nested callbacks included, when `T` is a transport type.
662
+ **Trigger**: `new T(…)`, `T.method(…)` and `T(…)` inside an Effect generator, callbacks passed to `Effect.*` combinators included, when `T` is a transport type.
657
663
 
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.
664
+ **Exemptions and blind spots**: pipeables of `Effect.fn`, where the conversion must live. Functions the generator only defines, such as an HTTP handler it registers. `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
665
 
660
666
  **Why**: an early `return new Response(…)` on the happy path is an error branch disguised as success, and escapes exhaustive translation.
661
667
 
662
- - **Pattern**: P26 · **Options**: `transportTypes`, replaces the default `["Response", "NextResponse"]`; add your own transport DTOs · **Fix**: none · **Severity**: `error`
668
+ **Options**:
669
+
670
+ - `transportTypes`: default `["Response", "NextResponse"]`, replaced by the configured list. Add your own transport DTOs.
671
+
672
+ **Fix**: none
673
+
674
+ ## Effectful functions
663
675
 
664
- ### `boundary-entrypoint-shape`
676
+ ### `prefer-effect-fn`
665
677
 
666
678
  <sup>[↑ rules index](#rules-index)</sup>
667
679
 
668
- **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.
669
681
 
670
682
  ```ts
671
683
  // 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>)
684
+ const outcomeFor = (name: string) => Effect.gen(function* () { … }).pipe(Effect.catch(toFailed))
685
+ const refresh = Effect.gen(function* () { … })
677
686
 
678
- // yes: a typed helper
679
- 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"))
680
690
  ```
681
691
 
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.
692
+ **Trigger**:
693
+
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(…)(…)`.
683
698
 
684
- **Exemptions and blind spots**: the `*Exit` variants, which return failures as values. The rule checks the shape; `tsc` gives the guarantee.
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).
685
700
 
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.
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(…)`.
687
702
 
688
- - **Pattern**: P27 · **Options**: none · **Fix**: none · **Severity**: `warn`
703
+ **Options**: none
689
704
 
690
- ## Wiring and code shape
705
+ **Fix**: none
691
706
 
692
- ### `provide-named-layer`
707
+ ### `untraced-recursion`
693
708
 
694
709
  <sup>[↑ rules index](#rules-index)</sup>
695
710
 
696
- **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.
697
712
 
698
713
  ```ts
699
714
  // no
700
- 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
+ })
701
718
 
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)),
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
+ })
705
726
  ```
706
727
 
707
- **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.
708
729
 
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).
730
+ **Exemptions and blind spots**: mutual recursion, recursion in pipeables.
710
731
 
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.
732
+ **Why**: with `Effect.fn` every level opens a span nested in the previous one.
712
733
 
713
- - **Pattern**: P28 · **Options**: `testFiles` ([File options](#file-options)) · **Fix**: none · **Severity**: `error`
734
+ **Options**: none
714
735
 
715
- ### `no-provide-on-definition`
736
+ **Fix**: none
737
+
738
+ ### `single-style-per-function`
716
739
 
717
740
  <sup>[↑ rules index](#rules-index)</sup>
718
741
 
719
- **Intent**: a program does not provide its own Layers; its caller does.
742
+ **Intent**: a function is entirely Effect or not Effect at all.
720
743
 
721
744
  ```ts
722
745
  // no
723
- 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
+ }
724
751
 
725
- // 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
+ })
726
760
  ```
727
761
 
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.
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.
729
763
 
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.
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.
731
765
 
732
- **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).
733
771
 
734
- - **Pattern**: P28 · **Options**: `testFiles` ([File options](#file-options)) · **Fix**: none · **Severity**: `error`
772
+ **Fix**: none
773
+
774
+ ## Code shape
735
775
 
736
776
  ### `no-nested-match`
737
777
 
@@ -773,7 +813,9 @@ const run = Effect.fn("run")(function* (argv: ReadonlyArray<string>) {
773
813
 
774
814
  **Why**: two nested levels of choice hide which branch is the normal one.
775
815
 
776
- - **Pattern**: P30 · **Options**: none · **Fix**: none · **Severity**: `warn`
816
+ **Options**: none
817
+
818
+ **Fix**: none
777
819
 
778
820
  ### `no-yielded-match`
779
821
 
@@ -800,13 +842,15 @@ const save = Effect.fn("save")(function* (id: PolicyId) {
800
842
  })
801
843
  ```
802
844
 
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`.
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`.
804
846
 
805
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.
806
848
 
807
849
  **Why**: the match splits the generator into two equal-weight branches; a guard clause leaves one main path of `yield*`.
808
850
 
809
- - **Pattern**: P40 · **Options**: none · **Fix**: none · **Severity**: —
851
+ **Options**: none
852
+
853
+ **Fix**: none
810
854
 
811
855
  ### `no-inline-yield-star`
812
856
 
@@ -816,11 +860,15 @@ const save = Effect.fn("save")(function* (id: PolicyId) {
816
860
 
817
861
  ```ts
818
862
  // no
819
- return yield * missingIn(yield * readSheet())
863
+ function* () {
864
+ return yield* missingIn(yield* readSheet())
865
+ }
820
866
 
821
867
  // yes
822
- const sheet = yield * readSheet()
823
- return yield * missingIn(sheet)
868
+ function* () {
869
+ const sheet = yield* readSheet()
870
+ return yield* missingIn(sheet)
871
+ }
824
872
  ```
825
873
 
826
874
  **Trigger**: a `yield*` whose result is used inside a larger expression: argument, operand, member, property, template.
@@ -829,7 +877,9 @@ return yield * missingIn(sheet)
829
877
 
830
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.
831
879
 
832
- - **Pattern**: P32 · **Options**: none · **Fix**: autofix · **Severity**: `warn`
880
+ **Options**: none
881
+
882
+ **Fix**: autofix
833
883
 
834
884
  ### `no-effect-return-type`
835
885
 
@@ -852,9 +902,13 @@ export const findPrice = (id: PriceId): Effect.Effect<Price, PriceNotFound> =>
852
902
 
853
903
  **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
904
 
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.
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`.
856
906
 
857
- - **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
858
912
 
859
913
  ### `no-asymmetric-match-handlers`
860
914
 
@@ -882,7 +936,9 @@ Option.match(only, {
882
936
 
883
937
  **Why**: a branch saying _what_ next to one showing _how_ forces the reader to switch level halfway through the choice.
884
938
 
885
- - **Pattern**: P34 · **Options**: none · **Fix**: none · **Severity**: `warn`
939
+ **Options**: none
940
+
941
+ **Fix**: none
886
942
 
887
943
  ### `anti-slop-prefer-effect-match`
888
944
 
@@ -908,4 +964,93 @@ const label = Match.value(kind).pipe(
908
964
 
909
965
  **Why**: a ternary chain hides which value is the fallback and cannot be made exhaustive.
910
966
 
911
- - **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