@blumintinc/eslint-plugin-blumint 1.20.49 → 1.20.51

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.49',
226
+ version: '1.20.51',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -52,7 +52,7 @@ module.exports = (0, createRule_1.createRule)({
52
52
  messages: {
53
53
  callbackPropPrefix: 'Callback prop "{{propName}}" is a function but lacks the "on" prefix. ' +
54
54
  'Consistent "on" prefixes signal event handlers to consumers and distinguish callbacks from data props. ' +
55
- 'Rename to "on{{eventName}}".',
55
+ 'Rename to "on{{eventName}}" here, in the props type that declares it, and in every reader of that prop.',
56
56
  callbackFunctionPrefix: 'Function "{{functionName}}" uses the "handle" prefix. ' +
57
57
  'The "handle" prefix is redundant and less descriptive than action-oriented verb phrases. ' +
58
58
  'Rename using a descriptive verb (e.g., click instead of handleClick).',
@@ -252,6 +252,18 @@ module.exports = (0, createRule_1.createRule)({
252
252
  !isRenderFunction(node.value.expression) &&
253
253
  !isReactComponentType(node.value.expression)) {
254
254
  const eventName = propName.charAt(0).toUpperCase() + propName.slice(1);
255
+ // Reported without an autofix (Bug #1522). A JSX attribute name is
256
+ // one end of a props contract: the other end is the declaration
257
+ // that binds the name — a props `type`/`interface`, a
258
+ // `JSX.IntrinsicElements` augmentation for host elements — plus
259
+ // every reader of that member (`props.validate`, destructuring) and
260
+ // every other call site of the component. Rewriting only the
261
+ // attribute yields TS2322, and renaming the local declaration
262
+ // as well merely relocates the break: readers in the same file fail
263
+ // with TS2551 and call sites in other files (which a single-file
264
+ // fixer cannot see, let alone edit atomically) fail with TS2322.
265
+ // No subset of the rename is safe to apply in isolation, so the
266
+ // report carries the full instruction instead of a broken fix.
255
267
  context.report({
256
268
  node,
257
269
  messageId: 'callbackPropPrefix',
@@ -259,10 +271,6 @@ module.exports = (0, createRule_1.createRule)({
259
271
  propName,
260
272
  eventName,
261
273
  },
262
- fix(fixer) {
263
- // Convert camelCase to PascalCase for the event name
264
- return fixer.replaceText(node.name, `on${eventName}`);
265
- },
266
274
  });
267
275
  }
268
276
  }
@@ -36,9 +36,11 @@ exports.DEFAULT_IGNORED_LIBRARIES = [
36
36
  'use-latest-callback',
37
37
  '@blumintinc/typescript-memoize',
38
38
  '@blumintinc/use-deep-compare',
39
+ '@blumintinc/microdiff',
39
40
  'microdiff',
40
41
  'safe-stable-stringify',
41
- 'fast-deep-equal', // fast-deep-equal-over-microdiff
42
+ '@blumintinc/fast-deep-equal',
43
+ 'fast-deep-equal', // fast-deep-equal-over-microdiff, for files already on upstream
42
44
  ];
43
45
  exports.DEFAULT_INTERNAL_PREFIXES = ['src/', 'functions/'];
44
46
  // Pre-built set of Node.js core module names for O(1) lookup.
@@ -407,8 +407,9 @@ function renderArrayPatternWithDefaults(pattern, sourceCode) {
407
407
  return `${leftText} = ${sourceCode.getText(element.right)}`;
408
408
  }
409
409
  if (element.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
410
- const nested = renderObjectPatternWithDefaults(element, sourceCode);
411
- return `${nested} = {}`;
410
+ // No synthesized `= {}` here either, for the reason spelled out in
411
+ // formatPropertyText: the default is checked against the bindings under it.
412
+ return renderObjectPatternWithDefaults(element, sourceCode);
412
413
  }
413
414
  if (element.type === utils_1.AST_NODE_TYPES.ArrayPattern) {
414
415
  const nested = renderArrayPatternWithDefaults(element, sourceCode);
@@ -441,10 +442,19 @@ function formatPropertyText(property, sourceCode) {
441
442
  return renderPropertyWithAssignment(property, value, keyText, sourceCode);
442
443
  }
443
444
  if (value.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
445
+ // A nested object pattern is re-emitted as authored, without a synthesized
446
+ // `= {}`. TypeScript checks such a default against every binding element
447
+ // beneath it, so `{ profile: { name, age } = {} }` reports TS2525 once per
448
+ // name: `{}` supplies no value and the names carry no defaults of their own.
449
+ // The default also only ever guarded a nullish parent (an explicitly `null`
450
+ // one still throws), so dropping it costs a partial runtime guard and buys
451
+ // back the invariant that compiling input yields compiling output.
444
452
  const nested = renderObjectPatternWithDefaults(value, sourceCode);
445
- return `${keyText}: ${nested} = {}`;
453
+ return `${keyText}: ${nested}`;
446
454
  }
447
455
  if (value.type === utils_1.AST_NODE_TYPES.ArrayPattern) {
456
+ // An array pattern's `= []` default is safe to synthesize: TypeScript does
457
+ // not push it down onto the element bindings the way it does for objects.
448
458
  const nested = renderArrayPatternWithDefaults(value, sourceCode);
449
459
  return `${keyText}: ${nested} = []`;
450
460
  }
@@ -5,7 +5,25 @@ const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
6
  const ASTHelpers_1 = require("../utils/ASTHelpers");
7
7
  const DIFF_NAME = 'diff';
8
- const MICRODIFF_MODULE = 'microdiff';
8
+ /**
9
+ * The package the fix imports from. BluMint's fork is the dependency this
10
+ * codebase declares, and every call site resolves against it.
11
+ */
12
+ const MICRODIFF_MODULE = '@blumintinc/microdiff';
13
+ /**
14
+ * The specifiers that already satisfy this rule. The fix emits the fork, but a
15
+ * file importing upstream `microdiff` is diffing structurally all the same, so
16
+ * both are recognised on the way in: an unrecognised one would earn a second
17
+ * import of the same binding (TS2300).
18
+ */
19
+ const MICRODIFF_MODULES = new Set([MICRODIFF_MODULE, 'microdiff']);
20
+ /**
21
+ * The import the fix emits. Both packages export their diff function as the
22
+ * module *default* — `Difference`, `MicrodiffOptions` and the `default*`
23
+ * predicates are the only named exports — so a `{ diff }` specifier binds
24
+ * nothing (TS2305) however the module resolves.
25
+ */
26
+ const MICRODIFF_IMPORT = `import ${DIFF_NAME} from '${MICRODIFF_MODULE}';`;
9
27
  /**
10
28
  * The names a competing library's diff export is conventionally bound to. They
11
29
  * are candidates for a report, never proof of one: what a call resolves to
@@ -68,7 +86,7 @@ function bindsMicrodiffDiff(specifier) {
68
86
  */
69
87
  function findMicrodiffImport(program) {
70
88
  return program.body.find((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
71
- statement.source.value === MICRODIFF_MODULE &&
89
+ MICRODIFF_MODULES.has(String(statement.source.value)) &&
72
90
  statement.importKind !== 'type' &&
73
91
  statement.specifiers.some(bindsMicrodiffDiff));
74
92
  }
@@ -85,7 +103,7 @@ function collectClaimableSpecifiers(program) {
85
103
  return;
86
104
  }
87
105
  const source = statement.source.value;
88
- if (source === MICRODIFF_MODULE) {
106
+ if (MICRODIFF_MODULES.has(source)) {
89
107
  statement.specifiers
90
108
  .filter(bindsMicrodiffDiff)
91
109
  .forEach((specifier) => claimable.add(specifier));
@@ -166,6 +184,28 @@ function toImportedDiffSource(variable, importedSpecifiers) {
166
184
  }
167
185
  return null;
168
186
  }
187
+ /**
188
+ * Whether a call carries operands `diff(obj, newObj, options?)` accepts.
189
+ * microdiff needs both sides of the comparison, so a call supplying fewer is
190
+ * reported but left alone: rewriting it would trade an unresolved name for a
191
+ * call that does not type-check (TS2554).
192
+ */
193
+ function hasRewritableArity(call) {
194
+ return call.arguments.length >= 2;
195
+ }
196
+ /**
197
+ * Whether a reference sits where the call fix rewrites it — as the callee of a
198
+ * convertible call. A reference in any other position (passed as a value,
199
+ * assigned to a variable, re-exported) has no rewrite of its own, so retiring
200
+ * the import that binds it would leave it unresolved (TS2304).
201
+ */
202
+ function isRewrittenCallee(identifier) {
203
+ const parent = identifier.parent;
204
+ return (!!parent &&
205
+ parent.type === utils_1.AST_NODE_TYPES.CallExpression &&
206
+ parent.callee === identifier &&
207
+ hasRewritableArity(parent));
208
+ }
169
209
  /**
170
210
  * Whether a bare `diff` written at `scope` reaches microdiff's function.
171
211
  * Resolving through the scope chain catches both failure modes: a module-scope
@@ -221,21 +261,86 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
221
261
  return canEmitDiff(ASTHelpers_1.ASTHelpers.getScope(context, node), collectClaimableSpecifiers(sourceCode.ast));
222
262
  }
223
263
  /**
224
- * Whether retiring `declaration` leaves every reference it binds able to
225
- * take the name `diff`. The import rewrite lands at module scope while the
226
- * references it serves sit in nested scopes: one standing where `diff` is
227
- * shadowed keeps the old name, because its own fix declines, so rewriting
228
- * the import would strand it without a binding (TS2304).
264
+ * Whether the call handler rewrites the calls a specifier's references
265
+ * make. It fires on a specifier the import handler tracked, or on a local
266
+ * name a competing library conventionally binds; a specifier matching
267
+ * neither `applyChange` from `deep-diff`, say — keeps its call sites, so
268
+ * its references survive only if the import that binds them does.
229
269
  */
230
- function canRenameReferencesOf(declaration) {
270
+ function isRewrittenSpecifier(specifier) {
271
+ return (importedDiffSpecifiers.has(specifier) ||
272
+ DIFF_FUNCTION_NAMES.has(specifier.local.name));
273
+ }
274
+ /**
275
+ * Whether retiring `declaration` leaves every name it binds accounted for.
276
+ * The fix removes the whole declaration, so each reference it serves has to
277
+ * be one the call fixes rewrite to `diff` in the same pass — an import swap
278
+ * that strands a reference behind is the defect this guards.
279
+ *
280
+ * Two things have to hold at every reference. `diff` must reach microdiff
281
+ * there: the import rewrite lands at module scope while the references it
282
+ * serves sit in nested scopes, and one standing where `diff` is shadowed
283
+ * would resolve to the shadow. And the reference has to sit where a rewrite
284
+ * exists at all, which is the callee position of a convertible call.
285
+ *
286
+ * A specifier nothing references is vacuously safe: retiring it removes an
287
+ * import no code reads.
288
+ */
289
+ function canRetireImport(declaration) {
231
290
  const claimable = collectClaimableSpecifiers(sourceCode.ast);
232
291
  const declarationScope = ASTHelpers_1.ASTHelpers.getScope(context, declaration);
233
292
  return declaration.specifiers.every((specifier) => {
234
293
  const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(declarationScope, specifier.local.name);
235
- return (!variable ||
236
- variable.references.every((reference) => canEmitDiff(reference.from, claimable)));
294
+ if (!variable) {
295
+ return true;
296
+ }
297
+ const rewritten = isRewrittenSpecifier(specifier);
298
+ return variable.references.every((reference) => canEmitDiff(reference.from, claimable) &&
299
+ rewritten &&
300
+ isRewrittenCallee(reference.identifier));
237
301
  });
238
302
  }
303
+ /**
304
+ * Whether the import fix retires `declaration` in this same pass.
305
+ */
306
+ function willRetireImport(declaration) {
307
+ return canEmitDiffAt(declaration) && canRetireImport(declaration);
308
+ }
309
+ /**
310
+ * Whether the call at `node`, whose callee `declaration` binds, may be
311
+ * rewritten to `diff(...)`. Separate from the decision to report: a call
312
+ * this rule cannot convert is still a use of a competing library, so it is
313
+ * reported and left for the author.
314
+ *
315
+ * The rename needs something to bind the `diff` it writes — an import the
316
+ * file already has, or the one that replaces `declaration` in this same
317
+ * pass. Renaming a callee whose import survives strands the rewritten call
318
+ * exactly as retiring an import whose callee survives strands that one.
319
+ */
320
+ function canRewriteCall(node, declaration) {
321
+ if (!canEmitDiffAt(node) || !hasRewritableArity(node)) {
322
+ return false;
323
+ }
324
+ if (findMicrodiffImport(sourceCode.ast)) {
325
+ return true;
326
+ }
327
+ return !!declaration && willRetireImport(declaration);
328
+ }
329
+ /**
330
+ * The fix element that puts microdiff's import at the top of the file, or
331
+ * null when the file already has one.
332
+ *
333
+ * It is its own element rather than text spliced into the replacement for
334
+ * the reported node, because the reported node is rarely at module scope: a
335
+ * comparison inside a function body, or a function behind an `export`,
336
+ * would otherwise take the import somewhere the grammar forbids it.
337
+ */
338
+ function buildMicrodiffImportFix(fixer) {
339
+ if (findMicrodiffImport(sourceCode.ast)) {
340
+ return null;
341
+ }
342
+ return fixer.insertTextBeforeRange([0, 0], `${MICRODIFF_IMPORT}\n\n`);
343
+ }
239
344
  // Add a specific set to track which import names are used
240
345
  const usedImportNames = new Set();
241
346
  // Check if a node is an object or array type
@@ -272,7 +377,7 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
272
377
  ImportDeclaration(node) {
273
378
  const importSource = node.source.value;
274
379
  // Check for microdiff import
275
- if (importSource === MICRODIFF_MODULE) {
380
+ if (MICRODIFF_MODULES.has(importSource)) {
276
381
  return;
277
382
  }
278
383
  // Track other diffing libraries
@@ -296,9 +401,10 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
296
401
  },
297
402
  fix(fixer) {
298
403
  // Decline rather than duplicate or shadow a `diff` this file
299
- // already binds to something else. The report stands so the
300
- // author resolves the name clash deliberately.
301
- if (!canEmitDiffAt(node) || !canRenameReferencesOf(node)) {
404
+ // already binds to something else, and rather than retire an
405
+ // import whose references no call fix rewrites. The report stands
406
+ // either way, so the author resolves it deliberately.
407
+ if (!canEmitDiffAt(node) || !canRetireImport(node)) {
302
408
  return null;
303
409
  }
304
410
  // If we already have a microdiff import, just remove this import
@@ -306,7 +412,7 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
306
412
  return fixer.remove(node);
307
413
  }
308
414
  // Otherwise, replace with microdiff import
309
- return fixer.replaceText(node, `import { ${DIFF_NAME} } from '${MICRODIFF_MODULE}';`);
415
+ return fixer.replaceText(node, MICRODIFF_IMPORT);
310
416
  },
311
417
  });
312
418
  // Check if importing a diff function or a known equality library
@@ -346,6 +452,9 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
346
452
  // Check if this call resolves to a function we specifically
347
453
  // imported from a diff library
348
454
  const importSource = toImportedDiffSource(calleeVariable, importedDiffSpecifiers);
455
+ // The declaration the rename depends on: retiring it is what frees
456
+ // the name `diff` and imports something under it.
457
+ const competingImport = toCompetingDiffImport(calleeVariable);
349
458
  if (importSource) {
350
459
  usedImportNames.add(name);
351
460
  // Skip reporting if it's from fast-deep-equal
@@ -358,7 +467,7 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
358
467
  node,
359
468
  messageId: 'enforceMicrodiff',
360
469
  fix(fixer) {
361
- if (!canEmitDiffAt(node)) {
470
+ if (!canRewriteCall(node, competingImport)) {
362
471
  return null;
363
472
  }
364
473
  return fixer.replaceText(callee, DIFF_NAME);
@@ -370,29 +479,28 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
370
479
  // The name is only a candidate until the scope chain says what it
371
480
  // binds: a local function, variable, parameter, or an import from
372
481
  // anywhere but a competing diff library keeps its call untouched.
373
- const competingImport = toCompetingDiffImport(calleeVariable);
374
482
  if (!competingImport) {
375
483
  return;
376
484
  }
377
485
  // Track this import name as used
378
486
  usedImportNames.add(name);
379
- // Check if we have at least 2 arguments that are objects or arrays
380
- if (node.arguments.length >= 2 &&
381
- isObjectOrArrayType(node.arguments[0]) &&
382
- isObjectOrArrayType(node.arguments[1])) {
383
- reportedNodes.add(node);
384
- context.report({
385
- node,
386
- messageId: 'enforceMicrodiff',
387
- fix(fixer) {
388
- if (!canEmitDiffAt(node)) {
389
- return null;
390
- }
391
- // When handling fast-diff and similar libraries, need to ensure the function name is replaced
392
- return fixer.replaceText(callee, DIFF_NAME);
393
- },
394
- });
395
- }
487
+ // What the callee resolves to already settles this: the argument
488
+ // shapes say nothing a competing library's own import has not.
489
+ // Gating the report on them left the call behind while the import
490
+ // handler retired the declaration binding it, so `deepDiff(a, b)`
491
+ // came out of a fix unresolved.
492
+ reportedNodes.add(node);
493
+ context.report({
494
+ node,
495
+ messageId: 'enforceMicrodiff',
496
+ fix(fixer) {
497
+ if (!canRewriteCall(node, competingImport)) {
498
+ return null;
499
+ }
500
+ // When handling fast-diff and similar libraries, need to ensure the function name is replaced
501
+ return fixer.replaceText(callee, DIFF_NAME);
502
+ },
503
+ });
396
504
  }
397
505
  }
398
506
  }
@@ -461,28 +569,13 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
461
569
  if (!canEmitDiffAt(node)) {
462
570
  return null;
463
571
  }
464
- // Find the containing function to add the import
465
- let functionNode = node;
466
- while (functionNode &&
467
- functionNode.type !== utils_1.AST_NODE_TYPES.FunctionDeclaration &&
468
- functionNode.type !==
469
- utils_1.AST_NODE_TYPES.ArrowFunctionExpression &&
470
- functionNode.type !== utils_1.AST_NODE_TYPES.Program) {
471
- functionNode = functionNode.parent;
472
- }
473
- // If we found a program node and microdiff isn't imported,
474
- // we'll need to add the import manually
475
- if (functionNode &&
476
- functionNode.type === utils_1.AST_NODE_TYPES.Program &&
477
- !findMicrodiffImport(sourceCode.ast)) {
478
- // Need to add an import
479
- const importFix = fixer.insertTextBeforeRange([0, 0], `import { ${DIFF_NAME} } from '${MICRODIFF_MODULE}';\n\n`);
480
- // Replace JSON.stringify comparison
481
- const compareFix = fixer.replaceText(node, `${DIFF_NAME}(${sourceCode.getText(leftArg)}, ${sourceCode.getText(rightArg)})${isEqual ? '.length === 0' : '.length > 0'}`);
482
- return [importFix, compareFix];
483
- }
484
- // Otherwise just replace the comparison
485
- return fixer.replaceText(node, `${DIFF_NAME}(${sourceCode.getText(leftArg)}, ${sourceCode.getText(rightArg)})${isEqual ? '.length === 0' : '.length > 0'}`);
572
+ const compareFix = fixer.replaceText(node, `${DIFF_NAME}(${sourceCode.getText(leftArg)}, ${sourceCode.getText(rightArg)})${isEqual ? '.length === 0' : '.length > 0'}`);
573
+ // The comparison this rewrites almost always sits inside a
574
+ // function, and the `diff` it emits needs an import whatever
575
+ // encloses it. Deciding on the enclosing node left every
576
+ // nested comparison calling a `diff` nothing bound.
577
+ const importFix = buildMicrodiffImportFix(fixer);
578
+ return importFix ? [importFix, compareFix] : compareFix;
486
579
  },
487
580
  });
488
581
  }
@@ -518,27 +611,31 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
518
611
  bodyText.includes('JSON.stringify') &&
519
612
  bodyText.includes('!==')) {
520
613
  reportedNodes.add(node);
521
- const param1 = sourceCode.getText(node.params[0]);
522
- const param2 = sourceCode.getText(node.params[1]);
614
+ // The operands are the parameter *names*, not the text of the
615
+ // parameters: a typed parameter's text carries its annotation,
616
+ // and `diff(oldConfig: Config, ...)` does not parse. A parameter
617
+ // that binds no single name — a destructuring or rest pattern —
618
+ // has no operand to pass, so the report stands without a fix.
619
+ const [firstParam, secondParam] = node.params;
620
+ const operands = firstParam.type === utils_1.AST_NODE_TYPES.Identifier &&
621
+ secondParam.type === utils_1.AST_NODE_TYPES.Identifier
622
+ ? [firstParam.name, secondParam.name]
623
+ : null;
523
624
  context.report({
524
625
  node,
525
626
  messageId: 'enforceMicrodiff',
526
627
  fix(fixer) {
527
- if (!canEmitDiffAt(node)) {
628
+ if (!operands || !canEmitDiffAt(node)) {
528
629
  return null;
529
630
  }
530
- // Create a new version of the function with microdiff
531
- const newFunctionBody = `{
532
- return ${DIFF_NAME}(${param1}, ${param2}).length > 0;
533
- }`;
534
- if (!findMicrodiffImport(sourceCode.ast)) {
535
- // Create a new import statement
536
- return fixer.replaceText(node, `import { ${DIFF_NAME} } from '${MICRODIFF_MODULE}';\n\nfunction ${node.id?.name}(${param1}, ${param2}) ${newFunctionBody}`);
537
- }
538
- else {
539
- // Just replace the function body
540
- return fixer.replaceText(body, newFunctionBody);
541
- }
631
+ // Only the body is rewritten, so the signature keeps its type
632
+ // annotations, its modifiers, and any `export` in front of
633
+ // it. Replacing the declaration wholesale used to drop those
634
+ // and, when it prefixed the import, put an `import` inside
635
+ // whatever enclosed the function.
636
+ const bodyFix = fixer.replaceText(body, `{\n return ${DIFF_NAME}(${operands[0]}, ${operands[1]}).length > 0;\n}`);
637
+ const importFix = buildMicrodiffImportFix(fixer);
638
+ return importFix ? [importFix, bodyFix] : bodyFix;
542
639
  },
543
640
  });
544
641
  return;