@blumintinc/eslint-plugin-blumint 1.20.83 → 1.20.84
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 +1 -1
- package/lib/rules/prefer-spread-over-reassembly.js +291 -0
- package/package.json +1 -1
- package/release-manifest.json +14 -0
package/lib/index.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.preferSpreadOverReassembly = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
|
+
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
6
7
|
const DEFAULT_MIN_FIELDS = 2;
|
|
7
8
|
/**
|
|
8
9
|
* Collects all identifier references (not declarations) used anywhere in a
|
|
@@ -70,6 +71,139 @@ function getSimpleDestructuredNames(pattern) {
|
|
|
70
71
|
}
|
|
71
72
|
return names;
|
|
72
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Array methods that hand each element of the receiver to their callback. They
|
|
76
|
+
* are the contextual route by which an unannotated destructured parameter still
|
|
77
|
+
* has a knowable type: the element type of the array being iterated.
|
|
78
|
+
*/
|
|
79
|
+
const ELEMENT_CALLBACK_METHODS = new Set([
|
|
80
|
+
'map',
|
|
81
|
+
'forEach',
|
|
82
|
+
'filter',
|
|
83
|
+
'flatMap',
|
|
84
|
+
]);
|
|
85
|
+
const ARRAY_TYPE_NAMES = new Set(['Array', 'ReadonlyArray']);
|
|
86
|
+
/**
|
|
87
|
+
* Resolves `Promise<T>` to `T`, leaving anything else as it stands, which is
|
|
88
|
+
* what `await` does to the type of the expression it operates on.
|
|
89
|
+
*/
|
|
90
|
+
function unwrapPromise(typeNode) {
|
|
91
|
+
if (typeNode.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
|
|
92
|
+
typeNode.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
93
|
+
typeNode.typeName.name === 'Promise' &&
|
|
94
|
+
typeNode.typeParameters?.params.length === 1) {
|
|
95
|
+
return typeNode.typeParameters.params[0];
|
|
96
|
+
}
|
|
97
|
+
return typeNode;
|
|
98
|
+
}
|
|
99
|
+
/** The element type of an array type, or null when the type is not an array. */
|
|
100
|
+
function arrayElementTypeOf(typeNode) {
|
|
101
|
+
if (typeNode.type === utils_1.AST_NODE_TYPES.TSArrayType) {
|
|
102
|
+
return typeNode.elementType;
|
|
103
|
+
}
|
|
104
|
+
// `readonly Unit[]` wraps the array type in a type operator.
|
|
105
|
+
if (typeNode.type === utils_1.AST_NODE_TYPES.TSTypeOperator &&
|
|
106
|
+
typeNode.operator === 'readonly' &&
|
|
107
|
+
typeNode.typeAnnotation) {
|
|
108
|
+
return arrayElementTypeOf(typeNode.typeAnnotation);
|
|
109
|
+
}
|
|
110
|
+
if (typeNode.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
|
|
111
|
+
typeNode.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
112
|
+
ARRAY_TYPE_NAMES.has(typeNode.typeName.name) &&
|
|
113
|
+
typeNode.typeParameters?.params.length === 1) {
|
|
114
|
+
return typeNode.typeParameters.params[0];
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The property names a member list declares, or null when the list cannot be
|
|
120
|
+
* enumerated exactly. An index signature, a call/construct signature or a
|
|
121
|
+
* computed key all describe members whose names are not written down, and a
|
|
122
|
+
* member set that may be larger than what is read would let a narrowing pick
|
|
123
|
+
* pass for an exhaustive one.
|
|
124
|
+
*/
|
|
125
|
+
function namesOfMembers(members) {
|
|
126
|
+
const names = new Set();
|
|
127
|
+
for (const member of members) {
|
|
128
|
+
if (member.type !== utils_1.AST_NODE_TYPES.TSPropertySignature &&
|
|
129
|
+
member.type !== utils_1.AST_NODE_TYPES.TSMethodSignature) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
if (member.computed) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const key = member.key;
|
|
136
|
+
if (key.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
137
|
+
names.add(key.name);
|
|
138
|
+
}
|
|
139
|
+
else if (key.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
140
|
+
typeof key.value === 'string') {
|
|
141
|
+
names.add(key.value);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return names;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Finds a type alias or interface declared at the top level of the file being
|
|
151
|
+
* linted, including one that is exported.
|
|
152
|
+
*
|
|
153
|
+
* Resolution stops at the file boundary on purpose: an imported name's members
|
|
154
|
+
* live in a module this rule cannot read, and guessing at them would be the
|
|
155
|
+
* opposite of a proof.
|
|
156
|
+
*/
|
|
157
|
+
function findLocalTypeDeclaration(program, name) {
|
|
158
|
+
for (const statement of program.body) {
|
|
159
|
+
const declaration = statement.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration &&
|
|
160
|
+
statement.declaration
|
|
161
|
+
? statement.declaration
|
|
162
|
+
: statement;
|
|
163
|
+
if ((declaration.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration ||
|
|
164
|
+
declaration.type === utils_1.AST_NODE_TYPES.TSInterfaceDeclaration) &&
|
|
165
|
+
declaration.id.name === name) {
|
|
166
|
+
return declaration;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Enumerates every property name a type node declares, or null when the member
|
|
173
|
+
* list cannot be established with certainty.
|
|
174
|
+
*
|
|
175
|
+
* Only an unambiguous, fully written-out member list qualifies. A union, an
|
|
176
|
+
* intersection, a mapped or conditional type, a generic instantiation and an
|
|
177
|
+
* interface with an `extends` clause all describe a member set assembled
|
|
178
|
+
* elsewhere, so none of them can prove anything here.
|
|
179
|
+
*/
|
|
180
|
+
function memberNamesOf(typeNode, program, seen = new Set()) {
|
|
181
|
+
if (typeNode.type === utils_1.AST_NODE_TYPES.TSTypeLiteral) {
|
|
182
|
+
return namesOfMembers(typeNode.members);
|
|
183
|
+
}
|
|
184
|
+
if (typeNode.type !== utils_1.AST_NODE_TYPES.TSTypeReference ||
|
|
185
|
+
typeNode.typeName.type !== utils_1.AST_NODE_TYPES.Identifier ||
|
|
186
|
+
typeNode.typeParameters) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
const name = typeNode.typeName.name;
|
|
190
|
+
// A self-referential alias (`type T = T`) would otherwise recur forever.
|
|
191
|
+
if (seen.has(name)) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
seen.add(name);
|
|
195
|
+
const declaration = findLocalTypeDeclaration(program, name);
|
|
196
|
+
if (!declaration || declaration.typeParameters) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
if (declaration.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration) {
|
|
200
|
+
return memberNamesOf(declaration.typeAnnotation, program, seen);
|
|
201
|
+
}
|
|
202
|
+
if (declaration.extends && declaration.extends.length > 0) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
return namesOfMembers(declaration.body.body);
|
|
206
|
+
}
|
|
73
207
|
/**
|
|
74
208
|
* For a JSX element, returns the set of destructured names that are forwarded
|
|
75
209
|
* with identical key names (e.g. `hits={hits}`, `isLoading={isLoading}`).
|
|
@@ -492,6 +626,158 @@ exports.preferSpreadOverReassembly = (0, createRule_1.createRule)({
|
|
|
492
626
|
create(context, [options]) {
|
|
493
627
|
const minFields = options?.minFields ?? DEFAULT_MIN_FIELDS;
|
|
494
628
|
const sourceCode = context.getSourceCode();
|
|
629
|
+
const program = sourceCode.ast;
|
|
630
|
+
/**
|
|
631
|
+
* Walks an expression toward its syntactic root and returns the type node
|
|
632
|
+
* that root declares, mirroring the receiver trace in
|
|
633
|
+
* `enforce-firestore-doc-ref-generic`.
|
|
634
|
+
*
|
|
635
|
+
* The trace is syntax only. `parserOptions.project` is absent from the
|
|
636
|
+
* shared testers and from many consumer configs, so a type-checker branch
|
|
637
|
+
* would be dead exactly where the destructured pick it must protect lives —
|
|
638
|
+
* inside an `Array.prototype.map` callback, whose signature comes from
|
|
639
|
+
* `lib.d.ts`.
|
|
640
|
+
*/
|
|
641
|
+
function typeNodeOfExpression(node, visited) {
|
|
642
|
+
// Guards against a self-referential declaration such as `const a = a.b;`.
|
|
643
|
+
if (!node || visited.has(node)) {
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
visited.add(node);
|
|
647
|
+
switch (node.type) {
|
|
648
|
+
case utils_1.AST_NODE_TYPES.AwaitExpression: {
|
|
649
|
+
const awaited = typeNodeOfExpression(node.argument, visited);
|
|
650
|
+
return awaited ? unwrapPromise(awaited) : null;
|
|
651
|
+
}
|
|
652
|
+
case utils_1.AST_NODE_TYPES.ChainExpression:
|
|
653
|
+
case utils_1.AST_NODE_TYPES.TSNonNullExpression:
|
|
654
|
+
return typeNodeOfExpression(node.expression, visited);
|
|
655
|
+
// An assertion states the type outright, which is stronger evidence
|
|
656
|
+
// than anything the operand could supply.
|
|
657
|
+
case utils_1.AST_NODE_TYPES.TSAsExpression:
|
|
658
|
+
case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
|
|
659
|
+
return node.typeAnnotation;
|
|
660
|
+
case utils_1.AST_NODE_TYPES.Identifier:
|
|
661
|
+
return typeNodeOfIdentifier(node, visited);
|
|
662
|
+
case utils_1.AST_NODE_TYPES.CallExpression:
|
|
663
|
+
return typeNodeOfCallResult(node);
|
|
664
|
+
default:
|
|
665
|
+
// A member expression is deliberately absent: a property's type lives
|
|
666
|
+
// in the type of its object, which syntax alone does not supply.
|
|
667
|
+
return null;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
function typeNodeOfIdentifier(node, visited) {
|
|
671
|
+
const scope = ASTHelpers_1.ASTHelpers.getScope(context, node);
|
|
672
|
+
const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(scope, node.name);
|
|
673
|
+
if (!variable || variable.defs.length !== 1) {
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
const def = variable.defs[0];
|
|
677
|
+
if (def.type === 'Parameter') {
|
|
678
|
+
return def.name.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
679
|
+
def.name.typeAnnotation
|
|
680
|
+
? def.name.typeAnnotation.typeAnnotation
|
|
681
|
+
: null;
|
|
682
|
+
}
|
|
683
|
+
if (def.type !== 'Variable' ||
|
|
684
|
+
def.node.type !== utils_1.AST_NODE_TYPES.VariableDeclarator) {
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
const declarator = def.node;
|
|
688
|
+
// An annotation constrains every assignment rather than just the
|
|
689
|
+
// initializer, so it describes the binding even when it is a `let`.
|
|
690
|
+
if (declarator.id.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
691
|
+
declarator.id.typeAnnotation) {
|
|
692
|
+
return declarator.id.typeAnnotation.typeAnnotation;
|
|
693
|
+
}
|
|
694
|
+
// Without an annotation only an immutable binding still holds its
|
|
695
|
+
// initializer's type by the time the callback runs.
|
|
696
|
+
if (def.parent?.type !== utils_1.AST_NODE_TYPES.VariableDeclaration ||
|
|
697
|
+
def.parent.kind !== 'const') {
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
return typeNodeOfExpression(declarator.init, visited);
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* A receiver that is a call result takes its type from what the callee
|
|
704
|
+
* declares it returns; an inferred return type is not written down and so
|
|
705
|
+
* proves nothing.
|
|
706
|
+
*/
|
|
707
|
+
function typeNodeOfCallResult(node) {
|
|
708
|
+
if (node.callee.type !== utils_1.AST_NODE_TYPES.Identifier) {
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
const scope = ASTHelpers_1.ASTHelpers.getScope(context, node.callee);
|
|
712
|
+
const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(scope, node.callee.name);
|
|
713
|
+
if (!variable || variable.defs.length !== 1) {
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
const def = variable.defs[0];
|
|
717
|
+
// A hoisted declaration binds the helper the same way a `const` arrow
|
|
718
|
+
// does, so both spellings are read.
|
|
719
|
+
if (def.type === 'FunctionName') {
|
|
720
|
+
return def.node.returnType?.typeAnnotation ?? null;
|
|
721
|
+
}
|
|
722
|
+
if (def.type !== 'Variable' ||
|
|
723
|
+
def.node.type !== utils_1.AST_NODE_TYPES.VariableDeclarator ||
|
|
724
|
+
def.parent?.type !== utils_1.AST_NODE_TYPES.VariableDeclaration ||
|
|
725
|
+
def.parent.kind !== 'const') {
|
|
726
|
+
return null;
|
|
727
|
+
}
|
|
728
|
+
const init = def.node.init;
|
|
729
|
+
if (init?.type !== utils_1.AST_NODE_TYPES.ArrowFunctionExpression &&
|
|
730
|
+
init?.type !== utils_1.AST_NODE_TYPES.FunctionExpression) {
|
|
731
|
+
return null;
|
|
732
|
+
}
|
|
733
|
+
return init.returnType?.typeAnnotation ?? null;
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* The member names of the element type of the array whose method call this
|
|
737
|
+
* function is the callback of, e.g. `Unit` for `units.map(fn)` where
|
|
738
|
+
* `units` is annotated `Unit[]`.
|
|
739
|
+
*/
|
|
740
|
+
function contextualElementMemberNames(fn) {
|
|
741
|
+
const call = fn.parent;
|
|
742
|
+
if (!call ||
|
|
743
|
+
call.type !== utils_1.AST_NODE_TYPES.CallExpression ||
|
|
744
|
+
call.arguments[0] !== fn ||
|
|
745
|
+
call.callee.type !== utils_1.AST_NODE_TYPES.MemberExpression ||
|
|
746
|
+
call.callee.computed ||
|
|
747
|
+
call.callee.property.type !== utils_1.AST_NODE_TYPES.Identifier ||
|
|
748
|
+
!ELEMENT_CALLBACK_METHODS.has(call.callee.property.name)) {
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
const receiverType = typeNodeOfExpression(call.callee.object, new Set());
|
|
752
|
+
if (!receiverType) {
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
const elementType = arrayElementTypeOf(receiverType);
|
|
756
|
+
return elementType ? memberNamesOf(elementType, program) : null;
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Reports whether the destructured pick is provably a PROPER subset of the
|
|
760
|
+
* source object's own type, in which case spreading the parameter would add
|
|
761
|
+
* the members the author left out and change what the function produces
|
|
762
|
+
* (#1642: a GitHub review payload gained unknown keys).
|
|
763
|
+
*
|
|
764
|
+
* The proof runs in the safe direction only. A member set that matches the
|
|
765
|
+
* pick exactly is exhaustive, so the rewrite is behavior-preserving and the
|
|
766
|
+
* rule still reports; a type it cannot resolve — imported, generic, a union,
|
|
767
|
+
* an index signature — yields no proof and the rule likewise still reports.
|
|
768
|
+
* Silence is reserved for the case where the widening is demonstrated.
|
|
769
|
+
*/
|
|
770
|
+
function isProvablyNarrowingPick(fn, param, destructuredNames) {
|
|
771
|
+
// An explicit annotation overrides whatever the call site would imply,
|
|
772
|
+
// so the contextual route is consulted only in its absence.
|
|
773
|
+
const memberNames = param.typeAnnotation
|
|
774
|
+
? memberNamesOf(param.typeAnnotation.typeAnnotation, program)
|
|
775
|
+
: contextualElementMemberNames(fn);
|
|
776
|
+
if (!memberNames || memberNames.size <= destructuredNames.length) {
|
|
777
|
+
return false;
|
|
778
|
+
}
|
|
779
|
+
return destructuredNames.every((name) => memberNames.has(name));
|
|
780
|
+
}
|
|
495
781
|
function checkFunction(fn) {
|
|
496
782
|
// Must have exactly one parameter that is an ObjectPattern.
|
|
497
783
|
if (fn.params.length !== 1)
|
|
@@ -547,6 +833,11 @@ exports.preferSpreadOverReassembly = (0, createRule_1.createRule)({
|
|
|
547
833
|
return;
|
|
548
834
|
}
|
|
549
835
|
}
|
|
836
|
+
// The pick may exist precisely because the omitted members must not flow
|
|
837
|
+
// through; spreading would reinstate them.
|
|
838
|
+
if (isProvablyNarrowingPick(fn, param, destructuredNames)) {
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
550
841
|
context.report({
|
|
551
842
|
node: param,
|
|
552
843
|
messageId: 'preferSpread',
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.84",
|
|
4
|
+
"date": "2026-08-03T02:56:38.740Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "prefer-spread-over-reassembly",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1642
|
|
11
|
+
],
|
|
12
|
+
"summary": "stay silent on a provably narrowing pick (closes #1642)"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"version": "1.20.83",
|
|
4
18
|
"date": "2026-08-03T02:05:36.672Z",
|