@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,127 @@
1
+ import { defineRule } from "@oxlint/plugins";
2
+ import { calleeOf, memberOf } from "../shared/effect-ast.js";
3
+ import { propertyName } from "../shared/tagged-values.js";
4
+ // A naming heuristic: `_JSON` is here because a service account key travels as inline JSON.
5
+ const secretName = /(KEY|SECRET|TOKEN|PASSWORD|CREDENTIALS?|_JSON)$/;
6
+ // No generic `token`: `nextPageToken` and `tokenType` are not secrets.
7
+ const defaultSecretFields = [
8
+ "password",
9
+ "secret",
10
+ "apiKey",
11
+ "accessToken",
12
+ "refreshToken",
13
+ "idToken",
14
+ ];
15
+ const nullableWrappers = new Set(["optional", "optionalKey", "NullOr", "UndefinedOr", "NullishOr"]);
16
+ const chainMethods = new Set(["pipe", "check", "annotate"]);
17
+ const isRedactedSchema = (node) => node?.type === "CallExpression" &&
18
+ (memberOf(node.callee, "Schema")?.startsWith("Redacted") ?? false);
19
+ const isRedactedConfig = (node) => {
20
+ const name = calleeOf(node, "Config");
21
+ return (name === "redacted" ||
22
+ (name === "schema" && isRedactedSchema(node.arguments[0])) ||
23
+ calleeOf(node.parent, "Config") === "redacted");
24
+ };
25
+ const isRedactedReference = (node) => (memberOf(node, "Schema") ?? calleeOf(node, "Schema"))?.startsWith("Redacted") ?? false;
26
+ // `Schema.String`, `Schema.NonEmptyString.check(…)`, `Schema.optional(Schema.String)`: a schema
27
+ // that is visibly plain text. A named schema (`AccessToken`) may already be `Redacted`.
28
+ const isPlainSchema = (node) => {
29
+ if (node?.type === "MemberExpression") {
30
+ const name = memberOf(node, "Schema");
31
+ return name !== undefined && !name.startsWith("Redacted");
32
+ }
33
+ if (node?.type !== "CallExpression")
34
+ return false;
35
+ const wrapper = calleeOf(node, "Schema");
36
+ if (wrapper !== undefined)
37
+ return nullableWrappers.has(wrapper) && isPlainSchema(node.arguments[0]);
38
+ const { callee } = node;
39
+ if (callee.type !== "MemberExpression" ||
40
+ callee.computed ||
41
+ callee.property.type !== "Identifier" ||
42
+ !chainMethods.has(callee.property.name)) {
43
+ return false;
44
+ }
45
+ return !node.arguments.some(isRedactedReference) && isPlainSchema(callee.object);
46
+ };
47
+ // `Schema.Struct({…})` and `Schema.Class<X>("X")({…})`.
48
+ const fieldsOf = (node) => {
49
+ const isStruct = calleeOf(node, "Schema") === "Struct";
50
+ const isClass = calleeOf(node.callee, "Schema") === "Class";
51
+ const fields = isStruct || isClass ? node.arguments[0] : undefined;
52
+ return fields?.type === "ObjectExpression" ? fields : undefined;
53
+ };
54
+ // `password` matches `password` and `dbPassword`, not `passwordPolicy`.
55
+ const matchesSecretField = (name, secretFields) => secretFields.some((field) => name === field || name.endsWith(`${field.charAt(0).toUpperCase()}${field.slice(1)}`));
56
+ const configuredSecretFields = (options) => {
57
+ const [first] = options;
58
+ if (typeof first !== "object" || first === null || !("secretFields" in first)) {
59
+ return defaultSecretFields;
60
+ }
61
+ const names = first.secretFields;
62
+ return Array.isArray(names) ? names.filter((name) => typeof name === "string") : [];
63
+ };
64
+ export const redactedSecretNamesRule = defineRule({
65
+ meta: {
66
+ type: "suggestion",
67
+ docs: {
68
+ description: "Read configuration values and declare Schema fields whose name looks like a secret as `Redacted`.",
69
+ },
70
+ messages: {
71
+ notRedacted: "`{{name}}` looks like a secret: read it with `Config.redacted` or `Config.schema(Schema.Redacted(...), name)`.",
72
+ fieldNotRedacted: "`{{name}}` looks like a secret: declare it as `Schema.Redacted(...)` so it stays out of logs.",
73
+ },
74
+ schema: [
75
+ {
76
+ type: "object",
77
+ properties: {
78
+ secretFields: { type: "array", items: { type: "string" }, uniqueItems: true },
79
+ },
80
+ additionalProperties: false,
81
+ },
82
+ ],
83
+ },
84
+ createOnce(context) {
85
+ let secretFields = defaultSecretFields;
86
+ const checkConfig = (node) => {
87
+ if (calleeOf(node, "Config") === undefined || isRedactedConfig(node))
88
+ return;
89
+ for (const argument of node.arguments) {
90
+ if (argument.type === "Literal" &&
91
+ typeof argument.value === "string" &&
92
+ secretName.test(argument.value)) {
93
+ context.report({
94
+ node: argument,
95
+ messageId: "notRedacted",
96
+ data: { name: argument.value },
97
+ });
98
+ }
99
+ }
100
+ };
101
+ const checkSchemaFields = (node) => {
102
+ for (const property of fieldsOf(node)?.properties ?? []) {
103
+ if (property.type !== "Property")
104
+ continue;
105
+ const name = propertyName(property);
106
+ if (name === undefined || !matchesSecretField(name, secretFields))
107
+ continue;
108
+ if (!isPlainSchema(property.value))
109
+ continue;
110
+ context.report({
111
+ node: property.key,
112
+ messageId: "fieldNotRedacted",
113
+ data: { name },
114
+ });
115
+ }
116
+ };
117
+ return {
118
+ before() {
119
+ secretFields = configuredSecretFields(context.options);
120
+ },
121
+ CallExpression(node) {
122
+ checkConfig(node);
123
+ checkSchemaFields(node);
124
+ },
125
+ };
126
+ },
127
+ });
@@ -0,0 +1 @@
1
+ export declare const runOnlyAtEntryRule: import("@oxlint/plugins").Rule;
@@ -0,0 +1,50 @@
1
+ import { defineRule } from "@oxlint/plugins";
2
+ import { memberOf } from "../shared/effect-ast.js";
3
+ import { isEntryFile } from "../shared/entry-files.js";
4
+ import { filePatternsSchema } from "../shared/file-globs.js";
5
+ const runtimeEntries = [
6
+ ["NodeRuntime", "runMain"],
7
+ ["BunRuntime", "runMain"],
8
+ ["ManagedRuntime", "make"],
9
+ ];
10
+ const runnerName = (node) => {
11
+ const effectMember = memberOf(node, "Effect");
12
+ if (effectMember?.startsWith("run"))
13
+ return `Effect.${effectMember}`;
14
+ const entry = runtimeEntries.find(([object, property]) => memberOf(node, object) === property);
15
+ return entry === undefined ? undefined : `${entry[0]}.${entry[1]}`;
16
+ };
17
+ // Reports references too, not only calls: `const run = Effect.runPromise` escapes nothing.
18
+ export const runOnlyAtEntryRule = defineRule({
19
+ meta: {
20
+ type: "problem",
21
+ docs: {
22
+ description: "Run Effect programs only at the entry point. Entry files are skipped.",
23
+ },
24
+ messages: {
25
+ runOutsideEntry: "`{{name}}` runs an Effect program: only the entry point may do that. Tests use `it.effect`, or a single test runner listed in `entryFiles`.",
26
+ },
27
+ schema: [
28
+ {
29
+ type: "object",
30
+ properties: { entryFiles: filePatternsSchema },
31
+ additionalProperties: false,
32
+ },
33
+ ],
34
+ },
35
+ createOnce(context) {
36
+ let inEntryFile = false;
37
+ return {
38
+ before() {
39
+ inEntryFile = isEntryFile(context);
40
+ },
41
+ MemberExpression(node) {
42
+ if (inEntryFile)
43
+ return;
44
+ const name = runnerName(node);
45
+ if (name !== undefined)
46
+ context.report({ node, messageId: "runOutsideEntry", data: { name } });
47
+ },
48
+ };
49
+ },
50
+ });
@@ -0,0 +1 @@
1
+ export declare const serviceBuiltWithOfRule: import("@oxlint/plugins").Rule;
@@ -0,0 +1,74 @@
1
+ import { defineRule } from "@oxlint/plugins";
2
+ import { calleeOf, isFunctionNode } from "../shared/effect-ast.js";
3
+ const layerConstructors = new Set(["succeed", "sync", "effect", "scoped"]);
4
+ // `Layer.succeed(T, impl)` and the curried `Layer.succeed(T)(impl)`.
5
+ const layerApplication = (node) => {
6
+ const dataFirst = calleeOf(node, "Layer");
7
+ if (dataFirst !== undefined && node.arguments.length === 2) {
8
+ return { constructor: dataFirst, tag: node.arguments[0], impl: node.arguments[1] };
9
+ }
10
+ const curried = node.callee.type === "CallExpression" ? calleeOf(node.callee, "Layer") : undefined;
11
+ if (curried !== undefined && node.callee.type === "CallExpression") {
12
+ return { constructor: curried, tag: node.callee.arguments[0], impl: node.arguments[0] };
13
+ }
14
+ return undefined;
15
+ };
16
+ const returnedObjects = (fn) => {
17
+ if (fn.body?.type === "ObjectExpression")
18
+ return [fn.body];
19
+ if (fn.body?.type !== "BlockStatement")
20
+ return [];
21
+ return fn.body.body.flatMap((statement) => statement.type === "ReturnStatement" && statement.argument?.type === "ObjectExpression"
22
+ ? [statement.argument]
23
+ : []);
24
+ };
25
+ const implementationObjects = (constructor, impl) => {
26
+ if (constructor === "succeed")
27
+ return impl?.type === "ObjectExpression" ? [impl] : [];
28
+ if (constructor === "sync")
29
+ return isFunctionNode(impl) ? returnedObjects(impl) : [];
30
+ if (impl?.type !== "CallExpression" || calleeOf(impl, "Effect") !== "gen")
31
+ return [];
32
+ return impl.arguments.flatMap((argument) => isFunctionNode(argument) && argument.generator ? returnedObjects(argument) : []);
33
+ };
34
+ export const serviceBuiltWithOfRule = defineRule({
35
+ meta: {
36
+ type: "suggestion",
37
+ docs: {
38
+ description: "Build the service a Layer provides with `Tag.of({…})` instead of a bare object literal.",
39
+ },
40
+ hasSuggestions: true,
41
+ messages: {
42
+ bareLiteral: "Build the service with `{{tag}}.of({…})`: it names the service the object implements and keeps its type checked if the literal moves out of the Layer.",
43
+ wrapWithOf: "Wrap the literal in `{{tag}}.of(…)`.",
44
+ },
45
+ },
46
+ createOnce(context) {
47
+ return {
48
+ CallExpression(node) {
49
+ const application = layerApplication(node);
50
+ if (application === undefined || !layerConstructors.has(application.constructor)) {
51
+ return;
52
+ }
53
+ const { tag, impl } = application;
54
+ if (tag === undefined || tag.type === "SpreadElement")
55
+ return;
56
+ const tagText = context.sourceCode.getText(tag);
57
+ for (const literal of implementationObjects(application.constructor, impl)) {
58
+ context.report({
59
+ node: literal,
60
+ messageId: "bareLiteral",
61
+ data: { tag: tagText },
62
+ suggest: [
63
+ {
64
+ messageId: "wrapWithOf",
65
+ data: { tag: tagText },
66
+ fix: (fixer) => fixer.replaceText(literal, `${tagText}.of(${context.sourceCode.getText(literal)})`),
67
+ },
68
+ ],
69
+ });
70
+ }
71
+ },
72
+ };
73
+ },
74
+ });
@@ -0,0 +1 @@
1
+ export declare const serviceKeyMatchesNameRule: import("@oxlint/plugins").Rule;
@@ -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,77 @@
1
+ import { defineRule } from "@oxlint/plugins";
2
+ import { isEffectGenerator, memberOf } from "../shared/effect-ast.js";
3
+ const isThenCall = (node) => node.callee.type === "MemberExpression" &&
4
+ !node.callee.computed &&
5
+ node.callee.property.type === "Identifier" &&
6
+ node.callee.property.name === "then";
7
+ const testRunners = new Set(["test", "it"]);
8
+ // `test(…)`, `test.only(…)`, `test.each(table)(…)`: the callee chain roots at `test` or `it`.
9
+ const isTestCallback = (fn) => {
10
+ const call = fn.parent;
11
+ if (call?.type !== "CallExpression" || !call.arguments.some((arg) => arg === fn))
12
+ return false;
13
+ let callee = call.callee;
14
+ while (callee.type === "MemberExpression" || callee.type === "CallExpression") {
15
+ callee = callee.type === "MemberExpression" ? callee.object : callee.callee;
16
+ }
17
+ return callee.type === "Identifier" && testRunners.has(callee.name);
18
+ };
19
+ export const singleStylePerFunctionRule = defineRule({
20
+ meta: {
21
+ type: "problem",
22
+ docs: {
23
+ description: "Keep each function entirely Effect or entirely async/await.",
24
+ },
25
+ messages: {
26
+ asyncEffect: "This function builds Effects: drop `async` and lift promises with `Effect.tryPromise({ try, catch })`.",
27
+ promiseStep: "Do not mix promises with Effect in the same function: lift the promise with `Effect.tryPromise({ try, catch })`.",
28
+ asyncEffectTest: "This test mixes promises with Effect: make its body one Effect, with `it.effect` from `@effect/vitest` or by returning a single test runner call from a non-async callback.",
29
+ },
30
+ },
31
+ createOnce(context) {
32
+ const frames = [];
33
+ const current = () => frames.at(-1);
34
+ const enter = (fn) => {
35
+ frames.push({ fn, effectful: isEffectGenerator(fn), promiseSteps: [] });
36
+ };
37
+ const exit = () => {
38
+ const frame = frames.pop();
39
+ if (frame === undefined || !frame.effectful)
40
+ return;
41
+ // One report per test: the remedy is the test's shape, not each promise in it.
42
+ if (isTestCallback(frame.fn)) {
43
+ if (frame.fn.async || frame.promiseSteps.length > 0)
44
+ context.report({ node: frame.fn, messageId: "asyncEffectTest" });
45
+ return;
46
+ }
47
+ if (frame.fn.async)
48
+ context.report({ node: frame.fn, messageId: "asyncEffect" });
49
+ for (const node of frame.promiseSteps)
50
+ context.report({ node, messageId: "promiseStep" });
51
+ };
52
+ return {
53
+ ArrowFunctionExpression: enter,
54
+ FunctionExpression: enter,
55
+ FunctionDeclaration: enter,
56
+ "ArrowFunctionExpression:exit": exit,
57
+ "FunctionExpression:exit": exit,
58
+ "FunctionDeclaration:exit": exit,
59
+ MemberExpression(node) {
60
+ const frame = current();
61
+ const name = memberOf(node, "Effect");
62
+ // `Effect.run*` is how an async boundary legitimately hands off to Effect (P27);
63
+ // where it may appear is run-only-at-entry's business.
64
+ if (frame !== undefined && name !== undefined && !name.startsWith("run")) {
65
+ frame.effectful = true;
66
+ }
67
+ },
68
+ AwaitExpression(node) {
69
+ current()?.promiseSteps.push(node);
70
+ },
71
+ CallExpression(node) {
72
+ if (isThenCall(node))
73
+ current()?.promiseSteps.push(node);
74
+ },
75
+ };
76
+ },
77
+ });
@@ -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,9 @@
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;
@@ -0,0 +1,52 @@
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
+ // Plain callbacks inside the generator still belong to its success path; the pipeable
32
+ // arguments of an `Effect.fn` nested in it are the handler slot, so they do not.
33
+ export const isInsideEffectGeneratorBody = (node) => {
34
+ let child = node;
35
+ for (let current = node.parent; current; current = current.parent) {
36
+ if (isEffectFnApplication(current) &&
37
+ current.arguments.findIndex((arg) => arg === child) > 0) {
38
+ return false;
39
+ }
40
+ if (isFunctionNode(current) && current.generator)
41
+ return isEffectGenerator(current);
42
+ child = current;
43
+ }
44
+ return false;
45
+ };
46
+ export const nearestFunction = (node) => {
47
+ for (let current = node.parent; current; current = current.parent) {
48
+ if (isFunctionNode(current))
49
+ return current;
50
+ }
51
+ return undefined;
52
+ };
@@ -0,0 +1,6 @@
1
+ export declare const defaultEntryFiles: string[];
2
+ export declare const isEntryFile: (context: {
3
+ readonly filename: string;
4
+ readonly cwd: string;
5
+ readonly options: ReadonlyArray<unknown>;
6
+ }) => boolean;
@@ -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 {};