@blumintinc/eslint-plugin-blumint 1.20.84 → 1.20.85

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.84',
226
+ version: '1.20.85',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -83,6 +83,75 @@ const ELEMENT_CALLBACK_METHODS = new Set([
83
83
  'flatMap',
84
84
  ]);
85
85
  const ARRAY_TYPE_NAMES = new Set(['Array', 'ReadonlyArray']);
86
+ /**
87
+ * Type operators that are homomorphic over their argument: each one rewrites
88
+ * the modifiers of every member and leaves the key set identical, so a member
89
+ * list read through them describes the wrapped type exactly. `Readonly<{...}>`
90
+ * is the idiomatic spelling of a data record, and refusing to see through it
91
+ * makes the narrowing proof inert on the code it exists to protect (#1643).
92
+ *
93
+ * `Pick`, `Omit`, `Record`, `Exclude` and `Extract` are deliberately absent:
94
+ * they rewrite the key set, and a wrong proof silences a report the rule owes
95
+ * rather than merely failing to find one.
96
+ */
97
+ const KEY_PRESERVING_TYPE_OPERATORS = new Set([
98
+ 'Readonly',
99
+ 'Required',
100
+ 'Partial',
101
+ ]);
102
+ /**
103
+ * Collects every name the file itself binds in a way that can stand in front of
104
+ * a type argument list — a type alias, an interface, a class, an enum, a
105
+ * namespace or an import. A file spelling `Readonly` as one of these is talking
106
+ * about its own declaration rather than the lib utility, so the key-preserving
107
+ * unwrap must not apply to it.
108
+ *
109
+ * The walk covers nested declarations too, since a `type Partial<T>` inside a
110
+ * function body shadows the global just as effectively as a top-level one.
111
+ */
112
+ function collectLocallyBoundNames(node, names) {
113
+ switch (node.type) {
114
+ case utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration:
115
+ case utils_1.AST_NODE_TYPES.TSInterfaceDeclaration:
116
+ case utils_1.AST_NODE_TYPES.TSEnumDeclaration:
117
+ case utils_1.AST_NODE_TYPES.TSImportEqualsDeclaration:
118
+ names.add(node.id.name);
119
+ break;
120
+ case utils_1.AST_NODE_TYPES.ClassDeclaration:
121
+ case utils_1.AST_NODE_TYPES.ClassExpression:
122
+ if (node.id) {
123
+ names.add(node.id.name);
124
+ }
125
+ break;
126
+ case utils_1.AST_NODE_TYPES.TSModuleDeclaration:
127
+ if (node.id.type === utils_1.AST_NODE_TYPES.Identifier) {
128
+ names.add(node.id.name);
129
+ }
130
+ break;
131
+ case utils_1.AST_NODE_TYPES.ImportSpecifier:
132
+ case utils_1.AST_NODE_TYPES.ImportDefaultSpecifier:
133
+ case utils_1.AST_NODE_TYPES.ImportNamespaceSpecifier:
134
+ names.add(node.local.name);
135
+ break;
136
+ default:
137
+ break;
138
+ }
139
+ for (const key of Object.keys(node)) {
140
+ if (key === 'parent')
141
+ continue;
142
+ const value = node[key];
143
+ if (Array.isArray(value)) {
144
+ for (const child of value) {
145
+ if (child && typeof child === 'object' && 'type' in child) {
146
+ collectLocallyBoundNames(child, names);
147
+ }
148
+ }
149
+ }
150
+ else if (value && typeof value === 'object' && 'type' in value) {
151
+ collectLocallyBoundNames(value, names);
152
+ }
153
+ }
154
+ }
86
155
  /**
87
156
  * Resolves `Promise<T>` to `T`, leaving anything else as it stands, which is
88
157
  * what `await` does to the type of the expression it operates on.
@@ -172,32 +241,43 @@ function findLocalTypeDeclaration(program, name) {
172
241
  * Enumerates every property name a type node declares, or null when the member
173
242
  * list cannot be established with certainty.
174
243
  *
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.
244
+ * Only an unambiguous, fully written-out member list qualifies, reached either
245
+ * directly or through the key-preserving operators in
246
+ * {@link KEY_PRESERVING_TYPE_OPERATORS}. A union, an intersection, a mapped or
247
+ * conditional type, any other generic instantiation and an interface with an
248
+ * `extends` clause all describe a member set assembled elsewhere, so none of
249
+ * them can prove anything here.
179
250
  */
