@doubleloop-io/oxlint-plugin-effect 0.1.0 → 0.1.1

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 (45) hide show
  1. package/CHANGELOG.md +12 -1
  2. package/README.md +16 -199
  3. package/docs/user-guide/README.md +24 -0
  4. package/docs/user-guide/alongside-effect-tsgo.md +23 -0
  5. package/docs/user-guide/common-limits.md +10 -0
  6. package/docs/user-guide/configuration.md +43 -0
  7. package/docs/user-guide/default-configuration.md +88 -0
  8. package/docs/user-guide/installation.md +12 -0
  9. package/docs/user-guide/rules/adapter-import-boundary.md +27 -0
  10. package/docs/user-guide/rules/all-errors-handled-at-boundary.md +30 -0
  11. package/docs/user-guide/rules/anti-slop-no-manual-effect-error-tag.md +26 -0
  12. package/docs/user-guide/rules/anti-slop-no-service-constructor-imports.md +32 -0
  13. package/docs/user-guide/rules/anti-slop-prefer-effect-match.md +30 -0
  14. package/docs/user-guide/rules/exported-schema-identifier.md +26 -0
  15. package/docs/user-guide/rules/no-asymmetric-match-handlers.md +30 -0
  16. package/docs/user-guide/rules/no-effect-promise.md +26 -0
  17. package/docs/user-guide/rules/no-effect-return-type.md +29 -0
  18. package/docs/user-guide/rules/no-inline-schema-codec.md +26 -0
  19. package/docs/user-guide/rules/no-inline-yield-star.md +29 -0
  20. package/docs/user-guide/rules/no-manual-discriminant-comparison.md +36 -0
  21. package/docs/user-guide/rules/no-manual-discriminant-construction.md +32 -0
  22. package/docs/user-guide/rules/no-nested-match.md +44 -0
  23. package/docs/user-guide/rules/no-parameterless-catch-all.md +28 -0
  24. package/docs/user-guide/rules/no-provide-on-definition.md +27 -0
  25. package/docs/user-guide/rules/no-throw-or-primitive-failure.md +34 -0
  26. package/docs/user-guide/rules/no-transport-types-in-generator.md +41 -0
  27. package/docs/user-guide/rules/no-yielded-match.md +35 -0
  28. package/docs/user-guide/rules/prefer-effect-fn.md +31 -0
  29. package/docs/user-guide/rules/prefer-effect-vitest.md +30 -0
  30. package/docs/user-guide/rules/provide-named-layer.md +27 -0
  31. package/docs/user-guide/rules/redacted-secret-names.md +31 -0
  32. package/docs/user-guide/rules/run-only-in-runner.md +32 -0
  33. package/docs/user-guide/rules/service-built-with-of.md +36 -0
  34. package/docs/user-guide/rules/service-key-matches-name.md +29 -0
  35. package/docs/user-guide/rules/service-port-shape-only.md +27 -0
  36. package/docs/user-guide/rules/single-style-per-function.md +36 -0
  37. package/docs/user-guide/rules/split-success-path-from-error-handling.md +37 -0
  38. package/docs/user-guide/rules/try-object-form.md +27 -0
  39. package/docs/user-guide/rules/untraced-recursion.md +31 -0
  40. package/docs/user-guide/rules.md +26 -0
  41. package/docs/user-guide/shared-settings.md +65 -0
  42. package/docs/user-guide/troubleshooting.md +30 -0
  43. package/docs/user-guide/versioning.md +8 -0
  44. package/package.json +14 -9
  45. package/RULES.md +0 -1056
