@blumintinc/eslint-plugin-blumint 1.20.111 → 1.20.113

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
@@ -223,7 +223,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
223
223
  module.exports = {
224
224
  meta: {
225
225
  name: '@blumintinc/eslint-plugin-blumint',
226
- version: '1.20.111',
226
+ version: '1.20.113',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -2,5 +2,17 @@ import { TSESLint } from '@typescript-eslint/utils';
2
2
  /**
3
3
  * This rule enforces the use of CSS media queries instead of JavaScript-based breakpoints
4
4
  * in React components for better performance and separation of concerns.
5
+ *
6
+ * Two exemptions exist, both resting on the same principle: the rule reports
7
+ * only where its remedy exists. A query testing capability or preference has no
8
+ * CSS remedy, and neither does a viewport breakpoint whose result never reaches
9
+ * a style.
10
+ *
11
+ * Known limitation of the destination exemption: a value handed to a child
12
+ * component through an ordinary prop is exempt here even if the child applies it
13
+ * to a class, because the walk stops at this file's props. That false negative
14
+ * is the accepted price of an analysis that stays inside one file; the
15
+ * alternative — reporting every value that leaves the component — is the
16
+ * unactionable report this exemption exists to remove.
5
17
  */
6
18
  export declare const enforceCssMediaQueries: TSESLint.RuleModule<"enforceCssMediaQueries", [], TSESLint.RuleListener>;
@@ -55,6 +55,18 @@ const NON_LAYOUT_FEATURES = new Set([
55
55
  ]);
56
56
  /** Every `prefers-*` feature is a user preference, never a layout measurement. */
57
57
  const PREFERENCE_FEATURE_PREFIX = 'prefers-';
58
+ /**
59
+ * JSX attributes and object properties that carry CSS. A value reaching one of
60
+ * these has the remedy the report prescribes — declare the breakpoint in a
61
+ * `@media` rule and let the class name change — so it keeps reporting.
62
+ */
63
+ const STYLE_DESTINATIONS = new Set([
64
+ 'sx',
65
+ 'style',
66
+ 'className',
67
+ 'classes',
68
+ 'css',
69
+ ]);
58
70
  /**
59
71
  * Guards the text a query carries outside its feature groups — media types and
60
72
  * combinators such as `screen`, `and`, `not`. A layout name appearing there
@@ -65,6 +77,12 @@ const LAYOUT_NAME = new RegExp([...LAYOUT_FEATURES].join('|'));
65
77
  const FEATURE_GROUP = /\(([^()]*)\)/g;
66
78
  /** Follows at most this many indirections while resolving a query argument. */
67
79
  const MAX_RESOLUTION_DEPTH = 4;
80
+ /**
81
+ * Follows at most this many hops while tracing a value to its destinations. A
82
+ * chain longer than this is unresolved and therefore reported, so the bound only
83
+ * ever costs an exemption.
84
+ */
85
+ const MAX_DESTINATION_DEPTH = 8;
68
86
  /**
69
87
  * The feature a parenthesized query group tests, or `null` when the group's
70
88
  * shape leaves it ambiguous (range syntax such as `(width >= 600px)`, a nested
@@ -154,9 +172,133 @@ function resolveBinding(node, scope, depth) {
154
172
  }
155
173
  return resolveQuery(definition.node.init, scope, depth + 1);
156
174
  }
175
+ /** The name a property is keyed by, or `null` when the key is computed. */
176
+ function propertyKeyName(property) {
177
+ const { key } = property;
178
+ if (!property.computed && key.type === utils_1.AST_NODE_TYPES.Identifier) {
179
+ return key.name;
180
+ }
181
+ if (key.type === utils_1.AST_NODE_TYPES.Literal && typeof key.value === 'string') {
182
+ return key.value;
183
+ }
184
+ return null;
185
+ }
186
+ /**
187
+ * Whether a JSX expression container hands its value to a prop CSS cannot
188
+ * express. A container in children position decides which markup renders, which
189
+ * a class name can do, so it is not exempt; neither is a style attribute nor a
190
+ * namespaced one, whose name this walk does not read.
191
+ */
192
+ function isNonStyleAttributeValue(container) {
193
+ const attribute = container.parent;
194
+ if (attribute?.type !== utils_1.AST_NODE_TYPES.JSXAttribute) {
195
+ return false;
196
+ }
197
+ return (attribute.name.type === utils_1.AST_NODE_TYPES.JSXIdentifier &&
198
+ !STYLE_DESTINATIONS.has(attribute.name.name));
199
+ }
200
+ /**
201
+ * Whether every destination the value reaches is one CSS cannot express — a
202
+ * `timeout`, an `anchorOrigin`, any prop a stylesheet has no way to select.
203
+ *
204
+ * The walk climbs from the value through the expressions that merely carry it
205
+ * (a conditional, an object it is nested in, a `const` it is bound to) until it
206
+ * reaches somewhere the value is consumed. It answers `false` for a style
207
+ * destination AND for every shape it does not model, because the exemption
208
+ * exists only where the rule's remedy is provably unavailable: a value returned,
209
+ * exported, passed to a call, or spread into props escapes to a destination this
210
+ * walk cannot see, and an unseen destination may well be a stylesheet.
211
+ */
212
+ function reachesOnlyNonStyleDestinations(node, context, depth) {
213
+ if (depth > MAX_DESTINATION_DEPTH) {
214
+ return false;
215
+ }
216
+ const parent = node.parent;
217
+ if (!parent) {
218
+ return false;
219
+ }
220
+ switch (parent.type) {
221
+ // Expressions that pass the value along: where their own result lands is
222
+ // the question that decides the original value.
223
+ case utils_1.AST_NODE_TYPES.ArrayExpression:
224
+ case utils_1.AST_NODE_TYPES.BinaryExpression:
225
+ case utils_1.AST_NODE_TYPES.ConditionalExpression:
226
+ case utils_1.AST_NODE_TYPES.LogicalExpression:
227
+ case utils_1.AST_NODE_TYPES.ObjectExpression:
228
+ case utils_1.AST_NODE_TYPES.SpreadElement:
229
+ case utils_1.AST_NODE_TYPES.TemplateLiteral:
230
+ case utils_1.AST_NODE_TYPES.TSAsExpression:
231
+ case utils_1.AST_NODE_TYPES.TSNonNullExpression:
232
+ case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
233
+ case utils_1.AST_NODE_TYPES.TSTypeAssertion:
234
+ case utils_1.AST_NODE_TYPES.UnaryExpression:
235
+ return reachesOnlyNonStyleDestinations(parent, context, depth + 1);
236
+ // A `sx`/`style`/`classes` key names a style destination wherever the object
237
+ // itself ends up, so the property is checked before the object is followed.
238
+ case utils_1.AST_NODE_TYPES.Property: {
239
+ const key = propertyKeyName(parent);
240
+ return ((key === null || !STYLE_DESTINATIONS.has(key)) &&
241
+ reachesOnlyNonStyleDestinations(parent, context, depth + 1));
242
+ }
243
+ case utils_1.AST_NODE_TYPES.JSXExpressionContainer:
244
+ return isNonStyleAttributeValue(parent);
245
+ case utils_1.AST_NODE_TYPES.VariableDeclarator:
246
+ return (parent.init === node &&
247
+ bindingReachesOnlyNonStyleDestinations(parent, context, depth));
248
+ default:
249
+ return false;
250
+ }
251
+ }
252
+ /**
253
+ * Whether every read of the binding this declarator introduces reaches a
254
+ * non-style destination.
255
+ *
256
+ * A binding with no reads is not exempt, mirroring `testsOnlyNonLayoutFeatures`
257
+ * on the query axis: a query naming no feature proves nothing, and neither does
258
+ * a value going nowhere.
259
+ */
260
+ function bindingReachesOnlyNonStyleDestinations(declarator, context, depth) {
261
+ if (declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier) {
262
+ return false;
263
+ }
264
+ // Only a `const` proves the reads below observe the value declared here; a
265
+ // `let` may hold something else by the time a style reads it. An exported
266
+ // binding is read in files this walk cannot open.
267
+ const declaration = declarator.parent;
268
+ if (declaration?.type !== utils_1.AST_NODE_TYPES.VariableDeclaration ||
269
+ declaration.kind !== 'const' ||
270
+ declaration.parent?.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration) {
271
+ return false;
272
+ }
273
+ const scope = ASTHelpers_1.ASTHelpers.getScope(context, declarator.id);
274
+ const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(scope, declarator.id.name);
275
+ if (!variable || variable.defs.length !== 1) {
276
+ return false;
277
+ }
278
+ const reads = variable.references.filter((reference) => reference.identifier !== declarator.id);
279
+ if (reads.length === 0) {
280
+ return false;
281
+ }
282
+ return reads.every((reference) => reference.isRead() &&
283
+ // `export { isMobile }` hands the value to another file.
284
+ reference.identifier.parent?.type !== utils_1.AST_NODE_TYPES.ExportSpecifier &&
285
+ reachesOnlyNonStyleDestinations(reference.identifier, context, depth + 1));
286
+ }
157
287
  /**
158
288
  * This rule enforces the use of CSS media queries instead of JavaScript-based breakpoints
159
289
  * in React components for better performance and separation of concerns.
290
+ *
291
+ * Two exemptions exist, both resting on the same principle: the rule reports
292
+ * only where its remedy exists. A query testing capability or preference has no
293
+ * CSS remedy, and neither does a viewport breakpoint whose result never reaches
294
+ * a style.
295
+ *
296
+ * Known limitation of the destination exemption: a value handed to a child
297
+ * component through an ordinary prop is exempt here even if the child applies it
298
+ * to a class, because the walk stops at this file's props. That false negative
299
+ * is the accepted price of an analysis that stays inside one file; the
300
+ * alternative — reporting every value that leaves the component — is the
301
+ * unactionable report this exemption exists to remove.
160
302
  */
161
303
  exports.enforceCssMediaQueries = (0, createRule_1.createRule)({
162
304
  name: 'enforce-css-media-queries',
@@ -187,7 +329,8 @@ exports.enforceCssMediaQueries = (0, createRule_1.createRule)({
187
329
  data: { source },
188
330
  });
189
331
  const localNamesOf = (node) => node.specifiers.map((specifier) => specifier.local.name);
190
- const isExemptCall = (node) => {
332
+ /** Whether the query the call carries is provably free of layout. */
333
+ const testsExemptQuery = (node) => {
191
334
  const [argument] = node.arguments;
192
335
  if (!argument) {
193
336
  return false;
@@ -195,6 +338,11 @@ exports.enforceCssMediaQueries = (0, createRule_1.createRule)({
195
338
  const query = resolveQuery(argument, ASTHelpers_1.ASTHelpers.getScope(context, node));
196
339
  return query !== null && testsOnlyNonLayoutFeatures(query);
197
340
  };
341
+ // A zero-argument hook such as `useMobile` carries no query, so the
342
+ // query axis can never clear it; the destination axis is the only one that
343
+ // can, and it applies to every media hook alike.
344
+ const isExemptCall = (node) => testsExemptQuery(node) ||
345
+ reachesOnlyNonStyleDestinations(node, context, 0);
198
346
  return {
199
347
  // Only react-responsive is handled at the declaration level to avoid duplicates.
200
348
  ImportDeclaration(node) {
@@ -5,5 +5,6 @@ declare const _default: import("@typescript-eslint/utils/dist/ts-eslint/Rule").R
5
5
  }[], {
6
6
  TSTypeAliasDeclaration(node: TSESTree.TSTypeAliasDeclaration): void;
7
7
  TSTypeReference(node: TSESTree.TSTypeReference): void;
8
+ 'Program:exit'(): void;
8
9
  }>;
9
10
  export default _default;
@@ -100,6 +100,13 @@ exports.default = (0, createRule_1.createRule)({
100
100
  break;
101
101
  }
102
102
  }
103
+ /**
104
+ * TypeScript hoists type aliases, so a request type may be declared below
105
+ * the function that consumes it. Collecting the wrapper references and
106
+ * resolving them once traversal is finished decouples the check from
107
+ * declaration order, which `typeAliasMap` alone cannot do.
108
+ */
109
+ const wrapperReferences = [];
103
110
  return {
104
111
  TSTypeAliasDeclaration(node) {
105
112
  typeAliasMap.set(node.id.name, node);
@@ -108,13 +115,25 @@ exports.default = (0, createRule_1.createRule)({
108
115
  const typeName = node.typeName.name;
109
116
  if (options.functionTypes.includes(typeName) &&
110
117
  node.typeParameters?.params[0]) {
111
- const typeParam = node.typeParameters.params[0];
118
+ wrapperReferences.push(node);
119
+ }
120
+ },
121
+ 'Program:exit'() {
122
+ for (const node of wrapperReferences) {
123
+ const typeParam = node.typeParameters?.params[0];
124
+ if (!typeParam)
125
+ continue;
112
126
  if (typeParam.type === utils_1.AST_NODE_TYPES.TSTypeReference) {
113
127
  const referencedTypeName = typeParam.typeName.name;
114
128
  const typeAlias = typeAliasMap.get(referencedTypeName);
115
- if (typeAlias) {
116
- checkTypeNode(typeAlias.typeAnnotation);
117
- }
129
+ /**
130
+ * A reference that names no local alias is still a type in its own
131
+ * right — `CallableRequest<Timestamp>` is the plainest form of the
132
+ * violation. Checking the node itself lets the non-serializable
133
+ * lookup and the generic descent apply; an unrecognized name simply
134
+ * yields no report, so an imported request type stays silent.
135
+ */
136
+ checkTypeNode(typeAlias ? typeAlias.typeAnnotation : typeParam);
118
137
  }
119
138
  else {
120
139
  checkTypeNode(typeParam);
@@ -4,6 +4,33 @@ exports.noUuidv4Base62AsKey = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
6
  const ASTHelpers_1 = require("../utils/ASTHelpers");
7
+ /**
8
+ * The helper's own module, identified by its file name rather than by a fixed
9
+ * list of specifiers: it is reachable as a package subpath, a tsconfig alias,
10
+ * or a relative path, and every one of those spellings imports the same
11
+ * function.
12
+ */
13
+ const UUIDV4_BASE62_MODULE = 'uuidv4Base62';
14
+ /** The barrel that re-exports the helper alongside unrelated utilities. */
15
+ const UUIDV4_BASE62_BARREL = '@blumint/utils';
16
+ const MODULE_EXTENSION = /\.(?:tsx?|jsx?)$/;
17
+ /**
18
+ * Matches the final path segment exactly rather than testing the whole
19
+ * specifier with a suffix check. A suffix test has no module resolution behind
20
+ * it, so it conflates monorepo tiers (`functions/src/util/uuidv4Base62` versus
21
+ * `src/util/uuidv4Base62`) and, worse, accepts sibling modules whose names
22
+ * merely end with the helper's name. An exact basename comparison keeps
23
+ * `../../util/uuidv4Base62Stable` — a different helper — out.
24
+ */
25
+ function isUuidv4Base62Module(source) {
26
+ if (typeof source !== 'string')
27
+ return false;
28
+ if (source === UUIDV4_BASE62_BARREL)
29
+ return true;
30
+ const segments = source.split('/');
31
+ const basename = segments[segments.length - 1].replace(MODULE_EXTENSION, '');
32
+ return basename === UUIDV4_BASE62_MODULE;
33
+ }
7
34
  exports.noUuidv4Base62AsKey = (0, createRule_1.createRule)({
8
35
  name: 'no-uuidv4-base62-as-key',
9
36
  meta: {
@@ -236,8 +263,7 @@ exports.noUuidv4Base62AsKey = (0, createRule_1.createRule)({
236
263
  },
237
264
  // Track imports of uuidv4Base62
238
265
  ImportDeclaration(node) {
239
- if (node.source.value === '@blumint/utils/uuidv4Base62' ||
240
- node.source.value === '@blumint/utils') {
266
+ if (isUuidv4Base62Module(node.source.value)) {
241
267
  for (const specifier of node.specifiers) {
242
268
  if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier) {
243
269
  if (specifier.imported.name === 'uuidv4Base62' ||
@@ -96,10 +96,14 @@ exports.preferCloneDeep = (0, createRule_1.createRule)({
96
96
  *
97
97
  * Merging unrelated sources copies nothing twice and is safe, which is why
98
98
  * shapes such as `{ ...a, nested: { ...b } }`, MUI `sx` style maps and
99
- * static config maps must not be flagged (#1371). A spread of the exact
100
- * same path (`{ ...a, x: { ...a } }`) is deliberately excluded as well: it
101
- * is a redundant copy rather than a partial one, and this repo prefers
102
- * false negatives over false positives.
99
+ * static config maps must not be flagged (#1371). That verdict depends on
100
+ * ALL of a literal's sources, not on any one of them: `{ ...props, sx: {
101
+ * ...DEFAULT_SX, ...props.sx } }` builds `sx` fresh out of two sources and
102
+ * aliases neither, so it is a merge even though one source happens to be a
103
+ * sub-path of the base (#1745). A spread of the exact same path
104
+ * (`{ ...a, x: { ...a } }`) is deliberately excluded as well: it is a
105
+ * redundant copy rather than a partial one, and this repo prefers false
106
+ * negatives over false positives.
103
107
  */
104
108
  function isPartialDeepCopy(node) {
105
109
  const cached = partialDeepCopyCache.get(node);
@@ -109,15 +113,18 @@ exports.preferCloneDeep = (0, createRule_1.createRule)({
109
113
  let hasFunction = false;
110
114
  let hasSymbol = false;
111
115
  const basePaths = new Set();
112
- const nestedPaths = [];
113
- function visit(current, depth = 0) {
116
+ // Spread paths kept grouped by the literal that writes them, because a
117
+ // literal is classified by its sources as a set: flattening them loses
118
+ // the co-spread relation the merge exemption is stated over.
119
+ const nestedGroups = [];
120
+ function visit(current, depth = 0, group = []) {
114
121
  if (current.type === utils_1.AST_NODE_TYPES.SpreadElement) {
115
122
  const path = accessPathOf(current.argument);
116
123
  if (depth === 0) {
117
124
  basePaths.add(path);
118
125
  }
119
126
  else {
120
- nestedPaths.push(path);
127
+ group.push(path);
121
128
  }
122
129
  }
123
130
  else if (current.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
@@ -145,24 +152,35 @@ exports.preferCloneDeep = (0, createRule_1.createRule)({
145
152
  // `...spread` at depth 0, where it names a base rather than a
146
153
  // hand-copied sub-path.
147
154
  if (current.type === utils_1.AST_NODE_TYPES.ObjectExpression) {
148
- current.properties.forEach((prop) => visit(prop, depth));
155
+ // Every literal owns the spreads written directly inside it. The
156
+ // root's spreads name bases instead, so only descendants contribute
157
+ // a group.
158
+ const ownGroup = [];
159
+ if (current !== node) {
160
+ nestedGroups.push(ownGroup);
161
+ }
162
+ current.properties.forEach((prop) => visit(prop, depth, ownGroup));
149
163
  }
150
164
  else if (current.type === utils_1.AST_NODE_TYPES.Property) {
151
- visit(current.value, depth + 1);
165
+ visit(current.value, depth + 1, group);
152
166
  }
153
167
  else if (current.type === utils_1.AST_NODE_TYPES.SpreadElement) {
154
- visit(current.argument, depth);
168
+ visit(current.argument, depth, group);
155
169
  }
156
170
  }
157
171
  visit(node);
172
+ // The separators guard against a sibling whose name merely starts with a
173
+ // base's name (`abc.x` is not a sub-path of `ab`).
174
+ const isBaseSubPath = (nested) => [...basePaths].some((base) => nested.startsWith(`${base}.`) || nested.startsWith(`${base}[`));
158
175
  // cloneDeep cannot faithfully reproduce functions or symbol keys, so
159
176
  // their presence suppresses the report regardless of the copy shape.
160
177
  const result = !hasFunction &&
161
178
  !hasSymbol &&
162
- nestedPaths.some((nested) =>
163
- // The separators guard against a sibling whose name merely starts
164
- // with a base's name (`abc.x` is not a sub-path of `ab`).
165
- [...basePaths].some((base) => nested.startsWith(`${base}.`) || nested.startsWith(`${base}[`)));
179
+ // A nested literal is a hand-written partial copy only when EVERY
180
+ // source it spreads is a sub-path of a spread base. One foreign source
181
+ // makes the literal a fresh merge of both, which aliases nothing and is
182
+ // not expressible as cloneDeep overrides.
183
+ nestedGroups.some((group) => group.length > 0 && group.every(isBaseSubPath));
166
184
  partialDeepCopyCache.set(node, result);
167
185
  return result;
168
186
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.111",
3
+ "version": "1.20.113",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,48 @@
1
1
  [
2
+ {
3
+ "version": "1.20.113",
4
+ "date": "2026-08-05T17:37:08.020Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-serializable-params",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1750
11
+ ],
12
+ "summary": "report a non-JSON-safe type used directly as the request type parameter (closes #1750)"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "version": "1.20.112",
18
+ "date": "2026-08-05T16:17:08.283Z",
19
+ "rules": [
20
+ {
21
+ "name": "enforce-css-media-queries",
22
+ "changeType": "fix",
23
+ "issues": [
24
+ 1746
25
+ ],
26
+ "summary": "exempt breakpoints that reach no style (closes #1746)"
27
+ },
28
+ {
29
+ "name": "no-uuidv4-base62-as-key",
30
+ "changeType": "fix",
31
+ "issues": [
32
+ 1744
33
+ ],
34
+ "summary": "recognize the helper by module basename (closes #1744)"
35
+ },
36
+ {
37
+ "name": "prefer-clone-deep",
38
+ "changeType": "fix",
39
+ "issues": [
40
+ 1745
41
+ ],
42
+ "summary": "classify a nested literal by all its sources (closes #1745)"
43
+ }
44
+ ]
45
+ },
2
46
  {
3
47
  "version": "1.20.111",
4
48
  "date": "2026-08-05T13:37:20.101Z",