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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE.anti-slop +21 -0
  3. package/README.md +216 -2
  4. package/RULES.md +931 -0
  5. package/dist/index.d.ts +6 -0
  6. package/dist/index.js +75 -0
  7. package/dist/presets/all.d.ts +7 -0
  8. package/dist/presets/all.js +9 -0
  9. package/dist/presets/all.json +38 -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/all-errors-handled-at-boundary.d.ts +1 -0
  13. package/dist/rules/all-errors-handled-at-boundary.js +111 -0
  14. package/dist/rules/anti-slop-no-manual-effect-error-tag.d.ts +1 -0
  15. package/dist/rules/anti-slop-no-manual-effect-error-tag.js +32 -0
  16. package/dist/rules/anti-slop-no-service-constructor-imports.d.ts +1 -0
  17. package/dist/rules/anti-slop-no-service-constructor-imports.js +49 -0
  18. package/dist/rules/anti-slop-prefer-effect-match.d.ts +1 -0
  19. package/dist/rules/anti-slop-prefer-effect-match.js +45 -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 +144 -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 +49 -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 +81 -0
  38. package/dist/rules/no-manual-discriminant-construction.d.ts +2 -0
  39. package/dist/rules/no-manual-discriminant-construction.js +132 -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 +123 -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/prefer-effect-vitest.d.ts +1 -0
  53. package/dist/rules/prefer-effect-vitest.js +52 -0
  54. package/dist/rules/provide-named-layer.d.ts +1 -0
  55. package/dist/rules/provide-named-layer.js +55 -0
  56. package/dist/rules/redacted-secret-names.d.ts +1 -0
  57. package/dist/rules/redacted-secret-names.js +125 -0
  58. package/dist/rules/run-only-in-runner.d.ts +1 -0
  59. package/dist/rules/run-only-in-runner.js +40 -0
  60. package/dist/rules/service-built-with-of.d.ts +1 -0
  61. package/dist/rules/service-built-with-of.js +74 -0
  62. package/dist/rules/service-key-matches-name.d.ts +1 -0
  63. package/dist/rules/service-key-matches-name.js +69 -0
  64. package/dist/rules/service-port-shape-only.d.ts +1 -0
  65. package/dist/rules/service-port-shape-only.js +65 -0
  66. package/dist/rules/single-style-per-function.d.ts +1 -0
  67. package/dist/rules/single-style-per-function.js +145 -0
  68. package/dist/rules/try-object-form.d.ts +1 -0
  69. package/dist/rules/try-object-form.js +26 -0
  70. package/dist/rules/untraced-recursion.d.ts +1 -0
  71. package/dist/rules/untraced-recursion.js +48 -0
  72. package/dist/shared/effect-ast.d.ts +10 -0
  73. package/dist/shared/effect-ast.js +63 -0
  74. package/dist/shared/effect-runners.d.ts +2 -0
  75. package/dist/shared/effect-runners.js +14 -0
  76. package/dist/shared/entry-files.d.ts +6 -0
  77. package/dist/shared/entry-files.js +5 -0
  78. package/dist/shared/file-globs.d.ts +14 -0
  79. package/dist/shared/file-globs.js +17 -0
  80. package/dist/shared/match-ast.d.ts +5 -0
  81. package/dist/shared/match-ast.js +52 -0
  82. package/dist/shared/tagged-values.d.ts +17 -0
  83. package/dist/shared/tagged-values.js +67 -0
  84. package/dist/shared/test-files.d.ts +6 -0
  85. package/dist/shared/test-files.js +10 -0
  86. package/package.json +60 -3
