@blumintinc/eslint-plugin-blumint 1.16.2 → 1.17.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 +21 -0
- package/lib/index.js +64 -1
- package/lib/rules/enforce-boolean-naming-prefixes.js +30 -14
- package/lib/rules/enforce-cloud-function-id-length.d.ts +5 -0
- package/lib/rules/enforce-cloud-function-id-length.js +104 -0
- package/lib/rules/enforce-is-prefix-validators.d.ts +13 -0
- package/lib/rules/enforce-is-prefix-validators.js +304 -0
- package/lib/rules/enforce-m3-sentence-case.d.ts +12 -0
- package/lib/rules/enforce-m3-sentence-case.js +430 -0
- package/lib/rules/enforce-snapshot-state-narrowing.d.ts +10 -0
- package/lib/rules/enforce-snapshot-state-narrowing.js +281 -0
- package/lib/rules/enforce-types-directory-placement.d.ts +9 -0
- package/lib/rules/enforce-types-directory-placement.js +276 -0
- package/lib/rules/no-direct-function-state.d.ts +8 -0
- package/lib/rules/no-direct-function-state.js +285 -0
- package/lib/rules/no-fill-template-mutation.d.ts +1 -0
- package/lib/rules/no-fill-template-mutation.js +324 -0
- package/lib/rules/no-portal-inside-tooltip.d.ts +10 -0
- package/lib/rules/no-portal-inside-tooltip.js +219 -0
- package/lib/rules/no-redundant-boolean-callback-props.d.ts +11 -0
- package/lib/rules/no-redundant-boolean-callback-props.js +355 -0
- package/lib/rules/no-satisfies-in-frontend-bundle.d.ts +8 -0
- package/lib/rules/no-satisfies-in-frontend-bundle.js +126 -0
- package/lib/rules/no-single-dismiss-dialog-button.d.ts +7 -0
- package/lib/rules/no-single-dismiss-dialog-button.js +127 -0
- package/lib/rules/no-stablehash-react-nodes.d.ts +1 -0
- package/lib/rules/no-stablehash-react-nodes.js +325 -0
- package/lib/rules/parallelize-loop-awaits.d.ts +8 -0
- package/lib/rules/parallelize-loop-awaits.js +582 -0
- package/lib/rules/prefer-flat-transform-each-keys.d.ts +1 -0
- package/lib/rules/prefer-flat-transform-each-keys.js +228 -0
- package/lib/rules/prefer-getter-over-parameterless-method.d.ts +1 -0
- package/lib/rules/prefer-getter-over-parameterless-method.js +44 -0
- package/lib/rules/prefer-spread-over-reassembly.d.ts +5 -0
- package/lib/rules/prefer-spread-over-reassembly.js +401 -0
- package/lib/rules/prefer-sx-prop-over-system-props.d.ts +9 -0
- package/lib/rules/prefer-sx-prop-over-system-props.js +401 -0
- package/lib/rules/prefer-use-base62-id.d.ts +8 -0
- package/lib/rules/prefer-use-base62-id.js +483 -0
- package/lib/rules/prefer-use-theme.d.ts +1 -0
- package/lib/rules/prefer-use-theme.js +206 -0
- package/lib/rules/prefer-utility-function-own-file.d.ts +9 -0
- package/lib/rules/prefer-utility-function-own-file.js +505 -0
- package/lib/rules/require-props-composition.d.ts +10 -0
- package/lib/rules/require-props-composition.js +433 -0
- package/lib/rules/require-server-timestamp-for-firestore-dates.d.ts +9 -0
- package/lib/rules/require-server-timestamp-for-firestore-dates.js +313 -0
- package/package.json +1 -1
- package/release-manifest.json +190 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.preferUseBase62Id = void 0;
|
|
4
|
+
const utils_1 = require("@typescript-eslint/utils");
|
|
5
|
+
const minimatch_1 = require("minimatch");
|
|
6
|
+
const createRule_1 = require("../utils/createRule");
|
|
7
|
+
const DEFAULT_TARGET_PATHS = [
|
|
8
|
+
'src/hooks/**',
|
|
9
|
+
'src/contexts/**',
|
|
10
|
+
'src/pages/**',
|
|
11
|
+
'src/components/**',
|
|
12
|
+
];
|
|
13
|
+
/**
|
|
14
|
+
* Returns true when the given source path ends with "util/uuidv4Base62",
|
|
15
|
+
* covering both relative and absolute import forms used in the BluMint monorepo.
|
|
16
|
+
*/
|
|
17
|
+
function isUuidv4Base62Source(source) {
|
|
18
|
+
// Strip any file extension the bundler may have appended
|
|
19
|
+
const normalized = source.replace(/\.(js|ts|tsx|jsx|mjs|cjs)$/, '');
|
|
20
|
+
return (normalized === 'uuidv4Base62' ||
|
|
21
|
+
normalized.endsWith('/uuidv4Base62') ||
|
|
22
|
+
normalized.endsWith('\\uuidv4Base62'));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Returns true when the function name matches the React hook naming convention:
|
|
26
|
+
* starts with "use" followed by an uppercase letter.
|
|
27
|
+
*/
|
|
28
|
+
function isHookName(name) {
|
|
29
|
+
return /^use[A-Z]/.test(name);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Returns true when the name is PascalCase (starts with uppercase letter),
|
|
33
|
+
* which is the naming convention for React components.
|
|
34
|
+
*/
|
|
35
|
+
function isPascalCase(name) {
|
|
36
|
+
return /^[A-Z]/.test(name);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Checks whether a node is a React component or hook function.
|
|
40
|
+
* Hooks: function whose name starts with "use" followed by an uppercase letter.
|
|
41
|
+
* Components: function with a PascalCase name.
|
|
42
|
+
*/
|
|
43
|
+
function isComponentOrHook(node) {
|
|
44
|
+
// FunctionDeclaration: function MyComponent() {} or function useHook() {}
|
|
45
|
+
if (node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration && node.id) {
|
|
46
|
+
return isPascalCase(node.id.name) || isHookName(node.id.name);
|
|
47
|
+
}
|
|
48
|
+
// FunctionExpression or ArrowFunctionExpression assigned to a variable:
|
|
49
|
+
// const MyComponent = () => {} or const useHook = () => {}
|
|
50
|
+
const parent = node.parent;
|
|
51
|
+
if (parent?.type === utils_1.AST_NODE_TYPES.VariableDeclarator) {
|
|
52
|
+
const id = parent.id;
|
|
53
|
+
if (id.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
54
|
+
return isPascalCase(id.name) || isHookName(id.name);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Recursively searches the given node tree for any CallExpression that calls
|
|
61
|
+
* one of the tracked uuidv4Base62 local names.
|
|
62
|
+
*/
|
|
63
|
+
function containsUuidv4Base62Call(node, trackedNames) {
|
|
64
|
+
if (!node)
|
|
65
|
+
return false;
|
|
66
|
+
if (node.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
67
|
+
node.callee.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
68
|
+
trackedNames.has(node.callee.name)) {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
// Walk child nodes
|
|
72
|
+
for (const key of Object.keys(node)) {
|
|
73
|
+
if (key === 'parent')
|
|
74
|
+
continue;
|
|
75
|
+
const child = node[key];
|
|
76
|
+
if (child && typeof child === 'object') {
|
|
77
|
+
if (Array.isArray(child)) {
|
|
78
|
+
for (const item of child) {
|
|
79
|
+
if (item &&
|
|
80
|
+
typeof item === 'object' &&
|
|
81
|
+
'type' in item &&
|
|
82
|
+
containsUuidv4Base62Call(item, trackedNames)) {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else if ('type' in child &&
|
|
88
|
+
containsUuidv4Base62Call(child, trackedNames)) {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Returns true when the argument to useRef contains uuidv4Base62() anywhere
|
|
97
|
+
* in its expression tree (direct call or inside a larger expression).
|
|
98
|
+
*/
|
|
99
|
+
function useRefArgContainsUuid(callNode, trackedNames) {
|
|
100
|
+
if (callNode.arguments.length === 0)
|
|
101
|
+
return false;
|
|
102
|
+
return containsUuidv4Base62Call(callNode.arguments[0], trackedNames);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* For a useState call, returns the argument node that contains uuidv4Base62()
|
|
106
|
+
* (either the direct first arg or the body of the first arrow function arg).
|
|
107
|
+
* Returns null when uuidv4Base62() is not found.
|
|
108
|
+
*/
|
|
109
|
+
function getUseStateUuidArg(callNode, trackedNames) {
|
|
110
|
+
if (callNode.arguments.length === 0)
|
|
111
|
+
return null;
|
|
112
|
+
const arg = callNode.arguments[0];
|
|
113
|
+
// Direct call: useState(uuidv4Base62()) or useState(expr ?? uuidv4Base62())
|
|
114
|
+
if (containsUuidv4Base62Call(arg, trackedNames))
|
|
115
|
+
return arg;
|
|
116
|
+
// Lazy initializer: useState(() => uuidv4Base62()) or useState(() => { return ...; })
|
|
117
|
+
if (arg.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
118
|
+
arg.type === utils_1.AST_NODE_TYPES.FunctionExpression) {
|
|
119
|
+
if (containsUuidv4Base62Call(arg.body, trackedNames))
|
|
120
|
+
return arg.body;
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Returns true when the useMemo call has an empty dependency array as its
|
|
126
|
+
* second argument.
|
|
127
|
+
*/
|
|
128
|
+
function hasEmptyDepsArray(callNode) {
|
|
129
|
+
if (callNode.arguments.length < 2)
|
|
130
|
+
return false;
|
|
131
|
+
const deps = callNode.arguments[1];
|
|
132
|
+
return (deps.type === utils_1.AST_NODE_TYPES.ArrayExpression && deps.elements.length === 0);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Checks whether the given identifier (the ref variable name) has its
|
|
136
|
+
* `.current` property assigned anywhere in the enclosing function body.
|
|
137
|
+
* This mirrors the useState setter-usage heuristic for useRef.
|
|
138
|
+
*/
|
|
139
|
+
function isRefCurrentReassigned(refName, functionBody) {
|
|
140
|
+
let found = false;
|
|
141
|
+
function walk(node) {
|
|
142
|
+
if (!node || found)
|
|
143
|
+
return;
|
|
144
|
+
// Look for `refName.current = ...`
|
|
145
|
+
if (node.type === utils_1.AST_NODE_TYPES.AssignmentExpression &&
|
|
146
|
+
node.left.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
147
|
+
!node.left.computed &&
|
|
148
|
+
node.left.object.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
149
|
+
node.left.object.name === refName &&
|
|
150
|
+
node.left.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
151
|
+
node.left.property.name === 'current') {
|
|
152
|
+
found = true;
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
for (const key of Object.keys(node)) {
|
|
156
|
+
if (key === 'parent')
|
|
157
|
+
continue;
|
|
158
|
+
const child = node[key];
|
|
159
|
+
if (child && typeof child === 'object') {
|
|
160
|
+
if (Array.isArray(child)) {
|
|
161
|
+
for (const item of child) {
|
|
162
|
+
if (item && typeof item === 'object' && 'type' in item) {
|
|
163
|
+
walk(item);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
else if ('type' in child) {
|
|
168
|
+
walk(child);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
walk(functionBody);
|
|
174
|
+
return found;
|
|
175
|
+
}
|
|
176
|
+
exports.preferUseBase62Id = (0, createRule_1.createRule)({
|
|
177
|
+
name: 'prefer-use-base62-id',
|
|
178
|
+
meta: {
|
|
179
|
+
type: 'suggestion',
|
|
180
|
+
docs: {
|
|
181
|
+
description: 'Prefer `useBase62Id()` over `uuidv4Base62()` inside useState/useRef/useMemo for stable component IDs to avoid SSR hydration mismatches',
|
|
182
|
+
recommended: 'error',
|
|
183
|
+
},
|
|
184
|
+
fixable: undefined,
|
|
185
|
+
schema: [
|
|
186
|
+
{
|
|
187
|
+
type: 'object',
|
|
188
|
+
properties: {
|
|
189
|
+
targetPaths: {
|
|
190
|
+
type: 'array',
|
|
191
|
+
items: { type: 'string' },
|
|
192
|
+
default: DEFAULT_TARGET_PATHS,
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
additionalProperties: false,
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
messages: {
|
|
199
|
+
preferUseBase62IdHook: "Prefer `useBase62Id()` from 'src/hooks/useBase62Id' for stable component IDs. `uuidv4Base62()` uses crypto.getRandomValues() which causes SSR hydration mismatches. `useBase62Id()` is deterministic across server and client rendering.",
|
|
200
|
+
preferUseBase62IdUseMemo: "Prefer `useBase62Id()` from 'src/hooks/useBase62Id' for stable component IDs. `useMemo(() => uuidv4Base62(), [])` with an empty dependency array is functionally equivalent to `useState(() => uuidv4Base62())` and causes SSR hydration mismatches. Use `useBase62Id()` instead.",
|
|
201
|
+
preferUseBase62IdTopLevel: 'Calling `uuidv4Base62()` at the top level of a component regenerates the ID on every render. Use `useBase62Id()` for a stable ID, or move the call inside a callback for per-operation uniqueness.',
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
defaultOptions: [{}],
|
|
205
|
+
create(context, [options]) {
|
|
206
|
+
const targetPaths = options.targetPaths ?? DEFAULT_TARGET_PATHS;
|
|
207
|
+
// Check whether the current file is inside a target path
|
|
208
|
+
const filename = context.getFilename();
|
|
209
|
+
const isInTargetPath = targetPaths.some((pattern) => (0, minimatch_1.minimatch)(filename, pattern, { matchBase: false }));
|
|
210
|
+
if (!isInTargetPath)
|
|
211
|
+
return {};
|
|
212
|
+
// Local names bound to the uuidv4Base62 export in the current file
|
|
213
|
+
const trackedUuidNames = new Set();
|
|
214
|
+
const pendingUseStateCalls = [];
|
|
215
|
+
const pendingUseRefCalls = [];
|
|
216
|
+
/**
|
|
217
|
+
* Finds the enclosing React component or hook function body for a given
|
|
218
|
+
* AST node, walking up the parent chain.
|
|
219
|
+
*/
|
|
220
|
+
function getEnclosingComponentOrHookBody(node) {
|
|
221
|
+
let current = node.parent;
|
|
222
|
+
while (current) {
|
|
223
|
+
if (current.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
|
|
224
|
+
current.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
|
|
225
|
+
current.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression) {
|
|
226
|
+
if (isComponentOrHook(current)) {
|
|
227
|
+
return current.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression
|
|
228
|
+
? current.body
|
|
229
|
+
: current.body;
|
|
230
|
+
}
|
|
231
|
+
// A non-component/hook function boundary — stop searching, we're
|
|
232
|
+
// inside a nested callback or event handler.
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
current = current.parent;
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Returns the depth at which the immediate parent is a React component or
|
|
241
|
+
* hook function body, ignoring expression-statement wrappers. Specifically,
|
|
242
|
+
* returns true when the node's parent chain is:
|
|
243
|
+
* ComponentOrHookBody → [ExpressionStatement] → VariableDeclaration → node
|
|
244
|
+
* i.e. the call is a direct statement inside the component body (not nested
|
|
245
|
+
* in a callback, effect, or hook call argument).
|
|
246
|
+
*/
|
|
247
|
+
function isDirectChildOfComponentBody(callNode) {
|
|
248
|
+
// Walk up past VariableDeclarator → VariableDeclaration → ...
|
|
249
|
+
let current = callNode.parent;
|
|
250
|
+
// Allow: CallExpression is the init of a VariableDeclarator
|
|
251
|
+
if (current?.type !== utils_1.AST_NODE_TYPES.VariableDeclarator)
|
|
252
|
+
return false;
|
|
253
|
+
current = current.parent;
|
|
254
|
+
if (current?.type !== utils_1.AST_NODE_TYPES.VariableDeclaration)
|
|
255
|
+
return false;
|
|
256
|
+
current = current.parent;
|
|
257
|
+
if (current?.type === utils_1.AST_NODE_TYPES.ExpressionStatement) {
|
|
258
|
+
current = current.parent;
|
|
259
|
+
}
|
|
260
|
+
// The parent must be a block that belongs to a component or hook
|
|
261
|
+
if (current?.type !== utils_1.AST_NODE_TYPES.BlockStatement)
|
|
262
|
+
return false;
|
|
263
|
+
const block = current;
|
|
264
|
+
const functionNode = block.parent;
|
|
265
|
+
if (!functionNode)
|
|
266
|
+
return false;
|
|
267
|
+
if (functionNode.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
|
|
268
|
+
functionNode.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
|
|
269
|
+
functionNode.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression) {
|
|
270
|
+
return isComponentOrHook(functionNode);
|
|
271
|
+
}
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Returns true when the given CallExpression to useState/useRef/useMemo is
|
|
276
|
+
* a direct top-level statement inside a React component or hook body
|
|
277
|
+
* (i.e. not nested inside another hook's callback argument, useEffect, etc.).
|
|
278
|
+
* Walking upward, the first function boundary we hit determines the answer:
|
|
279
|
+
* if it is a component or hook → true; otherwise → false.
|
|
280
|
+
*/
|
|
281
|
+
function isAtComponentTopLevel(callNode) {
|
|
282
|
+
let current = callNode.parent;
|
|
283
|
+
while (current) {
|
|
284
|
+
if (current.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
|
|
285
|
+
current.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
|
|
286
|
+
current.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression) {
|
|
287
|
+
// The first enclosing function boundary determines whether we are at
|
|
288
|
+
// the component/hook top level. If it is not a component or hook (e.g.
|
|
289
|
+
// a callback, effect, or event handler), the call is nested and should
|
|
290
|
+
// not be flagged.
|
|
291
|
+
return isComponentOrHook(current);
|
|
292
|
+
}
|
|
293
|
+
current = current.parent;
|
|
294
|
+
}
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
ImportDeclaration(node) {
|
|
299
|
+
if (!isUuidv4Base62Source(node.source.value))
|
|
300
|
+
return;
|
|
301
|
+
for (const specifier of node.specifiers) {
|
|
302
|
+
if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
303
|
+
specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
304
|
+
specifier.imported.name === 'uuidv4Base62') {
|
|
305
|
+
// Track the local alias (e.g. import { uuidv4Base62 as uuid } ...)
|
|
306
|
+
trackedUuidNames.add(specifier.local.name);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
CallExpression(node) {
|
|
311
|
+
if (trackedUuidNames.size === 0)
|
|
312
|
+
return;
|
|
313
|
+
const callee = node.callee;
|
|
314
|
+
if (callee.type !== utils_1.AST_NODE_TYPES.Identifier)
|
|
315
|
+
return;
|
|
316
|
+
// --- Pattern 3: useMemo(() => uuidv4Base62(), []) ---
|
|
317
|
+
if (callee.name === 'useMemo') {
|
|
318
|
+
if (!hasEmptyDepsArray(node))
|
|
319
|
+
return;
|
|
320
|
+
const firstArg = node.arguments[0];
|
|
321
|
+
if (!firstArg ||
|
|
322
|
+
(firstArg.type !== utils_1.AST_NODE_TYPES.ArrowFunctionExpression &&
|
|
323
|
+
firstArg.type !== utils_1.AST_NODE_TYPES.FunctionExpression)) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (!containsUuidv4Base62Call(firstArg.body, trackedUuidNames)) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (!isAtComponentTopLevel(node))
|
|
330
|
+
return;
|
|
331
|
+
context.report({
|
|
332
|
+
node,
|
|
333
|
+
messageId: 'preferUseBase62IdUseMemo',
|
|
334
|
+
});
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
// --- Pattern 1: useState(uuidv4Base62()) or useState(() => uuidv4Base62()) ---
|
|
338
|
+
if (callee.name === 'useState') {
|
|
339
|
+
if (!isAtComponentTopLevel(node))
|
|
340
|
+
return;
|
|
341
|
+
const uuidArg = getUseStateUuidArg(node, trackedUuidNames);
|
|
342
|
+
if (!uuidArg)
|
|
343
|
+
return;
|
|
344
|
+
// Inspect parent to determine setter destructuring
|
|
345
|
+
const parent = node.parent;
|
|
346
|
+
let setterName = null;
|
|
347
|
+
let noSetterDestructured = false;
|
|
348
|
+
if (parent?.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
|
|
349
|
+
parent.id.type === utils_1.AST_NODE_TYPES.ArrayPattern) {
|
|
350
|
+
const elements = parent.id.elements;
|
|
351
|
+
if (elements.length >= 2 && elements[1]) {
|
|
352
|
+
// Setter is present in destructuring
|
|
353
|
+
const setter = elements[1];
|
|
354
|
+
if (setter.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
355
|
+
setterName = setter.name;
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
// Non-identifier setter (e.g. rest, pattern): conservatively skip
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
// Only value destructured, no setter: `const [id] = useState(...)`
|
|
364
|
+
noSetterDestructured = true;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
else if (parent?.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
|
|
368
|
+
parent.id.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
369
|
+
// const state = useState(...) — no destructuring, no setter reference
|
|
370
|
+
noSetterDestructured = true;
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
// useState not assigned to a variable — treat as no setter
|
|
374
|
+
noSetterDestructured = true;
|
|
375
|
+
}
|
|
376
|
+
pendingUseStateCalls.push({
|
|
377
|
+
callNode: node,
|
|
378
|
+
setterName,
|
|
379
|
+
noSetterDestructured,
|
|
380
|
+
});
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
// --- Pattern 2: useRef(uuidv4Base62()) ---
|
|
384
|
+
if (callee.name === 'useRef') {
|
|
385
|
+
if (!isAtComponentTopLevel(node))
|
|
386
|
+
return;
|
|
387
|
+
if (!useRefArgContainsUuid(node, trackedUuidNames))
|
|
388
|
+
return;
|
|
389
|
+
// Find the variable name assigned to the ref
|
|
390
|
+
const parent = node.parent;
|
|
391
|
+
let refName = null;
|
|
392
|
+
if (parent?.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
|
|
393
|
+
parent.id.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
394
|
+
refName = parent.id.name;
|
|
395
|
+
}
|
|
396
|
+
const functionBody = getEnclosingComponentOrHookBody(node);
|
|
397
|
+
pendingUseRefCalls.push({
|
|
398
|
+
callNode: node,
|
|
399
|
+
refName,
|
|
400
|
+
functionBody,
|
|
401
|
+
});
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
// --- Pattern 4: bare top-level uuidv4Base62() call ---
|
|
405
|
+
if (trackedUuidNames.has(callee.name)) {
|
|
406
|
+
if (!isDirectChildOfComponentBody(node))
|
|
407
|
+
return;
|
|
408
|
+
context.report({
|
|
409
|
+
node,
|
|
410
|
+
messageId: 'preferUseBase62IdTopLevel',
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
'Program:exit'() {
|
|
415
|
+
// Evaluate pending useState calls
|
|
416
|
+
for (const pending of pendingUseStateCalls) {
|
|
417
|
+
const { callNode, setterName, noSetterDestructured } = pending;
|
|
418
|
+
if (noSetterDestructured) {
|
|
419
|
+
// No setter — always flag
|
|
420
|
+
context.report({
|
|
421
|
+
node: callNode,
|
|
422
|
+
messageId: 'preferUseBase62IdHook',
|
|
423
|
+
});
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
if (setterName === null)
|
|
427
|
+
continue;
|
|
428
|
+
// Check whether the setter identifier is referenced anywhere in the
|
|
429
|
+
// file beyond its destructuring site
|
|
430
|
+
const scope = context.getScope();
|
|
431
|
+
// Walk up to Program scope
|
|
432
|
+
let programScope = scope;
|
|
433
|
+
while (programScope.upper) {
|
|
434
|
+
programScope = programScope.upper;
|
|
435
|
+
}
|
|
436
|
+
// Find the variable for the setter
|
|
437
|
+
function findVarInScope(searchScope, name) {
|
|
438
|
+
for (const variable of searchScope.variables) {
|
|
439
|
+
if (variable.name === name) {
|
|
440
|
+
// References beyond the definition itself indicate usage
|
|
441
|
+
const usageRefs = variable.references.filter(
|
|
442
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
443
|
+
(ref) => !ref.isWrite() || !ref.init);
|
|
444
|
+
return usageRefs.length > 0;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
for (const childScope of searchScope.childScopes) {
|
|
448
|
+
if (findVarInScope(childScope, name))
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
const setterIsUsed = findVarInScope(programScope, setterName);
|
|
454
|
+
if (!setterIsUsed) {
|
|
455
|
+
context.report({
|
|
456
|
+
node: callNode,
|
|
457
|
+
messageId: 'preferUseBase62IdHook',
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
// Evaluate pending useRef calls
|
|
462
|
+
for (const pending of pendingUseRefCalls) {
|
|
463
|
+
const { callNode, refName, functionBody } = pending;
|
|
464
|
+
if (!refName || !functionBody) {
|
|
465
|
+
// Cannot determine ref name or body — flag conservatively
|
|
466
|
+
context.report({
|
|
467
|
+
node: callNode,
|
|
468
|
+
messageId: 'preferUseBase62IdHook',
|
|
469
|
+
});
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
if (!isRefCurrentReassigned(refName, functionBody)) {
|
|
473
|
+
context.report({
|
|
474
|
+
node: callNode,
|
|
475
|
+
messageId: 'preferUseBase62IdHook',
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
};
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
//# sourceMappingURL=prefer-use-base62-id.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const preferUseTheme: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"preferUseTheme", [], import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.preferUseTheme = void 0;
|
|
4
|
+
const utils_1 = require("@typescript-eslint/utils");
|
|
5
|
+
const createRule_1 = require("../utils/createRule");
|
|
6
|
+
/**
|
|
7
|
+
* The style module path fragments that contain theme constants. Matching is
|
|
8
|
+
* done via substring check on the raw import source value, so relative paths
|
|
9
|
+
* like `../../../styles/palette` and absolute-from-src paths like
|
|
10
|
+
* `src/styles/palette` both match `styles/palette`.
|
|
11
|
+
*/
|
|
12
|
+
const BANNED_STYLE_MODULE_FRAGMENTS = new Set([
|
|
13
|
+
'styles/palette',
|
|
14
|
+
'styles/elevations',
|
|
15
|
+
'styles/shadows',
|
|
16
|
+
'styles/scrollbars',
|
|
17
|
+
'styles/panels',
|
|
18
|
+
'styles/lineClamp',
|
|
19
|
+
'styles/typography',
|
|
20
|
+
'styles/system',
|
|
21
|
+
'styles/layout',
|
|
22
|
+
]);
|
|
23
|
+
/**
|
|
24
|
+
* The named export constants that are exposed on the MUI theme object and must
|
|
25
|
+
* be accessed via `useTheme()` (or the `sx` theme callback) rather than direct
|
|
26
|
+
* import. Only these specific names are flagged to avoid false positives from
|
|
27
|
+
* other exports (e.g. `COLORS`, `EASING`) that are NOT on the theme object.
|
|
28
|
+
*/
|
|
29
|
+
const BANNED_CONSTANTS = new Set([
|
|
30
|
+
'PALETTE',
|
|
31
|
+
'RARITIES',
|
|
32
|
+
'RARITIES_HOVER',
|
|
33
|
+
'RARITIES_BACKGROUND',
|
|
34
|
+
'ELEVATION',
|
|
35
|
+
'ELEVATIONS',
|
|
36
|
+
'SHADOWS',
|
|
37
|
+
'SHADOWS_HARD',
|
|
38
|
+
'BACKDROP_FILTERS',
|
|
39
|
+
'GLOWS',
|
|
40
|
+
'SCROLLBARS',
|
|
41
|
+
'PANELS',
|
|
42
|
+
'LINE_CLAMP',
|
|
43
|
+
'TYPOGRAPHY',
|
|
44
|
+
'ZINDEX',
|
|
45
|
+
'ASPECT_RATIO',
|
|
46
|
+
'BREAKPOINTS',
|
|
47
|
+
'BORDER_RADIUS',
|
|
48
|
+
'CONTAINER_WIDTH',
|
|
49
|
+
]);
|
|
50
|
+
/**
|
|
51
|
+
* Map from constant name to its theme-object equivalent path, used in the
|
|
52
|
+
* error message so that developers know exactly where to find the value on the
|
|
53
|
+
* theme object.
|
|
54
|
+
*/
|
|
55
|
+
const THEME_EQUIVALENTS = {
|
|
56
|
+
PALETTE: 'theme.palette',
|
|
57
|
+
RARITIES: 'theme.palette.rarity',
|
|
58
|
+
RARITIES_HOVER: 'theme.palette.rarityHover',
|
|
59
|
+
RARITIES_BACKGROUND: 'theme.palette.rarityBackground',
|
|
60
|
+
ELEVATION: 'theme.palette.background.elevation',
|
|
61
|
+
ELEVATIONS: 'theme.palette.background.elevation',
|
|
62
|
+
SHADOWS: 'theme.shadows',
|
|
63
|
+
SHADOWS_HARD: 'theme.shadowsHard',
|
|
64
|
+
BACKDROP_FILTERS: 'theme.glass',
|
|
65
|
+
GLOWS: 'theme.glow',
|
|
66
|
+
SCROLLBARS: 'theme.scrollbars',
|
|
67
|
+
PANELS: 'theme.panels',
|
|
68
|
+
LINE_CLAMP: 'theme.lineClamp',
|
|
69
|
+
TYPOGRAPHY: 'theme.typography',
|
|
70
|
+
ZINDEX: 'theme.zIndex',
|
|
71
|
+
ASPECT_RATIO: 'theme.aspectRatio',
|
|
72
|
+
BREAKPOINTS: 'theme.breakpoints',
|
|
73
|
+
BORDER_RADIUS: 'theme.shape.borderRadius',
|
|
74
|
+
CONTAINER_WIDTH: 'theme.mixins',
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Target file path fragments that the rule enforces inside. Files outside
|
|
78
|
+
* these directories are not flagged — this avoids false positives in utility
|
|
79
|
+
* or configuration modules that may have legitimate reasons to reference
|
|
80
|
+
* constants outside of a React rendering context.
|
|
81
|
+
*/
|
|
82
|
+
const TARGET_PATH_FRAGMENTS = [
|
|
83
|
+
'src/components/',
|
|
84
|
+
'src/hooks/',
|
|
85
|
+
'src/contexts/',
|
|
86
|
+
'src/pages/',
|
|
87
|
+
];
|
|
88
|
+
/** Returns true when the file is inside one of the target directories. */
|
|
89
|
+
function isInTargetPath(filename) {
|
|
90
|
+
return TARGET_PATH_FRAGMENTS.some((fragment) => filename.includes(fragment));
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Returns true when the file is inside `src/styles/`. These files BUILD the
|
|
94
|
+
* theme object and cannot use `useTheme()` because the hook is unavailable
|
|
95
|
+
* outside React component rendering context.
|
|
96
|
+
*/
|
|
97
|
+
function isInStylesDir(filename) {
|
|
98
|
+
return filename.includes('src/styles/');
|
|
99
|
+
}
|
|
100
|
+
/** Returns true when the file is a test or spec file. */
|
|
101
|
+
function isTestFile(filename) {
|
|
102
|
+
return (filename.endsWith('.test.ts') ||
|
|
103
|
+
filename.endsWith('.test.tsx') ||
|
|
104
|
+
filename.endsWith('.spec.ts') ||
|
|
105
|
+
filename.endsWith('.spec.tsx') ||
|
|
106
|
+
filename.includes('__tests__/'));
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Returns true when the import source resolves to one of the banned style
|
|
110
|
+
* modules. Matching is done via substring check, so both relative paths
|
|
111
|
+
* (`../../../styles/palette`) and paths with a `src/` prefix
|
|
112
|
+
* (`src/styles/palette`) are detected.
|
|
113
|
+
*/
|
|
114
|
+
function isBannedStyleModule(source) {
|
|
115
|
+
for (const fragment of BANNED_STYLE_MODULE_FRAGMENTS) {
|
|
116
|
+
if (source.includes(fragment)) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
exports.preferUseTheme = (0, createRule_1.createRule)({
|
|
123
|
+
name: 'prefer-use-theme',
|
|
124
|
+
meta: {
|
|
125
|
+
type: 'suggestion',
|
|
126
|
+
docs: {
|
|
127
|
+
description: 'Enforce reading MUI theme values via useTheme() or the sx theme callback instead of importing theme constants directly from src/styles/* modules.',
|
|
128
|
+
recommended: 'error',
|
|
129
|
+
},
|
|
130
|
+
schema: [],
|
|
131
|
+
messages: {
|
|
132
|
+
preferUseTheme: "Import '{{importName}}' from '{{sourceModule}}' bypasses the MUI theme system. Use useTheme() and access {{themeEquivalent}} instead.",
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
defaultOptions: [],
|
|
136
|
+
create(context) {
|
|
137
|
+
const filename = context.getFilename?.() ??
|
|
138
|
+
context.filename ??
|
|
139
|
+
'';
|
|
140
|
+
// Files that build the theme are always exempt.
|
|
141
|
+
if (isInStylesDir(filename)) {
|
|
142
|
+
return {};
|
|
143
|
+
}
|
|
144
|
+
// Test/spec files are exempt — they often assert against raw constant values.
|
|
145
|
+
if (isTestFile(filename)) {
|
|
146
|
+
return {};
|
|
147
|
+
}
|
|
148
|
+
// Only enforce inside the four target directories.
|
|
149
|
+
if (!isInTargetPath(filename)) {
|
|
150
|
+
return {};
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
ImportDeclaration(node) {
|
|
154
|
+
// Type-only import declarations never bypass the theme system.
|
|
155
|
+
if (node.importKind === 'type') {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const source = node.source.value;
|
|
159
|
+
if (!isBannedStyleModule(source)) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
// Namespace imports (`import * as X from '...'`) expose all exports,
|
|
163
|
+
// so we flag the entire namespace specifier when the source is banned.
|
|
164
|
+
for (const specifier of node.specifiers) {
|
|
165
|
+
if (specifier.type === utils_1.AST_NODE_TYPES.ImportNamespaceSpecifier) {
|
|
166
|
+
context.report({
|
|
167
|
+
node: specifier,
|
|
168
|
+
messageId: 'preferUseTheme',
|
|
169
|
+
data: {
|
|
170
|
+
importName: `* as ${specifier.local.name}`,
|
|
171
|
+
sourceModule: source,
|
|
172
|
+
themeEquivalent: 'the theme object via useTheme()',
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
// Named imports: only flag specifiers that are in the banned set.
|
|
178
|
+
if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier) {
|
|
179
|
+
// Skip type-only specifiers within a value import statement.
|
|
180
|
+
if (specifier.importKind === 'type') {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const importedName = specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier
|
|
184
|
+
? specifier.imported.name
|
|
185
|
+
: '';
|
|
186
|
+
if (!BANNED_CONSTANTS.has(importedName)) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const themeEquivalent = THEME_EQUIVALENTS[importedName] ??
|
|
190
|
+
'the theme object via useTheme()';
|
|
191
|
+
context.report({
|
|
192
|
+
node: specifier,
|
|
193
|
+
messageId: 'preferUseTheme',
|
|
194
|
+
data: {
|
|
195
|
+
importName: importedName,
|
|
196
|
+
sourceModule: source,
|
|
197
|
+
themeEquivalent,
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
//# sourceMappingURL=prefer-use-theme.js.map
|