@blumintinc/eslint-plugin-blumint 1.21.4 → 1.21.5

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
@@ -224,7 +224,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
224
224
  module.exports = {
225
225
  meta: {
226
226
  name: '@blumintinc/eslint-plugin-blumint',
227
- version: '1.21.4',
227
+ version: '1.21.5',
228
228
  },
229
229
  parseOptions: {
230
230
  ecmaVersion: 2020,
@@ -1,4 +1,4 @@
1
1
  import { TSESLint } from '@typescript-eslint/utils';
2
- type MessageIds = 'enforceMicrodiff' | 'enforceMicrodiffImport';
2
+ type MessageIds = 'enforceMicrodiff' | 'enforceMicrodiffManual' | 'enforceMicrodiffImport';
3
3
  export declare const enforceMicrodiff: TSESLint.RuleModule<MessageIds, [], TSESLint.RuleListener>;
4
4
  export {};
@@ -112,6 +112,30 @@ const COMPETING_DIFF_MODULES = new Set([
112
112
  // 'fast-deep-equal' and 'fast-deep-equal/es6' stay out of this set: they are
113
113
  // allowed alternatives to microdiff, so their imports survive the fix.
114
114
  ]);
115
+ /**
116
+ * The competing libraries whose calls are reported but never rewritten, because
117
+ * microdiff answers a different question than either of them does.
118
+ *
119
+ * jsdiff (`diff`) is a Myers sequence diff. `diffArrays(a, b)` returns runs of
120
+ * `{value, added, removed, count}`, so equal inputs yield one *kept* run where
121
+ * microdiff returns the empty list — `changes.length === 0` flips meaning under
122
+ * the rename — and a consumer reading `.added`, `.removed`, `.value` or
123
+ * `.count` gets `.type`, `.path` and `.oldValue` instead. Neither loss is a
124
+ * compile error, so nothing downstream flags it. Its optional third argument is
125
+ * a comparator bag with no counterpart in `Partial<MicrodiffOptions>`, which is
126
+ * a compile error (TS2345).
127
+ *
128
+ * `fast-diff` diffs STRINGS. Its operands satisfy neither half of microdiff's
129
+ * `TData extends Record<string, unknown> | unknown[]` bound, so that arm cannot
130
+ * emit a rewrite that compiles at all (TS2345).
131
+ *
132
+ * Detection is unchanged — both keep their entries in `DIFF_FUNCTION_NAMES` and
133
+ * `COMPETING_DIFF_MODULES` — because reaching for either where a structural
134
+ * per-path diff is wanted is still the finding. Only the rewrite is withheld,
135
+ * and the import retirement with it: dropping the declaration while the calls
136
+ * it binds stay behind is what would leave the file with an unbound name.
137
+ */
138
+ const UNCONVERTIBLE_DIFF_MODULES = new Set(['diff', 'fast-diff']);
115
139
  /**
116
140
  * A specifier that makes a bare `diff` resolve to microdiff's diff function:
117
141
  * its default export, or its named `diff` export, bound under the name the fix
@@ -162,7 +186,12 @@ function collectClaimableSpecifiers(program) {
162
186
  .forEach((specifier) => claimable.add(specifier));
163
187
  return;
164
188
  }
165
- if (COMPETING_DIFF_MODULES.has(source)) {
189
+ // A declaration no fix retires keeps every name it binds, so a `diff` it
190
+ // declares is not the fix's to write over: microdiff's import emitted
191
+ // beside a surviving `import { diff } from 'diff'` duplicates the binding
192
+ // (TS2300).
193
+ if (COMPETING_DIFF_MODULES.has(source) &&
194
+ !UNCONVERTIBLE_DIFF_MODULES.has(source)) {
166
195
  statement.specifiers.forEach((specifier) => claimable.add(specifier));
167
196
  }
168
197
  });
@@ -480,6 +509,7 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
480
509
  schema: [],
481
510
  messages: {
482
511
  enforceMicrodiff: 'Use the microdiff library for object and array comparison operations',
512
+ enforceMicrodiffManual: 'Use the microdiff library for object and array comparison operations. Convert this {{importSource}} call by hand: it returns a different result shape than the structural change list microdiff produces.',
483
513
  enforceMicrodiffImport: 'Import diff from microdiff instead of {{importSource}}',
484
514
  },
485
515
  },
@@ -777,6 +807,12 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
777
807
  importSource,
778
808
  },
779
809
  fix(fixer) {
810
+ // Every call this declaration serves is left for manual
811
+ // conversion, so retiring it would strand each of them on a name
812
+ // nothing binds.
813
+ if (UNCONVERTIBLE_DIFF_MODULES.has(importSource)) {
814
+ return null;
815
+ }
780
816
  // Decline rather than duplicate or shadow a `diff` this file
781
817
  // already binds to something else, and rather than retire an
782
818
  // import whose references no call fix rewrites. The report stands
@@ -853,6 +889,19 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
853
889
  if (ALLOWED_DIFF_MODULES.has(importSource)) {
854
890
  return;
855
891
  }
892
+ // A library microdiff does not stand in for is reported without
893
+ // the rename. The substitution would compile at some of these
894
+ // call sites and answer a different question there, which is a
895
+ // loss nothing downstream can flag.
896
+ if (UNCONVERTIBLE_DIFF_MODULES.has(importSource)) {
897
+ reportedNodes.add(node);
898
+ context.report({
899
+ node,
900
+ messageId: 'enforceMicrodiffManual',
901
+ data: { importSource },
902
+ });
903
+ return;
904
+ }
856
905
  // Report it if it's from any other tracked library
857
906
  reportedNodes.add(node);
858
907
  context.report({
@@ -874,6 +923,21 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
874
923
  if (!competingImport) {
875
924
  return;
876
925
  }
926
+ // The same withholding as above, reached by the other resolution
927
+ // path: a default import and an unrecognised export name are both
928
+ // bound here rather than tracked as specifiers, so gating only the
929
+ // tracked branch would leave `import fastDiff from 'fast-diff'`
930
+ // rewritten.
931
+ const competingSource = String(competingImport.source.value);
932
+ if (UNCONVERTIBLE_DIFF_MODULES.has(competingSource)) {
933
+ reportedNodes.add(node);
934
+ context.report({
935
+ node,
936
+ messageId: 'enforceMicrodiffManual',
937
+ data: { importSource: competingSource },
938
+ });
939
+ return;
940
+ }
877
941
  // Track this import name as used
878
942
  usedImportNames.add(name);
879
943
  // What the callee resolves to already settles this: the argument
@@ -223,6 +223,206 @@ function canSafelyFix(group) {
223
223
  });
224
224
  });
225
225
  }
226
+ /**
227
+ * `Array.prototype.push` is variadic, so merging consecutive appends onto one
228
+ * array preserves meaning. A method named `push` on any other receiver need not
229
+ * be: Next.js's `Router.push(url, as, options)` and react-router's
230
+ * `history.push(path, state)` are POSITIONAL, so folding two navigations into
231
+ * one call performs a single navigation with the address bar masked by the
232
+ * second argument. The merge is therefore gated on syntactic evidence that the
233
+ * receiver is an array, and stays silent without it — a missed consolidation
234
+ * costs far less than a fix that changes what the code does.
235
+ */
236
+ const ARRAY_TYPE_NAMES = new Set(['Array', 'ReadonlyArray']);
237
+ const ARRAY_FACTORY_METHODS = new Set(['from', 'of']);
238
+ const ARRAY_RETURNING_METHODS = new Set([
239
+ 'map',
240
+ 'filter',
241
+ 'slice',
242
+ 'concat',
243
+ 'split',
244
+ ]);
245
+ /** Hooks whose array type argument describes the binding they hand back. */
246
+ const ARRAY_STATE_HOOKS = new Set(['useState', 'useRef']);
247
+ function isFunctionNode(node) {
248
+ return (node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
249
+ node.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
250
+ node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression);
251
+ }
252
+ function isArrayTypeNode(node) {
253
+ if (!node)
254
+ return false;
255
+ switch (node.type) {
256
+ case utils_1.AST_NODE_TYPES.TSArrayType:
257
+ case utils_1.AST_NODE_TYPES.TSTupleType:
258
+ return true;
259
+ case utils_1.AST_NODE_TYPES.TSTypeReference:
260
+ return (node.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
261
+ ARRAY_TYPE_NAMES.has(node.typeName.name));
262
+ case utils_1.AST_NODE_TYPES.TSTypeOperator:
263
+ return (node.operator === 'readonly' && isArrayTypeNode(node.typeAnnotation));
264
+ /** A union counts only when every member is an array, so `T[] | undefined`
265
+ * — whose `push` can be absent — does not. */
266
+ case utils_1.AST_NODE_TYPES.TSUnionType:
267
+ return node.types.every((member) => isArrayTypeNode(member));
268
+ default:
269
+ return false;
270
+ }
271
+ }
272
+ function isArrayAnnotation(annotation) {
273
+ return isArrayTypeNode(annotation?.typeAnnotation);
274
+ }
275
+ function hasArrayTypeArgument(call) {
276
+ return isArrayTypeNode(call.typeParameters?.params[0]);
277
+ }
278
+ function getCalleeName(call) {
279
+ const callee = unwrapExpression(call.callee);
280
+ if (callee.type === utils_1.AST_NODE_TYPES.Identifier)
281
+ return callee.name;
282
+ if (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
283
+ !callee.computed &&
284
+ callee.property.type === utils_1.AST_NODE_TYPES.Identifier) {
285
+ return callee.property.name;
286
+ }
287
+ return null;
288
+ }
289
+ function isArrayStateHookCall(expression) {
290
+ if (!expression)
291
+ return false;
292
+ const node = unwrapExpression(expression);
293
+ if (node.type !== utils_1.AST_NODE_TYPES.CallExpression)
294
+ return false;
295
+ const name = getCalleeName(node);
296
+ return (name !== null && ARRAY_STATE_HOOKS.has(name) && hasArrayTypeArgument(node));
297
+ }
298
+ /**
299
+ * A call whose RESULT is an array. The state hooks are deliberately absent:
300
+ * `useRef<T[]>()` hands back a ref object and `useState<T[]>()` a tuple, so
301
+ * only the destructured head of that tuple is an array.
302
+ */
303
+ function isArrayProducingCall(call) {
304
+ const callee = unwrapExpression(call.callee);
305
+ if (callee.type !== utils_1.AST_NODE_TYPES.MemberExpression ||
306
+ callee.computed ||
307
+ callee.property.type !== utils_1.AST_NODE_TYPES.Identifier) {
308
+ return false;
309
+ }
310
+ const object = unwrapExpression(callee.object);
311
+ if (object.type === utils_1.AST_NODE_TYPES.Identifier && object.name === 'Array') {
312
+ return ARRAY_FACTORY_METHODS.has(callee.property.name);
313
+ }
314
+ return ARRAY_RETURNING_METHODS.has(callee.property.name);
315
+ }
316
+ function isArrayInitializer(expression) {
317
+ if (!expression)
318
+ return false;
319
+ const node = unwrapExpression(expression);
320
+ switch (node.type) {
321
+ case utils_1.AST_NODE_TYPES.ArrayExpression:
322
+ return true;
323
+ case utils_1.AST_NODE_TYPES.NewExpression:
324
+ return (node.callee.type === utils_1.AST_NODE_TYPES.Identifier &&
325
+ node.callee.name === 'Array');
326
+ case utils_1.AST_NODE_TYPES.CallExpression:
327
+ return isArrayProducingCall(node);
328
+ default:
329
+ return false;
330
+ }
331
+ }
332
+ function isArrayDeclarator(declarator, name) {
333
+ if (declarator.id === name) {
334
+ return isArrayInitializer(declarator.init);
335
+ }
336
+ /**
337
+ * The head of a `useState<T[]>()` / `useRef<T[]>()` tuple holds the array
338
+ * itself. Every other destructured binding holds an ELEMENT of whatever was
339
+ * destructured, which says nothing about the binding.
340
+ */
341
+ return (declarator.id.type === utils_1.AST_NODE_TYPES.ArrayPattern &&
342
+ declarator.id.elements[0] === name &&
343
+ isArrayStateHookCall(declarator.init));
344
+ }
345
+ function isArrayParameterElement(param, name) {
346
+ switch (param.type) {
347
+ case utils_1.AST_NODE_TYPES.AssignmentPattern:
348
+ return param.left === name && isArrayInitializer(param.right);
349
+ /** A rest parameter binds an array by construction. */
350
+ case utils_1.AST_NODE_TYPES.RestElement:
351
+ return param.argument === name;
352
+ case utils_1.AST_NODE_TYPES.TSParameterProperty:
353
+ return isArrayParameterElement(param.parameter, name);
354
+ default:
355
+ return false;
356
+ }
357
+ }
358
+ function isArrayParameter(fn, name) {
359
+ return fn.params.some((param) => isArrayParameterElement(param, name));
360
+ }
361
+ function hasDefinitionArrayEvidence(def) {
362
+ const name = def.name;
363
+ /** A binding introduced by anything but a plain name carries no annotation. */
364
+ if (name.type !== utils_1.AST_NODE_TYPES.Identifier)
365
+ return false;
366
+ if (isArrayAnnotation(name.typeAnnotation))
367
+ return true;
368
+ if (def.node.type === utils_1.AST_NODE_TYPES.VariableDeclarator) {
369
+ return isArrayDeclarator(def.node, name);
370
+ }
371
+ return isFunctionNode(def.node) && isArrayParameter(def.node, name);
372
+ }
373
+ function isArrayParameterProperty(param, name) {
374
+ const binding = param.parameter.type === utils_1.AST_NODE_TYPES.AssignmentPattern
375
+ ? param.parameter.left
376
+ : param.parameter;
377
+ if (binding.type !== utils_1.AST_NODE_TYPES.Identifier || binding.name !== name) {
378
+ return false;
379
+ }
380
+ return (isArrayAnnotation(binding.typeAnnotation) ||
381
+ (param.parameter.type === utils_1.AST_NODE_TYPES.AssignmentPattern &&
382
+ isArrayInitializer(param.parameter.right)));
383
+ }
384
+ function hasClassPropertyArrayEvidence(classBody, name) {
385
+ return classBody.body.some((member) => {
386
+ if (member.type === utils_1.AST_NODE_TYPES.PropertyDefinition) {
387
+ if (getPropertyKey(member.key, Boolean(member.computed)) !== name) {
388
+ return false;
389
+ }
390
+ return (isArrayAnnotation(member.typeAnnotation) ||
391
+ isArrayInitializer(member.value));
392
+ }
393
+ if (member.type === utils_1.AST_NODE_TYPES.MethodDefinition &&
394
+ member.kind === 'constructor') {
395
+ return member.value.params.some((param) => param.type === utils_1.AST_NODE_TYPES.TSParameterProperty &&
396
+ isArrayParameterProperty(param, name));
397
+ }
398
+ return false;
399
+ });
400
+ }
401
+ /**
402
+ * The class whose fields describe `this` at this position. A plain function
403
+ * rebinds `this` to whatever calls it, so fields declared around it say nothing
404
+ * about the receiver; an arrow function keeps the lexical `this`, and a method's
405
+ * own function expression is the class's.
406
+ */
407
+ function findEnclosingClassBody(node) {
408
+ let current = node.parent;
409
+ while (current) {
410
+ if (current.type === utils_1.AST_NODE_TYPES.ClassBody)
411
+ return current;
412
+ if (current.type === utils_1.AST_NODE_TYPES.FunctionDeclaration)
413
+ return null;
414
+ if (current.type === utils_1.AST_NODE_TYPES.FunctionExpression) {
415
+ const owner = current.parent;
416
+ const isClassMember = owner !== undefined &&
417
+ (owner.type === utils_1.AST_NODE_TYPES.MethodDefinition ||
418
+ owner.type === utils_1.AST_NODE_TYPES.PropertyDefinition);
419
+ if (!isClassMember)
420
+ return null;
421
+ }
422
+ current = current.parent;
423
+ }
424
+ return null;
425
+ }
226
426
  function isPushCallStatement(statement, sourceCode) {
227
427
  if (statement.type !== utils_1.AST_NODE_TYPES.ExpressionStatement)
228
428
  return null;
@@ -332,6 +532,73 @@ exports.flattenPushCalls = (0, createRule_1.createRule)({
332
532
  defaultOptions: [],
333
533
  create(context) {
334
534
  const sourceCode = context.getSourceCode();
535
+ /**
536
+ * Evidence is cached per binding rather than per receiver expression, so a
537
+ * long run of pushes onto one array resolves its declaration once.
538
+ */
539
+ const evidenceByVariable = new Map();
540
+ const evidenceByClassProperty = new Map();
541
+ let variablesByReference = null;
542
+ /**
543
+ * Resolution runs off the scope manager's own reference list rather than
544
+ * the scope of the visited node: `Program` yields the global scope, whose
545
+ * chain does not reach the module scope a top-level `const` lives in.
546
+ */
547
+ function resolveReference(identifier) {
548
+ if (!variablesByReference) {
549
+ const resolved = new Map();
550
+ sourceCode.scopeManager?.scopes.forEach((scope) => {
551
+ scope.references.forEach((reference) => {
552
+ resolved.set(reference.identifier, reference.resolved);
553
+ });
554
+ });
555
+ variablesByReference = resolved;
556
+ }
557
+ return variablesByReference.get(identifier) ?? null;
558
+ }
559
+ function hasVariableArrayEvidence(variable) {
560
+ const cached = evidenceByVariable.get(variable);
561
+ if (cached !== undefined)
562
+ return cached;
563
+ const evidence = variable.defs.some((def) => hasDefinitionArrayEvidence(def));
564
+ evidenceByVariable.set(variable, evidence);
565
+ return evidence;
566
+ }
567
+ function hasThisPropertyArrayEvidence(receiver) {
568
+ const name = getPropertyKey(receiver.property, Boolean(receiver.computed));
569
+ if (name === null)
570
+ return false;
571
+ const classBody = findEnclosingClassBody(receiver);
572
+ if (!classBody)
573
+ return false;
574
+ const key = `${classBody.range[0]}:${name}`;
575
+ const cached = evidenceByClassProperty.get(key);
576
+ if (cached !== undefined)
577
+ return cached;
578
+ const evidence = hasClassPropertyArrayEvidence(classBody, name);
579
+ evidenceByClassProperty.set(key, evidence);
580
+ return evidence;
581
+ }
582
+ /**
583
+ * Whether the receiver is syntactically an array. Anything the source does
584
+ * not describe — an unresolved name, a member chain, a call result — counts
585
+ * as no evidence, which keeps the rule silent rather than guessing.
586
+ */
587
+ function hasArrayReceiverEvidence(receiver) {
588
+ const node = unwrapExpression(receiver);
589
+ if (node.type === utils_1.AST_NODE_TYPES.ArrayExpression)
590
+ return true;
591
+ if (node.type === utils_1.AST_NODE_TYPES.Identifier) {
592
+ const variable = resolveReference(node);
593
+ return variable !== null && hasVariableArrayEvidence(variable);
594
+ }
595
+ if (node.type === utils_1.AST_NODE_TYPES.MemberExpression &&
596
+ unwrapExpression(node.object).type ===
597
+ utils_1.AST_NODE_TYPES.ThisExpression) {
598
+ return hasThisPropertyArrayEvidence(node);
599
+ }
600
+ return false;
601
+ }
335
602
  function buildChunks(call) {
336
603
  return call.arguments.map((arg) => ({
337
604
  leading: sourceCode.getCommentsBefore(arg),
@@ -563,7 +830,11 @@ exports.flattenPushCalls = (0, createRule_1.createRule)({
563
830
  return false;
564
831
  const totalArgs = group.reduce((count, entry) => count + entry.call.arguments.length, 0);
565
832
  const firstArgs = group[0].call.arguments.length;
566
- return totalArgs > firstArgs && canSafelyFix(group);
833
+ if (totalArgs <= firstArgs || !canSafelyFix(group))
834
+ return false;
835
+ const receiver = group[0].call.callee
836
+ .object;
837
+ return hasArrayReceiverEvidence(receiver);
567
838
  }
568
839
  function reportViolation(group) {
569
840
  context.report({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.21.4",
3
+ "version": "1.21.5",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,26 @@
1
1
  [
2
+ {
3
+ "version": "1.21.5",
4
+ "date": "2026-09-04T15:01:41.191Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-microdiff",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2322
11
+ ],
12
+ "summary": "report jsdiff and fast-diff without rewriting them (closes #2322)"
13
+ },
14
+ {
15
+ "name": "flatten-push-calls",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 2321
19
+ ],
20
+ "summary": "gate the merge on syntactic array evidence (closes #2321)"
21
+ }
22
+ ]
23
+ },
2
24
  {
3
25
  "version": "1.21.4",
4
26
  "date": "2026-09-04T11:47:38.427Z",