@rcrsr/rill-cli 0.17.0 → 0.18.1

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.
@@ -62,3 +62,23 @@ export declare const COMPLEX_CONDITION: ValidationRule;
62
62
  * - docs/topic-variables.md (Scope Rules)
63
63
  */
64
64
  export declare const LOOP_OUTER_CAPTURE: ValidationRule;
65
+ /**
66
+ * Warns when a stream variable is invoked before any iteration consumes it.
67
+ * Invoking a stream closure ($s()) before iterating ($s -> each { ... })
68
+ * consumes chunks internally, leaving no data for iteration.
69
+ *
70
+ * Detection:
71
+ * - Tracks variables captured from stream closures (returnTypeTarget = stream)
72
+ * or captured with explicit :stream type annotation
73
+ * - Records first invocation (ClosureCall) and first iteration (each/map/filter/fold)
74
+ * - Warns when invocation precedes iteration in source order
75
+ *
76
+ * No warning if:
77
+ * - Iteration appears before invocation
78
+ * - Variable is only iterated (never invoked before iteration)
79
+ * - Variable is only invoked (no iteration to conflict)
80
+ *
81
+ * References:
82
+ * - IR-15: Lint Warning for Pre-Iteration Invocation
83
+ */
84
+ export declare const STREAM_PRE_ITERATION: ValidationRule;
@@ -3,6 +3,7 @@
3
3
  * Enforces best practices from docs/guide-conventions.md:411-462.
4
4
  */
5
5
  import { extractContextLine } from './helpers.js';
6
+ import { visitNode } from '../visitor.js';
6
7
  // ============================================================
7
8
  // AVOID_REASSIGNMENT RULE
8
9
  // ============================================================
@@ -342,77 +343,30 @@ function isVariableInParentScope(variableScope, currentClosureScope, scopeStack)
342
343
  return variableIndex < currentIndex;
343
344
  }
344
345
  /**
345
- * Recursively find all Capture nodes in a loop body.
346
+ * Find all Capture nodes in a loop body, excluding closures.
347
+ * Uses visitNode for full AST traversal with closure depth tracking
348
+ * to skip captures inside nested closures (they have their own scope).
346
349
  */
347
350
  function findCapturesInBody(node) {
348
351
  const captures = [];
349
- function traverse(n) {
350
- if (n.type === 'Capture') {
351
- captures.push(n);
352
- return;
353
- }
354
- // Traverse children based on node type
355
- switch (n.type) {
356
- case 'Block':
357
- for (const stmt of n.statements)
358
- traverse(stmt);
359
- break;
360
- case 'Statement':
361
- traverse(n.expression);
362
- break;
363
- case 'AnnotatedStatement':
364
- traverse(n.statement);
365
- break;
366
- case 'PipeChain':
367
- traverse(n.head);
368
- for (const pipe of n.pipes)
369
- traverse(pipe);
370
- if (n.terminator)
371
- traverse(n.terminator);
372
- break;
373
- case 'PostfixExpr':
374
- traverse(n.primary);
375
- for (const method of n.methods)
376
- traverse(method);
377
- break;
378
- case 'BinaryExpr':
379
- traverse(n.left);
380
- traverse(n.right);
381
- break;
382
- case 'UnaryExpr':
383
- traverse(n.operand);
384
- break;
385
- case 'GroupedExpr':
386
- traverse(n.expression);
387
- break;
388
- case 'Conditional':
389
- if (n.input)
390
- traverse(n.input);
391
- if (n.condition)
392
- traverse(n.condition);
393
- traverse(n.thenBranch);
394
- if (n.elseBranch)
395
- traverse(n.elseBranch);
396
- break;
397
- case 'Closure':
398
- // Don't traverse into closures - they have their own scope
399
- break;
400
- // Nested loops - traverse their bodies too
401
- case 'WhileLoop':
402
- traverse(n.body);
403
- break;
404
- case 'DoWhileLoop':
405
- traverse(n.body);
406
- break;
407
- case 'EachExpr':
408
- case 'MapExpr':
409
- case 'FilterExpr':
410
- case 'FoldExpr':
411
- traverse(n.body);
412
- break;
413
- }
414
- }
415
- traverse(node);
352
+ let closureDepth = 0;
353
+ const ctx = {};
354
+ visitNode(node, ctx, {
355
+ enter(n) {
356
+ if (n.type === 'Closure') {
357
+ closureDepth++;
358
+ return;
359
+ }
360
+ if (n.type === 'Capture' && closureDepth === 0) {
361
+ captures.push(n);
362
+ }
363
+ },
364
+ exit(n) {
365
+ if (n.type === 'Closure') {
366
+ closureDepth--;
367
+ }
368
+ },
369
+ });
416
370
  return captures;
417
371
  }
