@rcrsr/rill-cli 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/LICENSE +21 -0
  2. package/dist/check/config.d.ts +20 -0
  3. package/dist/check/config.d.ts.map +1 -0
  4. package/dist/check/config.js +151 -0
  5. package/dist/check/config.js.map +1 -0
  6. package/dist/check/fixer.d.ts +39 -0
  7. package/dist/check/fixer.d.ts.map +1 -0
  8. package/dist/check/fixer.js +119 -0
  9. package/dist/check/fixer.js.map +1 -0
  10. package/dist/check/index.d.ts +10 -0
  11. package/dist/check/index.d.ts.map +1 -0
  12. package/dist/check/index.js +21 -0
  13. package/dist/check/index.js.map +1 -0
  14. package/dist/check/rules/anti-patterns.d.ts +65 -0
  15. package/dist/check/rules/anti-patterns.d.ts.map +1 -0
  16. package/dist/check/rules/anti-patterns.js +481 -0
  17. package/dist/check/rules/anti-patterns.js.map +1 -0
  18. package/dist/check/rules/closures.d.ts +66 -0
  19. package/dist/check/rules/closures.d.ts.map +1 -0
  20. package/dist/check/rules/closures.js +370 -0
  21. package/dist/check/rules/closures.js.map +1 -0
  22. package/dist/check/rules/collections.d.ts +90 -0
  23. package/dist/check/rules/collections.d.ts.map +1 -0
  24. package/dist/check/rules/collections.js +373 -0
  25. package/dist/check/rules/collections.js.map +1 -0
  26. package/dist/check/rules/conditionals.d.ts +41 -0
  27. package/dist/check/rules/conditionals.d.ts.map +1 -0
  28. package/dist/check/rules/conditionals.js +134 -0
  29. package/dist/check/rules/conditionals.js.map +1 -0
  30. package/dist/check/rules/flow.d.ts +46 -0
  31. package/dist/check/rules/flow.d.ts.map +1 -0
  32. package/dist/check/rules/flow.js +206 -0
  33. package/dist/check/rules/flow.js.map +1 -0
  34. package/dist/check/rules/formatting.d.ts +143 -0
  35. package/dist/check/rules/formatting.d.ts.map +1 -0
  36. package/dist/check/rules/formatting.js +656 -0
  37. package/dist/check/rules/formatting.js.map +1 -0
  38. package/dist/check/rules/helpers.d.ts +26 -0
  39. package/dist/check/rules/helpers.d.ts.map +1 -0
  40. package/dist/check/rules/helpers.js +66 -0
  41. package/dist/check/rules/helpers.js.map +1 -0
  42. package/dist/check/rules/index.d.ts +21 -0
  43. package/dist/check/rules/index.d.ts.map +1 -0
  44. package/dist/check/rules/index.js +78 -0
  45. package/dist/check/rules/index.js.map +1 -0
  46. package/dist/check/rules/loops.d.ts +77 -0
  47. package/dist/check/rules/loops.d.ts.map +1 -0
  48. package/dist/check/rules/loops.js +310 -0
  49. package/dist/check/rules/loops.js.map +1 -0
  50. package/dist/check/rules/naming.d.ts +21 -0
  51. package/dist/check/rules/naming.d.ts.map +1 -0
  52. package/dist/check/rules/naming.js +174 -0
  53. package/dist/check/rules/naming.js.map +1 -0
  54. package/dist/check/rules/strings.d.ts +28 -0
  55. package/dist/check/rules/strings.d.ts.map +1 -0
  56. package/dist/check/rules/strings.js +79 -0
  57. package/dist/check/rules/strings.js.map +1 -0
  58. package/dist/check/rules/types.d.ts +41 -0
  59. package/dist/check/rules/types.d.ts.map +1 -0
  60. package/dist/check/rules/types.js +167 -0
  61. package/dist/check/rules/types.js.map +1 -0
  62. package/dist/check/types.d.ts +112 -0
  63. package/dist/check/types.d.ts.map +1 -0
  64. package/dist/check/types.js +6 -0
  65. package/dist/check/types.js.map +1 -0
  66. package/dist/check/validator.d.ts +18 -0
  67. package/dist/check/validator.d.ts.map +1 -0
  68. package/dist/check/validator.js +110 -0
  69. package/dist/check/validator.js.map +1 -0
  70. package/dist/check/visitor.d.ts +33 -0
  71. package/dist/check/visitor.d.ts.map +1 -0
  72. package/dist/check/visitor.js +259 -0
  73. package/dist/check/visitor.js.map +1 -0
  74. package/dist/cli-check.d.ts +43 -0
  75. package/dist/cli-check.d.ts.map +1 -0
  76. package/dist/cli-check.js +366 -0
  77. package/dist/cli-check.js.map +1 -0
  78. package/dist/cli-error-enrichment.d.ts +73 -0
  79. package/dist/cli-error-enrichment.d.ts.map +1 -0
  80. package/dist/cli-error-enrichment.js +205 -0
  81. package/dist/cli-error-enrichment.js.map +1 -0
  82. package/dist/cli-error-formatter.d.ts +45 -0
  83. package/dist/cli-error-formatter.d.ts.map +1 -0
  84. package/dist/cli-error-formatter.js +218 -0
  85. package/dist/cli-error-formatter.js.map +1 -0
  86. package/dist/cli-eval.d.ts +15 -0
  87. package/dist/cli-eval.d.ts.map +1 -0
  88. package/dist/cli-eval.js +116 -0
  89. package/dist/cli-eval.js.map +1 -0
  90. package/dist/cli-exec.d.ts +58 -0
  91. package/dist/cli-exec.d.ts.map +1 -0
  92. package/dist/cli-exec.js +326 -0
  93. package/dist/cli-exec.js.map +1 -0
  94. package/dist/cli-explain.d.ts +24 -0
  95. package/dist/cli-explain.d.ts.map +1 -0
  96. package/dist/cli-explain.js +68 -0
  97. package/dist/cli-explain.js.map +1 -0
  98. package/dist/cli-lsp-diagnostic.d.ts +35 -0
  99. package/dist/cli-lsp-diagnostic.d.ts.map +1 -0
  100. package/dist/cli-lsp-diagnostic.js +98 -0
  101. package/dist/cli-lsp-diagnostic.js.map +1 -0
  102. package/dist/cli-module-loader.d.ts +19 -0
  103. package/dist/cli-module-loader.d.ts.map +1 -0
  104. package/dist/cli-module-loader.js +83 -0
  105. package/dist/cli-module-loader.js.map +1 -0
  106. package/dist/cli-shared.d.ts +62 -0
  107. package/dist/cli-shared.d.ts.map +1 -0
  108. package/dist/cli-shared.js +158 -0
  109. package/dist/cli-shared.js.map +1 -0
  110. package/dist/cli.d.ts +13 -0
  111. package/dist/cli.d.ts.map +1 -0
  112. package/dist/cli.js +62 -0
  113. package/dist/cli.js.map +1 -0
  114. package/dist/test-internal-import.d.ts +2 -0
  115. package/dist/test-internal-import.d.ts.map +1 -0
  116. package/dist/test-internal-import.js +7 -0
  117. package/dist/test-internal-import.js.map +1 -0
  118. package/package.json +24 -0
  119. package/src/check/config.ts +202 -0
  120. package/src/check/fixer.ts +174 -0
  121. package/src/check/index.ts +39 -0
  122. package/src/check/rules/anti-patterns.ts +585 -0
  123. package/src/check/rules/closures.ts +445 -0
  124. package/src/check/rules/collections.ts +437 -0
  125. package/src/check/rules/conditionals.ts +155 -0
  126. package/src/check/rules/flow.ts +262 -0
  127. package/src/check/rules/formatting.ts +811 -0
  128. package/src/check/rules/helpers.ts +89 -0
  129. package/src/check/rules/index.ts +140 -0
  130. package/src/check/rules/loops.ts +372 -0
  131. package/src/check/rules/naming.ts +242 -0
  132. package/src/check/rules/strings.ts +104 -0
  133. package/src/check/rules/types.ts +214 -0
  134. package/src/check/types.ts +163 -0
  135. package/src/check/validator.ts +136 -0
  136. package/src/check/visitor.ts +338 -0
  137. package/src/cli-check.ts +456 -0
  138. package/src/cli-error-enrichment.ts +274 -0
  139. package/src/cli-error-formatter.ts +313 -0
  140. package/src/cli-eval.ts +145 -0
  141. package/src/cli-exec.ts +408 -0
  142. package/src/cli-explain.ts +76 -0
  143. package/src/cli-lsp-diagnostic.ts +132 -0
  144. package/src/cli-module-loader.ts +101 -0
  145. package/src/cli-shared.ts +187 -0
  146. package/tests/check/cli-check.test.ts +189 -0
  147. package/tests/check/config.test.ts +350 -0
  148. package/tests/check/fixer.test.ts +373 -0
  149. package/tests/check/format-diagnostics.test.ts +327 -0
  150. package/tests/check/rules/anti-patterns.test.ts +467 -0
  151. package/tests/check/rules/closures.test.ts +192 -0
  152. package/tests/check/rules/collections.test.ts +380 -0
  153. package/tests/check/rules/conditionals.test.ts +185 -0
  154. package/tests/check/rules/flow.test.ts +250 -0
  155. package/tests/check/rules/formatting.test.ts +755 -0
  156. package/tests/check/rules/loops.test.ts +334 -0
  157. package/tests/check/rules/naming.test.ts +336 -0
  158. package/tests/check/rules/strings.test.ts +129 -0
  159. package/tests/check/rules/types.test.ts +257 -0
  160. package/tests/check/validator.test.ts +444 -0
  161. package/tests/check/visitor.test.ts +171 -0
  162. package/tests/cli/check.test.ts +801 -0
  163. package/tests/cli/error-enrichment.test.ts +510 -0
  164. package/tests/cli/error-formatter.test.ts +631 -0
  165. package/tests/cli/eval.test.ts +85 -0
  166. package/tests/cli/exec.test.ts +537 -0
  167. package/tests/cli-explain.test.ts +249 -0
  168. package/tests/cli-lsp-diagnostic.test.ts +202 -0
  169. package/tests/cli-shared.test.ts +439 -0
  170. package/tsconfig.json +9 -0
  171. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,585 @@
