@blumintinc/eslint-plugin-blumint 1.20.8 → 1.20.10

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.
@@ -9,7 +9,37 @@ const DEFAULT_SNAPSHOT_HOOKS = [
9
9
  'useCachedDocSnapshot',
10
10
  'useFirestore',
11
11
  ];
12
- const DEFAULT_GUARD_FUNCTIONS = ['isSnapshotReady'];
12
+ const GUARD_NAME = 'isSnapshotReady';
13
+ const DEFAULT_GUARD_FUNCTIONS = [GUARD_NAME];
14
+ /**
15
+ * Where the canonical guard lives. The rule's own default `excludeFiles` entry
16
+ * names the guard's implementation file, so the fixer writes an import for that
17
+ * very module rather than guessing a path.
18
+ */
19
+ const DEFAULT_GUARD_IMPORT_SOURCE = 'src/types/FirestoreSnapshotState';
20
+ /**
21
+ * Parents that accept a bare conditional expression without parentheses. Any
22
+ * other parent binds tighter than `?:` (or reads ambiguously next to it), so the
23
+ * rewritten expression is wrapped.
24
+ */
25
+ const PARENTHESES_FREE_PARENTS = new Set([
26
+ utils_1.AST_NODE_TYPES.ArrayExpression,
27
+ utils_1.AST_NODE_TYPES.ArrowFunctionExpression,
28
+ utils_1.AST_NODE_TYPES.AssignmentExpression,
29
+ utils_1.AST_NODE_TYPES.AssignmentPattern,
30
+ utils_1.AST_NODE_TYPES.DoWhileStatement,
31
+ utils_1.AST_NODE_TYPES.ExpressionStatement,
32
+ utils_1.AST_NODE_TYPES.ForStatement,
33
+ utils_1.AST_NODE_TYPES.IfStatement,
34
+ utils_1.AST_NODE_TYPES.JSXExpressionContainer,
35
+ utils_1.AST_NODE_TYPES.Property,
36
+ utils_1.AST_NODE_TYPES.ReturnStatement,
37
+ utils_1.AST_NODE_TYPES.SwitchCase,
38
+ utils_1.AST_NODE_TYPES.SwitchStatement,
39
+ utils_1.AST_NODE_TYPES.ThrowStatement,
40
+ utils_1.AST_NODE_TYPES.VariableDeclarator,
41
+ utils_1.AST_NODE_TYPES.WhileStatement,
42
+ ]);
13
43
  exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
14
44
  name: 'enforce-snapshot-state-narrowing',
15
45
  meta: {
@@ -38,6 +68,10 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
38
68
  items: { type: 'string' },
39
69
  description: 'File patterns to exclude from this rule',
40
70
  },
71
+ guardImportSource: {
72
+ type: 'string',
73
+ description: 'Module specifier the suggestion imports isSnapshotReady from',
74
+ },
41
75
  },
42
76
  additionalProperties: false,
43
77
  },
@@ -56,6 +90,7 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
56
90
  const excludeFiles = options?.excludeFiles ?? [
57
91
  'src/types/FirestoreSnapshotState.ts',
58
92
  ];
93
+ const guardImportSource = options?.guardImportSource ?? DEFAULT_GUARD_IMPORT_SOURCE;
59
94
  // Check if the current file should be excluded
60
95
  const filename = context.getFilename();
61
96
  if (excludeFiles.some((pattern) => filename.endsWith(pattern))) {
@@ -98,6 +133,149 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
98
133
  return '<expression>';
99
134
  }
100
135
  }
