oxlint-plugin-effect 0.14.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 +2 -1
- package/dist/presets/recommended.d.ts +1 -0
- package/dist/presets/recommended.js +1 -0
- package/dist/rules/_anti-slop-array-method.js +41 -33
- package/dist/rules/_anti-slop-dictionary-types.js +172 -129
- package/dist/rules/_anti-slop-function-parameters.js +9 -6
- package/dist/rules/_anti-slop-lexical-type-parameters.js +6 -14
- package/dist/rules/_anti-slop-reflect-method.js +7 -3
- package/dist/rules/_anti-slop-scope.js +8 -6
- package/dist/rules/_anti-slop-type-alias-resolution.js +58 -65
- 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 +63 -44
- package/dist/rules/_tagged-control-flow.js +38 -60
- package/dist/rules/_test-apis.js +47 -0
- package/dist/rules/index.d.ts +2 -1
- package/dist/rules/index.js +2 -0
- package/dist/rules/no-array-filter-map.js +24 -14
- package/dist/rules/no-chained-type-assertions.js +7 -4
- package/dist/rules/no-conditional-empty-object-spread.js +7 -4
- package/dist/rules/no-dynamic-imports.js +33 -25
- package/dist/rules/no-eager-acquire.js +40 -34
- package/dist/rules/no-effect-run-in-tests.js +11 -8
- package/dist/rules/no-globals.js +42 -31
- package/dist/rules/no-known-value-widening.js +64 -39
- package/dist/rules/no-module-level-mutable-state.js +4 -8
- 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 +9 -7
- package/dist/rules/no-per-call-cache-construction.js +2 -2
- package/dist/rules/no-reduce-accumulator-copy.js +65 -53
- package/dist/rules/no-reflect-apply.js +7 -4
- package/dist/rules/no-reflect-get.js +7 -4
- package/dist/rules/no-run-collect-on-unbounded-stream.js +23 -25
- package/dist/rules/no-runtime-typeof.js +9 -5
- package/dist/rules/no-sequential-effect-all.js +18 -11
- package/dist/rules/no-shape-in-symbol-names.js +10 -6
- 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.js +5 -3
- package/dist/rules/no-unknown-returns.js +11 -10
- package/dist/rules/no-unknown-type-aliases.js +13 -9
- package/dist/rules/no-unsafe-dictionary-type.js +22 -27
- package/dist/rules/no-widen-then-assert.js +95 -72
- 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.js +8 -3
- package/dist/vendor/effect-oxlint/Scope.js +1 -1
- package/package.json +1 -1
|
@@ -1,79 +1,57 @@
|
|
|
1
|
+
import { ancestors } from "./_ast-ancestors.js";
|
|
2
|
+
import * as Arr from "effect/Array";
|
|
3
|
+
import * as Option from "effect/Option";
|
|
4
|
+
import * as Predicate from "effect/Predicate";
|
|
1
5
|
//#region src/rules/_tagged-control-flow.ts
|
|
2
6
|
const expressionKey = (node) => {
|
|
3
|
-
if (node.type === "Identifier") return node.name;
|
|
4
|
-
if (node.type === "ThisExpression") return "this";
|
|
5
|
-
if (node.type !== "MemberExpression" || node.computed || node.property.type !== "Identifier") return
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
return `${object}.${node.property.name}`;
|
|
7
|
+
if (node.type === "Identifier") return Option.some(node.name);
|
|
8
|
+
if (node.type === "ThisExpression") return Option.some("this");
|
|
9
|
+
if (node.type !== "MemberExpression" || node.computed || node.property.type !== "Identifier") return Option.none();
|
|
10
|
+
const property = node.property.name;
|
|
11
|
+
return Option.map(expressionKey(node.object), (object) => `${object}.${property}`);
|
|
9
12
|
};
|
|
10
13
|
const stringLiteral = (node) => {
|
|
11
|
-
if (node.type !== "Literal") return
|
|
12
|
-
|
|
13
|
-
return node.value;
|
|
14
|
+
if (node.type !== "Literal" || !Predicate.isString(node.value)) return Option.none();
|
|
15
|
+
return Option.some(node.value);
|
|
14
16
|
};
|
|
15
17
|
const taggedSubject = (node) => {
|
|
16
|
-
if (node.type !== "MemberExpression" || node.computed || node.property.type !== "Identifier" || node.property.name !== "_tag") return
|
|
18
|
+
if (node.type !== "MemberExpression" || node.computed || node.property.type !== "Identifier" || node.property.name !== "_tag") return Option.none();
|
|
17
19
|
return expressionKey(node.object);
|
|
18
20
|
};
|
|
21
|
+
/** `subjectSide._tag === "Tag"` read from one orientation of the comparison. */
|
|
22
|
+
const orientedComparison = (subjectSide, tagSide) => Option.all({
|
|
23
|
+
subject: taggedSubject(subjectSide),
|
|
24
|
+
tag: stringLiteral(tagSide)
|
|
25
|
+
});
|
|
19
26
|
const tagComparison = (node) => {
|
|
20
|
-
if (node.type !== "BinaryExpression" || node.operator !== "===") return
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
if (leftSubject !== null && rightTag !== null) return {
|
|
24
|
-
subject: leftSubject,
|
|
25
|
-
tag: rightTag
|
|
26
|
-
};
|
|
27
|
-
const rightSubject = taggedSubject(node.right);
|
|
28
|
-
const leftTag = stringLiteral(node.left);
|
|
29
|
-
if (rightSubject === null || leftTag === null) return null;
|
|
30
|
-
return {
|
|
31
|
-
subject: rightSubject,
|
|
32
|
-
tag: leftTag
|
|
33
|
-
};
|
|
27
|
+
if (node.type !== "BinaryExpression" || node.operator !== "===") return Option.none();
|
|
28
|
+
const { left, right } = node;
|
|
29
|
+
return Option.orElse(orientedComparison(left, right), () => orientedComparison(right, left));
|
|
34
30
|
};
|
|
35
31
|
const tagComparisonsInOr = (node) => {
|
|
36
|
-
if (node.type === "LogicalExpression" && node.operator === "||")
|
|
37
|
-
|
|
38
|
-
const right = tagComparisonsInOr(node.right);
|
|
39
|
-
if (left === null || right === null) return null;
|
|
40
|
-
return [...left, ...right];
|
|
41
|
-
}
|
|
42
|
-
const comparison = tagComparison(node);
|
|
43
|
-
if (comparison === null) return null;
|
|
44
|
-
return [comparison];
|
|
45
|
-
};
|
|
46
|
-
const hasOneTaggedSubject = (comparisons) => {
|
|
47
|
-
const first = comparisons[0];
|
|
48
|
-
if (first === void 0 || comparisons.length < 2) return false;
|
|
49
|
-
return comparisons.every((comparison) => comparison.subject === first.subject);
|
|
32
|
+
if (node.type === "LogicalExpression" && node.operator === "||") return Option.zipWith(tagComparisonsInOr(node.left), tagComparisonsInOr(node.right), (left, right) => [...left, ...right]);
|
|
33
|
+
return Option.map(tagComparison(node), (comparison) => [comparison]);
|
|
50
34
|
};
|
|
35
|
+
const hasOneTaggedSubject = (comparisons) => comparisons.length >= 2 && Option.exists(Arr.head(comparisons), (first) => comparisons.every((comparison) => comparison.subject === first.subject));
|
|
51
36
|
const isEffectCall = (node, operation) => node.callee.type !== "Super" && node.callee.type === "MemberExpression" && !node.callee.computed && node.callee.object.type === "Identifier" && node.callee.object.name === "Effect" && node.callee.property.type === "Identifier" && node.callee.property.name === operation;
|
|
52
|
-
const isFunction = (node) => node
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
current = current.parent;
|
|
67
|
-
}
|
|
68
|
-
return null;
|
|
69
|
-
};
|
|
70
|
-
const isInsideCatchAllHandler = (node) => {
|
|
71
|
-
const location = effectCallbackLocation(node, "catchAll");
|
|
72
|
-
if (location === null) return false;
|
|
37
|
+
const isFunction = (node) => node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression";
|
|
38
|
+
const callbackLocation = (callback, operation) => {
|
|
39
|
+
const call = callback.parent;
|
|
40
|
+
if (call?.type !== "CallExpression" || !isEffectCall(call, operation)) return Option.none();
|
|
41
|
+
const index = call.arguments.indexOf(callback);
|
|
42
|
+
if (index < 0) return Option.none();
|
|
43
|
+
return Option.some({
|
|
44
|
+
argumentCount: call.arguments.length,
|
|
45
|
+
index
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
/** Where the nearest enclosing function sits among the arguments of an `Effect.<operation>` call. */
|
|
49
|
+
const effectCallbackLocation = (node, operation) => Option.flatMap(Arr.findFirst(ancestors(node), isFunction), (callback) => callbackLocation(callback, operation));
|
|
50
|
+
const isInsideCatchAllHandler = (node) => Option.exists(effectCallbackLocation(node, "catchAll"), (location) => {
|
|
73
51
|
let handlerIndex = 1;
|
|
74
52
|
if (location.argumentCount === 1) handlerIndex = 0;
|
|
75
53
|
return location.index === handlerIndex;
|
|
76
|
-
};
|
|
54
|
+
});
|
|
77
55
|
const taggedSwitchSubject = (node) => taggedSubject(node.discriminant);
|
|
78
56
|
//#endregion
|
|
79
57
|
export { effectCallbackLocation, hasOneTaggedSubject, isEffectCall, isInsideCatchAllHandler, tagComparison, tagComparisonsInOr, taggedSwitchSubject };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { importSource, narrow } from "../vendor/effect-oxlint/AST.js";
|
|
2
|
+
import { findVariableUp } from "../vendor/effect-oxlint/Scope.js";
|
|
3
|
+
import "../vendor/effect-oxlint/index.js";
|
|
4
|
+
import * as Option from "effect/Option";
|
|
5
|
+
//#region src/rules/_test-apis.ts
|
|
6
|
+
const bindingsBySource = new Map([
|
|
7
|
+
["vitest", new Map([["vi", "vi"]])],
|
|
8
|
+
["@jest/globals", new Map([["jest", "jest"]])],
|
|
9
|
+
["bun:test", new Map([
|
|
10
|
+
["jest", "jest"],
|
|
11
|
+
["mock", "mock"],
|
|
12
|
+
["spyOn", "spyOn"],
|
|
13
|
+
["vi", "vi"]
|
|
14
|
+
])]
|
|
15
|
+
]);
|
|
16
|
+
/** Runner objects that exist as ambient globals when no import provides them. */
|
|
17
|
+
const globalBinding = (name) => {
|
|
18
|
+
if (name === "jest" || name === "vi") return Option.some(name);
|
|
19
|
+
return Option.none();
|
|
20
|
+
};
|
|
21
|
+
const identifierName = (node) => {
|
|
22
|
+
if (node.type === "Identifier") return Option.some(node.name);
|
|
23
|
+
return Option.none();
|
|
24
|
+
};
|
|
25
|
+
/** Record each named import of a runner binding under its local name. */
|
|
26
|
+
const collectTestBindings = (node, bindings) => {
|
|
27
|
+
const declaration = narrow(node, "ImportDeclaration");
|
|
28
|
+
if (Option.isNone(declaration)) return;
|
|
29
|
+
const bySource = Option.fromUndefinedOr(bindingsBySource.get(importSource(declaration.value)));
|
|
30
|
+
if (Option.isNone(bySource)) return;
|
|
31
|
+
for (const specifier of declaration.value.specifiers) {
|
|
32
|
+
if (specifier.type !== "ImportSpecifier") continue;
|
|
33
|
+
const binding = Option.flatMap(identifierName(specifier.imported), (imported) => Option.fromUndefinedOr(bySource.value.get(imported)));
|
|
34
|
+
if (Option.isSome(binding)) bindings.set(specifier.local.name, binding.value);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const bindingFromVariable = (variable, bindings) => {
|
|
38
|
+
if (variable.defs.some((definition) => definition.type === "ImportBinding")) return Option.fromUndefinedOr(bindings.get(variable.name));
|
|
39
|
+
return Option.none();
|
|
40
|
+
};
|
|
41
|
+
/** The runner binding `name` refers to at `scope`: an imported binding, or the unshadowed global. */
|
|
42
|
+
const resolveTestBinding = (scope, name, bindings) => Option.match(findVariableUp(scope, name), {
|
|
43
|
+
onNone: () => globalBinding(name),
|
|
44
|
+
onSome: (variable) => Option.orElse(bindingFromVariable(variable, bindings), () => Option.filter(globalBinding(name), () => variable.defs.length === 0))
|
|
45
|
+
});
|
|
46
|
+
//#endregion
|
|
47
|
+
export { collectTestBindings, resolveTestBinding };
|
package/dist/rules/index.d.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { noSequentialEffectAll } from "./no-sequential-effect-all.js";
|
|
|
32
32
|
import { noShapeInSymbolNames } from "./no-shape-in-symbol-names.js";
|
|
33
33
|
import { noSilentCatchAll } from "./no-silent-catch-all.js";
|
|
34
34
|
import { noTernary } from "./no-ternary.js";
|
|
35
|
+
import { noTestGlobals } from "./no-test-globals.js";
|
|
35
36
|
import { noTestLifecycleHooks } from "./no-test-lifecycle-hooks.js";
|
|
36
37
|
import { noThrowStatement } from "./no-throw-statement.js";
|
|
37
38
|
import { noTryCatch } from "./no-try-catch.js";
|
|
@@ -52,7 +53,7 @@ import { requireSuppressionReason } from "./require-suppression-reason.js";
|
|
|
52
53
|
|
|
53
54
|
//#region src/rules/index.d.ts
|
|
54
55
|
declare namespace index_d_exports {
|
|
55
|
-
export { maxCognitiveComplexity, maxHalsteadDifficulty, noArrayFilterMap, noAs, noAsyncFunction, noChainedTypeAssertions, noConditionalEmptyObjectSpread, noDynamicImports, noEagerAcquire, noEffectBind, noEffectDo, noEffectRunInTests, noGlobals, noInlineProvide, noKnownValueWidening, noManagedRuntimeInEffect, noModuleLevelMutableState, noModuleMocks, noNestedEffectGen, noNewError, noNewPromise, noNodeBuiltinImport, noNullish, noObjectParameters, noPerCallCacheConstruction, noReduceAccumulatorCopy, noReflectApply, noReflectGet, noRunCollectOnUnboundedStream, noRuntimeTypeof, noSequentialEffectAll, noShapeInSymbolNames, noSilentCatchAll, noTernary, noTestLifecycleHooks, noThrowStatement, noTryCatch, noUnboundedConcurrency, noUnboundedRetry, noUnknownParameters, noUnknownReturns, noUnknownTypeAliases, noUnsafeDictionaryType, noWidenThenAssert, preferCatchTag, preferEffectFn, preferMatchTagsExhaustive, preferPredicateIsTagged, preferServiceOf, requireNamedEffectFn, requireSuppressionReason };
|
|
56
|
+
export { maxCognitiveComplexity, maxHalsteadDifficulty, noArrayFilterMap, noAs, noAsyncFunction, noChainedTypeAssertions, noConditionalEmptyObjectSpread, noDynamicImports, noEagerAcquire, noEffectBind, noEffectDo, noEffectRunInTests, noGlobals, noInlineProvide, noKnownValueWidening, noManagedRuntimeInEffect, noModuleLevelMutableState, noModuleMocks, noNestedEffectGen, noNewError, noNewPromise, noNodeBuiltinImport, noNullish, noObjectParameters, noPerCallCacheConstruction, noReduceAccumulatorCopy, noReflectApply, noReflectGet, noRunCollectOnUnboundedStream, noRuntimeTypeof, noSequentialEffectAll, noShapeInSymbolNames, noSilentCatchAll, noTernary, noTestGlobals, noTestLifecycleHooks, noThrowStatement, noTryCatch, noUnboundedConcurrency, noUnboundedRetry, noUnknownParameters, noUnknownReturns, noUnknownTypeAliases, noUnsafeDictionaryType, noWidenThenAssert, preferCatchTag, preferEffectFn, preferMatchTagsExhaustive, preferPredicateIsTagged, preferServiceOf, requireNamedEffectFn, requireSuppressionReason };
|
|
56
57
|
}
|
|
57
58
|
//#endregion
|
|
58
59
|
export { index_d_exports };
|
package/dist/rules/index.js
CHANGED
|
@@ -33,6 +33,7 @@ import { noSequentialEffectAll } from "./no-sequential-effect-all.js";
|
|
|
33
33
|
import { noShapeInSymbolNames } from "./no-shape-in-symbol-names.js";
|
|
34
34
|
import { noSilentCatchAll } from "./no-silent-catch-all.js";
|
|
35
35
|
import { noTernary } from "./no-ternary.js";
|
|
36
|
+
import { noTestGlobals } from "./no-test-globals.js";
|
|
36
37
|
import { noTestLifecycleHooks } from "./no-test-lifecycle-hooks.js";
|
|
37
38
|
import { noThrowStatement } from "./no-throw-statement.js";
|
|
38
39
|
import { noTryCatch } from "./no-try-catch.js";
|
|
@@ -86,6 +87,7 @@ var rules_exports = /* @__PURE__ */ __exportAll({
|
|
|
86
87
|
noShapeInSymbolNames: () => noShapeInSymbolNames,
|
|
87
88
|
noSilentCatchAll: () => noSilentCatchAll,
|
|
88
89
|
noTernary: () => noTernary,
|
|
90
|
+
noTestGlobals: () => noTestGlobals,
|
|
89
91
|
noTestLifecycleHooks: () => noTestLifecycleHooks,
|
|
90
92
|
noThrowStatement: () => noThrowStatement,
|
|
91
93
|
noTryCatch: () => noTryCatch,
|
|
@@ -3,8 +3,25 @@ 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 { arrayMethodTarget, isKnownArrayExpression, unwrapArrayExpression } from "./_anti-slop-array-method.js";
|
|
6
|
+
import * as Option from "effect/Option";
|
|
6
7
|
import * as Effect from "effect/Effect";
|
|
7
8
|
//#region src/rules/no-array-filter-map.ts
|
|
9
|
+
const pairedMethod = (name) => {
|
|
10
|
+
if (name === "map") return Option.some("filter");
|
|
11
|
+
if (name === "filter") return Option.some("map");
|
|
12
|
+
return Option.none();
|
|
13
|
+
};
|
|
14
|
+
function filterMapPair(sourceCode, node) {
|
|
15
|
+
return Option.flatMap(arrayMethodTarget(node.callee), (outer) => Option.flatMap(pairedMethod(outer.name), (pairedName) => {
|
|
16
|
+
const innerCall = unwrapArrayExpression(outer.object);
|
|
17
|
+
if (innerCall.type !== "CallExpression") return Option.none();
|
|
18
|
+
const inner = Option.filter(arrayMethodTarget(innerCall.callee), (method) => method.name === pairedName && isKnownArrayExpression(sourceCode, method.object));
|
|
19
|
+
return Option.map(inner, (method) => ({
|
|
20
|
+
first: method.name,
|
|
21
|
+
second: outer.name
|
|
22
|
+
}));
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
8
25
|
/** Reject eager array filter/map pipelines; lazy iterator helpers remain allowed. */
|
|
9
26
|
const noArrayFilterMap = define({
|
|
10
27
|
name: "no-array-filter-map",
|
|
@@ -15,24 +32,17 @@ const noArrayFilterMap = define({
|
|
|
15
32
|
}),
|
|
16
33
|
create: function* () {
|
|
17
34
|
const context = yield* RuleContext;
|
|
18
|
-
return { CallExpression: (node) => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const innerCall = unwrapArrayExpression(outer.object);
|
|
22
|
-
if (innerCall.type !== "CallExpression") return Effect.void;
|
|
23
|
-
const inner = arrayMethodTarget(innerCall.callee);
|
|
24
|
-
const pairedName = outer.name === "map" ? "filter" : "map";
|
|
25
|
-
if (inner === null || inner.name !== pairedName) return Effect.void;
|
|
26
|
-
if (!isKnownArrayExpression(context.sourceCode, inner.object)) return Effect.void;
|
|
27
|
-
return context.report(fromId({
|
|
35
|
+
return { CallExpression: (node) => Option.match(filterMapPair(context.sourceCode, node), {
|
|
36
|
+
onNone: () => Effect.void,
|
|
37
|
+
onSome: ({ first, second }) => context.report(fromId({
|
|
28
38
|
node,
|
|
29
39
|
messageId: "arrayFilterMap",
|
|
30
40
|
data: {
|
|
31
|
-
first
|
|
32
|
-
second
|
|
41
|
+
first,
|
|
42
|
+
second
|
|
33
43
|
}
|
|
34
|
-
}))
|
|
35
|
-
} };
|
|
44
|
+
}))
|
|
45
|
+
}) };
|
|
36
46
|
}
|
|
37
47
|
});
|
|
38
48
|
//#endregion
|
|
@@ -46,10 +46,13 @@ const noChainedTypeAssertions = define({
|
|
|
46
46
|
}),
|
|
47
47
|
create: function* () {
|
|
48
48
|
const context = yield* RuleContext;
|
|
49
|
-
const checkTypeAssertion = (node) =>
|
|
50
|
-
node
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
const checkTypeAssertion = (node) => {
|
|
50
|
+
if (!isOutermostAssertionInChain(node) || !isForbiddenAssertionChain(node)) return Effect.void;
|
|
51
|
+
return context.report(fromId({
|
|
52
|
+
node,
|
|
53
|
+
messageId: "chained"
|
|
54
|
+
}));
|
|
55
|
+
};
|
|
53
56
|
return {
|
|
54
57
|
TSAsExpression: checkTypeAssertion,
|
|
55
58
|
TSTypeAssertion: checkTypeAssertion
|
|
@@ -26,10 +26,13 @@ const noConditionalEmptyObjectSpread = define({
|
|
|
26
26
|
}),
|
|
27
27
|
create: function* () {
|
|
28
28
|
const context = yield* RuleContext;
|
|
29
|
-
return { SpreadElement: (node) =>
|
|
30
|
-
node
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
return { SpreadElement: (node) => {
|
|
30
|
+
if (node.parent?.type !== "ObjectExpression" || !isConditionalEmptyObjectSpread(node.argument)) return Effect.void;
|
|
31
|
+
return context.report(fromId({
|
|
32
|
+
node,
|
|
33
|
+
messageId: "avoid"
|
|
34
|
+
}));
|
|
35
|
+
} };
|
|
33
36
|
}
|
|
34
37
|
});
|
|
35
38
|
//#endregion
|
|
@@ -3,14 +3,20 @@ import { make } from "../vendor/effect-oxlint/Diagnostic.js";
|
|
|
3
3
|
import { RuleContext } from "../vendor/effect-oxlint/RuleContext.js";
|
|
4
4
|
import { define, meta } from "../vendor/effect-oxlint/Rule.js";
|
|
5
5
|
import "../vendor/effect-oxlint/index.js";
|
|
6
|
+
import { ancestors, parentOf } from "./_ast-ancestors.js";
|
|
6
7
|
import * as Option from "effect/Option";
|
|
8
|
+
import * as Predicate from "effect/Predicate";
|
|
7
9
|
import * as Effect from "effect/Effect";
|
|
8
10
|
//#region src/rules/no-dynamic-imports.ts
|
|
9
|
-
const identifierName = (node) =>
|
|
11
|
+
const identifierName = (node) => {
|
|
12
|
+
if (node.type === "Identifier") return Option.some(node.name);
|
|
13
|
+
return Option.none();
|
|
14
|
+
};
|
|
15
|
+
const isIdentifierNamed = (node, name) => Option.contains(identifierName(node), name);
|
|
10
16
|
const hasNamedBinding = (pattern) => {
|
|
11
17
|
if (pattern.type === "Identifier") return true;
|
|
12
18
|
if (pattern.type === "ObjectPattern") return pattern.properties.length > 0;
|
|
13
|
-
if (pattern.type === "ArrayPattern") return pattern.elements.some(
|
|
19
|
+
if (pattern.type === "ArrayPattern") return pattern.elements.some(Predicate.isNotNull);
|
|
14
20
|
return false;
|
|
15
21
|
};
|
|
16
22
|
const isNamedVariable = (node, value) => node.type === "VariableDeclarator" && node.init === value && hasNamedBinding(node.id);
|
|
@@ -22,42 +28,45 @@ const transparentWrappers = new Set([
|
|
|
22
28
|
"TSTypeAssertion",
|
|
23
29
|
"YieldExpression"
|
|
24
30
|
]);
|
|
31
|
+
/** The outermost node reached from `start` through transparent wrapper ancestors. */
|
|
25
32
|
const climbTransparent = (start) => {
|
|
26
33
|
let current = start;
|
|
27
|
-
|
|
34
|
+
for (const parent of ancestors(start)) {
|
|
35
|
+
if (!transparentWrappers.has(parent.type)) break;
|
|
36
|
+
current = parent;
|
|
37
|
+
}
|
|
28
38
|
return current;
|
|
29
39
|
};
|
|
30
40
|
const isNamedDirectBinding = (node) => {
|
|
31
41
|
const value = climbTransparent(node);
|
|
32
|
-
return value
|
|
42
|
+
return Option.exists(parentOf(value), (parent) => isNamedVariable(parent, value));
|
|
33
43
|
};
|
|
44
|
+
const isNamedFunctionDeclarationBody = (block) => block.type === "BlockStatement" && Option.exists(parentOf(block), (fn) => fn.type === "FunctionDeclaration" && Predicate.isNotNull(fn.id));
|
|
34
45
|
const isNamedFunctionBoundary = (node) => {
|
|
35
46
|
const parent = node.parent;
|
|
36
|
-
if (parent?.type === "ArrowFunctionExpression" && parent.body === node) return
|
|
47
|
+
if (parent?.type === "ArrowFunctionExpression" && parent.body === node) return Option.exists(parentOf(parent), (declarator) => isNamedVariable(declarator, parent));
|
|
37
48
|
if (parent?.type !== "ReturnStatement" || parent.argument !== node) return false;
|
|
38
|
-
|
|
39
|
-
const fn = block?.parent;
|
|
40
|
-
return block?.type === "BlockStatement" && fn?.type === "FunctionDeclaration" && fn.id !== null;
|
|
49
|
+
return Option.exists(parentOf(parent), isNamedFunctionDeclarationBody);
|
|
41
50
|
};
|
|
42
51
|
const isEffectPromiseCall = (node) => {
|
|
43
52
|
if (node.type !== "CallExpression") return false;
|
|
44
53
|
const callee = node.callee;
|
|
45
54
|
if (callee.type !== "MemberExpression" || callee.computed) return false;
|
|
46
|
-
return
|
|
55
|
+
return isIdentifierNamed(callee.object, "Effect") && Option.exists(identifierName(callee.property), (name) => ["promise", "tryPromise"].includes(name));
|
|
47
56
|
};
|
|
48
57
|
const isEffectPromiseBoundary = (node) => {
|
|
49
58
|
const callback = node.parent;
|
|
50
59
|
if (callback?.type !== "ArrowFunctionExpression" || callback.body !== node) return false;
|
|
51
|
-
|
|
52
|
-
return call != null && isEffectPromiseCall(call);
|
|
60
|
+
return Option.exists(parentOf(callback), isEffectPromiseCall);
|
|
53
61
|
};
|
|
54
62
|
const isNamedLazyBoundary = (node) => {
|
|
55
63
|
return isNamedDirectBinding(node) || isNamedFunctionBoundary(node) || isEffectPromiseBoundary(node);
|
|
56
64
|
};
|
|
57
65
|
const dynamicRequireMessage = (callee) => {
|
|
58
|
-
if (
|
|
59
|
-
if (callee
|
|
60
|
-
|
|
66
|
+
if (isIdentifierNamed(callee, "require")) return Option.some("Avoid require(). Use a static import.");
|
|
67
|
+
if (callee.type !== "MemberExpression") return Option.none();
|
|
68
|
+
if (isIdentifierNamed(callee.object, "module") && isIdentifierNamed(callee.property, "require")) return Option.some("Avoid module.require(). Use a static import.");
|
|
69
|
+
return Option.none();
|
|
61
70
|
};
|
|
62
71
|
const noDynamicImports = define({
|
|
63
72
|
name: "no-dynamic-imports",
|
|
@@ -78,7 +87,7 @@ const noDynamicImports = define({
|
|
|
78
87
|
onNone: () => Effect.void,
|
|
79
88
|
onSome: (declaration) => {
|
|
80
89
|
if (!["module", "node:module"].includes(importSource(declaration))) return Effect.void;
|
|
81
|
-
for (const specifier of declaration.specifiers) if (specifier.type === "ImportSpecifier" &&
|
|
90
|
+
for (const specifier of declaration.specifiers) if (specifier.type === "ImportSpecifier" && isIdentifierNamed(specifier.imported, "createRequire")) createRequireNames.add(specifier.local.name);
|
|
82
91
|
return Effect.void;
|
|
83
92
|
}
|
|
84
93
|
}),
|
|
@@ -94,21 +103,20 @@ const noDynamicImports = define({
|
|
|
94
103
|
}),
|
|
95
104
|
ImportExpression: (node) => Option.match(narrow(node, "ImportExpression"), {
|
|
96
105
|
onNone: () => Effect.void,
|
|
97
|
-
onSome: (importExpression) =>
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
106
|
+
onSome: (importExpression) => {
|
|
107
|
+
if (isNamedLazyBoundary(importExpression)) return Effect.void;
|
|
108
|
+
return report(node, "Avoid inline dynamic imports. Bind the imported module or a lazy loader to a descriptive name before using it.");
|
|
109
|
+
}
|
|
101
110
|
}),
|
|
102
111
|
CallExpression: (node) => {
|
|
103
112
|
return Option.match(narrow(node, "CallExpression"), {
|
|
104
113
|
onNone: () => Effect.void,
|
|
105
114
|
onSome: (call) => {
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}));
|
|
115
|
+
const aliasMessage = Option.map(Option.filter(identifierName(call.callee), (name) => requireAliases.has(name)), () => "Avoid createRequire aliases. Keep module loading static.");
|
|
116
|
+
return Option.match(Option.orElse(aliasMessage, () => dynamicRequireMessage(call.callee)), {
|
|
117
|
+
onNone: () => Effect.void,
|
|
118
|
+
onSome: (message) => report(call, message)
|
|
119
|
+
});
|
|
112
120
|
}
|
|
113
121
|
});
|
|
114
122
|
}
|
|
@@ -3,8 +3,12 @@ import { RuleContext } from "../vendor/effect-oxlint/RuleContext.js";
|
|
|
3
3
|
import { define, meta } from "../vendor/effect-oxlint/Rule.js";
|
|
4
4
|
import { findVariableUp } from "../vendor/effect-oxlint/Scope.js";
|
|
5
5
|
import "../vendor/effect-oxlint/index.js";
|
|
6
|
+
import { ancestors } from "./_ast-ancestors.js";
|
|
7
|
+
import { expressionArgument } from "./_call-arguments.js";
|
|
6
8
|
import { importedNamespaces, isStaticCall, visibleNamespaces } from "./_effect-namespaces.js";
|
|
9
|
+
import * as Arr from "effect/Array";
|
|
7
10
|
import * as Option from "effect/Option";
|
|
11
|
+
import * as Predicate from "effect/Predicate";
|
|
8
12
|
import * as Effect from "effect/Effect";
|
|
9
13
|
//#region src/rules/no-eager-acquire.ts
|
|
10
14
|
const unwrapExpression = (node) => {
|
|
@@ -13,21 +17,19 @@ const unwrapExpression = (node) => {
|
|
|
13
17
|
return current;
|
|
14
18
|
};
|
|
15
19
|
const isWithin = (node, ancestor) => {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (current === ancestor) return true;
|
|
19
|
-
current = current.parent;
|
|
20
|
-
}
|
|
20
|
+
if (node === ancestor) return true;
|
|
21
|
+
for (const current of ancestors(node)) if (current === ancestor) return true;
|
|
21
22
|
return false;
|
|
22
23
|
};
|
|
24
|
+
const isThunk = (node) => node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression";
|
|
25
|
+
/** The value a thunk returns, when its body is an expression or holds exactly one return. */
|
|
23
26
|
const singleReturnedValue = (thunk) => {
|
|
24
27
|
const body = thunk.body;
|
|
25
|
-
if (body
|
|
26
|
-
if (body.type !== "BlockStatement") return body;
|
|
28
|
+
if (Predicate.isNull(body)) return Option.none();
|
|
29
|
+
if (body.type !== "BlockStatement") return Option.some(body);
|
|
27
30
|
const returns = body.body.filter((statement) => statement.type === "ReturnStatement");
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return returned.argument ?? void 0;
|
|
31
|
+
if (returns.length !== 1) return Option.none();
|
|
32
|
+
return Option.flatMap(Arr.head(returns), (returned) => Option.fromNullOr(returned.argument));
|
|
31
33
|
};
|
|
32
34
|
const noEagerAcquire = define({
|
|
33
35
|
name: "no-eager-acquire",
|
|
@@ -46,26 +48,27 @@ const noEagerAcquire = define({
|
|
|
46
48
|
if (value.type !== "Identifier") return false;
|
|
47
49
|
return Option.match(findVariableUp(ctx.sourceCode.getScope(value), value.name), {
|
|
48
50
|
onNone: () => true,
|
|
49
|
-
onSome: (variable) => {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
seen.add(definition.node);
|
|
54
|
-
if (!isWithin(definition.node, thunk)) return true;
|
|
55
|
-
if (definition.node.type === "VariableDeclarator") return definition.node.init !== null && isCaptured(definition.node.init, thunk, seen);
|
|
56
|
-
const defaulted = thunk.params.find((parameter) => parameter.type === "AssignmentPattern" && parameter.left.type === "Identifier" && parameter.left.name === value.name);
|
|
57
|
-
return defaulted !== void 0 && isCaptured(defaulted.right, thunk, seen);
|
|
58
|
-
}
|
|
51
|
+
onSome: (variable) => Option.match(Arr.head(variable.defs), {
|
|
52
|
+
onNone: () => true,
|
|
53
|
+
onSome: (definition) => isCapturedDefinition(definition.node, value.name, thunk, seen)
|
|
54
|
+
})
|
|
59
55
|
});
|
|
60
56
|
};
|
|
57
|
+
/** Whether a binding's definition holds a value that exists before the thunk runs. */
|
|
58
|
+
const isCapturedDefinition = (definition, name, thunk, seen) => {
|
|
59
|
+
if (seen.has(definition)) return false;
|
|
60
|
+
seen.add(definition);
|
|
61
|
+
if (!isWithin(definition, thunk)) return true;
|
|
62
|
+
if (definition.type === "VariableDeclarator") {
|
|
63
|
+
const init = definition.init;
|
|
64
|
+
return Predicate.isNotNull(init) && isCaptured(init, thunk, seen);
|
|
65
|
+
}
|
|
66
|
+
return Option.exists(Arr.findFirst(thunk.params, (parameter) => parameter.type === "AssignmentPattern" && parameter.left.type === "Identifier" && parameter.left.name === name), (defaulted) => isCaptured(defaulted.right, thunk, seen));
|
|
67
|
+
};
|
|
61
68
|
const returnsCapturedHandle = (expression, effects) => {
|
|
62
69
|
if (!isStaticCall(expression, effects, "sync")) return false;
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
const thunk = unwrapExpression(argument);
|
|
66
|
-
if (thunk.type !== "ArrowFunctionExpression" && thunk.type !== "FunctionExpression") return false;
|
|
67
|
-
const returned = singleReturnedValue(thunk);
|
|
68
|
-
return returned !== void 0 && isCaptured(returned, thunk, /* @__PURE__ */ new Set());
|
|
70
|
+
const thunk = Option.filter(Option.map(expressionArgument(expression, 0), unwrapExpression), isThunk);
|
|
71
|
+
return Option.exists(thunk, (lazy) => Option.exists(singleReturnedValue(lazy), (returned) => isCaptured(returned, lazy, /* @__PURE__ */ new Set())));
|
|
69
72
|
};
|
|
70
73
|
return {
|
|
71
74
|
ImportDeclaration: (node) => {
|
|
@@ -76,14 +79,17 @@ const noEagerAcquire = define({
|
|
|
76
79
|
if (node.callee.type === "Super") return Effect.void;
|
|
77
80
|
const effects = visibleNamespaces(ctx, node, effectNamespaces);
|
|
78
81
|
if (!isStaticCall(node, effects, "acquireRelease")) return Effect.void;
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
const eagerAcquire = Option.filter(expressionArgument(node, 0), (acquire) => {
|
|
83
|
+
const expression = unwrapExpression(acquire);
|
|
84
|
+
return isStaticCall(expression, effects, "succeed") || returnsCapturedHandle(expression, effects);
|
|
85
|
+
});
|
|
86
|
+
return Option.match(eagerAcquire, {
|
|
87
|
+
onNone: () => Effect.void,
|
|
88
|
+
onSome: (acquire) => ctx.report(make({
|
|
89
|
+
node: acquire,
|
|
90
|
+
message: "Build the resource inside acquire (Effect.sync(() => new Resource()), Effect.tryPromise, or another lazy Effect). Effect.succeed(handle) or Effect.sync(() => handle) creates it before acquire runs, so an interruption in between leaks it."
|
|
91
|
+
}))
|
|
92
|
+
});
|
|
87
93
|
}
|
|
88
94
|
};
|
|
89
95
|
}
|
|
@@ -4,6 +4,8 @@ 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
6
|
import { isTestFile, skipFile } from "./_test-files.js";
|
|
7
|
+
import * as Arr from "effect/Array";
|
|
8
|
+
import * as Option from "effect/Option";
|
|
7
9
|
import * as Effect from "effect/Effect";
|
|
8
10
|
//#region src/rules/no-effect-run-in-tests.ts
|
|
9
11
|
const effectRunners = [
|
|
@@ -32,9 +34,9 @@ const noEffectRunInTests = define({
|
|
|
32
34
|
const effectNamespaces = new Set(["Effect"]);
|
|
33
35
|
const managedRuntimeNamespaces = new Set(["ManagedRuntime"]);
|
|
34
36
|
const runnerName = (node) => {
|
|
35
|
-
if (isStaticMember(node, visibleNamespaces(ctx, node, managedRuntimeNamespaces), "make")) return "ManagedRuntime.make";
|
|
37
|
+
if (isStaticMember(node, visibleNamespaces(ctx, node, managedRuntimeNamespaces), "make")) return Option.some("ManagedRuntime.make");
|
|
36
38
|
const effects = visibleNamespaces(ctx, node, effectNamespaces);
|
|
37
|
-
return
|
|
39
|
+
return Arr.findFirst(effectRunners, (runner) => isStaticMember(node, effects, runner));
|
|
38
40
|
};
|
|
39
41
|
return {
|
|
40
42
|
ImportDeclaration: (node) => {
|
|
@@ -43,12 +45,13 @@ const noEffectRunInTests = define({
|
|
|
43
45
|
return Effect.void;
|
|
44
46
|
},
|
|
45
47
|
MemberExpression: (node) => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
return Option.match(runnerName(node), {
|
|
49
|
+
onNone: () => Effect.void,
|
|
50
|
+
onSome: (runner) => ctx.report(make({
|
|
51
|
+
node,
|
|
52
|
+
message: `Do not run Effects by hand in tests (${runner}). Use the test runner's Effect integration: it.effect(...) or it.layer(layer)(...) from @effect/vitest or effect-bun-test.`
|
|
53
|
+
}))
|
|
54
|
+
});
|
|
52
55
|
}
|
|
53
56
|
};
|
|
54
57
|
}
|