@doubleloop-io/oxlint-plugin-effect 0.1.0-rc.0 → 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/README.md +42 -36
- package/RULES.md +87 -67
- package/dist/index.d.ts +2 -0
- package/dist/index.js +8 -4
- package/dist/presets/all.json +3 -2
- 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/error-handling-in-effect-fn-handler.js +80 -2
- package/dist/rules/no-bare-error-in-effect.js +17 -2
- package/dist/rules/no-manual-discriminant-comparison.js +33 -10
- package/dist/rules/no-manual-discriminant-construction.js +47 -5
- package/dist/rules/no-provide-on-definition.js +30 -1
- package/dist/rules/prefer-effect-vitest.d.ts +1 -0
- package/dist/rules/prefer-effect-vitest.js +52 -0
- package/dist/rules/redacted-secret-names.js +2 -4
- package/dist/rules/run-only-in-runner.d.ts +1 -0
- package/dist/rules/{run-only-at-entry.js → run-only-in-runner.js} +10 -20
- package/dist/rules/single-style-per-function.js +89 -21
- package/dist/shared/effect-ast.d.ts +1 -0
- package/dist/shared/effect-ast.js +15 -4
- package/dist/shared/effect-runners.d.ts +2 -0
- package/dist/shared/effect-runners.js +14 -0
- package/dist/shared/entry-files.d.ts +1 -1
- package/dist/shared/test-files.d.ts +1 -1
- package/package.json +62 -56
- package/dist/rules/boundary-entrypoint-shape.d.ts +0 -1
- package/dist/rules/boundary-entrypoint-shape.js +0 -52
- package/dist/rules/run-only-at-entry.d.ts +0 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { calleeOf, isServerComponent, memberOf, nearestFunction, } from "../shared/effect-ast.js";
|
|
3
|
+
import { filePatternsSchema } from "../shared/file-globs.js";
|
|
4
|
+
import { isTestFile } from "../shared/test-files.js";
|
|
5
|
+
// The *Exit runners hand failures back as values, so an open error channel cannot leak there.
|
|
6
|
+
// `NodeRuntime.runMain`/`BunRuntime.runMain` are boundaries too: they log the Cause and exit with
|
|
7
|
+
// code 1, which is the right outcome for a process failing to start (a ConfigError, a busy port).
|
|
8
|
+
const effectRunners = new Set(["runPromise", "runSync", "runFork", "runCallback"]);
|
|
9
|
+
const isRunnerCall = (node) => effectRunners.has(calleeOf(node, "Effect") ?? "");
|
|
10
|
+
const isEffectTypeName = (name) => (name.type === "Identifier" && name.name === "Effect") ||
|
|
11
|
+
(name.type === "TSQualifiedName" &&
|
|
12
|
+
name.left.type === "Identifier" &&
|
|
13
|
+
name.left.name === "Effect" &&
|
|
14
|
+
name.right.name === "Effect");
|
|
15
|
+
const isClosedEffectType = (type) => type?.type === "TSTypeReference" &&
|
|
16
|
+
isEffectTypeName(type.typeName) &&
|
|
17
|
+
type.typeArguments?.params[1]?.type === "TSNeverKeyword";
|
|
18
|
+
const parameterType = (identifier) => {
|
|
19
|
+
for (let fn = nearestFunction(identifier); fn; fn = nearestFunction(fn)) {
|
|
20
|
+
for (const param of fn.params) {
|
|
21
|
+
if (param.type === "Identifier" && param.name === identifier.name) {
|
|
22
|
+
return param.typeAnnotation?.typeAnnotation;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
};
|
|
28
|
+
// They turn every failure into a success value, so whatever comes before cannot leak.
|
|
29
|
+
const failureToValue = new Set(["result", "exit"]);
|
|
30
|
+
const isPipeCall = (node) => node.callee.type === "MemberExpression" &&
|
|
31
|
+
!node.callee.computed &&
|
|
32
|
+
node.callee.property.type === "Identifier" &&
|
|
33
|
+
node.callee.property.name === "pipe";
|
|
34
|
+
// `Effect.result(program)` or `program.pipe(…, Effect.result)`.
|
|
35
|
+
const endsWithFailureToValue = (node) => {
|
|
36
|
+
const last = isPipeCall(node) ? node.arguments.at(-1) : undefined;
|
|
37
|
+
const step = last === undefined ? calleeOf(node, "Effect") : memberOf(last, "Effect");
|
|
38
|
+
return failureToValue.has(step ?? "");
|
|
39
|
+
};
|
|
40
|
+
const hasClosedErrorChannel = (node) => {
|
|
41
|
+
if (node?.type === "TSSatisfiesExpression")
|
|
42
|
+
return isClosedEffectType(node.typeAnnotation);
|
|
43
|
+
if (node?.type === "Identifier")
|
|
44
|
+
return isClosedEffectType(parameterType(node));
|
|
45
|
+
if (node?.type === "CallExpression")
|
|
46
|
+
return endsWithFailureToValue(node);
|
|
47
|
+
return false;
|
|
48
|
+
};
|
|
49
|
+
export const allErrorsHandledAtBoundaryRule = defineRule({
|
|
50
|
+
meta: {
|
|
51
|
+
type: "problem",
|
|
52
|
+
docs: {
|
|
53
|
+
description: "Close the error channel to `never` before running an Effect at the boundary. Test files are skipped.",
|
|
54
|
+
},
|
|
55
|
+
messages: {
|
|
56
|
+
openErrorChannel: "Close the error channel before running: `program satisfies Effect.Effect<A, never>`, or a runner parameter typed `Effect.Effect<A, never, never>`.",
|
|
57
|
+
},
|
|
58
|
+
schema: [
|
|
59
|
+
{
|
|
60
|
+
type: "object",
|
|
61
|
+
properties: { testFiles: filePatternsSchema },
|
|
62
|
+
additionalProperties: false,
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
createOnce(context) {
|
|
67
|
+
const frames = [];
|
|
68
|
+
let inTestFile = false;
|
|
69
|
+
const enter = (fn) => {
|
|
70
|
+
frames.push({ fn, rendersJsx: false, openRuns: [] });
|
|
71
|
+
};
|
|
72
|
+
// The JSX can follow the run, so whether the function is a Server Component is known only on exit.
|
|
73
|
+
const exit = () => {
|
|
74
|
+
const frame = frames.pop();
|
|
75
|
+
if (frame === undefined || isServerComponent(frame.fn, frame.rendersJsx))
|
|
76
|
+
return;
|
|
77
|
+
for (const node of frame.openRuns)
|
|
78
|
+
context.report({ node, messageId: "openErrorChannel" });
|
|
79
|
+
};
|
|
80
|
+
const markJsx = () => {
|
|
81
|
+
const frame = frames.at(-1);
|
|
82
|
+
if (frame !== undefined)
|
|
83
|
+
frame.rendersJsx = true;
|
|
84
|
+
};
|
|
85
|
+
return {
|
|
86
|
+
// A test is not a boundary: a rejected run fails it with the error, which is the point.
|
|
87
|
+
before() {
|
|
88
|
+
inTestFile = isTestFile(context);
|
|
89
|
+
},
|
|
90
|
+
ArrowFunctionExpression: enter,
|
|
91
|
+
FunctionExpression: enter,
|
|
92
|
+
FunctionDeclaration: enter,
|
|
93
|
+
"ArrowFunctionExpression:exit": exit,
|
|
94
|
+
"FunctionExpression:exit": exit,
|
|
95
|
+
"FunctionDeclaration:exit": exit,
|
|
96
|
+
JSXElement: markJsx,
|
|
97
|
+
JSXFragment: markJsx,
|
|
98
|
+
CallExpression(node) {
|
|
99
|
+
if (inTestFile)
|
|
100
|
+
return;
|
|
101
|
+
if (!isRunnerCall(node) || hasClosedErrorChannel(node.arguments[0]))
|
|
102
|
+
return;
|
|
103
|
+
const frame = frames.at(-1);
|
|
104
|
+
if (frame === undefined)
|
|
105
|
+
context.report({ node, messageId: "openErrorChannel" });
|
|
106
|
+
else
|
|
107
|
+
frame.openRuns.push(node);
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineRule } from "@oxlint/plugins";
|
|
2
|
-
import { isInsideEffectGeneratorBody, memberOf } from "../shared/effect-ast.js";
|
|
2
|
+
import { isEffectGenerator, isInsideEffectGeneratorBody, memberOf, nearestFunction, } from "../shared/effect-ast.js";
|
|
3
3
|
import { filePatternsSchema } from "../shared/file-globs.js";
|
|
4
4
|
import { isTestFile } from "../shared/test-files.js";
|
|
5
5
|
// mapError, orElse and orElseSucceed stay allowed: they name a failure or give an alternative
|
|
@@ -27,6 +27,82 @@ const errorHandlers = new Set([
|
|
|
27
27
|
"result",
|
|
28
28
|
"sandbox",
|
|
29
29
|
]);
|
|
30
|
+
// Effect 4 names first, then the v3 ones without a v4 counterpart.
|
|
31
|
+
const iterationCombinators = new Set([
|
|
32
|
+
"filter",
|
|
33
|
+
"filterMap",
|
|
34
|
+
"filterMapEffect",
|
|
35
|
+
"findFirst",
|
|
36
|
+
"findFirstFilter",
|
|
37
|
+
"forEach",
|
|
38
|
+
"partition",
|
|
39
|
+
"reduce",
|
|
40
|
+
"validate",
|
|
41
|
+
"dropUntil",
|
|
42
|
+
"dropWhile",
|
|
43
|
+
"every",
|
|
44
|
+
"exists",
|
|
45
|
+
"mergeAll",
|
|
46
|
+
"reduceWhile",
|
|
47
|
+
"takeUntil",
|
|
48
|
+
"takeWhile",
|
|
49
|
+
"validateAll",
|
|
50
|
+
"validateFirst",
|
|
51
|
+
]);
|
|
52
|
+
const failureCatchers = new Set([
|
|
53
|
+
"catch",
|
|
54
|
+
"catchAll",
|
|
55
|
+
"catchFilter",
|
|
56
|
+
"catchIf",
|
|
57
|
+
"catchTag",
|
|
58
|
+
"catchTags",
|
|
59
|
+
]);
|
|
60
|
+
const isEffectDie = (node) => ["die", "dieMessage"].includes(memberOf(node, "Effect") ?? "");
|
|
61
|
+
const returnedExpression = (fn) => {
|
|
62
|
+
if (fn.body?.type !== "BlockStatement")
|
|
63
|
+
return fn.body;
|
|
64
|
+
const [statement, ...rest] = fn.body.body;
|
|
65
|
+
return rest.length === 0 && statement?.type === "ReturnStatement" ? statement.argument : null;
|
|
66
|
+
};
|
|
67
|
+
const onlyDies = (handler) => {
|
|
68
|
+
if (isEffectDie(handler))
|
|
69
|
+
return true;
|
|
70
|
+
if (handler.type !== "ArrowFunctionExpression" && handler.type !== "FunctionExpression") {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
const returned = returnedExpression(handler);
|
|
74
|
+
return returned?.type === "CallExpression" && isEffectDie(returned.callee);
|
|
75
|
+
};
|
|
76
|
+
// Turning a failure into a defect is a selective `orDie`, which stays allowed: it declares the case
|
|
77
|
+
// untracked rather than handling it. The handler is the last argument in both the pipeable and the
|
|
78
|
+
// data-first forms; `catchTags` takes an object of handlers and is exempt only when all of them die.
|
|
79
|
+
const isDieOnlyCatch = (node, name) => {
|
|
80
|
+
const call = node.parent;
|
|
81
|
+
if (call?.type !== "CallExpression" || call.callee !== node || !failureCatchers.has(name)) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
const handler = call.arguments.at(-1);
|
|
85
|
+
if (handler === undefined)
|
|
86
|
+
return false;
|
|
87
|
+
if (name !== "catchTags")
|
|
88
|
+
return onlyDies(handler);
|
|
89
|
+
return (handler.type === "ObjectExpression" &&
|
|
90
|
+
handler.properties.length > 0 &&
|
|
91
|
+
handler.properties.every((property) => property.type === "Property" && onlyDies(property.value)));
|
|
92
|
+
};
|
|
93
|
+
// A handler in the callback of an iteration (`Effect.forEach(items, (item) => ... Effect.catch ...)`)
|
|
94
|
+
// handles each element on its own; moved to the `Effect.fn` pipeables, the first failure would stop
|
|
95
|
+
// them all. Other callbacks (`flatMap`, `tap`) stay on the success path and are still reported.
|
|
96
|
+
const isInsideIterationCallback = (node) => {
|
|
97
|
+
for (let fn = nearestFunction(node); fn && !isEffectGenerator(fn); fn = nearestFunction(fn)) {
|
|
98
|
+
const call = fn.parent;
|
|
99
|
+
if (call?.type === "CallExpression" &&
|
|
100
|
+
iterationCombinators.has(memberOf(call.callee, "Effect") ?? "")) {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
};
|
|
30
106
|
export const errorHandlingInEffectFnHandlerRule = defineRule({
|
|
31
107
|
meta: {
|
|
32
108
|
type: "suggestion",
|
|
@@ -56,7 +132,9 @@ export const errorHandlingInEffectFnHandlerRule = defineRule({
|
|
|
56
132
|
if (inTestFile ||
|
|
57
133
|
name === undefined ||
|
|
58
134
|
!errorHandlers.has(name) ||
|
|
59
|
-
!isInsideEffectGeneratorBody(node)
|
|
135
|
+
!isInsideEffectGeneratorBody(node) ||
|
|
136
|
+
isInsideIterationCallback(node) ||
|
|
137
|
+
isDieOnlyCatch(node, name)) {
|
|
60
138
|
return;
|
|
61
139
|
}
|
|
62
140
|
context.report({ node, messageId: "handlerInGenerator", data: { name } });
|
|
@@ -1,20 +1,35 @@
|
|
|
1
1
|
import { defineRule } from "@oxlint/plugins";
|
|
2
2
|
import { calleeOf, isEffectGenerator, nearestFunction } from "../shared/effect-ast.js";
|
|
3
|
+
import { filePatternsSchema } from "../shared/file-globs.js";
|
|
4
|
+
import { isTestFile } from "../shared/test-files.js";
|
|
3
5
|
// Only the two forms the effecttsgo preset misses: `Effect.fail(new Error(…))` and
|
|
4
6
|
// `extends Error` are already reported by global-error-in-effect-failure and extends-native-error.
|
|
5
7
|
export const noBareErrorInEffectRule = defineRule({
|
|
6
8
|
meta: {
|
|
7
9
|
type: "problem",
|
|
8
10
|
docs: {
|
|
9
|
-
description: "Fail Effects with tagged errors, never with a `throw` or a primitive value.",
|
|
11
|
+
description: "Fail Effects with tagged errors, never with a `throw` or a primitive value. Primitive failures in test files are allowed.",
|
|
10
12
|
},
|
|
11
13
|
messages: {
|
|
12
14
|
throwInGenerator: "A `throw` inside an Effect generator becomes a defect: `return yield* new TaggedError(...)` instead.",
|
|
13
15
|
primitiveFailure: "Fail with a tagged error (`Data.TaggedError` or `Schema.TaggedError`), not a primitive value.",
|
|
14
16
|
},
|
|
17
|
+
schema: [
|
|
18
|
+
{
|
|
19
|
+
type: "object",
|
|
20
|
+
properties: { testFiles: filePatternsSchema },
|
|
21
|
+
additionalProperties: false,
|
|
22
|
+
},
|
|
23
|
+
],
|
|
15
24
|
},
|
|
16
25
|
createOnce(context) {
|
|
26
|
+
let inTestFile = false;
|
|
17
27
|
return {
|
|
28
|
+
// A test's synthetic failure (`Effect.fail("boom")`) feeds code generic over the error,
|
|
29
|
+
// which never discriminates it; a `throw` is still a defect there.
|
|
30
|
+
before() {
|
|
31
|
+
inTestFile = isTestFile(context);
|
|
32
|
+
},
|
|
18
33
|
ThrowStatement(node) {
|
|
19
34
|
const fn = nearestFunction(node);
|
|
20
35
|
if (fn !== undefined && isEffectGenerator(fn)) {
|
|
@@ -22,7 +37,7 @@ export const noBareErrorInEffectRule = defineRule({
|
|
|
22
37
|
}
|
|
23
38
|
},
|
|
24
39
|
CallExpression(node) {
|
|
25
|
-
if (calleeOf(node, "Effect") !== "fail")
|
|
40
|
+
if (inTestFile || calleeOf(node, "Effect") !== "fail")
|
|
26
41
|
return;
|
|
27
42
|
const [failure] = node.arguments;
|
|
28
43
|
if (failure?.type === "Literal" || failure?.type === "TemplateLiteral") {
|
|
@@ -3,6 +3,16 @@ import { configuredDiscriminants, discriminantOf, discriminantsSchema, isEqualit
|
|
|
3
3
|
export const remedyFor = (discriminant) => discriminant === "_tag"
|
|
4
4
|
? "`Match.tagsExhaustive`, the tagged enum `$match` or an exhaustive ts-pattern match; for a single check, `Predicate.isTagged`"
|
|
5
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;
|
|
6
16
|
export const noManualDiscriminantComparisonRule = defineRule({
|
|
7
17
|
meta: {
|
|
8
18
|
type: "suggestion",
|
|
@@ -11,6 +21,7 @@ export const noManualDiscriminantComparisonRule = defineRule({
|
|
|
11
21
|
},
|
|
12
22
|
messages: {
|
|
13
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}}.',
|
|
14
25
|
manualSwitch: "A `switch` on `{{discriminant}}` still compiles when a variant is added: use {{remedy}}.",
|
|
15
26
|
},
|
|
16
27
|
schema: [
|
|
@@ -23,15 +34,23 @@ export const noManualDiscriminantComparisonRule = defineRule({
|
|
|
23
34
|
},
|
|
24
35
|
createOnce(context) {
|
|
25
36
|
let discriminants = new Set();
|
|
26
|
-
const report = (node, discriminant, messageId) => {
|
|
37
|
+
const report = (node, discriminant, messageId, literal) => {
|
|
27
38
|
// Inside `Effect.catch` handlers, `_tag` belongs to `anti-slop-no-manual-effect-error-tag`.
|
|
28
39
|
if (discriminant === "_tag" && isInsideBroadEffectHandler(node))
|
|
29
40
|
return;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 } });
|
|
35
54
|
};
|
|
36
55
|
return {
|
|
37
56
|
before() {
|
|
@@ -40,13 +59,17 @@ export const noManualDiscriminantComparisonRule = defineRule({
|
|
|
40
59
|
BinaryExpression(node) {
|
|
41
60
|
if (!isEqualityComparison(node))
|
|
42
61
|
return;
|
|
43
|
-
const
|
|
44
|
-
?
|
|
62
|
+
const literal = isStringLiteral(node.right)
|
|
63
|
+
? node.right
|
|
45
64
|
: isStringLiteral(node.left)
|
|
46
|
-
?
|
|
65
|
+
? node.left
|
|
47
66
|
: undefined;
|
|
67
|
+
if (literal === undefined)
|
|
68
|
+
return;
|
|
69
|
+
const subject = literal === node.right ? node.left : node.right;
|
|
70
|
+
const discriminant = discriminantOf(subject, discriminants);
|
|
48
71
|
if (discriminant !== undefined)
|
|
49
|
-
report(node, discriminant, "manualComparison");
|
|
72
|
+
report(node, discriminant, "manualComparison", literal.value);
|
|
50
73
|
},
|
|
51
74
|
SwitchStatement(node) {
|
|
52
75
|
const discriminant = discriminantOf(node.discriminant, discriminants);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineRule } from "@oxlint/plugins";
|
|
2
|
-
import { calleeOf } from "../shared/effect-ast.js";
|
|
2
|
+
import { calleeOf, isFunctionNode } from "../shared/effect-ast.js";
|
|
3
3
|
import { configuredDiscriminants, discriminantsSchema, isStringLiteral, propertyName, } from "../shared/tagged-values.js";
|
|
4
4
|
export const remedyFor = (discriminant) => discriminant === "_tag"
|
|
5
5
|
? "the Schema's tagged `.make`, the tagged class or error constructor, or the `Data.taggedEnum` variant constructor"
|
|
@@ -29,8 +29,47 @@ const isSchemaCodecApplication = (call) => {
|
|
|
29
29
|
const method = calleeOf(call.callee, "Schema");
|
|
30
30
|
return method !== undefined && (method.startsWith("decode") || method.startsWith("encode"));
|
|
31
31
|
};
|
|
32
|
-
//
|
|
33
|
-
|
|
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) => {
|
|
34
73
|
let child = node;
|
|
35
74
|
let current = node.parent;
|
|
36
75
|
while (current?.type === "Property" ||
|
|
@@ -43,7 +82,10 @@ const isPatternOrCodecInput = (node) => {
|
|
|
43
82
|
current.arguments.some((argument) => argument === child) &&
|
|
44
83
|
(isMatchPatternCall(current) ||
|
|
45
84
|
isTsPatternWithCall(current) ||
|
|
46
|
-
|
|
85
|
+
isAssertionExpectation(current) ||
|
|
86
|
+
isSchemaCodecApplication(current) ||
|
|
87
|
+
isJsonSerialization(current) ||
|
|
88
|
+
isUnknownArgument(current, child, sourceCode)));
|
|
47
89
|
};
|
|
48
90
|
export const noManualDiscriminantConstructionRule = defineRule({
|
|
49
91
|
meta: {
|
|
@@ -75,7 +117,7 @@ export const noManualDiscriminantConstructionRule = defineRule({
|
|
|
75
117
|
const discriminant = propertyName(property);
|
|
76
118
|
if (discriminant === undefined || !discriminants.has(discriminant))
|
|
77
119
|
continue;
|
|
78
|
-
if (
|
|
120
|
+
if (isPatternExpectationOrRawInput(node, context.sourceCode))
|
|
79
121
|
return;
|
|
80
122
|
context.report({
|
|
81
123
|
node: property,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineRule } from "@oxlint/plugins";
|
|
2
2
|
import { calleeOf, isEffectFnApplication } from "../shared/effect-ast.js";
|
|
3
|
+
import { runnerName } from "../shared/effect-runners.js";
|
|
3
4
|
import { filePatternsSchema } from "../shared/file-globs.js";
|
|
4
5
|
import { isTestFile } from "../shared/test-files.js";
|
|
5
6
|
const pipeReceiver = (call) => {
|
|
@@ -54,6 +55,32 @@ const providedDefinition = (provide) => {
|
|
|
54
55
|
return true;
|
|
55
56
|
return parent.arguments.includes(provide) && isProgramDefinition(pipeReceiver(parent));
|
|
56
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
|
+
};
|
|
57
84
|
// A program that provides its own layers can no longer be wired differently by its caller:
|
|
58
85
|
// the dispatch that picks the adapters is where `Effect.provide` belongs.
|
|
59
86
|
export const noProvideOnDefinitionRule = defineRule({
|
|
@@ -85,7 +112,9 @@ export const noProvideOnDefinitionRule = defineRule({
|
|
|
85
112
|
return;
|
|
86
113
|
// A Layer that captures its context with `Effect.context()` hands it back to each
|
|
87
114
|
// method: the caller already chose the adapters when it built the Layer.
|
|
88
|
-
if (providedDefinition(node) &&
|
|
115
|
+
if (providedDefinition(node) &&
|
|
116
|
+
!isCapturedContext(providedValue(node)) &&
|
|
117
|
+
!isDispatchedInline(node)) {
|
|
89
118
|
context.report({ node, messageId: "provideOnDefinition" });
|
|
90
119
|
}
|
|
91
120
|
},
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const preferEffectVitestRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { defineRule } from "@oxlint/plugins";
|
|
4
|
+
import { runnerName } from "../shared/effect-runners.js";
|
|
5
|
+
import { filePatternsSchema } from "../shared/file-globs.js";
|
|
6
|
+
import { isTestFile } from "../shared/test-files.js";
|
|
7
|
+
// Without `@effect/vitest` the remedy is out of reach, so the rule stays silent. Not cached:
|
|
8
|
+
// a long-lived linter would miss a later install, and only test files pay for the lookup.
|
|
9
|
+
const isEffectVitestInstalled = (filename) => {
|
|
10
|
+
for (let dir = dirname(filename);; dir = dirname(dir)) {
|
|
11
|
+
if (existsSync(join(dir, "node_modules", "@effect", "vitest", "package.json")))
|
|
12
|
+
return true;
|
|
13
|
+
if (dirname(dir) === dir)
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
// Reports references too, not only calls: `const run = Effect.runPromise` escapes nothing.
|
|
18
|
+
export const preferEffectVitestRule = defineRule({
|
|
19
|
+
meta: {
|
|
20
|
+
type: "suggestion",
|
|
21
|
+
docs: {
|
|
22
|
+
description: "Run Effects in tests through `@effect/vitest`. Only test files of projects that install it are checked.",
|
|
23
|
+
},
|
|
24
|
+
messages: {
|
|
25
|
+
runInTest: "`{{name}}` runs an Effect program in a test: let `@effect/vitest` run it, so the test gets the test runtime and its fixtures become Layers.",
|
|
26
|
+
},
|
|
27
|
+
schema: [
|
|
28
|
+
{
|
|
29
|
+
type: "object",
|
|
30
|
+
properties: { testFiles: filePatternsSchema },
|
|
31
|
+
additionalProperties: false,
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
createOnce(context) {
|
|
36
|
+
let checked = false;
|
|
37
|
+
return {
|
|
38
|
+
before() {
|
|
39
|
+
checked =
|
|
40
|
+
isTestFile(context) &&
|
|
41
|
+
isEffectVitestInstalled(resolve(context.cwd, context.filename));
|
|
42
|
+
},
|
|
43
|
+
MemberExpression(node) {
|
|
44
|
+
if (!checked)
|
|
45
|
+
return;
|
|
46
|
+
const name = runnerName(node);
|
|
47
|
+
if (name !== undefined)
|
|
48
|
+
context.report({ node, messageId: "runInTest", data: { name } });
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
});
|
|
@@ -18,9 +18,7 @@ const isRedactedSchema = (node) => node?.type === "CallExpression" &&
|
|
|
18
18
|
(memberOf(node.callee, "Schema")?.startsWith("Redacted") ?? false);
|
|
19
19
|
const isRedactedConfig = (node) => {
|
|
20
20
|
const name = calleeOf(node, "Config");
|
|
21
|
-
return (name === "
|
|
22
|
-
(name === "schema" && isRedactedSchema(node.arguments[0])) ||
|
|
23
|
-
calleeOf(node.parent, "Config") === "redacted");
|
|
21
|
+
return name === "Redacted" || (name === "schema" && isRedactedSchema(node.arguments[0]));
|
|
24
22
|
};
|
|
25
23
|
const isRedactedReference = (node) => (memberOf(node, "Schema") ?? calleeOf(node, "Schema"))?.startsWith("Redacted") ?? false;
|
|
26
24
|
// `Schema.String`, `Schema.NonEmptyString.check(…)`, `Schema.optional(Schema.String)`: a schema
|
|
@@ -68,7 +66,7 @@ export const redactedSecretNamesRule = defineRule({
|
|
|
68
66
|
description: "Read configuration values and declare Schema fields whose name looks like a secret as `Redacted`.",
|
|
69
67
|
},
|
|
70
68
|
messages: {
|
|
71
|
-
notRedacted: "`{{name}}` looks like a secret: read it with `Config.
|
|
69
|
+
notRedacted: "`{{name}}` looks like a secret: read it with `Config.Redacted` or `Config.schema(Schema.Redacted(...), name)`.",
|
|
72
70
|
fieldNotRedacted: "`{{name}}` looks like a secret: declare it as `Schema.Redacted(...)` so it stays out of logs.",
|
|
73
71
|
},
|
|
74
72
|
schema: [
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const runOnlyInRunnerRule: import("@oxlint/plugins").Rule;
|
|
@@ -1,45 +1,35 @@
|
|
|
1
1
|
import { defineRule } from "@oxlint/plugins";
|
|
2
|
-
import {
|
|
2
|
+
import { runnerName } from "../shared/effect-runners.js";
|
|
3
3
|
import { isEntryFile } from "../shared/entry-files.js";
|
|
4
4
|
import { filePatternsSchema } from "../shared/file-globs.js";
|
|
5
|
-
|
|
6
|
-
["NodeRuntime", "runMain"],
|
|
7
|
-
["BunRuntime", "runMain"],
|
|
8
|
-
["ManagedRuntime", "make"],
|
|
9
|
-
];
|
|
10
|
-
const runnerName = (node) => {
|
|
11
|
-
const effectMember = memberOf(node, "Effect");
|
|
12
|
-
if (effectMember?.startsWith("run"))
|
|
13
|
-
return `Effect.${effectMember}`;
|
|
14
|
-
const entry = runtimeEntries.find(([object, property]) => memberOf(node, object) === property);
|
|
15
|
-
return entry === undefined ? undefined : `${entry[0]}.${entry[1]}`;
|
|
16
|
-
};
|
|
5
|
+
import { isTestFile } from "../shared/test-files.js";
|
|
17
6
|
// Reports references too, not only calls: `const run = Effect.runPromise` escapes nothing.
|
|
18
|
-
export const
|
|
7
|
+
export const runOnlyInRunnerRule = defineRule({
|
|
19
8
|
meta: {
|
|
20
9
|
type: "problem",
|
|
21
10
|
docs: {
|
|
22
|
-
description: "Run Effect programs only at the entry point. Entry files are skipped.",
|
|
11
|
+
description: "Run Effect programs only at the entry point. Entry and test files are skipped.",
|
|
23
12
|
},
|
|
24
13
|
messages: {
|
|
25
|
-
runOutsideEntry: "`{{name}}` runs an Effect program: only the entry point may do that.
|
|
14
|
+
runOutsideEntry: "`{{name}}` runs an Effect program: only the entry point may do that.",
|
|
26
15
|
},
|
|
27
16
|
schema: [
|
|
28
17
|
{
|
|
29
18
|
type: "object",
|
|
30
|
-
properties: { entryFiles: filePatternsSchema },
|
|
19
|
+
properties: { entryFiles: filePatternsSchema, testFiles: filePatternsSchema },
|
|
31
20
|
additionalProperties: false,
|
|
32
21
|
},
|
|
33
22
|
],
|
|
34
23
|
},
|
|
35
24
|
createOnce(context) {
|
|
36
|
-
let
|
|
25
|
+
let skipped = false;
|
|
37
26
|
return {
|
|
27
|
+
// How tests run their Effects is `prefer-effect-vitest`'s concern.
|
|
38
28
|
before() {
|
|
39
|
-
|
|
29
|
+
skipped = isEntryFile(context) || isTestFile(context);
|
|
40
30
|
},
|
|
41
31
|
MemberExpression(node) {
|
|
42
|
-
if (
|
|
32
|
+
if (skipped)
|
|
43
33
|
return;
|
|
44
34
|
const name = runnerName(node);
|
|
45
35
|
if (name !== undefined)
|