@blumintinc/eslint-plugin-blumint 1.20.96 → 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.96',
226
+ version: '1.20.98',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -113,12 +113,25 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
113
113
  return true;
114
114
  }
115
115
  const nextChar = normalizedName.charAt(p.length);
116
- // For SCREAMING_SNAKE_CASE or similar all-uppercase names,
117
- // we require an underscore boundary.
116
+ // For SCREAMING_SNAKE_CASE or similar all-uppercase names the prefix must
117
+ // end at a separator, since case can no longer mark the word boundary
118
+ // (ISVALID stays unprefixed). Digits fused onto the prefix belong to that
119
+ // first segment rather than to the next word — ARE2_VALID is the
120
+ // UPPER_SNAKE spelling of are2Valid, which the camelCase branch below
121
+ // accepts — so a trailing digit run is consumed before the separator is
122
+ // examined, and the same separators the camelCase branch honours (`_`,
123
+ // `$`, end of name) close the segment. Only a digit run directly after
124
+ // the prefix qualifies: ARENA2_MAP still fails because a letter follows
125
+ // the prefix, and H2AS_ITEMS never reaches here because the prefix does
126
+ // not match the segment's start.
118
127
  const isAllUppercase = normalizedName === normalizedName.toUpperCase() &&
119
128
  /[a-z]/i.test(normalizedName);
120
129
  if (isAllUppercase) {
121
- return nextChar === '_';
130
+ const afterPrefix = normalizedName
131
+ .slice(p.length)
132
+ .replace(/^\d+/, '');
133
+ const boundaryChar = afterPrefix.charAt(0);
134
+ return (boundaryChar === '' || boundaryChar === '_' || boundaryChar === '$');
122
135
  }
123
136
  // For camelCase, the next char must be uppercase, a digit, or $
124
137
  return (nextChar === '_' ||
@@ -710,6 +723,28 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
710
723
  expression.name === 'undefined') {
711
724
  return 'nonBoolean';
712
725
  }
726
+ // A returned binding is governed by this very rule: a boolean variable,
727
+ // parameter or function must carry an approved prefix. So an unprefixed
728
+ // `id` — or the result of calling an unprefixed `compute(x)` — is not a
729
+ // boolean under the regime the rule enforces, and the callee's own name
730
+ // must not override that. Deciding here keeps the exemption in the body
731
+ // rather than in a return annotation, which `no-explicit-return-type`
732
+ // deletes (issue #1691).
733
+ //
734
+ // Member accesses (`source.flag`, `source.read()`) are deliberately
735
+ // excluded: property signatures are only enforced under
736
+ // `enforceForPropertySignatures` and third-party method names are outside
737
+ // this rule's reach, so an unprefixed member may legitimately yield a
738
+ // boolean and the callee's name keeps its say.
739
+ if (expression.type === utils_1.AST_NODE_TYPES.Identifier) {
740
+ return identifierIsBoolean(expression) ? 'boolean' : 'nonBoolean';
741
+ }
742
+ if (expression.type === utils_1.AST_NODE_TYPES.CallExpression &&
743
+ expression.callee.type === utils_1.AST_NODE_TYPES.Identifier) {
744
+ return callExpressionLooksBoolean(expression) === 'boolean'
745
+ ? 'boolean'
746
+ : 'nonBoolean';
747
+ }
713
748
  if (expression.type === utils_1.AST_NODE_TYPES.UnaryExpression) {
714
749
  if (expression.operator === '!' || expression.operator === 'delete') {
715
750
  return 'boolean';
@@ -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
  },
@@ -973,6 +973,78 @@ function isDefinitelyNonBooleanExpression(node) {
973
973
  return false;
974
974
  }
975
975
  }
