oxlint-plugin-effect 0.14.0 → 0.15.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.
Files changed (60) hide show
  1. package/README.md +2 -1
  2. package/dist/presets/recommended.d.ts +1 -0
  3. package/dist/presets/recommended.js +1 -0
  4. package/dist/rules/_anti-slop-array-method.js +41 -33
  5. package/dist/rules/_anti-slop-dictionary-types.js +172 -129
  6. package/dist/rules/_anti-slop-function-parameters.js +9 -6
  7. package/dist/rules/_anti-slop-lexical-type-parameters.js +6 -14
  8. package/dist/rules/_anti-slop-reflect-method.js +7 -3
  9. package/dist/rules/_anti-slop-scope.js +8 -6
  10. package/dist/rules/_anti-slop-type-alias-resolution.js +58 -65
  11. package/dist/rules/_ast-ancestors.js +14 -0
  12. package/dist/rules/_ast-children.js +18 -0
  13. package/dist/rules/_call-arguments.js +11 -0
  14. package/dist/rules/_effect-context.js +25 -20
  15. package/dist/rules/_function-metrics.js +63 -44
  16. package/dist/rules/_tagged-control-flow.js +38 -60
  17. package/dist/rules/_test-apis.js +47 -0
  18. package/dist/rules/index.d.ts +2 -1
  19. package/dist/rules/index.js +2 -0
  20. package/dist/rules/no-array-filter-map.js +24 -14
  21. package/dist/rules/no-chained-type-assertions.js +7 -4
  22. package/dist/rules/no-conditional-empty-object-spread.js +7 -4
  23. package/dist/rules/no-dynamic-imports.js +33 -25
  24. package/dist/rules/no-eager-acquire.js +40 -34
  25. package/dist/rules/no-effect-run-in-tests.js +11 -8
  26. package/dist/rules/no-globals.js +42 -31
  27. package/dist/rules/no-known-value-widening.js +64 -39
  28. package/dist/rules/no-module-level-mutable-state.js +4 -8
  29. package/dist/rules/no-module-mocks.js +37 -48
  30. package/dist/rules/no-new-promise.js +6 -2
  31. package/dist/rules/no-node-builtin-import.js +56 -33
  32. package/dist/rules/no-nullish.js +5 -1
  33. package/dist/rules/no-object-parameters.js +9 -7
  34. package/dist/rules/no-per-call-cache-construction.js +2 -2
  35. package/dist/rules/no-reduce-accumulator-copy.js +65 -53
  36. package/dist/rules/no-reflect-apply.js +7 -4
  37. package/dist/rules/no-reflect-get.js +7 -4
  38. package/dist/rules/no-run-collect-on-unbounded-stream.js +23 -25
  39. package/dist/rules/no-runtime-typeof.js +9 -5
  40. package/dist/rules/no-sequential-effect-all.js +18 -11
  41. package/dist/rules/no-shape-in-symbol-names.js +10 -6
  42. package/dist/rules/no-silent-catch-all.js +20 -14
  43. package/dist/rules/no-test-globals.d.ts +6 -0
  44. package/dist/rules/no-test-globals.js +147 -0
  45. package/dist/rules/no-test-lifecycle-hooks.js +4 -2
  46. package/dist/rules/no-unbounded-concurrency.js +22 -17
  47. package/dist/rules/no-unbounded-retry.js +47 -62
  48. package/dist/rules/no-unknown-parameters.js +5 -3
  49. package/dist/rules/no-unknown-returns.js +11 -10
  50. package/dist/rules/no-unknown-type-aliases.js +13 -9
  51. package/dist/rules/no-unsafe-dictionary-type.js +22 -27
  52. package/dist/rules/no-widen-then-assert.js +95 -72
  53. package/dist/rules/prefer-catch-tag.js +29 -37
  54. package/dist/rules/prefer-match-tags-exhaustive.js +35 -47
  55. package/dist/rules/prefer-predicate-is-tagged.js +4 -6
  56. package/dist/rules/prefer-service-of.js +39 -26
  57. package/dist/rules/require-named-effect-fn.js +4 -2
  58. package/dist/rules/require-suppression-reason.js +8 -3
  59. package/dist/vendor/effect-oxlint/Scope.js +1 -1
  60. package/package.json +1 -1
@@ -1,79 +1,57 @@
1
+ import { ancestors } from "./_ast-ancestors.js";
2
+ import * as Arr from "effect/Array";
3
+ import * as Option from "effect/Option";
4
+ import * as Predicate from "effect/Predicate";
1
5
  //#region src/rules/_tagged-control-flow.ts
2
6
  const expressionKey = (node) => {
3
- if (node.type === "Identifier") return node.name;
4
- if (node.type === "ThisExpression") return "this";
5
- if (node.type !== "MemberExpression" || node.computed || node.property.type !== "Identifier") return null;
6
- const object = expressionKey(node.object);
7
- if (object === null) return null;
8
- return `${object}.${node.property.name}`;
7
+ if (node.type === "Identifier") return Option.some(node.name);
8
+ if (node.type === "ThisExpression") return Option.some("this");
9
+ if (node.type !== "MemberExpression" || node.computed || node.property.type !== "Identifier") return Option.none();
10
+ const property = node.property.name;
11
+ return Option.map(expressionKey(node.object), (object) => `${object}.${property}`);
9
12
  };