1
+ /**
2
+ * Anti-Pattern Rules
3
+ * Enforces best practices from docs/guide-conventions.md:411-462.
4
+ */
5
+
6
+ import type {
7
+ ValidationRule,
8
+ Diagnostic,
9
+ ValidationContext,
10
+ } from '../types.js';
11
+ import type {
12
+ ASTNode,
13
+ BinaryExprNode,
14
+ CaptureNode,
15
+ ConditionalNode,
16
+ GroupedExprNode,
17
+ EachExprNode,
18
+ MapExprNode,
19
+ FilterExprNode,
20
+ FoldExprNode,
21
+ PipeChainNode,
22
+ PostfixExprNode,
23
+ UnaryExprNode,
24
+ WhileLoopNode,
25
+ DoWhileLoopNode,
26
+ } from '@rcrsr/rill';
27
+ import { extractContextLine } from './helpers.js';
28
+
29
+ // ============================================================
30
+ // AVOID_REASSIGNMENT RULE
31
+ // ============================================================
32
+
33
+ /**
34
+ * Warns on variable reassignment patterns.
35
+ * Variables lock to their first type, and reassignment suggests confusing
36
+ * flow control. Prefer functional style or new variables.
37
+ *
38
+ * Detection:
39
+ * - Capture node (=> $var) where $var already exists in validation context
40
+ * - Tracks variables seen during validation pass
41
+ *
42
+ * Valid alternatives:
43
+ * - Use new variable: $result1, $result2
44
+ * - Functional chains: value -> op1 -> op2
45
+ *
46
+ * References:
47
+ * - docs/guide-conventions.md:413-424
48
+ */
49
+ export const AVOID_REASSIGNMENT: ValidationRule = {
50
+ code: 'AVOID_REASSIGNMENT',
51
+ category: 'anti-patterns',
52
+ severity: 'warning',
53
+ nodeTypes: ['Capture'],
54
+
55
+ validate(node: ASTNode, context: ValidationContext): Diagnostic[] {
56
+ const captureNode = node as CaptureNode;
57
+ const varName = captureNode.name;
58
+
59
+ // Check if this variable was already captured before
60
+ if (context.variables.has(varName)) {
61
+ const firstLocation = context.variables.get(varName)!;
62
+ const variableScope = context.variableScopes.get(varName) ?? null;
63
+
64
+ // Get the current closure scope (if we're inside a closure)
65
+ const currentClosureScope =
66
+ context.scopeStack.length > 0
67
+ ? context.scopeStack[context.scopeStack.length - 1]!
68
+ : null;
69
+
70
+ // Only warn if the variable is truly in the same scope or a parent scope
71
+ // Variables in sibling closures are independent and should not trigger warnings
72
+ const isInSameOrParentScope = isVariableInParentScope(
73
+ variableScope,
74
+ currentClosureScope,
75
+ context.scopeStack
76
+ );
77
+
78
+ if (isInSameOrParentScope) {
79
+ return [
80
+ {
81
+ location: captureNode.span.start,
82
+ severity: 'warning',
83
+ code: 'AVOID_REASSIGNMENT',
84
+ message: `Variable reassignment detected: '$${varName}' first defined at line ${firstLocation.line}. Prefer new variable or functional style.`,
85
+ context: extractContextLine(
86
+ captureNode.span.start.line,
87
+ context.source
88
+ ),
89
+ fix: null, // Cannot auto-fix without understanding intent
90
+ },
91
+ ];
92
+ }
93
+ }
94
+
95
+ return [];
96
+ },
97
+ };
98
+
99
+ // ============================================================
100
+ // COMPLEX_CONDITION RULE
101
+ // ============================================================
102
+
103
+ /**
104
+ * Warns on complex nested boolean conditions.
105
+ * Complex conditions with multiple nested operators are hard to read.
106
+ * Extract to named variables for clarity.
107
+ *
108
+ * Detection:
109
+ * - Conditional nodes with conditions containing 3+ boolean operators (&&, ||)
110
+ * - Nesting depth > 2 for boolean expressions
111
+ *
112
+ * Valid alternatives:
113
+ * - Extract sub-conditions to named variables
114
+ * - Split complex checks into multiple smaller checks
115
+ *
116
+ * References:
117
+ * - docs/guide-conventions.md:451-461
118
+ */
119
+ export const COMPLEX_CONDITION: ValidationRule = {
120
+ code: 'COMPLEX_CONDITION',
121
+ category: 'anti-patterns',
122
+ severity: 'info',
123
+ nodeTypes: ['Conditional'],
124
+
125
+ validate(node: ASTNode, context: ValidationContext): Diagnostic[] {
126
+ const conditionalNode = node as ConditionalNode;
127
+ const condition = conditionalNode.condition;
128
+
129
+ if (!condition) {
130
+ return [];
131
+ }
132
+
133
+ // Unwrap GroupedExpr to get to the actual condition
134
+ let unwrappedCondition: ASTNode = condition;
135
+ if (unwrappedCondition.type === 'GroupedExpr') {
136
+ unwrappedCondition = (unwrappedCondition as GroupedExprNode).expression;
137
+ }
138
+
139
+ // Count boolean operators, boolean nesting depth, and parenthetical nesting
140
+ const operatorCount = countBooleanOperators(unwrappedCondition);
141
+ const booleanDepth = getBooleanNestingDepth(unwrappedCondition);
142
+ const parenDepth = getParenNestingDepth(unwrappedCondition);
143
+
144
+ // Flag if 3+ operators, boolean nesting > 2, or excessive parentheses (> 2)
145
+ if (operatorCount >= 3 || booleanDepth > 2 || parenDepth > 2) {
146
+ return [
147
+ {
148
+ location: conditionalNode.span.start,
149
+ severity: 'info',
150
+ code: 'COMPLEX_CONDITION',
151
+ message:
152
+ 'Complex condition with multiple operators. Extract to named checks for clarity.',
153
+ context: extractContextLine(
154
+ conditionalNode.span.start.line,
155
+ context.source
156
+ ),
157
+ fix: null, // Auto-fix would require semantic understanding
158
+ },
159
+ ];
160
+ }
161
+
162
+ return [];
163
+ },
164
+ };
165
+
166
+ /**
167
+ * Count boolean operators (&&, ||) in an expression tree.
168
+ */
169
+ function countBooleanOperators(node: ASTNode): number {
170
+ let count = 0;
171
+
172
+ if (node.type === 'BinaryExpr') {
173
+ const binaryNode = node as BinaryExprNode;
174
+ if (binaryNode.op === '&&' || binaryNode.op === '||') {
175
+ count = 1;
176
+ }
177
+
178
+ count += countBooleanOperators(binaryNode.left);
179
+ count += countBooleanOperators(binaryNode.right);
180
+ }
181
+
182
+ // Traverse other node types that might contain expressions
183
+ switch (node.type) {
184
+ case 'UnaryExpr': {
185
+ const unaryNode = node as UnaryExprNode;
186
+ count += countBooleanOperators(unaryNode.operand);
187
+ break;
188
+ }
189
+
190
+ case 'GroupedExpr': {
191
+ const groupedNode = node as GroupedExprNode;
192
+ count += countBooleanOperators(groupedNode.expression);
193
+ break;
194
+ }
195
+
196
+ case 'PipeChain': {
197
+ const pipeNode = node as PipeChainNode;
198
+ if (pipeNode.head) count += countBooleanOperators(pipeNode.head);
199
+ if (pipeNode.pipes) {
200
+ for (const pipe of pipeNode.pipes) {
201
+ count += countBooleanOperators(pipe);
202
+ }
203
+ }
204
+ break;
205
+ }
206
+
207
+ case 'PostfixExpr': {
208
+ const postfixNode = node as PostfixExprNode;
209
+ if (postfixNode.primary)
210
+ count += countBooleanOperators(postfixNode.primary);
211
+ break;
212
+ }
213
+ }
214
+
215
+ return count;
216
+ }
217
+
218
+ /**
219
+ * Calculate maximum nesting depth of boolean operators.
220
+ */
221
+ function getBooleanNestingDepth(node: ASTNode, currentDepth = 0): number {
222
+ let maxDepth = currentDepth;
223
+
224
+ if (node.type === 'BinaryExpr') {
225
+ const binaryNode = node as BinaryExprNode;
226
+ const depth =
227
+ binaryNode.op === '&&' || binaryNode.op === '||'
228
+ ? currentDepth + 1
229
+ : currentDepth;
230
+
231
+ const leftDepth = getBooleanNestingDepth(binaryNode.left, depth);
232
+ const rightDepth = getBooleanNestingDepth(binaryNode.right, depth);
233
+
234
+ maxDepth = Math.max(maxDepth, leftDepth, rightDepth);
235
+ }
236
+
237
+ // Traverse other node types
238
+ switch (node.type) {
239
+ case 'UnaryExpr': {
240
+ const unaryNode = node as UnaryExprNode;
241
+ maxDepth = Math.max(
242
+ maxDepth,
243
+ getBooleanNestingDepth(unaryNode.operand, currentDepth)
244
+ );
245
+ break;
246
+ }
247
+
248
+ case 'GroupedExpr': {
249
+ const groupedNode = node as GroupedExprNode;
250
+ maxDepth = Math.max(
251
+ maxDepth,
252
+ getBooleanNestingDepth(groupedNode.expression, currentDepth)
253
+ );
254
+ break;
255
+ }
256
+
257
+ case 'PipeChain': {
258
+ const pipeNode = node as PipeChainNode;
259
+ if (pipeNode.head) {
260
+ maxDepth = Math.max(
261
+ maxDepth,
262
+ getBooleanNestingDepth(pipeNode.head, currentDepth)
263
+ );
264
+ }
265
+ if (pipeNode.pipes) {
266
+ for (const pipe of pipeNode.pipes) {
267
+ maxDepth = Math.max(
268
+ maxDepth,
269
+ getBooleanNestingDepth(pipe, currentDepth)
270
+ );
271
+ }
272
+ }
273
+ break;
274
+ }
275
+
276
+ case 'PostfixExpr': {
277
+ const postfixNode = node as PostfixExprNode;
278
+ if (postfixNode.primary) {
279
+ maxDepth = Math.max(
280
+ maxDepth,
281
+ getBooleanNestingDepth(postfixNode.primary, currentDepth)
282
+ );
283
+ }
284
+ break;
285
+ }
286
+ }
287
+
288
+ return maxDepth;
289
+ }
290
+
291
+ // ============================================================
292
+ // LOOP_OUTER_CAPTURE RULE
293
+ // ============================================================
294
+
295
+ /**
296
+ * Detects attempts to modify outer-scope variables from inside loops.
297
+ * This is a common LLM-generated anti-pattern that never works in Rill.
298
+ *
299
+ * Rill's scoping rules mean that captures inside loop bodies create LOCAL
300
+ * variables that don't affect outer scope. This is a fundamental language
301
+ * constraint, not a style preference.
302
+ *
303
+ * WRONG - this pattern NEVER works:
304
+ * 0 => $count
305
+ * [1, 2, 3] -> each { $count + 1 => $count } # creates LOCAL $count
306
+ * $count # still 0!
307
+ *
308
+ * RIGHT - use accumulators:
309
+ * [1, 2, 3] -> fold(0) { $@ + 1 } # returns 3
310
+ * [1, 2, 3] -> each(0) { $@ + 1 } # returns [1, 2, 3]
311
+ *
312
+ * This rule catches captures inside loop/collection bodies where the
313
+ * variable name matches an outer-scope variable.
314
+ *
315
+ * References:
316
+ * - docs/ref-llm.txt (LOOP STATE PATTERNS)
317
+ * - docs/topic-variables.md (Scope Rules)
318
+ */
319
+ export const LOOP_OUTER_CAPTURE: ValidationRule = {
320
+ code: 'LOOP_OUTER_CAPTURE',
321
+ category: 'anti-patterns',
322
+ severity: 'warning',
323
+ nodeTypes: [
324
+ 'EachExpr',
325
+ 'MapExpr',
326
+ 'FilterExpr',
327
+ 'FoldExpr',
328
+ 'WhileLoop',
329
+ 'DoWhileLoop',
330
+ ],
331
+
332
+ validate(node: ASTNode, context: ValidationContext): Diagnostic[] {
333
+ const diagnostics: Diagnostic[] = [];
334
+
335
+ // Get the loop body based on node type
336
+ let body: ASTNode | null = null;
337
+ switch (node.type) {
338
+ case 'EachExpr':
339
+ body = (node as EachExprNode).body;
340
+ break;
341
+ case 'MapExpr':
342
+ body = (node as MapExprNode).body;
343
+ break;
344
+ case 'FilterExpr':
345
+ body = (node as FilterExprNode).body;
346
+ break;
347
+ case 'FoldExpr':
348
+ body = (node as FoldExprNode).body;
349
+ break;
350
+ case 'WhileLoop':
351
+ body = (node as WhileLoopNode).body;
352
+ break;
353
+ case 'DoWhileLoop':
354
+ body = (node as DoWhileLoopNode).body;
355
+ break;
356
+ }
357
+
358
+ if (!body) return diagnostics;
359
+
360
+ // Find all captures in the body
361
+ const captures = findCapturesInBody(body);
362
+
363
+ // Get the current closure scope (if we're inside a closure)
364
+ const currentClosureScope =
365
+ context.scopeStack.length > 0
366
+ ? context.scopeStack[context.scopeStack.length - 1]!
367
+ : null;
368
+
369
+ // Check if any capture targets an outer-scope variable
370
+ for (const capture of captures) {
371
+ if (context.variables.has(capture.name)) {
372
+ const outerLocation = context.variables.get(capture.name)!;
373
+ const variableScope = context.variableScopes.get(capture.name) ?? null;
374
+
375
+ // Only flag if the variable is in a parent scope, not a sibling closure
376
+ // Variable is "outer" if:
377
+ // 1. It was defined in script scope (variableScope === null), OR
378
+ // 2. It was defined in a parent closure that contains the current closure
379
+ const isOuterScope = isVariableInParentScope(
380
+ variableScope,
381
+ currentClosureScope,
382
+ context.scopeStack
383
+ );
384
+
385
+ if (isOuterScope) {
386
+ diagnostics.push({
387
+ location: capture.span.start,
388
+ severity: 'warning',
389
+ code: 'LOOP_OUTER_CAPTURE',
390
+ message:
391
+ `Cannot modify outer variable '$${capture.name}' from inside loop. ` +
392
+ `Captures inside loops create LOCAL variables. ` +
393
+ `Use fold(init) with $@ accumulator, or pack state into $ as a dict. ` +
394
+ `(Outer '$${capture.name}' defined at line ${outerLocation.line})`,
395
+ context: extractContextLine(
396
+ capture.span.start.line,
397
+ context.source
398
+ ),
399
+ fix: null,
400
+ });
401
+ }
402
+ }
403
+ }
404
+
405
+ return diagnostics;
406
+ },
407
+ };
408
+
409
+ /**
410
+ * Check if a variable's scope is in the parent scope chain.
411
+ * Returns true if the variable is accessible from the current scope.
412
+ *
413
+ * A variable is "outer" (parent scope) if:
414
+ * - It was defined at script level (variableScope === null), OR
415
+ * - It was defined in the SAME closure as the loop (same scope), OR
416
+ * - It was defined in a closure that is an ancestor of the current closure
417
+ *
418
+ * A variable is NOT outer (sibling scope) if:
419
+ * - It was defined in a different closure that is not an ancestor
420
+ */
421
+ function isVariableInParentScope(
422
+ variableScope: ASTNode | null,
423
+ currentClosureScope: ASTNode | null,
424
+ scopeStack: ASTNode[]
425
+ ): boolean {
426
+ // Variable defined at script level is always outer
427
+ if (variableScope === null) {
428
+ return true;
429
+ }
430
+
431
+ // If we're not in a closure, variable can't be outer to us
432
+ if (currentClosureScope === null) {
433
+ return variableScope === null;
434
+ }
435
+
436
+ // Variable is outer if its scope is the same as current closure
437
+ // (loop body creates new scope within the closure)
438
+ if (variableScope === currentClosureScope) {
439
+ return true;
440
+ }
441
+
442
+ // Variable is outer if its scope is in our parent chain
443
+ // Check if variableScope appears in scopeStack before currentClosureScope
444
+ const currentIndex = scopeStack.indexOf(currentClosureScope);
445
+ const variableIndex = scopeStack.indexOf(variableScope);
446
+
447
+ // If variable scope is not in stack, it's not accessible
448
+ if (variableIndex === -1) {
449
+ return false;
450
+ }
451
+
452
+ // Variable is outer if it appears before current scope in stack (ancestor)
453
+ return variableIndex < currentIndex;
454
+ }
455
+
456
+ /**
457
+ * Recursively find all Capture nodes in a loop body.
458
+ */
459
+ function findCapturesInBody(node: ASTNode): CaptureNode[] {
460
+ const captures: CaptureNode[] = [];
461
+
462
+ function traverse(n: ASTNode): void {
463
+ if (n.type === 'Capture') {
464
+ captures.push(n as CaptureNode);
465
+ return;
466
+ }
467
+
468
+ // Traverse children based on node type
469
+ switch (n.type) {
470
+ case 'Block':
471
+ for (const stmt of n.statements) traverse(stmt);
472
+ break;
473
+ case 'Statement':
474
+ traverse(n.expression);
475
+ break;
476
+ case 'AnnotatedStatement':
477
+ traverse(n.statement);
478
+ break;
479
+ case 'PipeChain':
480
+ traverse(n.head);
481
+ for (const pipe of n.pipes) traverse(pipe as ASTNode);
482
+ if (n.terminator) traverse(n.terminator);
483
+ break;
484
+ case 'PostfixExpr':
485
+ traverse(n.primary);
486
+ for (const method of n.methods) traverse(method);
487
+ break;
488
+ case 'BinaryExpr':
489
+ traverse(n.left);
490
+ traverse(n.right);
491
+ break;
492
+ case 'UnaryExpr':
493
+ traverse(n.operand);
494
+ break;
495
+ case 'GroupedExpr':
496
+ traverse(n.expression);
497
+ break;
498
+ case 'Conditional':
499
+ if (n.input) traverse(n.input);
500
+ if (n.condition) traverse(n.condition);
501
+ traverse(n.thenBranch);
502
+ if (n.elseBranch) traverse(n.elseBranch);
503
+ break;
504
+ case 'Closure':
505
+ // Don't traverse into closures - they have their own scope
506
+ break;
507
+ // Nested loops - traverse their bodies too
508
+ case 'WhileLoop':
509
+ traverse(n.body);
510
+ break;
511
+ case 'DoWhileLoop':
512
+ traverse(n.body);
513
+ break;
514
+ case 'EachExpr':
515
+ case 'MapExpr':
516
+ case 'FilterExpr':
517
+ case 'FoldExpr':
518
+ traverse(n.body);
519
+ break;
520
+ }
521
+ }
522
+
523
+ traverse(node);
524
+ return captures;
525
+ }
526
+
527
+ /**
528
+ * Calculate maximum consecutive GroupedExpr (parenthetical) nesting depth.
529
+ * Counts chains of nested parentheses like ((($x))).
530
+ * Treats PipeChain (single head) and PostfixExpr (primary only) as transparent wrappers.
531
+ */
532
+ function getParenNestingDepth(node: ASTNode): number {
533
+ let maxDepth = 0;
534
+
535
+ function traverse(n: ASTNode, consecutiveDepth: number): void {
536
+ if (n.type === 'GroupedExpr') {
537
+ const groupedNode = n as GroupedExprNode;
538
+ const newDepth = consecutiveDepth + 1;
539
+ maxDepth = Math.max(maxDepth, newDepth);
540
+ traverse(groupedNode.expression, newDepth);
541
+ } else if (n.type === 'PipeChain') {
542
+ // Treat simple PipeChain (head only) as transparent for nesting
543
+ const pipeNode = n as PipeChainNode;
544
+ if (pipeNode.head && (!pipeNode.pipes || pipeNode.pipes.length === 0)) {
545
+ // Transparent: pass through consecutive depth
546
+ traverse(pipeNode.head, consecutiveDepth);
547
+ } else {
548
+ // Complex pipe chain: reset depth but continue traversing
549
+ if (pipeNode.head) traverse(pipeNode.head, 0);
550
+ if (pipeNode.pipes) {
551
+ for (const pipe of pipeNode.pipes) {
552
+ traverse(pipe, 0);
553
+ }
554
+ }
555
+ }
556
+ } else if (n.type === 'PostfixExpr') {
557
+ // Treat simple PostfixExpr (primary only) as transparent for nesting
558
+ const postfixNode = n as PostfixExprNode;
559
+ if (
560
+ postfixNode.primary &&
561
+ (!postfixNode.methods || postfixNode.methods.length === 0)
562
+ ) {
563
+ // Transparent: pass through consecutive depth
564
+ traverse(postfixNode.primary, consecutiveDepth);
565
+ } else {
566
+ // Complex postfix: reset depth
567
+ if (postfixNode.primary) traverse(postfixNode.primary, 0);
568
+ }
569
+ } else {
570
+ // Reset consecutive depth when we hit a structural node
571
+ // but continue traversing children
572
+ if (n.type === 'BinaryExpr') {
573
+ const binaryNode = n as BinaryExprNode;
574
+ traverse(binaryNode.left, 0);
575
+ traverse(binaryNode.right, 0);
576
+ } else if (n.type === 'UnaryExpr') {
577
+ const unaryNode = n as UnaryExprNode;
578
+ traverse(unaryNode.operand, 0);
579
+ }
580
+ }
581
+ }
582
+
583
+ traverse(node, 0);
584
+ return maxDepth;
585
+ }