@blumintinc/eslint-plugin-blumint 1.20.97 → 1.20.98

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.97',
226
+ version: '1.20.98',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -3,7 +3,154 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.enforceCentralizedMockFirestore = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
+ const importInsertion_1 = require("../utils/importInsertion");
6
7
  const MOCK_FIRESTORE_PATH = '../../../../../__test-utils__/mockFirestore';
8
+ function isHorizontalWhitespace(character) {
9
+ return character === ' ' || character === '\t';
10
+ }
11
+ /**
12
+ * The span a retired declaration occupies, widened to its whole line only when
13
+ * the declaration is the sole occupant of that line. Anything else sharing the
14
+ * line — a live statement, a trailing comment, an `eslint-disable-line`
15
+ * directive — is outside the declaration's range and must survive the
16
+ * retirement: unrelated statements would become unbound references, and a
17
+ * deleted comment is the one damage class a formatter can never restore.
18
+ */
19
+ function retirementEdit(text, start, end) {
20
+ const lineStart = text.lastIndexOf('\n', start - 1) + 1;
21
+ const newlineIndex = text.indexOf('\n', end);
22
+ const lineEnd = newlineIndex === -1 ? text.length : newlineIndex;
23
+ const before = text.slice(lineStart, start);
24
+ const after = text.slice(end, lineEnd);
25
+ if (/^\s*$/.test(before) && /^\s*$/.test(after)) {
26
+ return {
27
+ start: lineStart,
28
+ end: Math.min(lineEnd + 1, text.length),
29
+ text: '',
30
+ };
31
+ }
32
+ if (/^\s*$/.test(before)) {
33
+ // Code follows on this line, so the retired declaration hands over the
34
+ // indentation it was occupying rather than leaving the successor adrift.
35
+ let cursor = end;
36
+ while (isHorizontalWhitespace(text[cursor])) {
37
+ cursor++;
38
+ }
39
+ return { start, end: cursor, text: '' };
40
+ }
41
+ // Code precedes on this line; absorb the gap so no trailing space is left.
42
+ let cursor = start;
43
+ while (cursor > lineStart && isHorizontalWhitespace(text[cursor - 1])) {
44
+ cursor--;
45
+ }
46
+ return { start: cursor, end, text: '' };
47
+ }
48
+ /**
49
+ * Node types whose children are free-standing statements. A declaration
50
+ * anywhere else — a `for (const mockFirestore of …)` head, say — cannot be
51
+ * excised without leaving the enclosing construct malformed.
52
+ */
53
+ const STATEMENT_CONTAINERS = new Set([
54
+ utils_1.AST_NODE_TYPES.Program,
55
+ utils_1.AST_NODE_TYPES.BlockStatement,
56
+ utils_1.AST_NODE_TYPES.StaticBlock,
57
+ utils_1.AST_NODE_TYPES.SwitchCase,
58
+ utils_1.AST_NODE_TYPES.TSModuleBlock,
59
+ ]);
60
+ /**
61
+ * Widens a declaration to the `export` that fronts it, whose keyword lives
62
+ * outside the declaration's own range and would otherwise be stranded.
63
+ */
64
+ function retirableStatement(declaration) {
65
+ const statement = declaration.parent?.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration
66
+ ? declaration.parent
67
+ : declaration;
68
+ return statement.parent &&
69
+ STATEMENT_CONTAINERS.has(statement.parent.type)
70
+ ? statement
71
+ : undefined;
72
+ }
73
+ function retiredSpan(node) {
74
+ const parent = node.parent;
75
+ if (parent?.type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
76
+ const { declarations } = parent;
77
+ if (declarations.length === 1) {
78
+ const statement = retirableStatement(parent);
79
+ if (!statement) {
80
+ return undefined;
81
+ }
82
+ return {
83
+ start: statement.range[0],
84
+ end: statement.range[1],
85
+ whole: true,
86
+ };
87
+ }
88
+ const index = declarations.indexOf(node);
89
+ if (index > 0) {
90
+ return {
91
+ start: declarations[index - 1].range[1],
92
+ end: node.range[1],
93
+ whole: false,
94
+ };
95
+ }
96
+ if (index === 0) {
97
+ // Reach forward to the next declarator instead of backward past the
98
+ // `const`, whose trailing space still belongs to the survivors.
99
+ return {
100
+ start: node.range[0],
101
+ end: declarations[1].range[0],
102
+ whole: false,
103
+ };
104
+ }
105
+ return undefined;
106
+ }
107
+ if (node.type === utils_1.AST_NODE_TYPES.PropertyDefinition) {
108
+ return { start: node.range[0], end: node.range[1], whole: true };
109
+ }
110
+ return undefined;
111
+ }
112
+ /**
113
+ * Collapses edits that touch, so an overlap can never rewrite a range twice.
114
+ */
115
+ function mergeEdits(edits) {
116
+ const sorted = [...edits].sort((a, b) => a.start - b.start || a.end - b.end);
117
+ const merged = [];
118
+ for (const edit of sorted) {
119
+ const last = merged[merged.length - 1];
120
+ if (last && edit.start < last.end) {
121
+ last.end = Math.max(last.end, edit.end);
122
+ last.text += edit.text;
123
+ continue;
124
+ }
125
+ merged.push({ ...edit });
126
+ }
127
+ return merged;
128
+ }
129
+ function editOffset(anchor) {
130
+ return anchor.kind === 'before' ? anchor.target.range[0] : anchor.index;
131
+ }
132
+ /**
133
+ * Where the import may be spliced in given the edits retiring the local mock.
134
+ *
135
+ * Widening to the anchor's line start is what lets the emitted
136
+ * `${indent}import …\n` leave the displaced statement on the indentation it
137
+ * already had, but that is sound only while whitespace is all that precedes
138
+ * the anchor: a `'use client';` sharing the anchor's line would be demoted by
139
+ * an insertion at column 0. When the resulting position still falls inside a
140
+ * retirement — the anchor statement is itself the declaration being retired,
141
+ * and the retirement claims the indentation ahead of it — the insertion moves
142
+ * to that edit's start, since ESLint rejects a fix nested inside another.
143
+ */
144
+ function importPlacement(sourceCode, anchor, edits) {
145
+ const anchorStart = editOffset(anchor);
146
+ const lineStart = sourceCode.text.lastIndexOf('\n', anchorStart - 1) + 1;
147
+ const placement = /^[ \t]*$/.test(sourceCode.text.slice(lineStart, anchorStart))
148
+ ? (0, importInsertion_1.importAnchorLineStart)(sourceCode, anchor)
149
+ : anchor;
150
+ const offset = editOffset(placement);
151
+ const enclosing = edits.find((edit) => edit.start < offset && offset < edit.end);
152
+ return enclosing ? { kind: 'index', index: enclosing.start } : placement;
153
+ }
7
154
  exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
