@mggarofalo/eslint-plugin-react-hook-stability 0.1.1

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.
@@ -0,0 +1,33 @@
1
+ import type { TSESLint } from "@typescript-eslint/utils";
2
+ declare const _default: {
3
+ configs: {
4
+ recommended: {
5
+ plugins: {
6
+ "react-hook-stability": {
7
+ meta: {
8
+ name: string;
9
+ version: string;
10
+ };
11
+ rules: {
12
+ "require-stable-hook-returns": TSESLint.RuleModule<"unstableReturn", [], unknown, TSESLint.RuleListener> & {
13
+ name: string;
14
+ };
15
+ };
16
+ };
17
+ };
18
+ rules: {
19
+ "react-hook-stability/require-stable-hook-returns": "warn";
20
+ };
21
+ };
22
+ };
23
+ meta: {
24
+ name: string;
25
+ version: string;
26
+ };
27
+ rules: {
28
+ "require-stable-hook-returns": TSESLint.RuleModule<"unstableReturn", [], unknown, TSESLint.RuleListener> & {
29
+ name: string;
30
+ };
31
+ };
32
+ };
33
+ export default _default;
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
1
+ import { rule } from "./rules/require-stable-hook-returns.js";
2
+ const plugin = {
3
+ meta: {
4
+ name: "@mggarofalo/eslint-plugin-react-hook-stability",
5
+ version: "0.1.0",
6
+ },
7
+ rules: {
8
+ "require-stable-hook-returns": rule,
9
+ },
10
+ };
11
+ const configs = {
12
+ recommended: {
13
+ plugins: {
14
+ "react-hook-stability": plugin,
15
+ },
16
+ rules: {
17
+ "react-hook-stability/require-stable-hook-returns": "warn",
18
+ },
19
+ },
20
+ };
21
+ export default { ...plugin, configs };
@@ -0,0 +1,4 @@
1
+ import { ESLintUtils } from "@typescript-eslint/utils";
2
+ export declare const rule: ESLintUtils.RuleModule<"unstableReturn", [], unknown, ESLintUtils.RuleListener> & {
3
+ name: string;
4
+ };
@@ -0,0 +1,269 @@
1
+ import { AST_NODE_TYPES, ESLintUtils } from "@typescript-eslint/utils";
2
+ import { isHookName, getFunctionName, isFunctionNode, } from "../utils/ast-helpers.js";
3
+ import { processDeclarator, resolveExpressionKind, } from "../utils/variable-tracker.js";
4
+ import { ensureReactImport } from "../utils/import-manager.js";
5
+ const UNSTABLE_KINDS = new Set([
6
+ "bare-function",
7
+ "object-literal",
8
+ "array-literal",
9
+ ]);
10
+ const MESSAGE_MAP = {
11
+ "bare-function": "useCallback",
12
+ "object-literal": "useMemo",
13
+ "array-literal": "useMemo",
14
+ };
15
+ const createRule = ESLintUtils.RuleCreator(() => "https://github.com/mggarofalo/eslint-plugins/tree/main/packages/eslint-plugin-react-hook-stability#require-stable-hook-returns");
16
+ export const rule = createRule({
17
+ name: "require-stable-hook-returns",
18
+ meta: {
19
+ type: "problem",
20
+ docs: {
21
+ description: "Require that exported custom hooks return stable references (wrapped in useCallback/useMemo)",
22
+ },
23
+ fixable: "code",
24
+ schema: [],
25
+ messages: {
26
+ unstableReturn: 'Hook "{{hookName}}" returns unstable {{kind}} "{{propName}}". Wrap it in {{wrapper}}.',
27
+ },
28
+ },
29
+ defaultOptions: [],
30
+ create(context) {
31
+ const exportedHookNames = new Set();
32
+ const hookScopeStack = [];
33
+ const sourceCode = context.sourceCode;
34
+ // We need to track function nodes that correspond to exported hooks so we
35
+ // can push/pop scope. We collect them in Program then match in function visitors.
36
+ const hookFunctionNodes = new Set();
37
+ function currentScope() {
38
+ return hookScopeStack[hookScopeStack.length - 1];
39
+ }
40
+ /**
41
+ * Pre-scan: collect exported hook names from export declarations.
42
+ */
43
+ function collectExportedHooks(program) {
44
+ for (const node of program.body) {
45
+ switch (node.type) {
46
+ case AST_NODE_TYPES.ExportNamedDeclaration: {
47
+ // export function useFoo() {}
48
+ if (node.declaration?.type === AST_NODE_TYPES.FunctionDeclaration &&
49
+ node.declaration.id &&
50
+ isHookName(node.declaration.id.name)) {
51
+ exportedHookNames.add(node.declaration.id.name);
52
+ hookFunctionNodes.add(node.declaration);
53
+ }
54
+ // export const useFoo = () => {} or export const useFoo = function() {}
55
+ if (node.declaration?.type === AST_NODE_TYPES.VariableDeclaration) {
56
+ for (const decl of node.declaration.declarations) {
57
+ if (decl.id.type === AST_NODE_TYPES.Identifier &&
58
+ isHookName(decl.id.name) &&
59
+ decl.init &&
60
+ isFunctionNode(decl.init)) {
61
+ exportedHookNames.add(decl.id.name);
62
+ hookFunctionNodes.add(decl.init);
63
+ }
64
+ }
65
+ }
66
+ // export { useFoo }
67
+ for (const spec of node.specifiers) {
68
+ if (spec.type === AST_NODE_TYPES.ExportSpecifier &&
69
+ spec.local.type === AST_NODE_TYPES.Identifier &&
70
+ isHookName(spec.local.name)) {
71
+ exportedHookNames.add(spec.local.name);
72
+ }
73
+ }
74
+ break;
75
+ }
76
+ case AST_NODE_TYPES.ExportDefaultDeclaration: {
77
+ // export default function useFoo() {}
78
+ if (node.declaration.type === AST_NODE_TYPES.FunctionDeclaration &&
79
+ node.declaration.id &&
80
+ isHookName(node.declaration.id.name)) {
81
+ exportedHookNames.add(node.declaration.id.name);
82
+ hookFunctionNodes.add(node.declaration);
83
+ }
84
+ // export default useFoo (identifier reference — resolved later)
85
+ if (node.declaration.type === AST_NODE_TYPES.Identifier &&
86
+ isHookName(node.declaration.name)) {
87
+ exportedHookNames.add(node.declaration.name);
88
+ }
89
+ break;
90
+ }
91
+ }
92
+ }
93
+ }
94
+ /**
95
+ * For `export { useFoo }` and `export default useFoo` patterns,
96
+ * we need to find the actual function declarations/expressions in the program scope.
97
+ */
98
+ function resolveExportedIdentifiers(program) {
99
+ for (const node of program.body) {
100
+ // function useFoo() { ... } (top-level, referenced by export specifier)
101
+ if (node.type === AST_NODE_TYPES.FunctionDeclaration &&
102
+ node.id &&
103
+ exportedHookNames.has(node.id.name) &&
104
+ !hookFunctionNodes.has(node)) {
105
+ hookFunctionNodes.add(node);
106
+ }
107
+ // const useFoo = () => { ... } (top-level, referenced by export specifier)
108
+ if (node.type === AST_NODE_TYPES.VariableDeclaration) {
109
+ for (const decl of node.declarations) {
110
+ if (decl.id.type === AST_NODE_TYPES.Identifier &&
111
+ exportedHookNames.has(decl.id.name) &&
112
+ decl.init &&
113
+ isFunctionNode(decl.init) &&
114
+ !hookFunctionNodes.has(decl.init)) {
115
+ hookFunctionNodes.add(decl.init);
116
+ }
117
+ }
118
+ }
119
+ }
120
+ }
121
+ function enterFunction(node) {
122
+ const scope = currentScope();
123
+ // If we're inside a hook scope, increment nesting depth
124
+ if (scope) {
125
+ scope.nestedDepth++;
126
+ return;
127
+ }
128
+ // Check if this function node is an exported hook
129
+ if (hookFunctionNodes.has(node)) {
130
+ const name = getFunctionName(node) ?? "anonymous";
131
+ hookScopeStack.push({
132
+ name,
133
+ variables: new Map(),
134
+ nestedDepth: 0,
135
+ });
136
+ }
137
+ }
138
+ function exitFunction(node) {
139
+ const scope = currentScope();
140
+ if (!scope)
141
+ return;
142
+ if (scope.nestedDepth > 0) {
143
+ scope.nestedDepth--;
144
+ return;
145
+ }
146
+ // We're exiting the hook function itself
147
+ if (hookFunctionNodes.has(node)) {
148
+ hookScopeStack.pop();
149
+ }
150
+ }
151
+ function analyzeReturnProperties(properties, scope) {
152
+ for (const prop of properties) {
153
+ // Skip spread elements — too dynamic
154
+ if (prop.type === AST_NODE_TYPES.SpreadElement)
155
+ continue;
156
+ if (prop.type !== AST_NODE_TYPES.Property)
157
+ continue;
158
+ const propName = prop.key.type === AST_NODE_TYPES.Identifier
159
+ ? prop.key.name
160
+ : sourceCode.getText(prop.key);
161
+ // For shorthand properties (e.g. { setPage }), the value is the identifier
162
+ const valueNode = prop.value;
163
+ const { kind, originNode } = resolveExpressionKind(valueNode, scope.variables);
164
+ if (!UNSTABLE_KINDS.has(kind))
165
+ continue;
166
+ const wrapper = MESSAGE_MAP[kind];
167
+ context.report({
168
+ node: prop,
169
+ messageId: "unstableReturn",
170
+ data: {
171
+ hookName: scope.name,
172
+ kind: kind.replace("-", " "),
173
+ propName,
174
+ wrapper,
175
+ },
176
+ fix(fixer) {
177
+ const fixes = [];
178
+ // Wrap the value
179
+ const originText = sourceCode.getText(originNode);
180
+ if (kind === "bare-function") {
181
+ fixes.push(fixer.replaceText(originNode, `useCallback(${originText}, [] /* TODO: add dependencies */)`));
182
+ const importFix = ensureReactImport("useCallback", sourceCode, fixer);
183
+ if (importFix)
184
+ fixes.push(importFix);
185
+ }
186
+ else {
187
+ // object-literal or array-literal
188
+ const needsParens = kind === "object-literal";
189
+ const inner = needsParens ? `(${originText})` : originText;
190
+ fixes.push(fixer.replaceText(originNode, `useMemo(() => ${inner}, [] /* TODO: add dependencies */)`));
191
+ const importFix = ensureReactImport("useMemo", sourceCode, fixer);
192
+ if (importFix)
193
+ fixes.push(importFix);
194
+ }
195
+ return fixes;
196
+ },
197
+ });
198
+ }
199
+ }
200
+ return {
201
+ Program(node) {
202
+ collectExportedHooks(node);
203
+ resolveExportedIdentifiers(node);
204
+ },
205
+ FunctionDeclaration: enterFunction,
206
+ FunctionExpression: enterFunction,
207
+ ArrowFunctionExpression: enterFunction,
208
+ "FunctionDeclaration:exit": exitFunction,
209
+ "FunctionExpression:exit": exitFunction,
210
+ "ArrowFunctionExpression:exit": exitFunction,
211
+ VariableDeclarator(node) {
212
+ const scope = currentScope();
213
+ if (!scope || scope.nestedDepth > 0)
214
+ return;
215
+ processDeclarator(node, scope.variables);
216
+ },
217
+ // Handle hoisted function declarations inside hooks
218
+ // (FunctionDeclaration inside hook body at depth 0 is a local function)
219
+ "FunctionDeclaration[id]"(node) {
220
+ // This fires before enterFunction increments depth, so check if
221
+ // the parent scope (before push) has this as a nested function.
222
+ // Actually, enterFunction runs first via the same selector. We
223
+ // need to check the scope AFTER enterFunction has run.
224
+ // At this point, if we're inside a hook, nestedDepth was just
225
+ // incremented to 1 by enterFunction. So the current scope has
226
+ // nestedDepth === 1, meaning this is a direct child function declaration.
227
+ const scope = currentScope();
228
+ if (!scope)
229
+ return;
230
+ if (scope.nestedDepth === 1 && node.id) {
231
+ // This is a function declaration directly inside the hook body
232
+ scope.variables.set(node.id.name, {
233
+ kind: "bare-function",
234
+ node,
235
+ });
236
+ }
237
+ },
238
+ ReturnStatement(node) {
239
+ const scope = currentScope();
240
+ if (!scope || scope.nestedDepth > 0)
241
+ return;
242
+ if (!node.argument)
243
+ return;
244
+ let returnObj;
245
+ if (node.argument.type === AST_NODE_TYPES.ObjectExpression) {
246
+ returnObj = node.argument;
247
+ }
248
+ else if (node.argument.type === AST_NODE_TYPES.Identifier) {
249
+ // Trace through variables to find the object
250
+ const origin = scope.variables.get(node.argument.name);
251
+ if (origin &&
252
+ origin.node.type === AST_NODE_TYPES.ObjectExpression) {
253
+ returnObj = origin.node;
254
+ }
255
+ else if (origin && origin.kind === "object-literal") {
256
+ // The variable was classified as object-literal but the node might
257
+ // be the init which is an ObjectExpression
258
+ if (origin.node.type === AST_NODE_TYPES.ObjectExpression) {
259
+ returnObj = origin.node;
260
+ }
261
+ }
262
+ }
263
+ if (returnObj) {
264
+ analyzeReturnProperties(returnObj.properties, scope);
265
+ }
266
+ },
267
+ };
268
+ },
269
+ });
@@ -0,0 +1,11 @@
1
+ export type VariableOriginKind = "stable-hook" | "state-setter" | "dispatch" | "ref" | "other-hook" | "primitive" | "bare-function" | "object-literal" | "array-literal" | "unknown";
2
+ export interface VariableOrigin {
3
+ kind: VariableOriginKind;
4
+ /** AST node where the value was defined (for auto-fix) */
5
+ node: import("@typescript-eslint/utils").TSESTree.Node;
6
+ }
7
+ export interface HookScope {
8
+ name: string;
9
+ variables: Map<string, VariableOrigin>;
10
+ nestedDepth: number;
11
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,12 @@
1
+ import { TSESTree } from "@typescript-eslint/utils";
2
+ /** Returns true if name matches the React hook naming convention (use + uppercase letter). */
3
+ export declare function isHookName(name: string): boolean;
4
+ /** Extract callee name from a CallExpression, e.g. `useMemo(...)` → "useMemo". */
5
+ export declare function getCalleeName(node: TSESTree.CallExpression): string | undefined;
6
+ /**
7
+ * Get the function name for a function-like node.
8
+ * Handles FunctionDeclaration, named FunctionExpression, and variable-assigned arrows/functions.
9
+ */
10
+ export declare function getFunctionName(node: TSESTree.FunctionDeclaration | TSESTree.FunctionExpression | TSESTree.ArrowFunctionExpression): string | undefined;
11
+ /** Returns true if the node is a function-like (arrow, expression, declaration). */
12
+ export declare function isFunctionNode(node: TSESTree.Node): node is TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression | TSESTree.FunctionDeclaration;
@@ -0,0 +1,43 @@
1
+ import { AST_NODE_TYPES } from "@typescript-eslint/utils";
2
+ /** Returns true if name matches the React hook naming convention (use + uppercase letter). */
3
+ export function isHookName(name) {
4
+ return /^use[A-Z]/.test(name);
5
+ }
6
+ /** Extract callee name from a CallExpression, e.g. `useMemo(...)` → "useMemo". */
7
+ export function getCalleeName(node) {
8
+ if (node.callee.type === AST_NODE_TYPES.Identifier) {
9
+ return node.callee.name;
10
+ }
11
+ // e.g. React.useMemo(...)
12
+ if (node.callee.type === AST_NODE_TYPES.MemberExpression &&
13
+ node.callee.property.type === AST_NODE_TYPES.Identifier) {
14
+ return node.callee.property.name;
15
+ }
16
+ return undefined;
17
+ }
18
+ /**
19
+ * Get the function name for a function-like node.
20
+ * Handles FunctionDeclaration, named FunctionExpression, and variable-assigned arrows/functions.
21
+ */
22
+ export function getFunctionName(node) {
23
+ if (node.type === AST_NODE_TYPES.FunctionDeclaration &&
24
+ node.id) {
25
+ return node.id.name;
26
+ }
27
+ if (node.type === AST_NODE_TYPES.FunctionExpression &&
28
+ node.id) {
29
+ return node.id.name;
30
+ }
31
+ // Check parent: const useFoo = () => { ... }
32
+ if (node.parent?.type === AST_NODE_TYPES.VariableDeclarator &&
33
+ node.parent.id.type === AST_NODE_TYPES.Identifier) {
34
+ return node.parent.id.name;
35
+ }
36
+ return undefined;
37
+ }
38
+ /** Returns true if the node is a function-like (arrow, expression, declaration). */
39
+ export function isFunctionNode(node) {
40
+ return (node.type === AST_NODE_TYPES.ArrowFunctionExpression ||
41
+ node.type === AST_NODE_TYPES.FunctionExpression ||
42
+ node.type === AST_NODE_TYPES.FunctionDeclaration);
43
+ }
@@ -0,0 +1,10 @@
1
+ import { TSESTree } from "@typescript-eslint/utils";
2
+ import type { RuleFixer, RuleFix } from "@typescript-eslint/utils/ts-eslint";
3
+ /**
4
+ * Ensure that `specifierName` (e.g. "useCallback") is imported from "react".
5
+ * Returns a fix that either adds it to an existing import or creates a new one.
6
+ */
7
+ export declare function ensureReactImport(specifierName: string, sourceCode: {
8
+ ast: TSESTree.Program;
9
+ getText(node: TSESTree.Node): string;
10
+ }, fixer: RuleFixer): RuleFix | null;
@@ -0,0 +1,33 @@
1
+ import { AST_NODE_TYPES } from "@typescript-eslint/utils";
2
+ /**
3
+ * Ensure that `specifierName` (e.g. "useCallback") is imported from "react".
4
+ * Returns a fix that either adds it to an existing import or creates a new one.
5
+ */
6
+ export function ensureReactImport(specifierName, sourceCode, fixer) {
7
+ // Find existing `import { ... } from 'react'`
8
+ for (const node of sourceCode.ast.body) {
9
+ if (node.type === AST_NODE_TYPES.ImportDeclaration &&
10
+ node.source.value === "react") {
11
+ // Check if already imported
12
+ const alreadyImported = node.specifiers.some((s) => s.type === AST_NODE_TYPES.ImportSpecifier &&
13
+ s.imported.type === AST_NODE_TYPES.Identifier &&
14
+ s.imported.name === specifierName);
15
+ if (alreadyImported)
16
+ return null;
17
+ // Find the last named specifier and add after it
18
+ const namedSpecifiers = node.specifiers.filter((s) => s.type === AST_NODE_TYPES.ImportSpecifier);
19
+ if (namedSpecifiers.length > 0) {
20
+ const last = namedSpecifiers[namedSpecifiers.length - 1];
21
+ return fixer.insertTextAfter(last, `, ${specifierName}`);
22
+ }
23
+ // Has default import but no named specifiers: `import React from 'react'`
24
+ const defaultSpecifier = node.specifiers.find((s) => s.type === AST_NODE_TYPES.ImportDefaultSpecifier);
25
+ if (defaultSpecifier) {
26
+ return fixer.insertTextAfter(defaultSpecifier, `, { ${specifierName} }`);
27
+ }
28
+ return null;
29
+ }
30
+ }
31
+ // No react import at all — insert at top of file
32
+ return fixer.insertTextBefore(sourceCode.ast.body[0], `import { ${specifierName} } from "react";\n`);
33
+ }
@@ -0,0 +1,22 @@
1
+ import { TSESTree } from "@typescript-eslint/utils";
2
+ import type { VariableOrigin, VariableOriginKind } from "../types.js";
3
+ /**
4
+ * Classify the origin of an init expression in a variable declarator.
5
+ */
6
+ export declare function classifyInit(init: TSESTree.Expression | null | undefined, variables: Map<string, VariableOrigin>): VariableOriginKind;
7
+ /**
8
+ * Process a VariableDeclarator and record all bindings into the variables map.
9
+ * Handles:
10
+ * const x = expr
11
+ * const [a, b] = useHook()
12
+ * const { a, b } = useHook()
13
+ */
14
+ export declare function processDeclarator(declarator: TSESTree.VariableDeclarator, variables: Map<string, VariableOrigin>): void;
15
+ /**
16
+ * Resolve the stability kind for a return-object property value.
17
+ * Handles inline expressions and identifier lookups.
18
+ */
19
+ export declare function resolveExpressionKind(node: TSESTree.Expression, variables: Map<string, VariableOrigin>): {
20
+ kind: VariableOriginKind;
21
+ originNode: TSESTree.Node;
22
+ };
@@ -0,0 +1,132 @@
1
+ import { AST_NODE_TYPES } from "@typescript-eslint/utils";
2
+ import { getCalleeName, isHookName } from "./ast-helpers.js";
3
+ /**
4
+ * Classify the origin of an init expression in a variable declarator.
5
+ */
6
+ export function classifyInit(init, variables) {
7
+ if (!init)
8
+ return "unknown";
9
+ switch (init.type) {
10
+ case AST_NODE_TYPES.Literal:
11
+ return "primitive";
12
+ case AST_NODE_TYPES.TemplateLiteral:
13
+ return "primitive";
14
+ case AST_NODE_TYPES.ArrowFunctionExpression:
15
+ case AST_NODE_TYPES.FunctionExpression:
16
+ return "bare-function";
17
+ case AST_NODE_TYPES.ObjectExpression:
18
+ return "object-literal";
19
+ case AST_NODE_TYPES.ArrayExpression:
20
+ return "array-literal";
21
+ case AST_NODE_TYPES.CallExpression: {
22
+ const callee = getCalleeName(init);
23
+ if (!callee)
24
+ return "unknown";
25
+ if (callee === "useCallback" || callee === "useMemo")
26
+ return "stable-hook";
27
+ if (callee === "useRef")
28
+ return "ref";
29
+ if (isHookName(callee))
30
+ return "other-hook";
31
+ return "unknown";
32
+ }
33
+ case AST_NODE_TYPES.Identifier: {
34
+ // Trace through variables
35
+ const origin = variables.get(init.name);
36
+ return origin ? origin.kind : "unknown";
37
+ }
38
+ default:
39
+ return "unknown";
40
+ }
41
+ }
42
+ /**
43
+ * Process a VariableDeclarator and record all bindings into the variables map.
44
+ * Handles:
45
+ * const x = expr
46
+ * const [a, b] = useHook()
47
+ * const { a, b } = useHook()
48
+ */
49
+ export function processDeclarator(declarator, variables) {
50
+ const { id, init } = declarator;
51
+ if (id.type === AST_NODE_TYPES.Identifier) {
52
+ const kind = classifyInit(init, variables);
53
+ variables.set(id.name, { kind, node: init ?? id });
54
+ return;
55
+ }
56
+ // Array destructuring: const [state, setState] = useState()
57
+ if (id.type === AST_NODE_TYPES.ArrayPattern && init) {
58
+ const callee = init.type === AST_NODE_TYPES.CallExpression
59
+ ? getCalleeName(init)
60
+ : undefined;
61
+ for (let i = 0; i < id.elements.length; i++) {
62
+ const el = id.elements[i];
63
+ if (!el || el.type !== AST_NODE_TYPES.Identifier)
64
+ continue;
65
+ let kind;
66
+ if (callee === "useState" && i === 1) {
67
+ kind = "state-setter";
68
+ }
69
+ else if (callee === "useReducer" && i === 1) {
70
+ kind = "dispatch";
71
+ }
72
+ else if (callee && isHookName(callee)) {
73
+ kind = "other-hook";
74
+ }
75
+ else {
76
+ kind = "unknown";
77
+ }
78
+ variables.set(el.name, { kind, node: el });
79
+ }
80
+ return;
81
+ }
82
+ // Object destructuring: const { data, refetch } = useQuery()
83
+ if (id.type === AST_NODE_TYPES.ObjectPattern && init) {
84
+ const callee = init.type === AST_NODE_TYPES.CallExpression
85
+ ? getCalleeName(init)
86
+ : undefined;
87
+ for (const prop of id.properties) {
88
+ if (prop.type === AST_NODE_TYPES.Property &&
89
+ prop.value.type === AST_NODE_TYPES.Identifier) {
90
+ const kind = callee && isHookName(callee) ? "other-hook" : "unknown";
91
+ variables.set(prop.value.name, { kind, node: prop.value });
92
+ }
93
+ }
94
+ return;
95
+ }
96
+ }
97
+ /**
98
+ * Resolve the stability kind for a return-object property value.
99
+ * Handles inline expressions and identifier lookups.
100
+ */
101
+ export function resolveExpressionKind(node, variables) {
102
+ switch (node.type) {
103
+ case AST_NODE_TYPES.ArrowFunctionExpression:
104
+ case AST_NODE_TYPES.FunctionExpression:
105
+ return { kind: "bare-function", originNode: node };
106
+ case AST_NODE_TYPES.ObjectExpression:
107
+ return { kind: "object-literal", originNode: node };
108
+ case AST_NODE_TYPES.ArrayExpression:
109
+ return { kind: "array-literal", originNode: node };
110
+ case AST_NODE_TYPES.Literal:
111
+ case AST_NODE_TYPES.TemplateLiteral:
112
+ return { kind: "primitive", originNode: node };
113
+ case AST_NODE_TYPES.CallExpression: {
114
+ const callee = getCalleeName(node);
115
+ if (callee === "useCallback" || callee === "useMemo")
116
+ return { kind: "stable-hook", originNode: node };
117
+ if (callee === "useRef")
118
+ return { kind: "ref", originNode: node };
119
+ if (callee && isHookName(callee))
120
+ return { kind: "other-hook", originNode: node };
121
+ return { kind: "unknown", originNode: node };
122
+ }
123
+ case AST_NODE_TYPES.Identifier: {
124
+ const origin = variables.get(node.name);
125
+ if (origin)
126
+ return { kind: origin.kind, originNode: origin.node };
127
+ return { kind: "unknown", originNode: node };
128
+ }
129
+ default:
130
+ return { kind: "unknown", originNode: node };
131
+ }
132
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@mggarofalo/eslint-plugin-react-hook-stability",
3
+ "version": "0.1.1",
4
+ "description": "ESLint plugin to detect unstable references returned from React hooks",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsc",
19
+ "test": "vitest run",
20
+ "test:watch": "vitest"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/mggarofalo/eslint-plugins.git",
28
+ "directory": "packages/eslint-plugin-react-hook-stability"
29
+ },
30
+ "homepage": "https://github.com/mggarofalo/eslint-plugins/tree/main/packages/eslint-plugin-react-hook-stability",
31
+ "bugs": {
32
+ "url": "https://github.com/mggarofalo/eslint-plugins/issues"
33
+ },
34
+ "peerDependencies": {
35
+ "eslint": ">=9.0.0"
36
+ },
37
+ "dependencies": {
38
+ "@typescript-eslint/utils": "^8.0.0"
39
+ },
40
+ "devDependencies": {
41
+ "@typescript-eslint/rule-tester": "^8.0.0",
42
+ "eslint": "^9.0.0",
43
+ "typescript": "^5.5.0",
44
+ "vitest": "^3.0.0"
45
+ },
46
+ "keywords": [
47
+ "eslint",
48
+ "eslintplugin",
49
+ "react",
50
+ "hooks",
51
+ "useCallback",
52
+ "useMemo",
53
+ "stability"
54
+ ],
55
+ "license": "MIT"
56
+ }