History

Add Effect lint skills and diagnostics

Rhys Sullivan committed 6 months ago

Commit 48272141 parent 726574534 files · +1297 −21Browse files

Unified · Split

34 files
  1. .agents/skills/wrdn-effect-atom-reactivity-keys/SKILL.md +25 −0
  2. .agents/skills/wrdn-effect-promise-exit/SKILL.md +115 −0
  3. .agents/skills/wrdn-effect-schema-boundaries/SKILL.md +30 −0
  4. .agents/skills/wrdn-effect-schema-inferred-types/SKILL.md +107 −0
  5. .agents/skills/wrdn-effect-typed-errors/SKILL.md +329 −0
  6. .agents/skills/wrdn-effect-value-inferred-types/SKILL.md +95 −0
  7. .agents/skills/wrdn-effect-vitest-tests/SKILL.md +29 −0
  8. .agents/skills/wrdn-package-boundaries/SKILL.md +26 −0
  9. .agents/skills/wrdn-typescript-type-safety/SKILL.md +14 −0
  10. .oxlintrc.jsonc +12 −0
  11. scripts/oxlint-plugin-executor.js +18 −0
  12. scripts/oxlint-plugin-executor/rules/no-conditional-tests.js +2 −1
  13. scripts/oxlint-plugin-executor/rules/no-cross-package-relative-imports.js +1 −1
  14. scripts/oxlint-plugin-executor/rules/no-double-cast.js +1 −1
  15. scripts/oxlint-plugin-executor/rules/no-effect-internal-tags.js +1 −1
  16. scripts/oxlint-plugin-executor/rules/no-error-constructor.js +40 −0
  17. scripts/oxlint-plugin-executor/rules/no-inline-object-type-assertion.js +1 −1
  18. scripts/oxlint-plugin-executor/rules/no-instanceof-error.js +22 −0
  19. scripts/oxlint-plugin-executor/rules/no-instanceof-tagged-error.js +1 −1
  20. scripts/oxlint-plugin-executor/rules/no-manual-tag-check.js +13 −1
  21. scripts/oxlint-plugin-executor/rules/no-promise-catch.js +30 −0
  22. scripts/oxlint-plugin-executor/rules/no-promise-client-surface.js +1 −1
  23. scripts/oxlint-plugin-executor/rules/no-promise-reject.js +74 −0
  24. scripts/oxlint-plugin-executor/rules/no-raw-error-throw.js +1 −1
  25. scripts/oxlint-plugin-executor/rules/no-redundant-error-factory.js +46 −8
  26. scripts/oxlint-plugin-executor/rules/no-try-catch-or-throw.js +23 −0
  27. scripts/oxlint-plugin-executor/rules/no-ts-nocheck.js +1 −1
  28. scripts/oxlint-plugin-executor/rules/no-unknown-error-message.js +49 −0
  29. scripts/oxlint-plugin-executor/rules/no-unknown-shape-probing.js +1 −1
  30. scripts/oxlint-plugin-executor/rules/no-vitest-import.js +1 −1
  31. scripts/oxlint-plugin-executor/rules/prefer-schema-inferred-types.js +66 −0
  32. scripts/oxlint-plugin-executor/rules/prefer-value-inferred-extension-types.js +81 −0
  33. scripts/oxlint-plugin-executor/rules/prefer-yield-tagged-error.js +40 −0
  34. scripts/oxlint-plugin-executor/rules/require-reactivity-keys.js +1 −1