@@ -0,0 +1,26 @@
1
+ ---
2
+ title: exported-schema-identifier
3
+ group: Schema
4
+ ---
5
+
6
+ **Intent**: an exported schema carries an `identifier` equal to its name.
7
+
8
+ ```ts
9
+ // ❌ no
10
+ export const EventBusEvents = Schema.Union([Created, Deleted])
11
+
12
+ // ✅ yes
13
+ export const EventBusEvents = Schema.Union([Created, Deleted]).annotate({
14
+ identifier: "EventBusEvents",
15
+ })
16
+ ```
17
+
18
+ **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"`.
19
+
20
+ **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 = { … }`).
21
+
22
+ **Why**: without an `identifier`, decoding errors, JSON Schema and arbitraries describe the schema by its structure instead of its name.
23
+
24
+ **Options**: none
25
+
26
+ **Fix**: suggestion that corrects the `identifier`, or two that add it, `v3:` with `.annotations(…)` and `v4:` with `.annotate(…)`
@@ -0,0 +1,30 @@
1
+ ---
2
+ title: no-asymmetric-match-handlers
3
+ group: Code shape
4
+ ---
5
+
6
+ **Intent**: if one match handler delegates to a named function, the others do too.
7
+
8
+ ```ts
9
+ // ❌ no
10
+ Option.match(only, {
11
+ onNone: () => missingIn(sheet),
12
+ onSome: Effect.fn("onlyCandidate")(function* (name: string) { … }),
13
+ })
14
+
15
+ // ✅ yes
16
+ Option.match(only, {
17
+ onNone: () => missingIn(sheet),
18
+ onSome: (name) => onlyCandidate(sheet, name),
19
+ })
20
+ ```
21
+
22
+ **Trigger**: on the same matches as [`no-nested-match`](no-nested-match.md), a handler with an inline body next to one that delegates. A handler delegates if it is a reference, a call, or a function returning only a call; it is inline if it has multiple statements, is a generator, or builds an `Effect.fn`/`Effect.gen`.
23
+
24
+ **Exemptions and blind spots**: returned literals, templates, arrays and objects are values and count on neither side.
25
+
26
+ **Why**: a branch saying _what_ next to one showing _how_ forces the reader to switch level halfway through the choice.
27
+
28
+ **Options**: none
29
+
30
+ **Fix**: none
@@ -0,0 +1,26 @@
1
+ ---
2
+ title: no-effect-promise
3
+ group: Error channel
4
+ ---
5
+
6
+ **Intent**: a promise that can reject is lifted with [`Effect.tryPromise`](try-object-form.md), not `Effect.promise`.
7
+
8
+ ```ts
9
+ // ❌ no
10
+ Effect.promise(() => client.connect())
11
+
12
+ // ✅ yes
13
+ Effect.tryPromise({ try: () => client.connect(), catch: toConnectionError })
14
+ ```
15
+
16
+ **Trigger**: `Effect.promise` outside the release step of `acquireRelease`, `acquireUseRelease` and `addFinalizer`.
17
+
18
+ **Exemptions and blind spots**: the release step, where a promise that cannot reject is the normal case. Test files, where a rejection during setup is a broken test and the defect fails it with the cause. Elsewhere, a promise that truly cannot reject must be suppressed by hand.
19
+
20
+ **Why**: `Effect.promise` turns a rejection into a defect.
21
+
22
+ **Options**:
23
+
24
+ - `testFiles`: shared setting, see [Shared settings](../shared-settings.md).
25
+
26
+ **Fix**: none
@@ -0,0 +1,29 @@
1
+ ---
2
+ title: no-effect-return-type
3
+ group: Code shape
4
+ ---
5
+
6
+ **Intent**: `Effect`, `Stream` and `Layer` return types are inferred, not annotated, except on exported declarations.
7
+
8
+ ```ts
9
+ // ❌ no
10
+ (effect, name): Effect.Effect<RaceFillResult, never, Agent | SheetReader | SheetWriter> =>
11
+
12
+ // ✅ yes
13
+ (effect, name) =>
14
+
15
+ // ✅ yes: an exported declaration keeps its annotation as a contract
16
+ export const findPrice = (id: PriceId): Effect.Effect<Price, PriceNotFound> =>
17
+ ```
18
+
19
+ **Trigger**: `Effect.Effect`, `Stream.Stream` or `Layer.Layer` in an annotated return type, even nested (`Option.Option<Effect.Effect<…>>`, a function returning an `Effect`).
20
+
21
+ **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.
22
+
23
+ **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`.
24
+
25
+ **Options**:
26
+
27
+ - `checkExported`: default `false`. Also checks exported declarations.
28
+
29
+ **Fix**: suggestion that removes the annotation
@@ -0,0 +1,26 @@
1
+ ---
2
+ title: no-inline-schema-codec
3
+ group: Schema
4
+ ---
5
+
6
+ **Intent**: a decoder or encoder is a named module-level constant, not built and applied inline.
7
+
8
+ ```ts
9
+ // ❌ no
10
+ const toPayment = (raw: unknown) => Schema.decodeUnknownSync(PaymentResponse)(raw)
11
+
12
+ // ✅ yes
13
+ const decodePaymentResponse = Schema.decodeUnknownSync(PaymentResponse)
14
+
15
+ const toPayment = (raw: unknown) => decodePaymentResponse(raw)
16
+ ```
17
+
18
+ **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.
19
+
20
+ **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).
21
+
22
+ **Why**: a named codec says what it decodes at the call site and is built once, instead of at every call.
23
+
24
+ **Options**: none
25
+
26
+ **Fix**: none
@@ -0,0 +1,29 @@
1
+ ---
2
+ title: no-inline-yield-star
3
+ group: Code shape
4
+ ---
5
+
6
+ **Intent**: a `yield*` result is bound to a `const` before being used.
7
+
8
+ ```ts
9
+ // ❌ no
10
+ function* () {
11
+ return yield* missingIn(yield* readSheet())
12
+ }
13
+
14
+ // ✅ yes
15
+ function* () {
16
+ const sheet = yield* readSheet()
17
+ return yield* missingIn(sheet)
18
+ }
19
+ ```
20
+
21
+ **Trigger**: a `yield*` whose result is used inside a larger expression: argument, operand, member, property, template.
22
+
23
+ **Exemptions and blind spots**: a `yield*` that is the whole value of a statement (`yield* x`, `return yield* x`, `if (yield* x)`, `for (… of yield* x)`), a declaration or an assignment. Also the whole branch of a ternary, or the right operand of `&&`/`||`/`??`, in one of those positions (`const a = cached ?? (yield* x)`): binding first would run the effect unconditionally. The autofix extracts every nested `yield*` into `const`s, innermost first, named after the argument's last identifier without a verb prefix (`readSheet()` → `sheet`). There is no autofix when moving the effect could change when or how often it runs: branches of a ternary or `&&`/`||`/`??` inside a larger expression, optional chaining, `while`/`for` conditions, statements without braces, another call evaluated before the `yield*`.
24
+
25
+ **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.
26
+
27
+ **Options**: none
28
+
29
+ **Fix**: autofix
@@ -0,0 +1,36 @@
1
+ ---
2
+ title: no-manual-discriminant-comparison
3
+ group: Tagged values
4
+ ---
5
+
6
+ **Intent**: branching on a union's discriminant goes through an exhaustive match, never through comparisons or `switch`.
7
+
8
+ ```ts
9
+ // ❌ no
10
+ switch (event._tag) {
11
+ case "Created":
12
+ return onCreated(event)
13
+ case "Deleted":
14
+ return onDeleted(event)
15
+ }
16
+
17
+ // ✅ yes
18
+ Match.value(event).pipe(Match.tagsExhaustive({ Created: onCreated, Deleted: onDeleted }))
19
+
20
+ // ✅ yes, with `discriminants: ["_tag", "type"]`
21
+ Match.value(payment).pipe(
22
+ Match.discriminatorsExhaustive("type")({ card: fromCard, transfer: fromTransfer }),
23
+ )
24
+ ```
25
+
26
+ **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`.
27
+
28
+ **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.md): 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.
29
+
30
+ **Why**: `Match.tagsExhaustive`, `Match.discriminatorsExhaustive` and `$match` fail to compile when a variant is added; a comparison or a `switch` does not.
31
+
32
+ **Options**:
33
+
34
+ - `discriminants`: shared setting, see [Shared settings](../shared-settings.md).
35
+
36
+ **Fix**: none
@@ -0,0 +1,32 @@
1
+ ---
2
+ title: no-manual-discriminant-construction
3
+ group: Tagged values
4
+ ---
5
+
6
+ **Intent**: a union's variant is built by its constructor, never as an object literal with the discriminant.
7
+
8
+ ```ts
9
+ // ❌ no
10
+ const error = { _tag: "NotFound", id }
11
+
12
+ // ✅ yes
13
+ const error = new NotFound({ id })
14
+
15
+ // ❌ no, with `discriminants: ["_tag", "eventType"]`
16
+ const event = { eventType: "PaymentStarted", id, payment }
17
+
18
+ // ✅ yes
19
+ const event = PaymentStartedEvent.make({ id, payment })
20
+ ```
21
+
22
+ **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`.
23
+
24
+ **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.
25
+
26
+ **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.
27
+
28
+ **Options**:
29
+
30
+ - `discriminants`: shared setting, see [Shared settings](../shared-settings.md).
31
+
32
+ **Fix**: none
@@ -0,0 +1,44 @@
1
+ ---
2
+ title: no-nested-match
3
+ group: Code shape
4
+ ---
5
+
6
+ **Intent**: a match handler contains neither another match nor a ternary.
7
+
8
+ ```ts
9
+ // ❌ no
10
+ const run = (argv: ReadonlyArray<string>) =>
11
+ Result.match(parseArgs(argv), {
12
+ onFailure: usageError,
13
+ onSuccess: (command) =>
14
+ Option.match(command.only, { onNone: () => fillAll, onSome: fillOnly }),
15
+ })
16
+
17
+ // ✅ yes, outside a generator: a named handler
18
+ const fill = (command: CliCommand) =>
19
+ Option.match(command.only, { onNone: () => fillAll, onSome: fillOnly })
20
+
21
+ const run = (argv: ReadonlyArray<string>) =>
22
+ Result.match(parseArgs(argv), { onFailure: usageError, onSuccess: fill })
23
+
24
+ // ✅ yes, inside a generator: guard clauses
25
+ const run = Effect.fn("run")(function* (argv: ReadonlyArray<string>) {
26
+ const parsed = parseArgs(argv)
27
+ if (Result.isFailure(parsed)) return yield* usageError(parsed.failure)
28
+
29
+ const { only } = parsed.success
30
+ if (Option.isNone(only)) return yield* fillAll
31
+
32
+ return yield* fillOnly(only.value)
33
+ })
34
+ ```
35
+
36
+ **Trigger**: a handler of `Option.match`, `Result.match`, `Exit.match`, `Either.match`, `$match` or `Match.valueTags` that is itself a data-last match (also `Match.type().pipe(…)` and `pipe(Match.type(), …)`), or that returns a match, a `Match.value(…).pipe(…)` or `pipe(Match.value(…), …)` chain or a ternary.
37
+
38
+ **Exemptions and blind spots**: `if` inside handlers, and whatever they return from inside an `if` or another nested block; the handlers of an outer `Match.value`/`Match.type` chain; matches nested deeper than the returned value, or assigned to a variable before being returned.
39
+
40
+ **Why**: two nested levels of choice hide which branch is the normal one.
41
+
42
+ **Options**: none
43
+
44
+ **Fix**: none
@@ -0,0 +1,28 @@
1
+ ---
2
+ title: no-parameterless-catch-all
3
+ group: Error channel
4
+ ---
5
+
6
+ **Intent**: errors are caught by tag; a fallback that ignores the error is spelled `orElseSucceed`/`orElse`, never a catch-all with an ignored parameter.
7
+
8
+ ```ts
9
+ // ❌ no
10
+ Effect.catch(() => Effect.succeed(fallback))
11
+
12
+ // ✅ yes
13
+ Effect.catchTag("AgentTimeout", () => Effect.succeed(skip))
14
+ Effect.catchTag(["ParseError", "ReservedPortError"], () => Effect.succeed(3000))
15
+
16
+ // ✅ yes: every failure truly means the same fallback
17
+ Effect.orElseSucceed(() => fallback)
18
+ ```
19
+
20
+ **Trigger**: `Effect.catch`, `catchAll`, `catchCause` and `catchEager` with a parameterless handler.
21
+
22
+ **Exemptions and blind spots**: a catch-all that discriminates on `_tag` by hand: see [`anti-slop-no-manual-effect-error-tag`](anti-slop-no-manual-effect-error-tag.md), and `catch-all-to-map-error`, `catch-all-tag-dispatch-to-catch-tag` and `catch-to-ignore` (effecttsgo).
23
+
24
+ **Why**: a catch-all that ignores its parameter also swallows the errors added later, which `catchTag` would leave visible in the type.
25
+
26
+ **Options**: none
27
+
28
+ **Fix**: none
@@ -0,0 +1,27 @@
1
+ ---
2
+ title: no-provide-on-definition
3
+ group: Services and Layers
4
+ ---
5
+
6
+ **Intent**: a program does not provide its own Layers; its caller does.
7
+
8
+ ```ts
9
+ // ❌ no
10
+ const printMissing = Effect.gen(function* () { … }).pipe(Effect.provide(missingLayer))
11
+
12
+ // ✅ yes: the program stays unprovided, its caller provides
13
+ const printMissing = Effect.gen(function* () { … })
14
+ Missing: () => printMissing.pipe(Effect.provide(missingLayer)),
15
+ ```
16
+
17
+ **Trigger**: `Effect.provide` applied to `Effect.gen(…)` or to `Effect.fn(…)(…)`, in all three forms: with `.pipe`, as an `Effect.fn` pipeable, data-first.
18
+
19
+ **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.
20
+
21
+ **Why**: a program that provides its own Layers can no longer be wired to different adapters by its caller.
22
+
23
+ **Options**:
24
+
25
+ - `testFiles`: shared setting, see [Shared settings](../shared-settings.md).
26
+
27
+ **Fix**: none
@@ -0,0 +1,34 @@
1
+ ---
2
+ title: no-throw-or-primitive-failure
3
+ group: Error channel
4
+ ---
5
+
6
+ **Intent**: an expected failure is a tagged error, never a thrown or primitive value.
7
+
8
+ ```ts
9
+ // ❌ no
10
+ Effect.gen(function* () {
11
+ if (tab === undefined) throw new Error("tab not found")
12
+ })
13
+ Effect.fail("tab not found")
14
+
15
+ // ✅ yes
16
+ export class SheetTabNotFoundError extends Schema.TaggedError<SheetTabNotFoundError>()(
17
+ "SheetTabNotFoundError",
18
+ { spreadsheetId: Schema.String, message: Schema.String },
19
+ ) {}
20
+
21
+ Effect.fail(new SheetTabNotFoundError({ spreadsheetId, message: "tab not found" }))
22
+ ```
23
+
24
+ **Trigger**: a `throw` directly inside a generator passed to `Effect.gen`/`Effect.fn`; `Effect.fail` with a literal or a template.
25
+
26
+ **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).
27
+
28
+ **Why**: the typed error channel discriminates only on tags. A `throw` in the generator becomes a defect, outside the error channel.
29
+
30
+ **Options**:
31
+
32
+ - `testFiles`: shared setting, see [Shared settings](../shared-settings.md).
33
+
34
+ **Fix**: none
@@ -0,0 +1,41 @@
1
+ ---
2
+ title: no-transport-types-in-generator
3
+ group: Design and boundaries
4
+ ---
5
+
6
+ **Intent**: the generator returns a domain value; conversion to a transport type happens in the second argument of `Effect.fn`.
7
+
8
+ ```ts
9
+ // ❌ no: an error branch inside the happy path
10
+ function* (request: Request) {
11
+ const credential = yield* sessionCredential
12
+ if (credential === undefined) return Response.redirect(new URL("/login", origin), 303)
13
+ // …
14
+ }
15
+
16
+ // ✅ yes
17
+ const createEvent = Effect.fn("events.create")(
18
+ function* (request: Request) {
19
+ const credential = yield* sessionCredential // fails with Unauthenticated
20
+ const submission = yield* form(request)
21
+ return yield* apiCreateEvent(credential, submission)
22
+ },
23
+ (effect, request) =>
24
+ effect.pipe(
25
+ Effect.map((created) => Response.redirect(eventUrl(request, created), 303)),
26
+ Effect.catchTags({ Unauthenticated: () => Effect.succeed(toLogin(request)) }),
27
+ ),
28
+ )
29
+ ```
30
+
31
+ **Trigger**: `new T(…)`, `T.method(…)` and `T(…)` inside an Effect generator, callbacks passed to `Effect.*` combinators included, when `T` is a transport type.
32
+
33
+ **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.
34
+
35
+ **Why**: an early `return new Response(…)` on the happy path is an error branch disguised as success, and escapes exhaustive translation.
36
+
37
+ **Options**:
38
+
39
+ - `transportTypes`: default `["Response", "NextResponse"]`, replaced by the configured list. Add your own transport DTOs.
40
+
41
+ **Fix**: none
@@ -0,0 +1,35 @@
1
+ ---
2
+ title: no-yielded-match
3
+ group: Code shape
4
+ ---
5
+
6
+ **Intent**: inside an Effect generator, return early on the empty or failed case instead of yielding a match.
7
+
8
+ ```ts
9
+ // ❌ no
10
+ const save = Effect.fn("save")(function* (id: PolicyId) {
11
+ const found = yield* findPolicy(id)
12
+ return yield* Option.match(found, {
13
+ onNone: () => register(id),
14
+ onSome: (policy) => update(policy),
15
+ })
16
+ })
17
+
18
+ // ✅ yes
19
+ const save = Effect.fn("save")(function* (id: PolicyId) {
20
+ const found = yield* findPolicy(id)
21
+ if (Option.isNone(found)) return yield* register(id)
22
+
23
+ return yield* update(found.value)
24
+ })
25
+ ```
26
+
27
+ **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`.
28
+
29
+ **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.
30
+
31
+ **Why**: the match splits the generator into two equal-weight branches; a guard clause leaves one main path of `yield*`.
32
+
33
+ **Options**: none
34
+
35
+ **Fix**: none
@@ -0,0 +1,31 @@
1
+ ---
2
+ title: prefer-effect-fn
3
+ group: Effectful functions
4
+ ---
5
+
6
+ **Intent**: an effectful function is `Effect.fn("name")`, and a named effect value has a span.
7
+
8
+ ```ts
9
+ // ❌ no
10
+ const outcomeFor = (name: string) => Effect.gen(function* () { … }).pipe(Effect.catch(toFailed))
11
+ const refresh = Effect.gen(function* () { … })
12
+
13
+ // ✅ yes: extra combinators as arguments, not .pipe
14
+ const outcomeFor = Effect.fn("outcomeFor")(function* (name: string) { … }, Effect.catch(toFailed))
15
+ const refresh = Effect.gen(function* () { … }).pipe(Effect.withSpan("refresh"))
16
+ ```
17
+
18
+ **Trigger**:
19
+
20
+ - a declared function, a variable or an object member that only returns `Effect.gen(…)`, even with `.pipe`;
21
+ - a variable or an object member whose value is `Effect.gen(…)` without `Effect.withSpan` in its `.pipe`;
22
+ - `Effect.fn(body)` without a name;
23
+ - `.pipe` on `Effect.fn(…)(…)`.
24
+
25
+ **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.md).
26
+
27
+ **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(…)`.
28
+
29
+ **Options**: none
30
+
31
+ **Fix**: none
@@ -0,0 +1,30 @@
1
+ ---
2
+ title: prefer-effect-vitest
3
+ group: Runtime and entry point
4
+ ---
5
+
6
+ **Intent**: tests hand their Effects to `@effect/vitest` instead of running them.
7
+
8
+ ```ts
9
+ // ❌ no
10
+ test("…", async () => {
11
+ await Effect.runPromise(authorize({ role: "Manager" }))
12
+ })
13
+
14
+ // ✅ yes: fakes as Layers
15
+ import { effect } from "@effect/vitest"
16
+
17
+ effect("…", () => authorize({ role: "Manager" }).pipe(Effect.provide(sheetWith(row))))
18
+ ```
19
+
20
+ **Trigger**: the same references as [`run-only-in-runner`](run-only-in-runner.md), inside test files only.
21
+
22
+ **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.
23
+
24
+ **Why**: the test runtime brings `TestClock` and a scope per test, and fixtures provided as Layers replace services where the code reads them.
25
+
26
+ **Options**:
27
+
28
+ - `testFiles`: shared setting, see [Shared settings](../shared-settings.md).
29
+
30
+ **Fix**: none
@@ -0,0 +1,27 @@
1
+ ---
2
+ title: provide-named-layer
3
+ group: Services and Layers
4
+ ---
5
+
6
+ **Intent**: `Effect.provide` receives a named Layer, never an inline composition.
7
+
8
+ ```ts
9
+ // ❌ no
10
+ printMissing.pipe(Effect.provide(Layer.mergeAll(FileRaceList.layer, GoogleSheet.readerLayer)))
11
+
12
+ // ✅ yes: the Layer is named in the entry point, provided in the dispatch
13
+ const missingLayer = Layer.mergeAll(FileRaceList.layer, GoogleSheet.readerLayer)
14
+ Missing: () => printMissing.pipe(Effect.provide(missingLayer)),
15
+ ```
16
+
17
+ **Trigger**: an `Effect.provide` argument composed inline: `Layer.*(…)`, a ternary, an `&&`/`||`, a `match`/`$match`.
18
+
19
+ **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).
20
+
21
+ **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.
22
+
23
+ **Options**:
24
+
25
+ - `testFiles`: shared setting, see [Shared settings](../shared-settings.md).
26
+
27
+ **Fix**: none
@@ -0,0 +1,31 @@
1
+ ---
2
+ title: redacted-secret-names
3
+ group: Config and secrets
4
+ ---
5
+
6
+ **Intent**: a secret read from config or declared in a schema is `Redacted`.
7
+
8
+ ```ts
9
+ // ❌ no
10
+ serviceAccount: Config.schema(NonBlank, "GOOGLE_SERVICE_ACCOUNT_JSON")
11
+ const Session = Schema.Struct({ userId: UserId, accessToken: Schema.String })
12
+
13
+ // ✅ yes
14
+ serviceAccount: Config.schema(Schema.Redacted(NonBlank), "GOOGLE_SERVICE_ACCOUNT_JSON")
15
+ const Session = Schema.Struct({ userId: UserId, accessToken: Schema.Redacted(Schema.String) })
16
+ ```
17
+
18
+ **Trigger**:
19
+
20
+ - 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)`;
21
+ - 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.
22
+
23
+ **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).
24
+
25
+ **Why**: a `Redacted` value does not end up in logs or `toString`.
26
+
27
+ **Options**:
28
+
29
+ - `secretFields`: default `["password", "secret", "apiKey", "accessToken", "refreshToken", "idToken"]` (no generic `token`), replaced by the configured list. `Config` names are fixed.
30
+
31
+ **Fix**: none
@@ -0,0 +1,32 @@
1
+ ---
2
+ title: run-only-in-runner
3
+ group: Runtime and entry point
4
+ ---
5
+
6
+ **Intent**: Effects run only in the program's runner, the entry point.
7
+
8
+ ```ts
9
+ // ❌ no
10
+ export const check = () => Effect.runPromise(pingDb(url))
11
+
12
+ // ✅ yes: in main.ts
13
+ NodeRuntime.runMain(
14
+ run(process.argv.slice(2)).pipe(Effect.provide(NodeServices.layer)) satisfies Effect.Effect<
15
+ void,
16
+ never
17
+ >,
18
+ )
19
+ ```
20
+
21
+ **Trigger**: every reference, not just calls, to `Effect.run*`, `NodeRuntime.runMain`, `BunRuntime.runMain` and `ManagedRuntime.make` outside entry files.
22
+
23
+ **Exemptions and blind spots**: test files, checked by [`prefer-effect-vitest`](prefer-effect-vitest.md) instead.
24
+
25
+ **Why**: logger, Layers and defect handling are configured in one place.
26
+
27
+ **Options**:
28
+
29
+ - `entryFiles`: shared setting, see [Shared settings](../shared-settings.md).
30
+ - `testFiles`: shared setting, see [Shared settings](../shared-settings.md).
31
+
32
+ **Fix**: none
@@ -0,0 +1,36 @@
1
+ ---
2
+ title: service-built-with-of
3
+ group: Services and Layers
4
+ ---
5
+
6
+ **Intent**: the service a Layer provides is built with `Tag.of({…})`, not a bare object literal.
7
+
8
+ ```ts
9
+ // ❌ no
10
+ export const RepoLive = Layer.effect(
11
+ Repo,
12
+ Effect.gen(function* () {
13
+ const db = yield* Database
14
+ return { find: (id: RepoId) => db.get(id) }
15
+ }),
16
+ )
17
+
18
+ // ✅ yes
19
+ export const RepoLive = Layer.effect(
20
+ Repo,
21
+ Effect.gen(function* () {
22
+ const db = yield* Database
23
+ return Repo.of({ find: (id: RepoId) => db.get(id) })
24
+ }),
25
+ )
26
+ ```
27
+
28
+ **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.
29
+
30
+ **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.
31
+
32
+ **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.
33
+
34
+ **Options**: none
35
+
36
+ **Fix**: suggestion that wraps the literal in `T.of(…)`