@blumintinc/eslint-plugin-blumint 1.20.84 → 1.20.86
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 +371 -21
- package/package.json +1 -1
- package/release-manifest.json +28 -0
package/lib/index.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.preferSpreadOverReassembly = void 0;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
4
9
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
10
|
const createRule_1 = require("../utils/createRule");
|
|
6
11
|
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
@@ -83,6 +88,75 @@ const ELEMENT_CALLBACK_METHODS = new Set([
|
|
|
83
88
|
'flatMap',
|
|
84
89
|
]);
|
|
85
90
|
const ARRAY_TYPE_NAMES = new Set(['Array', 'ReadonlyArray']);
|
|
91
|
+
/**
|
|
92
|
+
* Type operators that are homomorphic over their argument: each one rewrites
|
|
93
|
+
* the modifiers of every member and leaves the key set identical, so a member
|
|
94
|
+
* list read through them describes the wrapped type exactly. `Readonly<{...}>`
|
|
95
|
+
* is the idiomatic spelling of a data record, and refusing to see through it
|
|
96
|
+
* makes the narrowing proof inert on the code it exists to protect (#1643).
|
|
97
|
+
*
|
|
98
|
+
* `Pick`, `Omit`, `Record`, `Exclude` and `Extract` are deliberately absent:
|
|
99
|
+
* they rewrite the key set, and a wrong proof silences a report the rule owes
|
|
100
|
+
* rather than merely failing to find one.
|
|
101
|
+
*/
|
|
102
|
+
const KEY_PRESERVING_TYPE_OPERATORS = new Set([
|
|
103
|
+
'Readonly',
|
|
104
|
+
'Required',
|
|
105
|
+
'Partial',
|
|
106
|
+
]);
|
|
107
|
+
/**
|
|
108
|
+
* Collects every name the file itself binds in a way that can stand in front of
|
|
109
|
+
* a type argument list — a type alias, an interface, a class, an enum, a
|
|
110
|
+
* namespace or an import. A file spelling `Readonly` as one of these is talking
|
|
111
|
+
* about its own declaration rather than the lib utility, so the key-preserving
|
|
112
|
+
* unwrap must not apply to it.
|
|
113
|
+
*
|
|
114
|
+
* The walk covers nested declarations too, since a `type Partial<T>` inside a
|
|
115
|
+
* function body shadows the global just as effectively as a top-level one.
|
|
116
|
+
*/
|
|
117
|
+
function collectLocallyBoundNames(node, names) {
|
|
118
|
+
switch (node.type) {
|
|
119
|
+
case utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration:
|
|
120
|
+
case utils_1.AST_NODE_TYPES.TSInterfaceDeclaration:
|
|
121
|
+
case utils_1.AST_NODE_TYPES.TSEnumDeclaration:
|
|
122
|
+
case utils_1.AST_NODE_TYPES.TSImportEqualsDeclaration:
|
|
123
|
+
names.add(node.id.name);
|
|
124
|
+
break;
|
|
125
|
+
case utils_1.AST_NODE_TYPES.ClassDeclaration:
|
|
126
|
+
case utils_1.AST_NODE_TYPES.ClassExpression:
|
|
127
|
+
if (node.id) {
|
|
128
|
+
names.add(node.id.name);
|
|
129
|
+
}
|
|
130
|
+
break;
|
|
131
|
+
case utils_1.AST_NODE_TYPES.TSModuleDeclaration:
|
|
132
|
+
if (node.id.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
133
|
+
names.add(node.id.name);
|
|
134
|
+
}
|
|
135
|
+
break;
|
|
136
|
+
case utils_1.AST_NODE_TYPES.ImportSpecifier:
|
|
137
|
+
case utils_1.AST_NODE_TYPES.ImportDefaultSpecifier:
|
|
138
|
+
case utils_1.AST_NODE_TYPES.ImportNamespaceSpecifier:
|
|
139
|
+
names.add(node.local.name);
|
|
140
|
+
break;
|
|
141
|
+
default:
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
for (const key of Object.keys(node)) {
|
|
145
|
+
if (key === 'parent')
|
|
146
|
+
continue;
|
|
147
|
+
const value = node[key];
|
|
148
|
+
if (Array.isArray(value)) {
|
|
149
|
+
for (const child of value) {
|
|
150
|
+
if (child && typeof child === 'object' && 'type' in child) {
|
|
151
|
+
collectLocallyBoundNames(child, names);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
else if (value && typeof value === 'object' && 'type' in value) {
|
|
156
|
+
collectLocallyBoundNames(value, names);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
86
160
|
/**
|
|
87
161
|
* Resolves `Promise<T>` to `T`, leaving anything else as it stands, which is
|
|
88
162
|
* what `await` does to the type of the expression it operates on.
|
|
@@ -147,12 +221,8 @@ function namesOfMembers(members) {
|
|
|
147
221
|
return names;
|
|
148
222
|
}
|
|
149
223
|
/**
|
|
150
|
-
* Finds a type alias or interface declared at the top level of
|
|
151
|
-
*
|
|
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.
|
|
224
|
+
* Finds a type alias or interface declared at the top level of a program,
|
|
225
|
+
* including one that is exported.
|
|
156
226
|
*/
|
|
157
227
|
function findLocalTypeDeclaration(program, name) {
|
|
158
228
|
for (const statement of program.body) {
|
|
@@ -168,42 +238,292 @@ function findLocalTypeDeclaration(program, name) {
|
|
|
168
238
|
}
|
|
169
239
|
return null;
|
|
170
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* Finds a type alias or interface a module exports by writing `export` in front
|
|
243
|
+
* of the declaration itself.
|
|
244
|
+
*
|
|
245
|
+
* Only that spelling qualifies. `export { X }` and `export { X } from './y'` are
|
|
246
|
+
* indistinguishable at the specifier — the second names a declaration in a third
|
|
247
|
+
* module — and `export * from './y'` names no declaration at all, so following
|
|
248
|
+
* either would be a guess rather than a proof.
|
|
249
|
+
*/
|
|
250
|
+
function findDirectlyExportedTypeDeclaration(program, name) {
|
|
251
|
+
for (const statement of program.body) {
|
|
252
|
+
if (statement.type !== utils_1.AST_NODE_TYPES.ExportNamedDeclaration ||
|
|
253
|
+
!statement.declaration) {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const declaration = statement.declaration;
|
|
257
|
+
if ((declaration.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration ||
|
|
258
|
+
declaration.type === utils_1.AST_NODE_TYPES.TSInterfaceDeclaration) &&
|
|
259
|
+
declaration.id.name === name) {
|
|
260
|
+
return declaration;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
const RELATIVE_SOURCE = /^\.\.?\//;
|
|
266
|
+
/**
|
|
267
|
+
* A type declaration overwhelmingly lives in a `.ts` sibling, so that extension
|
|
268
|
+
* is tried first; the rest follow a bundler's own order.
|
|
269
|
+
*/
|
|
270
|
+
const MODULE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx'];
|
|
271
|
+
/**
|
|
272
|
+
* The resolution cache stores which path a specifier picks, never the file's
|
|
273
|
+
* contents: a resolution *miss* is safe to keep because a module created later
|
|
274
|
+
* stays unresolved, which reports. The member cache instead carries the resolved
|
|
275
|
+
* file's stamp as its VALUE, so a sibling edited under a long-lived host (the VS
|
|
276
|
+
* Code ESLint extension, eslint_d) is re-read rather than answered from a stale
|
|
277
|
+
* member list — a stale list is what would silence a report the rule owes. The
|
|
278
|
+
* stamp lives in the value so an edited file replaces its entry instead of
|
|
279
|
+
* accumulating one per revision.
|
|
280
|
+
*/
|
|
281
|
+
const moduleResolutionCache = new Map();
|
|
282
|
+
const importedMembersCache = new Map();
|
|
283
|
+
/**
|
|
284
|
+
* Stat a candidate path, returning its identity stamp when it is a file. The
|
|
285
|
+
* single stat both resolves existence and stamps the file, so reading a sibling
|
|
286
|
+
* never pays for two.
|
|
287
|
+
*/
|
|
288
|
+
function statModule(candidate) {
|
|
289
|
+
try {
|
|
290
|
+
const stats = fs_1.default.statSync(candidate);
|
|
291
|
+
return stats.isFile()
|
|
292
|
+
? { filePath: candidate, mtimeMs: stats.mtimeMs, size: stats.size }
|
|
293
|
+
: null;
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Resolve a relative import specifier the way a bundler would: sibling file
|
|
301
|
+
* first, then the directory's index file. Package specifiers never reach here —
|
|
302
|
+
* a bare specifier names a module whose location depends on resolution settings
|
|
303
|
+
* this rule does not read, so it proves nothing.
|
|
304
|
+
*/
|
|
305
|
+
function resolveRelativeModule(fromDir, source) {
|
|
306
|
+
const cacheKey = JSON.stringify([fromDir, source]);
|
|
307
|
+
const cached = moduleResolutionCache.get(cacheKey);
|
|
308
|
+
if (cached !== undefined) {
|
|
309
|
+
return cached === null ? null : statModule(cached);
|
|
310
|
+
}
|
|
311
|
+
const base = path_1.default.resolve(fromDir, source);
|
|
312
|
+
let resolved = null;
|
|
313
|
+
for (const extension of MODULE_EXTENSIONS) {
|
|
314
|
+
resolved = statModule(`${base}${extension}`);
|
|
315
|
+
if (resolved) {
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (!resolved) {
|
|
320
|
+
for (const extension of MODULE_EXTENSIONS) {
|
|
321
|
+
resolved = statModule(path_1.default.join(base, `index${extension}`));
|
|
322
|
+
if (resolved) {
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
moduleResolutionCache.set(cacheKey, resolved?.filePath ?? null);
|
|
328
|
+
return resolved;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* `undefined` means the parser has not been looked up yet; `null` means the
|
|
332
|
+
* lookup failed and must not be retried.
|
|
333
|
+
*/
|
|
334
|
+
let foreignParse;
|
|
335
|
+
/**
|
|
336
|
+
* The sibling module is read with the parser that backs
|
|
337
|
+
* `@typescript-eslint/utils` itself, loaded lazily and memoized (failure
|
|
338
|
+
* included) so the resolution cost is paid once per process. An install that
|
|
339
|
+
* somehow lacks the parser degrades to "proves nothing", which keeps the rule
|
|
340
|
+
* reporting rather than throwing.
|
|
341
|
+
*/
|
|
342
|
+
function getForeignParse() {
|
|
343
|
+
if (foreignParse === undefined) {
|
|
344
|
+
try {
|
|
345
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
346
|
+
const estree = require('@typescript-eslint/typescript-estree');
|
|
347
|
+
foreignParse = estree?.parse ?? null;
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
foreignParse = null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return foreignParse;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Parse a sibling module into an AST. Only a real parse can decide what a type
|
|
357
|
+
* declares: text that merely looks like a declaration — inside a string, a
|
|
358
|
+
* template literal, a comment or a nested scope — must never stand in for it. A
|
|
359
|
+
* file that cannot be read or parsed proves nothing.
|
|
360
|
+
*/
|
|
361
|
+
function parseModuleProgram(filePath) {
|
|
362
|
+
const parse = getForeignParse();
|
|
363
|
+
if (!parse) {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
try {
|
|
367
|
+
return parse(fs_1.default.readFileSync(filePath, 'utf8'), {
|
|
368
|
+
jsx: true,
|
|
369
|
+
loc: false,
|
|
370
|
+
range: false,
|
|
371
|
+
comment: false,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Locate the relative import that introduces `localName` as a type.
|
|
380
|
+
*
|
|
381
|
+
* Only a named specifier qualifies, in either the `import type { X }` or the
|
|
382
|
+
* `import { X }` spelling. A namespace import is referenced as `Ns.X`, a
|
|
383
|
+
* qualified name this enumerator does not read, and a default-exported type has
|
|
384
|
+
* no name to look up in the sibling.
|
|
385
|
+
*/
|
|
386
|
+
function findRelativeTypeImport(program, localName) {
|
|
387
|
+
for (const statement of program.body) {
|
|
388
|
+
if (statement.type !== utils_1.AST_NODE_TYPES.ImportDeclaration ||
|
|
389
|
+
typeof statement.source.value !== 'string' ||
|
|
390
|
+
!RELATIVE_SOURCE.test(statement.source.value)) {
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
for (const specifier of statement.specifiers) {
|
|
394
|
+
if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
395
|
+
specifier.local.name === localName) {
|
|
396
|
+
return {
|
|
397
|
+
source: statement.source.value,
|
|
398
|
+
exported: specifier.imported.name,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
171
405
|
/**
|
|
172
406
|
* Enumerates every property name a type node declares, or null when the member
|
|
173
407
|
* list cannot be established with certainty.
|
|
174
408
|
*
|
|
175
|
-
* Only an unambiguous, fully written-out member list qualifies
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
409
|
+
* Only an unambiguous, fully written-out member list qualifies, reached either
|
|
410
|
+
* directly or through the key-preserving operators in
|
|
411
|
+
* {@link KEY_PRESERVING_TYPE_OPERATORS}. A union, an intersection, a mapped or
|
|
412
|
+
* conditional type, any other generic instantiation and an interface with an
|
|
413
|
+
* `extends` clause all describe a member set assembled elsewhere, so none of
|
|
414
|
+
* them can prove anything here.
|
|
415
|
+
*
|
|
416
|
+
* A name the file does not declare is looked up in the relative module that
|
|
417
|
+
* imports it, once; see {@link importedTypeMemberNames}.
|
|
179
418
|
*/
|
|
180
|
-
function memberNamesOf(typeNode,
|
|
419
|
+
function memberNamesOf(typeNode, scope, seen = new Set()) {
|
|
181
420
|
if (typeNode.type === utils_1.AST_NODE_TYPES.TSTypeLiteral) {
|
|
182
421
|
return namesOfMembers(typeNode.members);
|
|
183
422
|
}
|
|
184
423
|
if (typeNode.type !== utils_1.AST_NODE_TYPES.TSTypeReference ||
|
|
185
|
-
typeNode.typeName.type !== utils_1.AST_NODE_TYPES.Identifier
|
|
186
|
-
typeNode.typeParameters) {
|
|
424
|
+
typeNode.typeName.type !== utils_1.AST_NODE_TYPES.Identifier) {
|
|
187
425
|
return null;
|
|
188
426
|
}
|
|
189
427
|
const name = typeNode.typeName.name;
|
|
428
|
+
if (typeNode.typeParameters) {
|
|
429
|
+
// An arity other than one is not the lib utility this name spells, so
|
|
430
|
+
// nothing about the wrapped member list follows from it.
|
|
431
|
+
if (!KEY_PRESERVING_TYPE_OPERATORS.has(name) ||
|
|
432
|
+
typeNode.typeParameters.params.length !== 1 ||
|
|
433
|
+
scope.isLocallyBound(name)) {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
return memberNamesOf(typeNode.typeParameters.params[0], scope, seen);
|
|
437
|
+
}
|
|
190
438
|
// A self-referential alias (`type T = T`) would otherwise recur forever.
|
|
191
439
|
if (seen.has(name)) {
|
|
192
440
|
return null;
|
|
193
441
|
}
|
|
194
442
|
seen.add(name);
|
|
195
|
-
const declaration = findLocalTypeDeclaration(program, name);
|
|
196
|
-
if (!declaration
|
|
443
|
+
const declaration = findLocalTypeDeclaration(scope.program, name);
|
|
444
|
+
if (!declaration) {
|
|
445
|
+
// The file does not declare the name, so the only remaining source of a
|
|
446
|
+
// written-down member list is the module it comes from.
|
|
447
|
+
return scope.resolveImportedMembers?.(name) ?? null;
|
|
448
|
+
}
|
|
449
|
+
return memberNamesOfDeclaration(declaration, scope, seen);
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* The member names a type alias or interface declares. A generic declaration
|
|
453
|
+
* describes a different member set per instantiation and an interface with an
|
|
454
|
+
* `extends` clause inherits members written elsewhere, so neither enumerates.
|
|
455
|
+
*/
|
|
456
|
+
function memberNamesOfDeclaration(declaration, scope, seen) {
|
|
457
|
+
if (declaration.typeParameters) {
|
|
197
458
|
return null;
|
|
198
459
|
}
|
|
199
460
|
if (declaration.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration) {
|
|
200
|
-
return memberNamesOf(declaration.typeAnnotation,
|
|
461
|
+
return memberNamesOf(declaration.typeAnnotation, scope, seen);
|
|
201
462
|
}
|
|
202
463
|
if (declaration.extends && declaration.extends.length > 0) {
|
|
203
464
|
return null;
|
|
204
465
|
}
|
|
205
466
|
return namesOfMembers(declaration.body.body);
|
|
206
467
|
}
|
|
468
|
+
/**
|
|
469
|
+
* The member names of a type the file under lint imports from a relative
|
|
470
|
+
* sibling, or null when the sibling proves nothing.
|
|
471
|
+
*
|
|
472
|
+
* Organising a module's types in a neighbouring `types.ts` is the ordinary
|
|
473
|
+
* layout, so a proof that stops at the file boundary misses the common case
|
|
474
|
+
* rather than the edge case (#1644). The hop is a single one: the sibling's own
|
|
475
|
+
* scope carries no `resolveImportedMembers`, so a type it in turn imports, a
|
|
476
|
+
* re-export and a barrel all stop the walk and leave the pick unproven, which
|
|
477
|
+
* reports. Within the sibling, resolution is the same enumerator the file under
|
|
478
|
+
* lint gets — alias chains, `Readonly`/`Required`/`Partial` unwrapping and the
|
|
479
|
+
* shadowing guard all behave identically there.
|
|
480
|
+
*/
|
|
481
|
+
function importedTypeMemberNames(program, fromDir, localName) {
|
|
482
|
+
const typeImport = findRelativeTypeImport(program, localName);
|
|
483
|
+
if (!typeImport) {
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
const resolved = resolveRelativeModule(fromDir, typeImport.source);
|
|
487
|
+
if (!resolved) {
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
const cacheKey = JSON.stringify([resolved.filePath, typeImport.exported]);
|
|
491
|
+
const cached = importedMembersCache.get(cacheKey);
|
|
492
|
+
if (cached &&
|
|
493
|
+
cached.mtimeMs === resolved.mtimeMs &&
|
|
494
|
+
cached.size === resolved.size) {
|
|
495
|
+
return cached.members;
|
|
496
|
+
}
|
|
497
|
+
const members = readExportedTypeMembers(resolved.filePath, typeImport.exported);
|
|
498
|
+
importedMembersCache.set(cacheKey, {
|
|
499
|
+
mtimeMs: resolved.mtimeMs,
|
|
500
|
+
size: resolved.size,
|
|
501
|
+
members,
|
|
502
|
+
});
|
|
503
|
+
return members;
|
|
504
|
+
}
|
|
505
|
+
function readExportedTypeMembers(filePath, exported) {
|
|
506
|
+
const siblingProgram = parseModuleProgram(filePath);
|
|
507
|
+
if (!siblingProgram) {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
const declaration = findDirectlyExportedTypeDeclaration(siblingProgram, exported);
|
|
511
|
+
if (!declaration) {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
let siblingBoundNames = null;
|
|
515
|
+
const siblingScope = {
|
|
516
|
+
program: siblingProgram,
|
|
517
|
+
isLocallyBound: (name) => {
|
|
518
|
+
if (!siblingBoundNames) {
|
|
519
|
+
siblingBoundNames = new Set();
|
|
520
|
+
collectLocallyBoundNames(siblingProgram, siblingBoundNames);
|
|
521
|
+
}
|
|
522
|
+
return siblingBoundNames.has(name);
|
|
523
|
+
},
|
|
524
|
+
};
|
|
525
|
+
return memberNamesOfDeclaration(declaration, siblingScope, new Set([exported]));
|
|
526
|
+
}
|
|
207
527
|
/**
|
|
208
528
|
* For a JSX element, returns the set of destructured names that are forwarded
|
|
209
529
|
* with identical key names (e.g. `hits={hits}`, `isLoading={isLoading}`).
|
|
@@ -627,6 +947,34 @@ exports.preferSpreadOverReassembly = (0, createRule_1.createRule)({
|
|
|
627
947
|
const minFields = options?.minFields ?? DEFAULT_MIN_FIELDS;
|
|
628
948
|
const sourceCode = context.getSourceCode();
|
|
629
949
|
const program = sourceCode.ast;
|
|
950
|
+
// A relative specifier is anchored at the directory ESLint was configured
|
|
951
|
+
// with, never the node process cwd. The two differ under the VS Code ESLint
|
|
952
|
+
// extension, in monorepos and for any programmatic `new Linter({ cwd })`,
|
|
953
|
+
// and anchoring at the process cwd there reads the wrong directory — which
|
|
954
|
+
// in this rule's safe direction merely loses the proof, but loses it exactly
|
|
955
|
+
// where the sibling `types.ts` layout is most common (issue #1476).
|
|
956
|
+
const cwd = typeof context.getCwd === 'function' ? context.getCwd() : process.cwd();
|
|
957
|
+
const rawFilename = context.getFilename();
|
|
958
|
+
const containingDir = path_1.default.dirname(path_1.default.isAbsolute(rawFilename)
|
|
959
|
+
? rawFilename
|
|
960
|
+
: path_1.default.resolve(cwd, rawFilename));
|
|
961
|
+
// The scan walks the whole file, so it is deferred until a key-preserving
|
|
962
|
+
// operator actually turns up in a position the proof depends on — most
|
|
963
|
+
// files never reach it.
|
|
964
|
+
let locallyBoundNames = null;
|
|
965
|
+
const typeScope = {
|
|
966
|
+
program,
|
|
967
|
+
isLocallyBound: (name) => {
|
|
968
|
+
if (!locallyBoundNames) {
|
|
969
|
+
locallyBoundNames = new Set();
|
|
970
|
+
collectLocallyBoundNames(program, locallyBoundNames);
|
|
971
|
+
}
|
|
972
|
+
return locallyBoundNames.has(name);
|
|
973
|
+
},
|
|
974
|
+
// Reached only from the about-to-report path, so a file with nothing to
|
|
975
|
+
// report never stats or reads a sibling.
|
|
976
|
+
resolveImportedMembers: (name) => importedTypeMemberNames(program, containingDir, name),
|
|
977
|
+
};
|
|
630
978
|
/**
|
|
631
979
|
* Walks an expression toward its syntactic root and returns the type node
|
|
632
980
|
* that root declares, mirroring the receiver trace in
|
|
@@ -753,7 +1101,7 @@ exports.preferSpreadOverReassembly = (0, createRule_1.createRule)({
|
|
|
753
1101
|
return null;
|
|
754
1102
|
}
|
|
755
1103
|
const elementType = arrayElementTypeOf(receiverType);
|
|
756
|
-
return elementType ? memberNamesOf(elementType,
|
|
1104
|
+
return elementType ? memberNamesOf(elementType, typeScope) : null;
|
|
757
1105
|
}
|
|
758
1106
|
/**
|
|
759
1107
|
* Reports whether the destructured pick is provably a PROPER subset of the
|
|
@@ -763,15 +1111,17 @@ exports.preferSpreadOverReassembly = (0, createRule_1.createRule)({
|
|
|
763
1111
|
*
|
|
764
1112
|
* The proof runs in the safe direction only. A member set that matches the
|
|
765
1113
|
* pick exactly is exhaustive, so the rewrite is behavior-preserving and the
|
|
766
|
-
* rule still reports; a type it cannot resolve —
|
|
767
|
-
*
|
|
768
|
-
*
|
|
1114
|
+
* rule still reports; a type it cannot resolve — a union, an index
|
|
1115
|
+
* signature, an instantiation of anything but a key-preserving operator, or
|
|
1116
|
+
* an import whose module it declines to follow — yields no proof and the
|
|
1117
|
+
* rule likewise still reports. Silence is reserved for the case where the
|
|
1118
|
+
* widening is demonstrated.
|
|
769
1119
|
*/
|
|
770
1120
|
function isProvablyNarrowingPick(fn, param, destructuredNames) {
|
|
771
1121
|
// An explicit annotation overrides whatever the call site would imply,
|
|
772
1122
|
// so the contextual route is consulted only in its absence.
|
|
773
1123
|
const memberNames = param.typeAnnotation
|
|
774
|
-
? memberNamesOf(param.typeAnnotation.typeAnnotation,
|
|
1124
|
+
? memberNamesOf(param.typeAnnotation.typeAnnotation, typeScope)
|
|
775
1125
|
: contextualElementMemberNames(fn);
|
|
776
1126
|
if (!memberNames || memberNames.size <= destructuredNames.length) {
|
|
777
1127
|
return false;
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,32 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.86",
|
|
4
|
+
"date": "2026-08-03T03:52:41.277Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "prefer-spread-over-reassembly",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1644
|
|
11
|
+
],
|
|
12
|
+
"summary": "prove narrowing through a relative import (closes #1644)"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "1.20.85",
|
|
18
|
+
"date": "2026-08-03T03:21:29.711Z",
|
|
19
|
+
"rules": [
|
|
20
|
+
{
|
|
21
|
+
"name": "prefer-spread-over-reassembly",
|
|
22
|
+
"changeType": "fix",
|
|
23
|
+
"issues": [
|
|
24
|
+
1643
|
|
25
|
+
],
|
|
26
|
+
"summary": "read the narrowing proof through Readonly (closes #1643)"
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
},
|
|
2
30
|
{
|
|
3
31
|
"version": "1.20.84",
|
|
4
32
|
"date": "2026-08-03T02:56:38.740Z",
|