forked from eslint/eslint
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathcode-sample-minimizer.js
205 lines (178 loc) · 7.55 KB
/
code-sample-minimizer.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
"use strict";
const evk = require("eslint-visitor-keys");
const recast = require("recast");
const espree = require("espree");
const assert = require("node:assert");
/**
* Determines whether an AST node could be an expression, based on the type
* @param {ASTNode} node The node
* @returns {boolean} `true` if the node could be an expression
*/
function isMaybeExpression(node) {
return node.type.endsWith("Expression") ||
node.type === "Identifier" ||
node.type === "MetaProperty" ||
node.type.endsWith("Literal");
}
/**
* Determines whether an AST node is a statement
* @param {ASTNode} node The node
* @returns {boolean} `true` if the node is a statement
*/
function isStatement(node) {
return node.type.endsWith("Statement") || node.type.endsWith("Declaration");
}
/**
* Given "bad" source text (e.g. an code sample that causes a rule to crash), tries to return a smaller
* piece of source text which is also "bad", to make it easier for a human to figure out where the
* problem is.
* @param {Object} options Options to process
* @param {string} options.sourceText Initial piece of "bad" source text
* @param {function(string): boolean} options.predicate A predicate that returns `true` for bad source text and `false` for good source text
* @param {Parser} [options.parser] The parser used to parse the source text. Defaults to a modified
* version of espree that uses recent parser options.
* @param {Object} [options.visitorKeys] The visitor keys of the AST. Defaults to eslint-visitor-keys.
* @returns {string} Another piece of "bad" source text, which may or may not be smaller than the original source text.
*/
function reduceBadExampleSize({
sourceText,
predicate,
parser = {
parse: (code, options) =>
espree.parse(code, {
...options,
loc: true,
range: true,
raw: true,
tokens: true,
comment: true,
eslintVisitorKeys: true,
eslintScopeManager: true,
ecmaVersion: espree.latestEcmaVersion,
sourceType: "script"
})
},
visitorKeys = evk.KEYS
}) {
let counter = 0;
/**
* Returns a new unique identifier
* @returns {string} A name for a new identifier
*/
function generateNewIdentifierName() {
return `$${(counter++)}`;
}
/**
* Determines whether a source text sample is "bad"
* @param {string} updatedSourceText The sample
* @returns {boolean} `true` if the sample is "bad"
*/
function reproducesBadCase(updatedSourceText) {
try {
parser.parse(updatedSourceText);
} catch {
return false;
}
return predicate(updatedSourceText);
}
assert(reproducesBadCase(sourceText), "Original source text should reproduce issue");
const parseResult = recast.parse(sourceText, { parser });
/**
* Recursively removes descendant subtrees of the given AST node and replaces
* them with simplified variants to produce a simplified AST which is still considered "bad".
* @param {ASTNode} node An AST node to prune. May be mutated by this call, but the
* resulting AST will still produce "bad" source code.
* @returns {void}
*/
function pruneIrrelevantSubtrees(node) {
for (const key of visitorKeys[node.type]) {
if (Array.isArray(node[key])) {
for (let index = node[key].length - 1; index >= 0; index--) {
const [childNode] = node[key].splice(index, 1);
if (!reproducesBadCase(recast.print(parseResult).code)) {
node[key].splice(index, 0, childNode);
if (childNode) {
pruneIrrelevantSubtrees(childNode);
}
}
}
} else if (typeof node[key] === "object" && node[key] !== null) {
const childNode = node[key];
if (isMaybeExpression(childNode)) {
node[key] = { type: "Identifier", name: generateNewIdentifierName(), range: childNode.range };
if (!reproducesBadCase(recast.print(parseResult).code)) {
node[key] = childNode;
pruneIrrelevantSubtrees(childNode);
}
} else if (isStatement(childNode)) {
node[key] = { type: "EmptyStatement", range: childNode.range };
if (!reproducesBadCase(recast.print(parseResult).code)) {
node[key] = childNode;
pruneIrrelevantSubtrees(childNode);
}
}
}
}
}
/**
* Recursively tries to extract a descendant node from the AST that is "bad" on its own
* @param {ASTNode} node A node which produces "bad" source code
* @returns {ASTNode} A descendent of `node` which is also bad
*/
function extractRelevantChild(node) {
const childNodes = visitorKeys[node.type]
.flatMap(key => (Array.isArray(node[key]) ? node[key] : [node[key]]));
for (const childNode of childNodes) {
if (!childNode) {
continue;
}
if (isMaybeExpression(childNode)) {
if (reproducesBadCase(recast.print(childNode).code)) {
return extractRelevantChild(childNode);
}
} else if (isStatement(childNode)) {
if (reproducesBadCase(recast.print(childNode).code)) {
return extractRelevantChild(childNode);
}
} else {
const childResult = extractRelevantChild(childNode);
if (reproducesBadCase(recast.print(childResult).code)) {
return childResult;
}
}
}
return node;
}
/**
* Removes and simplifies comments from the source text
* @param {string} text A piece of "bad" source text
* @returns {string} A piece of "bad" source text with fewer and/or simpler comments.
*/
function removeIrrelevantComments(text) {
const ast = parser.parse(text);
if (ast.comments) {
for (const comment of ast.comments) {
for (const potentialSimplification of [
// Try deleting the comment
`${text.slice(0, comment.range[0])}${text.slice(comment.range[1])}`,
// Try replacing the comment with a space
`${text.slice(0, comment.range[0])} ${text.slice(comment.range[1])}`,
// Try deleting the contents of the comment
text.slice(0, comment.range[0] + 2) + text.slice(comment.type === "Block" ? comment.range[1] - 2 : comment.range[1])
]) {
if (reproducesBadCase(potentialSimplification)) {
return removeIrrelevantComments(potentialSimplification);
}
}
}
}
return text;
}
pruneIrrelevantSubtrees(parseResult.program);
const relevantChild = recast.print(extractRelevantChild(parseResult.program)).code;
assert(reproducesBadCase(relevantChild), "Extracted relevant source text should reproduce issue");
const result = removeIrrelevantComments(relevantChild);
assert(reproducesBadCase(result), "Source text with irrelevant comments removed should reproduce issue");
return result;
}
module.exports = reduceBadExampleSize;