8
155
  name: 'enforce-centralized-mock-firestore',
9
156
  meta: {
@@ -149,85 +296,66 @@ exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
149
296
  requiredPath: MOCK_FIRESTORE_PATH,
150
297
  },
151
298
  fix(fixer) {
152
- // Instead of trying to modify the code incrementally, we'll generate the entire fixed code
153
299
  const originalText = sourceCode.getText();
154
- const lines = originalText.split('\n');
155
- // Find the indentation of the code
156
- const indentMatch = lines[0].match(/^(\s*)/);
157
- const indent = indentMatch ? indentMatch[1] : '';
158
- // Create the import statement
159
- const importLine = `${indent}import { mockFirestore } from '${MOCK_FIRESTORE_PATH}';`;
160
- // Find all the lines that need to be removed
161
- const linesToRemove = new Set();
162
- // Process all nodes that need to be removed
163
- mockFirestoreNodes.forEach((node) => {
164
- const startLine = sourceCode.getLocFromIndex(node.range[0]).line - 1;
165
- const endLine = sourceCode.getLocFromIndex(node.range[1]).line - 1;
166
- if (node.parent?.type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
167
- // If it's the only declarator, remove the entire declaration
168
- if (node.parent.declarations.length === 1) {
169
- const declStartLine = sourceCode.getLocFromIndex(node.parent.range[0]).line - 1;
170
- const declEndLine = sourceCode.getLocFromIndex(node.parent.range[1]).line - 1;
171
- for (let i = declStartLine; i <= declEndLine; i++) {
172
- linesToRemove.add(i);
173
- }
174
- }
175
- else {
176
- // Otherwise, just remove this declarator
177
- for (let i = startLine; i <= endLine; i++) {
178
- linesToRemove.add(i);
179
- }
180
- }
181
- }
182
- else if (node.type === utils_1.AST_NODE_TYPES.PropertyDefinition) {
183
- // Remove class property
184
- for (let i = startLine; i <= endLine; i++) {
185
- linesToRemove.add(i);
186
- }
300
+ // Retire each declaration by its own character range. Line
301
+ // indices would take every other occupant of those lines with
302
+ // them.
303
+ const removals = [];
304
+ for (const node of mockFirestoreNodes) {
305
+ const span = retiredSpan(node);
306
+ if (!span) {
307
+ // A declaration that cannot be excised cleanly gets no
308
+ // autofix at all: leaving it behind while adding the import
309
+ // would report forever, and cutting it anyway would emit
310
+ // code that no longer parses.
311
+ return null;
187
312
  }
188
- });
313
+ removals.push(span.whole
314
+ ? retirementEdit(originalText, span.start, span.end)
315
+ : { start: span.start, end: span.end, text: '' });
316
+ }
189
317
  // Replace custom mockFirestore references with the standard one
190
318
  const replacements = [];
191
319
  // Add replacements for custom mockFirestore names
192
320
  customMockFirestoreCallExpressions.forEach((node) => {
193
321
  if (node.callee.type === utils_1.AST_NODE_TYPES.Identifier) {
194
- replacements.push([
195
- node.callee.range[0],
196
- node.callee.name,
197
- 'mockFirestore',
198
- ]);
322
+ replacements.push({
323
+ start: node.callee.range[0],
324
+ end: node.callee.range[1],
325
+ text: 'mockFirestore',
326
+ });
199
327
  }
200
328
  });
201
329
  // Add replacements for this.mockFirestore
202
330
  thisExpressions.forEach((expr) => {
203
- replacements.push([
204
- expr.range[0],
205
- sourceCode.getText(expr),
206
- 'mockFirestore',
207
- ]);
331
+ replacements.push({
332
+ start: expr.range[0],
333
+ end: expr.range[1],
334
+ text: 'mockFirestore',
335
+ });
208
336
  });
209
- // Sort replacements in reverse order to avoid range issues
210
- replacements.sort((a, b) => b[0] - a[0]);
211
- // Apply replacements to the original text
212
- let fixedText = originalText;
213
- for (const [pos, oldText, newText] of replacements) {
214
- fixedText =
215
- fixedText.substring(0, pos) +
216
- newText +
217
- fixedText.substring(pos + oldText.length);
218
- }
219
- // Filter out the lines to remove
220
- const fixedLines = fixedText
221
- .split('\n')
222
- .filter((_, i) => !linesToRemove.has(i));
223
- // Add the import statement at the beginning
337
+ // A reference inside a retired declaration goes away with it, so
338
+ // rewriting it would only fight the removal for the same range.
339
+ const survivingReplacements = replacements.filter((replacement) => !removals.some((removal) => replacement.start >= removal.start &&
340
+ replacement.end <= removal.end));
341
+ const edits = mergeEdits([...removals, ...survivingReplacements]);
342
+ // Every edit is bounded to the characters it owns. Rebuilding
343
+ // the file and writing it over the `Program` node instead would
344
+ // emit whatever precedes `Program.range[0]` — a header comment,
345
+ // a `@ts-nocheck`, a license block — a second time, and would
346
+ // claim a fix range spanning the file, which wins every pass-1
347
+ // fixer race and suppresses sibling rules' fixes.
348
+ const fixes = edits.map(({ start, end, text }) => fixer.replaceTextRange([start, end], text));
224
349
  if (!hasCentralizedImport) {
225
- fixedLines.unshift(importLine);
350
+ // The shared anchor keeps the import below the file's
351
+ // prologue: spliced above a `'use client'` / `'use server'`
352
+ // directive it demotes the directive to a plain expression,
353
+ // and above a `#!` shebang it leaves the file unparseable.
354
+ const anchor = (0, importInsertion_1.importInsertionAnchor)(sourceCode);
355
+ const indent = (0, importInsertion_1.importAnchorIndent)(sourceCode, anchor);
356
+ fixes.push((0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, importPlacement(sourceCode, anchor, edits), `${indent}import { mockFirestore } from '${MOCK_FIRESTORE_PATH}';\n`));
226
357
  }
227
- // Join the lines back together
228
- const result = fixedLines.join('\n');
229
- // Return the fixed text
230
- return fixer.replaceText(sourceCode.ast, result);
358
+ return fixes;
231
359
  },
232
360
  });
