oxlint-plugin-effect 0.13.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 (74) hide show
  1. package/README.md +26 -14
  2. package/THIRD_PARTY_NOTICES.md +4 -2
  3. package/dist/presets/recommended.d.ts +10 -0
  4. package/dist/presets/recommended.js +11 -1
  5. package/dist/rules/_anti-slop-array-method.js +78 -0
  6. package/dist/rules/_anti-slop-dictionary-types.js +198 -150
  7. package/dist/rules/_anti-slop-function-parameters.js +30 -0
  8. package/dist/rules/_anti-slop-lexical-type-parameters.js +25 -0
  9. package/dist/rules/_anti-slop-reflect-method.js +25 -0
  10. package/dist/rules/_anti-slop-scope.js +15 -0
  11. package/dist/rules/_anti-slop-type-alias-resolution.js +139 -0
  12. package/dist/rules/_ast-ancestors.js +14 -0
  13. package/dist/rules/_ast-children.js +18 -0
  14. package/dist/rules/_call-arguments.js +11 -0
  15. package/dist/rules/_effect-context.js +25 -20
  16. package/dist/rules/_function-metrics.js +94 -75
  17. package/dist/rules/_tagged-control-flow.js +38 -60
  18. package/dist/rules/_test-apis.js +47 -0
  19. package/dist/rules/_test-files.js +8 -0
  20. package/dist/rules/index.d.ts +11 -1
  21. package/dist/rules/index.js +21 -1
  22. package/dist/rules/no-array-filter-map.d.ts +7 -0
  23. package/dist/rules/no-array-filter-map.js +49 -0
  24. package/dist/rules/no-chained-type-assertions.js +7 -4
  25. package/dist/rules/no-conditional-empty-object-spread.d.ts +1 -1
  26. package/dist/rules/no-conditional-empty-object-spread.js +7 -47
  27. package/dist/rules/no-dynamic-imports.js +33 -25
  28. package/dist/rules/no-eager-acquire.d.ts +6 -0
  29. package/dist/rules/no-eager-acquire.js +98 -0
  30. package/dist/rules/no-effect-run-in-tests.d.ts +6 -0
  31. package/dist/rules/no-effect-run-in-tests.js +60 -0
  32. package/dist/rules/no-globals.js +42 -31
  33. package/dist/rules/no-known-value-widening.js +67 -50
  34. package/dist/rules/no-module-level-mutable-state.d.ts +6 -0
  35. package/dist/rules/no-module-level-mutable-state.js +47 -0
  36. package/dist/rules/no-module-mocks.js +37 -48
  37. package/dist/rules/no-new-promise.js +6 -2
  38. package/dist/rules/no-node-builtin-import.js +56 -33
  39. package/dist/rules/no-nullish.js +5 -1
  40. package/dist/rules/no-object-parameters.js +16 -30
  41. package/dist/rules/no-per-call-cache-construction.js +2 -2
  42. package/dist/rules/no-reduce-accumulator-copy.d.ts +7 -0
  43. package/dist/rules/no-reduce-accumulator-copy.js +106 -0
  44. package/dist/rules/no-reflect-apply.d.ts +7 -0
  45. package/dist/rules/no-reflect-apply.js +28 -0
  46. package/dist/rules/no-reflect-get.d.ts +7 -0
  47. package/dist/rules/no-reflect-get.js +28 -0
  48. package/dist/rules/no-run-collect-on-unbounded-stream.js +23 -25
  49. package/dist/rules/no-runtime-typeof.js +21 -4
  50. package/dist/rules/no-sequential-effect-all.js +18 -11
  51. package/dist/rules/no-shape-in-symbol-names.js +15 -5
  52. package/dist/rules/no-silent-catch-all.js +20 -14
  53. package/dist/rules/no-test-globals.d.ts +6 -0
  54. package/dist/rules/no-test-globals.js +147 -0
  55. package/dist/rules/no-test-lifecycle-hooks.js +4 -2
  56. package/dist/rules/no-unbounded-concurrency.js +22 -17
  57. package/dist/rules/no-unbounded-retry.js +47 -62
  58. package/dist/rules/no-unknown-parameters.d.ts +1 -1
  59. package/dist/rules/no-unknown-parameters.js +26 -17
  60. package/dist/rules/no-unknown-returns.d.ts +7 -0
  61. package/dist/rules/no-unknown-returns.js +74 -0
  62. package/dist/rules/no-unknown-type-aliases.js +21 -27
  63. package/dist/rules/no-unsafe-dictionary-type.js +71 -25
  64. package/dist/rules/no-widen-then-assert.js +96 -73
  65. package/dist/rules/prefer-catch-tag.js +29 -37
  66. package/dist/rules/prefer-match-tags-exhaustive.js +35 -47
  67. package/dist/rules/prefer-predicate-is-tagged.js +4 -6
  68. package/dist/rules/prefer-service-of.js +39 -26
  69. package/dist/rules/require-named-effect-fn.js +4 -2
  70. package/dist/rules/require-suppression-reason.d.ts +6 -0
  71. package/dist/rules/require-suppression-reason.js +92 -0
  72. package/dist/vendor/effect-oxlint/Diagnostic.js +1 -1
  73. package/dist/vendor/effect-oxlint/Scope.js +1 -1
  74. package/package.json +3 -2
