Unified · Split
| @@ -0,0 +1,25 @@ | |||
| 1 | --- | ||
| 2 | name: wrdn-effect-atom-reactivity-keys | ||
| 3 | description: Add reactivityKeys to effect-atom write mutation calls. Use when lint flags a useAtomSet mutation call that mutates data without invalidation keys. | ||
| 4 | allowed-tools: Read Grep Glob Bash | ||
| 5 | --- | ||
| 6 | |||
| 7 | Effect-atom write mutations must say which reads they invalidate. | ||
| 8 | |||
| 9 | ## Fix Shape | ||
| 10 | |||
| 11 | - Find the `useAtomSet(...)` write mutation call. | ||
| 12 | - Add `reactivityKeys` to the mutation payload at the call site. | ||
| 13 | - Use the narrowest keys that cover the rows/lists affected by the write. | ||
| 14 | - Keep read-only probe/preview OAuth flows out of this pattern. | ||
| 15 | - If the mutation should update UI immediately, check whether `wrdn-effect-atom-optimistic` also applies. | ||
| 16 | |||
| 17 | ## Good | ||
| 18 | |||
| 19 | ```ts | ||
| 20 | await updateSource({ | ||
| 21 | params: { scopeId, sourceId }, | ||
| 22 | payload, | ||
| 23 | reactivityKeys: [["sources", scopeId]], | ||
| 24 | }); | ||
| 25 | ``` | ||
| @@ -0,0 +1,115 @@ | |||
| 1 | --- | ||
| 2 | name: wrdn-effect-promise-exit | ||
| 3 | description: Replace React/effect-atom mutation handlers that use promise-mode plus try/catch with promiseExit and explicit Exit handling. Use when lint or review flags try/catch around useAtomSet mutation calls, especially UI handlers that set error/busy state after a failed mutation. | ||
| 4 | allowed-tools: Read Grep Glob Bash | ||
| 5 | --- | ||
| 6 | |||
| 7 | You fix one pattern: a React handler awaits an effect-atom mutation in `mode: "promise"` and catches failures with `try/catch`. | ||
| 8 | |||
| 9 | The preferred UI boundary is `mode: "promiseExit"` plus `Exit.isFailure`. This keeps mutation failures as values, matches Effect's error model, and prevents optimistic mutation cleanup from depending on thrown exceptions. | ||
| 10 | |||
| 11 | ## Trace before changing | ||
| 12 | |||
| 13 | 1. **Find the mutation setter.** Look for `const doX = useAtomSet(<mutationAtom>, { mode: "promise" })`. | ||
| 14 | 2. **Confirm it is an effect-atom mutation boundary.** The setter should come from `@effect/atom-react` and a mutation atom from `./atoms`, `../api/atoms`, or plugin React atoms. | ||
| 15 | 3. **Find thrown-control handling.** The same handler has `try { await doX(...) } catch (e) { ... }`, usually setting error text, resetting `adding`/`saving`, or showing a toast. | ||
| 16 | 4. **Check for non-mutation async work in the same block.** If the block also awaits follow-up mutations, convert those to `promiseExit` too or keep a narrow boundary only around truly non-effect APIs. | ||
| 17 | 5. **Do not rewrite unrelated local async code.** Probe requests, OAuth popup helpers, `fetch`, and browser APIs may need a different skill unless the lint finding specifically points at the mutation call. | ||
| 18 | |||
| 19 | ## Fix shape | ||
| 20 | |||
| 21 | - Change the setter to `{ mode: "promiseExit" }`. | ||
| 22 | - Import `* as Exit from "effect/Exit"` if missing. | ||
| 23 | - Import `* as Option from "effect/Option"` only when extracting an optional error. | ||
| 24 | - Replace `try/catch` around the mutation with: | ||
| 25 | - `const exit = await doX(args);` | ||
| 26 | - `if (Exit.isFailure(exit)) { ...; return; }` | ||
| 27 | - success work after the failure branch. | ||
| 28 | - Use `Exit.findErrorOption(exit)` when preserving an existing error message or typed error branch. | ||
| 29 | - Keep existing typed error handling when present, e.g. `SecretInUseError`, `ConnectionInUseError`. | ||
| 30 | |||
| 31 | ## Bad | ||
| 32 | |||
| 33 | ```tsx | ||
| 34 | const doAdd = useAtomSet(addGraphqlSource, { mode: "promise" }); | ||
| 35 | |||
| 36 | const handleAdd = async () => { | ||
| 37 | setAdding(true); | ||
| 38 | setAddError(null); | ||
| 39 | try { | ||
| 40 | await doAdd({ | ||
| 41 | params: { scopeId }, | ||
| 42 | payload, | ||
| 43 | reactivityKeys: sourceWriteKeys, | ||
| 44 | }); | ||
| 45 | props.onComplete(); | ||
| 46 | } catch (e) { | ||
| 47 | setAddError(e instanceof Error ? e.message : "Failed to add source"); | ||
| 48 | setAdding(false); | ||
| 49 | } | ||
| 50 | }; | ||
| 51 | ``` | ||
| 52 | |||
| 53 | ## Good | ||
| 54 | |||
| 55 | ```tsx | ||
| 56 | import * as Exit from "effect/Exit"; | ||
| 57 | import * as Option from "effect/Option"; | ||
| 58 | |||
| 59 | const doAdd = useAtomSet(addGraphqlSource, { mode: "promiseExit" }); | ||
| 60 | |||
| 61 | const handleAdd = async () => { | ||
| 62 | setAdding(true); | ||
| 63 | setAddError(null); | ||
| 64 | const exit = await doAdd({ | ||
| 65 | params: { scopeId }, | ||
| 66 | payload, | ||
| 67 | reactivityKeys: sourceWriteKeys, | ||
| 68 | }); | ||
| 69 | if (Exit.isFailure(exit)) { | ||
| 70 | const error = Exit.findErrorOption(exit); | ||
| 71 | setAddError( | ||
| 72 | Option.isSome(error) && error.value instanceof Error | ||
| 73 | ? error.value.message | ||
| 74 | : "Failed to add source", | ||
| 75 | ); | ||
| 76 | setAdding(false); | ||
| 77 | return; | ||
| 78 | } | ||
| 79 | props.onComplete(); | ||
| 80 | }; | ||
| 81 | ``` | ||
| 82 | |||
| 83 | ## Follow-up mutation chains | ||
| 84 | |||
| 85 | If success work depends on the mutation result, read it after the failure branch: | ||
| 86 | |||
| 87 | ```tsx | ||
| 88 | const exit = await doAdd(args); | ||
| 89 | if (Exit.isFailure(exit)) { | ||
| 90 | setAdding(false); | ||
| 91 | return; | ||
| 92 | } | ||
| 93 | |||
| 94 | const sourceId = exit.value.namespace; | ||
| 95 | ``` | ||
| 96 | |||
| 97 | If a follow-up effect-atom mutation can fail and the UI treats that as add failure, make that setter `promiseExit` too and branch the same way. Do not put the follow-up mutation in `try/catch` just because the first mutation now returns `Exit`. | ||
| 98 | |||
| 99 | ## What not to report | ||
| 100 | |||
| 101 | - `try/catch` around non-effect APIs such as `new URL`, `JSON.parse`, raw `fetch`, or browser popup code. Those may be real lint findings, but they need a different remediation skill. | ||
| 102 | - `useAtomSet(..., { mode: "promise" })` with no local failure handling and no lint finding. Some call sites intentionally let callers decide the boundary. | ||
| 103 | - Tests or SDK/server Effect code. This skill is for React/effect-atom UI mutation handlers. | ||
| 104 | - Manual optimistic placeholder cleanup. Use `wrdn-effect-atom-optimistic` for that; if both patterns appear together, fix optimistic plumbing first, then use `promiseExit` for the remaining mutation boundary. | ||
| 105 | |||
| 106 | ## Output requirements | ||
| 107 | |||
| 108 | When reviewing, report: | ||
| 109 | |||
| 110 | - **File and line** of the `useAtomSet(..., { mode: "promise" })` or `try/catch`. | ||
| 111 | - **Mutation** being called. | ||
| 112 | - **Why** it should return `Exit` at this UI boundary. | ||
| 113 | - **Fix**: the exact setter mode and the failure branch to add. | ||
| 114 | |||
| 115 | When editing, keep changes local to the handler and imports unless a follow-up mutation in the same success path must also become `promiseExit`. | ||
| @@ -0,0 +1,30 @@ | |||
| 1 | --- | ||
| 2 | name: wrdn-effect-schema-boundaries | ||
| 3 | description: Normalize unknown or loosely typed data at boundaries with Effect Schema, named guards, or typed adapters. Use when lint flags double casts, inline object assertions, unknown shape probing, or ad hoc property checks on unknown values. | ||
| 4 | allowed-tools: Read Grep Glob Bash | ||
| 5 | --- | ||
| 6 | |||
| 7 | You fix one pattern: domain code is asserting or probing an unknown shape instead of parsing it once at the boundary. | ||
| 8 | |||
| 9 | ## Fix Shape | ||
| 10 | |||
| 11 | - Prefer `Schema.decodeUnknownEffect(MySchema)(value)` for untrusted input. | ||
| 12 | - Keep domain code typed after the decode; do not keep `unknown` and probe it repeatedly. | ||
| 13 | - Replace `as unknown as X`, `as Record<string, unknown>`, inline object assertions, `"field" in value`, and `Reflect.get` with a schema, typed adapter, or named guard. | ||
| 14 | - A named guard is acceptable only when parsing is not the right abstraction and the guard has a precise return type. | ||
| 15 | |||
| 16 | ## Good | ||
| 17 | |||
| 18 | ```ts | ||
| 19 | const ParsedConfig = Schema.Struct({ | ||
| 20 | endpoint: Schema.String, | ||
| 21 | }); | ||
| 22 | |||
| 23 | const config = yield * Schema.decodeUnknownEffect(ParsedConfig)(raw); | ||
| 24 | ``` | ||
| 25 | |||
| 26 | ## Bad | ||
| 27 | |||
| 28 | ```ts | ||
| 29 | const config = raw as unknown as { endpoint: string }; | ||
| 30 | ``` | ||
| @@ -0,0 +1,107 @@ | |||
| 1 | --- | ||
| 2 | name: wrdn-effect-schema-inferred-types | ||
| 3 | description: Replace duplicated TypeScript shape declarations next to Effect Schema definitions with schema-derived types. Use when lint or review flags an interface/type alias that repeats fields already described by a nearby Schema.Struct, Schema.Union, Schema.TaggedStruct, or other Effect Schema model. | ||
| 4 | allowed-tools: Read Grep Glob Bash | ||
| 5 | --- | ||
| 6 | |||
| 7 | You fix one pattern: a runtime `Schema` and a manual TypeScript type describe the same shape. | ||
| 8 | |||
| 9 | The preferred boundary is schema-first. Define the schema once, export `type X = typeof XSchema.Type` or `type X = Schema.Schema.Type<typeof XSchema>`, and make domain code consume the inferred type. This prevents drift between parsing and static types. | ||
| 10 | |||
| 11 | ## Trace before changing | ||
| 12 | |||
| 13 | 1. **Find the runtime schema.** Look for `Schema.Struct`, `Schema.Union`, `Schema.TaggedStruct`, `Schema.Record`, `Schema.Array`, or `Schema.decodeTo`. | ||
| 14 | 2. **Find the duplicate static shape.** A nearby `interface X` or `type X = { ... }` repeats the same fields, nullability, optionality, or literals. | ||
| 15 | 3. **Check export consumers.** If callers import the type, keep the exported type name stable and change only its definition. | ||
| 16 | 4. **Confirm the schema is the source of truth.** If the manual type is wider/narrower than runtime parsing, decide whether the schema or consumers are wrong before replacing it. | ||
| 17 | 5. **Handle recursion narrowly.** Recursive schemas may need one private recursive helper type to annotate `Schema.suspend`; keep exported domain types inferred from the schema. | ||
| 18 | |||
| 19 | ## Fix shape | ||
| 20 | |||
| 21 | - Move the schema before the exported type alias when needed. | ||
| 22 | - Replace duplicated exported interfaces with aliases derived from the schema: | ||
| 23 | |||
| 24 | ```ts | ||
| 25 | export const SourceSchema = Schema.Struct({ | ||
| 26 | id: SourceId, | ||
| 27 | name: Schema.String, | ||
| 28 | enabled: Schema.Boolean, | ||
| 29 | }); | ||
| 30 | |||
| 31 | export type Source = typeof SourceSchema.Type; | ||
| 32 | ``` | ||
| 33 | |||
| 34 | - Use `Schema.Schema.Type<typeof XSchema>` when it reads better for non-exported or generic schemas: | ||
| 35 | |||
| 36 | ```ts | ||
| 37 | type IntrospectionResult = Schema.Schema.Type<typeof IntrospectionResultModel>; | ||
| 38 | ``` | ||
| 39 | |||
| 40 | - If using `Schema.decodeTo`, infer the domain type from the decoded/domain schema, not from the raw transport schema. | ||
| 41 | - Do not keep a manual interface solely for documentation. Add schema annotations or comments only when they clarify behavior the schema cannot express. | ||
| 42 | |||
| 43 | ## Bad | ||
| 44 | |||
| 45 | ```ts | ||
| 46 | export interface StoredSource { | ||
| 47 | readonly id: string; | ||
| 48 | readonly url: string; | ||
| 49 | readonly headers: readonly Header[]; | ||
| 50 | } | ||
| 51 | |||
| 52 | export const StoredSourceSchema = Schema.Struct({ | ||
| 53 | id: Schema.String, | ||
| 54 | url: Schema.String, | ||
| 55 | headers: Schema.Array(HeaderSchema), | ||
| 56 | }); | ||
| 57 | ``` | ||
| 58 | |||
| 59 | ## Good | ||
| 60 | |||
| 61 | ```ts | ||
| 62 | export const StoredSourceSchema = Schema.Struct({ | ||
| 63 | id: Schema.String, | ||
| 64 | url: Schema.String, | ||
| 65 | headers: Schema.Array(HeaderSchema), | ||
| 66 | }); | ||
| 67 | |||
| 68 | export type StoredSource = typeof StoredSourceSchema.Type; | ||
| 69 | ``` | ||
| 70 | |||
| 71 | ## Recursive schemas | ||
| 72 | |||
| 73 | Use a private helper only where TypeScript needs an annotation for self-reference: | ||
| 74 | |||
| 75 | ```ts | ||
| 76 | interface TypeRefRecursive { | ||
| 77 | readonly kind: string; | ||
| 78 | readonly ofType: TypeRefRecursive | null; | ||
| 79 | } | ||
| 80 | |||
| 81 | const TypeRefSchema: Schema.Codec<TypeRefRecursive> = Schema.Struct({ | ||
| 82 | kind: Schema.String, | ||
| 83 | ofType: Schema.NullOr(Schema.suspend(() => TypeRefSchema)), | ||
| 84 | }); | ||
| 85 | |||
| 86 | export type TypeRef = typeof TypeRefSchema.Type; | ||
| 87 | ``` | ||
| 88 | |||
| 89 | The exported domain type is still schema-derived. The private helper exists only to satisfy the recursive schema definition. | ||
| 90 | |||
| 91 | ## What not to report | ||
| 92 | |||
| 93 | - Domain types that intentionally do not have a runtime schema. | ||
| 94 | - Input builder types where the schema parses a different transport representation. | ||
| 95 | - Branded IDs or opaque aliases that are used by schemas but are not themselves duplicate object shapes. | ||
| 96 | - Private recursive helper types used only to type `Schema.suspend`, as long as exported consumer-facing types are inferred. | ||
| 97 | |||
| 98 | ## Output requirements | ||
| 99 | |||
| 100 | When reviewing, report: | ||
| 101 | |||
| 102 | - **File and line** of the duplicated manual type. | ||
| 103 | - **Schema** that already owns the shape. | ||
| 104 | - **Why** the manual type can drift. | ||
| 105 | - **Fix**: the exact inferred alias to use. | ||
| 106 | |||
| 107 | When editing, keep exported type names stable unless every caller is updated in the same change. | ||
| @@ -0,0 +1,329 @@ | |||
| 1 | --- | ||
| 2 | name: wrdn-effect-typed-errors | ||
| 3 | description: Fix lint findings that use untyped JavaScript error handling instead of Effect typed failures. Use when lint flags new Error, throw, try/catch, Promise.catch, Promise.reject, instanceof Error, unknown error message/stringification, or redundant helpers that only construct tagged errors. | ||
| 4 | allowed-tools: Read Grep Glob Bash | ||
| 5 | --- | ||
| 6 | |||
| 7 | You fix one family of patterns: untyped JavaScript error handling in Effect code. | ||
| 8 | |||
| 9 | The preferred boundary is typed `Schema.TaggedError` / `Data.TaggedError` values in the Effect error channel. Construct the tagged error directly at the failure site unless a helper performs real classification or normalization. | ||
| 10 | |||
| 11 | ## Trace before changing | ||
| 12 | |||
| 13 | 1. **Identify the boundary.** Is this Effect domain code, React UI code, a third-party callback, or plain test/tooling code? | ||
| 14 | 2. **Find the existing domain errors.** Check nearby `errors.ts`, `Schema.TaggedError`, `Data.TaggedError`, and API `.addError(...)` declarations before adding a new class. | ||
| 15 | 3. **Decide whether a new error is needed.** Add a new tagged error only if callers have a distinct recovery path, HTTP status, UI affordance, retry policy, or telemetry classification. | ||
| 16 | 4. **Preserve failure semantics.** If the old code failed, the new code should fail in the Effect error channel. Do not replace thrown failures with fallback values like `false`, `null`, `undefined`, `[]`, or `"unknown"` unless the existing contract already treats that condition as non-fatal. | ||
| 17 | 5. **Preserve the typed channel.** Do not convert typed failures into `Error`, thrown exceptions, `String(error)`, or `.message` reads from unknown values. | ||
| 18 | 6. **Recognize real boundaries.** Runtime workers, Vite/CLI tooling, callback APIs, and third-party interfaces may have to throw, catch, or reject at the boundary. Do not contort those files into fake Effect shapes. Keep the boundary idiom when it is contained and immediately wrapped into an Effect error channel, stable IPC envelope, or test/tooling result. | ||
| 19 | 7. **Do not hide construction behind trivial helpers.** Inline `new DomainError(...)` unless the helper branches on input or maps an external error format into a domain error. | ||
| 20 | |||
| 21 | ## Preserve behavior first | ||
| 22 | |||
| 23 | The lint rule is about **where the failure lives**, not whether the operation should still fail. | ||
| 24 | |||
| 25 | Bad fix: this removes the lint finding by silently changing invalid input into a non-match. | ||
| 26 | |||
| 27 | ```ts | ||
| 28 | case "in": | ||
| 29 | if (!Array.isArray(value)) return false; | ||
| 30 | return value.some((v) => cmp(lhs, v)); | ||
| 31 | ``` | ||
| 32 | |||
| 33 | Good fix: keep the invalid input as a failure, but make it typed. | ||
| 34 | |||
| 35 | ```ts | ||
| 36 | case "in": | ||
| 37 | if (!Array.isArray(value)) { | ||
| 38 | return Effect.fail( | ||
| 39 | new StorageError({ message: "Value must be an array", cause: clause }), | ||
| 40 | ); | ||
| 41 | } | ||
| 42 | return Effect.succeed(value.some((v) => cmp(lhs, v))); | ||
| 43 | ``` | ||
| 44 | |||
| 45 | When the containing helper was synchronous, make the helper return `Effect.Effect<Success, DomainError>` and thread that through callers. Do not collapse the error into a success value to avoid changing call sites. | ||
| 46 | |||
| 47 | ## Boundary exceptions | ||
| 48 | |||
| 49 | The lint rule is not a mandate to make every file Effect-shaped. It is acceptable to keep `try/catch`, `throw`, `new Error`, `.catch`, or `String(error)` at a true adapter boundary when all of these are true: | ||
| 50 | |||
| 51 | - the surrounding API is inherently throwing, callback-based, Promise-based, process/IPC-based, or plain JS tooling | ||
| 52 | - the untyped behavior is contained to the boundary function or module | ||
| 53 | - control is immediately translated into a typed Effect failure, stable IPC payload, stable test assertion, or deliberately best-effort cleanup | ||
| 54 | - the suppression is narrow and explains the boundary | ||
| 55 | |||
| 56 | Good boundary suppression: | ||
| 57 | |||
| 58 | ```ts | ||
| 59 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: JSON.parse feeds stable IPC failure envelope | ||
| 60 | try { | ||
| 61 | const message = JSON.parse(line); | ||
| 62 | handleHostMessage(message); | ||
| 63 | } catch (error) { | ||
| 64 | writeIpcMessage({ type: "failed", error: formatBoundaryError(error) }); | ||
| 65 | } | ||
| 66 | ``` | ||
| 67 | |||
| 68 | Bad boundary fix: do not replace natural boundary code with fake thenables, fake error objects, promise chains that emulate `try/catch`, or broad helper machinery solely to make lint pass. | ||
| 69 | |||
| 70 | ```ts | ||
| 71 | return makeRejectedThenable(makeErrorLike("Tool path missing")); | ||
| 72 | ``` | ||
| 73 | |||
| 74 | For Effect domain code, fix the code. For boundary code, either wrap once with `Effect.try` / `Effect.tryPromise` at the entry point or use a narrow suppression with a reason. | ||
| 75 | |||
| 76 | ## Fix shapes | ||
| 77 | |||
| 78 | ### Throw / new Error | ||
| 79 | |||
| 80 | Bad: | ||
| 81 | |||
| 82 | ```ts | ||
| 83 | throw new Error("Missing source"); | ||
| 84 | ``` | ||
| 85 | |||
| 86 | Good in `Effect.gen`: | ||
| 87 | |||
| 88 | ```ts | ||
| 89 | return yield* new SourceNotFoundError({ sourceId }); | ||
| 90 | ``` | ||
| 91 | |||
| 92 | Good in combinators: | ||
| 93 | |||
| 94 | ```ts | ||
| 95 | Effect.fail(new SourceNotFoundError({ sourceId })); | ||
| 96 | ``` | ||
| 97 | |||
| 98 | If a third-party interface requires throwing, keep the throw at the adapter edge only and convert back into a typed failure as soon as control returns to Effect. Prefer a narrow `oxlint-disable-next-line` with a `boundary:` reason over code contortions. | ||
| 99 | |||
| 100 | ### Effect.fail inside generators | ||
| 101 | |||
| 102 | Prefer yielding the error directly in generator code: | ||
| 103 | |||
| 104 | ```ts | ||
| 105 | return yield* new SourceNotFoundError({ sourceId }); | ||
| 106 | ``` | ||
| 107 | |||
| 108 | Do not write: | ||
| 109 | |||
| 110 | ```ts | ||
| 111 | return yield* Effect.fail(new SourceNotFoundError({ sourceId })); | ||
| 112 | ``` | ||
| 113 | |||
| 114 | Use `Effect.fail(...)` in non-generator combinator code: | ||
| 115 | |||
| 116 | ```ts | ||
| 117 | Effect.flatMap( | ||
| 118 | source, | ||
| 119 | Option.match({ | ||
| 120 | onNone: () => Effect.fail(new SourceNotFoundError({ sourceId })), | ||
| 121 | onSome: Effect.succeed, | ||
| 122 | }), | ||
| 123 | ); | ||
| 124 | ``` | ||
| 125 | |||
| 126 | ### Promise.catch / Promise.reject | ||
| 127 | |||
| 128 | Bad: | ||
| 129 | |||
| 130 | ```ts | ||
| 131 | await client.close().catch(() => {}); | ||
| 132 | return Promise.reject(new Error("failed")); | ||
| 133 | ``` | ||
| 134 | |||
| 135 | Good: | ||
| 136 | |||
| 137 | ```ts | ||
| 138 | Effect.tryPromise({ | ||
| 139 | try: () => client.close(), | ||
| 140 | catch: (cause) => new ClientCloseError({ cause }), | ||
| 141 | }); | ||
| 142 | ``` | ||
| 143 | |||
| 144 | If the failure is intentionally ignored: | ||
| 145 | |||
| 146 | ```ts | ||
| 147 | Effect.ignore( | ||
| 148 | Effect.tryPromise({ | ||
| 149 | try: () => client.close(), | ||
| 150 | catch: (cause) => new ClientCloseError({ cause }), | ||
| 151 | }), | ||
| 152 | ); | ||
| 153 | ``` | ||
| 154 | |||
| 155 | ### try/catch | ||
| 156 | |||
| 157 | Bad: | ||
| 158 | |||
| 159 | ```ts | ||
| 160 | try { | ||
| 161 | return JSON.parse(text); | ||
| 162 | } catch (cause) { | ||
| 163 | return new ParseError({ message: String(cause) }); | ||
| 164 | } | ||
| 165 | ``` | ||
| 166 | |||
| 167 | Good for schema-backed input: | ||
| 168 | |||
| 169 | ```ts | ||
| 170 | Schema.decodeUnknownEffect(Schema.fromJsonString(InputSchema))(text).pipe( | ||
| 171 | Effect.mapError(() => new ParseError({ message: "Failed to parse input" })), | ||
| 172 | ); | ||
| 173 | ``` | ||
| 174 | |||
| 175 | Good for non-schema throwing APIs: | ||
| 176 | |||
| 177 | ```ts | ||
| 178 | Effect.try({ | ||
| 179 | try: () => new URL(value), | ||
| 180 | catch: (cause) => new UrlParseError({ value, cause }), | ||
| 181 | }); | ||
| 182 | ``` | ||
| 183 | |||
| 184 | ### Unknown error message / instanceof Error | ||
| 185 | |||
| 186 | Bad: | ||
| 187 | |||
| 188 | ```ts | ||
| 189 | err instanceof Error ? err.message : String(err); | ||
| 190 | ``` | ||
| 191 | |||
| 192 | Also bad: destructuring `message` only hides the same unknown-state problem from a shallow property-access lint. | ||
| 193 | |||
| 194 | ```ts | ||
| 195 | const { message } = err; | ||
| 196 | return message; | ||
| 197 | ``` | ||
| 198 | |||
| 199 | Prefer one of: | ||
| 200 | |||
| 201 | ```ts | ||
| 202 | Effect.mapError((err) => new DomainError({ cause: err })); | ||
| 203 | ``` | ||
| 204 | |||
| 205 | ```ts | ||
| 206 | Effect.catchTag("KnownError", (err) => Effect.fail(new DomainError({ message: err.message }))); | ||
| 207 | ``` | ||
| 208 | |||
| 209 | Only read `.message` from a typed error union when that field is explicitly part of the user-facing contract. Most boundary errors should instead use a stable product message and keep the original value in a separate `cause`, trace, log, or telemetry channel. Do not inspect unknown thrown values for domain behavior or customer copy. | ||
| 210 | |||
| 211 | If the lint rule overfires inside a branch that has already narrowed to a specific typed error, keep the direct typed read and use a narrow suppression with a reason. Do not rewrite to destructuring just to avoid the lint selector. | ||
| 212 | |||
| 213 | Bad: leaks internal provider/native details to users. | ||
| 214 | |||
| 215 | ```ts | ||
| 216 | Effect.tryPromise({ | ||
| 217 | try: () => client.call(), | ||
| 218 | catch: (cause) => | ||
| 219 | new SourceError({ | ||
| 220 | message: cause instanceof Error ? cause.message : String(cause), | ||
| 221 | }), | ||
| 222 | }); | ||
| 223 | ``` | ||
| 224 | |||
| 225 | Good: user-facing message is stable; internal detail goes into `cause` only if the error type has an internal channel. | ||
| 226 | |||
| 227 | ```ts | ||
| 228 | Effect.tryPromise({ | ||
| 229 | try: () => client.call(), | ||
| 230 | catch: (cause) => | ||
| 231 | new SourceError({ | ||
| 232 | message: "Failed to connect to source", | ||
| 233 | cause, | ||
| 234 | }), | ||
| 235 | }); | ||
| 236 | ``` | ||
| 237 | |||
| 238 | If the error schema is serialized to customers and only has `message`, do not put internal details there. Prefer adding a non-serialized/internal `cause` field or logging/telemetry over suppressing the lint rule. | ||
| 239 | |||
| 240 | ### Manual tags and broad error laundering | ||
| 241 | |||
| 242 | Bad: manually probing `_tag` to recover from typed Effect failures. | ||
| 243 | |||
| 244 | ```ts | ||
| 245 | Effect.mapError((err) => | ||
| 246 | "_tag" in err && err._tag === "SecretOwnedByConnectionError" | ||
| 247 | ? new SourceError({ message: "Failed to resolve secret" }) | ||
| 248 | : err, | ||
| 249 | ); | ||
| 250 | ``` | ||
| 251 | |||
| 252 | Good: catch the one typed case you intentionally translate. | ||
| 253 | |||
| 254 | ```ts | ||
| 255 | effect.pipe( | ||
| 256 | Effect.catchTag("SecretOwnedByConnectionError", () => | ||
| 257 | Effect.fail(new SourceError({ message: "Failed to resolve secret" })), | ||
| 258 | ), | ||
| 259 | ); | ||
| 260 | ``` | ||
| 261 | |||
| 262 | Do not wrap a typed error union into one local error only to satisfy a narrower helper signature. Widen the helper/cache/invocation error channel when callers can still use the original typed failure. Wrap only when the new error adds product meaning, such as turning a connection-owned secret into a source configuration problem. | ||
| 263 | |||
| 264 | For Effect data types, use public helpers instead of `_tag` checks: | ||
| 265 | |||
| 266 | ```ts | ||
| 267 | if (Option.isNone(parsed)) return null; | ||
| 268 | if (Exit.isFailure(exit)) return ... | ||
| 269 | ``` | ||
| 270 | |||
| 271 | ### Redundant error helpers | ||
| 272 | |||
| 273 | Bad: | ||
| 274 | |||
| 275 | ```ts | ||
| 276 | const connectionError = (message: string) => | ||
| 277 | new McpConnectionError({ transport: "remote", message }); | ||
| 278 | |||
| 279 | return yield* connectionError("Endpoint URL is required"); | ||
| 280 | ``` | ||
| 281 | |||
| 282 | Good: | ||
| 283 | |||
| 284 | ```ts | ||
| 285 | return yield* new McpConnectionError({ | ||
| 286 | transport: "remote", | ||
| 287 | message: "Endpoint URL is required", | ||
| 288 | }); | ||
| 289 | ``` | ||
| 290 | |||
| 291 | Helpers are allowed only when they do real work, such as: | ||
| 292 | |||
| 293 | - choosing between different tagged errors | ||
| 294 | - decoding/parsing an external error shape | ||
| 295 | - preserving protocol-specific fields | ||
| 296 | - normalizing third-party SDK failures into one domain error | ||
| 297 | |||
| 298 | ## New error or existing error? | ||
| 299 | |||
| 300 | Reuse an existing tagged error when only the message changes. | ||
| 301 | |||
| 302 | Create a new tagged error when a caller can reasonably branch differently: | ||
| 303 | |||
| 304 | - different HTTP status | ||
| 305 | - retry vs no retry | ||
| 306 | - auth/sign-in affordance | ||
| 307 | - not-found vs conflict vs validation | ||
| 308 | - user-actionable vs internal failure | ||
| 309 | - different telemetry grouping that should not depend on message text | ||
| 310 | |||
| 311 | Do not create one tagged error per sentence of prose. | ||
| 312 | |||
| 313 | ## What not to report | ||
| 314 | |||
| 315 | - Test assertions that intentionally construct errors as fixture values. | ||
| 316 | - Runtime adapter edges that must satisfy a third-party throwing API, IPC contract, process worker contract, or tooling contract, as long as the untyped behavior is contained and converted to typed Effect failure or a stable boundary envelope. | ||
| 317 | - Real normalization helpers like `toOAuth2Error(cause)` that inspect protocol fields and preserve structured semantics. | ||
| 318 | - React/effect-atom mutation handlers using `try/catch`; use `wrdn-effect-promise-exit` for that UI-specific boundary. | ||
| 319 | |||
| 320 | ## Output requirements | ||
| 321 | |||
| 322 | When reviewing, report: | ||
| 323 | |||
| 324 | - **File and line** of the untyped error pattern. | ||
| 325 | - **Rule** being violated. | ||
| 326 | - **Existing domain error** to use, or the new tagged error that should exist. | ||
| 327 | - **Fix** in the relevant shape: direct `yield* new ErrorType(...)`, `Effect.tryPromise`, schema decode, or direct constructor inline. | ||
| 328 | |||
| 329 | When editing, keep the error type precise and avoid broad message parsing. | ||
| @@ -0,0 +1,95 @@ | |||
| 1 | --- | ||
| 2 | name: wrdn-effect-value-inferred-types | ||
| 3 | description: Replace duplicated object API types with types inferred from the runtime value or factory that owns the shape. Use when lint or review flags an interface/type alias that mirrors a returned object such as a plugin extension, client surface, route map, or handler table. | ||
| 4 | allowed-tools: Read Grep Glob Bash | ||
| 5 | --- | ||
| 6 | |||
| 7 | You fix one pattern: a TypeScript object type manually mirrors a runtime object that already owns the shape. | ||
| 8 | |||
| 9 | Prefer value-first APIs. Build the object in a named factory, then export `type X = ReturnType<typeof makeX>`. Consumers keep importing the stable type name, but the type cannot drift from the implementation. | ||
| 10 | |||
| 11 | ## Trace before changing | ||
| 12 | |||
| 13 | 1. **Find the source value.** Look for an object returned from a named factory, `extension: (...) => ({ ... })`, a client object, route map, or handler table. | ||
| 14 | 2. **Find the duplicate type.** A nearby `interface X` or `type X = { ... }` repeats the object methods/properties. | ||
| 15 | 3. **Check whether the value is the source of truth.** If the interface is a contract with multiple implementations, keep the interface. | ||
| 16 | 4. **Preserve the exported type name.** Replace its definition with `ReturnType<typeof makeX>` and update callers only if needed. | ||
| 17 | 5. **Use `satisfies` only at boundaries.** Do not make the implementation satisfy a duplicate shape that could drift. | ||
| 18 | |||
| 19 | ## Fix shape | ||
| 20 | |||
| 21 | ```ts | ||
| 22 | const makePluginExtension = (ctx: PluginCtx<Store>) => { | ||
| 23 | const addSource = ... | ||
| 24 | const removeSource = ... | ||
| 25 | |||
| 26 | return { | ||
| 27 | addSource, | ||
| 28 | removeSource, | ||
| 29 | }; | ||
| 30 | }; | ||
| 31 | |||
| 32 | export type PluginExtension = ReturnType<typeof makePluginExtension>; | ||
| 33 | ``` | ||
| 34 | |||
| 35 | For factories that need options: | ||
| 36 | |||
| 37 | ```ts | ||
| 38 | const makePluginExtension = | ||
| 39 | (options: PluginOptions) => | ||
| 40 | (ctx: PluginCtx<Store>) => ({ | ||
| 41 | addSource: ..., | ||
| 42 | }); | ||
| 43 | |||
| 44 | export type PluginExtension = ReturnType<ReturnType<typeof makePluginExtension>>; | ||
| 45 | ``` | ||
| 46 | |||
| 47 | ## Bad | ||
| 48 | |||
| 49 | ```ts | ||
| 50 | export interface McpPluginExtension { | ||
| 51 | readonly addSource: (config: McpSourceConfig) => Effect.Effect<AddResult, Failure>; | ||
| 52 | readonly removeSource: (namespace: string, scope: string) => Effect.Effect<void, Failure>; | ||
| 53 | } | ||
| 54 | |||
| 55 | extension: (ctx) => { | ||
| 56 | return { | ||
| 57 | addSource, | ||
| 58 | removeSource, | ||
| 59 | } satisfies McpPluginExtension; | ||
| 60 | }; | ||
| 61 | ``` | ||
| 62 | |||
| 63 | ## Good | ||
| 64 | |||
| 65 | ```ts | ||
| 66 | const makeMcpPluginExtension = (ctx: PluginCtx<McpStore>) => { | ||
| 67 | return { | ||
| 68 | addSource, | ||
| 69 | removeSource, | ||
| 70 | }; | ||
| 71 | }; | ||
| 72 | |||
| 73 | export type McpPluginExtension = ReturnType<typeof makeMcpPluginExtension>; | ||
| 74 | |||
| 75 | extension: makeMcpPluginExtension; | ||
| 76 | ``` | ||
| 77 | |||
| 78 | ## What not to report | ||
| 79 | |||
| 80 | - Service/dependency interfaces with multiple implementations. | ||
| 81 | - Public config input types that are intentionally a stable authored API. | ||
| 82 | - Branded IDs, discriminated unions, or small aliases that do not mirror one object value. | ||
| 83 | - Test fakes typed against an existing exported contract. | ||
| 84 | - Schema-owned data shapes; use `wrdn-effect-schema-inferred-types` for those. | ||
| 85 | |||
| 86 | ## Output requirements | ||
| 87 | |||
| 88 | When reviewing, report: | ||
| 89 | |||
| 90 | - **File and line** of the duplicate object type or `satisfies` usage. | ||
| 91 | - **Value/factory** that owns the shape. | ||
| 92 | - **Why** the manual type can drift. | ||
| 93 | - **Fix**: the exact `ReturnType` alias to introduce. | ||
| 94 | |||
| 95 | When editing, name the factory after the exported type, e.g. `makeMcpPluginExtension` for `McpPluginExtension`. | ||
| @@ -0,0 +1,29 @@ | |||
| 1 | --- | ||
| 2 | name: wrdn-effect-vitest-tests | ||
| 3 | description: Keep tests deterministic and Effect-aware. Use when lint flags direct vitest imports or conditional assertions inside tests. | ||
| 4 | allowed-tools: Read Grep Glob Bash | ||
| 5 | --- | ||
| 6 | |||
| 7 | Use `@effect/vitest` for tests in this repo. | ||
| 8 | |||
| 9 | ## Fix Shape | ||
| 10 | |||
| 11 | - Import `describe`, `it`, `expect`, and helpers from `@effect/vitest`. | ||
| 12 | - Import utility helpers from `@effect/vitest/utils` when needed. | ||
| 13 | - Do not import from raw `vitest` except in config or tooling files. | ||
| 14 | - Do not put `expect(...)` behind `if`, ternary, logical, or switch branches. | ||
| 15 | - Split conditional behavior into separate tests, or assert the branch condition and expected value explicitly. | ||
| 16 | |||
| 17 | ## Bad | ||
| 18 | |||
| 19 | ```ts | ||
| 20 | if (result.ok) { | ||
| 21 | expect(result.value).toBe("x"); | ||
| 22 | } | ||
| 23 | ``` | ||
| 24 | |||
| 25 | ## Good | ||
| 26 | |||
| 27 | ```ts | ||
| 28 | expect(result).toEqual({ ok: true, value: "x" }); | ||
| 29 | ``` | ||
| @@ -0,0 +1,26 @@ | |||
| 1 | --- | ||
| 2 | name: wrdn-package-boundaries | ||
| 3 | description: Preserve workspace package boundaries. Use when lint flags relative imports that cross package roots. | ||
| 4 | allowed-tools: Read Grep Glob Bash | ||
| 5 | --- | ||
| 6 | |||
| 7 | Workspace packages should import each other through package exports, not relative paths. | ||
| 8 | |||
| 9 | ## Fix Shape | ||
| 10 | |||
| 11 | - Replace cross-package relative imports with the target package name. | ||
| 12 | - If the needed module is not exported, add the smallest package export that matches the package's public surface. | ||
| 13 | - Keep relative imports only within the same package root. | ||
| 14 | - Do not reach into another package's private source tree from an app or package. | ||
| 15 | |||
| 16 | ## Good | ||
| 17 | |||
| 18 | ```ts | ||
| 19 | import { createExecutor } from "@executor-js/sdk"; | ||
| 20 | ``` | ||
| 21 | |||
| 22 | ## Bad | ||
| 23 | |||
| 24 | ```ts | ||
| 25 | import { createExecutor } from "../../../core/sdk/src"; | ||
| 26 | ``` | ||
| @@ -0,0 +1,14 @@ | |||
| 1 | --- | ||
| 2 | name: wrdn-typescript-type-safety | ||
| 3 | description: Remove TypeScript escape hatches. Use when lint flags @ts-nocheck or similar broad type bypasses. | ||
| 4 | allowed-tools: Read Grep Glob Bash | ||
| 5 | --- | ||
| 6 | |||
| 7 | Fix the type boundary instead of disabling TypeScript. | ||
| 8 | |||
| 9 | ## Fix Shape | ||
| 10 | |||
| 11 | - Remove `@ts-nocheck`. | ||
| 12 | - Narrow the failing expression, add a schema/guard at an unknown boundary, or improve the local type. | ||
| 13 | - If a cast is unavoidable, keep it narrow and document the invariant at the cast site. | ||
| 14 | - Do not silence an entire file for a localized mismatch. | ||
| @@ -9,7 +9,19 @@ · 8 unmodified lines | |||
| 9 | "executor/no-cross-package-relative-imports": "error", | 9 | "executor/no-cross-package-relative-imports": "error", |
| 10 | "executor/require-reactivity-keys": "error", | 10 | "executor/require-reactivity-keys": "error", |
| 11 | "executor/no-effect-internal-tags": "error", | 11 | "executor/no-effect-internal-tags": "error", |
| 12 | "executor/no-error-constructor": "error", | ||
| 13 | "executor/no-instanceof-error": "error", | ||
| 14 | "executor/no-instanceof-tagged-error": "error", | ||
| 15 | "executor/no-manual-tag-check": "error", | ||
| 16 | "executor/no-promise-catch": "error", | ||
| 17 | "executor/no-promise-reject": "error", | ||
| 18 | "executor/no-redundant-error-factory": "error", | ||
| 12 | "executor/no-ts-nocheck": "error", | 19 | "executor/no-ts-nocheck": "error", |
| 20 | "executor/no-try-catch-or-throw": "error", | ||
| 21 | "executor/no-unknown-error-message": "error", | ||
| 22 | "executor/prefer-schema-inferred-types": "error", | ||
| 23 | "executor/prefer-value-inferred-extension-types": "error", | ||
| 24 | "executor/prefer-yield-tagged-error": "error", | ||
| 13 | "react/forbid-elements": [ | 25 | "react/forbid-elements": [ |
| 14 | "error", | 26 | "error", |
| 15 | { | 27 | { |
| @@ -2,15 +2,24 @@ import noConditionalTests from "./oxlint-plugin-executor/rules/no-conditional-te · 1 unmodified line | |||
| 2 | import noCrossPackageRelativeImports from "./oxlint-plugin-executor/rules/no-cross-package-relative-imports.js"; | 2 | import noCrossPackageRelativeImports from "./oxlint-plugin-executor/rules/no-cross-package-relative-imports.js"; |
| 3 | import noDoubleCast from "./oxlint-plugin-executor/rules/no-double-cast.js"; | 3 | import noDoubleCast from "./oxlint-plugin-executor/rules/no-double-cast.js"; |
| 4 | import noEffectInternalTags from "./oxlint-plugin-executor/rules/no-effect-internal-tags.js"; | 4 | import noEffectInternalTags from "./oxlint-plugin-executor/rules/no-effect-internal-tags.js"; |
| 5 | import noErrorConstructor from "./oxlint-plugin-executor/rules/no-error-constructor.js"; | ||
| 5 | import noInlineObjectTypeAssertion from "./oxlint-plugin-executor/rules/no-inline-object-type-assertion.js"; | 6 | import noInlineObjectTypeAssertion from "./oxlint-plugin-executor/rules/no-inline-object-type-assertion.js"; |
| 7 | import noInstanceofError from "./oxlint-plugin-executor/rules/no-instanceof-error.js"; | ||
| 6 | import noInstanceofTaggedError from "./oxlint-plugin-executor/rules/no-instanceof-tagged-error.js"; | 8 | import noInstanceofTaggedError from "./oxlint-plugin-executor/rules/no-instanceof-tagged-error.js"; |
| 7 | import noManualTagCheck from "./oxlint-plugin-executor/rules/no-manual-tag-check.js"; | 9 | import noManualTagCheck from "./oxlint-plugin-executor/rules/no-manual-tag-check.js"; |
| 10 | import noPromiseCatch from "./oxlint-plugin-executor/rules/no-promise-catch.js"; | ||
| 8 | import noPromiseClientSurface from "./oxlint-plugin-executor/rules/no-promise-client-surface.js"; | 11 | import noPromiseClientSurface from "./oxlint-plugin-executor/rules/no-promise-client-surface.js"; |
| 12 | import noPromiseReject from "./oxlint-plugin-executor/rules/no-promise-reject.js"; | ||
| 9 | import noRawErrorThrow from "./oxlint-plugin-executor/rules/no-raw-error-throw.js"; | 13 | import noRawErrorThrow from "./oxlint-plugin-executor/rules/no-raw-error-throw.js"; |
| 10 | import noRedundantErrorFactory from "./oxlint-plugin-executor/rules/no-redundant-error-factory.js"; | 14 | import noRedundantErrorFactory from "./oxlint-plugin-executor/rules/no-redundant-error-factory.js"; |
| 11 | import noTsNocheck from "./oxlint-plugin-executor/rules/no-ts-nocheck.js"; | 15 | import noTsNocheck from "./oxlint-plugin-executor/rules/no-ts-nocheck.js"; |
| 16 | import noTryCatchOrThrow from "./oxlint-plugin-executor/rules/no-try-catch-or-throw.js"; | ||
| 17 | import noUnknownErrorMessage from "./oxlint-plugin-executor/rules/no-unknown-error-message.js"; | ||
| 12 | import noUnknownShapeProbing from "./oxlint-plugin-executor/rules/no-unknown-shape-probing.js"; | 18 | import noUnknownShapeProbing from "./oxlint-plugin-executor/rules/no-unknown-shape-probing.js"; |
| 13 | import noVitestImport from "./oxlint-plugin-executor/rules/no-vitest-import.js"; | 19 | import noVitestImport from "./oxlint-plugin-executor/rules/no-vitest-import.js"; |
| 20 | import preferSchemaInferredTypes from "./oxlint-plugin-executor/rules/prefer-schema-inferred-types.js"; | ||
| 21 | import preferYieldTaggedError from "./oxlint-plugin-executor/rules/prefer-yield-tagged-error.js"; | ||
| 22 | import preferValueInferredExtensionTypes from "./oxlint-plugin-executor/rules/prefer-value-inferred-extension-types.js"; | ||
| 14 | import requireReactivityKeys from "./oxlint-plugin-executor/rules/require-reactivity-keys.js"; | 23 | import requireReactivityKeys from "./oxlint-plugin-executor/rules/require-reactivity-keys.js"; |
| 15 | 24 | ||
| 16 | export default { | 25 | export default { |
| @@ -24,13 +33,22 @@ export default { · 7 unmodified lines | |||
| 24 | "no-cross-package-relative-imports": noCrossPackageRelativeImports, | 33 | "no-cross-package-relative-imports": noCrossPackageRelativeImports, |
| 25 | "require-reactivity-keys": requireReactivityKeys, | 34 | "require-reactivity-keys": requireReactivityKeys, |
| 26 | "no-effect-internal-tags": noEffectInternalTags, | 35 | "no-effect-internal-tags": noEffectInternalTags, |
| 36 | "no-error-constructor": noErrorConstructor, | ||
| 27 | "no-ts-nocheck": noTsNocheck, | 37 | "no-ts-nocheck": noTsNocheck, |
| 28 | "no-inline-object-type-assertion": noInlineObjectTypeAssertion, | 38 | "no-inline-object-type-assertion": noInlineObjectTypeAssertion, |
| 39 | "no-instanceof-error": noInstanceofError, | ||
| 29 | "no-instanceof-tagged-error": noInstanceofTaggedError, | 40 | "no-instanceof-tagged-error": noInstanceofTaggedError, |
| 30 | "no-manual-tag-check": noManualTagCheck, | 41 | "no-manual-tag-check": noManualTagCheck, |
| 42 | "no-promise-catch": noPromiseCatch, | ||
| 31 | "no-promise-client-surface": noPromiseClientSurface, | 43 | "no-promise-client-surface": noPromiseClientSurface, |
| 44 | "no-promise-reject": noPromiseReject, | ||
| 32 | "no-raw-error-throw": noRawErrorThrow, | 45 | "no-raw-error-throw": noRawErrorThrow, |
| 33 | "no-redundant-error-factory": noRedundantErrorFactory, | 46 | "no-redundant-error-factory": noRedundantErrorFactory, |
| 47 | "no-try-catch-or-throw": noTryCatchOrThrow, | ||
| 48 | "no-unknown-error-message": noUnknownErrorMessage, | ||
| 34 | "no-unknown-shape-probing": noUnknownShapeProbing, | 49 | "no-unknown-shape-probing": noUnknownShapeProbing, |
| 50 | "prefer-schema-inferred-types": preferSchemaInferredTypes, | ||
| 51 | "prefer-value-inferred-extension-types": preferValueInferredExtensionTypes, | ||
| 52 | "prefer-yield-tagged-error": preferYieldTaggedError, | ||
| 35 | }, | 53 | }, |
| 36 | }; | 54 | }; |
| @@ -57,7 +57,8 @@ export default { · 56 unmodified lines | |||
| 57 | if (name !== "expect") return; | 57 | if (name !== "expect") return; |
| 58 | context.report({ | 58 | context.report({ |
| 59 | node, | 59 | node, |
| 60 | message: "Avoid conditional expect calls; split the test or assert both branches explicitly.", | 60 | message: |
| 61 | "Avoid conditional expect calls; split the test or assert both branches explicitly. Skill: wrdn-effect-vitest-tests.", | ||
| 61 | }); | 62 | }); |
| 62 | }, | 63 | }, |
| 63 | FunctionDeclaration: enterFunction, | 64 | FunctionDeclaration: enterFunction, |
| @@ -23,7 +23,7 @@ export default { · 22 unmodified lines | |||
| 23 | 23 | ||
| 24 | context.report({ | 24 | context.report({ |
| 25 | node: node.source, | 25 | node: node.source, |
| 26 | message: `Import ${target.name} via its package export instead of a relative path.`, | 26 | message: `Import ${target.name} via its package export instead of a relative path. Skill: wrdn-package-boundaries.`, |
| 27 | }); | 27 | }); |
| 28 | }, | 28 | }, |
| 29 | }; | 29 | }; |
| @@ -19,7 +19,7 @@ export default { · 18 unmodified lines | |||
| 19 | context.report({ | 19 | context.report({ |
| 20 | node, | 20 | node, |
| 21 | message: | 21 | message: |
| 22 | "Avoid double casts through unknown/any; use a typed boundary, schema decode, or a narrow allow comment with a reason.", | 22 | "Avoid double casts through unknown/any; use a typed boundary, schema decode, or a narrow allow comment with a reason. Skill: wrdn-effect-schema-boundaries.", |
| 23 | }); | 23 | }); |
| 24 | }, | 24 | }, |
| 25 | }; | 25 | }; |
| @@ -71,7 +71,7 @@ function reportIfEffectTagComparison( · 70 unmodified lines | |||
| 71 | 71 | ||
| 72 | context.report({ | 72 | context.report({ |
| 73 | node: access, | 73 | node: access, |
| 74 | message: `Use Effect's public helpers instead of checking internal _tag "${tag}".`, | 74 | message: `Use Effect's public helpers instead of checking internal _tag "${tag}". Skill: wrdn-effect-typed-errors.`, |
| 75 | }); | 75 | }); |
| 76 | } | 76 | } |
| 77 | 77 | ||
| @@ -0,0 +1,40 @@ | |||
| 1 | import { nodeName } from "../utils.js"; | ||
| 2 | |||
| 3 | const errorConstructors = new Set([ | ||
| 4 | "AggregateError", | ||
| 5 | "Error", | ||
| 6 | "EvalError", | ||
| 7 | "RangeError", | ||
| 8 | "ReferenceError", | ||
| 9 | "SyntaxError", | ||
| 10 | "TypeError", | ||
| 11 | "URIError", | ||
| 12 | ]); | ||
| 13 | |||
| 14 | const message = | ||
| 15 | "Do not construct built-in Error objects in Effect domain code. Use typed domain errors and Effect.fail instead; at true adapter boundaries use a narrow suppression with a boundary reason. Skill: wrdn-effect-typed-errors."; | ||
| 16 | |||
| 17 | const isErrorConstructor = (node) => errorConstructors.has(nodeName(node)); | ||
| 18 | |||
| 19 | export default { | ||
| 20 | meta: { | ||
| 21 | type: "problem", | ||
| 22 | docs: { | ||
| 23 | description: "Disallow built-in Error constructors.", | ||
| 24 | }, | ||
| 25 | }, | ||
| 26 | create(context) { | ||
| 27 | return { | ||
| 28 | NewExpression(node) { | ||
| 29 | if (isErrorConstructor(node.callee)) { | ||
| 30 | context.report({ node, message }); | ||
| 31 | } | ||
| 32 | }, | ||
| 33 | CallExpression(node) { | ||
| 34 | if (isErrorConstructor(node.callee)) { | ||
| 35 | context.report({ node, message }); | ||
| 36 | } | ||
| 37 | }, | ||
| 38 | }; | ||
| 39 | }, | ||
| 40 | }; | ||
| @@ -1,7 +1,7 @@ | |||
| 1 | import { isIdentifier } from "../utils.js"; | 1 | import { isIdentifier } from "../utils.js"; |
| 2 | 2 | ||
| 3 | const message = | 3 | const message = |
| 4 | "Do not assert against inline object-shaped types. Use a named type, Schema, or a proper type guard."; | 4 | "Do not assert against inline object-shaped types. Use a named type, Schema, or a proper type guard. Skill: wrdn-effect-schema-boundaries."; |
| 5 | 5 | ||
| 6 | const isUnknownKeyword = (node) => node?.type === "TSUnknownKeyword"; | 6 | const isUnknownKeyword = (node) => node?.type === "TSUnknownKeyword"; |
| 7 | 7 | ||
| @@ -0,0 +1,22 @@ | |||
| 1 | import { nodeName } from "../utils.js"; | ||
| 2 | |||
| 3 | const message = | ||
| 4 | "Do not use instanceof Error. Preserve typed failures with Effect tagged-error handling. Skill: wrdn-effect-typed-errors."; | ||
| 5 | |||
| 6 | export default { | ||
| 7 | meta: { | ||
| 8 | type: "problem", | ||
| 9 | docs: { | ||
| 10 | description: "Disallow instanceof Error checks.", | ||
| 11 | }, | ||
| 12 | }, | ||
| 13 | create(context) { | ||
| 14 | return { | ||
| 15 | BinaryExpression(node) { | ||
| 16 | if (node.operator === "instanceof" && nodeName(node.right) === "Error") { | ||
| 17 | context.report({ node, message }); | ||
| 18 | } | ||
| 19 | }, | ||
| 20 | }; | ||
| 21 | }, | ||
| 22 | }; | ||
| @@ -1,7 +1,7 @@ | |||
| 1 | import { isIdentifier, nodeName } from "../utils.js"; | 1 | import { isIdentifier, nodeName } from "../utils.js"; |
| 2 | 2 | ||
| 3 | const message = | 3 | const message = |
| 4 | "Do not use instanceof for tagged errors. Use Effect.catchTag, Effect.catchTags, or a _tag-based guard."; | 4 | "Do not use instanceof for tagged errors. Use Effect.catchTag, Effect.catchTags, or a _tag-based guard. Skill: wrdn-effect-typed-errors."; |
| 5 | 5 | ||
| 6 | const looksLikeTaggedErrorName = (name) => | 6 | const looksLikeTaggedErrorName = (name) => |
| 7 | typeof name === "string" && name !== "Error" && name.endsWith("Error"); | 7 | typeof name === "string" && name !== "Error" && name.endsWith("Error"); |
| @@ -1,7 +1,7 @@ | |||
| 1 | import { isIdentifier, isStringLiteral } from "../utils.js"; | 1 | import { isIdentifier, isStringLiteral } from "../utils.js"; |
| 2 | 2 | ||
| 3 | const message = | 3 | const message = |
| 4 | "Do not inspect _tag manually. Use Effect.catchTag, Effect.catchTags, Predicate.isTagged, or another Effect tagged-error API."; | 4 | "Do not inspect _tag manually. Use Effect.catchTag/catchTags for error handling, Predicate.isTagged for guards, or public Effect helpers for Effect data. Skill: wrdn-effect-typed-errors."; |
| 5 | 5 | ||
| 6 | const isTagProperty = (node) => | 6 | const isTagProperty = (node) => |
| 7 | isIdentifier(node, "_tag") || (isStringLiteral(node) && node.value === "_tag"); | 7 | isIdentifier(node, "_tag") || (isStringLiteral(node) && node.value === "_tag"); |
| @@ -15,6 +15,16 @@ export default { · 7 unmodified lines | |||
| 15 | }, | 15 | }, |
| 16 | create(context) { | 16 | create(context) { |
| 17 | return { | 17 | return { |
| 18 | BinaryExpression(node) { | ||
| 19 | if (node.operator === "in" && isTagProperty(node.left)) { | ||
| 20 | context.report({ node, message }); | ||
| 21 | return; | ||
| 22 | } | ||
| 23 | if (!["===", "!==", "==", "!="].includes(node.operator)) return; | ||
| 24 | if (isTagAccess(node.left) || isTagAccess(node.right)) { | ||
| 25 | context.report({ node, message }); | ||
| 26 | } | ||
| 27 | }, | ||
| 18 | MemberExpression(node) { | 28 | MemberExpression(node) { |
| 19 | if (isTagProperty(node.property)) { | 29 | if (isTagProperty(node.property)) { |
| 20 | context.report({ node, message }); | 30 | context.report({ node, message }); |
| @@ -23,3 +33,5 @@ export default { · 2 unmodified lines | |||
| 23 | }; | 33 | }; |
| 24 | }, | 34 | }, |
| 25 | }; | 35 | }; |
| 36 | |||
| 37 | const isTagAccess = (node) => node?.type === "MemberExpression" && isTagProperty(node.property); | ||
| @@ -0,0 +1,30 @@ | |||
| 1 | import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.js"; | ||
| 2 | |||
| 3 | const message = | ||
| 4 | "Do not use Promise .catch(). Model async failures with Effect.tryPromise and typed Effect error handling. Skill: wrdn-effect-typed-errors."; | ||
| 5 | |||
| 6 | const isCatchMember = (node) => { | ||
| 7 | const expression = unwrapExpression(node); | ||
| 8 | if (isIdentifier(unwrapExpression(expression?.object), "Effect")) return false; | ||
| 9 | return ( | ||
| 10 | expression?.type === "MemberExpression" && getPropertyName(expression.property) === "catch" | ||
| 11 | ); | ||
| 12 | }; | ||
| 13 | |||
| 14 | export default { | ||
| 15 | meta: { | ||
| 16 | type: "problem", | ||
| 17 | docs: { | ||
| 18 | description: "Disallow Promise-style .catch() error handling.", | ||
| 19 | }, | ||
| 20 | }, | ||
| 21 | create(context) { | ||
| 22 | return { | ||
| 23 | CallExpression(node) { | ||
| 24 | if (isCatchMember(node.callee)) { | ||
| 25 | context.report({ node, message }); | ||
| 26 | } | ||
| 27 | }, | ||
| 28 | }; | ||
| 29 | }, | ||
| 30 | }; | ||
| @@ -1,7 +1,7 @@ | |||
| 1 | import { containsPromiseType, nodeName } from "../utils.js"; | 1 | import { containsPromiseType, nodeName } from "../utils.js"; |
| 2 | 2 | ||
| 3 | const message = | 3 | const message = |
| 4 | "Do not expose Promise-shaped client surfaces. Wrap third-party SDK promises at the adapter boundary and expose Effect methods."; | 4 | "Do not expose Promise-shaped client surfaces. Wrap third-party SDK promises at the adapter boundary and expose Effect methods. Skill: effect-client-wrapper."; |
| 5 | 5 | ||
| 6 | const isExported = (node) => node?.parent?.type === "ExportNamedDeclaration"; | 6 | const isExported = (node) => node?.parent?.type === "ExportNamedDeclaration"; |
| 7 | 7 | ||
| @@ -0,0 +1,74 @@ | |||
| 1 | import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.js"; | ||
| 2 | |||
| 3 | const promiseRejectMessage = | ||
| 4 | "Do not use Promise.reject(). Model async failures with Effect.fail or Effect.tryPromise. Skill: wrdn-effect-typed-errors."; | ||
| 5 | const rejectCallbackMessage = | ||
| 6 | "Do not call Promise executor reject(). Model async failures with Effect.fail or Effect.tryPromise. Skill: wrdn-effect-typed-errors."; | ||
| 7 | |||
| 8 | const isPromiseReject = (node) => { | ||
| 9 | const expression = unwrapExpression(node); | ||
| 10 | return ( | ||
| 11 | expression?.type === "MemberExpression" && | ||
| 12 | isIdentifier(unwrapExpression(expression.object), "Promise") && | ||
| 13 | getPropertyName(expression.property) === "reject" | ||
| 14 | ); | ||
| 15 | }; | ||
| 16 | |||
| 17 | const isPromiseConstructor = (node) => | ||
| 18 | node?.type === "NewExpression" && isIdentifier(unwrapExpression(node.callee), "Promise"); | ||
| 19 | |||
| 20 | const isFunction = (node) => | ||
| 21 | node?.type === "ArrowFunctionExpression" || | ||
| 22 | node?.type === "FunctionExpression" || | ||
| 23 | node?.type === "FunctionDeclaration"; | ||
| 24 | |||
| 25 | export default { | ||
| 26 | meta: { | ||
| 27 | type: "problem", | ||
| 28 | docs: { | ||
| 29 | description: "Disallow Promise rejection APIs.", | ||
| 30 | }, | ||
| 31 | }, | ||
| 32 | create(context) { | ||
| 33 | const promiseExecutors = new WeakSet(); | ||
| 34 | const rejectNames = []; | ||
| 35 | |||
| 36 | const enterFunction = (node) => { | ||
| 37 | if (!promiseExecutors.has(node)) return; | ||
| 38 | const rejectParam = node.params?.[1]; | ||
| 39 | if (isIdentifier(rejectParam)) { | ||
| 40 | rejectNames.push(rejectParam.name); | ||
| 41 | } else { | ||
| 42 | rejectNames.push(undefined); | ||
| 43 | } | ||
| 44 | }; | ||
| 45 | |||
| 46 | const exitFunction = (node) => { | ||
| 47 | if (promiseExecutors.has(node)) rejectNames.pop(); | ||
| 48 | }; | ||
| 49 | |||
| 50 | return { | ||
| 51 | NewExpression(node) { | ||
| 52 | if (!isPromiseConstructor(node)) return; | ||
| 53 | const executor = node.arguments?.[0]; | ||
| 54 | if (isFunction(executor)) promiseExecutors.add(executor); | ||
| 55 | }, | ||
| 56 | CallExpression(node) { | ||
| 57 | if (isPromiseReject(node.callee)) { | ||
| 58 | context.report({ node, message: promiseRejectMessage }); | ||
| 59 | return; | ||
| 60 | } | ||
| 61 | |||
| 62 | if (isIdentifier(node.callee) && rejectNames.includes(node.callee.name)) { | ||
| 63 | context.report({ node, message: rejectCallbackMessage }); | ||
| 64 | } | ||
| 65 | }, | ||
| 66 | FunctionDeclaration: enterFunction, | ||
| 67 | "FunctionDeclaration:exit": exitFunction, | ||
| 68 | FunctionExpression: enterFunction, | ||
| 69 | "FunctionExpression:exit": exitFunction, | ||
| 70 | ArrowFunctionExpression: enterFunction, | ||
| 71 | "ArrowFunctionExpression:exit": exitFunction, | ||
| 72 | }; | ||
| 73 | }, | ||
| 74 | }; | ||
| @@ -1,7 +1,7 @@ | |||
| 1 | import { isIdentifier } from "../utils.js"; | 1 | import { isIdentifier } from "../utils.js"; |
| 2 | 2 | ||
| 3 | const message = | 3 | const message = |
| 4 | "Do not throw raw Error objects in Effect code. Return Effect.fail with a tagged error or assert directly in tests."; | 4 | "Do not throw raw Error objects in Effect code. Return Effect.fail with a tagged error or assert directly in tests. Skill: wrdn-effect-typed-errors."; |
| 5 | 5 | ||
| 6 | const isNewError = (node) => node?.type === "NewExpression" && isIdentifier(node.callee, "Error"); | 6 | const isNewError = (node) => node?.type === "NewExpression" && isIdentifier(node.callee, "Error"); |
| 7 | 7 | ||
| @@ -1,27 +1,65 @@ | |||
| 1 | import { isIdentifier } from "../utils.js"; | 1 | import { isIdentifier } from "../utils.js"; |
| 2 | 2 | ||
| 3 | const message = | 3 | const message = |
| 4 | "Do not add redundant make*Error wrappers that only construct a tagged error. Construct the tagged error directly."; | 4 | "Do not add redundant helpers that only construct a tagged error. Construct the tagged error directly. Skill: wrdn-effect-typed-errors."; |
| 5 | 5 | ||
| 6 | const isErrorFactoryName = (name) => /^make[A-Z].*Error$/.test(name); | 6 | const isErrorFactoryName = (name) => /^make[A-Z].*Error$/.test(name); |
| 7 | 7 | ||
| 8 | const isErrorHelperName = (name) => | ||
| 9 | isErrorFactoryName(name) || String(name ?? "").endsWith("Error"); | ||
| 10 | |||
| 11 | const parameterName = (param) => { | ||
| 12 | if (isIdentifier(param)) return param.name; | ||
| 13 | if (param?.type === "AssignmentPattern" && isIdentifier(param.left)) return param.left.name; | ||
| 14 | if (param?.type === "RestElement" && isIdentifier(param.argument)) return param.argument.name; | ||
| 15 | return undefined; | ||
| 16 | }; | ||
| 17 | |||
| 8 | const isNewErrorExpression = (node) => | 18 | const isNewErrorExpression = (node) => |
| 9 | node?.type === "NewExpression" && isIdentifier(node.callee) && node.callee.name.endsWith("Error"); | 19 | node?.type === "NewExpression" && isIdentifier(node.callee) && node.callee.name.endsWith("Error"); |
| 10 | 20 | ||
| 21 | const isForwardedValue = (node, parameterNames) => { | ||
| 22 | if (node?.type === "Literal" || node?.type === "StringLiteral") return true; | ||
| 23 | if (node?.type === "Identifier") return parameterNames.has(node.name); | ||
| 24 | return ( | ||
| 25 | node?.type === "MemberExpression" && | ||
| 26 | isIdentifier(node.object) && | ||
| 27 | parameterNames.has(node.object.name) | ||
| 28 | ); | ||
| 29 | }; | ||
| 30 | |||
| 31 | const isObjectWithOnlyForwardedFields = (node, parameterNames) => { | ||
| 32 | if (node?.type !== "ObjectExpression") return true; | ||
| 33 | return (node.properties ?? []).every((property) => { | ||
| 34 | if (property.type === "SpreadElement") return false; | ||
| 35 | return isForwardedValue(property.value, parameterNames); | ||
| 36 | }); | ||
| 37 | }; | ||
| 38 | |||
| 39 | const isRedundantNewErrorExpression = (node, parameterNames) => { | ||
| 40 | if (!isNewErrorExpression(node)) return false; | ||
| 41 | if ((node.arguments ?? []).length === 0) return true; | ||
| 42 | if (node.arguments.length > 1) return false; | ||
| 43 | const argument = node.arguments[0]; | ||
| 44 | if (argument?.type === "Identifier") return parameterNames.has(argument.name); | ||
| 45 | return isObjectWithOnlyForwardedFields(argument, parameterNames); | ||
| 46 | }; | ||
| 47 | |||
| 11 | const returnsOnlyNewError = (node) => { | 48 | const returnsOnlyNewError = (node) => { |
| 12 | if (isNewErrorExpression(node)) return true; | 49 | const parameterNames = new Set((node?.params ?? []).map(parameterName).filter(Boolean)); |
| 50 | if (isRedundantNewErrorExpression(node?.body ?? node, parameterNames)) return true; | ||
| 13 | if (node?.type !== "BlockStatement") return false; | 51 | if (node?.type !== "BlockStatement") return false; |
| 14 | const statements = node.body ?? []; | 52 | const statements = node.body ?? []; |
| 15 | return ( | 53 | return ( |
| 16 | statements.length === 1 && | 54 | statements.length === 1 && |
| 17 | statements[0]?.type === "ReturnStatement" && | 55 | statements[0]?.type === "ReturnStatement" && |
| 18 | isNewErrorExpression(statements[0].argument) | 56 | isRedundantNewErrorExpression(statements[0].argument, parameterNames) |
| 19 | ); | 57 | ); |
| 20 | }; | 58 | }; |
| 21 | 59 | ||
| 22 | const reportIfRedundantFactory = (context, name, body, node) => { | 60 | const reportIfRedundantFactory = (context, name, fnNode, reportNode) => { |
| 23 | if (isErrorFactoryName(name) && returnsOnlyNewError(body)) { | 61 | if (isErrorHelperName(name) && returnsOnlyNewError(fnNode)) { |
| 24 | context.report({ node, message }); | 62 | context.report({ node: reportNode, message }); |
| 25 | } | 63 | } |
| 26 | }; | 64 | }; |
| 27 | 65 | ||
| @@ -35,7 +73,7 @@ export default { · 7 unmodified lines | |||
| 35 | create(context) { | 73 | create(context) { |
| 36 | return { | 74 | return { |
| 37 | FunctionDeclaration(node) { | 75 | FunctionDeclaration(node) { |
| 38 | reportIfRedundantFactory(context, node.id?.name, node.body, node); | 76 | reportIfRedundantFactory(context, node.id?.name, node, node); |
| 39 | }, | 77 | }, |
| 40 | VariableDeclarator(node) { | 78 | VariableDeclarator(node) { |
| 41 | if (!isIdentifier(node.id)) return; | 79 | if (!isIdentifier(node.id)) return; |
| @@ -45,7 +83,7 @@ export default { · 3 unmodified lines | |||
| 45 | ) { | 83 | ) { |
| 46 | return; | 84 | return; |
| 47 | } | 85 | } |
| 48 | reportIfRedundantFactory(context, node.id.name, node.init.body, node); | 86 | reportIfRedundantFactory(context, node.id.name, node.init, node); |
| 49 | }, | 87 | }, |
| 50 | }; | 88 | }; |
| 51 | }, | 89 | }, |
| @@ -0,0 +1,23 @@ | |||
| 1 | const tryCatchMessage = | ||
| 2 | "Do not use try/catch blocks in Effect domain code. Model failures with Effect instead; at true adapter boundaries use a narrow suppression with a boundary reason. Skill: wrdn-effect-typed-errors; React useAtomSet mutation handlers use wrdn-effect-promise-exit."; | ||
| 3 | const throwMessage = | ||
| 4 | "Do not throw errors in Effect domain code. Model failures with Effect.fail or typed error values instead; at true adapter boundaries use a narrow suppression with a boundary reason. Skill: wrdn-effect-typed-errors."; | ||
| 5 | |||
| 6 | export default { | ||
| 7 | meta: { | ||
| 8 | type: "problem", | ||
| 9 | docs: { | ||
| 10 | description: "Disallow try/catch blocks and throw statements.", | ||
| 11 | }, | ||
| 12 | }, | ||
| 13 | create(context) { | ||
| 14 | return { | ||
| 15 | TryStatement(node) { | ||
| 16 | context.report({ node, message: tryCatchMessage }); | ||
| 17 | }, | ||
| 18 | ThrowStatement(node) { | ||
| 19 | context.report({ node, message: throwMessage }); | ||
| 20 | }, | ||
| 21 | }; | ||
| 22 | }, | ||
| 23 | }; | ||
| @@ -18,7 +18,7 @@ export default { · 17 unmodified lines | |||
| 18 | 18 | ||
| 19 | context.report({ | 19 | context.report({ |
| 20 | node, | 20 | node, |
| 21 | message: `Do not use ${directiveName}; fix the types or narrow the file scope.`, | 21 | message: `Do not use ${directiveName}; fix the types or narrow the file scope. Skill: wrdn-typescript-type-safety.`, |
| 22 | }); | 22 | }); |
| 23 | }, | 23 | }, |
| 24 | }; | 24 | }; |
| @@ -0,0 +1,49 @@ | |||
| 1 | import { getPropertyName, isIdentifier, nodeName, unwrapExpression } from "../utils.js"; | ||
| 2 | |||
| 3 | const stringMessage = | ||
| 4 | "Do not stringify unknown errors. Keep typed failures in Effect or normalize at a typed boundary. Skill: wrdn-effect-typed-errors."; | ||
| 5 | const messagePropertyMessage = | ||
| 6 | "Do not read .message from unknown errors. Preserve typed failures with Effect tagged-error handling. Skill: wrdn-effect-typed-errors."; | ||
| 7 | const destructuredMessage = | ||
| 8 | "Do not destructure .message from unknown errors. Preserve typed failures with Effect tagged-error handling. Skill: wrdn-effect-typed-errors."; | ||
| 9 | |||
| 10 | const errorLikeNames = new Set(["cause", "e", "err", "error", "reason", "unknownError"]); | ||
| 11 | |||
| 12 | const isErrorLikeIdentifier = (node) => { | ||
| 13 | const name = nodeName(unwrapExpression(node)); | ||
| 14 | return errorLikeNames.has(name); | ||
| 15 | }; | ||
| 16 | |||
| 17 | export default { | ||
| 18 | meta: { | ||
| 19 | type: "problem", | ||
| 20 | docs: { | ||
| 21 | description: "Disallow common unknown-error string and message normalization patterns.", | ||
| 22 | }, | ||
| 23 | }, | ||
| 24 | create(context) { | ||
| 25 | return { | ||
| 26 | CallExpression(node) { | ||
| 27 | if (!isIdentifier(unwrapExpression(node.callee), "String")) return; | ||
| 28 | if (node.arguments.some(isErrorLikeIdentifier)) { | ||
| 29 | context.report({ node, message: stringMessage }); | ||
| 30 | } | ||
| 31 | }, | ||
| 32 | MemberExpression(node) { | ||
| 33 | if (getPropertyName(node.property) !== "message") return; | ||
| 34 | if (isErrorLikeIdentifier(node.object)) { | ||
| 35 | context.report({ node, message: messagePropertyMessage }); | ||
| 36 | } | ||
| 37 | }, | ||
| 38 | VariableDeclarator(node) { | ||
| 39 | if (node.id?.type !== "ObjectPattern" || !isErrorLikeIdentifier(node.init)) return; | ||
| 40 | for (const property of node.id.properties ?? []) { | ||
| 41 | if (property.type !== "Property") continue; | ||
| 42 | if (getPropertyName(property.key) === "message") { | ||
| 43 | context.report({ node: property, message: destructuredMessage }); | ||
| 44 | } | ||
| 45 | } | ||
| 46 | }, | ||
| 47 | }; | ||
| 48 | }, | ||
| 49 | }; | ||
| @@ -1,7 +1,7 @@ | |||
| 1 | import { isIdentifier, isStringLiteral } from "../utils.js"; | 1 | import { isIdentifier, isStringLiteral } from "../utils.js"; |
| 2 | 2 | ||
| 3 | const message = | 3 | const message = |
| 4 | "Do not probe unknown object shapes in domain code. Normalize at a boundary with Schema, a typed adapter, or a named guard."; | 4 | "Do not probe unknown object shapes in domain code. Normalize at a boundary with Schema, a typed adapter, or a named guard. Skill: wrdn-effect-schema-boundaries."; |
| 5 | 5 | ||
| 6 | const isReflectGet = (node) => | 6 | const isReflectGet = (node) => |
| 7 | node?.type === "MemberExpression" && | 7 | node?.type === "MemberExpression" && |
| @@ -15,7 +15,7 @@ export default { · 14 unmodified lines | |||
| 15 | context.report({ | 15 | context.report({ |
| 16 | node: node.source, | 16 | node: node.source, |
| 17 | message: | 17 | message: |
| 18 | "Import test helpers from @effect/vitest or @effect/vitest/utils instead of vitest.", | 18 | "Import test helpers from @effect/vitest or @effect/vitest/utils instead of vitest. Skill: wrdn-effect-vitest-tests.", |
| 19 | }); | 19 | }); |
| 20 | }, | 20 | }, |
| 21 | }; | 21 | }; |
| @@ -0,0 +1,66 @@ | |||
| 1 | import { getCallName, isIdentifier, typeReferenceName } from "../utils.js"; | ||
| 2 | |||
| 3 | const message = | ||
| 4 | "This object type duplicates a nearby Effect Schema. Export an inferred type from the schema instead. Skill: wrdn-effect-schema-inferred-types."; | ||
| 5 | |||
| 6 | const schemaSuffixPattern = /(Schema|Model|Struct)$/; | ||
| 7 | |||
| 8 | const schemaBaseName = (name) => { | ||
| 9 | const base = name.replace(schemaSuffixPattern, ""); | ||
| 10 | return base.length > 0 && base !== name ? base : undefined; | ||
| 11 | }; | ||
| 12 | |||
| 13 | const isSchemaMemberCall = (node) => | ||
| 14 | node?.type === "CallExpression" && | ||
| 15 | node.callee?.type === "MemberExpression" && | ||
| 16 | isIdentifier(node.callee.object, "Schema"); | ||
| 17 | |||
| 18 | const isSchemaModelExpression = (node) => { | ||
| 19 | if (isSchemaMemberCall(node)) return true; | ||
| 20 | if (node?.type === "CallExpression" && getCallName(node.callee) === "pipe") { | ||
| 21 | return isSchemaModelExpression(node.callee.object); | ||
| 22 | } | ||
| 23 | return false; | ||
| 24 | }; | ||
| 25 | |||
| 26 | const isObjectTypeAlias = (node) => node.typeAnnotation?.type === "TSTypeLiteral"; | ||
| 27 | |||
| 28 | const isInferredSchemaType = (node) => { | ||
| 29 | if (node.typeAnnotation?.type !== "TSTypeReference") return false; | ||
| 30 | const name = typeReferenceName(node.typeAnnotation); | ||
| 31 | return name === "Schema.Schema.Type"; | ||
| 32 | }; | ||
| 33 | |||
| 34 | export default { | ||
| 35 | meta: { | ||
| 36 | type: "problem", | ||
| 37 | docs: { | ||
| 38 | description: message, | ||
| 39 | }, | ||
| 40 | }, | ||
| 41 | create(context) { | ||
| 42 | const schemaBases = new Set(); | ||
| 43 | const candidates = []; | ||
| 44 | |||
| 45 | return { | ||
| 46 | VariableDeclarator(node) { | ||
| 47 | if (!isIdentifier(node.id) || !isSchemaModelExpression(node.init)) return; | ||
| 48 | const base = schemaBaseName(node.id.name); | ||
| 49 | if (base) schemaBases.add(base); | ||
| 50 | }, | ||
| 51 | TSInterfaceDeclaration(node) { | ||
| 52 | candidates.push({ node, name: node.id?.name }); | ||
| 53 | }, | ||
| 54 | TSTypeAliasDeclaration(node) { | ||
| 55 | if (!isObjectTypeAlias(node) || isInferredSchemaType(node)) return; | ||
| 56 | candidates.push({ node, name: node.id?.name }); | ||
| 57 | }, | ||
| 58 | "Program:exit"() { | ||
| 59 | for (const candidate of candidates) { | ||
| 60 | if (!candidate.name || !schemaBases.has(candidate.name)) continue; | ||
| 61 | context.report({ node: candidate.node, message }); | ||
| 62 | } | ||
| 63 | }, | ||
| 64 | }; | ||
| 65 | }, | ||
| 66 | }; | ||
| @@ -0,0 +1,81 @@ | |||
| 1 | import { isIdentifier } from "../utils.js"; | ||
| 2 | |||
| 3 | const message = | ||
| 4 | "Do not duplicate plugin extension object shapes. Derive the extension type from the extension factory return value. Skill: wrdn-effect-value-inferred-types."; | ||
| 5 | |||
| 6 | const extensionNamePattern = /(?:Plugin)?Extension$/; | ||
| 7 | |||
| 8 | const isExtensionTypeName = (name) => typeof name === "string" && extensionNamePattern.test(name); | ||
| 9 | |||
| 10 | const isExtensionProperty = (node) => | ||
| 11 | node?.type === "Property" && | ||
| 12 | !node.computed && | ||
| 13 | ((node.key?.type === "Identifier" && node.key.name === "extension") || | ||
| 14 | ((node.key?.type === "Literal" || node.key?.type === "StringLiteral") && | ||
| 15 | node.key.value === "extension")); | ||
| 16 | |||
| 17 | const isSatisfiesExtension = (node, extensionTypeNames) => | ||
| 18 | node?.type === "TSSatisfiesExpression" && | ||
| 19 | node.typeAnnotation?.type === "TSTypeReference" && | ||
| 20 | isIdentifier(node.typeAnnotation.typeName) && | ||
| 21 | extensionTypeNames.has(node.typeAnnotation.typeName.name); | ||
| 22 | |||
| 23 | const returnsSatisfiesExtension = (node, extensionTypeNames) => { | ||
| 24 | if (!node) return false; | ||
| 25 | if (isSatisfiesExtension(node, extensionTypeNames)) return true; | ||
| 26 | if (node.type === "BlockStatement") { | ||
| 27 | return (node.body ?? []).some( | ||
| 28 | (statement) => | ||
| 29 | statement.type === "ReturnStatement" && | ||
| 30 | isSatisfiesExtension(statement.argument, extensionTypeNames), | ||
| 31 | ); | ||
| 32 | } | ||
| 33 | return false; | ||
| 34 | }; | ||
| 35 | |||
| 36 | const isAnnotatedExtensionFunction = (node, extensionTypeNames) => | ||
| 37 | (node?.type === "ArrowFunctionExpression" || node?.type === "FunctionExpression") && | ||
| 38 | node.returnType?.typeAnnotation?.type === "TSTypeReference" && | ||
| 39 | isIdentifier(node.returnType.typeAnnotation.typeName) && | ||
| 40 | extensionTypeNames.has(node.returnType.typeAnnotation.typeName.name); | ||
| 41 | |||
| 42 | export default { | ||
| 43 | meta: { | ||
| 44 | type: "problem", | ||
| 45 | docs: { | ||
| 46 | description: message, | ||
| 47 | }, | ||
| 48 | }, | ||
| 49 | create(context) { | ||
| 50 | const extensionTypeNames = new Set(); | ||
| 51 | const extensionProperties = []; | ||
| 52 | |||
| 53 | return { | ||
| 54 | TSInterfaceDeclaration(node) { | ||
| 55 | if (isExtensionTypeName(node.id?.name)) { | ||
| 56 | extensionTypeNames.add(node.id.name); | ||
| 57 | } | ||
| 58 | }, | ||
| 59 | TSTypeAliasDeclaration(node) { | ||
| 60 | if (isExtensionTypeName(node.id?.name) && node.typeAnnotation?.type === "TSTypeLiteral") { | ||
| 61 | extensionTypeNames.add(node.id.name); | ||
| 62 | } | ||
| 63 | }, | ||
| 64 | Property(node) { | ||
| 65 | if (!isExtensionProperty(node)) return; | ||
| 66 | extensionProperties.push(node); | ||
| 67 | }, | ||
| 68 | "Program:exit"() { | ||
| 69 | for (const node of extensionProperties) { | ||
| 70 | const value = node.value; | ||
| 71 | if ( | ||
| 72 | isAnnotatedExtensionFunction(value, extensionTypeNames) || | ||
| 73 | returnsSatisfiesExtension(value?.body, extensionTypeNames) | ||
| 74 | ) { | ||
| 75 | context.report({ node, message }); | ||
| 76 | } | ||
| 77 | } | ||
| 78 | }, | ||
| 79 | }; | ||
| 80 | }, | ||
| 81 | }; | ||
| @@ -0,0 +1,40 @@ | |||
| 1 | import { getPropertyName, isIdentifier } from "../utils.js"; | ||
| 2 | |||
| 3 | const message = | ||
| 4 | "Yield tagged errors directly in Effect.gen instead of yielding Effect.fail(new ErrorType(...)). Skill: wrdn-effect-typed-errors."; | ||
| 5 | |||
| 6 | const isEffectFail = (node) => | ||
| 7 | node?.type === "MemberExpression" && | ||
| 8 | isIdentifier(node.object, "Effect") && | ||
| 9 | getPropertyName(node.property) === "fail"; | ||
| 10 | |||
| 11 | const isTaggedErrorConstruction = (node) => | ||
| 12 | node?.type === "NewExpression" && | ||
| 13 | isIdentifier(node.callee) && | ||
| 14 | node.callee.name !== "Error" && | ||
| 15 | node.callee.name.endsWith("Error"); | ||
| 16 | |||
| 17 | const isYieldedEffectFailOfTaggedError = (node) => | ||
| 18 | node?.type === "YieldExpression" && | ||
| 19 | node.delegate === true && | ||
| 20 | node.argument?.type === "CallExpression" && | ||
| 21 | isEffectFail(node.argument.callee) && | ||
| 22 | isTaggedErrorConstruction(node.argument.arguments?.[0]); | ||
| 23 | |||
| 24 | export default { | ||
| 25 | meta: { | ||
| 26 | type: "problem", | ||
| 27 | docs: { | ||
| 28 | description: message, | ||
| 29 | }, | ||
| 30 | }, | ||
| 31 | create(context) { | ||
| 32 | return { | ||
| 33 | YieldExpression(node) { | ||
| 34 | if (isYieldedEffectFailOfTaggedError(node)) { | ||
| 35 | context.report({ node, message }); | ||
| 36 | } | ||
| 37 | }, | ||
| 38 | }; | ||
| 39 | }, | ||
| 40 | }; | ||
| @@ -56,7 +56,7 @@ export default { · 55 unmodified lines | |||
| 56 | 56 | ||
| 57 | context.report({ | 57 | context.report({ |
| 58 | node: call, | 58 | node: call, |
| 59 | message: `Mutation ${mutation.mutationName} must pass reactivityKeys at the call site.`, | 59 | message: `Mutation ${mutation.mutationName} must pass reactivityKeys at the call site. Skill: wrdn-effect-atom-reactivity-keys.`, |
| 60 | }); | 60 | }); |
| 61 | }, | 61 | }, |
| 62 | }; | 62 | }; |