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+---
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+```
.agents/skills/wrdn-effect-promise-exit/SKILL.md+115−0
@@ -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`.
.agents/skills/wrdn-effect-schema-boundaries/SKILL.md+30−0
@@ -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+```
.agents/skills/wrdn-effect-schema-inferred-types/SKILL.md+107−0
@@ -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.
.agents/skills/wrdn-effect-typed-errors/SKILL.md+329−0
@@ -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.
.agents/skills/wrdn-effect-value-inferred-types/SKILL.md+95−0
@@ -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`.
.agents/skills/wrdn-effect-vitest-tests/SKILL.md+29−0
@@ -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+```
.agents/skills/wrdn-package-boundaries/SKILL.md+26−0
@@ -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+```
.agents/skills/wrdn-typescript-type-safety/SKILL.md+14−0
@@ -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.
.oxlintrc.jsonc+12−0
@@ -9,7 +9,19 @@ · 8 unmodified lines
99 "executor/no-cross-package-relative-imports": "error",
1010 "executor/require-reactivity-keys": "error",
1111 "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",
1219 "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",
1325 "react/forbid-elements": [
1426 "error",
1527 {
scripts/oxlint-plugin-executor.js+18−0
@@ -2,15 +2,24 @@ import noConditionalTests from "./oxlint-plugin-executor/rules/no-conditional-te · 1 unmodified line
22 import noCrossPackageRelativeImports from "./oxlint-plugin-executor/rules/no-cross-package-relative-imports.js";
33 import noDoubleCast from "./oxlint-plugin-executor/rules/no-double-cast.js";
44 import noEffectInternalTags from "./oxlint-plugin-executor/rules/no-effect-internal-tags.js";
5+import noErrorConstructor from "./oxlint-plugin-executor/rules/no-error-constructor.js";
56 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";
68 import noInstanceofTaggedError from "./oxlint-plugin-executor/rules/no-instanceof-tagged-error.js";
79 import noManualTagCheck from "./oxlint-plugin-executor/rules/no-manual-tag-check.js";
10+import noPromiseCatch from "./oxlint-plugin-executor/rules/no-promise-catch.js";
811 import noPromiseClientSurface from "./oxlint-plugin-executor/rules/no-promise-client-surface.js";
12+import noPromiseReject from "./oxlint-plugin-executor/rules/no-promise-reject.js";
913 import noRawErrorThrow from "./oxlint-plugin-executor/rules/no-raw-error-throw.js";
1014 import noRedundantErrorFactory from "./oxlint-plugin-executor/rules/no-redundant-error-factory.js";
1115 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";
1218 import noUnknownShapeProbing from "./oxlint-plugin-executor/rules/no-unknown-shape-probing.js";
1319 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";
1423 import requireReactivityKeys from "./oxlint-plugin-executor/rules/require-reactivity-keys.js";
1524
1625 export default {
@@ -24,13 +33,22 @@ export default { · 7 unmodified lines
2433 "no-cross-package-relative-imports": noCrossPackageRelativeImports,
2534 "require-reactivity-keys": requireReactivityKeys,
2635 "no-effect-internal-tags": noEffectInternalTags,
36+ "no-error-constructor": noErrorConstructor,
2737 "no-ts-nocheck": noTsNocheck,
2838 "no-inline-object-type-assertion": noInlineObjectTypeAssertion,
39+ "no-instanceof-error": noInstanceofError,
2940 "no-instanceof-tagged-error": noInstanceofTaggedError,
3041 "no-manual-tag-check": noManualTagCheck,
42+ "no-promise-catch": noPromiseCatch,
3143 "no-promise-client-surface": noPromiseClientSurface,
44+ "no-promise-reject": noPromiseReject,
3245 "no-raw-error-throw": noRawErrorThrow,
3346 "no-redundant-error-factory": noRedundantErrorFactory,
47+ "no-try-catch-or-throw": noTryCatchOrThrow,
48+ "no-unknown-error-message": noUnknownErrorMessage,
3449 "no-unknown-shape-probing": noUnknownShapeProbing,
50+ "prefer-schema-inferred-types": preferSchemaInferredTypes,
51+ "prefer-value-inferred-extension-types": preferValueInferredExtensionTypes,
52+ "prefer-yield-tagged-error": preferYieldTaggedError,
3553 },
3654 };
scripts/oxlint-plugin-executor/rules/no-conditional-tests.js+2−1
@@ -57,7 +57,8 @@ export default { · 56 unmodified lines
5757 if (name !== "expect") return;
5858 context.report({
5959 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.",
6162 });
6263 },
6364 FunctionDeclaration: enterFunction,
scripts/oxlint-plugin-executor/rules/no-cross-package-relative-imports.js+1−1
@@ -23,7 +23,7 @@ export default { · 22 unmodified lines
2323
2424 context.report({
2525 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.`,
2727 });
2828 },
2929 };
scripts/oxlint-plugin-executor/rules/no-double-cast.js+1−1
@@ -19,7 +19,7 @@ export default { · 18 unmodified lines
1919 context.report({
2020 node,
2121 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.",
2323 });
2424 },
2525 };
scripts/oxlint-plugin-executor/rules/no-effect-internal-tags.js+1−1
@@ -71,7 +71,7 @@ function reportIfEffectTagComparison( · 70 unmodified lines
7171
7272 context.report({
7373 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.`,
7575 });
7676 }
7777
scripts/oxlint-plugin-executor/rules/no-error-constructor.js+40−0
@@ -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+};
scripts/oxlint-plugin-executor/rules/no-inline-object-type-assertion.js+1−1
@@ -1,7 +1,7 @@
11 import { isIdentifier } from "../utils.js";
22
33 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.";
55
66 const isUnknownKeyword = (node) => node?.type === "TSUnknownKeyword";
77
scripts/oxlint-plugin-executor/rules/no-instanceof-error.js+22−0
@@ -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+};
scripts/oxlint-plugin-executor/rules/no-instanceof-tagged-error.js+1−1
@@ -1,7 +1,7 @@
11 import { isIdentifier, nodeName } from "../utils.js";
22
33 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.";
55
66 const looksLikeTaggedErrorName = (name) =>
77 typeof name === "string" && name !== "Error" && name.endsWith("Error");
scripts/oxlint-plugin-executor/rules/no-manual-tag-check.js+13−1
@@ -1,7 +1,7 @@
11 import { isIdentifier, isStringLiteral } from "../utils.js";
22
33 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.";
55
66 const isTagProperty = (node) =>
77 isIdentifier(node, "_tag") || (isStringLiteral(node) && node.value === "_tag");
@@ -15,6 +15,16 @@ export default { · 7 unmodified lines
1515 },
1616 create(context) {
1717 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+ },
1828 MemberExpression(node) {
1929 if (isTagProperty(node.property)) {
2030 context.report({ node, message });
@@ -23,3 +33,5 @@ export default { · 2 unmodified lines
2333 };
2434 },
2535 };
36+
37+const isTagAccess = (node) => node?.type === "MemberExpression" && isTagProperty(node.property);
scripts/oxlint-plugin-executor/rules/no-promise-catch.js+30−0
@@ -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+};
scripts/oxlint-plugin-executor/rules/no-promise-client-surface.js+1−1
@@ -1,7 +1,7 @@
11 import { containsPromiseType, nodeName } from "../utils.js";
22
33 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.";
55
66 const isExported = (node) => node?.parent?.type === "ExportNamedDeclaration";
77
scripts/oxlint-plugin-executor/rules/no-promise-reject.js+74−0
@@ -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+};
scripts/oxlint-plugin-executor/rules/no-raw-error-throw.js+1−1
@@ -1,7 +1,7 @@
11 import { isIdentifier } from "../utils.js";
22
33 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.";
55
66 const isNewError = (node) => node?.type === "NewExpression" && isIdentifier(node.callee, "Error");
77
scripts/oxlint-plugin-executor/rules/no-redundant-error-factory.js+46−8
@@ -1,27 +1,65 @@
11 import { isIdentifier } from "../utils.js";
22
33 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.";
55
66 const isErrorFactoryName = (name) => /^make[A-Z].*Error$/.test(name);
77
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+
818 const isNewErrorExpression = (node) =>
919 node?.type === "NewExpression" && isIdentifier(node.callee) && node.callee.name.endsWith("Error");
1020
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+
1148 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;
1351 if (node?.type !== "BlockStatement") return false;
1452 const statements = node.body ?? [];
1553 return (
1654 statements.length === 1 &&
1755 statements[0]?.type === "ReturnStatement" &&
18− isNewErrorExpression(statements[0].argument)
56+ isRedundantNewErrorExpression(statements[0].argument, parameterNames)
1957 );
2058 };
2159
22−const reportIfRedundantFactory = (context, name, body, node) => {
23− if (isErrorFactoryName(name) && returnsOnlyNewError(body)) {
24− context.report({ node, message });
60+const reportIfRedundantFactory = (context, name, fnNode, reportNode) => {
61+ if (isErrorHelperName(name) && returnsOnlyNewError(fnNode)) {
62+ context.report({ node: reportNode, message });
2563 }
2664 };
2765
@@ -35,7 +73,7 @@ export default { · 7 unmodified lines
3573 create(context) {
3674 return {
3775 FunctionDeclaration(node) {
38− reportIfRedundantFactory(context, node.id?.name, node.body, node);
76+ reportIfRedundantFactory(context, node.id?.name, node, node);
3977 },
4078 VariableDeclarator(node) {
4179 if (!isIdentifier(node.id)) return;
@@ -45,7 +83,7 @@ export default { · 3 unmodified lines
4583 ) {
4684 return;
4785 }
48− reportIfRedundantFactory(context, node.id.name, node.init.body, node);
86+ reportIfRedundantFactory(context, node.id.name, node.init, node);
4987 },
5088 };
5189 },
scripts/oxlint-plugin-executor/rules/no-try-catch-or-throw.js+23−0
@@ -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+};
scripts/oxlint-plugin-executor/rules/no-ts-nocheck.js+1−1
@@ -18,7 +18,7 @@ export default { · 17 unmodified lines
1818
1919 context.report({
2020 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.`,
2222 });
2323 },
2424 };
scripts/oxlint-plugin-executor/rules/no-unknown-error-message.js+49−0
@@ -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+};
scripts/oxlint-plugin-executor/rules/no-unknown-shape-probing.js+1−1
@@ -1,7 +1,7 @@
11 import { isIdentifier, isStringLiteral } from "../utils.js";
22
33 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.";
55
66 const isReflectGet = (node) =>
77 node?.type === "MemberExpression" &&
scripts/oxlint-plugin-executor/rules/no-vitest-import.js+1−1
@@ -15,7 +15,7 @@ export default { · 14 unmodified lines
1515 context.report({
1616 node: node.source,
1717 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.",
1919 });
2020 },
2121 };
scripts/oxlint-plugin-executor/rules/prefer-schema-inferred-types.js+66−0
@@ -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+};
scripts/oxlint-plugin-executor/rules/prefer-value-inferred-extension-types.js+81−0
@@ -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+};
scripts/oxlint-plugin-executor/rules/prefer-yield-tagged-error.js+40−0
@@ -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+};
scripts/oxlint-plugin-executor/rules/require-reactivity-keys.js+1−1
@@ -56,7 +56,7 @@ export default { · 55 unmodified lines
5656
5757 context.report({
5858 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.`,
6060 });
6161 },
6262 };