@blumintinc/eslint-plugin-blumint 1.18.2 → 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 CHANGED
@@ -218,7 +218,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
218
218
  module.exports = {
219
219
  meta: {
220
220
  name: '@blumintinc/eslint-plugin-blumint',
221
- version: '1.18.2',
221
+ version: '1.18.4',
222
222
  },
223
223
  parseOptions: {
224
224
  ecmaVersion: 2020,
@@ -23,7 +23,11 @@ exports.avoidUtilsDirectory = (0, createRule_1.createRule)({
23
23
  create(context) {
24
24
  return {
25
25
  Program(node) {
26
- const filename = context.getFilename();
26
+ // Normalize Windows backslash separators so the forward-slash `utils/`
27
+ // regex below matches on every platform. Without this, `getFilename()`
28
+ // returns `C:\repo\src\utils\foo.ts` on Windows, the regex never
29
+ // matches, and the rule silently reports nothing (issue #1270).
30
+ const filename = context.getFilename().replace(/\\/g, '/');
27
31
  const relativePath = path_1.default.isAbsolute(filename)
28
32
  ? path_1.default.relative(process.cwd(), filename) || filename
29
33
  : filename;
@@ -24,7 +24,13 @@ exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
24
24
  },
25
25
  defaultOptions: [],
26
26
  create(context) {
27
- const filename = context.getFilename();
27
+ // Normalize Windows backslash separators so the forward-slash pattern below
28
+ // matches on every platform. Without this, `getFilename()` returns
29
+ // `C:\repo\functions\src\types\firestore\...\index.ts` on Windows, the
30
+ // pattern never matches, the guard bails, and the rule silently enforces
31
+ // nothing (issue #1271). Forward slashes are valid separators for the
32
+ // path.basename/path.dirname calls below on all platforms.
33
+ const filename = context.getFilename().replace(/\\/g, '/');
28
34
  const firestoreTypesPattern = /functions\/src\/types\/firestore\/.*\/index\.ts$/;
29
35
  // Only apply rule to index.ts files in the firestore types directory
30
36
  if (!firestoreTypesPattern.test(filename)) {
@@ -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
- reportPluralName(node.id, name, getSingularForm(name));
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: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"useServerTimestamp", Options, import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.18.2",
3
+ "version": "1.18.4",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,64 @@
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
+ },
40
+ {
41
+ "version": "1.18.3",
42
+ "date": "2026-07-06T05:16:06.067Z",
43
+ "rules": [
44
+ {
45
+ "name": "avoid-utils-directory",
46
+ "changeType": "fix",
47
+ "issues": [
48
+ 1270
49
+ ],
50
+ "summary": "normalize Windows path separators before regex match (closes #1270)"
51
+ },
52
+ {
53
+ "name": "enforce-identifiable-firestore-type",
54
+ "changeType": "fix",
55
+ "issues": [
56
+ 1271
57
+ ],
58
+ "summary": "normalize Windows path separators before regex match (closes #1271)"
59
+ }
60
+ ]
61
+ },
2
62
  {
3
63
  "version": "1.18.2",
4
64
  "date": "2026-07-06T05:00:28.741Z",