oxlint-plugin-effect 0.9.0 → 0.11.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 +32 -20
- package/dist/presets/recommended.d.ts +12 -0
- package/dist/presets/recommended.js +13 -1
- package/dist/rules/_effect-context.js +27 -0
- package/dist/rules/_effect-namespaces.js +25 -0
- package/dist/rules/_tagged-control-flow.js +1 -1
- package/dist/rules/index.d.ts +13 -1
- package/dist/rules/index.js +25 -1
- package/dist/rules/no-inline-provide.d.ts +6 -0
- package/dist/rules/no-inline-provide.js +39 -0
- package/dist/rules/no-managed-runtime-in-effect.d.ts +6 -0
- package/dist/rules/no-managed-runtime-in-effect.js +41 -0
- package/dist/rules/no-module-mocks.d.ts +6 -0
- package/dist/rules/no-module-mocks.js +73 -0
- package/dist/rules/no-nested-effect-gen.d.ts +6 -0
- package/dist/rules/no-nested-effect-gen.js +39 -0
- package/dist/rules/no-per-call-cache-construction.d.ts +6 -0
- package/dist/rules/no-per-call-cache-construction.js +52 -0
- package/dist/rules/no-run-collect-on-unbounded-stream.d.ts +6 -0
- package/dist/rules/no-run-collect-on-unbounded-stream.js +81 -0
- package/dist/rules/no-sequential-effect-all.d.ts +6 -0
- package/dist/rules/no-sequential-effect-all.js +61 -0
- package/dist/rules/no-silent-catch-all.d.ts +6 -0
- package/dist/rules/no-silent-catch-all.js +51 -0
- package/dist/rules/no-unbounded-concurrency.d.ts +6 -0
- package/dist/rules/no-unbounded-concurrency.js +50 -0
- package/dist/rules/no-unbounded-retry.d.ts +6 -0
- package/dist/rules/no-unbounded-retry.js +114 -0
- package/dist/rules/prefer-catch-tag.js +33 -6
- package/dist/rules/prefer-match-tags-exhaustive.js +62 -8
- package/dist/rules/prefer-service-of.d.ts +6 -0
- package/dist/rules/prefer-service-of.js +65 -0
- package/dist/rules/require-named-effect-fn.d.ts +6 -0
- package/dist/rules/require-named-effect-fn.js +37 -0
- package/package.json +1 -1
|
@@ -0,0 +1,81 @@
|
|
|
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 * as Effect from "effect/Effect";
|
|
7
|
+
//#region src/rules/no-run-collect-on-unbounded-stream.ts
|
|
8
|
+
const unboundedSources = new Set([
|
|
9
|
+
"fromPubSub",
|
|
10
|
+
"fromPubSubTake",
|
|
11
|
+
"fromQueue",
|
|
12
|
+
"repeat"
|
|
13
|
+
]);
|
|
14
|
+
const collectOperations = new Set(["runCollect"]);
|
|
15
|
+
const terminatingOperations = new Set([
|
|
16
|
+
"take",
|
|
17
|
+
"takeUntil",
|
|
18
|
+
"takeUntilEffect",
|
|
19
|
+
"takeWhile",
|
|
20
|
+
"takeWhileEffect",
|
|
21
|
+
"timeout",
|
|
22
|
+
"timeoutOrElse"
|
|
23
|
+
]);
|
|
24
|
+
const staticOperation = (node, streamNamespaces, operations) => {
|
|
25
|
+
if (node.type === "SpreadElement") return false;
|
|
26
|
+
const candidate = node.type === "CallExpression" && node.callee.type !== "Super" ? node.callee : node;
|
|
27
|
+
for (const operation of operations) if (isStaticMember(candidate, streamNamespaces, operation)) return true;
|
|
28
|
+
return false;
|
|
29
|
+
};
|
|
30
|
+
const unboundedRoot = (node, streamNamespaces) => {
|
|
31
|
+
if (node.type !== "CallExpression" || node.callee.type === "Super") return void 0;
|
|
32
|
+
for (const source of unboundedSources) if (isStaticMember(node.callee, streamNamespaces, source)) return node;
|
|
33
|
+
if (node.callee.type !== "MemberExpression" || node.callee.computed || node.callee.property.type !== "Identifier" || node.callee.property.name !== "pipe") return;
|
|
34
|
+
const root = unboundedRoot(node.callee.object, streamNamespaces);
|
|
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;
|
|
38
|
+
};
|
|
39
|
+
const collectedSource = (node, streamNamespaces) => {
|
|
40
|
+
if (node.callee.type === "Super") return void 0;
|
|
41
|
+
if (isStaticMember(node.callee, streamNamespaces, "runCollect")) {
|
|
42
|
+
const source = node.arguments[0];
|
|
43
|
+
if (source !== void 0 && source.type !== "SpreadElement") return source;
|
|
44
|
+
return;
|
|
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;
|
|
49
|
+
};
|
|
50
|
+
const noRunCollectOnUnboundedStream = define({
|
|
51
|
+
name: "no-run-collect-on-unbounded-stream",
|
|
52
|
+
meta: meta({
|
|
53
|
+
type: "problem",
|
|
54
|
+
description: "Do not collect a clearly unbounded Stream without a terminating operation."
|
|
55
|
+
}),
|
|
56
|
+
create: function* () {
|
|
57
|
+
const ctx = yield* RuleContext;
|
|
58
|
+
const streamNamespaces = new Set(["Stream"]);
|
|
59
|
+
return {
|
|
60
|
+
ImportDeclaration: (node) => {
|
|
61
|
+
if (node.type !== "ImportDeclaration") return Effect.void;
|
|
62
|
+
for (const name of importedNamespaces(node, "Stream", "effect/Stream")) streamNamespaces.add(name);
|
|
63
|
+
return Effect.void;
|
|
64
|
+
},
|
|
65
|
+
CallExpression: (node) => {
|
|
66
|
+
if (node.type !== "CallExpression") return Effect.void;
|
|
67
|
+
const namespaces = visibleNamespaces(ctx, node, streamNamespaces);
|
|
68
|
+
const source = collectedSource(node, namespaces);
|
|
69
|
+
if (source === void 0) return Effect.void;
|
|
70
|
+
const root = unboundedRoot(source, namespaces);
|
|
71
|
+
if (root === void 0) return Effect.void;
|
|
72
|
+
return ctx.report(make({
|
|
73
|
+
node: root,
|
|
74
|
+
message: "Do not collect a clearly unbounded Stream. Add a terminating operation or consume it with runForEach or runDrain."
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
//#endregion
|
|
81
|
+
export { noRunCollectOnUnboundedStream };
|
|
@@ -0,0 +1,61 @@
|
|
|
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 * as Effect from "effect/Effect";
|
|
7
|
+
//#region src/rules/no-sequential-effect-all.ts
|
|
8
|
+
const propertyLiteral = (node, name) => {
|
|
9
|
+
if (node?.type !== "ObjectExpression") return void 0;
|
|
10
|
+
for (const property of node.properties) {
|
|
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
|
+
}
|
|
17
|
+
};
|
|
18
|
+
const pipedToAsVoid = (node, effectNamespaces) => {
|
|
19
|
+
const member = node.parent;
|
|
20
|
+
if (member?.type !== "MemberExpression" || member.object !== node || member.computed || member.property.type !== "Identifier" || member.property.name !== "pipe") return false;
|
|
21
|
+
const pipe = member.parent;
|
|
22
|
+
if (pipe?.type !== "CallExpression") return false;
|
|
23
|
+
for (const operation of pipe.arguments) {
|
|
24
|
+
if (operation.type === "SpreadElement") continue;
|
|
25
|
+
if (isStaticMember(operation, effectNamespaces, "asVoid")) return true;
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
};
|
|
29
|
+
const noSequentialEffectAll = define({
|
|
30
|
+
name: "no-sequential-effect-all",
|
|
31
|
+
meta: meta({
|
|
32
|
+
type: "suggestion",
|
|
33
|
+
description: "Use explicit sequencing instead of a serial Effect.all whose result is discarded."
|
|
34
|
+
}),
|
|
35
|
+
create: function* () {
|
|
36
|
+
const ctx = yield* RuleContext;
|
|
37
|
+
const effectNamespaces = new Set(["Effect"]);
|
|
38
|
+
return {
|
|
39
|
+
ImportDeclaration: (node) => {
|
|
40
|
+
if (node.type !== "ImportDeclaration") return Effect.void;
|
|
41
|
+
for (const name of importedNamespaces(node, "Effect", "effect/Effect")) effectNamespaces.add(name);
|
|
42
|
+
return Effect.void;
|
|
43
|
+
},
|
|
44
|
+
CallExpression: (node) => {
|
|
45
|
+
if (node.type !== "CallExpression" || node.callee.type === "Super") return Effect.void;
|
|
46
|
+
const namespaces = visibleNamespaces(ctx, node, effectNamespaces);
|
|
47
|
+
if (!isStaticMember(node.callee, namespaces, "all")) return Effect.void;
|
|
48
|
+
if (node.arguments[0]?.type !== "ArrayExpression") return Effect.void;
|
|
49
|
+
const options = node.arguments[1];
|
|
50
|
+
if (propertyLiteral(options, "concurrency") !== 1) return Effect.void;
|
|
51
|
+
if (!(propertyLiteral(options, "discard") === true || pipedToAsVoid(node, namespaces))) return Effect.void;
|
|
52
|
+
return ctx.report(make({
|
|
53
|
+
node,
|
|
54
|
+
message: "Use Effect.andThen or one flat generator for sequential steps. Reserve Effect.all for value aggregation or real concurrency."
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
//#endregion
|
|
61
|
+
export { noSequentialEffectAll };
|
|
@@ -0,0 +1,51 @@
|
|
|
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 * as Effect from "effect/Effect";
|
|
7
|
+
//#region src/rules/no-silent-catch-all.ts
|
|
8
|
+
const isFunction = (node) => node?.type === "ArrowFunctionExpression" || node?.type === "FunctionExpression";
|
|
9
|
+
const isEffectVoid = (node, effectNamespaces) => isStaticMember(node, effectNamespaces, "void");
|
|
10
|
+
const silentlyReturnsVoid = (node, effectNamespaces) => {
|
|
11
|
+
if (node.params.length !== 0 || node.body === null) return false;
|
|
12
|
+
if (node.body.type !== "BlockStatement") return isEffectVoid(node.body, effectNamespaces);
|
|
13
|
+
if (node.body.body.length !== 1) return false;
|
|
14
|
+
const statement = node.body.body[0];
|
|
15
|
+
return statement?.type === "ReturnStatement" && statement.argument !== null && isEffectVoid(statement.argument, effectNamespaces);
|
|
16
|
+
};
|
|
17
|
+
const handlerArgument = (node) => {
|
|
18
|
+
if (node.arguments.length === 1) return node.arguments[0];
|
|
19
|
+
return node.arguments[1];
|
|
20
|
+
};
|
|
21
|
+
const noSilentCatchAll = define({
|
|
22
|
+
name: "no-silent-catch-all",
|
|
23
|
+
meta: meta({
|
|
24
|
+
type: "problem",
|
|
25
|
+
description: "Do not silently erase every failure with Effect.void."
|
|
26
|
+
}),
|
|
27
|
+
create: function* () {
|
|
28
|
+
const ctx = yield* RuleContext;
|
|
29
|
+
const effectNamespaces = new Set(["Effect"]);
|
|
30
|
+
return {
|
|
31
|
+
ImportDeclaration: (node) => {
|
|
32
|
+
if (node.type !== "ImportDeclaration") return Effect.void;
|
|
33
|
+
for (const name of importedNamespaces(node, "Effect", "effect/Effect")) effectNamespaces.add(name);
|
|
34
|
+
return Effect.void;
|
|
35
|
+
},
|
|
36
|
+
CallExpression: (node) => {
|
|
37
|
+
if (node.type !== "CallExpression" || node.callee.type === "Super") return Effect.void;
|
|
38
|
+
const namespaces = visibleNamespaces(ctx, node, effectNamespaces);
|
|
39
|
+
if (!(isStaticMember(node.callee, namespaces, "catchAll") || isStaticMember(node.callee, namespaces, "catchAllCause"))) return Effect.void;
|
|
40
|
+
const handler = handlerArgument(node);
|
|
41
|
+
if (!isFunction(handler) || !silentlyReturnsVoid(handler, namespaces)) return Effect.void;
|
|
42
|
+
return ctx.report(make({
|
|
43
|
+
node: handler,
|
|
44
|
+
message: "Do not erase every failure with Effect.void. Recover a typed failure truthfully or record the unexpected failure before recovery."
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
//#endregion
|
|
51
|
+
export { noSilentCatchAll };
|
|
@@ -0,0 +1,50 @@
|
|
|
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 * as Effect from "effect/Effect";
|
|
7
|
+
//#region src/rules/no-unbounded-concurrency.ts
|
|
8
|
+
const unboundedConcurrency = (node) => {
|
|
9
|
+
if (node?.type !== "ObjectExpression") return void 0;
|
|
10
|
+
for (const property of node.properties) {
|
|
11
|
+
if (property.type !== "Property" || property.computed) continue;
|
|
12
|
+
if (!(property.key.type === "Identifier" && property.key.name === "concurrency" || property.key.type === "Literal" && property.key.value === "concurrency")) continue;
|
|
13
|
+
if (property.value.type === "Literal" && property.value.value === "unbounded") return property.value;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const fixedCollection = (node) => node?.type === "ArrayExpression" || node?.type === "ObjectExpression";
|
|
17
|
+
const noUnboundedConcurrency = define({
|
|
18
|
+
name: "no-unbounded-concurrency",
|
|
19
|
+
meta: meta({
|
|
20
|
+
type: "problem",
|
|
21
|
+
description: "Require finite concurrency for Effect operations over collections that can grow."
|
|
22
|
+
}),
|
|
23
|
+
create: function* () {
|
|
24
|
+
const ctx = yield* RuleContext;
|
|
25
|
+
const effectNamespaces = new Set(["Effect"]);
|
|
26
|
+
return {
|
|
27
|
+
ImportDeclaration: (node) => {
|
|
28
|
+
if (node.type !== "ImportDeclaration") return Effect.void;
|
|
29
|
+
for (const name of importedNamespaces(node, "Effect", "effect/Effect")) effectNamespaces.add(name);
|
|
30
|
+
return Effect.void;
|
|
31
|
+
},
|
|
32
|
+
CallExpression: (node) => {
|
|
33
|
+
if (node.type !== "CallExpression" || node.callee.type === "Super") return Effect.void;
|
|
34
|
+
const namespaces = visibleNamespaces(ctx, node, effectNamespaces);
|
|
35
|
+
let optionsIndex;
|
|
36
|
+
if (isStaticMember(node.callee, namespaces, "all")) optionsIndex = 1;
|
|
37
|
+
if (isStaticMember(node.callee, namespaces, "forEach")) optionsIndex = 2;
|
|
38
|
+
if (optionsIndex === void 0 || fixedCollection(node.arguments[0])) return Effect.void;
|
|
39
|
+
const unbounded = unboundedConcurrency(node.arguments[optionsIndex]);
|
|
40
|
+
if (unbounded === void 0) return Effect.void;
|
|
41
|
+
return ctx.report(make({
|
|
42
|
+
node: unbounded,
|
|
43
|
+
message: "Bound concurrency for a collection that can grow. Use a finite concurrency value."
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
//#endregion
|
|
50
|
+
export { noUnboundedConcurrency };
|
|
@@ -0,0 +1,114 @@
|
|
|
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, isStaticCall, isStaticMember, visibleNamespaces } from "./_effect-namespaces.js";
|
|
6
|
+
import * as Effect from "effect/Effect";
|
|
7
|
+
//#region src/rules/no-unbounded-retry.ts
|
|
8
|
+
const unboundedConstructors = new Set([
|
|
9
|
+
"exponential",
|
|
10
|
+
"fibonacci",
|
|
11
|
+
"spaced"
|
|
12
|
+
]);
|
|
13
|
+
const boundedCombinators = new Set(["take"]);
|
|
14
|
+
const staticPropertyName = (node) => {
|
|
15
|
+
if (node.type !== "MemberExpression" || node.computed || node.property.type !== "Identifier") return;
|
|
16
|
+
return node.property.name;
|
|
17
|
+
};
|
|
18
|
+
const hasOwnProperty = (node, name) => {
|
|
19
|
+
for (const property of node.properties) {
|
|
20
|
+
if (property.type !== "Property" || property.computed) continue;
|
|
21
|
+
if (property.key.type === "Identifier" && property.key.name === name) return true;
|
|
22
|
+
if (property.key.type === "Literal" && property.key.value === name) return true;
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
};
|
|
26
|
+
const propertyValue = (node, name) => {
|
|
27
|
+
for (const property of node.properties) {
|
|
28
|
+
if (property.type !== "Property" || property.computed) continue;
|
|
29
|
+
if (property.key.type === "Identifier" && property.key.name === name || property.key.type === "Literal" && property.key.value === name) return property.value;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const isBoundedOperation = (node, scheduleNamespaces) => {
|
|
33
|
+
if (node.type !== "CallExpression" || node.callee.type === "Super") return false;
|
|
34
|
+
const property = staticPropertyName(node.callee);
|
|
35
|
+
if (property === void 0) return false;
|
|
36
|
+
if (boundedCombinators.has(property) && isStaticMember(node.callee, scheduleNamespaces, property)) return true;
|
|
37
|
+
if (!isStaticMember(node.callee, scheduleNamespaces, "both")) return false;
|
|
38
|
+
const bound = node.arguments[0];
|
|
39
|
+
return bound !== void 0 && bound.type !== "SpreadElement" && isStaticCall(bound, scheduleNamespaces, "recurs");
|
|
40
|
+
};
|
|
41
|
+
const unboundedSchedule = (node, scheduleNamespaces) => {
|
|
42
|
+
if (node.type === "MemberExpression" && isStaticMember(node, scheduleNamespaces, "forever")) return node;
|
|
43
|
+
if (node.type !== "CallExpression" || node.callee.type === "Super") return void 0;
|
|
44
|
+
const property = staticPropertyName(node.callee);
|
|
45
|
+
if (property !== void 0 && unboundedConstructors.has(property) && isStaticMember(node.callee, scheduleNamespaces, property)) return node;
|
|
46
|
+
if (property !== "pipe" || node.callee.type !== "MemberExpression") return void 0;
|
|
47
|
+
const root = unboundedSchedule(node.callee.object, scheduleNamespaces);
|
|
48
|
+
if (root === void 0) return void 0;
|
|
49
|
+
for (const operation of node.arguments) if (isBoundedOperation(operation, scheduleNamespaces)) return void 0;
|
|
50
|
+
return root;
|
|
51
|
+
};
|
|
52
|
+
const retryPolicy = (node, effectNamespaces, streamNamespaces, httpClientNamespaces) => {
|
|
53
|
+
if (node.callee.type === "Super") return void 0;
|
|
54
|
+
let policyIndex;
|
|
55
|
+
if (isStaticMember(node.callee, effectNamespaces, "retry")) {
|
|
56
|
+
if (node.arguments.length === 1) policyIndex = 0;
|
|
57
|
+
if (node.arguments.length >= 2) policyIndex = 1;
|
|
58
|
+
}
|
|
59
|
+
if (isStaticMember(node.callee, effectNamespaces, "retryOrElse")) {
|
|
60
|
+
if (node.arguments.length === 2) policyIndex = 0;
|
|
61
|
+
if (node.arguments.length >= 3) policyIndex = 1;
|
|
62
|
+
}
|
|
63
|
+
if (isStaticMember(node.callee, streamNamespaces, "retry")) {
|
|
64
|
+
if (node.arguments.length === 1) policyIndex = 0;
|
|
65
|
+
if (node.arguments.length >= 2) policyIndex = 1;
|
|
66
|
+
}
|
|
67
|
+
if (isStaticMember(node.callee, httpClientNamespaces, "retryTransient")) {
|
|
68
|
+
if (node.arguments.length === 1) policyIndex = 0;
|
|
69
|
+
if (node.arguments.length >= 2) policyIndex = 1;
|
|
70
|
+
}
|
|
71
|
+
if (policyIndex === void 0) return void 0;
|
|
72
|
+
const policy = node.arguments[policyIndex];
|
|
73
|
+
if (policy?.type === "SpreadElement") return void 0;
|
|
74
|
+
if (policy?.type !== "ObjectExpression") return policy;
|
|
75
|
+
if (hasOwnProperty(policy, "times")) return void 0;
|
|
76
|
+
return propertyValue(policy, "schedule");
|
|
77
|
+
};
|
|
78
|
+
const noUnboundedRetry = define({
|
|
79
|
+
name: "no-unbounded-retry",
|
|
80
|
+
meta: meta({
|
|
81
|
+
type: "problem",
|
|
82
|
+
description: "Require retry policies to have an explicit attempt or duration bound."
|
|
83
|
+
}),
|
|
84
|
+
create: function* () {
|
|
85
|
+
const ctx = yield* RuleContext;
|
|
86
|
+
const effectNamespaces = new Set(["Effect"]);
|
|
87
|
+
const httpClientNamespaces = new Set(["HttpClient"]);
|
|
88
|
+
const scheduleNamespaces = new Set(["Schedule"]);
|
|
89
|
+
const streamNamespaces = new Set(["Stream"]);
|
|
90
|
+
return {
|
|
91
|
+
ImportDeclaration: (node) => {
|
|
92
|
+
if (node.type !== "ImportDeclaration") return Effect.void;
|
|
93
|
+
for (const name of importedNamespaces(node, "Effect", "effect/Effect")) effectNamespaces.add(name);
|
|
94
|
+
for (const name of importedNamespaces(node, "HttpClient", "effect/unstable/http/HttpClient")) httpClientNamespaces.add(name);
|
|
95
|
+
for (const name of importedNamespaces(node, "Schedule", "effect/Schedule")) scheduleNamespaces.add(name);
|
|
96
|
+
for (const name of importedNamespaces(node, "Stream", "effect/Stream")) streamNamespaces.add(name);
|
|
97
|
+
return Effect.void;
|
|
98
|
+
},
|
|
99
|
+
CallExpression: (node) => {
|
|
100
|
+
if (node.type !== "CallExpression") return Effect.void;
|
|
101
|
+
const policy = retryPolicy(node, visibleNamespaces(ctx, node, effectNamespaces), visibleNamespaces(ctx, node, streamNamespaces), visibleNamespaces(ctx, node, httpClientNamespaces));
|
|
102
|
+
if (policy === void 0) return Effect.void;
|
|
103
|
+
const unbounded = unboundedSchedule(policy, visibleNamespaces(ctx, node, scheduleNamespaces));
|
|
104
|
+
if (unbounded === void 0) return Effect.void;
|
|
105
|
+
return ctx.report(make({
|
|
106
|
+
node: unbounded,
|
|
107
|
+
message: "Bound retry attempts or elapsed time. Add times or compose the schedule with Schedule.recurs or Schedule.take."
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
//#endregion
|
|
114
|
+
export { noUnboundedRetry };
|
|
@@ -2,7 +2,7 @@ import { make } from "../vendor/effect-oxlint/Diagnostic.js";
|
|
|
2
2
|
import { RuleContext } from "../vendor/effect-oxlint/RuleContext.js";
|
|
3
3
|
import { define, meta } from "../vendor/effect-oxlint/Rule.js";
|
|
4
4
|
import "../vendor/effect-oxlint/index.js";
|
|
5
|
-
import { isEffectCall, tagComparisonsInOr } from "./_tagged-control-flow.js";
|
|
5
|
+
import { isEffectCall, tagComparisonsInOr, taggedSwitchSubject } from "./_tagged-control-flow.js";
|
|
6
6
|
import * as Effect from "effect/Effect";
|
|
7
7
|
//#region src/rules/prefer-catch-tag.ts
|
|
8
8
|
const returnedExpression = (fn) => {
|
|
@@ -23,6 +23,32 @@ const hasManualTagPredicate = (argument) => {
|
|
|
23
23
|
if (comparisons === null || comparisons.length === 0) return false;
|
|
24
24
|
return comparisons.every((comparison) => comparison.subject === parameter.name);
|
|
25
25
|
};
|
|
26
|
+
const isFunction = (argument) => argument?.type === "ArrowFunctionExpression" || argument?.type === "FunctionExpression";
|
|
27
|
+
const hasTagCondition = (node, parameter) => {
|
|
28
|
+
const comparisons = tagComparisonsInOr(node.test);
|
|
29
|
+
if (comparisons === null || comparisons.length === 0) return false;
|
|
30
|
+
return comparisons.every((comparison) => comparison.subject === parameter);
|
|
31
|
+
};
|
|
32
|
+
const hasTagSwitch = (node, parameter) => {
|
|
33
|
+
if (taggedSwitchSubject(node) !== parameter) return false;
|
|
34
|
+
return node.cases.some((switchCase) => switchCase.test?.type === "Literal" && typeof switchCase.test.value === "string");
|
|
35
|
+
};
|
|
36
|
+
const hasManualTagDispatch = (argument) => {
|
|
37
|
+
if (!isFunction(argument) || argument.body?.type !== "BlockStatement") return false;
|
|
38
|
+
const parameter = argument.params[0];
|
|
39
|
+
if (parameter?.type !== "Identifier") return false;
|
|
40
|
+
return argument.body.body.some((statement) => {
|
|
41
|
+
if (statement.type === "IfStatement") return hasTagCondition(statement, parameter.name);
|
|
42
|
+
if (statement.type === "SwitchStatement") return hasTagSwitch(statement, parameter.name);
|
|
43
|
+
return false;
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
const catchAllHandler = (node) => {
|
|
47
|
+
if (!isEffectCall(node, "catchAll")) return void 0;
|
|
48
|
+
let handlerIndex = 1;
|
|
49
|
+
if (node.arguments.length === 1) handlerIndex = 0;
|
|
50
|
+
return node.arguments[handlerIndex];
|
|
51
|
+
};
|
|
26
52
|
const preferCatchTag = define({
|
|
27
53
|
name: "prefer-catch-tag",
|
|
28
54
|
meta: meta({
|
|
@@ -32,11 +58,12 @@ const preferCatchTag = define({
|
|
|
32
58
|
create: function* () {
|
|
33
59
|
const context = yield* RuleContext;
|
|
34
60
|
return { CallExpression: (node) => {
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
61
|
+
if (isEffectCall(node, "catchIf")) {
|
|
62
|
+
let predicateIndex = 1;
|
|
63
|
+
if (node.arguments.length === 2) predicateIndex = 0;
|
|
64
|
+
const predicate = node.arguments[predicateIndex];
|
|
65
|
+
if (!hasManualTagPredicate(predicate)) return Effect.void;
|
|
66
|
+
} else if (!hasManualTagDispatch(catchAllHandler(node))) return Effect.void;
|
|
40
67
|
return context.report(make({
|
|
41
68
|
node,
|
|
42
69
|
message: "Use Effect.catchTag for one tagged failure or Effect.catchTags for multiple tagged failures."
|
|
@@ -2,7 +2,7 @@ import { make } from "../vendor/effect-oxlint/Diagnostic.js";
|
|
|
2
2
|
import { RuleContext } from "../vendor/effect-oxlint/RuleContext.js";
|
|
3
3
|
import { define, meta } from "../vendor/effect-oxlint/Rule.js";
|
|
4
4
|
import "../vendor/effect-oxlint/index.js";
|
|
5
|
-
import { isInsideCatchAllHandler, taggedSwitchSubject } from "./_tagged-control-flow.js";
|
|
5
|
+
import { isInsideCatchAllHandler, tagComparison, taggedSwitchSubject } from "./_tagged-control-flow.js";
|
|
6
6
|
import * as Effect from "effect/Effect";
|
|
7
7
|
//#region src/rules/prefer-match-tags-exhaustive.ts
|
|
8
8
|
const statementReturns = (statement) => {
|
|
@@ -25,6 +25,51 @@ const isCompleteTransformationShape = (node) => {
|
|
|
25
25
|
}
|
|
26
26
|
return true;
|
|
27
27
|
};
|
|
28
|
+
const isElseIf = (node) => node.parent?.type === "IfStatement" && node.parent.alternate === node;
|
|
29
|
+
const isTerminalStatement = (node) => {
|
|
30
|
+
if (node.parent?.type !== "BlockStatement") return true;
|
|
31
|
+
return node.parent.body.at(-1) === node;
|
|
32
|
+
};
|
|
33
|
+
const isCompleteIfTransformationShape = (node) => {
|
|
34
|
+
if (isElseIf(node) || !isTerminalStatement(node)) return false;
|
|
35
|
+
let current = node;
|
|
36
|
+
let subject;
|
|
37
|
+
const tags = /* @__PURE__ */ new Set();
|
|
38
|
+
let branchCount = 0;
|
|
39
|
+
while (true) {
|
|
40
|
+
const comparison = tagComparison(current.test);
|
|
41
|
+
if (comparison === null || !statementReturns(current.consequent)) return false;
|
|
42
|
+
if (subject !== void 0 && comparison.subject !== subject) return false;
|
|
43
|
+
if (tags.has(comparison.tag)) return false;
|
|
44
|
+
subject = comparison.subject;
|
|
45
|
+
tags.add(comparison.tag);
|
|
46
|
+
branchCount += 1;
|
|
47
|
+
if (current.alternate === null) break;
|
|
48
|
+
if (current.alternate.type !== "IfStatement") return false;
|
|
49
|
+
current = current.alternate;
|
|
50
|
+
}
|
|
51
|
+
return branchCount >= 2;
|
|
52
|
+
};
|
|
53
|
+
const isCompleteSequentialIfTransformationShape = (node) => {
|
|
54
|
+
if (node.alternate !== null || node.parent?.type !== "BlockStatement") return false;
|
|
55
|
+
const statements = node.parent.body;
|
|
56
|
+
const index = statements.indexOf(node);
|
|
57
|
+
if (index < 0) return false;
|
|
58
|
+
const comparison = tagComparison(node.test);
|
|
59
|
+
if (comparison === null || !statementReturns(node.consequent)) return false;
|
|
60
|
+
const previous = statements[index - 1];
|
|
61
|
+
if (previous?.type === "IfStatement" && previous.alternate === null) {
|
|
62
|
+
if (tagComparison(previous.test)?.subject === comparison.subject && statementReturns(previous.consequent)) return false;
|
|
63
|
+
}
|
|
64
|
+
const tags = new Set([comparison.tag]);
|
|
65
|
+
for (const statement of statements.slice(index + 1)) {
|
|
66
|
+
if (statement.type !== "IfStatement" || statement.alternate !== null) return false;
|
|
67
|
+
const next = tagComparison(statement.test);
|
|
68
|
+
if (next === null || next.subject !== comparison.subject || tags.has(next.tag) || !statementReturns(statement.consequent)) return false;
|
|
69
|
+
tags.add(next.tag);
|
|
70
|
+
}
|
|
71
|
+
return tags.size >= 2;
|
|
72
|
+
};
|
|
28
73
|
const preferMatchTagsExhaustive = define({
|
|
29
74
|
name: "prefer-match-tags-exhaustive",
|
|
30
75
|
meta: meta({
|
|
@@ -33,13 +78,22 @@ const preferMatchTagsExhaustive = define({
|
|
|
33
78
|
}),
|
|
34
79
|
create: function* () {
|
|
35
80
|
const context = yield* RuleContext;
|
|
36
|
-
return {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
81
|
+
return {
|
|
82
|
+
SwitchStatement: (node) => {
|
|
83
|
+
if (!isCompleteTransformationShape(node) || isInsideCatchAllHandler(node)) return Effect.void;
|
|
84
|
+
return context.report(make({
|
|
85
|
+
node,
|
|
86
|
+
message: "Use Match.type with Match.tagsExhaustive so a new tagged variant requires a new case."
|
|
87
|
+
}));
|
|
88
|
+
},
|
|
89
|
+
IfStatement: (node) => {
|
|
90
|
+
if (!isCompleteIfTransformationShape(node) && !isCompleteSequentialIfTransformationShape(node) || isInsideCatchAllHandler(node)) return Effect.void;
|
|
91
|
+
return context.report(make({
|
|
92
|
+
node,
|
|
93
|
+
message: "Use Match.type with Match.tagsExhaustive so a new tagged variant requires a new branch."
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
};
|
|
43
97
|
}
|
|
44
98
|
});
|
|
45
99
|
//#endregion
|
|
@@ -0,0 +1,65 @@
|
|
|
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, isStaticCall, isStaticMember, visibleNamespaces } from "./_effect-namespaces.js";
|
|
6
|
+
import * as Effect from "effect/Effect";
|
|
7
|
+
//#region src/rules/prefer-service-of.ts
|
|
8
|
+
const returnedObject = (node) => {
|
|
9
|
+
if (node?.type !== "ArrowFunctionExpression" && node?.type !== "FunctionExpression") return;
|
|
10
|
+
if (node.body === null) return void 0;
|
|
11
|
+
if (node.body.type === "ObjectExpression") return node.body;
|
|
12
|
+
if (node.body.type !== "BlockStatement") return void 0;
|
|
13
|
+
for (const statement of node.body.body) if (statement.type === "ReturnStatement" && statement.argument?.type === "ObjectExpression") return statement.argument;
|
|
14
|
+
};
|
|
15
|
+
const implementationObject = (node, layerNamespaces, effectNamespaces) => {
|
|
16
|
+
if (node.callee.type === "Super" || node.arguments.length < 2) return void 0;
|
|
17
|
+
const implementation = node.arguments[1];
|
|
18
|
+
if (implementation === void 0 || implementation.type === "SpreadElement") return void 0;
|
|
19
|
+
if (isStaticMember(node.callee, layerNamespaces, "succeed")) {
|
|
20
|
+
if (implementation.type === "ObjectExpression") return implementation;
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (isStaticMember(node.callee, layerNamespaces, "sync")) return returnedObject(implementation);
|
|
24
|
+
if (!isStaticMember(node.callee, layerNamespaces, "effect")) return void 0;
|
|
25
|
+
if (implementation.type !== "CallExpression" || implementation.callee.type === "Super") return;
|
|
26
|
+
if (isStaticMember(implementation.callee, effectNamespaces, "succeed")) {
|
|
27
|
+
const value = implementation.arguments[0];
|
|
28
|
+
if (value?.type === "ObjectExpression") return value;
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (isStaticCall(implementation, effectNamespaces, "gen")) return returnedObject(implementation.arguments[0]);
|
|
32
|
+
};
|
|
33
|
+
const preferServiceOf = define({
|
|
34
|
+
name: "prefer-service-of",
|
|
35
|
+
meta: meta({
|
|
36
|
+
type: "suggestion",
|
|
37
|
+
description: "Use Service.of to check inline Layer implementations against the service interface."
|
|
38
|
+
}),
|
|
39
|
+
create: function* () {
|
|
40
|
+
const ctx = yield* RuleContext;
|
|
41
|
+
const layerNamespaces = new Set(["Layer"]);
|
|
42
|
+
const effectNamespaces = new Set(["Effect"]);
|
|
43
|
+
return {
|
|
44
|
+
ImportDeclaration: (node) => {
|
|
45
|
+
if (node.type !== "ImportDeclaration") return Effect.void;
|
|
46
|
+
for (const name of importedNamespaces(node, "Layer", "effect/Layer")) layerNamespaces.add(name);
|
|
47
|
+
for (const name of importedNamespaces(node, "Effect", "effect/Effect")) effectNamespaces.add(name);
|
|
48
|
+
return Effect.void;
|
|
49
|
+
},
|
|
50
|
+
CallExpression: (node) => {
|
|
51
|
+
if (node.type !== "CallExpression") return Effect.void;
|
|
52
|
+
const service = node.arguments[0];
|
|
53
|
+
if (service?.type !== "Identifier") return Effect.void;
|
|
54
|
+
const implementation = implementationObject(node, visibleNamespaces(ctx, node, layerNamespaces), visibleNamespaces(ctx, node, effectNamespaces));
|
|
55
|
+
if (implementation === void 0) return Effect.void;
|
|
56
|
+
return ctx.report(make({
|
|
57
|
+
node: implementation,
|
|
58
|
+
message: `Wrap this implementation with ${service.name}.of(...) so it stays checked against the service interface.`
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
//#endregion
|
|
65
|
+
export { preferServiceOf };
|