180
- function memberNamesOf(typeNode, program, seen = new Set()) {
251
+ function memberNamesOf(typeNode, scope, seen = new Set()) {
181
252
  if (typeNode.type === utils_1.AST_NODE_TYPES.TSTypeLiteral) {
182
253
  return namesOfMembers(typeNode.members);
183
254
  }
184
255
  if (typeNode.type !== utils_1.AST_NODE_TYPES.TSTypeReference ||
185
- typeNode.typeName.type !== utils_1.AST_NODE_TYPES.Identifier ||
186
- typeNode.typeParameters) {
256
+ typeNode.typeName.type !== utils_1.AST_NODE_TYPES.Identifier) {
187
257
  return null;
188
258
  }
189
259
  const name = typeNode.typeName.name;
260
+ if (typeNode.typeParameters) {
261
+ // An arity other than one is not the lib utility this name spells, so
262
+ // nothing about the wrapped member list follows from it.
263
+ if (!KEY_PRESERVING_TYPE_OPERATORS.has(name) ||
264
+ typeNode.typeParameters.params.length !== 1 ||
265
+ scope.isLocallyBound(name)) {
266
+ return null;
267
+ }
268
+ return memberNamesOf(typeNode.typeParameters.params[0], scope, seen);
269
+ }
190
270
  // A self-referential alias (`type T = T`) would otherwise recur forever.
191
271
  if (seen.has(name)) {
192
272
  return null;
193
273
  }
194
274
  seen.add(name);
195
- const declaration = findLocalTypeDeclaration(program, name);
275
+ const declaration = findLocalTypeDeclaration(scope.program, name);
196
276
  if (!declaration || declaration.typeParameters) {
197
277
  return null;
198
278
  }
199
279
  if (declaration.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration) {
200
- return memberNamesOf(declaration.typeAnnotation, program, seen);
280
+ return memberNamesOf(declaration.typeAnnotation, scope, seen);
201
281
  }
202
282
  if (declaration.extends && declaration.extends.length > 0) {
203
283
  return null;
@@ -627,6 +707,20 @@ exports.preferSpreadOverReassembly = (0, createRule_1.createRule)({
627
707
  const minFields = options?.minFields ?? DEFAULT_MIN_FIELDS;
628
708
  const sourceCode = context.getSourceCode();
629
709
  const program = sourceCode.ast;
710
+ // The scan walks the whole file, so it is deferred until a key-preserving
711
+ // operator actually turns up in a position the proof depends on — most
712
+ // files never reach it.
713
+ let locallyBoundNames = null;
714
+ const typeScope = {
715
+ program,
716
+ isLocallyBound: (name) => {
717
+ if (!locallyBoundNames) {
718
+ locallyBoundNames = new Set();
719
+ collectLocallyBoundNames(program, locallyBoundNames);
720
+ }
721
+ return locallyBoundNames.has(name);
722
+ },
723
+ };
630
724
  /**
631
725
  * Walks an expression toward its syntactic root and returns the type node
632
726
  * that root declares, mirroring the receiver trace in
@@ -753,7 +847,7 @@ exports.preferSpreadOverReassembly = (0, createRule_1.createRule)({
753
847
  return null;
754
848
  }
755
849
  const elementType = arrayElementTypeOf(receiverType);
756
- return elementType ? memberNamesOf(elementType, program) : null;
850
+ return elementType ? memberNamesOf(elementType, typeScope) : null;
757
851
  }
758
852
  /**
759
853
  * Reports whether the destructured pick is provably a PROPER subset of the
@@ -763,15 +857,16 @@ exports.preferSpreadOverReassembly = (0, createRule_1.createRule)({
763
857
  *
764
858
  * The proof runs in the safe direction only. A member set that matches the
765
859
  * 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.
860
+ * rule still reports; a type it cannot resolve — imported, a union, an
861
+ * index signature, or an instantiation of anything but a key-preserving
862
+ * operator yields no proof and the rule likewise still reports. Silence
863
+ * is reserved for the case where the widening is demonstrated.
769
864
  */
770
865
  function isProvablyNarrowingPick(fn, param, destructuredNames) {
771
866
  // An explicit annotation overrides whatever the call site would imply,
772
867
  // so the contextual route is consulted only in its absence.
773
868
  const memberNames = param.typeAnnotation
774
- ? memberNamesOf(param.typeAnnotation.typeAnnotation, program)
869
+ ? memberNamesOf(param.typeAnnotation.typeAnnotation, typeScope)
775
870
  : contextualElementMemberNames(fn);
776
871
  if (!memberNames || memberNames.size <= destructuredNames.length) {
777
872
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.84",
3
+ "version": "1.20.85",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "version": "1.20.85",
4
+ "date": "2026-08-03T03:21:29.711Z",
5
+ "rules": [
6
+ {
7
+ "name": "prefer-spread-over-reassembly",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1643
11
+ ],
12
+ "summary": "read the narrowing proof through Readonly (closes #1643)"
13
+ }
14
+ ]
15
+ },
2
16
  {
3
17
  "version": "1.20.84",
4
18
  "date": "2026-08-03T02:56:38.740Z",