@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,811 @@
1
+ /**
2
+ * Formatting Rules
3
+ * Enforces style conventions from docs/guide-conventions.md:465-662.
4
+ */
5
+
6
+ import type {
7
+ ValidationRule,
8
+ Diagnostic,
9
+ ValidationContext,
10
+ FixContext,
11
+ Fix,
12
+ } from '../types.js';
13
+ import type {
14
+ ASTNode,
15
+ BinaryExprNode,
16
+ PipeChainNode,
17
+ CaptureNode,
18
+ ClosureNode,
19
+ SourceSpan,
20
+ PostfixExprNode,
21
+ VariableNode,
22
+ BracketAccess,
23
+ MethodCallNode,
24
+ HostCallNode,
25
+ ClosureCallNode,
26
+ } from '@rcrsr/rill';
27
+ import { extractContextLine, isBareReference } from './helpers.js';
28
+
29
+ // ============================================================
30
+ // SPACING_OPERATOR RULE
31
+ // ============================================================
32
+
33
+ /**
34
+ * Enforces space on both sides of operators.
35
+ * Operators like +, -, ->, =>, ==, etc. should have spaces on both sides.
36
+ *
37
+ * Detection:
38
+ * - Extract operator text from source using source spans
39
+ * - Check if space exists before/after operator
40
+ *
41
+ * References:
42
+ * - docs/guide-conventions.md:467-482
43
+ */
44
+ export const SPACING_OPERATOR: ValidationRule = {
45
+ code: 'SPACING_OPERATOR',
46
+ category: 'formatting',
47
+ severity: 'info',
48
+ nodeTypes: ['BinaryExpr', 'PipeChain', 'Capture'],
49
+
50
+ validate(node: ASTNode, context: ValidationContext): Diagnostic[] {
51
+ const diagnostics: Diagnostic[] = [];
52
+
53
+ if (node.type === 'BinaryExpr') {
54
+ const binaryNode = node as BinaryExprNode;
55
+ const operator = binaryNode.op;
56
+
57
+ // Check spacing around operator in source
58
+ const violation = checkOperatorSpacing(
59
+ operator,
60
+ binaryNode.span,
61
+ context.source
62
+ );
63
+
64
+ if (violation) {
65
+ diagnostics.push({
66
+ location: binaryNode.span.start,
67
+ severity: 'info',
68
+ code: 'SPACING_OPERATOR',
69
+ message: `Operator '${operator}' should have spaces on both sides`,
70
+ context: extractContextLine(
71
+ binaryNode.span.start.line,
72
+ context.source
73
+ ),
74
+ fix: null, // Complex to fix without AST reconstruction
75
+ });
76
+ }
77
+ }
78
+
79
+ if (node.type === 'PipeChain') {
80
+ const pipeNode = node as PipeChainNode;
81
+ // Check -> operators between pipes
82
+ const violation = checkPipeSpacing(pipeNode.span, context.source);
83
+
84
+ if (violation) {
85
+ diagnostics.push({
86
+ location: pipeNode.span.start,
87
+ severity: 'info',
88
+ code: 'SPACING_OPERATOR',
89
+ message: "Pipe operator '->' should have spaces on both sides",
90
+ context: extractContextLine(pipeNode.span.start.line, context.source),
91
+ fix: null,
92
+ });
93
+ }
94
+ }
95
+
96
+ if (node.type === 'Capture') {
97
+ const captureNode = node as CaptureNode;
98
+ // Check => operator
99
+ const violation = checkCaptureSpacing(captureNode.span, context.source);
100
+
101
+ if (violation) {
102
+ diagnostics.push({
103
+ location: captureNode.span.start,
104
+ severity: 'info',
105
+ code: 'SPACING_OPERATOR',
106
+ message: "Capture operator '=>' should have spaces on both sides",
107
+ context: extractContextLine(
108
+ captureNode.span.start.line,
109
+ context.source
110
+ ),
111
+ fix: null,
112
+ });
113
+ }
114
+ }
115
+
116
+ return diagnostics;
117
+ },
118
+ };
119
+
120
+ /**
121
+ * Check if operator has proper spacing in source.
122
+ */
123
+ function checkOperatorSpacing(
124
+ operator: string,
125
+ span: SourceSpan,
126
+ source: string
127
+ ): boolean {
128
+ const text = extractSpanText(span, source);
129
+
130
+ // Look for operator without spaces
131
+ const patterns = [
132
+ new RegExp(`\\S${escapeRegex(operator)}`), // No space before
133
+ new RegExp(`${escapeRegex(operator)}\\S`), // No space after
134
+ ];
135
+
136
+ return patterns.some((pattern) => pattern.test(text));
137
+ }
138
+
139
+ /**
140
+ * Check pipe operator spacing.
141
+ */
142
+ function checkPipeSpacing(span: SourceSpan, source: string): boolean {
143
+ const text = extractSpanText(span, source);
144
+
145
+ // Check for -> without spaces
146
+ return /\S->/.test(text) || /->[\S&&[^\s]]/.test(text);
147
+ }
148
+
149
+ /**
150
+ * Check capture operator spacing.
151
+ */
152
+ function checkCaptureSpacing(span: SourceSpan, source: string): boolean {
153
+ const text = extractSpanText(span, source);
154
+
155
+ // Check for => without spaces
156
+ return /\S=>/.test(text) || /=>\S/.test(text);
157
+ }
158
+
159
+ // ============================================================
160
+ // SPACING_BRACES RULE
161
+ // ============================================================
162
+
163
+ /**
164
+ * Enforces space after { and before } in blocks.
165
+ * Braces for blocks, closures, etc. should have internal spacing.
166
+ *
167
+ * Detection:
168
+ * - Extract brace content from source
169
+ * - Check if opening { has space after, closing } has space before
170
+ *
171
+ * References:
172
+ * - docs/guide-conventions.md:497-508
173
+ */
174
+ export const SPACING_BRACES: ValidationRule = {
175
+ code: 'SPACING_BRACES',
176
+ category: 'formatting',
177
+ severity: 'info',
178
+ nodeTypes: ['Block', 'Closure'],
179
+
180
+ validate(node: ASTNode, context: ValidationContext): Diagnostic[] {
181
+ const diagnostics: Diagnostic[] = [];
182
+ const span = node.span;
183
+ const lines = context.source.split('\n');
184
+
185
+ const openLine = lines[span.start.line - 1] ?? '';
186
+ const closeLine = lines[span.end.line - 1] ?? '';
187
+
188
+ // Check for opening brace without space after
189
+ // Only examine the opening line (from the { onward)
190
+ // Use ^ anchor to only check the block's opening brace, not string interpolation
191
+ const openFrom = openLine.substring(span.start.column - 1);
192
+ if (/^\{[^\s\n]/.test(openFrom)) {
193
+ diagnostics.push({
194
+ location: span.start,
195
+ severity: 'info',
196
+ code: 'SPACING_BRACES',
197
+ message: 'Space required after opening brace {',
198
+ context: extractContextLine(span.start.line, context.source),
199
+ fix: null,
200
+ });
201
+ }
202
+
203
+ // Check for closing brace without space before
204
+ // span.end.column is 1-indexed and points AFTER the }, so:
205
+ // - } is at 0-index: span.end.column - 2
206
+ // - Character before } is at 0-index: span.end.column - 3
207
+ const charBeforeClose = closeLine[span.end.column - 3];
208
+ const isCloseOnOwnLine = /^\s*$/.test(
209
+ closeLine.substring(0, span.end.column - 2)
210
+ );
211
+ if (charBeforeClose && !/\s/.test(charBeforeClose) && !isCloseOnOwnLine) {
212
+ diagnostics.push({
213
+ location: span.end,
214
+ severity: 'info',
215
+ code: 'SPACING_BRACES',
216
+ message: 'Space required before closing brace }',
217
+ context: extractContextLine(span.end.line, context.source),
218
+ fix: null,
219
+ });
220
+ }
221
+
222
+ return diagnostics;
223
+ },
224
+ };
225
+
226
+ // ============================================================
227
+ // SPACING_BRACKETS RULE
228
+ // ============================================================
229
+
230
+ /**
231
+ * Enforces no inner spaces for indexing brackets.
232
+ * Array/dict indexing should use $list[0] not $list[ 0 ].
233
+ *
234
+ * Detection:
235
+ * - PostfixExpr nodes with index access
236
+ * - Check for spaces inside brackets
237
+ *
238
+ * References:
239
+ * - docs/guide-conventions.md:526-535
240
+ */
241
+ export const SPACING_BRACKETS: ValidationRule = {
242
+ code: 'SPACING_BRACKETS',
243
+ category: 'formatting',
244
+ severity: 'info',
245
+ nodeTypes: ['PostfixExpr'],
246
+
247
+ validate(node: ASTNode, context: ValidationContext): Diagnostic[] {
248
+ const diagnostics: Diagnostic[] = [];
249
+ const postfixNode = node as PostfixExprNode;
250
+
251
+ // Only process if primary is a Variable (contains accessChain)
252
+ if (postfixNode.primary.type !== 'Variable') {
253
+ return diagnostics;
254
+ }
255
+
256
+ const variableNode = postfixNode.primary as VariableNode;
257
+
258
+ // Check each BracketAccess in the accessChain
259
+ for (const access of variableNode.accessChain) {
260
+ // Skip non-bracket accesses
261
+ if (!('accessKind' in access) || access.accessKind !== 'bracket') {
262
+ continue;
263
+ }
264
+
265
+ const bracketAccess = access as BracketAccess;
266
+
267
+ // Skip if span is missing or invalid (EC-3, EC-4)
268
+ if (!isValidSpan(bracketAccess.span)) {
269
+ continue;
270
+ }
271
+
272
+ // Extract text from bracket span
273
+ const text = extractSpanText(bracketAccess.span, context.source);
274
+
275
+ // Check for space after opening bracket: /\[\s/
276
+ // Check for space before closing bracket: /\s\]/
277
+ const hasSpaceAfterOpen = /\[\s/.test(text);
278
+ const hasSpaceBeforeClose = /\s\]/.test(text);
279
+
280
+ if (hasSpaceAfterOpen || hasSpaceBeforeClose) {
281
+ // Extract content between brackets for error message
282
+ const content = text.substring(1, text.length - 1).trim();
283
+
284
+ diagnostics.push({
285
+ location: bracketAccess.span.start,
286
+ severity: 'info',
287
+ code: 'SPACING_BRACKETS',
288
+ message: `No spaces inside brackets: remove spaces around ${content}`,
289
+ context: extractContextLine(
290
+ bracketAccess.span.start.line,
291
+ context.source
292
+ ),
293
+ fix: null,
294
+ });
295
+ }
296
+ }
297
+
298
+ return diagnostics;
299
+ },
300
+
301
+ fix(node: ASTNode, context: FixContext): Fix | null {
302
+ const postfixNode = node as PostfixExprNode;
303
+
304
+ // Only process if primary is a Variable (contains accessChain)
305
+ if (postfixNode.primary.type !== 'Variable') {
306
+ return null;
307
+ }
308
+
309
+ const variableNode = postfixNode.primary as VariableNode;
310
+
311
+ // Find the first BracketAccess with spacing violation
312
+ for (const access of variableNode.accessChain) {
313
+ // Skip non-bracket accesses
314
+ if (!('accessKind' in access) || access.accessKind !== 'bracket') {
315
+ continue;
316
+ }
317
+
318
+ const bracketAccess = access as BracketAccess;
319
+
320
+ // Skip if span is missing or invalid
321
+ if (!isValidSpan(bracketAccess.span)) {
322
+ continue;
323
+ }
324
+
325
+ // Extract text from bracket span
326
+ const text = extractSpanText(bracketAccess.span, context.source);
327
+
328
+ // Check for spacing violations
329
+ const hasSpaceAfterOpen = /\[\s/.test(text);
330
+ const hasSpaceBeforeClose = /\s\]/.test(text);
331
+
332
+ if (hasSpaceAfterOpen || hasSpaceBeforeClose) {
333
+ // Build replacement text by removing inner spaces
334
+ // Replace [ followed by whitespace with [
335
+ // Replace whitespace followed by ] with ]
336
+ const replacement = text.replace(/\[\s+/g, '[').replace(/\s+\]/g, ']');
337
+
338
+ return {
339
+ description: 'Remove spaces inside brackets',
340
+ applicable: true,
341
+ range: bracketAccess.span,
342
+ replacement,
343
+ };
344
+ }
345
+ }
346
+
347
+ // No fixable violation found
348
+ return null;
349
+ },
350
+ };
351
+
352
+ // ============================================================
353
+ // SPACING_CLOSURE RULE
354
+ // ============================================================
355
+
356
+ /**
357
+ * Enforces no space before pipe, space after in closures.
358
+ * Closure parameters: |x| not | x |.
359
+ *
360
+ * Detection:
361
+ * - Extract closure parameter section from source
362
+ * - Check spacing around pipes
363
+ *
364
+ * References:
365
+ * - docs/guide-conventions.md:549-560
366
+ */
367
+ export const SPACING_CLOSURE: ValidationRule = {
368
+ code: 'SPACING_CLOSURE',
369
+ category: 'formatting',
370
+ severity: 'info',
371
+ nodeTypes: ['Closure'],
372
+
373
+ validate(node: ASTNode, context: ValidationContext): Diagnostic[] {
374
+ const diagnostics: Diagnostic[] = [];
375
+ const closureNode = node as ClosureNode;
376
+ const text = extractSpanText(closureNode.span, context.source);
377
+
378
+ // Check for space before opening pipe
379
+ if (/\s\|/.test(text.substring(0, text.indexOf('|') + 1))) {
380
+ diagnostics.push({
381
+ location: closureNode.span.start,
382
+ severity: 'info',
383
+ code: 'SPACING_CLOSURE',
384
+ message: 'No space before opening pipe in closure parameters',
385
+ context: extractContextLine(
386
+ closureNode.span.start.line,
387
+ context.source
388
+ ),
389
+ fix: null,
390
+ });
391
+ }
392
+
393
+ // Check for missing space after params (only if params exist)
394
+ if (closureNode.params.length > 0) {
395
+ // Look for pattern |params|( or |params|{ without space
396
+ const afterPipeIdx = text.lastIndexOf(
397
+ '|',
398
+ text.indexOf('{') || text.indexOf('(')
399
+ );
400
+ if (afterPipeIdx !== -1) {
401
+ const afterPipe = text.substring(afterPipeIdx + 1, afterPipeIdx + 2);
402
+ if (
403
+ afterPipe &&
404
+ /[^\s]/.test(afterPipe) &&
405
+ afterPipe !== '{' &&
406
+ afterPipe !== '('
407
+ ) {
408
+ // This is complex - skip for now as it requires better parsing
409
+ }
410
+ }
411
+ }
412
+
413
+ return diagnostics;
414
+ },
415
+ };
416
+
417
+ // ============================================================
418
+ // INDENT_CONTINUATION RULE
419
+ // ============================================================
420
+
421
+ /**
422
+ * Enforces 2-space indent for continued lines.
423
+ * Pipe chains should indent continuation lines by 2 spaces.
424
+ *
425
+ * Detection:
426
+ * - Multi-line pipe chains
427
+ * - Check indentation of continuation lines
428
+ *
429
+ * References:
430
+ * - docs/guide-conventions.md:636-662
431
+ */
432
+ export const INDENT_CONTINUATION: ValidationRule = {
433
+ code: 'INDENT_CONTINUATION',
434
+ category: 'formatting',
435
+ severity: 'info',
436
+ nodeTypes: ['PipeChain'],
437
+
438
+ validate(node: ASTNode, context: ValidationContext): Diagnostic[] {
439
+ const diagnostics: Diagnostic[] = [];
440
+ const pipeNode = node as PipeChainNode;
441
+
442
+ // EC-5: Single-line chain - Return []
443
+ if (pipeNode.span.start.line === pipeNode.span.end.line) {
444
+ return [];
445
+ }
446
+
447
+ // Extract full text and check continuation indentation
448
+ const text = extractSpanText(pipeNode.span, context.source);
449
+ const lines = text.split('\n');
450
+
451
+ // KNOWN LIMITATION: This rule validates multi-line pipe chains where the pipe
452
+ // operator (`->`) and its target appear on the same line. The parser requires
453
+ // pipe targets to be on the same line as the `->` operator, so patterns like
454
+ // `value ->\n .method()` are invalid. See tests/language/statement-boundaries.test.ts:211-215
455
+ // for authoritative language behavior.
456
+ if (lines.length > 1) {
457
+ // Check each continuation line (skip first line which establishes baseline)
458
+ for (let i = 1; i < lines.length; i++) {
459
+ const line = lines[i];
460
+
461
+ // EC-6: Empty continuation line - Skip line
462
+ if (!line) continue;
463
+
464
+ const indent = line.match(/^(\s*)/)?.[1] || '';
465
+
466
+ // Continuation = line starting with -> (after whitespace)
467
+ // Should have at least 2 spaces for continuation
468
+ if (line.trim().startsWith('->') && indent.length < 2) {
469
+ diagnostics.push({
470
+ location: {
471
+ line: pipeNode.span.start.line + i,
472
+ column: 1,
473
+ offset: 0,
474
+ },
475
+ severity: 'info',
476
+ code: 'INDENT_CONTINUATION',
477
+ message: 'Continuation lines should be indented by 2 spaces',
478
+ context: line.trim(),
479
+ fix: null,
480
+ });
481
+ }
482
+ }
483
+ }
484
+
485
+ return diagnostics;
486
+ },
487
+ };
488
+
489
+ // ============================================================
490
+ // IMPLICIT_DOLLAR_METHOD RULE
491
+ // ============================================================
492
+
493
+ /**
494
+ * Detect explicit $.method() patterns replaceable with .method.
495
+ *
496
+ * Flags method calls where the receiver is a bare $ (pipe variable).
497
+ * The implicit form .method is preferred when $ represents the current
498
+ * piped value (e.g., in blocks, closures, conditionals).
499
+ *
500
+ * Detection:
501
+ * - MethodCallNode with non-null receiverSpan
502
+ * - Receiver is bare $ (zero-width or single-char span)
503
+ * - Method call is first in chain (receiverSpan.end.offset <= 1)
504
+ *
505
+ * Note: Cannot use isBareReference() helper here because MethodCallNode.receiverSpan
506
+ * is a SourceSpan (position range), not an ExpressionNode. The helper requires
507
+ * an AST node to traverse. Instead, we detect bare $ by checking:
508
+ * 1. receiverSpan is zero-width (start == end) or single-char
509
+ * 2. Character at offset is '$'
510
+ * 3. Next character is '.' (not a variable name continuation)
511
+ *
512
+ * Examples:
513
+ * - $.upper() -> .upper
514
+ * - $.len -> .len
515
+ * - $.trim().upper() -> First method flagged, second is chained (not bare $)
516
+ *
517
+ * Not flagged:
518
+ * - .upper (receiverSpan is null)
519
+ * - $var.method() (receiverSpan is not bare $)
520
+ * - $.trim().upper() second method (receiverSpan covers $.trim())
521
+ *
522
+ * References:
523
+ * - docs/guide-conventions.md:587-598
524
+ */
525
+ export const IMPLICIT_DOLLAR_METHOD: ValidationRule = {
526
+ code: 'IMPLICIT_DOLLAR_METHOD',
527
+ category: 'formatting',
528
+ severity: 'info',
529
+ nodeTypes: ['MethodCall'],
530
+
531
+ validate(node: ASTNode, context: ValidationContext): Diagnostic[] {
532
+ const methodNode = node as MethodCallNode;
533
+
534
+ // EC-7: No receiverSpan means implicit receiver (already correct form)
535
+ if (methodNode.receiverSpan === null) {
536
+ return [];
537
+ }
538
+
539
+ // Detect bare $ receiver by analyzing the receiverSpan
540
+ // For bare $, the span is either:
541
+ // 1. Zero-width (start.offset == end.offset) at the $ character
542
+ // 2. Single-char span covering just $
543
+ const receiverSpan = methodNode.receiverSpan;
544
+ const spanLength = receiverSpan.end.offset - receiverSpan.start.offset;
545
+
546
+ // EC-8: Receiver is not bare $ if span is longer than 1 character
547
+ // This filters out chains like $.trim().upper() where second method
548
+ // has receiverSpan covering "$.trim()."
549
+ if (spanLength > 1) {
550
+ return [];
551
+ }
552
+
553
+ // Check that the character at the span is '$' and not part of a variable name
554
+ const offset = receiverSpan.start.offset;
555
+ const charAtOffset = context.source[offset];
556
+ const nextChar = context.source[offset + 1];
557
+
558
+ // Must be '$' followed by '.' (method call)
559
+ // This distinguishes $.method() from $var.method()
560
+ if (charAtOffset !== '$' || nextChar !== '.') {
561
+ return [];
562
+ }
563
+
564
+ // Generate diagnostic for bare $ receiver
565
+ const suggestedCode =
566
+ methodNode.args.length === 0
567
+ ? `.${methodNode.name}`
568
+ : `.${methodNode.name}()`;
569
+
570
+ return [
571
+ {
572
+ code: 'IMPLICIT_DOLLAR_METHOD',
573
+ message: `Prefer implicit '${suggestedCode}' over explicit '$.${methodNode.name}()'`,
574
+ severity: 'info',
575
+ location: {
576
+ line: methodNode.span.start.line,
577
+ column: methodNode.span.start.column,
578
+ offset: methodNode.span.start.offset,
579
+ },
580
+ context: extractContextLine(methodNode.span.start.line, context.source),
581
+ fix: null,
582
+ },
583
+ ];
584
+ },
585
+ };
586
+
587
+ // ============================================================
588
+ // IMPLICIT_DOLLAR_FUNCTION RULE
589
+ // ============================================================
590
+
591
+ /**
592
+ * Prefer foo over foo($) for global function calls.
593
+ * When single argument is bare $, prefer implicit form.
594
+ *
595
+ * Detection:
596
+ * - HostCall with single argument that is bare $
597
+ *
598
+ * References:
599
+ * - docs/guide-conventions.md:599-607
600
+ */
601
+ export const IMPLICIT_DOLLAR_FUNCTION: ValidationRule = {
602
+ code: 'IMPLICIT_DOLLAR_FUNCTION',
603
+ category: 'formatting',
604
+ severity: 'info',
605
+ nodeTypes: ['HostCall'],
606
+
607
+ validate(node: ASTNode, context: ValidationContext): Diagnostic[] {
608
+ const hostCallNode = node as HostCallNode;
609
+
610
+ // EC-9: Zero args - Return []
611
+ if (hostCallNode.args.length === 0) {
612
+ return [];
613
+ }
614
+
615
+ // EC-10: Multiple args - Return []
616
+ if (hostCallNode.args.length > 1) {
617
+ return [];
618
+ }
619
+
620
+ // EC-11: Single arg not bare $ - Return []
621
+ const singleArg = hostCallNode.args[0];
622
+ if (!isBareReference(singleArg)) {
623
+ return [];
624
+ }
625
+
626
+ // Generate diagnostic for bare $ argument
627
+ return [
628
+ {
629
+ code: 'IMPLICIT_DOLLAR_FUNCTION',
630
+ message: `Prefer pipe syntax '-> ${hostCallNode.name}' over explicit '${hostCallNode.name}($)'`,
631
+ severity: 'info',
632
+ location: {
633
+ line: hostCallNode.span.start.line,
634
+ column: hostCallNode.span.start.column,
635
+ offset: hostCallNode.span.start.offset,
636
+ },
637
+ context: extractContextLine(
638
+ hostCallNode.span.start.line,
639
+ context.source
640
+ ),
641
+ fix: null,
642
+ },
643
+ ];
644
+ },
645
+ };
646
+
647
+ // ============================================================
648
+ // IMPLICIT_DOLLAR_CLOSURE RULE
649
+ // ============================================================
650
+
651
+ /**
652
+ * Prefer $fn over $fn($) for closure invocation.
653
+ * When single argument is bare $, prefer implicit form.
654
+ *
655
+ * Detection:
656
+ * - ClosureCall with single argument that is bare $
657
+ *
658
+ * References:
659
+ * - docs/guide-conventions.md:608-615
660
+ */
661
+ export const IMPLICIT_DOLLAR_CLOSURE: ValidationRule = {
662
+ code: 'IMPLICIT_DOLLAR_CLOSURE',
663
+ category: 'formatting',
664
+ severity: 'info',
665
+ nodeTypes: ['ClosureCall'],
666
+
667
+ validate(node: ASTNode, context: ValidationContext): Diagnostic[] {
668
+ const closureCallNode = node as ClosureCallNode;
669
+
670
+ // EC-12: Zero args - Return []
671
+ if (closureCallNode.args.length === 0) {
672
+ return [];
673
+ }
674
+
675
+ // EC-13: Multiple args - Return []
676
+ if (closureCallNode.args.length > 1) {
677
+ return [];
678
+ }
679
+
680
+ // EC-14: Single arg not bare $ - Return []
681
+ const singleArg = closureCallNode.args[0];
682
+ if (!isBareReference(singleArg)) {
683
+ return [];
684
+ }
685
+
686
+ // Build closure name with access chain for display
687
+ const closureName =
688
+ closureCallNode.accessChain.length > 0
689
+ ? `$${closureCallNode.name}.${closureCallNode.accessChain.join('.')}`
690
+ : `$${closureCallNode.name}`;
691
+
692
+ // Generate diagnostic for bare $ argument
693
+ return [
694
+ {
695
+ code: 'IMPLICIT_DOLLAR_CLOSURE',
696
+ message: `Prefer pipe syntax '-> ${closureName}' over explicit '${closureName}($)'`,
697
+ severity: 'info',
698
+ location: {
699
+ line: closureCallNode.span.start.line,
700
+ column: closureCallNode.span.start.column,
701
+ offset: closureCallNode.span.start.offset,
702
+ },
703
+ context: extractContextLine(
704
+ closureCallNode.span.start.line,
705
+ context.source
706
+ ),
707
+ fix: null,
708
+ },
709
+ ];
710
+ },
711
+ };
712
+
713
+ // ============================================================
714
+ // THROWAWAY_CAPTURE RULE
715
+ // ============================================================
716
+
717
+ /**
718
+ * Warns on capture-only-to-continue patterns.
719
+ * Capturing a value just to use it immediately in the next line is unnecessary.
720
+ *
721
+ * Detection:
722
+ * - Capture node followed by immediate use of that variable only
723
+ * - Variable not referenced later in the script
724
+ *
725
+ * References:
726
+ * - docs/guide-conventions.md:617-634
727
+ */
728
+ export const THROWAWAY_CAPTURE: ValidationRule = {
729
+ code: 'THROWAWAY_CAPTURE',
730
+ category: 'formatting',
731
+ severity: 'info',
732
+ nodeTypes: ['Capture'],
733
+
734
+ validate(_node: ASTNode, _context: ValidationContext): Diagnostic[] {
735
+ // [DEBT] Stubbed - Requires full script analysis across statement boundaries
736
+ // Must track: 1) All captures 2) All variable references 3) Single-use detection
737
+ return [];
738
+ },
739
+ };
740
+
741
+ // ============================================================
742
+ // HELPER FUNCTIONS
743
+ // ============================================================
744
+
745
+ /**
746
+ * Validate that a SourceSpan has valid coordinates.
747
+ * Returns false if span, start, or end are missing,
748
+ * or if line/column values are less than 1.
749
+ *
750
+ * Exported for testing purposes to enable direct unit testing
751
+ * of edge cases (null spans, invalid coordinates).
752
+ */
753
+ export function isValidSpan(span: SourceSpan | null | undefined): boolean {
754
+ if (!span) {
755
+ return false;
756
+ }
757
+ if (!span.start || !span.end) {
758
+ return false;
759
+ }
760
+ if (
761
+ span.start.line < 1 ||
762
+ span.start.column < 1 ||
763
+ span.end.line < 1 ||
764
+ span.end.column < 1
765
+ ) {
766
+ return false;
767
+ }
768
+ return true;
769
+ }
770
+
771
+ /**
772
+ * Extract text from source using span coordinates.
773
+ */
774
+ function extractSpanText(span: SourceSpan, source: string): string {
775
+ const lines = source.split('\n');
776
+
777
+ if (span.start.line === span.end.line) {
778
+ // Single line
779
+ const line = lines[span.start.line - 1];
780
+ if (!line) return '';
781
+ return line.substring(span.start.column - 1, span.end.column - 1);
782
+ }
783
+
784
+ // Multi-line
785
+ const result: string[] = [];
786
+
787
+ for (let i = span.start.line - 1; i < span.end.line; i++) {
788
+ const line = lines[i];
789
+ if (!line) continue;
790
+
791
+ if (i === span.start.line - 1) {
792
+ // First line: from start column to end
793
+ result.push(line.substring(span.start.column - 1));
794
+ } else if (i === span.end.line - 1) {
795
+ // Last line: from start to end column
796
+ result.push(line.substring(0, span.end.column - 1));
797
+ } else {
798
+ // Middle lines: full line
799
+ result.push(line);
800
+ }
801
+ }
802
+
803
+ return result.join('\n');
804
+ }
805
+
806
+ /**
807
+ * Escape special regex characters.
808
+ */
809
+ function escapeRegex(str: string): string {
810
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
811
+ }