oxlint-plugin-effect 0.16.0 → 0.17.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/README.md CHANGED
@@ -67,8 +67,8 @@ Every recommended rule has `error` severity. The complexity rules carry their li
67
67
  | `effect/noEffectBind` | Bans Effect.bind |
68
68
  | `effect/preferCatchTag` | Replaces manual `_tag` predicates and `catchAll` dispatch with tagged recovery |
69
69
  | `effect/preferEffectFn` | Requires `Effect.fn` for a generator operation that adds a span |
70
- | `effect/preferMatchTagsExhaustive` | Requires exhaustive `Match` for return-only `_tag` switches and if chains |
71
- | `effect/preferPredicateIsTagged` | Replaces combined `_tag` comparisons with a named `Predicate` refinement |
70
+ | `effect/preferMatchTagsExhaustive` | Requires exhaustive `Match` for return-only `_tag` switches and if chains in Effect files |
71
+ | `effect/preferPredicateIsTagged` | Replaces combined `_tag` comparisons with a named `Predicate` refinement in Effect files |
72
72
  | `effect/preferServiceOf` | Checks inline Layer implementations through `Service.of` |
73
73
  | `effect/requireNamedEffectFn` | Requires stable names for `Effect.fn` operations |
74
74
  | `effect/requireSuppressionReason` | Requires lint, Effect, and TS suppressions to name their target and give a `--` reason |
@@ -0,0 +1,35 @@
1
+ import { findVariableUp } from "../vendor/effect-oxlint/Scope.js";
2
+ import "../vendor/effect-oxlint/index.js";
3
+ import * as Option from "effect/Option";
4
+ //#region src/rules/_const-bindings.ts
5
+ /** Strips type-only wrappers such as `as const`, `satisfies`, and `!`. */
6
+ const unwrapTypeSyntax = (node) => {
7
+ let current = node;
8
+ while (current.type === "ParenthesizedExpression" || current.type === "TSAsExpression" || current.type === "TSTypeAssertion" || current.type === "TSNonNullExpression" || current.type === "TSSatisfiesExpression") current = current.expression;
9
+ return current;
10
+ };
11
+ /** The initializer of a single, never reassigned const binding. */
12
+ const constInitializer = (ctx, node) => Option.flatMap(findVariableUp(ctx.sourceCode.getScope(node), node.name), (variable) => {
13
+ const definition = variable.defs[0];
14
+ if (variable.defs.length !== 1 || definition?.node.type !== "VariableDeclarator") return Option.none();
15
+ const declarator = definition.node;
16
+ if (declarator.parent.type !== "VariableDeclaration" || declarator.parent.kind !== "const") return Option.none();
17
+ return Option.fromNullishOr(declarator.init);
18
+ });
19
+ /**
20
+ * A value held in a const is checked like the inline value, through type-only wrappers.
21
+ * Unresolved identifiers, such as parameters, stay as they are. `seen` stops a self-referential chain.
22
+ */
23
+ const constResolver = (ctx) => {
24
+ const resolveWith = (seen) => (expression) => {
25
+ const node = unwrapTypeSyntax(expression);
26
+ if (node.type !== "Identifier" || seen.has(node)) return node;
27
+ return Option.match(constInitializer(ctx, node), {
28
+ onNone: () => node,
29
+ onSome: (init) => resolveWith(new Set([...seen, node]))(init)
30
+ });
31
+ };
32
+ return resolveWith(/* @__PURE__ */ new Set());
33
+ };
34
+ //#endregion
35
+ export { constResolver };
@@ -21,5 +21,10 @@ const visibleNamespaces = (ctx, node, namespaces) => new Set([...namespaces].fil
21
21
  })));
22
22
  const isStaticMember = (node, namespaces, property) => node.type === "MemberExpression" && !node.computed && node.object.type === "Identifier" && namespaces.has(node.object.name) && node.property.type === "Identifier" && node.property.name === property;
23
23
  const isStaticCall = (node, namespaces, property) => node.type === "CallExpression" && node.callee.type !== "Super" && isStaticMember(node.callee, namespaces, property);
