@blumintinc/eslint-plugin-blumint 1.21.3 → 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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({
|
|
@@ -140,6 +140,32 @@ function attributePrintedName(intended, printed, type) {
|
|
|
140
140
|
}
|
|
141
141
|
recordAttribution(intended, printed, symbol);
|
|
142
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* The bare identifier a declaration's own type annotation is written as, or
|
|
145
|
+
* null when the annotation is anything else.
|
|
146
|
+
*
|
|
147
|
+
* Only an un-parameterised reference to a plain identifier qualifies. A
|
|
148
|
+
* qualified name (`Theme.Mode`) needs its namespace to resolve at the fix site,
|
|
149
|
+
* which this answer cannot establish; a parameterised one (`Alias<'x'>`) is not
|
|
150
|
+
* denoted by its head at all, and emitting the head alone would name a
|
|
151
|
+
* different type. Both keep the printed union rather than guess.
|
|
152
|
+
*/
|
|
153
|
+
function annotatedTypeReferenceName(declaration) {
|
|
154
|
+
const annotation = ts.isParameter(declaration) ||
|
|
155
|
+
ts.isVariableDeclaration(declaration) ||
|
|
156
|
+
ts.isPropertySignature(declaration) ||
|
|
157
|
+
ts.isPropertyDeclaration(declaration)
|
|
158
|
+
? declaration.type
|
|
159
|
+
: undefined;
|
|
160
|
+
if (!annotation ||
|
|
161
|
+
!ts.isTypeReferenceNode(annotation) ||
|
|
162
|
+
!ts.isIdentifier(annotation.typeName) ||
|
|
163
|
+
(annotation.typeArguments?.length ?? 0) > 0) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
const name = annotation.typeName.text;
|
|
167
|
+
return BARE_TYPE_NAME.test(name) ? name : null;
|
|
168
|
+
}
|
|
143
169
|
/**
|
|
144
170
|
* Function/constructor/conditional type notation must be parenthesized to
|
|
145
171
|
* appear as a `|` union member, or the emitted annotation does not parse
|
|
@@ -833,11 +859,75 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
|
|
|
833
859
|
return null;
|
|
834
860
|
}
|
|
835
861
|
}
|
|
862
|
+
/**
|
|
863
|
+
* The bare type-alias name written on the discriminant's own annotation,
|
|
864
|
+
* when that name is provably the union the checker printed the members of.
|
|
865
|
+
*
|
|
866
|
+
* `checker.typeToString()` keeps an alias only while the type still carries
|
|
867
|
+
* an `aliasSymbol`, and TypeScript drops that resolving an indexed access
|
|
868
|
+
* over a `typeof` query — `const M = ['a', 'b'] as const; type Mode =
|
|
869
|
+
* (typeof M)[number]`, which is exactly the shape the key-space companion
|
|
870
|
+
* rule named on this rule's docs page emits. The printed text there is the
|
|
871
|
+
* widened literal union, the one spelling this rule's exhaustiveness
|
|
872
|
+
* promise exists to avoid: a stale `Record<'a' | 'b', V>` keeps
|
|
873
|
+
* typechecking after the union grows and only the LOOKUP complains, as
|
|
874
|
+
* TS7053 — an implicit-any diagnostic that disappears entirely under
|
|
875
|
+
* `noImplicitAny: false` and leaves the new member yielding `undefined` at
|
|
876
|
+
* runtime (#2009).
|
|
877
|
+
*
|
|
878
|
+
* The name is read off SYNTAX because the type no longer carries it, so it
|
|
879
|
+
* ships only against evidence that it denotes this very union: a bare,
|
|
880
|
+
* un-parameterised type reference written on the declaration the
|
|
881
|
+
* discriminant resolves to, whose symbol — resolved where the `Record`
|
|
882
|
+
* lands, not where the annotation was written — declares exactly this type.
|
|
883
|
+
* Mere presence of the name is not enough. Naming the WRONG union in the
|
|
884
|
+
* emitted `Record` breaks the build where the widened union merely weakens
|
|
885
|
+
* it, so anything short of type identity declines and keeps the printed
|
|
886
|
+
* text. Flow narrowing declines through the same check: a discriminant
|
|
887
|
+
* narrowed above the dispatch has a type the alias does not declare, and
|
|
888
|
+
* the alias would demand keys the construct has no branches for.
|
|
889
|
+
*/
|
|
890
|
+
function annotationAliasTypeText(discriminant, discriminantType) {
|
|
891
|
+
const tsNode = esTreeNodeToTSNodeMap.get(unwrapLink(discriminant));
|
|
892
|
+
const tsFixSite = esTreeNodeToTSNodeMap.get(discriminant);
|
|
893
|
+
if (!tsNode || !tsFixSite) {
|
|
894
|
+
return null;
|
|
895
|
+
}
|
|
896
|
+
try {
|
|
897
|
+
const symbol = checker.getSymbolAtLocation(tsNode);
|
|
898
|
+
const names = new Set((symbol?.declarations ?? [])
|
|
899
|
+
.map(annotatedTypeReferenceName)
|
|
900
|
+
.filter((name) => name !== null));
|
|
901
|
+
// Two declarations annotated with different names (a merged symbol, an
|
|
902
|
+
// overload set) leave no single spelling that is right for both.
|
|
903
|
+
if (names.size !== 1) {
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
const [name] = names;
|
|
907
|
+
const inScope = checker
|
|
908
|
+
.getSymbolsInScope(tsFixSite, ts.SymbolFlags.Type | ts.SymbolFlags.Alias)
|
|
909
|
+
.find((candidate) => candidate.name === name);
|
|
910
|
+
if (!inScope) {
|
|
911
|
+
return null;
|
|
912
|
+
}
|
|
913
|
+
const declaredSymbol = inScope.flags & ts.SymbolFlags.Alias
|
|
914
|
+
? checker.getAliasedSymbol(inScope)
|
|
915
|
+
: inScope;
|
|
916
|
+
if (checker.getDeclaredTypeOfSymbol(declaredSymbol) !== discriminantType) {
|
|
917
|
+
return null;
|
|
918
|
+
}
|
|
919
|
+
return { text: name, symbol: declaredSymbol };
|
|
920
|
+
}
|
|
921
|
+
catch {
|
|
922
|
+
return null;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
836
925
|
/**
|
|
837
926
|
* Key type for the emitted `Record`. A discriminant whose own type prints
|
|
838
927
|
* as a bare name already carries the union's identity, so that name is
|
|
839
|
-
* kept; otherwise the fix reaches for the tag's declaring type
|
|
840
|
-
*
|
|
928
|
+
* kept; otherwise the fix reaches for the tag's declaring type (#1926),
|
|
929
|
+
* then for the alias its annotation names where the checker resolved past
|
|
930
|
+
* one (#2009), before settling for the resolved literal union.
|
|
841
931
|
*/
|
|
842
932
|
function discriminantTypeText(type, discriminant) {
|
|
843
933
|
let printed;
|
|
@@ -865,10 +955,27 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
|
|
|
865
955
|
// already ships only when the name resolves, at the fix site, to exactly
|
|
866
956
|
// the object's declared type, and identity of the TYPE accepts a nearer
|
|
867
957
|
// alias that denotes the same type — which compiles, and reads the same.
|
|
868
|
-
const
|
|
869
|
-
|
|
958
|
+
const indexed = indexedAccessTypeText(discriminant, type);
|
|
959
|
+
if (indexed !== null) {
|
|
960
|
+
return { text: normalizeTypeQuotes(indexed, singleQuote), intended };
|
|
961
|
+
}
|
|
962
|
+
// Recovery runs only where the printer already produced usable text, so
|
|
963
|
+
// it can only change the SPELLING of a key type the rule was going to
|
|
964
|
+
// emit anyway — never whether the rule fires. A discriminant the printer
|
|
965
|
+
// could not print at all stays silent exactly as before.
|
|
966
|
+
if (printed !== null) {
|
|
967
|
+
const alias = annotationAliasTypeText(discriminant, type);
|
|
968
|
+
if (alias) {
|
|
969
|
+
// Unlike the printed bare name, this one was never checked against
|
|
970
|
+
// the fix site's scope by the printer, so it carries its symbol into
|
|
971
|
+
// the gate for the same shadow check (#2229).
|
|
972
|
+
recordAttribution(intended, alias.text, alias.symbol);
|
|
973
|
+
return { text: alias.text, intended };
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
return printed === null
|
|
870
977
|
? null
|
|
871
|
-
: { text: normalizeTypeQuotes(
|
|
978
|
+
: { text: normalizeTypeQuotes(printed, singleQuote), intended };
|
|
872
979
|
}
|
|
873
980
|
/**
|
|
874
981
|
* Whether the binding a type name reaches at the fix site is the symbol the
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,40 @@
|
|
|
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
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"version": "1.21.4",
|
|
26
|
+
"date": "2026-09-04T11:47:38.427Z",
|
|
27
|
+
"rules": [
|
|
28
|
+
{
|
|
29
|
+
"name": "prefer-map-over-conditional-dispatch",
|
|
30
|
+
"changeType": "fix",
|
|
31
|
+
"issues": [
|
|
32
|
+
2009
|
|
33
|
+
],
|
|
34
|
+
"summary": "keep the alias when the checker resolves past it (closes #2009)"
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
},
|
|
2
38
|
{
|
|
3
39
|
"version": "1.21.3",
|
|
4
40
|
"date": "2026-09-03T23:34:34.310Z",
|