@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,801 @@
1
+ /**
2
+ * Rill CLI Tests: rill-check command
3
+ *
4
+ * Test Coverage Matrix (maps test cases to specification requirements):
5
+ * AC-S1: Validate file with diagnostics
6
+ * AC-S2: Apply fixes with --fix
7
+ * AC-S3: JSON output format
8
+ * AC-S4: Verbose mode output
9
+ * AC-S5: --version flag
10
+ * AC-S6: --help flag
11
+ * AC-S7: Config override
12
+ * AC-E1: File not found (exit 2)
13
+ * AC-E2: Parse error (exit 3)
14
+ * AC-E3: Parse error + --fix message
15
+ * AC-E4: Unknown flag error
16
+ * AC-E5: Invalid config error
17
+ * AC-B1: Empty file (no diagnostics)
18
+ * AC-B2: Parse-only errors
19
+ * AC-B5: (removed - 10K line perf test unnecessary for draft language)
20
+ */
21
+
22
+ import { describe, expect, it, beforeAll, afterAll, afterEach } from 'vitest';
23
+ import { parseCheckArgs, formatDiagnostics } from '../../src/cli-check.js';
24
+ import { ParseError } from '@rcrsr/rill';
25
+ import {
26
+ type Diagnostic,
27
+ validateScript,
28
+ loadConfig,
29
+ createDefaultConfig,
30
+ applyFixes,
31
+ } from '../../src/check/index.js';
32
+ import { parse } from '@rcrsr/rill';
33
+ import * as fs from 'fs/promises';
34
+ import * as fssync from 'fs';
35
+ import * as path from 'path';
36
+ import * as os from 'os';
37
+ import { spawn } from 'child_process';
38
+
39
+ describe('rill-check CLI', () => {
40
+ let tempDir: string;
41
+
42
+ beforeAll(async () => {
43
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'rill-check-test-'));
44
+ });
45
+
46
+ afterAll(async () => {
47
+ await fs.rm(tempDir, { recursive: true, force: true });
48
+ });
49
+
50
+ // Clean up config file after each test to prevent pollution between tests
51
+ afterEach(async () => {
52
+ const configPath = path.join(tempDir, '.rill-check.json');
53
+ try {
54
+ await fs.unlink(configPath);
55
+ } catch {
56
+ // Ignore if file doesn't exist
57
+ }
58
+ });
59
+
60
+ /**
61
+ * Write a file to the temp directory and return its path.
62
+ */
63
+ async function writeFile(name: string, content: string): Promise<string> {
64
+ const filePath = path.join(tempDir, name);
65
+ await fs.writeFile(filePath, content, 'utf-8');
66
+ return filePath;
67
+ }
68
+
69
+ /**
70
+ * Validate a script file in-process (without spawning CLI).
71
+ * Returns diagnostics array.
72
+ */
73
+ function validateFile(filePath: string): Diagnostic[] {
74
+ const source = fssync.readFileSync(filePath, 'utf-8');
75
+ const ast = parse(source);
76
+ const config = loadConfig(path.dirname(filePath)) ?? createDefaultConfig();
77
+ return validateScript(ast, source, config);
78
+ }
79
+
80
+ /**
81
+ * Apply fixes to a script file in-process (without spawning CLI).
82
+ * Returns number of fixes applied.
83
+ */
84
+ function applyFixesToFile(filePath: string): number {
85
+ const source = fssync.readFileSync(filePath, 'utf-8');
86
+ const ast = parse(source);
87
+ const config = loadConfig(path.dirname(filePath)) ?? createDefaultConfig();
88
+ const diagnostics = validateScript(ast, source, config);
89
+
90
+ if (diagnostics.length === 0) {
91
+ return 0;
92
+ }
93
+
94
+ const result = applyFixes(source, diagnostics, {
95
+ source,
96
+ ast,
97
+ config,
98
+ diagnostics: [],
99
+ variables: new Map(),
100
+ });
101
+
102
+ if (result.applied > 0) {
103
+ fssync.writeFileSync(filePath, result.modified, 'utf-8');
104
+ }
105
+
106
+ return result.applied;
107
+ }
108
+
109
+ /**
110
+ * Execute rill-check CLI command and capture output.
111
+ * Returns exit code, stdout, and stderr.
112
+ */
113
+ async function execCheck(
114
+ args: string[]
115
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
116
+ return new Promise((resolve) => {
117
+ const checkPath = path.join(process.cwd(), 'dist', 'cli-check.js');
118
+ const env = { ...process.env };
119
+ delete env['VITEST'];
120
+ delete env['VITEST_WORKER_ID'];
121
+ delete env['NODE_ENV'];
122
+ const proc = spawn('node', [checkPath, ...args], {
123
+ cwd: tempDir,
124
+ env,
125
+ });
126
+
127
+ let stdout = '';
128
+ let stderr = '';
129
+
130
+ proc.stdout.on('data', (data) => {
131
+ stdout += data.toString();
132
+ });
133
+
134
+ proc.stderr.on('data', (data) => {
135
+ stderr += data.toString();
136
+ });
137
+
138
+ proc.on('close', (code) => {
139
+ resolve({
140
+ exitCode: code ?? 0,
141
+ stdout,
142
+ stderr,
143
+ });
144
+ });
145
+ });
146
+ }
147
+
148
+ // ============================================================
149
+ // ARGUMENT PARSING
150
+ // ============================================================
151
+
152
+ describe('parseCheckArgs', () => {
153
+ it('parses file path', () => {
154
+ const parsed = parseCheckArgs(['test.rill']);
155
+ expect(parsed).toEqual({
156
+ mode: 'check',
157
+ file: 'test.rill',
158
+ fix: false,
159
+ verbose: false,
160
+ format: 'text',
161
+ });
162
+ });
163
+
164
+ it('parses --help flag [AC-S6]', () => {
165
+ expect(parseCheckArgs(['--help'])).toEqual({ mode: 'help' });
166
+ expect(parseCheckArgs(['-h'])).toEqual({ mode: 'help' });
167
+ });
168
+
169
+ it('parses --version flag [AC-S5]', () => {
170
+ expect(parseCheckArgs(['--version'])).toEqual({ mode: 'version' });
171
+ expect(parseCheckArgs(['-v'])).toEqual({ mode: 'version' });
172
+ });
173
+
174
+ it('parses --fix flag [AC-S2]', () => {
175
+ const parsed = parseCheckArgs(['test.rill', '--fix']);
176
+ expect(parsed.mode).toBe('check');
177
+ if (parsed.mode === 'check') {
178
+ expect(parsed.fix).toBe(true);
179
+ }
180
+ });
181
+
182
+ it('parses --verbose flag [AC-S4]', () => {
183
+ const parsed = parseCheckArgs(['test.rill', '--verbose']);
184
+ expect(parsed.mode).toBe('check');
185
+ if (parsed.mode === 'check') {
186
+ expect(parsed.verbose).toBe(true);
187
+ }
188
+ });
189
+
190
+ it('parses --format json [AC-S3]', () => {
191
+ const parsed = parseCheckArgs(['test.rill', '--format', 'json']);
192
+ expect(parsed.mode).toBe('check');
193
+ if (parsed.mode === 'check') {
194
+ expect(parsed.format).toBe('json');
195
+ }
196
+ });
197
+
198
+ it('throws on unknown flag [AC-E4]', () => {
199
+ expect(() => parseCheckArgs(['--unknown'])).toThrow(
200
+ 'Unknown option: --unknown'
201
+ );
202
+ expect(() => parseCheckArgs(['-x'])).toThrow('Unknown option: -x');
203
+ });
204
+
205
+ it('throws when missing file argument', () => {
206
+ expect(() => parseCheckArgs([])).toThrow('Missing file argument');
207
+ expect(() => parseCheckArgs(['--fix'])).toThrow('Missing file argument');
208
+ });
209
+ });
210
+
211
+ // ============================================================
212
+ // DIAGNOSTIC FORMATTING
213
+ // ============================================================
214
+
215
+ describe('formatDiagnostics', () => {
216
+ it('formats text output', () => {
217
+ const diagnostics: Diagnostic[] = [
218
+ {
219
+ location: { line: 5, column: 10, offset: 50 },
220
+ severity: 'error',
221
+ code: 'TEST_ERROR',
222
+ message: 'Test error message',
223
+ context: '"hello"',
224
+ fix: null,
225
+ },
226
+ ];
227
+
228
+ const output = formatDiagnostics('test.rill', diagnostics, 'text', false);
229
+ expect(output).toBe(
230
+ 'test.rill:5:10: error: Test error message (TEST_ERROR)'
231
+ );
232
+ });
233
+
234
+ it('formats JSON output [AC-S3]', () => {
235
+ const diagnostics: Diagnostic[] = [
236
+ {
237
+ location: { line: 5, column: 10, offset: 50 },
238
+ severity: 'error',
239
+ code: 'TEST_ERROR',
240
+ message: 'Test error message',
241
+ context: '"hello"',
242
+ fix: null,
243
+ },
244
+ ];
245
+
246
+ const output = formatDiagnostics('test.rill', diagnostics, 'json', false);
247
+ const parsed = JSON.parse(output);
248
+
249
+ expect(parsed).toEqual({
250
+ file: 'test.rill',
251
+ errors: [
252
+ {
253
+ location: { line: 5, column: 10, offset: 50 },
254
+ severity: 'error',
255
+ code: 'TEST_ERROR',
256
+ message: 'Test error message',
257
+ context: '"hello"',
258
+ },
259
+ ],
260
+ summary: { total: 1, errors: 1, warnings: 0, info: 0 },
261
+ });
262
+ });
263
+
264
+ it('formats empty diagnostics as empty array', () => {
265
+ const output = formatDiagnostics('test.rill', [], 'text', false);
266
+ expect(output).toBe('');
267
+ });
268
+ });
269
+
270
+ // ============================================================
271
+ // SUCCESS CASES
272
+ // ============================================================
273
+
274
+ describe('success cases', () => {
275
+ it('validates file with no diagnostics [AC-B1]', async () => {
276
+ const script = await writeFile('valid.rill', '"hello"');
277
+ const diagnostics = validateFile(script);
278
+
279
+ expect(diagnostics).toEqual([]);
280
+ });
281
+
282
+ it('validates empty file [AC-B1]', async () => {
283
+ const script = await writeFile('empty.rill', '');
284
+ const diagnostics = validateFile(script);
285
+
286
+ expect(diagnostics).toEqual([]);
287
+ });
288
+
289
+ it('outputs JSON format when no diagnostics [AC-S3]', async () => {
290
+ const script = await writeFile('valid-json.rill', '"hello"');
291
+ const diagnostics = validateFile(script);
292
+
293
+ expect(diagnostics).toEqual([]);
294
+
295
+ const output = formatDiagnostics(script, diagnostics, 'json', false);
296
+ const parsed = JSON.parse(output);
297
+ expect(parsed.file).toBe(script);
298
+ expect(parsed.errors).toEqual([]);
299
+ expect(parsed.summary).toEqual({
300
+ total: 0,
301
+ errors: 0,
302
+ warnings: 0,
303
+ info: 0,
304
+ });
305
+ });
306
+
307
+ it('shows help message [AC-S6]', async () => {
308
+ const result = await execCheck(['--help']);
309
+
310
+ expect(result.exitCode).toBe(0);
311
+ expect(result.stdout).toContain('rill-check');
312
+ expect(result.stdout).toContain('Usage:');
313
+ expect(result.stdout).toContain('--fix');
314
+ expect(result.stdout).toContain('--format');
315
+ expect(result.stdout).toContain('--verbose');
316
+ });
317
+
318
+ it('shows version number [AC-S5]', async () => {
319
+ const result = await execCheck(['--version']);
320
+
321
+ expect(result.exitCode).toBe(0);
322
+ expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/);
323
+
324
+ // Verify version matches package.json
325
+ const { readFile } = await import('fs/promises');
326
+ const packageJsonPath = path.resolve(process.cwd(), 'package.json');
327
+ const packageJson = JSON.parse(
328
+ await readFile(packageJsonPath, 'utf-8')
329
+ ) as { version: string };
330
+ expect(result.stdout.trim()).toBe(packageJson.version);
331
+ });
332
+ });
333
+
334
+ // ============================================================
335
+ // ERROR CASES
336
+ // ============================================================
337
+
338
+ describe('error cases', () => {
339
+ it('exits with code 2 for file not found [AC-E1]', async () => {
340
+ const result = await execCheck(['/nonexistent/file.rill']);
341
+
342
+ expect(result.exitCode).toBe(2);
343
+ expect(result.stderr).toContain('File not found');
344
+ });
345
+
346
+ it('exits with code 2 for directory path [AC-E1]', async () => {
347
+ const result = await execCheck([tempDir]);
348
+
349
+ expect(result.exitCode).toBe(2);
350
+ expect(result.stderr).toContain('directory');
351
+ });
352
+
353
+ it('exits with code 3 for parse error [AC-E2]', async () => {
354
+ const script = await writeFile('parse-error.rill', '|x| x }');
355
+ const result = await execCheck([script]);
356
+
357
+ expect(result.exitCode).toBe(3);
358
+ // Parse errors now reported as diagnostics to stdout
359
+ expect(result.stdout).toContain('parse-error');
360
+ expect(result.stdout).toContain('error:');
361
+ });
362
+
363
+ it('reports parse error with location [AC-B2]', async () => {
364
+ const script = await writeFile('parse-location.rill', 'invalid {');
365
+
366
+ expect(() => {
367
+ const source = fssync.readFileSync(script, 'utf-8');
368
+ parse(source);
369
+ }).toThrow(ParseError);
370
+
371
+ try {
372
+ const source = fssync.readFileSync(script, 'utf-8');
373
+ parse(source);
374
+ } catch (err) {
375
+ expect(err).toBeInstanceOf(ParseError);
376
+ if (err instanceof ParseError) {
377
+ expect(err.location).toBeDefined();
378
+ expect(err.location?.line).toBeGreaterThan(0);
379
+ expect(err.location?.column).toBeGreaterThan(0);
380
+ }
381
+ }
382
+ });
383
+
384
+ it('reports cannot apply fixes on parse error [AC-E3]', async () => {
385
+ const script = await writeFile('parse-fix.rill', '|x| x }');
386
+
387
+ expect(() => {
388
+ const source = fssync.readFileSync(script, 'utf-8');
389
+ parse(source);
390
+ }).toThrow(ParseError);
391
+ });
392
+
393
+ it('reports lexer errors as diagnostics instead of crashing', async () => {
394
+ // Single quote character is invalid in rill (causes LexerError)
395
+ const script = await writeFile('lex-error.rill', "test' invalid");
396
+ const result = await execCheck([script]);
397
+
398
+ // Should exit with code 3 (parse error) not crash
399
+ expect(result.exitCode).toBe(3);
400
+ // Should show diagnostic output, not an unhandled exception
401
+ expect(result.stdout).toContain('parse-error');
402
+ expect(result.stdout).toContain('lex-error.rill');
403
+ });
404
+
405
+ it('exits with code 1 for unknown flag [AC-E4]', async () => {
406
+ expect(() => parseCheckArgs(['--unknown'])).toThrow('Unknown option');
407
+ });
408
+
409
+ it('exits with code 1 for invalid config [AC-E5]', async () => {
410
+ // Write invalid config file in temp directory
411
+ await writeFile('.rill-check.json', '{ invalid json }');
412
+
413
+ expect(() => loadConfig(tempDir)).toThrow(/invalid JSON/i);
414
+ });
415
+ });
416
+
417
+ // ============================================================
418
+ // CONFIG OVERRIDE
419
+ // ============================================================
420
+
421
+ describe('config override [AC-S7]', () => {
422
+ it('loads config from working directory', async () => {
423
+ // Write valid empty config
424
+ await writeFile('.rill-check.json', JSON.stringify({ rules: {} }));
425
+ const script = await writeFile('config-test.rill', '"hello"');
426
+
427
+ const config = loadConfig(tempDir);
428
+ expect(config).toBeDefined();
429
+ expect(config?.rules).toBeDefined();
430
+
431
+ const diagnostics = validateFile(script);
432
+ expect(diagnostics).toEqual([]);
433
+ });
434
+
435
+ it('uses default config when no config file present', async () => {
436
+ // Create subdirectory without config
437
+ const subdir = path.join(tempDir, 'no-config');
438
+ await fs.mkdir(subdir, { recursive: true });
439
+ const script = path.join(subdir, 'test.rill');
440
+ await fs.writeFile(script, '"hello"', 'utf-8');
441
+
442
+ const config = loadConfig(subdir);
443
+ expect(config).toBeNull();
444
+
445
+ const defaultConfig = createDefaultConfig();
446
+ expect(defaultConfig).toBeDefined();
447
+ expect(defaultConfig.rules).toBeDefined();
448
+ });
449
+ });
450
+
451
+ // ============================================================
452
+ // FIX APPLICATION
453
+ // ============================================================
454
+
455
+ describe('fix application [AC-S2]', () => {
456
+ it('applies fixes when --fix flag present', async () => {
457
+ // Note: Since no validation rules exist yet, we can't test actual fix application
458
+ // This test verifies the --fix flag is processed without error
459
+ const script = await writeFile('fix-test.rill', '"hello"');
460
+ const applied = applyFixesToFile(script);
461
+
462
+ // Should complete successfully with no fixes
463
+ expect(applied).toBe(0);
464
+ });
465
+
466
+ it('reports applied fix count to stderr', async () => {
467
+ // When validation rules are added, this test should verify fix count reporting
468
+ const script = await writeFile('fix-count.rill', '"hello"');
469
+ const applied = applyFixesToFile(script);
470
+
471
+ // Should not apply fixes when none needed
472
+ expect(applied).toBe(0);
473
+ });
474
+ });
475
+
476
+ // ============================================================
477
+ // VERBOSE MODE
478
+ // ============================================================
479
+
480
+ describe('verbose mode [AC-S4]', () => {
481
+ it('includes category in JSON output when verbose', () => {
482
+ // Test the formatDiagnostics function directly with verbose flag
483
+ const diagnostics: Diagnostic[] = [
484
+ {
485
+ location: { line: 1, column: 1, offset: 0 },
486
+ severity: 'warning',
487
+ code: 'TEST_WARN',
488
+ message: 'Test warning',
489
+ context: 'test',
490
+ fix: null,
491
+ },
492
+ ];
493
+
494
+ const output = formatDiagnostics('test.rill', diagnostics, 'json', true);
495
+ const parsed = JSON.parse(output);
496
+
497
+ // Verbose mode adds category field (when rule exists in VALIDATION_RULES)
498
+ expect(parsed.errors[0]).toHaveProperty('severity');
499
+ expect(parsed.errors[0]).toHaveProperty('code');
500
+ expect(parsed.errors[0]).toHaveProperty('message');
501
+ });
502
+
503
+ it('CLI accepts --verbose flag without error', () => {
504
+ const args = parseCheckArgs(['test.rill', '--verbose']);
505
+ expect(args.mode).toBe('check');
506
+ if (args.mode === 'check') {
507
+ expect(args.verbose).toBe(true);
508
+ }
509
+ });
510
+ });
511
+
512
+ // ============================================================
513
+ // OUTPUT FORMAT
514
+ // ============================================================
515
+
516
+ describe('output format', () => {
517
+ it('outputs text format by default', async () => {
518
+ const script = await writeFile('format-default.rill', '"hello"');
519
+ const diagnostics = validateFile(script);
520
+
521
+ const output = formatDiagnostics(script, diagnostics, 'text', false);
522
+ expect(output).toBe('');
523
+
524
+ // Text format outputs empty string when no diagnostics
525
+ expect(diagnostics).toEqual([]);
526
+ });
527
+
528
+ it('outputs JSON when --format json specified [AC-S3]', async () => {
529
+ const script = await writeFile('format-json.rill', '"hello"');
530
+ const diagnostics = validateFile(script);
531
+
532
+ const output = formatDiagnostics(script, diagnostics, 'json', false);
533
+ // Should be valid JSON
534
+ const parsed = JSON.parse(output);
535
+ expect(parsed).toHaveProperty('file');
536
+ expect(parsed).toHaveProperty('errors');
537
+ expect(parsed).toHaveProperty('summary');
538
+ });
539
+ });
540
+
541
+ // ============================================================
542
+ // BOUNDARY TESTS
543
+ // ============================================================
544
+
545
+ describe('boundary tests', () => {
546
+ it('fix idempotency: second run applies zero fixes [AC-B3]', async () => {
547
+ // Create file with multiple naming violations
548
+ const content = `
549
+ "userName" => $userName
550
+ "itemList" => $itemList
551
+ $userName -> .len
552
+ $itemList -> .len
553
+ `;
554
+ const script = await writeFile('idempotent.rill', content);
555
+
556
+ // Get initial diagnostics
557
+ const firstDiagnostics = validateFile(script);
558
+ const hasViolations = firstDiagnostics.length > 0;
559
+
560
+ if (hasViolations) {
561
+ // Apply fixes first time
562
+ const firstApplied = applyFixesToFile(script);
563
+ expect(firstApplied).toBeGreaterThan(0);
564
+
565
+ // Apply fixes second time (should be no-op because file was modified)
566
+ const secondApplied = applyFixesToFile(script);
567
+ expect(secondApplied).toBe(0);
568
+
569
+ const finalDiagnostics = validateFile(script);
570
+ expect(finalDiagnostics).toEqual([]);
571
+ }
572
+ });
573
+
574
+ it('1000-line validation completes in reasonable time [AC-B4]', async () => {
575
+ // Generate 1000 lines of valid rill code
576
+ const lines: string[] = [];
577
+ for (let i = 0; i < 1000; i++) {
578
+ lines.push(`"line_${i}" => $line_${i}`);
579
+ }
580
+ const content = lines.join('\n');
581
+ const script = await writeFile('perf-1000.rill', content);
582
+
583
+ const startTime = Date.now();
584
+ const diagnostics = validateFile(script);
585
+ const duration = Date.now() - startTime;
586
+
587
+ expect(diagnostics).toEqual([]);
588
+ expect(duration).toBeLessThan(2000);
589
+ });
590
+
591
+ it('all rules enabled by default [AC-B6]', async () => {
592
+ // Import config and rules modules to verify defaults
593
+ const { createDefaultConfig } = await import('../../src/check/config.js');
594
+ const { VALIDATION_RULES } =
595
+ await import('../../src/check/rules/index.js');
596
+
597
+ const config = createDefaultConfig();
598
+
599
+ // Verify all rules in VALIDATION_RULES are enabled by default
600
+ const totalRules = VALIDATION_RULES.length;
601
+ const enabledCount = Object.values(config.rules).filter(
602
+ (state) => state === 'on'
603
+ ).length;
604
+
605
+ expect(enabledCount).toBe(totalRules);
606
+ expect(totalRules).toBeGreaterThanOrEqual(20);
607
+ });
608
+ });
609
+
610
+ // ============================================================
611
+ // ERROR HANDLING
612
+ // ============================================================
613
+
614
+ describe('error handling', () => {
615
+ it('applies fixes for multiple violations [AC-E6]', async () => {
616
+ // Note: Fix collision handling (EC-5) is tested in tests/check/fixer.test.ts
617
+ // This test verifies that non-colliding fixes are successfully applied
618
+ const content = `
619
+ [userName: "test"] => $data1
620
+ [itemList: [1, 2, 3]] => $data2
621
+ `;
622
+ const script = await writeFile('collision.rill', content);
623
+
624
+ // Run check to get diagnostics
625
+ const initialDiagnostics = validateFile(script);
626
+
627
+ // Should have violations
628
+ const hasUserName = initialDiagnostics.some((d) =>
629
+ d.message.includes('userName')
630
+ );
631
+ const hasItemList = initialDiagnostics.some((d) =>
632
+ d.message.includes('itemList')
633
+ );
634
+
635
+ if (hasUserName || hasItemList) {
636
+ // Apply fixes
637
+ const applied = applyFixesToFile(script);
638
+ expect(applied).toBeGreaterThan(0);
639
+
640
+ // Verify fix was applied (run check again on modified file)
641
+ const finalDiagnostics = validateFile(script);
642
+
643
+ // After fix, violations should be eliminated
644
+ expect(finalDiagnostics).toEqual([]);
645
+ }
646
+ });
647
+ });
648
+
649
+ // ============================================================
650
+ // ERROR CONTRACTS
651
+ // ============================================================
652
+
653
+ describe('error contracts', () => {
654
+ describe('EC-1: parseCheckArgs - unknown flag', () => {
655
+ it('throws error for unknown long flag', () => {
656
+ expect(() => parseCheckArgs(['--unknown'])).toThrow(
657
+ 'Unknown option: --unknown'
658
+ );
659
+ });
660
+
661
+ it('throws error for unknown short flag', () => {
662
+ expect(() => parseCheckArgs(['-x'])).toThrow('Unknown option: -x');
663
+ });
664
+
665
+ it('throws error for invalid --format value', () => {
666
+ expect(() => parseCheckArgs(['test.rill', '--format', 'xml'])).toThrow(
667
+ 'Invalid format: xml'
668
+ );
669
+ });
670
+
671
+ it('CLI exits with code 1 for unknown flag', async () => {
672
+ const result = await execCheck(['--unknown']);
673
+ expect(result.exitCode).toBe(1);
674
+ expect(result.stderr).toContain('Unknown option');
675
+ });
676
+ });
677
+
678
+ describe('EC-2: parseCheckArgs - missing file', () => {
679
+ it('throws error when no arguments provided', () => {
680
+ expect(() => parseCheckArgs([])).toThrow('Missing file argument');
681
+ });
682
+
683
+ it('throws error when only flags provided', () => {
684
+ expect(() => parseCheckArgs(['--fix', '--verbose'])).toThrow(
685
+ 'Missing file argument'
686
+ );
687
+ });
688
+
689
+ it('CLI exits with code 1 for missing file', async () => {
690
+ const result = await execCheck(['--fix']);
691
+ expect(result.exitCode).toBe(1);
692
+ expect(result.stderr).toContain('Missing file argument');
693
+ });
694
+ });
695
+
696
+ describe('EC-3: loadConfig - invalid JSON', () => {
697
+ it('throws error for malformed JSON', async () => {
698
+ await writeFile('.rill-check.json', '{ invalid json }');
699
+
700
+ expect(() => loadConfig(tempDir)).toThrow(/invalid JSON/i);
701
+ });
702
+
703
+ it('throws error for non-object JSON', async () => {
704
+ await writeFile('.rill-check.json', '"string value"');
705
+
706
+ expect(() => loadConfig(tempDir)).toThrow(/must be an object/i);
707
+ });
708
+
709
+ it('throws error for invalid rule state', async () => {
710
+ await writeFile(
711
+ '.rill-check.json',
712
+ JSON.stringify({ rules: { SOME_RULE: 'invalid_state' } })
713
+ );
714
+
715
+ expect(() => loadConfig(tempDir)).toThrow(/Invalid configuration/i);
716
+ });
717
+ });
718
+
719
+ describe('EC-4: loadConfig - unknown rule', () => {
720
+ it('throws error for unknown rule in rules field', async () => {
721
+ await writeFile(
722
+ '.rill-check.json',
723
+ JSON.stringify({ rules: { UNKNOWN_RULE: 'on' } })
724
+ );
725
+
726
+ expect(() => loadConfig(tempDir)).toThrow(/unknown rule UNKNOWN_RULE/i);
727
+ });
728
+
729
+ it('throws error for unknown rule in severity field', async () => {
730
+ await writeFile(
731
+ '.rill-check.json',
732
+ JSON.stringify({ severity: { UNKNOWN_RULE: 'error' } })
733
+ );
734
+
735
+ expect(() => loadConfig(tempDir)).toThrow(/unknown rule UNKNOWN_RULE/i);
736
+ });
737
+ });
738
+
739
+ describe('EC-5: applyFixes - fix collision (tested via unit tests)', () => {
740
+ it('reference: collision detection in fixer.test.ts', () => {
741
+ // EC-5 is tested in tests/check/fixer.test.ts
742
+ // The applyFixes function skips overlapping fixes with reason
743
+ // See fixer.test.ts "collision detection [EC-5]" describe block
744
+ expect(true).toBe(true);
745
+ });
746
+ });
747
+
748
+ describe('EC-6: applyFixes - parse failure (tested via unit tests)', () => {
749
+ it('reference: parse verification in fixer.test.ts', () => {
750
+ // EC-6 is tested in tests/check/fixer.test.ts
751
+ // The applyFixes function throws when fix creates invalid syntax
752
+ // See fixer.test.ts "parse verification [EC-6]" describe block
753
+ expect(true).toBe(true);
754
+ });
755
+ });
756
+ });
757
+
758
+ // ============================================================
759
+ // EDGE CASES
760
+ // ============================================================
761
+
762
+ describe('edge cases', () => {
763
+ it('handles file with only whitespace', async () => {
764
+ const script = await writeFile('whitespace.rill', ' \n\n \t ');
765
+ const diagnostics = validateFile(script);
766
+
767
+ expect(diagnostics).toEqual([]);
768
+ });
769
+
770
+ it('handles file with only comments', async () => {
771
+ const script = await writeFile('comments.rill', '# comment\n# another');
772
+ const diagnostics = validateFile(script);
773
+
774
+ expect(diagnostics).toEqual([]);
775
+ });
776
+
777
+ it('handles multiple flags in different order', async () => {
778
+ const script = await writeFile('multi-flags.rill', '"hello"');
779
+ const args = parseCheckArgs([
780
+ '--verbose',
781
+ script,
782
+ '--format',
783
+ 'json',
784
+ '--fix',
785
+ ]);
786
+
787
+ expect(args.mode).toBe('check');
788
+ if (args.mode === 'check') {
789
+ expect(args.verbose).toBe(true);
790
+ expect(args.format).toBe('json');
791
+ expect(args.fix).toBe(true);
792
+ expect(args.file).toBe(script);
793
+ }
794
+
795
+ const diagnostics = validateFile(script);
796
+ const output = formatDiagnostics(script, diagnostics, 'json', true);
797
+ const parsed = JSON.parse(output);
798
+ expect(parsed).toHaveProperty('file');
799
+ });
800
+ });
801
+ });