@@ -0,0 +1,60 @@
1
+ import { make } from "../vendor/effect-oxlint/Diagnostic.js";
2
+ import { RuleContext } from "../vendor/effect-oxlint/RuleContext.js";
3
+ import { define, meta } from "../vendor/effect-oxlint/Rule.js";
4
+ import "../vendor/effect-oxlint/index.js";
5
+ import { importedNamespaces, isStaticMember, visibleNamespaces } from "./_effect-namespaces.js";
6
+ import { isTestFile, skipFile } from "./_test-files.js";
7
+ import * as Arr from "effect/Array";
8
+ import * as Option from "effect/Option";
9
+ import * as Effect from "effect/Effect";
10
+ //#region src/rules/no-effect-run-in-tests.ts
11
+ const effectRunners = [
12
+ "runCallback",
13
+ "runCallbackWith",
14
+ "runFork",
15
+ "runForkWith",
16
+ "runPromise",
17
+ "runPromiseExit",
18
+ "runPromiseExitWith",
19
+ "runPromiseWith",
20
+ "runSync",
21
+ "runSyncExit",
22
+ "runSyncExitWith",
23
+ "runSyncWith"
24
+ ];
25
+ const noEffectRunInTests = define({
26
+ name: "no-effect-run-in-tests",
27
+ meta: meta({
28
+ type: "problem",
29
+ description: "Run Effects in tests through it.effect or it.layer instead of Effect.run* or ManagedRuntime.make."
30
+ }),
31
+ create: function* () {
32
+ const ctx = yield* RuleContext;
33
+ if (!isTestFile(ctx.filename)) return skipFile;
34
+ const effectNamespaces = new Set(["Effect"]);
35
+ const managedRuntimeNamespaces = new Set(["ManagedRuntime"]);
36
+ const runnerName = (node) => {
37
+ if (isStaticMember(node, visibleNamespaces(ctx, node, managedRuntimeNamespaces), "make")) return Option.some("ManagedRuntime.make");
38
+ const effects = visibleNamespaces(ctx, node, effectNamespaces);
39
+ return Arr.findFirst(effectRunners, (runner) => isStaticMember(node, effects, runner));
40
+ };
41
+ return {
42
+ ImportDeclaration: (node) => {
43
+ for (const name of importedNamespaces(node, "Effect", "effect/Effect")) effectNamespaces.add(name);
44
+ for (const name of importedNamespaces(node, "ManagedRuntime", "effect/ManagedRuntime")) managedRuntimeNamespaces.add(name);
45
+ return Effect.void;
46
+ },
47
+ MemberExpression: (node) => {
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
+ });
55
+ }
56
+ };
57
+ }
58
+ });
59
+ //#endregion
60
+ export { noEffectRunInTests };
@@ -147,7 +147,6 @@ const isUnshadowedGlobal = (ctx, node, name) => Option.match(findVariableUp(ctx.
147
147
  onNone: () => true,
148
148
  onSome: (variable) => variable.defs.length === 0
149
149
  });
