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.
Files changed (74) hide show
  1. package/README.md +26 -14
  2. package/THIRD_PARTY_NOTICES.md +4 -2
  3. package/dist/presets/recommended.d.ts +10 -0
  4. package/dist/presets/recommended.js +11 -1
  5. package/dist/rules/_anti-slop-array-method.js +78 -0
  6. package/dist/rules/_anti-slop-dictionary-types.js +198 -150
  7. package/dist/rules/_anti-slop-function-parameters.js +30 -0
  8. package/dist/rules/_anti-slop-lexical-type-parameters.js +25 -0
  9. package/dist/rules/_anti-slop-reflect-method.js +25 -0
  10. package/dist/rules/_anti-slop-scope.js +15 -0
  11. package/dist/rules/_anti-slop-type-alias-resolution.js +139 -0
  12. package/dist/rules/_ast-ancestors.js +14 -0
  13. package/dist/rules/_ast-children.js +18 -0
  14. package/dist/rules/_call-arguments.js +11 -0
  15. package/dist/rules/_effect-context.js +25 -20
  16. package/dist/rules/_function-metrics.js +94 -75
  17. package/dist/rules/_tagged-control-flow.js +38 -60
  18. package/dist/rules/_test-apis.js +47 -0
  19. package/dist/rules/_test-files.js +8 -0
  20. package/dist/rules/index.d.ts +11 -1
  21. package/dist/rules/index.js +21 -1
  22. package/dist/rules/no-array-filter-map.d.ts +7 -0
  23. package/dist/rules/no-array-filter-map.js +49 -0
  24. package/dist/rules/no-chained-type-assertions.js +7 -4
  25. package/dist/rules/no-conditional-empty-object-spread.d.ts +1 -1
  26. package/dist/rules/no-conditional-empty-object-spread.js +7 -47
  27. package/dist/rules/no-dynamic-imports.js +33 -25
  28. package/dist/rules/no-eager-acquire.d.ts +6 -0
  29. package/dist/rules/no-eager-acquire.js +98 -0
  30. package/dist/rules/no-effect-run-in-tests.d.ts +6 -0
  31. package/dist/rules/no-effect-run-in-tests.js +60 -0
  32. package/dist/rules/no-globals.js +42 -31
  33. package/dist/rules/no-known-value-widening.js +67 -50
  34. package/dist/rules/no-module-level-mutable-state.d.ts +6 -0
  35. package/dist/rules/no-module-level-mutable-state.js +47 -0
  36. package/dist/rules/no-module-mocks.js +37 -48
  37. package/dist/rules/no-new-promise.js +6 -2
  38. package/dist/rules/no-node-builtin-import.js +56 -33
  39. package/dist/rules/no-nullish.js +5 -1
  40. package/dist/rules/no-object-parameters.js +16 -30
  41. package/dist/rules/no-per-call-cache-construction.js +2 -2
  42. package/dist/rules/no-reduce-accumulator-copy.d.ts +7 -0
  43. package/dist/rules/no-reduce-accumulator-copy.js +106 -0
  44. package/dist/rules/no-reflect-apply.d.ts +7 -0
  45. package/dist/rules/no-reflect-apply.js +28 -0
  46. package/dist/rules/no-reflect-get.d.ts +7 -0
  47. package/dist/rules/no-reflect-get.js +28 -0
  48. package/dist/rules/no-run-collect-on-unbounded-stream.js +23 -25
  49. package/dist/rules/no-runtime-typeof.js +21 -4
  50. package/dist/rules/no-sequential-effect-all.js +18 -11
  51. package/dist/rules/no-shape-in-symbol-names.js +15 -5
  52. package/dist/rules/no-silent-catch-all.js +20 -14
  53. package/dist/rules/no-test-globals.d.ts +6 -0
  54. package/dist/rules/no-test-globals.js +147 -0
  55. package/dist/rules/no-test-lifecycle-hooks.js +4 -2
  56. package/dist/rules/no-unbounded-concurrency.js +22 -17
  57. package/dist/rules/no-unbounded-retry.js +47 -62
  58. package/dist/rules/no-unknown-parameters.d.ts +1 -1
  59. package/dist/rules/no-unknown-parameters.js +26 -17
  60. package/dist/rules/no-unknown-returns.d.ts +7 -0
  61. package/dist/rules/no-unknown-returns.js +74 -0
  62. package/dist/rules/no-unknown-type-aliases.js +21 -27
  63. package/dist/rules/no-unsafe-dictionary-type.js +71 -25
  64. package/dist/rules/no-widen-then-assert.js +96 -73
  65. package/dist/rules/prefer-catch-tag.js +29 -37
  66. package/dist/rules/prefer-match-tags-exhaustive.js +35 -47
  67. package/dist/rules/prefer-predicate-is-tagged.js +4 -6
  68. package/dist/rules/prefer-service-of.js +39 -26
  69. package/dist/rules/require-named-effect-fn.js +4 -2
  70. package/dist/rules/require-suppression-reason.d.ts +6 -0
  71. package/dist/rules/require-suppression-reason.js +92 -0
  72. package/dist/vendor/effect-oxlint/Diagnostic.js +1 -1
  73. package/dist/vendor/effect-oxlint/Scope.js +1 -1
  74. 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?.type === "ArrowFunctionExpression" || node?.type === "FunctionExpression";
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
- if (node.params.length !== 0 || node.body === null) return false;
12
- if (node.body.type !== "BlockStatement") return isEffectVoid(node.body, effectNamespaces);
13
- if (node.body.body.length !== 1) return false;
14
- const statement = node.body.body[0];
15
- return statement?.type === "ReturnStatement" && statement.argument !== null && isEffectVoid(statement.argument, effectNamespaces);
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[0];
19
- return node.arguments[1];
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 handler = handlerArgument(node);
41
- if (!isFunction(handler) || !silentlyReturnsVoid(handler, namespaces)) return Effect.void;
42
- return ctx.report(make({
43
- node: handler,
44
- message: "Do not erase every failure with Effect.void. Recover a typed failure truthfully or record the unexpected failure before recovery."
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,6 @@
1
+ import * as _$_oxlint_plugins0 from "@oxlint/plugins";
2
+
3
+ //#region src/rules/no-test-globals.d.ts
4
+ declare const noTestGlobals: _$_oxlint_plugins0.CreateRule;
5
+ //#endregion
6
+ export { noTestGlobals };
@@ -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 name = Option.getOrUndefined(calleeName(call));
27
- if (name === void 0 || !lifecycleHooks.has(name)) return Effect.void;
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?.type !== "ObjectExpression") return void 0;
10
- for (const property of node.properties) {
11
- if (property.type !== "Property" || property.computed) continue;
12
- if (!(property.key.type === "Identifier" && property.key.name === "concurrency" || property.key.type === "Literal" && property.key.value === "concurrency")) continue;
13
- if (property.value.type === "Literal" && property.value.value === "unbounded") return property.value;
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
- let optionsIndex;
36
- if (isStaticMember(node.callee, namespaces, "all")) optionsIndex = 1;
37
- if (isStaticMember(node.callee, namespaces, "forEach")) optionsIndex = 2;
38
- if (optionsIndex === void 0 || fixedCollection(node.arguments[0])) return Effect.void;
39
- const unbounded = unboundedConcurrency(node.arguments[optionsIndex]);
40
- if (unbounded === void 0) return Effect.void;
41
- return ctx.report(make({
42
- node: unbounded,
43
- message: "Bound concurrency for a collection that can grow. Use a finite concurrency value."
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 property = staticPropertyName(node.callee);
35
- if (property === void 0) return false;
36
- if (boundedCombinators.has(property) && isStaticMember(node.callee, scheduleNamespaces, property)) return true;
37
- if (!isStaticMember(node.callee, scheduleNamespaces, "both")) return false;
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 void 0;
44
- const property = staticPropertyName(node.callee);
45
- if (property !== void 0 && unboundedConstructors.has(property) && isStaticMember(node.callee, scheduleNamespaces, property)) return node;
46
- if (property !== "pipe" || node.callee.type !== "MemberExpression") return void 0;
47
- const root = unboundedSchedule(node.callee.object, scheduleNamespaces);
48
- if (root === void 0) return void 0;
49
- for (const operation of node.arguments) if (isBoundedOperation(operation, scheduleNamespaces)) return void 0;
50
- return root;
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 void 0;
54
- let policyIndex;
55
- if (isStaticMember(node.callee, effectNamespaces, "retry")) {
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
- if (policy === void 0) return Effect.void;
103
- const unbounded = unboundedSchedule(policy, visibleNamespaces(ctx, node, scheduleNamespaces));
104
- if (unbounded === void 0) return Effect.void;
105
- return ctx.report(make({
106
- node: unbounded,
107
- message: "Bound retry attempts or elapsed time. Add times or compose the schedule with Schedule.recurs or Schedule.take."
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 explicitly named error-cause enrichment. */
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 parameterAnnotation(parameter) {
8
- if (parameter.type === "TSParameterProperty") return parameterAnnotation(parameter.parameter);
9
- if (parameter.type === "RestElement") return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument);
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
- function parameterName(parameter, sourceText) {
14
- if (parameter.type === "TSParameterProperty") return parameterName(parameter.parameter, sourceText);
15
- if (parameter.type === "AssignmentPattern") return parameterName(parameter.left, sourceText);
16
- if (parameter.type === "RestElement") return parameterName(parameter.argument, sourceText);
17
- return parameter.type === "Identifier" ? parameter.name : sourceText.replace(/\s*:\s*unknown\s*$/u, "");
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 explicitly named error-cause enrichment. */
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`; decode unknown input at its I/O boundary instead.",
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 = parameterAnnotation(parameter);
31
- if (annotation?.typeAnnotation.type !== "TSUnknownKeyword") return Effect.void;
32
- const name = parameterName(parameter, context.sourceCode.getText(parameter));
33
- if (name === "cause") return Effect.void;
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: annotation.typeAnnotation,
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 };