@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.
- package/dist/check/rules/anti-patterns.d.ts +20 -0
- package/dist/check/rules/anti-patterns.js +225 -68
- package/dist/check/rules/closures.js +65 -177
- package/dist/check/rules/collections.js +38 -72
- package/dist/check/rules/formatting.js +10 -6
- package/dist/check/rules/index.d.ts +1 -1
- package/dist/check/rules/index.js +3 -2
- package/dist/check/visitor.js +1 -0
- package/dist/cli-run.js +7 -4
- package/dist/run/runner.d.ts +1 -0
- package/dist/run/runner.js +1 -1
- package/package.json +3 -3
|
@@ -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
|
-
*
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
*
|
|
58
|
+
* Uses visitNode for full AST traversal, detecting VariableNode with isPipeVar=true.
|
|
58
59
|
*/
|
|
59
60
|
function containsBareReference(node) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
return
|
|
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(
|
|
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(
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
if (
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
12
|
+
* Uses visitNode for full AST traversal.
|
|
12
13
|
*/
|
|
13
14
|
function containsBreak(node) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
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,
|
|
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
|
-
//
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
const
|
|
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,
|
package/dist/check/visitor.js
CHANGED
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 (
|
|
292
|
-
|
|
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) {
|
package/dist/run/runner.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/run/runner.js
CHANGED
|
@@ -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.
|
|
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.
|
|
25
|
-
"@rcrsr/rill
|
|
24
|
+
"@rcrsr/rill-config": "^0.18.1",
|
|
25
|
+
"@rcrsr/rill": "^0.18.0"
|
|
26
26
|
},
|
|
27
27
|
"files": [
|
|
28
28
|
"dist"
|