summaryrefslogtreecommitdiff
path: root/eslint/rules/prefer-object-params.mjs
blob: a5a8caae577ca9f88df97f4f7820396fc9f3457a (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
export default {
	meta: {
		type: "suggestion",
		docs: {
			description:
				"Prefer a single destructured object parameter over multiple positional parameters",
		},
		schema: [],
		messages: {
			preferObjectParams:
				"Use a single destructured object parameter instead of multiple positional parameters. Example: function someFunction({ name, age }: { name: string, age: number }) {}",
		},
	},
	create(context) {
		function isCallArgument(node) {
			return (
				node &&
				(node.type === "CallExpression" ||
					node.type === "NewExpression" ||
					node.type === "OptionalCallExpression")
			);
		}

		function isDirectCallback(node) {
			const parent = node.parent;
			return (
				isCallArgument(parent) && parent.arguments.includes(node)
			);
		}

		// Some APIs receive callbacks via a property somewhere inside an inline
		// option object (e.g. `persist(state, { migrate: (s, v) => ... })` or
		// `betterAuth({ rateLimit: { customStorage: { set: (k, v) => ... }}})`).
		// The caller still dictates the signature, so the same carve-out as
		// `isDirectCallback` applies. Pre-declared option objects don't qualify —
		// once you name the object, the signature is yours to evolve.
		function isInlineOptionCallback(node) {
			let current = node;
			let property = current.parent;
			if (
				!property ||
				property.type !== "Property" ||
				property.value !== current
			) {
				return false;
			}

			let objectExpression = property.parent;
			while (objectExpression?.type === "ObjectExpression") {
				const parent = objectExpression.parent;
				if (isCallArgument(parent) && parent.arguments.includes(objectExpression)) {
					return true;
				}

				if (
					parent?.type !== "Property" ||
					parent.value !== objectExpression
				) {
					return false;
				}

				property = parent;
				objectExpression = property.parent;
			}

			return false;
		}

		// Type predicates (`x is Foo`) require a positional subject by language
		// rule, so the readability/evolution argument doesn't apply. AGENTS.md
		// carves these out explicitly.
		function isTypePredicate(node) {
			return node.returnType?.typeAnnotation?.type === "TSTypePredicate";
		}

		function reportIfNeeded(node) {
			if (
				node.params.length <= 1 ||
				isDirectCallback(node) ||
				isInlineOptionCallback(node) ||
				isTypePredicate(node)
			) {
				return;
			}

			// Report from the function's first line through params[1] so a
			// conventional `// eslint-disable-next-line` placed above the
			// declaration suppresses the violation. The directive matches
			// against `loc.start.line`, which must land on the function
			// declaration line — not on a later parameter row.
			context.report({
				node,
				loc: {
					start: node.loc.start,
					end: node.params[1].loc.end,
				},
				messageId: "preferObjectParams",
			});
		}

		return {
			ArrowFunctionExpression: reportIfNeeded,
			FunctionDeclaration: reportIfNeeded,
			FunctionExpression: reportIfNeeded,
		};
	},
};