@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,510 @@
1
+ /**
2
+ * Tests for CLI Error Enrichment Functions
3
+ * Covers: IR-4, IR-6, IR-8, EC-3, EC-4, EC-6, EC-7, EC-9, EC-10, IC-1
4
+ */
5
+
6
+ import { describe, it, expect } from 'vitest';
7
+ import {
8
+ extractSnippet,
9
+ suggestSimilarNames,
10
+ enrichError,
11
+ } from '../../src/cli-error-enrichment.js';
12
+ import type {
13
+ SourceSpan,
14
+ SourceLocation,
15
+ } from '@rcrsr/rill';
16
+ import { RuntimeError } from '@rcrsr/rill';
17
+
18
+ describe('extractSnippet', () => {
19
+ describe('IR-6: Context lines and line numbering', () => {
20
+ it('extracts 2 context lines before and after by default', () => {
21
+ const source = 'line1\nline2\nline3\nERROR\nline5\nline6\nline7';
22
+ const span: SourceSpan = {
23
+ start: { line: 4, column: 0, offset: 18 },
24
+ end: { line: 4, column: 5, offset: 23 },
25
+ };
26
+
27
+ const snippet = extractSnippet(source, span);
28
+
29
+ expect(snippet.lines).toHaveLength(5); // 2 before + error line + 2 after
30
+ expect(snippet.lines[0]).toEqual({
31
+ lineNumber: 2,
32
+ content: 'line2',
33
+ isErrorLine: false,
34
+ });
35
+ expect(snippet.lines[1]).toEqual({
36
+ lineNumber: 3,
37
+ content: 'line3',
38
+ isErrorLine: false,
39
+ });
40
+ expect(snippet.lines[2]).toEqual({
41
+ lineNumber: 4,
42
+ content: 'ERROR',
43
+ isErrorLine: true,
44
+ });
45
+ expect(snippet.lines[3]).toEqual({
46
+ lineNumber: 5,
47
+ content: 'line5',
48
+ isErrorLine: false,
49
+ });
50
+ expect(snippet.lines[4]).toEqual({
51
+ lineNumber: 6,
52
+ content: 'line6',
53
+ isErrorLine: false,
54
+ });
55
+ expect(snippet.highlightSpan).toBe(span);
56
+ });
57
+
58
+ it('handles configurable context lines', () => {
59
+ const source = 'L1\nL2\nL3\nERROR\nL5\nL6\nL7';
60
+ const span: SourceSpan = {
61
+ start: { line: 4, column: 0, offset: 9 },
62
+ end: { line: 4, column: 5, offset: 14 },
63
+ };
64
+
65
+ const snippet = extractSnippet(source, span, 1);
66
+
67
+ expect(snippet.lines).toHaveLength(3); // 1 before + error line + 1 after
68
+ expect(snippet.lines[0]?.lineNumber).toBe(3);
69
+ expect(snippet.lines[1]?.lineNumber).toBe(4);
70
+ expect(snippet.lines[2]?.lineNumber).toBe(5);
71
+ });
72
+
73
+ it('handles line 1 edge case (no negative lines)', () => {
74
+ const source = 'ERROR\nline2\nline3';
75
+ const span: SourceSpan = {
76
+ start: { line: 1, column: 0, offset: 0 },
77
+ end: { line: 1, column: 5, offset: 5 },
78
+ };
79
+
80
+ const snippet = extractSnippet(source, span);
81
+
82
+ expect(snippet.lines).toHaveLength(3); // error line + 2 after (no before)
83
+ expect(snippet.lines[0]).toEqual({
84
+ lineNumber: 1,
85
+ content: 'ERROR',
86
+ isErrorLine: true,
87
+ });
88
+ expect(snippet.lines[1]?.lineNumber).toBe(2);
89
+ expect(snippet.lines[2]?.lineNumber).toBe(3);
90
+ });
91
+
92
+ it('handles last line edge case', () => {
93
+ const source = 'line1\nline2\nline3\nERROR';
94
+ const span: SourceSpan = {
95
+ start: { line: 4, column: 0, offset: 18 },
96
+ end: { line: 4, column: 5, offset: 23 },
97
+ };
98
+
99
+ const snippet = extractSnippet(source, span);
100
+
101
+ expect(snippet.lines).toHaveLength(3); // 2 before + error line (no after)
102
+ expect(snippet.lines[0]?.lineNumber).toBe(2);
103
+ expect(snippet.lines[1]?.lineNumber).toBe(3);
104
+ expect(snippet.lines[2]).toEqual({
105
+ lineNumber: 4,
106
+ content: 'ERROR',
107
+ isErrorLine: true,
108
+ });
109
+ });
110
+
111
+ it('uses 1-based line numbers', () => {
112
+ const source = 'first\nsecond\nthird';
113
+ const span: SourceSpan = {
114
+ start: { line: 1, column: 0, offset: 0 },
115
+ end: { line: 1, column: 5, offset: 5 },
116
+ };
117
+
118
+ const snippet = extractSnippet(source, span);
119
+
120
+ expect(snippet.lines[0]?.lineNumber).toBe(1);
121
+ expect(snippet.lines[0]?.isErrorLine).toBe(true);
122
+ });
123
+ });
124
+
125
+ describe('EC-6: Span outside source bounds throws RangeError', () => {
126
+ it('throws when span line exceeds source', () => {
127
+ const source = 'line1\nline2';
128
+ const span: SourceSpan = {
129
+ start: { line: 10, column: 0, offset: 999 },
130
+ end: { line: 10, column: 5, offset: 1004 },
131
+ };
132
+
133
+ expect(() => extractSnippet(source, span)).toThrow(RangeError);
134
+ expect(() => extractSnippet(source, span)).toThrow(
135
+ 'Span exceeds source bounds'
136
+ );
137
+ });
138
+
139
+ it('throws when span line is 0', () => {
140
+ const source = 'line1\nline2';
141
+ const span: SourceSpan = {
142
+ start: { line: 0, column: 0, offset: 0 },
143
+ end: { line: 0, column: 5, offset: 5 },
144
+ };
145
+
146
+ expect(() => extractSnippet(source, span)).toThrow(RangeError);
147
+ expect(() => extractSnippet(source, span)).toThrow(
148
+ 'Span exceeds source bounds'
149
+ );
150
+ });
151
+ });
152
+
153
+ describe('EC-7: Empty source returns empty snippet', () => {
154
+ it('returns empty snippet for empty source', () => {
155
+ const source = '';
156
+ const span: SourceSpan = {
157
+ start: { line: 1, column: 0, offset: 0 },
158
+ end: { line: 1, column: 0, offset: 0 },
159
+ };
160
+
161
+ const snippet = extractSnippet(source, span);
162
+
163
+ expect(snippet.lines).toEqual([]);
164
+ expect(snippet.highlightSpan).toBe(span);
165
+ });
166
+ });
167
+
168
+ describe('IC-1: Multi-line span handling', () => {
169
+ it('handles multi-line error spans', () => {
170
+ const source = 'line1\nline2\nERROR_START\nERROR_END\nline5';
171
+ const span: SourceSpan = {
172
+ start: { line: 3, column: 0, offset: 12 },
173
+ end: { line: 4, column: 9, offset: 33 },
174
+ };
175
+
176
+ const snippet = extractSnippet(source, span);
177
+
178
+ // Should mark both lines as error lines
179
+ const errorLines = snippet.lines.filter((l) => l.isErrorLine);
180
+ expect(errorLines).toHaveLength(2);
181
+ expect(errorLines[0]?.lineNumber).toBe(3);
182
+ expect(errorLines[1]?.lineNumber).toBe(4);
183
+ });
184
+ });
185
+ });
186
+
187
+ describe('suggestSimilarNames', () => {
188
+ describe('IR-8: Edit distance and suggestion limits', () => {
189
+ it('returns names with edit distance <= 2', () => {
190
+ const target = 'test';
191
+ const candidates = [
192
+ 'test', // distance 0
193
+ 'tost', // distance 1
194
+ 'tast', // distance 1
195
+ 'toast', // distance 2
196
+ 'testing', // distance 3 (excluded)
197
+ 'best', // distance 1
198
+ ];
199
+
200
+ const suggestions = suggestSimilarNames(target, candidates);
201
+
202
+ // Max 3 suggestions, all must be within distance 2
203
+ expect(suggestions.length).toBeLessThanOrEqual(3);
204
+ for (const suggestion of suggestions) {
205
+ expect(candidates).toContain(suggestion);
206
+ }
207
+ expect(suggestions).not.toContain('testing'); // distance 3 excluded
208
+ expect(suggestions[0]).toBe('test'); // exact match first
209
+ });
210
+
211
+ it('returns maximum 3 suggestions', () => {
212
+ const target = 'x';
213
+ const candidates = ['a', 'b', 'c', 'd', 'e']; // All have distance 1
214
+
215
+ const suggestions = suggestSimilarNames(target, candidates);
216
+
217
+ expect(suggestions).toHaveLength(3);
218
+ });
219
+
220
+ it('sorts by ascending distance, then alphabetically', () => {
221
+ const target = 'test';
222
+ const candidates = [
223
+ 'zest', // distance 1
224
+ 'best', // distance 1
225
+ 'toast', // distance 2
226
+ 'roast', // distance 2
227
+ 'test', // distance 0
228
+ ];
229
+
230
+ const suggestions = suggestSimilarNames(target, candidates);
231
+
232
+ expect(suggestions[0]).toBe('test'); // distance 0
233
+ expect(suggestions[1]).toBe('best'); // distance 1, alphabetically before 'zest'
234
+ expect(suggestions[2]).toBe('zest'); // distance 1
235
+ // Only 3 suggestions, so 'toast' and 'roast' excluded
236
+ });
237
+
238
+ it('prioritizes exact matches', () => {
239
+ const target = 'value';
240
+ const candidates = ['value', 'values', 'valve'];
241
+
242
+ const suggestions = suggestSimilarNames(target, candidates);
243
+
244
+ expect(suggestions[0]).toBe('value'); // exact match first
245
+ });
246
+ });
247
+
248
+ describe('EC-9: Empty target returns []', () => {
249
+ it('returns empty array for empty target', () => {
250
+ const suggestions = suggestSimilarNames('', ['test', 'value']);
251
+
252
+ expect(suggestions).toEqual([]);
253
+ });
254
+ });
255
+
256
+ describe('EC-10: Empty candidates returns []', () => {
257
+ it('returns empty array for empty candidates', () => {
258
+ const suggestions = suggestSimilarNames('test', []);
259
+
260
+ expect(suggestions).toEqual([]);
261
+ });
262
+ });
263
+
264
+ describe('AC-10: Undefined $valeu with $value in scope suggests correction', () => {
265
+ it('suggests $value when $valeu is undefined (typo scenario)', () => {
266
+ const target = 'valeu'; // Typo: missing 'e'
267
+ const candidates = ['value', 'values', 'valid'];
268
+
269
+ const suggestions = suggestSimilarNames(target, candidates);
270
+
271
+ expect(suggestions.length).toBeGreaterThan(0);
272
+ expect(suggestions).toContain('value'); // Edit distance 1 (e->u substitution)
273
+ // This simulates: "Variable $valeu is not defined. Did you mean $value?"
274
+ });
275
+ });
276
+
277
+ describe('AC-11: Dict key error shows Available keys', () => {
278
+ it('provides list of available keys for dict key errors', () => {
279
+ // This test demonstrates the pattern for dict key suggestions
280
+ const attemptedKey = 'nam'; // Typo: should be 'name'
281
+ const availableKeys = ['name', 'age', 'email'];
282
+
283
+ const suggestions = suggestSimilarNames(attemptedKey, availableKeys);
284
+
285
+ expect(suggestions.length).toBeGreaterThan(0);
286
+ expect(suggestions).toContain('name'); // Edit distance 1
287
+ // This simulates: "Key 'nam' not found. Available keys: name, age, email"
288
+ });
289
+
290
+ it('formats available keys as comma-separated list', () => {
291
+ const availableKeys = ['a', 'b', 'c'];
292
+ const formattedKeys = availableKeys.join(', ');
293
+
294
+ expect(formattedKeys).toBe('a, b, c');
295
+ // This demonstrates the format: "Available keys: a, b, c"
296
+ });
297
+ });
298
+
299
+ describe('Edge cases', () => {
300
+ it('handles single character targets', () => {
301
+ const suggestions = suggestSimilarNames('x', ['x', 'y', 'xy', 'xyz']);
302
+
303
+ expect(suggestions.length).toBeLessThanOrEqual(3);
304
+ expect(suggestions).toContain('x'); // exact match
305
+ // 'y' has distance 1, 'xy' has distance 1, 'xyz' has distance 2
306
+ });
307
+
308
+ it('excludes names with distance > 2', () => {
309
+ const target = 'cat';
310
+ const candidates = ['cat', 'bat', 'hat', 'chat', 'chats', 'elephant'];
311
+
312
+ const suggestions = suggestSimilarNames(target, candidates);
313
+
314
+ expect(suggestions.length).toBeLessThanOrEqual(3);
315
+ expect(suggestions[0]).toBe('cat'); // exact match first
316
+ expect(suggestions).not.toContain('chats'); // distance 3 (c->c, h->a, a->t, t->s, +s = 3)
317
+ expect(suggestions).not.toContain('elephant'); // distance > 2
318
+ // Remaining should be within distance 2
319
+ });
320
+ });
321
+ });
322
+
323
+ describe('AC-13/EC-3: Invalid UTF-8 source handling', () => {
324
+ it('handles invalid UTF-8 gracefully in extractSnippet', () => {
325
+ // JavaScript strings are always valid Unicode/UTF-16
326
+ // Invalid UTF-8 bytes become replacement characters (�) when decoded
327
+ // This test demonstrates behavior with malformed Unicode
328
+ const invalidSource = 'line1\nline2\u{FFFD}\nline3'; // U+FFFD is replacement character
329
+ const span: SourceSpan = {
330
+ start: { line: 2, column: 0, offset: 6 },
331
+ end: { line: 2, column: 5, offset: 11 },
332
+ };
333
+
334
+ // Should not throw TypeError - extractSnippet handles strings
335
+ const snippet = extractSnippet(invalidSource, span);
336
+
337
+ expect(snippet.lines).toHaveLength(3);
338
+ expect(snippet.lines[1]?.content).toContain('\u{FFFD}');
339
+ });
340
+
341
+ it('validates source is a string', () => {
342
+ const span: SourceSpan = {
343
+ start: { line: 1, column: 0, offset: 0 },
344
+ end: { line: 1, column: 5, offset: 5 },
345
+ };
346
+
347
+ // TypeScript prevents this, but at runtime source must be a string
348
+ expect(() => extractSnippet(123 as unknown as string, span)).toThrow(
349
+ TypeError
350
+ );
351
+ });
352
+ });
353
+
354
+ describe('enrichError', () => {
355
+ describe('IR-4: enrichError returns EnrichedError with all fields populated', () => {
356
+ it('enriches error with source snippet', () => {
357
+ const source = 'line1\nline2\nerror line\nline4\nline5';
358
+ const location: SourceLocation = { line: 3, column: 0, offset: 12 };
359
+ const error = new RuntimeError('RILL-R001', 'Test error', location, {
360
+ key: 'value',
361
+ });
362
+
363
+ const enriched = enrichError(error, source);
364
+
365
+ expect(enriched.errorId).toBe('RILL-R001');
366
+ expect(enriched.message).toBe('Test error');
367
+ expect(enriched.span).toEqual({
368
+ start: location,
369
+ end: location,
370
+ });
371
+ expect(enriched.context).toEqual({ key: 'value' });
372
+ expect(enriched.sourceSnippet).toBeDefined();
373
+ expect(enriched.sourceSnippet?.lines).toHaveLength(5);
374
+ expect(enriched.sourceSnippet?.lines[2]?.content).toBe('error line');
375
+ expect(enriched.sourceSnippet?.lines[2]?.isErrorLine).toBe(true);
376
+ });
377
+
378
+ it('enriches error with suggestions when scope provided', () => {
379
+ const source = 'line1\nline2';
380
+ const location: SourceLocation = { line: 1, column: 0, offset: 0 };
381
+ const error = new RuntimeError(
382
+ 'RILL-R001',
383
+ 'Variable not defined',
384
+ location,
385
+ {
386
+ name: 'valeu',
387
+ }
388
+ );
389
+
390
+ const enriched = enrichError(error, source, {
391
+ variableNames: ['value', 'valid'],
392
+ functionNames: ['test'],
393
+ });
394
+
395
+ expect(enriched.suggestions).toBeDefined();
396
+ expect(enriched.suggestions).toContain('value');
397
+ });
398
+
399
+ it('includes helpUrl when present in error', () => {
400
+ const source = 'test';
401
+ const location: SourceLocation = { line: 1, column: 0, offset: 0 };
402
+ const error = new RuntimeError('RILL-R001', 'Test error', location);
403
+ // Set helpUrl via error data
404
+ Object.defineProperty(error, 'helpUrl', {
405
+ value: 'https://example.com/help',
406
+ });
407
+
408
+ const enriched = enrichError(error, source);
409
+
410
+ expect(enriched.helpUrl).toBe('https://example.com/help');
411
+ });
412
+
413
+ it('handles error without location', () => {
414
+ const source = 'test source';
415
+ const error = new RuntimeError('RILL-R001', 'Test error');
416
+
417
+ const enriched = enrichError(error, source);
418
+
419
+ expect(enriched.errorId).toBe('RILL-R001');
420
+ expect(enriched.message).toBe('Test error');
421
+ expect(enriched.span).toBeUndefined();
422
+ expect(enriched.sourceSnippet).toBeUndefined();
423
+ });
424
+
425
+ it('handles empty source', () => {
426
+ const source = '';
427
+ const location: SourceLocation = { line: 1, column: 0, offset: 0 };
428
+ const error = new RuntimeError('RILL-R001', 'Test error', location);
429
+
430
+ const enriched = enrichError(error, source);
431
+
432
+ expect(enriched.errorId).toBe('RILL-R001');
433
+ expect(enriched.sourceSnippet).toBeUndefined();
434
+ });
435
+
436
+ it('strips location suffix from message', () => {
437
+ const source = 'test';
438
+ const location: SourceLocation = { line: 1, column: 5, offset: 5 };
439
+ // RuntimeError constructor adds " at 1:5" automatically when location is provided
440
+ const error = new RuntimeError('RILL-R001', 'Test error', location);
441
+
442
+ const enriched = enrichError(error, source);
443
+
444
+ // enrichError strips the location suffix that was added by constructor
445
+ expect(enriched.message).toBe('Test error');
446
+ });
447
+ });
448
+
449
+ describe('EC-3: Invalid source encoding', () => {
450
+ it('throws TypeError when source is not a string', () => {
451
+ const location: SourceLocation = { line: 1, column: 0, offset: 0 };
452
+ const error = new RuntimeError('RILL-R001', 'Test error', location);
453
+
454
+ expect(() => enrichError(error, 123 as unknown as string)).toThrow(
455
+ TypeError
456
+ );
457
+ expect(() => enrichError(error, 123 as unknown as string)).toThrow(
458
+ 'Source must be valid UTF-8'
459
+ );
460
+ });
461
+
462
+ it('throws TypeError when source is null', () => {
463
+ const location: SourceLocation = { line: 1, column: 0, offset: 0 };
464
+ const error = new RuntimeError('RILL-R001', 'Test error', location);
465
+
466
+ expect(() => enrichError(error, null as unknown as string)).toThrow(
467
+ TypeError
468
+ );
469
+ expect(() => enrichError(error, null as unknown as string)).toThrow(
470
+ 'Source must be valid UTF-8'
471
+ );
472
+ });
473
+
474
+ it('throws TypeError when source is undefined', () => {
475
+ const location: SourceLocation = { line: 1, column: 0, offset: 0 };
476
+ const error = new RuntimeError('RILL-R001', 'Test error', location);
477
+
478
+ expect(() => enrichError(error, undefined as unknown as string)).toThrow(
479
+ TypeError
480
+ );
481
+ expect(() => enrichError(error, undefined as unknown as string)).toThrow(
482
+ 'Source must be valid UTF-8'
483
+ );
484
+ });
485
+ });
486
+
487
+ describe('EC-4: Null error', () => {
488
+ it('throws TypeError when error is null', () => {
489
+ const source = 'test source';
490
+
491
+ expect(() =>
492
+ enrichError(null as unknown as RuntimeError, source)
493
+ ).toThrow(TypeError);
494
+ expect(() =>
495
+ enrichError(null as unknown as RuntimeError, source)
496
+ ).toThrow('Error is required');
497
+ });
498
+
499
+ it('throws TypeError when error is undefined', () => {
500
+ const source = 'test source';
501
+
502
+ expect(() =>
503
+ enrichError(undefined as unknown as RuntimeError, source)
504
+ ).toThrow(TypeError);
505
+ expect(() =>
506
+ enrichError(undefined as unknown as RuntimeError, source)
507
+ ).toThrow('Error is required');
508
+ });
509
+ });
510
+ });