@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.
Files changed (49) hide show
  1. package/README.md +21 -0
  2. package/lib/index.js +64 -1
  3. package/lib/rules/enforce-boolean-naming-prefixes.js +30 -14
  4. package/lib/rules/enforce-cloud-function-id-length.d.ts +5 -0
  5. package/lib/rules/enforce-cloud-function-id-length.js +104 -0
  6. package/lib/rules/enforce-is-prefix-validators.d.ts +13 -0
  7. package/lib/rules/enforce-is-prefix-validators.js +304 -0
  8. package/lib/rules/enforce-m3-sentence-case.d.ts +12 -0
  9. package/lib/rules/enforce-m3-sentence-case.js +430 -0
  10. package/lib/rules/enforce-snapshot-state-narrowing.d.ts +10 -0
  11. package/lib/rules/enforce-snapshot-state-narrowing.js +281 -0
  12. package/lib/rules/enforce-types-directory-placement.d.ts +9 -0
  13. package/lib/rules/enforce-types-directory-placement.js +276 -0
  14. package/lib/rules/no-direct-function-state.d.ts +8 -0
  15. package/lib/rules/no-direct-function-state.js +285 -0
  16. package/lib/rules/no-fill-template-mutation.d.ts +1 -0
  17. package/lib/rules/no-fill-template-mutation.js +324 -0
  18. package/lib/rules/no-portal-inside-tooltip.d.ts +10 -0
  19. package/lib/rules/no-portal-inside-tooltip.js +219 -0
  20. package/lib/rules/no-redundant-boolean-callback-props.d.ts +11 -0
  21. package/lib/rules/no-redundant-boolean-callback-props.js +355 -0
  22. package/lib/rules/no-satisfies-in-frontend-bundle.d.ts +8 -0
  23. package/lib/rules/no-satisfies-in-frontend-bundle.js +126 -0
  24. package/lib/rules/no-single-dismiss-dialog-button.d.ts +7 -0
  25. package/lib/rules/no-single-dismiss-dialog-button.js +127 -0
  26. package/lib/rules/no-stablehash-react-nodes.d.ts +1 -0
  27. package/lib/rules/no-stablehash-react-nodes.js +325 -0
  28. package/lib/rules/parallelize-loop-awaits.d.ts +8 -0
  29. package/lib/rules/parallelize-loop-awaits.js +582 -0
  30. package/lib/rules/prefer-flat-transform-each-keys.d.ts +1 -0
  31. package/lib/rules/prefer-flat-transform-each-keys.js +228 -0
  32. package/lib/rules/prefer-getter-over-parameterless-method.d.ts +1 -0
  33. package/lib/rules/prefer-getter-over-parameterless-method.js +44 -0
  34. package/lib/rules/prefer-spread-over-reassembly.d.ts +5 -0
  35. package/lib/rules/prefer-spread-over-reassembly.js +401 -0
  36. package/lib/rules/prefer-sx-prop-over-system-props.d.ts +9 -0
  37. package/lib/rules/prefer-sx-prop-over-system-props.js +401 -0
  38. package/lib/rules/prefer-use-base62-id.d.ts +8 -0
  39. package/lib/rules/prefer-use-base62-id.js +483 -0
  40. package/lib/rules/prefer-use-theme.d.ts +1 -0
  41. package/lib/rules/prefer-use-theme.js +206 -0
  42. package/lib/rules/prefer-utility-function-own-file.d.ts +9 -0
  43. package/lib/rules/prefer-utility-function-own-file.js +505 -0
  44. package/lib/rules/require-props-composition.d.ts +10 -0
  45. package/lib/rules/require-props-composition.js +433 -0
  46. package/lib/rules/require-server-timestamp-for-firestore-dates.d.ts +9 -0
  47. package/lib/rules/require-server-timestamp-for-firestore-dates.js +313 -0
  48. package/package.json +1 -1
  49. 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