418
372
  /**
@@ -478,3 +432,206 @@ function getParenNestingDepth(node) {
478
432
  traverse(node, 0);
479
433
  return maxDepth;
480
434
  }
435
+ // ============================================================
436
+ // STREAM_PRE_ITERATION RULE
437
+ // ============================================================
438
+ /** Collection operator node types that consume a stream via iteration */
439
+ const ITERATION_NODE_TYPES = new Set([
440
+ 'EachExpr',
441
+ 'MapExpr',
442
+ 'FilterExpr',
443
+ 'FoldExpr',
444
+ ]);
445
+ /**
446
+ * Warns when a stream variable is invoked before any iteration consumes it.
447
+ * Invoking a stream closure ($s()) before iterating ($s -> each { ... })
448
+ * consumes chunks internally, leaving no data for iteration.
449
+ *
450
+ * Detection:
451
+ * - Tracks variables captured from stream closures (returnTypeTarget = stream)
452
+ * or captured with explicit :stream type annotation
453
+ * - Records first invocation (ClosureCall) and first iteration (each/map/filter/fold)
454
+ * - Warns when invocation precedes iteration in source order
455
+ *
456
+ * No warning if:
457
+ * - Iteration appears before invocation
458
+ * - Variable is only iterated (never invoked before iteration)
459
+ * - Variable is only invoked (no iteration to conflict)
460
+ *
461
+ * References:
462
+ * - IR-15: Lint Warning for Pre-Iteration Invocation
463
+ */
464
+ export const STREAM_PRE_ITERATION = {
465
+ code: 'STREAM_PRE_ITERATION',
466
+ category: 'anti-patterns',
467
+ severity: 'warning',
468
+ nodeTypes: ['Script'],
469
+ validate(node, context) {
470
+ const scriptNode = node;
471
+ const diagnostics = [];
472
+ // Phase 1: Collect stream variable names from capture sites
473
+ const streamVars = new Set();
474
+ collectStreamVariables(scriptNode, streamVars);
475
+ if (streamVars.size === 0) {
476
+ return diagnostics;
477
+ }
478
+ // Phase 2: Find first invocation and first iteration for each stream variable
479
+ const firstInvocation = new Map();
480
+ const firstIteration = new Map();
481
+ collectStreamUsages(scriptNode, streamVars, firstInvocation, firstIteration);
482
+ // Phase 3: Emit warning for each variable invoked before iteration
483
+ for (const varName of streamVars) {
484
+ const invocation = firstInvocation.get(varName);
485
+ const iteration = firstIteration.get(varName);
486
+ if (!invocation) {
487
+ continue;
488
+ }
489
+ // Warn if invoked and no iteration exists, or invocation precedes iteration
490
+ const invokedBeforeIteration = !iteration ||
491
+ invocation.span.start.line < iteration.span.start.line ||
492
+ (invocation.span.start.line === iteration.span.start.line &&
493
+ invocation.span.start.column < iteration.span.start.column);
494
+ if (invokedBeforeIteration) {
495
+ diagnostics.push({
496
+ location: invocation.span.start,
497
+ severity: 'warning',
498
+ code: 'STREAM_PRE_ITERATION',
499
+ message: `Stream invoked before iteration; chunks consumed internally. '$${varName}' at line ${invocation.span.start.line}`,
500
+ context: extractContextLine(invocation.span.start.line, context.source),
501
+ fix: null,
502
+ });
503
+ }
504
+ }
505
+ return diagnostics;
506
+ },
507
+ };
508
+ /**
509
+ * Collect variable names captured from stream closures or with :stream type.
510
+ * Traverses AST to find PipeChains containing a Capture in their pipes where:
511
+ * - The capture has typeRef with typeName 'stream'
512
+ * - The PipeChain head is a stream-returning closure
513
+ *
514
+ * Note: Capture nodes appear in PipeChain.pipes (not .terminator).
515
+ * Only Break/Return/Yield use the terminator slot.
516
+ */
517
+ function collectStreamVariables(node, streamVars) {
518
+ const ctx = {};
519
+ visitNode(node, ctx, {
520
+ enter(n) {
521
+ if (n.type !== 'PipeChain')
522
+ return;
523
+ const chain = n;
524
+ // Find the last Capture in the pipes array
525
+ const capture = findTrailingCapture(chain);
526
+ if (!capture)
527
+ return;
528
+ const varName = capture.name;
529
+ // Check 1: Capture with explicit :stream type annotation
530
+ if (capture.typeRef &&
531
+ capture.typeRef.kind === 'static' &&
532
+ capture.typeRef.typeName === 'stream') {
533
+ streamVars.add(varName);
534
+ return;
535
+ }
536
+ // Check 2: Head is a closure with stream return type
537
+ if (isStreamClosure(chain)) {
538
+ streamVars.add(varName);
539
+ }
540
+ },
541
+ exit() { },
542
+ });
543
+ }
544
+ /**
545
+ * Find the trailing Capture node in a PipeChain's pipes array.
546
+ * Returns null if the last pipe element is not a Capture.
547
+ */
548
+ function findTrailingCapture(chain) {
549
+ const lastPipe = chain.pipes[chain.pipes.length - 1];
550
+ if (lastPipe && lastPipe.type === 'Capture') {
551
+ return lastPipe;
552
+ }
553
+ return null;
554
+ }
555
+ /**
556
+ * Check if a PipeChain's head (or piped result) is a stream-returning closure.
557
+ * Detects closures with returnTypeTarget of stream type.
558
+ */
559
+ function isStreamClosure(chain) {
560
+ const head = chain.head;
561
+ if (head.type !== 'PostfixExpr')
562
+ return false;
563
+ const postfix = head;
564
+ if (postfix.primary.type !== 'Closure')
565
+ return false;
566
+ const closure = postfix.primary;
567
+ const returnType = closure.returnTypeTarget;
568
+ if (!returnType)
569
+ return false;
570
+ // TypeConstructorNode: :stream(), :stream(number), :stream(number):string
571
+ if ('type' in returnType && returnType.type === 'TypeConstructor') {
572
+ return returnType.constructorName === 'stream';
573
+ }
574
+ // TypeRef: :stream (simple form)
575
+ if ('kind' in returnType && returnType.kind === 'static') {
576
+ return returnType.typeName === 'stream';
577
+ }
578
+ return false;
579
+ }
580
+ /**
581
+ * Collect first invocation and first iteration sites for stream variables.
582
+ * Traverses AST in source order, recording only the first occurrence of each.
583
+ */
584
+ function collectStreamUsages(node, streamVars, firstInvocation, firstIteration) {
585
+ const ctx = {};
586
+ visitNode(node, ctx, {
587
+ enter(n) {
588
+ // Detect invocation: $s() as ClosureCall
589
+ if (n.type === 'ClosureCall') {
590
+ const call = n;
591
+ if (streamVars.has(call.name) &&
592
+ call.accessChain.length === 0 &&
593
+ !firstInvocation.has(call.name)) {
594
+ firstInvocation.set(call.name, call);
595
+ }
596
+ return;
597
+ }
598
+ // Detect iteration: $s -> each/map/filter/fold
599
+ if (n.type === 'PipeChain') {
600
+ const chain = n;
601
+ const varName = getPipeHeadVariableName(chain);
602
+ if (varName !== null &&
603
+ streamVars.has(varName) &&
604
+ !firstIteration.has(varName)) {
605
+ // Check if any pipe target is an iteration operator
606
+ for (const pipe of chain.pipes) {
607
+ if (ITERATION_NODE_TYPES.has(pipe.type)) {
608
+ firstIteration.set(varName, pipe);
609
+ break;
610
+ }
611
+ }
612
+ }
613
+ }
614
+ },
615
+ exit() { },
616
+ });
617
+ }
618
+ /**
619
+ * Extract the variable name from a PipeChain head if it's a simple variable reference.
620
+ * Returns null for complex heads (binary expressions, host calls, etc.).
621
+ */
622
+ function getPipeHeadVariableName(chain) {
623
+ const head = chain.head;
624
+ if (head.type !== 'PostfixExpr')
625
+ return null;
626
+ const postfix = head;
627
+ if (postfix.primary.type !== 'Variable')
628
+ return null;
629
+ if (postfix.methods.length > 0)
630
+ return null;
631
+ const variable = postfix.primary;
632
+ if (variable.isPipeVar || variable.name === null)
633
+ return null;
634
+ if (variable.accessChain.length > 0)
635
+ return null;
636
+ return variable.name;
637
+ }
@@ -3,6 +3,7 @@
3
3
  * Enforces closure best practices from docs/guide-conventions.md:237-286.