136
+ /**
137
+ * Strips a module specifier down to its final segment so an import written
138
+ * as a relative path, an alias, or with an extension is still recognized as
139
+ * the guard's module.
140
+ */
141
+ function moduleBasename(source) {
142
+ const withoutExtension = source.replace(/\.[cm]?[jt]sx?$/, '');
143
+ const segments = withoutExtension.split('/');
144
+ return segments[segments.length - 1];
145
+ }
146
+ const guardModuleName = moduleBasename(guardImportSource);
147
+ function isGuardModule(source) {
148
+ return (source === guardImportSource ||
149
+ moduleBasename(source) === guardModuleName);
150
+ }
151
+ function importDeclarationsOf() {
152
+ return context
153
+ .getSourceCode()
154
+ .ast.body.filter((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
155
+ typeof statement.source.value === 'string');
156
+ }
157
+ function isValueImportSpecifier(specifier) {
158
+ return (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
159
+ specifier.importKind !== 'type');
160
+ }
161
+ /**
162
+ * Decides whether the emitted guard call resolves as written, needs an
163
+ * import, or would collide with an unrelated binding of the same name.
164
+ */
165
+ function resolveGuardBinding(scope) {
166
+ const variable = utils_1.ASTUtils.findVariable(scope, GUARD_NAME);
167
+ if (!variable) {
168
+ return 'missing';
169
+ }
170
+ const [definition] = variable.defs;
171
+ if (!definition) {
172
+ return 'conflict';
173
+ }
174
+ const definitionNode = definition.node;
175
+ if (definitionNode.type === utils_1.AST_NODE_TYPES.ImportSpecifier ||
176
+ definitionNode.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier) {
177
+ // A type-only binding is erased at runtime, so calling it is not an
178
+ // option — importing the value would then clash with the type binding.
179
+ if (definitionNode.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
180
+ definitionNode.importKind === 'type') {
181
+ return 'conflict';
182
+ }
183
+ const declaration = definitionNode.parent;
184
+ if (!declaration ||
185
+ declaration.type !== utils_1.AST_NODE_TYPES.ImportDeclaration ||
186
+ declaration.importKind === 'type') {
187
+ return 'conflict';
188
+ }
189
+ // Any value import under this name reaches the guard (a barrel or
190
+ // re-export is still the same function), so no second import is needed.
191
+ return 'bound';
192
+ }
193
+ // A file that declares the guard itself already has a callable binding.
194
+ if (definitionNode.type === utils_1.AST_NODE_TYPES.FunctionDeclaration) {
195
+ return 'bound';
196
+ }
197
+ if (definitionNode.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
198
+ (definitionNode.init?.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
199
+ definitionNode.init?.type === utils_1.AST_NODE_TYPES.FunctionExpression)) {
200
+ return 'bound';
201
+ }
202
+ return 'conflict';
203
+ }
204
+ /**
205
+ * Makes the emitted guard call resolve: extend an existing import of the
206
+ * guard's module when there is one to extend (reusing that file's own path),
207
+ * otherwise add the canonical import statement.
208
+ */
209
+ function buildImportFix(fixer) {
210
+ const declarations = importDeclarationsOf();
211
+ const guardDeclarations = declarations.filter((declaration) => isGuardModule(String(declaration.source.value)));
212
+ const reusable = guardDeclarations.find((declaration) => declaration.importKind !== 'type' &&
213
+ declaration.specifiers.some(isValueImportSpecifier));
214
+ if (reusable) {
215
+ const namedSpecifiers = reusable.specifiers.filter(isValueImportSpecifier);
216
+ const lastSpecifier = namedSpecifiers[namedSpecifiers.length - 1];
217
+ return fixer.insertTextAfter(lastSpecifier, `, ${GUARD_NAME}`);
218
+ }
219
+ // A namespace or type-only import of the module cannot take a named value
220
+ // specifier, but its path is proof of how this file reaches the module.
221
+ const source = guardDeclarations.length
222
+ ? String(guardDeclarations[0].source.value)
223
+ : guardImportSource;
224
+ const importText = `import { ${GUARD_NAME} } from '${source}';\n`;
225
+ const [firstImport] = declarations;
226
+ if (firstImport) {
227
+ return fixer.insertTextBefore(firstImport, importText);
228
+ }
229
+ // Keep the import visually separated from the code it precedes unless the
230
+ // file already opens with a blank line.
231
+ const separator = /^\r?\n/.test(context.getSourceCode().text) ? '' : '\n';
232
+ return fixer.insertTextBeforeRange([0, 0], `${importText}${separator}`);
233
+ }
234
+ /**
235
+ * Builds the single suggestion shared by every report: swap the flagged
236
+ * expression for its guard-based equivalent and bring `isSnapshotReady` into
237
+ * scope. Declines (no suggestion) when the name is already taken by
238
+ * something that is not the guard.
239
+ */
240
+ function guardSuggestion(messageId, fixNode, replacement, scope) {
241
+ return [
242
+ {
243
+ messageId,
244
+ data: { expression: replacement },
245
+ fix(fixer) {
246
+ const binding = resolveGuardBinding(scope);
247
+ if (binding === 'conflict') {
248
+ return null;
249
+ }
250
+ const fixes = [fixer.replaceText(fixNode, replacement)];
251
+ if (binding === 'missing') {
252
+ fixes.unshift(buildImportFix(fixer));
253
+ }
254
+ return fixes;
255
+ },
256
+ },
257
+ ];
258
+ }
259
+ /**
260
+ * True when a conditional expression substituted for `node` would need
261
+ * parentheses to preserve the surrounding expression's grouping.
262
+ */
263
+ function needsParentheses(node) {
264
+ // Source parentheses live outside the replaced range, so they already do
265
+ // the grouping and a second pair would only add noise.
266
+ if (utils_1.ASTUtils.isParenthesized(node, context.getSourceCode())) {
267
+ return false;
268
+ }
269
+ const parent = node.parent;
270
+ if (!parent) {
271
+ return true;
272
+ }
273
+ if (parent.type === utils_1.AST_NODE_TYPES.CallExpression ||
274
+ parent.type === utils_1.AST_NODE_TYPES.NewExpression) {
275
+ return parent.callee === node;
276
+ }
277
+ return !PARENTHESES_FREE_PARENTS.has(parent.type);
278
+ }
101
279
  /**
102
280
  * Checks a BinaryExpression for raw typeof narrowing patterns that attempt
103
281
  * to narrow the state to data:
@@ -120,42 +298,16 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
120
298
  const literal = right.value;
121
299
  if (operand.type === utils_1.AST_NODE_TYPES.Identifier &&
122
300
  isSnapshotVar(operand)) {
123
- // typeof state === 'object' — bad: narrows to data, use isSnapshotReady
124
- if (operator === '===' && literal === 'object') {
125
- context.report({
126
- node,
127
- messageId: 'noRawTypeof',
128
- data: { expression: getText(node) },
129
- suggest: [
130
- {
131
- messageId: 'noRawTypeof',
132
- data: {
133
- expression: `isSnapshotReady(${operand.name})`,
134
- },
135
- fix(fixer) {
136
- return fixer.replaceText(node, `isSnapshotReady(${operand.name})`);
137
- },
138
- },
139
- ],
140
- });
141
- }
142
- // typeof state !== 'string' — bad: equivalent to isSnapshotReady
143
- else if (operator === '!==' && literal === 'string') {
301
+ // Both flagged forms are true exactly when the state holds data, so
302
+ // the guard replaces them with matching polarity.
303
+ const isToDataCheck = (operator === '===' && literal === 'object') ||
304
+ (operator === '!==' && literal === 'string');
305
+ if (isToDataCheck) {
144
306
  context.report({
145
307
  node,
146
308
  messageId: 'noRawTypeof',
147
309
  data: { expression: getText(node) },
148
- suggest: [
149
- {
150
- messageId: 'noRawTypeof',
151
- data: {
152
- expression: `isSnapshotReady(${operand.name})`,
153
- },
154
- fix(fixer) {
155
- return fixer.replaceText(node, `isSnapshotReady(${operand.name})`);
156
- },
157
- },
158
- ],
310
+ suggest: guardSuggestion('noRawTypeof', node, `${GUARD_NAME}(${operand.name})`, context.getScope()),
159
311
  });
160
312
  }
161
313
  // typeof state === 'string' — allowed (narrows to non-data states)
@@ -165,22 +317,18 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
165
317
  }
166
318
  /**
167
319
  * Reports a falsy/truthy check on a snapshot-state identifier.
320
+ *
321
+ * `replacement` must match the polarity of the flagged expression: a falsy
322
+ * check reads `!isSnapshotReady(state)`, a truthy one `isSnapshotReady(state)`.
323
+ * `fixNode` defaults to the reported node but can be widened when the whole
324
+ * surrounding expression has to be rewritten (e.g. `state || fallback`).
168
325
  */
169
- function reportFalsyCheck(node, expression, varName) {
326
+ function reportFalsyCheck(node, expression, replacement, fixNode = node) {
170
327
  context.report({
171
328
  node,
172
329
  messageId: 'noFalsyCheck',
173
330
  data: { expression },
174
- suggest: [
175
- {
176
- messageId: 'noFalsyCheck',
177
- data: { expression: `isSnapshotReady(${varName})` },
178
- fix(fixer) {
179
- // Replace the entire flagged expression with the canonical guard
180
- return fixer.replaceText(node, `isSnapshotReady(${varName})`);
181
- },
182
- },
183
- ],
331
+ suggest: guardSuggestion('noFalsyCheck', fixNode, replacement, context.getScope()),
184
332
  });
185
333
  }
186
334
  return {
@@ -219,19 +367,21 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
219
367
  return;
220
368
  }
221
369
  const argument = node.argument;
222
- // !state — the argument is directly the snapshot var
370
+ // !state — the argument is directly the snapshot var.
371
+ // The check is true when the state is NOT usable, so the guard has to
372
+ // stay negated or the suggestion would reverse the control flow.
223
373
  if (argument.type === utils_1.AST_NODE_TYPES.Identifier &&
224
374
  isSnapshotVar(argument)) {
225
- const expression = `!${argument.name}`;
226
- reportFalsyCheck(node, expression, argument.name);
375
+ reportFalsyCheck(node, `!${argument.name}`, `!${GUARD_NAME}(${argument.name})`);
227
376
  }
228
- // !!state — the argument is another `!` whose argument is the snapshot var
377
+ // !!state — the argument is another `!` whose argument is the snapshot var.
378
+ // The double negation is a truthiness coercion, so the guard is positive.
229
379
  else if (argument.type === utils_1.AST_NODE_TYPES.UnaryExpression &&
230
380
  argument.operator === '!' &&
231
381
  argument.argument.type === utils_1.AST_NODE_TYPES.Identifier &&
232
382
  isSnapshotVar(argument.argument)) {
233
383
  const varName = argument.argument.name;
234
- reportFalsyCheck(node, `!!${varName}`, varName);
384
+ reportFalsyCheck(node, `!!${varName}`, `${GUARD_NAME}(${varName})`);
235
385
  }
236
386
  },
237
387
  // IfStatement: if (state) { ... } or if (!state) { ... }
@@ -239,14 +389,14 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
239
389
  IfStatement(node) {
240
390
  const test = node.test;
241
391
  if (test.type === utils_1.AST_NODE_TYPES.Identifier && isSnapshotVar(test)) {
242
- reportFalsyCheck(test, test.name, test.name);
392
+ reportFalsyCheck(test, test.name, `${GUARD_NAME}(${test.name})`);
243
393
  }
244
394
  },
245
395
  // ConditionalExpression: state ? a : b
246
396
  ConditionalExpression(node) {
247
397
  const test = node.test;
248
398
  if (test.type === utils_1.AST_NODE_TYPES.Identifier && isSnapshotVar(test)) {
249
- reportFalsyCheck(test, test.name, test.name);
399
+ reportFalsyCheck(test, test.name, `${GUARD_NAME}(${test.name})`);
250
400
  }
251
401
  },
252
402
  // LogicalExpression: state && expr, state || expr
@@ -255,7 +405,17 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
255
405
  if ((node.operator === '&&' || node.operator === '||') &&
256
406
  left.type === utils_1.AST_NODE_TYPES.Identifier &&
257
407
  isSnapshotVar(left)) {
258
- reportFalsyCheck(left, left.name, left.name);
408
+ if (node.operator === '&&') {
409
+ // `state && expr` guards expr, so swapping the operand for the
410
+ // guard keeps both the polarity and the narrowing of `state`.
411
+ reportFalsyCheck(left, left.name, `${GUARD_NAME}(${left.name})`);
412
+ return;
413
+ }
414
+ // `state || fallback` evaluates to the state itself when it is
415
+ // usable, so a bare operand swap would yield `true` instead of the
416
+ // data. Only the conditional form preserves that value.
417
+ const guarded = `${GUARD_NAME}(${left.name}) ? ${left.name} : ${getText(node.right)}`;
418
+ reportFalsyCheck(left, left.name, needsParentheses(node) ? `(${guarded})` : guarded, node);
259
419
  }
260
420
  },