233
361
  }
@@ -207,6 +207,98 @@ function isRewrittenCallee(identifier) {
207
207
  parent.callee === identifier &&
208
208
  hasRewritableArity(parent));
209
209
  }
210
+ /**
211
+ * The node types that open a scope of their own. The body walk stops at them
212
+ * because the emitted `diff` is only checked against the reported function's
213
+ * scope: a comparison inside a callback whose parameter is named `diff` would
214
+ * be rewritten into a call on that parameter, with no diagnostic to show for
215
+ * it.
216
+ */
217
+ const FUNCTION_NODE_TYPES = new Set([
218
+ utils_1.AST_NODE_TYPES.ArrowFunctionExpression,
219
+ utils_1.AST_NODE_TYPES.FunctionDeclaration,
220
+ utils_1.AST_NODE_TYPES.FunctionExpression,
221
+ ]);
222
+ /**
223
+ * Whether an expression is a call of `JSON.stringify`. The callee's shape
224
+ * decides rather than the source text, so a `stringify` read off anything but
225
+ * `JSON` — a local serializer bound under the same property name — keeps its
226
+ * call site.
227
+ */
228
+ function isJsonStringify(node) {
229
+ return (node.type === utils_1.AST_NODE_TYPES.CallExpression &&
230
+ node.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
231
+ node.callee.object.type === utils_1.AST_NODE_TYPES.Identifier &&
232
+ node.callee.object.name === 'JSON' &&
233
+ node.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
234
+ node.callee.property.name === 'stringify');
235
+ }
236
+ /**
237
+ * The stringify comparison an expression makes, or null for every other
238
+ * expression.
239
+ *
240
+ * A zero-argument `JSON.stringify()` yields null. The indexed argument read is
241
+ * typed non-optional, so nothing else forces the check, and there is no operand
242
+ * to hand `diff`.
243
+ */
244
+ function toStringifyComparison(node) {
245
+ if (node.type !== utils_1.AST_NODE_TYPES.BinaryExpression ||
246
+ (node.operator !== '===' && node.operator !== '!==')) {
247
+ return null;
248
+ }
249
+ if (!isJsonStringify(node.left) || !isJsonStringify(node.right)) {
250
+ return null;
251
+ }
252
+ const left = node.left.arguments[0];
253
+ const right = node.right.arguments[0];
254
+ if (!left || !right) {
255
+ return null;
256
+ }
257
+ return { node, left, right, isEqual: node.operator === '===' };
258
+ }
259
+ /**
260
+ * Every stringify comparison `body` makes in its own scope, nested functions
261
+ * excluded.
262
+ *
263
+ * A rewrite driven by the enclosing function's parameter list answers a
264
+ * question the source never asked: `JSON.stringify(a.settings) !==
265
+ * JSON.stringify(b.settings)` compares two properties, and the signature's
266
+ * operands widen that to the whole objects. Reading the comparison itself is
267
+ * also what makes a destructuring parameter a non-issue, since the operands
268
+ * name what to diff whatever the signature binds.
269
+ */
270
+ function collectStringifyComparisons(body) {
271
+ const comparisons = [];
272
+ const pending = [body];
273
+ while (pending.length > 0) {
274
+ const current = pending.pop();
275
+ if (current !== body && FUNCTION_NODE_TYPES.has(current.type)) {
276
+ continue;
277
+ }
278
+ const comparison = toStringifyComparison(current);
279
+ if (comparison) {
280
+ comparisons.push(comparison);
281
+ }
282
+ for (const key in current) {
283
+ // The parent link closes a cycle over every node in the file.
284
+ if (key === 'parent') {
285
+ continue;
286
+ }
287
+ const value = current[key];
288
+ if (Array.isArray(value)) {
289
+ value.forEach((child) => {
290
+ if (ASTHelpers_1.ASTHelpers.isNode(child)) {
291
+ pending.push(child);
292
+ }
293
+ });
294
+ }
295
+ else if (ASTHelpers_1.ASTHelpers.isNode(value)) {
296
+ pending.push(value);
297
+ }
298
+ }
299
+ }
300
+ return comparisons;
301
+ }
210
302
  /**
211
303
  * Whether a bare `diff` written at `scope` reaches microdiff's function.
212
304
  * Resolving through the scope chain catches both failure modes: a module-scope
@@ -354,6 +446,17 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
354
446
  : '\n\n';
355
447
  return (0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, anchor, `${MICRODIFF_IMPORT}${separator}`);
356
448
  }
449
+ /**
450
+ * The microdiff form of a stringify comparison: a change list whose
451
+ * emptiness carries the sense of the operator it replaces, so `===` becomes
452
+ * `.length === 0` and `!==` becomes `.length > 0`.
453
+ */
454
+ function buildDiffComparison(comparison) {
455
+ const left = sourceCode.getText(comparison.left);
456
+ const right = sourceCode.getText(comparison.right);
457
+ const emptiness = comparison.isEqual ? '.length === 0' : '.length > 0';
458
+ return `${DIFF_NAME}(${left}, ${right})${emptiness}`;
459
+ }
357
460
  // Add a specific set to track which import names are used
