@doubleloop-io/oxlint-plugin-effect 0.0.0-stage → 0.1.0-rc.1
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/LICENSE +21 -0
- package/LICENSE.anti-slop +21 -0
- package/README.md +216 -2
- package/RULES.md +931 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +75 -0
- package/dist/presets/all.d.ts +7 -0
- package/dist/presets/all.js +9 -0
- package/dist/presets/all.json +38 -0
- package/dist/rules/adapter-import-boundary.d.ts +7 -0
- package/dist/rules/adapter-import-boundary.js +103 -0
- package/dist/rules/all-errors-handled-at-boundary.d.ts +1 -0
- package/dist/rules/all-errors-handled-at-boundary.js +111 -0
- package/dist/rules/anti-slop-no-manual-effect-error-tag.d.ts +1 -0
- package/dist/rules/anti-slop-no-manual-effect-error-tag.js +32 -0
- package/dist/rules/anti-slop-no-service-constructor-imports.d.ts +1 -0
- package/dist/rules/anti-slop-no-service-constructor-imports.js +49 -0
- package/dist/rules/anti-slop-prefer-effect-match.d.ts +1 -0
- package/dist/rules/anti-slop-prefer-effect-match.js +45 -0
- package/dist/rules/error-handling-in-effect-fn-handler.d.ts +1 -0
- package/dist/rules/error-handling-in-effect-fn-handler.js +144 -0
- package/dist/rules/exported-schema-identifier.d.ts +1 -0
- package/dist/rules/exported-schema-identifier.js +117 -0
- package/dist/rules/no-asymmetric-match-handlers.d.ts +1 -0
- package/dist/rules/no-asymmetric-match-handlers.js +74 -0
- package/dist/rules/no-bare-error-in-effect.d.ts +1 -0
- package/dist/rules/no-bare-error-in-effect.js +49 -0
- package/dist/rules/no-effect-promise.d.ts +1 -0
- package/dist/rules/no-effect-promise.js +57 -0
- package/dist/rules/no-effect-return-type.d.ts +1 -0
- package/dist/rules/no-effect-return-type.js +130 -0
- package/dist/rules/no-inline-schema-codec.d.ts +1 -0
- package/dist/rules/no-inline-schema-codec.js +46 -0
- package/dist/rules/no-inline-yield-star.d.ts +1 -0
- package/dist/rules/no-inline-yield-star.js +230 -0
- package/dist/rules/no-manual-discriminant-comparison.d.ts +2 -0
- package/dist/rules/no-manual-discriminant-comparison.js +81 -0
- package/dist/rules/no-manual-discriminant-construction.d.ts +2 -0
- package/dist/rules/no-manual-discriminant-construction.js +132 -0
- package/dist/rules/no-nested-match.d.ts +1 -0
- package/dist/rules/no-nested-match.js +24 -0
- package/dist/rules/no-parameterless-catch-all.d.ts +1 -0
- package/dist/rules/no-parameterless-catch-all.js +29 -0
- package/dist/rules/no-provide-on-definition.d.ts +1 -0
- package/dist/rules/no-provide-on-definition.js +123 -0
- package/dist/rules/no-transport-types-in-generator.d.ts +1 -0
- package/dist/rules/no-transport-types-in-generator.js +63 -0
- package/dist/rules/no-yielded-match.d.ts +1 -0
- package/dist/rules/no-yielded-match.js +91 -0
- package/dist/rules/prefer-effect-fn.d.ts +1 -0
- package/dist/rules/prefer-effect-fn.js +102 -0
- package/dist/rules/prefer-effect-vitest.d.ts +1 -0
- package/dist/rules/prefer-effect-vitest.js +52 -0
- package/dist/rules/provide-named-layer.d.ts +1 -0
- package/dist/rules/provide-named-layer.js +55 -0
- package/dist/rules/redacted-secret-names.d.ts +1 -0
- package/dist/rules/redacted-secret-names.js +125 -0
- package/dist/rules/run-only-in-runner.d.ts +1 -0
- package/dist/rules/run-only-in-runner.js +40 -0
- package/dist/rules/service-built-with-of.d.ts +1 -0
- package/dist/rules/service-built-with-of.js +74 -0
- package/dist/rules/service-key-matches-name.d.ts +1 -0
- package/dist/rules/service-key-matches-name.js +69 -0
- package/dist/rules/service-port-shape-only.d.ts +1 -0
- package/dist/rules/service-port-shape-only.js +65 -0
- package/dist/rules/single-style-per-function.d.ts +1 -0
- package/dist/rules/single-style-per-function.js +145 -0
- package/dist/rules/try-object-form.d.ts +1 -0
- package/dist/rules/try-object-form.js +26 -0
- package/dist/rules/untraced-recursion.d.ts +1 -0
- package/dist/rules/untraced-recursion.js +48 -0
- package/dist/shared/effect-ast.d.ts +10 -0
- package/dist/shared/effect-ast.js +63 -0
- package/dist/shared/effect-runners.d.ts +2 -0
- package/dist/shared/effect-runners.js +14 -0
- package/dist/shared/entry-files.d.ts +6 -0
- package/dist/shared/entry-files.js +5 -0
- package/dist/shared/file-globs.d.ts +14 -0
- package/dist/shared/file-globs.js +17 -0
- package/dist/shared/match-ast.d.ts +5 -0
- package/dist/shared/match-ast.js +52 -0
- package/dist/shared/tagged-values.d.ts +17 -0
- package/dist/shared/tagged-values.js +67 -0
- package/dist/shared/test-files.d.ts +6 -0
- package/dist/shared/test-files.js +10 -0
- package/package.json +60 -3
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { nearestFunction } from "../shared/effect-ast.js";
|
|
3
|
+
const contains = (outer, inner) => outer[0] <= inner[0] && inner[1] <= outer[1];
|
|
4
|
+
const isStandalone = (node) => {
|
|
5
|
+
const { parent } = node;
|
|
6
|
+
if (!parent)
|
|
7
|
+
return false;
|
|
8
|
+
if (parent.type === "ConditionalExpression" && parent.test !== node)
|
|
9
|
+
return isStandalone(parent);
|
|
10
|
+
if (parent.type === "LogicalExpression" && parent.right === node)
|
|
11
|
+
return isStandalone(parent);
|
|
12
|
+
if (parent.type === "VariableDeclarator")
|
|
13
|
+
return true;
|
|
14
|
+
if (parent.type === "AssignmentExpression")
|
|
15
|
+
return parent.parent.type === "ExpressionStatement";
|
|
16
|
+
return parent.type.endsWith("Statement");
|
|
17
|
+
};
|
|
18
|
+
const isInStatementList = (node) => {
|
|
19
|
+
const { parent } = node;
|
|
20
|
+
switch (parent?.type) {
|
|
21
|
+
case "BlockStatement":
|
|
22
|
+
case "Program":
|
|
23
|
+
case "StaticBlock":
|
|
24
|
+
return true;
|
|
25
|
+
case "SwitchCase":
|
|
26
|
+
return parent.test !== node;
|
|
27
|
+
default:
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const statementOf = (node) => {
|
|
32
|
+
let current = node;
|
|
33
|
+
while (current.parent && !isInStatementList(current))
|
|
34
|
+
current = current.parent;
|
|
35
|
+
return current;
|
|
36
|
+
};
|
|
37
|
+
const logicalAssignments = new Set(["&&=", "||=", "??="]);
|
|
38
|
+
// The extracted `const` runs before its statement, once and unconditionally: any position that
|
|
39
|
+
// short-circuits, repeats or belongs to a nested statement would change when the effect runs.
|
|
40
|
+
const runsOnceBeforeStatement = (child, parent, statement) => {
|
|
41
|
+
switch (parent.type) {
|
|
42
|
+
case "LogicalExpression":
|
|
43
|
+
return parent.left === child;
|
|
44
|
+
case "ConditionalExpression":
|
|
45
|
+
return parent.test === child;
|
|
46
|
+
case "AssignmentExpression":
|
|
47
|
+
return parent.left === child || !logicalAssignments.has(parent.operator);
|
|
48
|
+
case "ChainExpression":
|
|
49
|
+
case "ClassBody":
|
|
50
|
+
case "WhileStatement":
|
|
51
|
+
case "DoWhileStatement":
|
|
52
|
+
return false;
|
|
53
|
+
case "ForStatement":
|
|
54
|
+
return parent === statement && parent.init === child;
|
|
55
|
+
case "ForInStatement":
|
|
56
|
+
case "ForOfStatement":
|
|
57
|
+
return parent === statement && parent.right === child;
|
|
58
|
+
default:
|
|
59
|
+
return parent === statement || !parent.type.endsWith("Statement");
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const canHoist = (node, statement) => {
|
|
63
|
+
let child = node;
|
|
64
|
+
while (child !== statement) {
|
|
65
|
+
const parent = child.parent;
|
|
66
|
+
if (!parent || !runsOnceBeforeStatement(child, parent, statement))
|
|
67
|
+
return false;
|
|
68
|
+
child = parent;
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
};
|
|
72
|
+
// Anything evaluated before a hoisted `yield*` must be hoisted with it, or the order of effects flips.
|
|
73
|
+
const reordersEffects = (hoisted, effectful) => hoisted.some((target) => effectful.some((node) => node.range[1] <= target.range[0] &&
|
|
74
|
+
!hoisted.some((moved) => contains(moved.range, node.range) && moved.range[1] <= target.range[0])));
|
|
75
|
+
const verbPrefix = /^(?:get|read|fetch|load|find|parse|decode|make|create|build|compute|resolve|from|to)(?=[A-Z])/;
|
|
76
|
+
const reservedWords = new Set("arguments await break case catch class const continue debugger default delete do else enum eval export extends false finally for function if implements import in instanceof interface let new null package private protected public return static super switch this throw true try typeof undefined var void while with yield".split(" "));
|
|
77
|
+
const trailingName = (node) => {
|
|
78
|
+
switch (node.type) {
|
|
79
|
+
case "Identifier":
|
|
80
|
+
return node.name;
|
|
81
|
+
case "MemberExpression":
|
|
82
|
+
return !node.computed && node.property.type === "Identifier"
|
|
83
|
+
? node.property.name
|
|
84
|
+
: undefined;
|
|
85
|
+
case "CallExpression":
|
|
86
|
+
case "NewExpression":
|
|
87
|
+
return trailingName(node.callee);
|
|
88
|
+
default:
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
// `yield* readSheet()` becomes `sheet`, `yield* reader.rows` becomes `rows`.
|
|
93
|
+
const baseName = (node) => {
|
|
94
|
+
const name = node.argument ? trailingName(node.argument)?.replace(verbPrefix, "") : undefined;
|
|
95
|
+
if (name === undefined || name === "")
|
|
96
|
+
return "value";
|
|
97
|
+
const variable = name.charAt(0).toLowerCase() + name.slice(1);
|
|
98
|
+
return reservedWords.has(variable) ? "value" : variable;
|
|
99
|
+
};
|
|
100
|
+
const namesInUse = (scope) => {
|
|
101
|
+
const names = new Set();
|
|
102
|
+
const collect = (inner) => {
|
|
103
|
+
for (const variable of inner.variables)
|
|
104
|
+
names.add(variable.name);
|
|
105
|
+
for (const reference of inner.references)
|
|
106
|
+
names.add(reference.identifier.name);
|
|
107
|
+
inner.childScopes.forEach(collect);
|
|
108
|
+
};
|
|
109
|
+
collect(scope);
|
|
110
|
+
for (let outer = scope.upper; outer; outer = outer.upper) {
|
|
111
|
+
for (const name of outer.set.keys())
|
|
112
|
+
names.add(name);
|
|
113
|
+
}
|
|
114
|
+
return names;
|
|
115
|
+
};
|
|
116
|
+
// The parentheses of a call with a single argument belong to the call, not to the `yield*`.
|
|
117
|
+
const replacedRange = (node, sourceCode) => {
|
|
118
|
+
const { parent } = node;
|
|
119
|
+
const callParens = (parent.type === "CallExpression" || parent.type === "NewExpression") &&
|
|
120
|
+
parent.arguments.length === 1
|
|
121
|
+
? 1
|
|
122
|
+
: 0;
|
|
123
|
+
const wrappers = [];
|
|
124
|
+
let before = sourceCode.getTokenBefore(node);
|
|
125
|
+
let after = sourceCode.getTokenAfter(node);
|
|
126
|
+
while (before?.value === "(" && after?.value === ")") {
|
|
127
|
+
wrappers.push([before.range[0], after.range[1]]);
|
|
128
|
+
before = sourceCode.getTokenBefore(before);
|
|
129
|
+
after = sourceCode.getTokenAfter(after);
|
|
130
|
+
}
|
|
131
|
+
return wrappers[wrappers.length - 1 - callParens] ?? node.range;
|
|
132
|
+
};
|
|
133
|
+
const extraction = (statement, hoisted, sourceCode) => {
|
|
134
|
+
const taken = namesInUse(sourceCode.getScope(statement).variableScope);
|
|
135
|
+
const replacements = hoisted.map((node) => {
|
|
136
|
+
let name = baseName(node);
|
|
137
|
+
for (let suffix = 2; taken.has(name); suffix++)
|
|
138
|
+
name = `${baseName(node)}${suffix}`;
|
|
139
|
+
taken.add(name);
|
|
140
|
+
return { node, name, range: replacedRange(node, sourceCode) };
|
|
141
|
+
});
|
|
142
|
+
const { text } = sourceCode;
|
|
143
|
+
const rewrite = (range, self) => {
|
|
144
|
+
const inside = replacements.filter((replacement) => replacement.node !== self && contains(range, replacement.range));
|
|
145
|
+
const outermost = inside
|
|
146
|
+
.filter((replacement) => inside.every((other) => other === replacement || !contains(other.range, replacement.range)))
|
|
147
|
+
.toSorted((left, right) => left.range[0] - right.range[0]);
|
|
148
|
+
let result = "";
|
|
149
|
+
let cursor = range[0];
|
|
150
|
+
for (const replacement of outermost) {
|
|
151
|
+
result += text.slice(cursor, replacement.range[0]) + replacement.name;
|
|
152
|
+
cursor = replacement.range[1];
|
|
153
|
+
}
|
|
154
|
+
return result + text.slice(cursor, range[1]);
|
|
155
|
+
};
|
|
156
|
+
const lineStart = text.lastIndexOf("\n", statement.range[0] - 1) + 1;
|
|
157
|
+
const indentation = text.slice(lineStart, statement.range[0]);
|
|
158
|
+
const startsLine = indentation.trim() === "";
|
|
159
|
+
const declarations = replacements.map(({ node, name }) => `const ${name} = ${rewrite(node.range, node)}`);
|
|
160
|
+
const remainder = rewrite(statement.range);
|
|
161
|
+
const guarded = startsLine && /^[[(`+\-/]/.test(remainder) ? `;${remainder}` : remainder;
|
|
162
|
+
return [...declarations, guarded].join(startsLine ? `\n${indentation}` : "; ");
|
|
163
|
+
};
|
|
164
|
+
export const noInlineYieldStarRule = defineRule({
|
|
165
|
+
meta: {
|
|
166
|
+
type: "suggestion",
|
|
167
|
+
fixable: "code",
|
|
168
|
+
docs: {
|
|
169
|
+
description: "Bind the result of a `yield*` to a `const` instead of using it inside a larger expression.",
|
|
170
|
+
},
|
|
171
|
+
messages: {
|
|
172
|
+
inlineYieldStar: "This `yield*` runs inside a larger expression. Bind its result first (`const rows = yield* reader.rows`), so each statement runs one effect in reading order.",
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
createOnce(context) {
|
|
176
|
+
let inline = [];
|
|
177
|
+
let effectful = [];
|
|
178
|
+
const collect = (node) => {
|
|
179
|
+
effectful.push(node);
|
|
180
|
+
};
|
|
181
|
+
return {
|
|
182
|
+
before() {
|
|
183
|
+
inline = [];
|
|
184
|
+
effectful = [];
|
|
185
|
+
},
|
|
186
|
+
YieldExpression(node) {
|
|
187
|
+
effectful.push(node);
|
|
188
|
+
if (node.delegate && !isStandalone(node))
|
|
189
|
+
inline.push(node);
|
|
190
|
+
},
|
|
191
|
+
AwaitExpression: collect,
|
|
192
|
+
AssignmentExpression: collect,
|
|
193
|
+
CallExpression: collect,
|
|
194
|
+
NewExpression: collect,
|
|
195
|
+
TaggedTemplateExpression: collect,
|
|
196
|
+
UpdateExpression: collect,
|
|
197
|
+
VariableDeclarator: collect,
|
|
198
|
+
"Program:exit"() {
|
|
199
|
+
const byStatement = new Map();
|
|
200
|
+
for (const node of inline) {
|
|
201
|
+
const statement = statementOf(node);
|
|
202
|
+
byStatement.set(statement, [...(byStatement.get(statement) ?? []), node]);
|
|
203
|
+
}
|
|
204
|
+
for (const [statement, yields] of byStatement) {
|
|
205
|
+
const owner = nearestFunction(statement);
|
|
206
|
+
const hoisted = yields
|
|
207
|
+
.filter((node) => canHoist(node, statement))
|
|
208
|
+
.toSorted((left, right) => left.range[1] - right.range[1] || right.range[0] - left.range[0]);
|
|
209
|
+
const evaluatedInStatement = effectful.filter((node) => contains(statement.range, node.range) &&
|
|
210
|
+
nearestFunction(node) === owner);
|
|
211
|
+
const fixable = statement.type !== "Program" &&
|
|
212
|
+
hoisted.length > 0 &&
|
|
213
|
+
!reordersEffects(hoisted, evaluatedInStatement);
|
|
214
|
+
const replacement = fixable
|
|
215
|
+
? extraction(statement, hoisted, context.sourceCode)
|
|
216
|
+
: undefined;
|
|
217
|
+
for (const node of yields) {
|
|
218
|
+
context.report({
|
|
219
|
+
node,
|
|
220
|
+
messageId: "inlineYieldStar",
|
|
221
|
+
...(replacement !== undefined && hoisted.includes(node)
|
|
222
|
+
? { fix: (fixer) => fixer.replaceText(statement, replacement) }
|
|
223
|
+
: {}),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
},
|
|
230
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { configuredDiscriminants, discriminantOf, discriminantsSchema, isEqualityComparison, isInsideBroadEffectHandler, isStringLiteral, } from "../shared/tagged-values.js";
|
|
3
|
+
export const remedyFor = (discriminant) => discriminant === "_tag"
|
|
4
|
+
? "`Match.tagsExhaustive`, the tagged enum `$match` or an exhaustive ts-pattern match; for a single check, `Predicate.isTagged`"
|
|
5
|
+
: `\`Match.discriminatorsExhaustive("${discriminant}")\` or an exhaustive ts-pattern match`;
|
|
6
|
+
// Effect's own two-variant types cannot gain a variant: their refinements are the remedy.
|
|
7
|
+
const effectVariants = {
|
|
8
|
+
Success: { owner: "`Result`/`Exit`", guard: "`Result.isSuccess` or `Exit.isSuccess`" },
|
|
9
|
+
Failure: { owner: "`Result`/`Exit`", guard: "`Result.isFailure` or `Exit.isFailure`" },
|
|
10
|
+
Left: { owner: "`Either`", guard: "`Either.isLeft`" },
|
|
11
|
+
Right: { owner: "`Either`", guard: "`Either.isRight`" },
|
|
12
|
+
Some: { owner: "`Option`", guard: "`Option.isSome`" },
|
|
13
|
+
None: { owner: "`Option`", guard: "`Option.isNone`" },
|
|
14
|
+
};
|
|
15
|
+
const effectVariantOf = (tag) => Object.hasOwn(effectVariants, tag) ? { tag, ...effectVariants[tag] } : undefined;
|
|
16
|
+
export const noManualDiscriminantComparisonRule = defineRule({
|
|
17
|
+
meta: {
|
|
18
|
+
type: "suggestion",
|
|
19
|
+
docs: {
|
|
20
|
+
description: "Branch on a union's discriminant with an exhaustive match, not with comparisons or `switch`.",
|
|
21
|
+
},
|
|
22
|
+
messages: {
|
|
23
|
+
manualComparison: "Comparing `{{discriminant}}` with a literal still compiles when a variant is added: use {{remedy}}.",
|
|
24
|
+
effectVariantComparison: 'For the `"{{tag}}"` variant of {{owner}}, use {{guard}}; for a union of your own, {{remedy}}.',
|
|
25
|
+
manualSwitch: "A `switch` on `{{discriminant}}` still compiles when a variant is added: use {{remedy}}.",
|
|
26
|
+
},
|
|
27
|
+
schema: [
|
|
28
|
+
{
|
|
29
|
+
type: "object",
|
|
30
|
+
properties: { discriminants: discriminantsSchema },
|
|
31
|
+
additionalProperties: false,
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
createOnce(context) {
|
|
36
|
+
let discriminants = new Set();
|
|
37
|
+
const report = (node, discriminant, messageId, literal) => {
|
|
38
|
+
// Inside `Effect.catch` handlers, `_tag` belongs to `anti-slop-no-manual-effect-error-tag`.
|
|
39
|
+
if (discriminant === "_tag" && isInsideBroadEffectHandler(node))
|
|
40
|
+
return;
|
|
41
|
+
const remedy = remedyFor(discriminant);
|
|
42
|
+
const variant = discriminant === "_tag" && literal !== undefined
|
|
43
|
+
? effectVariantOf(literal)
|
|
44
|
+
: undefined;
|
|
45
|
+
if (variant !== undefined) {
|
|
46
|
+
context.report({
|
|
47
|
+
node,
|
|
48
|
+
messageId: "effectVariantComparison",
|
|
49
|
+
data: { ...variant, remedy },
|
|
50
|
+
});
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
context.report({ node, messageId, data: { discriminant, remedy } });
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
before() {
|
|
57
|
+
discriminants = configuredDiscriminants(context.options);
|
|
58
|
+
},
|
|
59
|
+
BinaryExpression(node) {
|
|
60
|
+
if (!isEqualityComparison(node))
|
|
61
|
+
return;
|
|
62
|
+
const literal = isStringLiteral(node.right)
|
|
63
|
+
? node.right
|
|
64
|
+
: isStringLiteral(node.left)
|
|
65
|
+
? node.left
|
|
66
|
+
: undefined;
|
|
67
|
+
if (literal === undefined)
|
|
68
|
+
return;
|
|
69
|
+
const subject = literal === node.right ? node.left : node.right;
|
|
70
|
+
const discriminant = discriminantOf(subject, discriminants);
|
|
71
|
+
if (discriminant !== undefined)
|
|
72
|
+
report(node, discriminant, "manualComparison", literal.value);
|
|
73
|
+
},
|
|
74
|
+
SwitchStatement(node) {
|
|
75
|
+
const discriminant = discriminantOf(node.discriminant, discriminants);
|
|
76
|
+
if (discriminant !== undefined)
|
|
77
|
+
report(node, discriminant, "manualSwitch");
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
});
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export declare const remedyFor: (discriminant: string) => "the Schema's `.make` or the class constructor" | "the Schema's tagged `.make`, the tagged class or error constructor, or the `Data.taggedEnum` variant constructor";
|
|
2
|
+
export declare const noManualDiscriminantConstructionRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { calleeOf, isFunctionNode } from "../shared/effect-ast.js";
|
|
3
|
+
import { configuredDiscriminants, discriminantsSchema, isStringLiteral, propertyName, } from "../shared/tagged-values.js";
|
|
4
|
+
export const remedyFor = (discriminant) => discriminant === "_tag"
|
|
5
|
+
? "the Schema's tagged `.make`, the tagged class or error constructor, or the `Data.taggedEnum` variant constructor"
|
|
6
|
+
: "the Schema's `.make` or the class constructor";
|
|
7
|
+
const isMatchPatternCall = (call) => {
|
|
8
|
+
const method = calleeOf(call, "Match");
|
|
9
|
+
return method === "when" || method === "not";
|
|
10
|
+
};
|
|
11
|
+
// `match(value).with(pattern, handler)`, also after earlier `.with`/`.when` links of the chain.
|
|
12
|
+
const isTsPatternWithCall = (call) => {
|
|
13
|
+
if (call.callee.type !== "MemberExpression" ||
|
|
14
|
+
call.callee.computed ||
|
|
15
|
+
call.callee.property.type !== "Identifier" ||
|
|
16
|
+
call.callee.property.name !== "with") {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
let link = call.callee.object;
|
|
20
|
+
while (link.type === "CallExpression" && link.callee.type === "MemberExpression") {
|
|
21
|
+
link = link.callee.object;
|
|
22
|
+
}
|
|
23
|
+
return (link.type === "CallExpression" &&
|
|
24
|
+
link.callee.type === "Identifier" &&
|
|
25
|
+
link.callee.name === "match");
|
|
26
|
+
};
|
|
27
|
+
// `Schema.decodeUnknownSync(X)(input)`: the literal is raw data to validate, not a value of X.
|
|
28
|
+
const isSchemaCodecApplication = (call) => {
|
|
29
|
+
const method = calleeOf(call.callee, "Schema");
|
|
30
|
+
return method !== undefined && (method.startsWith("decode") || method.startsWith("encode"));
|
|
31
|
+
};
|
|
32
|
+
// `expect(x).toMatchObject(…)`, `expect(x).not.toEqual(…)`, `expect.objectContaining(…)`: the
|
|
33
|
+
// literal describes the expected shape, and a partial match has no constructor to call.
|
|
34
|
+
const isAssertionExpectation = (call) => {
|
|
35
|
+
if (call.callee.type !== "MemberExpression")
|
|
36
|
+
return false;
|
|
37
|
+
let link = call.callee;
|
|
38
|
+
while (link.type === "MemberExpression" || link.type === "CallExpression") {
|
|
39
|
+
link = link.type === "MemberExpression" ? link.object : link.callee;
|
|
40
|
+
}
|
|
41
|
+
return link.type === "Identifier" && link.name === "expect";
|
|
42
|
+
};
|
|
43
|
+
// `JSON.stringify({ _tag: "NotReady" })`: the literal is the serialized form, which no constructor builds.
|
|
44
|
+
const isJsonSerialization = (call) => calleeOf(call, "JSON") === "stringify";
|
|
45
|
+
const declaredFunction = (name, scope) => {
|
|
46
|
+
for (let current = scope; current; current = current.upper) {
|
|
47
|
+
const definition = current.set.get(name)?.defs[0];
|
|
48
|
+
if (definition === undefined)
|
|
49
|
+
continue;
|
|
50
|
+
const declared = definition.node.type === "VariableDeclarator" ? definition.node.init : definition.node;
|
|
51
|
+
return isFunctionNode(declared) ? declared : undefined;
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
};
|
|
55
|
+
// `readinessAgainst(503, { _tag: "NotReady" })` with `readinessAgainst` declared in the file as
|
|
56
|
+
// `(status: number, body: unknown) => …`: the author declared the argument raw data, such as the
|
|
57
|
+
// body a fake HTTP server answers with. Without types, a function imported from elsewhere is not seen.
|
|
58
|
+
const isUnknownArgument = (call, argument, sourceCode) => {
|
|
59
|
+
if (call.callee.type !== "Identifier")
|
|
60
|
+
return false;
|
|
61
|
+
const index = call.arguments.findIndex((candidate) => candidate === argument);
|
|
62
|
+
if (call.arguments.slice(0, index).some((candidate) => candidate.type === "SpreadElement")) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
const fn = declaredFunction(call.callee.name, sourceCode.getScope(call));
|
|
66
|
+
const parameter = fn?.params[index];
|
|
67
|
+
const binding = parameter?.type === "AssignmentPattern" ? parameter.left : parameter;
|
|
68
|
+
return (binding?.type === "Identifier" &&
|
|
69
|
+
binding.typeAnnotation?.typeAnnotation.type === "TSUnknownKeyword");
|
|
70
|
+
};
|
|
71
|
+
// A literal nested in a pattern, in an expectation or in raw input belongs to it as much as the outermost one.
|
|
72
|
+
const isPatternExpectationOrRawInput = (node, sourceCode) => {
|
|
73
|
+
let child = node;
|
|
74
|
+
let current = node.parent;
|
|
75
|
+
while (current?.type === "Property" ||
|
|
76
|
+
current?.type === "ObjectExpression" ||
|
|
77
|
+
current?.type === "ArrayExpression") {
|
|
78
|
+
child = current;
|
|
79
|
+
current = current.parent;
|
|
80
|
+
}
|
|
81
|
+
return (current?.type === "CallExpression" &&
|
|
82
|
+
current.arguments.some((argument) => argument === child) &&
|
|
83
|
+
(isMatchPatternCall(current) ||
|
|
84
|
+
isTsPatternWithCall(current) ||
|
|
85
|
+
isAssertionExpectation(current) ||
|
|
86
|
+
isSchemaCodecApplication(current) ||
|
|
87
|
+
isJsonSerialization(current) ||
|
|
88
|
+
isUnknownArgument(current, child, sourceCode)));
|
|
89
|
+
};
|
|
90
|
+
export const noManualDiscriminantConstructionRule = defineRule({
|
|
91
|
+
meta: {
|
|
92
|
+
type: "problem",
|
|
93
|
+
docs: {
|
|
94
|
+
description: "Construct a union's variants with their constructor, not as object literals with the discriminant.",
|
|
95
|
+
},
|
|
96
|
+
messages: {
|
|
97
|
+
manualConstruction: "An object literal with a literal `{{discriminant}}` skips the variant's constructor: use {{remedy}}.",
|
|
98
|
+
},
|
|
99
|
+
schema: [
|
|
100
|
+
{
|
|
101
|
+
type: "object",
|
|
102
|
+
properties: { discriminants: discriminantsSchema },
|
|
103
|
+
additionalProperties: false,
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
createOnce(context) {
|
|
108
|
+
let discriminants = new Set();
|
|
109
|
+
return {
|
|
110
|
+
before() {
|
|
111
|
+
discriminants = configuredDiscriminants(context.options);
|
|
112
|
+
},
|
|
113
|
+
ObjectExpression(node) {
|
|
114
|
+
for (const property of node.properties) {
|
|
115
|
+
if (property.type !== "Property" || !isStringLiteral(property.value))
|
|
116
|
+
continue;
|
|
117
|
+
const discriminant = propertyName(property);
|
|
118
|
+
if (discriminant === undefined || !discriminants.has(discriminant))
|
|
119
|
+
continue;
|
|
120
|
+
if (isPatternExpectationOrRawInput(node, context.sourceCode))
|
|
121
|
+
return;
|
|
122
|
+
context.report({
|
|
123
|
+
node: property,
|
|
124
|
+
messageId: "manualConstruction",
|
|
125
|
+
data: { discriminant, remedy: remedyFor(discriminant) },
|
|
126
|
+
});
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const noNestedMatchRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { handlersOf, isMatchCall, nestedBranches } from "../shared/match-ast.js";
|
|
3
|
+
export const noNestedMatchRule = defineRule({
|
|
4
|
+
meta: {
|
|
5
|
+
type: "suggestion",
|
|
6
|
+
docs: {
|
|
7
|
+
description: "Flatten a match whose handler branches again: a guard clause inside generators, a named handler outside.",
|
|
8
|
+
},
|
|
9
|
+
messages: {
|
|
10
|
+
nestedBranch: "This handler branches again inside a match. Inside a generator, return early on the exit case (`if (Result.isFailure(parsed)) return yield* ...`); outside, name the inner choice and pass it as the handler.",
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
createOnce(context) {
|
|
14
|
+
return {
|
|
15
|
+
CallExpression(node) {
|
|
16
|
+
if (!isMatchCall(node))
|
|
17
|
+
return;
|
|
18
|
+
for (const branch of handlersOf(node).flatMap(nestedBranches)) {
|
|
19
|
+
context.report({ node: branch, messageId: "nestedBranch" });
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
},
|
|
24
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const noParameterlessCatchAllRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { calleeOf } from "../shared/effect-ast.js";
|
|
3
|
+
const catchAlls = new Set(["catch", "catchAll", "catchCause", "catchEager"]);
|
|
4
|
+
export const noParameterlessCatchAllRule = defineRule({
|
|
5
|
+
meta: {
|
|
6
|
+
type: "problem",
|
|
7
|
+
docs: {
|
|
8
|
+
description: "Forbid catch-all handlers that ignore the error they catch.",
|
|
9
|
+
},
|
|
10
|
+
messages: {
|
|
11
|
+
swallowed: "`Effect.{{name}}` ignores what it catches, so it also swallows failures added later: use `Effect.catchTag`, or `Effect.orElseSucceed`/`Effect.orElse` when every failure truly means the same fallback.",
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
createOnce(context) {
|
|
15
|
+
return {
|
|
16
|
+
CallExpression(node) {
|
|
17
|
+
const name = calleeOf(node, "Effect");
|
|
18
|
+
if (name === undefined || !catchAlls.has(name))
|
|
19
|
+
return;
|
|
20
|
+
const handler = node.arguments.at(-1);
|
|
21
|
+
if ((handler?.type === "ArrowFunctionExpression" ||
|
|
22
|
+
handler?.type === "FunctionExpression") &&
|
|
23
|
+
handler.params.length === 0) {
|
|
24
|
+
context.report({ node: handler, messageId: "swallowed", data: { name } });
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const noProvideOnDefinitionRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { calleeOf, isEffectFnApplication } from "../shared/effect-ast.js";
|
|
3
|
+
import { runnerName } from "../shared/effect-runners.js";
|
|
4
|
+
import { filePatternsSchema } from "../shared/file-globs.js";
|
|
5
|
+
import { isTestFile } from "../shared/test-files.js";
|
|
6
|
+
const pipeReceiver = (call) => {
|
|
7
|
+
const { callee } = call;
|
|
8
|
+
return callee.type === "MemberExpression" &&
|
|
9
|
+
!callee.computed &&
|
|
10
|
+
callee.property.type === "Identifier" &&
|
|
11
|
+
callee.property.name === "pipe"
|
|
12
|
+
? callee.object
|
|
13
|
+
: undefined;
|
|
14
|
+
};
|
|
15
|
+
const isProgramDefinition = (node) => {
|
|
16
|
+
if (node?.type !== "CallExpression")
|
|
17
|
+
return false;
|
|
18
|
+
if (calleeOf(node, "Effect") === "gen" || isEffectFnApplication(node))
|
|
19
|
+
return true;
|
|
20
|
+
return isProgramDefinition(pipeReceiver(node));
|
|
21
|
+
};
|
|
22
|
+
const isContextCapture = (init) => {
|
|
23
|
+
if (init?.type !== "YieldExpression" || !init.delegate)
|
|
24
|
+
return false;
|
|
25
|
+
let source = init.argument;
|
|
26
|
+
while (source?.type === "CallExpression" && pipeReceiver(source))
|
|
27
|
+
source = pipeReceiver(source);
|
|
28
|
+
return calleeOf(source, "Effect") === "context";
|
|
29
|
+
};
|
|
30
|
+
const isCapturedContext = (value) => {
|
|
31
|
+
if (value?.type !== "Identifier")
|
|
32
|
+
return false;
|
|
33
|
+
for (let current = value.parent; current; current = current.parent) {
|
|
34
|
+
if (current.type !== "BlockStatement")
|
|
35
|
+
continue;
|
|
36
|
+
const captured = current.body.some((statement) => statement.type === "VariableDeclaration" &&
|
|
37
|
+
statement.kind === "const" &&
|
|
38
|
+
statement.declarations.some((declarator) => declarator.id.type === "Identifier" &&
|
|
39
|
+
declarator.id.name === value.name &&
|
|
40
|
+
isContextCapture(declarator.init)));
|
|
41
|
+
if (captured)
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
};
|
|
46
|
+
const providedValue = (provide) => provide.arguments[provide.arguments.length > 1 ? 1 : 0];
|
|
47
|
+
const providedDefinition = (provide) => {
|
|
48
|
+
if (provide.arguments.length > 1)
|
|
49
|
+
return isProgramDefinition(provide.arguments[0]);
|
|
50
|
+
const parent = provide.parent;
|
|
51
|
+
if (parent?.type !== "CallExpression")
|
|
52
|
+
return false;
|
|
53
|
+
// `Effect.fn("name")(body, Effect.provide(layer))`: the pipeable slot belongs to the definition.
|
|
54
|
+
if (isEffectFnApplication(parent) && parent.arguments.indexOf(provide) > 0)
|
|
55
|
+
return true;
|
|
56
|
+
return parent.arguments.includes(provide) && isProgramDefinition(pipeReceiver(parent));
|
|
57
|
+
};
|
|
58
|
+
const isRunnerCall = (node) => node?.type === "CallExpression" &&
|
|
59
|
+
node.callee.type === "MemberExpression" &&
|
|
60
|
+
runnerName(node.callee) !== undefined;
|
|
61
|
+
// The expression a `.pipe(…)` or a data-first `Effect.*` combinator builds around `node`.
|
|
62
|
+
const enclosingProgram = (node) => {
|
|
63
|
+
const { parent } = node;
|
|
64
|
+
if (parent?.type === "MemberExpression" && parent.object === node) {
|
|
65
|
+
const call = parent.parent;
|
|
66
|
+
return call?.type === "CallExpression" && pipeReceiver(call) === node ? call : undefined;
|
|
67
|
+
}
|
|
68
|
+
return parent?.type === "CallExpression" &&
|
|
69
|
+
parent.arguments.some((argument) => argument === node) &&
|
|
70
|
+
(pipeReceiver(parent) !== undefined || calleeOf(parent, "Effect") !== undefined) &&
|
|
71
|
+
!isRunnerCall(parent)
|
|
72
|
+
? parent
|
|
73
|
+
: undefined;
|
|
74
|
+
};
|
|
75
|
+
// A program handed straight to `Effect.run*` has no caller to wire it: it is being dispatched.
|
|
76
|
+
const isDispatchedInline = (provide) => {
|
|
77
|
+
let program = provide;
|
|
78
|
+
for (let outer = enclosingProgram(program); outer; outer = enclosingProgram(program)) {
|
|
79
|
+
program = outer;
|
|
80
|
+
}
|
|
81
|
+
const { parent } = program;
|
|
82
|
+
return isRunnerCall(parent) && parent.arguments.some((argument) => argument === program);
|
|
83
|
+
};
|
|
84
|
+
// A program that provides its own layers can no longer be wired differently by its caller:
|
|
85
|
+
// the dispatch that picks the adapters is where `Effect.provide` belongs.
|
|
86
|
+
export const noProvideOnDefinitionRule = defineRule({
|
|
87
|
+
meta: {
|
|
88
|
+
type: "suggestion",
|
|
89
|
+
docs: {
|
|
90
|
+
description: "Keep `Effect.provide` off `Effect.gen`/`Effect.fn` definitions; provide where the program is dispatched. Test files are skipped.",
|
|
91
|
+
},
|
|
92
|
+
messages: {
|
|
93
|
+
provideOnDefinition: "Define the program without layers and apply `Effect.provide` where it is dispatched, to a named program.",
|
|
94
|
+
},
|
|
95
|
+
schema: [
|
|
96
|
+
{
|
|
97
|
+
type: "object",
|
|
98
|
+
properties: { testFiles: filePatternsSchema },
|
|
99
|
+
additionalProperties: false,
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
createOnce(context) {
|
|
104
|
+
let inTestFile = false;
|
|
105
|
+
return {
|
|
106
|
+
// `it.effect(…, () => Effect.gen(…).pipe(Effect.provide(fakes)))` is the idiomatic test.
|
|
107
|
+
before() {
|
|
108
|
+
inTestFile = isTestFile(context);
|
|
109
|
+
},
|
|
110
|
+
CallExpression(node) {
|
|
111
|
+
if (inTestFile || calleeOf(node, "Effect") !== "provide")
|
|
112
|
+
return;
|
|
113
|
+
// A Layer that captures its context with `Effect.context()` hands it back to each
|
|
114
|
+
// method: the caller already chose the adapters when it built the Layer.
|
|
115
|
+
if (providedDefinition(node) &&
|
|
116
|
+
!isCapturedContext(providedValue(node)) &&
|
|
117
|
+
!isDispatchedInline(node)) {
|
|
118
|
+
context.report({ node, messageId: "provideOnDefinition" });
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const noTransportTypesInGeneratorRule: import("@oxlint/plugins").Rule;
|