@doubleloop-io/oxlint-plugin-effect 0.0.0-stage → 0.1.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/LICENSE.anti-slop +21 -0
- package/README.md +210 -2
- package/RULES.md +911 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +71 -0
- package/dist/presets/all.d.ts +7 -0
- package/dist/presets/all.js +9 -0
- package/dist/presets/all.json +37 -0
- package/dist/rules/adapter-import-boundary.d.ts +7 -0
- package/dist/rules/adapter-import-boundary.js +103 -0
- package/dist/rules/anti-slop-no-manual-effect-error-tag.d.ts +1 -0
- package/dist/rules/anti-slop-no-manual-effect-error-tag.js +32 -0
- package/dist/rules/anti-slop-no-service-constructor-imports.d.ts +1 -0
- package/dist/rules/anti-slop-no-service-constructor-imports.js +49 -0
- package/dist/rules/anti-slop-prefer-effect-match.d.ts +1 -0
- package/dist/rules/anti-slop-prefer-effect-match.js +45 -0
- package/dist/rules/boundary-entrypoint-shape.d.ts +1 -0
- package/dist/rules/boundary-entrypoint-shape.js +52 -0
- package/dist/rules/error-handling-in-effect-fn-handler.d.ts +1 -0
- package/dist/rules/error-handling-in-effect-fn-handler.js +66 -0
- package/dist/rules/exported-schema-identifier.d.ts +1 -0
- package/dist/rules/exported-schema-identifier.js +117 -0
- package/dist/rules/no-asymmetric-match-handlers.d.ts +1 -0
- package/dist/rules/no-asymmetric-match-handlers.js +74 -0
- package/dist/rules/no-bare-error-in-effect.d.ts +1 -0
- package/dist/rules/no-bare-error-in-effect.js +34 -0
- package/dist/rules/no-effect-promise.d.ts +1 -0
- package/dist/rules/no-effect-promise.js +57 -0
- package/dist/rules/no-effect-return-type.d.ts +1 -0
- package/dist/rules/no-effect-return-type.js +130 -0
- package/dist/rules/no-inline-schema-codec.d.ts +1 -0
- package/dist/rules/no-inline-schema-codec.js +46 -0
- package/dist/rules/no-inline-yield-star.d.ts +1 -0
- package/dist/rules/no-inline-yield-star.js +230 -0
- package/dist/rules/no-manual-discriminant-comparison.d.ts +2 -0
- package/dist/rules/no-manual-discriminant-comparison.js +58 -0
- package/dist/rules/no-manual-discriminant-construction.d.ts +2 -0
- package/dist/rules/no-manual-discriminant-construction.js +90 -0
- package/dist/rules/no-nested-match.d.ts +1 -0
- package/dist/rules/no-nested-match.js +24 -0
- package/dist/rules/no-parameterless-catch-all.d.ts +1 -0
- package/dist/rules/no-parameterless-catch-all.js +29 -0
- package/dist/rules/no-provide-on-definition.d.ts +1 -0
- package/dist/rules/no-provide-on-definition.js +94 -0
- package/dist/rules/no-transport-types-in-generator.d.ts +1 -0
- package/dist/rules/no-transport-types-in-generator.js +63 -0
- package/dist/rules/no-yielded-match.d.ts +1 -0
- package/dist/rules/no-yielded-match.js +91 -0
- package/dist/rules/prefer-effect-fn.d.ts +1 -0
- package/dist/rules/prefer-effect-fn.js +102 -0
- package/dist/rules/provide-named-layer.d.ts +1 -0
- package/dist/rules/provide-named-layer.js +55 -0
- package/dist/rules/redacted-secret-names.d.ts +1 -0
- package/dist/rules/redacted-secret-names.js +127 -0
- package/dist/rules/run-only-at-entry.d.ts +1 -0
- package/dist/rules/run-only-at-entry.js +50 -0
- package/dist/rules/service-built-with-of.d.ts +1 -0
- package/dist/rules/service-built-with-of.js +74 -0
- package/dist/rules/service-key-matches-name.d.ts +1 -0
- package/dist/rules/service-key-matches-name.js +69 -0
- package/dist/rules/service-port-shape-only.d.ts +1 -0
- package/dist/rules/service-port-shape-only.js +65 -0
- package/dist/rules/single-style-per-function.d.ts +1 -0
- package/dist/rules/single-style-per-function.js +77 -0
- package/dist/rules/try-object-form.d.ts +1 -0
- package/dist/rules/try-object-form.js +26 -0
- package/dist/rules/untraced-recursion.d.ts +1 -0
- package/dist/rules/untraced-recursion.js +48 -0
- package/dist/shared/effect-ast.d.ts +9 -0
- package/dist/shared/effect-ast.js +52 -0
- package/dist/shared/entry-files.d.ts +6 -0
- package/dist/shared/entry-files.js +5 -0
- package/dist/shared/file-globs.d.ts +14 -0
- package/dist/shared/file-globs.js +17 -0
- package/dist/shared/match-ast.d.ts +5 -0
- package/dist/shared/match-ast.js +52 -0
- package/dist/shared/tagged-values.d.ts +17 -0
- package/dist/shared/tagged-values.js +67 -0
- package/dist/shared/test-files.d.ts +6 -0
- package/dist/shared/test-files.js +10 -0
- package/package.json +56 -5
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { calleeOf, isEffectFnApplication, isFunctionNode } from "../shared/effect-ast.js";
|
|
3
|
+
import { handlersOf, isMatchCall } from "../shared/match-ast.js";
|
|
4
|
+
const isDelegation = (node) => node?.type === "Identifier" ||
|
|
5
|
+
node?.type === "MemberExpression" ||
|
|
6
|
+
node?.type === "CallExpression" ||
|
|
7
|
+
node?.type === "NewExpression";
|
|
8
|
+
const isEffectBody = (node) => node.type === "CallExpression" &&
|
|
9
|
+
(isEffectFnApplication(node) || calleeOf(node, "Effect") === "gen") &&
|
|
10
|
+
node.arguments.some(isFunctionNode);
|
|
11
|
+
const shapeOfResult = (node) => {
|
|
12
|
+
if (!node)
|
|
13
|
+
return "value";
|
|
14
|
+
if (isEffectBody(node))
|
|
15
|
+
return "inline";
|
|
16
|
+
return isDelegation(node) ? "delegating" : "value";
|
|
17
|
+
};
|
|
18
|
+
// Literals, templates, arrays and ternaries produce a value rather than implement a step,
|
|
19
|
+
// so they sit on either side of an asymmetry without breaking it.
|
|
20
|
+
const shapeOf = (handler) => {
|
|
21
|
+
if (!isFunctionNode(handler))
|
|
22
|
+
return shapeOfResult(handler);
|
|
23
|
+
if (!handler.body)
|
|
24
|
+
return "value";
|
|
25
|
+
if (handler.generator)
|
|
26
|
+
return "inline";
|
|
27
|
+
if (handler.body.type !== "BlockStatement")
|
|
28
|
+
return shapeOfResult(handler.body);
|
|
29
|
+
const [only, ...rest] = handler.body.body;
|
|
30
|
+
if (!only)
|
|
31
|
+
return "value";
|
|
32
|
+
if (rest.length > 0 || only.type !== "ReturnStatement")
|
|
33
|
+
return "inline";
|
|
34
|
+
return shapeOfResult(only.argument);
|
|
35
|
+
};
|
|
36
|
+
const effectFnName = (handler) => {
|
|
37
|
+
if (handler.type !== "CallExpression" || handler.callee.type !== "CallExpression")
|
|
38
|
+
return;
|
|
39
|
+
const [name] = handler.callee.arguments;
|
|
40
|
+
return name?.type === "Literal" && typeof name.value === "string" ? name.value : undefined;
|
|
41
|
+
};
|
|
42
|
+
export const noAsymmetricMatchHandlersRule = defineRule({
|
|
43
|
+
meta: {
|
|
44
|
+
type: "suggestion",
|
|
45
|
+
docs: {
|
|
46
|
+
description: "Keep the handlers of a match at one level of abstraction: when one delegates to a named function, extract the inline ones too.",
|
|
47
|
+
},
|
|
48
|
+
messages: {
|
|
49
|
+
inlineHandler: "This handler is implemented inline while another branch of the same match delegates to a named function. Extract it into a named function{{hint}} and call it from the handler.",
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
createOnce(context) {
|
|
53
|
+
return {
|
|
54
|
+
CallExpression(node) {
|
|
55
|
+
if (!isMatchCall(node))
|
|
56
|
+
return;
|
|
57
|
+
const handlers = handlersOf(node);
|
|
58
|
+
const shapes = handlers.map(shapeOf);
|
|
59
|
+
if (!shapes.includes("delegating"))
|
|
60
|
+
return;
|
|
61
|
+
handlers.forEach((handler, index) => {
|
|
62
|
+
if (shapes[index] !== "inline")
|
|
63
|
+
return;
|
|
64
|
+
const name = effectFnName(handler);
|
|
65
|
+
context.report({
|
|
66
|
+
node: handler,
|
|
67
|
+
messageId: "inlineHandler",
|
|
68
|
+
data: { hint: name ? ` (\`${name}\`)` : "" },
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const noBareErrorInEffectRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { calleeOf, isEffectGenerator, nearestFunction } from "../shared/effect-ast.js";
|
|
3
|
+
// Only the two forms the effecttsgo preset misses: `Effect.fail(new Error(…))` and
|
|
4
|
+
// `extends Error` are already reported by global-error-in-effect-failure and extends-native-error.
|
|
5
|
+
export const noBareErrorInEffectRule = defineRule({
|
|
6
|
+
meta: {
|
|
7
|
+
type: "problem",
|
|
8
|
+
docs: {
|
|
9
|
+
description: "Fail Effects with tagged errors, never with a `throw` or a primitive value.",
|
|
10
|
+
},
|
|
11
|
+
messages: {
|
|
12
|
+
throwInGenerator: "A `throw` inside an Effect generator becomes a defect: `return yield* new TaggedError(...)` instead.",
|
|
13
|
+
primitiveFailure: "Fail with a tagged error (`Data.TaggedError` or `Schema.TaggedError`), not a primitive value.",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
createOnce(context) {
|
|
17
|
+
return {
|
|
18
|
+
ThrowStatement(node) {
|
|
19
|
+
const fn = nearestFunction(node);
|
|
20
|
+
if (fn !== undefined && isEffectGenerator(fn)) {
|
|
21
|
+
context.report({ node, messageId: "throwInGenerator" });
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
CallExpression(node) {
|
|
25
|
+
if (calleeOf(node, "Effect") !== "fail")
|
|
26
|
+
return;
|
|
27
|
+
const [failure] = node.arguments;
|
|
28
|
+
if (failure?.type === "Literal" || failure?.type === "TemplateLiteral") {
|
|
29
|
+
context.report({ node: failure, messageId: "primitiveFailure" });
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const noEffectPromiseRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { calleeOf } from "../shared/effect-ast.js";
|
|
3
|
+
import { filePatternsSchema } from "../shared/file-globs.js";
|
|
4
|
+
import { isTestFile } from "../shared/test-files.js";
|
|
5
|
+
// Argument index of the release step, where a promise that cannot reject is the normal case.
|
|
6
|
+
const releaseSlots = new Map([
|
|
7
|
+
["acquireRelease", 1],
|
|
8
|
+
["acquireUseRelease", 2],
|
|
9
|
+
["addFinalizer", 0],
|
|
10
|
+
]);
|
|
11
|
+
const isInsideRelease = (node) => {
|
|
12
|
+
let child = node;
|
|
13
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
14
|
+
const slot = releaseSlots.get(calleeOf(current, "Effect") ?? "");
|
|
15
|
+
if (current.type === "CallExpression" &&
|
|
16
|
+
slot !== undefined &&
|
|
17
|
+
current.arguments[slot] === child) {
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
child = current;
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
};
|
|
24
|
+
export const noEffectPromiseRule = defineRule({
|
|
25
|
+
meta: {
|
|
26
|
+
type: "suggestion",
|
|
27
|
+
docs: {
|
|
28
|
+
description: "Prefer `Effect.tryPromise` over `Effect.promise` outside resource release. Test files are skipped.",
|
|
29
|
+
},
|
|
30
|
+
messages: {
|
|
31
|
+
rejectionAsDefect: "`Effect.promise` turns a rejection into a defect: use `Effect.tryPromise({ try, catch })` unless the promise cannot reject.",
|
|
32
|
+
},
|
|
33
|
+
schema: [
|
|
34
|
+
{
|
|
35
|
+
type: "object",
|
|
36
|
+
properties: { testFiles: filePatternsSchema },
|
|
37
|
+
additionalProperties: false,
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
createOnce(context) {
|
|
42
|
+
let inTestFile = false;
|
|
43
|
+
return {
|
|
44
|
+
// A rejection during test setup is a broken test, and a defect fails it with the cause.
|
|
45
|
+
before() {
|
|
46
|
+
inTestFile = isTestFile(context);
|
|
47
|
+
},
|
|
48
|
+
CallExpression(node) {
|
|
49
|
+
if (inTestFile)
|
|
50
|
+
return;
|
|
51
|
+
if (calleeOf(node, "Effect") === "promise" && !isInsideRelease(node)) {
|
|
52
|
+
context.report({ node, messageId: "rejectionAsDefect" });
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const noEffectReturnTypeRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { nearestFunction } from "../shared/effect-ast.js";
|
|
3
|
+
// `Effect.Effect`, `Stream.Stream`, `Layer.Layer`: the types that carry error and requirement
|
|
4
|
+
// channels collected from everything they call.
|
|
5
|
+
const channelModules = new Set(["Effect", "Stream", "Layer"]);
|
|
6
|
+
const isChannelType = (node) => node.typeName.type === "TSQualifiedName" &&
|
|
7
|
+
node.typeName.left.type === "Identifier" &&
|
|
8
|
+
channelModules.has(node.typeName.left.name) &&
|
|
9
|
+
node.typeName.right.name === node.typeName.left.name;
|
|
10
|
+
const isInside = (node, ancestor) => {
|
|
11
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
12
|
+
if (current === ancestor)
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
};
|
|
17
|
+
const branchesOf = (expression) => expression.type === "ConditionalExpression"
|
|
18
|
+
? [...branchesOf(expression.consequent), ...branchesOf(expression.alternate)]
|
|
19
|
+
: [expression];
|
|
20
|
+
// Without type information, branches built differently (`Effect.succeed` and `Effect.fail`)
|
|
21
|
+
// stand in for an inferred union of effects that the annotation folds into one type.
|
|
22
|
+
const returnsDifferentShapes = (branches, sourceCode) => {
|
|
23
|
+
const shapeOf = (branch) => branch.type === "CallExpression" || branch.type === "NewExpression"
|
|
24
|
+
? `${branch.type} ${sourceCode.getText(branch.callee)}`
|
|
25
|
+
: branch.type;
|
|
26
|
+
return new Set(branches.map(shapeOf)).size > 1;
|
|
27
|
+
};
|
|
28
|
+
// TypeScript cannot infer the return type of a function that calls itself: it falls back to
|
|
29
|
+
// `any` and asks for the annotation.
|
|
30
|
+
const isRecursive = (node, sourceCode) => {
|
|
31
|
+
const declaration = node.type === "FunctionDeclaration" ? node : node.parent;
|
|
32
|
+
const name = declaration?.type === "FunctionDeclaration"
|
|
33
|
+
? declaration.id?.name
|
|
34
|
+
: declaration?.type === "VariableDeclarator" && declaration.id.type === "Identifier"
|
|
35
|
+
? declaration.id.name
|
|
36
|
+
: undefined;
|
|
37
|
+
if (declaration === null || name === undefined)
|
|
38
|
+
return false;
|
|
39
|
+
// The declared variables of a function declaration include its parameters.
|
|
40
|
+
return sourceCode
|
|
41
|
+
.getDeclaredVariables(declaration)
|
|
42
|
+
.some((variable) => variable.name === name &&
|
|
43
|
+
variable.references.some((reference) => isInside(reference.identifier, node)));
|
|
44
|
+
};
|
|
45
|
+
// Only the declaration itself: methods of an exported object and functions it returns are not
|
|
46
|
+
// part of the module's signature.
|
|
47
|
+
const isExportedDeclaration = (node) => {
|
|
48
|
+
const declarator = node.parent;
|
|
49
|
+
const declaration = declarator?.type === "VariableDeclarator" && declarator.init === node
|
|
50
|
+
? declarator.parent
|
|
51
|
+
: node;
|
|
52
|
+
const parent = declaration?.parent?.type;
|
|
53
|
+
return parent === "ExportNamedDeclaration" || parent === "ExportDefaultDeclaration";
|
|
54
|
+
};
|
|
55
|
+
const checksExported = (options) => {
|
|
56
|
+
const [first] = options;
|
|
57
|
+
return typeof first === "object" && first !== null && "checkExported" in first
|
|
58
|
+
? first.checkExported === true
|
|
59
|
+
: false;
|
|
60
|
+
};
|
|
61
|
+
export const noEffectReturnTypeRule = defineRule({
|
|
62
|
+
meta: {
|
|
63
|
+
type: "suggestion",
|
|
64
|
+
docs: {
|
|
65
|
+
description: "Let inference type a function returning an Effect: its error and requirement channels change with every callee. Exported declarations are skipped: their annotation is a contract.",
|
|
66
|
+
},
|
|
67
|
+
hasSuggestions: true,
|
|
68
|
+
messages: {
|
|
69
|
+
effectReturnType: "Remove the return type annotation: error and requirement channels follow the callees, so inference keeps them up to date.",
|
|
70
|
+
removeReturnType: "Remove the return type annotation.",
|
|
71
|
+
},
|
|
72
|
+
schema: [
|
|
73
|
+
{
|
|
74
|
+
type: "object",
|
|
75
|
+
properties: { checkExported: { type: "boolean" } },
|
|
76
|
+
additionalProperties: false,
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
createOnce(context) {
|
|
81
|
+
let checkExported = false;
|
|
82
|
+
const annotated = new Set();
|
|
83
|
+
const returned = new Map();
|
|
84
|
+
const collect = (node) => {
|
|
85
|
+
const fn = nearestFunction(node);
|
|
86
|
+
if (fn === undefined || node.argument === null)
|
|
87
|
+
return;
|
|
88
|
+
returned.set(fn, [...(returned.get(fn) ?? []), node.argument]);
|
|
89
|
+
};
|
|
90
|
+
const check = (node) => {
|
|
91
|
+
const statements = returned.get(node) ?? [];
|
|
92
|
+
returned.delete(node);
|
|
93
|
+
if (!annotated.delete(node) || !node.returnType)
|
|
94
|
+
return;
|
|
95
|
+
const expressions = node.body?.type === "BlockStatement" ? statements : node.body ? [node.body] : [];
|
|
96
|
+
if (returnsDifferentShapes(expressions.flatMap(branchesOf), context.sourceCode))
|
|
97
|
+
return;
|
|
98
|
+
if (isRecursive(node, context.sourceCode))
|
|
99
|
+
return;
|
|
100
|
+
if (!checkExported && isExportedDeclaration(node))
|
|
101
|
+
return;
|
|
102
|
+
const { returnType } = node;
|
|
103
|
+
context.report({
|
|
104
|
+
node: returnType,
|
|
105
|
+
messageId: "effectReturnType",
|
|
106
|
+
suggest: [
|
|
107
|
+
{
|
|
108
|
+
messageId: "removeReturnType",
|
|
109
|
+
fix: (fixer) => fixer.remove(returnType),
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
return {
|
|
115
|
+
before() {
|
|
116
|
+
checkExported = checksExported(context.options);
|
|
117
|
+
},
|
|
118
|
+
TSTypeReference(node) {
|
|
119
|
+
const fn = nearestFunction(node);
|
|
120
|
+
if (fn?.returnType && isChannelType(node) && isInside(node, fn.returnType)) {
|
|
121
|
+
annotated.add(fn);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
ReturnStatement: collect,
|
|
125
|
+
"ArrowFunctionExpression:exit": check,
|
|
126
|
+
"FunctionDeclaration:exit": check,
|
|
127
|
+
"FunctionExpression:exit": check,
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const noInlineSchemaCodecRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { calleeOf, nearestFunction } from "../shared/effect-ast.js";
|
|
3
|
+
// Only the codec factories: `Schema.decode`, `decodeTo`, `encodeKeys`, … build schemas.
|
|
4
|
+
const codecFactory = /^(decode|encode)(Unknown)?(Effect|Exit|Option|Promise|Result|Sync)$/;
|
|
5
|
+
const layerConstructors = new Set(["effect", "scoped"]);
|
|
6
|
+
const isLayerConstructor = (node) => {
|
|
7
|
+
const name = calleeOf(node, "Layer");
|
|
8
|
+
return name !== undefined && layerConstructors.has(name);
|
|
9
|
+
};
|
|
10
|
+
// `Layer.effect(T, Effect.gen(…))` and the curried `Layer.effect(T)(Effect.gen(…))`: the
|
|
11
|
+
// generator runs once, when the Layer is built.
|
|
12
|
+
const isLayerConstructionGenerator = (fn) => {
|
|
13
|
+
const gen = fn.parent;
|
|
14
|
+
if (!fn.generator || gen?.type !== "CallExpression" || calleeOf(gen, "Effect") !== "gen") {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
const layer = gen.parent;
|
|
18
|
+
if (layer?.type !== "CallExpression" || !layer.arguments.some((arg) => arg === gen)) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
return isLayerConstructor(layer) || isLayerConstructor(layer.callee);
|
|
22
|
+
};
|
|
23
|
+
export const noInlineSchemaCodecRule = defineRule({
|
|
24
|
+
meta: {
|
|
25
|
+
type: "suggestion",
|
|
26
|
+
docs: {
|
|
27
|
+
description: "Extract `Schema.decode*`/`Schema.encode*` codecs into module-level constants instead of applying them inline.",
|
|
28
|
+
},
|
|
29
|
+
messages: {
|
|
30
|
+
inlineCodec: "`Schema.{{factory}}(…)` is built and applied inline: extract it into a named module-level constant.",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
createOnce(context) {
|
|
34
|
+
return {
|
|
35
|
+
CallExpression(node) {
|
|
36
|
+
const factory = calleeOf(node.callee, "Schema");
|
|
37
|
+
if (factory === undefined || !codecFactory.test(factory))
|
|
38
|
+
return;
|
|
39
|
+
const fn = nearestFunction(node);
|
|
40
|
+
if (fn === undefined || isLayerConstructionGenerator(fn))
|
|
41
|
+
return;
|
|
42
|
+
context.report({ node: node.callee, messageId: "inlineCodec", data: { factory } });
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const noInlineYieldStarRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { nearestFunction } from "../shared/effect-ast.js";
|
|
3
|
+
const contains = (outer, inner) => outer[0] <= inner[0] && inner[1] <= outer[1];
|
|
4
|
+
const isStandalone = (node) => {
|
|
5
|
+
const { parent } = node;
|
|
6
|
+
if (!parent)
|
|
7
|
+
return false;
|
|
8
|
+
if (parent.type === "ConditionalExpression" && parent.test !== node)
|
|
9
|
+
return isStandalone(parent);
|
|
10
|
+
if (parent.type === "LogicalExpression" && parent.right === node)
|
|
11
|
+
return isStandalone(parent);
|
|
12
|
+
if (parent.type === "VariableDeclarator")
|
|
13
|
+
return true;
|
|
14
|
+
if (parent.type === "AssignmentExpression")
|
|
15
|
+
return parent.parent.type === "ExpressionStatement";
|
|
16
|
+
return parent.type.endsWith("Statement");
|
|
17
|
+
};
|
|
18
|
+
const isInStatementList = (node) => {
|
|
19
|
+
const { parent } = node;
|
|
20
|
+
switch (parent?.type) {
|
|
21
|
+
case "BlockStatement":
|
|
22
|
+
case "Program":
|
|
23
|
+
case "StaticBlock":
|
|
24
|
+
return true;
|
|
25
|
+
case "SwitchCase":
|
|
26
|
+
return parent.test !== node;
|
|
27
|
+
default:
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const statementOf = (node) => {
|
|
32
|
+
let current = node;
|
|
33
|
+
while (current.parent && !isInStatementList(current))
|
|
34
|
+
current = current.parent;
|
|
35
|
+
return current;
|
|
36
|
+
};
|
|
37
|
+
const logicalAssignments = new Set(["&&=", "||=", "??="]);
|
|
38
|
+
// The extracted `const` runs before its statement, once and unconditionally: any position that
|
|
39
|
+
// short-circuits, repeats or belongs to a nested statement would change when the effect runs.
|
|
40
|
+
const runsOnceBeforeStatement = (child, parent, statement) => {
|
|
41
|
+
switch (parent.type) {
|
|
42
|
+
case "LogicalExpression":
|
|
43
|
+
return parent.left === child;
|
|
44
|
+
case "ConditionalExpression":
|
|
45
|
+
return parent.test === child;
|
|
46
|
+
case "AssignmentExpression":
|
|
47
|
+
return parent.left === child || !logicalAssignments.has(parent.operator);
|
|
48
|
+
case "ChainExpression":
|
|
49
|
+
case "ClassBody":
|
|
50
|
+
case "WhileStatement":
|
|
51
|
+
case "DoWhileStatement":
|
|
52
|
+
return false;
|
|
53
|
+
case "ForStatement":
|
|
54
|
+
return parent === statement && parent.init === child;
|
|
55
|
+
case "ForInStatement":
|
|
56
|
+
case "ForOfStatement":
|
|
57
|
+
return parent === statement && parent.right === child;
|
|
58
|
+
default:
|
|
59
|
+
return parent === statement || !parent.type.endsWith("Statement");
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const canHoist = (node, statement) => {
|
|
63
|
+
let child = node;
|
|
64
|
+
while (child !== statement) {
|
|
65
|
+
const parent = child.parent;
|
|
66
|
+
if (!parent || !runsOnceBeforeStatement(child, parent, statement))
|
|
67
|
+
return false;
|
|
68
|
+
child = parent;
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
};
|
|
72
|
+
// Anything evaluated before a hoisted `yield*` must be hoisted with it, or the order of effects flips.
|
|
73
|
+
const reordersEffects = (hoisted, effectful) => hoisted.some((target) => effectful.some((node) => node.range[1] <= target.range[0] &&
|
|
74
|
+
!hoisted.some((moved) => contains(moved.range, node.range) && moved.range[1] <= target.range[0])));
|
|
75
|
+
const verbPrefix = /^(?:get|read|fetch|load|find|parse|decode|make|create|build|compute|resolve|from|to)(?=[A-Z])/;
|
|
76
|
+
const reservedWords = new Set("arguments await break case catch class const continue debugger default delete do else enum eval export extends false finally for function if implements import in instanceof interface let new null package private protected public return static super switch this throw true try typeof undefined var void while with yield".split(" "));
|
|
77
|
+
const trailingName = (node) => {
|
|
78
|
+
switch (node.type) {
|
|
79
|
+
case "Identifier":
|
|
80
|
+
return node.name;
|
|
81
|
+
case "MemberExpression":
|
|
82
|
+
return !node.computed && node.property.type === "Identifier"
|
|
83
|
+
? node.property.name
|
|
84
|
+
: undefined;
|
|
85
|
+
case "CallExpression":
|
|
86
|
+
case "NewExpression":
|
|
87
|
+
return trailingName(node.callee);
|
|
88
|
+
default:
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
// `yield* readSheet()` becomes `sheet`, `yield* reader.rows` becomes `rows`.
|
|
93
|
+
const baseName = (node) => {
|
|
94
|
+
const name = node.argument ? trailingName(node.argument)?.replace(verbPrefix, "") : undefined;
|
|
95
|
+
if (name === undefined || name === "")
|
|
96
|
+
return "value";
|
|
97
|
+
const variable = name.charAt(0).toLowerCase() + name.slice(1);
|
|
98
|
+
return reservedWords.has(variable) ? "value" : variable;
|
|
99
|
+
};
|
|
100
|
+
const namesInUse = (scope) => {
|
|
101
|
+
const names = new Set();
|
|
102
|
+
const collect = (inner) => {
|
|
103
|
+
for (const variable of inner.variables)
|
|
104
|
+
names.add(variable.name);
|
|
105
|
+
for (const reference of inner.references)
|
|
106
|
+
names.add(reference.identifier.name);
|
|
107
|
+
inner.childScopes.forEach(collect);
|
|
108
|
+
};
|
|
109
|
+
collect(scope);
|
|
110
|
+
for (let outer = scope.upper; outer; outer = outer.upper) {
|
|
111
|
+
for (const name of outer.set.keys())
|
|
112
|
+
names.add(name);
|
|
113
|
+
}
|
|
114
|
+
return names;
|
|
115
|
+
};
|
|
116
|
+
// The parentheses of a call with a single argument belong to the call, not to the `yield*`.
|
|
117
|
+
const replacedRange = (node, sourceCode) => {
|
|
118
|
+
const { parent } = node;
|
|
119
|
+
const callParens = (parent.type === "CallExpression" || parent.type === "NewExpression") &&
|
|
120
|
+
parent.arguments.length === 1
|
|
121
|
+
? 1
|
|
122
|
+
: 0;
|
|
123
|
+
const wrappers = [];
|
|
124
|
+
let before = sourceCode.getTokenBefore(node);
|
|
125
|
+
let after = sourceCode.getTokenAfter(node);
|
|
126
|
+
while (before?.value === "(" && after?.value === ")") {
|
|
127
|
+
wrappers.push([before.range[0], after.range[1]]);
|
|
128
|
+
before = sourceCode.getTokenBefore(before);
|
|
129
|
+
after = sourceCode.getTokenAfter(after);
|
|
130
|
+
}
|
|
131
|
+
return wrappers[wrappers.length - 1 - callParens] ?? node.range;
|
|
132
|
+
};
|
|
133
|
+
const extraction = (statement, hoisted, sourceCode) => {
|
|
134
|
+
const taken = namesInUse(sourceCode.getScope(statement).variableScope);
|
|
135
|
+
const replacements = hoisted.map((node) => {
|
|
136
|
+
let name = baseName(node);
|
|
137
|
+
for (let suffix = 2; taken.has(name); suffix++)
|
|
138
|
+
name = `${baseName(node)}${suffix}`;
|
|
139
|
+
taken.add(name);
|
|
140
|
+
return { node, name, range: replacedRange(node, sourceCode) };
|
|
141
|
+
});
|
|
142
|
+
const { text } = sourceCode;
|
|
143
|
+
const rewrite = (range, self) => {
|
|
144
|
+
const inside = replacements.filter((replacement) => replacement.node !== self && contains(range, replacement.range));
|
|
145
|
+
const outermost = inside
|
|
146
|
+
.filter((replacement) => inside.every((other) => other === replacement || !contains(other.range, replacement.range)))
|
|
147
|
+
.toSorted((left, right) => left.range[0] - right.range[0]);
|
|
148
|
+
let result = "";
|
|
149
|
+
let cursor = range[0];
|
|
150
|
+
for (const replacement of outermost) {
|
|
151
|
+
result += text.slice(cursor, replacement.range[0]) + replacement.name;
|
|
152
|
+
cursor = replacement.range[1];
|
|
153
|
+
}
|
|
154
|
+
return result + text.slice(cursor, range[1]);
|
|
155
|
+
};
|
|
156
|
+
const lineStart = text.lastIndexOf("\n", statement.range[0] - 1) + 1;
|
|
157
|
+
const indentation = text.slice(lineStart, statement.range[0]);
|
|
158
|
+
const startsLine = indentation.trim() === "";
|
|
159
|
+
const declarations = replacements.map(({ node, name }) => `const ${name} = ${rewrite(node.range, node)}`);
|
|
160
|
+
const remainder = rewrite(statement.range);
|
|
161
|
+
const guarded = startsLine && /^[[(`+\-/]/.test(remainder) ? `;${remainder}` : remainder;
|
|
162
|
+
return [...declarations, guarded].join(startsLine ? `\n${indentation}` : "; ");
|
|
163
|
+
};
|
|
164
|
+
export const noInlineYieldStarRule = defineRule({
|
|
165
|
+
meta: {
|
|
166
|
+
type: "suggestion",
|
|
167
|
+
fixable: "code",
|
|
168
|
+
docs: {
|
|
169
|
+
description: "Bind the result of a `yield*` to a `const` instead of using it inside a larger expression.",
|
|
170
|
+
},
|
|
171
|
+
messages: {
|
|
172
|
+
inlineYieldStar: "This `yield*` runs inside a larger expression. Bind its result first (`const rows = yield* reader.rows`), so each statement runs one effect in reading order.",
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
createOnce(context) {
|
|
176
|
+
let inline = [];
|
|
177
|
+
let effectful = [];
|
|
178
|
+
const collect = (node) => {
|
|
179
|
+
effectful.push(node);
|
|
180
|
+
};
|
|
181
|
+
return {
|
|
182
|
+
before() {
|
|
183
|
+
inline = [];
|
|
184
|
+
effectful = [];
|
|
185
|
+
},
|
|
186
|
+
YieldExpression(node) {
|
|
187
|
+
effectful.push(node);
|
|
188
|
+
if (node.delegate && !isStandalone(node))
|
|
189
|
+
inline.push(node);
|
|
190
|
+
},
|
|
191
|
+
AwaitExpression: collect,
|
|
192
|
+
AssignmentExpression: collect,
|
|
193
|
+
CallExpression: collect,
|
|
194
|
+
NewExpression: collect,
|
|
195
|
+
TaggedTemplateExpression: collect,
|
|
196
|
+
UpdateExpression: collect,
|
|
197
|
+
VariableDeclarator: collect,
|
|
198
|
+
"Program:exit"() {
|
|
199
|
+
const byStatement = new Map();
|
|
200
|
+
for (const node of inline) {
|
|
201
|
+
const statement = statementOf(node);
|
|
202
|
+
byStatement.set(statement, [...(byStatement.get(statement) ?? []), node]);
|
|
203
|
+
}
|
|
204
|
+
for (const [statement, yields] of byStatement) {
|
|
205
|
+
const owner = nearestFunction(statement);
|
|
206
|
+
const hoisted = yields
|
|
207
|
+
.filter((node) => canHoist(node, statement))
|
|
208
|
+
.toSorted((left, right) => left.range[1] - right.range[1] || right.range[0] - left.range[0]);
|
|
209
|
+
const evaluatedInStatement = effectful.filter((node) => contains(statement.range, node.range) &&
|
|
210
|
+
nearestFunction(node) === owner);
|
|
211
|
+
const fixable = statement.type !== "Program" &&
|
|
212
|
+
hoisted.length > 0 &&
|
|
213
|
+
!reordersEffects(hoisted, evaluatedInStatement);
|
|
214
|
+
const replacement = fixable
|
|
215
|
+
? extraction(statement, hoisted, context.sourceCode)
|
|
216
|
+
: undefined;
|
|
217
|
+
for (const node of yields) {
|
|
218
|
+
context.report({
|
|
219
|
+
node,
|
|
220
|
+
messageId: "inlineYieldStar",
|
|
221
|
+
...(replacement !== undefined && hoisted.includes(node)
|
|
222
|
+
? { fix: (fixer) => fixer.replaceText(statement, replacement) }
|
|
223
|
+
: {}),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
},
|
|
230
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { configuredDiscriminants, discriminantOf, discriminantsSchema, isEqualityComparison, isInsideBroadEffectHandler, isStringLiteral, } from "../shared/tagged-values.js";
|
|
3
|
+
export const remedyFor = (discriminant) => discriminant === "_tag"
|
|
4
|
+
? "`Match.tagsExhaustive`, the tagged enum `$match` or an exhaustive ts-pattern match; for a single check, `Predicate.isTagged`"
|
|
5
|
+
: `\`Match.discriminatorsExhaustive("${discriminant}")\` or an exhaustive ts-pattern match`;
|
|
6
|
+
export const noManualDiscriminantComparisonRule = defineRule({
|
|
7
|
+
meta: {
|
|
8
|
+
type: "suggestion",
|
|
9
|
+
docs: {
|
|
10
|
+
description: "Branch on a union's discriminant with an exhaustive match, not with comparisons or `switch`.",
|
|
11
|
+
},
|
|
12
|
+
messages: {
|
|
13
|
+
manualComparison: "Comparing `{{discriminant}}` with a literal still compiles when a variant is added: use {{remedy}}.",
|
|
14
|
+
manualSwitch: "A `switch` on `{{discriminant}}` still compiles when a variant is added: use {{remedy}}.",
|
|
15
|
+
},
|
|
16
|
+
schema: [
|
|
17
|
+
{
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: { discriminants: discriminantsSchema },
|
|
20
|
+
additionalProperties: false,
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
createOnce(context) {
|
|
25
|
+
let discriminants = new Set();
|
|
26
|
+
const report = (node, discriminant, messageId) => {
|
|
27
|
+
// Inside `Effect.catch` handlers, `_tag` belongs to `anti-slop-no-manual-effect-error-tag`.
|
|
28
|
+
if (discriminant === "_tag" && isInsideBroadEffectHandler(node))
|
|
29
|
+
return;
|
|
30
|
+
context.report({
|
|
31
|
+
node,
|
|
32
|
+
messageId,
|
|
33
|
+
data: { discriminant, remedy: remedyFor(discriminant) },
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
before() {
|
|
38
|
+
discriminants = configuredDiscriminants(context.options);
|
|
39
|
+
},
|
|
40
|
+
BinaryExpression(node) {
|
|
41
|
+
if (!isEqualityComparison(node))
|
|
42
|
+
return;
|
|
43
|
+
const discriminant = isStringLiteral(node.right)
|
|
44
|
+
? discriminantOf(node.left, discriminants)
|
|
45
|
+
: isStringLiteral(node.left)
|
|
46
|
+
? discriminantOf(node.right, discriminants)
|
|
47
|
+
: undefined;
|
|
48
|
+
if (discriminant !== undefined)
|
|
49
|
+
report(node, discriminant, "manualComparison");
|
|
50
|
+
},
|
|
51
|
+
SwitchStatement(node) {
|
|
52
|
+
const discriminant = discriminantOf(node.discriminant, discriminants);
|
|
53
|
+
if (discriminant !== undefined)
|
|
54
|
+
report(node, discriminant, "manualSwitch");
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
});
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export declare const remedyFor: (discriminant: string) => "the Schema's `.make` or the class constructor" | "the Schema's tagged `.make`, the tagged class or error constructor, or the `Data.taggedEnum` variant constructor";
|
|
2
|
+
export declare const noManualDiscriminantConstructionRule: import("@oxlint/plugins").Rule;
|