358
461
  const usedImportNames = new Set();
359
462
  // Check if a node is an object or array type
@@ -559,46 +662,29 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
559
662
  return;
560
663
  }
561
664
  // Check for JSON.stringify comparison pattern
562
- if ((node.operator === '===' || node.operator === '!==') &&
563
- node.left.type === utils_1.AST_NODE_TYPES.CallExpression &&
564
- node.right.type === utils_1.AST_NODE_TYPES.CallExpression) {
565
- const isJsonStringify = (expr) => {
566
- return (expr.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
567
- expr.callee.object.type === utils_1.AST_NODE_TYPES.Identifier &&
568
- expr.callee.object.name === 'JSON' &&
569
- expr.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
570
- expr.callee.property.name === 'stringify');
571
- };
572
- if (isJsonStringify(node.left) && isJsonStringify(node.right)) {
573
- const leftArg = node.left.arguments[0];
574
- const rightArg = node.right.arguments[0];
575
- // A zero-argument JSON.stringify() leaves these undefined. The
576
- // indexed read is typed non-optional, so nothing forces the check.
577
- if (!leftArg || !rightArg) {
578
- return;
579
- }
580
- if (isObjectOrArrayType(leftArg) && isObjectOrArrayType(rightArg)) {
581
- reportedNodes.add(node);
582
- const isEqual = node.operator === '===';
583
- context.report({
584
- node,
585
- messageId: 'enforceMicrodiff',
586
- fix(fixer) {
587
- if (!canEmitDiffAt(node)) {
588
- return null;
589
- }
590
- const compareFix = fixer.replaceText(node, `${DIFF_NAME}(${sourceCode.getText(leftArg)}, ${sourceCode.getText(rightArg)})${isEqual ? '.length === 0' : '.length > 0'}`);
591
- // The comparison this rewrites almost always sits inside a
592
- // function, and the `diff` it emits needs an import whatever
593
- // encloses it. Deciding on the enclosing node left every
594
- // nested comparison calling a `diff` nothing bound.
595
- const importFix = buildMicrodiffImportFix(fixer);
596
- return importFix ? [importFix, compareFix] : compareFix;
597
- },
598
- });
599
- }
600
- }
665
+ const comparison = toStringifyComparison(node);
666
+ if (!comparison ||
667
+ !isObjectOrArrayType(comparison.left) ||
668
+ !isObjectOrArrayType(comparison.right)) {
669
+ return;
601
670
  }
671
+ reportedNodes.add(node);
672
+ context.report({
673
+ node,
674
+ messageId: 'enforceMicrodiff',
675
+ fix(fixer) {
676
+ if (!canEmitDiffAt(node)) {
677
+ return null;
678
+ }
679
+ const compareFix = fixer.replaceText(node, buildDiffComparison(comparison));
680
+ // The comparison this rewrites almost always sits inside a
681
+ // function, and the `diff` it emits needs an import whatever
682
+ // encloses it. Deciding on the enclosing node left every nested
683
+ // comparison calling a `diff` nothing bound.
684
+ const importFix = buildMicrodiffImportFix(fixer);
685
+ return importFix ? [importFix, compareFix] : compareFix;
686
+ },
687
+ });
602
688
  },
