@eagleoutice/eslint-config-flowr 2.1.1 → 2.1.3

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 CHANGED
@@ -75,6 +75,22 @@ To add one, append to [`patterns.ts`](patterns.ts):
75
75
  | `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 |
76
76
  | `id` | names the pattern in `@lintIgnore` and in the message |
77
77
 
78
+ The shipped set groups into families:
79
+
80
+ | family | shape it finds | helper it points at |
81
+ | :-- | :-- | :-- |
82
+ | `edge-*` | `e.types === T`, `(e.types & T) !== 0`, and the bitmask spellings around them | `DfEdge` |
83
+ | `vertex-is*`, `vertex-has-origin` | `v.tag === VertexType.X`, `FunctionCallVertex.is(v) && v.origin.includes(o)` | the `*Vertex` objects |
84
+ | `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 |
86
+ | `symbol-name-comparison*` | `symbol.content === 'name'`, which misses `pkg::name` because an identifier with a namespace is an array | `Identifier.getName` / `Identifier.matches` |
87
+ | `graph-edges-no-alloc`, `some-instead-of-filter-length`, `array-sum` | an allocation or a full walk where a shared constant or a helper does it once | `NoEdges`, `some`, `arraySum` |
88
+
89
+ A pattern is exempt in the file `declaredIn` resolves to, which is why a shape only earns a place here when the
90
+ match names a flowR symbol: without one the rule cannot tell the helper's own body apart from a call site, and
91
+ `@lintIgnore` on the declaration would be papering over that. A guard that *is* the raw comparison
92
+ (`RSymbol.is` compares `type` against `RType.Symbol`) is the case the tag is actually for.
93
+
78
94
  An own `patterns` array replaces the defaults rather than merging with them. To propose one, open an issue with the
79
95
  [replacement pattern template](.github/ISSUE_TEMPLATE/replacement-pattern.yaml).
80
96
 
@@ -83,8 +99,7 @@ An own `patterns` array replaces the defaults rather than merging with them. To
83
99
  | | silences |
84
100
  | :-- | :-- |
85
101
  | `// eslint-disable-next-line` | the next line, as usual |
86
- | `@performanceCritical` | a hot path that has to keep the raw form |
87
- | `@lintIgnore <ids>` | the named rules or pattern ids, all of them when given none |
102
+ | `@lintIgnore <ids>` | the named rules or pattern ids, all of them when given none (put the reason in the prose above it) |
88
103
 
89
104
  A tag on a declaration covers everything below it, a header comment above the imports covers the file. The lookup is
90
105
  guarded by a single string search, so files without either tag pay nothing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eagleoutice/eslint-config-flowr",
3
- "version": "2.1.1",
3
+ "version": "2.1.3",
4
4
  "description": "Linting rules for flowr and friends",
5
5
  "license": "ISC",
6
6
  "main": "index.js",
package/patterns.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Default patterns of `flowr/replacement-pattern`, see the README for the format.
3
3
  * The helpers they point at live in flowR: `DfEdge` in `dataflow/graph/edge`, the vertex helpers in
4
- * `dataflow/graph/vertex`, and `NoEdges` in `dataflow/graph/graph`.
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`.
5
6
  */
6
7
  import type { ReplacementPattern } from './pattern-type';
7
8
  declare const patterns: readonly ReplacementPattern[];
package/patterns.js CHANGED
@@ -2,16 +2,22 @@
2
2
  /**
3
3
  * Default patterns of `flowr/replacement-pattern`, see the README for the format.
4
4
  * The helpers they point at live in flowR: `DfEdge` in `dataflow/graph/edge`, the vertex helpers in
5
- * `dataflow/graph/vertex`, and `NoEdges` in `dataflow/graph/graph`.
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`.
6
7
  */
7
8
  const EDGE = 'dataflow/graph/edge';
8
9
  const VERTEX = 'dataflow/graph/vertex';
9
10
  const GRAPH = 'dataflow/graph/graph';
