@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,467 @@
1
+ /**
2
+ * Anti-Pattern Rules Tests
3
+ * Verify anti-pattern detection enforcement.
4
+ */
5
+
6
+ import { describe, it, expect } from 'vitest';
7
+ import { parse } from '@rcrsr/rill';
8
+ import { validateScript } from '../../../src/check/validator.js';
9
+ import type { CheckConfig } from '../../../src/check/types.js';
10
+
11
+ // ============================================================
12
+ // TEST HELPERS
13
+ // ============================================================
14
+
15
+ /**
16
+ * Create a config with anti-pattern rules enabled.
17
+ */
18
+ function createConfig(rules: Record<string, 'on' | 'off'> = {}): CheckConfig {
19
+ return {
20
+ rules: {
21
+ AVOID_REASSIGNMENT: 'on',
22
+ COMPLEX_CONDITION: 'on',
23
+ LOOP_OUTER_CAPTURE: 'on',
24
+ ...rules,
25
+ },
26
+ severity: {},
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Validate source and extract diagnostic messages.
32
+ */
33
+ function getDiagnostics(source: string, config?: CheckConfig): string[] {
34
+ const ast = parse(source);
35
+ const diagnostics = validateScript(ast, source, config ?? createConfig());
36
+ return diagnostics.map((d) => d.message);
37
+ }
38
+
39
+ /**
40
+ * Validate source and check for violations.
41
+ */
42
+ function hasViolations(source: string, config?: CheckConfig): boolean {
43
+ const ast = parse(source);
44
+ const diagnostics = validateScript(ast, source, config ?? createConfig());
45
+ return diagnostics.length > 0;
46
+ }
47
+
48
+ /**
49
+ * Validate source and get diagnostic codes.
50
+ */
51
+ function getCodes(source: string, config?: CheckConfig): string[] {
52
+ const ast = parse(source);
53
+ const diagnostics = validateScript(ast, source, config ?? createConfig());
54
+ return diagnostics.map((d) => d.code);
55
+ }
56
+
57
+ // ============================================================
58
+ // AVOID_REASSIGNMENT TESTS
59
+ // ============================================================
60
+
61
+ describe('AVOID_REASSIGNMENT', () => {
62
+ const config = createConfig({
63
+ COMPLEX_CONDITION: 'off',
64
+ });
65
+
66
+ it('accepts first variable assignment', () => {
67
+ expect(hasViolations('"initial" => $x', config)).toBe(false);
68
+ });
69
+
70
+ it('accepts multiple different variables', () => {
71
+ const source = `
72
+ "first" => $x
73
+ "second" => $y
74
+ "third" => $z
75
+ `;
76
+ expect(hasViolations(source, config)).toBe(false);
77
+ });
78
+
79
+ it('warns on variable reassignment', () => {
80
+ const source = `
81
+ "initial" => $x
82
+ "updated" => $x
83
+ `;
84
+
85
+ expect(hasViolations(source, config)).toBe(true);
86
+ const messages = getDiagnostics(source, config);
87
+ expect(messages.length).toBeGreaterThan(0);
88
+ expect(messages[0]).toContain('reassignment');
89
+ });
90
+
91
+ it('includes line number of first definition', () => {
92
+ const source = `
93
+ "initial" => $x
94
+ "updated" => $x
95
+ `;
96
+
97
+ const messages = getDiagnostics(source, config);
98
+ expect(messages[0]).toContain('line');
99
+ });
100
+
101
+ it('suggests alternatives in message', () => {
102
+ const source = `
103
+ "first" => $x
104
+ "second" => $x
105
+ `;
106
+
107
+ const messages = getDiagnostics(source, config);
108
+ expect(messages[0]).toMatch(/new variable|functional/i);
109
+ });
110
+
111
+ it('has correct severity and code', () => {
112
+ const source = `
113
+ "a" => $x
114
+ "b" => $x
115
+ `;
116
+
117
+ const ast = parse(source);
118
+ const diagnostics = validateScript(ast, source, config);
119
+
120
+ expect(diagnostics.length).toBeGreaterThan(0);
121
+ expect(diagnostics[0]?.code).toBe('AVOID_REASSIGNMENT');
122
+ expect(diagnostics[0]?.severity).toBe('warning');
123
+ });
124
+
125
+ it('detects multiple reassignments', () => {
126
+ const source = `
127
+ "first" => $x
128
+ "second" => $x
129
+ "third" => $x
130
+ `;
131
+
132
+ const diagnostics = getDiagnostics(source, config);
133
+ expect(diagnostics.length).toBe(2); // Two reassignments (second and third)
134
+ });
135
+
136
+ it('does not warn for variables in sibling closures', () => {
137
+ // Variables captured in different sibling closures are independent
138
+ // and should not be considered reassignments
139
+ const source = `
140
+ |skill_name| {
141
+ "output" => $result
142
+ $result
143
+ } => $run_skill
144
+
145
+ |doc_path| {
146
+ "output" => $result
147
+ $result
148
+ } => $review_loop
149
+ `;
150
+
151
+ expect(hasViolations(source, config)).toBe(false);
152
+ });
153
+
154
+ it('does warn for variables in same closure', () => {
155
+ // Variables reassigned within the same closure should trigger warning
156
+ const source = `
157
+ |param| {
158
+ "first" => $result
159
+ "second" => $result
160
+ $result
161
+ } => $fn
162
+ `;
163
+
164
+ expect(hasViolations(source, config)).toBe(true);
165
+ const codes = getCodes(source, config);
166
+ expect(codes).toContain('AVOID_REASSIGNMENT');
167
+ });
168
+
169
+ it('does warn for variables in parent scope', () => {
170
+ // Variables defined in outer scope and reassigned in nested closure
171
+ // should trigger warning
172
+ const source = `
173
+ "outer" => $result
174
+
175
+ |param| {
176
+ "inner" => $result
177
+ $result
178
+ } => $fn
179
+ `;
180
+
181
+ expect(hasViolations(source, config)).toBe(true);
182
+ const codes = getCodes(source, config);
183
+ expect(codes).toContain('AVOID_REASSIGNMENT');
184
+ });
185
+ });
186
+
187
+ // ============================================================
188
+ // COMPLEX_CONDITION TESTS
189
+ // ============================================================
190
+
191
+ describe('COMPLEX_CONDITION', () => {
192
+ const config = createConfig({
193
+ AVOID_REASSIGNMENT: 'off',
194
+ });
195
+
196
+ it('accepts simple conditions', () => {
197
+ expect(hasViolations('($x > 5) ? "big"', config)).toBe(false);
198
+ });
199
+
200
+ it('accepts conditions with one operator', () => {
201
+ expect(hasViolations('(($x > 5) && ($y < 10)) ? "valid"', config)).toBe(
202
+ false
203
+ );
204
+ });
205
+
206
+ it('accepts conditions with two operators', () => {
207
+ expect(
208
+ hasViolations('(($x > 5) && ($y < 10) && ($z == 0)) ? "ok"', config)
209
+ ).toBe(false);
210
+ });
211
+
212
+ it('warns on conditions with 3+ boolean operators', () => {
213
+ const source =
214
+ '(($x > 5) && (($y < 10) || ($z == 0)) && ($a != 1)) ? "complex"';
215
+
216
+ expect(hasViolations(source, config)).toBe(true);
217
+ const messages = getDiagnostics(source, config);
218
+ expect(messages[0]).toContain('Complex condition');
219
+ });
220
+
221
+ it('warns on deeply nested conditions', () => {
222
+ const source =
223
+ '((($x > 5) && ($y < 10)) || (($z == 0) && ($a != 1))) ? "nested"';
224
+
225
+ expect(hasViolations(source, config)).toBe(true);
226
+ });
227
+
228
+ it('suggests extracting to named checks', () => {
229
+ const source =
230
+ '(($x > 5) && ($y < 10) && ($z == 0) && ($a != 1)) ? "extract"';
231
+
232
+ const messages = getDiagnostics(source, config);
233
+ expect(messages[0]).toMatch(/extract|named/i);
234
+ });
235
+
236
+ it('has correct severity and code', () => {
237
+ const source = '(($x > 5) && ($y < 10) && ($z == 0) && ($a != 1)) ? "test"';
238
+
239
+ const ast = parse(source);
240
+ const diagnostics = validateScript(ast, source, config);
241
+
242
+ expect(diagnostics.length).toBeGreaterThan(0);
243
+ expect(diagnostics[0]?.code).toBe('COMPLEX_CONDITION');
244
+ expect(diagnostics[0]?.severity).toBe('info');
245
+ });
246
+
247
+ it('checks nesting depth independent of operator count', () => {
248
+ // High nesting but few operators
249
+ const source = '(((($x > 5))) || ((($y < 10)))) ? "deep"';
250
+
251
+ expect(hasViolations(source, config)).toBe(true);
252
+ });
253
+
254
+ it('does not flag non-boolean operators', () => {
255
+ const source = '((($x + 5) * ($y - 10)) > 0) ? "arithmetic"';
256
+
257
+ expect(hasViolations(source, config)).toBe(false);
258
+ });
259
+ });
260
+
261
+ // ============================================================
262
+ // LOOP_OUTER_CAPTURE TESTS
263
+ // ============================================================
264
+
265
+ describe('LOOP_OUTER_CAPTURE', () => {
266
+ const config = createConfig({
267
+ AVOID_REASSIGNMENT: 'off',
268
+ COMPLEX_CONDITION: 'off',
269
+ });
270
+
271
+ it('accepts captures of new variables in loop body', () => {
272
+ // This is fine - $temp is new, not modifying outer scope
273
+ const source = `
274
+ [1, 2, 3] -> each {
275
+ $ * 2 => $temp
276
+ $temp
277
+ }
278
+ `;
279
+ expect(hasViolations(source, config)).toBe(false);
280
+ });
281
+
282
+ it('accepts loops without captures', () => {
283
+ const source = '[1, 2, 3] -> each { $ * 2 }';
284
+ expect(hasViolations(source, config)).toBe(false);
285
+ });
286
+
287
+ it('accepts fold with accumulator pattern', () => {
288
+ const source = '[1, 2, 3] -> fold(0) { $@ + $ }';
289
+ expect(hasViolations(source, config)).toBe(false);
290
+ });
291
+
292
+ it('warns when each body captures outer variable', () => {
293
+ const source = `
294
+ 0 => $count
295
+ [1, 2, 3] -> each { $count + 1 => $count }
296
+ `;
297
+
298
+ expect(hasViolations(source, config)).toBe(true);
299
+ const codes = getCodes(source, config);
300
+ expect(codes).toContain('LOOP_OUTER_CAPTURE');
301
+ });
302
+
303
+ it('warns when map body captures outer variable', () => {
304
+ const source = `
305
+ "" => $result
306
+ [1, 2, 3] -> map { $result + $ => $result }
307
+ `;
308
+
309
+ expect(hasViolations(source, config)).toBe(true);
310
+ const codes = getCodes(source, config);
311
+ expect(codes).toContain('LOOP_OUTER_CAPTURE');
312
+ });
313
+
314
+ it('warns when while loop body captures outer variable', () => {
315
+ const source = `
316
+ 0 => $i
317
+ 0 -> ($ < 3) @ {
318
+ $i + 1 => $i
319
+ $ + 1
320
+ }
321
+ `;
322
+
323
+ expect(hasViolations(source, config)).toBe(true);
324
+ const codes = getCodes(source, config);
325
+ expect(codes).toContain('LOOP_OUTER_CAPTURE');
326
+ });
327
+
328
+ it('warns when filter body captures outer variable', () => {
329
+ const source = `
330
+ 0 => $count
331
+ [1, 2, 3] -> filter {
332
+ $count + 1 => $count
333
+ ($ > 1)
334
+ }
335
+ `;
336
+
337
+ expect(hasViolations(source, config)).toBe(true);
338
+ const codes = getCodes(source, config);
339
+ expect(codes).toContain('LOOP_OUTER_CAPTURE');
340
+ });
341
+
342
+ it('provides helpful message with line reference', () => {
343
+ const source = `
344
+ 0 => $sum
345
+ [1, 2, 3] -> each { $sum + $ => $sum }
346
+ `;
347
+
348
+ const messages = getDiagnostics(source, config);
349
+ expect(messages.length).toBeGreaterThan(0);
350
+ expect(messages[0]).toContain('Cannot modify outer variable');
351
+ expect(messages[0]).toContain('$sum');
352
+ expect(messages[0]).toContain('fold');
353
+ expect(messages[0]).toContain('line');
354
+ });
355
+
356
+ it('has warning severity', () => {
357
+ const source = `
358
+ 0 => $x
359
+ [1, 2, 3] -> each { $x + 1 => $x }
360
+ `;
361
+
362
+ const ast = parse(source);
363
+ const diagnostics = validateScript(ast, source, config);
364
+ const loopCapture = diagnostics.find(
365
+ (d) => d.code === 'LOOP_OUTER_CAPTURE'
366
+ );
367
+
368
+ expect(loopCapture).toBeDefined();
369
+ expect(loopCapture?.severity).toBe('warning');
370
+ });
371
+
372
+ it('detects multiple outer captures in same loop', () => {
373
+ const source = `
374
+ 0 => $a
375
+ 0 => $b
376
+ [1, 2, 3] -> each {
377
+ $a + 1 => $a
378
+ $b + 1 => $b
379
+ }
380
+ `;
381
+
382
+ const ast = parse(source);
383
+ const diagnostics = validateScript(ast, source, config);
384
+ const loopCaptures = diagnostics.filter(
385
+ (d) => d.code === 'LOOP_OUTER_CAPTURE'
386
+ );
387
+
388
+ expect(loopCaptures.length).toBe(2);
389
+ });
390
+
391
+ it('warns when do-while loop body captures outer variable', () => {
392
+ const source = `
393
+ 0 => $count
394
+ 0 -> @ {
395
+ $count + 1 => $count
396
+ $ + 1
397
+ } ? ($ < 3)
398
+ `;
399
+
400
+ expect(hasViolations(source, config)).toBe(true);
401
+ const codes = getCodes(source, config);
402
+ expect(codes).toContain('LOOP_OUTER_CAPTURE');
403
+ });
404
+
405
+ it('accepts closures that capture outer variables (different scope)', () => {
406
+ // Closures have their own scope, so captures inside them shouldn't trigger
407
+ const source = `
408
+ 10 => $multiplier
409
+ [1, 2, 3] -> map {
410
+ |x| ($x * $multiplier) => $fn
411
+ $fn($)
412
+ }
413
+ `;
414
+
415
+ expect(hasViolations(source, config)).toBe(false);
416
+ });
417
+
418
+ it('warns when fold body captures outer variable (distinct from accumulator)', () => {
419
+ const source = `
420
+ 0 => $extraSum
421
+ [1, 2, 3] -> fold(0) {
422
+ $extraSum + 1 => $extraSum
423
+ $@ + $ + $extraSum
424
+ }
425
+ `;
426
+
427
+ expect(hasViolations(source, config)).toBe(true);
428
+ const codes = getCodes(source, config);
429
+ expect(codes).toContain('LOOP_OUTER_CAPTURE');
430
+ });
431
+
432
+ it('does not warn for variables in sibling closures', () => {
433
+ // Variables captured in different sibling closures should not be
434
+ // considered "outer" to each other
435
+ const source = `
436
+ |skill_name| {
437
+ "output" => $result
438
+ $result
439
+ } => $run_skill
440
+
441
+ |doc_path| {
442
+ ^(limit: 5) 0 -> ($ < 3) @ {
443
+ "output" => $result
444
+ $ + 1
445
+ }
446
+ } => $review_loop
447
+ `;
448
+
449
+ expect(hasViolations(source, config)).toBe(false);
450
+ });
451
+
452
+ it('does warn for variables in parent closure captured in nested loop', () => {
453
+ // Variable in parent closure should trigger warning when captured in nested loop
454
+ const source = `
455
+ |outer_param| {
456
+ 0 => $count
457
+ [1, 2, 3] -> each {
458
+ $count + 1 => $count
459
+ }
460
+ } => $fn
461
+ `;
462
+
463
+ expect(hasViolations(source, config)).toBe(true);
464
+ const codes = getCodes(source, config);
465
+ expect(codes).toContain('LOOP_OUTER_CAPTURE');
466
+ });
467
+ });
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Closure Convention Rules Tests
3
+ * Verify closure convention enforcement.
4
+ */
5
+
6
+ import { describe, it, expect } from 'vitest';
7
+ import { parse } from '@rcrsr/rill';
8
+ import { validateScript } from '../../../src/check/validator.js';
9
+ import type { CheckConfig } from '../../../src/check/types.js';
10
+
11
+ // ============================================================
12
+ // TEST HELPERS
13
+ // ============================================================
14
+
15
+ /**
16
+ * Create a config with closure rules enabled.
17
+ */
18
+ function createConfig(rules: Record<string, 'on' | 'off'> = {}): CheckConfig {
19
+ return {
20
+ rules: {
21
+ CLOSURE_BARE_DOLLAR: 'on',
22
+ CLOSURE_BRACES: 'on',
23
+ CLOSURE_LATE_BINDING: 'on',
24
+ ...rules,
25
+ },
26
+ severity: {},
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Validate source and extract diagnostic messages.
32
+ */
33
+ function getDiagnostics(source: string, config?: CheckConfig): string[] {
34
+ const ast = parse(source);
35
+ const diagnostics = validateScript(ast, source, config ?? createConfig());
36
+ return diagnostics.map((d) => d.message);
37
+ }
38
+
39
+ /**
40
+ * Validate source and check for violations.
41
+ */
42
+ function hasViolations(source: string, config?: CheckConfig): boolean {
43
+ const ast = parse(source);
44
+ const diagnostics = validateScript(ast, source, config ?? createConfig());
45
+ return diagnostics.length > 0;
46
+ }
47
+
48
+ /**
49
+ * Validate source and get diagnostic codes.
50
+ */
51
+ function getCodes(source: string, config?: CheckConfig): string[] {
52
+ const ast = parse(source);
53
+ const diagnostics = validateScript(ast, source, config ?? createConfig());
54
+ return diagnostics.map((d) => d.code);
55
+ }
56
+
57
+ // ============================================================
58
+ // CLOSURE_BARE_DOLLAR TESTS
59
+ // ============================================================
60
+
61
+ describe('CLOSURE_BARE_DOLLAR', () => {
62
+ const config = createConfig({
63
+ CLOSURE_BRACES: 'off',
64
+ CLOSURE_LATE_BINDING: 'off',
65
+ });
66
+
67
+ it('accepts closures with parameters', () => {
68
+ expect(hasViolations('|x|($x * 2) => $fn', config)).toBe(false);
69
+ });
70
+
71
+ it('accepts closures without $ reference', () => {
72
+ expect(hasViolations('||{ 42 } => $fn', config)).toBe(false);
73
+ });
74
+
75
+ it('accepts inline blocks with bare $', () => {
76
+ // Inline blocks are not Closure nodes, they're immediate evaluation
77
+ expect(hasViolations('5 -> { $ * 2 }', config)).toBe(false);
78
+ });
79
+
80
+ it('warns on bare $ in zero-param stored closure', () => {
81
+ const source = '||{ $ * 2 } => $fn';
82
+
83
+ const messages = getDiagnostics(source, config);
84
+ expect(messages.length).toBeGreaterThan(0);
85
+ expect(messages[0]).toContain('Bare $ in stored closure');
86
+ expect(messages[0]).toContain('ambiguous binding');
87
+ });
88
+
89
+ it('suggests explicit capture', () => {
90
+ const source = '||{ $ + 5 } => $fn';
91
+
92
+ const messages = getDiagnostics(source, config);
93
+ expect(messages.length).toBeGreaterThan(0);
94
+ expect(messages[0]).toContain('$ => $item');
95
+ });
96
+
97
+ it('has correct severity and code', () => {
98
+ const source = '||{ $ } => $fn';
99
+ const ast = parse(source);
100
+ const diagnostics = validateScript(ast, source, config);
101
+
102
+ expect(diagnostics.length).toBeGreaterThan(0);
103
+ expect(diagnostics[0]?.code).toBe('CLOSURE_BARE_DOLLAR');
104
+ expect(diagnostics[0]?.severity).toBe('warning');
105
+ });
106
+ });
107
+
108
+ // ============================================================
109
+ // CLOSURE_BRACES TESTS
110
+ // ============================================================
111
+
112
+ describe('CLOSURE_BRACES', () => {
113
+ const config = createConfig({
114
+ CLOSURE_BARE_DOLLAR: 'off',
115
+ CLOSURE_LATE_BINDING: 'off',
116
+ });
117
+
118
+ it('accepts simple closure with parentheses', () => {
119
+ expect(hasViolations('|x|($x * 2) => $fn', config)).toBe(false);
120
+ });
121
+
122
+ it('accepts complex closure with braces', () => {
123
+ const source = `
124
+ |n| {
125
+ ($n < 1) ? 1 ! ($n * 2)
126
+ } => $fn
127
+ `;
128
+ expect(hasViolations(source, config)).toBe(false);
129
+ });
130
+
131
+ it('recommends braces for conditional in closure body', () => {
132
+ const source = '|n|(($n < 1) ? 1 ! ($n * 2)) => $fn';
133
+
134
+ const messages = getDiagnostics(source, config);
135
+ expect(messages.length).toBeGreaterThan(0);
136
+ expect(messages[0]).toContain('braces for complex closure bodies');
137
+ });
138
+
139
+ it('has correct severity and code', () => {
140
+ const source = '|x|(($x > 0) ? "pos" ! "neg") => $fn';
141
+ const ast = parse(source);
142
+ const diagnostics = validateScript(ast, source, config);
143
+
144
+ expect(diagnostics.length).toBeGreaterThan(0);
145
+ expect(diagnostics[0]?.code).toBe('CLOSURE_BRACES');
146
+ expect(diagnostics[0]?.severity).toBe('info');
147
+ });
148
+ });
149
+
150
+ // ============================================================
151
+ // CLOSURE_LATE_BINDING TESTS
152
+ // ============================================================
153
+
154
+ describe('CLOSURE_LATE_BINDING', () => {
155
+ const config = createConfig({
156
+ CLOSURE_BARE_DOLLAR: 'off',
157
+ CLOSURE_BRACES: 'off',
158
+ });
159
+
160
+ it('accepts each loops without closure creation', () => {
161
+ expect(hasViolations('[1, 2, 3] -> each { $ * 2 }', config)).toBe(false);
162
+ });
163
+
164
+ it('accepts closures with explicit capture', () => {
165
+ const source = `
166
+ [1, 2, 3] -> each {
167
+ $ => $item
168
+ ||{ $item }
169
+ }
170
+ `;
171
+ expect(hasViolations(source, config)).toBe(false);
172
+ });
173
+
174
+ it('warns on closure creation without explicit capture', () => {
175
+ const source = '[1, 2, 3] -> each { ||{ $ } }';
176
+
177
+ const messages = getDiagnostics(source, config);
178
+ expect(messages.length).toBeGreaterThan(0);
179
+ expect(messages[0]).toContain('Capture loop variable explicitly');
180
+ expect(messages[0]).toContain('$ => $item');
181
+ });
182
+
183
+ it('has correct severity and code', () => {
184
+ const source = '[1, 2, 3] -> each { ||{ $ } }';
185
+ const ast = parse(source);
186
+ const diagnostics = validateScript(ast, source, config);
187
+
188
+ expect(diagnostics.length).toBeGreaterThan(0);
189
+ expect(diagnostics[0]?.code).toBe('CLOSURE_LATE_BINDING');
190
+ expect(diagnostics[0]?.severity).toBe('warning');
191
+ });
192
+ });