oxlint-plugin-effect 0.13.0 → 0.15.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/README.md +26 -14
- package/THIRD_PARTY_NOTICES.md +4 -2
- package/dist/presets/recommended.d.ts +10 -0
- package/dist/presets/recommended.js +11 -1
- package/dist/rules/_anti-slop-array-method.js +78 -0
- package/dist/rules/_anti-slop-dictionary-types.js +198 -150
- package/dist/rules/_anti-slop-function-parameters.js +30 -0
- package/dist/rules/_anti-slop-lexical-type-parameters.js +25 -0
- package/dist/rules/_anti-slop-reflect-method.js +25 -0
- package/dist/rules/_anti-slop-scope.js +15 -0
- package/dist/rules/_anti-slop-type-alias-resolution.js +139 -0
- package/dist/rules/_ast-ancestors.js +14 -0
- package/dist/rules/_ast-children.js +18 -0
- package/dist/rules/_call-arguments.js +11 -0
- package/dist/rules/_effect-context.js +25 -20
- package/dist/rules/_function-metrics.js +94 -75
- package/dist/rules/_tagged-control-flow.js +38 -60
- package/dist/rules/_test-apis.js +47 -0
- package/dist/rules/_test-files.js +8 -0
- package/dist/rules/index.d.ts +11 -1
- package/dist/rules/index.js +21 -1
- package/dist/rules/no-array-filter-map.d.ts +7 -0
- package/dist/rules/no-array-filter-map.js +49 -0
- package/dist/rules/no-chained-type-assertions.js +7 -4
- package/dist/rules/no-conditional-empty-object-spread.d.ts +1 -1
- package/dist/rules/no-conditional-empty-object-spread.js +7 -47
- package/dist/rules/no-dynamic-imports.js +33 -25
- package/dist/rules/no-eager-acquire.d.ts +6 -0
- package/dist/rules/no-eager-acquire.js +98 -0
- package/dist/rules/no-effect-run-in-tests.d.ts +6 -0
- package/dist/rules/no-effect-run-in-tests.js +60 -0
- package/dist/rules/no-globals.js +42 -31
- package/dist/rules/no-known-value-widening.js +67 -50
- package/dist/rules/no-module-level-mutable-state.d.ts +6 -0
- package/dist/rules/no-module-level-mutable-state.js +47 -0
- package/dist/rules/no-module-mocks.js +37 -48
- package/dist/rules/no-new-promise.js +6 -2
- package/dist/rules/no-node-builtin-import.js +56 -33
- package/dist/rules/no-nullish.js +5 -1
- package/dist/rules/no-object-parameters.js +16 -30
- package/dist/rules/no-per-call-cache-construction.js +2 -2
- package/dist/rules/no-reduce-accumulator-copy.d.ts +7 -0
- package/dist/rules/no-reduce-accumulator-copy.js +106 -0
- package/dist/rules/no-reflect-apply.d.ts +7 -0
- package/dist/rules/no-reflect-apply.js +28 -0
- package/dist/rules/no-reflect-get.d.ts +7 -0
- package/dist/rules/no-reflect-get.js +28 -0
- package/dist/rules/no-run-collect-on-unbounded-stream.js +23 -25
- package/dist/rules/no-runtime-typeof.js +21 -4
- package/dist/rules/no-sequential-effect-all.js +18 -11
- package/dist/rules/no-shape-in-symbol-names.js +15 -5
- package/dist/rules/no-silent-catch-all.js +20 -14
- package/dist/rules/no-test-globals.d.ts +6 -0
- package/dist/rules/no-test-globals.js +147 -0
- package/dist/rules/no-test-lifecycle-hooks.js +4 -2
- package/dist/rules/no-unbounded-concurrency.js +22 -17
- package/dist/rules/no-unbounded-retry.js +47 -62
- package/dist/rules/no-unknown-parameters.d.ts +1 -1
- package/dist/rules/no-unknown-parameters.js +26 -17
- package/dist/rules/no-unknown-returns.d.ts +7 -0
- package/dist/rules/no-unknown-returns.js +74 -0
- package/dist/rules/no-unknown-type-aliases.js +21 -27
- package/dist/rules/no-unsafe-dictionary-type.js +71 -25
- package/dist/rules/no-widen-then-assert.js +96 -73
- package/dist/rules/prefer-catch-tag.js +29 -37
- package/dist/rules/prefer-match-tags-exhaustive.js +35 -47
- package/dist/rules/prefer-predicate-is-tagged.js +4 -6
- package/dist/rules/prefer-service-of.js +39 -26
- package/dist/rules/require-named-effect-fn.js +4 -2
- package/dist/rules/require-suppression-reason.d.ts +6 -0
- package/dist/rules/require-suppression-reason.js +92 -0
- package/dist/vendor/effect-oxlint/Diagnostic.js +1 -1
- package/dist/vendor/effect-oxlint/Scope.js +1 -1
- package/package.json +3 -2
|
@@ -3,20 +3,24 @@ import { RuleContext } from "../vendor/effect-oxlint/RuleContext.js";
|
|
|
3
3
|
import { define, meta } from "../vendor/effect-oxlint/Rule.js";
|
|
4
4
|
import "../vendor/effect-oxlint/index.js";
|
|
5
5
|
import { importedNamespaces, isStaticMember, visibleNamespaces } from "./_effect-namespaces.js";
|
|
6
|
+
import * as Arr from "effect/Array";
|
|
7
|
+
import * as Option from "effect/Option";
|
|
8
|
+
import * as Predicate from "effect/Predicate";
|
|
6
9
|
import * as Effect from "effect/Effect";
|
|
7
10
|
//#region src/rules/no-silent-catch-all.ts
|
|
8
|
-
const isFunction = (node) => node
|
|
11
|
+
const isFunction = (node) => node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression";
|
|
9
12
|
const isEffectVoid = (node, effectNamespaces) => isStaticMember(node, effectNamespaces, "void");
|
|
10
13
|
const silentlyReturnsVoid = (node, effectNamespaces) => {
|
|
11
|
-
|
|
12
|
-
if (node.
|
|
13
|
-
if (
|
|
14
|
-
|
|
15
|
-
return statement
|
|
14
|
+
const body = node.body;
|
|
15
|
+
if (node.params.length !== 0 || Predicate.isNull(body)) return false;
|
|
16
|
+
if (body.type !== "BlockStatement") return isEffectVoid(body, effectNamespaces);
|
|
17
|
+
if (body.body.length !== 1) return false;
|
|
18
|
+
return Option.exists(Arr.head(body.body), (statement) => statement.type === "ReturnStatement" && Predicate.isNotNull(statement.argument) && isEffectVoid(statement.argument, effectNamespaces));
|
|
16
19
|
};
|
|
20
|
+
/** The handler of a data-last (one argument) or data-first catch call. */
|
|
17
21
|
const handlerArgument = (node) => {
|
|
18
|
-
if (node.arguments.length === 1) return node.arguments
|
|
19
|
-
return node.arguments
|
|
22
|
+
if (node.arguments.length === 1) return Arr.get(node.arguments, 0);
|
|
23
|
+
return Arr.get(node.arguments, 1);
|
|
20
24
|
};
|
|
21
25
|
const noSilentCatchAll = define({
|
|
22
26
|
name: "no-silent-catch-all",
|
|
@@ -37,12 +41,14 @@ const noSilentCatchAll = define({
|
|
|
37
41
|
if (node.type !== "CallExpression" || node.callee.type === "Super") return Effect.void;
|
|
38
42
|
const namespaces = visibleNamespaces(ctx, node, effectNamespaces);
|
|
39
43
|
if (!(isStaticMember(node.callee, namespaces, "catchAll") || isStaticMember(node.callee, namespaces, "catchAllCause"))) return Effect.void;
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
const silentHandler = Option.filter(Option.filter(handlerArgument(node), isFunction), (handler) => silentlyReturnsVoid(handler, namespaces));
|
|
45
|
+
return Option.match(silentHandler, {
|
|
46
|
+
onNone: () => Effect.void,
|
|
47
|
+
onSome: (handler) => ctx.report(make({
|
|
48
|
+
node: handler,
|
|
49
|
+
message: "Do not erase every failure with Effect.void. Recover a typed failure truthfully or record the unexpected failure before recovery."
|
|
50
|
+
}))
|
|
51
|
+
});
|
|
46
52
|
}
|
|
47
53
|
};
|
|
48
54
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { memberNames, narrow } from "../vendor/effect-oxlint/AST.js";
|
|
2
|
+
import { make } from "../vendor/effect-oxlint/Diagnostic.js";
|
|
3
|
+
import { RuleContext } from "../vendor/effect-oxlint/RuleContext.js";
|
|
4
|
+
import { define, meta } from "../vendor/effect-oxlint/Rule.js";
|
|
5
|
+
import { findVariableUp, getReferences, throughReferences, variables } from "../vendor/effect-oxlint/Scope.js";
|
|
6
|
+
import "../vendor/effect-oxlint/index.js";
|
|
7
|
+
import { isTestFile, skipFile } from "./_test-files.js";
|
|
8
|
+
import { collectTestBindings, resolveTestBinding } from "./_test-apis.js";
|
|
9
|
+
import * as Option from "effect/Option";
|
|
10
|
+
import * as Effect from "effect/Effect";
|
|
11
|
+
//#region src/rules/no-test-globals.ts
|
|
12
|
+
const globalRoots = new Set([
|
|
13
|
+
"global",
|
|
14
|
+
"globalThis",
|
|
15
|
+
"self",
|
|
16
|
+
"window"
|
|
17
|
+
]);
|
|
18
|
+
const mutatingCalls = new Map([["Object", new Set([
|
|
19
|
+
"assign",
|
|
20
|
+
"defineProperties",
|
|
21
|
+
"defineProperty"
|
|
22
|
+
])], ["Reflect", new Set([
|
|
23
|
+
"defineProperty",
|
|
24
|
+
"deleteProperty",
|
|
25
|
+
"set"
|
|
26
|
+
])]]);
|
|
27
|
+
const stubbingMethods = new Set([
|
|
28
|
+
"replaceProperty",
|
|
29
|
+
"stubEnv",
|
|
30
|
+
"stubGlobal",
|
|
31
|
+
"unstubAllEnvs",
|
|
32
|
+
"unstubAllGlobals"
|
|
33
|
+
]);
|
|
34
|
+
const runnerGlobals = new Set([
|
|
35
|
+
"afterAll",
|
|
36
|
+
"afterEach",
|
|
37
|
+
"beforeAll",
|
|
38
|
+
"beforeEach",
|
|
39
|
+
"describe",
|
|
40
|
+
"expect",
|
|
41
|
+
"fdescribe",
|
|
42
|
+
"fit",
|
|
43
|
+
"it",
|
|
44
|
+
"jest",
|
|
45
|
+
"mock",
|
|
46
|
+
"spyOn",
|
|
47
|
+
"test",
|
|
48
|
+
"vi",
|
|
49
|
+
"xdescribe",
|
|
50
|
+
"xit",
|
|
51
|
+
"xtest"
|
|
52
|
+
]);
|
|
53
|
+
const capabilityHint = "Provide the capability through an Effect service or runtime input.";
|
|
54
|
+
const innermostMember = (node) => {
|
|
55
|
+
if (node.object.type === "MemberExpression") return innermostMember(node.object);
|
|
56
|
+
return node;
|
|
57
|
+
};
|
|
58
|
+
const isStaticProperty = (node, name) => !node.computed && node.property.type === "Identifier" && node.property.name === name;
|
|
59
|
+
/**
|
|
60
|
+
* References to runner names that no import or declaration provides: unresolved
|
|
61
|
+
* references, plus references to globals the linter configuration declares.
|
|
62
|
+
*/
|
|
63
|
+
const runnerGlobalReferences = (globalScope) => {
|
|
64
|
+
const ambient = variables(globalScope).filter((variable) => variable.defs.length === 0).flatMap((variable) => getReferences(variable));
|
|
65
|
+
return [...new Map([...throughReferences(globalScope), ...ambient].map((reference) => [reference.identifier, reference])).values()].filter((reference) => runnerGlobals.has(reference.identifier.name));
|
|
66
|
+
};
|
|
67
|
+
const noTestGlobals = define({
|
|
68
|
+
name: "no-test-globals",
|
|
69
|
+
meta: meta({
|
|
70
|
+
type: "problem",
|
|
71
|
+
description: "Keep tests off shared global state and implicit test globals."
|
|
72
|
+
}),
|
|
73
|
+
create: function* () {
|
|
74
|
+
const ctx = yield* RuleContext;
|
|
75
|
+
if (!isTestFile(ctx.filename)) return skipFile;
|
|
76
|
+
const bindings = /* @__PURE__ */ new Map();
|
|
77
|
+
const isUnshadowedGlobal = (node, name) => Option.match(findVariableUp(ctx.sourceCode.getScope(node), name), {
|
|
78
|
+
onNone: () => true,
|
|
79
|
+
onSome: (variable) => variable.defs.length === 0
|
|
80
|
+
});
|
|
81
|
+
const isGlobalRoot = (node) => node.type === "Identifier" && globalRoots.has(node.name) && isUnshadowedGlobal(node, node.name);
|
|
82
|
+
/** A global object, a member chain rooted at one, or a chain rooted at `process.env`. */
|
|
83
|
+
const isGlobalState = (node) => {
|
|
84
|
+
if (node.type !== "MemberExpression") return isGlobalRoot(node);
|
|
85
|
+
const innermost = innermostMember(node);
|
|
86
|
+
if (isGlobalRoot(innermost.object)) return true;
|
|
87
|
+
return innermost.object.type === "Identifier" && innermost.object.name === "process" && isStaticProperty(innermost, "env") && isUnshadowedGlobal(innermost, "process");
|
|
88
|
+
};
|
|
89
|
+
const reportWrite = (target) => {
|
|
90
|
+
if (target.type !== "MemberExpression" || !isGlobalState(target)) return Effect.void;
|
|
91
|
+
return ctx.report(make({
|
|
92
|
+
node: target,
|
|
93
|
+
message: `Avoid writing ${ctx.sourceCode.getText(target)} in tests. ${capabilityHint}`
|
|
94
|
+
}));
|
|
95
|
+
};
|
|
96
|
+
const mutatedGlobal = (call) => {
|
|
97
|
+
if (call.callee.type !== "MemberExpression") return Option.none();
|
|
98
|
+
const target = Option.filter(Option.fromUndefinedOr(call.arguments[0]), (argument) => argument.type !== "SpreadElement" && isGlobalState(argument));
|
|
99
|
+
if (Option.isNone(target)) return Option.none();
|
|
100
|
+
return Option.map(Option.filter(memberNames(call.callee), ([object, method]) => Option.exists(Option.fromUndefinedOr(mutatingCalls.get(object)), (methods) => methods.has(method)) && isUnshadowedGlobal(call, object)), ([object, method]) => `${object}.${method}() on ${ctx.sourceCode.getText(target.value)}`);
|
|
101
|
+
};
|
|
102
|
+
const stubbingCall = (call) => {
|
|
103
|
+
if (call.callee.type !== "MemberExpression") return Option.none();
|
|
104
|
+
const scope = ctx.sourceCode.getScope(call);
|
|
105
|
+
return Option.flatMap(Option.filter(memberNames(call.callee), ([, method]) => stubbingMethods.has(method)), ([object, method]) => Option.map(Option.filter(resolveTestBinding(scope, object, bindings), (binding) => binding === "jest" || binding === "vi"), (binding) => `${binding}.${method}()`));
|
|
106
|
+
};
|
|
107
|
+
const reportCall = (call) => Option.match(Option.orElse(mutatedGlobal(call), () => stubbingCall(call)), {
|
|
108
|
+
onNone: () => Effect.void,
|
|
109
|
+
onSome: (used) => ctx.report(make({
|
|
110
|
+
node: call,
|
|
111
|
+
message: `Avoid ${used} in tests. ${capabilityHint}`
|
|
112
|
+
}))
|
|
113
|
+
});
|
|
114
|
+
const reportRunnerGlobal = (reference) => ctx.report(make({
|
|
115
|
+
node: reference.identifier,
|
|
116
|
+
message: `Import ${reference.identifier.name} from your test library instead of using the runner global.`
|
|
117
|
+
}));
|
|
118
|
+
return {
|
|
119
|
+
ImportDeclaration: (node) => {
|
|
120
|
+
collectTestBindings(node, bindings);
|
|
121
|
+
return Effect.void;
|
|
122
|
+
},
|
|
123
|
+
AssignmentExpression: (node) => Option.match(narrow(node, "AssignmentExpression"), {
|
|
124
|
+
onNone: () => Effect.void,
|
|
125
|
+
onSome: (assignment) => reportWrite(assignment.left)
|
|
126
|
+
}),
|
|
127
|
+
UpdateExpression: (node) => Option.match(narrow(node, "UpdateExpression"), {
|
|
128
|
+
onNone: () => Effect.void,
|
|
129
|
+
onSome: (update) => reportWrite(update.argument)
|
|
130
|
+
}),
|
|
131
|
+
UnaryExpression: (node) => Option.match(Option.filter(narrow(node, "UnaryExpression"), (unary) => unary.operator === "delete"), {
|
|
132
|
+
onNone: () => Effect.void,
|
|
133
|
+
onSome: (unary) => reportWrite(unary.argument)
|
|
134
|
+
}),
|
|
135
|
+
CallExpression: (node) => Option.match(narrow(node, "CallExpression"), {
|
|
136
|
+
onNone: () => Effect.void,
|
|
137
|
+
onSome: reportCall
|
|
138
|
+
}),
|
|
139
|
+
"Program:exit": () => Option.match(Option.fromNullishOr(ctx.sourceCode.scopeManager.globalScope), {
|
|
140
|
+
onNone: () => Effect.void,
|
|
141
|
+
onSome: (globalScope) => Effect.forEach(runnerGlobalReferences(globalScope), reportRunnerGlobal, { discard: true })
|
|
142
|
+
})
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
//#endregion
|
|
147
|
+
export { noTestGlobals };
|
|
@@ -6,6 +6,7 @@ import "../vendor/effect-oxlint/index.js";
|
|
|
6
6
|
import * as Option from "effect/Option";
|
|
7
7
|
import * as Effect from "effect/Effect";
|
|
8
8
|
//#region src/rules/no-test-lifecycle-hooks.ts
|
|
9
|
+
/** Ban test lifecycle hooks in favor of Effect scopes and scoped test variants. */
|
|
9
10
|
const lifecycleHooks = new Set([
|
|
10
11
|
"afterAll",
|
|
11
12
|
"afterEach",
|
|
@@ -23,8 +24,9 @@ const noTestLifecycleHooks = define({
|
|
|
23
24
|
return { CallExpression: (node) => Option.match(narrow(node, "CallExpression"), {
|
|
24
25
|
onNone: () => Effect.void,
|
|
25
26
|
onSome: (call) => {
|
|
26
|
-
const
|
|
27
|
-
if (
|
|
27
|
+
const hook = Option.filter(calleeName(call), (name) => lifecycleHooks.has(name));
|
|
28
|
+
if (Option.isNone(hook)) return Effect.void;
|
|
29
|
+
const name = hook.value;
|
|
28
30
|
return ctx.report(make({
|
|
29
31
|
node: call,
|
|
30
32
|
message: `Avoid ${name}(). Use effect-bun-test scoped tests and Effect.acquireRelease for fixture lifecycles.`
|
|
@@ -2,18 +2,24 @@ import { make } from "../vendor/effect-oxlint/Diagnostic.js";
|
|
|
2
2
|
import { RuleContext } from "../vendor/effect-oxlint/RuleContext.js";
|
|
3
3
|
import { define, meta } from "../vendor/effect-oxlint/Rule.js";
|
|
4
4
|
import "../vendor/effect-oxlint/index.js";
|
|
5
|
+
import { expressionArgument, staticProperties } from "./_call-arguments.js";
|
|
5
6
|
import { importedNamespaces, isStaticMember, visibleNamespaces } from "./_effect-namespaces.js";
|
|
7
|
+
import * as Arr from "effect/Array";
|
|
8
|
+
import * as Option from "effect/Option";
|
|
6
9
|
import * as Effect from "effect/Effect";
|
|
7
10
|
//#region src/rules/no-unbounded-concurrency.ts
|
|
11
|
+
/** The first `concurrency: "unbounded"` value in an options object literal. */
|
|
8
12
|
const unboundedConcurrency = (node) => {
|
|
9
|
-
if (node
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
if (node.type !== "ObjectExpression") return Option.none();
|
|
14
|
+
return Option.map(Arr.findFirst(staticProperties(node, "concurrency"), (property) => property.value.type === "Literal" && property.value.value === "unbounded"), (property) => property.value);
|
|
15
|
+
};
|
|
16
|
+
const fixedCollection = (node) => node.type === "ArrayExpression" || node.type === "ObjectExpression";
|
|
17
|
+
/** Where `Effect.all` and `Effect.forEach` take their options. */
|
|
18
|
+
const optionsIndex = (callee, effectNamespaces) => {
|
|
19
|
+
if (isStaticMember(callee, effectNamespaces, "forEach")) return Option.some(2);
|
|
20
|
+
if (isStaticMember(callee, effectNamespaces, "all")) return Option.some(1);
|
|
21
|
+
return Option.none();
|
|
15
22
|
};
|
|
16
|
-
const fixedCollection = (node) => node?.type === "ArrayExpression" || node?.type === "ObjectExpression";
|
|
17
23
|
const noUnboundedConcurrency = define({
|
|
18
24
|
name: "no-unbounded-concurrency",
|
|
19
25
|
meta: meta({
|
|
@@ -32,16 +38,15 @@ const noUnboundedConcurrency = define({
|
|
|
32
38
|
CallExpression: (node) => {
|
|
33
39
|
if (node.type !== "CallExpression" || node.callee.type === "Super") return Effect.void;
|
|
34
40
|
const namespaces = visibleNamespaces(ctx, node, effectNamespaces);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}));
|
|
41
|
+
if (Option.exists(Arr.head(node.arguments), fixedCollection)) return Effect.void;
|
|
42
|
+
const unbounded = Option.flatMap(optionsIndex(node.callee, namespaces), (index) => Option.flatMap(expressionArgument(node, index), unboundedConcurrency));
|
|
43
|
+
return Option.match(unbounded, {
|
|
44
|
+
onNone: () => Effect.void,
|
|
45
|
+
onSome: (value) => ctx.report(make({
|
|
46
|
+
node: value,
|
|
47
|
+
message: "Bound concurrency for a collection that can grow. Use a finite concurrency value."
|
|
48
|
+
}))
|
|
49
|
+
});
|
|
45
50
|
}
|
|
46
51
|
};
|
|
47
52
|
}
|
|
@@ -2,7 +2,10 @@ import { make } from "../vendor/effect-oxlint/Diagnostic.js";
|
|
|
2
2
|
import { RuleContext } from "../vendor/effect-oxlint/RuleContext.js";
|
|
3
3
|
import { define, meta } from "../vendor/effect-oxlint/Rule.js";
|
|
4
4
|
import "../vendor/effect-oxlint/index.js";
|
|
5
|
+
import { expressionArgument, staticProperties } from "./_call-arguments.js";
|
|
5
6
|
import { importedNamespaces, isStaticCall, isStaticMember, visibleNamespaces } from "./_effect-namespaces.js";
|
|
7
|
+
import * as Arr from "effect/Array";
|
|
8
|
+
import * as Option from "effect/Option";
|
|
6
9
|
import * as Effect from "effect/Effect";
|
|
7
10
|
//#region src/rules/no-unbounded-retry.ts
|
|
8
11
|
const unboundedConstructors = new Set([
|
|
@@ -12,68 +15,49 @@ const unboundedConstructors = new Set([
|
|
|
12
15
|
]);
|
|
13
16
|
const boundedCombinators = new Set(["take"]);
|
|
14
17
|
const staticPropertyName = (node) => {
|
|
15
|
-
if (node.type !== "MemberExpression" || node.computed || node.property.type !== "Identifier") return;
|
|
16
|
-
return node.property.name;
|
|
17
|
-
};
|
|
18
|
-
const hasOwnProperty = (node, name) => {
|
|
19
|
-
for (const property of node.properties) {
|
|
20
|
-
if (property.type !== "Property" || property.computed) continue;
|
|
21
|
-
if (property.key.type === "Identifier" && property.key.name === name) return true;
|
|
22
|
-
if (property.key.type === "Literal" && property.key.value === name) return true;
|
|
23
|
-
}
|
|
24
|
-
return false;
|
|
25
|
-
};
|
|
26
|
-
const propertyValue = (node, name) => {
|
|
27
|
-
for (const property of node.properties) {
|
|
28
|
-
if (property.type !== "Property" || property.computed) continue;
|
|
29
|
-
if (property.key.type === "Identifier" && property.key.name === name || property.key.type === "Literal" && property.key.value === name) return property.value;
|
|
30
|
-
}
|
|
18
|
+
if (node.type !== "MemberExpression" || node.computed || node.property.type !== "Identifier") return Option.none();
|
|
19
|
+
return Option.some(node.property.name);
|
|
31
20
|
};
|
|
32
21
|
const isBoundedOperation = (node, scheduleNamespaces) => {
|
|
33
22
|
if (node.type !== "CallExpression" || node.callee.type === "Super") return false;
|
|
34
|
-
const
|
|
35
|
-
if (property
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
const bound = node.arguments[0];
|
|
39
|
-
return bound !== void 0 && bound.type !== "SpreadElement" && isStaticCall(bound, scheduleNamespaces, "recurs");
|
|
23
|
+
const callee = node.callee;
|
|
24
|
+
if (Option.exists(staticPropertyName(callee), (property) => boundedCombinators.has(property) && isStaticMember(callee, scheduleNamespaces, property))) return true;
|
|
25
|
+
if (!isStaticMember(callee, scheduleNamespaces, "both")) return false;
|
|
26
|
+
return Option.exists(expressionArgument(node, 0), (bound) => isStaticCall(bound, scheduleNamespaces, "recurs"));
|
|
40
27
|
};
|
|
41
28
|
const unboundedSchedule = (node, scheduleNamespaces) => {
|
|
42
|
-
if (node.type === "MemberExpression" && isStaticMember(node, scheduleNamespaces, "forever")) return node;
|
|
43
|
-
if (node.type !== "CallExpression" || node.callee.type === "Super") return
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
if (property
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
29
|
+
if (node.type === "MemberExpression" && isStaticMember(node, scheduleNamespaces, "forever")) return Option.some(node);
|
|
30
|
+
if (node.type !== "CallExpression" || node.callee.type === "Super") return Option.none();
|
|
31
|
+
const callee = node.callee;
|
|
32
|
+
const property = staticPropertyName(callee);
|
|
33
|
+
if (Option.exists(property, (name) => unboundedConstructors.has(name) && isStaticMember(callee, scheduleNamespaces, name))) return Option.some(node);
|
|
34
|
+
if (!Option.contains(property, "pipe") || callee.type !== "MemberExpression") return Option.none();
|
|
35
|
+
return Option.filter(unboundedSchedule(callee.object, scheduleNamespaces), () => !node.arguments.some((operation) => isBoundedOperation(operation, scheduleNamespaces)));
|
|
36
|
+
};
|
|
37
|
+
/** Arguments a data-last retry call takes; one more means the data-first form. */
|
|
38
|
+
const retryArity = (callee, effectNamespaces, streamNamespaces, httpClientNamespaces) => {
|
|
39
|
+
if (isStaticMember(callee, effectNamespaces, "retryOrElse")) return Option.some(2);
|
|
40
|
+
if (isStaticMember(callee, effectNamespaces, "retry") || isStaticMember(callee, streamNamespaces, "retry") || isStaticMember(callee, httpClientNamespaces, "retryTransient")) return Option.some(1);
|
|
41
|
+
return Option.none();
|
|
42
|
+
};
|
|
43
|
+
/** The policy argument: first in the data-last form, second in the data-first form. */
|
|
44
|
+
const policyArgument = (node, arity) => {
|
|
45
|
+
if (node.arguments.length < arity) return Option.none();
|
|
46
|
+
let policyIndex = 1;
|
|
47
|
+
if (node.arguments.length === arity) policyIndex = 0;
|
|
48
|
+
return Arr.get(node.arguments, policyIndex);
|
|
49
|
+
};
|
|
50
|
+
/** The schedule a policy argument retries on, unless it sets `times`. */
|
|
51
|
+
const policySchedule = (policy) => {
|
|
52
|
+
if (policy.type === "SpreadElement") return Option.none();
|
|
53
|
+
if (policy.type !== "ObjectExpression") return Option.some(policy);
|
|
54
|
+
if (Arr.isReadonlyArrayNonEmpty(staticProperties(policy, "times"))) return Option.none();
|
|
55
|
+
return Option.map(Arr.head(staticProperties(policy, "schedule")), (property) => property.value);
|
|
51
56
|
};
|
|
52
57
|
const retryPolicy = (node, effectNamespaces, streamNamespaces, httpClientNamespaces) => {
|
|
53
|
-
if (node.callee.type === "Super") return
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (node.arguments.length === 1) policyIndex = 0;
|
|
57
|
-
if (node.arguments.length >= 2) policyIndex = 1;
|
|
58
|
-
}
|
|
59
|
-
if (isStaticMember(node.callee, effectNamespaces, "retryOrElse")) {
|
|
60
|
-
if (node.arguments.length === 2) policyIndex = 0;
|
|
61
|
-
if (node.arguments.length >= 3) policyIndex = 1;
|
|
62
|
-
}
|
|
63
|
-
if (isStaticMember(node.callee, streamNamespaces, "retry")) {
|
|
64
|
-
if (node.arguments.length === 1) policyIndex = 0;
|
|
65
|
-
if (node.arguments.length >= 2) policyIndex = 1;
|
|
66
|
-
}
|
|
67
|
-
if (isStaticMember(node.callee, httpClientNamespaces, "retryTransient")) {
|
|
68
|
-
if (node.arguments.length === 1) policyIndex = 0;
|
|
69
|
-
if (node.arguments.length >= 2) policyIndex = 1;
|
|
70
|
-
}
|
|
71
|
-
if (policyIndex === void 0) return void 0;
|
|
72
|
-
const policy = node.arguments[policyIndex];
|
|
73
|
-
if (policy?.type === "SpreadElement") return void 0;
|
|
74
|
-
if (policy?.type !== "ObjectExpression") return policy;
|
|
75
|
-
if (hasOwnProperty(policy, "times")) return void 0;
|
|
76
|
-
return propertyValue(policy, "schedule");
|
|
58
|
+
if (node.callee.type === "Super") return Option.none();
|
|
59
|
+
const arity = retryArity(node.callee, effectNamespaces, streamNamespaces, httpClientNamespaces);
|
|
60
|
+
return Option.flatMap(Option.flatMap(arity, (count) => policyArgument(node, count)), policySchedule);
|
|
77
61
|
};
|
|
78
62
|
const noUnboundedRetry = define({
|
|
79
63
|
name: "no-unbounded-retry",
|
|
@@ -99,13 +83,14 @@ const noUnboundedRetry = define({
|
|
|
99
83
|
CallExpression: (node) => {
|
|
100
84
|
if (node.type !== "CallExpression") return Effect.void;
|
|
101
85
|
const policy = retryPolicy(node, visibleNamespaces(ctx, node, effectNamespaces), visibleNamespaces(ctx, node, streamNamespaces), visibleNamespaces(ctx, node, httpClientNamespaces));
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
86
|
+
const unbounded = Option.flatMap(policy, (schedule) => unboundedSchedule(schedule, visibleNamespaces(ctx, node, scheduleNamespaces)));
|
|
87
|
+
return Option.match(unbounded, {
|
|
88
|
+
onNone: () => Effect.void,
|
|
89
|
+
onSome: (schedule) => ctx.report(make({
|
|
90
|
+
node: schedule,
|
|
91
|
+
message: "Bound retry attempts or elapsed time. Add times or compose the schedule with Schedule.recurs or Schedule.take."
|
|
92
|
+
}))
|
|
93
|
+
});
|
|
109
94
|
}
|
|
110
95
|
};
|
|
111
96
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as _$_oxlint_plugins0 from "@oxlint/plugins";
|
|
2
2
|
|
|
3
3
|
//#region src/rules/no-unknown-parameters.d.ts
|
|
4
|
-
/** Disallow unknown inputs except
|
|
4
|
+
/** Disallow unknown inputs except error-cause enrichment, type guards, and rejection handlers. */
|
|
5
5
|
declare const noUnknownParameters: _$_oxlint_plugins0.CreateRule;
|
|
6
6
|
//#endregion
|
|
7
7
|
export { noUnknownParameters };
|
|
@@ -2,37 +2,46 @@ import { fromId } from "../vendor/effect-oxlint/Diagnostic.js";
|
|
|
2
2
|
import { RuleContext } from "../vendor/effect-oxlint/RuleContext.js";
|
|
3
3
|
import { define, meta } from "../vendor/effect-oxlint/Rule.js";
|
|
4
4
|
import "../vendor/effect-oxlint/index.js";
|
|
5
|
+
import { containsUnknownType, functionParameterBindingName, functionParameterTypeAnnotation } from "./_anti-slop-function-parameters.js";
|
|
6
|
+
import * as Option from "effect/Option";
|
|
5
7
|
import * as Effect from "effect/Effect";
|
|
6
8
|
//#region src/rules/no-unknown-parameters.ts
|
|
7
|
-
function
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
if (parameter.type === "AssignmentPattern") return parameter.typeAnnotation ?? parameter.left.typeAnnotation;
|
|
11
|
-
return parameter.typeAnnotation;
|
|
9
|
+
function isTypePredicateSubject(owner, parameterName) {
|
|
10
|
+
const predicate = owner.returnType?.typeAnnotation;
|
|
11
|
+
return predicate?.type === "TSTypePredicate" && predicate.parameterName.type === "Identifier" && predicate.parameterName.name === parameterName;
|
|
12
12
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Whether the function is a promise rejection handler: the argument to `.catch`
|
|
15
|
+
* or the second argument to `.then`. Its first parameter is the function form of
|
|
16
|
+
* a `catch` clause binding and receives `unknown` by nature.
|
|
17
|
+
*/
|
|
18
|
+
function isRejectionHandler(owner) {
|
|
19
|
+
if (owner.type !== "ArrowFunctionExpression" && owner.type !== "FunctionExpression") return false;
|
|
20
|
+
const call = owner.parent;
|
|
21
|
+
if (call?.type !== "CallExpression" || call.callee.type !== "MemberExpression" || call.callee.computed || call.callee.property.type !== "Identifier") return false;
|
|
22
|
+
const position = call.arguments.indexOf(owner);
|
|
23
|
+
const method = call.callee.property.name;
|
|
24
|
+
return method === "catch" && position === 0 || method === "then" && position === 1;
|
|
18
25
|
}
|
|
19
|
-
/** Disallow unknown inputs except
|
|
26
|
+
/** Disallow unknown inputs except error-cause enrichment, type guards, and rejection handlers. */
|
|
20
27
|
const noUnknownParameters = define({
|
|
21
28
|
name: "no-unknown-parameters",
|
|
22
29
|
meta: meta({
|
|
23
30
|
type: "problem",
|
|
24
|
-
description: "Disallow explicitly unknown function parameters except `cause
|
|
31
|
+
description: "Disallow explicitly unknown function parameters except `cause`, type-predicate subjects, and the reason a promise rejection handler receives; decode unknown input at its I/O boundary instead.",
|
|
25
32
|
messages: { unknownParameter: "Parameter `{{parameter}}` accepts `unknown` without establishing its contract. Define the expected schema or parser so the value becomes a strongly typed domain type at the earliest possible point, as close as possible to the I/O boundary where the data originated." }
|
|
26
33
|
}),
|
|
27
34
|
create: function* () {
|
|
28
35
|
const context = yield* RuleContext;
|
|
29
36
|
const checkParameters = (node) => Effect.forEach(node.params, (parameter) => {
|
|
30
|
-
const annotation =
|
|
31
|
-
if (annotation
|
|
32
|
-
const
|
|
33
|
-
if (
|
|
37
|
+
const annotation = functionParameterTypeAnnotation(parameter);
|
|
38
|
+
if (Option.isNone(annotation)) return Effect.void;
|
|
39
|
+
const type = annotation.value.typeAnnotation;
|
|
40
|
+
if (!containsUnknownType(type)) return Effect.void;
|
|
41
|
+
const name = functionParameterBindingName(parameter, context.sourceCode);
|
|
42
|
+
if (name === "cause" || isTypePredicateSubject(node, name) || parameter === node.params[0] && isRejectionHandler(node)) return Effect.void;
|
|
34
43
|
return context.report(fromId({
|
|
35
|
-
node:
|
|
44
|
+
node: type,
|
|
36
45
|
messageId: "unknownParameter",
|
|
37
46
|
data: { parameter: name }
|
|
38
47
|
}));
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import * as _$_oxlint_plugins0 from "@oxlint/plugins";
|
|
2
|
+
|
|
3
|
+
//#region src/rules/no-unknown-returns.d.ts
|
|
4
|
+
/** Ban function contracts that return unknown instead of a parsed domain type. */
|
|
5
|
+
declare const noUnknownReturns: _$_oxlint_plugins0.CreateRule;
|
|
6
|
+
//#endregion
|
|
7
|
+
export { noUnknownReturns };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { fromId } from "../vendor/effect-oxlint/Diagnostic.js";
|
|
2
|
+
import { RuleContext } from "../vendor/effect-oxlint/RuleContext.js";
|
|
3
|
+
import { define, meta } from "../vendor/effect-oxlint/Rule.js";
|
|
4
|
+
import "../vendor/effect-oxlint/index.js";
|
|
5
|
+
import { ancestors } from "./_ast-ancestors.js";
|
|
6
|
+
import { createTypeAliasEnvironment, resolvedTypeMatches } from "./_anti-slop-type-alias-resolution.js";
|
|
7
|
+
import * as Arr from "effect/Array";
|
|
8
|
+
import * as Option from "effect/Option";
|
|
9
|
+
import * as Predicate from "effect/Predicate";
|
|
10
|
+
import * as Effect from "effect/Effect";
|
|
11
|
+
//#region src/rules/no-unknown-returns.ts
|
|
12
|
+
/**
|
|
13
|
+
* Whether the node sits in a type pattern rather than a contract: the `extends`
|
|
14
|
+
* clause of a conditional type or a type parameter constraint. There
|
|
15
|
+
* `(...args: never[]) => unknown` matches any function; it promises nothing.
|
|
16
|
+
* (Local change: upstream reports these.)
|
|
17
|
+
*/
|
|
18
|
+
function isInsideTypePattern(node) {
|
|
19
|
+
let child = node;
|
|
20
|
+
for (const parent of ancestors(node)) {
|
|
21
|
+
if (parent.type === "TSConditionalType" && parent.extendsType === child) return true;
|
|
22
|
+
if (parent.type === "TSTypeParameter" && parent.constraint === child) return true;
|
|
23
|
+
child = parent;
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
/** Ban function contracts that return unknown instead of a parsed domain type. */
|
|
28
|
+
const noUnknownReturns = define({
|
|
29
|
+
name: "no-unknown-returns",
|
|
30
|
+
meta: meta({
|
|
31
|
+
type: "problem",
|
|
32
|
+
description: "Disallow functions whose explicit return contract is unknown or Promise<unknown>.",
|
|
33
|
+
messages: { unknownReturn: "This function exposes `unknown` to its caller. Parse the value at its boundary and return a named domain type." }
|
|
34
|
+
}),
|
|
35
|
+
create: function* () {
|
|
36
|
+
const context = yield* RuleContext;
|
|
37
|
+
let environment = Option.none();
|
|
38
|
+
const resolvesToUnknown = (type) => Option.exists(environment, (present) => resolvedTypeMatches(type, present, (resolved, matches) => {
|
|
39
|
+
if (resolved.type === "TSUnknownKeyword") return true;
|
|
40
|
+
if (resolved.type === "TSParenthesizedType") return matches(resolved.typeAnnotation);
|
|
41
|
+
if (resolved.type === "TSUnionType") return resolved.types.some(matches);
|
|
42
|
+
if (resolved.type !== "TSTypeReference" || resolved.typeName.type !== "Identifier" || resolved.typeName.name !== "Promise" && resolved.typeName.name !== "PromiseLike") return false;
|
|
43
|
+
return Option.exists(Arr.head(resolved.typeArguments?.params ?? []), matches);
|
|
44
|
+
}));
|
|
45
|
+
const checkReturnType = (node) => {
|
|
46
|
+
const annotation = node.returnType;
|
|
47
|
+
if (Predicate.isNullish(annotation)) return Effect.void;
|
|
48
|
+
if (!resolvesToUnknown(annotation.typeAnnotation)) return Effect.void;
|
|
49
|
+
if (isInsideTypePattern(node)) return Effect.void;
|
|
50
|
+
return context.report(fromId({
|
|
51
|
+
node: annotation.typeAnnotation,
|
|
52
|
+
messageId: "unknownReturn"
|
|
53
|
+
}));
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
Program: (node) => {
|
|
57
|
+
environment = Option.some(createTypeAliasEnvironment(node, context.sourceCode.visitorKeys));
|
|
58
|
+
return Effect.void;
|
|
59
|
+
},
|
|
60
|
+
ArrowFunctionExpression: checkReturnType,
|
|
61
|
+
FunctionDeclaration: checkReturnType,
|
|
62
|
+
FunctionExpression: checkReturnType,
|
|
63
|
+
TSCallSignatureDeclaration: checkReturnType,
|
|
64
|
+
TSConstructSignatureDeclaration: checkReturnType,
|
|
65
|
+
TSConstructorType: checkReturnType,
|
|
66
|
+
TSDeclareFunction: checkReturnType,
|
|
67
|
+
TSEmptyBodyFunctionExpression: checkReturnType,
|
|
68
|
+
TSFunctionType: checkReturnType,
|
|
69
|
+
TSMethodSignature: checkReturnType
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
//#endregion
|
|
74
|
+
export { noUnknownReturns };
|