11
+ const RTYPE = 'r-bridge/lang-4.x/ast/model/type';
12
+ const CALL_NODE = 'r-bridge/lang-4.x/ast/model/nodes/r-function-call';
13
+ const ARGUMENT_NODE = 'r-bridge/lang-4.x/ast/model/nodes/r-argument';
14
+ const SYMBOL_NODE = 'r-bridge/lang-4.x/ast/model/nodes/r-symbol';
15
+ const LIST_NODE = 'r-bridge/lang-4.x/ast/model/nodes/r-expression-list';
10
16
  /** `x.types` under the given path */
11
17
  const types = (prefix) => `[${prefix}.type="MemberExpression"][${prefix}.computed=false][${prefix}.property.name="types"]`;
12
- /** `x.tag` under the given path */
13
- const tag = (prefix) => `[${prefix}.computed=false][${prefix}.property.name="tag"]`;
14
- /** `EdgeType.<Member>` / `VertexType.<Member>` on the right */
18
+ /** `x.<name>` under the given path, without asserting the node kind, which the caller pins down */
19
+ const member = (prefix, name) => `[${prefix}.computed=false][${prefix}.property.name="${name}"]`;
20
+ /** `EdgeType.<Member>` / `VertexType.<Member>` / `RType.<Member>` on the right */
15
21
  const rightIs = (name) => `[right.type="MemberExpression"][right.computed=false][right.object.name="${name}"]`;
16
22
  /** compared against the literal `0`, either for "some bit is set" or for "none is" */
17
23
  const versusZero = (nonZero) => `BinaryExpression[operator=${nonZero ? '/^(!==|>)$/' : '"==="'}][right.type="Literal"][right.value=0]`;
@@ -19,6 +25,10 @@ const versusZero = (nonZero) => `BinaryExpression[operator=${nonZero ? '/^(!==|>
19
25
  const mask = '[left.type="BinaryExpression"][left.operator="&"]';
20
26
  /** a call of `<prefix>.<property>(...)` */
21
27
  const call = (prefix, property) => `[${prefix}.type="CallExpression"][${prefix}.callee.type="MemberExpression"][${prefix}.callee.property.name="${property}"]`;
28
+ /** a call of `<Helper>.<property>(<one argument>)` under the given path */
29
+ const helperCall = (prefix, helper, property) => `${call(prefix, property)}[${prefix}.callee.object.name="${helper}"][${prefix}.arguments.length=1]`;
30
+ /** `x === undefined` / `x !== undefined` under the given path */
31
+ const versusUndefined = (prefix, set) => `[${prefix}.type="BinaryExpression"][${prefix}.operator="${set ? '!==' : '==='}"][${prefix}.right.type="Identifier"][${prefix}.right.name="undefined"]`;
22
32
  const destructuringHint = ' Prefer keeping the edge over destructuring `types`, the helpers take it directly and the wrapper object goes away.';
23
33
  /** `x.types === T`, on the edge itself or on a destructured `types` */
24
34
  const edgeIsOnly = (id, { negated = false, destructured = false } = {}) => ({
@@ -51,32 +61,65 @@ const edgeStates = (id, helper, what, nonZero) => ({
51
61
  declaredIn: { 'left.property': EDGE },
52
62
  message: `Use \`DfEdge.${helper}\` to ask whether the edge states ${what}.`
53
63
  });
