Open History to see the last commit
1import { defineRule, type ESTree } from "@oxlint/plugins";
2
3export default defineRule({
4 meta: {
5 type: "suggestion",
6 docs: {
7 description: "Use Effect.fn for reusable generator-based Effect functions.",
8 },
9 },
10 create(context) {
11 const isEffectImport = (identifier: ESTree.IdentifierReference, member: string) => {
12 let scope = context.sourceCode.getScope(identifier);
13 while (true) {
14 const variable = scope.set.get(identifier.name);
15 if (variable !== undefined) {
16 return variable.defs.some((def) => {
17 if (def.type !== "ImportBinding" || def.parent?.type !== "ImportDeclaration") {
18 return false;
19 }
20 const source = def.parent.source.value;
21 if (source === "effect/Effect") {
22 if (member === "") {
23 return (
24 def.node.type === "ImportSpecifier" &&
25 (def.node.imported.type === "Identifier"
26 ? def.node.imported.name
27 : def.node.imported.value) === "gen"
28 );
29 }
30 return def.node.type === "ImportNamespaceSpecifier";
31 }
32 return (
33 source === "effect" &&
34 def.node.type === "ImportSpecifier" &&
35 (def.node.imported.type === "Identifier"
36 ? def.node.imported.name
37 : def.node.imported.value) === "Effect"
38 );
39 });
40 }
41 if (scope.upper === null) return false;
42 scope = scope.upper;
43 }
44 };
45
46 const isGeneratorCall = (expression: ESTree.Expression): boolean => {
47 if (expression.type !== "CallExpression") return false;
48 const callee = expression.callee;
49 if (callee.type === "Identifier") return isEffectImport(callee, "");
50 if (callee.type !== "MemberExpression" || callee.computed) return false;
51 if (callee.property.type !== "Identifier") return false;
52 if (callee.property.name === "pipe") return isGeneratorCall(callee.object);
53 return (
54 callee.property.name === "gen" &&
55 callee.object.type === "Identifier" &&
56 isEffectImport(callee.object, "gen")
57 );
58 };
59
60 const check = (node: ESTree.ArrowFunctionExpression | ESTree.Function) => {
61 if (node.async || node.generator) return;
62 const parent = node.parent;
63 if (node.type !== "FunctionDeclaration") {
64 if (parent.type === "MethodDefinition") {
65 if (parent.kind !== "method") return;
66 } else if (parent.type === "Property") {
67 if (parent.kind !== "init") return;
68 const name =
69 parent.key.type === "Identifier"
70 ? parent.key.name
71 : parent.key.type === "Literal"
72 ? parent.key.value
73 : undefined;
74 const call = parent.parent.parent;
75 if (call?.type === "CallExpression") {
76 let callee = call.callee;
77 while (callee.type === "CallExpression") callee = callee.callee;
78 if (callee.type === "MemberExpression" && callee.property.type === "Identifier") {
79 if (
80 ["catchTags", "match", "matchEffect", "matchCauseEffect"].includes(
81 callee.property.name,
82 ) ||
83 (name === "defaultValue" && callee.property.name === "Reference")
84 ) {
85 return;
86 }
87 }
88 }
89 } else if (parent.type !== "VariableDeclarator" && parent.type !== "PropertyDefinition") {
90 return;
91 }
92 }
93 const body = node.body;
94 if (body === null) return;
95 let expression: ESTree.Expression | null;
96 if (body.type === "BlockStatement") {
97 if (body.body.length !== 1 || body.body[0]?.type !== "ReturnStatement") return;
98 expression = body.body[0].argument;
99 } else {
100 expression = body;
101 }
102 if (expression === null || !isGeneratorCall(expression)) return;
103 context.report({
104 node,
105 message:
106 'Use Effect.fn("Service.operation") for reusable Effect functions. Reserve Effect.fnUntraced for documented hot paths or an existing span; keep Effect.gen for inline programs.',
107 });
108 };
109
110 return {
111 ArrowFunctionExpression: check,
112 FunctionExpression: check,
113 FunctionDeclaration: check,
114 };
115 },
116});