603
689
  // Check for custom deep comparison functions
604
690
  FunctionDeclaration(node) {
@@ -629,29 +715,27 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
629
715
  bodyText.includes('JSON.stringify') &&
630
716
  bodyText.includes('!==')) {
631
717
  reportedNodes.add(node);
632
- // The operands are the parameter *names*, not the text of the
633
- // parameters: a typed parameter's text carries its annotation,
634
- // and `diff(oldConfig: Config, ...)` does not parse. A parameter
635
- // that binds no single name — a destructuring or rest pattern —
636
- // has no operand to pass, so the report stands without a fix.
637
- const [firstParam, secondParam] = node.params;
638
- const operands = firstParam.type === utils_1.AST_NODE_TYPES.Identifier &&
639
- secondParam.type === utils_1.AST_NODE_TYPES.Identifier
640
- ? [firstParam.name, secondParam.name]
641
- : null;
718
+ // Exactly one comparison is the condition for a fix. With none
719
+ // there is no expression to rewrite, and with several the rule
720
+ // cannot tell which one the function's answer turns on, so the
721
+ // report stands on its own.
722
+ const comparisons = collectStringifyComparisons(body);
723
+ const comparison = comparisons.length === 1 ? comparisons[0] : null;
642
724
  context.report({
643
725
  node,
644
726
  messageId: 'enforceMicrodiff',
645
727
  fix(fixer) {
646
- if (!operands || !canEmitDiffAt(node)) {
728
+ if (!comparison || !canEmitDiffAt(node)) {
647
729
  return null;
648
730
  }
649
- // Only the body is rewritten, so the signature keeps its type
650
- // annotations, its modifiers, and any `export` in front of
651
- // it. Replacing the declaration wholesale used to drop those
652
- // and, when it prefixed the import, put an `import` inside
653
- // whatever enclosed the function.
654
- const bodyFix = fixer.replaceText(body, `{\n return ${DIFF_NAME}(${operands[0]}, ${operands[1]}).length > 0;\n}`);
731
+ // Only the comparison's own range is rewritten. The signature
732
+ // keeps its type annotations, its modifiers and any `export`
733
+ // in front of it, and the body keeps everything the
734
+ // comparison shares it with: side effects, guard clauses,
735
+ // locals, and the comments around them. Re-emitting the body
736
+ // as a single return drops all of that silently — the fix
737
+ // compiles, so nothing downstream flags the loss.
738
+ const bodyFix = fixer.replaceText(comparison.node, buildDiffComparison(comparison));
655
739
  const importFix = buildMicrodiffImportFix(fixer);
656
740
  return importFix ? [importFix, bodyFix] : bodyFix;
657
741
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.97",
3
+ "version": "1.20.98",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,27 @@
1
1
  [
2
+ {
3
+ "version": "1.20.98",
4
+ "date": "2026-08-04T10:58:56.821Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-centralized-mock-firestore",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1694,
11
+ 1695
12
+ ],
13
+ "summary": "place the import by anchor instead of rewriting the Program (closes #1695); retire declarations by range, not by line index (closes #1694)"
14
+ },
15
+ {
16
+ "name": "enforce-microdiff",
17
+ "changeType": "fix",
18
+ "issues": [
19
+ 1693
20
+ ],
21
+ "summary": "rewrite the comparison in place instead of the whole body (closes #1693)"
22
+ }
23
+ ]
24
+ },
2
25
  {
3
26
  "version": "1.20.97",
4
27
  "date": "2026-08-04T08:50:14.394Z",