24
+ /** Whether an import loads the effect package, one of its subpaths, or an @effect package. */
25
+ const isEffectPackageImport = (node) => {
26
+ const source = node.source.value;
27
+ return source === "effect" || source.startsWith("effect/") || source.startsWith("@effect/");
28
+ };
24
29
  //#endregion
25
- export { importedNamespaces, isStaticCall, isStaticMember, visibleNamespaces };
30
+ export { importedNamespaces, isEffectPackageImport, isStaticCall, isStaticMember, visibleNamespaces };
@@ -4,14 +4,19 @@ import { define, meta } from "../vendor/effect-oxlint/Rule.js";
4
4
  import "../vendor/effect-oxlint/index.js";
5
5
  import { expressionArgument, staticProperties } from "./_call-arguments.js";
6
6
  import { importedNamespaces, isStaticMember, visibleNamespaces } from "./_effect-namespaces.js";
7
+ import { constResolver } from "./_const-bindings.js";
7
8
  import * as Arr from "effect/Array";
8
9
  import * as Option from "effect/Option";
9
10
  import * as Effect from "effect/Effect";
10
11
  //#region src/rules/no-unbounded-concurrency.ts
11
- /** The first `concurrency: "unbounded"` value in an options object literal. */
12
- const unboundedConcurrency = (node) => {
13
- if (node.type !== "ObjectExpression") return Option.none();
14
- return Option.map(Arr.findFirst(staticProperties(node, "concurrency"), (property) => property.value.type === "Literal" && property.value.value === "unbounded"), (property) => property.value);
12
+ /** Whether an options object, inline or held in a const, sets `concurrency: "unbounded"`. */
13
+ const isUnboundedConcurrency = (argument, resolve) => {
14
+ const options = resolve(argument);
15
+ if (options.type !== "ObjectExpression") return false;
16
+ return staticProperties(options, "concurrency").some((property) => {
17
+ const value = resolve(property.value);
18
+ return value.type === "Literal" && value.value === "unbounded";
19
+ });
15
20
  };
16
21
  const fixedCollection = (node) => node.type === "ArrayExpression" || node.type === "ObjectExpression";
17
22
  /** Where `Effect.all` and `Effect.forEach` take their options. */
@@ -29,6 +34,7 @@ const noUnboundedConcurrency = define({
29
34
  create: function* () {
30
35
  const ctx = yield* RuleContext;
31
36
  const effectNamespaces = new Set(["Effect"]);
37
+ const resolve = constResolver(ctx);
32
38
  return {
33
39
  ImportDeclaration: (node) => {
34
40
  if (node.type !== "ImportDeclaration") return Effect.void;
@@ -39,14 +45,11 @@ const noUnboundedConcurrency = define({
39
45
  if (node.type !== "CallExpression" || node.callee.type === "Super") return Effect.void;
40
46
  const namespaces = visibleNamespaces(ctx, node, effectNamespaces);
41
47
  if (Option.exists(Arr.head(node.arguments), fixedCollection)) return Effect.void;
42
- const unbounded = Option.flatMap(optionsIndex(node.callee, namespaces), (index) => Option.flatMap(expressionArgument(node, index), unboundedConcurrency));
43
- return Option.match(unbounded, {
44
- onNone: () => Effect.void,
45
- onSome: (value) => ctx.report(make({
46
- node: value,
47
- message: "Bound concurrency for a collection that can grow. Use a finite concurrency value."
48
- }))
49
- });
48
+ if (!Option.exists(optionsIndex(node.callee, namespaces), (index) => Option.exists(expressionArgument(node, index), (options) => isUnboundedConcurrency(options, resolve)))) return Effect.void;
49
+ return ctx.report(make({
50
+ node,
51
+ message: "Bound concurrency for a collection that can grow. Use a finite concurrency value."
52
+ }));
50
53
  }
51
54
  };
52
55
  }
@@ -4,6 +4,7 @@ import { define, meta } from "../vendor/effect-oxlint/Rule.js";
4
4
  import "../vendor/effect-oxlint/index.js";
5
5
  import { expressionArgument, staticProperties } from "./_call-arguments.js";
6
6
  import { importedNamespaces, isStaticCall, isStaticMember, visibleNamespaces } from "./_effect-namespaces.js";
7
+ import { constResolver } from "./_const-bindings.js";
7
8
  import * as Arr from "effect/Array";
8
9
  import * as Option from "effect/Option";
9
10
  import * as Effect from "effect/Effect";
@@ -18,21 +19,25 @@ const staticPropertyName = (node) => {
18
19
  if (node.type !== "MemberExpression" || node.computed || node.property.type !== "Identifier") return Option.none();
19
20
  return Option.some(node.property.name);
20
21
  };
21
- const isBoundedOperation = (node, scheduleNamespaces) => {
22
+ const isBoundedOperation = (argument, scheduleNamespaces, resolve) => {
23
+ if (argument.type === "SpreadElement") return false;
24
+ const node = resolve(argument);
22
25
  if (node.type !== "CallExpression" || node.callee.type === "Super") return false;
23
26
  const callee = node.callee;
24
27
  if (Option.exists(staticPropertyName(callee), (property) => boundedCombinators.has(property) && isStaticMember(callee, scheduleNamespaces, property))) return true;
25
28
  if (!isStaticMember(callee, scheduleNamespaces, "both")) return false;
26
- return Option.exists(expressionArgument(node, 0), (bound) => isStaticCall(bound, scheduleNamespaces, "recurs"));
29
+ return Option.exists(expressionArgument(node, 0), (bound) => isStaticCall(resolve(bound), scheduleNamespaces, "recurs"));
27
30
  };
28
- const unboundedSchedule = (node, scheduleNamespaces) => {
29
- if (node.type === "MemberExpression" && isStaticMember(node, scheduleNamespaces, "forever")) return Option.some(node);
30
- if (node.type !== "CallExpression" || node.callee.type === "Super") return Option.none();
31
+ const isUnboundedSchedule = (expression, scheduleNamespaces, resolve) => {
32
+ const node = resolve(expression);
33
+ if (node.type === "MemberExpression" && isStaticMember(node, scheduleNamespaces, "forever")) return true;
34
+ if (node.type !== "CallExpression" || node.callee.type === "Super") return false;
31
35
  const callee = node.callee;
32
36
  const property = staticPropertyName(callee);
33
- if (Option.exists(property, (name) => unboundedConstructors.has(name) && isStaticMember(callee, scheduleNamespaces, name))) return Option.some(node);
34
- if (!Option.contains(property, "pipe") || callee.type !== "MemberExpression") return Option.none();
35
- return Option.filter(unboundedSchedule(callee.object, scheduleNamespaces), () => !node.arguments.some((operation) => isBoundedOperation(operation, scheduleNamespaces)));
37
+ if (Option.exists(property, (name) => unboundedConstructors.has(name) && isStaticMember(callee, scheduleNamespaces, name))) return true;
38
+ if (!Option.contains(property, "pipe") || callee.type !== "MemberExpression") return false;
39
+ if (callee.object.type === "Super") return false;
40
+ return isUnboundedSchedule(callee.object, scheduleNamespaces, resolve) && !node.arguments.some((operation) => isBoundedOperation(operation, scheduleNamespaces, resolve));
36
41
  };
37
42
  /** Arguments a data-last retry call takes; one more means the data-first form. */
38
43
  const retryArity = (callee, effectNamespaces, streamNamespaces, httpClientNamespaces) => {
@@ -48,16 +53,17 @@ const policyArgument = (node, arity) => {
48
53
  return Arr.get(node.arguments, policyIndex);
49
54
  };
50
55
  /** The schedule a policy argument retries on, unless it sets `times`. */
51
- const policySchedule = (policy) => {
52
- if (policy.type === "SpreadElement") return Option.none();
56
+ const policySchedule = (argument, resolve) => {
57
+ if (argument.type === "SpreadElement") return Option.none();
58
+ const policy = resolve(argument);
53
59
  if (policy.type !== "ObjectExpression") return Option.some(policy);
54
60
  if (Arr.isReadonlyArrayNonEmpty(staticProperties(policy, "times"))) return Option.none();
55
61
  return Option.map(Arr.head(staticProperties(policy, "schedule")), (property) => property.value);
56
62
  };
57
- const retryPolicy = (node, effectNamespaces, streamNamespaces, httpClientNamespaces) => {
63
+ const retryPolicy = (node, effectNamespaces, streamNamespaces, httpClientNamespaces, resolve) => {
58
64
  if (node.callee.type === "Super") return Option.none();
59
65
  const arity = retryArity(node.callee, effectNamespaces, streamNamespaces, httpClientNamespaces);
60
- return Option.flatMap(Option.flatMap(arity, (count) => policyArgument(node, count)), policySchedule);
66
+ return Option.flatMap(Option.flatMap(arity, (count) => policyArgument(node, count)), (policy) => policySchedule(policy, resolve));
61
67
  };
62
68
  const noUnboundedRetry = define({
63
69
  name: "no-unbounded-retry",
@@ -71,6 +77,7 @@ const noUnboundedRetry = define({
71
77
  const httpClientNamespaces = new Set(["HttpClient"]);
72
78
  const scheduleNamespaces = new Set(["Schedule"]);
73
79
  const streamNamespaces = new Set(["Stream"]);
80
+ const resolve = constResolver(ctx);
74
81
  return {
75
82
  ImportDeclaration: (node) => {
76
83
  if (node.type !== "ImportDeclaration") return Effect.void;
@@ -82,15 +89,13 @@ const noUnboundedRetry = define({
82
89
  },
83
90
  CallExpression: (node) => {
84
91
  if (node.type !== "CallExpression") return Effect.void;
85
- const policy = retryPolicy(node, visibleNamespaces(ctx, node, effectNamespaces), visibleNamespaces(ctx, node, streamNamespaces), visibleNamespaces(ctx, node, httpClientNamespaces));
86
- const unbounded = Option.flatMap(policy, (schedule) => unboundedSchedule(schedule, visibleNamespaces(ctx, node, scheduleNamespaces)));
87
- return Option.match(unbounded, {
88
- onNone: () => Effect.void,
89
- onSome: (schedule) => ctx.report(make({
90
- node: schedule,
91
- message: "Bound retry attempts or elapsed time. Add times or compose the schedule with Schedule.recurs or Schedule.take."
92
- }))
93
- });
92
+ const policy = retryPolicy(node, visibleNamespaces(ctx, node, effectNamespaces), visibleNamespaces(ctx, node, streamNamespaces), visibleNamespaces(ctx, node, httpClientNamespaces), resolve);
93
+ const scheduleNamespacesInScope = visibleNamespaces(ctx, node, scheduleNamespaces);
94
+ if (!Option.exists(policy, (schedule) => isUnboundedSchedule(schedule, scheduleNamespacesInScope, resolve))) return Effect.void;
95
+ return ctx.report(make({
96
+ node,
97
+ message: "Bound retry attempts or elapsed time. Add times or compose the schedule with Schedule.recurs or Schedule.take."
98
+ }));
94
99
  }
95
100
  };
96
101
  }
@@ -2,6 +2,7 @@ import { make } from "../vendor/effect-oxlint/Diagnostic.js";
2
2
  import { RuleContext } from "../vendor/effect-oxlint/RuleContext.js";
3
3
  import { define, meta } from "../vendor/effect-oxlint/Rule.js";
4
4
  import "../vendor/effect-oxlint/index.js";
5
+ import { isEffectPackageImport } from "./_effect-namespaces.js";
5
6
  import { hasOneTaggedSubject, isInsideCatchAllHandler, tagComparison, taggedSwitchSubject } from "./_tagged-control-flow.js";
6
7
  import * as Arr from "effect/Array";
7
8
  import * as Option from "effect/Option";
@@ -66,16 +67,21 @@ const preferMatchTagsExhaustive = define({
66
67
  }),
67
68
  create: function* () {
68
69
  const context = yield* RuleContext;
70
+ const effectImports = /* @__PURE__ */ new Set();
69
71
  return {
72
+ ImportDeclaration: (node) => {
73
+ if (isEffectPackageImport(node)) effectImports.add(node.source.value);
74
+ return Effect.void;
75
+ },
70
76
  SwitchStatement: (node) => {
71
- if (!isCompleteSwitchTransformation(node) || isInsideCatchAllHandler(node)) return Effect.void;
77
+ if (effectImports.size === 0 || !isCompleteSwitchTransformation(node) || isInsideCatchAllHandler(node)) return Effect.void;
72
78
  return context.report(make({
73
79
  node,
74
80
  message: "Use Match.type with Match.tagsExhaustive so a new tagged variant requires a new case."
75
81
  }));
76
82
  },
77
83
  IfStatement: (node) => {
78
- if (!isCompleteIfTransformation(node) && !isCompleteSequentialIfTransformation(node) || isInsideCatchAllHandler(node)) return Effect.void;
84
+ if (effectImports.size === 0 || !isCompleteIfTransformation(node) && !isCompleteSequentialIfTransformation(node) || isInsideCatchAllHandler(node)) return Effect.void;
79
85
  return context.report(make({
80
86
  node,
81
87
  message: "Use Match.type with Match.tagsExhaustive so a new tagged variant requires a new branch."
@@ -2,6 +2,7 @@ import { make } from "../vendor/effect-oxlint/Diagnostic.js";
2
2
  import { RuleContext } from "../vendor/effect-oxlint/RuleContext.js";
3
3
  import { define, meta } from "../vendor/effect-oxlint/Rule.js";
4
4
  import "../vendor/effect-oxlint/index.js";
5
+ import { isEffectPackageImport } from "./_effect-namespaces.js";
5
6
  import { effectCallbackLocation, hasOneTaggedSubject, tagComparisonsInOr } from "./_tagged-control-flow.js";
6
7
  import * as Option from "effect/Option";
7
8
  import * as Effect from "effect/Effect";
@@ -19,16 +20,23 @@ const preferPredicateIsTagged = define({
19
20
  }),
20
21
  create: function* () {
21
22
  const context = yield* RuleContext;
22
- return { LogicalExpression: (node) => {
23
- if (node.operator !== "||") return Effect.void;
24
- if (node.parent?.type === "LogicalExpression" && node.parent.operator === "||") return Effect.void;
25
- if (isCatchIfPredicate(node)) return Effect.void;
26
- if (!Option.exists(tagComparisonsInOr(node), hasOneTaggedSubject)) return Effect.void;
27
- return context.report(make({
28
- node,
29
- message: "Use a named Predicate refinement composed from Predicate.isTagged for multiple tags."
30
- }));
31
- } };
23
+ const effectImports = /* @__PURE__ */ new Set();
24
+ return {
25
+ ImportDeclaration: (node) => {
26
+ if (isEffectPackageImport(node)) effectImports.add(node.source.value);
27
+ return Effect.void;
28
+ },
29
+ LogicalExpression: (node) => {
30
+ if (effectImports.size === 0 || node.operator !== "||") return Effect.void;
31
+ if (node.parent?.type === "LogicalExpression" && node.parent.operator === "||") return Effect.void;
32
+ if (isCatchIfPredicate(node)) return Effect.void;
33
+ if (!Option.exists(tagComparisonsInOr(node), hasOneTaggedSubject)) return Effect.void;
34
+ return context.report(make({
35
+ node,
36
+ message: "Use a named Predicate refinement composed from Predicate.isTagged for multiple tags."
37
+ }));
38
+ }
39
+ };
32
40
  }
33
41
  });
34
42
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxlint-plugin-effect",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "oxlint plugin with Effect-specific lint rules",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -51,8 +51,8 @@
51
51
  "lint:fix": "oxlint --fix",
52
52
  "fmt": "oxfmt",
53
53
  "fmt:check": "oxfmt --check",
54
- "test": "bun run build && bun test",
55
- "gate": "bun run build && concurrently -n type,style,self,generated,test -c blue,yellow,cyan,magenta,green \"bun run typecheck\" \"bun run lint\" \"bun run lint:self\" \"bun run codegen:check\" \"bun test\"",
54
+ "test": "bun run build && bun test --timeout=30000",
55
+ "gate": "bun run build && concurrently -n type,style,self,generated,test -c blue,yellow,cyan,magenta,green \"bun run typecheck\" \"bun run lint\" \"bun run lint:self\" \"bun run codegen:check\" \"bun test --timeout=30000\"",
56
56
  "codegen": "bun run scripts/codegen.ts",
57
57
  "codegen:check": "bun run scripts/codegen.ts --check",
58
58
  "add-rule": "bun run scripts/add-rule.ts",