find-primordials 1.0.0

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/analyzer.mjs ADDED
@@ -0,0 +1,2209 @@
1
+ /* eslint max-lines: 'off' */
2
+
3
+ import { parse } from '@typescript-eslint/parser';
4
+ import fs from 'fs';
5
+ import os from 'os';
6
+ import path from 'path';
7
+ import ts from 'typescript';
8
+ import { fileURLToPath } from 'url';
9
+ import { Worker } from 'worker_threads';
10
+
11
+ import traverse from 'traverse';
12
+
13
+ import {
14
+ allGlobals,
15
+ allInstanceMethods,
16
+ ambiguousInstanceMethods,
17
+ globalToCategory,
18
+ primordials,
19
+ } from '#/primordials';
20
+
21
+ /**
22
+ * @import {
23
+ * Node as TSNode,
24
+ * Program,
25
+ * SourceFile,
26
+ * TypeChecker,
27
+ * } from 'typescript'
28
+ */
29
+
30
+ /**
31
+ * A source position, as ESTree records it.
32
+ * @typedef {object} SourceLocation
33
+ * @property {{ line: number, column: number }} start
34
+ * @property {{ line: number, column: number }} end
35
+ */
36
+
37
+ /**
38
+ * A parsed ESTree/TSESTree node. The analyzer walks across many node kinds and reads
39
+ * whichever properties the kind at hand carries. Rather than narrow to a discriminated
40
+ * union at every access, this is one permissive shape whose fields are treated as always
41
+ * present - the analyzer only reads a field after establishing the node's `type` - and
42
+ * whose leaves that mean different things by kind (`value`, `body`) are left loose.
43
+ * A node that may be absent is typed {@link MaybeNode} instead.
44
+ * @typedef {object} ASTNode
45
+ * @property {string} type
46
+ * @property {string} name
47
+ * @property {string} operator
48
+ * @property {string} kind
49
+ * @property {boolean} computed
50
+ * @property {boolean} shorthand
51
+ * @property {ASTNode} object
52
+ * @property {ASTNode} property
53
+ * @property {ASTNode} callee
54
+ * @property {ASTNode} left
55
+ * @property {ASTNode} right
56
+ * @property {ASTNode} test
57
+ * @property {ASTNode} consequent
58
+ * @property {ASTNode} alternate
59
+ * @property {ASTNode} argument
60
+ * @property {ASTNode} id
61
+ * @property {ASTNode} init
62
+ * @property {ASTNode} key
63
+ * @property {ASTNode} local
64
+ * @property {ASTNode} param
65
+ * @property {ASTNode[]} arguments
66
+ * @property {ASTNode[]} properties
67
+ * @property {ASTNode[]} params
68
+ * @property {ASTNode[]} expressions
69
+ * @property {ASTNode[]} declarations
70
+ * @property {ASTNode[]} specifiers
71
+ * @property {(ASTNode | null)[]} elements
72
+ * @property {ASTNode[]} comments
73
+ * @property {unknown} value
74
+ * @property {unknown} body
75
+ * @property {[number, number]} range
76
+ * @property {SourceLocation} loc
77
+ */
78
+
79
+ /** @typedef {ASTNode | null | undefined} MaybeNode */
80
+
81
+ /**
82
+ * A node as `traverse` exposes it on the callback `this`: the AST node and its parent link.
83
+ * @typedef {{ node: ASTNode, parent?: TraverseNode }} TraverseNode
84
+ */
85
+
86
+ /** @typedef {'at' | 'constructor' | 'isNaN' | 'push' | 'undefined'} FixKind */
87
+
88
+ /**
89
+ * A primordial usage the analyzer reports.
90
+ * @typedef {object} Finding
91
+ * @property {string} type
92
+ * @property {string} name
93
+ * @property {string} certainty
94
+ * @property {string} file
95
+ * @property {number} [line]
96
+ * @property {number} [column]
97
+ * @property {string | null} [category]
98
+ * @property {string[]} [possibleCategories]
99
+ */
100
+
101
+ /**
102
+ * A single source rewrite, as a half-open `[start, end)` range and its replacement.
103
+ * @typedef {object} Fix
104
+ * @property {number} start
105
+ * @property {number} end
106
+ * @property {FixKind} kind
107
+ * @property {string} replacement
108
+ */
109
+
110
+ /**
111
+ * The result of rewriting a file.
112
+ * @typedef {object} FixResult
113
+ * @property {boolean} fixed
114
+ * @property {string} output
115
+ * @property {number} fixCount
116
+ * @property {Record<FixKind, number>} fixCounts
117
+ */
118
+
119
+ /**
120
+ * A run of lines with an ESLint rule disabled. `end` is `Infinity` while the range is
121
+ * still open, and `rules` is null when every rule is disabled.
122
+ * @typedef {object} DisableRange
123
+ * @property {number} start
124
+ * @property {number} end
125
+ * @property {Set<string> | null} rules
126
+ */
127
+
128
+ /**
129
+ * The disable directives parsed out of a file's comments.
130
+ * @typedef {object} DisableState
131
+ * @property {Map<number, Set<string> | null>} disabledLines
132
+ * @property {DisableRange[]} disabledRanges
133
+ */
134
+
135
+ /**
136
+ * Options accepted by {@link analyzeFile} and the batch analyzers.
137
+ * @typedef {object} AnalyzeOptions
138
+ * @property {boolean} [includeGlobals]
139
+ * @property {boolean} [includeSpread]
140
+ * @property {boolean} [includeStatic]
141
+ * @property {boolean} [includeUncertain]
142
+ * @property {boolean} [isSafe]
143
+ * @property {ParserServices | null} [parserServices]
144
+ * @property {((filePath: string) => boolean) | null} [isSafeFile]
145
+ * @property {number} [concurrency]
146
+ * @property {string} [workerPath] - Overrides the worker module path (test seam)
147
+ * @property {(filePath: string, sourceCode: string) => { sourceFile: (import('typescript').SourceFile | undefined), typeChecker: import('typescript').TypeChecker }} [typeProgramFactory] - Overrides standalone TypeScript program creation (test seam)
148
+ */
149
+
150
+ /** @typedef {{ error: string, file: string }} AnalysisError */
151
+
152
+ /** @typedef {{ errors: AnalysisError[], findings: Finding[] }} AnalysisResult */
153
+
154
+ const CERTAINTY_CERTAIN = 'certain';
155
+ const CERTAINTY_UNCERTAIN = 'uncertain';
156
+
157
+ const CALL_APPLY_BIND = /** @type {Set<string>} */ (new Set([
158
+ 'call',
159
+ 'apply',
160
+ 'bind',
161
+ ]));
162
+
163
+ /**
164
+ * Whether a member expression is being invoked, rather than merely read.
165
+ * `arr.at(0)` and `arr.at.call(arr, 0)` both reach the method; `row.at` on its own
166
+ * only reads whatever `at` happens to be.
167
+ * @param {ASTNode} node - The MemberExpression
168
+ * @param {MaybeNode} parent - Its parent
169
+ * @returns {boolean}
170
+ */
171
+ export function isCalled(node, parent) {
172
+ if (parent?.type === 'CallExpression' || parent?.type === 'NewExpression') {
173
+ return parent.callee === node;
174
+ }
175
+ return parent?.type === 'MemberExpression'
176
+ && parent.object === node
177
+ && !parent.computed
178
+ && CALL_APPLY_BIND.has(parent.property?.name);
179
+ }
180
+
181
+ /**
182
+ * Whether a node sits in module-level scope, outside any function.
183
+ * @param {ASTNode[]} ancestors - The node's ancestors, nearest last
184
+ * @returns {boolean}
185
+ */
186
+ function isModuleLevelScope(ancestors) {
187
+ for (const ancestor of ancestors) {
188
+ const { type } = ancestor;
189
+ if (
190
+ type === 'FunctionDeclaration'
191
+ || type === 'FunctionExpression'
192
+ || type === 'ArrowFunctionExpression'
193
+ || type === 'ClassMethod'
194
+ || type === 'MethodDefinition'
195
+ ) {
196
+ return false;
197
+ }
198
+ }
199
+ return true;
200
+ }
201
+
202
+ /**
203
+ * Whether the expression is being stored or cached rather than used at runtime.
204
+ * @param {ASTNode} node - The node in question
205
+ * @param {ASTNode} parent - Its parent
206
+ * @returns {boolean}
207
+ */
208
+ function isBeingCached(node, parent) {
209
+ if (parent.type === 'VariableDeclarator' && parent.init === node) {
210
+ return true;
211
+ }
212
+ if (parent.type === 'AssignmentExpression' && parent.right === node) {
213
+ return true;
214
+ }
215
+ if (parent.type === 'CallExpression' && parent.arguments.includes(node)) {
216
+ return true;
217
+ }
218
+ if (parent.type === 'ArrayExpression') {
219
+ return true;
220
+ }
221
+ if (parent.type === 'Property' && parent.value === node) {
222
+ return true;
223
+ }
224
+ return false;
225
+ }
226
+
227
+ /**
228
+ * The slice of a TypeScript `Type` this module reads.
229
+ * @typedef {{ flags: number }} TypeLike
230
+ */
231
+
232
+ /**
233
+ * Describe a type as a string the patterns below can read.
234
+ *
235
+ * `typeToString` names an alias rather than describing it, which loses exactly what
236
+ * those patterns look for: `type Coverage = Array<T>` prints as `Coverage`, and so does
237
+ * an alias that failed to resolve - even though the latter is `any`. Either way a name
238
+ * alone reads as some concrete non-primordial type, so the checker is asked instead.
239
+ *
240
+ * The checker is described by the methods used rather than by `typescript`'s exported
241
+ * types, which differ across installs; it is generic over the type so both a real checker
242
+ * and the one ESLint's parser services expose satisfy it. `isArrayType`/`isTupleType`
243
+ * are internal, so they are optional.
244
+ * @template {TypeLike} T
245
+ * @param {{ typeToString: (type: T) => string, isArrayType?: (type: T) => boolean, isTupleType?: (type: T) => boolean }} typeChecker - The TypeScript type checker
246
+ * @param {T} type - The type to describe
247
+ * @returns {string | null}
248
+ */
249
+ export function describeType(typeChecker, type) {
250
+ if (!type) {
251
+ return null;
252
+ }
253
+ if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) {
254
+ return /** @type {const} */ ('any');
255
+ }
256
+ // isArrayType/isTupleType are internal, so fall back to naming the type if they go away
257
+ if (
258
+ typeof typeChecker.isArrayType === 'function'
259
+ && typeof typeChecker.isTupleType === 'function'
260
+ && (typeChecker.isArrayType(type) || typeChecker.isTupleType(type))
261
+ ) {
262
+ return /** @type {const} */ ('Array<unknown>');
263
+ }
264
+ return typeChecker.typeToString(type);
265
+ }
266
+
267
+ /**
268
+ * @typedef {object} ParserServices
269
+ * @property {Program} [program]
270
+ * @property {{ get: (node: ASTNode) => (TSNode | undefined) }} [esTreeNodeToTSNodeMap]
271
+ */
272
+
273
+ /**
274
+ * Get the type of a node from ESLint parser services. Only called once the caller has
275
+ * established that `services.program` is present.
276
+ * @param {ASTNode} node - The node to type
277
+ * @param {ParserServices} services - ESLint parser services
278
+ * @returns {string | null}
279
+ */
280
+ function getTypeFromServices(node, services) {
281
+ if (!services.esTreeNodeToTSNodeMap) {
282
+ return null;
283
+ }
284
+ try {
285
+ const tsNode = services.esTreeNodeToTSNodeMap.get(node);
286
+ if (!tsNode) {
287
+ return null;
288
+ }
289
+ const typeChecker = /** @type {Program} */ (services.program).getTypeChecker();
290
+ return describeType(typeChecker, typeChecker.getTypeAtLocation(tsNode));
291
+ } catch {
292
+ return null;
293
+ }
294
+ }
295
+
296
+ /** @type {Map<string, string[]>} Cache for type roots by directory */
297
+ const typeRootsCache = new Map();
298
+
299
+ /**
300
+ * Find node_modules/@types directory by walking up from file (memoized).
301
+ * @param {string} filePath - The file to resolve type roots for
302
+ * @returns {string[]}
303
+ */
304
+ function findTypeRoots(filePath) {
305
+ const startDir = path.dirname(path.resolve(filePath));
306
+
307
+ // Check cache first
308
+ if (typeRootsCache.has(startDir)) {
309
+ return /** @type {string[]} */ (typeRootsCache.get(startDir));
310
+ }
311
+
312
+ const roots = [];
313
+ let dir = startDir;
314
+ const { root } = path.parse(dir);
315
+
316
+ while (dir !== root) {
317
+ const typesDir = path.join(dir, 'node_modules', '@types');
318
+ try {
319
+ fs.accessSync(typesDir);
320
+ roots[roots.length] = typesDir;
321
+ } catch {
322
+ // Directory doesn't exist
323
+ }
324
+ dir = path.dirname(dir);
325
+ }
326
+
327
+ typeRootsCache.set(startDir, roots);
328
+ return roots;
329
+ }
330
+
331
+ /**
332
+ * Create a TypeScript program for standalone type checking.
333
+ * @param {string} filePath - The file to check
334
+ * @param {string} sourceCode - Its source
335
+ * @returns {{ sourceFile: SourceFile | undefined, typeChecker: TypeChecker }}
336
+ */
337
+ function createTypeProgram(filePath, sourceCode) {
338
+ const typeRoots = findTypeRoots(filePath);
339
+
340
+ const compilerOptions = {
341
+ allowJs: true,
342
+ checkJs: true,
343
+ esModuleInterop: true,
344
+ lib: ['lib.esnext.full.d.ts'],
345
+ module: ts.ModuleKind.ESNext,
346
+ moduleResolution: ts.ModuleResolutionKind.NodeNext,
347
+ noEmit: true,
348
+ skipLibCheck: true,
349
+ target: ts.ScriptTarget.ESNext,
350
+ typeRoots: typeRoots.length > 0 ? typeRoots : void undefined,
351
+ };
352
+
353
+ const host = ts.createCompilerHost(compilerOptions);
354
+ const originalReadFile = host.readFile.bind(host);
355
+ host.readFile = (fileName) => {
356
+ if (path.resolve(fileName) === path.resolve(filePath)) {
357
+ return sourceCode;
358
+ }
359
+ return originalReadFile(fileName);
360
+ };
361
+
362
+ const program = ts.createProgram([filePath], compilerOptions, host);
363
+ return {
364
+ sourceFile: program.getSourceFile(filePath),
365
+ typeChecker: program.getTypeChecker(),
366
+ };
367
+ }
368
+
369
+ /**
370
+ * Find the innermost TypeScript node at a source position.
371
+ * @param {SourceFile} sourceFile - The source file
372
+ * @param {number} pos - The character offset
373
+ * @returns {TSNode | null}
374
+ */
375
+ function findNodeAtPosition(sourceFile, pos) {
376
+ /** @type {TSNode | null} */
377
+ let found = null;
378
+ /** @param {TSNode} node */
379
+ function visit(node) {
380
+ if (pos >= node.getStart() && pos < node.getEnd()) {
381
+ found = node;
382
+ ts.forEachChild(node, visit);
383
+ }
384
+ }
385
+ visit(sourceFile);
386
+ return found;
387
+ }
388
+
389
+ /**
390
+ * Get the type at a source position from a standalone TypeScript program.
391
+ * @param {TypeChecker | null} typeChecker - The type checker
392
+ * @param {SourceFile | null} sourceFile - The source file
393
+ * @param {number} pos - The character offset
394
+ * @returns {string | null}
395
+ */
396
+ function getTypeAtPosition(typeChecker, sourceFile, pos) {
397
+ if (!typeChecker || !sourceFile) {
398
+ return null;
399
+ }
400
+ try {
401
+ const tsNode = findNodeAtPosition(sourceFile, pos);
402
+ if (!tsNode) {
403
+ return null;
404
+ }
405
+ return describeType(typeChecker, typeChecker.getTypeAtLocation(tsNode));
406
+ } catch {
407
+ return null;
408
+ }
409
+ }
410
+
411
+ const ARRAY_PATTERNS = [
412
+ /^Array</,
413
+ /\[\]$/,
414
+ /^readonly\s+\w+\[\]$/,
415
+ /^Int8Array$/,
416
+ /^Uint8Array$/,
417
+ /^Uint8ClampedArray$/,
418
+ /^Int16Array$/,
419
+ /^Uint16Array$/,
420
+ /^Int32Array$/,
421
+ /^Uint32Array$/,
422
+ /^BigInt64Array$/,
423
+ /^BigUint64Array$/,
424
+ /^Float16Array$/,
425
+ /^Float32Array$/,
426
+ /^Float64Array$/,
427
+ ];
428
+
429
+ const ITERATOR_PATTERNS = [
430
+ /^Iterator</,
431
+ /^IterableIterator</,
432
+ /^Generator</,
433
+ /^AsyncIterator</,
434
+ /^AsyncGenerator</,
435
+ ];
436
+
437
+ // Types that are too generic to determine primordial usage
438
+ const UNKNOWN_TYPE_PATTERNS = /** @type {const} */ ([
439
+ /^any$/,
440
+ /^unknown$/,
441
+ /^never$/,
442
+ /^void$/,
443
+ /^undefined$/,
444
+ /^null$/,
445
+ /^object$/i,
446
+ /^\{\s*\}$/, // empty object type
447
+ ]);
448
+
449
+ /**
450
+ * Whether a type string is too generic to determine primordial usage.
451
+ * @param {string | null | undefined} typeStr - The type's string form
452
+ * @returns {boolean}
453
+ */
454
+ function isUnknownType(typeStr) {
455
+ if (!typeStr) {
456
+ return true;
457
+ }
458
+ for (const pattern of UNKNOWN_TYPE_PATTERNS) {
459
+ if (pattern.test(typeStr)) {
460
+ return true;
461
+ }
462
+ }
463
+ return false;
464
+ }
465
+
466
+ /**
467
+ * Determine whether a type string indicates an array, iterator, or something else.
468
+ * @param {string | null | undefined} typeStr - The type's string form
469
+ * @returns {'array' | 'iterator' | 'other' | null} null when the type is unknown/any
470
+ */
471
+ function isArrayOrIterType(typeStr) {
472
+ if (isUnknownType(typeStr)) {
473
+ return null;
474
+ }
475
+ const str = /** @type {string} */ (typeStr);
476
+ for (const pattern of ARRAY_PATTERNS) {
477
+ if (pattern.test(str)) {
478
+ return 'array';
479
+ }
480
+ }
481
+ for (const pattern of ITERATOR_PATTERNS) {
482
+ if (pattern.test(str)) {
483
+ return 'iterator';
484
+ }
485
+ }
486
+ // If we have a concrete type that's not array/iterator, it's something else - don't flag it
487
+ return 'other';
488
+ }
489
+
490
+ /**
491
+ * Whether a type string names a known primordial type (arrays, iterators, typed arrays).
492
+ * @param {string | null | undefined} typeStr - The type's string form
493
+ * @returns {'Array' | 'Iterator' | false | null} null when unknown, false when known but not primordial
494
+ */
495
+ function isKnownPrimordialType(typeStr) {
496
+ if (isUnknownType(typeStr)) {
497
+ return null; // Can't determine
498
+ }
499
+
500
+ const str = /** @type {string} */ (typeStr);
501
+ // Check for Array types
502
+ for (const pattern of ARRAY_PATTERNS) {
503
+ if (pattern.test(str)) {
504
+ return 'Array';
505
+ }
506
+ }
507
+
508
+ // Check for Iterator types
509
+ for (const pattern of ITERATOR_PATTERNS) {
510
+ if (pattern.test(str)) {
511
+ return 'Iterator';
512
+ }
513
+ }
514
+
515
+ // We have a concrete type that's not a known primordial - it's something else
516
+ return false;
517
+ }
518
+
519
+ /**
520
+ * Whether a member expression accesses a primordial prototype, e.g. `Array.prototype.push`.
521
+ * Only ever called on a MemberExpression.
522
+ * @param {ASTNode} node - The MemberExpression
523
+ * @returns {{ globalName: string, methodName: (string | null), type: 'prototype' } | null}
524
+ */
525
+ function isProtoAccess(node) {
526
+ if (
527
+ node.object.type === 'MemberExpression'
528
+ && node.object.property.type === 'Identifier'
529
+ && node.object.property.name === 'prototype'
530
+ && node.object.object.type === 'Identifier'
531
+ && allGlobals.has(node.object.object.name)
532
+ ) {
533
+ const globalName = node.object.object.name;
534
+ const methodName = node.property.type === 'Identifier' ? node.property.name : null;
535
+ return {
536
+ globalName,
537
+ methodName,
538
+ type: 'prototype',
539
+ };
540
+ }
541
+ return null;
542
+ }
543
+
544
+ /**
545
+ * Whether a member expression is a primordial static method or property access,
546
+ * e.g. `Object.keys`. Only ever called on a MemberExpression.
547
+ * @param {ASTNode} node - The MemberExpression
548
+ * @returns {{ category: string, globalName: string, methodName: string, type: ('static' | 'staticProperty') } | null}
549
+ */
550
+ function isStaticAccess(node) {
551
+ if (
552
+ node.object.type === 'Identifier'
553
+ && allGlobals.has(node.object.name)
554
+ && node.property.type === 'Identifier'
555
+ ) {
556
+ const globalName = node.object.name;
557
+ const methodName = node.property.name;
558
+ const category = globalToCategory.get(globalName);
559
+ const prim = category && /** @type {{ staticMethods: string[], staticProperties?: string[] } | undefined} */ (
560
+ primordials[/** @type {keyof typeof primordials} */ (category)]
561
+ );
562
+ if (category && prim) {
563
+ if (prim.staticMethods.includes(methodName)) {
564
+ return {
565
+ category,
566
+ globalName,
567
+ methodName,
568
+ type: 'static',
569
+ };
570
+ }
571
+ if (prim.staticProperties?.includes(methodName)) {
572
+ return {
573
+ category,
574
+ globalName,
575
+ methodName,
576
+ type: 'staticProperty',
577
+ };
578
+ }
579
+ }
580
+ }
581
+ return null;
582
+ }
583
+
584
+ const JSX_EXTS = new Set([
585
+ '.jsx',
586
+ '.tsx',
587
+ ]);
588
+
589
+ // Globals that are constant values and safe to use directly
590
+ const SAFE_GLOBALS = new Set(['NaN', 'Infinity']);
591
+
592
+ /**
593
+ * Whether an identifier is in a non-reference position (a declaration, a property key, etc.).
594
+ * @param {ASTNode} node - The identifier
595
+ * @param {MaybeNode} parent - Its parent
596
+ * @returns {boolean}
597
+ */
598
+ function isNonReferencePosition(node, parent) {
599
+ if (parent?.type === 'MemberExpression' && parent.property === node && !parent.computed) {
600
+ return true;
601
+ }
602
+ if (parent?.type === 'Property' && parent.key === node && !parent.computed) {
603
+ return true;
604
+ }
605
+ // Function declaration/expression names and variable declarator ids are declarations
606
+ const isNameDecl = parent?.type === 'FunctionDeclaration'
607
+ || parent?.type === 'FunctionExpression'
608
+ || parent?.type === 'VariableDeclarator'
609
+ || parent?.type === 'ClassDeclaration'
610
+ || parent?.type === 'ClassExpression';
611
+ if (isNameDecl && parent.id === node) {
612
+ return true;
613
+ }
614
+ const isFnParent = parent?.type === 'FunctionDeclaration'
615
+ || parent?.type === 'FunctionExpression'
616
+ || parent?.type === 'ArrowFunctionExpression';
617
+ if (isFnParent && parent.params?.includes(node)) {
618
+ return true;
619
+ }
620
+ return false;
621
+ }
622
+
623
+ /**
624
+ * Whether a global identifier is safe to use directly.
625
+ * @param {ASTNode} node - The global identifier
626
+ * @param {MaybeNode} parent - Its parent
627
+ * @returns {boolean}
628
+ */
629
+ function isSafeGlobalUsage(node, parent) {
630
+ // `void undefined` is safe - void always returns undefined regardless of its argument
631
+ if (node.name === 'undefined' && parent?.type === 'UnaryExpression' && parent.operator === 'void') {
632
+ return true;
633
+ }
634
+ // NaN and Infinity are constant values that can't be meaningfully modified
635
+ if (SAFE_GLOBALS.has(node.name)) {
636
+ return true;
637
+ }
638
+ return false;
639
+ }
640
+
641
+ /**
642
+ * Collect the names a binding pattern declares (handles destructuring).
643
+ * @param {ASTNode} pattern - The pattern node
644
+ * @param {Set<string>} names - Accumulator the found names are added to
645
+ * @returns {void}
646
+ */
647
+ function getNamesFromPattern(pattern, names) {
648
+ if (pattern.type === 'Identifier') {
649
+ names.add(pattern.name);
650
+ } else if (pattern.type === 'ObjectPattern') {
651
+ for (const prop of pattern.properties) {
652
+ if (prop.type === 'RestElement') {
653
+ getNamesFromPattern(prop.argument, names);
654
+ } else {
655
+ getNamesFromPattern(/** @type {ASTNode} */ (prop.value), names);
656
+ }
657
+ }
658
+ } else if (pattern.type === 'ArrayPattern') {
659
+ for (const elem of pattern.elements) {
660
+ if (elem) {
661
+ if (elem.type === 'RestElement') {
662
+ getNamesFromPattern(elem.argument, names);
663
+ } else {
664
+ getNamesFromPattern(elem, names);
665
+ }
666
+ }
667
+ }
668
+ } else if (pattern.type === 'AssignmentPattern') {
669
+ getNamesFromPattern(pattern.left, names);
670
+ } else if (pattern.type === 'RestElement') {
671
+ getNamesFromPattern(pattern.argument, names);
672
+ }
673
+ }
674
+
675
+ /**
676
+ * Whether a variable declaration list declares a name.
677
+ * @param {ASTNode[]} declarations - The declarators
678
+ * @param {string} name - The name to look for
679
+ * @returns {boolean}
680
+ */
681
+ function varDeclListDeclaresName(declarations, name) {
682
+ for (const decl of declarations) {
683
+ /** @type {Set<string>} */
684
+ const declNames = new Set();
685
+ getNamesFromPattern(decl.id, declNames);
686
+ if (declNames.has(name)) {
687
+ return true;
688
+ }
689
+ }
690
+ return false;
691
+ }
692
+
693
+ /**
694
+ * Whether a function's parameters include a name.
695
+ * @param {ASTNode[]} params - The parameter patterns
696
+ * @param {string} name - The name to look for
697
+ * @returns {boolean}
698
+ */
699
+ function functionParamDeclaresName(params, name) {
700
+ /** @type {Set<string>} */
701
+ const paramNames = new Set();
702
+ for (const param of params) {
703
+ getNamesFromPattern(param, paramNames);
704
+ }
705
+ return paramNames.has(name);
706
+ }
707
+
708
+ /**
709
+ * Whether a statement declares a name (variable, function, class, or import).
710
+ * @param {ASTNode} stmt - The statement
711
+ * @param {string} name - The name to look for
712
+ * @returns {boolean}
713
+ */
714
+ function statementDeclaresName(stmt, name) {
715
+ if (stmt.type === 'VariableDeclaration') {
716
+ return varDeclListDeclaresName(stmt.declarations, name);
717
+ }
718
+ if (stmt.type === 'FunctionDeclaration' && stmt.id?.name === name) {
719
+ return true;
720
+ }
721
+ if (stmt.type === 'ClassDeclaration' && stmt.id?.name === name) {
722
+ return true;
723
+ }
724
+ if (stmt.type === 'ImportDeclaration') {
725
+ for (const spec of stmt.specifiers) {
726
+ if (spec.local?.name === name) {
727
+ return true;
728
+ }
729
+ }
730
+ }
731
+ return false;
732
+ }
733
+
734
+ /**
735
+ * Whether a block or program body declares a name.
736
+ * @param {ASTNode[]} body - The statement list
737
+ * @param {string} name - The name to look for
738
+ * @returns {boolean}
739
+ */
740
+ function blockDeclaresName(body, name) {
741
+ for (const stmt of body) {
742
+ if (statementDeclaresName(stmt, name)) {
743
+ return true;
744
+ }
745
+ }
746
+ return false;
747
+ }
748
+
749
+ /**
750
+ * Whether a name is shadowed by any declaration in the scope chain.
751
+ * @param {string} name - The name to look for
752
+ * @param {ASTNode[]} ancestors - The node's ancestors, nearest last
753
+ * @returns {boolean}
754
+ */
755
+ function isShadowedInScope(name, ancestors) {
756
+ // Walk through ancestors looking for declarations that shadow this name
757
+ for (const ancestor of ancestors) {
758
+ const { type } = ancestor;
759
+
760
+ // Check function parameters
761
+ const isFnType = type === 'FunctionDeclaration'
762
+ || type === 'FunctionExpression'
763
+ || type === 'ArrowFunctionExpression';
764
+ if (isFnType && functionParamDeclaresName(ancestor.params, name)) {
765
+ return true;
766
+ }
767
+
768
+ // Check variable declarations in block/program body
769
+ if ((type === 'BlockStatement' || type === 'Program') && blockDeclaresName(/** @type {ASTNode[]} */ (ancestor.body), name)) {
770
+ return true;
771
+ }
772
+
773
+ // Check catch clause parameter
774
+ if (type === 'CatchClause' && ancestor.param) {
775
+ /** @type {Set<string>} */
776
+ const catchNames = new Set();
777
+ getNamesFromPattern(ancestor.param, catchNames);
778
+ if (catchNames.has(name)) {
779
+ return true;
780
+ }
781
+ }
782
+
783
+ // Check for-in/for-of variable
784
+ const isForEach = type === 'ForInStatement' || type === 'ForOfStatement';
785
+ if (isForEach && ancestor.left?.type === 'VariableDeclaration') {
786
+ if (varDeclListDeclaresName(ancestor.left.declarations, name)) {
787
+ return true;
788
+ }
789
+ }
790
+
791
+ // Check for loop variable
792
+ if (type === 'ForStatement' && ancestor.init?.type === 'VariableDeclaration') {
793
+ if (varDeclListDeclaresName(ancestor.init.declarations, name)) {
794
+ return true;
795
+ }
796
+ }
797
+ }
798
+
799
+ return false;
800
+ }
801
+
802
+ const PLUGIN_PREFIX = 'find-primordials/';
803
+ const ALL_RULES = new Set([
804
+ 'no-globals',
805
+ 'no-instance-methods',
806
+ 'no-spread-syntax',
807
+ 'no-static-methods',
808
+ ]);
809
+
810
+ const FINDING_TYPE_TO_RULE = {
811
+ global: 'no-globals',
812
+ instanceMethod: 'no-instance-methods',
813
+ prototypeAccess: 'no-instance-methods',
814
+ spread: 'no-spread-syntax',
815
+ staticMethod: 'no-static-methods',
816
+ staticProperty: 'no-static-methods',
817
+ };
818
+
819
+ // eslint-disable-next-line prefer-named-capture-group
820
+ const DISABLE_NEXT_LINE_REGEX = (/^eslint-disable-next-line(?:\s+(.*))?$/);
821
+ // eslint-disable-next-line prefer-named-capture-group
822
+ const DISABLE_LINE_REGEX = (/^eslint-disable-line(?:\s+(.*))?$/);
823
+ // eslint-disable-next-line prefer-named-capture-group
824
+ const DISABLE_REGEX = (/^eslint-disable(?:\s+(.*))?$/);
825
+ // eslint-disable-next-line prefer-named-capture-group
826
+ const ENABLE_REGEX = (/^eslint-enable(?:\s+(.*))?$/);
827
+
828
+ /**
829
+ * Parse rule names from an ESLint directive.
830
+ * @param {string | undefined} rulesStr - Comma-separated rule names
831
+ * @returns {Set<string> | null} The named rules, or null to mean all rules
832
+ */
833
+ function parseDirectiveRules(rulesStr) {
834
+ if (!rulesStr || rulesStr.trim() === '') {
835
+ return null; // Disable all
836
+ }
837
+ /** @type {Set<string>} */
838
+ const rules = new Set();
839
+ for (const rule of rulesStr.split(',')) {
840
+ const trimmed = rule.trim();
841
+ if (trimmed.startsWith(PLUGIN_PREFIX)) {
842
+ rules.add(trimmed.slice(PLUGIN_PREFIX.length));
843
+ } else if (ALL_RULES.has(trimmed)) {
844
+ rules.add(trimmed);
845
+ }
846
+ }
847
+ return rules.size > 0 ? rules : null;
848
+ }
849
+
850
+ /**
851
+ * Record a line-specific disable.
852
+ * @param {Map<number, Set<string> | null>} map - Line number to its disabled rules (null means all)
853
+ * @param {number} line - The line to disable on
854
+ * @param {Set<string> | null} rules - The rules to disable, or null for all
855
+ * @returns {void}
856
+ */
857
+ function addLineDisable(map, line, rules) {
858
+ if (!map.has(line)) {
859
+ map.set(line, rules);
860
+ } else if (rules === null) {
861
+ map.set(line, null); // Disable all overrides specific rules
862
+ } else {
863
+ const existing = map.get(line);
864
+ if (existing) {
865
+ for (const r of rules) {
866
+ existing.add(r);
867
+ }
868
+ }
869
+ }
870
+ }
871
+
872
+ /**
873
+ * Close open disable ranges that an `eslint-enable` for specific rules matches.
874
+ * @param {Set<string>} rules - The rules being re-enabled
875
+ * @param {number} endLine - The line the enable sits on
876
+ * @param {DisableRange[]} disabledRanges - The open ranges, mutated in place
877
+ * @returns {void}
878
+ */
879
+ function closeMatchingRanges(rules, endLine, disabledRanges) {
880
+ for (const range of disabledRanges) {
881
+ if (range.end === Infinity && range.rules !== null) {
882
+ let allMatch = true;
883
+ for (const r of rules) {
884
+ if (!range.rules.has(r)) {
885
+ allMatch = false;
886
+ }
887
+ }
888
+ if (allMatch) {
889
+ range.end = endLine;
890
+ }
891
+ }
892
+ }
893
+ }
894
+
895
+ /**
896
+ * Fold a single ESLint disable/enable comment into the running disable state.
897
+ * @param {ASTNode} comment - The comment node
898
+ * @param {Map<number, Set<string> | null>} disabledLines - Line-specific disables, mutated
899
+ * @param {DisableRange[]} disabledRanges - Open/closed ranges, mutated
900
+ * @param {number | null} disableAllStart - Start line of an open disable-all, or null
901
+ * @returns {number | null} The updated disable-all start line
902
+ */
903
+ function processDisableComment(comment, disabledLines, disabledRanges, disableAllStart) {
904
+ const text = /** @type {string} */ (comment.value).trim();
905
+
906
+ const nextLineMatch = DISABLE_NEXT_LINE_REGEX.exec(text);
907
+ if (nextLineMatch) {
908
+ const rules = parseDirectiveRules(nextLineMatch[1]);
909
+ const targetLine = comment.loc.end.line + 1; // eslint-disable-line no-magic-numbers
910
+ addLineDisable(disabledLines, targetLine, rules);
911
+ return disableAllStart;
912
+ }
913
+
914
+ const sameLineMatch = DISABLE_LINE_REGEX.exec(text);
915
+ if (sameLineMatch) {
916
+ const rules = parseDirectiveRules(sameLineMatch[1]);
917
+ addLineDisable(disabledLines, comment.loc.start.line, rules);
918
+ return disableAllStart;
919
+ }
920
+
921
+ const disableMatch = DISABLE_REGEX.exec(text);
922
+ if (disableMatch) {
923
+ const rules = parseDirectiveRules(disableMatch[1]);
924
+ if (rules === null) {
925
+ return comment.loc.end.line;
926
+ }
927
+ // eslint-disable-next-line no-param-reassign -- appending to the accumulator is the point; `push` is what this repo avoids
928
+ disabledRanges[disabledRanges.length] = {
929
+ end: Infinity,
930
+ rules,
931
+ start: comment.loc.end.line,
932
+ };
933
+ return disableAllStart;
934
+ }
935
+
936
+ const enableMatch = ENABLE_REGEX.exec(text);
937
+ if (enableMatch) {
938
+ const rules = parseDirectiveRules(enableMatch[1]);
939
+ if (rules === null && disableAllStart !== null) {
940
+ // eslint-disable-next-line no-param-reassign -- appending to the accumulator is the point; `push` is what this repo avoids
941
+ disabledRanges[disabledRanges.length] = {
942
+ end: comment.loc.start.line,
943
+ rules: null,
944
+ start: disableAllStart,
945
+ };
946
+ return null;
947
+ }
948
+ if (rules !== null) {
949
+ closeMatchingRanges(rules, comment.loc.start.line, disabledRanges);
950
+ }
951
+ }
952
+
953
+ return disableAllStart;
954
+ }
955
+
956
+ /**
957
+ * Parse all ESLint disable directives out of a file's comments.
958
+ * @param {ASTNode[]} comments - The comment nodes from the AST
959
+ * @returns {DisableState}
960
+ */
961
+ function parseDisableDirectives(comments) {
962
+ /** @type {Map<number, Set<string> | null>} */
963
+ const disabledLines = new Map();
964
+ /** @type {number | null} */
965
+ let disableAllStart = null;
966
+ /** @type {DisableRange[]} */
967
+ const disabledRanges = [];
968
+
969
+ for (const comment of comments) {
970
+ disableAllStart = processDisableComment(comment, disabledLines, disabledRanges, disableAllStart);
971
+ }
972
+
973
+ // If disable-all is still open, extend to end of file
974
+ if (disableAllStart !== null) {
975
+ disabledRanges[disabledRanges.length] = {
976
+ end: Infinity,
977
+ rules: null,
978
+ start: disableAllStart,
979
+ };
980
+ }
981
+
982
+ return { disabledLines, disabledRanges };
983
+ }
984
+
985
+ /**
986
+ * Whether a finding's rule is disabled at a given line.
987
+ * @param {DisableState} state - The parsed disable state
988
+ * @param {number} line - The line to check
989
+ * @param {string} ruleType - The finding type (global, instanceMethod, etc.)
990
+ * @returns {boolean}
991
+ */
992
+ function isLineDisabled(state, line, ruleType) {
993
+ const { disabledLines, disabledRanges } = state;
994
+ const ruleName = FINDING_TYPE_TO_RULE[/** @type {keyof typeof FINDING_TYPE_TO_RULE} */ (ruleType)];
995
+
996
+ // Check line-specific disables
997
+ if (disabledLines.has(line)) {
998
+ const rules = disabledLines.get(line);
999
+ if (rules === null) {
1000
+ return true;
1001
+ } // All rules disabled
1002
+ if (ruleName && rules?.has(ruleName)) {
1003
+ return true;
1004
+ }
1005
+ }
1006
+ // Check range disables
1007
+ for (const range of disabledRanges) {
1008
+ if (line >= range.start && line <= range.end) {
1009
+ if (range.rules === null) {
1010
+ return true;
1011
+ }
1012
+ if (ruleName && range.rules.has(ruleName)) {
1013
+ return true;
1014
+ }
1015
+ }
1016
+ }
1017
+ return false;
1018
+ }
1019
+
1020
+ /**
1021
+ * Analyze a single file for primordial usage.
1022
+ * @param {string} filePath - The file to analyze
1023
+ * @param {AnalyzeOptions} [options] - What to include and how to resolve types
1024
+ * @returns {{ error: (string | null), findings: Finding[] }}
1025
+ */
1026
+ export function analyzeFile(filePath, options = {}) {
1027
+ const {
1028
+ includeGlobals = false,
1029
+ includeSpread = false,
1030
+ includeStatic = false,
1031
+ includeUncertain = true,
1032
+ isSafe = false,
1033
+ parserServices = null,
1034
+ typeProgramFactory = createTypeProgram,
1035
+ } = options;
1036
+
1037
+ /** @type {Finding[]} */
1038
+ const findings = [];
1039
+
1040
+ // Safe files (bin entry points, test files) have no findings
1041
+ if (isSafe) {
1042
+ return { error: null, findings };
1043
+ }
1044
+
1045
+ // Skip TypeScript declaration files - they're just type definitions with no runtime code
1046
+ const lowerPath = filePath.toLowerCase();
1047
+ if (lowerPath.endsWith('.d.ts') || lowerPath.endsWith('.d.mts') || lowerPath.endsWith('.d.cts')) {
1048
+ return { error: null, findings };
1049
+ }
1050
+
1051
+ const ext = path.extname(filePath).toLowerCase();
1052
+ const isJSX = JSX_EXTS.has(ext);
1053
+
1054
+ /** @type {string} */
1055
+ let sourceCode;
1056
+ try {
1057
+ sourceCode = fs.readFileSync(filePath, 'utf8');
1058
+ } catch {
1059
+ return { error: `Could not read file: ${filePath}`, findings };
1060
+ }
1061
+
1062
+ /** @type {ASTNode} */
1063
+ let ast;
1064
+ try {
1065
+ ast = /** @type {ASTNode} */ (/** @type {unknown} */ (parse(sourceCode, {
1066
+ comment: true,
1067
+ ecmaFeatures: { jsx: isJSX },
1068
+ ecmaVersion: 'latest',
1069
+ loc: true,
1070
+ range: true,
1071
+ sourceType: 'module',
1072
+ })));
1073
+ } catch (parseError) {
1074
+ // the parser always throws an Error
1075
+ return { error: `Parse error: ${/** @type {Error} */ (parseError).message}`, findings };
1076
+ }
1077
+
1078
+ // Parse ESLint disable directives from comments
1079
+ const disableState = parseDisableDirectives(ast.comments);
1080
+
1081
+ /**
1082
+ * Whether a finding's rule is disabled at a line.
1083
+ * @param {number} line - The line
1084
+ * @param {string} ruleType - The finding type
1085
+ * @returns {boolean}
1086
+ */
1087
+ function isRuleDisabled(line, ruleType) {
1088
+ return isLineDisabled(disableState, line, ruleType);
1089
+ }
1090
+
1091
+ // Lazy TypeScript initialization - only create when actually needed for type checking
1092
+ /** @type {TypeChecker | null} */
1093
+ let tsTypeChecker = null;
1094
+ /** @type {SourceFile | null} */
1095
+ let tsSourceFile = null;
1096
+ let tsInitialized = false;
1097
+
1098
+ /**
1099
+ * Initialize the TypeScript type checker lazily.
1100
+ * @returns {void}
1101
+ */
1102
+ function initTypeChecker() {
1103
+ if (tsInitialized) {
1104
+ return;
1105
+ }
1106
+ tsInitialized = true;
1107
+
1108
+ if (parserServices?.program) {
1109
+ // Use ESLint parser services if available
1110
+ tsTypeChecker = /** @type {TypeChecker} */ (/** @type {unknown} */ (parserServices));
1111
+ } else {
1112
+ // Create standalone TypeScript program for type inference (including JSDoc)
1113
+ try {
1114
+ const tsProgram = typeProgramFactory(filePath, sourceCode);
1115
+ tsTypeChecker = tsProgram.typeChecker;
1116
+ tsSourceFile = tsProgram.sourceFile ?? null;
1117
+ } catch {
1118
+ // Type checking not available
1119
+ }
1120
+ }
1121
+ }
1122
+
1123
+ /**
1124
+ * Get the type of a node, initializing TypeScript lazily.
1125
+ * @param {ASTNode} node - The node to type
1126
+ * @returns {string | null}
1127
+ */
1128
+ function getNodeType(node) {
1129
+ initTypeChecker();
1130
+ if (parserServices?.program) {
1131
+ return getTypeFromServices(node, parserServices);
1132
+ }
1133
+ return getTypeAtPosition(tsTypeChecker, tsSourceFile, node.range[0]);
1134
+ }
1135
+
1136
+ /**
1137
+ * Record a finding, honoring ESLint disable directives. Uncertain findings are already
1138
+ * filtered upstream when `includeUncertain` is false, so they never reach here.
1139
+ * @param {Omit<Finding, 'file'> & { line: number }} info - The finding, without its `file`
1140
+ * @returns {void}
1141
+ */
1142
+ function addFinding(info) {
1143
+ if (isRuleDisabled(info.line, info.type)) {
1144
+ return;
1145
+ }
1146
+ findings[findings.length] = { file: filePath, ...info };
1147
+ }
1148
+
1149
+ /**
1150
+ * Handle an identifier node, reporting global primordial usage.
1151
+ * @param {ASTNode} node - The identifier
1152
+ * @param {ASTNode[]} ancestors - Its ancestors, nearest last
1153
+ * @returns {void}
1154
+ */
1155
+ function handleIdentifier(node, ancestors) {
1156
+ if (!includeGlobals) {
1157
+ return;
1158
+ }
1159
+ const parent = ancestors[ancestors.length - 1]; // eslint-disable-line no-magic-numbers
1160
+ if (isNonReferencePosition(node, parent)) {
1161
+ return;
1162
+ }
1163
+ if (isSafeGlobalUsage(node, parent)) {
1164
+ return;
1165
+ }
1166
+ if (!allGlobals.has(node.name)) {
1167
+ return;
1168
+ }
1169
+ // Skip if the name is shadowed by a local declaration
1170
+ if (isShadowedInScope(node.name, ancestors)) {
1171
+ return;
1172
+ }
1173
+ const isModuleLevel = isModuleLevelScope(ancestors);
1174
+ if (isModuleLevel && isBeingCached(node, parent)) {
1175
+ return;
1176
+ }
1177
+ addFinding({
1178
+ category: globalToCategory.get(node.name),
1179
+ certainty: CERTAINTY_CERTAIN,
1180
+ column: node.loc.start.column + 1, // eslint-disable-line no-magic-numbers
1181
+ line: node.loc.start.line,
1182
+ name: node.name,
1183
+ type: 'global',
1184
+ });
1185
+ }
1186
+
1187
+ /**
1188
+ * Determine the certainty and category for an instance-method access.
1189
+ * @param {ASTNode} node - The MemberExpression
1190
+ * @param {string[]} categories - The primordial categories the method name belongs to
1191
+ * @param {boolean} isAmbiguous - Whether the name maps to more than one category
1192
+ * @returns {{ category: (string | null), certainty: string, typed: boolean } | null}
1193
+ */
1194
+ function getInstanceMethodInfo(node, categories, isAmbiguous) {
1195
+ const detectedCategory = categories.length === 1 ? categories[0] : null; // eslint-disable-line no-magic-numbers
1196
+
1197
+ // Fast path: array literals are always certain
1198
+ if (node.object.type === 'ArrayExpression') {
1199
+ return {
1200
+ category: 'Array',
1201
+ certainty: CERTAINTY_CERTAIN,
1202
+ typed: true,
1203
+ };
1204
+ }
1205
+
1206
+ if (isAmbiguous && includeUncertain) {
1207
+ // Only do expensive type checking for ambiguous methods when we need uncertain results
1208
+ const typeStr = getNodeType(node.object);
1209
+ const typeKind = typeStr ? isArrayOrIterType(typeStr) : null;
1210
+ if (typeKind === 'array' && categories.includes('Array')) {
1211
+ return {
1212
+ category: 'Array',
1213
+ certainty: CERTAINTY_CERTAIN,
1214
+ typed: true,
1215
+ };
1216
+ }
1217
+ // every ambiguous iterator method also belongs to Iterator, so that is the category
1218
+ if (typeKind === 'iterator' && categories.includes('Iterator')) {
1219
+ return {
1220
+ category: 'Iterator',
1221
+ certainty: CERTAINTY_CERTAIN,
1222
+ typed: true,
1223
+ };
1224
+ }
1225
+ if (typeKind === 'other') {
1226
+ return null; // Skip - not a primordial type
1227
+ }
1228
+ // Type unknown, keep as uncertain
1229
+ return {
1230
+ category: detectedCategory,
1231
+ certainty: CERTAINTY_UNCERTAIN,
1232
+ typed: false,
1233
+ };
1234
+ } else if (isAmbiguous) {
1235
+ // Skip ambiguous methods when --no-uncertain is set
1236
+ return null;
1237
+ }
1238
+
1239
+ /*
1240
+ * Non-ambiguous method - only one possible primordial category.
1241
+ * Check if type is a known non-primordial.
1242
+ */
1243
+ const typeStr = getNodeType(node.object);
1244
+ const primordialType = isKnownPrimordialType(typeStr);
1245
+ if (primordialType === false) {
1246
+ // Type is known and not a primordial - skip (e.g., CharSet.test() is not RegExp.test())
1247
+ return null;
1248
+ }
1249
+ /*
1250
+ * Only one category can own this name, so a call to it is a call to that primordial.
1251
+ * That is a claim about the name, not about the object, which is why `typed` says
1252
+ * where the certainty came from.
1253
+ */
1254
+ return {
1255
+ category: detectedCategory,
1256
+ certainty: CERTAINTY_CERTAIN,
1257
+ typed: !!primordialType,
1258
+ };
1259
+ }
1260
+
1261
+ /**
1262
+ * Handle a member expression, reporting prototype access, static methods, and
1263
+ * instance methods.
1264
+ * @param {ASTNode} node - The MemberExpression
1265
+ * @param {ASTNode[]} ancestors - Its ancestors, nearest last
1266
+ * @returns {void}
1267
+ */
1268
+ function handleMemberExpr(node, ancestors) {
1269
+ const parent = ancestors[ancestors.length - 1]; // eslint-disable-line no-magic-numbers
1270
+
1271
+ // Skip if this is the left side of an assignment (property being set, not read)
1272
+ if (parent?.type === 'AssignmentExpression' && parent.left === node) {
1273
+ return;
1274
+ }
1275
+
1276
+ const isModuleLevel = isModuleLevelScope(ancestors);
1277
+
1278
+ const protoAccess = isProtoAccess(node);
1279
+ if (protoAccess) {
1280
+ if (isShadowedInScope(protoAccess.globalName, ancestors)) {
1281
+ return;
1282
+ }
1283
+ if (isModuleLevel && isBeingCached(node, parent)) {
1284
+ return;
1285
+ }
1286
+ addFinding({
1287
+ category: globalToCategory.get(protoAccess.globalName),
1288
+ certainty: CERTAINTY_CERTAIN,
1289
+ column: node.loc.start.column + 1, // eslint-disable-line no-magic-numbers
1290
+ line: node.loc.start.line,
1291
+ name: `${protoAccess.globalName}.prototype.${protoAccess.methodName}`,
1292
+ type: 'prototypeAccess',
1293
+ });
1294
+ return;
1295
+ }
1296
+
1297
+ if (includeStatic) {
1298
+ const staticAcc = isStaticAccess(node);
1299
+ if (staticAcc) {
1300
+ if (isShadowedInScope(staticAcc.globalName, ancestors)) {
1301
+ return;
1302
+ }
1303
+ if (isModuleLevel && isBeingCached(node, parent)) {
1304
+ return;
1305
+ }
1306
+ addFinding({
1307
+ category: staticAcc.category,
1308
+ certainty: CERTAINTY_CERTAIN,
1309
+ column: node.loc.start.column + 1, // eslint-disable-line no-magic-numbers
1310
+ line: node.loc.start.line,
1311
+ name: `${staticAcc.globalName}.${staticAcc.methodName}`,
1312
+ type: staticAcc.type === 'staticProperty' ? 'staticProperty' : 'staticMethod',
1313
+ });
1314
+ return;
1315
+ }
1316
+ }
1317
+
1318
+ if (node.property.type !== 'Identifier') {
1319
+ return;
1320
+ }
1321
+ const methodName = node.property.name;
1322
+ if (!allInstanceMethods.has(methodName)) {
1323
+ return;
1324
+ }
1325
+ if (CALL_APPLY_BIND.has(methodName) && !isModuleLevel && node.object.type === 'Identifier') {
1326
+ return;
1327
+ }
1328
+ const categories = /** @type {string[]} */ (allInstanceMethods.get(methodName));
1329
+ const isAmbiguous = ambiguousInstanceMethods.has(methodName);
1330
+ const methodInfo = getInstanceMethodInfo(node, categories, isAmbiguous);
1331
+ if (!methodInfo) {
1332
+ return;
1333
+ }
1334
+
1335
+ /*
1336
+ * Reading `row.test` without calling it says nothing on its own: plenty of objects
1337
+ * carry a data property that happens to be named after a method. A call at least
1338
+ * reaches something callable, but a bare read needs the object's type to say it is
1339
+ * a primordial - the name alone cannot.
1340
+ */
1341
+ if (!isCalled(node, parent) && !methodInfo.typed) {
1342
+ return;
1343
+ }
1344
+
1345
+ if (isModuleLevel) {
1346
+ return;
1347
+ }
1348
+
1349
+ addFinding({
1350
+ category: methodInfo.category,
1351
+ certainty: methodInfo.certainty,
1352
+ column: node.loc.start.column + 1, // eslint-disable-line no-magic-numbers
1353
+ line: node.loc.start.line,
1354
+ name: methodName,
1355
+ possibleCategories: isAmbiguous ? categories : void undefined,
1356
+ type: 'instanceMethod',
1357
+ });
1358
+ }
1359
+
1360
+ /**
1361
+ * Handle a spread element, reporting spread syntax.
1362
+ * @param {ASTNode} node - The SpreadElement
1363
+ * @param {ASTNode[]} ancestors - Its ancestors, nearest last
1364
+ * @returns {void}
1365
+ */
1366
+ function handleSpread(node, ancestors) {
1367
+ if (!includeSpread) {
1368
+ return;
1369
+ }
1370
+ if (isModuleLevelScope(ancestors)) {
1371
+ return;
1372
+ }
1373
+ addFinding({
1374
+ category: 'syntax',
1375
+ certainty: CERTAINTY_CERTAIN,
1376
+ column: node.loc.start.column + 1, // eslint-disable-line no-magic-numbers
1377
+ line: node.loc.start.line,
1378
+ name: 'spread',
1379
+ type: 'spread',
1380
+ });
1381
+ }
1382
+
1383
+ /** @type {Record<string, (node: ASTNode, ancestors: ASTNode[]) => void>} */
1384
+ const visitors = {
1385
+ Identifier: handleIdentifier,
1386
+ MemberExpression: handleMemberExpr,
1387
+ SpreadElement: handleSpread,
1388
+ };
1389
+
1390
+ traverse(ast).forEach(/** @this {{ parents: TraverseNode[] }} @param {unknown} value */ function (value) {
1391
+ if (value && typeof value === 'object' && /** @type {ASTNode} */ (value).type) {
1392
+ const node = /** @type {ASTNode} */ (value);
1393
+ const handler = visitors[node.type];
1394
+ if (handler) {
1395
+ // Filter parents to only include AST nodes (objects with 'type' property)
1396
+ const ancestors = this.parents.filter((p) => p && typeof p === 'object' && p.node && p.node.type).map((p) => p.node);
1397
+ handler(node, ancestors);
1398
+ }
1399
+ }
1400
+ });
1401
+
1402
+ return { error: null, findings };
1403
+ }
1404
+
1405
+ /**
1406
+ * Analyze many files sequentially.
1407
+ * @param {string[]} filePaths - The files to analyze
1408
+ * @param {AnalyzeOptions} [options] - What to include and how to resolve types
1409
+ * @returns {AnalysisResult}
1410
+ */
1411
+ export function analyzeFiles(filePaths, options = {}) {
1412
+ const { isSafeFile: checkSafe, ...fileOptions } = options;
1413
+ /** @type {Finding[]} */
1414
+ const allFindings = [];
1415
+ /** @type {AnalysisError[]} */
1416
+ const errors = [];
1417
+ for (const filePath of filePaths) {
1418
+ const isSafe = typeof checkSafe === 'function' ? checkSafe(filePath) : false;
1419
+ const result = analyzeFile(filePath, { ...fileOptions, isSafe });
1420
+ if (result.error) {
1421
+ errors[errors.length] = { error: result.error, file: filePath };
1422
+ }
1423
+ allFindings.push(...result.findings);
1424
+ }
1425
+ return { errors, findings: allFindings };
1426
+ }
1427
+
1428
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
1429
+ const WORKER_PATH = path.join(__dirname, 'worker.mjs');
1430
+
1431
+ /**
1432
+ * Analyze many files in parallel using a pool of worker threads, falling back to
1433
+ * sequential analysis for small batches.
1434
+ * @param {string[]} filePaths - The files to analyze
1435
+ * @param {AnalyzeOptions} [options] - What to include and how to resolve types
1436
+ * @returns {Promise<AnalysisResult>}
1437
+ */
1438
+ export async function analyzeFilesParallel(filePaths, options = {}) {
1439
+ const {
1440
+ concurrency = os.cpus().length,
1441
+ isSafeFile: checkSafe,
1442
+ workerPath = WORKER_PATH,
1443
+ ...fileOptions
1444
+ } = options;
1445
+
1446
+ // For small number of files, use sequential processing
1447
+ if (filePaths.length <= concurrency) {
1448
+ return analyzeFiles(filePaths, options);
1449
+ }
1450
+
1451
+ /** @type {Finding[]} */
1452
+ const allFindings = [];
1453
+ /** @type {AnalysisError[]} */
1454
+ const errors = [];
1455
+ let fileIndex = 0;
1456
+
1457
+ /**
1458
+ * Fold a worker's result into the running totals.
1459
+ * @param {string} filePath - The analyzed file
1460
+ * @param {{ error: (string | null), findings: Finding[] }} result - Its result
1461
+ * @returns {void}
1462
+ */
1463
+ function handleResult(filePath, result) {
1464
+ if (result.error) {
1465
+ errors[errors.length] = { error: result.error, file: filePath };
1466
+ }
1467
+ allFindings.push(...result.findings);
1468
+ }
1469
+
1470
+ /**
1471
+ * Create a worker that drains the shared file queue.
1472
+ * @returns {Promise<void>}
1473
+ */
1474
+ function createWorker() {
1475
+ return /** @type {Promise<void>} */ (new Promise((resolve, reject) => {
1476
+ const worker = new Worker(workerPath);
1477
+ let tasksCompleted = 0;
1478
+ let tasksSent = 0;
1479
+
1480
+ /** @returns {void} */
1481
+ function sendNextTask() {
1482
+ if (fileIndex >= filePaths.length) {
1483
+ if (tasksCompleted === tasksSent) {
1484
+ worker.terminate();
1485
+ resolve();
1486
+ }
1487
+ return;
1488
+ }
1489
+
1490
+ const filePath = filePaths[fileIndex];
1491
+ fileIndex += 1;
1492
+ tasksSent += 1;
1493
+
1494
+ const isSafe = typeof checkSafe === 'function' ? checkSafe(filePath) : false;
1495
+ worker.postMessage({
1496
+ filePath,
1497
+ options: { ...fileOptions, isSafe },
1498
+ });
1499
+ }
1500
+
1501
+ worker.on('message', (message) => {
1502
+ const { filePath, result } = message;
1503
+ handleResult(filePath, result);
1504
+ tasksCompleted += 1;
1505
+ sendNextTask();
1506
+ });
1507
+
1508
+ worker.on('error', (err) => {
1509
+ worker.terminate();
1510
+ reject(err);
1511
+ });
1512
+
1513
+ worker.on('exit', (code) => {
1514
+ if (code !== 0 && tasksCompleted !== tasksSent) {
1515
+ reject(new Error(`Worker exited with code ${code}`));
1516
+ }
1517
+ });
1518
+
1519
+ // Start the worker with first task
1520
+ sendNextTask();
1521
+ }));
1522
+ }
1523
+
1524
+ // Create worker pool
1525
+ const numWorkers = Math.min(concurrency, filePaths.length);
1526
+ const workers = [];
1527
+ for (let i = 0; i < numWorkers; i += 1) {
1528
+ workers[workers.length] = createWorker();
1529
+ }
1530
+
1531
+ await Promise.all(workers);
1532
+
1533
+ return { errors, findings: allFindings };
1534
+ }
1535
+
1536
+ /**
1537
+ * The display label for a finding's category: its category, its joined possible categories,
1538
+ * or "unknown" when neither is present.
1539
+ * @param {Finding} finding - The finding
1540
+ * @returns {string}
1541
+ */
1542
+ export function categoryLabel(finding) {
1543
+ return finding.category || (finding.possibleCategories ? finding.possibleCategories.join('/') : 'unknown');
1544
+ }
1545
+
1546
+ /**
1547
+ * Group findings by their category.
1548
+ * @param {Finding[]} findings - The findings to group
1549
+ * @returns {Record<string, Finding[]>}
1550
+ */
1551
+ export function groupByCategory(findings) {
1552
+ /** @type {Record<string, Finding[]>} */
1553
+ const grouped = {};
1554
+ for (const finding of findings) {
1555
+ const category = categoryLabel(finding);
1556
+ if (!grouped[category]) {
1557
+ grouped[category] = [];
1558
+ }
1559
+ grouped[category][grouped[category].length] = finding;
1560
+ }
1561
+ return grouped;
1562
+ }
1563
+
1564
+ /** @type {Record<string, (f: Finding) => string>} */
1565
+ const FINDING_DESCRIPTIONS = {
1566
+ global: (f) => `${f.name}`,
1567
+ instanceMethod: (f) => `.${f.name}()`,
1568
+ prototypeAccess: (f) => `${f.name}`,
1569
+ spread: () => 'spread syntax (...)',
1570
+ staticMethod: (f) => `${f.name}()`,
1571
+ staticProperty: (f) => `${f.name}`,
1572
+ };
1573
+
1574
+ /**
1575
+ * Format a single finding as a TAP test line.
1576
+ * @param {Finding} finding - The finding
1577
+ * @param {number} testNum - Its 1-based test number
1578
+ * @returns {string}
1579
+ */
1580
+ export function formatFindingAsTAP(finding, testNum) {
1581
+ const certaintyNote = finding.certainty === CERTAINTY_UNCERTAIN
1582
+ ? ' [uncertain - could not determine type]'
1583
+ : '';
1584
+ const location = `${finding.file}:${finding.line}:${finding.column}`;
1585
+ const descFn = FINDING_DESCRIPTIONS[finding.type] || ((f) => f.name);
1586
+ const description = descFn(finding);
1587
+ return `not ok ${testNum} - ${location} - ${description}${certaintyNote}`;
1588
+ }
1589
+
1590
+ /**
1591
+ * Format findings as a complete TAP report.
1592
+ * @param {Finding[]} findings - The findings
1593
+ * @param {{ showUncertain?: boolean }} [options] - Whether to include uncertain findings
1594
+ * @returns {string}
1595
+ */
1596
+ export function formatAsTAP(findings, options = {}) {
1597
+ const { showUncertain = true } = options;
1598
+ const filtered = showUncertain
1599
+ ? findings
1600
+ : findings.filter((f) => f.certainty !== CERTAINTY_UNCERTAIN);
1601
+
1602
+ if (filtered.length === 0) {
1603
+ return 'TAP version 14\n1..0\n# No primordial usages found\n';
1604
+ }
1605
+
1606
+ const grouped = groupByCategory(filtered);
1607
+ const lines = ['TAP version 14'];
1608
+ let testNum = 0;
1609
+ let certainCount = 0;
1610
+ let uncertainCount = 0;
1611
+
1612
+ for (const [category, categoryFindings] of Object.entries(grouped).sort()) {
1613
+ lines[lines.length] = `# ${category}`;
1614
+ for (const finding of categoryFindings) {
1615
+ testNum += 1; // eslint-disable-line no-magic-numbers
1616
+ if (finding.certainty === CERTAINTY_CERTAIN) {
1617
+ certainCount += 1; // eslint-disable-line no-magic-numbers
1618
+ } else {
1619
+ uncertainCount += 1; // eslint-disable-line no-magic-numbers
1620
+ }
1621
+ const certaintyNote = finding.certainty === CERTAINTY_UNCERTAIN
1622
+ ? ' [uncertain - could not determine type]'
1623
+ : '';
1624
+ const location = `${finding.file}:${finding.line}:${finding.column}`;
1625
+ const descFn = FINDING_DESCRIPTIONS[finding.type] || ((f) => f.name);
1626
+ const description = descFn(finding);
1627
+ lines[lines.length] = `not ok ${testNum} - ${location} - ${description}${certaintyNote}`;
1628
+ }
1629
+ }
1630
+
1631
+ lines[lines.length] = '';
1632
+ lines[lines.length] = `1..${testNum}`;
1633
+ lines.push(`# ${testNum} primordial usage${testNum === 1 ? '' : 's'} found`); // eslint-disable-line no-magic-numbers
1634
+ if (uncertainCount > 0) {
1635
+ lines[lines.length] = `# (${certainCount} certain, ${uncertainCount} uncertain)`;
1636
+ }
1637
+
1638
+ return `${lines.join('\n')}\n`;
1639
+ }
1640
+
1641
+ // Re-export for backwards compatibility
1642
+ export const groupFindingsByCategory = groupByCategory;
1643
+
1644
+ /** The kinds of fix this module knows how to apply */
1645
+ const FIX_KINDS = /** @type {const} */ ([
1646
+ 'at',
1647
+ 'constructor',
1648
+ 'isNaN',
1649
+ 'push',
1650
+ 'undefined',
1651
+ ]);
1652
+
1653
+ /**
1654
+ * A fresh per-kind fix tally, all zero.
1655
+ * @returns {Record<FixKind, number>}
1656
+ */
1657
+ function emptyFixCounts() {
1658
+ const counts = /** @type {Record<FixKind, number>} */ ({});
1659
+ for (const kind of FIX_KINDS) {
1660
+ counts[kind] = 0;
1661
+ }
1662
+ return counts;
1663
+ }
1664
+
1665
+ /**
1666
+ * The `line:column` key a node is matched by. Findings record a 1-based column, so node
1667
+ * positions are shifted to match.
1668
+ * @param {ASTNode} node - The node
1669
+ * @returns {string}
1670
+ */
1671
+ function nodeKey(node) {
1672
+ return `${node.loc.start.line}:${node.loc.start.column + 1}`; // eslint-disable-line no-magic-numbers
1673
+ }
1674
+
1675
+ /**
1676
+ * The source text a node spans.
1677
+ * @param {string} content - The file's source
1678
+ * @param {ASTNode} node - The node
1679
+ * @returns {string}
1680
+ */
1681
+ function sourceText(content, node) {
1682
+ return content.slice(node.range[0], node.range[1]); // eslint-disable-line no-magic-numbers
1683
+ }
1684
+
1685
+ /**
1686
+ * Whether naming an expression twice yields the same value both times: a variable,
1687
+ * `this`, a literal, or a fixed property path off one. A call does not qualify - it
1688
+ * would simply run a second time.
1689
+ *
1690
+ * A property read is taken at face value. Reaching it twice means reaching a getter
1691
+ * twice, and a getter that answers differently each time, or counts its reads, will
1692
+ * not survive the rewrite - but a getter like that breaks its own contract.
1693
+ * @param {MaybeNode} node - The node that would be named twice
1694
+ * @returns {boolean}
1695
+ */
1696
+ export function isRepeatable(node) {
1697
+ if (!node) {
1698
+ return false;
1699
+ }
1700
+ if (node.type === 'Identifier' || node.type === 'ThisExpression' || node.type === 'Literal') {
1701
+ return true;
1702
+ }
1703
+ // a computed key is an expression of its own, and could be anything
1704
+ return node.type === 'MemberExpression' && !node.computed && isRepeatable(node.object);
1705
+ }
1706
+
1707
+ /**
1708
+ * Whether evaluating an expression has nothing to observe beyond a well-behaved read.
1709
+ * Weaker than `isRepeatable`, which also demands the very same value: a literal built
1710
+ * only from such parts qualifies here but not there, since each evaluation builds a new
1711
+ * one.
1712
+ *
1713
+ * This is what a rewrite needs when it evaluates an expression a second time only to
1714
+ * read through it (`arr.at(-1)` reads its `length`), or when it keeps the expression
1715
+ * but moves when it runs.
1716
+ * @param {MaybeNode} node - The node being evaluated
1717
+ * @returns {boolean}
1718
+ */
1719
+ export function isReevaluable(node) {
1720
+ if (isRepeatable(node)) {
1721
+ return true;
1722
+ }
1723
+ switch (node?.type) {
1724
+ case 'MemberExpression':
1725
+ return isReevaluable(node.object) && (!node.computed || isReevaluable(node.property));
1726
+ case 'ArrayExpression':
1727
+ // a spread reads through something arbitrary
1728
+ return node.elements.every((el) => el === null || (el.type !== 'SpreadElement' && isReevaluable(el)));
1729
+ case 'ObjectExpression':
1730
+ // an accessor is a call in waiting, and a spread reads through something arbitrary
1731
+ return node.properties.every((p) => p.type === 'Property' && p.kind === 'init' && (!p.computed || isReevaluable(p.key)) && isReevaluable(/** @type {ASTNode} */ (p.value)));
1732
+ case 'BinaryExpression':
1733
+ case 'LogicalExpression':
1734
+ return isReevaluable(node.left) && isReevaluable(node.right);
1735
+ case 'ConditionalExpression':
1736
+ return isReevaluable(node.test) && isReevaluable(node.consequent) && isReevaluable(node.alternate);
1737
+ case 'UnaryExpression':
1738
+ // `delete` is the one unary operator that changes something
1739
+ return node.operator !== 'delete' && isReevaluable(node.argument);
1740
+ case 'TemplateLiteral':
1741
+ return node.expressions.every(isReevaluable);
1742
+ case 'SequenceExpression':
1743
+ return node.expressions.every(isReevaluable);
1744
+ default:
1745
+ return false;
1746
+ }
1747
+ }
1748
+
1749
+ /**
1750
+ * The integer an `.at()` argument resolves to, or null if it isn't a plain integer literal.
1751
+ * @param {ASTNode} arg - The argument node
1752
+ * @returns {number | null}
1753
+ */
1754
+ export function literalIndex(arg) {
1755
+ /** @type {unknown} */
1756
+ let index = null;
1757
+ if (arg.type === 'Literal') {
1758
+ index = arg.value;
1759
+ } else if (arg.type === 'UnaryExpression' && arg.operator === '-' && arg.argument?.type === 'Literal') {
1760
+ index = -(/** @type {number} */ (arg.argument.value));
1761
+ }
1762
+ // `.at()` truncates toward zero, so only integers survive the rewrite unchanged
1763
+ return typeof index === 'number' && Number.isInteger(index) ? index : null;
1764
+ }
1765
+
1766
+ /**
1767
+ * Whether `{` in this position would open a block rather than an object literal.
1768
+ * @param {ASTNode} node - The node being replaced
1769
+ * @param {MaybeNode} parent - Its parent
1770
+ * @returns {boolean}
1771
+ */
1772
+ export function startsAStatement(node, parent) {
1773
+ if (parent?.type === 'ExpressionStatement') {
1774
+ return true;
1775
+ }
1776
+ return parent?.type === 'ArrowFunctionExpression' && parent.body === node;
1777
+ }
1778
+
1779
+ /**
1780
+ * Whether `Array(...args)` has an equivalent array literal. A lone argument sets the
1781
+ * length rather than the contents, and a spread can stand for any number of arguments -
1782
+ * including that one.
1783
+ * @param {ASTNode[]} args - The call's arguments
1784
+ * @returns {boolean}
1785
+ */
1786
+ export function canBeArrayLiteral(args) {
1787
+ return args.length > 1 && !args.some((arg) => arg.type === 'SpreadElement'); // eslint-disable-line no-magic-numbers
1788
+ }
1789
+
1790
+ /**
1791
+ * Whether replacing `node` with `void undefined` needs parens to stay valid.
1792
+ * `void undefined ** n` is a syntax error.
1793
+ * @param {ASTNode} node - The `undefined` node
1794
+ * @param {MaybeNode} parent - Its parent
1795
+ * @returns {boolean}
1796
+ */
1797
+ export function voidNeedsParens(node, parent) {
1798
+ return parent?.type === 'BinaryExpression' && parent.operator === '**' && parent.left === node;
1799
+ }
1800
+
1801
+ /**
1802
+ * Whether an `undefined` in this position can be rewritten at all.
1803
+ * `{ undefined }` names the value through the key, so the value has nowhere to be rewritten.
1804
+ * @param {MaybeNode} parent - The parent of the `undefined` node
1805
+ * @returns {boolean}
1806
+ */
1807
+ export function canRewriteUndefined(parent) {
1808
+ if (parent?.type === 'UnaryExpression' && parent.operator === 'void') {
1809
+ return false;
1810
+ }
1811
+ return !(parent?.type === 'Property' && parent.shorthand);
1812
+ }
1813
+
1814
+ /**
1815
+ * The fix for a global finding: `undefined` -> `void undefined`, and argument-less
1816
+ * `Array`/`Object` construction -> a literal.
1817
+ * @param {ASTNode} node - The global identifier
1818
+ * @param {MaybeNode} parent - Its parent
1819
+ * @param {MaybeNode} grandparent - Its grandparent
1820
+ * @param {string} content - The file's source
1821
+ * @returns {Fix | null}
1822
+ */
1823
+ function getGlobalFix(node, parent, grandparent, content) {
1824
+ if (node.name === 'undefined') {
1825
+ if (!canRewriteUndefined(parent)) {
1826
+ return null;
1827
+ }
1828
+ return {
1829
+ end: node.range[1], // eslint-disable-line no-magic-numbers
1830
+ kind: 'undefined',
1831
+ replacement: voidNeedsParens(node, parent) ? '(void undefined)' : 'void undefined',
1832
+ start: node.range[0],
1833
+ };
1834
+ }
1835
+
1836
+ const constructs = (parent?.type === 'NewExpression' || parent?.type === 'CallExpression') && parent.callee === node;
1837
+ if (!constructs) {
1838
+ return null;
1839
+ }
1840
+
1841
+ const args = parent.arguments;
1842
+ const range = {
1843
+ end: parent.range[1], // eslint-disable-line no-magic-numbers
1844
+ kind: /** @type {FixKind} */ ('constructor'),
1845
+ start: parent.range[0],
1846
+ };
1847
+
1848
+ if (node.name === 'Array') {
1849
+ if (args.length === 0) {
1850
+ return { ...range, replacement: '[]' };
1851
+ }
1852
+ if (canBeArrayLiteral(args)) {
1853
+ return { ...range, replacement: `[${args.map((arg) => sourceText(content, arg)).join(', ')}]` };
1854
+ }
1855
+ return null;
1856
+ }
1857
+
1858
+ // Object(x) coerces its argument, so only the argument-less form is a plain object literal
1859
+ if (node.name === 'Object' && args.length === 0) {
1860
+ // where a statement could begin, a bare `{}` would parse as an empty block
1861
+ return { ...range, replacement: startsAStatement(parent, grandparent) ? '({})' : '{}' };
1862
+ }
1863
+ return null;
1864
+ }
1865
+
1866
+ /**
1867
+ * The fix for an instance-method finding: `arr.push(x)` -> an index assignment, and
1868
+ * `arr.at(i)` -> an index access.
1869
+ * @param {ASTNode} node - The MemberExpression
1870
+ * @param {Finding} finding - The finding being fixed
1871
+ * @param {MaybeNode} parent - The node's parent
1872
+ * @param {MaybeNode} grandparent - The node's grandparent
1873
+ * @param {string} content - The file's source
1874
+ * @returns {Fix | null}
1875
+ */
1876
+ function getInstanceMethodFix(node, finding, parent, grandparent, content) {
1877
+ if (finding.certainty !== CERTAINTY_CERTAIN) {
1878
+ return null;
1879
+ }
1880
+ if (parent?.type !== 'CallExpression' || parent.callee !== node) {
1881
+ return null;
1882
+ }
1883
+
1884
+ const args = parent.arguments;
1885
+ const range = {
1886
+ end: parent.range[1], // eslint-disable-line no-magic-numbers
1887
+ start: parent.range[0],
1888
+ };
1889
+
1890
+ if (finding.name === 'push') {
1891
+ if (args.length !== 1 || args[0].type === 'SpreadElement') { // eslint-disable-line no-magic-numbers
1892
+ return null;
1893
+ }
1894
+ // a used return value is the new length, which the assignment form does not produce
1895
+ if (grandparent?.type !== 'ExpressionStatement') {
1896
+ return null;
1897
+ }
1898
+ // the assignment names the object twice
1899
+ if (!isRepeatable(node.object)) {
1900
+ return null;
1901
+ }
1902
+ /*
1903
+ * push evaluates its argument before reading the length, while the assignment
1904
+ * resolves its target - length and all - first. Only an argument with nothing
1905
+ * to observe survives that reordering.
1906
+ */
1907
+ if (!isReevaluable(args[0])) {
1908
+ return null;
1909
+ }
1910
+ const objectText = sourceText(content, node.object);
1911
+ return {
1912
+ ...range,
1913
+ kind: 'push',
1914
+ replacement: `${objectText}[${objectText}.length] = ${sourceText(content, args[0])}`,
1915
+ };
1916
+ }
1917
+
1918
+ if (finding.name === 'at') {
1919
+ if (args.length !== 1) { // eslint-disable-line no-magic-numbers
1920
+ return null;
1921
+ }
1922
+ const index = literalIndex(args[0]);
1923
+ if (index === null) {
1924
+ return null;
1925
+ }
1926
+ const objectText = sourceText(content, node.object);
1927
+ if (index >= 0) {
1928
+ return {
1929
+ ...range, kind: 'at', replacement: `${objectText}[${index}]`,
1930
+ };
1931
+ }
1932
+ // counting back from the end evaluates the object a second time, to read its length
1933
+ if (!isReevaluable(node.object)) {
1934
+ return null;
1935
+ }
1936
+ return {
1937
+ ...range, kind: 'at', replacement: `${objectText}[${objectText}.length - ${-index}]`,
1938
+ };
1939
+ }
1940
+
1941
+ return null;
1942
+ }
1943
+
1944
+ /**
1945
+ * The fix for a static-method finding: `Number.isNaN(x)` -> `(x !== x)`.
1946
+ * @param {ASTNode} node - The MemberExpression
1947
+ * @param {Finding} finding - The finding being fixed
1948
+ * @param {MaybeNode} parent - The node's parent
1949
+ * @param {string} content - The file's source
1950
+ * @returns {Fix | null}
1951
+ */
1952
+ function getStaticMethodFix(node, finding, parent, content) {
1953
+ if (finding.name !== 'Number.isNaN') {
1954
+ return null;
1955
+ }
1956
+ if (parent?.type !== 'CallExpression' || parent.callee !== node || parent.arguments.length !== 1) { // eslint-disable-line no-magic-numbers
1957
+ return null;
1958
+ }
1959
+ // the comparison names the argument twice
1960
+ const arg = parent.arguments[0];
1961
+ if (!isRepeatable(arg)) {
1962
+ return null;
1963
+ }
1964
+ const argText = sourceText(content, arg);
1965
+ /*
1966
+ * The parens keep the comparison intact wherever the call sat: without them
1967
+ * `!Number.isNaN(x)` would rewrite to `!x !== x`.
1968
+ */
1969
+ return {
1970
+ end: parent.range[1], // eslint-disable-line no-magic-numbers
1971
+ kind: 'isNaN',
1972
+ replacement: `(${argText} !== ${argText})`,
1973
+ start: parent.range[0],
1974
+ };
1975
+ }
1976
+
1977
+ /**
1978
+ * The name a static access reads as, so `Number.isNaN.call` cannot pass for `Number.isNaN`.
1979
+ * @param {ASTNode} node - The MemberExpression
1980
+ * @returns {string | null}
1981
+ */
1982
+ function staticName(node) {
1983
+ return node.object?.type === 'Identifier' && node.property?.type === 'Identifier'
1984
+ ? `${node.object.name}.${node.property.name}`
1985
+ : null;
1986
+ }
1987
+
1988
+ /**
1989
+ * @typedef {object} FixContext
1990
+ * @property {string} content - The file's source
1991
+ * @property {MaybeNode} parent - The current node's parent
1992
+ * @property {MaybeNode} grandparent - The current node's grandparent
1993
+ * @property {Set<string>} kinds - The fix kinds to apply
1994
+ */
1995
+
1996
+ /**
1997
+ * The fix a single finding calls for at this node, if the node is the one it describes.
1998
+ * @param {ASTNode} node - The node under consideration
1999
+ * @param {Finding} finding - The candidate finding
2000
+ * @param {FixContext} ctx - The surrounding context
2001
+ * @returns {Fix | null}
2002
+ */
2003
+ function getFixFor(node, finding, ctx) {
2004
+ if (finding.type === 'global' && node.type === 'Identifier' && node.name === finding.name) {
2005
+ return getGlobalFix(node, ctx.parent, ctx.grandparent, ctx.content);
2006
+ }
2007
+ if (finding.type === 'instanceMethod' && node.type === 'MemberExpression' && node.property?.name === finding.name) {
2008
+ return getInstanceMethodFix(node, finding, ctx.parent, ctx.grandparent, ctx.content);
2009
+ }
2010
+ if (finding.type === 'staticMethod' && node.type === 'MemberExpression' && staticName(node) === finding.name) {
2011
+ return getStaticMethodFix(node, finding, ctx.parent, ctx.content);
2012
+ }
2013
+ return null;
2014
+ }
2015
+
2016
+ /**
2017
+ * The fix called for by the first finding at this position that asks for one.
2018
+ * @param {ASTNode} node - The node under consideration
2019
+ * @param {Finding[]} candidates - The findings recorded at this position
2020
+ * @param {FixContext} ctx - The surrounding context
2021
+ * @returns {Fix | null}
2022
+ */
2023
+ function firstFix(node, candidates, ctx) {
2024
+ for (const finding of candidates) {
2025
+ const fix = getFixFor(node, finding, ctx);
2026
+ if (fix && ctx.kinds.has(fix.kind)) {
2027
+ return fix;
2028
+ }
2029
+ }
2030
+ return null;
2031
+ }
2032
+
2033
+ /**
2034
+ * Collect every fix available for the reported findings.
2035
+ * @param {string} content - The file's source
2036
+ * @param {ASTNode} ast - The parsed AST
2037
+ * @param {Finding[]} findings - The findings to fix
2038
+ * @param {Set<string>} kinds - The fix kinds to apply
2039
+ * @returns {Fix[]}
2040
+ */
2041
+ function collectFixes(content, ast, findings, kinds) {
2042
+ /*
2043
+ * `Number.isNaN` and its `Number` both start where the finding says, so a
2044
+ * position can name several findings and each node has to pick out its own.
2045
+ */
2046
+ /** @type {Map<string, Finding[]>} */
2047
+ const findingMap = new Map();
2048
+ for (const f of findings) {
2049
+ const key = `${f.line}:${f.column}`;
2050
+ if (!findingMap.has(key)) {
2051
+ findingMap.set(key, []);
2052
+ }
2053
+ findingMap.get(key)?.push(f);
2054
+ }
2055
+
2056
+ /** @type {Fix[]} */
2057
+ const fixes = [];
2058
+ traverse(ast).forEach(/** @this {{ parent?: TraverseNode }} @param {unknown} value */ function (value) {
2059
+ if (!value || typeof value !== 'object') {
2060
+ return;
2061
+ }
2062
+ const node = /** @type {ASTNode} */ (value);
2063
+ if (typeof node.type !== 'string' || !node.loc) {
2064
+ return;
2065
+ }
2066
+
2067
+ const candidates = findingMap.get(nodeKey(node));
2068
+ if (!candidates) {
2069
+ return;
2070
+ }
2071
+
2072
+ const fix = firstFix(node, candidates, {
2073
+ content,
2074
+ grandparent: this.parent?.parent?.node,
2075
+ kinds,
2076
+ parent: this.parent?.node,
2077
+ });
2078
+ if (fix) {
2079
+ fixes[fixes.length] = fix;
2080
+ }
2081
+ });
2082
+ return fixes;
2083
+ }
2084
+
2085
+ /**
2086
+ * Apply the fixes that don't overlap, preferring the outermost of any that do.
2087
+ * @param {string} content - The file's source
2088
+ * @param {Fix[]} fixes - The candidate fixes
2089
+ * @returns {FixResult}
2090
+ */
2091
+ function applyFixList(content, fixes) {
2092
+ const ordered = [...fixes].sort((a, b) => a.start - b.start || b.end - a.end);
2093
+
2094
+ const counts = emptyFixCounts();
2095
+ /** @type {Fix[]} */
2096
+ const kept = [];
2097
+ let lastEnd = 0;
2098
+ for (const fix of ordered) {
2099
+ // an inner fix is dropped here; re-analyzing the output surfaces it again
2100
+ if (fix.start >= lastEnd) {
2101
+ kept[kept.length] = fix;
2102
+ counts[fix.kind] += 1;
2103
+ lastEnd = fix.end;
2104
+ }
2105
+ }
2106
+
2107
+ let output = content;
2108
+ for (let i = kept.length - 1; i >= 0; i -= 1) { // eslint-disable-line no-magic-numbers
2109
+ output = output.slice(0, kept[i].start) + kept[i].replacement + output.slice(kept[i].end);
2110
+ }
2111
+
2112
+ return {
2113
+ fixCount: kept.length,
2114
+ fixCounts: counts,
2115
+ fixed: true,
2116
+ output,
2117
+ };
2118
+ }
2119
+
2120
+ /**
2121
+ * Parse source into an AST, or null if it doesn't parse.
2122
+ * @param {string} content - The source to parse
2123
+ * @returns {ASTNode | null}
2124
+ */
2125
+ function tryParse(content) {
2126
+ try {
2127
+ return /** @type {ASTNode} */ (/** @type {unknown} */ (parse(content, {
2128
+ ecmaFeatures: { jsx: true },
2129
+ ecmaVersion: 'latest',
2130
+ loc: true,
2131
+ range: true,
2132
+ sourceType: 'module',
2133
+ })));
2134
+ } catch {
2135
+ return null;
2136
+ }
2137
+ }
2138
+
2139
+ /**
2140
+ * Rewrite a file, applying only the requested kinds of fix. Refuses to return a rewrite
2141
+ * that no longer parses.
2142
+ * @param {string} filePath - The file to rewrite
2143
+ * @param {Finding[]} findings - The findings to fix
2144
+ * @param {Set<string>} kinds - The fix kinds to apply
2145
+ * @returns {FixResult}
2146
+ */
2147
+ function runFixes(filePath, findings, kinds) {
2148
+ const content = fs.readFileSync(filePath, 'utf8');
2149
+ const unchanged = {
2150
+ fixCount: 0,
2151
+ fixCounts: emptyFixCounts(),
2152
+ fixed: false,
2153
+ output: content,
2154
+ };
2155
+
2156
+ const fileFindings = findings.filter((f) => f.file === filePath);
2157
+ if (fileFindings.length === 0) {
2158
+ return unchanged;
2159
+ }
2160
+
2161
+ const ast = tryParse(content);
2162
+ if (!ast) {
2163
+ return unchanged;
2164
+ }
2165
+
2166
+ const fixes = collectFixes(content, ast, fileFindings, kinds);
2167
+ if (fixes.length === 0) {
2168
+ return unchanged;
2169
+ }
2170
+
2171
+ const result = applyFixList(content, fixes);
2172
+ /*
2173
+ * Every rewrite above is meant to be equivalent, so this should never catch anything.
2174
+ * It is here because the alternative to catching it is writing a broken file.
2175
+ */
2176
+ return tryParse(result.output) ? result : unchanged;
2177
+ }
2178
+
2179
+ /**
2180
+ * Apply push-to-assignment fixes to a file.
2181
+ * @param {string} filePath - Path to the file to fix
2182
+ * @param {Finding[]} findings - Findings to fix (filtered to fixable push findings)
2183
+ * @returns {FixResult}
2184
+ */
2185
+ export function applyPushFixes(filePath, findings) {
2186
+ return runFixes(filePath, findings, new Set(['push']));
2187
+ }
2188
+
2189
+ /**
2190
+ * Apply undefined-to-void fixes to a file.
2191
+ * @param {string} filePath - Path to the file to fix
2192
+ * @param {Finding[]} findings - Findings to fix (filtered to fixable undefined findings)
2193
+ * @returns {FixResult}
2194
+ */
2195
+ export function applyUndefinedFixes(filePath, findings) {
2196
+ return runFixes(filePath, findings, new Set(['undefined']));
2197
+ }
2198
+
2199
+ /**
2200
+ * Apply every available fix to a file, in a single pass.
2201
+ * Fixes move the positions the findings recorded, so callers that want the fixes
2202
+ * this pass had to drop should re-analyze the output and call again.
2203
+ * @param {string} filePath - Path to the file to fix
2204
+ * @param {Finding[]} findings - Findings to fix (only reported findings are fixed)
2205
+ * @returns {FixResult}
2206
+ */
2207
+ export function applyFixes(filePath, findings) {
2208
+ return runFixes(filePath, findings, new Set(FIX_KINDS));
2209
+ }