@@ -0,0 +1,6 @@
1
+ export { defaultEntryFiles } from "./shared/entry-files.ts";
2
+ export { defaultTestFiles } from "./shared/test-files.ts";
3
+ export declare const pluginName = "dl-effect";
4
+ export declare const pluginPath: string;
5
+ declare const dlEffectPlugin: import("@oxlint/plugins").Plugin;
6
+ export default dlEffectPlugin;
package/dist/index.js ADDED
@@ -0,0 +1,75 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { eslintCompatPlugin } from "@oxlint/plugins";
3
+ import { adapterImportBoundaryRule } from "./rules/adapter-import-boundary.js";
4
+ import { allErrorsHandledAtBoundaryRule } from "./rules/all-errors-handled-at-boundary.js";
5
+ import { antiSlopNoManualEffectErrorTagRule } from "./rules/anti-slop-no-manual-effect-error-tag.js";
6
+ import { antiSlopNoServiceConstructorImportsRule } from "./rules/anti-slop-no-service-constructor-imports.js";
7
+ import { antiSlopPreferEffectMatchRule } from "./rules/anti-slop-prefer-effect-match.js";
8
+ import { errorHandlingInEffectFnHandlerRule } from "./rules/error-handling-in-effect-fn-handler.js";
9
+ import { exportedSchemaIdentifierRule } from "./rules/exported-schema-identifier.js";
10
+ import { noAsymmetricMatchHandlersRule } from "./rules/no-asymmetric-match-handlers.js";
11
+ import { noBareErrorInEffectRule } from "./rules/no-bare-error-in-effect.js";
12
+ import { noEffectPromiseRule } from "./rules/no-effect-promise.js";
13
+ import { noEffectReturnTypeRule } from "./rules/no-effect-return-type.js";
14
+ import { noInlineSchemaCodecRule } from "./rules/no-inline-schema-codec.js";
15
+ import { noInlineYieldStarRule } from "./rules/no-inline-yield-star.js";
16
+ import { noManualDiscriminantComparisonRule } from "./rules/no-manual-discriminant-comparison.js";
17
+ import { noManualDiscriminantConstructionRule } from "./rules/no-manual-discriminant-construction.js";
18
+ import { noNestedMatchRule } from "./rules/no-nested-match.js";
19
+ import { noParameterlessCatchAllRule } from "./rules/no-parameterless-catch-all.js";
20
+ import { noProvideOnDefinitionRule } from "./rules/no-provide-on-definition.js";
21
+ import { noTransportTypesInGeneratorRule } from "./rules/no-transport-types-in-generator.js";
22
+ import { noYieldedMatchRule } from "./rules/no-yielded-match.js";
23
+ import { preferEffectFnRule } from "./rules/prefer-effect-fn.js";
24
+ import { preferEffectVitestRule } from "./rules/prefer-effect-vitest.js";
25
+ import { provideNamedLayerRule } from "./rules/provide-named-layer.js";
26
+ import { redactedSecretNamesRule } from "./rules/redacted-secret-names.js";
27
+ import { runOnlyInRunnerRule } from "./rules/run-only-in-runner.js";
28
+ import { serviceBuiltWithOfRule } from "./rules/service-built-with-of.js";
29
+ import { serviceKeyMatchesNameRule } from "./rules/service-key-matches-name.js";
30
+ import { servicePortShapeOnlyRule } from "./rules/service-port-shape-only.js";
31
+ import { singleStylePerFunctionRule } from "./rules/single-style-per-function.js";
32
+ import { tryObjectFormRule } from "./rules/try-object-form.js";
33
+ import { untracedRecursionRule } from "./rules/untraced-recursion.js";
34
+ export { defaultEntryFiles } from "./shared/entry-files.js";
35
+ export { defaultTestFiles } from "./shared/test-files.js";
36
+ export const pluginName = "dl-effect";
37
+ // Resolved at runtime so it points at this file both from the sources and from `dist/`.
38
+ export const pluginPath = fileURLToPath(import.meta.url);
39
+ const dlEffectPlugin = eslintCompatPlugin({
40
+ meta: { name: pluginName },
41
+ rules: {
42
+ "anti-slop-no-manual-effect-error-tag": antiSlopNoManualEffectErrorTagRule,
43
+ "anti-slop-no-service-constructor-imports": antiSlopNoServiceConstructorImportsRule,
44
+ "anti-slop-prefer-effect-match": antiSlopPreferEffectMatchRule,
45
+ "adapter-import-boundary": adapterImportBoundaryRule,
46
+ "all-errors-handled-at-boundary": allErrorsHandledAtBoundaryRule,
47
+ "error-handling-in-effect-fn-handler": errorHandlingInEffectFnHandlerRule,
48
+ "exported-schema-identifier": exportedSchemaIdentifierRule,
49
+ "no-asymmetric-match-handlers": noAsymmetricMatchHandlersRule,
50
+ "no-bare-error-in-effect": noBareErrorInEffectRule,
51
+ "no-effect-promise": noEffectPromiseRule,
52
+ "no-effect-return-type": noEffectReturnTypeRule,
53
+ "no-inline-schema-codec": noInlineSchemaCodecRule,
54
+ "no-inline-yield-star": noInlineYieldStarRule,
55
+ "no-manual-discriminant-comparison": noManualDiscriminantComparisonRule,
56
+ "no-manual-discriminant-construction": noManualDiscriminantConstructionRule,
57
+ "no-nested-match": noNestedMatchRule,
58
+ "no-parameterless-catch-all": noParameterlessCatchAllRule,
59
+ "no-provide-on-definition": noProvideOnDefinitionRule,
60
+ "no-transport-types-in-generator": noTransportTypesInGeneratorRule,
61
+ "no-yielded-match": noYieldedMatchRule,
62
+ "prefer-effect-fn": preferEffectFnRule,
63
+ "prefer-effect-vitest": preferEffectVitestRule,
64
+ "provide-named-layer": provideNamedLayerRule,
65
+ "redacted-secret-names": redactedSecretNamesRule,
66
+ "run-only-in-runner": runOnlyInRunnerRule,
67
+ "service-built-with-of": serviceBuiltWithOfRule,
68
+ "service-key-matches-name": serviceKeyMatchesNameRule,
69
+ "service-port-shape-only": servicePortShapeOnlyRule,
70
+ "single-style-per-function": singleStylePerFunctionRule,
71
+ "try-object-form": tryObjectFormRule,
72
+ "untraced-recursion": untracedRecursionRule,
73
+ },
74
+ });
75
+ export default dlEffectPlugin;
@@ -0,0 +1,7 @@
1
+ declare const all: {
2
+ jsPlugins: string[];
3
+ rules: {
4
+ [k: string]: "warn";
5
+ };
6
+ };
7
+ export default all;
@@ -0,0 +1,9 @@
1
+ import dlEffectPlugin, { pluginName, pluginPath } from "../index.js";
2
+ const allRules = Object.fromEntries(Object.keys(dlEffectPlugin.rules).map((rule) => [`${pluginName}/${rule}`, "warn"]));
3
+ // Absolute because an imported config object carries no location of its own: a relative
4
+ // path would resolve against the consumer's config file, not this package.
5
+ const all = {
6
+ jsPlugins: [pluginPath],
7
+ rules: allRules,
8
+ };
9
+ export default all;
@@ -0,0 +1,38 @@
1
+ {
2
+ "jsPlugins": [
3
+ "../index.js"
4
+ ],
5
+ "rules": {
6
+ "dl-effect/anti-slop-no-manual-effect-error-tag": "warn",
7
+ "dl-effect/anti-slop-no-service-constructor-imports": "warn",
8
+ "dl-effect/anti-slop-prefer-effect-match": "warn",
9
+ "dl-effect/adapter-import-boundary": "warn",
10
+ "dl-effect/all-errors-handled-at-boundary": "warn",
11
+ "dl-effect/error-handling-in-effect-fn-handler": "warn",
12
+ "dl-effect/exported-schema-identifier": "warn",
13
+ "dl-effect/no-asymmetric-match-handlers": "warn",
14
+ "dl-effect/no-bare-error-in-effect": "warn",
15
+ "dl-effect/no-effect-promise": "warn",
16
+ "dl-effect/no-effect-return-type": "warn",
17
+ "dl-effect/no-inline-schema-codec": "warn",
18
+ "dl-effect/no-inline-yield-star": "warn",
19
+ "dl-effect/no-manual-discriminant-comparison": "warn",
20
+ "dl-effect/no-manual-discriminant-construction": "warn",
21
+ "dl-effect/no-nested-match": "warn",
22
+ "dl-effect/no-parameterless-catch-all": "warn",
23
+ "dl-effect/no-provide-on-definition": "warn",
24
+ "dl-effect/no-transport-types-in-generator": "warn",
25
+ "dl-effect/no-yielded-match": "warn",
26
+ "dl-effect/prefer-effect-fn": "warn",
27
+ "dl-effect/prefer-effect-vitest": "warn",
28
+ "dl-effect/provide-named-layer": "warn",
29
+ "dl-effect/redacted-secret-names": "warn",
30
+ "dl-effect/run-only-in-runner": "warn",
31
+ "dl-effect/service-built-with-of": "warn",
32
+ "dl-effect/service-key-matches-name": "warn",
33
+ "dl-effect/service-port-shape-only": "warn",
34
+ "dl-effect/single-style-per-function": "warn",
35
+ "dl-effect/try-object-form": "warn",
36
+ "dl-effect/untraced-recursion": "warn"
37
+ }
38
+ }
@@ -0,0 +1,7 @@
1
+ type Deps = {
2
+ readonly isAdapterDir: (dir: string) => boolean;
3
+ };
4
+ export declare const buildsLayer: (source: string) => boolean;
5
+ export declare const makeAdapterImportBoundaryRule: (deps: Deps) => import("@oxlint/plugins").Rule;
6
+ export declare const adapterImportBoundaryRule: import("@oxlint/plugins").Rule;
7
+ export {};
@@ -0,0 +1,103 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { dirname, relative, resolve, sep } from "node:path";
3
+ import { defineRule } from "@oxlint/plugins";
4
+ import { isEntryFile } from "../shared/entry-files.js";
5
+ import { filePatternsSchema } from "../shared/file-globs.js";
6
+ import { isTestFile } from "../shared/test-files.js";
7
+ // Only the APIs that build a service implementation, so composition of existing layers
8
+ // (`mergeAll`, `provide`, `merge`) leaves the entry point out. Longest names first: the
9
+ // alternation is ordered, and `effect` would otherwise shadow `effectContext`.
10
+ // `scoped*`, `context`, `service` and `function` exist in Effect 3 only; `unwrap` and
11
+ // `mock` in Effect 4 only. Naming the API instead of the binding keeps `FooLive` covered.
12
+ const LAYER_CONSTRUCTORS = [
13
+ "effectContext",
14
+ "effectDiscard",
15
+ "succeedContext",
16
+ "scopedContext",
17
+ "scopedDiscard",
18
+ "syncContext",
19
+ "function",
20
+ "service",
21
+ "context",
22
+ "succeed",
23
+ "scoped",
24
+ "effect",
25
+ "unwrap",
26
+ "sync",
27
+ "mock",
28
+ ];
29
+ // Like the other rules, names are matched literally: `import * as L from "effect/Layer"` escapes.
30
+ const LAYER_CONSTRUCTION = new RegExp(`\\bLayer\\.(?:${LAYER_CONSTRUCTORS.join("|")})\\s*\\(`, "u");
31
+ export const buildsLayer = (source) => LAYER_CONSTRUCTION.test(source);
32
+ // Test files are skipped so a fixture layer cannot promote a domain directory to an adapter.
33
+ const isSource = (entry) => entry.endsWith(".ts") && !entry.endsWith(".test.ts");
34
+ const buildsLayerIn = (dir) => {
35
+ try {
36
+ return readdirSync(dir)
37
+ .filter(isSource)
38
+ .some((entry) => buildsLayer(readFileSync(resolve(dir, entry), "utf8")));
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ };
44
+ const cachedBuildsLayerIn = () => {
45
+ const known = new Map();
46
+ return (dir) => {
47
+ const seen = known.get(dir);
48
+ if (seen !== undefined)
49
+ return seen;
50
+ const verdict = buildsLayerIn(dir);
51
+ known.set(dir, verdict);
52
+ return verdict;
53
+ };
54
+ };
55
+ const isRelativeImport = (source) => source.startsWith("./") || source.startsWith("../");
56
+ const toPosix = (path) => path.split(sep).join("/");
57
+ export const makeAdapterImportBoundaryRule = (deps) => defineRule({
58
+ meta: {
59
+ type: "problem",
60
+ docs: {
61
+ description: "Import adapters only at the entry point, and never from another adapter. Entry and test files are skipped.",
62
+ },
63
+ messages: {
64
+ adapterOutsideEntry: "`{{adapter}}` is an adapter: depend on its port instead and let the entry point provide the Layer.",
65
+ crossAdapter: "`{{adapter}}` is another adapter: depend on its port instead and let the entry point compose the Layers.",
66
+ },
67
+ schema: [
68
+ {
69
+ type: "object",
70
+ properties: {
71
+ entryFiles: filePatternsSchema,
72
+ testFiles: filePatternsSchema,
73
+ },
74
+ additionalProperties: false,
75
+ },
76
+ ],
77
+ },
78
+ create(context) {
79
+ const ownDir = dirname(context.filename);
80
+ // A test composes the Layers it exercises, so it is an entry point too.
81
+ const composesLayers = isEntryFile(context) || isTestFile(context);
82
+ return {
83
+ ImportDeclaration(node) {
84
+ const source = node.source.value;
85
+ if (composesLayers || !isRelativeImport(source))
86
+ return;
87
+ const targetDir = dirname(resolve(ownDir, source));
88
+ if (targetDir === ownDir || !deps.isAdapterDir(targetDir))
89
+ return;
90
+ context.report({
91
+ node,
92
+ messageId: deps.isAdapterDir(ownDir)
93
+ ? "crossAdapter"
94
+ : "adapterOutsideEntry",
95
+ data: { adapter: toPosix(relative(context.cwd, targetDir)) },
96
+ });
97
+ },
98
+ };
99
+ },
100
+ });
101
+ export const adapterImportBoundaryRule = makeAdapterImportBoundaryRule({
102
+ isAdapterDir: cachedBuildsLayerIn(),
103
+ });
@@ -0,0 +1 @@
1
+ export declare const allErrorsHandledAtBoundaryRule: import("@oxlint/plugins").Rule;
@@ -0,0 +1,111 @@
1
+ import { defineRule } from "@oxlint/plugins";
2
+ import { calleeOf, isServerComponent, memberOf, nearestFunction, } from "../shared/effect-ast.js";
3
+ import { filePatternsSchema } from "../shared/file-globs.js";
4
+ import { isTestFile } from "../shared/test-files.js";
5
+ // The *Exit runners hand failures back as values, so an open error channel cannot leak there.
6
+ // `NodeRuntime.runMain`/`BunRuntime.runMain` are boundaries too: they log the Cause and exit with
7
+ // code 1, which is the right outcome for a process failing to start (a ConfigError, a busy port).
8
+ const effectRunners = new Set(["runPromise", "runSync", "runFork", "runCallback"]);
9
+ const isRunnerCall = (node) => effectRunners.has(calleeOf(node, "Effect") ?? "");
10
+ const isEffectTypeName = (name) => (name.type === "Identifier" && name.name === "Effect") ||
11
+ (name.type === "TSQualifiedName" &&
12
+ name.left.type === "Identifier" &&
13
+ name.left.name === "Effect" &&
14
+ name.right.name === "Effect");
15
+ const isClosedEffectType = (type) => type?.type === "TSTypeReference" &&
16
+ isEffectTypeName(type.typeName) &&
17
+ type.typeArguments?.params[1]?.type === "TSNeverKeyword";
18
+ const parameterType = (identifier) => {
19
+ for (let fn = nearestFunction(identifier); fn; fn = nearestFunction(fn)) {
20
+ for (const param of fn.params) {
21
+ if (param.type === "Identifier" && param.name === identifier.name) {
22
+ return param.typeAnnotation?.typeAnnotation;
23
+ }
24
+ }
25
+ }
26
+ return undefined;
27
+ };
28
+ // They turn every failure into a success value, so whatever comes before cannot leak.
29
+ const failureToValue = new Set(["result", "exit"]);
30
+ const isPipeCall = (node) => node.callee.type === "MemberExpression" &&
31
+ !node.callee.computed &&
32
+ node.callee.property.type === "Identifier" &&
33
+ node.callee.property.name === "pipe";
34
+ // `Effect.result(program)` or `program.pipe(…, Effect.result)`.
35
+ const endsWithFailureToValue = (node) => {
36
+ const last = isPipeCall(node) ? node.arguments.at(-1) : undefined;
37
+ const step = last === undefined ? calleeOf(node, "Effect") : memberOf(last, "Effect");
38
+ return failureToValue.has(step ?? "");
39
+ };
40
+ const hasClosedErrorChannel = (node) => {
41
+ if (node?.type === "TSSatisfiesExpression")
42
+ return isClosedEffectType(node.typeAnnotation);
43
+ if (node?.type === "Identifier")
44
+ return isClosedEffectType(parameterType(node));
45
+ if (node?.type === "CallExpression")
46
+ return endsWithFailureToValue(node);
47
+ return false;
48
+ };
49
+ export const allErrorsHandledAtBoundaryRule = defineRule({
50
+ meta: {
51
+ type: "problem",
52
+ docs: {
53
+ description: "Close the error channel to `never` before running an Effect at the boundary. Test files are skipped.",
54
+ },
55
+ messages: {
56
+ openErrorChannel: "Close the error channel before running: `program satisfies Effect.Effect<A, never>`, or a runner parameter typed `Effect.Effect<A, never, never>`.",
57
+ },
58
+ schema: [
59
+ {
60
+ type: "object",
61
+ properties: { testFiles: filePatternsSchema },
62
+ additionalProperties: false,
63
+ },
64
+ ],
65
+ },
66
+ createOnce(context) {
67
+ const frames = [];
68
+ let inTestFile = false;
69
+ const enter = (fn) => {
70
+ frames.push({ fn, rendersJsx: false, openRuns: [] });
71
+ };
72
+ // The JSX can follow the run, so whether the function is a Server Component is known only on exit.
73
+ const exit = () => {
74
+ const frame = frames.pop();
75
+ if (frame === undefined || isServerComponent(frame.fn, frame.rendersJsx))
76
+ return;
77
+ for (const node of frame.openRuns)
78
+ context.report({ node, messageId: "openErrorChannel" });
79
+ };
80
+ const markJsx = () => {
81
+ const frame = frames.at(-1);
82
+ if (frame !== undefined)
83
+ frame.rendersJsx = true;
84
+ };
85
+ return {
86
+ // A test is not a boundary: a rejected run fails it with the error, which is the point.
87
+ before() {
88
+ inTestFile = isTestFile(context);
89
+ },
90
+ ArrowFunctionExpression: enter,
91
+ FunctionExpression: enter,
92
+ FunctionDeclaration: enter,
93
+ "ArrowFunctionExpression:exit": exit,
94
+ "FunctionExpression:exit": exit,
95
+ "FunctionDeclaration:exit": exit,
96
+ JSXElement: markJsx,
97
+ JSXFragment: markJsx,
98
+ CallExpression(node) {
99
+ if (inTestFile)
100
+ return;
101
+ if (!isRunnerCall(node) || hasClosedErrorChannel(node.arguments[0]))
102
+ return;
103
+ const frame = frames.at(-1);
104
+ if (frame === undefined)
105
+ context.report({ node, messageId: "openErrorChannel" });
106
+ else
107
+ frame.openRuns.push(node);
108
+ },
109
+ };
110
+ },
111
+ });
@@ -0,0 +1 @@
1
+ export declare const antiSlopNoManualEffectErrorTagRule: import("@oxlint/plugins").Rule;
@@ -0,0 +1,32 @@
1
+ import { defineRule } from "@oxlint/plugins";
2
+ import { isInsideBroadEffectHandler, isReasonTagMember, isTagMember, tagMemberFromComparison, } from "../shared/tagged-values.js";
3
+ export const antiSlopNoManualEffectErrorTagRule = defineRule({
4
+ meta: {
5
+ type: "problem",
6
+ docs: {
7
+ description: "Use Effect tagged error handlers instead of manually branching on `_tag` in a catch handler.",
8
+ },
9
+ messages: {
10
+ tag: "Use Effect.catchTag or Effect.catchTags instead of manually discriminating a tagged error in a broad Effect catch handler.",
11
+ reason: "Use Effect.catchReason or Effect.catchReasons instead of manually discriminating a tagged `reason` in a broad Effect catch handler.",
12
+ },
13
+ },
14
+ createOnce(context) {
15
+ return {
16
+ BinaryExpression(node) {
17
+ const tagMember = tagMemberFromComparison(node);
18
+ if (tagMember === undefined || !isInsideBroadEffectHandler(node))
19
+ return;
20
+ context.report({ node, messageId: isReasonTagMember(tagMember) ? "reason" : "tag" });
21
+ },
22
+ SwitchStatement(node) {
23
+ if (!isTagMember(node.discriminant) || !isInsideBroadEffectHandler(node))
24
+ return;
25
+ context.report({
26
+ node,
27
+ messageId: isReasonTagMember(node.discriminant) ? "reason" : "tag",
28
+ });
29
+ },
30
+ };
31
+ },
32
+ });
@@ -0,0 +1 @@
1
+ export declare const antiSlopNoServiceConstructorImportsRule: import("@oxlint/plugins").Rule;
@@ -0,0 +1,49 @@
1
+ import { defineRule } from "@oxlint/plugins";
2
+ import { filePatternsSchema } from "../shared/file-globs.js";
3
+ import { isTestFile } from "../shared/test-files.js";
4
+ const serviceConstructorName = /^make[A-Z]/u;
5
+ const isProjectLocalImport = (source) => source.startsWith("./") || source.startsWith("../");
6
+ const importedName = (specifier) => specifier.imported.type === "Identifier" ? specifier.imported.name : specifier.imported.value;
7
+ export const antiSlopNoServiceConstructorImportsRule = defineRule({
8
+ meta: {
9
+ type: "problem",
10
+ docs: {
11
+ description: "Disallow project-local make<CapabilityName> imports outside test files.",
12
+ },
13
+ messages: {
14
+ serviceConstructorImport: 'Do not import Effect service constructor "{{name}}" into runtime code. Import the owning Layer, yield the contextual service, and allow its requirements to propagate to the composition root.',
15
+ },
16
+ schema: [
17
+ {
18
+ type: "object",
19
+ properties: { testFiles: filePatternsSchema },
20
+ additionalProperties: false,
21
+ },
22
+ ],
23
+ },
24
+ createOnce(context) {
25
+ let inTestFile = false;
26
+ return {
27
+ // Tests build services from their constructors to inject fakes.
28
+ before() {
29
+ inTestFile = isTestFile(context);
30
+ },
31
+ ImportDeclaration(node) {
32
+ if (inTestFile || !isProjectLocalImport(node.source.value))
33
+ return;
34
+ for (const specifier of node.specifiers) {
35
+ if (specifier.type !== "ImportSpecifier")
36
+ continue;
37
+ const name = importedName(specifier);
38
+ if (!serviceConstructorName.test(name))
39
+ continue;
40
+ context.report({
41
+ node: specifier,
42
+ messageId: "serviceConstructorImport",
43
+ data: { name },
44
+ });
45
+ }
46
+ },
47
+ };
48
+ },
49
+ });
@@ -0,0 +1 @@
1
+ export declare const antiSlopPreferEffectMatchRule: import("@oxlint/plugins").Rule;
@@ -0,0 +1,45 @@
1
+ import { defineRule } from "@oxlint/plugins";
2
+ import { isEqualityComparison } from "../shared/tagged-values.js";
3
+ const isLiteral = (node) => node.type === "Literal" || (node.type === "TemplateLiteral" && node.expressions.length === 0);
4
+ export const antiSlopPreferEffectMatchRule = defineRule({
5
+ meta: {
6
+ type: "problem",
7
+ docs: {
8
+ description: "Use Match from Effect for chained literal ternaries over the same value.",
9
+ },
10
+ messages: {
11
+ preferMatch: "Use Match from Effect instead of a chained literal ternary.",
12
+ },
13
+ },
14
+ createOnce(context) {
15
+ // The compared value is identified by its source text: no type or binding analysis.
16
+ const comparedValue = (node) => {
17
+ if (!isEqualityComparison(node))
18
+ return undefined;
19
+ if (isLiteral(node.left))
20
+ return context.sourceCode.getText(node.right);
21
+ if (isLiteral(node.right))
22
+ return context.sourceCode.getText(node.left);
23
+ return undefined;
24
+ };
25
+ return {
26
+ ConditionalExpression(node) {
27
+ if (node.parent?.type === "ConditionalExpression")
28
+ return;
29
+ const value = comparedValue(node.test);
30
+ if (value === undefined)
31
+ return;
32
+ let alternate = node.alternate;
33
+ let literalChecks = 1;
34
+ while (alternate.type === "ConditionalExpression") {
35
+ if (comparedValue(alternate.test) !== value)
36
+ return;
37
+ literalChecks += 1;
38
+ alternate = alternate.alternate;
39
+ }
40
+ if (literalChecks > 1)
41
+ context.report({ node, messageId: "preferMatch" });
42
+ },
43
+ };
44
+ },
45
+ });
@@ -0,0 +1 @@
1
+ export declare const errorHandlingInEffectFnHandlerRule: import("@oxlint/plugins").Rule;
@@ -0,0 +1,144 @@
1
+ import { defineRule } from "@oxlint/plugins";
2
+ import { isEffectGenerator, isInsideEffectGeneratorBody, memberOf, nearestFunction, } from "../shared/effect-ast.js";
3
+ import { filePatternsSchema } from "../shared/file-globs.js";
4
+ import { isTestFile } from "../shared/test-files.js";
5
+ // mapError, orElse and orElseSucceed stay allowed: they name a failure or give an alternative
6
+ // without opening a handling branch inside the success path.
7
+ const errorHandlers = new Set([
8
+ "catch",
9
+ "catchAll",
10
+ "catchCause",
11
+ "catchCauseFilter",
12
+ "catchCauseIf",
13
+ "catchDefect",
14
+ "catchEager",
15
+ "catchFilter",
16
+ "catchIf",
17
+ "catchReason",
18
+ "catchReasons",
19
+ "catchTag",
20
+ "catchTags",
21
+ "either",
22
+ "exit",
23
+ "match",
24
+ "matchCause",
25
+ "matchCauseEffect",
26
+ "matchEffect",
27
+ "result",
28
+ "sandbox",
29
+ ]);
30
+ // Effect 4 names first, then the v3 ones without a v4 counterpart.
31
+ const iterationCombinators = new Set([
32
+ "filter",
33
+ "filterMap",
34
+ "filterMapEffect",
35
+ "findFirst",
36
+ "findFirstFilter",
37
+ "forEach",
38
+ "partition",
39
+ "reduce",
40
+ "validate",
41
+ "dropUntil",
42
+ "dropWhile",
43
+ "every",
44
+ "exists",
45
+ "mergeAll",
46
+ "reduceWhile",
47
+ "takeUntil",
48
+ "takeWhile",
49
+ "validateAll",
50
+ "validateFirst",
51
+ ]);
52
+ const failureCatchers = new Set([
53
+ "catch",
54
+ "catchAll",
55
+ "catchFilter",
56
+ "catchIf",
57
+ "catchTag",
58
+ "catchTags",
59
+ ]);
60
+ const isEffectDie = (node) => ["die", "dieMessage"].includes(memberOf(node, "Effect") ?? "");
61
+ const returnedExpression = (fn) => {
62
+ if (fn.body?.type !== "BlockStatement")
63
+ return fn.body;
64
+ const [statement, ...rest] = fn.body.body;
65
+ return rest.length === 0 && statement?.type === "ReturnStatement" ? statement.argument : null;
66
+ };
67
+ const onlyDies = (handler) => {
68
+ if (isEffectDie(handler))
69
+ return true;
70
+ if (handler.type !== "ArrowFunctionExpression" && handler.type !== "FunctionExpression") {
71
+ return false;
72
+ }
73
+ const returned = returnedExpression(handler);
74
+ return returned?.type === "CallExpression" && isEffectDie(returned.callee);
75
+ };
76
+ // Turning a failure into a defect is a selective `orDie`, which stays allowed: it declares the case
77
+ // untracked rather than handling it. The handler is the last argument in both the pipeable and the
78
+ // data-first forms; `catchTags` takes an object of handlers and is exempt only when all of them die.
79
+ const isDieOnlyCatch = (node, name) => {
80
+ const call = node.parent;
81
+ if (call?.type !== "CallExpression" || call.callee !== node || !failureCatchers.has(name)) {
82
+ return false;
83
+ }
84
+ const handler = call.arguments.at(-1);
85
+ if (handler === undefined)
86
+ return false;
87
+ if (name !== "catchTags")
88
+ return onlyDies(handler);
89
+ return (handler.type === "ObjectExpression" &&
90
+ handler.properties.length > 0 &&
91
+ handler.properties.every((property) => property.type === "Property" && onlyDies(property.value)));
92
+ };
93
+ // A handler in the callback of an iteration (`Effect.forEach(items, (item) => ... Effect.catch ...)`)
94
+ // handles each element on its own; moved to the `Effect.fn` pipeables, the first failure would stop
95
+ // them all. Other callbacks (`flatMap`, `tap`) stay on the success path and are still reported.
96
+ const isInsideIterationCallback = (node) => {
97
+ for (let fn = nearestFunction(node); fn && !isEffectGenerator(fn); fn = nearestFunction(fn)) {
98
+ const call = fn.parent;
99
+ if (call?.type === "CallExpression" &&
100
+ iterationCombinators.has(memberOf(call.callee, "Effect") ?? "")) {
101
+ return true;
102
+ }
103
+ }
104
+ return false;
105
+ };
106
+ export const errorHandlingInEffectFnHandlerRule = defineRule({
107
+ meta: {
108
+ type: "suggestion",
109
+ docs: {
110
+ description: "Keep only the success path in Effect generators and handle errors in the second argument of `Effect.fn`. Test files are skipped.",
111
+ },
112
+ messages: {
113
+ 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`.",
114
+ },
115
+ schema: [
116
+ {
117
+ type: "object",
118
+ properties: { testFiles: filePatternsSchema },
119
+ additionalProperties: false,
120
+ },
121
+ ],
122
+ },
123
+ createOnce(context) {
124
+ let inTestFile = false;
125
+ return {
126
+ // In a test the failure is the observed outcome, not a branch of the success path.
127
+ before() {
128
+ inTestFile = isTestFile(context);
129
+ },
130
+ MemberExpression(node) {
131
+ const name = memberOf(node, "Effect");
132
+ if (inTestFile ||
133
+ name === undefined ||
134
+ !errorHandlers.has(name) ||
135
+ !isInsideEffectGeneratorBody(node) ||
136
+ isInsideIterationCallback(node) ||
137
+ isDieOnlyCatch(node, name)) {
138
+ return;
139
+ }
140
+ context.report({ node, messageId: "handlerInGenerator", data: { name } });
141
+ },
142
+ };
143
+ },
144
+ });
@@ -0,0 +1 @@
1
+ export declare const exportedSchemaIdentifierRule: import("@oxlint/plugins").Rule;