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.
- package/README.md +26 -14
- package/THIRD_PARTY_NOTICES.md +4 -2
- package/dist/presets/recommended.d.ts +10 -0
- package/dist/presets/recommended.js +11 -1
- package/dist/rules/_anti-slop-array-method.js +78 -0
- package/dist/rules/_anti-slop-dictionary-types.js +198 -150
- package/dist/rules/_anti-slop-function-parameters.js +30 -0
- package/dist/rules/_anti-slop-lexical-type-parameters.js +25 -0
- package/dist/rules/_anti-slop-reflect-method.js +25 -0
- package/dist/rules/_anti-slop-scope.js +15 -0
- package/dist/rules/_anti-slop-type-alias-resolution.js +139 -0
- package/dist/rules/_ast-ancestors.js +14 -0
- package/dist/rules/_ast-children.js +18 -0
- package/dist/rules/_call-arguments.js +11 -0
- package/dist/rules/_effect-context.js +25 -20
- package/dist/rules/_function-metrics.js +94 -75
- package/dist/rules/_tagged-control-flow.js +38 -60
- package/dist/rules/_test-apis.js +47 -0
- package/dist/rules/_test-files.js +8 -0
- package/dist/rules/index.d.ts +11 -1
- package/dist/rules/index.js +21 -1
- package/dist/rules/no-array-filter-map.d.ts +7 -0
- package/dist/rules/no-array-filter-map.js +49 -0
- package/dist/rules/no-chained-type-assertions.js +7 -4
- package/dist/rules/no-conditional-empty-object-spread.d.ts +1 -1
- package/dist/rules/no-conditional-empty-object-spread.js +7 -47
- package/dist/rules/no-dynamic-imports.js +33 -25
- package/dist/rules/no-eager-acquire.d.ts +6 -0
- package/dist/rules/no-eager-acquire.js +98 -0
- package/dist/rules/no-effect-run-in-tests.d.ts +6 -0
- package/dist/rules/no-effect-run-in-tests.js +60 -0
- package/dist/rules/no-globals.js +42 -31
- package/dist/rules/no-known-value-widening.js +67 -50
- package/dist/rules/no-module-level-mutable-state.d.ts +6 -0
- package/dist/rules/no-module-level-mutable-state.js +47 -0
- package/dist/rules/no-module-mocks.js +37 -48
- package/dist/rules/no-new-promise.js +6 -2
- package/dist/rules/no-node-builtin-import.js +56 -33
- package/dist/rules/no-nullish.js +5 -1
- package/dist/rules/no-object-parameters.js +16 -30
- package/dist/rules/no-per-call-cache-construction.js +2 -2
- package/dist/rules/no-reduce-accumulator-copy.d.ts +7 -0
- package/dist/rules/no-reduce-accumulator-copy.js +106 -0
- package/dist/rules/no-reflect-apply.d.ts +7 -0
- package/dist/rules/no-reflect-apply.js +28 -0
- package/dist/rules/no-reflect-get.d.ts +7 -0
- package/dist/rules/no-reflect-get.js +28 -0
- package/dist/rules/no-run-collect-on-unbounded-stream.js +23 -25
- package/dist/rules/no-runtime-typeof.js +21 -4
- package/dist/rules/no-sequential-effect-all.js +18 -11
- package/dist/rules/no-shape-in-symbol-names.js +15 -5
- package/dist/rules/no-silent-catch-all.js +20 -14
- package/dist/rules/no-test-globals.d.ts +6 -0
- package/dist/rules/no-test-globals.js +147 -0
- package/dist/rules/no-test-lifecycle-hooks.js +4 -2
- package/dist/rules/no-unbounded-concurrency.js +22 -17
- package/dist/rules/no-unbounded-retry.js +47 -62
- package/dist/rules/no-unknown-parameters.d.ts +1 -1
- package/dist/rules/no-unknown-parameters.js +26 -17
- package/dist/rules/no-unknown-returns.d.ts +7 -0
- package/dist/rules/no-unknown-returns.js +74 -0
- package/dist/rules/no-unknown-type-aliases.js +21 -27
- package/dist/rules/no-unsafe-dictionary-type.js +71 -25
- package/dist/rules/no-widen-then-assert.js +96 -73
- package/dist/rules/prefer-catch-tag.js +29 -37
- package/dist/rules/prefer-match-tags-exhaustive.js +35 -47
- package/dist/rules/prefer-predicate-is-tagged.js +4 -6
- package/dist/rules/prefer-service-of.js +39 -26
- package/dist/rules/require-named-effect-fn.js +4 -2
- package/dist/rules/require-suppression-reason.d.ts +6 -0
- package/dist/rules/require-suppression-reason.js +92 -0
- package/dist/vendor/effect-oxlint/Diagnostic.js +1 -1
- package/dist/vendor/effect-oxlint/Scope.js +1 -1
- 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 };
|
package/dist/rules/no-globals.js
CHANGED
|
@@ -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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
*
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
40
|
-
const
|
|
41
|
-
if (
|
|
42
|
-
visitedVariables.add(variable);
|
|
43
|
-
return hasKnownEvidence(sourceCode,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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 =
|
|
90
|
-
const reportFlow = (expression, destination, subject
|
|
91
|
-
if (destination
|
|
92
|
-
|
|
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:
|
|
111
|
+
target: target.kind
|
|
100
112
|
}
|
|
101
113
|
}));
|
|
102
114
|
};
|
|
103
|
-
const targetFromAnnotation = (annotation) =>
|
|
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
|
|
111
|
-
return reportFlow(node.init, targetFromAnnotation(node.id.typeAnnotation), `binding \`${node.id.name}
|
|
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
|
|
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
|
|
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
|
|
124
|
-
if (
|
|
125
|
-
const
|
|
126
|
-
if (
|
|
127
|
-
return reportFlow(node.right, targetFromAnnotation(
|
|
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
|
|
147
|
+
if (Predicate.isNull(node.argument)) return Effect.void;
|
|
131
148
|
const owner = enclosingFunction(node);
|
|
132
|
-
return reportFlow(node.argument, targetFromAnnotation(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:
|
|
139
|
-
TSTypeAssertion:
|
|
155
|
+
TSAsExpression: reportAssertion,
|
|
156
|
+
TSTypeAssertion: reportAssertion
|
|
140
157
|
};
|
|
141
158
|
}
|
|
142
159
|
});
|
|
@@ -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 {
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
15
|
-
if (
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (
|
|
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
|
|
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) =>
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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: (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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) =>
|
|
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
|
-
|
|
35
|
+
if (!Option.exists(memberNames(call.callee), ([object]) => object === "Promise")) return Effect.void;
|
|
36
|
+
return report(call);
|
|
33
37
|
}
|
|
34
38
|
})
|
|
35
39
|
};
|