oxlint-plugin-effect 0.14.0 → 0.16.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 +16 -3
- package/dist/presets/recommended.d.ts +1 -0
- package/dist/presets/recommended.js +1 -0
- package/dist/rules/_anti-slop-array-method.js +41 -33
- package/dist/rules/_anti-slop-dictionary-types.js +172 -129
- package/dist/rules/_anti-slop-function-parameters.js +9 -6
- package/dist/rules/_anti-slop-lexical-type-parameters.js +6 -14
- package/dist/rules/_anti-slop-reflect-method.js +7 -3
- package/dist/rules/_anti-slop-scope.js +8 -6
- package/dist/rules/_anti-slop-type-alias-resolution.js +58 -65
- 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 +63 -44
- package/dist/rules/_tagged-control-flow.js +38 -60
- package/dist/rules/_test-apis.js +47 -0
- package/dist/rules/index.d.ts +2 -1
- package/dist/rules/index.js +2 -0
- package/dist/rules/no-array-filter-map.js +24 -14
- package/dist/rules/no-chained-type-assertions.js +7 -4
- package/dist/rules/no-conditional-empty-object-spread.js +7 -4
- package/dist/rules/no-dynamic-imports.js +33 -25
- package/dist/rules/no-eager-acquire.js +40 -34
- package/dist/rules/no-effect-run-in-tests.js +11 -8
- package/dist/rules/no-globals.js +42 -31
- package/dist/rules/no-known-value-widening.js +64 -39
- package/dist/rules/no-module-level-mutable-state.js +4 -8
- 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 +9 -7
- package/dist/rules/no-per-call-cache-construction.js +2 -2
- package/dist/rules/no-reduce-accumulator-copy.js +65 -53
- package/dist/rules/no-reflect-apply.js +7 -4
- package/dist/rules/no-reflect-get.js +7 -4
- package/dist/rules/no-run-collect-on-unbounded-stream.js +23 -25
- package/dist/rules/no-runtime-typeof.js +9 -5
- package/dist/rules/no-sequential-effect-all.js +18 -11
- package/dist/rules/no-shape-in-symbol-names.js +10 -6
- 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.js +5 -3
- package/dist/rules/no-unknown-returns.js +11 -10
- package/dist/rules/no-unknown-type-aliases.js +13 -9
- package/dist/rules/no-unsafe-dictionary-type.js +22 -27
- package/dist/rules/no-widen-then-assert.js +95 -72
- 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.js +8 -3
- package/dist/vendor/effect-oxlint/Scope.js +1 -1
- package/package.json +3 -1
- package/presets/recommended.json +58 -0
|
@@ -2,7 +2,10 @@ 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 { arrayMethodTarget, isKnownArrayExpression, resolveArrayBinding, unwrapArrayExpression } from "./_anti-slop-array-method.js";
|
|
5
|
+
import { arrayMethodTarget, constInitializer, isKnownArrayExpression, resolveArrayBinding, unwrapArrayExpression } from "./_anti-slop-array-method.js";
|
|
6
|
+
import { ancestors } from "./_ast-ancestors.js";
|
|
7
|
+
import * as Arr from "effect/Array";
|
|
8
|
+
import * as Option from "effect/Option";
|
|
6
9
|
import * as Effect from "effect/Effect";
|
|
7
10
|
//#region src/rules/no-reduce-accumulator-copy.ts
|
|
8
11
|
const arrayCopyMethods = new Set([
|
|
@@ -13,45 +16,64 @@ const arrayCopyMethods = new Set([
|
|
|
13
16
|
"toReversed",
|
|
14
17
|
"with"
|
|
15
18
|
]);
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (accumulator?.type !== "Identifier") return null;
|
|
31
|
-
return {
|
|
19
|
+
const isCallback = (node) => node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression";
|
|
20
|
+
const parameterBinding = (parameter) => {
|
|
21
|
+
if (parameter.type === "AssignmentPattern") return parameter.left;
|
|
22
|
+
return parameter;
|
|
23
|
+
};
|
|
24
|
+
const isReduceCall = (owner, callback) => Option.exists(arrayMethodTarget(owner.callee), (method) => method.name === "reduce" || method.name === "reduceRight") && owner.arguments.length <= 2 && Option.exists(Arr.head(owner.arguments), (firstArgument) => unwrapArrayExpression(firstArgument) === callback);
|
|
25
|
+
function reducerForCallback(callback) {
|
|
26
|
+
const owner = Arr.findFirst(ancestors(callback), (node) => unwrapArrayExpression(node) !== callback);
|
|
27
|
+
return Option.flatMap(owner, (call) => {
|
|
28
|
+
if (call.type !== "CallExpression" || !isReduceCall(call, callback)) return Option.none();
|
|
29
|
+
return Option.flatMap(Arr.head(callback.params), (firstParameter) => {
|
|
30
|
+
const accumulator = parameterBinding(firstParameter);
|
|
31
|
+
if (accumulator.type !== "Identifier") return Option.none();
|
|
32
|
+
return Option.some({
|
|
32
33
|
callback,
|
|
33
34
|
accumulator,
|
|
34
|
-
initialValue:
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
initialValue: Arr.get(call.arguments, 1)
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function enclosingReducer(node) {
|
|
41
|
+
const boundary = Arr.findFirst(ancestors(node), (parent) => parent.type === "FunctionDeclaration" || isCallback(parent));
|
|
42
|
+
return Option.flatMap(boundary, (parent) => {
|
|
43
|
+
if (!isCallback(parent)) return Option.none();
|
|
44
|
+
return reducerForCallback(parent);
|
|
45
|
+
});
|
|
40
46
|
}
|
|
41
47
|
function referencesAccumulator(sourceCode, node, accumulator, visited = /* @__PURE__ */ new Set()) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
return Option.exists(resolveArrayBinding(sourceCode, node), (variable) => {
|
|
49
|
+
if (visited.has(variable)) return false;
|
|
50
|
+
if (variable === accumulator) return true;
|
|
51
|
+
visited.add(variable);
|
|
52
|
+
if (variable.references.some((reference) => reference.isWrite() && !reference.init)) return false;
|
|
53
|
+
return Option.exists(constInitializer(variable), (init) => referencesAccumulator(sourceCode, init, accumulator, visited));
|
|
54
|
+
});
|
|
49
55
|
}
|
|
50
56
|
function isGlobalCopyOwner(sourceCode, node, name) {
|
|
51
57
|
const owner = unwrapArrayExpression(node);
|
|
52
58
|
if (owner.type !== "Identifier" || owner.name !== name) return false;
|
|
53
|
-
|
|
54
|
-
|
|
59
|
+
return Option.match(resolveArrayBinding(sourceCode, owner), {
|
|
60
|
+
onNone: () => true,
|
|
61
|
+
onSome: (variable) => variable.defs.length === 0
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function accumulatorCopyCandidate(sourceCode, node) {
|
|
65
|
+
const lookups = Option.all({
|
|
66
|
+
method: arrayMethodTarget(node.callee),
|
|
67
|
+
reducer: enclosingReducer(node)
|
|
68
|
+
});
|
|
69
|
+
return Option.flatMap(lookups, ({ method, reducer }) => {
|
|
70
|
+
const accumulator = Arr.findFirst(sourceCode.getDeclaredVariables(reducer.callback), (variable) => variable.identifiers.some((identifier) => identifier.start === reducer.accumulator.start));
|
|
71
|
+
return Option.map(accumulator, (variable) => ({
|
|
72
|
+
method,
|
|
73
|
+
reducer,
|
|
74
|
+
accumulator: variable
|
|
75
|
+
}));
|
|
76
|
+
});
|
|
55
77
|
}
|
|
56
78
|
/** Reject non-spread copies of reducer accumulators; pair with oxc/no-accumulating-spread. */
|
|
57
79
|
const noReduceAccumulatorCopy = define({
|
|
@@ -64,30 +86,20 @@ const noReduceAccumulatorCopy = define({
|
|
|
64
86
|
create: function* () {
|
|
65
87
|
const context = yield* RuleContext;
|
|
66
88
|
const { sourceCode } = context;
|
|
67
|
-
const copiesAccumulator = (node) => {
|
|
68
|
-
const method = arrayMethodTarget(node.callee);
|
|
69
|
-
if (method === null) return false;
|
|
70
|
-
const reducer = enclosingReducer(node);
|
|
71
|
-
if (reducer === null) return false;
|
|
72
|
-
const accumulator = sourceCode.getDeclaredVariables(reducer.callback).find((variable) => variable.identifiers.some((identifier) => identifier.start === reducer.accumulator.start));
|
|
73
|
-
if (accumulator === void 0) return false;
|
|
89
|
+
const copiesAccumulator = (node, { method, reducer, accumulator }) => {
|
|
74
90
|
const isAccumulator = (expression) => referencesAccumulator(sourceCode, expression, accumulator);
|
|
75
|
-
if (method.name === "assign" && isGlobalCopyOwner(sourceCode, method.object, "Object"))
|
|
76
|
-
|
|
77
|
-
return target !== void 0 && unwrapArrayExpression(target).type === "ObjectExpression" && node.arguments.slice(1).some(isAccumulator);
|
|
78
|
-
}
|
|
79
|
-
if (method.name === "from" && isGlobalCopyOwner(sourceCode, method.object, "Array")) {
|
|
80
|
-
const source = node.arguments[0];
|
|
81
|
-
return source !== void 0 && isAccumulator(source);
|
|
82
|
-
}
|
|
91
|
+
if (method.name === "assign" && isGlobalCopyOwner(sourceCode, method.object, "Object")) return Option.exists(Arr.head(node.arguments), (target) => unwrapArrayExpression(target).type === "ObjectExpression") && node.arguments.slice(1).some(isAccumulator);
|
|
92
|
+
if (method.name === "from" && isGlobalCopyOwner(sourceCode, method.object, "Array")) return Option.exists(Arr.head(node.arguments), isAccumulator);
|
|
83
93
|
if (!arrayCopyMethods.has(method.name)) return false;
|
|
84
|
-
|
|
85
|
-
return initialValue !== void 0 && isKnownArrayExpression(sourceCode, initialValue) && isAccumulator(method.object);
|
|
94
|
+
return Option.exists(reducer.initialValue, (initialValue) => isKnownArrayExpression(sourceCode, initialValue)) && isAccumulator(method.object);
|
|
86
95
|
};
|
|
87
|
-
return { CallExpression: (node) =>
|
|
88
|
-
node,
|
|
89
|
-
|
|
90
|
-
|
|
96
|
+
return { CallExpression: (node) => {
|
|
97
|
+
if (!Option.exists(accumulatorCopyCandidate(sourceCode, node), (candidate) => copiesAccumulator(node, candidate))) return Effect.void;
|
|
98
|
+
return context.report(fromId({
|
|
99
|
+
node,
|
|
100
|
+
messageId: "accumulatorCopy"
|
|
101
|
+
}));
|
|
102
|
+
} };
|
|
91
103
|
}
|
|
92
104
|
});
|
|
93
105
|
//#endregion
|
|
@@ -15,10 +15,13 @@ const noReflectApply = define({
|
|
|
15
15
|
}),
|
|
16
16
|
create: function* () {
|
|
17
17
|
const context = yield* RuleContext;
|
|
18
|
-
return { CallExpression: (node) =>
|
|
19
|
-
node,
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
return { CallExpression: (node) => {
|
|
19
|
+
if (node.callee.type === "Super" || !isGlobalReflectMethodCall(context.sourceCode, node.callee, "apply")) return Effect.void;
|
|
20
|
+
return context.report(fromId({
|
|
21
|
+
node,
|
|
22
|
+
messageId: "reflectApply"
|
|
23
|
+
}));
|
|
24
|
+
} };
|
|
22
25
|
}
|
|
23
26
|
});
|
|
24
27
|
//#endregion
|
|
@@ -15,10 +15,13 @@ const noReflectGet = define({
|
|
|
15
15
|
}),
|
|
16
16
|
create: function* () {
|
|
17
17
|
const context = yield* RuleContext;
|
|
18
|
-
return { CallExpression: (node) =>
|
|
19
|
-
node,
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
return { CallExpression: (node) => {
|
|
19
|
+
if (node.callee.type === "Super" || !isGlobalReflectMethodCall(context.sourceCode, node.callee, "get")) return Effect.void;
|
|
20
|
+
return context.report(fromId({
|
|
21
|
+
node,
|
|
22
|
+
messageId: "reflectGet"
|
|
23
|
+
}));
|
|
24
|
+
} };
|
|
22
25
|
}
|
|
23
26
|
});
|
|
24
27
|
//#endregion
|
|
@@ -2,7 +2,9 @@ import { make } from "../vendor/effect-oxlint/Diagnostic.js";
|
|
|
2
2
|
import { RuleContext } from "../vendor/effect-oxlint/RuleContext.js";
|
|
3
3
|
import { define, meta } from "../vendor/effect-oxlint/Rule.js";
|
|
4
4
|
import "../vendor/effect-oxlint/index.js";
|
|
5
|
+
import { expressionArgument } from "./_call-arguments.js";
|
|
5
6
|
import { importedNamespaces, isStaticMember, visibleNamespaces } from "./_effect-namespaces.js";
|
|
7
|
+
import * as Option from "effect/Option";
|
|
6
8
|
import * as Effect from "effect/Effect";
|
|
7
9
|
//#region src/rules/no-run-collect-on-unbounded-stream.ts
|
|
8
10
|
const unboundedSources = new Set([
|
|
@@ -23,29 +25,25 @@ const terminatingOperations = new Set([
|
|
|
23
25
|
]);
|
|
24
26
|
const staticOperation = (node, streamNamespaces, operations) => {
|
|
25
27
|
if (node.type === "SpreadElement") return false;
|
|
26
|
-
|
|
28
|
+
let candidate = node;
|
|
29
|
+
if (node.type === "CallExpression" && node.callee.type !== "Super") candidate = node.callee;
|
|
27
30
|
for (const operation of operations) if (isStaticMember(candidate, streamNamespaces, operation)) return true;
|
|
28
31
|
return false;
|
|
29
32
|
};
|
|
33
|
+
const isPipeMember = (node) => node.type === "MemberExpression" && !node.computed && node.property.type === "Identifier" && node.property.name === "pipe";
|
|
34
|
+
const pipesTerminatingOperation = (node, streamNamespaces) => node.arguments.some((operation) => staticOperation(operation, streamNamespaces, terminatingOperations));
|
|
30
35
|
const unboundedRoot = (node, streamNamespaces) => {
|
|
31
|
-
if (node.type !== "CallExpression" || node.callee.type === "Super") return
|
|
32
|
-
for (const source of unboundedSources) if (isStaticMember(node.callee, streamNamespaces, source)) return node;
|
|
33
|
-
if (node.callee
|
|
34
|
-
|
|
35
|
-
if (root === void 0) return void 0;
|
|
36
|
-
for (const operation of node.arguments) if (staticOperation(operation, streamNamespaces, terminatingOperations)) return void 0;
|
|
37
|
-
return root;
|
|
36
|
+
if (node.type !== "CallExpression" || node.callee.type === "Super") return Option.none();
|
|
37
|
+
for (const source of unboundedSources) if (isStaticMember(node.callee, streamNamespaces, source)) return Option.some(node);
|
|
38
|
+
if (!isPipeMember(node.callee)) return Option.none();
|
|
39
|
+
return Option.filter(unboundedRoot(node.callee.object, streamNamespaces), () => !pipesTerminatingOperation(node, streamNamespaces));
|
|
38
40
|
};
|
|
39
41
|
const collectedSource = (node, streamNamespaces) => {
|
|
40
|
-
if (node.callee.type === "Super") return
|
|
41
|
-
if (isStaticMember(node.callee, streamNamespaces, "runCollect"))
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
if (node.callee.type !== "MemberExpression" || node.callee.computed || node.callee.property.type !== "Identifier" || node.callee.property.name !== "pipe") return;
|
|
47
|
-
for (const operation of node.arguments) if (staticOperation(operation, streamNamespaces, terminatingOperations)) return void 0;
|
|
48
|
-
for (const operation of node.arguments) if (staticOperation(operation, streamNamespaces, collectOperations)) return node.callee.object;
|
|
42
|
+
if (node.callee.type === "Super") return Option.none();
|
|
43
|
+
if (isStaticMember(node.callee, streamNamespaces, "runCollect")) return expressionArgument(node, 0);
|
|
44
|
+
if (!isPipeMember(node.callee) || pipesTerminatingOperation(node, streamNamespaces)) return Option.none();
|
|
45
|
+
if (!node.arguments.some((operation) => staticOperation(operation, streamNamespaces, collectOperations))) return Option.none();
|
|
46
|
+
return Option.some(node.callee.object);
|
|
49
47
|
};
|
|
50
48
|
const noRunCollectOnUnboundedStream = define({
|
|
51
49
|
name: "no-run-collect-on-unbounded-stream",
|
|
@@ -65,14 +63,14 @@ const noRunCollectOnUnboundedStream = define({
|
|
|
65
63
|
CallExpression: (node) => {
|
|
66
64
|
if (node.type !== "CallExpression") return Effect.void;
|
|
67
65
|
const namespaces = visibleNamespaces(ctx, node, streamNamespaces);
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
})
|
|
66
|
+
const root = Option.flatMap(collectedSource(node, namespaces), (source) => unboundedRoot(source, namespaces));
|
|
67
|
+
return Option.match(root, {
|
|
68
|
+
onNone: () => Effect.void,
|
|
69
|
+
onSome: (stream) => ctx.report(make({
|
|
70
|
+
node: stream,
|
|
71
|
+
message: "Do not collect a clearly unbounded Stream. Add a terminating operation or consume it with runForEach or runDrain."
|
|
72
|
+
}))
|
|
73
|
+
});
|
|
76
74
|
}
|
|
77
75
|
};
|
|
78
76
|
}
|
|
@@ -14,7 +14,8 @@ function isExistenceProbe(node) {
|
|
|
14
14
|
"==",
|
|
15
15
|
"!="
|
|
16
16
|
].includes(parent.operator)) return false;
|
|
17
|
-
|
|
17
|
+
let other = parent.left;
|
|
18
|
+
if (parent.left === node) other = parent.right;
|
|
18
19
|
return other.type === "Literal" && other.value === "undefined";
|
|
19
20
|
}
|
|
20
21
|
/** Disallow runtime typeof checks that narrow unparsed values instead of decoding them. */
|
|
@@ -27,10 +28,13 @@ const noRuntimeTypeof = define({
|
|
|
27
28
|
}),
|
|
28
29
|
create: function* () {
|
|
29
30
|
const context = yield* RuleContext;
|
|
30
|
-
return { UnaryExpression: (node) =>
|
|
31
|
-
node
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
return { UnaryExpression: (node) => {
|
|
32
|
+
if (node.operator !== "typeof" || isExistenceProbe(node)) return Effect.void;
|
|
33
|
+
return context.report(fromId({
|
|
34
|
+
node,
|
|
35
|
+
messageId: "runtimeTypeof"
|
|
36
|
+
}));
|
|
37
|
+
} };
|
|
34
38
|
}
|
|
35
39
|
});
|
|
36
40
|
//#endregion
|
|
@@ -2,19 +2,26 @@ import { make } from "../vendor/effect-oxlint/Diagnostic.js";
|
|
|
2
2
|
import { RuleContext } from "../vendor/effect-oxlint/RuleContext.js";
|
|
3
3
|
import { define, meta } from "../vendor/effect-oxlint/Rule.js";
|
|
4
4
|
import "../vendor/effect-oxlint/index.js";
|
|
5
|
+
import { expressionArgument, staticProperties } from "./_call-arguments.js";
|
|
5
6
|
import { importedNamespaces, isStaticMember, visibleNamespaces } from "./_effect-namespaces.js";
|
|
7
|
+
import * as Arr from "effect/Array";
|
|
8
|
+
import * as Option from "effect/Option";
|
|
9
|
+
import * as Predicate from "effect/Predicate";
|
|
6
10
|
import * as Effect from "effect/Effect";
|
|
7
11
|
//#region src/rules/no-sequential-effect-all.ts
|
|
12
|
+
const primitiveLiteral = (node) => {
|
|
13
|
+
if (node.type !== "Literal") return Option.none();
|
|
14
|
+
const value = node.value;
|
|
15
|
+
if (Predicate.isNull(value) || Predicate.isString(value) || Predicate.isNumber(value) || Predicate.isBoolean(value)) return Option.some(node);
|
|
16
|
+
return Option.none();
|
|
17
|
+
};
|
|
18
|
+
/** The first primitive literal given for `name` in an options object literal. */
|
|
8
19
|
const propertyLiteral = (node, name) => {
|
|
9
|
-
if (node
|
|
10
|
-
|
|
11
|
-
if (property.type !== "Property" || property.computed) continue;
|
|
12
|
-
if ((property.key.type === "Identifier" && property.key.name === name || property.key.type === "Literal" && property.key.value === name) && property.value.type === "Literal") {
|
|
13
|
-
const value = property.value.value;
|
|
14
|
-
if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
20
|
+
if (node.type !== "ObjectExpression") return Option.none();
|
|
21
|
+
return Arr.findFirst(staticProperties(node, name), (property) => primitiveLiteral(property.value));
|
|
17
22
|
};
|
|
23
|
+
/** Whether the options set `name` to the literal `expected`. */
|
|
24
|
+
const hasLiteralOption = (options, name, expected) => Option.exists(Option.flatMap(options, (node) => propertyLiteral(node, name)), (literal) => literal.value === expected);
|
|
18
25
|
const pipedToAsVoid = (node, effectNamespaces) => {
|
|
19
26
|
const member = node.parent;
|
|
20
27
|
if (member?.type !== "MemberExpression" || member.object !== node || member.computed || member.property.type !== "Identifier" || member.property.name !== "pipe") return false;
|
|
@@ -46,9 +53,9 @@ const noSequentialEffectAll = define({
|
|
|
46
53
|
const namespaces = visibleNamespaces(ctx, node, effectNamespaces);
|
|
47
54
|
if (!isStaticMember(node.callee, namespaces, "all")) return Effect.void;
|
|
48
55
|
if (node.arguments[0]?.type !== "ArrayExpression") return Effect.void;
|
|
49
|
-
const options = node
|
|
50
|
-
if (
|
|
51
|
-
if (!(
|
|
56
|
+
const options = expressionArgument(node, 1);
|
|
57
|
+
if (!hasLiteralOption(options, "concurrency", 1)) return Effect.void;
|
|
58
|
+
if (!(hasLiteralOption(options, "discard", true) || pipedToAsVoid(node, namespaces))) return Effect.void;
|
|
52
59
|
return ctx.report(make({
|
|
53
60
|
node,
|
|
54
61
|
message: "Use Effect.andThen or one flat generator for sequential steps. Reserve Effect.all for value aggregation or real concurrency."
|
|
@@ -2,6 +2,7 @@ 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 * as Predicate from "effect/Predicate";
|
|
5
6
|
import * as Effect from "effect/Effect";
|
|
6
7
|
//#region src/rules/no-shape-in-symbol-names.ts
|
|
7
8
|
const forbiddenSymbolName = "shape";
|
|
@@ -11,7 +12,7 @@ function containsForbiddenSymbolName(name) {
|
|
|
11
12
|
/** Return whether an identifier names a statically accessed member owned by another value. */
|
|
12
13
|
function isBorrowedMemberName(node) {
|
|
13
14
|
const parent = node.parent;
|
|
14
|
-
if (parent
|
|
15
|
+
if (Predicate.isNull(parent) || parent.type !== "MemberExpression") return false;
|
|
15
16
|
return parent.property === node && !parent.computed;
|
|
16
17
|
}
|
|
17
18
|
/** Ban the case-insensitive substring "shape" in every JavaScript and TypeScript symbol name. */
|
|
@@ -24,11 +25,14 @@ const noShapeInSymbolNames = define({
|
|
|
24
25
|
}),
|
|
25
26
|
create: function* () {
|
|
26
27
|
const context = yield* RuleContext;
|
|
27
|
-
const reportForbiddenSymbolName = (node) =>
|
|
28
|
-
node
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
const reportForbiddenSymbolName = (node) => {
|
|
29
|
+
if (!containsForbiddenSymbolName(node.name) || isBorrowedMemberName(node)) return Effect.void;
|
|
30
|
+
return context.report(fromId({
|
|
31
|
+
node,
|
|
32
|
+
messageId: "forbiddenSymbolName",
|
|
33
|
+
data: { name: node.name }
|
|
34
|
+
}));
|
|
35
|
+
};
|
|
32
36
|
return {
|
|
33
37
|
Identifier: reportForbiddenSymbolName,
|
|
34
38
|
PrivateIdentifier: reportForbiddenSymbolName,
|
|
@@ -3,20 +3,24 @@ 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 { importedNamespaces, isStaticMember, visibleNamespaces } from "./_effect-namespaces.js";
|
|
6
|
+
import * as Arr from "effect/Array";
|
|
7
|
+
import * as Option from "effect/Option";
|
|
8
|
+
import * as Predicate from "effect/Predicate";
|
|
6
9
|
import * as Effect from "effect/Effect";
|
|
7
10
|
//#region src/rules/no-silent-catch-all.ts
|
|
8
|
-
const isFunction = (node) => node
|
|
11
|
+
const isFunction = (node) => node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression";
|
|
9
12
|
const isEffectVoid = (node, effectNamespaces) => isStaticMember(node, effectNamespaces, "void");
|
|
10
13
|
const silentlyReturnsVoid = (node, effectNamespaces) => {
|
|
11
|
-
|
|
12
|
-
if (node.
|
|
13
|
-
if (
|
|
14
|
-
|
|
15
|
-
return statement
|
|
14
|
+
const body = node.body;
|
|
15
|
+
if (node.params.length !== 0 || Predicate.isNull(body)) return false;
|
|
16
|
+
if (body.type !== "BlockStatement") return isEffectVoid(body, effectNamespaces);
|
|
17
|
+
if (body.body.length !== 1) return false;
|
|
18
|
+
return Option.exists(Arr.head(body.body), (statement) => statement.type === "ReturnStatement" && Predicate.isNotNull(statement.argument) && isEffectVoid(statement.argument, effectNamespaces));
|
|
16
19
|
};
|
|
20
|
+
/** The handler of a data-last (one argument) or data-first catch call. */
|
|
17
21
|
const handlerArgument = (node) => {
|
|
18
|
-
if (node.arguments.length === 1) return node.arguments
|
|
19
|
-
return node.arguments
|
|
22
|
+
if (node.arguments.length === 1) return Arr.get(node.arguments, 0);
|
|
23
|
+
return Arr.get(node.arguments, 1);
|
|
20
24
|
};
|
|
21
25
|
const noSilentCatchAll = define({
|
|
22
26
|
name: "no-silent-catch-all",
|
|
@@ -37,12 +41,14 @@ const noSilentCatchAll = define({
|
|
|
37
41
|
if (node.type !== "CallExpression" || node.callee.type === "Super") return Effect.void;
|
|
38
42
|
const namespaces = visibleNamespaces(ctx, node, effectNamespaces);
|
|
39
43
|
if (!(isStaticMember(node.callee, namespaces, "catchAll") || isStaticMember(node.callee, namespaces, "catchAllCause"))) return Effect.void;
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
const silentHandler = Option.filter(Option.filter(handlerArgument(node), isFunction), (handler) => silentlyReturnsVoid(handler, namespaces));
|
|
45
|
+
return Option.match(silentHandler, {
|
|
46
|
+
onNone: () => Effect.void,
|
|
47
|
+
onSome: (handler) => ctx.report(make({
|
|
48
|
+
node: handler,
|
|
49
|
+
message: "Do not erase every failure with Effect.void. Recover a typed failure truthfully or record the unexpected failure before recovery."
|
|
50
|
+
}))
|
|
51
|
+
});
|
|
46
52
|
}
|
|
47
53
|
};
|
|
48
54
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { memberNames, narrow } from "../vendor/effect-oxlint/AST.js";
|
|
2
|
+
import { make } from "../vendor/effect-oxlint/Diagnostic.js";
|
|
3
|
+
import { RuleContext } from "../vendor/effect-oxlint/RuleContext.js";
|
|
4
|
+
import { define, meta } from "../vendor/effect-oxlint/Rule.js";
|
|
5
|
+
import { findVariableUp, getReferences, throughReferences, variables } from "../vendor/effect-oxlint/Scope.js";
|
|
6
|
+
import "../vendor/effect-oxlint/index.js";
|
|
7
|
+
import { isTestFile, skipFile } from "./_test-files.js";
|
|
8
|
+
import { collectTestBindings, resolveTestBinding } from "./_test-apis.js";
|
|
9
|
+
import * as Option from "effect/Option";
|
|
10
|
+
import * as Effect from "effect/Effect";
|
|
11
|
+
//#region src/rules/no-test-globals.ts
|
|
12
|
+
const globalRoots = new Set([
|
|
13
|
+
"global",
|
|
14
|
+
"globalThis",
|
|
15
|
+
"self",
|
|
16
|
+
"window"
|
|
17
|
+
]);
|
|
18
|
+
const mutatingCalls = new Map([["Object", new Set([
|
|
19
|
+
"assign",
|
|
20
|
+
"defineProperties",
|
|
21
|
+
"defineProperty"
|
|
22
|
+
])], ["Reflect", new Set([
|
|
23
|
+
"defineProperty",
|
|
24
|
+
"deleteProperty",
|
|
25
|
+
"set"
|
|
26
|
+
])]]);
|
|
27
|
+
const stubbingMethods = new Set([
|
|
28
|
+
"replaceProperty",
|
|
29
|
+
"stubEnv",
|
|
30
|
+
"stubGlobal",
|
|
31
|
+
"unstubAllEnvs",
|
|
32
|
+
"unstubAllGlobals"
|
|
33
|
+
]);
|
|
34
|
+
const runnerGlobals = new Set([
|
|
35
|
+
"afterAll",
|
|
36
|
+
"afterEach",
|
|
37
|
+
"beforeAll",
|
|
38
|
+
"beforeEach",
|
|
39
|
+
"describe",
|
|
40
|
+
"expect",
|
|
41
|
+
"fdescribe",
|
|
42
|
+
"fit",
|
|
43
|
+
"it",
|
|
44
|
+
"jest",
|
|
45
|
+
"mock",
|
|
46
|
+
"spyOn",
|
|
47
|
+
"test",
|
|
48
|
+
"vi",
|
|
49
|
+
"xdescribe",
|
|
50
|
+
"xit",
|
|
51
|
+
"xtest"
|
|
52
|
+
]);
|
|
53
|
+
const capabilityHint = "Provide the capability through an Effect service or runtime input.";
|
|
54
|
+
const innermostMember = (node) => {
|
|
55
|
+
if (node.object.type === "MemberExpression") return innermostMember(node.object);
|
|
56
|
+
return node;
|
|
57
|
+
};
|
|
58
|
+
const isStaticProperty = (node, name) => !node.computed && node.property.type === "Identifier" && node.property.name === name;
|
|
59
|
+
/**
|
|
60
|
+
* References to runner names that no import or declaration provides: unresolved
|
|
61
|
+
* references, plus references to globals the linter configuration declares.
|
|
62
|
+
*/
|
|
63
|
+
const runnerGlobalReferences = (globalScope) => {
|
|
64
|
+
const ambient = variables(globalScope).filter((variable) => variable.defs.length === 0).flatMap((variable) => getReferences(variable));
|
|
65
|
+
return [...new Map([...throughReferences(globalScope), ...ambient].map((reference) => [reference.identifier, reference])).values()].filter((reference) => runnerGlobals.has(reference.identifier.name));
|
|
66
|
+
};
|
|
67
|
+
const noTestGlobals = define({
|
|
68
|
+
name: "no-test-globals",
|
|
69
|
+
meta: meta({
|
|
70
|
+
type: "problem",
|
|
71
|
+
description: "Keep tests off shared global state and implicit test globals."
|
|
72
|
+
}),
|
|
73
|
+
create: function* () {
|
|
74
|
+
const ctx = yield* RuleContext;
|
|
75
|
+
if (!isTestFile(ctx.filename)) return skipFile;
|
|
76
|
+
const bindings = /* @__PURE__ */ new Map();
|
|
77
|
+
const isUnshadowedGlobal = (node, name) => Option.match(findVariableUp(ctx.sourceCode.getScope(node), name), {
|
|
78
|
+
onNone: () => true,
|
|
79
|
+
onSome: (variable) => variable.defs.length === 0
|
|
80
|
+
});
|
|
81
|
+
const isGlobalRoot = (node) => node.type === "Identifier" && globalRoots.has(node.name) && isUnshadowedGlobal(node, node.name);
|
|
82
|
+
/** A global object, a member chain rooted at one, or a chain rooted at `process.env`. */
|
|
83
|
+
const isGlobalState = (node) => {
|
|
84
|
+
if (node.type !== "MemberExpression") return isGlobalRoot(node);
|
|
85
|
+
const innermost = innermostMember(node);
|
|
86
|
+
if (isGlobalRoot(innermost.object)) return true;
|
|
87
|
+
return innermost.object.type === "Identifier" && innermost.object.name === "process" && isStaticProperty(innermost, "env") && isUnshadowedGlobal(innermost, "process");
|
|
88
|
+
};
|
|
89
|
+
const reportWrite = (target) => {
|
|
90
|
+
if (target.type !== "MemberExpression" || !isGlobalState(target)) return Effect.void;
|
|
91
|
+
return ctx.report(make({
|
|
92
|
+
node: target,
|
|
93
|
+
message: `Avoid writing ${ctx.sourceCode.getText(target)} in tests. ${capabilityHint}`
|
|
94
|
+
}));
|
|
95
|
+
};
|
|
96
|
+
const mutatedGlobal = (call) => {
|
|
97
|
+
if (call.callee.type !== "MemberExpression") return Option.none();
|
|
98
|
+
const target = Option.filter(Option.fromUndefinedOr(call.arguments[0]), (argument) => argument.type !== "SpreadElement" && isGlobalState(argument));
|
|
99
|
+
if (Option.isNone(target)) return Option.none();
|
|
100
|
+
return Option.map(Option.filter(memberNames(call.callee), ([object, method]) => Option.exists(Option.fromUndefinedOr(mutatingCalls.get(object)), (methods) => methods.has(method)) && isUnshadowedGlobal(call, object)), ([object, method]) => `${object}.${method}() on ${ctx.sourceCode.getText(target.value)}`);
|
|
101
|
+
};
|
|
102
|
+
const stubbingCall = (call) => {
|
|
103
|
+
if (call.callee.type !== "MemberExpression") return Option.none();
|
|
104
|
+
const scope = ctx.sourceCode.getScope(call);
|
|
105
|
+
return Option.flatMap(Option.filter(memberNames(call.callee), ([, method]) => stubbingMethods.has(method)), ([object, method]) => Option.map(Option.filter(resolveTestBinding(scope, object, bindings), (binding) => binding === "jest" || binding === "vi"), (binding) => `${binding}.${method}()`));
|
|
106
|
+
};
|
|
107
|
+
const reportCall = (call) => Option.match(Option.orElse(mutatedGlobal(call), () => stubbingCall(call)), {
|
|
108
|
+
onNone: () => Effect.void,
|
|
109
|
+
onSome: (used) => ctx.report(make({
|
|
110
|
+
node: call,
|
|
111
|
+
message: `Avoid ${used} in tests. ${capabilityHint}`
|
|
112
|
+
}))
|
|
113
|
+
});
|
|
114
|
+
const reportRunnerGlobal = (reference) => ctx.report(make({
|
|
115
|
+
node: reference.identifier,
|
|
116
|
+
message: `Import ${reference.identifier.name} from your test library instead of using the runner global.`
|
|
117
|
+
}));
|
|
118
|
+
return {
|
|
119
|
+
ImportDeclaration: (node) => {
|
|
120
|
+
collectTestBindings(node, bindings);
|
|
121
|
+
return Effect.void;
|
|
122
|
+
},
|
|
123
|
+
AssignmentExpression: (node) => Option.match(narrow(node, "AssignmentExpression"), {
|
|
124
|
+
onNone: () => Effect.void,
|
|
125
|
+
onSome: (assignment) => reportWrite(assignment.left)
|
|
126
|
+
}),
|
|
127
|
+
UpdateExpression: (node) => Option.match(narrow(node, "UpdateExpression"), {
|
|
128
|
+
onNone: () => Effect.void,
|
|
129
|
+
onSome: (update) => reportWrite(update.argument)
|
|
130
|
+
}),
|
|
131
|
+
UnaryExpression: (node) => Option.match(Option.filter(narrow(node, "UnaryExpression"), (unary) => unary.operator === "delete"), {
|
|
132
|
+
onNone: () => Effect.void,
|
|
133
|
+
onSome: (unary) => reportWrite(unary.argument)
|
|
134
|
+
}),
|
|
135
|
+
CallExpression: (node) => Option.match(narrow(node, "CallExpression"), {
|
|
136
|
+
onNone: () => Effect.void,
|
|
137
|
+
onSome: reportCall
|
|
138
|
+
}),
|
|
139
|
+
"Program:exit": () => Option.match(Option.fromNullishOr(ctx.sourceCode.scopeManager.globalScope), {
|
|
140
|
+
onNone: () => Effect.void,
|
|
141
|
+
onSome: (globalScope) => Effect.forEach(runnerGlobalReferences(globalScope), reportRunnerGlobal, { discard: true })
|
|
142
|
+
})
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
//#endregion
|
|
147
|
+
export { noTestGlobals };
|
|
@@ -6,6 +6,7 @@ import "../vendor/effect-oxlint/index.js";
|
|
|
6
6
|
import * as Option from "effect/Option";
|
|
7
7
|
import * as Effect from "effect/Effect";
|
|
8
8
|
//#region src/rules/no-test-lifecycle-hooks.ts
|
|
9
|
+
/** Ban test lifecycle hooks in favor of Effect scopes and scoped test variants. */
|
|
9
10
|
const lifecycleHooks = new Set([
|
|
10
11
|
"afterAll",
|
|
11
12
|
"afterEach",
|
|
@@ -23,8 +24,9 @@ const noTestLifecycleHooks = define({
|
|
|
23
24
|
return { CallExpression: (node) => Option.match(narrow(node, "CallExpression"), {
|
|
24
25
|
onNone: () => Effect.void,
|
|
25
26
|
onSome: (call) => {
|
|
26
|
-
const
|
|
27
|
-
if (
|
|
27
|
+
const hook = Option.filter(calleeName(call), (name) => lifecycleHooks.has(name));
|
|
28
|
+
if (Option.isNone(hook)) return Effect.void;
|
|
29
|
+
const name = hook.value;
|
|
28
30
|
return ctx.report(make({
|
|
29
31
|
node: call,
|
|
30
32
|
message: `Avoid ${name}(). Use effect-bun-test scoped tests and Effect.acquireRelease for fixture lifecycles.`
|