54
- /** `v.tag === VertexType.X`, with `{{type|last}}` spelling the name of the matching helper */
55
- const vertexIs = (id, { negated = false, optional = false } = {}) => {
56
- const vertex = optional ? 'left.expression.object' : 'left.object';
64
+ /**
65
+ * A discriminator compared against its enum, `v.tag === VertexType.X` and `n.type === RType.X`.
66
+ * `optional` covers the `?.` spelling, which parses as a chain around the same member expression.
67
+ * `declaring` maps a path of the match to the file its symbol has to come from; the enum on the right is
68
+ * always pinned, the property on the left only where one file declares it for every case.
69
+ */
70
+ const discriminatorIs = (id, { property, enumName, enumFile, propertyFile, helper, hint }, { negated = false, optional = false } = {}) => {
57
71
  const left = optional ? 'left.expression' : 'left';
58
72
  return {
59
73
  id,
60
- selector: `BinaryExpression[operator="${negated ? '!==' : '==='}"][left.type="${optional ? 'ChainExpression' : 'MemberExpression'}"]${tag(left)}${rightIs('VertexType')}`,
61
- capture: { vertex, type: 'right' },
62
- replace: `${negated ? '!' : ''}{{type|last}}Vertex.is({{vertex}})`,
63
- declaredIn: { [`${left}.property`]: VERTEX, 'right.object': VERTEX },
64
- message: 'Compare through the vertex helper (`{{type|last}}Vertex.is`), it narrows the type as well.'
74
+ selector: `BinaryExpression[operator="${negated ? '!==' : '==='}"][left.type="${optional ? 'ChainExpression' : 'MemberExpression'}"]${member(left, property)}${rightIs(enumName)}`,
75
+ capture: { subject: `${left}.object`, type: 'right' },
76
+ replace: `${negated ? '!' : ''}${helper}.is({{subject}})`,
77
+ declaredIn: { 'right.object': enumFile, ...propertyFile ? { [`${left}.property`]: propertyFile } : {} },
78
+ message: `${hint} (\`${helper}.is\`), it narrows the type as well.`
65
79
  };
66
80
  };
67
- /** `FunctionCallVertex.is(v) && v.origin.includes(T)`, the body of `hasOrigin` written out */
68
- const hasOrigin = (id) => ({
81
+ /** `v.tag === VertexType.X`, with `{{type|last}}` spelling the name of the matching helper */
82
+ const vertexIs = (id, options) => discriminatorIs(id, {
83
+ property: 'tag', enumName: 'VertexType', enumFile: VERTEX, propertyFile: VERTEX,
84
+ helper: '{{type|last}}Vertex', hint: 'Compare through the vertex helper'
85
+ }, options);
86
+ /**
87
+ * `n.type === RType.X`. Every `RType` member has a helper object of the same name prefixed with `R`
88
+ * (`RType.Symbol` to `RSymbol`), so `{{type|last}}` names it without a table.
89
+ * Only the enum is pinned to its file: `type` is declared once per node interface, and on the `RNode`
90
+ * union it resolves to a synthesized symbol, so demanding a declaring file for it would match nothing.
91
+ */
92
+ const nodeIs = (id, options) => discriminatorIs(id, {
93
+ property: 'type', enumName: 'RType', enumFile: RTYPE,
94
+ helper: 'R{{type|last}}', hint: 'Ask the AST node helper'
95
+ }, options);
96
+ /**
97
+ * `<Helper>.is(x) && <extra check on x>`, the body of a more specific guard written out.
98
+ * `guard` pins down the right-hand side, `subject` is the path of the operand it repeats.
99
+ */
100
+ const guardWrittenOut = ({ id, helper, member: narrower, file, guard, subject, message, fix }) => ({
69
101
  id,
70
- selector: 'LogicalExpression[operator="&&"]'
71
- + `${call('left', 'is')}[left.callee.object.name="FunctionCallVertex"]`
72
- + `${call('right', 'includes')}[right.callee.object.type="MemberExpression"][right.callee.object.property.name="origin"]`,
73
- capture: { vertex: 'left.arguments.0', origin: 'right.arguments.0' },
74
- replace: 'FunctionCallVertex.hasOrigin({{vertex}}, {{origin}})',
75
- declaredIn: { 'left.callee.object': VERTEX, 'right.callee.object.property': VERTEX },
76
- sameText: [['left.arguments.0', 'right.callee.object.object']],
77
- /* `hasOrigin` is no type predicate, so the replacement can drop a narrowing the code below relies on */
102
+ selector: `LogicalExpression[operator="&&"]${helperCall('left', helper, 'is')}${guard}`,
103
+ capture: { node: 'left.arguments.0' },
104
+ replace: `${helper}.${narrower}({{node}})`,
105
+ declaredIn: { 'left.callee.object': file },
106
+ sameText: [['left.arguments.0', subject]],
107
+ ...fix === undefined ? {} : { fix },
108
+ message
109
+ });
110
+ /**
111
+ * `symbol.content === 'name'`. `content` of an {@link RSymbol} is an `Identifier`, which carries a namespace
112
+ * and turns into an array once it has one, so `===` against a bare name silently misses `pkg::name`.
113
+ */
114
+ const symbolName = (id, negated) => ({
115
+ id,
116
+ selector: `BinaryExpression[operator="${negated ? '!==' : '==='}"][left.type="MemberExpression"]${member('left', 'content')}[right.type="Literal"][right.value=type(string)]`,
117
+ capture: { symbol: 'left.object', name: 'right' },
118
+ replace: `Identifier.getName({{symbol}}.content) ${negated ? '!==' : '==='} {{name}}`,
119
+ declaredIn: { 'left.property': SYMBOL_NODE },
120
+ /* which of the two is meant is the author's call, so this is offered rather than applied */
78
121
  fix: false,
79
- message: 'Use `FunctionCallVertex.hasOrigin`, it is exactly this check. Check the narrowing first, `hasOrigin` returns a plain boolean.'
122
+ 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.'
80
123
  });
81
124
  const patterns = [
82
125
  edgeIsOnly('edge-is-only-type'),
@@ -95,7 +138,19 @@ const patterns = [
95
138
  declaredIn: { 'left.property': EDGE },
96
139
  message: 'Do not rely on the bitmask being truthy, `DfEdge.includesType` says what is meant.'
97
140
  },
98
- hasOrigin('vertex-has-origin'),
141
+ {
142
+ id: 'vertex-has-origin',
143
+ selector: 'LogicalExpression[operator="&&"]'
144
+ + `${helperCall('left', 'FunctionCallVertex', 'is')}`
145
+ + `${call('right', 'includes')}[right.callee.object.type="MemberExpression"][right.callee.object.property.name="origin"]`,
146
+ capture: { vertex: 'left.arguments.0', origin: 'right.arguments.0' },
147
+ replace: 'FunctionCallVertex.hasOrigin({{vertex}}, {{origin}})',
148
+ declaredIn: { 'left.callee.object': VERTEX, 'right.callee.object.property': VERTEX },
149
+ sameText: [['left.arguments.0', 'right.callee.object.object']],
150
+ /* `hasOrigin` is no type predicate, so the replacement can drop a narrowing the code below relies on */
151
+ fix: false,
152
+ message: 'Use `FunctionCallVertex.hasOrigin`, it is exactly this check. Check the narrowing first, `hasOrigin` returns a plain boolean.'
153
+ },
99
154
  {
100
155
  /* the `[]` fallback allocates on every miss, and these sit in traversal loops */
101
156
  id: 'graph-edges-no-alloc',
@@ -108,6 +163,76 @@ const patterns = [
108
163
  vertexIs('vertex-is'),
109
164
  vertexIs('vertex-is-not', { negated: true }),
110
165
  vertexIs('vertex-is-optional', { optional: true }),
111
- vertexIs('vertex-is-not-optional', { negated: true, optional: true })
166
+ vertexIs('vertex-is-not-optional', { negated: true, optional: true }),
167
+ nodeIs('node-is'),
168
+ nodeIs('node-is-not', { negated: true }),
169
+ nodeIs('node-is-optional', { optional: true }),
170
+ nodeIs('node-is-not-optional', { negated: true, optional: true }),
171
+ guardWrittenOut({
172
+ id: 'call-is-named', helper: 'RFunctionCall', member: 'isNamed', file: CALL_NODE,
173
+ guard: `[right.type="MemberExpression"]${member('right', 'named')}`,
174
+ subject: 'right.object',
175
+ message: 'Use `RFunctionCall.isNamed`, it is this check and narrows to `RNamedFunctionCall`.'
176
+ }),
177
+ guardWrittenOut({
178
+ id: 'call-is-named-strict', helper: 'RFunctionCall', member: 'isNamed', file: CALL_NODE,
179
+ guard: `[right.type="BinaryExpression"][right.operator="==="][right.right.type="Literal"][right.right.value=true]${member('right.left', 'named')}`,
180
+ subject: 'right.left.object',
181
+ message: 'Use `RFunctionCall.isNamed`, it is this check and narrows to `RNamedFunctionCall`.'
182
+ }),
183
+ guardWrittenOut({
184
+ id: 'call-is-unnamed', helper: 'RFunctionCall', member: 'isUnnamed', file: CALL_NODE,
185
+ guard: `[right.type="UnaryExpression"][right.operator="!"][right.argument.type="MemberExpression"]${member('right.argument', 'named')}`,
186
+ subject: 'right.argument.object',
187
+ message: 'Use `RFunctionCall.isUnnamed`, it is this check and narrows to `RUnnamedFunctionCall`.'
188
+ }),
189
+ guardWrittenOut({
190
+ id: 'argument-is-named', helper: 'RArgument', member: 'isNamed', file: ARGUMENT_NODE,
191
+ guard: `${versusUndefined('right', true)}${member('right.left', 'name')}`,
192
+ subject: 'right.left.object',
193
+ message: 'Use `RArgument.isNamed`, it is this check and keeps the `name` non-optional afterwards.'
194
+ }),
195
+ guardWrittenOut({
196
+ id: 'argument-is-with-value', helper: 'RArgument', member: 'isWithValue', file: ARGUMENT_NODE,
197
+ guard: `${versusUndefined('right', true)}${member('right.left', 'value')}`,
198
+ subject: 'right.left.object',
199
+ message: 'Use `RArgument.isWithValue`, it is this check and keeps the `value` non-optional afterwards.'
200
+ }),
201
+ guardWrittenOut({
202
+ id: 'list-is-implicit', helper: 'RExpressionList', member: 'isImplicit', file: LIST_NODE,
203
+ guard: `${versusUndefined('right', false)}${member('right.left', 'grouping')}`,
204
+ subject: 'right.left.object',
205
+ message: 'Use `RExpressionList.isImplicit`, an expression list without `grouping` is exactly the implicit one.'
206
+ }),
207
+ symbolName('symbol-name-comparison', false),
208
+ symbolName('symbol-name-comparison-not', true),
209
+ {
210
+ id: 'array-sum',
211
+ selector: 'CallExpression[callee.type="MemberExpression"][callee.property.name="reduce"][arguments.length=2]'
212
+ + '[arguments.1.type="Literal"][arguments.1.value=0]'
213
+ + '[arguments.0.type="ArrowFunctionExpression"][arguments.0.params.length=2]'
214
+ + '[arguments.0.body.type="BinaryExpression"][arguments.0.body.operator="+"]',
215
+ capture: { array: 'callee.object' },
216
+ replace: 'arraySum({{array}})',
217
+ sameText: [['arguments.0.params.0', 'arguments.0.body.left'], ['arguments.0.params.1', 'arguments.0.body.right']],
218
+ message: 'Use `arraySum`, summing a list is not a place to spell out a fold.'
219
+ },
220
+ {
221
+ /* `filter` walks the whole list and allocates the matches only to ask whether there is one */
222
+ id: 'some-instead-of-filter-length',
223
+ selector: `${versusZero(true)}[left.type="MemberExpression"]${member('left', 'length')}`
224
+ + `${call('left.object', 'filter')}[left.object.arguments.length=1]`,
225
+ capture: { array: 'left.object.callee.object', predicate: 'left.object.arguments.0' },
226
+ replace: '{{array}}.some({{predicate}})',
227
+ message: '`filter(...).length > 0` walks the whole list and allocates the matches, `some` stops at the first hit.'
228
+ },
229
+ {
230
+ id: 'none-instead-of-filter-length',
231
+ selector: `${versusZero(false)}[left.type="MemberExpression"]${member('left', 'length')}`
232
+ + `${call('left.object', 'filter')}[left.object.arguments.length=1]`,
233
+ capture: { array: 'left.object.callee.object', predicate: 'left.object.arguments.0' },
234
+ replace: '!{{array}}.some({{predicate}})',
235
+ message: '`filter(...).length === 0` walks the whole list and allocates the matches, `!some` stops at the first hit.'
236
+ }
112
237
  ];
113
238
  module.exports = patterns;
package/rules/util.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Shared helpers of the flowR rules.
3
- * Tags: `@useInstead <target>` names a replacement, `@performanceCritical` and `@lintIgnore [ids]` silence the rules.
3
+ * Tags: `@useInstead <target>` names a replacement, `@lintIgnore [ids]` silences the rules.
4
4
  */
5
5
  import type { Rule } from 'eslint';
6
6
  import type { Node } from 'estree';
@@ -8,8 +8,8 @@ import type { TsSymbol, TsTypeChecker, TypeServices } from './ts-types';
8
8
  /** The type-aware services, or `undefined` if the file is parsed without a program. */
9
9
  export declare function typeServices(context: Rule.RuleContext): TypeServices | undefined;
10
10
  /**
11
- * Whether `node` sits below a declaration documented with a suppressing tag, or in a file whose
12
- * header comment carries one. Guarded by one string search, so untagged files pay nothing.
11
+ * Whether `node` sits below a declaration documented with `@lintIgnore`, or in a file whose header comment
12
+ * carries it. Guarded by one string search, so untagged files pay nothing.
13
13
  */
14
14
  export declare function suppressed(context: Rule.RuleContext, node: Rule.Node, ids: readonly string[]): boolean;
15
15
  /** The symbol an ESTree node resolves to, with import aliases followed. */
package/rules/util.js CHANGED
@@ -7,7 +7,7 @@ exports.declarationFile = declarationFile;
7
7
  exports.useInsteadOf = useInsteadOf;
8
8
  exports.isApplicable = isApplicable;
9
9
  const USE_INSTEAD_TAG = 'useInstead';
10
- const SUPPRESS_TAGS = ['@performanceCritical', '@lintIgnore'];
10
+ const SUPPRESS_TAG = '@lintIgnore';
11
11
  /* ts.SymbolFlags.Alias, spelled out to keep typescript out of the dependencies */
12
12
  const ALIAS_FLAG = 1 << 21;
13
13
  /** The type-aware services, or `undefined` if the file is parsed without a program. */
@@ -23,9 +23,6 @@ function silences(comment, ids) {
23
23
  if (!isDocComment(comment)) {
24
24
  return false;
25
25
  }
26
- else if (comment.value.includes('@performanceCritical')) {
27
- return true;
28
- }
29
26
  const ignore = /@lintIgnore([^\n@*]*)/.exec(comment.value);
30
27
  if (!ignore) {
31
28
  return false;
@@ -34,12 +31,12 @@ function silences(comment, ids) {
34
31
  return listed.length === 0 || listed.some(id => ids.includes(id));
35
32
  }
36
33
  /**
37
- * Whether `node` sits below a declaration documented with a suppressing tag, or in a file whose
38
- * header comment carries one. Guarded by one string search, so untagged files pay nothing.
34
+ * Whether `node` sits below a declaration documented with `@lintIgnore`, or in a file whose header comment
35
+ * carries it. Guarded by one string search, so untagged files pay nothing.
39
36
  */
40
37
  function suppressed(context, node, ids) {
41
38
  const sourceCode = context.sourceCode;
42
- if (!SUPPRESS_TAGS.some(tag => sourceCode.text.includes(tag))) {
39
+ if (!sourceCode.text.includes(SUPPRESS_TAG)) {
43
40
  return false;
44
41
  }
45
42
  /* a header comment marks the whole file: above the imports, or separated by a blank line */
package/tsdoc.json CHANGED
@@ -5,10 +5,6 @@
5
5
  "tagName": "@useInstead",
6
6
  "syntaxKind": "block"
7
7
  },
8
- {
9
- "tagName": "@performanceCritical",
10
- "syntaxKind": "modifier"
11
- },
12
8
  {
13
9
  "tagName": "@lintIgnore",
14
10
  "syntaxKind": "block"