4
4
  */
5
5
  import { extractContextLine } from './helpers.js';
6
+ import { visitNode } from '../visitor.js';
6
7
  // ============================================================
7
8
  // CLOSURE_BARE_DOLLAR RULE
8
9
  // ============================================================
@@ -54,118 +55,20 @@ export const CLOSURE_BARE_DOLLAR = {
54
55
  };
55
56
  /**
56
57
  * Check if a node tree contains bare $ variable references.
57
- * Recursively walks the AST looking for VariableNode with isPipeVar=true.
58
+ * Uses visitNode for full AST traversal, detecting VariableNode with isPipeVar=true.
58
59
  */
59
60
  function containsBareReference(node) {
60
- if (node.type === 'Variable') {
61
- const varNode = node;
62
- // $ is represented as isPipeVar: true with name: null
63
- if (varNode.isPipeVar) {
64
- return true;
65
- }
66
- }
67
- // Recursively check child nodes based on node type
68
- switch (node.type) {
69
- case 'Block': {
70
- const blockNode = node;
71
- for (const stmt of blockNode.statements) {
72
- if (containsBareReference(stmt))
73
- return true;
74
- }
75
- break;
76
- }
77
- case 'Statement': {
78
- const stmtNode = node;
79
- if (stmtNode.expression && containsBareReference(stmtNode.expression))
80
- return true;
81
- break;
82
- }
83
- case 'PipeChain': {
84
- const pipeNode = node;
85
- if (pipeNode.head && containsBareReference(pipeNode.head))
86
- return true;
87
- if (pipeNode.pipes) {
88
- for (const pipe of pipeNode.pipes) {
89
- if (containsBareReference(pipe))
90
- return true;
91
- }
92
- }
93
- break;
94
- }
95
- case 'PostfixExpr': {
96
- const postfixNode = node;
97
- if (postfixNode.primary && containsBareReference(postfixNode.primary))
98
- return true;
99
- if (postfixNode.methods) {
100
- for (const method of postfixNode.methods) {
101
- if (containsBareReference(method))
102
- return true;
103
- }
104
- }
105
- break;
106
- }
107
- case 'BinaryExpr': {
108
- const binaryNode = node;
109
- if (binaryNode.left && containsBareReference(binaryNode.left))
110
- return true;
111
- if (binaryNode.right && containsBareReference(binaryNode.right))
112
- return true;
113
- break;
114
- }
115
- case 'UnaryExpr': {
116
- const unaryNode = node;
117
- if (unaryNode.operand && containsBareReference(unaryNode.operand))
118
- return true;
119
- break;
120
- }
121
- case 'GroupedExpr': {
122
- const groupedNode = node;
123
- if (groupedNode.expression &&
124
- containsBareReference(groupedNode.expression))
125
- return true;
126
- break;
127
- }
128
- case 'StringLiteral': {
129
- const stringNode = node;
130
- if (stringNode.parts) {
131
- for (const part of stringNode.parts) {
132
- if (typeof part === 'object' && containsBareReference(part))
133
- return true;
134
- }
135
- }
136
- break;
137
- }
138
- case 'Interpolation': {
139
- const interpNode = node;
140
- if (interpNode.expression && containsBareReference(interpNode.expression))
141
- return true;
142
- break;
143
- }
144
- case 'Conditional': {
145
- const condNode = node;
146
- if (condNode.condition && containsBareReference(condNode.condition))
147
- return true;
148
- if (condNode.thenBranch && containsBareReference(condNode.thenBranch))
149
- return true;
150
- if (condNode.elseBranch && containsBareReference(condNode.elseBranch))
151
- return true;
152
- break;
153
- }
154
- case 'MethodCall':
155
- case 'HostCall':
156
- case 'ClosureCall':
157
- case 'Invoke': {
158
- const callNode = node;
159
- if (callNode.args) {
160
- for (const arg of callNode.args) {
161
- if (containsBareReference(arg))
162
- return true;
163
- }
61
+ let found = false;
62
+ const ctx = {};
63
+ visitNode(node, ctx, {
64
+ enter(n) {
65
+ if (n.type === 'Variable' && n.isPipeVar) {
66
+ found = true;
164
67
  }
165
- break;
166
- }
167
- }
168
- return false;
68
+ },
69
+ exit() { },
70
+ });
71
+ return found;
169
72
  }
170
73
  // ============================================================
171
74
  // CLOSURE_BRACES RULE
@@ -265,11 +168,15 @@ export const CLOSURE_LATE_BINDING = {
265
168
  validate(node, context) {
266
169
  const eachNode = node;
267
170
  const body = eachNode.body;
171
+ // For named parameter closures (|entry| { ... }), the body itself is a
172
+ // Closure. Look inside the inner block for nested closure creations,
173
+ // not the body closure itself.
174
+ const innerBody = body.type === 'Closure' ? body.body : body;
268
175
  // Check if body contains a closure creation
269
- const hasClosureCreation = containsClosureCreation(body);
176
+ const hasClosureCreation = containsClosureCreation(innerBody);
270
177
  if (hasClosureCreation) {
271
178
  // Check if there's an explicit capture before the closure
272
- const hasExplicitCapture = containsExplicitCapture(body);
179
+ const hasExplicitCapture = containsExplicitCapture(innerBody);
273
180
  if (!hasExplicitCapture) {
274
181
  return [
275
182
  {
@@ -288,82 +195,63 @@ export const CLOSURE_LATE_BINDING = {
288
195
  };
289
196
  /**
290
197
  * Check if a node contains a closure creation (Closure node).
198
+ * Uses visitNode for full AST traversal.
291
199
  */
292
200
  function containsClosureCreation(node) {
293
- if (node.type === 'Closure') {
294
- return true;
295
- }
296
- // Recursively check child nodes
297
- switch (node.type) {
298
- case 'Block': {
299
- const blockNode = node;
300
- for (const stmt of blockNode.statements) {
301
- if (containsClosureCreation(stmt))
302
- return true;
201
+ let found = false;
202
+ const ctx = {};
203
+ visitNode(node, ctx, {
204
+ enter(n) {
205
+ if (n.type === 'Closure') {
206
+ found = true;
303
207
  }
304
- break;
305
- }
306
- case 'Statement': {
307
- const stmtNode = node;
308
- if (stmtNode.expression && containsClosureCreation(stmtNode.expression))
309
- return true;
310
- break;
311
- }
312
- case 'PipeChain': {
313
- const pipeNode = node;
314
- if (pipeNode.head && containsClosureCreation(pipeNode.head))
315
- return true;
316
- if (pipeNode.pipes) {
317
- for (const pipe of pipeNode.pipes) {
318
- if (containsClosureCreation(pipe))
319
- return true;
320
- }
321
- }
322
- break;
323
- }
324
- case 'PostfixExpr': {
325
- const postfixNode = node;
326
- if (postfixNode.primary && containsClosureCreation(postfixNode.primary))
327
- return true;
328
- break;
329
- }
330
- }
331
- return false;
208
+ },
209
+ exit() { },
210
+ });
211
+ return found;
332
212
  }
333
213
  /**
334
- * Check if a Block node contains an explicit capture statement ($ => $name).
214
+ * Check if a Block node contains an explicit capture statement ($ => $name)
215
+ * at the top level (closureDepth === 0). Captures inside nested closures
216
+ * are scoped to that closure and do not fix late binding for the each body.
335
217
  */
336
218
  function containsExplicitCapture(node) {
337
219
  if (node.type !== 'Block') {
338
220
  return false;
339
221
  }
340
- const blockNode = node;
341
- const statements = blockNode.statements;
342
- // Look for capture of $ into a named variable
343
- for (const stmt of statements) {
344
- if (stmt.type === 'Statement' &&
345
- stmt.expression &&
346
- stmt.expression.type === 'PipeChain') {
347
- const chain = stmt.expression;
348
- // Check if any pipe is a Capture
349
- if (chain.pipes && Array.isArray(chain.pipes)) {
350
- for (const pipe of chain.pipes) {
351
- if (pipe.type === 'Capture') {
352
- // Check if the head is bare $
353
- const head = chain.head;
354
- if (head && head.type === 'PostfixExpr') {
355
- const postfix = head;
356
- if (postfix.primary && postfix.primary.type === 'Variable') {
357
- const varNode = postfix.primary;
358
- if (varNode.isPipeVar) {
359
- return true;
360
- }
361
- }
362
- }
363
- }
222
+ let found = false;
223
+ let closureDepth = 0;
224
+ const ctx = {};
225
+ visitNode(node, ctx, {
226
+ enter(n) {
227
+ if (n.type === 'Closure') {
228
+ closureDepth++;
229
+ return;
230
+ }
231
+ if (closureDepth > 0)
232
+ return;
233
+ if (n.type !== 'PipeChain')
234
+ return;
235
+ const chain = n;
236
+ const head = chain.head;
237
+ if (!head || head.type !== 'PostfixExpr')
238
+ return;
239
+ const postfix = head;
240
+ if (!postfix.primary || postfix.primary.type !== 'Variable')
241
+ return;
242
+ if (!postfix.primary.isPipeVar)
243
+ return;
244
+ for (const pipe of chain.pipes) {
245
+ if (pipe.type === 'Capture') {
246
+ found = true;
364
247
  }
365
248
  }
366
- }
367
- }
368
- return false;
249
+ },
250
+ exit(n) {
251
+ if (n.type === 'Closure') {
252
+ closureDepth--;
253
+ }
254
+ },
255
+ });
256
+ return found;
369
257
  }
@@ -3,81 +3,43 @@
3
3
  * Enforces conventions for each, map, fold, and filter operators.
4
4
  */
5
5
  import { extractContextLine } from './helpers.js';
6
+ import { visitNode } from '../visitor.js';
6
7
  // ============================================================
7
8
  // HELPER FUNCTIONS
8
9
  // ============================================================
9
10
  /**
10
11
  * Check if an AST subtree contains a Break node.
11
- * Recursively traverses all node types.
12
+ * Uses visitNode for full AST traversal.
12
13
  */
13
14
  function containsBreak(node) {
14
- if (node.type === 'Break') {
15
- return true;
16
- }
17
- // Recursively check children based on node type
18
- switch (node.type) {
19
- case 'Block':
20
- return node.statements.some((stmt) => containsBreak(stmt));
21
- case 'Statement':
22
- return containsBreak(node.expression);
23
- case 'AnnotatedStatement':
24
- return containsBreak(node.statement);
25
- case 'PipeChain':
26
- if (containsBreak(node.head))
27
- return true;
28
- if (node.pipes.some((pipe) => containsBreak(pipe)))
29
- return true;
30
- if (node.terminator && node.terminator.type === 'Break')
31
- return true;
32
- return false;
33
- case 'PostfixExpr':
34
- if (containsBreak(node.primary))
35
- return true;
36
- return node.methods.some((method) => containsBreak(method));
37
- case 'BinaryExpr':
38
- return containsBreak(node.left) || containsBreak(node.right);
39
- case 'UnaryExpr':
40
- return containsBreak(node.operand);
41
- case 'GroupedExpr':
42
- return containsBreak(node.expression);
43
- case 'Conditional':
44
- if (node.input && containsBreak(node.input))
45
- return true;
46
- if (node.condition && containsBreak(node.condition))
47
- return true;
48
- if (containsBreak(node.thenBranch))
49
- return true;
50
- if (node.elseBranch && containsBreak(node.elseBranch))
51
- return true;
52
- return false;
53
- case 'WhileLoop':
54
- case 'DoWhileLoop':
55
- return containsBreak(node.body);
56
- case 'Closure':
57
- return containsBreak(node.body);
58
- case 'EachExpr':
59
- case 'MapExpr':
60
- case 'FoldExpr':
61
- case 'FilterExpr':
62
- return containsBreak(node.body);
63
- case 'HostCall':
64
- case 'ClosureCall':
65
- case 'MethodCall':
66
- case 'Invoke':
67
- case 'PipeInvoke':
68
- return node.args.some((arg) => containsBreak(arg));
69
- case 'StringLiteral':
70
- return node.parts.some((part) => typeof part !== 'string' && containsBreak(part));
71
- case 'TupleLiteral':
72
- return node.elements.some((elem) => containsBreak(elem));
73
- case 'Dict':
74
- return node.entries.some((entry) => containsBreak(entry));
75
- case 'DictEntry':
76
- return containsBreak(node.value);
77
- default:
78
- // Leaf nodes and other types don't contain breaks
79
- return false;
80
- }
15
+ let found = false;
16
+ const ctx = {};
17
+ visitNode(node, ctx, {
18
+ enter(n) {
19
+ if (n.type === 'Break') {
20
+ found = true;
21
+ }
22
+ },
23
+ exit() { },
24
+ });
25
+ return found;
26
+ }
27
+ /**
28
+ * Check if an AST subtree contains side-effecting operations.
29
+ * Detects HostCall (log, host functions) and ClosureCall ($fn(), $obj.method()).
30
+ */
31
+ function containsSideEffects(node) {
32
+ let found = false;
33
+ const ctx = {};
34
+ visitNode(node, ctx, {
35
+ enter(n) {
36
+ if (n.type === 'HostCall' || n.type === 'ClosureCall') {
37
+ found = true;
38
+ }
39
+ },
40
+ exit() { },
41
+ });
42
+ return found;
81
43
  }
82
44
  /**
83
45
  * Check if a body is a simple method shorthand.
@@ -234,9 +196,13 @@ export const PREFER_MAP = {
234
196
  return [];
235
197
  }
236
198
  }
237
- // Simple heuristic: if body is pure (no side effects), suggest map
238
- // For now, suggest map for simple transformations
239
- // Full implementation would check for host calls, logging, etc.
199
+ // Check for side effects: host calls (log, etc.) and closure calls ($fn())
200
+ const innerBody = eachExpr.body.type === 'Closure'
201
+ ? eachExpr.body.body
202
+ : eachExpr.body;
203
+ if (containsSideEffects(innerBody)) {
204
+ return [];
205
+ }
240
206
  return [
241
207
  {
242
208
  location: node.span.start,
@@ -244,7 +210,7 @@ export const PREFER_MAP = {
244
210
  code: 'PREFER_MAP',
245
211
  message: "Consider using 'map' instead of 'each' for pure transformations (no side effects)",
246
212
  context: extractContextLine(node.span.start.line, context.source),
247
- fix: null, // Could generate fix by replacing 'each' with 'map'
213
+ fix: null,
248
214
  },
249
215
  ];
250
216
  },
@@ -125,7 +125,6 @@ export const SPACING_BRACES = {
125
125
  const span = node.span;
126
126
  const lines = context.source.split('\n');
127
127
  const openLine = lines[span.start.line - 1] ?? '';
128
- const closeLine = lines[span.end.line - 1] ?? '';
129
128
  // Check for opening brace without space after
130
129
  // Only examine the opening line (from the { onward)
131
130
  // Use ^ anchor to only check the block's opening brace, not string interpolation
@@ -141,11 +140,16 @@ export const SPACING_BRACES = {
141
140
  });
142
141
  }
143
142
  // Check for closing brace without space before
144
- // span.end.column is 1-indexed and points AFTER the }, so:
145
- // - } is at 0-index: span.end.column - 2
146
- // - Character before } is at 0-index: span.end.column - 3
147
- const charBeforeClose = closeLine[span.end.column - 3];
148
- const isCloseOnOwnLine = /^\s*$/.test(closeLine.substring(0, span.end.column - 2));
143
+ // For Closure nodes with return type annotations, span.end extends past }
144
+ // to include the type annotation. Use body.span.end to find the actual }.
145
+ const closeSpan = node.type === 'Closure' ? node.body.span : span;
146
+ const closeEnd = closeSpan.end;
147
+ const closeLineActual = lines[closeEnd.line - 1] ?? '';
148
+ // closeEnd.column is 1-indexed and points AFTER the }, so:
149
+ // - } is at 0-index: closeEnd.column - 2
150
+ // - Character before } is at 0-index: closeEnd.column - 3
151
+ const charBeforeClose = closeLineActual[closeEnd.column - 3];
152
+ const isCloseOnOwnLine = /^\s*$/.test(closeLineActual.substring(0, closeEnd.column - 2));
149
153
  if (charBeforeClose && !/\s/.test(charBeforeClose) && !isCloseOnOwnLine) {
150
154
  diagnostics.push({
151
155
  location: span.end,
@@ -11,7 +11,7 @@ export { USE_DEFAULT_OPERATOR, CONDITION_TYPE } from './conditionals.js';
11
11
  export { CLOSURE_BARE_DOLLAR, CLOSURE_BRACES, CLOSURE_LATE_BINDING, } from './closures.js';
12
12
  export { UNNECESSARY_ASSERTION, VALIDATE_EXTERNAL } from './types.js';
13
13
  export { USE_EMPTY_METHOD } from './strings.js';
14
- export { AVOID_REASSIGNMENT, COMPLEX_CONDITION, LOOP_OUTER_CAPTURE, } from './anti-patterns.js';
14
+ export { AVOID_REASSIGNMENT, COMPLEX_CONDITION, LOOP_OUTER_CAPTURE, STREAM_PRE_ITERATION, } from './anti-patterns.js';
15
15
  export { SPACING_OPERATOR, SPACING_BRACES, SPACING_BRACKETS, SPACING_CLOSURE, INDENT_CONTINUATION, IMPLICIT_DOLLAR_METHOD, IMPLICIT_DOLLAR_FUNCTION, IMPLICIT_DOLLAR_CLOSURE, THROWAWAY_CAPTURE, } from './formatting.js';
16
16
  export { USE_DYNAMIC_IDENTIFIER, USE_UNTYPED_HOST_REF, } from './use-expressions.js';
17
17
  /**
@@ -10,7 +10,7 @@ import { USE_DEFAULT_OPERATOR, CONDITION_TYPE } from './conditionals.js';
10
10
  import { CLOSURE_BARE_DOLLAR, CLOSURE_BRACES, CLOSURE_LATE_BINDING, } from './closures.js';
11
11
  import { UNNECESSARY_ASSERTION, VALIDATE_EXTERNAL } from './types.js';
12
12
  import { USE_EMPTY_METHOD } from './strings.js';
13
- import { AVOID_REASSIGNMENT, COMPLEX_CONDITION, LOOP_OUTER_CAPTURE, } from './anti-patterns.js';
13
+ import { AVOID_REASSIGNMENT, COMPLEX_CONDITION, LOOP_OUTER_CAPTURE, STREAM_PRE_ITERATION, } from './anti-patterns.js';
14
14
  import { SPACING_OPERATOR, SPACING_BRACES, SPACING_BRACKETS, SPACING_CLOSURE, INDENT_CONTINUATION, IMPLICIT_DOLLAR_METHOD, IMPLICIT_DOLLAR_FUNCTION, IMPLICIT_DOLLAR_CLOSURE, THROWAWAY_CAPTURE, } from './formatting.js';
15
15
  import { USE_DYNAMIC_IDENTIFIER, USE_UNTYPED_HOST_REF, } from './use-expressions.js';
16
16
  // ============================================================
@@ -24,7 +24,7 @@ export { USE_DEFAULT_OPERATOR, CONDITION_TYPE } from './conditionals.js';
24
24
  export { CLOSURE_BARE_DOLLAR, CLOSURE_BRACES, CLOSURE_LATE_BINDING, } from './closures.js';
25
25
  export { UNNECESSARY_ASSERTION, VALIDATE_EXTERNAL } from './types.js';
26
26
  export { USE_EMPTY_METHOD } from './strings.js';
27
- export { AVOID_REASSIGNMENT, COMPLEX_CONDITION, LOOP_OUTER_CAPTURE, } from './anti-patterns.js';
27
+ export { AVOID_REASSIGNMENT, COMPLEX_CONDITION, LOOP_OUTER_CAPTURE, STREAM_PRE_ITERATION, } from './anti-patterns.js';
28
28
  export { SPACING_OPERATOR, SPACING_BRACES, SPACING_BRACKETS, SPACING_CLOSURE, INDENT_CONTINUATION, IMPLICIT_DOLLAR_METHOD, IMPLICIT_DOLLAR_FUNCTION, IMPLICIT_DOLLAR_CLOSURE, THROWAWAY_CAPTURE, } from './formatting.js';
29
29
  export { USE_DYNAMIC_IDENTIFIER, USE_UNTYPED_HOST_REF, } from './use-expressions.js';
30
30
  // ============================================================
@@ -66,6 +66,7 @@ export const VALIDATION_RULES = [
66
66
  AVOID_REASSIGNMENT,
67
67
  COMPLEX_CONDITION,
68
68
  LOOP_OUTER_CAPTURE,
69
+ STREAM_PRE_ITERATION,
69
70
  // Formatting
70
71
  SPACING_OPERATOR,
71
72
  SPACING_BRACES,
@@ -257,6 +257,7 @@ export function visitNode(node, context, visitor) {
257
257
  case 'Break':
258
258
  case 'Return':
259
259
  case 'Pass':
260
+ case 'Yield':
260
261
  // Leaf nodes - no children
261
262
  break;
262
263
  case 'RecoveryError':
package/dist/cli-run.js CHANGED
@@ -11,7 +11,7 @@ import { parse, execute, createRuntimeContext, invokeCallable, isScriptCallable,
11
11
  import { resolveConfigPath, loadProject, parseMainField, introspectHandler, marshalCliArgs, ConfigError, } from '@rcrsr/rill-config';
12
12
  import { CLI_VERSION } from './cli-shared.js';
13
13
  import { explainError } from './cli-explain.js';
14
- import { runScript } from './run/runner.js';
14
+ import { formatOutput, runScript } from './run/runner.js';
15
15
  // ============================================================
16
16
  // HELP TEXT
17
17
  // ============================================================
@@ -288,10 +288,13 @@ export async function main() {
288
288
  }
289
289
  }
290
290
  }
291
- if (typeof handlerResult === 'string' && handlerResult.length > 0) {
292
- process.stdout.write(handlerResult + '\n');
291
+ if (handlerResult !== false &&
292
+ handlerResult !== '' &&
293
+ handlerResult !== undefined) {
294
+ const output = formatOutput(handlerResult, opts.format);
295
+ process.stdout.write(output + '\n');
293
296
  }
294
- process.exit(0);
297
+ process.exit(handlerResult === false || handlerResult === '' ? 1 : 0);
295
298
  }
296
299
  // Module mode: main field in config is required
297
300
  if (mainField === undefined) {
@@ -17,6 +17,7 @@ export interface RunResult {
17
17
  * - `module:alias` → `{dir}/index.rill`
18
18
  */
19
19
  export declare function buildModuleResolver(modulesConfig: Record<string, string>, configDir: string): SchemeResolver;
20
+ export declare function formatOutput(value: RillValue, format: RunCliOptions['format']): string;
20
21
  /**
21
22
  * Run a rill script file with the given extension tree and config.
22
23
  */
@@ -59,7 +59,7 @@ function mapResultToRunResult(result, format) {
59
59
  const formatted = formatOutput(result, format);
60
60
  return { exitCode: 0, output: formatted };
61
61
  }
62
- function formatOutput(value, format) {
62
+ export function formatOutput(value, format) {
63
63
  const native = toNative(value);
64
64
  if (format === 'json' || format === 'compact') {
65
65
  return JSON.stringify(native.value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rcrsr/rill-cli",
3
- "version": "0.17.0",
3
+ "version": "0.18.1",
4
4
  "description": "CLI tools for the rill scripting language",
5
5
  "license": "MIT",
6
6
  "author": "Andre Bremer",
@@ -21,8 +21,8 @@
21
21
  "dependencies": {
22
22
  "dotenv": "^16.0.0",
23
23
  "yaml": "^2.8.2",
24
- "@rcrsr/rill": "^0.17.0",
25
- "@rcrsr/rill-config": "^0.17.0"
24
+ "@rcrsr/rill-config": "^0.18.1",
25
+ "@rcrsr/rill": "^0.18.0"
26
26
  },
27
27
  "files": [
28
28
  "dist"