10
13
  const stringLiteral = (node) => {
11
- if (node.type !== "Literal") return null;
12
- if (typeof node.value !== "string") return null;
13
- return node.value;
14
+ if (node.type !== "Literal" || !Predicate.isString(node.value)) return Option.none();
15
+ return Option.some(node.value);
14
16
  };
15
17
  const taggedSubject = (node) => {
16
- if (node.type !== "MemberExpression" || node.computed || node.property.type !== "Identifier" || node.property.name !== "_tag") return null;
18
+ if (node.type !== "MemberExpression" || node.computed || node.property.type !== "Identifier" || node.property.name !== "_tag") return Option.none();
17
19
  return expressionKey(node.object);
18
20
  };
21
+ /** `subjectSide._tag === "Tag"` read from one orientation of the comparison. */
22
+ const orientedComparison = (subjectSide, tagSide) => Option.all({
23
+ subject: taggedSubject(subjectSide),
24
+ tag: stringLiteral(tagSide)
25
+ });
19
26
  const tagComparison = (node) => {
20
- if (node.type !== "BinaryExpression" || node.operator !== "===") return null;
21
- const leftSubject = taggedSubject(node.left);
22
- const rightTag = stringLiteral(node.right);
23
- if (leftSubject !== null && rightTag !== null) return {
24
- subject: leftSubject,
25
- tag: rightTag
26
- };
27
- const rightSubject = taggedSubject(node.right);
28
- const leftTag = stringLiteral(node.left);
29
- if (rightSubject === null || leftTag === null) return null;
30
- return {
31
- subject: rightSubject,
32
- tag: leftTag
33
- };
27
+ if (node.type !== "BinaryExpression" || node.operator !== "===") return Option.none();
28
+ const { left, right } = node;
29
+ return Option.orElse(orientedComparison(left, right), () => orientedComparison(right, left));
34
30
  };
35
31
  const tagComparisonsInOr = (node) => {
36
- if (node.type === "LogicalExpression" && node.operator === "||") {
37
- const left = tagComparisonsInOr(node.left);
38
- const right = tagComparisonsInOr(node.right);
39
- if (left === null || right === null) return null;
40
- return [...left, ...right];
41
- }
42
- const comparison = tagComparison(node);
43
- if (comparison === null) return null;
44
- return [comparison];
45
- };
46
- const hasOneTaggedSubject = (comparisons) => {
47
- const first = comparisons[0];
48
- if (first === void 0 || comparisons.length < 2) return false;
49
- return comparisons.every((comparison) => comparison.subject === first.subject);
32
+ if (node.type === "LogicalExpression" && node.operator === "||") return Option.zipWith(tagComparisonsInOr(node.left), tagComparisonsInOr(node.right), (left, right) => [...left, ...right]);
33
+ return Option.map(tagComparison(node), (comparison) => [comparison]);
50
34
  };
35
+ const hasOneTaggedSubject = (comparisons) => comparisons.length >= 2 && Option.exists(Arr.head(comparisons), (first) => comparisons.every((comparison) => comparison.subject === first.subject));
51
36
  const isEffectCall = (node, operation) => node.callee.type !== "Super" && node.callee.type === "MemberExpression" && !node.callee.computed && node.callee.object.type === "Identifier" && node.callee.object.name === "Effect" && node.callee.property.type === "Identifier" && node.callee.property.name === operation;
52
- const isFunction = (node) => node?.type === "ArrowFunctionExpression" || node?.type === "FunctionExpression";
53
- const effectCallbackLocation = (node, operation) => {
54
- let current = node.parent;
55
- while (current !== void 0 && current !== null) {
56
- if (isFunction(current)) {
57
- const call = current.parent;
58
- if (call?.type !== "CallExpression" || !isEffectCall(call, operation)) return null;
59
- const index = call.arguments.indexOf(current);
60
- if (index < 0) return null;
61
- return {
62
- argumentCount: call.arguments.length,
63
- index
64
- };
65
- }
66
- current = current.parent;
67
- }
68
- return null;
69
- };
70
- const isInsideCatchAllHandler = (node) => {
71
- const location = effectCallbackLocation(node, "catchAll");
72
- if (location === null) return false;
37
+ const isFunction = (node) => node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression";
38
+ const callbackLocation = (callback, operation) => {
39
+ const call = callback.parent;
40
+ if (call?.type !== "CallExpression" || !isEffectCall(call, operation)) return Option.none();
41
+ const index = call.arguments.indexOf(callback);
42
+ if (index < 0) return Option.none();
43
+ return Option.some({
44
+ argumentCount: call.arguments.length,
45
+ index
46
+ });
47
+ };
48
+ /** Where the nearest enclosing function sits among the arguments of an `Effect.<operation>` call. */
49
+ const effectCallbackLocation = (node, operation) => Option.flatMap(Arr.findFirst(ancestors(node), isFunction), (callback) => callbackLocation(callback, operation));
50
+ const isInsideCatchAllHandler = (node) => Option.exists(effectCallbackLocation(node, "catchAll"), (location) => {
73
51
  let handlerIndex = 1;
74
52
  if (location.argumentCount === 1) handlerIndex = 0;
75
53
  return location.index === handlerIndex;
76
- };
54
+ });
77
55
  const taggedSwitchSubject = (node) => taggedSubject(node.discriminant);
