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

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