@doubleloop-io/oxlint-plugin-effect 0.0.0-stage → 0.1.0-rc.0

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.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE.anti-slop +21 -0
  3. package/README.md +210 -2
  4. package/RULES.md +911 -0
  5. package/dist/index.d.ts +4 -0
  6. package/dist/index.js +71 -0
  7. package/dist/presets/all.d.ts +7 -0
  8. package/dist/presets/all.js +9 -0
  9. package/dist/presets/all.json +37 -0
  10. package/dist/rules/adapter-import-boundary.d.ts +7 -0
  11. package/dist/rules/adapter-import-boundary.js +103 -0
  12. package/dist/rules/anti-slop-no-manual-effect-error-tag.d.ts +1 -0
  13. package/dist/rules/anti-slop-no-manual-effect-error-tag.js +32 -0
  14. package/dist/rules/anti-slop-no-service-constructor-imports.d.ts +1 -0
  15. package/dist/rules/anti-slop-no-service-constructor-imports.js +49 -0
  16. package/dist/rules/anti-slop-prefer-effect-match.d.ts +1 -0
  17. package/dist/rules/anti-slop-prefer-effect-match.js +45 -0
  18. package/dist/rules/boundary-entrypoint-shape.d.ts +1 -0
  19. package/dist/rules/boundary-entrypoint-shape.js +52 -0
  20. package/dist/rules/error-handling-in-effect-fn-handler.d.ts +1 -0
  21. package/dist/rules/error-handling-in-effect-fn-handler.js +66 -0
  22. package/dist/rules/exported-schema-identifier.d.ts +1 -0
  23. package/dist/rules/exported-schema-identifier.js +117 -0
  24. package/dist/rules/no-asymmetric-match-handlers.d.ts +1 -0
  25. package/dist/rules/no-asymmetric-match-handlers.js +74 -0
  26. package/dist/rules/no-bare-error-in-effect.d.ts +1 -0
  27. package/dist/rules/no-bare-error-in-effect.js +34 -0
  28. package/dist/rules/no-effect-promise.d.ts +1 -0
  29. package/dist/rules/no-effect-promise.js +57 -0
  30. package/dist/rules/no-effect-return-type.d.ts +1 -0
  31. package/dist/rules/no-effect-return-type.js +130 -0
  32. package/dist/rules/no-inline-schema-codec.d.ts +1 -0
  33. package/dist/rules/no-inline-schema-codec.js +46 -0
  34. package/dist/rules/no-inline-yield-star.d.ts +1 -0
  35. package/dist/rules/no-inline-yield-star.js +230 -0
  36. package/dist/rules/no-manual-discriminant-comparison.d.ts +2 -0
  37. package/dist/rules/no-manual-discriminant-comparison.js +58 -0
  38. package/dist/rules/no-manual-discriminant-construction.d.ts +2 -0
  39. package/dist/rules/no-manual-discriminant-construction.js +90 -0
  40. package/dist/rules/no-nested-match.d.ts +1 -0
  41. package/dist/rules/no-nested-match.js +24 -0
  42. package/dist/rules/no-parameterless-catch-all.d.ts +1 -0
  43. package/dist/rules/no-parameterless-catch-all.js +29 -0
  44. package/dist/rules/no-provide-on-definition.d.ts +1 -0
  45. package/dist/rules/no-provide-on-definition.js +94 -0
  46. package/dist/rules/no-transport-types-in-generator.d.ts +1 -0
  47. package/dist/rules/no-transport-types-in-generator.js +63 -0
  48. package/dist/rules/no-yielded-match.d.ts +1 -0
  49. package/dist/rules/no-yielded-match.js +91 -0
  50. package/dist/rules/prefer-effect-fn.d.ts +1 -0
  51. package/dist/rules/prefer-effect-fn.js +102 -0
  52. package/dist/rules/provide-named-layer.d.ts +1 -0
  53. package/dist/rules/provide-named-layer.js +55 -0
  54. package/dist/rules/redacted-secret-names.d.ts +1 -0
  55. package/dist/rules/redacted-secret-names.js +127 -0
  56. package/dist/rules/run-only-at-entry.d.ts +1 -0
  57. package/dist/rules/run-only-at-entry.js +50 -0
  58. package/dist/rules/service-built-with-of.d.ts +1 -0
  59. package/dist/rules/service-built-with-of.js +74 -0
  60. package/dist/rules/service-key-matches-name.d.ts +1 -0
  61. package/dist/rules/service-key-matches-name.js +69 -0
  62. package/dist/rules/service-port-shape-only.d.ts +1 -0
  63. package/dist/rules/service-port-shape-only.js +65 -0
  64. package/dist/rules/single-style-per-function.d.ts +1 -0
  65. package/dist/rules/single-style-per-function.js +77 -0
  66. package/dist/rules/try-object-form.d.ts +1 -0
  67. package/dist/rules/try-object-form.js +26 -0
  68. package/dist/rules/untraced-recursion.d.ts +1 -0
  69. package/dist/rules/untraced-recursion.js +48 -0
  70. package/dist/shared/effect-ast.d.ts +9 -0
  71. package/dist/shared/effect-ast.js +52 -0
  72. package/dist/shared/entry-files.d.ts +6 -0
  73. package/dist/shared/entry-files.js +5 -0
  74. package/dist/shared/file-globs.d.ts +14 -0
  75. package/dist/shared/file-globs.js +17 -0
  76. package/dist/shared/match-ast.d.ts +5 -0
  77. package/dist/shared/match-ast.js +52 -0
  78. package/dist/shared/tagged-values.d.ts +17 -0
  79. package/dist/shared/tagged-values.js +67 -0
  80. package/dist/shared/test-files.d.ts +6 -0
  81. package/dist/shared/test-files.js +10 -0
  82. package/package.json +56 -5