150
- const staticMember = (node) => Option.getOrUndefined(memberNames(node));
151
150
  const processStreams = new Set([
152
151
  "stderr",
153
152
  "stdin",
@@ -176,37 +175,49 @@ const noGlobals = define({
176
175
  node,
177
176
  message: `Avoid ${used}. Use ${alternative}; platform adapters may disable this rule explicitly.`
178
177
  }));
179
- return {
180
- MemberExpression: (node) => {
181
- const memberExpression = Option.getOrUndefined(narrow(node, "MemberExpression"));
182
- if (memberExpression === void 0) return Effect.void;
183
- if (isCryptoDigest(memberExpression) && isUnshadowedGlobal(ctx, memberExpression, "crypto")) return report(memberExpression, "crypto.subtle.digest", "Crypto.digest");
184
- const names = staticMember(memberExpression);
185
- if (names === void 0) return Effect.void;
186
- const [object, property] = names;
187
- if (!isUnshadowedGlobal(ctx, memberExpression, object)) return Effect.void;
188
- for (const [bannedObject, properties, alternative] of memberBans) if (object === bannedObject && properties.has(property)) {
189
- if (object === "process" && processStreams.has(property) && isTtyRead(memberExpression)) return Effect.void;
190
- return report(memberExpression, `${object}.${property}`, alternative);
178
+ const reportMember = (memberExpression) => {
179
+ if (isCryptoDigest(memberExpression) && isUnshadowedGlobal(ctx, memberExpression, "crypto")) return report(memberExpression, "crypto.subtle.digest", "Crypto.digest");
180
+ return Option.match(memberNames(memberExpression), {
181
+ onNone: () => Effect.void,
182
+ onSome: ([object, property]) => {
183
+ if (!isUnshadowedGlobal(ctx, memberExpression, object)) return Effect.void;
184
+ for (const [bannedObject, properties, alternative] of memberBans) if (object === bannedObject && properties.has(property)) {
185
+ if (object === "process" && processStreams.has(property) && isTtyRead(memberExpression)) return Effect.void;
186
+ return report(memberExpression, `${object}.${property}`, alternative);
187
+ }
188
+ return Effect.void;
191
189
  }
192
- return Effect.void;
193
- },
194
- CallExpression: (node) => {
195
- const call = Option.getOrUndefined(narrow(node, "CallExpression"));
196
- if (call === void 0) return Effect.void;
197
- const name = Option.getOrUndefined(calleeName(call));
198
- if (name === void 0 || !isUnshadowedGlobal(ctx, call, name)) return Effect.void;
199
- const alternative = callBans.get(name);
200
- return alternative === void 0 ? Effect.void : report(call, `${name}()`, alternative);
201
- },
202
- NewExpression: (node) => {
203
- const expression = Option.getOrUndefined(narrow(node, "NewExpression"));
204
- if (expression === void 0 || expression.callee.type !== "Identifier") return Effect.void;
205
- const name = expression.callee.name;
206
- if (!isUnshadowedGlobal(ctx, expression, name)) return Effect.void;
207
- const alternative = constructorBans.get(name);
208
- return alternative === void 0 ? Effect.void : report(expression, `new ${name}()`, alternative);
209
- }
190
+ });
191
+ };
192
+ const reportCall = (call) => Option.match(Option.filter(calleeName(call), (name) => isUnshadowedGlobal(ctx, call, name)), {
193
+ onNone: () => Effect.void,
194
+ onSome: (name) => Option.match(Option.fromUndefinedOr(callBans.get(name)), {
195
+ onNone: () => Effect.void,
196
+ onSome: (alternative) => report(call, `${name}()`, alternative)
197
+ })
198
+ });
199
+ const reportConstructor = (expression) => {
200
+ if (expression.callee.type !== "Identifier") return Effect.void;
201
+ const name = expression.callee.name;
202
+ if (!isUnshadowedGlobal(ctx, expression, name)) return Effect.void;
203
+ return Option.match(Option.fromUndefinedOr(constructorBans.get(name)), {
204
+ onNone: () => Effect.void,
205
+ onSome: (alternative) => report(expression, `new ${name}()`, alternative)
206
+ });
207
+ };
208
+ return {
209
+ MemberExpression: (node) => Option.match(narrow(node, "MemberExpression"), {
210
+ onNone: () => Effect.void,
211
+ onSome: reportMember
212
+ }),
213
+ CallExpression: (node) => Option.match(narrow(node, "CallExpression"), {
214
+ onNone: () => Effect.void,
215
+ onSome: reportCall
216
+ }),
217
+ NewExpression: (node) => Option.match(narrow(node, "NewExpression"), {
218
+ onNone: () => Effect.void,
219
+ onSome: reportConstructor
220
+ })
210
221
  };
211
222
  }
212
223
  });