78
56
  //#endregion
79
57
  export { effectCallbackLocation, hasOneTaggedSubject, isEffectCall, isInsideCatchAllHandler, tagComparison, tagComparisonsInOr, taggedSwitchSubject };
@@ -0,0 +1,47 @@
1
+ import { importSource, narrow } from "../vendor/effect-oxlint/AST.js";
2
+ import { findVariableUp } from "../vendor/effect-oxlint/Scope.js";
3
+ import "../vendor/effect-oxlint/index.js";
4
+ import * as Option from "effect/Option";
5
+ //#region src/rules/_test-apis.ts
6
+ const bindingsBySource = new Map([
7
+ ["vitest", new Map([["vi", "vi"]])],
8
+ ["@jest/globals", new Map([["jest", "jest"]])],
9
+ ["bun:test", new Map([
10
+ ["jest", "jest"],
11
+ ["mock", "mock"],
12
+ ["spyOn", "spyOn"],
13
+ ["vi", "vi"]
14
+ ])]
15
+ ]);
16
+ /** Runner objects that exist as ambient globals when no import provides them. */
17
+ const globalBinding = (name) => {
18
+ if (name === "jest" || name === "vi") return Option.some(name);
19
+ return Option.none();
20
+ };
21
+ const identifierName = (node) => {
22
+ if (node.type === "Identifier") return Option.some(node.name);
23
+ return Option.none();
24
+ };
25
+ /** Record each named import of a runner binding under its local name. */
26
+ const collectTestBindings = (node, bindings) => {
27
+ const declaration = narrow(node, "ImportDeclaration");
28
+ if (Option.isNone(declaration)) return;
29
+ const bySource = Option.fromUndefinedOr(bindingsBySource.get(importSource(declaration.value)));
30
+ if (Option.isNone(bySource)) return;
31
+ for (const specifier of declaration.value.specifiers) {
32
+ if (specifier.type !== "ImportSpecifier") continue;
33
+ const binding = Option.flatMap(identifierName(specifier.imported), (imported) => Option.fromUndefinedOr(bySource.value.get(imported)));
34
+ if (Option.isSome(binding)) bindings.set(specifier.local.name, binding.value);
35
+ }
36
+ };
37
+ const bindingFromVariable = (variable, bindings) => {
38
+ if (variable.defs.some((definition) => definition.type === "ImportBinding")) return Option.fromUndefinedOr(bindings.get(variable.name));
39
+ return Option.none();
40
+ };
41
+ /** The runner binding `name` refers to at `scope`: an imported binding, or the unshadowed global. */
42
+ const resolveTestBinding = (scope, name, bindings) => Option.match(findVariableUp(scope, name), {
43
+ onNone: () => globalBinding(name),
44
+ onSome: (variable) => Option.orElse(bindingFromVariable(variable, bindings), () => Option.filter(globalBinding(name), () => variable.defs.length === 0))
45
+ });
46
+ //#endregion
47
+ export { collectTestBindings, resolveTestBinding };
@@ -32,6 +32,7 @@ import { noSequentialEffectAll } from "./no-sequential-effect-all.js";
32
32
  import { noShapeInSymbolNames } from "./no-shape-in-symbol-names.js";
33
33
  import { noSilentCatchAll } from "./no-silent-catch-all.js";
34
34
  import { noTernary } from "./no-ternary.js";
35
+ import { noTestGlobals } from "./no-test-globals.js";
35
36
  import { noTestLifecycleHooks } from "./no-test-lifecycle-hooks.js";
36
37
  import { noThrowStatement } from "./no-throw-statement.js";
37
38
  import { noTryCatch } from "./no-try-catch.js";
@@ -52,7 +53,7 @@ import { requireSuppressionReason } from "./require-suppression-reason.js";
52
53
 
53
54
  //#region src/rules/index.d.ts
