oxlint-plugin-effect 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # oxlint-plugin-effect
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`5ecb6fd`](https://github.com/cevr/effect-oxlint/commit/5ecb6fd543d6183d34f037c407f0a9cc0defc5e6) Thanks [@cevr](https://github.com/cevr)! - Support v3/v4 dual-version codebases and add rule-version metadata.
8
+
9
+ **`tsgolint-effect` (Go type-aware linter):**
10
+
11
+ - `IsEffectPackageSymbol` now matches paths across all mainstream package managers (npm/yarn-classic `node_modules/effect`, pnpm `.pnpm/effect@`, bun isolated `.bun/effect@`, yarn berry `.yarn/cache/effect-npm-`) and the `effect-v3` alias used by dual-version projects. Previously, v3 code imported as `effect-v3` silently bypassed every type-aware rule.
12
+ - `IsEffectType`, `IsLayerType`, `IsStreamType`, `IsScopeType` now check the `~effect/*` brand property on types first, falling back to the symbol-name + path heuristic. More robust across package managers and future-proof against package-name changes.
13
+ - Added `Rule.EffectVersion` field (`"v3" | "v4" | "both"`, default `"both"`) for version-specific rules.
14
+
15
+ **`oxlint-plugin-effect` (JS AST linter):**
16
+
17
+ - Added `meta.docs.effectVersion` on rules (`"v3" | "v4" | "both"`, default `"both"`). Preset authors can filter rules per project version.
18
+ - Tagged the following rules as `"v3"` (they detect v3-only APIs): `noCatchAllToMapError`, `noEffectGenAdapter`, `noRuntimeRunFork`.
19
+ - **Renamed `noRunInEffect` → `noRunInEffectGen`.** The old rule fired globally (outside Effect context too — a bug). The new rule is context-aware via `makeEffectContextTracker` and only fires inside `Effect.gen`/`Effect.fn`. Expanded coverage to `Runtime.run{Sync,Promise,Fork,Callback}`. Explicitly permits `Effect.run*With` (v4 explicit-services variants) and `<identifier>.run*` (ManagedRuntime-shaped calls) — both are legitimate at callback boundaries. Preset users must update `"effect/noRunInEffect"` references to `"effect/noRunInEffectGen"`.
20
+
3
21
  ## 0.1.0
4
22
 
5
23
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxlint-plugin-effect",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "oxlint plugin with Effect-specific lint rules",
6
6
  "license": "MIT",
@@ -8,7 +8,7 @@ export const core = {
8
8
  "effect/noEffectNever": "error",
9
9
  "effect/noEffectAs": "error",
10
10
  "effect/noEffectBind": "error",
11
- "effect/noRunInEffect": "error",
11
+ "effect/noRunInEffectGen": "error",
12
12
 
13
13
  // AST pattern rules
14
14
  "effect/noNestedPipe": "error",
@@ -13,7 +13,7 @@ export const full = {
13
13
  "effect/noEffectBind": "error",
14
14
  "effect/noOptionAs": "error",
15
15
  "effect/noRuntimeRunFork": "error",
16
- "effect/noRunInEffect": "error",
16
+ "effect/noRunInEffectGen": "error",
17
17
 
18
18
  // Global bans (everywhere, not just Effect context)
19
19
  "effect/noAsyncFunction": "warn",
@@ -11,7 +11,7 @@ export const strict = {
11
11
  "effect/noEffectBind": "error",
12
12
  "effect/noOptionAs": "error",
13
13
  "effect/noRuntimeRunFork": "error",
14
- "effect/noRunInEffect": "error",
14
+ "effect/noRunInEffectGen": "error",
15
15
 
16
16
  // Style / functional
17
17
  "effect/noReturnNull": "error",
@@ -11,7 +11,6 @@ export { noEffectBind } from "./no-effect-bind.js"
11
11
  export { noEffectDo } from "./no-effect-do.js"
12
12
  export { noEffectNever } from "./no-effect-never.js"
13
13
  export { noOptionAs } from "./no-option-as.js"
14
- export { noRunInEffect } from "./no-run-in-effect.js"
15
14
  export { noRuntimeRunFork } from "./no-runtime-run-fork.js"
16
15
 
17
16
  // --- Statement bans ---
@@ -68,5 +67,6 @@ export { noInlineProvide } from "./no-inline-provide.js"
68
67
  export { noNewError } from "./no-new-error.js"
69
68
  export { noNewPromise } from "./no-new-promise.js"
70
69
  export { noReturnNullish } from "./no-return-nullish.js"
70
+ export { noRunInEffectGen } from "./no-run-in-effect-gen.js"
71
71
  export { noThrowStatement } from "./no-throw-statement.js"
72
72
  export { noTryCatch } from "./no-try-catch.js"
@@ -49,6 +49,7 @@ export const noCatchAllToMapError = Rule.define({
49
49
  type: "suggestion",
50
50
  description:
51
51
  "Use Effect.mapError instead of Effect.catchAll(e => Effect.fail(f(e))).",
52
+ effectVersion: "v3",
52
53
  }),
53
54
  create: function* () {
54
55
  const ctx = yield* RuleContext
@@ -14,6 +14,7 @@ export const noEffectGenAdapter = Rule.define({
14
14
  meta: Rule.meta({
15
15
  type: "suggestion",
16
16
  description: "Deprecated adapter parameter in Effect.gen. Use `yield*` directly.",
17
+ effectVersion: "v3",
17
18
  }),
18
19
  create: function* () {
19
20
  const ctx = yield* RuleContext
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Ban `Effect.run*` and `Runtime.run*` calls inside `Effect.gen`/`Effect.fn`.
3
+ *
4
+ * Running an effect inside a generator drops the ambient context — fibers,
5
+ * services, tracing, and memoized layers. Just `yield*` the child effect;
6
+ * for callback boundaries use `Effect.async`, for fork use `Effect.fork`.
7
+ *
8
+ * Explicitly allowed inside gen: `Effect.runForkWith`, `Effect.runPromiseWith`,
9
+ * `Effect.runSyncWith` (they take services explicitly — legitimate at
10
+ * callback boundaries) and `managedRuntime.runX` on identifier callees
11
+ * (can't be discriminated syntactically).
12
+ *
13
+ * Source: language-service/runEffectInsideEffect, extended for v4.
14
+ */
15
+ import type { ESTree } from "@oxlint/plugins"
16
+ import { AST, Diagnostic, Rule, Visitor, RuleContext } from "../vendor/effect-oxlint/index.js"
17
+ import * as Effect from "effect/Effect"
18
+ import * as Ref from "effect/Ref"
19
+
20
+ import { makeEffectContextTracker } from "./_effect-context.js"
21
+
22
+ const effectRunMethods = ["runSync", "runPromise", "runFork", "runCallback"]
23
+ const runtimeRunMethods = ["runSync", "runPromise", "runFork", "runCallback"]
24
+
25
+ export const noRunInEffectGen = Rule.define({
26
+ name: "no-run-in-effect-gen",
27
+ meta: Rule.meta({
28
+ type: "problem",
29
+ description:
30
+ "Running effects inside Effect.gen/fn drops the ambient context. Use yield* instead.",
31
+ }),
32
+ create: function* () {
33
+ const ctx = yield* RuleContext
34
+ const [depth, tracker] = yield* makeEffectContextTracker
35
+
36
+ return Visitor.merge(tracker, {
37
+ CallExpression: (node) =>
38
+ Effect.flatMap(Ref.get(depth), (d) => {
39
+ if (d <= 0) return Effect.void
40
+ const call = node as ESTree.CallExpression
41
+
42
+ if (AST.isCallOf(call, "Effect", effectRunMethods)) {
43
+ return ctx.report(
44
+ Diagnostic.make({
45
+ node,
46
+ message:
47
+ "Avoid Effect.run* inside Effect.gen/fn. `yield*` the child effect, or use Effect.fork for concurrency.",
48
+ }),
49
+ )
50
+ }
51
+
52
+ if (AST.isCallOf(call, "Runtime", runtimeRunMethods)) {
53
+ return ctx.report(
54
+ Diagnostic.make({
55
+ node,
56
+ message:
57
+ "Avoid Runtime.run* inside Effect.gen/fn. The ambient context already has your services — `yield*` the child effect.",
58
+ }),
59
+ )
60
+ }
61
+
62
+ return Effect.void
63
+ }),
64
+ })
65
+ },
66
+ })
@@ -10,5 +10,5 @@ import { Rule } from "../vendor/effect-oxlint/index.js"
10
10
  export const noRuntimeRunFork = Rule.banMember("Runtime", "runFork", {
11
11
  message:
12
12
  "Avoid Runtime.runFork. Use forkScoped, Stream, or runtime-provided layers.",
13
- meta: { type: "problem" },
13
+ meta: { type: "problem", effectVersion: "v3" },
14
14
  })
@@ -130,6 +130,16 @@ export const define = <Options = undefined>(
130
130
  // Metadata helper
131
131
  // ---------------------------------------------------------------------------
132
132
 
133
+ /**
134
+ * Which Effect version a rule applies to.
135
+ *
136
+ * Stored on `meta.docs.effectVersion` so preset authors can filter rules
137
+ * per project. Default `both`, meaning the rule is version-agnostic.
138
+ *
139
+ * @since 0.2.0
140
+ */
141
+ export type EffectVersion = 'v3' | 'v4' | 'both';
142
+
133
143
  /**
134
144
  * Build `RuleMeta` with sensible defaults.
135
145
  *
@@ -142,6 +152,7 @@ export const meta = (opts: {
142
152
  readonly hasSuggestions?: boolean | undefined;
143
153
  readonly messages?: Record<string, string> | undefined;
144
154
  readonly docs?: RuleDocs | undefined;
155
+ readonly effectVersion?: EffectVersion | undefined;
145
156
  }): RuleMeta => ({
146
157
  type: opts.type,
147
158
  ...(opts.fixable !== undefined ? { fixable: opts.fixable } : {}),
@@ -151,6 +162,7 @@ export const meta = (opts: {
151
162
  ...(opts.messages !== undefined ? { messages: opts.messages } : {}),
152
163
  docs: {
153
164
  description: opts.description,
165
+ effectVersion: opts.effectVersion ?? 'both',
154
166
  ...opts.docs
155
167
  }
156
168
  });
@@ -172,7 +184,7 @@ export const banMember = (
172
184
  opts: {
173
185
  readonly message: string;
174
186
  readonly meta?:
175
- | { readonly type?: 'problem' | 'suggestion' }
187
+ | { readonly type?: 'problem' | 'suggestion'; readonly effectVersion?: EffectVersion }
176
188
  | undefined;
177
189
  }
178
190
  ): CreateRule =>
@@ -180,7 +192,8 @@ export const banMember = (
180
192
  name: `ban-${obj}-${P.isString(prop) ? prop : Arr.join(prop, '-')}`,
181
193
  meta: meta({
182
194
  type: opts.meta?.type ?? 'suggestion',
183
- description: opts.message
195
+ description: opts.message,
196
+ effectVersion: opts.meta?.effectVersion
184
197
  }),
185
198
  create: function* () {
186
199
  const ctx = yield* RuleContext;
@@ -216,7 +229,7 @@ export const banImport = (
216
229
  opts: {
217
230
  readonly message: string;
218
231
  readonly meta?:
219
- | { readonly type?: 'problem' | 'suggestion' }
232
+ | { readonly type?: 'problem' | 'suggestion'; readonly effectVersion?: EffectVersion }
220
233
  | undefined;
221
234
  }
222
235
  ): CreateRule =>
@@ -224,7 +237,8 @@ export const banImport = (
224
237
  name: 'ban-import',
225
238
  meta: meta({
226
239
  type: opts.meta?.type ?? 'suggestion',
227
- description: opts.message
240
+ description: opts.message,
241
+ effectVersion: opts.meta?.effectVersion
228
242
  }),
229
243
  create: function* () {
230
244
  const ctx = yield* RuleContext;
@@ -270,7 +284,7 @@ export const banCallOf = (
270
284
  opts: {
271
285
  readonly message: string;
272
286
  readonly meta?:
273
- | { readonly type?: 'problem' | 'suggestion' }
287
+ | { readonly type?: 'problem' | 'suggestion'; readonly effectVersion?: EffectVersion }
274
288
  | undefined;
275
289
  }
276
290
  ): CreateRule => {
@@ -279,7 +293,8 @@ export const banCallOf = (
279
293
  name: `ban-call-${Arr.join(names, '-')}`,
280
294
  meta: meta({
281
295
  type: opts.meta?.type ?? 'suggestion',
282
- description: opts.message
296
+ description: opts.message,
297
+ effectVersion: opts.meta?.effectVersion
283
298
  }),
284
299
  create: function* () {
285
300
  const ctx = yield* RuleContext;
@@ -327,7 +342,7 @@ export const banNewExpr = (
327
342
  opts: {
328
343
  readonly message: string;
329
344
  readonly meta?:
330
- | { readonly type?: 'problem' | 'suggestion' }
345
+ | { readonly type?: 'problem' | 'suggestion'; readonly effectVersion?: EffectVersion }
331
346
  | undefined;
332
347
  }
333
348
  ): CreateRule => {
@@ -336,7 +351,8 @@ export const banNewExpr = (
336
351
  name: `ban-new-${Arr.join(names, '-')}`,
337
352
  meta: meta({
338
353
  type: opts.meta?.type ?? 'suggestion',
339
- description: opts.message
354
+ description: opts.message,
355
+ effectVersion: opts.meta?.effectVersion
340
356
  }),
341
357
  create: function* () {
342
358
  const ctx = yield* RuleContext;
@@ -372,7 +388,7 @@ export const banStatement = (
372
388
  opts: {
373
389
  readonly message: string;
374
390
  readonly meta?:
375
- | { readonly type?: 'problem' | 'suggestion' }
391
+ | { readonly type?: 'problem' | 'suggestion'; readonly effectVersion?: EffectVersion }
376
392
  | undefined;
377
393
  }
378
394
  ): CreateRule =>
@@ -380,7 +396,8 @@ export const banStatement = (
380
396
  name: `ban-${nodeType}`,
381
397
  meta: meta({
382
398
  type: opts.meta?.type ?? 'suggestion',
383
- description: opts.message
399
+ description: opts.message,
400
+ effectVersion: opts.meta?.effectVersion
384
401
  }),
385
402
  create: function* () {
386
403
  const ctx = yield* RuleContext;
@@ -427,7 +444,9 @@ export type BanSpec =
427
444
  */
428
445
  export const banMultiple = (config: {
429
446
  readonly name: string;
430
- readonly meta?: { readonly type?: 'problem' | 'suggestion' } | undefined;
447
+ readonly meta?:
448
+ | { readonly type?: 'problem' | 'suggestion'; readonly effectVersion?: EffectVersion }
449
+ | undefined;
431
450
  readonly specs: ReadonlyArray<BanSpec>;
432
451
  }): CreateRule => {
433
452
  const description = config.specs.map((s) => s.message).join(' ');
@@ -435,7 +454,8 @@ export const banMultiple = (config: {
435
454
  name: config.name,
436
455
  meta: meta({
437
456
  type: config.meta?.type ?? 'suggestion',
438
- description
457
+ description,
458
+ effectVersion: config.meta?.effectVersion
439
459
  }),
440
460
  create: function* () {
441
461
  const ctx = yield* RuleContext;
@@ -8,7 +8,6 @@ import {
8
8
  noEffectBind,
9
9
  noOptionAs,
10
10
  noRuntimeRunFork,
11
- noRunInEffect,
12
11
  } from "../src/rules/index.js"
13
12
 
14
13
  // --- API ban rules ---
@@ -67,19 +66,3 @@ describe("noRuntimeRunFork", () => {
67
66
  })
68
67
  })
69
68
 
70
- describe("noRunInEffect", () => {
71
- test("reports Effect.runSync", () => {
72
- const result = Testing.runRule(noRunInEffect, "MemberExpression", Testing.memberExpr("Effect", "runSync"))
73
- expect(result.length).toBe(1)
74
- })
75
-
76
- test("reports Effect.runPromise", () => {
77
- const result = Testing.runRule(noRunInEffect, "MemberExpression", Testing.memberExpr("Effect", "runPromise"))
78
- expect(result.length).toBe(1)
79
- })
80
-
81
- test("ignores Effect.gen", () => {
82
- const result = Testing.runRule(noRunInEffect, "MemberExpression", Testing.memberExpr("Effect", "gen"))
83
- Testing.expectNoDiagnostics(result)
84
- })
85
- })
@@ -7,6 +7,7 @@ import {
7
7
  noNewPromise,
8
8
  noNewError,
9
9
  noReturnNullish,
10
+ noRunInEffectGen,
10
11
  } from "../src/rules/index.js"
11
12
 
12
13
  // Helper: simulate entering and exiting Effect.gen context
@@ -196,3 +197,67 @@ describe("noGlobals", () => {
196
197
  Testing.expectNoDiagnostics(result)
197
198
  })
198
199
  })
200
+
201
+ describe("noRunInEffectGen", () => {
202
+ test("reports Effect.runPromise inside Effect.gen", () => {
203
+ const result = Testing.runRuleMulti(noRunInEffectGen, [
204
+ ["CallExpression", effectGenNode],
205
+ ["CallExpression", Testing.callOfMember("Effect", "runPromise", [Testing.id("x")])],
206
+ ["CallExpression:exit", effectGenNode],
207
+ ])
208
+ expect(result.length).toBe(1)
209
+ })
210
+
211
+ test("reports Effect.runFork inside Effect.fn", () => {
212
+ const effectFnNode = Testing.callOfMember("Effect", "fn", [])
213
+ const result = Testing.runRuleMulti(noRunInEffectGen, [
214
+ ["CallExpression", effectFnNode],
215
+ ["CallExpression", Testing.callOfMember("Effect", "runFork", [Testing.id("x")])],
216
+ ["CallExpression:exit", effectFnNode],
217
+ ])
218
+ expect(result.length).toBe(1)
219
+ })
220
+
221
+ test("reports Runtime.runFork inside Effect.gen", () => {
222
+ const result = Testing.runRuleMulti(noRunInEffectGen, [
223
+ ["CallExpression", effectGenNode],
224
+ ["CallExpression", Testing.callOfMember("Runtime", "runFork", [Testing.id("x")])],
225
+ ["CallExpression:exit", effectGenNode],
226
+ ])
227
+ expect(result.length).toBe(1)
228
+ })
229
+
230
+ test("ignores Effect.runForkWith inside Effect.gen (legit v4 callback boundary)", () => {
231
+ const result = Testing.runRuleMulti(noRunInEffectGen, [
232
+ ["CallExpression", effectGenNode],
233
+ ["CallExpression", Testing.callOfMember("Effect", "runForkWith", [Testing.id("services")])],
234
+ ["CallExpression:exit", effectGenNode],
235
+ ])
236
+ Testing.expectNoDiagnostics(result)
237
+ })
238
+
239
+ test("ignores managedRuntime.runFork on identifier callee", () => {
240
+ const result = Testing.runRuleMulti(noRunInEffectGen, [
241
+ ["CallExpression", effectGenNode],
242
+ ["CallExpression", Testing.callOfMember("managedRuntime", "runFork", [Testing.id("x")])],
243
+ ["CallExpression:exit", effectGenNode],
244
+ ])
245
+ Testing.expectNoDiagnostics(result)
246
+ })
247
+
248
+ test("ignores Effect.runPromise outside Effect.gen", () => {
249
+ const result = Testing.runRuleMulti(noRunInEffectGen, [
250
+ ["CallExpression", Testing.callOfMember("Effect", "runPromise", [Testing.id("x")])],
251
+ ])
252
+ Testing.expectNoDiagnostics(result)
253
+ })
254
+
255
+ test("ignores yield* child effect inside Effect.gen", () => {
256
+ const result = Testing.runRuleMulti(noRunInEffectGen, [
257
+ ["CallExpression", effectGenNode],
258
+ ["CallExpression", Testing.callOfMember("Effect", "succeed", [Testing.id("x")])],
259
+ ["CallExpression:exit", effectGenNode],
260
+ ])
261
+ Testing.expectNoDiagnostics(result)
262
+ })
263
+ })
@@ -1,18 +0,0 @@
1
- /**
2
- * Ban `Effect.runSync`, `Effect.runPromise`, `Effect.runFork`, `Effect.runCallback`.
3
- *
4
- * Inside Effect context, use Runtime or Effect.services pattern.
5
- *
6
- * Source: language-service/runEffectInsideEffect
7
- */
8
- import { Rule } from "../vendor/effect-oxlint/index.js"
9
-
10
- export const noRunInEffect = Rule.banMember(
11
- "Effect",
12
- ["runSync", "runPromise", "runFork", "runCallback"],
13
- {
14
- message:
15
- "Avoid Effect.run* calls. Use Effect.services (v4) or Runtime pattern instead.",
16
- meta: { type: "problem" },
17
- },
18
- )