976
+ // Operators whose result is always a boolean, regardless of operand types.
977
+ const BOOLEAN_BINARY_OPERATORS = new Set([
978
+ '==',
979
+ '!=',
980
+ '===',
981
+ '!==',
982
+ '<',
983
+ '<=',
984
+ '>',
985
+ '>=',
986
+ 'in',
987
+ 'instanceof',
988
+ ]);
989
+ /**
990
+ * Detects an expression that is definitively a boolean — a boolean literal, a
991
+ * negation, a comparison, or a branch/`Boolean()` call built from those. Opaque
992
+ * expressions (calls, identifiers, member accesses) yield no verdict here: they
993
+ * are the shapes a validator's body takes once its return annotation is gone,
994
+ * and assuming boolean for them is what produced the false positive in #1692.
995
+ */
996
+ function isDefinitelyBooleanExpression(node) {
997
+ switch (node.type) {
998
+ case utils_1.AST_NODE_TYPES.Literal:
999
+ return typeof node.value === 'boolean';
1000
+ case utils_1.AST_NODE_TYPES.UnaryExpression:
1001
+ return node.operator === '!' || node.operator === 'delete';
1002
+ case utils_1.AST_NODE_TYPES.BinaryExpression:
1003
+ return BOOLEAN_BINARY_OPERATORS.has(node.operator);
1004
+ case utils_1.AST_NODE_TYPES.LogicalExpression:
1005
+ return (isDefinitelyBooleanExpression(node.left) &&
1006
+ isDefinitelyBooleanExpression(node.right));
1007
+ case utils_1.AST_NODE_TYPES.ConditionalExpression:
1008
+ return (isDefinitelyBooleanExpression(node.consequent) &&
1009
+ isDefinitelyBooleanExpression(node.alternate));
1010
+ case utils_1.AST_NODE_TYPES.CallExpression:
1011
+ // `Boolean(x)` is the one call whose result is boolean by construction.
1012
+ return (node.callee.type === utils_1.AST_NODE_TYPES.Identifier &&
1013
+ node.callee.name === 'Boolean');
1014
+ case utils_1.AST_NODE_TYPES.TSAsExpression:
1015
+ case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
1016
+ // `x as boolean` asserts booleanness; `x as const` and other assertions
1017
+ // say nothing, so the asserted expression decides.
1018
+ return (isBooleanOnlyType(node.typeAnnotation) ||
1019
+ isDefinitelyBooleanExpression(node.expression));
1020
+ case utils_1.AST_NODE_TYPES.TSNonNullExpression:
1021
+ return isDefinitelyBooleanExpression(node.expression);
1022
+ default:
1023
+ return false;
1024
+ }
1025
+ }
1026
+ function classifyExpression(node) {
1027
+ // A definitively non-boolean shape wins over a boolean one: a validator's
1028
+ // `return 'Must not be blank'` proves the function is not a predicate even
1029
+ // though its success path returns `true`.
1030
+ if (isDefinitelyNonBooleanExpression(node))
1031
+ return 'nonBoolean';
1032
+ if (isDefinitelyBooleanExpression(node))
1033
+ return 'boolean';
1034
+ return 'indeterminate';
1035
+ }
1036
+ /**
1037
+ * Combines the verdicts of a body's returns under the same precedence:
1038
+ * non-boolean beats boolean, and boolean beats no verdict at all. An empty list
1039
+ * (a body with no returns) is `indeterminate`.
1040
+ */
1041
+ function combineReturnKinds(kinds) {
1042
+ if (kinds.includes('nonBoolean'))
1043
+ return 'nonBoolean';
1044
+ if (kinds.includes('boolean'))
1045
+ return 'boolean';
1046
+ return 'indeterminate';
1047
+ }
976
1048
  /**
977
1049
  * Yields the immediate AST-node children of `node`, skipping the `parent`
978
1050
  * back-reference so traversal only walks downward.
@@ -995,10 +1067,12 @@ function childNodesOf(node) {
995
1067
  return children;
996
1068
  }
997
1069
  /**
998
- * Whether any `return` statement belonging to `fn`'s own body (not a nested
999
- * function's) yields a definitively non-boolean value.
1070
+ * Classifies the `return` statements belonging to `fn`'s own body (not a nested
1071
+ * function's). A bare `return;` carries no verdict, so it neither exempts the
1072
+ * function nor keeps a sibling boolean return from deciding.
1000
1073
  */
