@eagleoutice/eslint-config-flowr 2.1.8 → 2.1.10
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/README.md +11 -1
- package/index.js +2 -1
- package/package.json +1 -1
- package/pattern-type.d.ts +2 -0
- package/patterns.d.ts +1 -3
- package/patterns.js +488 -209
- package/rules/replacement-pattern.js +26 -7
- package/rules/ts-types.d.ts +7 -0
- package/rules/use-instead.js +26 -15
- package/rules/util.d.ts +13 -1
- package/rules/util.js +111 -6
package/patterns.js
CHANGED
|
@@ -1,60 +1,75 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
3
|
* Default patterns of `flowr/replacement-pattern`, see the README for the format.
|
|
4
|
-
* The helpers they point at live in flowR
|
|
5
|
-
* `dataflow/graph/vertex`, `NoEdges` in `dataflow/graph/graph`, the `RNode` helpers in
|
|
6
|
-
* `r-bridge/lang-4.x/ast/model`, and the collection helpers in `util/collections`.
|
|
4
|
+
* The helpers they point at live in flowR, the paths below are the files declaring them.
|
|
7
5
|
*/
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
6
|
+
const Files = {
|
|
7
|
+
edge: 'dataflow/graph/edge',
|
|
8
|
+
vertex: 'dataflow/graph/vertex',
|
|
9
|
+
graph: 'dataflow/graph/graph',
|
|
10
|
+
rType: 'r-bridge/lang-4.x/ast/model/type',
|
|
11
|
+
call: 'r-bridge/lang-4.x/ast/model/nodes/r-function-call',
|
|
12
|
+
argument: 'r-bridge/lang-4.x/ast/model/nodes/r-argument',
|
|
13
|
+
symbol: 'r-bridge/lang-4.x/ast/model/nodes/r-symbol',
|
|
14
|
+
string: 'r-bridge/lang-4.x/ast/model/nodes/r-string',
|
|
15
|
+
list: 'r-bridge/lang-4.x/ast/model/nodes/r-expression-list',
|
|
16
|
+
model: 'r-bridge/lang-4.x/ast/model/model',
|
|
17
|
+
nodeId: 'r-bridge/lang-4.x/ast/model/processing/node-id',
|
|
18
|
+
range: 'util/range',
|
|
19
|
+
identifier: 'dataflow/environments/identifier',
|
|
20
|
+
environment: 'dataflow/environments/environment',
|
|
21
|
+
arrays: 'util/collections/arrays',
|
|
22
|
+
strings: 'util/text/strings'
|
|
23
|
+
};
|
|
24
|
+
/* selector parts, each about the node at the given path, `''` being the match itself */
|
|
25
|
+
const at = (path, key) => path === '' ? key : `${path}.${key}`;
|
|
26
|
+
/** `x.<name>`, without asserting the node kind */
|
|
27
|
+
const member = (path, name) => `[${at(path, 'computed')}=false][${at(path, 'property.name')}="${name}"]`;
|
|
28
|
+
/** `x.<name>` */
|
|
29
|
+
const prop = (path, name) => `[${at(path, 'type')}="MemberExpression"]${member(path, name)}`;
|
|
30
|
+
/** a call of `x.<method>(...)` */
|
|
31
|
+
const call = (path, method) => `[${at(path, 'type')}="CallExpression"]${prop(at(path, 'callee'), method)}`;
|
|
32
|
+
/** a call of `<Helper>.<method>(...)` with `n` arguments */
|
|
33
|
+
const helperCall = (path, helper, method, n = 1) => `${call(path, method)}[${at(path, 'callee.object.name')}="${helper}"][${at(path, 'arguments.length')}=${n}]`;
|
|
34
|
+
/** the identifier `<name>` */
|
|
35
|
+
const identifier = (path, name) => `[${at(path, 'type')}="Identifier"][${at(path, 'name')}="${name}"]`;
|
|
36
|
+
/** the string literal `<value>`, or any string literal */
|
|
37
|
+
const text = (path, value) => `[${at(path, 'type')}="Literal"][${at(path, 'value')}=${value === undefined ? 'type(string)' : `"${value}"`}]`;
|
|
38
|
+
/** `!x` */
|
|
39
|
+
const not = (path) => `[${at(path, 'type')}="UnaryExpression"][${at(path, 'operator')}="!"]`;
|
|
40
|
+
/** `x === undefined` or, `set`, `x !== undefined` */
|
|
41
|
+
const versusUndefined = (at, set) => `[${at}.type="BinaryExpression"][${at}.operator="${set ? '!==' : '==='}"]${identifier(`${at}.right`, 'undefined')}`;
|
|
42
|
+
/** `<Enum>.<Member>` */
|
|
43
|
+
const enumMember = (at, name) => `[${at}.type="MemberExpression"][${at}.computed=false][${at}.object.name="${name}"]`;
|
|
44
|
+
/** `x.types` */
|
|
45
|
+
const types = (at) => prop(at, 'types');
|
|
46
|
+
/** compared against `0`, for "some bit is set" or, `nonZero` false, for "none is" */
|
|
25
47
|
const versusZero = (nonZero) => `BinaryExpression[operator=${nonZero ? '/^(!==|>)$/' : '"==="'}][right.type="Literal"][right.value=0]`;
|
|
26
|
-
/** the
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const versusUndefined = (prefix, set) => `[${prefix}.type="BinaryExpression"][${prefix}.operator="${set ? '!==' : '==='}"][${prefix}.right.type="Identifier"][${prefix}.right.name="undefined"]`;
|
|
37
|
-
const destructuringHint = ' Prefer keeping the edge over destructuring `types`, the helpers take it directly and the wrapper object goes away.';
|
|
38
|
-
/** `x.types === T`, on the edge itself or on a destructured `types` */
|
|
39
|
-
const edgeIsOnly = (id, { negated = false, destructured = false } = {}) => ({
|
|
40
|
-
id,
|
|
41
|
-
selector: `BinaryExpression[operator="${negated ? '!==' : '==='}"]`
|
|
42
|
-
+ (destructured ? `[left.type="Identifier"][left.name="types"]${rightIs('EdgeType')}` : `${types('left')}:not([right.type="Literal"])`),
|
|
48
|
+
/** the root as `===` or, `negated`, `!==` */
|
|
49
|
+
const equality = (negated) => `BinaryExpression[operator="${negated ? '!==' : '==='}"]`;
|
|
50
|
+
const bang = (negated) => negated ? '!' : '';
|
|
51
|
+
/** A pattern and its negated twin, under the two ids given. */
|
|
52
|
+
const negatable = (ids, make) => [{ id: ids[0], ...make(false) }, { id: ids[1], ...make(true) }];
|
|
53
|
+
/** A pattern and its twin spelled with `?.`, which wraps the member expression in a chain. */
|
|
54
|
+
const optionalToo = (ids, make) => [{ id: ids[0], ...make(false) }, { id: ids[1], ...make(true) }];
|
|
55
|
+
/** `x.types === T`, on the edge or on a destructured `types` */
|
|
56
|
+
const edgeIsOnly = (negated, destructured) => ({
|
|
57
|
+
selector: equality(negated) + (destructured ? `${identifier('left', 'types')}${enumMember('right', 'EdgeType')}` : `${types('left')}:not([right.type="Literal"])`),
|
|
43
58
|
capture: destructured ? { type: 'right' } : { edge: 'left.object', type: 'right' },
|
|
44
|
-
replace: `${negated
|
|
45
|
-
declaredIn: destructured ? { 'right.object':
|
|
59
|
+
replace: `${bang(negated)}DfEdge.isOnlyType(${destructured ? '{ types }' : '{{edge}}'}, {{type}})`,
|
|
60
|
+
declaredIn: destructured ? { 'right.object': Files.edge } : { 'left.property': Files.edge },
|
|
46
61
|
message: `\`${destructured ? '' : 'x.'}types ${negated ? '!==' : '==='} T\` `
|
|
47
62
|
+ (negated ? 'holds as soon as any other type is set' : 'holds only if T is the *only* type')
|
|
48
|
-
+ `; say so with \`${negated
|
|
49
|
-
+ (destructured ?
|
|
63
|
+
+ `; say so with \`${bang(negated)}DfEdge.isOnlyType\`, or use \`DfEdge.${negated ? 'doesNotIncludeType' : 'includesType'}\` for "${negated ? 'has not' : 'has'} this type".`
|
|
64
|
+
+ (destructured ? ' Prefer keeping the edge over destructuring `types`, the helpers take it directly.' : '')
|
|
50
65
|
});
|
|
51
66
|
/** `(x.types & T) !== 0` and its inverse */
|
|
52
67
|
const edgeMask = (id, helper, nonZero) => ({
|
|
53
68
|
id,
|
|
54
|
-
selector: `${versusZero(nonZero)}${
|
|
69
|
+
selector: `${versusZero(nonZero)}[left.type="BinaryExpression"][left.operator="&"]${types('left.left')}`,
|
|
55
70
|
capture: { edge: 'left.left.object', type: 'left.right' },
|
|
56
71
|
replace: `DfEdge.${helper}({{edge}}, {{type}})`,
|
|
57
|
-
declaredIn: { 'left.left.property':
|
|
72
|
+
declaredIn: { 'left.left.property': Files.edge },
|
|
58
73
|
message: `Use \`DfEdge.${helper}\` instead of testing the bitmask by hand.`
|
|
59
74
|
});
|
|
60
75
|
/** `x.types !== 0`, the literal-0 comparisons {@link edgeIsOnly} leaves out */
|
|
@@ -63,149 +78,286 @@ const edgeStates = (id, helper, what, nonZero) => ({
|
|
|
63
78
|
selector: `${versusZero(nonZero)}${types('left')}`,
|
|
64
79
|
capture: { edge: 'left.object' },
|
|
65
80
|
replace: `DfEdge.${helper}({{edge}})`,
|
|
66
|
-
declaredIn: { 'left.property':
|
|
81
|
+
declaredIn: { 'left.property': Files.edge },
|
|
67
82
|
message: `Use \`DfEdge.${helper}\` to ask whether the edge states ${what}.`
|
|
68
83
|
});
|
|
69
84
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* `
|
|
73
|
-
* always pinned, the property on the left only where one file declares it for every case.
|
|
85
|
+
* `v.tag === VertexType.X` and `n.type === RType.X`, in all four spellings.
|
|
86
|
+
* The helpers are named after the enum members, so `{{type|last}}` spells the check: `VertexType.Use` to `DfgVertex.isUse`,
|
|
87
|
+
* `RType.Symbol` to `RSymbol.is`. The property is only pinned for vertices, on the `RNode` union `type` has no declaring file.
|
|
74
88
|
*/
|
|
75
|
-
const discriminatorIs = (
|
|
89
|
+
const discriminatorIs = (ids, { property, enumName, enumFile, propertyFile, helper, hint }) => [false, true].flatMap(optional => {
|
|
76
90
|
const left = optional ? 'left.expression' : 'left';
|
|
77
|
-
return {
|
|
78
|
-
|
|
79
|
-
selector: `BinaryExpression[operator="${negated ? '!==' : '==='}"][left.type="${optional ? 'ChainExpression' : 'MemberExpression'}"]${member(left, property)}${rightIs(enumName)}`,
|
|
91
|
+
return negatable([ids[optional ? 2 : 0], ids[optional ? 3 : 1]], negated => ({
|
|
92
|
+
selector: `${equality(negated)}[left.type="${optional ? 'ChainExpression' : 'MemberExpression'}"]${member(left, property)}${enumMember('right', enumName)}`,
|
|
80
93
|
capture: { subject: `${left}.object`, type: 'right' },
|
|
81
|
-
replace: `${negated
|
|
94
|
+
replace: `${bang(negated)}${helper}({{subject}})`,
|
|
82
95
|
declaredIn: { 'right.object': enumFile, ...propertyFile ? { [`${left}.property`]: propertyFile } : {} },
|
|
83
|
-
message: `${hint} (\`${helper}
|
|
84
|
-
};
|
|
85
|
-
};
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
* the enum, so `{{type|last}}` spells the check without a table: `VertexType.Use` to `DfgVertex.isUse`.
|
|
89
|
-
*/
|
|
90
|
-
const vertexIs = (id, options) => discriminatorIs(id, {
|
|
91
|
-
property: 'tag', enumName: 'VertexType', enumFile: VERTEX, propertyFile: VERTEX,
|
|
92
|
-
helper: 'DfgVertex', member: 'is{{type|last}}', hint: 'Compare through the vertex helper'
|
|
93
|
-
}, options);
|
|
94
|
-
/**
|
|
95
|
-
* `n.type === RType.X`. Every `RType` member has a helper object of the same name prefixed with `R`
|
|
96
|
-
* (`RType.Symbol` to `RSymbol`), so `{{type|last}}` names it without a table.
|
|
97
|
-
* Only the enum is pinned to its file: `type` is declared once per node interface, and on the `RNode`
|
|
98
|
-
* union it resolves to a synthesized symbol, so demanding a declaring file for it would match nothing.
|
|
99
|
-
*/
|
|
100
|
-
const nodeIs = (id, options) => discriminatorIs(id, {
|
|
101
|
-
property: 'type', enumName: 'RType', enumFile: RTYPE,
|
|
102
|
-
helper: 'R{{type|last}}', hint: 'Ask the AST node helper'
|
|
103
|
-
}, options);
|
|
104
|
-
/**
|
|
105
|
-
* `<Helper>.is(x) && <extra check on x>`, the body of a more specific guard written out.
|
|
106
|
-
* `guard` pins down the right-hand side, `subject` is the path of the operand it repeats.
|
|
107
|
-
*/
|
|
108
|
-
const guardWrittenOut = ({ id, helper, member: narrower, file, guard, subject, message, fix }) => ({
|
|
96
|
+
message: `${hint} (\`${helper}\`), it narrows the type as well.`
|
|
97
|
+
}));
|
|
98
|
+
});
|
|
99
|
+
/** `<Helper>.is(x) && <extra check on x>`, a more specific guard written out; `subject` is where the check repeats `x` */
|
|
100
|
+
const guardWrittenOut = (id, helper, narrower, file, guard, subject, message) => ({
|
|
109
101
|
id,
|
|
110
102
|
selector: `LogicalExpression[operator="&&"]${helperCall('left', helper, 'is')}${guard}`,
|
|
111
103
|
capture: { node: 'left.arguments.0' },
|
|
112
104
|
replace: `${helper}.${narrower}({{node}})`,
|
|
113
105
|
declaredIn: { 'left.callee.object': file },
|
|
114
106
|
sameText: [['left.arguments.0', subject]],
|
|
115
|
-
...fix === undefined ? {} : { fix },
|
|
116
107
|
message
|
|
117
108
|
});
|
|
109
|
+
/** `Identifier.getName(<call>.functionName.content)` */
|
|
110
|
+
const calledName = (at) => `${helperCall(at, 'Identifier', 'getName')}${prop(`${at}.arguments.0`, 'content')}${prop(`${at}.arguments.0.object`, 'functionName')}`;
|
|
111
|
+
/**
|
|
112
|
+
* `RFunctionCall.isNamed(x) && Identifier.getName(x.functionName.content) === 'f'`, and the negation
|
|
113
|
+
* `!RFunctionCall.isNamed(x) || ... !== 'f'`. `inSet` covers `Names.has(...)` in place of the comparison.
|
|
114
|
+
*/
|
|
115
|
+
const namedCallOf = (inSet) => (negated) => {
|
|
116
|
+
const guard = negated ? 'left.argument' : 'left';
|
|
117
|
+
const test = inSet ? (negated ? 'right.argument' : 'right') : 'right';
|
|
118
|
+
const nameCall = inSet ? `${test}.arguments.0` : 'right.left';
|
|
119
|
+
return {
|
|
120
|
+
selector: `LogicalExpression[operator="${negated ? '||' : '&&'}"]${negated ? not('left') : ''}${helperCall(guard, 'RFunctionCall', 'isNamed')}`
|
|
121
|
+
+ (inSet
|
|
122
|
+
? `${negated ? not('right') : ''}${call(test, 'has')}[${test}.arguments.length=1]`
|
|
123
|
+
: `[right.type="BinaryExpression"][right.operator="${negated ? '!==' : '==='}"]:matches(${text('right.right')}, [right.right.type="Identifier"])`)
|
|
124
|
+
+ calledName(nameCall),
|
|
125
|
+
capture: { node: `${guard}.arguments.0`, names: inSet ? `${test}.callee.object` : 'right.right' },
|
|
126
|
+
replace: `${bang(negated)}RFunctionCall.isNamed({{node}}, {{names}})`,
|
|
127
|
+
declaredIn: { [`${guard}.callee.object`]: Files.call },
|
|
128
|
+
sameText: [[`${guard}.arguments.0`, `${nameCall}.arguments.0.object.object`]],
|
|
129
|
+
message: `Use \`${bang(negated)}RFunctionCall.isNamed({{node}}, {{names}})\`, it checks the call and its name in one go and still narrows.`
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
/** `g.outgoingEdges(id) ?? []` or `?? NoEdges`, which `edgesFrom`/`edgesTo` already are */
|
|
133
|
+
const edgesFallback = (id, raw, total) => ({
|
|
134
|
+
id,
|
|
135
|
+
selector: 'LogicalExpression[operator="??"]:matches([right.type="ArrayExpression"][right.elements.length=0], [right.type="Identifier"][right.name="NoEdges"])'
|
|
136
|
+
+ `${call('left', raw)}[left.callee.computed=false][left.arguments.length=1]`,
|
|
137
|
+
capture: { graph: 'left.callee.object', id: 'left.arguments.0' },
|
|
138
|
+
replace: `{{graph}}.${total}({{id}})`,
|
|
139
|
+
declaredIn: { 'left.callee.property': Files.graph },
|
|
140
|
+
message: `\`${total}\` is \`${raw}\` without the fallback: it answers with the shared empty map itself.`
|
|
141
|
+
});
|
|
118
142
|
/**
|
|
119
|
-
* `
|
|
120
|
-
*
|
|
143
|
+
* `x.info.fullLexeme ?? x.lexeme` and its mirrors, reading the text or the range of a node by hand.
|
|
144
|
+
* `infoFirst` says which side of the `??` goes through `info`.
|
|
121
145
|
*/
|
|
122
|
-
const
|
|
146
|
+
const nodeFallback = (ids, infoProp, plainProp, infoFirst, helper, message) => optionalToo(ids, optional => {
|
|
147
|
+
const [infoSide, plainSide] = infoFirst ? ['left', 'right'] : ['right', 'left'];
|
|
148
|
+
const info = optional ? `${infoSide}.expression` : infoSide, plain = optional ? `${plainSide}.expression` : plainSide;
|
|
149
|
+
const kind = optional ? 'ChainExpression' : 'MemberExpression';
|
|
150
|
+
return {
|
|
151
|
+
selector: `LogicalExpression[operator="??"][${infoSide}.type="${kind}"][${plainSide}.type="${kind}"]${member(info, infoProp)}${prop(`${info}.object`, 'info')}${member(plain, plainProp)}`,
|
|
152
|
+
capture: { node: `${plain}.object` },
|
|
153
|
+
replace: `${helper}({{node}})`,
|
|
154
|
+
declaredIn: { [`${info}.property`]: Files.model, [`${plain}.property`]: Files.model },
|
|
155
|
+
sameText: [[`${info}.object.object`, `${plain}.object`]],
|
|
156
|
+
message
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
/** `range?.[0]` and friends, the accessors say which index is which */
|
|
160
|
+
const rangeIndex = (id, index, reader) => ({
|
|
123
161
|
id,
|
|
124
|
-
selector:
|
|
162
|
+
selector: 'ChainExpression[expression.type="MemberExpression"][expression.computed=true][expression.optional=true]'
|
|
163
|
+
+ `[expression.property.type="Literal"][expression.property.value=${index}][expression.object.type="MemberExpression"]`
|
|
164
|
+
+ ':matches([expression.object.property.name="location"], [expression.object.property.name="fullRange"])',
|
|
165
|
+
capture: { range: 'expression.object' },
|
|
166
|
+
replace: `SourceRange.${reader}({{range}})`,
|
|
167
|
+
declaredIn: { 'expression.object.property': Files.model },
|
|
168
|
+
message: `\`range?.[${index}]\` hides what the index means; \`SourceRange.${reader}\` says it and answers \`undefined\` for an absent range just the same.`
|
|
169
|
+
});
|
|
170
|
+
/** `arg === EmptyArgument ? undefined : arg.value`, and the inverted spelling */
|
|
171
|
+
const unpacked = (id, empty) => {
|
|
172
|
+
const [value, absent] = empty ? ['alternate', 'consequent'] : ['consequent', 'alternate'];
|
|
173
|
+
return {
|
|
174
|
+
id,
|
|
175
|
+
selector: `ConditionalExpression[test.type="BinaryExpression"][test.operator="${empty ? '===' : '!=='}"]${identifier('test.right', 'EmptyArgument')}`
|
|
176
|
+
+ `${identifier(absent, 'undefined')}${prop(value, 'value')}`,
|
|
177
|
+
capture: { arg: 'test.left' },
|
|
178
|
+
replace: 'RArgument.valueOf({{arg}})',
|
|
179
|
+
declaredIn: { 'test.right': Files.call, [`${value}.property`]: Files.argument },
|
|
180
|
+
sameText: [['test.left', `${value}.object`]],
|
|
181
|
+
message: '`RArgument.valueOf` is this: the value of an argument, `undefined` for an empty one.'
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
/**
|
|
185
|
+
* `args.find(a => <a is not empty> && a.name?.content === 'x')`.
|
|
186
|
+
* `guard` is how the emptiness is tested: `a !== EmptyArgument`, `!RArgument.isEmpty(a)` or `RArgument.isNotEmpty(a)`.
|
|
187
|
+
*/
|
|
188
|
+
const findNamed = (id, guard) => {
|
|
189
|
+
const body = 'arguments.0.body', test = `${body}.left`, rhs = `${body}.right`, content = `${rhs}.left.expression`;
|
|
190
|
+
const [reference, pin, shape] = {
|
|
191
|
+
neq: [`${test}.left`, `${test}.right`, `[${test}.type="BinaryExpression"][${test}.operator="!=="]${identifier(`${test}.right`, 'EmptyArgument')}`],
|
|
192
|
+
isEmpty: [`${test}.argument.arguments.0`, `${test}.argument.callee.object`, `${not(test)}${helperCall(`${test}.argument`, 'RArgument', 'isEmpty')}`],
|
|
193
|
+
isNotEmpty: [`${test}.arguments.0`, `${test}.callee.object`, helperCall(test, 'RArgument', 'isNotEmpty')]
|
|
194
|
+
}[guard];
|
|
195
|
+
return {
|
|
196
|
+
id,
|
|
197
|
+
selector: `CallExpression${prop('callee', 'find')}[arguments.length=1]`
|
|
198
|
+
+ `[arguments.0.type="ArrowFunctionExpression"][arguments.0.params.length=1][${body}.type="LogicalExpression"][${body}.operator="&&"]`
|
|
199
|
+
+ `${shape}[${rhs}.type="BinaryExpression"][${rhs}.operator="==="][${rhs}.left.type="ChainExpression"]`
|
|
200
|
+
+ `${member(content, 'content')}${prop(`${content}.object`, 'name')}:matches([${rhs}.right.type="Literal"], [${rhs}.right.type="Identifier"])`,
|
|
201
|
+
capture: { args: 'callee.object', name: `${rhs}.right` },
|
|
202
|
+
replace: 'RArgument.findNamed({{args}}, {{name}})',
|
|
203
|
+
declaredIn: { [pin]: guard === 'neq' ? Files.call : Files.argument, [`${content}.property`]: Files.symbol },
|
|
204
|
+
sameText: [['arguments.0.params.0', reference], ['arguments.0.params.0', `${content}.object.object`]],
|
|
205
|
+
message: '`RArgument.findNamed` finds the argument given by this exact name and skips the empty ones itself.'
|
|
206
|
+
};
|
|
207
|
+
};
|
|
208
|
+
/** `[...new Set(<x>)]` */
|
|
209
|
+
const spreadSet = (extra) => 'ArrayExpression[elements.length=1][elements.0.type="SpreadElement"][elements.0.argument.type="NewExpression"]'
|
|
210
|
+
+ `[elements.0.argument.callee.name="Set"][elements.0.argument.arguments.length=1]${extra}`;
|
|
211
|
+
/** `[...g.edgesFrom(id)].filter(([, e]) => DfEdge.includesType(e, T)).map(([t]) => t)` and its `edgesTo` twin */
|
|
212
|
+
const edgeNeighbors = (id, raw, helper) => {
|
|
213
|
+
const filter = 'callee.object', spread = `${filter}.callee.object.elements.0`, test = `${filter}.arguments.0`;
|
|
214
|
+
return {
|
|
215
|
+
id,
|
|
216
|
+
selector: `CallExpression${call('', 'map')}[arguments.length=1]`
|
|
217
|
+
+ '[arguments.0.type="ArrowFunctionExpression"][arguments.0.params.length=1][arguments.0.params.0.type="ArrayPattern"]'
|
|
218
|
+
+ '[arguments.0.params.0.elements.length=1][arguments.0.body.type="Identifier"]'
|
|
219
|
+
+ `${call(filter, 'filter')}[${filter}.arguments.length=1][${filter}.callee.object.type="ArrayExpression"]`
|
|
220
|
+
+ `[${filter}.callee.object.elements.length=1][${spread}.type="SpreadElement"]${call(`${spread}.argument`, raw)}[${spread}.argument.arguments.length=1]`
|
|
221
|
+
+ `[${test}.type="ArrowFunctionExpression"][${test}.params.0.type="ArrayPattern"][${test}.params.0.elements.length=2]`
|
|
222
|
+
+ `[${test}.params.0.elements.1.type="Identifier"]${helperCall(`${test}.body`, 'DfEdge', 'includesType', 2)}`,
|
|
223
|
+
capture: { graph: `${spread}.argument.callee.object`, node: `${spread}.argument.arguments.0`, types: `${test}.body.arguments.1` },
|
|
224
|
+
replace: `${helper}({{graph}}, {{node}}, {{types}})`,
|
|
225
|
+
declaredIn: { [`${spread}.argument.callee.property`]: Files.graph, [`${test}.body.callee.property`]: Files.edge },
|
|
226
|
+
sameText: [['arguments.0.params.0.elements.0', 'arguments.0.body'], [`${test}.params.0.elements.1`, `${test}.body.arguments.0`]],
|
|
227
|
+
message: `\`${helper}\` is this walk, name it instead of spelling out the filter.`
|
|
228
|
+
};
|
|
229
|
+
};
|
|
230
|
+
/** `x.name === 'lit'` on a vertex */
|
|
231
|
+
const vertexName = (negated) => ({
|
|
232
|
+
selector: `${equality(negated)}${prop('left', 'name')}${text('right')}`,
|
|
233
|
+
capture: { vertex: 'left.object', name: 'right' },
|
|
234
|
+
replace: `Identifier.getName({{vertex}}.name) ${negated ? '!==' : '==='} {{name}}`,
|
|
235
|
+
declaredIn: { 'left.property': Files.vertex },
|
|
236
|
+
/* what the call is, rather than how it is spelled, may be what is meant */
|
|
237
|
+
fix: false,
|
|
238
|
+
message: 'The name of a call is how it is spelled: `pkg::name` is an array here, and an alias or a shadowing definition is not seen at all. Ask what it is with `DfgVertex.hasOrigin`, or `Dataflow.qualify` for the package.'
|
|
239
|
+
});
|
|
240
|
+
/** `symbol.content === 'name'`, which misses `pkg::name` as an identifier with a namespace is an array */
|
|
241
|
+
const symbolName = (negated) => ({
|
|
242
|
+
selector: `${equality(negated)}${prop('left', 'content')}${text('right')}`,
|
|
125
243
|
capture: { symbol: 'left.object', name: 'right' },
|
|
126
244
|
replace: `Identifier.getName({{symbol}}.content) ${negated ? '!==' : '==='} {{name}}`,
|
|
127
|
-
declaredIn: { 'left.property':
|
|
128
|
-
/*
|
|
245
|
+
declaredIn: { 'left.property': Files.symbol },
|
|
246
|
+
/* whether the namespace should count is the author's call */
|
|
129
247
|
fix: false,
|
|
130
248
|
message: 'An `Identifier` is not its name: `pkg::{{name}}` is an array and never `===` a string. Compare `Identifier.getName({{symbol}}.content)`, or `Identifier.matches` when the namespace should count.'
|
|
131
249
|
});
|
|
132
|
-
/**
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
*/
|
|
139
|
-
|
|
250
|
+
/** `c.functionName.lexeme === 'lit'` on a call */
|
|
251
|
+
const calleeLexeme = (negated) => ({
|
|
252
|
+
selector: `${equality(negated)}${prop('left', 'lexeme')}${prop('left.object', 'functionName')}${text('right')}`,
|
|
253
|
+
capture: { call: 'left.object.object', name: 'right' },
|
|
254
|
+
replace: `${bang(negated)}RFunctionCall.isNamed({{call}}, {{name}})`,
|
|
255
|
+
declaredIn: { 'left.property': Files.model },
|
|
256
|
+
/* the lexeme keeps quotes and the namespace, the helper compares the name */
|
|
257
|
+
fix: false,
|
|
258
|
+
message: 'The lexeme is how the callee is spelled, `RFunctionCall.isNamed` compares its name and keeps `pkg::{{name}}` and backticks out of the way.'
|
|
259
|
+
});
|
|
260
|
+
/** `isNamed(n) ? n.functionName.content : undefined`, guarded by the call or by a read of `named` */
|
|
261
|
+
const calledNameOf = (id, guard) => ({
|
|
140
262
|
id,
|
|
141
|
-
selector: `
|
|
142
|
-
+
|
|
143
|
-
capture: {
|
|
144
|
-
replace:
|
|
145
|
-
declaredIn: { '
|
|
146
|
-
|
|
263
|
+
selector: `ConditionalExpression${identifier('alternate', 'undefined')}${prop('consequent', 'content')}${prop('consequent.object', 'functionName')}`
|
|
264
|
+
+ (guard === 'call' ? helperCall('test', 'RFunctionCall', 'isNamed') : prop('test', 'named')),
|
|
265
|
+
capture: { node: 'consequent.object.object' },
|
|
266
|
+
replace: 'RFunctionCall.calledName({{node}})',
|
|
267
|
+
declaredIn: { 'consequent.object.property': Files.call, [guard === 'call' ? 'test.callee.object' : 'test.property']: Files.call },
|
|
268
|
+
sameText: [[guard === 'call' ? 'test.arguments.0' : 'test.object', 'consequent.object.object']],
|
|
269
|
+
message: '`RFunctionCall.calledName` is this: the name of a named call, `undefined` otherwise.'
|
|
270
|
+
});
|
|
271
|
+
/** `v.origin.includes(o)` behind a call guard, as `isFunctionCall(v) && ...` or `!isFunctionCall(v) || !...` */
|
|
272
|
+
const originGuarded = (negated) => {
|
|
273
|
+
const guard = negated ? 'left.argument' : 'left', includes = negated ? 'right.argument' : 'right';
|
|
274
|
+
return {
|
|
275
|
+
selector: `LogicalExpression[operator="${negated ? '||' : '&&'}"]${negated ? `${not('left')}${not('right')}` : ''}${helperCall(guard, 'DfgVertex', 'isFunctionCall')}`
|
|
276
|
+
+ `${call(includes, 'includes')}[${includes}.arguments.length=1]${prop(`${includes}.callee.object`, 'origin')}`,
|
|
277
|
+
capture: { vertex: `${guard}.arguments.0`, origin: `${includes}.arguments.0` },
|
|
278
|
+
replace: `${bang(negated)}DfgVertex.hasOrigin({{vertex}}, {{origin}})`,
|
|
279
|
+
declaredIn: { [`${guard}.callee.object`]: Files.vertex, [`${includes}.callee.object.property`]: Files.vertex },
|
|
280
|
+
sameText: [[`${guard}.arguments.0`, `${includes}.callee.object.object`]],
|
|
281
|
+
/* `hasOrigin` does not narrow, code below may rely on the guard */
|
|
282
|
+
fix: false,
|
|
283
|
+
message: `Use \`${bang(negated)}DfgVertex.hasOrigin\`, it is exactly this check. Check the narrowing first, \`hasOrigin\` returns a plain boolean.`
|
|
284
|
+
};
|
|
285
|
+
};
|
|
286
|
+
/** `String(id).startsWith('built-in:')` and the plain `id.startsWith('built-in:')` */
|
|
287
|
+
const builtInPrefix = (id, wrapped) => ({
|
|
288
|
+
id,
|
|
289
|
+
selector: `CallExpression${prop('callee', 'startsWith')}[arguments.length=1]${text('arguments.0', 'built-in:')}`
|
|
290
|
+
+ (wrapped ? '[callee.object.type="CallExpression"][callee.object.callee.name="String"][callee.object.arguments.length=1]' : ':not([callee.object.type="CallExpression"])'),
|
|
291
|
+
capture: { id: wrapped ? 'callee.object.arguments.0' : 'callee.object' },
|
|
292
|
+
replace: 'NodeId.isBuiltIn({{id}})',
|
|
293
|
+
helperIn: Files.nodeId,
|
|
294
|
+
/* a lexeme or a path is not a node id */
|
|
295
|
+
...wrapped ? {} : { fix: false },
|
|
296
|
+
message: 'Ask `NodeId.isBuiltIn`, the `built-in:` prefix is the helper\'s business.'
|
|
297
|
+
});
|
|
298
|
+
const builtInId = (id, selector, name) => ({
|
|
299
|
+
id,
|
|
300
|
+
selector,
|
|
301
|
+
capture: { name },
|
|
302
|
+
replace: 'NodeId.toBuiltIn({{name}})',
|
|
303
|
+
helperIn: Files.nodeId,
|
|
304
|
+
message: 'Build a built-in id with `NodeId.toBuiltIn`, the prefix is the helper\'s business.'
|
|
305
|
+
});
|
|
306
|
+
/** `filter(...).length > 0` or `=== 0`, `some` stops at the first hit */
|
|
307
|
+
const someInsteadOfFilter = (id, nonZero) => ({
|
|
308
|
+
id,
|
|
309
|
+
selector: `${versusZero(nonZero)}${prop('left', 'length')}${call('left.object', 'filter')}[left.object.arguments.length=1]`,
|
|
310
|
+
capture: { array: 'left.object.callee.object', predicate: 'left.object.arguments.0' },
|
|
311
|
+
replace: `${bang(!nonZero)}{{array}}.some({{predicate}})`,
|
|
312
|
+
message: `\`filter(...).length ${nonZero ? '> 0' : '=== 0'}\` walks the whole list and allocates the matches, \`${bang(!nonZero)}some\` stops at the first hit.`
|
|
147
313
|
});
|
|
148
314
|
const patterns = [
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
edgeIsOnly('edge-is-only-type-destructured', { destructured: true }),
|
|
152
|
-
edgeIsOnly('edge-is-not-only-type-destructured', { negated: true, destructured: true }),
|
|
315
|
+
...negatable(['edge-is-only-type', 'edge-is-not-only-type'], negated => edgeIsOnly(negated, false)),
|
|
316
|
+
...negatable(['edge-is-only-type-destructured', 'edge-is-not-only-type-destructured'], negated => edgeIsOnly(negated, true)),
|
|
153
317
|
edgeStates('edge-has-any-type', 'hasAnyType', 'anything', true),
|
|
154
318
|
edgeStates('edge-has-no-type', 'hasNoType', 'nothing', false),
|
|
155
319
|
edgeMask('edge-includes-type', 'includesType', true),
|
|
156
320
|
edgeMask('edge-does-not-include-type', 'doesNotIncludeType', false),
|
|
157
321
|
{
|
|
158
|
-
/*
|
|
159
|
-
* `SourceLocation.fromNode(n) ?? []` only exists so the positional destructure below it survives an
|
|
160
|
-
* absent location; the view reads the same places by name and combines with `?.`
|
|
161
|
-
*/
|
|
322
|
+
/* the `?? []` only exists so a positional destructure survives an absent location */
|
|
162
323
|
id: 'location-at-node',
|
|
163
324
|
selector: `LogicalExpression[operator="??"][right.type="ArrayExpression"][right.elements.length=0]${helperCall('left', 'SourceLocation', 'fromNode')}`,
|
|
164
325
|
capture: { node: 'left.arguments.0' },
|
|
165
326
|
replace: 'SourceLocation.at({{node}})',
|
|
166
|
-
declaredIn: { 'left.callee.object':
|
|
167
|
-
/* the destructure
|
|
327
|
+
declaredIn: { 'left.callee.object': Files.range },
|
|
328
|
+
/* the destructure has to become property reads */
|
|
168
329
|
fix: false,
|
|
169
330
|
message: '`SourceLocation.at({{node}})` answers with a view: `.startLine`, `.file`, and `?.` for the absent case, so the `?? []` and the positional destructure both go away.'
|
|
170
331
|
},
|
|
171
332
|
{
|
|
172
|
-
/*
|
|
173
|
-
* every source range is a source location without a file, so the range readers take a location as it
|
|
174
|
-
* is and `SourceLocation` re-exports them under its own name
|
|
175
|
-
*/
|
|
333
|
+
/* a source range is a source location without a file, so the readers take a location as it is */
|
|
176
334
|
id: 'location-range-hop',
|
|
177
|
-
selector: 'CallExpression[callee.type="MemberExpression"][callee.computed=false]'
|
|
178
|
-
+ '
|
|
179
|
-
+ `${helperCall('arguments.0', 'SourceLocation', 'getRange')}`,
|
|
335
|
+
selector: 'CallExpression[callee.type="MemberExpression"][callee.computed=false][callee.object.name=/^Source(Range|Location)$/][arguments.length=1]'
|
|
336
|
+
+ helperCall('arguments.0', 'SourceLocation', 'getRange'),
|
|
180
337
|
capture: { reader: 'callee.property', location: 'arguments.0.arguments.0' },
|
|
181
338
|
replace: 'SourceLocation.{{reader}}({{location}})',
|
|
182
|
-
declaredIn: { 'callee.object':
|
|
339
|
+
declaredIn: { 'callee.object': Files.range, 'arguments.0.callee.object': Files.range },
|
|
183
340
|
message: 'Drop the `getRange` hop, `SourceLocation.{{reader}}` reads the location directly.'
|
|
184
341
|
},
|
|
185
342
|
{
|
|
186
|
-
/* the readers answer `undefined` for an absent input
|
|
343
|
+
/* the readers answer `undefined` for an absent input */
|
|
187
344
|
id: 'accessor-undefined-guard',
|
|
188
|
-
selector: `ConditionalExpression${versusUndefined('test', false)}`
|
|
189
|
-
+ '[consequent.type="Identifier"][consequent.name="undefined"]'
|
|
345
|
+
selector: `ConditionalExpression${versusUndefined('test', false)}${identifier('consequent', 'undefined')}`
|
|
190
346
|
+ '[alternate.type="CallExpression"][alternate.callee.type="MemberExpression"][alternate.callee.computed=false]'
|
|
191
|
-
+ '[alternate.callee.property.name=/^(get|format|view|at)/]'
|
|
192
|
-
+ '[alternate.callee.object.name=/^Source(Range|Location)$/][alternate.arguments.length=1]',
|
|
347
|
+
+ '[alternate.callee.property.name=/^(get|format|view|at)/][alternate.callee.object.name=/^Source(Range|Location)$/][alternate.arguments.length=1]',
|
|
193
348
|
capture: { call: 'alternate' },
|
|
194
349
|
replace: '{{call}}',
|
|
195
350
|
sameText: [['test.left', 'alternate.arguments.0']],
|
|
196
|
-
declaredIn: { 'alternate.callee.object':
|
|
351
|
+
declaredIn: { 'alternate.callee.object': Files.range },
|
|
197
352
|
message: 'The reader passes an absent input through, the `=== undefined` guard says nothing on top of it.'
|
|
198
353
|
},
|
|
199
354
|
{
|
|
200
|
-
/* `Identifier.toString` answers `undefined` for an absent identifier, an empty name is a different thing */
|
|
201
355
|
id: 'identifier-tostring-fallback',
|
|
202
|
-
selector:
|
|
203
|
-
+ '[arguments.0.type="LogicalExpression"][arguments.0.operator="??"]'
|
|
204
|
-
+ '[arguments.0.right.type="Literal"][arguments.0.right.value=""]',
|
|
356
|
+
selector: `CallExpression${helperCall('', 'Identifier', 'toString')}[arguments.0.type="LogicalExpression"][arguments.0.operator="??"]${text('arguments.0.right', '')}`,
|
|
205
357
|
capture: { id: 'arguments.0.left' },
|
|
206
358
|
replace: 'Identifier.toString({{id}})',
|
|
207
|
-
declaredIn: { 'callee.object':
|
|
208
|
-
/* an empty string and `undefined` read differently downstream
|
|
359
|
+
declaredIn: { 'callee.object': Files.identifier },
|
|
360
|
+
/* an empty string and `undefined` read differently downstream */
|
|
209
361
|
fix: false,
|
|
210
362
|
message: '`Identifier.toString` passes an absent identifier through; the `?? \'\'` turns "no name" into the name `""`.'
|
|
211
363
|
},
|
|
@@ -214,97 +366,224 @@ const patterns = [
|
|
|
214
366
|
selector: `:matches(IfStatement, ConditionalExpression, WhileStatement, DoWhileStatement, LogicalExpression, UnaryExpression[operator="!"], ArrowFunctionExpression) > BinaryExpression[operator="&"]${types('left')}`,
|
|
215
367
|
capture: { edge: 'left.object', type: 'right' },
|
|
216
368
|
replace: 'DfEdge.includesType({{edge}}, {{type}})',
|
|
217
|
-
declaredIn: { 'left.property':
|
|
369
|
+
declaredIn: { 'left.property': Files.edge },
|
|
218
370
|
message: 'Do not rely on the bitmask being truthy, `DfEdge.includesType` says what is meant.'
|
|
219
371
|
},
|
|
220
|
-
|
|
221
|
-
id: 'vertex-has-origin',
|
|
222
|
-
selector: 'LogicalExpression[operator="&&"]'
|
|
223
|
-
+ `${helperCall('left', 'DfgVertex', 'isFunctionCall')}`
|
|
224
|
-
+ `${call('right', 'includes')}[right.callee.object.type="MemberExpression"][right.callee.object.property.name="origin"]`,
|
|
225
|
-
capture: { vertex: 'left.arguments.0', origin: 'right.arguments.0' },
|
|
226
|
-
replace: 'DfgVertex.hasOrigin({{vertex}}, {{origin}})',
|
|
227
|
-
declaredIn: { 'left.callee.object': VERTEX, 'right.callee.object.property': VERTEX },
|
|
228
|
-
sameText: [['left.arguments.0', 'right.callee.object.object']],
|
|
229
|
-
/* `hasOrigin` is no type predicate, so the replacement can drop a narrowing the code below relies on */
|
|
230
|
-
fix: false,
|
|
231
|
-
message: 'Use `DfgVertex.hasOrigin`, it is exactly this check. Check the narrowing first, `hasOrigin` returns a plain boolean.'
|
|
232
|
-
},
|
|
372
|
+
...negatable(['vertex-has-origin', 'vertex-lacks-origin'], originGuarded),
|
|
233
373
|
edgesFallback('graph-edges-from', 'outgoingEdges', 'edgesFrom'),
|
|
234
374
|
edgesFallback('graph-edges-to', 'ingoingEdges', 'edgesTo'),
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
vertexIs('vertex-is-not-optional', { negated: true, optional: true }),
|
|
239
|
-
nodeIs('node-is'),
|
|
240
|
-
nodeIs('node-is-not', { negated: true }),
|
|
241
|
-
nodeIs('node-is-optional', { optional: true }),
|
|
242
|
-
nodeIs('node-is-not-optional', { negated: true, optional: true }),
|
|
243
|
-
guardWrittenOut({
|
|
244
|
-
id: 'call-is-named', helper: 'RFunctionCall', member: 'isNamed', file: CALL_NODE,
|
|
245
|
-
guard: `[right.type="MemberExpression"]${member('right', 'named')}`,
|
|
246
|
-
subject: 'right.object',
|
|
247
|
-
message: 'Use `RFunctionCall.isNamed`, it is this check and narrows to `RNamedFunctionCall`.'
|
|
248
|
-
}),
|
|
249
|
-
guardWrittenOut({
|
|
250
|
-
id: 'call-is-named-strict', helper: 'RFunctionCall', member: 'isNamed', file: CALL_NODE,
|
|
251
|
-
guard: `[right.type="BinaryExpression"][right.operator="==="][right.right.type="Literal"][right.right.value=true]${member('right.left', 'named')}`,
|
|
252
|
-
subject: 'right.left.object',
|
|
253
|
-
message: 'Use `RFunctionCall.isNamed`, it is this check and narrows to `RNamedFunctionCall`.'
|
|
254
|
-
}),
|
|
255
|
-
guardWrittenOut({
|
|
256
|
-
id: 'call-is-unnamed', helper: 'RFunctionCall', member: 'isUnnamed', file: CALL_NODE,
|
|
257
|
-
guard: `[right.type="UnaryExpression"][right.operator="!"][right.argument.type="MemberExpression"]${member('right.argument', 'named')}`,
|
|
258
|
-
subject: 'right.argument.object',
|
|
259
|
-
message: 'Use `RFunctionCall.isUnnamed`, it is this check and narrows to `RUnnamedFunctionCall`.'
|
|
260
|
-
}),
|
|
261
|
-
guardWrittenOut({
|
|
262
|
-
id: 'argument-is-named', helper: 'RArgument', member: 'isNamed', file: ARGUMENT_NODE,
|
|
263
|
-
guard: `${versusUndefined('right', true)}${member('right.left', 'name')}`,
|
|
264
|
-
subject: 'right.left.object',
|
|
265
|
-
message: 'Use `RArgument.isNamed`, it is this check and keeps the `name` non-optional afterwards.'
|
|
266
|
-
}),
|
|
267
|
-
guardWrittenOut({
|
|
268
|
-
id: 'argument-is-with-value', helper: 'RArgument', member: 'isWithValue', file: ARGUMENT_NODE,
|
|
269
|
-
guard: `${versusUndefined('right', true)}${member('right.left', 'value')}`,
|
|
270
|
-
subject: 'right.left.object',
|
|
271
|
-
message: 'Use `RArgument.isWithValue`, it is this check and keeps the `value` non-optional afterwards.'
|
|
375
|
+
...discriminatorIs(['vertex-is', 'vertex-is-not', 'vertex-is-optional', 'vertex-is-not-optional'], {
|
|
376
|
+
property: 'tag', enumName: 'VertexType', enumFile: Files.vertex, propertyFile: Files.vertex,
|
|
377
|
+
helper: 'DfgVertex.is{{type|last}}', hint: 'Compare through the vertex helper'
|
|
272
378
|
}),
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
subject: 'right.left.object',
|
|
277
|
-
message: 'Use `RExpressionList.isImplicit`, an expression list without `grouping` is exactly the implicit one.'
|
|
379
|
+
...discriminatorIs(['node-is', 'node-is-not', 'node-is-optional', 'node-is-not-optional'], {
|
|
380
|
+
property: 'type', enumName: 'RType', enumFile: Files.rType,
|
|
381
|
+
helper: 'R{{type|last}}.is', hint: 'Ask the AST node helper'
|
|
278
382
|
}),
|
|
279
|
-
|
|
280
|
-
|
|
383
|
+
guardWrittenOut('call-is-named', 'RFunctionCall', 'isNamed', Files.call, prop('right', 'named'), 'right.object', 'Use `RFunctionCall.isNamed`, it is this check and narrows to `RNamedFunctionCall`.'),
|
|
384
|
+
guardWrittenOut('call-is-named-strict', 'RFunctionCall', 'isNamed', Files.call, `[right.type="BinaryExpression"][right.operator="==="][right.right.type="Literal"][right.right.value=true]${member('right.left', 'named')}`, 'right.left.object', 'Use `RFunctionCall.isNamed`, it is this check and narrows to `RNamedFunctionCall`.'),
|
|
385
|
+
guardWrittenOut('call-is-unnamed', 'RFunctionCall', 'isUnnamed', Files.call, `${not('right')}${prop('right.argument', 'named')}`, 'right.argument.object', 'Use `RFunctionCall.isUnnamed`, it is this check and narrows to `RUnnamedFunctionCall`.'),
|
|
386
|
+
guardWrittenOut('argument-is-named', 'RArgument', 'isNamed', Files.argument, `${versusUndefined('right', true)}${member('right.left', 'name')}`, 'right.left.object', 'Use `RArgument.isNamed`, it is this check and keeps the `name` non-optional afterwards.'),
|
|
387
|
+
guardWrittenOut('argument-is-with-value', 'RArgument', 'isWithValue', Files.argument, `${versusUndefined('right', true)}${member('right.left', 'value')}`, 'right.left.object', 'Use `RArgument.isWithValue`, it is this check and keeps the `value` non-optional afterwards.'),
|
|
388
|
+
guardWrittenOut('list-is-implicit', 'RExpressionList', 'isImplicit', Files.list, `${versusUndefined('right', false)}${member('right.left', 'grouping')}`, 'right.left.object', 'Use `RExpressionList.isImplicit`, an expression list without `grouping` is exactly the implicit one.'),
|
|
389
|
+
...negatable(['call-is-named-literal', 'call-is-not-named-literal'], namedCallOf(false)),
|
|
390
|
+
...negatable(['call-is-named-in-set', 'call-is-not-named-in-set'], namedCallOf(true)),
|
|
391
|
+
...nodeFallback(['node-lexeme', 'node-lexeme-optional'], 'fullLexeme', 'lexeme', true, 'RNode.lexeme', 'Use `RNode.lexeme`, it reads the code the node spans, the full lexeme first.'),
|
|
392
|
+
...nodeFallback(['node-own-lexeme', 'node-own-lexeme-optional'], 'fullLexeme', 'lexeme', false, 'RNode.ownLexeme', 'Use `RNode.ownLexeme`, it reads the token the node is made of, its own lexeme first.'),
|
|
393
|
+
...nodeFallback(['node-range', 'node-range-optional'], 'fullRange', 'location', true, 'SourceRange.fromNode', 'Use `SourceRange.fromNode`, it reads the full range and falls back to the location.'),
|
|
394
|
+
...nodeFallback(['node-own-range', 'node-own-range-optional'], 'fullRange', 'location', false, 'SourceRange.ownFromNode', 'Use `SourceRange.ownFromNode`, it reads the location of the node\'s own token and falls back to the full range.'),
|
|
395
|
+
rangeIndex('range-start-line', 0, 'getStartLine'),
|
|
396
|
+
rangeIndex('range-start-column', 1, 'getStartColumn'),
|
|
397
|
+
rangeIndex('range-end-line', 2, 'getEndLine'),
|
|
398
|
+
rangeIndex('range-end-column', 3, 'getEndColumn'),
|
|
399
|
+
{
|
|
400
|
+
id: 'node-id-recover-name',
|
|
401
|
+
selector: `ChainExpression[expression.type="MemberExpression"][expression.optional=true]${member('expression', 'lexeme')}`
|
|
402
|
+
+ `${call('expression.object', 'get')}[expression.object.callee.computed=false][expression.object.arguments.length=1]`
|
|
403
|
+
+ ':matches([expression.object.callee.object.name=/idMap/i], [expression.object.callee.object.property.name=/idMap/i])',
|
|
404
|
+
capture: { id: 'expression.object.arguments.0', map: 'expression.object.callee.object' },
|
|
405
|
+
replace: 'NodeId.recoverName({{id}}, {{map}})',
|
|
406
|
+
declaredIn: { 'expression.property': Files.model },
|
|
407
|
+
message: '`NodeId.recoverName` is this lookup, the lexeme of the node an id belongs to.'
|
|
408
|
+
},
|
|
409
|
+
unpacked('unpack-arg', true),
|
|
410
|
+
unpacked('unpack-arg-inverted', false),
|
|
411
|
+
findNamed('find-named-arg', 'neq'),
|
|
412
|
+
findNamed('find-named-arg-not-empty', 'isNotEmpty'),
|
|
413
|
+
findNamed('find-named-arg-negated-empty', 'isEmpty'),
|
|
414
|
+
{
|
|
415
|
+
id: 'call-sole-argument',
|
|
416
|
+
selector: 'ConditionalExpression[test.type="LogicalExpression"][test.operator="&&"][test.left.type="BinaryExpression"][test.left.operator="==="]'
|
|
417
|
+
+ `${prop('test.left.left', 'length')}[test.left.right.type="Literal"][test.left.right.value=1]`
|
|
418
|
+
+ '[test.right.type="BinaryExpression"][test.right.operator="!=="][test.right.left.type="MemberExpression"][test.right.left.computed=true]'
|
|
419
|
+
+ `[test.right.left.property.value=0]${identifier('test.right.right', 'EmptyArgument')}`
|
|
420
|
+
+ `[consequent.type="MemberExpression"][consequent.computed=true][consequent.property.value=0]${identifier('alternate', 'undefined')}`,
|
|
421
|
+
capture: { args: 'test.left.left.object' },
|
|
422
|
+
replace: 'RFunctionCall.soleArgument({{args}})',
|
|
423
|
+
declaredIn: { 'test.right.right': Files.call },
|
|
424
|
+
sameText: [['test.left.left.object', 'test.right.left.object'], ['test.left.left.object', 'consequent.object']],
|
|
425
|
+
message: '`RFunctionCall.soleArgument` is this: the one argument, `undefined` unless there is exactly one and it is not empty.'
|
|
426
|
+
},
|
|
427
|
+
calledNameOf('call-called-name', 'call'),
|
|
428
|
+
calledNameOf('call-called-name-named', 'property'),
|
|
429
|
+
...negatable(['callee-lexeme-comparison', 'callee-lexeme-comparison-not'], calleeLexeme),
|
|
430
|
+
...negatable(['vertex-name-comparison', 'vertex-name-comparison-not'], vertexName),
|
|
431
|
+
...negatable(['symbol-name-comparison', 'symbol-name-comparison-not'], symbolName),
|
|
432
|
+
{
|
|
433
|
+
id: 'vertex-has-origin-array',
|
|
434
|
+
selector: `LogicalExpression[operator="&&"][left.type="LogicalExpression"][left.operator="&&"]${helperCall('left.left', 'DfgVertex', 'isFunctionCall')}`
|
|
435
|
+
+ `${helperCall('left.right', 'Array', 'isArray')}${prop('left.right.arguments.0', 'origin')}`
|
|
436
|
+
+ `${call('right', 'includes')}[right.arguments.length=1]${prop('right.callee.object', 'origin')}`,
|
|
437
|
+
capture: { vertex: 'left.left.arguments.0', origin: 'right.arguments.0' },
|
|
438
|
+
replace: 'DfgVertex.hasOrigin({{vertex}}, {{origin}})',
|
|
439
|
+
declaredIn: { 'left.left.callee.object': Files.vertex, 'right.callee.object.property': Files.vertex },
|
|
440
|
+
sameText: [['left.left.arguments.0', 'left.right.arguments.0.object'], ['left.left.arguments.0', 'right.callee.object.object']],
|
|
441
|
+
fix: false,
|
|
442
|
+
message: '`DfgVertex.hasOrigin` is this check including the `Array.isArray`. Check the narrowing first, `hasOrigin` returns a plain boolean.'
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
id: 'vertex-origin-includes',
|
|
446
|
+
/* the guarded spellings have patterns of their own, and the origin of a `link` is no vertex origin */
|
|
447
|
+
selector: `CallExpression${prop('callee', 'includes')}`
|
|
448
|
+
+ ':not(LogicalExpression[operator="&&"] > CallExpression):not(LogicalExpression[operator="||"] > UnaryExpression > CallExpression)'
|
|
449
|
+
+ `[arguments.length=1]${prop('callee.object', 'origin')}:not([callee.object.object.property.name="link"])`,
|
|
450
|
+
capture: { vertex: 'callee.object.object', origin: 'arguments.0' },
|
|
451
|
+
replace: 'DfgVertex.hasOrigin({{vertex}}, {{origin}})',
|
|
452
|
+
declaredIn: { 'callee.object.property': Files.vertex },
|
|
453
|
+
/* the vertex may need the narrowing `hasOrigin` adds */
|
|
454
|
+
fix: false,
|
|
455
|
+
message: 'Ask `DfgVertex.hasOrigin` instead of reading the origin list by hand.'
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
id: 'graph-vertices-of-type',
|
|
459
|
+
selector: `CallExpression${prop('callee', 'filter')}[arguments.length=1]${call('callee.object', 'vertices')}[callee.object.arguments.length=1]`
|
|
460
|
+
+ '[arguments.0.type="ArrowFunctionExpression"][arguments.0.params.length=1][arguments.0.params.0.type="ArrayPattern"][arguments.0.params.0.elements.length=2]'
|
|
461
|
+
+ '[arguments.0.params.0.elements.1.type="Identifier"][arguments.0.body.type="BinaryExpression"][arguments.0.body.operator="==="]'
|
|
462
|
+
+ `${prop('arguments.0.body.left', 'tag')}${enumMember('arguments.0.body.right', 'VertexType')}`,
|
|
463
|
+
capture: { graph: 'callee.object.callee.object', type: 'arguments.0.body.right' },
|
|
464
|
+
replace: '{{graph}}.verticesOfType({{type}})',
|
|
465
|
+
declaredIn: { 'callee.object.callee.property': Files.graph, 'arguments.0.body.right.object': Files.vertex },
|
|
466
|
+
sameText: [['arguments.0.params.0.elements.1', 'arguments.0.body.left.object']],
|
|
467
|
+
/* the order follows the types, not the insertion */
|
|
468
|
+
fix: false,
|
|
469
|
+
message: '`verticesOfType` goes straight to the vertices of that type instead of walking all of them.'
|
|
470
|
+
},
|
|
471
|
+
edgeNeighbors('edge-targets', 'edgesFrom', 'Dataflow.targetsOf'),
|
|
472
|
+
edgeNeighbors('edge-sources', 'edgesTo', 'Dataflow.sourcesOf'),
|
|
473
|
+
{
|
|
474
|
+
id: 'edge-has-type-between',
|
|
475
|
+
selector: `CallExpression${helperCall('', 'DfEdge', 'includesType', 2)}`
|
|
476
|
+
+ `[arguments.0.type="LogicalExpression"][arguments.0.operator="??"]${call('arguments.0.left', 'get')}[arguments.0.left.arguments.length=1]`
|
|
477
|
+
+ `${call('arguments.0.left.callee.object', 'edgesFrom')}[arguments.0.left.callee.object.arguments.length=1]`
|
|
478
|
+
+ '[arguments.0.right.type="ObjectExpression"][arguments.0.right.properties.length=1][arguments.0.right.properties.0.key.name="types"][arguments.0.right.properties.0.value.value=0]',
|
|
479
|
+
capture: { graph: 'arguments.0.left.callee.object.callee.object', from: 'arguments.0.left.callee.object.arguments.0', to: 'arguments.0.left.arguments.0', types: 'arguments.1' },
|
|
480
|
+
replace: 'Dataflow.hasEdge({{graph}}, {{from}}, {{to}}, {{types}})',
|
|
481
|
+
declaredIn: { 'callee.property': Files.edge, 'arguments.0.left.callee.object.callee.property': Files.graph },
|
|
482
|
+
message: '`Dataflow.hasEdge` asks whether an edge between two vertices carries a type, absent edge included.'
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
id: 'environment-frames',
|
|
486
|
+
selector: `ForStatement[init.type="VariableDeclaration"][init.declarations.length=1]${not('test')}${prop('test.argument', 'builtInEnv')}`
|
|
487
|
+
+ `[update.type="AssignmentExpression"][update.operator="="]${prop('update.right', 'parent')}`,
|
|
488
|
+
capture: { frame: 'init.declarations.0.id', start: 'init.declarations.0.init', body: 'body' },
|
|
489
|
+
replace: 'for(const {{frame}} of REnvironment.frames({{start}})) {{body}}',
|
|
490
|
+
declaredIn: { 'test.argument.property': Files.environment },
|
|
491
|
+
sameText: [['init.declarations.0.id', 'test.argument.object'], ['init.declarations.0.id', 'update.left'], ['init.declarations.0.id', 'update.right.object']],
|
|
492
|
+
/* the walk may stop early or reassign the variable */
|
|
493
|
+
fix: false,
|
|
494
|
+
message: '`REnvironment.frames` yields these frames, innermost first and without the built-in one.'
|
|
495
|
+
},
|
|
496
|
+
builtInPrefix('built-in-id-string', true),
|
|
497
|
+
builtInPrefix('built-in-id-prefix', false),
|
|
498
|
+
builtInId('to-built-in-template', 'TemplateLiteral[quasis.length=2][expressions.length=1][quasis.0.value.raw="built-in:"][quasis.1.value.raw=""]', 'expressions.0'),
|
|
499
|
+
builtInId('to-built-in-concat', `BinaryExpression[operator="+"]${text('left', 'built-in:')}`, 'right'),
|
|
500
|
+
{
|
|
501
|
+
id: 'remove-r-quotes',
|
|
502
|
+
selector: `CallExpression${prop('callee', 'replace')}[arguments.length=2][arguments.0.type="Literal"][arguments.0.regex.flags="g"]`
|
|
503
|
+
+ String.raw `[arguments.0.regex.pattern=/^\^\[[^\]]*[\x22\x27\x60][^\]]*\]\|\[[^\]]*[\x22\x27\x60][^\]]*\]\$$/]`
|
|
504
|
+
+ text('arguments.1', ''),
|
|
505
|
+
capture: { str: 'callee.object' },
|
|
506
|
+
replace: 'RString.unquote({{str}})',
|
|
507
|
+
helperIn: Files.string,
|
|
508
|
+
/* the regex strips each end on its own, the helper only a matching pair */
|
|
509
|
+
fix: false,
|
|
510
|
+
message: '`RString.unquote` strips the R quotes of a name, `RString.splitQuotes` also tells which one it was.'
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
id: 'escape-regex',
|
|
514
|
+
selector: 'CallExpression[callee.type="MemberExpression"][callee.property.name=/^replace(All)?$/][arguments.length=2][arguments.0.type="Literal"]'
|
|
515
|
+
+ String.raw `[arguments.0.regex.flags="g"][arguments.0.regex.pattern=/^\[\.\*\+\?\^\$\{\}\(\)\|\[\\\]\\\\\]$/]`
|
|
516
|
+
+ String.raw `[arguments.1.type="Literal"][arguments.1.value=/^\\\$&$/]`,
|
|
517
|
+
capture: { str: 'callee.object' },
|
|
518
|
+
replace: 'escapeRegex({{str}})',
|
|
519
|
+
helperIn: Files.strings,
|
|
520
|
+
message: 'Use `escapeRegex` to match text literally, do not spell out the character class.'
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
id: 'identifier-split',
|
|
524
|
+
selector: `CallExpression${prop('callee', 'split')}[arguments.length=1]${text('arguments.0', '::')}`
|
|
525
|
+
+ ':matches([callee.object.name=/name|ident|symbol|qualified|fn/i], [callee.object.property.name=/name|ident|symbol|qualified|fn/i], [callee.object.expression.property.name=/name|ident|symbol|qualified|fn/i])',
|
|
526
|
+
capture: { str: 'callee.object' },
|
|
527
|
+
replace: 'Identifier.parse({{str}})',
|
|
528
|
+
helperIn: Files.identifier,
|
|
529
|
+
/* the parts come back as a tuple, and the text may not be an identifier at all */
|
|
530
|
+
fix: false,
|
|
531
|
+
message: 'Do not take `pkg::name` apart by hand: `Identifier.parse`, then `Identifier.getName` and `Identifier.getNamespace` (`:::` is part of the syntax).'
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
id: 'unique-array',
|
|
535
|
+
selector: spreadSet(':not([elements.0.argument.arguments.0.type="ArrayExpression"])'),
|
|
536
|
+
capture: { items: 'elements.0.argument.arguments.0' },
|
|
537
|
+
replace: 'uniqueArray({{items}})',
|
|
538
|
+
helperIn: Files.arrays,
|
|
539
|
+
message: 'Use `uniqueArray`, a duplicate-free copy is not a place for a `Set` detour.'
|
|
540
|
+
},
|
|
541
|
+
{
|
|
542
|
+
id: 'unique-array-merge',
|
|
543
|
+
selector: spreadSet('[elements.0.argument.arguments.0.type="ArrayExpression"][elements.0.argument.arguments.0.elements.length=2]'
|
|
544
|
+
+ '[elements.0.argument.arguments.0.elements.0.type="SpreadElement"][elements.0.argument.arguments.0.elements.1.type="SpreadElement"]'),
|
|
545
|
+
capture: { left: 'elements.0.argument.arguments.0.elements.0.argument', right: 'elements.0.argument.arguments.0.elements.1.argument' },
|
|
546
|
+
replace: 'uniqueArrayMerge({{left}}, {{right}})',
|
|
547
|
+
helperIn: Files.arrays,
|
|
548
|
+
/* the helper wants arrays, a spread also takes iterables */
|
|
549
|
+
fix: false,
|
|
550
|
+
message: 'Use `uniqueArrayMerge`, it merges two arrays without duplicates.'
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
id: 'array-equal',
|
|
554
|
+
selector: `LogicalExpression[operator="&&"][left.type="BinaryExpression"][left.operator="==="]${prop('left.left', 'length')}${prop('left.right', 'length')}`
|
|
555
|
+
+ `${call('right', 'every')}[right.arguments.length=1][right.arguments.0.type="ArrowFunctionExpression"][right.arguments.0.params.length=2]`
|
|
556
|
+
+ '[right.arguments.0.body.type="BinaryExpression"][right.arguments.0.body.operator="==="][right.arguments.0.body.right.type="MemberExpression"][right.arguments.0.body.right.computed=true]',
|
|
557
|
+
capture: { left: 'left.left.object', right: 'left.right.object' },
|
|
558
|
+
replace: 'arrayEqual({{left}}, {{right}})',
|
|
559
|
+
helperIn: Files.arrays,
|
|
560
|
+
sameText: [['left.left.object', 'right.callee.object'], ['left.right.object', 'right.arguments.0.body.right.object'],
|
|
561
|
+
['right.arguments.0.params.0', 'right.arguments.0.body.left'], ['right.arguments.0.params.1', 'right.arguments.0.body.right.property']],
|
|
562
|
+
message: 'Use `arrayEqual`, comparing two arrays element by element is its job.'
|
|
563
|
+
},
|
|
564
|
+
{
|
|
565
|
+
id: 'sort-code-unit',
|
|
566
|
+
selector: `CallExpression${prop('callee', 'sort')}[arguments.length=1][arguments.0.type="ArrowFunctionExpression"]`
|
|
567
|
+
+ `[arguments.0.params.length=2]${call('arguments.0.body', 'localeCompare')}[arguments.0.body.arguments.length=1]`,
|
|
568
|
+
capture: { list: 'callee.object' },
|
|
569
|
+
replace: '{{list}}.sort(compareByCodeUnit)',
|
|
570
|
+
helperIn: Files.strings,
|
|
571
|
+
sameText: [['arguments.0.params.0', 'arguments.0.body.callee.object'], ['arguments.0.params.1', 'arguments.0.body.arguments.0']],
|
|
572
|
+
/* `localeCompare` is fine for an order only a reader sees */
|
|
573
|
+
fix: false,
|
|
574
|
+
message: '`localeCompare` depends on the locale and the ICU build, so an order that feeds an analysis result should use `compareByCodeUnit`.'
|
|
575
|
+
},
|
|
281
576
|
{
|
|
282
577
|
id: 'array-sum',
|
|
283
|
-
selector: '
|
|
284
|
-
+ '[arguments.
|
|
285
|
-
+ '[arguments.0.type="ArrowFunctionExpression"][arguments.0.params.length=2]'
|
|
286
|
-
+ '[arguments.0.body.type="BinaryExpression"][arguments.0.body.operator="+"]',
|
|
578
|
+
selector: `CallExpression${prop('callee', 'reduce')}[arguments.length=2][arguments.1.type="Literal"][arguments.1.value=0]`
|
|
579
|
+
+ '[arguments.0.type="ArrowFunctionExpression"][arguments.0.params.length=2][arguments.0.body.type="BinaryExpression"][arguments.0.body.operator="+"]',
|
|
287
580
|
capture: { array: 'callee.object' },
|
|
288
581
|
replace: 'arraySum({{array}})',
|
|
582
|
+
helperIn: Files.arrays,
|
|
289
583
|
sameText: [['arguments.0.params.0', 'arguments.0.body.left'], ['arguments.0.params.1', 'arguments.0.body.right']],
|
|
290
584
|
message: 'Use `arraySum`, summing a list is not a place to spell out a fold.'
|
|
291
585
|
},
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
id: 'some-instead-of-filter-length',
|
|
295
|
-
selector: `${versusZero(true)}[left.type="MemberExpression"]${member('left', 'length')}`
|
|
296
|
-
+ `${call('left.object', 'filter')}[left.object.arguments.length=1]`,
|
|
297
|
-
capture: { array: 'left.object.callee.object', predicate: 'left.object.arguments.0' },
|
|
298
|
-
replace: '{{array}}.some({{predicate}})',
|
|
299
|
-
message: '`filter(...).length > 0` walks the whole list and allocates the matches, `some` stops at the first hit.'
|
|
300
|
-
},
|
|
301
|
-
{
|
|
302
|
-
id: 'none-instead-of-filter-length',
|
|
303
|
-
selector: `${versusZero(false)}[left.type="MemberExpression"]${member('left', 'length')}`
|
|
304
|
-
+ `${call('left.object', 'filter')}[left.object.arguments.length=1]`,
|
|
305
|
-
capture: { array: 'left.object.callee.object', predicate: 'left.object.arguments.0' },
|
|
306
|
-
replace: '!{{array}}.some({{predicate}})',
|
|
307
|
-
message: '`filter(...).length === 0` walks the whole list and allocates the matches, `!some` stops at the first hit.'
|
|
308
|
-
}
|
|
586
|
+
someInsteadOfFilter('some-instead-of-filter-length', true),
|
|
587
|
+
someInsteadOfFilter('none-instead-of-filter-length', false)
|
|
309
588
|
];
|
|
310
589
|
module.exports = patterns;
|