54
55
  declare namespace index_d_exports {
55
- export { maxCognitiveComplexity, maxHalsteadDifficulty, noArrayFilterMap, noAs, noAsyncFunction, noChainedTypeAssertions, noConditionalEmptyObjectSpread, noDynamicImports, noEagerAcquire, noEffectBind, noEffectDo, noEffectRunInTests, noGlobals, noInlineProvide, noKnownValueWidening, noManagedRuntimeInEffect, noModuleLevelMutableState, noModuleMocks, noNestedEffectGen, noNewError, noNewPromise, noNodeBuiltinImport, noNullish, noObjectParameters, noPerCallCacheConstruction, noReduceAccumulatorCopy, noReflectApply, noReflectGet, noRunCollectOnUnboundedStream, noRuntimeTypeof, noSequentialEffectAll, noShapeInSymbolNames, noSilentCatchAll, noTernary, noTestLifecycleHooks, noThrowStatement, noTryCatch, noUnboundedConcurrency, noUnboundedRetry, noUnknownParameters, noUnknownReturns, noUnknownTypeAliases, noUnsafeDictionaryType, noWidenThenAssert, preferCatchTag, preferEffectFn, preferMatchTagsExhaustive, preferPredicateIsTagged, preferServiceOf, requireNamedEffectFn, requireSuppressionReason };
56
+ export { maxCognitiveComplexity, maxHalsteadDifficulty, noArrayFilterMap, noAs, noAsyncFunction, noChainedTypeAssertions, noConditionalEmptyObjectSpread, noDynamicImports, noEagerAcquire, noEffectBind, noEffectDo, noEffectRunInTests, noGlobals, noInlineProvide, noKnownValueWidening, noManagedRuntimeInEffect, noModuleLevelMutableState, noModuleMocks, noNestedEffectGen, noNewError, noNewPromise, noNodeBuiltinImport, noNullish, noObjectParameters, noPerCallCacheConstruction, noReduceAccumulatorCopy, noReflectApply, noReflectGet, noRunCollectOnUnboundedStream, noRuntimeTypeof, noSequentialEffectAll, noShapeInSymbolNames, noSilentCatchAll, noTernary, noTestGlobals, noTestLifecycleHooks, noThrowStatement, noTryCatch, noUnboundedConcurrency, noUnboundedRetry, noUnknownParameters, noUnknownReturns, noUnknownTypeAliases, noUnsafeDictionaryType, noWidenThenAssert, preferCatchTag, preferEffectFn, preferMatchTagsExhaustive, preferPredicateIsTagged, preferServiceOf, requireNamedEffectFn, requireSuppressionReason };
56
57
  }
57
58
  //#endregion
58
59
  export { index_d_exports };
@@ -33,6 +33,7 @@ import { noSequentialEffectAll } from "./no-sequential-effect-all.js";
33
33
  import { noShapeInSymbolNames } from "./no-shape-in-symbol-names.js";
34
34
  import { noSilentCatchAll } from "./no-silent-catch-all.js";
35
35
  import { noTernary } from "./no-ternary.js";
36
+ import { noTestGlobals } from "./no-test-globals.js";
36
37
  import { noTestLifecycleHooks } from "./no-test-lifecycle-hooks.js";
37
38
  import { noThrowStatement } from "./no-throw-statement.js";
38
39
  import { noTryCatch } from "./no-try-catch.js";