1001
- function blockReturnsNonBoolean(block) {
1074
+ function classifyBlockReturns(block) {
1075
+ const kinds = [];
1002
1076
  const stack = [block];
1003
1077
  while (stack.length > 0) {
1004
1078
  const current = stack.pop();
@@ -1009,41 +1083,54 @@ function blockReturnsNonBoolean(block) {
1009
1083
  current.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression)) {
1010
1084
  continue;
1011
1085
  }
1012
- if (current.type === utils_1.AST_NODE_TYPES.ReturnStatement &&
1013
- current.argument &&
1014
- isDefinitelyNonBooleanExpression(current.argument)) {
1015
- return true;
1086
+ if (current.type === utils_1.AST_NODE_TYPES.ReturnStatement && current.argument) {
1087
+ kinds.push(classifyExpression(current.argument));
1016
1088
  }
1017
1089
  for (const child of childNodesOf(current)) {
1018
1090
  stack.push(child);
1019
1091
  }
1020
1092
  }
1021
- return false;
1093
+ return combineReturnKinds(kinds);
1022
1094
  }
1023
1095
  /**
1024
- * Whether a function backing an `is`/`has`-prefixed name actually returns a
1025
- * non-boolean value — e.g. a validator predicate returning `string | true`. An
1096
+ * The booleanness of a function backing an `is`/`has`-prefixed name. An
1026
1097
  * explicit return-type annotation is authoritative; otherwise the body's own
1027
- * `return` statements (or the concise-arrow expression) are inspected.
1098
+ * `return` statements (or the concise-arrow expression) decide.
1028
1099
  */
1029
- function functionReturnsNonBoolean(fn) {
1100
+ function classifyFunctionReturn(fn) {
1030
1101
  if (fn.returnType) {
1031
- return !isBooleanOnlyType(fn.returnType.typeAnnotation);
1102
+ return isBooleanOnlyType(fn.returnType.typeAnnotation)
1103
+ ? 'boolean'
1104
+ : 'nonBoolean';
1032
1105
  }
1033
1106
  if (fn.body.type !== utils_1.AST_NODE_TYPES.BlockStatement) {
1034
- return isDefinitelyNonBooleanExpression(fn.body);
1107
+ return classifyExpression(fn.body);
1035
1108
  }
1036
- return blockReturnsNonBoolean(fn.body);
1109
+ return classifyBlockReturns(fn.body);
1110
+ }
1111
+ /**
1112
+ * Whether a function backing an `is`/`has`-prefixed name must be exempt from
1113
+ * boolean negative-naming. Only a function proven to return a boolean is
1114
+ * flagged: a validator predicate returning `string | true` is exempt, and so is
1115
+ * one whose returns are syntactically opaque (`=> validate(value)`). That
1116
+ * matters because `no-explicit-return-type` deletes the very annotation that
1117
+ * spells the validator's non-boolean return, leaving nothing but the name to go
1118
+ * on — and guessing "boolean" from the name alone reports a rename that inverts
1119
+ * the predicate's meaning (#1692). Preferring a false negative here is the
1120
+ * repository's stated trade-off.
1121
+ */
1122
+ function isExemptFromBooleanNaming(fn) {
1123
+ return classifyFunctionReturn(fn) !== 'boolean';
1037
1124
  }
1038
1125
  /**
1039
- * When a declarator/property value is a function, whether that function is a
1040
- * non-boolean predicate that must be exempt from boolean negative-naming.
1126
+ * When a declarator/property value is a function, whether that function is
1127
+ * exempt from boolean negative-naming.
1041
1128
  */
1042
- function isNonBooleanFunctionValue(node) {
1129
+ function isExemptFunctionValue(node) {
1043
1130
  return (!!node &&
1044
1131
  (node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
1045
1132
  node.type === utils_1.AST_NODE_TYPES.FunctionExpression) &&
1046
- functionReturnsNonBoolean(node));
1133
+ isExemptFromBooleanNaming(node));
1047
1134
  }
1048
1135
  exports.enforcePositiveNaming = (0, createRule_1.createRule)({
1049
1136
  name: 'enforce-positive-naming',
@@ -1280,7 +1367,7 @@ exports.enforcePositiveNaming = (0, createRule_1.createRule)({
1280
1367
  // with `is`/`has` but is not a boolean, so its domain-correct negation
1281
1368
  // ("isNotBlank") must not be flagged. The name heuristic alone cannot
1282
1369
  // tell them apart; the initializer's return shape can.
1283
- if (isNonBooleanFunctionValue(node.init))
1370
+ if (isExemptFunctionValue(node.init))
1284
1371
  return;
1285
1372
  const variableName = node.id.name;
1286
1373
  const { isNegative, alternatives } = hasBooleanNegativeNaming(variableName);
@@ -1323,8 +1410,9 @@ exports.enforcePositiveNaming = (0, createRule_1.createRule)({
1323
1410
  if (!isBooleanLike(node.id || node))
1324
1411
  return;
1325
1412
  // Skip validator predicates that return a non-boolean value (e.g.
1326
- // `string | true`), whose negation is the domain-correct term.
1327
- if (functionReturnsNonBoolean(node))
1413
+ // `string | true`), whose negation is the domain-correct term, and any
1414
+ // function whose returns give no syntactic verdict.
1415
+ if (isExemptFromBooleanNaming(node))
1328
1416
  return;
1329
1417
  const { isNegative, alternatives } = hasBooleanNegativeNaming(functionName);
1330
1418
  if (isNegative) {
@@ -1348,7 +1436,7 @@ exports.enforcePositiveNaming = (0, createRule_1.createRule)({
1348
1436
  if (!isBooleanLike(node.key))
1349
1437
  return;
1350
1438
  // Skip validator predicates returning a non-boolean value.
1351
- if (isNonBooleanFunctionValue(node.value))
1439
+ if (isExemptFunctionValue(node.value))
1352
1440
  return;
1353
1441
  const methodName = node.key.name;
1354
1442
  const { isNegative, alternatives } = hasBooleanNegativeNaming(methodName);
@@ -1373,7 +1461,7 @@ exports.enforcePositiveNaming = (0, createRule_1.createRule)({
1373
1461
  if (!isBooleanLike(node.key))
1374
1462
  return;
1375
1463
  // Skip validator predicates returning a non-boolean value.
1376
- if (isNonBooleanFunctionValue(node.value))
1464
+ if (isExemptFunctionValue(node.value))
1377
1465
  return;
1378
1466
  const propertyName = node.key.name;
1379
1467
  const { isNegative, alternatives } = hasBooleanNegativeNaming(propertyName);
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Collects every `RuleTester` case the suite declares WITHOUT executing any of
3
+ * them, by shadowing `run` on the shared tester instances and then loading each
4
+ * suite for its declarations alone.
5
+ *
6
+ * A guard that wants to exercise fixtures rather than documented snippets has no
7
+ * other way in. `src/tests/*.test.ts` call `RuleTester.run` at module scope, so
8
+ * importing one normally re-executes it — measured at 2350 tests, 2 minutes and
9
+ * 48 cross-file side-effect failures, which is why
10
+ * `recommended-config-fix-closure.test.ts` reads docs fenced blocks instead.
11
+ * Shadowing `run` before the load turns each of those calls into a declaration
12
+ * capture, so the cases are collected at the cost of loading the module and
13
+ * nothing more.
14
+ *
15
+ * The fixtures are worth reaching precisely because they are not the docs: a
16
+ * rule's `valid` list is written to sit on its carve-out boundaries, which is
17
+ * where a sibling fixer destroys an exemption. Every finding of that class
18
+ * (#1595-#1599, #1603, #1677-#1682) came from this corpus; the docs corpus
19
+ * caught none of them.
20
+ */
21
+ /** A single `ruleTester.run(name, rule, tests)` call, captured but not run. */
22
+ export type HarvestedSuite = {
23
+ /** The display name the suite passed to `run`. */
24
+ name: string;
25
+ /** Which shared tester export declared it, which fixes the parser. */
26
+ tester: string;
27
+ /** Basename of the declaring file, so a finding is reproducible by hand. */
28
+ file: string;
29
+ /**
30
+ * The rule object itself. Callers resolve a rule NAME from this by identity
31
+ * against the plugin's own map rather than from `name`: 100 of the suites
32
+ * pass a display name that is not a rule name (`requireMemo`,
33
+ * `prefer-next-dynamic (JSX scenarios)`, `no-hungarian-phone-number-test`),
34
+ * and name-keyed matching silently drops every one of them.
35
+ */
36
+ rule: unknown;
37
+ valid: readonly unknown[];
38
+ invalid: readonly unknown[];
39
+ };
40
+ export type HarvestResult = {
41
+ suites: HarvestedSuite[];
42
+ /** Files that threw while loading, `basename: message`. */
43
+ failures: string[];
44
+ /** Non-vacuity accounting: a silent drop here would fake a clean sweep. */
45
+ filesLoaded: number;
46
+ filesSkipped: number;
47
+ };
48
+ export declare function harvestRuleTesterCases(): HarvestResult;
@@ -0,0 +1,168 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.harvestRuleTesterCases = void 0;
27
+ const fs = __importStar(require("fs"));
28
+ const os = __importStar(require("os"));
29
+ const path = __importStar(require("path"));
30
+ const sharedTesters = __importStar(require("./ruleTester"));
31
+ const TESTS_DIR = path.join(__dirname, '..', 'tests');
32
+ /**
33
+ * Only suites that import the shared tester module can declare a case, since
34
+ * `src/tests/no-local-rule-tester.test.ts` forbids a locally-built tester. That
35
+ * makes the import a sound admission test rather than a heuristic, and it is
36
+ * what keeps this affordable: the ~28 files it excludes are the meta-suites
37
+ * (`fixer-type-safety`, `docs-examples-conformance`, `rule-crash-robustness`,
38
+ * this guard's own siblings) which run full corpus sweeps at module scope and
39
+ * cost more to load than every rule suite combined.
40
+ *
41
+ * Matching the import rather than a `ruleTesterTs.run(` call site is
42
+ * deliberate: `prefer-next-dynamic.test.ts` aliases the tester
43
+ * (`const jsx = ruleTesterJsx`) before calling `run`, so a call-site pattern
44
+ * drops it.
45
+ */
46
+ const IMPORTS_SHARED_TESTER = /from\s+'\.\.\/utils\/ruleTester'/;
47
+ /**
48
+ * Jest registers a test for every `describe`/`it` a loaded module calls, so
49
+ * loading 271 suites inside a suite would graft their entire test list onto
50
+ * this one. Neutralizing the registrars for the duration of the load keeps the
51
+ * captured declarations and discards the registrations.
52
+ *
53
+ * `describe` bodies still execute — several suites call `run` inside one, and
54
+ * skipping the body would drop those cases — but everything that would register
55
+ * or assert becomes a no-op.
56
+ */
57
+ const REGISTRAR_GLOBALS = [
58
+ 'it',
59
+ 'test',
60
+ 'beforeEach',
61
+ 'afterEach',
62
+ 'beforeAll',
63
+ 'afterAll',
64
+ ];
65
+ /* eslint-disable @typescript-eslint/no-explicit-any */
66
+ const asAny = (value) => value;
67
+ const noop = () => undefined;
68
+ /** `it.each(rows)(name, fn)` is a call chain, so the stub needs the same shape. */
69
+ const withEach = (fn) => {
70
+ fn.each = () => () => undefined;
71
+ fn.only = fn;
72
+ fn.skip = fn;
73
+ fn.todo = noop;
74
+ fn.failing = fn;
75
+ fn.concurrent = fn;
76
+ return fn;
77
+ };
78
+ function harvestRuleTesterCases() {
79
+ const suites = [];
80
+ const failures = [];
81
+ let currentFile = '';
82
+ let filesLoaded = 0;
83
+ let filesSkipped = 0;
84
+ const testerEntries = Object.entries(sharedTesters).filter(([, value]) => typeof asAny(value)?.run === 'function');
85
+ const originalRun = new Map();
86
+ for (const [key, tester] of testerEntries) {
87
+ originalRun.set(key, tester.run);
88
+ tester.run = (name, rule, tests) => {
89
+ const bag = asAny(tests) || {};
90
+ suites.push({
91
+ name,
92
+ tester: key,
93
+ file: currentFile,
94
+ rule,
95
+ valid: bag.valid || [],
96
+ invalid: bag.invalid || [],
97
+ });
98
+ return undefined;
99
+ };
100
+ }
101
+ const globalScope = global;
102
+ const savedGlobals = new Map();
103
+ const stub = (key, value) => {
104
+ savedGlobals.set(key, globalScope[key]);
105
+ globalScope[key] = value;
106
+ };
107
+ stub('describe', withEach((_name, body) => {
108
+ if (typeof body === 'function')
109
+ body();
110
+ }));
111
+ for (const key of REGISTRAR_GLOBALS)
112
+ stub(key, withEach(noop));
113
+ /**
114
+ * A handful of suites write fixture files at module scope, and
115
+ * `test-file-location-enforcement` writes them under
116
+ * `path.join(process.cwd(), '.cursor/tmp/...')` — a path it also `rmSync`s in
117
+ * an `afterAll` that the stubs above turn into a no-op. Loading it from the
118
+ * real working directory would therefore race that suite when it runs
119
+ * concurrently in another worker (its cleanup landing between this harvest's
120
+ * `mkdirSync` and `writeFileSync` throws ENOENT) and would strand fixtures in
121
+ * the repo when it does not. Pointing the working directory at a private
122
+ * scratch root for the duration of the load sends every cwd-derived write
123
+ * somewhere no other worker can see, and it is removed below.
124
+ *
125
+ * The paths the suites *record* are unaffected: they are made relative to the
126
+ * same cwd they were built from, so the harvested filenames come out
127
+ * identical either way.
128
+ */
129
+ const scratchRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'blumint-harvest-'));
130
+ const realCwd = process.cwd();
131
+ process.chdir(scratchRoot);
132
+ try {
133
+ const files = fs
134
+ .readdirSync(TESTS_DIR)
135
+ .filter((file) => file.endsWith('.test.ts'))
136
+ .sort();
137
+ for (const file of files) {
138
+ const fullPath = path.join(TESTS_DIR, file);
139
+ if (!IMPORTS_SHARED_TESTER.test(fs.readFileSync(fullPath, 'utf8'))) {
140
+ filesSkipped++;
141
+ continue;
142
+ }
143
+ currentFile = file;
144
+ try {
145
+ require(fullPath);
146
+ filesLoaded++;
147
+ }
148
+ catch (error) {
149
+ failures.push(`${file}: ${error?.message}`);
150
+ }
151
+ }
152
+ }
153
+ finally {
154
+ process.chdir(realCwd);
155
+ fs.rmSync(scratchRoot, { recursive: true, force: true });
156
+ for (const [key, tester] of testerEntries) {
157
+ const original = originalRun.get(key);
158
+ if (original)
159
+ tester.run = original;
160
+ }
161
+ for (const [key, value] of savedGlobals)
162
+ globalScope[key] = value;
163
+ }
164
+ return { suites, failures, filesLoaded, filesSkipped };
165
+ }
166
+ exports.harvestRuleTesterCases = harvestRuleTesterCases;
167
+ /* eslint-enable @typescript-eslint/no-explicit-any */
168
+ //# sourceMappingURL=harvestRuleTesterCases.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.96",
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,50 @@
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
+ },
25
+ {
26
+ "version": "1.20.97",
27
+ "date": "2026-08-04T08:50:14.394Z",
28
+ "rules": [
29
+ {
30
+ "name": "enforce-boolean-naming-prefixes",
31
+ "changeType": "fix",
32
+ "issues": [
33
+ 1690,
34
+ 1691
35
+ ],
36
+ "summary": "infer a callee's return from its body when the annotation is absent (closes #1691); accept a digit or $ fused onto an UPPER_SNAKE prefix (closes #1690)"
37
+ },
38
+ {
39
+ "name": "enforce-positive-naming",
40
+ "changeType": "fix",
41
+ "issues": [
42
+ 1692
43
+ ],
44
+ "summary": "decline when a function's returns yield no verdict (closes #1692)"
45
+ }
46
+ ]
47
+ },
2
48
  {
3
49
  "version": "1.20.96",
4
50
  "date": "2026-08-04T07:23:58.552Z",