@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.
- package/README.md +106 -82
- package/RULES.md +527 -382
- package/dist/index.d.ts +3 -0
- package/dist/index.js +13 -8
- package/dist/presets/all.json +5 -4
- package/dist/rules/adapter-import-boundary.js +5 -12
- package/dist/rules/all-errors-handled-at-boundary.d.ts +1 -0
- package/dist/rules/all-errors-handled-at-boundary.js +105 -0
- package/dist/rules/anti-slop-no-service-constructor-imports.js +1 -8
- package/dist/rules/exported-schema-identifier.js +15 -10
- package/dist/rules/no-effect-promise.js +1 -8
- package/dist/rules/no-manual-discriminant-comparison.js +36 -19
- package/dist/rules/no-manual-discriminant-construction.js +50 -14
- package/dist/rules/no-provide-on-definition.js +31 -9
- package/dist/rules/no-throw-or-primitive-failure.d.ts +1 -0
- package/dist/rules/{no-bare-error-in-effect.js → no-throw-or-primitive-failure.js} +11 -3
- package/dist/rules/no-yielded-match.js +9 -2
- package/dist/rules/prefer-effect-vitest.d.ts +1 -0
- package/dist/rules/prefer-effect-vitest.js +45 -0
- package/dist/rules/provide-named-layer.js +1 -8
- package/dist/rules/redacted-secret-names.js +18 -7
- package/dist/rules/run-only-in-runner.d.ts +1 -0
- package/dist/rules/run-only-in-runner.js +33 -0
- package/dist/rules/service-key-matches-name.js +15 -6
- package/dist/rules/service-port-shape-only.js +10 -18
- package/dist/rules/single-style-per-function.js +82 -21
- package/dist/rules/split-success-path-from-error-handling.d.ts +1 -0
- package/dist/rules/split-success-path-from-error-handling.js +137 -0
- package/dist/shared/effect-ast.d.ts +1 -0
- package/dist/shared/effect-ast.js +15 -4
- package/dist/shared/effect-runners.d.ts +2 -0
- package/dist/shared/effect-runners.js +14 -0
- package/dist/shared/entry-files.d.ts +2 -2
- package/dist/shared/file-globs.d.ts +3 -9
- package/dist/shared/file-globs.js +3 -12
- package/dist/shared/settings.d.ts +5 -0
- package/dist/shared/settings.js +26 -0
- package/dist/shared/tagged-values.d.ts +2 -8
- package/dist/shared/tagged-values.js +3 -14
- package/dist/shared/test-files.d.ts +2 -2
- package/package.json +61 -54
- package/dist/rules/boundary-entrypoint-shape.d.ts +0 -1
- package/dist/rules/boundary-entrypoint-shape.js +0 -52
- package/dist/rules/error-handling-in-effect-fn-handler.d.ts +0 -1
- package/dist/rules/error-handling-in-effect-fn-handler.js +0 -66
- package/dist/rules/no-bare-error-in-effect.d.ts +0 -1
- package/dist/rules/run-only-at-entry.d.ts +0 -1
- package/dist/rules/run-only-at-entry.js +0 -50
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
export { defaultEntryFiles } from "./shared/entry-files.ts";
|
|
2
|
+
export { defaultDiscriminants } from "./shared/tagged-values.ts";
|
|
3
|
+
export { defaultTestFiles } from "./shared/test-files.ts";
|
|
1
4
|
export declare const pluginName = "dl-effect";
|
|
2
5
|
export declare const pluginPath: string;
|
|
3
6
|
declare const dlEffectPlugin: import("@oxlint/plugins").Plugin;
|
package/dist/index.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
2
|
import { eslintCompatPlugin } from "@oxlint/plugins";
|
|
3
3
|
import { adapterImportBoundaryRule } from "./rules/adapter-import-boundary.js";
|
|
4
|
+
import { allErrorsHandledAtBoundaryRule } from "./rules/all-errors-handled-at-boundary.js";
|
|
4
5
|
import { antiSlopNoManualEffectErrorTagRule } from "./rules/anti-slop-no-manual-effect-error-tag.js";
|
|
5
6
|
import { antiSlopNoServiceConstructorImportsRule } from "./rules/anti-slop-no-service-constructor-imports.js";
|
|
6
7
|
import { antiSlopPreferEffectMatchRule } from "./rules/anti-slop-prefer-effect-match.js";
|
|
7
|
-
import { boundaryEntrypointShapeRule } from "./rules/boundary-entrypoint-shape.js";
|
|
8
|
-
import { errorHandlingInEffectFnHandlerRule } from "./rules/error-handling-in-effect-fn-handler.js";
|
|
9
8
|
import { exportedSchemaIdentifierRule } from "./rules/exported-schema-identifier.js";
|
|
10
9
|
import { noAsymmetricMatchHandlersRule } from "./rules/no-asymmetric-match-handlers.js";
|
|
11
|
-
import { noBareErrorInEffectRule } from "./rules/no-bare-error-in-effect.js";
|
|
12
10
|
import { noEffectPromiseRule } from "./rules/no-effect-promise.js";
|
|
13
11
|
import { noEffectReturnTypeRule } from "./rules/no-effect-return-type.js";
|
|
14
12
|
import { noInlineSchemaCodecRule } from "./rules/no-inline-schema-codec.js";
|
|
@@ -18,18 +16,24 @@ import { noManualDiscriminantConstructionRule } from "./rules/no-manual-discrimi
|
|
|
18
16
|
import { noNestedMatchRule } from "./rules/no-nested-match.js";
|
|
19
17
|
import { noParameterlessCatchAllRule } from "./rules/no-parameterless-catch-all.js";
|
|
20
18
|
import { noProvideOnDefinitionRule } from "./rules/no-provide-on-definition.js";
|
|
19
|
+
import { noThrowOrPrimitiveFailureRule } from "./rules/no-throw-or-primitive-failure.js";
|
|
21
20
|
import { noTransportTypesInGeneratorRule } from "./rules/no-transport-types-in-generator.js";
|
|
22
21
|
import { noYieldedMatchRule } from "./rules/no-yielded-match.js";
|
|
23
22
|
import { preferEffectFnRule } from "./rules/prefer-effect-fn.js";
|
|
23
|
+
import { preferEffectVitestRule } from "./rules/prefer-effect-vitest.js";
|
|
24
24
|
import { provideNamedLayerRule } from "./rules/provide-named-layer.js";
|
|
25
25
|
import { redactedSecretNamesRule } from "./rules/redacted-secret-names.js";
|
|
26
|
-
import {
|
|
26
|
+
import { runOnlyInRunnerRule } from "./rules/run-only-in-runner.js";
|
|
27
27
|
import { serviceBuiltWithOfRule } from "./rules/service-built-with-of.js";
|
|
28
28
|
import { serviceKeyMatchesNameRule } from "./rules/service-key-matches-name.js";
|
|
29
29
|
import { servicePortShapeOnlyRule } from "./rules/service-port-shape-only.js";
|
|
30
30
|
import { singleStylePerFunctionRule } from "./rules/single-style-per-function.js";
|
|
31
|
+
import { splitSuccessPathFromErrorHandlingRule } from "./rules/split-success-path-from-error-handling.js";
|
|
31
32
|
import { tryObjectFormRule } from "./rules/try-object-form.js";
|
|
32
33
|
import { untracedRecursionRule } from "./rules/untraced-recursion.js";
|
|
34
|
+
export { defaultEntryFiles } from "./shared/entry-files.js";
|
|
35
|
+
export { defaultDiscriminants } from "./shared/tagged-values.js";
|
|
36
|
+
export { defaultTestFiles } from "./shared/test-files.js";
|
|
33
37
|
export const pluginName = "dl-effect";
|
|
34
38
|
// Resolved at runtime so it points at this file both from the sources and from `dist/`.
|
|
35
39
|
export const pluginPath = fileURLToPath(import.meta.url);
|
|
@@ -40,11 +44,11 @@ const dlEffectPlugin = eslintCompatPlugin({
|
|
|
40
44
|
"anti-slop-no-service-constructor-imports": antiSlopNoServiceConstructorImportsRule,
|
|
41
45
|
"anti-slop-prefer-effect-match": antiSlopPreferEffectMatchRule,
|
|
42
46
|
"adapter-import-boundary": adapterImportBoundaryRule,
|
|
43
|
-
"
|
|
44
|
-
"
|
|
47
|
+
"all-errors-handled-at-boundary": allErrorsHandledAtBoundaryRule,
|
|
48
|
+
"split-success-path-from-error-handling": splitSuccessPathFromErrorHandlingRule,
|
|
45
49
|
"exported-schema-identifier": exportedSchemaIdentifierRule,
|
|
46
50
|
"no-asymmetric-match-handlers": noAsymmetricMatchHandlersRule,
|
|
47
|
-
"no-
|
|
51
|
+
"no-throw-or-primitive-failure": noThrowOrPrimitiveFailureRule,
|
|
48
52
|
"no-effect-promise": noEffectPromiseRule,
|
|
49
53
|
"no-effect-return-type": noEffectReturnTypeRule,
|
|
50
54
|
"no-inline-schema-codec": noInlineSchemaCodecRule,
|
|
@@ -57,9 +61,10 @@ const dlEffectPlugin = eslintCompatPlugin({
|
|
|
57
61
|
"no-transport-types-in-generator": noTransportTypesInGeneratorRule,
|
|
58
62
|
"no-yielded-match": noYieldedMatchRule,
|
|
59
63
|
"prefer-effect-fn": preferEffectFnRule,
|
|
64
|
+
"prefer-effect-vitest": preferEffectVitestRule,
|
|
60
65
|
"provide-named-layer": provideNamedLayerRule,
|
|
61
66
|
"redacted-secret-names": redactedSecretNamesRule,
|
|
62
|
-
"run-only-
|
|
67
|
+
"run-only-in-runner": runOnlyInRunnerRule,
|
|
63
68
|
"service-built-with-of": serviceBuiltWithOfRule,
|
|
64
69
|
"service-key-matches-name": serviceKeyMatchesNameRule,
|
|
65
70
|
"service-port-shape-only": servicePortShapeOnlyRule,
|
package/dist/presets/all.json
CHANGED
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
"dl-effect/anti-slop-no-service-constructor-imports": "warn",
|
|
8
8
|
"dl-effect/anti-slop-prefer-effect-match": "warn",
|
|
9
9
|
"dl-effect/adapter-import-boundary": "warn",
|
|
10
|
-
"dl-effect/
|
|
11
|
-
"dl-effect/
|
|
10
|
+
"dl-effect/all-errors-handled-at-boundary": "warn",
|
|
11
|
+
"dl-effect/split-success-path-from-error-handling": "warn",
|
|
12
12
|
"dl-effect/exported-schema-identifier": "warn",
|
|
13
13
|
"dl-effect/no-asymmetric-match-handlers": "warn",
|
|
14
|
-
"dl-effect/no-
|
|
14
|
+
"dl-effect/no-throw-or-primitive-failure": "warn",
|
|
15
15
|
"dl-effect/no-effect-promise": "warn",
|
|
16
16
|
"dl-effect/no-effect-return-type": "warn",
|
|
17
17
|
"dl-effect/no-inline-schema-codec": "warn",
|
|
@@ -24,9 +24,10 @@
|
|
|
24
24
|
"dl-effect/no-transport-types-in-generator": "warn",
|
|
25
25
|
"dl-effect/no-yielded-match": "warn",
|
|
26
26
|
"dl-effect/prefer-effect-fn": "warn",
|
|
27
|
+
"dl-effect/prefer-effect-vitest": "warn",
|
|
27
28
|
"dl-effect/provide-named-layer": "warn",
|
|
28
29
|
"dl-effect/redacted-secret-names": "warn",
|
|
29
|
-
"dl-effect/run-only-
|
|
30
|
+
"dl-effect/run-only-in-runner": "warn",
|
|
30
31
|
"dl-effect/service-built-with-of": "warn",
|
|
31
32
|
"dl-effect/service-key-matches-name": "warn",
|
|
32
33
|
"dl-effect/service-port-shape-only": "warn",
|
|
@@ -2,7 +2,6 @@ import { readdirSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { dirname, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { defineRule } from "@oxlint/plugins";
|
|
4
4
|
import { isEntryFile } from "../shared/entry-files.js";
|
|
5
|
-
import { filePatternsSchema } from "../shared/file-globs.js";
|
|
6
5
|
import { isTestFile } from "../shared/test-files.js";
|
|
7
6
|
// Only the APIs that build a service implementation, so composition of existing layers
|
|
8
7
|
// (`mergeAll`, `provide`, `merge`) leaves the entry point out. Longest names first: the
|
|
@@ -28,7 +27,10 @@ const LAYER_CONSTRUCTORS = [
|
|
|
28
27
|
];
|
|
29
28
|
// Like the other rules, names are matched literally: `import * as L from "effect/Layer"` escapes.
|
|
30
29
|
const LAYER_CONSTRUCTION = new RegExp(`\\bLayer\\.(?:${LAYER_CONSTRUCTORS.join("|")})\\s*\\(`, "u");
|
|
31
|
-
|
|
30
|
+
// An Effect 3 `Effect.Service` always carries its implementation and the `Default` layer built
|
|
31
|
+
// from it, so a file that declares one is an adapter even without a `Layer.*` call.
|
|
32
|
+
const EFFECT_SERVICE = /\bEffect\.Service\s*[<(]/u;
|
|
33
|
+
export const buildsLayer = (source) => LAYER_CONSTRUCTION.test(source) || EFFECT_SERVICE.test(source);
|
|
32
34
|
// Test files are skipped so a fixture layer cannot promote a domain directory to an adapter.
|
|
33
35
|
const isSource = (entry) => entry.endsWith(".ts") && !entry.endsWith(".test.ts");
|
|
34
36
|
const buildsLayerIn = (dir) => {
|
|
@@ -64,16 +66,7 @@ export const makeAdapterImportBoundaryRule = (deps) => defineRule({
|
|
|
64
66
|
adapterOutsideEntry: "`{{adapter}}` is an adapter: depend on its port instead and let the entry point provide the Layer.",
|
|
65
67
|
crossAdapter: "`{{adapter}}` is another adapter: depend on its port instead and let the entry point compose the Layers.",
|
|
66
68
|
},
|
|
67
|
-
schema: [
|
|
68
|
-
{
|
|
69
|
-
type: "object",
|
|
70
|
-
properties: {
|
|
71
|
-
entryFiles: filePatternsSchema,
|
|
72
|
-
testFiles: filePatternsSchema,
|
|
73
|
-
},
|
|
74
|
-
additionalProperties: false,
|
|
75
|
-
},
|
|
76
|
-
],
|
|
69
|
+
schema: [],
|
|
77
70
|
},
|
|
78
71
|
create(context) {
|
|
79
72
|
const ownDir = dirname(context.filename);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const allErrorsHandledAtBoundaryRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { calleeOf, isServerComponent, memberOf, nearestFunction, } from "../shared/effect-ast.js";
|
|
3
|
+
import { isTestFile } from "../shared/test-files.js";
|
|
4
|
+
// The *Exit runners hand failures back as values, so an open error channel cannot leak there.
|
|
5
|
+
// `NodeRuntime.runMain`/`BunRuntime.runMain` are boundaries too: they log the Cause and exit with
|
|
6
|
+
// code 1, which is the right outcome for a process failing to start (a ConfigError, a busy port).
|
|
7
|
+
const effectRunners = new Set(["runPromise", "runSync", "runFork", "runCallback"]);
|
|
8
|
+
const isRunnerCall = (node) => effectRunners.has(calleeOf(node, "Effect") ?? "");
|
|
9
|
+
const isEffectTypeName = (name) => (name.type === "Identifier" && name.name === "Effect") ||
|
|
10
|
+
(name.type === "TSQualifiedName" &&
|
|
11
|
+
name.left.type === "Identifier" &&
|
|
12
|
+
name.left.name === "Effect" &&
|
|
13
|
+
name.right.name === "Effect");
|
|
14
|
+
const isClosedEffectType = (type) => type?.type === "TSTypeReference" &&
|
|
15
|
+
isEffectTypeName(type.typeName) &&
|
|
16
|
+
type.typeArguments?.params[1]?.type === "TSNeverKeyword";
|
|
17
|
+
const parameterType = (identifier) => {
|
|
18
|
+
for (let fn = nearestFunction(identifier); fn; fn = nearestFunction(fn)) {
|
|
19
|
+
for (const param of fn.params) {
|
|
20
|
+
if (param.type === "Identifier" && param.name === identifier.name) {
|
|
21
|
+
return param.typeAnnotation?.typeAnnotation;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
};
|
|
27
|
+
// They turn every failure into a success value, so whatever comes before cannot leak.
|
|
28
|
+
// `either` is the Effect 3 name of `result`.
|
|
29
|
+
const failureToValue = new Set(["result", "either", "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
|
+
createOnce(context) {
|
|
61
|
+
const frames = [];
|
|
62
|
+
let inTestFile = false;
|
|
63
|
+
const enter = (fn) => {
|
|
64
|
+
frames.push({ fn, rendersJsx: false, openRuns: [] });
|
|
65
|
+
};
|
|
66
|
+
// The JSX can follow the run, so whether the function is a Server Component is known only on exit.
|
|
67
|
+
const exit = () => {
|
|
68
|
+
const frame = frames.pop();
|
|
69
|
+
if (frame === undefined || isServerComponent(frame.fn, frame.rendersJsx))
|
|
70
|
+
return;
|
|
71
|
+
for (const node of frame.openRuns)
|
|
72
|
+
context.report({ node, messageId: "openErrorChannel" });
|
|
73
|
+
};
|
|
74
|
+
const markJsx = () => {
|
|
75
|
+
const frame = frames.at(-1);
|
|
76
|
+
if (frame !== undefined)
|
|
77
|
+
frame.rendersJsx = true;
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
// A test is not a boundary: a rejected run fails it with the error, which is the point.
|
|
81
|
+
before() {
|
|
82
|
+
inTestFile = isTestFile(context);
|
|
83
|
+
},
|
|
84
|
+
ArrowFunctionExpression: enter,
|
|
85
|
+
FunctionExpression: enter,
|
|
86
|
+
FunctionDeclaration: enter,
|
|
87
|
+
"ArrowFunctionExpression:exit": exit,
|
|
88
|
+
"FunctionExpression:exit": exit,
|
|
89
|
+
"FunctionDeclaration:exit": exit,
|
|
90
|
+
JSXElement: markJsx,
|
|
91
|
+
JSXFragment: markJsx,
|
|
92
|
+
CallExpression(node) {
|
|
93
|
+
if (inTestFile)
|
|
94
|
+
return;
|
|
95
|
+
if (!isRunnerCall(node) || hasClosedErrorChannel(node.arguments[0]))
|
|
96
|
+
return;
|
|
97
|
+
const frame = frames.at(-1);
|
|
98
|
+
if (frame === undefined)
|
|
99
|
+
context.report({ node, messageId: "openErrorChannel" });
|
|
100
|
+
else
|
|
101
|
+
frame.openRuns.push(node);
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
});
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { defineRule } from "@oxlint/plugins";
|
|
2
|
-
import { filePatternsSchema } from "../shared/file-globs.js";
|
|
3
2
|
import { isTestFile } from "../shared/test-files.js";
|
|
4
3
|
const serviceConstructorName = /^make[A-Z]/u;
|
|
5
4
|
const isProjectLocalImport = (source) => source.startsWith("./") || source.startsWith("../");
|
|
@@ -13,13 +12,7 @@ export const antiSlopNoServiceConstructorImportsRule = defineRule({
|
|
|
13
12
|
messages: {
|
|
14
13
|
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
14
|
},
|
|
16
|
-
schema: [
|
|
17
|
-
{
|
|
18
|
-
type: "object",
|
|
19
|
-
properties: { testFiles: filePatternsSchema },
|
|
20
|
-
additionalProperties: false,
|
|
21
|
-
},
|
|
22
|
-
],
|
|
15
|
+
schema: [],
|
|
23
16
|
},
|
|
24
17
|
createOnce(context) {
|
|
25
18
|
let inTestFile = false;
|
|
@@ -8,6 +8,13 @@ const chainedMethod = (node) => node.type === "CallExpression" &&
|
|
|
8
8
|
node.callee.property.type === "Identifier"
|
|
9
9
|
? { name: node.callee.property.name, receiver: node.callee.object, call: node }
|
|
10
10
|
: undefined;
|
|
11
|
+
// `annotations` is the Effect 3 name of `annotate`.
|
|
12
|
+
const isAnnotate = (name) => name === "annotate" || name === "annotations";
|
|
13
|
+
// One suggestion per version instead of detecting the installed one: the fix stays syntax only.
|
|
14
|
+
const annotateMethods = [
|
|
15
|
+
{ version: "v3", method: "annotations" },
|
|
16
|
+
{ version: "v4", method: "annotate" },
|
|
17
|
+
];
|
|
11
18
|
// `X.pipe(Schema.annotate(a))` and `X.annotate(a)`, in any order along the chain.
|
|
12
19
|
const annotationsOf = (init) => {
|
|
13
20
|
const annotations = [];
|
|
@@ -16,11 +23,11 @@ const annotationsOf = (init) => {
|
|
|
16
23
|
const link = chainedMethod(current);
|
|
17
24
|
if (link === undefined)
|
|
18
25
|
return undefined;
|
|
19
|
-
if (link.name
|
|
26
|
+
if (isAnnotate(link.name))
|
|
20
27
|
annotations.push(...link.call.arguments);
|
|
21
28
|
else if (link.name === "pipe") {
|
|
22
29
|
for (const argument of link.call.arguments) {
|
|
23
|
-
if (calleeOf(argument, "Schema")
|
|
30
|
+
if (isAnnotate(calleeOf(argument, "Schema")) &&
|
|
24
31
|
argument.type === "CallExpression") {
|
|
25
32
|
annotations.push(...argument.arguments);
|
|
26
33
|
}
|
|
@@ -58,7 +65,7 @@ export const exportedSchemaIdentifierRule = defineRule({
|
|
|
58
65
|
messages: {
|
|
59
66
|
missingIdentifier: 'Annotate `{{name}}` with `identifier: "{{name}}"`, so errors, JSON Schema and arbitraries name it.',
|
|
60
67
|
wrongIdentifier: 'The `identifier` of `{{name}}` is `{{identifier}}`: use `"{{name}}"`, the name the schema is imported by.',
|
|
61
|
-
addIdentifier: '
|
|
68
|
+
addIdentifier: '{{version}}: append `.{{method}}({ identifier: "{{name}}" })`.',
|
|
62
69
|
renameIdentifier: 'Use `"{{name}}"` as identifier.',
|
|
63
70
|
},
|
|
64
71
|
},
|
|
@@ -77,13 +84,11 @@ export const exportedSchemaIdentifierRule = defineRule({
|
|
|
77
84
|
node: id,
|
|
78
85
|
messageId: "missingIdentifier",
|
|
79
86
|
data: { name },
|
|
80
|
-
suggest:
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
},
|
|
86
|
-
],
|
|
87
|
+
suggest: annotateMethods.map(({ version, method }) => ({
|
|
88
|
+
messageId: "addIdentifier",
|
|
89
|
+
data: { name, version, method },
|
|
90
|
+
fix: (fixer) => fixer.insertTextAfter(init, `.${method}({ identifier: "${name}" })`),
|
|
91
|
+
})),
|
|
87
92
|
});
|
|
88
93
|
return;
|
|
89
94
|
}
|
|
@@ -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
|
// Argument index of the release step, where a promise that cannot reject is the normal case.
|
|
6
5
|
const releaseSlots = new Map([
|
|
@@ -30,13 +29,7 @@ export const noEffectPromiseRule = defineRule({
|
|
|
30
29
|
messages: {
|
|
31
30
|
rejectionAsDefect: "`Effect.promise` turns a rejection into a defect: use `Effect.tryPromise({ try, catch })` unless the promise cannot reject.",
|
|
32
31
|
},
|
|
33
|
-
schema: [
|
|
34
|
-
{
|
|
35
|
-
type: "object",
|
|
36
|
-
properties: { testFiles: filePatternsSchema },
|
|
37
|
-
additionalProperties: false,
|
|
38
|
-
},
|
|
39
|
-
],
|
|
32
|
+
schema: [],
|
|
40
33
|
},
|
|
41
34
|
createOnce(context) {
|
|
42
35
|
let inTestFile = false;
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import { defineRule } from "@oxlint/plugins";
|
|
2
|
-
import { configuredDiscriminants, discriminantOf,
|
|
2
|
+
import { configuredDiscriminants, discriminantOf, isEqualityComparison, isInsideBroadEffectHandler, isStringLiteral, } from "../shared/tagged-values.js";
|
|
3
3
|
export const remedyFor = (discriminant) => discriminant === "_tag"
|
|
4
4
|
? "`Match.tagsExhaustive`, the tagged enum `$match` or an exhaustive ts-pattern match; for a single check, `Predicate.isTagged`"
|
|
5
5
|
: `\`Match.discriminatorsExhaustive("${discriminant}")\` or an exhaustive ts-pattern match`;
|
|
6
|
+
// Effect's own two-variant types cannot gain a variant: their refinements are the remedy.
|
|
7
|
+
const effectVariants = {
|
|
8
|
+
Success: { owner: "`Result`/`Exit`", guard: "`Result.isSuccess` or `Exit.isSuccess`" },
|
|
9
|
+
Failure: { owner: "`Result`/`Exit`", guard: "`Result.isFailure` or `Exit.isFailure`" },
|
|
10
|
+
Left: { owner: "`Either`", guard: "`Either.isLeft`" },
|
|
11
|
+
Right: { owner: "`Either`", guard: "`Either.isRight`" },
|
|
12
|
+
Some: { owner: "`Option`", guard: "`Option.isSome`" },
|
|
13
|
+
None: { owner: "`Option`", guard: "`Option.isNone`" },
|
|
14
|
+
};
|
|
15
|
+
const effectVariantOf = (tag) => Object.hasOwn(effectVariants, tag) ? { tag, ...effectVariants[tag] } : undefined;
|
|
6
16
|
export const noManualDiscriminantComparisonRule = defineRule({
|
|
7
17
|
meta: {
|
|
8
18
|
type: "suggestion",
|
|
@@ -11,42 +21,49 @@ export const noManualDiscriminantComparisonRule = defineRule({
|
|
|
11
21
|
},
|
|
12
22
|
messages: {
|
|
13
23
|
manualComparison: "Comparing `{{discriminant}}` with a literal still compiles when a variant is added: use {{remedy}}.",
|
|
24
|
+
effectVariantComparison: 'For the `"{{tag}}"` variant of {{owner}}, use {{guard}}; for a union of your own, {{remedy}}.',
|
|
14
25
|
manualSwitch: "A `switch` on `{{discriminant}}` still compiles when a variant is added: use {{remedy}}.",
|
|
15
26
|
},
|
|
16
|
-
schema: [
|
|
17
|
-
{
|
|
18
|
-
type: "object",
|
|
19
|
-
properties: { discriminants: discriminantsSchema },
|
|
20
|
-
additionalProperties: false,
|
|
21
|
-
},
|
|
22
|
-
],
|
|
27
|
+
schema: [],
|
|
23
28
|
},
|
|
24
29
|
createOnce(context) {
|
|
25
30
|
let discriminants = new Set();
|
|
26
|
-
const report = (node, discriminant, messageId) => {
|
|
31
|
+
const report = (node, discriminant, messageId, literal) => {
|
|
27
32
|
// Inside `Effect.catch` handlers, `_tag` belongs to `anti-slop-no-manual-effect-error-tag`.
|
|
28
33
|
if (discriminant === "_tag" && isInsideBroadEffectHandler(node))
|
|
29
34
|
return;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
const remedy = remedyFor(discriminant);
|
|
36
|
+
const variant = discriminant === "_tag" && literal !== undefined
|
|
37
|
+
? effectVariantOf(literal)
|
|
38
|
+
: undefined;
|
|
39
|
+
if (variant !== undefined) {
|
|
40
|
+
context.report({
|
|
41
|
+
node,
|
|
42
|
+
messageId: "effectVariantComparison",
|
|
43
|
+
data: { ...variant, remedy },
|
|
44
|
+
});
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
context.report({ node, messageId, data: { discriminant, remedy } });
|
|
35
48
|
};
|
|
36
49
|
return {
|
|
37
50
|
before() {
|
|
38
|
-
discriminants = configuredDiscriminants(context.
|
|
51
|
+
discriminants = configuredDiscriminants(context.settings);
|
|
39
52
|
},
|
|
40
53
|
BinaryExpression(node) {
|
|
41
54
|
if (!isEqualityComparison(node))
|
|
42
55
|
return;
|
|
43
|
-
const
|
|
44
|
-
?
|
|
56
|
+
const literal = isStringLiteral(node.right)
|
|
57
|
+
? node.right
|
|
45
58
|
: isStringLiteral(node.left)
|
|
46
|
-
?
|
|
59
|
+
? node.left
|
|
47
60
|
: undefined;
|
|
61
|
+
if (literal === undefined)
|
|
62
|
+
return;
|
|
63
|
+
const subject = literal === node.right ? node.left : node.right;
|
|
64
|
+
const discriminant = discriminantOf(subject, discriminants);
|
|
48
65
|
if (discriminant !== undefined)
|
|
49
|
-
report(node, discriminant, "manualComparison");
|
|
66
|
+
report(node, discriminant, "manualComparison", literal.value);
|
|
50
67
|
},
|
|
51
68
|
SwitchStatement(node) {
|
|
52
69
|
const discriminant = discriminantOf(node.discriminant, discriminants);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineRule } from "@oxlint/plugins";
|
|
2
|
-
import { calleeOf } from "../shared/effect-ast.js";
|
|
3
|
-
import { configuredDiscriminants,
|
|
2
|
+
import { calleeOf, isFunctionNode } from "../shared/effect-ast.js";
|
|
3
|
+
import { configuredDiscriminants, isStringLiteral, propertyName } from "../shared/tagged-values.js";
|
|
4
4
|
export const remedyFor = (discriminant) => discriminant === "_tag"
|
|
5
5
|
? "the Schema's tagged `.make`, the tagged class or error constructor, or the `Data.taggedEnum` variant constructor"
|
|
6
6
|
: "the Schema's `.make` or the class constructor";
|
|
@@ -29,8 +29,47 @@ const isSchemaCodecApplication = (call) => {
|
|
|
29
29
|
const method = calleeOf(call.callee, "Schema");
|
|
30
30
|
return method !== undefined && (method.startsWith("decode") || method.startsWith("encode"));
|
|
31
31
|
};
|
|
32
|
-
//
|
|
33
|
-
|
|
32
|
+
// `expect(x).toMatchObject(…)`, `expect(x).not.toEqual(…)`, `expect.objectContaining(…)`: the
|
|
33
|
+
// literal describes the expected shape, and a partial match has no constructor to call.
|
|
34
|
+
const isAssertionExpectation = (call) => {
|
|
35
|
+
if (call.callee.type !== "MemberExpression")
|
|
36
|
+
return false;
|
|
37
|
+
let link = call.callee;
|
|
38
|
+
while (link.type === "MemberExpression" || link.type === "CallExpression") {
|
|
39
|
+
link = link.type === "MemberExpression" ? link.object : link.callee;
|
|
40
|
+
}
|
|
41
|
+
return link.type === "Identifier" && link.name === "expect";
|
|
42
|
+
};
|
|
43
|
+
// `JSON.stringify({ _tag: "NotReady" })`: the literal is the serialized form, which no constructor builds.
|
|
44
|
+
const isJsonSerialization = (call) => calleeOf(call, "JSON") === "stringify";
|
|
45
|
+
const declaredFunction = (name, scope) => {
|
|
46
|
+
for (let current = scope; current; current = current.upper) {
|
|
47
|
+
const definition = current.set.get(name)?.defs[0];
|
|
48
|
+
if (definition === undefined)
|
|
49
|
+
continue;
|
|
50
|
+
const declared = definition.node.type === "VariableDeclarator" ? definition.node.init : definition.node;
|
|
51
|
+
return isFunctionNode(declared) ? declared : undefined;
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
};
|
|
55
|
+
// `readinessAgainst(503, { _tag: "NotReady" })` with `readinessAgainst` declared in the file as
|
|
56
|
+
// `(status: number, body: unknown) => …`: the author declared the argument raw data, such as the
|
|
57
|
+
// body a fake HTTP server answers with. Without types, a function imported from elsewhere is not seen.
|
|
58
|
+
const isUnknownArgument = (call, argument, sourceCode) => {
|
|
59
|
+
if (call.callee.type !== "Identifier")
|
|
60
|
+
return false;
|
|
61
|
+
const index = call.arguments.findIndex((candidate) => candidate === argument);
|
|
62
|
+
if (call.arguments.slice(0, index).some((candidate) => candidate.type === "SpreadElement")) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
const fn = declaredFunction(call.callee.name, sourceCode.getScope(call));
|
|
66
|
+
const parameter = fn?.params[index];
|
|
67
|
+
const binding = parameter?.type === "AssignmentPattern" ? parameter.left : parameter;
|
|
68
|
+
return (binding?.type === "Identifier" &&
|
|
69
|
+
binding.typeAnnotation?.typeAnnotation.type === "TSUnknownKeyword");
|
|
70
|
+
};
|
|
71
|
+
// A literal nested in a pattern, in an expectation or in raw input belongs to it as much as the outermost one.
|
|
72
|
+
const isPatternExpectationOrRawInput = (node, sourceCode) => {
|
|
34
73
|
let child = node;
|
|
35
74
|
let current = node.parent;
|
|
36
75
|
while (current?.type === "Property" ||
|
|
@@ -43,7 +82,10 @@ const isPatternOrCodecInput = (node) => {
|
|
|
43
82
|
current.arguments.some((argument) => argument === child) &&
|
|
44
83
|
(isMatchPatternCall(current) ||
|
|
45
84
|
isTsPatternWithCall(current) ||
|
|
46
|
-
|
|
85
|
+
isAssertionExpectation(current) ||
|
|
86
|
+
isSchemaCodecApplication(current) ||
|
|
87
|
+
isJsonSerialization(current) ||
|
|
88
|
+
isUnknownArgument(current, child, sourceCode)));
|
|
47
89
|
};
|
|
48
90
|
export const noManualDiscriminantConstructionRule = defineRule({
|
|
49
91
|
meta: {
|
|
@@ -54,19 +96,13 @@ export const noManualDiscriminantConstructionRule = defineRule({
|
|
|
54
96
|
messages: {
|
|
55
97
|
manualConstruction: "An object literal with a literal `{{discriminant}}` skips the variant's constructor: use {{remedy}}.",
|
|
56
98
|
},
|
|
57
|
-
schema: [
|
|
58
|
-
{
|
|
59
|
-
type: "object",
|
|
60
|
-
properties: { discriminants: discriminantsSchema },
|
|
61
|
-
additionalProperties: false,
|
|
62
|
-
},
|
|
63
|
-
],
|
|
99
|
+
schema: [],
|
|
64
100
|
},
|
|
65
101
|
createOnce(context) {
|
|
66
102
|
let discriminants = new Set();
|
|
67
103
|
return {
|
|
68
104
|
before() {
|
|
69
|
-
discriminants = configuredDiscriminants(context.
|
|
105
|
+
discriminants = configuredDiscriminants(context.settings);
|
|
70
106
|
},
|
|
71
107
|
ObjectExpression(node) {
|
|
72
108
|
for (const property of node.properties) {
|
|
@@ -75,7 +111,7 @@ export const noManualDiscriminantConstructionRule = defineRule({
|
|
|
75
111
|
const discriminant = propertyName(property);
|
|
76
112
|
if (discriminant === undefined || !discriminants.has(discriminant))
|
|
77
113
|
continue;
|
|
78
|
-
if (
|
|
114
|
+
if (isPatternExpectationOrRawInput(node, context.sourceCode))
|
|
79
115
|
return;
|
|
80
116
|
context.report({
|
|
81
117
|
node: property,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineRule } from "@oxlint/plugins";
|
|
2
2
|
import { calleeOf, isEffectFnApplication } from "../shared/effect-ast.js";
|
|
3
|
-
import {
|
|
3
|
+
import { runnerName } from "../shared/effect-runners.js";
|
|
4
4
|
import { isTestFile } from "../shared/test-files.js";
|
|
5
5
|
const pipeReceiver = (call) => {
|
|
6
6
|
const { callee } = call;
|
|
@@ -54,6 +54,32 @@ const providedDefinition = (provide) => {
|
|
|
54
54
|
return true;
|
|
55
55
|
return parent.arguments.includes(provide) && isProgramDefinition(pipeReceiver(parent));
|
|
56
56
|
};
|
|
57
|
+
const isRunnerCall = (node) => node?.type === "CallExpression" &&
|
|
58
|
+
node.callee.type === "MemberExpression" &&
|
|
59
|
+
runnerName(node.callee) !== undefined;
|
|
60
|
+
// The expression a `.pipe(…)` or a data-first `Effect.*` combinator builds around `node`.
|
|
61
|
+
const enclosingProgram = (node) => {
|
|
62
|
+
const { parent } = node;
|
|
63
|
+
if (parent?.type === "MemberExpression" && parent.object === node) {
|
|
64
|
+
const call = parent.parent;
|
|
65
|
+
return call?.type === "CallExpression" && pipeReceiver(call) === node ? call : undefined;
|
|
66
|
+
}
|
|
67
|
+
return parent?.type === "CallExpression" &&
|
|
68
|
+
parent.arguments.some((argument) => argument === node) &&
|
|
69
|
+
(pipeReceiver(parent) !== undefined || calleeOf(parent, "Effect") !== undefined) &&
|
|
70
|
+
!isRunnerCall(parent)
|
|
71
|
+
? parent
|
|
72
|
+
: undefined;
|
|
73
|
+
};
|
|
74
|
+
// A program handed straight to `Effect.run*` has no caller to wire it: it is being dispatched.
|
|
75
|
+
const isDispatchedInline = (provide) => {
|
|
76
|
+
let program = provide;
|
|
77
|
+
for (let outer = enclosingProgram(program); outer; outer = enclosingProgram(program)) {
|
|
78
|
+
program = outer;
|
|
79
|
+
}
|
|
80
|
+
const { parent } = program;
|
|
81
|
+
return isRunnerCall(parent) && parent.arguments.some((argument) => argument === program);
|
|
82
|
+
};
|
|
57
83
|
// A program that provides its own layers can no longer be wired differently by its caller:
|
|
58
84
|
// the dispatch that picks the adapters is where `Effect.provide` belongs.
|
|
59
85
|
export const noProvideOnDefinitionRule = defineRule({
|
|
@@ -65,13 +91,7 @@ export const noProvideOnDefinitionRule = defineRule({
|
|
|
65
91
|
messages: {
|
|
66
92
|
provideOnDefinition: "Define the program without layers and apply `Effect.provide` where it is dispatched, to a named program.",
|
|
67
93
|
},
|
|
68
|
-
schema: [
|
|
69
|
-
{
|
|
70
|
-
type: "object",
|
|
71
|
-
properties: { testFiles: filePatternsSchema },
|
|
72
|
-
additionalProperties: false,
|
|
73
|
-
},
|
|
74
|
-
],
|
|
94
|
+
schema: [],
|
|
75
95
|
},
|
|
76
96
|
createOnce(context) {
|
|
77
97
|
let inTestFile = false;
|
|
@@ -85,7 +105,9 @@ export const noProvideOnDefinitionRule = defineRule({
|
|
|
85
105
|
return;
|
|
86
106
|
// A Layer that captures its context with `Effect.context()` hands it back to each
|
|
87
107
|
// method: the caller already chose the adapters when it built the Layer.
|
|
88
|
-
if (providedDefinition(node) &&
|
|
108
|
+
if (providedDefinition(node) &&
|
|
109
|
+
!isCapturedContext(providedValue(node)) &&
|
|
110
|
+
!isDispatchedInline(node)) {
|
|
89
111
|
context.report({ node, messageId: "provideOnDefinition" });
|
|
90
112
|
}
|
|
91
113
|
},
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const noThrowOrPrimitiveFailureRule: import("@oxlint/plugins").Rule;
|