@@ -2,56 +2,62 @@ import { fromId } 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 { ancestors } from "./_ast-ancestors.js";
5
6
  import { classifyWideningTarget, createTypeEnvironment, isKnownEvidenceExpression } from "./_anti-slop-dictionary-types.js";
7
+ import { resolveVariable } from "./_anti-slop-scope.js";
8
+ import * as Arr from "effect/Array";
9
+ import * as Option from "effect/Option";
10
+ import * as Predicate from "effect/Predicate";
6
11
  import * as Effect from "effect/Effect";
7
12
  //#region src/rules/no-known-value-widening.ts
8
13
  /**
9
14
  * Ported from dmmulroy/anti-slop at
10
- * b5d2288db1f00469a1d5f2e3b0e265e5a5676fd0.
15
+ * c44ef22ca116d0ba62a3ff663a0bd13a3f3fa40b.
11
16
  */
12
17
  function unwrapExpression(expression) {
13
18
  let current = expression;
14
19
  while (current.type === "ParenthesizedExpression" || current.type === "TSAsExpression" || current.type === "TSSatisfiesExpression" || current.type === "TSTypeAssertion" || current.type === "TSNonNullExpression") current = current.expression;
15
20
  return current;
16
21
  }
17
- function resolveVariable(sourceCode, identifier) {
18
- let scope = sourceCode.getScope(identifier);
19
- while (scope !== null) {
20
- const variable = scope.set.get(identifier.name);
21
- if (variable !== void 0) return variable;
22
- scope = scope.upper;
23
- }
24
- return null;
25
- }
26
22
  function variableDeclarator(variable) {
27
- if (variable.defs.length !== 1) return null;
28
- const [definition] = variable.defs;
29
- return definition?.type === "Variable" && definition.node.type === "VariableDeclarator" ? definition.node : null;
23
+ if (variable.defs.length !== 1) return Option.none();
24
+ return Option.flatMap(Arr.head(variable.defs), (definition) => {
25
+ if (definition.type !== "Variable" || definition.node.type !== "VariableDeclarator") return Option.none();
26
+ return Option.some(definition.node);
27
+ });
30
28
  }
31
29
  function isStableConstVariable(variable, declarator) {
32
30
  return declarator.parent.type === "VariableDeclaration" && declarator.parent.kind === "const" && variable.references.every((reference) => reference.init || !reference.isWrite());
33
31
  }
32
+ /**
33
+ * The initializer of a variable's single const declarator that is never reassigned.
34
+ * (Local change: split from hasKnownEvidence to read the Option-returning helpers.)
35
+ */
36
+ function stableConstInitializer(variable) {
37
+ return Option.flatMap(Option.filter(variableDeclarator(variable), (declarator) => isStableConstVariable(variable, declarator)), (declarator) => Option.fromNullishOr(declarator.init));
38
+ }
34
39
  function hasKnownEvidence(sourceCode, expression, visitedVariables = /* @__PURE__ */ new Set()) {
35
40
  if (isKnownEvidenceExpression(expression)) return true;
36
41
  const unwrapped = unwrapExpression(expression);
37
42
  if (unwrapped.type !== "Identifier") return false;
38
43
  const variable = resolveVariable(sourceCode, unwrapped);
39
- if (variable === null || visitedVariables.has(variable)) return false;
40
- const declarator = variableDeclarator(variable);
41
- if (declarator === null || declarator.init === null || !isStableConstVariable(variable, declarator)) return false;
42
- visitedVariables.add(variable);
43
- return hasKnownEvidence(sourceCode, declarator.init, visitedVariables);
44
+ if (Option.isNone(variable) || visitedVariables.has(variable.value)) return false;
45
+ const initializer = stableConstInitializer(variable.value);
46
+ if (Option.isNone(initializer)) return false;
47
+ visitedVariables.add(variable.value);
48
+ return hasKnownEvidence(sourceCode, initializer.value, visitedVariables);
44
49
  }
45
50
  function annotationTarget(annotation, environment) {
46
- return annotation === null || annotation === void 0 ? null : classifyWideningTarget(annotation.typeAnnotation, environment);
51
+ return Option.flatMap(Option.all({
52
+ annotation,
53
+ environment
54
+ }), (present) => classifyWideningTarget(present.annotation.typeAnnotation, present.environment));
55
+ }
56
+ function isFunctionExpression(node) {
57
+ return node.type === "ArrowFunctionExpression" || node.type === "FunctionDeclaration" || node.type === "FunctionExpression";
47
58
  }
