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/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/analyzer.mjs +2209 -0
- package/ignore.mjs +161 -0
- package/index.d.mts +201 -0
- package/index.mjs +381 -0
- package/package.json +85 -0
- package/primordials.d.mts +32 -0
- package/primordials.mjs +836 -0
- package/worker.mjs +35 -0
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
|
+
}
|