@doubleloop-io/oxlint-plugin-effect 0.1.0-rc.0 → 0.1.0-rc.2
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 +106 -82
- package/RULES.md +527 -382
- package/dist/index.d.ts +3 -0
- package/dist/index.js +13 -8
- package/dist/presets/all.json +5 -4
- package/dist/rules/adapter-import-boundary.js +5 -12
- package/dist/rules/all-errors-handled-at-boundary.d.ts +1 -0
- package/dist/rules/all-errors-handled-at-boundary.js +105 -0
- package/dist/rules/anti-slop-no-service-constructor-imports.js +1 -8
- package/dist/rules/exported-schema-identifier.js +15 -10
- package/dist/rules/no-effect-promise.js +1 -8
- package/dist/rules/no-manual-discriminant-comparison.js +36 -19
- package/dist/rules/no-manual-discriminant-construction.js +50 -14
- package/dist/rules/no-provide-on-definition.js +31 -9
- package/dist/rules/no-throw-or-primitive-failure.d.ts +1 -0
- package/dist/rules/{no-bare-error-in-effect.js → no-throw-or-primitive-failure.js} +11 -3
- package/dist/rules/no-yielded-match.js +9 -2
- package/dist/rules/prefer-effect-vitest.d.ts +1 -0
- package/dist/rules/prefer-effect-vitest.js +45 -0
- package/dist/rules/provide-named-layer.js +1 -8
- package/dist/rules/redacted-secret-names.js +18 -7
- package/dist/rules/run-only-in-runner.d.ts +1 -0
- package/dist/rules/run-only-in-runner.js +33 -0
- package/dist/rules/service-key-matches-name.js +15 -6
- package/dist/rules/service-port-shape-only.js +10 -18
- package/dist/rules/single-style-per-function.js +82 -21
- package/dist/rules/split-success-path-from-error-handling.d.ts +1 -0
- package/dist/rules/split-success-path-from-error-handling.js +137 -0
- 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 +2 -2
- package/dist/shared/file-globs.d.ts +3 -9
- package/dist/shared/file-globs.js +3 -12
- package/dist/shared/settings.d.ts +5 -0
- package/dist/shared/settings.js +26 -0
- package/dist/shared/tagged-values.d.ts +2 -8
- package/dist/shared/tagged-values.js +3 -14
- package/dist/shared/test-files.d.ts +2 -2
- package/package.json +61 -54
- package/dist/rules/boundary-entrypoint-shape.d.ts +0 -1
- package/dist/rules/boundary-entrypoint-shape.js +0 -52
- package/dist/rules/error-handling-in-effect-fn-handler.d.ts +0 -1
- package/dist/rules/error-handling-in-effect-fn-handler.js +0 -66
- package/dist/rules/no-bare-error-in-effect.d.ts +0 -1
- package/dist/rules/run-only-at-entry.d.ts +0 -1
- package/dist/rules/run-only-at-entry.js +0 -50
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
import { defineRule } from "@oxlint/plugins";
|
|
2
2
|
import { calleeOf, isEffectGenerator, nearestFunction } from "../shared/effect-ast.js";
|
|
3
|
+
import { isTestFile } from "../shared/test-files.js";
|
|
3
4
|
// Only the two forms the effecttsgo preset misses: `Effect.fail(new Error(…))` and
|
|
4
5
|
// `extends Error` are already reported by global-error-in-effect-failure and extends-native-error.
|
|
5
|
-
export const
|
|
6
|
+
export const noThrowOrPrimitiveFailureRule = defineRule({
|
|
6
7
|
meta: {
|
|
7
8
|
type: "problem",
|
|
8
9
|
docs: {
|
|
9
|
-
description: "Fail Effects with tagged errors, never with a `throw` or a primitive value.",
|
|
10
|
+
description: "Fail Effects with tagged errors, never with a `throw` or a primitive value. Primitive failures in test files are allowed.",
|
|
10
11
|
},
|
|
11
12
|
messages: {
|
|
12
13
|
throwInGenerator: "A `throw` inside an Effect generator becomes a defect: `return yield* new TaggedError(...)` instead.",
|
|
13
14
|
primitiveFailure: "Fail with a tagged error (`Data.TaggedError` or `Schema.TaggedError`), not a primitive value.",
|
|
14
15
|
},
|
|
16
|
+
schema: [],
|
|
15
17
|
},
|
|
16
18
|
createOnce(context) {
|
|
19
|
+
let inTestFile = false;
|
|
17
20
|
return {
|
|
21
|
+
// A test's synthetic failure (`Effect.fail("boom")`) feeds code generic over the error,
|
|
22
|
+
// which never discriminates it; a `throw` is still a defect there.
|
|
23
|
+
before() {
|
|
24
|
+
inTestFile = isTestFile(context);
|
|
25
|
+
},
|
|
18
26
|
ThrowStatement(node) {
|
|
19
27
|
const fn = nearestFunction(node);
|
|
20
28
|
if (fn !== undefined && isEffectGenerator(fn)) {
|
|
@@ -22,7 +30,7 @@ export const noBareErrorInEffectRule = defineRule({
|
|
|
22
30
|
}
|
|
23
31
|
},
|
|
24
32
|
CallExpression(node) {
|
|
25
|
-
if (calleeOf(node, "Effect") !== "fail")
|
|
33
|
+
if (inTestFile || calleeOf(node, "Effect") !== "fail")
|
|
26
34
|
return;
|
|
27
35
|
const [failure] = node.arguments;
|
|
28
36
|
if (failure?.type === "Literal" || failure?.type === "TemplateLiteral") {
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { defineRule } from "@oxlint/plugins";
|
|
2
2
|
import { calleeOf, isEffectGenerator, isFunctionNode, memberOf, nearestFunction, } from "../shared/effect-ast.js";
|
|
3
3
|
import { handlersOf, nestedBranches } from "../shared/match-ast.js";
|
|
4
|
-
|
|
4
|
+
// `Either` is the Effect 3 name of `Result`.
|
|
5
|
+
const guards = {
|
|
6
|
+
Option: "Option.isNone",
|
|
7
|
+
Result: "Result.isFailure",
|
|
8
|
+
Either: "Either.isLeft",
|
|
9
|
+
};
|
|
5
10
|
const matchedModule = (node) => {
|
|
6
11
|
if (node?.type !== "CallExpression")
|
|
7
12
|
return undefined;
|
|
@@ -9,6 +14,8 @@ const matchedModule = (node) => {
|
|
|
9
14
|
return "Option";
|
|
10
15
|
if (memberOf(node.callee, "Result") === "match")
|
|
11
16
|
return "Result";
|
|
17
|
+
if (memberOf(node.callee, "Either") === "match")
|
|
18
|
+
return "Either";
|
|
12
19
|
return undefined;
|
|
13
20
|
};
|
|
14
21
|
const isPipe = (callee) => (callee.type === "Identifier" && callee.name === "pipe") ||
|
|
@@ -58,7 +65,7 @@ export const noYieldedMatchRule = defineRule({
|
|
|
58
65
|
meta: {
|
|
59
66
|
type: "suggestion",
|
|
60
67
|
docs: {
|
|
61
|
-
description: "Inside an Effect generator, return early on the empty or failed case instead of yielding
|
|
68
|
+
description: "Inside an Effect generator, return early on the empty or failed case instead of yielding a match (`Option.match`; v3: `Either.match`, v4: `Result.match`).",
|
|
62
69
|
},
|
|
63
70
|
messages: {
|
|
64
71
|
yieldedMatch: "This `yield*` of a `{{module}}.match` splits the generator into two branches: return early with `if ({{guard}}(…)) return …` and keep one main path.",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const preferEffectVitestRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,45 @@
|
|
|
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 { isTestFile } from "../shared/test-files.js";
|
|
6
|
+
// Without `@effect/vitest` the remedy is out of reach, so the rule stays silent. Not cached:
|
|
7
|
+
// a long-lived linter would miss a later install, and only test files pay for the lookup.
|
|
8
|
+
const isEffectVitestInstalled = (filename) => {
|
|
9
|
+
for (let dir = dirname(filename);; dir = dirname(dir)) {
|
|
10
|
+
if (existsSync(join(dir, "node_modules", "@effect", "vitest", "package.json")))
|
|
11
|
+
return true;
|
|
12
|
+
if (dirname(dir) === dir)
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
// Reports references too, not only calls: `const run = Effect.runPromise` escapes nothing.
|
|
17
|
+
export const preferEffectVitestRule = defineRule({
|
|
18
|
+
meta: {
|
|
19
|
+
type: "suggestion",
|
|
20
|
+
docs: {
|
|
21
|
+
description: "Run Effects in tests through `@effect/vitest`. Only test files of projects that install it are checked.",
|
|
22
|
+
},
|
|
23
|
+
messages: {
|
|
24
|
+
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.",
|
|
25
|
+
},
|
|
26
|
+
schema: [],
|
|
27
|
+
},
|
|
28
|
+
createOnce(context) {
|
|
29
|
+
let checked = false;
|
|
30
|
+
return {
|
|
31
|
+
before() {
|
|
32
|
+
checked =
|
|
33
|
+
isTestFile(context) &&
|
|
34
|
+
isEffectVitestInstalled(resolve(context.cwd, context.filename));
|
|
35
|
+
},
|
|
36
|
+
MemberExpression(node) {
|
|
37
|
+
if (!checked)
|
|
38
|
+
return;
|
|
39
|
+
const name = runnerName(node);
|
|
40
|
+
if (name !== undefined)
|
|
41
|
+
context.report({ node, messageId: "runInTest", data: { name } });
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
});
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { defineRule } from "@oxlint/plugins";
|
|
2
2
|
import { calleeOf } from "../shared/effect-ast.js";
|
|
3
|
-
import { filePatternsSchema } from "../shared/file-globs.js";
|
|
4
3
|
import { isTestFile } from "../shared/test-files.js";
|
|
5
4
|
const matchers = new Set(["match", "$match"]);
|
|
6
5
|
// Factories and module members (`planLayer(csv)`, `CsvSheetReader.layer(path)`) are names;
|
|
@@ -26,13 +25,7 @@ export const provideNamedLayerRule = defineRule({
|
|
|
26
25
|
messages: {
|
|
27
26
|
inlineLayer: "Name the layer passed to `Effect.provide` (`const appLayer = Layer.mergeAll(...)`) instead of composing it inline.",
|
|
28
27
|
},
|
|
29
|
-
schema: [
|
|
30
|
-
{
|
|
31
|
-
type: "object",
|
|
32
|
-
properties: { testFiles: filePatternsSchema },
|
|
33
|
-
additionalProperties: false,
|
|
34
|
-
},
|
|
35
|
-
],
|
|
28
|
+
schema: [],
|
|
36
29
|
},
|
|
37
30
|
createOnce(context) {
|
|
38
31
|
let inTestFile = false;
|
|
@@ -12,15 +12,24 @@ const defaultSecretFields = [
|
|
|
12
12
|
"refreshToken",
|
|
13
13
|
"idToken",
|
|
14
14
|
];
|
|
15
|
-
|
|
16
|
-
const
|
|
15
|
+
// `optionalWith` and `annotations` are the Effect 3 names.
|
|
16
|
+
const nullableWrappers = new Set([
|
|
17
|
+
"optional",
|
|
18
|
+
"optionalKey",
|
|
19
|
+
"optionalWith",
|
|
20
|
+
"NullOr",
|
|
21
|
+
"UndefinedOr",
|
|
22
|
+
"NullishOr",
|
|
23
|
+
]);
|
|
24
|
+
const chainMethods = new Set(["pipe", "check", "annotate", "annotations"]);
|
|
17
25
|
const isRedactedSchema = (node) => node?.type === "CallExpression" &&
|
|
18
26
|
(memberOf(node.callee, "Schema")?.startsWith("Redacted") ?? false);
|
|
27
|
+
// Effect 3 spells it `Config.redacted`, which also wraps a config: `Config.redacted(Config.string(…))`.
|
|
19
28
|
const isRedactedConfig = (node) => {
|
|
20
29
|
const name = calleeOf(node, "Config");
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
30
|
+
if (name === "Redacted" || name === "redacted")
|
|
31
|
+
return true;
|
|
32
|
+
return (name === "schema" && node?.type === "CallExpression" && isRedactedSchema(node.arguments[0]));
|
|
24
33
|
};
|
|
25
34
|
const isRedactedReference = (node) => (memberOf(node, "Schema") ?? calleeOf(node, "Schema"))?.startsWith("Redacted") ?? false;
|
|
26
35
|
// `Schema.String`, `Schema.NonEmptyString.check(…)`, `Schema.optional(Schema.String)`: a schema
|
|
@@ -68,7 +77,7 @@ export const redactedSecretNamesRule = defineRule({
|
|
|
68
77
|
description: "Read configuration values and declare Schema fields whose name looks like a secret as `Redacted`.",
|
|
69
78
|
},
|
|
70
79
|
messages: {
|
|
71
|
-
notRedacted: "`{{name}}` looks like a secret: read it with `Config.
|
|
80
|
+
notRedacted: "`{{name}}` looks like a secret: read it with `Config.schema(Schema.Redacted(...), name)` or, v3: `Config.redacted(name)`, v4: `Config.Redacted(name)`.",
|
|
72
81
|
fieldNotRedacted: "`{{name}}` looks like a secret: declare it as `Schema.Redacted(...)` so it stays out of logs.",
|
|
73
82
|
},
|
|
74
83
|
schema: [
|
|
@@ -84,7 +93,9 @@ export const redactedSecretNamesRule = defineRule({
|
|
|
84
93
|
createOnce(context) {
|
|
85
94
|
let secretFields = defaultSecretFields;
|
|
86
95
|
const checkConfig = (node) => {
|
|
87
|
-
if (calleeOf(node, "Config") === undefined
|
|
96
|
+
if (calleeOf(node, "Config") === undefined)
|
|
97
|
+
return;
|
|
98
|
+
if (isRedactedConfig(node) || isRedactedConfig(node.parent))
|
|
88
99
|
return;
|
|
89
100
|
for (const argument of node.arguments) {
|
|
90
101
|
if (argument.type === "Literal" &&
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const runOnlyInRunnerRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { runnerName } from "../shared/effect-runners.js";
|
|
3
|
+
import { isEntryFile } from "../shared/entry-files.js";
|
|
4
|
+
import { isTestFile } from "../shared/test-files.js";
|
|
5
|
+
// Reports references too, not only calls: `const run = Effect.runPromise` escapes nothing.
|
|
6
|
+
export const runOnlyInRunnerRule = defineRule({
|
|
7
|
+
meta: {
|
|
8
|
+
type: "problem",
|
|
9
|
+
docs: {
|
|
10
|
+
description: "Run Effect programs only at the entry point. Entry and test files are skipped.",
|
|
11
|
+
},
|
|
12
|
+
messages: {
|
|
13
|
+
runOutsideEntry: "`{{name}}` runs an Effect program: only the entry point may do that.",
|
|
14
|
+
},
|
|
15
|
+
schema: [],
|
|
16
|
+
},
|
|
17
|
+
createOnce(context) {
|
|
18
|
+
let skipped = false;
|
|
19
|
+
return {
|
|
20
|
+
// How tests run their Effects is `prefer-effect-vitest`'s concern.
|
|
21
|
+
before() {
|
|
22
|
+
skipped = isEntryFile(context) || isTestFile(context);
|
|
23
|
+
},
|
|
24
|
+
MemberExpression(node) {
|
|
25
|
+
if (skipped)
|
|
26
|
+
return;
|
|
27
|
+
const name = runnerName(node);
|
|
28
|
+
if (name !== undefined)
|
|
29
|
+
context.report({ node, messageId: "runOutsideEntry", data: { name } });
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
});
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { defineRule } from "@oxlint/plugins";
|
|
2
2
|
import { calleeOf } from "../shared/effect-ast.js";
|
|
3
|
-
// `class X extends Context.Service<X, Shape>()(key)
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
// `class X extends Context.Service<X, Shape>()(key)` and Effect 3 `Effect.Service<X>()(key, …)`:
|
|
4
|
+
// the outer call carries the key. Effect 3 `Context.Tag(key)<X, Shape>()`: the inner one does.
|
|
5
|
+
const serviceKey = (superClass) => {
|
|
6
|
+
if (superClass?.type !== "CallExpression")
|
|
7
|
+
return undefined;
|
|
8
|
+
const factory = superClass.callee;
|
|
9
|
+
if (calleeOf(factory, "Context") === "Service" || calleeOf(factory, "Effect") === "Service") {
|
|
10
|
+
return superClass.arguments[0];
|
|
11
|
+
}
|
|
12
|
+
return factory.type === "CallExpression" && calleeOf(factory, "Context") === "Tag"
|
|
13
|
+
? factory.arguments[0]
|
|
14
|
+
: undefined;
|
|
15
|
+
};
|
|
7
16
|
const literalKey = (node) => {
|
|
8
17
|
if (node?.type === "Literal" && typeof node.value === "string")
|
|
9
18
|
return node.value;
|
|
@@ -30,7 +39,7 @@ export const serviceKeyMatchesNameRule = defineRule({
|
|
|
30
39
|
meta: {
|
|
31
40
|
type: "suggestion",
|
|
32
41
|
docs: {
|
|
33
|
-
description: "Key a
|
|
42
|
+
description: "Key a service class with its own name, alone or after a path prefix (v3: `Context.Tag`/`Effect.Service`, v4: `Context.Service`).",
|
|
34
43
|
},
|
|
35
44
|
hasSuggestions: true,
|
|
36
45
|
messages: {
|
|
@@ -40,7 +49,7 @@ export const serviceKeyMatchesNameRule = defineRule({
|
|
|
40
49
|
},
|
|
41
50
|
createOnce(context) {
|
|
42
51
|
const checkClass = (node) => {
|
|
43
|
-
const keyNode =
|
|
52
|
+
const keyNode = serviceKey(node.superClass);
|
|
44
53
|
const key = literalKey(keyNode);
|
|
45
54
|
const name = className(node);
|
|
46
55
|
if (keyNode === undefined || key === undefined || name === undefined)
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
import { defineRule } from "@oxlint/plugins";
|
|
2
2
|
import { calleeOf, memberOf } from "../shared/effect-ast.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
["Context", "Tag"],
|
|
6
|
-
["Context", "GenericTag"],
|
|
7
|
-
];
|
|
8
|
-
const implementationStatics = new Set(["layer", "Default"]);
|
|
3
|
+
// `Live` is the Effect 3 convention for the static layer.
|
|
4
|
+
const implementationStatics = new Set(["layer", "Default", "Live"]);
|
|
9
5
|
// `class X extends Context.Service<X, Shape>()(id, options?)`: the outer call carries the id.
|
|
10
6
|
const contextServiceIdCall = (superClass) => superClass?.type === "CallExpression" && calleeOf(superClass.callee, "Context") === "Service"
|
|
11
7
|
? superClass
|
|
12
8
|
: undefined;
|
|
9
|
+
// Effect 3 `class X extends Context.Tag(id)<X, Shape>() {}`: shape only, like `Context.Service`.
|
|
10
|
+
const isContextTagClass = (superClass) => superClass?.type === "CallExpression" && calleeOf(superClass.callee, "Context") === "Tag";
|
|
13
11
|
const staticMemberName = (member) => (member.type === "PropertyDefinition" || member.type === "MethodDefinition") &&
|
|
14
12
|
member.static &&
|
|
15
13
|
member.key.type === "Identifier"
|
|
@@ -19,20 +17,20 @@ export const servicePortShapeOnlyRule = defineRule({
|
|
|
19
17
|
meta: {
|
|
20
18
|
type: "problem",
|
|
21
19
|
docs: {
|
|
22
|
-
description: "Keep a
|
|
20
|
+
description: "Keep a service port a pure contract (v3: `Context.Tag`, v4: `Context.Service`), with implementations in separate Layers.",
|
|
23
21
|
},
|
|
24
22
|
messages: {
|
|
25
23
|
makeOption: "Declare the port as shape only: move the implementation into a Layer in its own adapter file.",
|
|
26
24
|
staticImplementation: "Do not attach `static {{name}}` to the port: provide the implementation as a Layer in its own adapter file.",
|
|
27
|
-
|
|
25
|
+
effectService: "`Effect.Service` ties the port to its implementation: declare the port shape only (v3: `Context.Tag`, v4: `Context.Service`) and provide the implementation as a Layer in its own adapter file.",
|
|
28
26
|
},
|
|
29
27
|
},
|
|
30
28
|
createOnce(context) {
|
|
31
29
|
const checkClass = (node) => {
|
|
32
30
|
const idCall = contextServiceIdCall(node.superClass);
|
|
33
|
-
if (idCall === undefined)
|
|
31
|
+
if (idCall === undefined && !isContextTagClass(node.superClass))
|
|
34
32
|
return;
|
|
35
|
-
const options = idCall
|
|
33
|
+
const options = idCall?.arguments[1];
|
|
36
34
|
if (options !== undefined)
|
|
37
35
|
context.report({ node: options, messageId: "makeOption" });
|
|
38
36
|
for (const member of node.body.body) {
|
|
@@ -50,14 +48,8 @@ export const servicePortShapeOnlyRule = defineRule({
|
|
|
50
48
|
ClassDeclaration: checkClass,
|
|
51
49
|
ClassExpression: checkClass,
|
|
52
50
|
MemberExpression(node) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
context.report({
|
|
56
|
-
node,
|
|
57
|
-
messageId: "legacyService",
|
|
58
|
-
data: { name: `${object}.${property}` },
|
|
59
|
-
});
|
|
60
|
-
}
|
|
51
|
+
if (memberOf(node, "Effect") === "Service") {
|
|
52
|
+
context.report({ node, messageId: "effectService" });
|
|
61
53
|
}
|
|
62
54
|
},
|
|
63
55
|
};
|
|
@@ -1,20 +1,39 @@
|
|
|
1
1
|
import { defineRule } from "@oxlint/plugins";
|
|
2
|
-
import { isEffectGenerator, memberOf } from "../shared/effect-ast.js";
|
|
2
|
+
import { isEffectGenerator, isServerComponent, memberOf, } from "../shared/effect-ast.js";
|
|
3
|
+
import { isTestFile } from "../shared/test-files.js";
|
|
3
4
|
const isThenCall = (node) => node.callee.type === "MemberExpression" &&
|
|
4
5
|
!node.callee.computed &&
|
|
5
6
|
node.callee.property.type === "Identifier" &&
|
|
6
7
|
node.callee.property.name === "then";
|
|
7
8
|
const testRunners = new Set(["test", "it"]);
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
const testHooks = new Set(["beforeAll", "afterAll", "beforeEach", "afterEach"]);
|
|
10
|
+
// `test(…)`, `test.only(…)`, `test.each(table)(…)`: the callee chain roots at the runner or hook.
|
|
11
|
+
const testRoleOf = (fn) => {
|
|
10
12
|
const call = fn.parent;
|
|
11
13
|
if (call?.type !== "CallExpression" || !call.arguments.some((arg) => arg === fn))
|
|
12
|
-
return
|
|
14
|
+
return undefined;
|
|
13
15
|
let callee = call.callee;
|
|
14
16
|
while (callee.type === "MemberExpression" || callee.type === "CallExpression") {
|
|
15
17
|
callee = callee.type === "MemberExpression" ? callee.object : callee.callee;
|
|
16
18
|
}
|
|
17
|
-
|
|
19
|
+
if (callee.type !== "Identifier")
|
|
20
|
+
return undefined;
|
|
21
|
+
if (testRunners.has(callee.name))
|
|
22
|
+
return "test";
|
|
23
|
+
if (testHooks.has(callee.name))
|
|
24
|
+
return "hook";
|
|
25
|
+
return undefined;
|
|
26
|
+
};
|
|
27
|
+
// Awaiting promise-based fixtures and then one Effect program reads as Arrange/Act/Assert;
|
|
28
|
+
// a second Effect step is where promise and Effect sequencing start to interleave.
|
|
29
|
+
const interleavesEffectSteps = (frame) => {
|
|
30
|
+
const effectSteps = frame.promiseSteps.filter((step) => step.involvesEffect);
|
|
31
|
+
return (effectSteps.length > 1 || effectSteps.some((step) => step.node.type !== "AwaitExpression"));
|
|
32
|
+
};
|
|
33
|
+
const reportFor = {
|
|
34
|
+
test: "asyncEffectTest",
|
|
35
|
+
hook: "asyncEffectHook",
|
|
36
|
+
helper: "asyncEffectHelper",
|
|
18
37
|
};
|
|
19
38
|
export const singleStylePerFunctionRule = defineRule({
|
|
20
39
|
meta: {
|
|
@@ -25,31 +44,60 @@ export const singleStylePerFunctionRule = defineRule({
|
|
|
25
44
|
messages: {
|
|
26
45
|
asyncEffect: "This function builds Effects: drop `async` and lift promises with `Effect.tryPromise({ try, catch })`.",
|
|
27
46
|
promiseStep: "Do not mix promises with Effect in the same function: lift the promise with `Effect.tryPromise({ try, catch })`.",
|
|
28
|
-
asyncEffectTest: "This test
|
|
47
|
+
asyncEffectTest: "This test chains promises around Effect: await a single program and assert on its result, or make its body one Effect with `it.effect` from `@effect/vitest`.",
|
|
48
|
+
asyncEffectHook: "This hook chains promises around Effect: await a single program, lifting the promises into it with `Effect.tryPromise({ try, catch })`, or build the fixture as a Layer with `layer` from `@effect/vitest`.",
|
|
49
|
+
asyncEffectHelper: "This test helper chains promises around Effect: await a single program, lifting the promises into it with `Effect.tryPromise({ try, catch })`.",
|
|
29
50
|
},
|
|
51
|
+
schema: [],
|
|
30
52
|
},
|
|
31
53
|
createOnce(context) {
|
|
32
54
|
const frames = [];
|
|
33
55
|
const current = () => frames.at(-1);
|
|
56
|
+
const openSteps = [];
|
|
57
|
+
let inTestFile = false;
|
|
58
|
+
// In a test file a helper such as `probe` or `captureLines` is a promise/Effect boundary
|
|
59
|
+
// like the test that calls it, so it gets the same allowance.
|
|
60
|
+
const roleOf = (fn) => testRoleOf(fn) ?? (inTestFile && !isEffectGenerator(fn) ? "helper" : undefined);
|
|
34
61
|
const enter = (fn) => {
|
|
35
|
-
frames.push({
|
|
62
|
+
frames.push({
|
|
63
|
+
fn,
|
|
64
|
+
effectful: isEffectGenerator(fn),
|
|
65
|
+
rendersJsx: false,
|
|
66
|
+
promiseSteps: [],
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
const openStep = (node) => {
|
|
70
|
+
const step = { node, involvesEffect: false };
|
|
71
|
+
current()?.promiseSteps.push(step);
|
|
72
|
+
openSteps.push(step);
|
|
36
73
|
};
|
|
37
74
|
const exit = () => {
|
|
38
75
|
const frame = frames.pop();
|
|
39
|
-
if (frame === undefined
|
|
76
|
+
if (frame === undefined)
|
|
40
77
|
return;
|
|
41
|
-
// One report per test: the remedy is
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
78
|
+
// One report per test or hook: the remedy is its shape, not each promise in it.
|
|
79
|
+
const role = roleOf(frame.fn);
|
|
80
|
+
if (role !== undefined) {
|
|
81
|
+
if (interleavesEffectSteps(frame))
|
|
82
|
+
context.report({ node: frame.fn, messageId: reportFor[role] });
|
|
45
83
|
return;
|
|
46
84
|
}
|
|
85
|
+
if (!frame.effectful || isServerComponent(frame.fn, frame.rendersJsx))
|
|
86
|
+
return;
|
|
47
87
|
if (frame.fn.async)
|
|
48
88
|
context.report({ node: frame.fn, messageId: "asyncEffect" });
|
|
49
|
-
for (const
|
|
50
|
-
context.report({ node, messageId: "promiseStep" });
|
|
89
|
+
for (const step of frame.promiseSteps)
|
|
90
|
+
context.report({ node: step.node, messageId: "promiseStep" });
|
|
91
|
+
};
|
|
92
|
+
const markJsx = () => {
|
|
93
|
+
const frame = current();
|
|
94
|
+
if (frame !== undefined)
|
|
95
|
+
frame.rendersJsx = true;
|
|
51
96
|
};
|
|
52
97
|
return {
|
|
98
|
+
before() {
|
|
99
|
+
inTestFile = isTestFile(context);
|
|
100
|
+
},
|
|
53
101
|
ArrowFunctionExpression: enter,
|
|
54
102
|
FunctionExpression: enter,
|
|
55
103
|
FunctionDeclaration: enter,
|
|
@@ -57,20 +105,33 @@ export const singleStylePerFunctionRule = defineRule({
|
|
|
57
105
|
"FunctionExpression:exit": exit,
|
|
58
106
|
"FunctionDeclaration:exit": exit,
|
|
59
107
|
MemberExpression(node) {
|
|
60
|
-
const frame = current();
|
|
61
108
|
const name = memberOf(node, "Effect");
|
|
109
|
+
if (name === undefined)
|
|
110
|
+
return;
|
|
111
|
+
// A step involves Effect wherever it appears in it, nested callbacks included.
|
|
112
|
+
for (const step of openSteps)
|
|
113
|
+
step.involvesEffect = true;
|
|
114
|
+
const frame = current();
|
|
115
|
+
if (frame === undefined)
|
|
116
|
+
return;
|
|
62
117
|
// `Effect.run*` is how an async boundary legitimately hands off to Effect (P27);
|
|
63
|
-
// where it may appear is run-only-
|
|
64
|
-
if (
|
|
118
|
+
// where it may appear is run-only-in-runner's business.
|
|
119
|
+
if (!name.startsWith("run"))
|
|
65
120
|
frame.effectful = true;
|
|
66
|
-
}
|
|
67
121
|
},
|
|
68
|
-
|
|
69
|
-
|
|
122
|
+
JSXElement: markJsx,
|
|
123
|
+
JSXFragment: markJsx,
|
|
124
|
+
AwaitExpression: openStep,
|
|
125
|
+
"AwaitExpression:exit"() {
|
|
126
|
+
openSteps.pop();
|
|
70
127
|
},
|
|
71
128
|
CallExpression(node) {
|
|
72
129
|
if (isThenCall(node))
|
|
73
|
-
|
|
130
|
+
openStep(node);
|
|
131
|
+
},
|
|
132
|
+
"CallExpression:exit"(node) {
|
|
133
|
+
if (isThenCall(node))
|
|
134
|
+
openSteps.pop();
|
|
74
135
|
},
|
|
75
136
|
};
|
|
76
137
|
},
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const splitSuccessPathFromErrorHandlingRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { isEffectGenerator, isInsideEffectGeneratorBody, memberOf, nearestFunction, } from "../shared/effect-ast.js";
|
|
3
|
+
import { isTestFile } from "../shared/test-files.js";
|
|
4
|
+
// mapError, orElse and orElseSucceed stay allowed: they name a failure or give an alternative
|
|
5
|
+
// without opening a handling branch inside the success path.
|
|
6
|
+
const errorHandlers = new Set([
|
|
7
|
+
"catch",
|
|
8
|
+
"catchAll",
|
|
9
|
+
"catchCause",
|
|
10
|
+
"catchCauseFilter",
|
|
11
|
+
"catchCauseIf",
|
|
12
|
+
"catchDefect",
|
|
13
|
+
"catchEager",
|
|
14
|
+
"catchFilter",
|
|
15
|
+
"catchIf",
|
|
16
|
+
"catchReason",
|
|
17
|
+
"catchReasons",
|
|
18
|
+
"catchTag",
|
|
19
|
+
"catchTags",
|
|
20
|
+
"either",
|
|
21
|
+
"exit",
|
|
22
|
+
"match",
|
|
23
|
+
"matchCause",
|
|
24
|
+
"matchCauseEffect",
|
|
25
|
+
"matchEffect",
|
|
26
|
+
"result",
|
|
27
|
+
"sandbox",
|
|
28
|
+
]);
|
|
29
|
+
// Effect 4 names first, then the v3 ones without a v4 counterpart.
|
|
30
|
+
const iterationCombinators = new Set([
|
|
31
|
+
"filter",
|
|
32
|
+
"filterMap",
|
|
33
|
+
"filterMapEffect",
|
|
34
|
+
"findFirst",
|
|
35
|
+
"findFirstFilter",
|
|
36
|
+
"forEach",
|
|
37
|
+
"partition",
|
|
38
|
+
"reduce",
|
|
39
|
+
"validate",
|
|
40
|
+
"dropUntil",
|
|
41
|
+
"dropWhile",
|
|
42
|
+
"every",
|
|
43
|
+
"exists",
|
|
44
|
+
"mergeAll",
|
|
45
|
+
"reduceWhile",
|
|
46
|
+
"takeUntil",
|
|
47
|
+
"takeWhile",
|
|
48
|
+
"validateAll",
|
|
49
|
+
"validateFirst",
|
|
50
|
+
]);
|
|
51
|
+
const failureCatchers = new Set([
|
|
52
|
+
"catch",
|
|
53
|
+
"catchAll",
|
|
54
|
+
"catchFilter",
|
|
55
|
+
"catchIf",
|
|
56
|
+
"catchTag",
|
|
57
|
+
"catchTags",
|
|
58
|
+
]);
|
|
59
|
+
const isEffectDie = (node) => ["die", "dieMessage"].includes(memberOf(node, "Effect") ?? "");
|
|
60
|
+
const returnedExpression = (fn) => {
|
|
61
|
+
if (fn.body?.type !== "BlockStatement")
|
|
62
|
+
return fn.body;
|
|
63
|
+
const [statement, ...rest] = fn.body.body;
|
|
64
|
+
return rest.length === 0 && statement?.type === "ReturnStatement" ? statement.argument : null;
|
|
65
|
+
};
|
|
66
|
+
const onlyDies = (handler) => {
|
|
67
|
+
if (isEffectDie(handler))
|
|
68
|
+
return true;
|
|
69
|
+
if (handler.type !== "ArrowFunctionExpression" && handler.type !== "FunctionExpression") {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
const returned = returnedExpression(handler);
|
|
73
|
+
return returned?.type === "CallExpression" && isEffectDie(returned.callee);
|
|
74
|
+
};
|
|
75
|
+
// Turning a failure into a defect is a selective `orDie`, which stays allowed: it declares the case
|
|
76
|
+
// untracked rather than handling it. The handler is the last argument in both the pipeable and the
|
|
77
|
+
// data-first forms; `catchTags` takes an object of handlers and is exempt only when all of them die.
|
|
78
|
+
const isDieOnlyCatch = (node, name) => {
|
|
79
|
+
const call = node.parent;
|
|
80
|
+
if (call?.type !== "CallExpression" || call.callee !== node || !failureCatchers.has(name)) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
const handler = call.arguments.at(-1);
|
|
84
|
+
if (handler === undefined)
|
|
85
|
+
return false;
|
|
86
|
+
if (name !== "catchTags")
|
|
87
|
+
return onlyDies(handler);
|
|
88
|
+
return (handler.type === "ObjectExpression" &&
|
|
89
|
+
handler.properties.length > 0 &&
|
|
90
|
+
handler.properties.every((property) => property.type === "Property" && onlyDies(property.value)));
|
|
91
|
+
};
|
|
92
|
+
// A handler in the callback of an iteration (`Effect.forEach(items, (item) => ... Effect.catch ...)`)
|
|
93
|
+
// handles each element on its own; moved to the `Effect.fn` pipeables, the first failure would stop
|
|
94
|
+
// them all. Other callbacks (`flatMap`, `tap`) stay on the success path and are still reported.
|
|
95
|
+
const isInsideIterationCallback = (node) => {
|
|
96
|
+
for (let fn = nearestFunction(node); fn && !isEffectGenerator(fn); fn = nearestFunction(fn)) {
|
|
97
|
+
const call = fn.parent;
|
|
98
|
+
if (call?.type === "CallExpression" &&
|
|
99
|
+
iterationCombinators.has(memberOf(call.callee, "Effect") ?? "")) {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
};
|
|
105
|
+
export const splitSuccessPathFromErrorHandlingRule = defineRule({
|
|
106
|
+
meta: {
|
|
107
|
+
type: "suggestion",
|
|
108
|
+
docs: {
|
|
109
|
+
description: "Keep only the success path in Effect generators and handle errors in the second argument of `Effect.fn`. Test files are skipped.",
|
|
110
|
+
},
|
|
111
|
+
messages: {
|
|
112
|
+
handlerInGenerator: "Move `Effect.{{name}}` out of the generator: keep the success path in the body and handle errors in the pipeable arguments of `Effect.fn`.",
|
|
113
|
+
},
|
|
114
|
+
schema: [],
|
|
115
|
+
},
|
|
116
|
+
createOnce(context) {
|
|
117
|
+
let inTestFile = false;
|
|
118
|
+
return {
|
|
119
|
+
// In a test the failure is the observed outcome, not a branch of the success path.
|
|
120
|
+
before() {
|
|
121
|
+
inTestFile = isTestFile(context);
|
|
122
|
+
},
|
|
123
|
+
MemberExpression(node) {
|
|
124
|
+
const name = memberOf(node, "Effect");
|
|
125
|
+
if (inTestFile ||
|
|
126
|
+
name === undefined ||
|
|
127
|
+
!errorHandlers.has(name) ||
|
|
128
|
+
!isInsideEffectGeneratorBody(node) ||
|
|
129
|
+
isInsideIterationCallback(node) ||
|
|
130
|
+
isDieOnlyCatch(node, name)) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
context.report({ node, messageId: "handlerInGenerator", data: { name } });
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
});
|
|
@@ -7,3 +7,4 @@ export declare const isEffectFnApplication: (node: ESTree.Node | null | undefine
|
|
|
7
7
|
export declare const isEffectGenerator: (fn: FunctionNode) => boolean;
|
|
8
8
|
export declare const isInsideEffectGeneratorBody: (node: ESTree.Node) => boolean;
|
|
9
9
|
export declare const nearestFunction: (node: ESTree.Node) => FunctionNode | undefined;
|
|
10
|
+
export declare const isServerComponent: (fn: FunctionNode, rendersJsx: boolean) => boolean;
|