oxlint-plugin-effect 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- 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 +1 -1
|
@@ -2,6 +2,9 @@ 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";
|
|
6
|
+
import * as Arr from "effect/Array";
|
|
7
|
+
import * as Option from "effect/Option";
|
|
5
8
|
import * as Effect from "effect/Effect";
|
|
6
9
|
//#region src/rules/no-widen-then-assert.ts
|
|
7
10
|
/**
|
|
@@ -15,6 +18,7 @@ const functionBoundaryTypes = new Set([
|
|
|
15
18
|
"TSDeclareFunction",
|
|
16
19
|
"TSEmptyBodyFunctionExpression"
|
|
17
20
|
]);
|
|
21
|
+
const sameBoundary = Option.makeEquivalence((left, right) => left === right);
|
|
18
22
|
function unwrapExpressionParentheses(expression) {
|
|
19
23
|
let current = expression;
|
|
20
24
|
while (current.type === "ParenthesizedExpression") current = current.expression;
|
|
@@ -26,7 +30,20 @@ function unwrapTypeParentheses(type) {
|
|
|
26
30
|
return current;
|
|
27
31
|
}
|
|
28
32
|
function typeReferenceName(type) {
|
|
29
|
-
|
|
33
|
+
if (type.typeName.type !== "Identifier") return Option.none();
|
|
34
|
+
return Option.some(type.typeName.name);
|
|
35
|
+
}
|
|
36
|
+
function isReferenceNamed(type, name) {
|
|
37
|
+
return Option.contains(typeReferenceName(type), name);
|
|
38
|
+
}
|
|
39
|
+
/** The two type arguments of a reference such as `Record<K, V>`, when it has exactly two. */
|
|
40
|
+
function typeArgumentPair(reference) {
|
|
41
|
+
const parameters = reference.typeArguments?.params ?? [];
|
|
42
|
+
if (parameters.length !== 2) return Option.none();
|
|
43
|
+
return Option.all([Arr.get(parameters, 0), Arr.get(parameters, 1)]);
|
|
44
|
+
}
|
|
45
|
+
function firstTypeArgument(reference) {
|
|
46
|
+
return Arr.head(reference.typeArguments?.params ?? []);
|
|
30
47
|
}
|
|
31
48
|
function isUnknownOrAnyType(type) {
|
|
32
49
|
const unwrapped = unwrapTypeParentheses(type);
|
|
@@ -36,42 +53,45 @@ function isBroadRecordKeyType(type) {
|
|
|
36
53
|
const unwrapped = unwrapTypeParentheses(type);
|
|
37
54
|
if (unwrapped.type === "TSStringKeyword" || unwrapped.type === "TSNumberKeyword" || unwrapped.type === "TSSymbolKeyword") return true;
|
|
38
55
|
if (unwrapped.type === "TSUnionType") return unwrapped.types.every(isBroadRecordKeyType);
|
|
39
|
-
return unwrapped.type === "TSTypeReference" &&
|
|
56
|
+
return unwrapped.type === "TSTypeReference" && isReferenceNamed(unwrapped, "PropertyKey");
|
|
57
|
+
}
|
|
58
|
+
function isBroadRecordReference(reference) {
|
|
59
|
+
if (isReferenceNamed(reference, "Readonly")) return Option.exists(firstTypeArgument(reference), isBroadRecordType);
|
|
60
|
+
if (!isReferenceNamed(reference, "Record")) return false;
|
|
61
|
+
return Option.exists(typeArgumentPair(reference), ([key, value]) => isBroadRecordKeyType(key) && isUnknownOrAnyType(value));
|
|
62
|
+
}
|
|
63
|
+
function isBroadIndexSignature(member) {
|
|
64
|
+
return member.parameters.length === 1 && Option.exists(Arr.head(member.parameters), (parameter) => isBroadRecordKeyType(parameter.typeAnnotation.typeAnnotation)) && isUnknownOrAnyType(member.typeAnnotation.typeAnnotation);
|
|
40
65
|
}
|
|
41
66
|
function isBroadRecordType(type) {
|
|
42
67
|
const unwrapped = unwrapTypeParentheses(type);
|
|
43
|
-
if (unwrapped.type === "TSTypeReference")
|
|
44
|
-
if (typeReferenceName(unwrapped) === "Readonly") {
|
|
45
|
-
const [inner] = unwrapped.typeArguments?.params ?? [];
|
|
46
|
-
return inner !== void 0 && isBroadRecordType(inner);
|
|
47
|
-
}
|
|
48
|
-
if (typeReferenceName(unwrapped) !== "Record") return false;
|
|
49
|
-
const parameters = unwrapped.typeArguments?.params ?? [];
|
|
50
|
-
return parameters.length === 2 && parameters[0] !== void 0 && parameters[1] !== void 0 && isBroadRecordKeyType(parameters[0]) && isUnknownOrAnyType(parameters[1]);
|
|
51
|
-
}
|
|
68
|
+
if (unwrapped.type === "TSTypeReference") return isBroadRecordReference(unwrapped);
|
|
52
69
|
if (unwrapped.type !== "TSTypeLiteral" || unwrapped.members.length !== 1) return false;
|
|
53
|
-
|
|
54
|
-
const [parameter] = member?.type === "TSIndexSignature" ? member.parameters : [];
|
|
55
|
-
return member?.type === "TSIndexSignature" && member.parameters.length === 1 && parameter !== void 0 && isBroadRecordKeyType(parameter.typeAnnotation.typeAnnotation) && isUnknownOrAnyType(member.typeAnnotation.typeAnnotation);
|
|
70
|
+
return Option.exists(Arr.head(unwrapped.members), (member) => member.type === "TSIndexSignature" && isBroadIndexSignature(member));
|
|
56
71
|
}
|
|
57
72
|
function broadTypeKind(type) {
|
|
58
73
|
const unwrapped = unwrapTypeParentheses(type);
|
|
59
|
-
if (unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword") return "top";
|
|
60
|
-
if (unwrapped.type === "TSObjectKeyword") return "object";
|
|
61
|
-
|
|
74
|
+
if (unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword") return Option.some("top");
|
|
75
|
+
if (unwrapped.type === "TSObjectKeyword") return Option.some("object");
|
|
76
|
+
if (isBroadRecordType(unwrapped)) return Option.some("record");
|
|
77
|
+
return Option.none();
|
|
78
|
+
}
|
|
79
|
+
function isBroadType(type) {
|
|
80
|
+
return Option.isSome(broadTypeKind(type));
|
|
62
81
|
}
|
|
63
82
|
function assertedExpression(node) {
|
|
64
83
|
return unwrapExpressionParentheses(node.expression);
|
|
65
84
|
}
|
|
66
85
|
function assertionFromExpression(expression) {
|
|
67
86
|
const unwrapped = unwrapExpressionParentheses(expression);
|
|
68
|
-
|
|
87
|
+
if (unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion") return Option.some(unwrapped);
|
|
88
|
+
return Option.none();
|
|
69
89
|
}
|
|
70
90
|
function normalizedTypeText(sourceText, type) {
|
|
71
91
|
return sourceText.slice(type.start, type.end).replaceAll(/\s+/gu, "");
|
|
72
92
|
}
|
|
73
93
|
function typesHaveSameSyntax(sourceText, left, right) {
|
|
74
|
-
return left
|
|
94
|
+
return Option.exists(left, (type) => normalizedTypeText(sourceText, unwrapTypeParentheses(type)) === normalizedTypeText(sourceText, unwrapTypeParentheses(right)));
|
|
75
95
|
}
|
|
76
96
|
function isDefinitelyObjectType(type) {
|
|
77
97
|
const unwrapped = unwrapTypeParentheses(type);
|
|
@@ -92,74 +112,79 @@ function isDefinitelyNarrowerRecordType(type) {
|
|
|
92
112
|
const unwrapped = unwrapTypeParentheses(type);
|
|
93
113
|
if (unwrapped.type === "TSTypeLiteral") return unwrapped.members.some((member) => member.type !== "TSIndexSignature");
|
|
94
114
|
if (unwrapped.type !== "TSTypeReference") return false;
|
|
95
|
-
if (
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
if (typeReferenceName(unwrapped) !== "Record") return false;
|
|
100
|
-
const parameters = unwrapped.typeArguments?.params ?? [];
|
|
101
|
-
return parameters.length === 2 && parameters[1] !== void 0 && !isUnknownOrAnyType(parameters[1]);
|
|
115
|
+
if (isReferenceNamed(unwrapped, "Readonly")) return Option.exists(firstTypeArgument(unwrapped), isDefinitelyNarrowerRecordType);
|
|
116
|
+
if (!isReferenceNamed(unwrapped, "Record")) return false;
|
|
117
|
+
return Option.exists(typeArgumentPair(unwrapped), ([, value]) => !isUnknownOrAnyType(value));
|
|
102
118
|
}
|
|
103
119
|
function functionBoundary(node) {
|
|
104
|
-
|
|
105
|
-
while (current !== null && current.type !== "Program") {
|
|
106
|
-
if (functionBoundaryTypes.has(current.type)) return current;
|
|
107
|
-
current = current.parent;
|
|
108
|
-
}
|
|
109
|
-
return null;
|
|
120
|
+
return Arr.findFirst(ancestors(node), (ancestor) => functionBoundaryTypes.has(ancestor.type));
|
|
110
121
|
}
|
|
111
122
|
function resolvedVariableForIdentifier(scopes, identifier) {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (reference !== void 0) return reference.resolved;
|
|
115
|
-
}
|
|
116
|
-
return null;
|
|
123
|
+
const reference = Arr.findFirst(scopes, (scope) => Arr.findFirst(scope.references, (candidate) => candidate.identifier.start === identifier.start && candidate.identifier.end === identifier.end));
|
|
124
|
+
return Option.flatMap(reference, (found) => Option.fromNullishOr(found.resolved));
|
|
117
125
|
}
|
|
118
126
|
function variableDeclarator(variable) {
|
|
119
|
-
for (const definition of variable.defs) if (definition.type === "Variable" && definition.node.type === "VariableDeclarator") return definition.node;
|
|
120
|
-
return
|
|
127
|
+
for (const definition of variable.defs) if (definition.type === "Variable" && definition.node.type === "VariableDeclarator") return Option.some(definition.node);
|
|
128
|
+
return Option.none();
|
|
129
|
+
}
|
|
130
|
+
function isConstDeclarator(declarator) {
|
|
131
|
+
return declarator.parent.type === "VariableDeclaration" && declarator.parent.kind === "const";
|
|
132
|
+
}
|
|
133
|
+
function isReassigned(variable) {
|
|
134
|
+
return variable.references.some((reference) => reference.isWrite() && !reference.init);
|
|
121
135
|
}
|
|
122
136
|
function knownValueEvidence(expression, scopes, boundary, visitedVariables) {
|
|
123
137
|
const unwrapped = unwrapExpressionParentheses(expression);
|
|
124
138
|
if (unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion") {
|
|
125
|
-
if (
|
|
126
|
-
return { type: unwrapped.typeAnnotation };
|
|
139
|
+
if (isBroadType(unwrapped.typeAnnotation)) return Option.none();
|
|
140
|
+
return Option.some({ type: Option.some(unwrapped.typeAnnotation) });
|
|
127
141
|
}
|
|
128
|
-
if (unwrapped.type === "Literal" || unwrapped.type === "TemplateLiteral") return { type:
|
|
129
|
-
if (unwrapped.type === "ArrayExpression" || unwrapped.type === "ArrowFunctionExpression" || unwrapped.type === "ClassExpression" || unwrapped.type === "FunctionExpression" || unwrapped.type === "NewExpression" || unwrapped.type === "ObjectExpression") return { type:
|
|
130
|
-
if (unwrapped.type
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
return
|
|
142
|
+
if (unwrapped.type === "Literal" || unwrapped.type === "TemplateLiteral") return Option.some({ type: Option.none() });
|
|
143
|
+
if (unwrapped.type === "ArrayExpression" || unwrapped.type === "ArrowFunctionExpression" || unwrapped.type === "ClassExpression" || unwrapped.type === "FunctionExpression" || unwrapped.type === "NewExpression" || unwrapped.type === "ObjectExpression") return Option.some({ type: Option.none() });
|
|
144
|
+
if (unwrapped.type === "Identifier") return identifierValueEvidence(unwrapped, scopes, boundary, visitedVariables);
|
|
145
|
+
return Option.none();
|
|
146
|
+
}
|
|
147
|
+
function identifierValueEvidence(expression, scopes, boundary, visitedVariables) {
|
|
148
|
+
return resolvedVariableForIdentifier(scopes, expression).pipe(Option.filter((variable) => !visitedVariables.has(variable)), Option.flatMap((variable) => Option.match(annotatedIdentifier(variable), {
|
|
149
|
+
onSome: ([identifier, annotation]) => annotatedValueEvidence(identifier, annotation, boundary),
|
|
150
|
+
onNone: () => declaredValueEvidence(variable, scopes, boundary, visitedVariables)
|
|
151
|
+
})));
|
|
152
|
+
}
|
|
153
|
+
/** The first declaration identifier of a variable that carries a type annotation. */
|
|
154
|
+
function annotatedIdentifier(variable) {
|
|
155
|
+
return Arr.findFirst(variable.identifiers, (identifier) => Option.map(Option.fromNullishOr(identifier.typeAnnotation), (annotation) => [identifier, annotation.typeAnnotation]));
|
|
156
|
+
}
|
|
157
|
+
function annotatedValueEvidence(identifier, annotation, boundary) {
|
|
158
|
+
if (!sameBoundary(functionBoundary(identifier), boundary) || isBroadType(annotation)) return Option.none();
|
|
159
|
+
return Option.some({ type: Option.some(annotation) });
|
|
160
|
+
}
|
|
161
|
+
function declaredValueEvidence(variable, scopes, boundary, visitedVariables) {
|
|
162
|
+
return variableDeclarator(variable).pipe(Option.filter((declarator) => isConstDeclarator(declarator) && !isReassigned(variable) && sameBoundary(functionBoundary(declarator), boundary)), Option.flatMap((declarator) => Option.fromNullishOr(declarator.init)), Option.flatMap((init) => knownValueEvidence(init, scopes, boundary, new Set([...visitedVariables, variable]))));
|
|
142
163
|
}
|
|
143
164
|
function widenedBinding(variable, scopes) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
165
|
+
return variableDeclarator(variable).pipe(Option.filter((declarator) => isConstDeclarator(declarator) && declarator.id.type === "Identifier" && !isReassigned(variable)), Option.flatMap((declarator) => Option.flatMap(Option.fromNullishOr(declarator.init), (init) => widenedDeclarator(variable, declarator, init, scopes))));
|
|
166
|
+
}
|
|
167
|
+
function widenedDeclarator(variable, declarator, init, scopes) {
|
|
168
|
+
const identifier = Arr.findFirst(variable.identifiers, (candidate) => candidate.start === declarator.id.start && candidate.end === declarator.id.end);
|
|
147
169
|
const boundary = functionBoundary(declarator);
|
|
148
|
-
const declaredType = identifier
|
|
149
|
-
const initializerAssertion = assertionFromExpression(
|
|
150
|
-
const initializerBroadKind = initializerAssertion
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
170
|
+
const declaredType = identifier.pipe(Option.flatMap((found) => Option.fromNullishOr(found.typeAnnotation)), Option.map((annotation) => annotation.typeAnnotation));
|
|
171
|
+
const initializerAssertion = assertionFromExpression(init);
|
|
172
|
+
const initializerBroadKind = Option.flatMap(initializerAssertion, (assertion) => broadTypeKind(assertion.typeAnnotation));
|
|
173
|
+
const declaredBroadKind = Option.flatMap(declaredType, broadTypeKind);
|
|
174
|
+
const broadKind = Option.orElse(declaredBroadKind, () => initializerBroadKind);
|
|
175
|
+
const originalExpression = Option.match(Option.zipLeft(initializerAssertion, initializerBroadKind), {
|
|
176
|
+
onNone: () => init,
|
|
177
|
+
onSome: assertedExpression
|
|
178
|
+
});
|
|
179
|
+
return Option.flatMap(broadKind, (kind) => Option.map(knownValueEvidence(originalExpression, scopes, boundary, new Set([variable])), (evidence) => ({
|
|
180
|
+
broadKind: kind,
|
|
156
181
|
evidence,
|
|
157
182
|
declaredAt: declarator.end,
|
|
158
183
|
boundary
|
|
159
|
-
};
|
|
184
|
+
})));
|
|
160
185
|
}
|
|
161
186
|
function assertionIsNarrower(sourceText, broadKind, evidence, assertedType) {
|
|
162
|
-
if (
|
|
187
|
+
if (isBroadType(assertedType)) return false;
|
|
163
188
|
if (broadKind === "top") return true;
|
|
164
189
|
if (typesHaveSameSyntax(sourceText, evidence.type, assertedType)) return true;
|
|
165
190
|
if (broadKind === "object") return isDefinitelyObjectType(assertedType);
|
|
@@ -179,10 +204,8 @@ const noWidenThenAssert = define({
|
|
|
179
204
|
const checkAssertion = (node) => {
|
|
180
205
|
const expression = assertedExpression(node);
|
|
181
206
|
if (expression.type !== "Identifier") return Effect.void;
|
|
182
|
-
const
|
|
183
|
-
if (
|
|
184
|
-
const widened = widenedBinding(variable, scopes);
|
|
185
|
-
if (widened === null || node.start <= widened.declaredAt || functionBoundary(node) !== widened.boundary || !assertionIsNarrower(context.sourceCode.text, widened.broadKind, widened.evidence, node.typeAnnotation)) return Effect.void;
|
|
207
|
+
const widened = Option.flatMap(resolvedVariableForIdentifier(scopes, expression), (variable) => widenedBinding(variable, scopes));
|
|
208
|
+
if (!Option.exists(widened, (binding) => node.start > binding.declaredAt && sameBoundary(functionBoundary(node), binding.boundary) && assertionIsNarrower(context.sourceCode.text, binding.broadKind, binding.evidence, node.typeAnnotation))) return Effect.void;
|
|
186
209
|
return context.report(fromId({
|
|
187
210
|
node,
|
|
188
211
|
messageId: "widenThenAssert",
|
|
@@ -3,51 +3,43 @@ 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 { isEffectCall, tagComparisonsInOr, taggedSwitchSubject } from "./_tagged-control-flow.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/prefer-catch-tag.ts
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const comparisons = tagComparisonsInOr(expression);
|
|
23
|
-
if (comparisons === null || comparisons.length === 0) return false;
|
|
24
|
-
return comparisons.every((comparison) => comparison.subject === parameter.name);
|
|
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
|
-
};
|
|
11
|
+
const isFunction = (argument) => argument.type === "ArrowFunctionExpression" || argument.type === "FunctionExpression";
|
|
12
|
+
const isIdentifier = (parameter) => parameter.type === "Identifier";
|
|
13
|
+
/** The name of a callback's first parameter when it is a plain identifier. */
|
|
14
|
+
const identifierParameter = (fn) => Arr.head(fn.params).pipe(Option.filter(isIdentifier), Option.map((parameter) => parameter.name));
|
|
15
|
+
/** The expression a callback returns when its body is that expression or a lone `return`. */
|
|
16
|
+
const returnedExpression = (fn) => Option.flatMap(Option.fromNullishOr(fn.body), (body) => {
|
|
17
|
+
if (body.type !== "BlockStatement") return Option.some(body);
|
|
18
|
+
const [statement] = body.body;
|
|
19
|
+
if (body.body.length !== 1 || statement?.type !== "ReturnStatement") return Option.none();
|
|
20
|
+
return Option.fromNullishOr(statement.argument);
|
|
21
|
+
});
|
|
22
|
+
/** Whether `expression` is one or more `parameter._tag === "Tag"` checks joined by `||`. */
|
|
23
|
+
const isTagCheckOn = (expression, parameter) => Option.exists(tagComparisonsInOr(expression), (comparisons) => comparisons.every((comparison) => comparison.subject === parameter));
|
|
24
|
+
const hasManualTagPredicate = (argument) => isFunction(argument) && Option.exists(Option.all([identifierParameter(argument), returnedExpression(argument)]), ([parameter, expression]) => isTagCheckOn(expression, parameter));
|
|
32
25
|
const hasTagSwitch = (node, parameter) => {
|
|
33
|
-
if (taggedSwitchSubject(node)
|
|
34
|
-
return node.cases.some((switchCase) => switchCase.test?.type === "Literal" &&
|
|
26
|
+
if (!Option.contains(taggedSwitchSubject(node), parameter)) return false;
|
|
27
|
+
return node.cases.some((switchCase) => switchCase.test?.type === "Literal" && Predicate.isString(switchCase.test.value));
|
|
35
28
|
};
|
|
36
29
|
const hasManualTagDispatch = (argument) => {
|
|
37
30
|
if (!isFunction(argument) || argument.body?.type !== "BlockStatement") return false;
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (statement.type === "
|
|
42
|
-
if (statement.type === "SwitchStatement") return hasTagSwitch(statement, parameter.name);
|
|
31
|
+
const statements = argument.body.body;
|
|
32
|
+
return Option.exists(identifierParameter(argument), (parameter) => statements.some((statement) => {
|
|
33
|
+
if (statement.type === "IfStatement") return isTagCheckOn(statement.test, parameter);
|
|
34
|
+
if (statement.type === "SwitchStatement") return hasTagSwitch(statement, parameter);
|
|
43
35
|
return false;
|
|
44
|
-
});
|
|
36
|
+
}));
|
|
45
37
|
};
|
|
46
38
|
const catchAllHandler = (node) => {
|
|
47
|
-
if (!isEffectCall(node, "catchAll")) return
|
|
39
|
+
if (!isEffectCall(node, "catchAll")) return Option.none();
|
|
48
40
|
let handlerIndex = 1;
|
|
49
41
|
if (node.arguments.length === 1) handlerIndex = 0;
|
|
50
|
-
return node.arguments
|
|
42
|
+
return Arr.get(node.arguments, handlerIndex);
|
|
51
43
|
};
|
|
52
44
|
const preferCatchTag = define({
|
|
53
45
|
name: "prefer-catch-tag",
|
|
@@ -61,9 +53,9 @@ const preferCatchTag = define({
|
|
|
61
53
|
if (isEffectCall(node, "catchIf")) {
|
|
62
54
|
let predicateIndex = 1;
|
|
63
55
|
if (node.arguments.length === 2) predicateIndex = 0;
|
|
64
|
-
const predicate = node.arguments
|
|
65
|
-
if (!
|
|
66
|
-
} else if (!
|
|
56
|
+
const predicate = Arr.get(node.arguments, predicateIndex);
|
|
57
|
+
if (!Option.exists(predicate, hasManualTagPredicate)) return Effect.void;
|
|
58
|
+
} else if (!Option.exists(catchAllHandler(node), hasManualTagDispatch)) return Effect.void;
|
|
67
59
|
return context.report(make({
|
|
68
60
|
node,
|
|
69
61
|
message: "Use Effect.catchTag for one tagged failure or Effect.catchTags for multiple tagged failures."
|
|
@@ -2,26 +2,23 @@ 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, tagComparison, taggedSwitchSubject } from "./_tagged-control-flow.js";
|
|
5
|
+
import { hasOneTaggedSubject, isInsideCatchAllHandler, tagComparison, taggedSwitchSubject } from "./_tagged-control-flow.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/prefer-match-tags-exhaustive.ts
|
|
8
11
|
const statementReturns = (statement) => {
|
|
9
12
|
if (statement.type === "ReturnStatement") return true;
|
|
10
13
|
if (statement.type !== "BlockStatement") return false;
|
|
11
|
-
|
|
12
|
-
if (last === void 0) return false;
|
|
13
|
-
return statementReturns(last);
|
|
14
|
+
return Option.exists(Arr.last(statement.body), statementReturns);
|
|
14
15
|
};
|
|
15
|
-
const caseReturns = (switchCase) =>
|
|
16
|
-
|
|
17
|
-
if (
|
|
18
|
-
return statementReturns(last);
|
|
19
|
-
};
|
|
20
|
-
const isCompleteTransformationShape = (node) => {
|
|
21
|
-
if (node.cases.length < 2 || taggedSwitchSubject(node) === null) return false;
|
|
16
|
+
const caseReturns = (switchCase) => Option.exists(Arr.last(switchCase.consequent), statementReturns);
|
|
17
|
+
const isCompleteSwitchTransformation = (node) => {
|
|
18
|
+
if (node.cases.length < 2 || Option.isNone(taggedSwitchSubject(node))) return false;
|
|
22
19
|
for (const switchCase of node.cases) {
|
|
23
|
-
if (switchCase.test
|
|
24
|
-
if (
|
|
20
|
+
if (switchCase.test?.type !== "Literal") return false;
|
|
21
|
+
if (!Predicate.isString(switchCase.test.value) || !caseReturns(switchCase)) return false;
|
|
25
22
|
}
|
|
26
23
|
return true;
|
|
27
24
|
};
|
|
@@ -30,45 +27,36 @@ const isTerminalStatement = (node) => {
|
|
|
30
27
|
if (node.parent?.type !== "BlockStatement") return true;
|
|
31
28
|
return node.parent.body.at(-1) === node;
|
|
32
29
|
};
|
|
33
|
-
const
|
|
34
|
-
|
|
30
|
+
const isPlainIf = (statement) => statement.type === "IfStatement" && Predicate.isNull(statement.alternate);
|
|
31
|
+
/** The tag a branch compares, when the branch always returns. */
|
|
32
|
+
const returningTagComparison = (branch) => Option.filter(tagComparison(branch.test), () => statementReturns(branch.consequent));
|
|
33
|
+
/** Whether at least two returning branches each compare one subject against a distinct tag. */
|
|
34
|
+
const isDistinctTagDispatch = (branches) => Option.exists(Option.all(branches.map(returningTagComparison)), (comparisons) => hasOneTaggedSubject(comparisons) && new Set(comparisons.map((comparison) => comparison.tag)).size === comparisons.length);
|
|
35
|
+
/** The if statement and each `else if` after it; none when the chain ends in a plain else. */
|
|
36
|
+
const elseIfChain = (node) => {
|
|
37
|
+
const chain = [node];
|
|
35
38
|
let current = node;
|
|
36
|
-
|
|
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;
|
|
39
|
+
while (current.alternate?.type === "IfStatement") {
|
|
49
40
|
current = current.alternate;
|
|
41
|
+
chain.push(current);
|
|
50
42
|
}
|
|
51
|
-
|
|
43
|
+
if (Predicate.isNotNull(current.alternate)) return Option.none();
|
|
44
|
+
return Option.some(chain);
|
|
45
|
+
};
|
|
46
|
+
const isCompleteIfTransformation = (node) => {
|
|
47
|
+
if (isElseIf(node) || !isTerminalStatement(node)) return false;
|
|
48
|
+
return Option.exists(elseIfChain(node), isDistinctTagDispatch);
|
|
52
49
|
};
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
/** Whether `previous` is a returning tag check on the same subject, so `node` does not start a run. */
|
|
51
|
+
const continuesRun = (previous, node) => Option.exists(previous, (statement) => isPlainIf(statement) && statementReturns(statement.consequent) && Option.exists(Option.all([tagComparison(statement.test), tagComparison(node.test)]), ([before, current]) => before.subject === current.subject));
|
|
52
|
+
const isCompleteSequentialIfTransformation = (node) => {
|
|
53
|
+
if (!isPlainIf(node) || node.parent?.type !== "BlockStatement") return false;
|
|
55
54
|
const statements = node.parent.body;
|
|
56
55
|
const index = statements.indexOf(node);
|
|
57
56
|
if (index < 0) return false;
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
60
|
-
|
|
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;
|
|
57
|
+
const run = statements.slice(index);
|
|
58
|
+
if (!run.every(isPlainIf) || continuesRun(Arr.get(statements, index - 1), node)) return false;
|
|
59
|
+
return isDistinctTagDispatch(run);
|
|
72
60
|
};
|
|
73
61
|
const preferMatchTagsExhaustive = define({
|
|
74
62
|
name: "prefer-match-tags-exhaustive",
|
|
@@ -80,14 +68,14 @@ const preferMatchTagsExhaustive = define({
|
|
|
80
68
|
const context = yield* RuleContext;
|
|
81
69
|
return {
|
|
82
70
|
SwitchStatement: (node) => {
|
|
83
|
-
if (!
|
|
71
|
+
if (!isCompleteSwitchTransformation(node) || isInsideCatchAllHandler(node)) return Effect.void;
|
|
84
72
|
return context.report(make({
|
|
85
73
|
node,
|
|
86
74
|
message: "Use Match.type with Match.tagsExhaustive so a new tagged variant requires a new case."
|
|
87
75
|
}));
|
|
88
76
|
},
|
|
89
77
|
IfStatement: (node) => {
|
|
90
|
-
if (!
|
|
78
|
+
if (!isCompleteIfTransformation(node) && !isCompleteSequentialIfTransformation(node) || isInsideCatchAllHandler(node)) return Effect.void;
|
|
91
79
|
return context.report(make({
|
|
92
80
|
node,
|
|
93
81
|
message: "Use Match.type with Match.tagsExhaustive so a new tagged variant requires a new branch."
|
|
@@ -3,15 +3,14 @@ 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 { effectCallbackLocation, hasOneTaggedSubject, tagComparisonsInOr } from "./_tagged-control-flow.js";
|
|
6
|
+
import * as Option from "effect/Option";
|
|
6
7
|
import * as Effect from "effect/Effect";
|
|
7
8
|
//#region src/rules/prefer-predicate-is-tagged.ts
|
|
8
|
-
const isCatchIfPredicate = (node) => {
|
|
9
|
-
const location = effectCallbackLocation(node, "catchIf");
|
|
10
|
-
if (location === null) return false;
|
|
9
|
+
const isCatchIfPredicate = (node) => Option.exists(effectCallbackLocation(node, "catchIf"), (location) => {
|
|
11
10
|
let predicateIndex = 1;
|
|
12
11
|
if (location.argumentCount === 2) predicateIndex = 0;
|
|
13
12
|
return location.index === predicateIndex;
|
|
14
|
-
};
|
|
13
|
+
});
|
|
15
14
|
const preferPredicateIsTagged = define({
|
|
16
15
|
name: "prefer-predicate-is-tagged",
|
|
17
16
|
meta: meta({
|
|
@@ -24,8 +23,7 @@ const preferPredicateIsTagged = define({
|
|
|
24
23
|
if (node.operator !== "||") return Effect.void;
|
|
25
24
|
if (node.parent?.type === "LogicalExpression" && node.parent.operator === "||") return Effect.void;
|
|
26
25
|
if (isCatchIfPredicate(node)) return Effect.void;
|
|
27
|
-
|
|
28
|
-
if (comparisons === null || !hasOneTaggedSubject(comparisons)) return Effect.void;
|
|
26
|
+
if (!Option.exists(tagComparisonsInOr(node), hasOneTaggedSubject)) return Effect.void;
|
|
29
27
|
return context.report(make({
|
|
30
28
|
node,
|
|
31
29
|
message: "Use a named Predicate refinement composed from Predicate.isTagged for multiple tags."
|
|
@@ -2,33 +2,44 @@ 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, isStaticCall, 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/prefer-service-of.ts
|
|
12
|
+
const isObjectExpression = (node) => node.type === "ObjectExpression";
|
|
13
|
+
const returnedObjectStatement = (statement) => {
|
|
14
|
+
if (statement.type !== "ReturnStatement" || statement.argument?.type !== "ObjectExpression") return Option.none();
|
|
15
|
+
return Option.some(statement.argument);
|
|
16
|
+
};
|
|
17
|
+
/** The object a function argument returns: its expression body or its first returned object. */
|
|
8
18
|
const returnedObject = (node) => {
|
|
9
|
-
if (node
|
|
10
|
-
|
|
11
|
-
if (
|
|
12
|
-
if (
|
|
13
|
-
|
|
19
|
+
if (node.type !== "ArrowFunctionExpression" && node.type !== "FunctionExpression") return Option.none();
|
|
20
|
+
const body = node.body;
|
|
21
|
+
if (Predicate.isNull(body)) return Option.none();
|
|
22
|
+
if (body.type === "ObjectExpression") return Option.some(body);
|
|
23
|
+
if (body.type !== "BlockStatement") return Option.none();
|
|
24
|
+
return Arr.findFirst(body.body, returnedObjectStatement);
|
|
25
|
+
};
|
|
26
|
+
/** The object an `Effect.succeed(...)` or `Effect.gen(...)` implementation produces. */
|
|
27
|
+
const effectImplementationObject = (implementation, effectNamespaces) => {
|
|
28
|
+
if (implementation.type !== "CallExpression" || implementation.callee.type === "Super") return Option.none();
|
|
29
|
+
if (isStaticMember(implementation.callee, effectNamespaces, "succeed")) return Option.filter(Arr.head(implementation.arguments), isObjectExpression);
|
|
30
|
+
if (isStaticCall(implementation, effectNamespaces, "gen")) return Option.flatMap(Arr.head(implementation.arguments), returnedObject);
|
|
31
|
+
return Option.none();
|
|
32
|
+
};
|
|
33
|
+
const layerImplementationObject = (callee, implementation, layerNamespaces, effectNamespaces) => {
|
|
34
|
+
if (isStaticMember(callee, layerNamespaces, "succeed")) return Option.liftPredicate(implementation, isObjectExpression);
|
|
35
|
+
if (isStaticMember(callee, layerNamespaces, "sync")) return returnedObject(implementation);
|
|
36
|
+
if (!isStaticMember(callee, layerNamespaces, "effect")) return Option.none();
|
|
37
|
+
return effectImplementationObject(implementation, effectNamespaces);
|
|
14
38
|
};
|
|
15
39
|
const implementationObject = (node, layerNamespaces, effectNamespaces) => {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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]);
|
|
40
|
+
const callee = node.callee;
|
|
41
|
+
if (callee.type === "Super") return Option.none();
|
|
42
|
+
return Option.flatMap(expressionArgument(node, 1), (implementation) => layerImplementationObject(callee, implementation, layerNamespaces, effectNamespaces));
|
|
32
43
|
};
|
|
33
44
|
const preferServiceOf = define({
|
|
34
45
|
name: "prefer-service-of",
|
|
@@ -52,11 +63,13 @@ const preferServiceOf = define({
|
|
|
52
63
|
const service = node.arguments[0];
|
|
53
64
|
if (service?.type !== "Identifier") return Effect.void;
|
|
54
65
|
const implementation = implementationObject(node, visibleNamespaces(ctx, node, layerNamespaces), visibleNamespaces(ctx, node, effectNamespaces));
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
return Option.match(implementation, {
|
|
67
|
+
onNone: () => Effect.void,
|
|
68
|
+
onSome: (object) => ctx.report(make({
|
|
69
|
+
node: object,
|
|
70
|
+
message: `Wrap this implementation with ${service.name}.of(...) so it stays checked against the service interface.`
|
|
71
|
+
}))
|
|
72
|
+
});
|
|
60
73
|
}
|
|
61
74
|
};
|
|
62
75
|
}
|
|
@@ -3,9 +3,11 @@ 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";
|
|
6
8
|
import * as Effect from "effect/Effect";
|
|
7
9
|
//#region src/rules/require-named-effect-fn.ts
|
|
8
|
-
const isFunction = (node) => node
|
|
10
|
+
const isFunction = (node) => node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression";
|
|
9
11
|
const requireNamedEffectFn = define({
|
|
10
12
|
name: "require-named-effect-fn",
|
|
11
13
|
meta: meta({
|
|
@@ -24,7 +26,7 @@ const requireNamedEffectFn = define({
|
|
|
24
26
|
CallExpression: (node) => {
|
|
25
27
|
if (node.type !== "CallExpression" || node.callee.type === "Super") return Effect.void;
|
|
26
28
|
const namespaces = visibleNamespaces(ctx, node, effectNamespaces);
|
|
27
|
-
if (!isStaticMember(node.callee, namespaces, "fn") || !
|
|
29
|
+
if (!isStaticMember(node.callee, namespaces, "fn") || !Option.exists(Arr.head(node.arguments), isFunction)) return Effect.void;
|
|
28
30
|
return ctx.report(make({
|
|
29
31
|
node,
|
|
30
32
|
message: "Name this operation with Effect.fn(\"Domain.operation\") for stable tracing and diagnostics."
|