@@ -0,0 +1,90 @@
1
+ import { defineRule } from "@oxlint/plugins";
2
+ import { calleeOf } from "../shared/effect-ast.js";
3
+ import { configuredDiscriminants, discriminantsSchema, isStringLiteral, propertyName, } from "../shared/tagged-values.js";
4
+ export const remedyFor = (discriminant) => discriminant === "_tag"
5
+ ? "the Schema's tagged `.make`, the tagged class or error constructor, or the `Data.taggedEnum` variant constructor"
6
+ : "the Schema's `.make` or the class constructor";
7
+ const isMatchPatternCall = (call) => {
8
+ const method = calleeOf(call, "Match");
9
+ return method === "when" || method === "not";
10
+ };
11
+ // `match(value).with(pattern, handler)`, also after earlier `.with`/`.when` links of the chain.
12
+ const isTsPatternWithCall = (call) => {
13
+ if (call.callee.type !== "MemberExpression" ||
14
+ call.callee.computed ||
15
+ call.callee.property.type !== "Identifier" ||
16
+ call.callee.property.name !== "with") {
17
+ return false;
18
+ }
19
+ let link = call.callee.object;
20
+ while (link.type === "CallExpression" && link.callee.type === "MemberExpression") {
21
+ link = link.callee.object;
22
+ }
23
+ return (link.type === "CallExpression" &&
24
+ link.callee.type === "Identifier" &&
25
+ link.callee.name === "match");
26
+ };
27
+ // `Schema.decodeUnknownSync(X)(input)`: the literal is raw data to validate, not a value of X.
28
+ const isSchemaCodecApplication = (call) => {
29
+ const method = calleeOf(call.callee, "Schema");
30
+ return method !== undefined && (method.startsWith("decode") || method.startsWith("encode"));
31
+ };
32
+ // A literal nested in a pattern or in a codec input belongs to it as much as the outermost one.
33
+ const isPatternOrCodecInput = (node) => {
34
+ let child = node;
35
+ let current = node.parent;
36
+ while (current?.type === "Property" ||
37
+ current?.type === "ObjectExpression" ||
38
+ current?.type === "ArrayExpression") {
39
+ child = current;
40
+ current = current.parent;
41
+ }
42
+ return (current?.type === "CallExpression" &&
43
+ current.arguments.some((argument) => argument === child) &&
44
+ (isMatchPatternCall(current) ||
45
+ isTsPatternWithCall(current) ||
46
+ isSchemaCodecApplication(current)));
47
+ };
48
+ export const noManualDiscriminantConstructionRule = defineRule({
49
+ meta: {
50
+ type: "problem",
51
+ docs: {
52
+ description: "Construct a union's variants with their constructor, not as object literals with the discriminant.",
53
+ },
54
+ messages: {
55
+ manualConstruction: "An object literal with a literal `{{discriminant}}` skips the variant's constructor: use {{remedy}}.",
56
+ },
57
+ schema: [
58
+ {
59
+ type: "object",
60
+ properties: { discriminants: discriminantsSchema },
61
+ additionalProperties: false,
62
+ },
63
+ ],
64
+ },
65
+ createOnce(context) {
66
+ let discriminants = new Set();
67
+ return {
68
+ before() {
69
+ discriminants = configuredDiscriminants(context.options);
70
+ },
71
+ ObjectExpression(node) {
72
+ for (const property of node.properties) {
73
+ if (property.type !== "Property" || !isStringLiteral(property.value))
74
+ continue;
75
+ const discriminant = propertyName(property);
76
+ if (discriminant === undefined || !discriminants.has(discriminant))
77
+ continue;
78
+ if (isPatternOrCodecInput(node))
79
+ return;
80
+ context.report({
81
+ node: property,
82
+ messageId: "manualConstruction",
83
+ data: { discriminant, remedy: remedyFor(discriminant) },
84
+ });
85
+ return;
86
+ }
87
+ },
88
+ };
89
+ },
90
+ });
@@ -0,0 +1 @@
1
+ export declare const noNestedMatchRule: import("@oxlint/plugins").Rule;
@@ -0,0 +1,24 @@
1
+ import { defineRule } from "@oxlint/plugins";
2
+ import { handlersOf, isMatchCall, nestedBranches } from "../shared/match-ast.js";
3
+ export const noNestedMatchRule = defineRule({
4
+ meta: {
5
+ type: "suggestion",
6
+ docs: {
7
+ description: "Flatten a match whose handler branches again: a guard clause inside generators, a named handler outside.",
8
+ },
9
+ messages: {
10
+ nestedBranch: "This handler branches again inside a match. Inside a generator, return early on the exit case (`if (Result.isFailure(parsed)) return yield* ...`); outside, name the inner choice and pass it as the handler.",
11
+ },
12
+ },
13
+ createOnce(context) {
14
+ return {
15
+ CallExpression(node) {
16
+ if (!isMatchCall(node))
17
+ return;
18
+ for (const branch of handlersOf(node).flatMap(nestedBranches)) {
19
+ context.report({ node: branch, messageId: "nestedBranch" });
20
+ }
21
+ },
22
+ };
23
+ },
24
+ });
@@ -0,0 +1 @@
1
+ export declare const noParameterlessCatchAllRule: import("@oxlint/plugins").Rule;
@@ -0,0 +1,29 @@
1
+ import { defineRule } from "@oxlint/plugins";
2
+ import { calleeOf } from "../shared/effect-ast.js";
3
+ const catchAlls = new Set(["catch", "catchAll", "catchCause", "catchEager"]);
4
+ export const noParameterlessCatchAllRule = defineRule({
5
+ meta: {
6
+ type: "problem",
7
+ docs: {
8
+ description: "Forbid catch-all handlers that ignore the error they catch.",
9
+ },
10
+ messages: {
11
+ swallowed: "`Effect.{{name}}` ignores what it catches, so it also swallows failures added later: use `Effect.catchTag`, or `Effect.orElseSucceed`/`Effect.orElse` when every failure truly means the same fallback.",
12
+ },
13
+ },
14
+ createOnce(context) {
15
+ return {
16
+ CallExpression(node) {
17
+ const name = calleeOf(node, "Effect");
18
+ if (name === undefined || !catchAlls.has(name))
19
+ return;
20
+ const handler = node.arguments.at(-1);
21
+ if ((handler?.type === "ArrowFunctionExpression" ||
22
+ handler?.type === "FunctionExpression") &&
23
+ handler.params.length === 0) {
24
+ context.report({ node: handler, messageId: "swallowed", data: { name } });
25
+ }
26
+ },
27
+ };
28
+ },
29
+ });
@@ -0,0 +1 @@
1
+ export declare const noProvideOnDefinitionRule: import("@oxlint/plugins").Rule;
@@ -0,0 +1,94 @@
1
+ import { defineRule } from "@oxlint/plugins";
2
+ import { calleeOf, isEffectFnApplication } from "../shared/effect-ast.js";
3
+ import { filePatternsSchema } from "../shared/file-globs.js";
4
+ import { isTestFile } from "../shared/test-files.js";
5
+ const pipeReceiver = (call) => {
6
+ const { callee } = call;
7
+ return callee.type === "MemberExpression" &&
8
+ !callee.computed &&
9
+ callee.property.type === "Identifier" &&
10
+ callee.property.name === "pipe"
11
+ ? callee.object
12
+ : undefined;
13
+ };
14
+ const isProgramDefinition = (node) => {
15
+ if (node?.type !== "CallExpression")
16
+ return false;
17
+ if (calleeOf(node, "Effect") === "gen" || isEffectFnApplication(node))
18
+ return true;
19
+ return isProgramDefinition(pipeReceiver(node));
20
+ };
21
+ const isContextCapture = (init) => {
22
+ if (init?.type !== "YieldExpression" || !init.delegate)
23
+ return false;
24
+ let source = init.argument;
25
+ while (source?.type === "CallExpression" && pipeReceiver(source))
26
+ source = pipeReceiver(source);
27
+ return calleeOf(source, "Effect") === "context";
28
+ };
29
+ const isCapturedContext = (value) => {
30
+ if (value?.type !== "Identifier")
31
+ return false;
32
+ for (let current = value.parent; current; current = current.parent) {
33
+ if (current.type !== "BlockStatement")
34
+ continue;
35
+ const captured = current.body.some((statement) => statement.type === "VariableDeclaration" &&
36
+ statement.kind === "const" &&
37
+ statement.declarations.some((declarator) => declarator.id.type === "Identifier" &&
38
+ declarator.id.name === value.name &&
39
+ isContextCapture(declarator.init)));
40
+ if (captured)
41
+ return true;
42
+ }
43
+ return false;
44
+ };
45
+ const providedValue = (provide) => provide.arguments[provide.arguments.length > 1 ? 1 : 0];
46
+ const providedDefinition = (provide) => {
47
+ if (provide.arguments.length > 1)
48
+ return isProgramDefinition(provide.arguments[0]);
49
+ const parent = provide.parent;
50
+ if (parent?.type !== "CallExpression")
51
+ return false;
52
+ // `Effect.fn("name")(body, Effect.provide(layer))`: the pipeable slot belongs to the definition.
53
+ if (isEffectFnApplication(parent) && parent.arguments.indexOf(provide) > 0)
54
+ return true;
55
+ return parent.arguments.includes(provide) && isProgramDefinition(pipeReceiver(parent));
56
+ };
57
+ // A program that provides its own layers can no longer be wired differently by its caller:
58
+ // the dispatch that picks the adapters is where `Effect.provide` belongs.
59
+ export const noProvideOnDefinitionRule = defineRule({
60
+ meta: {
61
+ type: "suggestion",
62
+ docs: {
63
+ description: "Keep `Effect.provide` off `Effect.gen`/`Effect.fn` definitions; provide where the program is dispatched. Test files are skipped.",
64
+ },
65
+ messages: {
66
+ provideOnDefinition: "Define the program without layers and apply `Effect.provide` where it is dispatched, to a named program.",
67
+ },
68
+ schema: [
69
+ {
70
+ type: "object",
71
+ properties: { testFiles: filePatternsSchema },
72
+ additionalProperties: false,
73
+ },
74
+ ],
75
+ },
76
+ createOnce(context) {
77
+ let inTestFile = false;
78
+ return {
79
+ // `it.effect(…, () => Effect.gen(…).pipe(Effect.provide(fakes)))` is the idiomatic test.
80
+ before() {
81
+ inTestFile = isTestFile(context);
82
+ },
83
+ CallExpression(node) {
84
+ if (inTestFile || calleeOf(node, "Effect") !== "provide")
85
+ return;
86
+ // A Layer that captures its context with `Effect.context()` hands it back to each
87
+ // method: the caller already chose the adapters when it built the Layer.
88
+ if (providedDefinition(node) && !isCapturedContext(providedValue(node))) {
89
+ context.report({ node, messageId: "provideOnDefinition" });
90
+ }
91
+ },
92
+ };
93
+ },
94
+ });
@@ -0,0 +1 @@
1
+ export declare const noTransportTypesInGeneratorRule: import("@oxlint/plugins").Rule;
@@ -0,0 +1,63 @@
1
+ import { defineRule } from "@oxlint/plugins";
2
+ import { isInsideEffectGeneratorBody } from "../shared/effect-ast.js";
3
+ const defaultTransportTypes = ["Response", "NextResponse"];
4
+ const configuredTransportTypes = (options) => {
5
+ const [first] = options;
6
+ if (typeof first !== "object" || first === null || !("transportTypes" in first)) {
7
+ return new Set(defaultTransportTypes);
8
+ }
9
+ const types = first.transportTypes;
10
+ return new Set(Array.isArray(types) ? types.filter((type) => typeof type === "string") : []);
11
+ };
12
+ // `new Response(…)`, `Response.json(…)` and a plain factory call `toDto(…)` all build one.
13
+ const constructedTypeName = (node) => {
14
+ const callee = node.callee;
15
+ if (callee.type === "Identifier")
16
+ return callee.name;
17
+ if (node.type === "CallExpression" &&
18
+ callee.type === "MemberExpression" &&
19
+ callee.object.type === "Identifier") {
20
+ return callee.object.name;
21
+ }
22
+ return undefined;
23
+ };
24
+ export const noTransportTypesInGeneratorRule = defineRule({
25
+ meta: {
26
+ type: "suggestion",
27
+ docs: {
28
+ description: "Return domain values from Effect generators and convert them to transport types in the second argument of `Effect.fn`.",
29
+ },
30
+ messages: {
31
+ transportInGenerator: "Do not build `{{name}}` inside the generator: return a domain value and convert it with `Effect.map`/`Effect.catchTags` in the pipeable arguments of `Effect.fn`.",
32
+ },
33
+ schema: [
34
+ {
35
+ type: "object",
36
+ properties: {
37
+ transportTypes: { type: "array", items: { type: "string" }, uniqueItems: true },
38
+ },
39
+ additionalProperties: false,
40
+ },
41
+ ],
42
+ },
43
+ createOnce(context) {
44
+ let transportTypes = new Set();
45
+ const check = (node) => {
46
+ const name = constructedTypeName(node);
47
+ if (name === undefined ||
48
+ !transportTypes.has(name) ||
49
+ !isInsideEffectGeneratorBody(node)) {
50
+ return;
51
+ }
52
+ context.report({ node, messageId: "transportInGenerator", data: { name } });
53
+ };
54
+ return {
55
+ // Options are per file, so they can only be read once a file starts.
56
+ before() {
57
+ transportTypes = configuredTransportTypes(context.options);
58
+ },
59
+ NewExpression: check,
60
+ CallExpression: check,
61
+ };
62
+ },
63
+ });
@@ -0,0 +1 @@
1
+ export declare const noYieldedMatchRule: import("@oxlint/plugins").Rule;
@@ -0,0 +1,91 @@
1
+ import { defineRule } from "@oxlint/plugins";
2
+ import { calleeOf, isEffectGenerator, isFunctionNode, memberOf, nearestFunction, } from "../shared/effect-ast.js";
3
+ import { handlersOf, nestedBranches } from "../shared/match-ast.js";
4
+ const guards = { Option: "Option.isNone", Result: "Result.isFailure" };
5
+ const matchedModule = (node) => {
6
+ if (node?.type !== "CallExpression")
7
+ return undefined;
8
+ if (memberOf(node.callee, "Option") === "match")
9
+ return "Option";
10
+ if (memberOf(node.callee, "Result") === "match")
11
+ return "Result";
12
+ return undefined;
13
+ };
14
+ const isPipe = (callee) => (callee.type === "Identifier" && callee.name === "pipe") ||
15
+ (callee.type === "MemberExpression" &&
16
+ !callee.computed &&
17
+ callee.property.type === "Identifier" &&
18
+ callee.property.name === "pipe");
19
+ // Data-first `Option.match(x, {…})`, or a match as the last step of `x.pipe(…)`/`pipe(x, …)`.
20
+ const yieldedMatch = (node) => {
21
+ if (node?.type !== "CallExpression")
22
+ return undefined;
23
+ if (matchedModule(node) !== undefined && node.arguments.length === 2)
24
+ return node;
25
+ if (!isPipe(node.callee))
26
+ return undefined;
27
+ const last = node.arguments.at(-1);
28
+ return matchedModule(last) !== undefined && last?.type === "CallExpression" ? last : undefined;
29
+ };
30
+ const handlerNamed = (match, name) => {
31
+ for (const argument of match.arguments) {
32
+ if (argument.type !== "ObjectExpression")
33
+ continue;
34
+ for (const property of argument.properties) {
35
+ if (property.type === "Property" &&
36
+ !property.computed &&
37
+ property.key.type === "Identifier" &&
38
+ property.key.name === name) {
39
+ return property.value;
40
+ }
41
+ }
42
+ }
43
+ return undefined;
44
+ };
45
+ // `onSome: Effect.succeed` or `onSome: (x) => Effect.succeed(x)`.
46
+ const resultsIn = (handler, constructor) => {
47
+ if (memberOf(handler, "Effect") === constructor)
48
+ return true;
49
+ return (isFunctionNode(handler) &&
50
+ handler.body?.type !== "BlockStatement" &&
51
+ calleeOf(handler.body, "Effect") === constructor);
52
+ };
53
+ // `option-match-to-from-option` (effecttsgo) proposes `Effect.fromOption` for this shape.
54
+ const isFromOptionShape = (match) => matchedModule(match) === "Option" &&
55
+ resultsIn(handlerNamed(match, "onNone"), "fail") &&
56
+ resultsIn(handlerNamed(match, "onSome"), "succeed");
57
+ export const noYieldedMatchRule = defineRule({
58
+ meta: {
59
+ type: "suggestion",
60
+ docs: {
61
+ description: "Inside an Effect generator, return early on the empty or failed case instead of yielding an `Option.match`/`Result.match`.",
62
+ },
63
+ messages: {
64
+ yieldedMatch: "This `yield*` of a `{{module}}.match` splits the generator into two branches: return early with `if ({{guard}}(…)) return …` and keep one main path.",
65
+ },
66
+ },
67
+ createOnce(context) {
68
+ return {
69
+ YieldExpression(node) {
70
+ if (!node.delegate)
71
+ return;
72
+ const match = yieldedMatch(node.argument);
73
+ const module = matchedModule(match);
74
+ if (match === undefined || module === undefined)
75
+ return;
76
+ const generator = nearestFunction(node);
77
+ if (generator === undefined || !isEffectGenerator(generator))
78
+ return;
79
+ if (isFromOptionShape(match))
80
+ return;
81
+ if (handlersOf(match).some((handler) => nestedBranches(handler).length > 0))
82
+ return;
83
+ context.report({
84
+ node: match,
85
+ messageId: "yieldedMatch",
86
+ data: { module, guard: guards[module] },
87
+ });
88
+ },
89
+ };
90
+ },
91
+ });
@@ -0,0 +1 @@
1
+ export declare const preferEffectFnRule: import("@oxlint/plugins").Rule;
@@ -0,0 +1,102 @@
1
+ import { defineRule } from "@oxlint/plugins";
2
+ import { calleeOf, isEffectFnApplication, isFunctionNode, memberOf, } from "../shared/effect-ast.js";
3
+ const isEffectGenChain = (node) => {
4
+ if (node?.type !== "CallExpression")
5
+ return false;
6
+ if (calleeOf(node, "Effect") === "gen")
7
+ return true;
8
+ const callee = node.callee;
9
+ return (callee.type === "MemberExpression" &&
10
+ !callee.computed &&
11
+ callee.property.type === "Identifier" &&
12
+ callee.property.name === "pipe" &&
13
+ isEffectGenChain(callee.object));
14
+ };
15
+ const returnedExpression = (fn) => {
16
+ if (fn.body?.type !== "BlockStatement")
17
+ return fn.body;
18
+ const statements = fn.body.body;
19
+ const [only] = statements;
20
+ return statements.length === 1 && only?.type === "ReturnStatement" ? only.argument : undefined;
21
+ };
22
+ // Callbacks such as `it.effect("…", () => Effect.gen(…))` are not named functions: only
23
+ // declarations, variables and object members get a span name worth having.
24
+ const isNamedFunction = (fn) => {
25
+ const parent = fn.parent;
26
+ return (fn.type === "FunctionDeclaration" ||
27
+ (parent?.type === "VariableDeclarator" && parent.init === fn) ||
28
+ ((parent?.type === "Property" || parent?.type === "PropertyDefinition") &&
29
+ parent.value === fn));
30
+ };
31
+ const isPipeCall = (node) => node.callee.type === "MemberExpression" &&
32
+ !node.callee.computed &&
33
+ node.callee.property.type === "Identifier" &&
34
+ node.callee.property.name === "pipe";
35
+ // A value keeps its span through `Effect.withSpan`: `Effect.fn` would add only a call-site frame
36
+ // that always points at the same line.
37
+ const hasSpan = (node) => node?.type === "CallExpression" &&
38
+ isPipeCall(node) &&
39
+ node.callee.type === "MemberExpression" &&
40
+ (node.arguments.some((argument) => calleeOf(argument, "Effect") === "withSpan") ||
41
+ hasSpan(node.callee.object));
42
+ const isUntracedValue = (node) => isEffectGenChain(node) && !hasSpan(node);
43
+ export const preferEffectFnRule = defineRule({
44
+ meta: {
45
+ type: "suggestion",
46
+ docs: {
47
+ description: "Write effectful functions with a named `Effect.fn` instead of returning `Effect.gen`.",
48
+ },
49
+ messages: {
50
+ useEffectFn: 'Use `Effect.fn("name")(function* (...) {...})` instead of a function that returns `Effect.gen`.',
51
+ missingName: 'Give `Effect.fn` a span name: `Effect.fn("name")(function* (...) {...})`.',
52
+ useEffectFnForValue: 'Give `{{name}}` a span: declare it as `Effect.fn("{{name}}")(function* () {...})` and call it, or append `.pipe(Effect.withSpan("{{name}}"))`.',
53
+ pipeOnEffectFn: "Pass combinators as extra arguments of `Effect.fn(...)(body, ...)` instead of calling `.pipe` on it.",
54
+ },
55
+ },
56
+ createOnce(context) {
57
+ const checkFunction = (fn) => {
58
+ if (fn.generator || !isNamedFunction(fn))
59
+ return;
60
+ if (isEffectGenChain(returnedExpression(fn))) {
61
+ context.report({ node: fn, messageId: "useEffectFn" });
62
+ }
63
+ };
64
+ return {
65
+ VariableDeclarator(node) {
66
+ if (node.id.type === "Identifier" && isUntracedValue(node.init)) {
67
+ context.report({
68
+ node,
69
+ messageId: "useEffectFnForValue",
70
+ data: { name: node.id.name },
71
+ });
72
+ }
73
+ },
74
+ Property(node) {
75
+ if (node.key.type === "Identifier" && isUntracedValue(node.value)) {
76
+ context.report({
77
+ node,
78
+ messageId: "useEffectFnForValue",
79
+ data: { name: node.key.name },
80
+ });
81
+ }
82
+ },
83
+ ArrowFunctionExpression: checkFunction,
84
+ FunctionExpression: checkFunction,
85
+ FunctionDeclaration: checkFunction,
86
+ CallExpression(node) {
87
+ if (memberOf(node.callee, "Effect") === "fn" && isFunctionNode(node.arguments[0])) {
88
+ context.report({ node, messageId: "missingName" });
89
+ }
90
+ const callee = node.callee;
91
+ if (callee.type === "MemberExpression" &&
92
+ !callee.computed &&
93
+ callee.property.type === "Identifier" &&
94
+ callee.property.name === "pipe" &&
95
+ isEffectFnApplication(callee.object) &&
96
+ isFunctionNode(callee.object.arguments[0])) {
97
+ context.report({ node, messageId: "pipeOnEffectFn" });
98
+ }
99
+ },
100
+ };
101
+ },
102
+ });
@@ -0,0 +1 @@
1
+ export declare const provideNamedLayerRule: import("@oxlint/plugins").Rule;
@@ -0,0 +1,55 @@
1
+ import { defineRule } from "@oxlint/plugins";
2
+ import { calleeOf } from "../shared/effect-ast.js";
3
+ import { filePatternsSchema } from "../shared/file-globs.js";
4
+ import { isTestFile } from "../shared/test-files.js";
5
+ const matchers = new Set(["match", "$match"]);
6
+ // Factories and module members (`planLayer(csv)`, `CsvSheetReader.layer(path)`) are names;
7
+ // only composition written in the argument itself is reported.
8
+ const isInlineComposition = (layer) => {
9
+ if (layer?.type === "ConditionalExpression" || layer?.type === "LogicalExpression")
10
+ return true;
11
+ if (layer?.type !== "CallExpression" || layer.callee.type !== "MemberExpression")
12
+ return false;
13
+ const { callee } = layer;
14
+ if (callee.object.type === "Identifier" && callee.object.name === "Layer")
15
+ return true;
16
+ return (!callee.computed &&
17
+ callee.property.type === "Identifier" &&
18
+ matchers.has(callee.property.name));
19
+ };
20
+ export const provideNamedLayerRule = defineRule({
21
+ meta: {
22
+ type: "suggestion",
23
+ docs: {
24
+ description: "Provide named layers instead of composing them inside `Effect.provide`. Test files are skipped.",
25
+ },
26
+ messages: {
27
+ inlineLayer: "Name the layer passed to `Effect.provide` (`const appLayer = Layer.mergeAll(...)`) instead of composing it inline.",
28
+ },
29
+ schema: [
30
+ {
31
+ type: "object",
32
+ properties: { testFiles: filePatternsSchema },
33
+ additionalProperties: false,
34
+ },
35
+ ],
36
+ },
37
+ createOnce(context) {
38
+ let inTestFile = false;
39
+ return {
40
+ // Tests build per-test fakes inline, which is the idiomatic form there.
41
+ before() {
42
+ inTestFile = isTestFile(context);
43
+ },
44
+ CallExpression(node) {
45
+ if (inTestFile || calleeOf(node, "Effect") !== "provide")
46
+ return;
47
+ // Data-first `Effect.provide(effect, layer)` and data-last `Effect.provide(layer)`.
48
+ const layer = node.arguments.at(-1);
49
+ if (isInlineComposition(layer)) {
50
+ context.report({ node: layer, messageId: "inlineLayer" });
51
+ }
52
+ },
53
+ };
54
+ },
55
+ });
@@ -0,0 +1 @@
1
+ export declare const redactedSecretNamesRule: import("@oxlint/plugins").Rule;