48
59
  function enclosingFunction(node) {
49
- let current = node.parent;
50
- while (current !== null && current.type !== "Program") {
51
- if (current.type === "ArrowFunctionExpression" || current.type === "FunctionDeclaration" || current.type === "FunctionExpression") return current;
52
- current = current.parent;
53
- }
54
- return null;
60
+ return Arr.findFirst(ancestors(node), isFunctionExpression);
55
61
  }
56
62
  function sourceKeyName(sourceCode, key) {
57
63
  if (key.type === "Identifier" || key.type === "PrivateIdentifier") return key.name;
@@ -59,8 +65,13 @@ function sourceKeyName(sourceCode, key) {
59
65
  return sourceCode.getText(key);
60
66
  }
61
67
  function functionName(sourceCode, owner) {
62
- if (owner === null) return "anonymous function";
63
- if (owner.id !== null) return owner.id.name;
68
+ return Option.match(owner, {
69
+ onNone: () => "anonymous function",
70
+ onSome: (present) => ownerFunctionName(sourceCode, present)
71
+ });
72
+ }
73
+ function ownerFunctionName(sourceCode, owner) {
74
+ if (Predicate.isNotNull(owner.id)) return owner.id.name;
64
75
  const parent = owner.parent;
65
76
  if (parent.type === "VariableDeclarator" && parent.id.type === "Identifier") return parent.id.name;
66
77
  if (parent.type === "MethodDefinition") return sourceKeyName(sourceCode, parent.key);
@@ -86,57 +97,63 @@ const noKnownValueWidening = define({
86
97
  }),
87
98
  create: function* () {
88
99
  const context = yield* RuleContext;
89
- let environment = null;
90
- const reportFlow = (expression, destination, subject, options = {}) => {
91
- if (destination === null) return Effect.void;
92
- if (options.allowEmptyDictionaryAccumulator === true && isDictionaryAccumulatorTarget(destination) && isEmptyObjectExpression(expression)) return Effect.void;
100
+ let environment = Option.none();
101
+ const reportFlow = (expression, destination, subject) => {
102
+ if (Option.isNone(destination)) return Effect.void;
103
+ const target = destination.value;
104
+ if (isDictionaryAccumulatorTarget(target) && isEmptyObjectExpression(expression)) return Effect.void;
93
105
  if (!hasKnownEvidence(context.sourceCode, expression)) return Effect.void;
94
106
  return context.report(fromId({
95
107
  node: expression,
96
108
  messageId: "widening",
97
109
  data: {
98
110
  subject,
99
- target: destination.kind
111
+ target: target.kind
100
112
  }
101
113
  }));
102
114
  };
103
- const targetFromAnnotation = (annotation) => environment === null ? null : annotationTarget(annotation, environment);
115
+ const targetFromAnnotation = (annotation) => annotationTarget(annotation, environment);
116
+ const reportAssertion = (node) => {
117
+ if (hasParentAssertion(node)) return Effect.void;
118
+ const target = Option.flatMap(environment, (present) => classifyWideningTarget(node.typeAnnotation, present));
119
+ return reportFlow(node.expression, target, "assertion");
120
+ };
104
121
  return {
105
122
  Program: (node) => {
106
- environment = createTypeEnvironment(node);
123
+ environment = Option.some(createTypeEnvironment(node, context.sourceCode.visitorKeys));
107
124
  return Effect.void;
108
125
  },
109
126
  VariableDeclarator: (node) => {
110
- if (node.init === null || node.id.type !== "Identifier") return Effect.void;
111
- return reportFlow(node.init, targetFromAnnotation(node.id.typeAnnotation), `binding \`${node.id.name}\``, { allowEmptyDictionaryAccumulator: true });
127
+ if (Predicate.isNull(node.init) || node.id.type !== "Identifier") return Effect.void;
128
+ return reportFlow(node.init, targetFromAnnotation(Option.fromNullishOr(node.id.typeAnnotation)), `binding \`${node.id.name}\``);
112
129
  },
113
130
  PropertyDefinition: (node) => {
114
- if (node.value === null) return Effect.void;
115
- return reportFlow(node.value, targetFromAnnotation(node.typeAnnotation), `property \`${sourceKeyName(context.sourceCode, node.key)}\``);
131
+ if (Predicate.isNull(node.value)) return Effect.void;
132
+ return reportFlow(node.value, targetFromAnnotation(Option.fromNullishOr(node.typeAnnotation)), `property \`${sourceKeyName(context.sourceCode, node.key)}\``);
116
133
  },
117
134
  AccessorProperty: (node) => {
118
- if (node.value === null) return Effect.void;
119
- return reportFlow(node.value, targetFromAnnotation(node.typeAnnotation), `property \`${sourceKeyName(context.sourceCode, node.key)}\``);
135
+ if (Predicate.isNull(node.value)) return Effect.void;
136
+ return reportFlow(node.value, targetFromAnnotation(Option.fromNullishOr(node.typeAnnotation)), `property \`${sourceKeyName(context.sourceCode, node.key)}\``);
120
137
  },
121
138
  AssignmentExpression: (node) => {
122
139
  if (node.operator !== "=" || node.left.type !== "Identifier") return Effect.void;
123
- const variable = resolveVariable(context.sourceCode, node.left);
124
- if (variable === null) return Effect.void;
125
- const declarator = variableDeclarator(variable);
126
- if (declarator === null || declarator.id.type !== "Identifier") return Effect.void;
127
- return reportFlow(node.right, targetFromAnnotation(declarator.id.typeAnnotation), `binding \`${declarator.id.name}\``);
140
+ const declarator = Option.flatMap(resolveVariable(context.sourceCode, node.left), variableDeclarator);
141
+ if (Option.isNone(declarator)) return Effect.void;
142
+ const { id } = declarator.value;
143
+ if (id.type !== "Identifier") return Effect.void;
144
+ return reportFlow(node.right, targetFromAnnotation(Option.fromNullishOr(id.typeAnnotation)), `binding \`${id.name}\``);
128
145
  },
129
146
  ReturnStatement: (node) => {
130
- if (node.argument === null) return Effect.void;
147
+ if (Predicate.isNull(node.argument)) return Effect.void;
131
148
  const owner = enclosingFunction(node);
132
- return reportFlow(node.argument, targetFromAnnotation(owner?.returnType), `return value of \`${functionName(context.sourceCode, owner)}\``);
149
+ return reportFlow(node.argument, targetFromAnnotation(Option.flatMap(owner, (present) => Option.fromNullishOr(present.returnType))), `return value of \`${functionName(context.sourceCode, owner)}\``);
133
150
  },
134
151
  ArrowFunctionExpression: (node) => {
135
152
  if (node.body.type === "BlockStatement") return Effect.void;
136
- return reportFlow(node.body, targetFromAnnotation(node.returnType), `return value of \`${functionName(context.sourceCode, node)}\``);
153
+ return reportFlow(node.body, targetFromAnnotation(Option.fromNullishOr(node.returnType)), `return value of \`${functionName(context.sourceCode, Option.some(node))}\``);
137
154
  },
138
- TSAsExpression: (node) => environment === null || hasParentAssertion(node) ? Effect.void : reportFlow(node.expression, classifyWideningTarget(node.typeAnnotation, environment), "assertion"),
139
- TSTypeAssertion: (node) => environment === null || hasParentAssertion(node) ? Effect.void : reportFlow(node.expression, classifyWideningTarget(node.typeAnnotation, environment), "assertion")
155
+ TSAsExpression: reportAssertion,
156
+ TSTypeAssertion: reportAssertion
140
157
  };
141
158
  }
142
159
  });
@@ -0,0 +1,6 @@
1
+ import * as _$_oxlint_plugins0 from "@oxlint/plugins";
2
+
3
+ //#region src/rules/no-module-level-mutable-state.d.ts
4
+ declare const noModuleLevelMutableState: _$_oxlint_plugins0.CreateRule;
5
+ //#endregion
6
+ export { noModuleLevelMutableState };
@@ -0,0 +1,47 @@
1
+ import { make } from "../vendor/effect-oxlint/Diagnostic.js";
2
+ import { RuleContext } from "../vendor/effect-oxlint/RuleContext.js";
3
+ import { define, meta } from "../vendor/effect-oxlint/Rule.js";
4
+ import "../vendor/effect-oxlint/index.js";
5
+ import { ancestors } from "./_ast-ancestors.js";
6
+ import { isTestFile, skipFile } from "./_test-files.js";
7
+ import * as Arr from "effect/Array";
8
+ import * as Option from "effect/Option";
9
+ import * as Effect from "effect/Effect";
10
+ //#region src/rules/no-module-level-mutable-state.ts
11
+ /** Nodes that start a new `var` scope, so a `var` below them is not module state. */
12
+ const functionScopeBoundaries = new Set([
13
+ "ArrowFunctionExpression",
14
+ "FunctionDeclaration",
15
+ "FunctionExpression",
16
+ "StaticBlock",
17
+ "TSDeclareFunction",
18
+ "TSModuleBlock"
19
+ ]);
20
+ const isModuleScopedVar = (node) => Option.exists(Arr.findFirst(ancestors(node), (ancestor) => ancestor.type === "Program" || functionScopeBoundaries.has(ancestor.type)), (boundary) => boundary.type === "Program");
21
+ const noModuleLevelMutableState = define({
22
+ name: "no-module-level-mutable-state",
23
+ meta: meta({
24
+ type: "problem",
25
+ description: "Keep state out of module-level let and var bindings."
26
+ }),
27
+ create: function* () {
28
+ const ctx = yield* RuleContext;
29
+ if (isTestFile(ctx.filename)) return skipFile;
30
+ const isModuleScoped = (node) => {
31
+ if (node.kind === "var") return isModuleScopedVar(node);
32
+ const scope = ctx.sourceCode.getScope(node);
33
+ return scope.type === "module" || scope.type === "global";
34
+ };
35
+ return { VariableDeclaration: (node) => {
36
+ if (node.declare === true) return Effect.void;
37
+ if (node.kind !== "let" && node.kind !== "var") return Effect.void;
38
+ if (!isModuleScoped(node)) return Effect.void;
39
+ return ctx.report(make({
40
+ node,
41
+ message: `Module-level ${node.kind} is shared by every request a server process or Worker isolate handles. Keep the state in a Ref built by a Layer, or in the service that owns it.`
42
+ }));
43
+ } };
44
+ }
45
+ });
46
+ //#endregion
47
+ export { noModuleLevelMutableState };
@@ -1,25 +1,32 @@
1
- import { importSource, memberNames, narrow } from "../vendor/effect-oxlint/AST.js";
1
+ import { memberNames, narrow } from "../vendor/effect-oxlint/AST.js";
2
2
  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
- import { findVariableUp } from "../vendor/effect-oxlint/Scope.js";
6
5
  import "../vendor/effect-oxlint/index.js";
6
+ import { collectTestBindings, resolveTestBinding } from "./_test-apis.js";
7
7
  import * as Option from "effect/Option";
8
8
  import * as Effect from "effect/Effect";
9
9
  //#region src/rules/no-module-mocks.ts
10
- const bannedMethods = new Set(["mock", "spyOn"]);
11
- const identifierName = (node) => {
12
- if (node?.type === "Identifier") return node.name;
10
+ const mockingMethods = new Set([
11
+ "doMock",
12
+ "fn",
13
+ "mock",
14
+ "mocked",
15
+ "spyOn",
16
+ "unmock"
17
+ ]);
18
+ /** bun:test `mock(...)` and `spyOn(...)` are mock doubles in their own right. */
19
+ const bannedBareCall = (binding) => {
20
+ if (binding === "mock" || binding === "spyOn") return Option.some(`${binding}()`);
21
+ return Option.none();
13
22
  };
14
- const globalTestApi = (name) => {
15
- if (name === "jest" || name === "vi") return name;
16
- };
17
- const importedTestApi = (source, imported) => {
18
- if (source === "vitest" && imported === "vi") return "vi";
19
- if (source === "@jest/globals" && imported === "jest") return "jest";
20
- };
21
- const testApiFromVariable = (variable, importedBindings) => {
22
- if (variable.defs.some((definition) => definition.type === "ImportBinding")) return importedBindings.get(variable.name);
23
+ const bannedMemberCall = (binding, method) => {
24
+ if (binding === "mock") {
25
+ if (method === "module") return Option.some("mock.module()");
26
+ return Option.none();
27
+ }
28
+ if (binding === "spyOn" || !mockingMethods.has(method)) return Option.none();
29
+ return Option.some(`${binding}.${method}()`);
23
30
  };
24
31
  const noModuleMocks = define({
25
32
  name: "no-module-mocks",
@@ -29,42 +36,24 @@ const noModuleMocks = define({
29
36
  }),
30
37
  create: function* () {
31
38
  const context = yield* RuleContext;
32
- const importedBindings = /* @__PURE__ */ new Map();
39
+ const bindings = /* @__PURE__ */ new Map();
40
+ const bannedCall = (call) => {
41
+ const scope = context.sourceCode.getScope(call);
42
+ if (call.callee.type === "Identifier") return Option.flatMap(resolveTestBinding(scope, call.callee.name, bindings), bannedBareCall);
43
+ if (call.callee.type !== "MemberExpression") return Option.none();
44
+ return Option.flatMap(memberNames(call.callee), ([object, method]) => Option.flatMap(resolveTestBinding(scope, object, bindings), (binding) => bannedMemberCall(binding, method)));
45
+ };
33
46
  return {
34
- ImportDeclaration: (node) => Option.match(narrow(node, "ImportDeclaration"), {
35
- onNone: () => Effect.void,
36
- onSome: (declaration) => {
37
- const source = importSource(declaration);
38
- for (const specifier of declaration.specifiers) {
39
- if (specifier.type !== "ImportSpecifier") continue;
40
- const imported = identifierName(specifier.imported);
41
- if (imported === void 0) continue;
42
- const api = importedTestApi(source, imported);
43
- if (api !== void 0) importedBindings.set(specifier.local.name, api);
44
- }
45
- return Effect.void;
46
- }
47
- }),
48
- CallExpression: (node) => Option.match(narrow(node, "CallExpression"), {
47
+ ImportDeclaration: (node) => {
48
+ collectTestBindings(node, bindings);
49
+ return Effect.void;
50
+ },
51
+ CallExpression: (node) => Option.match(Option.flatMap(narrow(node, "CallExpression"), bannedCall), {
49
52
  onNone: () => Effect.void,
50
- onSome: (call) => {
51
- if (call.callee.type !== "MemberExpression") return Effect.void;
52
- const names = Option.getOrUndefined(memberNames(call.callee));
53
- if (names === void 0) return Effect.void;
54
- const [object, method] = names;
55
- if (!bannedMethods.has(method)) return Effect.void;
56
- const variable = Option.getOrUndefined(findVariableUp(context.sourceCode.getScope(call), object));
57
- let api = globalTestApi(object);
58
- if (variable !== void 0) {
59
- api = testApiFromVariable(variable, importedBindings);
60
- if (api === void 0 && variable.defs.length === 0) api = globalTestApi(object);
61
- }
62
- if (api === void 0) return Effect.void;
63
- return context.report(make({
64
- node: call,
65
- message: `Avoid ${api}.${method}(). Replace the external boundary with an Effect service test Layer.`
66
- }));
67
- }
53
+ onSome: (used) => context.report(make({
54
+ node,
55
+ message: `Avoid ${used}. Replace the external boundary with an Effect service test Layer.`
56
+ }))
68
57
  })
69
58
  };
70
59
  }
@@ -22,14 +22,18 @@ const noNewPromise = define({
22
22
  return {
23
23
  NewExpression: (node) => Option.match(narrow(node, "NewExpression"), {
24
24
  onNone: () => Effect.void,
25
- onSome: (expression) => isPromiseIdentifier(expression.callee) ? report(expression) : Effect.void
25
+ onSome: (expression) => {
26
+ if (!isPromiseIdentifier(expression.callee)) return Effect.void;
27
+ return report(expression);
28
+ }
26
29
  }),
27
30
  CallExpression: (node) => Option.match(narrow(node, "CallExpression"), {
28
31
  onNone: () => Effect.void,
29
32
  onSome: (call) => {
30
33
  if (isPromiseIdentifier(call.callee)) return report(call);
31
34
  if (call.callee.type !== "MemberExpression") return Effect.void;
32
- return Option.getOrUndefined(memberNames(call.callee))?.[0] === "Promise" ? report(call) : Effect.void;
35
+ if (!Option.exists(memberNames(call.callee), ([object]) => object === "Promise")) return Effect.void;
36
+ return report(call);
33
37
  }
34
38
  })
35
39
  };