@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,69 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { calleeOf } from "../shared/effect-ast.js";
|
|
3
|
+
// `class X extends Context.Service<X, Shape>()(key)`: the outer call carries the key.
|
|
4
|
+
const contextServiceKey = (superClass) => superClass?.type === "CallExpression" && calleeOf(superClass.callee, "Context") === "Service"
|
|
5
|
+
? superClass.arguments[0]
|
|
6
|
+
: undefined;
|
|
7
|
+
const literalKey = (node) => {
|
|
8
|
+
if (node?.type === "Literal" && typeof node.value === "string")
|
|
9
|
+
return node.value;
|
|
10
|
+
if (node?.type === "TemplateLiteral" && node.expressions.length === 0) {
|
|
11
|
+
return node.quasis[0]?.value.cooked ?? undefined;
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
};
|
|
15
|
+
const className = (node) => {
|
|
16
|
+
if (node.id !== null)
|
|
17
|
+
return node.id.name;
|
|
18
|
+
const declarator = node.parent;
|
|
19
|
+
return declarator?.type === "VariableDeclarator" && declarator.id.type === "Identifier"
|
|
20
|
+
? declarator.id.name
|
|
21
|
+
: undefined;
|
|
22
|
+
};
|
|
23
|
+
// A path prefix (`"app/db/Database"`) is the key `AGENTS.md` recommends: only the last segment
|
|
24
|
+
// has to be the name.
|
|
25
|
+
const keyFor = (key, name) => {
|
|
26
|
+
const slash = key.lastIndexOf("/");
|
|
27
|
+
return slash === -1 ? name : `${key.slice(0, slash + 1)}${name}`;
|
|
28
|
+
};
|
|
29
|
+
export const serviceKeyMatchesNameRule = defineRule({
|
|
30
|
+
meta: {
|
|
31
|
+
type: "suggestion",
|
|
32
|
+
docs: {
|
|
33
|
+
description: "Key a `Context.Service` class with its own name, alone or after a path prefix.",
|
|
34
|
+
},
|
|
35
|
+
hasSuggestions: true,
|
|
36
|
+
messages: {
|
|
37
|
+
keyMismatch: 'The key `"{{key}}"` does not end with the class name `{{name}}`: the key is the service\'s identity in errors and traces.',
|
|
38
|
+
useKey: 'Use `"{{expected}}"`.',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
createOnce(context) {
|
|
42
|
+
const checkClass = (node) => {
|
|
43
|
+
const keyNode = contextServiceKey(node.superClass);
|
|
44
|
+
const key = literalKey(keyNode);
|
|
45
|
+
const name = className(node);
|
|
46
|
+
if (keyNode === undefined || key === undefined || name === undefined)
|
|
47
|
+
return;
|
|
48
|
+
if (key === name || key.endsWith(`/${name}`))
|
|
49
|
+
return;
|
|
50
|
+
const expected = keyFor(key, name);
|
|
51
|
+
context.report({
|
|
52
|
+
node: keyNode,
|
|
53
|
+
messageId: "keyMismatch",
|
|
54
|
+
data: { key, name },
|
|
55
|
+
suggest: [
|
|
56
|
+
{
|
|
57
|
+
messageId: "useKey",
|
|
58
|
+
data: { expected },
|
|
59
|
+
fix: (fixer) => fixer.replaceText(keyNode, JSON.stringify(expected)),
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
ClassDeclaration: checkClass,
|
|
66
|
+
ClassExpression: checkClass,
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const servicePortShapeOnlyRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { calleeOf, memberOf } from "../shared/effect-ast.js";
|
|
3
|
+
const legacyServices = [
|
|
4
|
+
["Effect", "Service"],
|
|
5
|
+
["Context", "Tag"],
|
|
6
|
+
["Context", "GenericTag"],
|
|
7
|
+
];
|
|
8
|
+
const implementationStatics = new Set(["layer", "Default"]);
|
|
9
|
+
// `class X extends Context.Service<X, Shape>()(id, options?)`: the outer call carries the id.
|
|
10
|
+
const contextServiceIdCall = (superClass) => superClass?.type === "CallExpression" && calleeOf(superClass.callee, "Context") === "Service"
|
|
11
|
+
? superClass
|
|
12
|
+
: undefined;
|
|
13
|
+
const staticMemberName = (member) => (member.type === "PropertyDefinition" || member.type === "MethodDefinition") &&
|
|
14
|
+
member.static &&
|
|
15
|
+
member.key.type === "Identifier"
|
|
16
|
+
? member.key.name
|
|
17
|
+
: undefined;
|
|
18
|
+
export const servicePortShapeOnlyRule = defineRule({
|
|
19
|
+
meta: {
|
|
20
|
+
type: "problem",
|
|
21
|
+
docs: {
|
|
22
|
+
description: "Keep a `Context.Service` port a pure contract, with implementations in separate Layers.",
|
|
23
|
+
},
|
|
24
|
+
messages: {
|
|
25
|
+
makeOption: "Declare the port as shape only: move the implementation into a Layer in its own adapter file.",
|
|
26
|
+
staticImplementation: "Do not attach `static {{name}}` to the port: provide the implementation as a Layer in its own adapter file.",
|
|
27
|
+
legacyService: "Use `class X extends Context.Service<X, Shape>()(id) {}` instead of `{{name}}`.",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
createOnce(context) {
|
|
31
|
+
const checkClass = (node) => {
|
|
32
|
+
const idCall = contextServiceIdCall(node.superClass);
|
|
33
|
+
if (idCall === undefined)
|
|
34
|
+
return;
|
|
35
|
+
const options = idCall.arguments[1];
|
|
36
|
+
if (options !== undefined)
|
|
37
|
+
context.report({ node: options, messageId: "makeOption" });
|
|
38
|
+
for (const member of node.body.body) {
|
|
39
|
+
const name = staticMemberName(member);
|
|
40
|
+
if (name !== undefined && implementationStatics.has(name)) {
|
|
41
|
+
context.report({
|
|
42
|
+
node: member,
|
|
43
|
+
messageId: "staticImplementation",
|
|
44
|
+
data: { name },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
ClassDeclaration: checkClass,
|
|
51
|
+
ClassExpression: checkClass,
|
|
52
|
+
MemberExpression(node) {
|
|
53
|
+
for (const [object, property] of legacyServices) {
|
|
54
|
+
if (memberOf(node, object) === property) {
|
|
55
|
+
context.report({
|
|
56
|
+
node,
|
|
57
|
+
messageId: "legacyService",
|
|
58
|
+
data: { name: `${object}.${property}` },
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const singleStylePerFunctionRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { isEffectGenerator, isServerComponent, memberOf, } from "../shared/effect-ast.js";
|
|
3
|
+
import { filePatternsSchema } from "../shared/file-globs.js";
|
|
4
|
+
import { isTestFile } from "../shared/test-files.js";
|
|
5
|
+
const isThenCall = (node) => node.callee.type === "MemberExpression" &&
|
|
6
|
+
!node.callee.computed &&
|
|
7
|
+
node.callee.property.type === "Identifier" &&
|
|
8
|
+
node.callee.property.name === "then";
|
|
9
|
+
const testRunners = new Set(["test", "it"]);
|
|
10
|
+
const testHooks = new Set(["beforeAll", "afterAll", "beforeEach", "afterEach"]);
|
|
11
|
+
// `test(…)`, `test.only(…)`, `test.each(table)(…)`: the callee chain roots at the runner or hook.
|
|
12
|
+
const testRoleOf = (fn) => {
|
|
13
|
+
const call = fn.parent;
|
|
14
|
+
if (call?.type !== "CallExpression" || !call.arguments.some((arg) => arg === fn))
|
|
15
|
+
return undefined;
|
|
16
|
+
let callee = call.callee;
|
|
17
|
+
while (callee.type === "MemberExpression" || callee.type === "CallExpression") {
|
|
18
|
+
callee = callee.type === "MemberExpression" ? callee.object : callee.callee;
|
|
19
|
+
}
|
|
20
|
+
if (callee.type !== "Identifier")
|
|
21
|
+
return undefined;
|
|
22
|
+
if (testRunners.has(callee.name))
|
|
23
|
+
return "test";
|
|
24
|
+
if (testHooks.has(callee.name))
|
|
25
|
+
return "hook";
|
|
26
|
+
return undefined;
|
|
27
|
+
};
|
|
28
|
+
// Awaiting promise-based fixtures and then one Effect program reads as Arrange/Act/Assert;
|
|
29
|
+
// a second Effect step is where promise and Effect sequencing start to interleave.
|
|
30
|
+
const interleavesEffectSteps = (frame) => {
|
|
31
|
+
const effectSteps = frame.promiseSteps.filter((step) => step.involvesEffect);
|
|
32
|
+
return (effectSteps.length > 1 || effectSteps.some((step) => step.node.type !== "AwaitExpression"));
|
|
33
|
+
};
|
|
34
|
+
const reportFor = {
|
|
35
|
+
test: "asyncEffectTest",
|
|
36
|
+
hook: "asyncEffectHook",
|
|
37
|
+
helper: "asyncEffectHelper",
|
|
38
|
+
};
|
|
39
|
+
export const singleStylePerFunctionRule = defineRule({
|
|
40
|
+
meta: {
|
|
41
|
+
type: "problem",
|
|
42
|
+
docs: {
|
|
43
|
+
description: "Keep each function entirely Effect or entirely async/await.",
|
|
44
|
+
},
|
|
45
|
+
messages: {
|
|
46
|
+
asyncEffect: "This function builds Effects: drop `async` and lift promises with `Effect.tryPromise({ try, catch })`.",
|
|
47
|
+
promiseStep: "Do not mix promises with Effect in the same function: lift the promise with `Effect.tryPromise({ try, catch })`.",
|
|
48
|
+
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`.",
|
|
49
|
+
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`.",
|
|
50
|
+
asyncEffectHelper: "This test helper chains promises around Effect: await a single program, lifting the promises into it with `Effect.tryPromise({ try, catch })`.",
|
|
51
|
+
},
|
|
52
|
+
schema: [
|
|
53
|
+
{
|
|
54
|
+
type: "object",
|
|
55
|
+
properties: { testFiles: filePatternsSchema },
|
|
56
|
+
additionalProperties: false,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
createOnce(context) {
|
|
61
|
+
const frames = [];
|
|
62
|
+
const current = () => frames.at(-1);
|
|
63
|
+
const openSteps = [];
|
|
64
|
+
let inTestFile = false;
|
|
65
|
+
// In a test file a helper such as `probe` or `captureLines` is a promise/Effect boundary
|
|
66
|
+
// like the test that calls it, so it gets the same allowance.
|
|
67
|
+
const roleOf = (fn) => testRoleOf(fn) ?? (inTestFile && !isEffectGenerator(fn) ? "helper" : undefined);
|
|
68
|
+
const enter = (fn) => {
|
|
69
|
+
frames.push({
|
|
70
|
+
fn,
|
|
71
|
+
effectful: isEffectGenerator(fn),
|
|
72
|
+
rendersJsx: false,
|
|
73
|
+
promiseSteps: [],
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
const openStep = (node) => {
|
|
77
|
+
const step = { node, involvesEffect: false };
|
|
78
|
+
current()?.promiseSteps.push(step);
|
|
79
|
+
openSteps.push(step);
|
|
80
|
+
};
|
|
81
|
+
const exit = () => {
|
|
82
|
+
const frame = frames.pop();
|
|
83
|
+
if (frame === undefined)
|
|
84
|
+
return;
|
|
85
|
+
// One report per test or hook: the remedy is its shape, not each promise in it.
|
|
86
|
+
const role = roleOf(frame.fn);
|
|
87
|
+
if (role !== undefined) {
|
|
88
|
+
if (interleavesEffectSteps(frame))
|
|
89
|
+
context.report({ node: frame.fn, messageId: reportFor[role] });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (!frame.effectful || isServerComponent(frame.fn, frame.rendersJsx))
|
|
93
|
+
return;
|
|
94
|
+
if (frame.fn.async)
|
|
95
|
+
context.report({ node: frame.fn, messageId: "asyncEffect" });
|
|
96
|
+
for (const step of frame.promiseSteps)
|
|
97
|
+
context.report({ node: step.node, messageId: "promiseStep" });
|
|
98
|
+
};
|
|
99
|
+
const markJsx = () => {
|
|
100
|
+
const frame = current();
|
|
101
|
+
if (frame !== undefined)
|
|
102
|
+
frame.rendersJsx = true;
|
|
103
|
+
};
|
|
104
|
+
return {
|
|
105
|
+
before() {
|
|
106
|
+
inTestFile = isTestFile(context);
|
|
107
|
+
},
|
|
108
|
+
ArrowFunctionExpression: enter,
|
|
109
|
+
FunctionExpression: enter,
|
|
110
|
+
FunctionDeclaration: enter,
|
|
111
|
+
"ArrowFunctionExpression:exit": exit,
|
|
112
|
+
"FunctionExpression:exit": exit,
|
|
113
|
+
"FunctionDeclaration:exit": exit,
|
|
114
|
+
MemberExpression(node) {
|
|
115
|
+
const name = memberOf(node, "Effect");
|
|
116
|
+
if (name === undefined)
|
|
117
|
+
return;
|
|
118
|
+
// A step involves Effect wherever it appears in it, nested callbacks included.
|
|
119
|
+
for (const step of openSteps)
|
|
120
|
+
step.involvesEffect = true;
|
|
121
|
+
const frame = current();
|
|
122
|
+
if (frame === undefined)
|
|
123
|
+
return;
|
|
124
|
+
// `Effect.run*` is how an async boundary legitimately hands off to Effect (P27);
|
|
125
|
+
// where it may appear is run-only-in-runner's business.
|
|
126
|
+
if (!name.startsWith("run"))
|
|
127
|
+
frame.effectful = true;
|
|
128
|
+
},
|
|
129
|
+
JSXElement: markJsx,
|
|
130
|
+
JSXFragment: markJsx,
|
|
131
|
+
AwaitExpression: openStep,
|
|
132
|
+
"AwaitExpression:exit"() {
|
|
133
|
+
openSteps.pop();
|
|
134
|
+
},
|
|
135
|
+
CallExpression(node) {
|
|
136
|
+
if (isThenCall(node))
|
|
137
|
+
openStep(node);
|
|
138
|
+
},
|
|
139
|
+
"CallExpression:exit"(node) {
|
|
140
|
+
if (isThenCall(node))
|
|
141
|
+
openSteps.pop();
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const tryObjectFormRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { calleeOf } from "../shared/effect-ast.js";
|
|
3
|
+
const tryConstructors = new Set(["try", "tryPromise"]);
|
|
4
|
+
export const tryObjectFormRule = defineRule({
|
|
5
|
+
meta: {
|
|
6
|
+
type: "problem",
|
|
7
|
+
docs: {
|
|
8
|
+
description: "Use the object form of `Effect.try` and `Effect.tryPromise`, mapping failures to tagged errors.",
|
|
9
|
+
},
|
|
10
|
+
messages: {
|
|
11
|
+
objectForm: "Use `Effect.{{name}}({ try, catch })`: the thunk form types the failure as `Cause.UnknownError`.",
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
createOnce(context) {
|
|
15
|
+
return {
|
|
16
|
+
CallExpression(node) {
|
|
17
|
+
const name = calleeOf(node, "Effect");
|
|
18
|
+
if (name === undefined || !tryConstructors.has(name))
|
|
19
|
+
return;
|
|
20
|
+
if (node.arguments[0]?.type !== "ObjectExpression") {
|
|
21
|
+
context.report({ node, messageId: "objectForm", data: { name } });
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const untracedRecursionRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { calleeOf, isFunctionNode } from "../shared/effect-ast.js";
|
|
3
|
+
// `Effect.fn("name")(body)` and `Effect.fn(body)`; `Effect.fnUntraced` is what the rule asks for.
|
|
4
|
+
const tracedBody = (node) => {
|
|
5
|
+
if (node?.type !== "CallExpression")
|
|
6
|
+
return undefined;
|
|
7
|
+
const isTraced = calleeOf(node, "Effect") === "fn" || calleeOf(node.callee, "Effect") === "fn";
|
|
8
|
+
const [body] = node.arguments;
|
|
9
|
+
return isTraced && isFunctionNode(body) ? body : undefined;
|
|
10
|
+
};
|
|
11
|
+
const isInside = (node, ancestor) => {
|
|
12
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
13
|
+
if (current === ancestor)
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
};
|
|
18
|
+
// Every recursive call of a traced function opens a span nested in the previous one and
|
|
19
|
+
// captures a stack frame: the trace grows with the depth instead of naming one unit of work.
|
|
20
|
+
export const untracedRecursionRule = defineRule({
|
|
21
|
+
meta: {
|
|
22
|
+
type: "suggestion",
|
|
23
|
+
docs: {
|
|
24
|
+
description: "Write recursive effectful functions with `Effect.fnUntraced`, so each level does not open a nested span.",
|
|
25
|
+
},
|
|
26
|
+
messages: {
|
|
27
|
+
recursiveTraced: "`{{name}}` calls itself: use `Effect.fnUntraced` and trace the entry point that starts the recursion.",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
createOnce(context) {
|
|
31
|
+
return {
|
|
32
|
+
VariableDeclarator(node) {
|
|
33
|
+
const body = tracedBody(node.init);
|
|
34
|
+
if (node.id.type !== "Identifier" || body === undefined)
|
|
35
|
+
return;
|
|
36
|
+
const [variable] = context.sourceCode.getDeclaredVariables(node);
|
|
37
|
+
const recursive = variable?.references.some((reference) => isInside(reference.identifier, body));
|
|
38
|
+
if (recursive === true) {
|
|
39
|
+
context.report({
|
|
40
|
+
node: node.id,
|
|
41
|
+
messageId: "recursiveTraced",
|
|
42
|
+
data: { name: node.id.name },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ESTree } from "@oxlint/plugins";
|
|
2
|
+
export type FunctionNode = ESTree.Function | ESTree.ArrowFunctionExpression;
|
|
3
|
+
export declare const isFunctionNode: (node: ESTree.Node | null | undefined) => node is FunctionNode;
|
|
4
|
+
export declare const memberOf: (node: ESTree.Node | null | undefined, object: string) => string | undefined;
|
|
5
|
+
export declare const calleeOf: (node: ESTree.Node | null | undefined, object: string) => string | undefined;
|
|
6
|
+
export declare const isEffectFnApplication: (node: ESTree.Node | null | undefined) => node is ESTree.CallExpression;
|
|
7
|
+
export declare const isEffectGenerator: (fn: FunctionNode) => boolean;
|
|
8
|
+
export declare const isInsideEffectGeneratorBody: (node: ESTree.Node) => boolean;
|
|
9
|
+
export declare const nearestFunction: (node: ESTree.Node) => FunctionNode | undefined;
|
|
10
|
+
export declare const isServerComponent: (fn: FunctionNode, rendersJsx: boolean) => boolean;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export const isFunctionNode = (node) => node?.type === "ArrowFunctionExpression" ||
|
|
2
|
+
node?.type === "FunctionExpression" ||
|
|
3
|
+
node?.type === "FunctionDeclaration";
|
|
4
|
+
// Like the anti-slop rules, names are matched literally: `import { Effect as E }` escapes.
|
|
5
|
+
export const memberOf = (node, object) => {
|
|
6
|
+
if (node?.type !== "MemberExpression" ||
|
|
7
|
+
node.computed ||
|
|
8
|
+
node.object.type !== "Identifier" ||
|
|
9
|
+
node.object.name !== object ||
|
|
10
|
+
node.property.type !== "Identifier") {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
return node.property.name;
|
|
14
|
+
};
|
|
15
|
+
export const calleeOf = (node, object) => node?.type === "CallExpression" ? memberOf(node.callee, object) : undefined;
|
|
16
|
+
// Both `Effect.fn("name")(body, ...pipeables)` and the unnamed `Effect.fn(body, ...pipeables)`.
|
|
17
|
+
export const isEffectFnApplication = (node) => {
|
|
18
|
+
if (node?.type !== "CallExpression")
|
|
19
|
+
return false;
|
|
20
|
+
const factory = memberOf(node.callee, "Effect");
|
|
21
|
+
return factory === "fn" || factory === "fnUntraced" || calleeOf(node.callee, "Effect") === "fn";
|
|
22
|
+
};
|
|
23
|
+
export const isEffectGenerator = (fn) => {
|
|
24
|
+
const call = fn.parent;
|
|
25
|
+
if (!fn.generator || call?.type !== "CallExpression")
|
|
26
|
+
return false;
|
|
27
|
+
if (calleeOf(call, "Effect") === "gen")
|
|
28
|
+
return call.arguments.some((arg) => arg === fn);
|
|
29
|
+
return isEffectFnApplication(call) && call.arguments[0] === fn;
|
|
30
|
+
};
|
|
31
|
+
const isEffectCombinatorCallback = (fn) => fn.parent?.type === "CallExpression" &&
|
|
32
|
+
memberOf(fn.parent.callee, "Effect") !== undefined &&
|
|
33
|
+
fn.parent.arguments.some((arg) => arg === fn);
|
|
34
|
+
// Callbacks handed to an `Effect.*` combinator inside the generator still belong to its success
|
|
35
|
+
// path; the pipeable arguments of an `Effect.fn` nested in it are the handler slot, so they do
|
|
36
|
+
// not, and neither does a function the generator only defines (an HTTP handler, a service method).
|
|
37
|
+
export const isInsideEffectGeneratorBody = (node) => {
|
|
38
|
+
let child = node;
|
|
39
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
40
|
+
if (isEffectFnApplication(current) &&
|
|
41
|
+
current.arguments.findIndex((arg) => arg === child) > 0) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
if (isFunctionNode(current)) {
|
|
45
|
+
if (current.generator)
|
|
46
|
+
return isEffectGenerator(current);
|
|
47
|
+
if (!isEffectCombinatorCallback(current))
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
child = current;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
};
|
|
54
|
+
export const nearestFunction = (node) => {
|
|
55
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
56
|
+
if (isFunctionNode(current))
|
|
57
|
+
return current;
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
};
|
|
61
|
+
// An async function that renders JSX is a React Server Component: it hands its outcome to React,
|
|
62
|
+
// and how failures become UI is the application's call, not a rule's.
|
|
63
|
+
export const isServerComponent = (fn, rendersJsx) => fn.async && rendersJsx;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { memberOf } from "./effect-ast.js";
|
|
2
|
+
const runtimeEntries = [
|
|
3
|
+
["NodeRuntime", "runMain"],
|
|
4
|
+
["BunRuntime", "runMain"],
|
|
5
|
+
["ManagedRuntime", "make"],
|
|
6
|
+
];
|
|
7
|
+
// The qualified name of the runner `node` refers to, when it runs an Effect program.
|
|
8
|
+
export const runnerName = (node) => {
|
|
9
|
+
const effectMember = memberOf(node, "Effect");
|
|
10
|
+
if (effectMember?.startsWith("run"))
|
|
11
|
+
return `Effect.${effectMember}`;
|
|
12
|
+
const entry = runtimeEntries.find(([object, property]) => memberOf(node, object) === property);
|
|
13
|
+
return entry === undefined ? undefined : `${entry[0]}.${entry[1]}`;
|
|
14
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { makeFileMatcher } from "./file-globs.js";
|
|
2
|
+
// `index.ts` is not matched on purpose: it is usually a barrel, and exempting it would let
|
|
3
|
+
// adapters reach the rest of the application through the re-exports.
|
|
4
|
+
export const defaultEntryFiles = ["**/main.ts", "**/cli.ts", "**/bin/**"];
|
|
5
|
+
export const isEntryFile = makeFileMatcher("entryFiles", defaultEntryFiles);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare const filePatternsSchema: {
|
|
2
|
+
type: string;
|
|
3
|
+
items: {
|
|
4
|
+
type: string;
|
|
5
|
+
};
|
|
6
|
+
uniqueItems: boolean;
|
|
7
|
+
};
|
|
8
|
+
type FileContext = {
|
|
9
|
+
readonly filename: string;
|
|
10
|
+
readonly cwd: string;
|
|
11
|
+
readonly options: ReadonlyArray<unknown>;
|
|
12
|
+
};
|
|
13
|
+
export declare const makeFileMatcher: (key: string, defaults: ReadonlyArray<string>) => (context: FileContext) => boolean;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { matchesGlob, relative, sep } from "node:path";
|
|
2
|
+
export const filePatternsSchema = { type: "array", items: { type: "string" }, uniqueItems: true };
|
|
3
|
+
const configuredPatterns = (options, key, defaults) => {
|
|
4
|
+
const [first] = options;
|
|
5
|
+
if (typeof first !== "object" || first === null)
|
|
6
|
+
return defaults;
|
|
7
|
+
const entry = Object.entries(first).find(([name]) => name === key);
|
|
8
|
+
if (entry === undefined)
|
|
9
|
+
return defaults;
|
|
10
|
+
const patterns = entry[1];
|
|
11
|
+
return Array.isArray(patterns) ? patterns.filter((pattern) => typeof pattern === "string") : [];
|
|
12
|
+
};
|
|
13
|
+
// Globs match the path relative to the working directory, with `/` on every platform.
|
|
14
|
+
export const makeFileMatcher = (key, defaults) => (context) => {
|
|
15
|
+
const path = relative(context.cwd, context.filename).split(sep).join("/");
|
|
16
|
+
return configuredPatterns(context.options, key, defaults).some((pattern) => matchesGlob(path, pattern));
|
|
17
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ESTree } from "@oxlint/plugins";
|
|
2
|
+
export declare const isMatchCall: (node: ESTree.Node | null | undefined) => node is ESTree.CallExpression;
|
|
3
|
+
export declare const isMatchChain: (node: ESTree.Node | null | undefined) => boolean;
|
|
4
|
+
export declare const handlersOf: (match: ESTree.CallExpression) => ESTree.Expression[];
|
|
5
|
+
export declare const nestedBranches: (handler: ESTree.Node) => ReadonlyArray<ESTree.Node>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { calleeOf, isFunctionNode, memberOf } from "./effect-ast.js";
|
|
2
|
+
const modulesWithMatch = ["Option", "Result", "Exit", "Either"];
|
|
3
|
+
export const isMatchCall = (node) => {
|
|
4
|
+
if (node?.type !== "CallExpression")
|
|
5
|
+
return false;
|
|
6
|
+
const { callee } = node;
|
|
7
|
+
if (modulesWithMatch.some((module) => memberOf(callee, module) === "match"))
|
|
8
|
+
return true;
|
|
9
|
+
if (memberOf(callee, "Match") === "valueTags")
|
|
10
|
+
return true;
|
|
11
|
+
return (callee.type === "MemberExpression" &&
|
|
12
|
+
!callee.computed &&
|
|
13
|
+
callee.property.type === "Identifier" &&
|
|
14
|
+
callee.property.name === "$match");
|
|
15
|
+
};
|
|
16
|
+
const isMatchStart = (node) => {
|
|
17
|
+
const start = calleeOf(node, "Match");
|
|
18
|
+
return start === "value" || start === "type";
|
|
19
|
+
};
|
|
20
|
+
// `Match.value(x).pipe(...)`, the data-last `Match.type().pipe(...)`, and both through the
|
|
21
|
+
// function form `pipe(Match.value(x), ...)`. Their handlers are arguments of `Match.when`
|
|
22
|
+
// and friends, not object properties, so `handlersOf` sees none.
|
|
23
|
+
export const isMatchChain = (node) => {
|
|
24
|
+
if (node?.type !== "CallExpression")
|
|
25
|
+
return false;
|
|
26
|
+
const { callee } = node;
|
|
27
|
+
if (callee.type === "Identifier")
|
|
28
|
+
return callee.name === "pipe" && isMatchStart(node.arguments[0]);
|
|
29
|
+
return (callee.type === "MemberExpression" &&
|
|
30
|
+
!callee.computed &&
|
|
31
|
+
callee.property.type === "Identifier" &&
|
|
32
|
+
callee.property.name === "pipe" &&
|
|
33
|
+
isMatchStart(callee.object));
|
|
34
|
+
};
|
|
35
|
+
export const handlersOf = (match) => match.arguments.flatMap((argument) => argument.type === "ObjectExpression"
|
|
36
|
+
? argument.properties.flatMap((property) => property.type === "Property" ? [property.value] : [])
|
|
37
|
+
: []);
|
|
38
|
+
const returnedExpressions = (fn) => {
|
|
39
|
+
if (fn.body?.type !== "BlockStatement")
|
|
40
|
+
return fn.body ? [fn.body] : [];
|
|
41
|
+
return fn.body.body.flatMap((statement) => statement.type === "ReturnStatement" && statement.argument ? [statement.argument] : []);
|
|
42
|
+
};
|
|
43
|
+
// What `no-nested-match` reports in a handler: itself a match, or a returned match or ternary.
|
|
44
|
+
export const nestedBranches = (handler) => {
|
|
45
|
+
if (isMatchCall(handler) || isMatchChain(handler))
|
|
46
|
+
return [handler];
|
|
47
|
+
if (!isFunctionNode(handler))
|
|
48
|
+
return [];
|
|
49
|
+
return returnedExpressions(handler).filter((expression) => isMatchCall(expression) ||
|
|
50
|
+
isMatchChain(expression) ||
|
|
51
|
+
expression.type === "ConditionalExpression");
|
|
52
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ESTree } from "@oxlint/plugins";
|
|
2
|
+
export declare const isEqualityComparison: (node: ESTree.Node) => node is ESTree.BinaryExpression;
|
|
3
|
+
export declare const isStringLiteral: (node: ESTree.Node | null | undefined) => node is ESTree.StringLiteral;
|
|
4
|
+
export declare const isTagMember: (node: ESTree.Node | null | undefined) => node is ESTree.MemberExpression;
|
|
5
|
+
export declare const isReasonTagMember: (node: ESTree.MemberExpression) => boolean;
|
|
6
|
+
export declare const tagMemberFromComparison: (node: ESTree.BinaryExpression) => ESTree.MemberExpression | undefined;
|
|
7
|
+
export declare const isInsideBroadEffectHandler: (node: ESTree.Node) => boolean;
|
|
8
|
+
export declare const propertyName: (property: ESTree.ObjectProperty) => string | undefined;
|
|
9
|
+
export declare const discriminantsSchema: {
|
|
10
|
+
readonly type: "array";
|
|
11
|
+
readonly items: {
|
|
12
|
+
readonly type: "string";
|
|
13
|
+
};
|
|
14
|
+
readonly uniqueItems: true;
|
|
15
|
+
};
|
|
16
|
+
export declare const configuredDiscriminants: (options: ReadonlyArray<unknown>) => Set<string>;
|
|
17
|
+
export declare const discriminantOf: (node: ESTree.Node | null | undefined, discriminants: ReadonlySet<string>) => string | undefined;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { calleeOf } from "./effect-ast.js";
|
|
2
|
+
const equalityOperators = new Set(["==", "===", "!=", "!=="]);
|
|
3
|
+
const broadEffectCatchMethods = new Set(["catch", "catchAll", "catchIf"]);
|
|
4
|
+
export const isEqualityComparison = (node) => node.type === "BinaryExpression" && equalityOperators.has(node.operator);
|
|
5
|
+
export const isStringLiteral = (node) => node?.type === "Literal" && typeof node.value === "string";
|
|
6
|
+
const isPropertyNamed = (node, name) => node.computed
|
|
7
|
+
? isStringLiteral(node.property) && node.property.value === name
|
|
8
|
+
: node.property.type === "Identifier" && node.property.name === name;
|
|
9
|
+
export const isTagMember = (node) => node?.type === "MemberExpression" && isPropertyNamed(node, "_tag");
|
|
10
|
+
export const isReasonTagMember = (node) => node.object.type === "MemberExpression" && isPropertyNamed(node.object, "reason");
|
|
11
|
+
export const tagMemberFromComparison = (node) => {
|
|
12
|
+
if (!isEqualityComparison(node))
|
|
13
|
+
return undefined;
|
|
14
|
+
if (isTagMember(node.left) && isStringLiteral(node.right))
|
|
15
|
+
return node.left;
|
|
16
|
+
if (isTagMember(node.right) && isStringLiteral(node.left))
|
|
17
|
+
return node.right;
|
|
18
|
+
return undefined;
|
|
19
|
+
};
|
|
20
|
+
// Only the nearest function counts: a `_tag` check in a callback nested inside the handler
|
|
21
|
+
// is not the handler discriminating its error.
|
|
22
|
+
export const isInsideBroadEffectHandler = (node) => {
|
|
23
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
24
|
+
if (current.type === "ArrowFunctionExpression" || current.type === "FunctionExpression") {
|
|
25
|
+
const call = current.parent;
|
|
26
|
+
return (call?.type === "CallExpression" &&
|
|
27
|
+
broadEffectCatchMethods.has(calleeOf(call, "Effect") ?? "") &&
|
|
28
|
+
call.arguments.some((argument) => argument === current));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
};
|
|
33
|
+
export const propertyName = (property) => {
|
|
34
|
+
if (!property.computed && property.key.type === "Identifier")
|
|
35
|
+
return property.key.name;
|
|
36
|
+
if (isStringLiteral(property.key))
|
|
37
|
+
return property.key.value;
|
|
38
|
+
return undefined;
|
|
39
|
+
};
|
|
40
|
+
// The fields that discriminate a closed union: `_tag` for Effect's tagged values, plus whatever
|
|
41
|
+
// the codebase uses (`type`, `kind`). Shared by the rules on comparison and construction.
|
|
42
|
+
const defaultDiscriminants = ["_tag"];
|
|
43
|
+
export const discriminantsSchema = {
|
|
44
|
+
type: "array",
|
|
45
|
+
items: { type: "string" },
|
|
46
|
+
uniqueItems: true,
|
|
47
|
+
};
|
|
48
|
+
export const configuredDiscriminants = (options) => {
|
|
49
|
+
const [first] = options;
|
|
50
|
+
if (typeof first !== "object" || first === null || !("discriminants" in first)) {
|
|
51
|
+
return new Set(defaultDiscriminants);
|
|
52
|
+
}
|
|
53
|
+
const names = first.discriminants;
|
|
54
|
+
return new Set(Array.isArray(names) ? names.filter((name) => typeof name === "string") : []);
|
|
55
|
+
};
|
|
56
|
+
export const discriminantOf = (node, discriminants) => {
|
|
57
|
+
if (node?.type !== "MemberExpression")
|
|
58
|
+
return undefined;
|
|
59
|
+
const name = node.computed
|
|
60
|
+
? isStringLiteral(node.property)
|
|
61
|
+
? node.property.value
|
|
62
|
+
: undefined
|
|
63
|
+
: node.property.type === "Identifier"
|
|
64
|
+
? node.property.name
|
|
65
|
+
: undefined;
|
|
66
|
+
return name !== undefined && discriminants.has(name) ? name : undefined;
|
|
67
|
+
};
|