@blumintinc/eslint-plugin-blumint 1.20.123 → 1.20.125

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.123',
226
+ version: '1.20.125',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -41,6 +41,45 @@ exports.enforceConsoleError = (0, createRule_1.createRule)({
41
41
  node.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
42
42
  node.callee.property.name === method);
43
43
  }
44
+ /**
45
+ * A type assertion carries no runtime behaviour, so `'error' as const`,
46
+ * `'error' satisfies Severity`, `<const>'error'` and `'error'!` all evaluate
47
+ * to exactly the string their inner expression evaluates to. Reading through
48
+ * the wrapper keeps a severity that is pinned at compile time out of the
49
+ * `dynamic` bucket, whose report demands both console methods and asserts
50
+ * the severity may render either dialog. The wrappers nest, so unwrap until
51
+ * a non-assertion node is reached.
52
+ */
53
+ function unwrapTypeAssertions(node) {
54
+ let current = node;
55
+ while (current.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
56
+ current.type === utils_1.AST_NODE_TYPES.TSSatisfiesExpression ||
57
+ current.type === utils_1.AST_NODE_TYPES.TSNonNullExpression ||
58
+ current.type === utils_1.AST_NODE_TYPES.TSTypeAssertion) {
59
+ current = current.expression;
60
+ }
61
+ return current;
62
+ }
63
+ /**
64
+ * Reads a severity that is fixed at compile time, or null when the value can
65
+ * only be known at runtime. A template with no substitutions denotes a
66
+ * single constant string exactly as a quoted literal does; only an
67
+ * INTERPOLATED template is genuinely dynamic.
68
+ */
69
+ function getStaticSeverityValue(node) {
70
+ const value = unwrapTypeAssertions(node);
71
+ if (value.type === utils_1.AST_NODE_TYPES.Literal &&
72
+ typeof value.value === 'string') {
73
+ return value.value;
74
+ }
75
+ if (value.type === utils_1.AST_NODE_TYPES.TemplateLiteral &&
76
+ value.expressions.length === 0 &&
77
+ value.quasis.length === 1 &&
78
+ typeof value.quasis[0].value.cooked === 'string') {
79
+ return value.quasis[0].value.cooked;
80
+ }
81
+ return null;
82
+ }
44
83
  function getSeverityFromObjectExpression(node) {
45
84
  for (const prop of node.properties) {
46
85
  if (prop.type === utils_1.AST_NODE_TYPES.Property) {
@@ -57,11 +96,11 @@ exports.enforceConsoleError = (0, createRule_1.createRule)({
57
96
  isSeverityProperty = true;
58
97
  }
59
98
  if (isSeverityProperty) {
60
- if (prop.value.type === utils_1.AST_NODE_TYPES.Literal &&
61
- typeof prop.value.value === 'string') {
62
- return prop.value.value;
99
+ const staticSeverity = getStaticSeverityValue(prop.value);
100
+ if (staticSeverity !== null) {
101
+ return staticSeverity;
63
102
  }
64
- // If severity is not a literal, treat as dynamic
103
+ // A severity only known at runtime forces the stricter dynamic path
65
104
  return 'dynamic';
66
105
  }
67
106
  }
@@ -8,6 +8,38 @@ const FIRESTORE_PATHS = [
8
8
  'firebase-admin',
9
9
  'firebase-admin/firestore',
10
10
  ];
11
+ /**
12
+ * `x as T`, `<T>x`, `x satisfies T` and `x!` assert a type without contributing
13
+ * a value of their own, so a check that classifies the *shape* of an expression
14
+ * must look through all four alike.
15
+ *
16
+ * This matters beyond hand-written code: sibling rules' autofixes append
17
+ * ` as const` to the very objects this rule inspects
18
+ * (`enforce-object-literal-as-const` rewrites
19
+ * `jest.mock(path, () => { return ({ db }); })` into
20
+ * `jest.mock(path, () => { return ({ db } as const); })`). A bare
21
+ * `node.type === ObjectExpression` test taken on the wrapper therefore goes
22
+ * silent on code `eslint --fix` had just reported (Issue #1806).
23
+ */
24
+ const ASSERTION_EXPRESSION_TYPES = new Set([
25
+ utils_1.AST_NODE_TYPES.TSAsExpression,
26
+ utils_1.AST_NODE_TYPES.TSSatisfiesExpression,
27
+ utils_1.AST_NODE_TYPES.TSNonNullExpression,
28
+ utils_1.AST_NODE_TYPES.TSTypeAssertion,
29
+ ]);
30
+ const isAssertionExpression = (node) => ASSERTION_EXPRESSION_TYPES.has(node.type);
31
+ /**
32
+ * Peels every assertion wrapper off an expression, so `{ db } as const`,
33
+ * `<const>{ db }` and chains such as `{ db } as const satisfies Module` all
34
+ * classify as the object literal they wrap.
35
+ */
36
+ const unwrapAssertions = (node) => {
37
+ let target = node;
38
+ while (isAssertionExpression(target)) {
39
+ target = target.expression;
40
+ }
41
+ return target;
42
+ };
11
43
  /**
12
44
  * A `jest.mock` factory produces the same module shape whether it is written as
13
45
  * a concise arrow, a block-bodied arrow, or a `function` expression. Matching
@@ -15,28 +47,37 @@ const FIRESTORE_PATHS = [
15
47
  * identical mock evade the rule on a body-form choice alone. Resolving to the
16
48
  * produced object keeps every spelling on one matching path.
17
49
  *
50
+ * Assertions are peeled at both ends — off the factory and off the expression
51
+ * it produces — so every body form is covered by one unwrap rather than only
52
+ * the spelling a bug report happened to quote.
53
+ *
18
54
  * A body with more than a lone `return` is deliberately unresolved: the object
19
55
  * reaching the caller can no longer be read off a single expression.
20
56
  */
21
57
  const resolveFactoryReturn = (factory) => {
22
- if (!factory ||
23
- (factory.type !== utils_1.AST_NODE_TYPES.ArrowFunctionExpression &&
24
- factory.type !== utils_1.AST_NODE_TYPES.FunctionExpression)) {
58
+ if (!factory) {
59
+ return undefined;
60
+ }
61
+ const callable = unwrapAssertions(factory);
62
+ if (callable.type !== utils_1.AST_NODE_TYPES.ArrowFunctionExpression &&
63
+ callable.type !== utils_1.AST_NODE_TYPES.FunctionExpression) {
25
64
  return undefined;
26
65
  }
27
66
  // A concise arrow body is the produced expression itself. Parentheses around
28
67
  // an object body are not part of the AST, so the node is matched directly.
29
- if (factory.body.type !== utils_1.AST_NODE_TYPES.BlockStatement) {
30
- return factory.body;
68
+ if (callable.body.type !== utils_1.AST_NODE_TYPES.BlockStatement) {
69
+ return unwrapAssertions(callable.body);
31
70
  }
32
- const statements = factory.body.body;
71
+ const statements = callable.body.body;
33
72
  if (statements.length !== 1) {
34
73
  return undefined;
35
74
  }
36
75
  const [statement] = statements;
37
- return statement.type === utils_1.AST_NODE_TYPES.ReturnStatement
38
- ? statement.argument ?? undefined
39
- : undefined;
76
+ if (statement.type !== utils_1.AST_NODE_TYPES.ReturnStatement ||
77
+ !statement.argument) {
78
+ return undefined;
79
+ }
80
+ return unwrapAssertions(statement.argument);
40
81
  };
41
82
  exports.enforceFirestoreMock = (0, createRule_1.createRule)({
42
83
  name: 'enforce-mock-firestore',
@@ -57,34 +98,51 @@ exports.enforceFirestoreMock = (0, createRule_1.createRule)({
57
98
  return {
58
99
  // Detect jest.mock() calls for firebaseAdmin
59
100
  CallExpression(node) {
60
- if (node.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
61
- node.callee.object.type === utils_1.AST_NODE_TYPES.Identifier &&
62
- node.callee.object.name === 'jest' &&
63
- node.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
64
- node.callee.property.name === 'mock' &&
65
- node.arguments.length > 0 &&
66
- node.arguments[0].type === utils_1.AST_NODE_TYPES.Literal &&
67
- typeof node.arguments[0].value === 'string' &&
68
- FIRESTORE_PATHS.some((path) => node.arguments[0].type === utils_1.AST_NODE_TYPES.Literal &&
69
- typeof node.arguments[0].value === 'string' &&
70
- node.arguments[0].value?.includes(path))) {
71
- // Check if the mock includes Firestore-related properties
72
- const mockedModule = resolveFactoryReturn(node.arguments[1]);
73
- if (mockedModule &&
74
- mockedModule.type === utils_1.AST_NODE_TYPES.ObjectExpression &&
75
- mockedModule.properties.some((prop) => prop.type === utils_1.AST_NODE_TYPES.Property &&
76
- prop.key.type === utils_1.AST_NODE_TYPES.Identifier &&
77
- (prop.key.name === 'db' ||
78
- prop.key.name === 'firestore' ||
79
- prop.key.name === 'getFirestore'))) {
80
- context.report({
81
- node,
82
- messageId: 'noManualFirestoreMock',
83
- });
84
- }
101
+ // `(jest.mock as any)(...)` and `jest!.mock(...)` call the same
102
+ // function, so the callee and its object are read through assertions
103
+ // too. The property name cannot carry one: an assertion there requires
104
+ // computed access, a spelling this rule does not resolve at all.
105
+ const callee = unwrapAssertions(node.callee);
106
+ if (callee.type !== utils_1.AST_NODE_TYPES.MemberExpression) {
107
+ return;
108
+ }
109
+ const calleeObject = unwrapAssertions(callee.object);
110
+ if (calleeObject.type !== utils_1.AST_NODE_TYPES.Identifier ||
111
+ calleeObject.name !== 'jest' ||
112
+ callee.property.type !== utils_1.AST_NODE_TYPES.Identifier ||
113
+ callee.property.name !== 'mock' ||
114
+ node.arguments.length === 0) {
115
+ return;
116
+ }
117
+ const modulePath = unwrapAssertions(node.arguments[0]);
118
+ if (modulePath.type !== utils_1.AST_NODE_TYPES.Literal) {
119
+ return;
120
+ }
121
+ const mockedPath = modulePath.value;
122
+ if (typeof mockedPath !== 'string' ||
123
+ !FIRESTORE_PATHS.some((path) => mockedPath.includes(path))) {
124
+ return;
125
+ }
126
+ // Check if the mock includes Firestore-related properties
127
+ const mockedModule = resolveFactoryReturn(node.arguments[1]);
128
+ if (mockedModule &&
129
+ mockedModule.type === utils_1.AST_NODE_TYPES.ObjectExpression &&
130
+ mockedModule.properties.some((prop) => prop.type === utils_1.AST_NODE_TYPES.Property &&
131
+ prop.key.type === utils_1.AST_NODE_TYPES.Identifier &&
132
+ (prop.key.name === 'db' ||
133
+ prop.key.name === 'firestore' ||
134
+ prop.key.name === 'getFirestore'))) {
135
+ context.report({
136
+ node,
137
+ messageId: 'noManualFirestoreMock',
138
+ });
85
139
  }
86
140
  },
87
- // Detect imports of mockFirebase
141
+ // Detect imports of mockFirebase.
142
+ //
143
+ // No unwrapping applies here: the grammar admits only a bare string
144
+ // literal as an import source and only a bare identifier as an imported
145
+ // name, so neither position can carry an assertion for a fixer to add.
88
146
  ImportDeclaration(node) {
89
147
  if (node.source.type === utils_1.AST_NODE_TYPES.Literal &&
90
148
  node.source.value === 'firestore-jest-mock' &&
@@ -478,15 +478,60 @@ exports.enforceQueryKeyTs = (0, createRule_1.createRule)({
478
478
  return false;
479
479
  }
480
480
  /**
481
- * Generate auto-fix suggestion for string literals
481
+ * The key's value when it is knowable without running the program, paired
482
+ * with the node that spells it.
483
+ *
484
+ * The substituted `QUERY_KEY_*` name is derived from that value, so what a
485
+ * fix needs is the value — not the notation carrying it. Gating on the node
486
+ * type instead left a static template reported exactly like the quoted
487
+ * string it renders to but with no fix behind the report (#1803). Every
488
+ * genuinely underivable shape — concatenation, a ternary, a template WITH
489
+ * expressions — holds no single value and falls out here on its own, so the
490
+ * conservative carve-out survives without being keyed to notation.
491
+ *
492
+ * Read through `cooked` rather than `raw` so an escape names the character
493
+ * it renders to, and the two spellings of one key derive one constant.
494
+ */
495
+ function staticKeyOf(node) {
496
+ if (node.type === utils_1.AST_NODE_TYPES.Literal) {
497
+ return typeof node.value === 'string'
498
+ ? { node, text: node.value }
499
+ : null;
500
+ }
501
+ if (node.type === utils_1.AST_NODE_TYPES.TemplateLiteral &&
502
+ node.expressions.length === 0) {
503
+ const cooked = node.quasis[0]?.value.cooked;
504
+ // A cooked value is absent only for an invalid escape sequence, which
505
+ // names no character and so cannot name a constant either.
506
+ return typeof cooked === 'string' ? { node, text: cooked } : null;
507
+ }
508
+ return null;
509
+ }
510
+ /**
511
+ * The `QUERY_KEY_*` constant a key value names, or null when it names none.
512
+ *
513
+ * A key that is empty, or built only from the characters normalization
514
+ * folds into separators and then strips, leaves nothing after the prefix:
515
+ * the bare `QUERY_KEY_` that emitted is a name `queryKeys.ts` neither
516
+ * exports nor plausibly would, so applying it traded a report for a file
517
+ * that no longer compiles. Declining here leaves the report standing with
518
+ * no fix, which is the honest outcome — the author has to choose a real
519
+ * key, and no rewrite can choose one for them.
520
+ *
521
+ * The test is on the derived text alone, so it answers the same way for
522
+ * every notation the same value can be written in; putting it in
523
+ * `staticKeyOf` instead would gate the fix on content at the point that
524
+ * exists to keep notation out of the gate (#1803, #1813).
482
525
  */
483
526
  function generateAutoFix(keyValue) {
484
- // Simple heuristic to suggest query key constant names
485
527
  const normalizedKey = keyValue
486
528
  .toUpperCase()
487
529
  .replace(/[^A-Z0-9]/g, '_')
488
530
  .replace(/_+/g, '_')
489
531
  .replace(/^_|_$/g, '');
532
+ if (normalizedKey === '') {
533
+ return null;
534
+ }
490
535
  return `QUERY_KEY_${normalizedKey}`;
491
536
  }
492
537
  return {
@@ -549,18 +594,17 @@ exports.enforceQueryKeyTs = (0, createRule_1.createRule)({
549
594
  if (!isValidQueryKeyUsage(keyValue)) {
550
595
  // Check if it contains invalid string literals
551
596
  if (containsInvalidStringLiteral(keyValue)) {
552
- // Only simple string literals can be auto-fixed
553
- const suggestedConstant = keyValue.type === utils_1.AST_NODE_TYPES.Literal &&
554
- typeof keyValue.value === 'string'
555
- ? generateAutoFix(keyValue.value)
597
+ // Only a statically known key value can be auto-fixed.
598
+ const staticKey = staticKeyOf(keyValue);
599
+ const suggestedConstant = staticKey
600
+ ? generateAutoFix(staticKey.text)
556
601
  : null;
557
602
  pendingReports.push({
558
603
  node: keyValue,
559
604
  messageId: 'enforceQueryKeyImport',
560
- substitution: suggestedConstant &&
561
- keyValue.type === utils_1.AST_NODE_TYPES.Literal
605
+ substitution: staticKey && suggestedConstant
562
606
  ? {
563
- keyNode: keyValue,
607
+ keyNode: staticKey.node,
564
608
  constant: suggestedConstant,
565
609
  scope: scopeOf(keyValue),
566
610
  }
@@ -9,6 +9,34 @@ const createRule_1 = require("../utils/createRule");
9
9
  function hasPseudoElementSelector(selector) {
10
10
  return /::?(before|after)\b/i.test(selector);
11
11
  }
12
+ /**
13
+ * Reads the static string a node denotes, so a property name or value carries
14
+ * the same meaning however it is spelled. A no-substitution template literal is
15
+ * a notation-only rewrite of a quoted string, and CSS-in-JS code writes both.
16
+ *
17
+ * Reading every name and value through one accessor keeps detection and the
18
+ * `pointerEvents` exemption on the same footing. Widening only the detection
19
+ * side would make the rule report objects that already set `pointerEvents` in a
20
+ * spelling it cannot read, and its fixer would append a second key — an object
21
+ * literal with duplicate keys does not compile.
22
+ *
23
+ * An interpolated template stays opaque: its text is not known statically, so
24
+ * the rule keeps its conservative silence there.
25
+ */
26
+ function staticStringOf(node) {
27
+ if (node.type === utils_1.AST_NODE_TYPES.Literal) {
28
+ return String(node.value);
29
+ }
30
+ if (node.type === utils_1.AST_NODE_TYPES.Identifier) {
31
+ return node.name;
32
+ }
33
+ if (node.type === utils_1.AST_NODE_TYPES.TemplateLiteral &&
34
+ node.expressions.length === 0 &&
35
+ node.quasis.length === 1) {
36
+ return node.quasis[0].value.cooked ?? node.quasis[0].value.raw;
37
+ }
38
+ return undefined;
39
+ }
12
40
  /**
13
41
  * Checks if a property name is position with absolute or fixed value
14
42
  */
@@ -151,6 +179,13 @@ exports.ensurePointerEventsNone = (0, createRule_1.createRule)({
151
179
  const absolutePositionedStyles = new Map();
152
180
  // Track style objects that already have pointer-events defined
153
181
  const stylesWithPointerEvents = new Map();
182
+ // Track style objects that declare a `pointerEvents` key whose value cannot
183
+ // be read statically (a member expression, a call, a ternary, an
184
+ // interpolated template). Such a value earns no exemption — it might be
185
+ // 'auto', so the report stands — but it does veto the fix: the rule's only
186
+ // remedy is to append a `pointerEvents` key, and an object literal with two
187
+ // identical keys does not compile (TS1117).
188
+ const stylesWithUnreadablePointerEvents = new Map();
154
189
  // Track style objects that are hit-slop touch-target extensions: an
155
190
  // absolute/fixed overlay whose inset offsets only extend beyond the origin
156
191
  // box (>=1 negative, none positive). A browser attributes pointer events on
@@ -164,40 +199,29 @@ exports.ensurePointerEventsNone = (0, createRule_1.createRule)({
164
199
  function processStyleObject(node) {
165
200
  let hasAbsolutePosition = false;
166
201
  let pointerEventsValue;
202
+ let hasUnreadablePointerEvents = false;
167
203
  let hasNegativeOffset = false;
168
204
  let hasPositiveOffset = false;
169
205
  // Check each property in the style object
170
206
  for (const property of node.properties) {
171
207
  if (property.type !== utils_1.AST_NODE_TYPES.Property)
172
208
  continue;
173
- let propertyName = '';
174
- let propertyValue;
175
- // Get property name
176
- if (property.key.type === utils_1.AST_NODE_TYPES.Identifier) {
177
- propertyName = property.key.name;
178
- }
179
- else if (property.key.type === utils_1.AST_NODE_TYPES.Literal &&
180
- typeof property.key.value === 'string') {
181
- propertyName = property.key.value;
182
- }
183
- // Get property value if it's a string literal
184
- if (property.value.type === utils_1.AST_NODE_TYPES.Literal) {
185
- propertyValue = String(property.value.value);
186
- }
187
- else if (property.value.type === utils_1.AST_NODE_TYPES.Identifier) {
188
- propertyValue = property.value.name;
189
- }
209
+ const propertyName = staticStringOf(property.key) ?? '';
210
+ const propertyValue = staticStringOf(property.value);
190
211
  // Check if this is position: absolute/fixed
191
212
  if (isAbsoluteOrFixedPosition(propertyName, propertyValue)) {
192
213
  hasAbsolutePosition = true;
193
214
  }
194
- // Check if this is pointer-events property
215
+ // Check if this is pointer-events property. A value that can be read
216
+ // decides the exemption; one that cannot is recorded separately, because
217
+ // the key's presence vetoes the fix even where it cannot prove the
218
+ // overlay is inert. An unreadable value never clears one already read.
195
219
  if (isPointerEventsProperty(propertyName)) {
196
- if (property.value.type === utils_1.AST_NODE_TYPES.Literal) {
197
- pointerEventsValue = String(property.value.value);
220
+ if (propertyValue !== undefined) {
221
+ pointerEventsValue = propertyValue;
198
222
  }
199
- else if (property.value.type === utils_1.AST_NODE_TYPES.Identifier) {
200
- pointerEventsValue = property.value.name;
223
+ else {
224
+ hasUnreadablePointerEvents = true;
201
225
  }
202
226
  }
203
227
  // Track inset offsets to detect hit-slop touch-target extensions
@@ -216,6 +240,7 @@ exports.ensurePointerEventsNone = (0, createRule_1.createRule)({
216
240
  if (pointerEventsValue !== undefined) {
217
241
  stylesWithPointerEvents.set(node, pointerEventsValue);
218
242
  }
243
+ stylesWithUnreadablePointerEvents.set(node, hasUnreadablePointerEvents);
219
244
  // A hit-slop extension only enlarges the tappable area: it is
220
245
  // absolute/fixed and its inset offsets extend outward (>=1 negative, none
221
246
  // positive). Such overlays cannot occlude the control they belong to.
@@ -246,6 +271,13 @@ exports.ensurePointerEventsNone = (0, createRule_1.createRule)({
246
271
  selector: formatSelector(selector),
247
272
  },
248
273
  fix(fixer) {
274
+ // The object already declares `pointerEvents`, but in a spelling
275
+ // whose value cannot be read. Appending the key is the rule's only
276
+ // remedy, and here it would emit a duplicate key that does not
277
+ // compile. A report with no fix is the correct outcome: the reader
278
+ // decides what the opaque value resolves to.
279
+ if (stylesWithUnreadablePointerEvents.get(node))
280
+ return null;
249
281
  // Find the last property in the object
250
282
  const sourceCode = context.sourceCode;
251
283
  const properties = node.properties;
@@ -350,12 +382,12 @@ exports.ensurePointerEventsNone = (0, createRule_1.createRule)({
350
382
  // Process CSS-in-JS libraries that use objects with selectors
351
383
  Property(node) {
352
384
  // Check for patterns like { '&::before': { ... } }
353
- if (node.key.type === utils_1.AST_NODE_TYPES.Literal &&
354
- typeof node.key.value === 'string' &&
355
- hasPseudoElementSelector(node.key.value) &&
385
+ const selector = staticStringOf(node.key);
386
+ if (selector !== undefined &&
387
+ hasPseudoElementSelector(selector) &&
356
388
  node.value.type === utils_1.AST_NODE_TYPES.ObjectExpression) {
357
389
  processStyleObject(node.value);
358
- checkStyleObject(node.value, node.key.value);
390
+ checkStyleObject(node.value, selector);
359
391
  }
360
392
  },
361
393
  };