.agents/skills/wrdn-effect-atom-reactivity-keys/SKILL.md+25−0
@@ -0,0 +1,25 @@
1---
2name: wrdn-effect-atom-reactivity-keys
3description: Add reactivityKeys to effect-atom write mutation calls. Use when lint flags a useAtomSet mutation call that mutates data without invalidation keys.
4allowed-tools: Read Grep Glob Bash
5---
6
7Effect-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
20await updateSource({
21 params: { scopeId, sourceId },
22 payload,
23 reactivityKeys: [["sources", scopeId]],
24});
25```
.agents/skills/wrdn-effect-promise-exit/SKILL.md+115−0
@@ -0,0 +1,115 @@
1---
2name: wrdn-effect-promise-exit
3description: 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.
4allowed-tools: Read Grep Glob Bash
5---
6
7You fix one pattern: a React handler awaits an effect-atom mutation in `mode: "promise"` and catches failures with `try/catch`.
8
9The 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
131. **Find the mutation setter.** Look for `const doX = useAtomSet(<mutationAtom>, { mode: "promise" })`.
142. **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.
153. **Find thrown-control handling.** The same handler has `try { await doX(...) } catch (e) { ... }`, usually setting error text, resetting `adding`/`saving`, or showing a toast.
164. **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.
175. **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
34const doAdd = useAtomSet(addGraphqlSource, { mode: "promise" });
35
36const 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
56import * as Exit from "effect/Exit";
57import * as Option from "effect/Option";
58
59const doAdd = useAtomSet(addGraphqlSource, { mode: "promiseExit" });
60
61const 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
85If success work depends on the mutation result, read it after the failure branch:
86
87```tsx
88const exit = await doAdd(args);
89if (Exit.isFailure(exit)) {
90 setAdding(false);
91 return;
92}
93
94const sourceId = exit.value.namespace;
95```
96
97If 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
108When 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
115When editing, keep changes local to the handler and imports unless a follow-up mutation in the same success path must also become `promiseExit`.
.agents/skills/wrdn-effect-schema-boundaries/SKILL.md+30−0
@@ -0,0 +1,30 @@
1---
2name: wrdn-effect-schema-boundaries
3description: 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.
4allowed-tools: Read Grep Glob Bash
5---
6
7You 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
19const ParsedConfig = Schema.Struct({
20 endpoint: Schema.String,
21});
22
23const config = yield * Schema.decodeUnknownEffect(ParsedConfig)(raw);
24```
25
26## Bad
27
28```ts
29const config = raw as unknown as { endpoint: string };
30```
.agents/skills/wrdn-effect-schema-inferred-types/SKILL.md+107−0
@@ -0,0 +1,107 @@
1---
2name: wrdn-effect-schema-inferred-types
3description: 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.
4allowed-tools: Read Grep Glob Bash
5---
6
7You fix one pattern: a runtime `Schema` and a manual TypeScript type describe the same shape.
8
9The 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
131. **Find the runtime schema.** Look for `Schema.Struct`, `Schema.Union`, `Schema.TaggedStruct`, `Schema.Record`, `Schema.Array`, or `Schema.decodeTo`.
142. **Find the duplicate static shape.** A nearby `interface X` or `type X = { ... }` repeats the same fields, nullability, optionality, or literals.
153. **Check export consumers.** If callers import the type, keep the exported type name stable and change only its definition.
164. **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.
175. **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
25export const SourceSchema = Schema.Struct({
26 id: SourceId,
27 name: Schema.String,
28 enabled: Schema.Boolean,
29});
30
31export 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
37type 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
46export interface StoredSource {
47 readonly id: string;
48 readonly url: string;
49 readonly headers: readonly Header[];
50}
51
52export 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
62export const StoredSourceSchema = Schema.Struct({
63 id: Schema.String,
64 url: Schema.String,
65 headers: Schema.Array(HeaderSchema),
66});
67
68export type StoredSource = typeof StoredSourceSchema.Type;
69```
70
71## Recursive schemas
72
73Use a private helper only where TypeScript needs an annotation for self-reference:
74
75```ts
76interface TypeRefRecursive {
77 readonly kind: string;
78 readonly ofType: TypeRefRecursive | null;
79}
80
81const TypeRefSchema: Schema.Codec<TypeRefRecursive> = Schema.Struct({
82 kind: Schema.String,
83 ofType: Schema.NullOr(Schema.suspend(() => TypeRefSchema)),
84});
85
86export type TypeRef = typeof TypeRefSchema.Type;
87```
88
89The 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
100When 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
107When editing, keep exported type names stable unless every caller is updated in the same change.
.agents/skills/wrdn-effect-typed-errors/SKILL.md+329−0
@@ -0,0 +1,329 @@
1---
2name: wrdn-effect-typed-errors
3description: 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.
4allowed-tools: Read Grep Glob Bash
5---
6
7You fix one family of patterns: untyped JavaScript error handling in Effect code.
8
9The 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
131. **Identify the boundary.** Is this Effect domain code, React UI code, a third-party callback, or plain test/tooling code?
142. **Find the existing domain errors.** Check nearby `errors.ts`, `Schema.TaggedError`, `Data.TaggedError`, and API `.addError(...)` declarations before adding a new class.
153. **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.
164. **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.
175. **Preserve the typed channel.** Do not convert typed failures into `Error`, thrown exceptions, `String(error)`, or `.message` reads from unknown values.
186. **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.
197. **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
23The lint rule is about **where the failure lives**, not whether the operation should still fail.
24
25Bad fix: this removes the lint finding by silently changing invalid input into a non-match.
26
27```ts
28case "in":
29 if (!Array.isArray(value)) return false;
30 return value.some((v) => cmp(lhs, v));
31```
32
33Good fix: keep the invalid input as a failure, but make it typed.
34
35```ts
36case "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
45When 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
49The 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
56Good boundary suppression:
57
58```ts
59// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: JSON.parse feeds stable IPC failure envelope
60try {
61 const message = JSON.parse(line);
62 handleHostMessage(message);
63} catch (error) {
64 writeIpcMessage({ type: "failed", error: formatBoundaryError(error) });
65}
66```
67
68Bad 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
71return makeRejectedThenable(makeErrorLike("Tool path missing"));
72```
73
74For 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
80Bad:
81
82```ts
83throw new Error("Missing source");
84```
85
86Good in `Effect.gen`:
87
88```ts
89return yield* new SourceNotFoundError({ sourceId });
90```
91
92Good in combinators:
93
94```ts
95Effect.fail(new SourceNotFoundError({ sourceId }));
96```
97
98If 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
102Prefer yielding the error directly in generator code:
103
104```ts
105return yield* new SourceNotFoundError({ sourceId });
106```
107
108Do not write:
109
110```ts
111return yield* Effect.fail(new SourceNotFoundError({ sourceId }));
112```
113
114Use `Effect.fail(...)` in non-generator combinator code:
115
116```ts
117Effect.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
128Bad:
129
130```ts
131await client.close().catch(() => {});
132return Promise.reject(new Error("failed"));
133```
134
135Good:
136
137```ts
138Effect.tryPromise({
139 try: () => client.close(),
140 catch: (cause) => new ClientCloseError({ cause }),
141});
142```
143
144If the failure is intentionally ignored:
145
146```ts
147Effect.ignore(
148 Effect.tryPromise({
149 try: () => client.close(),
150 catch: (cause) => new ClientCloseError({ cause }),
151 }),
152);
153```
154
155### try/catch
156
157Bad:
158
159```ts
160try {
161 return JSON.parse(text);
162} catch (cause) {
163 return new ParseError({ message: String(cause) });
164}
165```
166
167Good for schema-backed input:
168
169```ts
170Schema.decodeUnknownEffect(Schema.fromJsonString(InputSchema))(text).pipe(
171 Effect.mapError(() => new ParseError({ message: "Failed to parse input" })),
172);
173```
174
175Good for non-schema throwing APIs:
176
177```ts
178Effect.try({
179 try: () => new URL(value),
180 catch: (cause) => new UrlParseError({ value, cause }),
181});
182```
183
184### Unknown error message / instanceof Error
185
186Bad:
187
188```ts
189err instanceof Error ? err.message : String(err);
190```
191
192Also bad: destructuring `message` only hides the same unknown-state problem from a shallow property-access lint.
193
194```ts
195const { message } = err;
196return message;
197```
198
199Prefer one of:
200
201```ts
202Effect.mapError((err) => new DomainError({ cause: err }));
203```
204
205```ts
206Effect.catchTag("KnownError", (err) => Effect.fail(new DomainError({ message: err.message })));
207```
208
209Only 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
211If 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
213Bad: leaks internal provider/native details to users.
214
215```ts
216Effect.tryPromise({
217 try: () => client.call(),
218 catch: (cause) =>
219 new SourceError({
220 message: cause instanceof Error ? cause.message : String(cause),
221 }),
222});
223```
224
225Good: user-facing message is stable; internal detail goes into `cause` only if the error type has an internal channel.
226
227```ts
228Effect.tryPromise({
229 try: () => client.call(),
230 catch: (cause) =>
231 new SourceError({
232 message: "Failed to connect to source",
233 cause,
234 }),
235});
236```
237
238If 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
242Bad: manually probing `_tag` to recover from typed Effect failures.
243
244```ts
245Effect.mapError((err) =>
246 "_tag" in err && err._tag === "SecretOwnedByConnectionError"
247 ? new SourceError({ message: "Failed to resolve secret" })
248 : err,
249);
250```
251
252Good: catch the one typed case you intentionally translate.
253
254```ts
255effect.pipe(
256 Effect.catchTag("SecretOwnedByConnectionError", () =>
257 Effect.fail(new SourceError({ message: "Failed to resolve secret" })),
258 ),
259);
260```
261
262Do 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
264For Effect data types, use public helpers instead of `_tag` checks:
265
266```ts
267if (Option.isNone(parsed)) return null;
268if (Exit.isFailure(exit)) return ...
269```
270
271### Redundant error helpers
272
273Bad:
274
275```ts
276const connectionError = (message: string) =>
277 new McpConnectionError({ transport: "remote", message });
278
279return yield* connectionError("Endpoint URL is required");
280```
281
282Good:
283
284```ts
285return yield* new McpConnectionError({
286 transport: "remote",
287 message: "Endpoint URL is required",
288});
289```
290
291Helpers 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
300Reuse an existing tagged error when only the message changes.
301
302Create 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
311Do 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
322When 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
329When editing, keep the error type precise and avoid broad message parsing.
.agents/skills/wrdn-effect-value-inferred-types/SKILL.md+95−0
@@ -0,0 +1,95 @@
1---
2name: wrdn-effect-value-inferred-types
3description: 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.
4allowed-tools: Read Grep Glob Bash
5---
6
7You fix one pattern: a TypeScript object type manually mirrors a runtime object that already owns the shape.
8
9Prefer 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
131. **Find the source value.** Look for an object returned from a named factory, `extension: (...) => ({ ... })`, a client object, route map, or handler table.
142. **Find the duplicate type.** A nearby `interface X` or `type X = { ... }` repeats the object methods/properties.
153. **Check whether the value is the source of truth.** If the interface is a contract with multiple implementations, keep the interface.
164. **Preserve the exported type name.** Replace its definition with `ReturnType<typeof makeX>` and update callers only if needed.
175. **Use `satisfies` only at boundaries.** Do not make the implementation satisfy a duplicate shape that could drift.
18
19## Fix shape
20
21```ts
22const makePluginExtension = (ctx: PluginCtx<Store>) => {
23 const addSource = ...
24 const removeSource = ...
25
26 return {
27 addSource,
28 removeSource,
29 };
30};
31
32export type PluginExtension = ReturnType<typeof makePluginExtension>;
33```
34
35For factories that need options:
36
37```ts
38const makePluginExtension =
39 (options: PluginOptions) =>
40 (ctx: PluginCtx<Store>) => ({
41 addSource: ...,
42 });
43
44export type PluginExtension = ReturnType<ReturnType<typeof makePluginExtension>>;
45```
46
47## Bad
48
49```ts
50export interface McpPluginExtension {
51 readonly addSource: (config: McpSourceConfig) => Effect.Effect<AddResult, Failure>;
52 readonly removeSource: (namespace: string, scope: string) => Effect.Effect<void, Failure>;
53}
54
55extension: (ctx) => {
56 return {
57 addSource,
58 removeSource,
59 } satisfies McpPluginExtension;
60};
61```
62
63## Good
64
65```ts
66const makeMcpPluginExtension = (ctx: PluginCtx<McpStore>) => {
67 return {
68 addSource,
69 removeSource,
70 };
71};
72
73export type McpPluginExtension = ReturnType<typeof makeMcpPluginExtension>;
74
75extension: 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
88When 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
95When editing, name the factory after the exported type, e.g. `makeMcpPluginExtension` for `McpPluginExtension`.
.agents/skills/wrdn-effect-vitest-tests/SKILL.md+29−0
@@ -0,0 +1,29 @@
1---
2name: wrdn-effect-vitest-tests
3description: Keep tests deterministic and Effect-aware. Use when lint flags direct vitest imports or conditional assertions inside tests.
4allowed-tools: Read Grep Glob Bash
5---
6
7Use `@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
20if (result.ok) {
21 expect(result.value).toBe("x");
22}
23```
24
25## Good
26
27```ts
28expect(result).toEqual({ ok: true, value: "x" });
29```
.agents/skills/wrdn-package-boundaries/SKILL.md+26−0
@@ -0,0 +1,26 @@
1---
2name: wrdn-package-boundaries
3description: Preserve workspace package boundaries. Use when lint flags relative imports that cross package roots.
4allowed-tools: Read Grep Glob Bash
5---
6
7Workspace 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
19import { createExecutor } from "@executor-js/sdk";
20```
21
22## Bad
23
24```ts
25import { createExecutor } from "../../../core/sdk/src";
26```
.agents/skills/wrdn-typescript-type-safety/SKILL.md+14−0
@@ -0,0 +1,14 @@
1---
2name: wrdn-typescript-type-safety
3description: Remove TypeScript escape hatches. Use when lint flags @ts-nocheck or similar broad type bypasses.
4allowed-tools: Read Grep Glob Bash
5---
6
7Fix 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.
.oxlintrc.jsonc+12−0
@@ -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 {
scripts/oxlint-plugin-executor.js+18−0
@@ -2,15 +2,24 @@ import noConditionalTests from "./oxlint-plugin-executor/rules/no-conditional-te · 1 unmodified line
2import noCrossPackageRelativeImports from "./oxlint-plugin-executor/rules/no-cross-package-relative-imports.js";2import noCrossPackageRelativeImports from "./oxlint-plugin-executor/rules/no-cross-package-relative-imports.js";
3import noDoubleCast from "./oxlint-plugin-executor/rules/no-double-cast.js";3import noDoubleCast from "./oxlint-plugin-executor/rules/no-double-cast.js";
4import noEffectInternalTags from "./oxlint-plugin-executor/rules/no-effect-internal-tags.js";4import noEffectInternalTags from "./oxlint-plugin-executor/rules/no-effect-internal-tags.js";
5import noErrorConstructor from "./oxlint-plugin-executor/rules/no-error-constructor.js";
5import noInlineObjectTypeAssertion from "./oxlint-plugin-executor/rules/no-inline-object-type-assertion.js";6import noInlineObjectTypeAssertion from "./oxlint-plugin-executor/rules/no-inline-object-type-assertion.js";
7import noInstanceofError from "./oxlint-plugin-executor/rules/no-instanceof-error.js";
6import noInstanceofTaggedError from "./oxlint-plugin-executor/rules/no-instanceof-tagged-error.js";8import noInstanceofTaggedError from "./oxlint-plugin-executor/rules/no-instanceof-tagged-error.js";
7import noManualTagCheck from "./oxlint-plugin-executor/rules/no-manual-tag-check.js";9import noManualTagCheck from "./oxlint-plugin-executor/rules/no-manual-tag-check.js";
10import noPromiseCatch from "./oxlint-plugin-executor/rules/no-promise-catch.js";
8import noPromiseClientSurface from "./oxlint-plugin-executor/rules/no-promise-client-surface.js";11import noPromiseClientSurface from "./oxlint-plugin-executor/rules/no-promise-client-surface.js";
12import noPromiseReject from "./oxlint-plugin-executor/rules/no-promise-reject.js";
9import noRawErrorThrow from "./oxlint-plugin-executor/rules/no-raw-error-throw.js";13import noRawErrorThrow from "./oxlint-plugin-executor/rules/no-raw-error-throw.js";
10import noRedundantErrorFactory from "./oxlint-plugin-executor/rules/no-redundant-error-factory.js";14import noRedundantErrorFactory from "./oxlint-plugin-executor/rules/no-redundant-error-factory.js";
11import noTsNocheck from "./oxlint-plugin-executor/rules/no-ts-nocheck.js";15import noTsNocheck from "./oxlint-plugin-executor/rules/no-ts-nocheck.js";
16import noTryCatchOrThrow from "./oxlint-plugin-executor/rules/no-try-catch-or-throw.js";
17import noUnknownErrorMessage from "./oxlint-plugin-executor/rules/no-unknown-error-message.js";
12import noUnknownShapeProbing from "./oxlint-plugin-executor/rules/no-unknown-shape-probing.js";18import noUnknownShapeProbing from "./oxlint-plugin-executor/rules/no-unknown-shape-probing.js";
13import noVitestImport from "./oxlint-plugin-executor/rules/no-vitest-import.js";19import noVitestImport from "./oxlint-plugin-executor/rules/no-vitest-import.js";
20import preferSchemaInferredTypes from "./oxlint-plugin-executor/rules/prefer-schema-inferred-types.js";
21import preferYieldTaggedError from "./oxlint-plugin-executor/rules/prefer-yield-tagged-error.js";
22import preferValueInferredExtensionTypes from "./oxlint-plugin-executor/rules/prefer-value-inferred-extension-types.js";
14import requireReactivityKeys from "./oxlint-plugin-executor/rules/require-reactivity-keys.js";23import requireReactivityKeys from "./oxlint-plugin-executor/rules/require-reactivity-keys.js";
15 24
16export default {25export 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};
scripts/oxlint-plugin-executor/rules/no-conditional-tests.js+2−1
@@ -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,
scripts/oxlint-plugin-executor/rules/no-cross-package-relative-imports.js+1−1
@@ -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 };
scripts/oxlint-plugin-executor/rules/no-double-cast.js+1−1
@@ -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 };
scripts/oxlint-plugin-executor/rules/no-effect-internal-tags.js+1−1
@@ -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
scripts/oxlint-plugin-executor/rules/no-error-constructor.js+40−0
@@ -0,0 +1,40 @@
1import { nodeName } from "../utils.js";
2
3const errorConstructors = new Set([
4 "AggregateError",
5 "Error",
6 "EvalError",
7 "RangeError",
8 "ReferenceError",
9 "SyntaxError",
10 "TypeError",
11 "URIError",
12]);
13
14const 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
17const isErrorConstructor = (node) => errorConstructors.has(nodeName(node));
18
19export 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};
scripts/oxlint-plugin-executor/rules/no-inline-object-type-assertion.js+1−1
@@ -1,7 +1,7 @@
1import { isIdentifier } from "../utils.js";1import { isIdentifier } from "../utils.js";
2 2
3const message =3const 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
6const isUnknownKeyword = (node) => node?.type === "TSUnknownKeyword";6const isUnknownKeyword = (node) => node?.type === "TSUnknownKeyword";
7 7
scripts/oxlint-plugin-executor/rules/no-instanceof-error.js+22−0
@@ -0,0 +1,22 @@
1import { nodeName } from "../utils.js";
2
3const message =
4 "Do not use instanceof Error. Preserve typed failures with Effect tagged-error handling. Skill: wrdn-effect-typed-errors.";
5
6export 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};
scripts/oxlint-plugin-executor/rules/no-instanceof-tagged-error.js+1−1
@@ -1,7 +1,7 @@
1import { isIdentifier, nodeName } from "../utils.js";1import { isIdentifier, nodeName } from "../utils.js";
2 2
3const message =3const 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
6const looksLikeTaggedErrorName = (name) =>6const looksLikeTaggedErrorName = (name) =>
7 typeof name === "string" && name !== "Error" && name.endsWith("Error");7 typeof name === "string" && name !== "Error" && name.endsWith("Error");
scripts/oxlint-plugin-executor/rules/no-manual-tag-check.js+13−1
@@ -1,7 +1,7 @@
1import { isIdentifier, isStringLiteral } from "../utils.js";1import { isIdentifier, isStringLiteral } from "../utils.js";
2 2
3const message =3const 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
6const isTagProperty = (node) =>6const 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
37const isTagAccess = (node) => node?.type === "MemberExpression" && isTagProperty(node.property);
scripts/oxlint-plugin-executor/rules/no-promise-catch.js+30−0
@@ -0,0 +1,30 @@
1import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.js";
2
3const message =
4 "Do not use Promise .catch(). Model async failures with Effect.tryPromise and typed Effect error handling. Skill: wrdn-effect-typed-errors.";
5
6const 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
14export 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};
scripts/oxlint-plugin-executor/rules/no-promise-client-surface.js+1−1
@@ -1,7 +1,7 @@
1import { containsPromiseType, nodeName } from "../utils.js";1import { containsPromiseType, nodeName } from "../utils.js";
2 2
3const message =3const 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
6const isExported = (node) => node?.parent?.type === "ExportNamedDeclaration";6const isExported = (node) => node?.parent?.type === "ExportNamedDeclaration";
7 7
scripts/oxlint-plugin-executor/rules/no-promise-reject.js+74−0
@@ -0,0 +1,74 @@
1import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.js";
2
3const promiseRejectMessage =
4 "Do not use Promise.reject(). Model async failures with Effect.fail or Effect.tryPromise. Skill: wrdn-effect-typed-errors.";
5const rejectCallbackMessage =
6 "Do not call Promise executor reject(). Model async failures with Effect.fail or Effect.tryPromise. Skill: wrdn-effect-typed-errors.";
7
8const 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
17const isPromiseConstructor = (node) =>
18 node?.type === "NewExpression" && isIdentifier(unwrapExpression(node.callee), "Promise");
19
20const isFunction = (node) =>
21 node?.type === "ArrowFunctionExpression" ||
22 node?.type === "FunctionExpression" ||
23 node?.type === "FunctionDeclaration";
24
25export 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};
scripts/oxlint-plugin-executor/rules/no-raw-error-throw.js+1−1
@@ -1,7 +1,7 @@
1import { isIdentifier } from "../utils.js";1import { isIdentifier } from "../utils.js";
2 2
3const message =3const 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
6const isNewError = (node) => node?.type === "NewExpression" && isIdentifier(node.callee, "Error");6const isNewError = (node) => node?.type === "NewExpression" && isIdentifier(node.callee, "Error");
7 7
scripts/oxlint-plugin-executor/rules/no-redundant-error-factory.js+46−8
@@ -1,27 +1,65 @@
1import { isIdentifier } from "../utils.js";1import { isIdentifier } from "../utils.js";
2 2
3const message =3const 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
6const isErrorFactoryName = (name) => /^make[A-Z].*Error$/.test(name);6const isErrorFactoryName = (name) => /^make[A-Z].*Error$/.test(name);
7 7
8const isErrorHelperName = (name) =>
9 isErrorFactoryName(name) || String(name ?? "").endsWith("Error");
10
11const 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
8const isNewErrorExpression = (node) =>18const 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
21const 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
31const 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
39const 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
11const returnsOnlyNewError = (node) => {48const 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
22const reportIfRedundantFactory = (context, name, body, node) => {60const 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 },
scripts/oxlint-plugin-executor/rules/no-try-catch-or-throw.js+23−0
@@ -0,0 +1,23 @@
1const 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.";
3const 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
6export 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};
scripts/oxlint-plugin-executor/rules/no-ts-nocheck.js+1−1
@@ -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 };
scripts/oxlint-plugin-executor/rules/no-unknown-error-message.js+49−0
@@ -0,0 +1,49 @@
1import { getPropertyName, isIdentifier, nodeName, unwrapExpression } from "../utils.js";
2
3const stringMessage =
4 "Do not stringify unknown errors. Keep typed failures in Effect or normalize at a typed boundary. Skill: wrdn-effect-typed-errors.";
5const messagePropertyMessage =
6 "Do not read .message from unknown errors. Preserve typed failures with Effect tagged-error handling. Skill: wrdn-effect-typed-errors.";
7const destructuredMessage =
8 "Do not destructure .message from unknown errors. Preserve typed failures with Effect tagged-error handling. Skill: wrdn-effect-typed-errors.";
9
10const errorLikeNames = new Set(["cause", "e", "err", "error", "reason", "unknownError"]);
11
12const isErrorLikeIdentifier = (node) => {
13 const name = nodeName(unwrapExpression(node));
14 return errorLikeNames.has(name);
15};
16
17export 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};
scripts/oxlint-plugin-executor/rules/no-unknown-shape-probing.js+1−1
@@ -1,7 +1,7 @@
1import { isIdentifier, isStringLiteral } from "../utils.js";1import { isIdentifier, isStringLiteral } from "../utils.js";
2 2
3const message =3const 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
6const isReflectGet = (node) =>6const isReflectGet = (node) =>
7 node?.type === "MemberExpression" &&7 node?.type === "MemberExpression" &&
scripts/oxlint-plugin-executor/rules/no-vitest-import.js+1−1
@@ -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 };
scripts/oxlint-plugin-executor/rules/prefer-schema-inferred-types.js+66−0
@@ -0,0 +1,66 @@
1import { getCallName, isIdentifier, typeReferenceName } from "../utils.js";
2
3const 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
6const schemaSuffixPattern = /(Schema|Model|Struct)$/;
7
8const schemaBaseName = (name) => {
9 const base = name.replace(schemaSuffixPattern, "");
10 return base.length > 0 && base !== name ? base : undefined;
11};
12
13const isSchemaMemberCall = (node) =>
14 node?.type === "CallExpression" &&
15 node.callee?.type === "MemberExpression" &&
16 isIdentifier(node.callee.object, "Schema");
17
18const 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
26const isObjectTypeAlias = (node) => node.typeAnnotation?.type === "TSTypeLiteral";
27
28const isInferredSchemaType = (node) => {
29 if (node.typeAnnotation?.type !== "TSTypeReference") return false;
30 const name = typeReferenceName(node.typeAnnotation);
31 return name === "Schema.Schema.Type";
32};
33
34export 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};
scripts/oxlint-plugin-executor/rules/prefer-value-inferred-extension-types.js+81−0
@@ -0,0 +1,81 @@
1import { isIdentifier } from "../utils.js";
2
3const 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
6const extensionNamePattern = /(?:Plugin)?Extension$/;
7
8const isExtensionTypeName = (name) => typeof name === "string" && extensionNamePattern.test(name);
9
10const 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
17const 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
23const 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
36const 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
42export 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};
scripts/oxlint-plugin-executor/rules/prefer-yield-tagged-error.js+40−0
@@ -0,0 +1,40 @@
1import { getPropertyName, isIdentifier } from "../utils.js";
2
3const message =
4 "Yield tagged errors directly in Effect.gen instead of yielding Effect.fail(new ErrorType(...)). Skill: wrdn-effect-typed-errors.";
5
6const isEffectFail = (node) =>
7 node?.type === "MemberExpression" &&
8 isIdentifier(node.object, "Effect") &&
9 getPropertyName(node.property) === "fail";
10
11const isTaggedErrorConstruction = (node) =>
12 node?.type === "NewExpression" &&
13 isIdentifier(node.callee) &&
14 node.callee.name !== "Error" &&
15 node.callee.name.endsWith("Error");
16
17const 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
24export 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};
scripts/oxlint-plugin-executor/rules/require-reactivity-keys.js+1−1
@@ -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 };