@blumintinc/eslint-plugin-blumint 1.18.3 → 1.18.4
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/lib/index.js +1 -1
- package/lib/rules/enforce-singular-type-names.js +70 -3
- package/lib/rules/prefer-sx-prop-over-system-props.js +23 -2
- package/lib/rules/react-memoize-literals.js +39 -0
- package/lib/rules/require-server-timestamp-for-firestore-dates.d.ts +2 -1
- package/lib/rules/require-server-timestamp-for-firestore-dates.js +109 -0
- package/package.json +1 -1
- package/release-manifest.json +38 -0
package/lib/index.js
CHANGED
|
@@ -25,6 +25,7 @@ var __importStar = (this && this.__importStar) || function (mod) {
|
|
|
25
25
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
26
|
exports.enforceSingularTypeNames = void 0;
|
|
27
27
|
const createRule_1 = require("../utils/createRule");
|
|
28
|
+
const utils_1 = require("@typescript-eslint/utils");
|
|
28
29
|
const pluralize = __importStar(require("pluralize"));
|
|
29
30
|
const NON_PLURALIZABLE_SUFFIXES = [
|
|
30
31
|
'Props',
|
|
@@ -33,6 +34,66 @@ const NON_PLURALIZABLE_SUFFIXES = [
|
|
|
33
34
|
'Settings',
|
|
34
35
|
'Data',
|
|
35
36
|
];
|
|
37
|
+
/**
|
|
38
|
+
* Generic references whose instantiation is itself a container: `Array<T>` and
|
|
39
|
+
* `ReadonlyArray<T>`. `Readonly<T>` is handled separately — it is an identity
|
|
40
|
+
* wrapper that preserves T's shape, so we recurse into T rather than treating
|
|
41
|
+
* the reference itself as a container.
|
|
42
|
+
*/
|
|
43
|
+
const ARRAY_GENERIC_NAMES = new Set(['Array', 'ReadonlyArray']);
|
|
44
|
+
/**
|
|
45
|
+
* Returns true when the type alias RHS resolves to a container shape — a
|
|
46
|
+
* `TSArrayType` (`Foo[]`) or `TSTupleType` (`[A, B]`) — for which a plural name
|
|
47
|
+
* is the correct, self-documenting choice. Sees through identity-ish wrappers
|
|
48
|
+
* over the same shape: the `readonly` type operator, parenthesized types, and
|
|
49
|
+
* the `Readonly<T>` utility type; `Array<T>`/`ReadonlyArray<T>` are containers
|
|
50
|
+
* outright.
|
|
51
|
+
*/
|
|
52
|
+
function resolvesToContainerType(node) {
|
|
53
|
+
let current = node;
|
|
54
|
+
// Fixpoint loop: peel identity wrappers until a concrete shape is reached.
|
|
55
|
+
// Wrappers are finite; the cap only guards against a pathological cycle.
|
|
56
|
+
for (let i = 0; i < 10; i++) {
|
|
57
|
+
switch (current.type) {
|
|
58
|
+
case utils_1.AST_NODE_TYPES.TSArrayType:
|
|
59
|
+
case utils_1.AST_NODE_TYPES.TSTupleType:
|
|
60
|
+
return true;
|
|
61
|
+
case utils_1.AST_NODE_TYPES.TSTypeOperator: {
|
|
62
|
+
const operator = current;
|
|
63
|
+
if (operator.operator !== 'readonly' || !operator.typeAnnotation) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
current = operator.typeAnnotation;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
case utils_1.AST_NODE_TYPES.TSTypeReference: {
|
|
70
|
+
const ref = current;
|
|
71
|
+
if (ref.typeName.type !== utils_1.AST_NODE_TYPES.Identifier)
|
|
72
|
+
return false;
|
|
73
|
+
if (ARRAY_GENERIC_NAMES.has(ref.typeName.name))
|
|
74
|
+
return true;
|
|
75
|
+
if (ref.typeName.name === 'Readonly' &&
|
|
76
|
+
ref.typeParameters &&
|
|
77
|
+
ref.typeParameters.params.length > 0) {
|
|
78
|
+
current = ref.typeParameters.params[0];
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
default: {
|
|
84
|
+
// Parentheses (`(Foo[])`) — the parser may emit TSParenthesizedType.
|
|
85
|
+
// Matched by string since the node type is not always in the enum.
|
|
86
|
+
if (current.type === 'TSParenthesizedType' &&
|
|
87
|
+
'typeAnnotation' in current) {
|
|
88
|
+
current = current.typeAnnotation;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
36
97
|
exports.enforceSingularTypeNames = (0, createRule_1.createRule)({
|
|
37
98
|
create(context) {
|
|
38
99
|
/**
|
|
@@ -82,9 +143,15 @@ exports.enforceSingularTypeNames = (0, createRule_1.createRule)({
|
|
|
82
143
|
// Check type aliases
|
|
83
144
|
TSTypeAliasDeclaration(node) {
|
|
84
145
|
const name = node.id.name;
|
|
85
|
-
if (isPlural(name))
|
|
86
|
-
|
|
87
|
-
|
|
146
|
+
if (!isPlural(name))
|
|
147
|
+
return;
|
|
148
|
+
// A plural name correctly models a container type (array/tuple),
|
|
149
|
+
// including through identity-ish wrappers (Readonly<>, readonly, parens),
|
|
150
|
+
// so exempt it — matching the rule message's "leaves plural names for
|
|
151
|
+
// container types" promise.
|
|
152
|
+
if (resolvesToContainerType(node.typeAnnotation))
|
|
153
|
+
return;
|
|
154
|
+
reportPluralName(node.id, name, getSingularForm(name));
|
|
88
155
|
},
|
|
89
156
|
// Check interfaces
|
|
90
157
|
TSInterfaceDeclaration(node) {
|
|
@@ -139,6 +139,21 @@ const DEFAULT_MUI_COMPONENTS = new Set([
|
|
|
139
139
|
'AppBar',
|
|
140
140
|
'Toolbar',
|
|
141
141
|
]);
|
|
142
|
+
/**
|
|
143
|
+
* Components whose public prop API defines `color` as a closed semantic enum
|
|
144
|
+
* (a palette / variant selector like `'primary' | 'secondary' | 'error' | …`),
|
|
145
|
+
* not a CSS-forwarded system style prop. On these, `color` feeds
|
|
146
|
+
* `ownerState.color`, selecting theme variants and MUI's internal
|
|
147
|
+
* `.Mui*-color*` class selectors — moving it into `sx` both drops the variant
|
|
148
|
+
* selection and produces an invalid CSS `color` value. So `color` here is a
|
|
149
|
+
* first-class component prop, never a deprecated system prop.
|
|
150
|
+
*/
|
|
151
|
+
const COMPONENT_COLOR_IS_SEMANTIC = new Set([
|
|
152
|
+
'Button',
|
|
153
|
+
'IconButton',
|
|
154
|
+
'Chip',
|
|
155
|
+
'Badge',
|
|
156
|
+
]);
|
|
142
157
|
/**
|
|
143
158
|
* Props that must never be moved to `sx` because they are genuine component
|
|
144
159
|
* API props, not MUI system styling shorthands. `direction` and `spacing` are
|
|
@@ -268,7 +283,13 @@ exports.preferSxPropOverSystemProps = (0, createRule_1.createRule)({
|
|
|
268
283
|
return true;
|
|
269
284
|
return false;
|
|
270
285
|
}
|
|
271
|
-
function isSystemProp(name) {
|
|
286
|
+
function isSystemProp(name, componentName) {
|
|
287
|
+
// `color` is a semantic enum prop (not a CSS system prop) on components
|
|
288
|
+
// like Button/IconButton/Chip/Badge — exempt it there so the autofix
|
|
289
|
+
// never rewrites a variant selector into an invalid CSS color.
|
|
290
|
+
if (name === 'color' && COMPONENT_COLOR_IS_SEMANTIC.has(componentName)) {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
272
293
|
return MUI_SYSTEM_PROPS.has(name) && !isAllowedProp(name);
|
|
273
294
|
}
|
|
274
295
|
return {
|
|
@@ -291,7 +312,7 @@ exports.preferSxPropOverSystemProps = (0, createRule_1.createRule)({
|
|
|
291
312
|
if (name === 'sx') {
|
|
292
313
|
sxAttr = attr;
|
|
293
314
|
}
|
|
294
|
-
else if (isSystemProp(name)) {
|
|
315
|
+
else if (isSystemProp(name, componentName)) {
|
|
295
316
|
systemPropAttrs.push(attr);
|
|
296
317
|
}
|
|
297
318
|
}
|
|
@@ -711,6 +711,39 @@ exports.reactMemoizeLiterals = (0, createRule_1.createRule)({
|
|
|
711
711
|
}
|
|
712
712
|
return false;
|
|
713
713
|
}
|
|
714
|
+
/**
|
|
715
|
+
* True when the literal is a variable initializer whose every usage resolves
|
|
716
|
+
* to a style JSX attribute value (sx/style). This extends the inline
|
|
717
|
+
* `isStyleJSXAttributeValue` exemption across a variable: extracting an
|
|
718
|
+
* `sx`/`style` object to a local for readability is common, and the library
|
|
719
|
+
* consumes it by merging/normalizing on each render, not by reference — so
|
|
720
|
+
* memoization adds no stability benefit whether the object is inline or
|
|
721
|
+
* lifted one line above the JSX.
|
|
722
|
+
*
|
|
723
|
+
* The exemption holds only if *every* reference is a style value. If any
|
|
724
|
+
* usage flows through a call, spread, or non-style prop, that consumer can
|
|
725
|
+
* observe the reference, so the literal stays reported — the
|
|
726
|
+
* variable-mediated analogue of the inline `sx={makeSx({ … })}` guard.
|
|
727
|
+
*/
|
|
728
|
+
function isStyleVariableInitializer(node) {
|
|
729
|
+
const parent = node.parent;
|
|
730
|
+
if (!parent ||
|
|
731
|
+
parent.type !== utils_1.AST_NODE_TYPES.VariableDeclarator ||
|
|
732
|
+
parent.init !== node) {
|
|
733
|
+
return false;
|
|
734
|
+
}
|
|
735
|
+
const variables = ASTHelpers_1.ASTHelpers.getDeclaredVariables(context, parent);
|
|
736
|
+
if (variables.length === 0) {
|
|
737
|
+
return false;
|
|
738
|
+
}
|
|
739
|
+
const usages = variables[0].references.filter((ref) => !ref.init);
|
|
740
|
+
// No usages (dead code): can't prove the literal only feeds a style
|
|
741
|
+
// attribute, so leave it reported (mirrors isVariableAlwaysThrown).
|
|
742
|
+
if (usages.length === 0) {
|
|
743
|
+
return false;
|
|
744
|
+
}
|
|
745
|
+
return usages.every((ref) => isStyleJSXAttributeValue(ref.identifier));
|
|
746
|
+
}
|
|
714
747
|
function reportLiteral(node) {
|
|
715
748
|
const descriptor = getLiteralDescriptor(node);
|
|
716
749
|
if (!descriptor)
|
|
@@ -731,6 +764,12 @@ exports.reactMemoizeLiterals = (0, createRule_1.createRule)({
|
|
|
731
764
|
if (isStyleJSXAttributeValue(node)) {
|
|
732
765
|
return;
|
|
733
766
|
}
|
|
767
|
+
// Same rationale, followed across a variable: a literal assigned to a
|
|
768
|
+
// local whose every usage is a style JSX attribute value gains nothing
|
|
769
|
+
// from memoization.
|
|
770
|
+
if (isStyleVariableInitializer(node)) {
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
734
773
|
const hookCall = findEnclosingHookCall(node);
|
|
735
774
|
if (hookCall) {
|
|
736
775
|
if (hookCall.isDirectArgument ||
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { TSESLint } from '@typescript-eslint/utils';
|
|
1
2
|
type Options = [
|
|
2
3
|
{
|
|
3
4
|
firestoreTypePaths?: string[];
|
|
@@ -5,5 +6,5 @@ type Options = [
|
|
|
5
6
|
ignoreTestFiles?: boolean;
|
|
6
7
|
}
|
|
7
8
|
];
|
|
8
|
-
export declare const requireServerTimestampForFirestoreDates:
|
|
9
|
+
export declare const requireServerTimestampForFirestoreDates: TSESLint.RuleModule<"useServerTimestamp", Options, TSESLint.RuleListener>;
|
|
9
10
|
export {};
|
|
@@ -168,6 +168,101 @@ context) {
|
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
170
|
}
|
|
171
|
+
// Bare Firestore write methods invoked on a document reference / DocSetter,
|
|
172
|
+
// e.g. `docRef.set(payload)`, `batch.update(payload)`, `collection.add(payload)`.
|
|
173
|
+
const FIRESTORE_WRITE_METHOD_NAMES = new Set(['set', 'update', 'add']);
|
|
174
|
+
// Standalone Firestore write functions. These are `set`-prefixed but are writes,
|
|
175
|
+
// not React state setters, so they must NOT be mistaken for a render-seed sink.
|
|
176
|
+
const FIRESTORE_WRITE_FN_NAMES = new Set([
|
|
177
|
+
'setDoc',
|
|
178
|
+
'updateDoc',
|
|
179
|
+
'addDoc',
|
|
180
|
+
'setDocument',
|
|
181
|
+
]);
|
|
182
|
+
/**
|
|
183
|
+
* Walks up from an identifier reference through the value positions of object /
|
|
184
|
+
* array literals, spreads and casts to find the CallExpression it is an
|
|
185
|
+
* argument to. Returns that call, or null if the identifier does not flow into
|
|
186
|
+
* a call argument (e.g. it is the callee, or sits in some other position).
|
|
187
|
+
*/
|
|
188
|
+
function findEnclosingCallArgument(idNode) {
|
|
189
|
+
let node = idNode;
|
|
190
|
+
let parent = node.parent;
|
|
191
|
+
while (parent) {
|
|
192
|
+
if (parent.type === utils_1.AST_NODE_TYPES.CallExpression) {
|
|
193
|
+
return parent.arguments.includes(node)
|
|
194
|
+
? parent
|
|
195
|
+
: null;
|
|
196
|
+
}
|
|
197
|
+
const canAscend = (parent.type === utils_1.AST_NODE_TYPES.Property && parent.value === node) ||
|
|
198
|
+
parent.type === utils_1.AST_NODE_TYPES.ObjectExpression ||
|
|
199
|
+
parent.type === utils_1.AST_NODE_TYPES.ArrayExpression ||
|
|
200
|
+
parent.type === utils_1.AST_NODE_TYPES.SpreadElement ||
|
|
201
|
+
parent.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
|
|
202
|
+
parent.type ===
|
|
203
|
+
utils_1.AST_NODE_TYPES
|
|
204
|
+
.TSSatisfiesExpression ||
|
|
205
|
+
parent.type === utils_1.AST_NODE_TYPES.TSNonNullExpression;
|
|
206
|
+
if (!canAscend)
|
|
207
|
+
return null;
|
|
208
|
+
node = parent;
|
|
209
|
+
parent = parent.parent;
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* True when the call is a Firestore write: a `.set()`/`.update()`/`.add()`
|
|
215
|
+
* member call, or a standalone `setDoc`/`updateDoc`/`addDoc`/`setDocument`.
|
|
216
|
+
*/
|
|
217
|
+
function isFirestoreWriteCall(call) {
|
|
218
|
+
const callee = call.callee;
|
|
219
|
+
if (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
220
|
+
!callee.computed &&
|
|
221
|
+
callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
222
|
+
FIRESTORE_WRITE_METHOD_NAMES.has(callee.property.name)) {
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
return (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
226
|
+
FIRESTORE_WRITE_FN_NAMES.has(callee.name));
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* True when the call hands its argument to a React state sink: a `setXxx`
|
|
230
|
+
* setter (the `useState` convention) or `useState` itself. Firestore write
|
|
231
|
+
* functions are excluded even though some share the `set` prefix.
|
|
232
|
+
*/
|
|
233
|
+
function isStateSetterCall(call) {
|
|
234
|
+
const callee = call.callee;
|
|
235
|
+
if (callee.type !== utils_1.AST_NODE_TYPES.Identifier)
|
|
236
|
+
return false;
|
|
237
|
+
if (callee.name === 'useState')
|
|
238
|
+
return true;
|
|
239
|
+
return (/^set[A-Z]/.test(callee.name) && !FIRESTORE_WRITE_FN_NAMES.has(callee.name));
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* A local render seed is a variable that is handed to a React state setter (or
|
|
243
|
+
* useState) and never flows into a Firestore write. Such objects are annotated
|
|
244
|
+
* with a Firestore document type only so React state can be seeded with a
|
|
245
|
+
* document-shaped value; the client clock is correct for an optimistic render,
|
|
246
|
+
* and the authoritative timestamp arrives when the Firestore subscription
|
|
247
|
+
* replaces the seed. Being *typed* as a Firestore document is not evidence that
|
|
248
|
+
* the object is *written* to Firestore.
|
|
249
|
+
*/
|
|
250
|
+
function isLocalRenderSeedVariable(variable) {
|
|
251
|
+
let flowsToStateSetter = false;
|
|
252
|
+
let flowsToWrite = false;
|
|
253
|
+
for (const ref of variable.references) {
|
|
254
|
+
const call = findEnclosingCallArgument(ref.identifier);
|
|
255
|
+
if (!call)
|
|
256
|
+
continue;
|
|
257
|
+
if (isFirestoreWriteCall(call)) {
|
|
258
|
+
flowsToWrite = true;
|
|
259
|
+
}
|
|
260
|
+
else if (isStateSetterCall(call)) {
|
|
261
|
+
flowsToStateSetter = true;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return flowsToStateSetter && !flowsToWrite;
|
|
265
|
+
}
|
|
171
266
|
exports.requireServerTimestampForFirestoreDates = (0, createRule_1.createRule)({
|
|
172
267
|
name: 'require-server-timestamp-for-firestore-dates',
|
|
173
268
|
meta: {
|
|
@@ -254,6 +349,11 @@ exports.requireServerTimestampForFirestoreDates = (0, createRule_1.createRule)({
|
|
|
254
349
|
typeAnnotationReferencesFirestoreType(typeAnnotation, firestoreTypeNames) &&
|
|
255
350
|
node.init &&
|
|
256
351
|
node.init.type === utils_1.AST_NODE_TYPES.ObjectExpression) {
|
|
352
|
+
// Exempt local render seeds handed to React state, never written to
|
|
353
|
+
// Firestore — being typed as a Firestore doc is not a write.
|
|
354
|
+
if (context.getDeclaredVariables(node).some(isLocalRenderSeedVariable)) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
257
357
|
reportNewDatesInObject(node.init, context);
|
|
258
358
|
}
|
|
259
359
|
},
|
|
@@ -265,6 +365,15 @@ exports.requireServerTimestampForFirestoreDates = (0, createRule_1.createRule)({
|
|
|
265
365
|
const typeAnnotation = node.typeAnnotation;
|
|
266
366
|
if (!typeAnnotationReferencesFirestoreType(typeAnnotation, firestoreTypeNames))
|
|
267
367
|
return;
|
|
368
|
+
// Exempt `const seed = { ... } as FirestoreType` when `seed` is a local
|
|
369
|
+
// render seed handed to React state and never written to Firestore.
|
|
370
|
+
const parent = node.parent;
|
|
371
|
+
if (parent &&
|
|
372
|
+
parent.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
|
|
373
|
+
parent.init === node &&
|
|
374
|
+
context.getDeclaredVariables(parent).some(isLocalRenderSeedVariable)) {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
268
377
|
const inner = unwrapCast(node.expression);
|
|
269
378
|
if (inner.type === utils_1.AST_NODE_TYPES.ObjectExpression) {
|
|
270
379
|
reportNewDatesInObject(inner, context);
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,42 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.18.4",
|
|
4
|
+
"date": "2026-07-08T07:14:09.487Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "enforce-singular-type-names",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1275
|
|
11
|
+
],
|
|
12
|
+
"summary": "exempt container (array/tuple) type aliases (closes #1275)"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "prefer-sx-prop-over-system-props",
|
|
16
|
+
"changeType": "fix",
|
|
17
|
+
"issues": [
|
|
18
|
+
1273
|
|
19
|
+
],
|
|
20
|
+
"summary": "exempt semantic `color` on Button/IconButton/Chip/Badge (closes #1273)"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "react-memoize-literals",
|
|
24
|
+
"changeType": "fix",
|
|
25
|
+
"issues": [
|
|
26
|
+
1274
|
|
27
|
+
],
|
|
28
|
+
"summary": "follow sx/style exemption through variable-mediated values (closes #1274)"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "require-server-timestamp-for-firestore-dates",
|
|
32
|
+
"changeType": "fix",
|
|
33
|
+
"issues": [
|
|
34
|
+
1272
|
|
35
|
+
],
|
|
36
|
+
"summary": "exempt local render seeds passed to React state setters (closes #1272)"
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
},
|
|
2
40
|
{
|
|
3
41
|
"version": "1.18.3",
|
|
4
42
|
"date": "2026-07-06T05:16:06.067Z",
|