@@ -86,6 +87,7 @@ var rules_exports = /* @__PURE__ */ __exportAll({
86
87
  noShapeInSymbolNames: () => noShapeInSymbolNames,
87
88
  noSilentCatchAll: () => noSilentCatchAll,
88
89
  noTernary: () => noTernary,
90
+ noTestGlobals: () => noTestGlobals,
89
91
  noTestLifecycleHooks: () => noTestLifecycleHooks,
90
92
  noThrowStatement: () => noThrowStatement,
91
93
  noTryCatch: () => noTryCatch,
@@ -3,8 +3,25 @@ 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
5
  import { arrayMethodTarget, isKnownArrayExpression, unwrapArrayExpression } from "./_anti-slop-array-method.js";
6
+ import * as Option from "effect/Option";
6
7
  import * as Effect from "effect/Effect";
7
8
  //#region src/rules/no-array-filter-map.ts
9
+ const pairedMethod = (name) => {
10
+ if (name === "map") return Option.some("filter");
11
+ if (name === "filter") return Option.some("map");
12
+ return Option.none();
13
+ };
14
+ function filterMapPair(sourceCode, node) {
15
+ return Option.flatMap(arrayMethodTarget(node.callee), (outer) => Option.flatMap(pairedMethod(outer.name), (pairedName) => {
16
+ const innerCall = unwrapArrayExpression(outer.object);
17
+ if (innerCall.type !== "CallExpression") return Option.none();
18
+ const inner = Option.filter(arrayMethodTarget(innerCall.callee), (method) => method.name === pairedName && isKnownArrayExpression(sourceCode, method.object));
19
+ return Option.map(inner, (method) => ({
20
+ first: method.name,
21
+ second: outer.name
22
+ }));
23
+ }));
24
+ }
8
25
  /** Reject eager array filter/map pipelines; lazy iterator helpers remain allowed. */
9
26
  const noArrayFilterMap = define({
10
27
  name: "no-array-filter-map",
@@ -15,24 +32,17 @@ const noArrayFilterMap = define({
15
32
  }),
16
33
  create: function* () {
17
34
  const context = yield* RuleContext;
18
- return { CallExpression: (node) => {
19
- const outer = arrayMethodTarget(node.callee);
20
- if (outer === null || outer.name !== "map" && outer.name !== "filter") return Effect.void;
21
- const innerCall = unwrapArrayExpression(outer.object);
22
- if (innerCall.type !== "CallExpression") return Effect.void;
23
- const inner = arrayMethodTarget(innerCall.callee);
24
- const pairedName = outer.name === "map" ? "filter" : "map";
25
- if (inner === null || inner.name !== pairedName) return Effect.void;
26
- if (!isKnownArrayExpression(context.sourceCode, inner.object)) return Effect.void;
27
- return context.report(fromId({
35
+ return { CallExpression: (node) => Option.match(filterMapPair(context.sourceCode, node), {
36
+ onNone: () => Effect.void,
37
+ onSome: ({ first, second }) => context.report(fromId({
28
38
  node,
29
39
  messageId: "arrayFilterMap",
30
40
  data: {
31
- first: inner.name,
32
- second: outer.name
41
+ first,
42
+ second
33
43
  }
34
- }));
35
- } };
44
+ }))
45
+ }) };
36
46
  }
37
47
  });
38
48
  //#endregion
@@ -46,10 +46,13 @@ const noChainedTypeAssertions = define({
46
46
  }),
47
47
  create: function* () {
48
48
  const context = yield* RuleContext;
49
- const checkTypeAssertion = (node) => isOutermostAssertionInChain(node) && isForbiddenAssertionChain(node) ? context.report(fromId({
50
- node,
51
- messageId: "chained"
52
- })) : Effect.void;
49
+ const checkTypeAssertion = (node) => {
50
+ if (!isOutermostAssertionInChain(node) || !isForbiddenAssertionChain(node)) return Effect.void;
51
+ return context.report(fromId({
52
+ node,
53
+ messageId: "chained"
54
+ }));
55
+ };
53
56
  return {
54
57
  TSAsExpression: checkTypeAssertion,
55
58
  TSTypeAssertion: checkTypeAssertion
@@ -26,10 +26,13 @@ const noConditionalEmptyObjectSpread = define({
26
26
  }),
27
27
  create: function* () {
28
28
  const context = yield* RuleContext;
29
- return { SpreadElement: (node) => node.parent?.type === "ObjectExpression" && isConditionalEmptyObjectSpread(node.argument) ? context.report(fromId({
30
- node,
31
- messageId: "avoid"
32
- })) : Effect.void };
29
+ return { SpreadElement: (node) => {
30
+ if (node.parent?.type !== "ObjectExpression" || !isConditionalEmptyObjectSpread(node.argument)) return Effect.void;
31
+ return context.report(fromId({
32
+ node,
33
+ messageId: "avoid"
34
+ }));
35
+ } };
33
36
  }
34
37
  });
35
38
  //#endregion
@@ -3,14 +3,20 @@ import { make } from "../vendor/effect-oxlint/Diagnostic.js";
3
3
  import { RuleContext } from "../vendor/effect-oxlint/RuleContext.js";
4
4
  import { define, meta } from "../vendor/effect-oxlint/Rule.js";
5
5
  import "../vendor/effect-oxlint/index.js";
6
+ import { ancestors, parentOf } from "./_ast-ancestors.js";
6
7
  import * as Option from "effect/Option";
8
+ import * as Predicate from "effect/Predicate";
7
9
  import * as Effect from "effect/Effect";
8
10
  //#region src/rules/no-dynamic-imports.ts
9
- const identifierName = (node) => node?.type === "Identifier" ? node.name : void 0;
11
+ const identifierName = (node) => {
12
+ if (node.type === "Identifier") return Option.some(node.name);
13
+ return Option.none();
14
+ };
15
+ const isIdentifierNamed = (node, name) => Option.contains(identifierName(node), name);
10
16
  const hasNamedBinding = (pattern) => {
11
17
  if (pattern.type === "Identifier") return true;
12
18
  if (pattern.type === "ObjectPattern") return pattern.properties.length > 0;
13
- if (pattern.type === "ArrayPattern") return pattern.elements.some((element) => element !== null);
19
+ if (pattern.type === "ArrayPattern") return pattern.elements.some(Predicate.isNotNull);
14
20
  return false;
15
21
  };
16
22
  const isNamedVariable = (node, value) => node.type === "VariableDeclarator" && node.init === value && hasNamedBinding(node.id);
@@ -22,42 +28,45 @@ const transparentWrappers = new Set([
22
28
  "TSTypeAssertion",
23
29
  "YieldExpression"
24
30
  ]);
31
+ /** The outermost node reached from `start` through transparent wrapper ancestors. */
25
32
  const climbTransparent = (start) => {
26
33
  let current = start;
27
- while (current.parent != null && transparentWrappers.has(current.parent.type)) current = current.parent;
34
+ for (const parent of ancestors(start)) {
35
+ if (!transparentWrappers.has(parent.type)) break;
36
+ current = parent;
37
+ }
28
38
  return current;
29
39
  };
30
40
  const isNamedDirectBinding = (node) => {
31
41
  const value = climbTransparent(node);
32
- return value.parent != null && isNamedVariable(value.parent, value);
42
+ return Option.exists(parentOf(value), (parent) => isNamedVariable(parent, value));
33
43
  };
44
+ const isNamedFunctionDeclarationBody = (block) => block.type === "BlockStatement" && Option.exists(parentOf(block), (fn) => fn.type === "FunctionDeclaration" && Predicate.isNotNull(fn.id));
34
45
  const isNamedFunctionBoundary = (node) => {
35
46
  const parent = node.parent;
36
- if (parent?.type === "ArrowFunctionExpression" && parent.body === node) return parent.parent != null && isNamedVariable(parent.parent, parent);
47
+ if (parent?.type === "ArrowFunctionExpression" && parent.body === node) return Option.exists(parentOf(parent), (declarator) => isNamedVariable(declarator, parent));
37
48
  if (parent?.type !== "ReturnStatement" || parent.argument !== node) return false;
38
- const block = parent.parent;
39
- const fn = block?.parent;
40
- return block?.type === "BlockStatement" && fn?.type === "FunctionDeclaration" && fn.id !== null;
49
+ return Option.exists(parentOf(parent), isNamedFunctionDeclarationBody);
41
50
  };
42
51
  const isEffectPromiseCall = (node) => {
43
52
  if (node.type !== "CallExpression") return false;
44
53
  const callee = node.callee;
45
54
  if (callee.type !== "MemberExpression" || callee.computed) return false;
46
- return identifierName(callee.object) === "Effect" && ["promise", "tryPromise"].includes(identifierName(callee.property) ?? "");
55
+ return isIdentifierNamed(callee.object, "Effect") && Option.exists(identifierName(callee.property), (name) => ["promise", "tryPromise"].includes(name));
47
56
  };
48
57
  const isEffectPromiseBoundary = (node) => {
49
58
  const callback = node.parent;
50
59
  if (callback?.type !== "ArrowFunctionExpression" || callback.body !== node) return false;
51
- const call = callback.parent;
52
- return call != null && isEffectPromiseCall(call);
60
+ return Option.exists(parentOf(callback), isEffectPromiseCall);
53
61
  };
54
62
  const isNamedLazyBoundary = (node) => {
55
63
  return isNamedDirectBinding(node) || isNamedFunctionBoundary(node) || isEffectPromiseBoundary(node);
56
64
  };
57
65
  const dynamicRequireMessage = (callee) => {
58
- if (identifierName(callee) === "require") return "Avoid require(). Use a static import.";
59
- if (callee?.type !== "MemberExpression") return void 0;
60
- return identifierName(callee.object) === "module" && identifierName(callee.property) === "require" ? "Avoid module.require(). Use a static import." : void 0;
66
+ if (isIdentifierNamed(callee, "require")) return Option.some("Avoid require(). Use a static import.");
67
+ if (callee.type !== "MemberExpression") return Option.none();
68
+ if (isIdentifierNamed(callee.object, "module") && isIdentifierNamed(callee.property, "require")) return Option.some("Avoid module.require(). Use a static import.");
69
+ return Option.none();
61
70
  };
62
71
  const noDynamicImports = define({
63
72
  name: "no-dynamic-imports",
@@ -78,7 +87,7 @@ const noDynamicImports = define({
78
87
  onNone: () => Effect.void,
79
88
  onSome: (declaration) => {
80
89
  if (!["module", "node:module"].includes(importSource(declaration))) return Effect.void;
81
- for (const specifier of declaration.specifiers) if (specifier.type === "ImportSpecifier" && identifierName(specifier.imported) === "createRequire") createRequireNames.add(specifier.local.name);
90
+ for (const specifier of declaration.specifiers) if (specifier.type === "ImportSpecifier" && isIdentifierNamed(specifier.imported, "createRequire")) createRequireNames.add(specifier.local.name);
82
91
  return Effect.void;
83
92
  }
84
93
  }),
@@ -94,21 +103,20 @@ const noDynamicImports = define({
94
103
  }),
95
104
  ImportExpression: (node) => Option.match(narrow(node, "ImportExpression"), {
96
105
  onNone: () => Effect.void,
97
- onSome: (importExpression) => isNamedLazyBoundary(importExpression) ? Effect.void : ctx.report(make({
98
- node,
99
- message: "Avoid inline dynamic imports. Bind the imported module or a lazy loader to a descriptive name before using it."
100
- }))
106
+ onSome: (importExpression) => {
107
+ if (isNamedLazyBoundary(importExpression)) return Effect.void;
108
+ return report(node, "Avoid inline dynamic imports. Bind the imported module or a lazy loader to a descriptive name before using it.");
109
+ }
101
110
  }),
102
111
  CallExpression: (node) => {
103
112
  return Option.match(narrow(node, "CallExpression"), {
104
113
  onNone: () => Effect.void,
105
114
  onSome: (call) => {
106
- const calleeName = identifierName(call.callee);
107
- const message = calleeName !== void 0 && requireAliases.has(calleeName) ? "Avoid createRequire aliases. Keep module loading static." : dynamicRequireMessage(call.callee);
108
- return message === void 0 ? Effect.void : ctx.report(make({
109
- node: call,
110
- message
111
- }));
115
+ const aliasMessage = Option.map(Option.filter(identifierName(call.callee), (name) => requireAliases.has(name)), () => "Avoid createRequire aliases. Keep module loading static.");
116
+ return Option.match(Option.orElse(aliasMessage, () => dynamicRequireMessage(call.callee)), {
117
+ onNone: () => Effect.void,
118
+ onSome: (message) => report(call, message)
119
+ });
112
120
  }
113
121
  });
114
122
  }
@@ -3,8 +3,12 @@ import { RuleContext } from "../vendor/effect-oxlint/RuleContext.js";
3
3
  import { define, meta } from "../vendor/effect-oxlint/Rule.js";
4
4
  import { findVariableUp } from "../vendor/effect-oxlint/Scope.js";
5
5
  import "../vendor/effect-oxlint/index.js";
6
+ import { ancestors } from "./_ast-ancestors.js";
7
+ import { expressionArgument } from "./_call-arguments.js";
6
8
  import { importedNamespaces, isStaticCall, visibleNamespaces } from "./_effect-namespaces.js";
9
+ import * as Arr from "effect/Array";
7
10
  import * as Option from "effect/Option";
11
+ import * as Predicate from "effect/Predicate";
8
12
  import * as Effect from "effect/Effect";
9
13
  //#region src/rules/no-eager-acquire.ts
10
14
  const unwrapExpression = (node) => {
@@ -13,21 +17,19 @@ const unwrapExpression = (node) => {
13
17
  return current;
14
18
  };
15
19
  const isWithin = (node, ancestor) => {
16
- let current = node;
17
- while (current !== null) {
18
- if (current === ancestor) return true;
19
- current = current.parent;
20
- }
20
+ if (node === ancestor) return true;
21
+ for (const current of ancestors(node)) if (current === ancestor) return true;
21
22
  return false;
22
23
  };
24
+ const isThunk = (node) => node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression";
25
+ /** The value a thunk returns, when its body is an expression or holds exactly one return. */
23
26
  const singleReturnedValue = (thunk) => {
24
27
  const body = thunk.body;
25
- if (body === null) return void 0;
26
- if (body.type !== "BlockStatement") return body;
28
+ if (Predicate.isNull(body)) return Option.none();
29
+ if (body.type !== "BlockStatement") return Option.some(body);
27
30
  const returns = body.body.filter((statement) => statement.type === "ReturnStatement");
28
- const [returned] = returns;
29
- if (returns.length !== 1 || returned === void 0) return void 0;
30
- return returned.argument ?? void 0;
31
+ if (returns.length !== 1) return Option.none();
32
+ return Option.flatMap(Arr.head(returns), (returned) => Option.fromNullOr(returned.argument));
31
33
  };
32
34
  const noEagerAcquire = define({
33
35
  name: "no-eager-acquire",
@@ -46,26 +48,27 @@ const noEagerAcquire = define({
46
48
  if (value.type !== "Identifier") return false;
47
49
  return Option.match(findVariableUp(ctx.sourceCode.getScope(value), value.name), {
48
50
  onNone: () => true,
49
- onSome: (variable) => {
50
- const [definition] = variable.defs;
51
- if (definition === void 0) return true;
52
- if (seen.has(definition.node)) return false;
53
- seen.add(definition.node);
54
- if (!isWithin(definition.node, thunk)) return true;
55
- if (definition.node.type === "VariableDeclarator") return definition.node.init !== null && isCaptured(definition.node.init, thunk, seen);
56
- const defaulted = thunk.params.find((parameter) => parameter.type === "AssignmentPattern" && parameter.left.type === "Identifier" && parameter.left.name === value.name);
57
- return defaulted !== void 0 && isCaptured(defaulted.right, thunk, seen);
58
- }
51
+ onSome: (variable) => Option.match(Arr.head(variable.defs), {
52
+ onNone: () => true,
53
+ onSome: (definition) => isCapturedDefinition(definition.node, value.name, thunk, seen)
54
+ })
59
55
  });
60
56
  };
57
+ /** Whether a binding's definition holds a value that exists before the thunk runs. */
58
+ const isCapturedDefinition = (definition, name, thunk, seen) => {
59
+ if (seen.has(definition)) return false;
60
+ seen.add(definition);
61
+ if (!isWithin(definition, thunk)) return true;
62
+ if (definition.type === "VariableDeclarator") {
63
+ const init = definition.init;
64
+ return Predicate.isNotNull(init) && isCaptured(init, thunk, seen);
65
+ }
66
+ return Option.exists(Arr.findFirst(thunk.params, (parameter) => parameter.type === "AssignmentPattern" && parameter.left.type === "Identifier" && parameter.left.name === name), (defaulted) => isCaptured(defaulted.right, thunk, seen));
67
+ };
61
68
  const returnsCapturedHandle = (expression, effects) => {
62
69
  if (!isStaticCall(expression, effects, "sync")) return false;
63
- const [argument] = expression.arguments;
64
- if (argument === void 0 || argument.type === "SpreadElement") return false;
65
- const thunk = unwrapExpression(argument);
66
- if (thunk.type !== "ArrowFunctionExpression" && thunk.type !== "FunctionExpression") return false;
67
- const returned = singleReturnedValue(thunk);
68
- return returned !== void 0 && isCaptured(returned, thunk, /* @__PURE__ */ new Set());
70
+ const thunk = Option.filter(Option.map(expressionArgument(expression, 0), unwrapExpression), isThunk);
71
+ return Option.exists(thunk, (lazy) => Option.exists(singleReturnedValue(lazy), (returned) => isCaptured(returned, lazy, /* @__PURE__ */ new Set())));
69
72
  };
70
73
  return {
71
74
  ImportDeclaration: (node) => {
@@ -76,14 +79,17 @@ const noEagerAcquire = define({
76
79
  if (node.callee.type === "Super") return Effect.void;
77
80
  const effects = visibleNamespaces(ctx, node, effectNamespaces);
78
81
  if (!isStaticCall(node, effects, "acquireRelease")) return Effect.void;
79
- const [acquire] = node.arguments;
80
- if (acquire === void 0 || acquire.type === "SpreadElement") return Effect.void;
81
- const expression = unwrapExpression(acquire);
82
- if (!isStaticCall(expression, effects, "succeed") && !returnsCapturedHandle(expression, effects)) return Effect.void;
83
- return ctx.report(make({
84
- node: acquire,
85
- message: "Build the resource inside acquire (Effect.sync(() => new Resource()), Effect.tryPromise, or another lazy Effect). Effect.succeed(handle) or Effect.sync(() => handle) creates it before acquire runs, so an interruption in between leaks it."
86
- }));
82
+ const eagerAcquire = Option.filter(expressionArgument(node, 0), (acquire) => {
83
+ const expression = unwrapExpression(acquire);
84
+ return isStaticCall(expression, effects, "succeed") || returnsCapturedHandle(expression, effects);
85
+ });
86
+ return Option.match(eagerAcquire, {
87
+ onNone: () => Effect.void,
88
+ onSome: (acquire) => ctx.report(make({
89
+ node: acquire,
90
+ message: "Build the resource inside acquire (Effect.sync(() => new Resource()), Effect.tryPromise, or another lazy Effect). Effect.succeed(handle) or Effect.sync(() => handle) creates it before acquire runs, so an interruption in between leaks it."
91
+ }))
92
+ });
87
93
  }
88
94
  };
89
95
  }
@@ -4,6 +4,8 @@ import { define, meta } from "../vendor/effect-oxlint/Rule.js";
4
4
  import "../vendor/effect-oxlint/index.js";
5
5
  import { importedNamespaces, isStaticMember, visibleNamespaces } from "./_effect-namespaces.js";
6
6
  import { isTestFile, skipFile } from "./_test-files.js";
7
+ import * as Arr from "effect/Array";
8
+ import * as Option from "effect/Option";
7
9
  import * as Effect from "effect/Effect";
8
10
  //#region src/rules/no-effect-run-in-tests.ts
9
11
  const effectRunners = [
@@ -32,9 +34,9 @@ const noEffectRunInTests = define({
32
34
  const effectNamespaces = new Set(["Effect"]);
33
35
  const managedRuntimeNamespaces = new Set(["ManagedRuntime"]);
34
36
  const runnerName = (node) => {
35
- if (isStaticMember(node, visibleNamespaces(ctx, node, managedRuntimeNamespaces), "make")) return "ManagedRuntime.make";
37
+ if (isStaticMember(node, visibleNamespaces(ctx, node, managedRuntimeNamespaces), "make")) return Option.some("ManagedRuntime.make");
36
38
  const effects = visibleNamespaces(ctx, node, effectNamespaces);
37
- return effectRunners.find((runner) => isStaticMember(node, effects, runner));
39
+ return Arr.findFirst(effectRunners, (runner) => isStaticMember(node, effects, runner));
38
40
  };
39
41
  return {
40
42
  ImportDeclaration: (node) => {
@@ -43,12 +45,13 @@ const noEffectRunInTests = define({
43
45
  return Effect.void;
44
46
  },
45
47
  MemberExpression: (node) => {
46
- const runner = runnerName(node);
47
- if (runner === void 0) return Effect.void;
48
- return ctx.report(make({
49
- node,
50
- message: `Do not run Effects by hand in tests (${runner}). Use the test runner's Effect integration: it.effect(...) or it.layer(layer)(...) from @effect/vitest or effect-bun-test.`
51
- }));
48
+ return Option.match(runnerName(node), {
49
+ onNone: () => Effect.void,
50
+ onSome: (runner) => ctx.report(make({
51
+ node,
52
+ message: `Do not run Effects by hand in tests (${runner}). Use the test runner's Effect integration: it.effect(...) or it.layer(layer)(...) from @effect/vitest or effect-bun-test.`
53
+ }))
54
+ });
52
55
  }
53
56
  };
54
57
  }