261
421
  // BinaryExpression: typeof state === 'object', typeof state !== 'string'
@@ -272,7 +432,7 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
272
432
  node.arguments[0].type === utils_1.AST_NODE_TYPES.Identifier &&
273
433
  isSnapshotVar(node.arguments[0])) {
274
434
  const varName = node.arguments[0].name;
275
- reportFalsyCheck(node, `Boolean(${varName})`, varName);
435
+ reportFalsyCheck(node, `Boolean(${varName})`, `${GUARD_NAME}(${varName})`);
276
436
  }
277
437
  },
278
438
  };
@@ -101,6 +101,13 @@ exports.noClassInstanceDestructuring = (0, createRule_1.createRule)({
101
101
  // Skip if there's no init expression
102
102
  if (!node.init)
103
103
  return null;
104
+ // An annotation on the pattern types the object as a whole, not
105
+ // each property, so it cannot be split across the per-property
106
+ // declarations this fixer emits. Deriving per-property types needs
107
+ // the type checker, so report without fixing rather than rewrite
108
+ // the code to something more weakly typed than the author wrote.
109
+ if (objectPattern.typeAnnotation)
110
+ return null;
104
111
  // For single property, use simple replacement
105
112
  if (properties.length === 1) {
106
113
  const prop = properties[0];
@@ -54,6 +54,36 @@ exports.noExcessiveParentChain = (0, createRule_1.createRule)({
54
54
  }
55
55
  return current.type === utils_1.AST_NODE_TYPES.Identifier ? current.name : null;
56
56
  };
57
+ // Suggestions rewrite the chain as `<event>.params`, so the event binding has
58
+ // to be resolved back to a real identifier. Handler parameters destructured
59
+ // in place (`async ({ data: change }) => ...`) have no such identifier and
60
+ // resolve to null so the report can decline to suggest rather than guess.
61
+ const resolveEventParamName = (rootIdentifier) => {
62
+ if (eventIdentifiers.has(rootIdentifier) &&
63
+ rootIdentifier !== HANDLER_PARAM_SOURCE) {
64
+ return rootIdentifier;
65
+ }
66
+ const source = eventDataVariables.get(rootIdentifier);
67
+ if (!source || source === HANDLER_PARAM_SOURCE) {
68
+ return null;
69
+ }
70
+ return source;
71
+ };
72
+ // Every hop of a chain is reported separately, so an inner report's fix must
73
+ // still span the outermost hop. Replacing only the reported slice leaves a
74
+ // dangling hop behind (for example `event.params.parent.id`).
75
+ const getOutermostParentHop = (node) => {
76
+ let outermost = node;
77
+ let ancestor = outermost.parent;
78
+ while (ancestor?.type === utils_1.AST_NODE_TYPES.MemberExpression &&
79
+ ancestor.object === outermost &&
80
+ ancestor.property.type === utils_1.AST_NODE_TYPES.Identifier &&
81
+ ancestor.property.name === 'parent') {
82
+ outermost = ancestor;
83
+ ancestor = outermost.parent;
84
+ }
85
+ return outermost;
86
+ };
57
87
  const hasRefProperty = (node) => {
58
88
  let current = node;
59
89
  while (current) {
@@ -251,23 +281,26 @@ exports.noExcessiveParentChain = (0, createRule_1.createRule)({
251
281
  !eventIdentifiers.has(rootIdentifier))) {
252
282
  return;
253
283
  }
284
+ const eventParamName = resolveEventParamName(rootIdentifier);
254
285
  context.report({
255
286
  node,
256
287
  messageId: 'excessiveParentChain',
257
288
  data: {
258
289
  count: parentCount,
259
290
  },
260
- suggest: [
261
- {
262
- messageId: 'excessiveParentChain',
263
- data: {
264
- count: parentCount,
265
- },
266
- fix(fixer) {
267
- return fixer.replaceText(node, 'event.params');
291
+ suggest: eventParamName
292
+ ? [
293
+ {
294
+ messageId: 'excessiveParentChain',
295
+ data: {
296
+ count: parentCount,
297
+ },
298
+ fix(fixer) {
299
+ return fixer.replaceText(getOutermostParentHop(node), `${eventParamName}.params`);
300
+ },
268
301
  },
269
- },
270
- ],
302
+ ]
303
+ : [],
271
304
  });
272
305
  },
273
306
  };
@@ -43,8 +43,15 @@ exports.noUnnecessaryDestructuring = (0, createRule_1.createRule)({
43
43
  return null;
44
44
  }
45
45
  const initText = sourceCode.getText(node.init);
46
+ // The declarator's range covers `node.id.typeAnnotation`, so
47
+ // replacing the whole declarator would silently drop it. A lone
48
+ // rest element binds the entire object, meaning the annotation
49
+ // describes the new binding exactly and can be re-emitted as-is.
50
+ const annotationText = node.id.typeAnnotation
51
+ ? sourceCode.getText(node.id.typeAnnotation)
52
+ : '';
46
53
  // Replace the destructuring with direct assignment
47
- return fixer.replaceText(node, `${restName} = ${initText}`);
54
+ return fixer.replaceText(node, `${restName}${annotationText} = ${initText}`);
48
55
  },
49
56
  });
50
57
  }
@@ -1 +1,2 @@
1
- export declare const preferCloneDeep: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"preferCloneDeep", [], import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
1
+ import { TSESLint } from '@typescript-eslint/utils';
2
+ export declare const preferCloneDeep: TSESLint.RuleModule<"preferCloneDeep", [], TSESLint.RuleListener>;