@eagleoutice/eslint-config-flowr 2.1.7 → 2.1.9
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 +14 -2
- package/index.js +3 -9
- package/package.json +1 -2
- package/pattern-type.d.ts +2 -0
- package/patterns.d.ts +1 -3
- package/patterns.js +488 -209
- package/plugin.d.ts +1 -0
- package/plugin.js +3 -1
- package/rules/file-naming.d.ts +3 -0
- package/rules/file-naming.js +37 -0
- package/rules/replacement-pattern.js +50 -2
- package/rules/ts-types.d.ts +3 -0
- package/rules/util.js +6 -6
package/README.md
CHANGED
|
@@ -19,7 +19,9 @@ configuration from your `tsdoc.json`:
|
|
|
19
19
|
## The `flowr` plugin
|
|
20
20
|
|
|
21
21
|
flowR groups its functions in helper objects (`DfEdge`, `Resolve`, `NodeId`, and friends) so that there is one obvious
|
|
22
|
-
entry point per topic. Two rules keep the code on those entry points, both on by default.
|
|
22
|
+
entry point per topic. Two rules keep the code on those entry points, both on by default. A third,
|
|
23
|
+
`flowr/file-naming`, checks file names (`dataflow-graph.ts`, `1-setup.ts`, `Vertex.ts`) and keeps `*.spec.ts` files
|
|
24
|
+
below `test/`.
|
|
23
25
|
|
|
24
26
|
| rule | driven by | finds |
|
|
25
27
|
| :-- | :-- | :-- |
|
|
@@ -70,6 +72,7 @@ To add one, append to [`patterns.ts`](patterns.ts):
|
|
|
70
72
|
| `replace` | the replacement, required. `{{name}}` is a capture's source text, `{{name\|last}}` keeps the part behind its last dot, turning `VertexType.Use` into `Use` |
|
|
71
73
|
| `capture` | name to a path relative to the match |
|
|
72
74
|
| `declaredIn` | path to a file the node's symbol must come from, so an unrelated `types` or `tag` does not match. The declaring file itself is exempt |
|
|
75
|
+
| `helperIn` | path to the file the replacement's helper lives in, for a shape without a flowR symbol to pin; in a program without that file the pattern stays silent |
|
|
73
76
|
| `sameText` | pairs of paths that must spell the same code, esquery has no back-references |
|
|
74
77
|
| `message` | what to tell the reader, filled from the captures like `replace` |
|
|
75
78
|
| `fix` | `true` always fixes, `false` always suggests, which is right when the replacement can change type narrowing. Omitted, it fixes when the replacement is in scope |
|
|
@@ -82,10 +85,16 @@ The shipped set groups into families:
|
|
|
82
85
|
| `edge-*` | `e.types === T`, `(e.types & T) !== 0`, and the bitmask spellings around them | `DfEdge` |
|
|
83
86
|
| `vertex-is*`, `vertex-has-origin` | `v.tag === VertexType.X`, `DfgVertex.isFunctionCall(v) && v.origin.includes(o)` | `DfgVertex` |
|
|
84
87
|
| `node-is*` | `n.type === RType.X`, negated and through `?.` | `RSymbol`, `RFunctionCall`, and the rest of the `R*` node objects |
|
|
85
|
-
| `argument-is-*`, `call-is-*`, `list-is-implicit` | a guard written out, `RFunctionCall.is(n) && n.named` | the narrower guard of the same object |
|
|
88
|
+
| `argument-is-*`, `call-is-*`, `list-is-implicit` | a guard written out, `RFunctionCall.is(n) && n.named`, `RFunctionCall.isNamed(c) && Identifier.getName(c.functionName.content) === 'f'` (also with a set's `has`, and negated) | the narrower guard of the same object, `RFunctionCall.isNamed(c, 'f')` |
|
|
86
89
|
| `symbol-name-comparison*` | `symbol.content === 'name'`, which misses `pkg::name` because an identifier with a namespace is an array | `Identifier.getName` / `Identifier.matches` |
|
|
87
90
|
| `graph-edges-from`, `graph-edges-to` | `g.outgoingEdges(id) ?? []` / `?? NoEdges`, a fallback the total accessors already are | `edgesFrom` / `edgesTo` |
|
|
88
91
|
| `location-range-hop`, `location-at-node`, `accessor-undefined-guard`, `identifier-tostring-fallback` | an unwrap, a positional destructure, or an absent-value guard in front of a reader that passes `undefined` through by itself | `SourceLocation`, `Identifier` |
|
|
92
|
+
| `node-lexeme*`, `node-own-lexeme*`, `node-range*`, `node-own-range*`, `range-*`, `node-id-recover-name` | `x.info.fullLexeme ?? x.lexeme`, `x.info.fullRange ?? x.location` (and the reverse orders), `range?.[0]`, `idMap.get(id)?.lexeme` | `RNode.lexeme`, `RNode.ownLexeme`, `SourceRange.fromNode`, `SourceRange.ownFromNode` and the range readers, `NodeId.recoverName` |
|
|
93
|
+
| `unpack-arg*`, `find-named-arg*`, `call-sole-argument`, `call-called-name*`, `callee-lexeme-comparison*` | argument and callee reads written out, `lexeme` compared against a name | `RArgument.valueOf`, `RArgument.findNamed`, `RFunctionCall.soleArgument`, `calledName`, `isNamed` |
|
|
94
|
+
| `vertex-origin-includes`, `vertex-lacks-origin`, `vertex-has-origin-array`, `vertex-name-comparison*` | origin lists read by hand, a call compared by its spelling | `DfgVertex.hasOrigin`, `Dataflow.qualify` |
|
|
95
|
+
| `edge-targets`, `edge-sources`, `edge-has-type-between`, `graph-vertices-of-type`, `environment-frames` | walks over edges, vertices and environment frames spelled out | `Dataflow.targetsOf`, `Dataflow.sourcesOf`, `Dataflow.hasEdge`, `verticesOfType`, `REnvironment.frames` |
|
|
96
|
+
| `built-in-id-*`, `to-built-in-*` | the `built-in:` prefix handled by hand | `NodeId.isBuiltIn`, `NodeId.toBuiltIn` |
|
|
97
|
+
| `remove-r-quotes`, `escape-regex`, `identifier-split`, `unique-array*`, `array-equal`, `sort-code-unit` | text, set and array chores with a helper | `RString.unquote`, `escapeRegex`, `Identifier.parse`, `uniqueArray`, `arrayEqual`, `compareByCodeUnit` |
|
|
89
98
|
| `some-instead-of-filter-length`, `array-sum` | a full walk where a helper does it once | `some`, `arraySum` |
|
|
90
99
|
|
|
91
100
|
A pattern is exempt in the file `declaredIn` resolves to, which is why a shape only earns a place here when the
|
|
@@ -93,6 +102,9 @@ match names a flowR symbol: without one the rule cannot tell the helper's own bo
|
|
|
93
102
|
`@lintIgnore` on the declaration would be papering over that. A guard that *is* the raw comparison
|
|
94
103
|
(`RSymbol.is` compares `type` against `RType.Symbol`) is the case the tag is actually for.
|
|
95
104
|
|
|
105
|
+
A match inside a function named like the one the replacement calls is never reported either: that is the helper's
|
|
106
|
+
own body (`escapeRegex` spelling out the class it escapes), where the fix would make the helper call itself.
|
|
107
|
+
|
|
96
108
|
An own `patterns` array replaces the defaults rather than merging with them. To propose one, open an issue with the
|
|
97
109
|
[replacement pattern template](.github/ISSUE_TEMPLATE/replacement-pattern.yaml).
|
|
98
110
|
|
package/index.js
CHANGED
|
@@ -10,7 +10,6 @@ const eslint_plugin_2 = __importDefault(require("@stylistic/eslint-plugin"));
|
|
|
10
10
|
const eslint_plugin_jsdoc_1 = __importDefault(require("eslint-plugin-jsdoc"));
|
|
11
11
|
const eslint_plugin_tsdoc_1 = __importDefault(require("eslint-plugin-tsdoc"));
|
|
12
12
|
const eslint_plugin_unused_imports_1 = __importDefault(require("eslint-plugin-unused-imports"));
|
|
13
|
-
const eslint_plugin_check_file_1 = __importDefault(require("eslint-plugin-check-file"));
|
|
14
13
|
const eslint_plugin_import_x_1 = __importDefault(require("eslint-plugin-import-x"));
|
|
15
14
|
const eslint_plugin_unicorn_1 = __importDefault(require("eslint-plugin-unicorn"));
|
|
16
15
|
const plugin_1 = __importDefault(require("./plugin"));
|
|
@@ -41,7 +40,6 @@ const config = [
|
|
|
41
40
|
'@stylistic': eslint_plugin_2.default,
|
|
42
41
|
'import-x': eslint_plugin_import_x_1.default,
|
|
43
42
|
'unused-imports': eslint_plugin_unused_imports_1.default,
|
|
44
|
-
'check-file': eslint_plugin_check_file_1.default,
|
|
45
43
|
tsdoc: eslint_plugin_tsdoc_1.default,
|
|
46
44
|
jsdoc: eslint_plugin_jsdoc_1.default,
|
|
47
45
|
flowr: plugin_1.default,
|
|
@@ -57,7 +55,8 @@ const config = [
|
|
|
57
55
|
* so it would force every consumer to strip it again before bundling. */
|
|
58
56
|
'no-restricted-imports': ['error', {
|
|
59
57
|
patterns: [{
|
|
60
|
-
|
|
58
|
+
/* `node:test` has no bare name */
|
|
59
|
+
group: ['node:*', 'node:*/*', '!node:test'],
|
|
61
60
|
message: 'Import the bare module (`path`, not `node:path`) so a bundler can swap it for a browser polyfill.'
|
|
62
61
|
}]
|
|
63
62
|
}],
|
|
@@ -185,12 +184,7 @@ const config = [
|
|
|
185
184
|
throw: { after: false }
|
|
186
185
|
}
|
|
187
186
|
}],
|
|
188
|
-
'
|
|
189
|
-
'**/*.ts': '?(\\d+-)?([A-Z])+([a-z])*((-|.)?([A-Z])+([a-z]))'
|
|
190
|
-
}],
|
|
191
|
-
'check-file/folder-match-with-fex': ['error', {
|
|
192
|
-
'*.spec.{js,jsx,ts,tsx}': 'test/**'
|
|
193
|
-
}],
|
|
187
|
+
'flowr/file-naming': 'error',
|
|
194
188
|
'@stylistic/no-trailing-spaces': 'error',
|
|
195
189
|
'@stylistic/space-infix-ops': ['error', { int32Hint: false }],
|
|
196
190
|
'@typescript-eslint/no-unused-vars': 'off',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eagleoutice/eslint-config-flowr",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.9",
|
|
4
4
|
"description": "Linting rules for flowr and friends",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"repository": {
|
|
@@ -37,7 +37,6 @@
|
|
|
37
37
|
"@stylistic/eslint-plugin": "^5.7.1",
|
|
38
38
|
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
|
39
39
|
"@typescript-eslint/parser": "^8.54.0",
|
|
40
|
-
"eslint-plugin-check-file": "^3.3.1",
|
|
41
40
|
"eslint-plugin-import-x": "^4.17.1",
|
|
42
41
|
"eslint-plugin-jsdoc": "^62.5.0",
|
|
43
42
|
"eslint-plugin-tsdoc": "^0.5.0",
|
package/pattern-type.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export interface ReplacementPattern {
|
|
|
11
11
|
readonly replace: string;
|
|
12
12
|
/** path to a substring of the file its symbol must be declared in */
|
|
13
13
|
readonly declaredIn?: Readonly<Record<string, string>>;
|
|
14
|
+
/** path to a substring of the file the replacement's helper lives in; without that file in the program the pattern stays silent */
|
|
15
|
+
readonly helperIn?: string;
|
|
14
16
|
/** pairs of paths that have to spell the same code */
|
|
15
17
|
readonly sameText?: readonly (readonly string[])[];
|
|
16
18
|
/** `true` forces the autofix, `false` forces a suggestion */
|
package/patterns.d.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Default patterns of `flowr/replacement-pattern`, see the README for the format.
|
|
3
|
-
* The helpers they point at live in flowR
|
|
4
|
-
* `dataflow/graph/vertex`, `NoEdges` in `dataflow/graph/graph`, the `RNode` helpers in
|
|
5
|
-
* `r-bridge/lang-4.x/ast/model`, and the collection helpers in `util/collections`.
|
|
3
|
+
* The helpers they point at live in flowR, the paths below are the files declaring them.
|
|
6
4
|
*/
|
|
7
5
|
import type { ReplacementPattern } from './pattern-type';
|
|
8
6
|
declare const patterns: readonly ReplacementPattern[];
|
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;
|
package/plugin.d.ts
CHANGED
package/plugin.js
CHANGED
|
@@ -5,11 +5,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
/** The flowR-specific rules, see the README for the tags they read. */
|
|
6
6
|
const replacement_pattern_1 = __importDefault(require("./rules/replacement-pattern"));
|
|
7
7
|
const use_instead_1 = __importDefault(require("./rules/use-instead"));
|
|
8
|
+
const file_naming_1 = __importDefault(require("./rules/file-naming"));
|
|
8
9
|
const plugin = {
|
|
9
10
|
meta: { name: '@eagleoutice/eslint-config-flowr' },
|
|
10
11
|
rules: {
|
|
11
12
|
'use-instead': use_instead_1.default,
|
|
12
|
-
'replacement-pattern': replacement_pattern_1.default
|
|
13
|
+
'replacement-pattern': replacement_pattern_1.default,
|
|
14
|
+
'file-naming': file_naming_1.default
|
|
13
15
|
}
|
|
14
16
|
};
|
|
15
17
|
module.exports = plugin;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
const path_1 = __importDefault(require("path"));
|
|
6
|
+
/** `dataflow-graph`, `Vertex`, `1-setup`, `r-bridge.test`: lower-case words, each optionally capitalized */
|
|
7
|
+
const fileName = /^(\d+-)?[A-Z]?[a-z]+([-.][A-Z]?[a-z]+)*$/;
|
|
8
|
+
const specFile = /\.spec\.[jt]sx?$/;
|
|
9
|
+
const rule = {
|
|
10
|
+
meta: {
|
|
11
|
+
type: 'layout',
|
|
12
|
+
docs: {
|
|
13
|
+
description: 'enforce the flowR file naming convention and keep spec files in the test folder',
|
|
14
|
+
url: 'https://github.com/flowr-analysis/flowr-lint#flowrfile-naming'
|
|
15
|
+
},
|
|
16
|
+
schema: [],
|
|
17
|
+
messages: {
|
|
18
|
+
name: 'The filename "{{ name }}" does not match {{ pattern }}',
|
|
19
|
+
spec: 'The spec file "{{ name }}" belongs below test/'
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
create(context) {
|
|
23
|
+
return {
|
|
24
|
+
Program(node) {
|
|
25
|
+
const file = path_1.default.relative(context.cwd, context.physicalFilename).split(path_1.default.sep).join('/');
|
|
26
|
+
const name = path_1.default.posix.basename(file);
|
|
27
|
+
if (name.endsWith('.ts') && !fileName.test(name.slice(0, name.lastIndexOf('.')))) {
|
|
28
|
+
context.report({ node, messageId: 'name', data: { name, pattern: String(fileName) } });
|
|
29
|
+
}
|
|
30
|
+
if (specFile.test(name) && !file.startsWith('test/')) {
|
|
31
|
+
context.report({ node, messageId: 'spec', data: { name } });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
module.exports = rule;
|
|
@@ -28,6 +28,46 @@ function render(template, captures, node, sourceCode) {
|
|
|
28
28
|
});
|
|
29
29
|
return failed ? undefined : filled;
|
|
30
30
|
}
|
|
31
|
+
/** the name a replacement calls, `escapeRegex` for `escapeRegex(s)` and `toBuiltIn` for `!NodeId.toBuiltIn(x)` */
|
|
32
|
+
function calledBy(replacement) {
|
|
33
|
+
return /^!?(?:[A-Za-z_$][\w$]*\.)*([A-Za-z_$][\w$]*)\(/.exec(replacement)?.[1];
|
|
34
|
+
}
|
|
35
|
+
/** the name a function is declared or bound under, as a declaration, an object member, or a variable */
|
|
36
|
+
function nameOf(fn, parent) {
|
|
37
|
+
if (fn.type === 'FunctionDeclaration' || fn.type === 'FunctionExpression') {
|
|
38
|
+
if (fn.id) {
|
|
39
|
+
return fn.id.name;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (parent?.type === 'Property' || parent?.type === 'MethodDefinition') {
|
|
43
|
+
return parent.key.type === 'Identifier' ? parent.key.name : undefined;
|
|
44
|
+
}
|
|
45
|
+
return parent?.type === 'VariableDeclarator' && parent.id.type === 'Identifier' ? parent.id.name : undefined;
|
|
46
|
+
}
|
|
47
|
+
/** Whether `node` sits in the function the replacement would call, where the fix would make the helper call itself. */
|
|
48
|
+
function withinReplacement(node, replacement, sourceCode) {
|
|
49
|
+
const called = calledBy(replacement);
|
|
50
|
+
if (called === undefined) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const ancestors = sourceCode.getAncestors(node);
|
|
54
|
+
return ancestors.some((fn, i) => (fn.type === 'FunctionDeclaration' || fn.type === 'FunctionExpression' || fn.type === 'ArrowFunctionExpression')
|
|
55
|
+
&& nameOf(fn, ancestors[i - 1]) === called);
|
|
56
|
+
}
|
|
57
|
+
/** per program, whether one of its files matches a `helperIn` path; every file of a run shares the program */
|
|
58
|
+
const helperFiles = new WeakMap();
|
|
59
|
+
function programHas(program, path) {
|
|
60
|
+
let known = helperFiles.get(program);
|
|
61
|
+
if (known === undefined) {
|
|
62
|
+
helperFiles.set(program, known = new Map());
|
|
63
|
+
}
|
|
64
|
+
let has = known.get(path);
|
|
65
|
+
if (has === undefined) {
|
|
66
|
+
has = program.getSourceFiles().some(file => file.fileName.replaceAll('\\', '/').includes(path));
|
|
67
|
+
known.set(path, has);
|
|
68
|
+
}
|
|
69
|
+
return has;
|
|
70
|
+
}
|
|
31
71
|
const rule = {
|
|
32
72
|
meta: {
|
|
33
73
|
type: 'suggestion',
|
|
@@ -56,6 +96,8 @@ const rule = {
|
|
|
56
96
|
replace: { type: 'string' },
|
|
57
97
|
/** path to a substring of the file its symbol must be declared in, this is what keeps the pattern flowR-specific */
|
|
58
98
|
declaredIn: { type: 'object', additionalProperties: { type: 'string' } },
|
|
99
|
+
/** path to a substring of the file the replacement's helper lives in, the pattern is silent in a program without it */
|
|
100
|
+
helperIn: { type: 'string' },
|
|
59
101
|
/** pairs of paths that have to spell the same code, esquery has no back-references */
|
|
60
102
|
sameText: { type: 'array', items: { type: 'array', items: { type: 'string' }, minItems: 2, maxItems: 2 } },
|
|
61
103
|
/** `true` forces the autofix, `false` forces a suggestion; by default it is fixed when the replacement is already in scope */
|
|
@@ -94,7 +136,10 @@ const rule = {
|
|
|
94
136
|
* The declaring file itself is exempt, it is where the helper is built.
|
|
95
137
|
*/
|
|
96
138
|
function isFlowr(node, pattern) {
|
|
97
|
-
if (!pattern.
|
|
139
|
+
if (pattern.helperIn !== undefined && (!services || !programHas(services.program, pattern.helperIn))) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
else if (!pattern.declaredIn) {
|
|
98
143
|
return true;
|
|
99
144
|
}
|
|
100
145
|
else if (!services || !checker) {
|
|
@@ -121,7 +166,10 @@ const rule = {
|
|
|
121
166
|
const replacement = render(pattern.replace, captures, node, sourceCode);
|
|
122
167
|
const message = (pattern.message === undefined ? undefined : render(pattern.message, captures, node, sourceCode))
|
|
123
168
|
?? (replacement ? `Use \`${replacement}\` here.` : `\`${pattern.id ?? pattern.selector}\` has a helper replacement.`);
|
|
124
|
-
if (replacement
|
|
169
|
+
if (replacement !== undefined && withinReplacement(node, replacement, sourceCode)) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
else if (replacement === undefined) {
|
|
125
173
|
context.report({ node, messageId: 'replacement', data: { message } });
|
|
126
174
|
return;
|
|
127
175
|
}
|
package/rules/ts-types.d.ts
CHANGED
package/rules/util.js
CHANGED
|
@@ -6,10 +6,10 @@ exports.symbolOf = symbolOf;
|
|
|
6
6
|
exports.declarationFile = declarationFile;
|
|
7
7
|
exports.useInsteadOf = useInsteadOf;
|
|
8
8
|
exports.isApplicable = isApplicable;
|
|
9
|
-
const
|
|
10
|
-
const
|
|
9
|
+
const UseInsteadTag = 'useInstead';
|
|
10
|
+
const SuppressTag = '@lintIgnore';
|
|
11
11
|
/* ts.SymbolFlags.Alias, spelled out to keep typescript out of the dependencies */
|
|
12
|
-
const
|
|
12
|
+
const AliasFlag = 1 << 21;
|
|
13
13
|
/** The type-aware services, or `undefined` if the file is parsed without a program. */
|
|
14
14
|
function typeServices(context) {
|
|
15
15
|
const services = context.sourceCode.parserServices;
|
|
@@ -36,7 +36,7 @@ function silences(comment, ids) {
|
|
|
36
36
|
*/
|
|
37
37
|
function suppressed(context, node, ids) {
|
|
38
38
|
const sourceCode = context.sourceCode;
|
|
39
|
-
if (!sourceCode.text.includes(
|
|
39
|
+
if (!sourceCode.text.includes(SuppressTag)) {
|
|
40
40
|
return false;
|
|
41
41
|
}
|
|
42
42
|
/* a header comment marks the whole file: above the imports, or separated by a blank line */
|
|
@@ -59,7 +59,7 @@ function suppressed(context, node, ids) {
|
|
|
59
59
|
}
|
|
60
60
|
/** Resolves import aliases so the documentation of the original declaration is read. */
|
|
61
61
|
function resolveAlias(symbol, checker) {
|
|
62
|
-
return (symbol.flags &
|
|
62
|
+
return (symbol.flags & AliasFlag) !== 0 ? checker.getAliasedSymbol(symbol) : symbol;
|
|
63
63
|
}
|
|
64
64
|
/** The symbol an ESTree node resolves to, with import aliases followed. */
|
|
65
65
|
function symbolOf(node, services, checker) {
|
|
@@ -74,7 +74,7 @@ function declarationFile(symbol) {
|
|
|
74
74
|
/** The text of the `@useInstead` tag of a symbol, `undefined` if it carries none. */
|
|
75
75
|
function useInsteadOf(symbol, checker) {
|
|
76
76
|
for (const tag of symbol.getJsDocTags(checker)) {
|
|
77
|
-
if (tag.name ===
|
|
77
|
+
if (tag.name === UseInsteadTag) {
|
|
78
78
|
return (tag.text ?? []).map(p => p.text).join('').trim();
|
|
79
79
|
}
|
|
80
80
|
}
|