@mainahq/core 0.2.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 (156) hide show
  1. package/README.md +31 -0
  2. package/package.json +37 -0
  3. package/src/ai/__tests__/ai.test.ts +207 -0
  4. package/src/ai/__tests__/design-approaches.test.ts +192 -0
  5. package/src/ai/__tests__/spec-questions.test.ts +191 -0
  6. package/src/ai/__tests__/tiers.test.ts +110 -0
  7. package/src/ai/commit-msg.ts +28 -0
  8. package/src/ai/design-approaches.ts +76 -0
  9. package/src/ai/index.ts +205 -0
  10. package/src/ai/pr-summary.ts +60 -0
  11. package/src/ai/spec-questions.ts +74 -0
  12. package/src/ai/tiers.ts +52 -0
  13. package/src/ai/try-generate.ts +89 -0
  14. package/src/ai/validate.ts +66 -0
  15. package/src/benchmark/__tests__/reporter.test.ts +525 -0
  16. package/src/benchmark/__tests__/runner.test.ts +113 -0
  17. package/src/benchmark/__tests__/story-loader.test.ts +152 -0
  18. package/src/benchmark/reporter.ts +332 -0
  19. package/src/benchmark/runner.ts +91 -0
  20. package/src/benchmark/story-loader.ts +88 -0
  21. package/src/benchmark/types.ts +95 -0
  22. package/src/cache/__tests__/keys.test.ts +97 -0
  23. package/src/cache/__tests__/manager.test.ts +312 -0
  24. package/src/cache/__tests__/ttl.test.ts +94 -0
  25. package/src/cache/keys.ts +44 -0
  26. package/src/cache/manager.ts +231 -0
  27. package/src/cache/ttl.ts +77 -0
  28. package/src/config/__tests__/config.test.ts +376 -0
  29. package/src/config/index.ts +198 -0
  30. package/src/context/__tests__/budget.test.ts +179 -0
  31. package/src/context/__tests__/engine.test.ts +163 -0
  32. package/src/context/__tests__/episodic.test.ts +291 -0
  33. package/src/context/__tests__/relevance.test.ts +323 -0
  34. package/src/context/__tests__/retrieval.test.ts +143 -0
  35. package/src/context/__tests__/selector.test.ts +174 -0
  36. package/src/context/__tests__/semantic.test.ts +252 -0
  37. package/src/context/__tests__/treesitter.test.ts +229 -0
  38. package/src/context/__tests__/working.test.ts +236 -0
  39. package/src/context/budget.ts +130 -0
  40. package/src/context/engine.ts +394 -0
  41. package/src/context/episodic.ts +251 -0
  42. package/src/context/relevance.ts +325 -0
  43. package/src/context/retrieval.ts +325 -0
  44. package/src/context/selector.ts +93 -0
  45. package/src/context/semantic.ts +331 -0
  46. package/src/context/treesitter.ts +216 -0
  47. package/src/context/working.ts +192 -0
  48. package/src/db/__tests__/db.test.ts +151 -0
  49. package/src/db/index.ts +211 -0
  50. package/src/db/schema.ts +84 -0
  51. package/src/design/__tests__/design.test.ts +310 -0
  52. package/src/design/__tests__/generate-hld-lld.test.ts +109 -0
  53. package/src/design/__tests__/review.test.ts +561 -0
  54. package/src/design/index.ts +297 -0
  55. package/src/design/review.ts +327 -0
  56. package/src/explain/__tests__/explain.test.ts +173 -0
  57. package/src/explain/index.ts +181 -0
  58. package/src/features/__tests__/analyzer.test.ts +358 -0
  59. package/src/features/__tests__/checklist.test.ts +454 -0
  60. package/src/features/__tests__/numbering.test.ts +319 -0
  61. package/src/features/__tests__/quality.test.ts +295 -0
  62. package/src/features/__tests__/traceability.test.ts +147 -0
  63. package/src/features/analyzer.ts +445 -0
  64. package/src/features/checklist.ts +366 -0
  65. package/src/features/index.ts +18 -0
  66. package/src/features/numbering.ts +404 -0
  67. package/src/features/quality.ts +349 -0
  68. package/src/features/test-stubs.ts +157 -0
  69. package/src/features/traceability.ts +260 -0
  70. package/src/feedback/__tests__/async-feedback.test.ts +52 -0
  71. package/src/feedback/__tests__/collector.test.ts +219 -0
  72. package/src/feedback/__tests__/compress.test.ts +150 -0
  73. package/src/feedback/__tests__/preferences.test.ts +169 -0
  74. package/src/feedback/collector.ts +135 -0
  75. package/src/feedback/compress.ts +92 -0
  76. package/src/feedback/preferences.ts +108 -0
  77. package/src/git/__tests__/git.test.ts +62 -0
  78. package/src/git/index.ts +110 -0
  79. package/src/hooks/__tests__/runner.test.ts +266 -0
  80. package/src/hooks/index.ts +8 -0
  81. package/src/hooks/runner.ts +130 -0
  82. package/src/index.ts +356 -0
  83. package/src/init/__tests__/init.test.ts +228 -0
  84. package/src/init/index.ts +364 -0
  85. package/src/language/__tests__/detect.test.ts +77 -0
  86. package/src/language/__tests__/profile.test.ts +51 -0
  87. package/src/language/detect.ts +70 -0
  88. package/src/language/profile.ts +110 -0
  89. package/src/prompts/__tests__/defaults.test.ts +52 -0
  90. package/src/prompts/__tests__/engine.test.ts +183 -0
  91. package/src/prompts/__tests__/evolution-resolve.test.ts +169 -0
  92. package/src/prompts/__tests__/evolution.test.ts +187 -0
  93. package/src/prompts/__tests__/loader.test.ts +105 -0
  94. package/src/prompts/candidates/review-v2.md +55 -0
  95. package/src/prompts/defaults/ai-review.md +49 -0
  96. package/src/prompts/defaults/commit.md +30 -0
  97. package/src/prompts/defaults/context.md +26 -0
  98. package/src/prompts/defaults/design-approaches.md +57 -0
  99. package/src/prompts/defaults/design-hld-lld.md +55 -0
  100. package/src/prompts/defaults/design.md +53 -0
  101. package/src/prompts/defaults/explain.md +31 -0
  102. package/src/prompts/defaults/fix.md +32 -0
  103. package/src/prompts/defaults/index.ts +38 -0
  104. package/src/prompts/defaults/review.md +41 -0
  105. package/src/prompts/defaults/spec-questions.md +59 -0
  106. package/src/prompts/defaults/tests.md +72 -0
  107. package/src/prompts/engine.ts +137 -0
  108. package/src/prompts/evolution.ts +409 -0
  109. package/src/prompts/loader.ts +71 -0
  110. package/src/review/__tests__/review.test.ts +288 -0
  111. package/src/review/comprehensive.ts +362 -0
  112. package/src/review/index.ts +417 -0
  113. package/src/stats/__tests__/tracker.test.ts +323 -0
  114. package/src/stats/index.ts +11 -0
  115. package/src/stats/tracker.ts +492 -0
  116. package/src/ticket/__tests__/ticket.test.ts +273 -0
  117. package/src/ticket/index.ts +185 -0
  118. package/src/utils.ts +87 -0
  119. package/src/verify/__tests__/ai-review.test.ts +242 -0
  120. package/src/verify/__tests__/coverage.test.ts +83 -0
  121. package/src/verify/__tests__/detect.test.ts +175 -0
  122. package/src/verify/__tests__/diff-filter.test.ts +338 -0
  123. package/src/verify/__tests__/fix.test.ts +478 -0
  124. package/src/verify/__tests__/linters/clippy.test.ts +45 -0
  125. package/src/verify/__tests__/linters/go-vet.test.ts +27 -0
  126. package/src/verify/__tests__/linters/ruff.test.ts +64 -0
  127. package/src/verify/__tests__/mutation.test.ts +141 -0
  128. package/src/verify/__tests__/pipeline.test.ts +553 -0
  129. package/src/verify/__tests__/proof.test.ts +97 -0
  130. package/src/verify/__tests__/secretlint.test.ts +190 -0
  131. package/src/verify/__tests__/semgrep.test.ts +217 -0
  132. package/src/verify/__tests__/slop.test.ts +366 -0
  133. package/src/verify/__tests__/sonar.test.ts +113 -0
  134. package/src/verify/__tests__/syntax-guard.test.ts +227 -0
  135. package/src/verify/__tests__/trivy.test.ts +191 -0
  136. package/src/verify/__tests__/visual.test.ts +139 -0
  137. package/src/verify/ai-review.ts +276 -0
  138. package/src/verify/coverage.ts +134 -0
  139. package/src/verify/detect.ts +171 -0
  140. package/src/verify/diff-filter.ts +183 -0
  141. package/src/verify/fix.ts +317 -0
  142. package/src/verify/linters/clippy.ts +52 -0
  143. package/src/verify/linters/go-vet.ts +32 -0
  144. package/src/verify/linters/ruff.ts +47 -0
  145. package/src/verify/mutation.ts +143 -0
  146. package/src/verify/pipeline.ts +328 -0
  147. package/src/verify/proof.ts +277 -0
  148. package/src/verify/secretlint.ts +168 -0
  149. package/src/verify/semgrep.ts +170 -0
  150. package/src/verify/slop.ts +493 -0
  151. package/src/verify/sonar.ts +146 -0
  152. package/src/verify/syntax-guard.ts +251 -0
  153. package/src/verify/trivy.ts +161 -0
  154. package/src/verify/visual.ts +460 -0
  155. package/src/workflow/__tests__/context.test.ts +110 -0
  156. package/src/workflow/context.ts +81 -0
@@ -0,0 +1,478 @@
1
+ import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test";
2
+ import type { Finding } from "../diff-filter";
3
+
4
+ // ─── Mock setup ──────────────────────────────────────────────────────────
5
+
6
+ // Mock the AI generate function
7
+ const mockGenerate = mock(() =>
8
+ Promise.resolve({
9
+ text: "",
10
+ cached: false,
11
+ model: "test-model",
12
+ tokens: { input: 100, output: 50 },
13
+ }),
14
+ );
15
+
16
+ // Mock the Prompt Engine
17
+ const mockBuildSystemPrompt = mock(() =>
18
+ Promise.resolve({
19
+ prompt: "You are a fix generator. Fix the issues.",
20
+ hash: "prompt-hash-abc",
21
+ }),
22
+ );
23
+
24
+ // Mock the cache manager
25
+ const mockCacheGet = mock(
26
+ () =>
27
+ null as {
28
+ key: string;
29
+ value: string;
30
+ createdAt: number;
31
+ ttl: number;
32
+ } | null,
33
+ );
34
+ const mockCacheSet = mock(() => {});
35
+ const mockCacheHas = mock(() => false);
36
+ const mockCreateCacheManager = mock(() => ({
37
+ get: mockCacheGet,
38
+ set: mockCacheSet,
39
+ has: mockCacheHas,
40
+ invalidate: mock(() => {}),
41
+ clear: mock(() => {}),
42
+ stats: mock(() => ({
43
+ l1Hits: 0,
44
+ l2Hits: 0,
45
+ misses: 0,
46
+ totalQueries: 0,
47
+ entriesL1: 0,
48
+ entriesL2: 0,
49
+ })),
50
+ }));
51
+
52
+ // Apply mocks before importing the module under test
53
+ mock.module("../../ai/index", () => ({
54
+ generate: mockGenerate,
55
+ }));
56
+
57
+ mock.module("../../prompts/engine", () => ({
58
+ buildSystemPrompt: mockBuildSystemPrompt,
59
+ }));
60
+
61
+ mock.module("../../cache/manager", () => ({
62
+ createCacheManager: mockCreateCacheManager,
63
+ }));
64
+
65
+ afterAll(() => {
66
+ mock.restore();
67
+ });
68
+
69
+ // Now import the module under test
70
+ import {
71
+ type FixOptions,
72
+ type FixResult,
73
+ type FixSuggestion,
74
+ generateFixes,
75
+ hashFinding,
76
+ parseFixResponse,
77
+ } from "../fix";
78
+
79
+ // ─── Test data ───────────────────────────────────────────────────────────
80
+
81
+ const sampleFinding: Finding = {
82
+ tool: "biome",
83
+ file: "src/utils.ts",
84
+ line: 42,
85
+ column: 5,
86
+ message: "Use const instead of let",
87
+ severity: "warning",
88
+ ruleId: "lint/style/useConst",
89
+ };
90
+
91
+ const sampleFinding2: Finding = {
92
+ tool: "semgrep",
93
+ file: "src/api.ts",
94
+ line: 10,
95
+ message: "Potential SQL injection",
96
+ severity: "error",
97
+ ruleId: "security/sql-injection",
98
+ };
99
+
100
+ const sampleAiResponse = `### Fix for finding: biome/lint/style/useConst at src/utils.ts:42
101
+
102
+ **Explanation:** The variable is never reassigned, so it should be declared with const instead of let for better immutability guarantees.
103
+
104
+ **Confidence:** high
105
+
106
+ \`\`\`diff
107
+ --- a/src/utils.ts
108
+ +++ b/src/utils.ts
109
+ @@ -42,1 +42,1 @@
110
+ - let result = computeValue();
111
+ + const result = computeValue();
112
+ \`\`\`
113
+ `;
114
+
115
+ const multiFixAiResponse = `### Fix for finding: biome/lint/style/useConst at src/utils.ts:42
116
+
117
+ **Explanation:** The variable is never reassigned, so it should be declared with const instead of let.
118
+
119
+ **Confidence:** high
120
+
121
+ \`\`\`diff
122
+ --- a/src/utils.ts
123
+ +++ b/src/utils.ts
124
+ @@ -42,1 +42,1 @@
125
+ - let result = computeValue();
126
+ + const result = computeValue();
127
+ \`\`\`
128
+
129
+ ### Fix for finding: semgrep/security/sql-injection at src/api.ts:10
130
+
131
+ **Explanation:** Use parameterized queries to prevent SQL injection attacks.
132
+
133
+ **Confidence:** medium
134
+
135
+ \`\`\`diff
136
+ --- a/src/api.ts
137
+ +++ b/src/api.ts
138
+ @@ -10,1 +10,1 @@
139
+ - const rows = db.query("SELECT * FROM users WHERE id = " + userId);
140
+ + const rows = db.query("SELECT * FROM users WHERE id = ?", [userId]);
141
+ \`\`\`
142
+ `;
143
+
144
+ // ─── hashFinding ─────────────────────────────────────────────────────────
145
+
146
+ describe("hashFinding", () => {
147
+ it("should return a deterministic hash for the same finding", () => {
148
+ const hash1 = hashFinding(sampleFinding);
149
+ const hash2 = hashFinding(sampleFinding);
150
+ expect(hash1).toBe(hash2);
151
+ });
152
+
153
+ it("should return different hashes for different findings", () => {
154
+ const hash1 = hashFinding(sampleFinding);
155
+ const hash2 = hashFinding(sampleFinding2);
156
+ expect(hash1).not.toBe(hash2);
157
+ });
158
+
159
+ it("should return a hex string", () => {
160
+ const hash = hashFinding(sampleFinding);
161
+ expect(hash).toMatch(/^[0-9a-f]+$/);
162
+ });
163
+
164
+ it("should include tool, file, line, message, and ruleId in the hash input", () => {
165
+ // Same finding but different line → different hash
166
+ const altered = { ...sampleFinding, line: 99 };
167
+ expect(hashFinding(sampleFinding)).not.toBe(hashFinding(altered));
168
+
169
+ // Same finding but different message → different hash
170
+ const alteredMsg = { ...sampleFinding, message: "Different message" };
171
+ expect(hashFinding(sampleFinding)).not.toBe(hashFinding(alteredMsg));
172
+ });
173
+ });
174
+
175
+ // ─── parseFixResponse ────────────────────────────────────────────────────
176
+
177
+ describe("parseFixResponse", () => {
178
+ it("should parse a single fix from AI response", () => {
179
+ const findings = [sampleFinding];
180
+ const suggestions = parseFixResponse(sampleAiResponse, findings);
181
+
182
+ expect(suggestions.length).toBe(1);
183
+ expect(suggestions[0]?.finding).toBe(sampleFinding);
184
+ expect(suggestions[0]?.confidence).toBe("high");
185
+ expect(suggestions[0]?.explanation).toContain("never reassigned");
186
+ expect(suggestions[0]?.diff).toContain("- let result");
187
+ expect(suggestions[0]?.diff).toContain("+ const result");
188
+ });
189
+
190
+ it("should parse multiple fixes from AI response", () => {
191
+ const findings = [sampleFinding, sampleFinding2];
192
+ const suggestions = parseFixResponse(multiFixAiResponse, findings);
193
+
194
+ expect(suggestions.length).toBe(2);
195
+
196
+ // First fix
197
+ expect(suggestions[0]?.finding).toBe(sampleFinding);
198
+ expect(suggestions[0]?.confidence).toBe("high");
199
+
200
+ // Second fix
201
+ expect(suggestions[1]?.finding).toBe(sampleFinding2);
202
+ expect(suggestions[1]?.confidence).toBe("medium");
203
+ expect(suggestions[1]?.explanation).toContain("parameterized queries");
204
+ });
205
+
206
+ it("should default confidence to low when not parseable", () => {
207
+ const badResponse = `### Fix for finding: biome/lint/style/useConst at src/utils.ts:42
208
+
209
+ **Explanation:** Just fix it.
210
+
211
+ \`\`\`diff
212
+ --- a/src/utils.ts
213
+ +++ b/src/utils.ts
214
+ @@ -42,1 +42,1 @@
215
+ - let result = computeValue();
216
+ + const result = computeValue();
217
+ \`\`\`
218
+ `;
219
+ const findings = [sampleFinding];
220
+ const suggestions = parseFixResponse(badResponse, findings);
221
+
222
+ expect(suggestions.length).toBe(1);
223
+ expect(suggestions[0]?.confidence).toBe("low");
224
+ });
225
+
226
+ it("should return empty array for empty response", () => {
227
+ const suggestions = parseFixResponse("", [sampleFinding]);
228
+ expect(suggestions.length).toBe(0);
229
+ });
230
+
231
+ it("should return empty array for unparseable response", () => {
232
+ const suggestions = parseFixResponse("I cannot fix this issue.", [
233
+ sampleFinding,
234
+ ]);
235
+ expect(suggestions.length).toBe(0);
236
+ });
237
+ });
238
+
239
+ // ─── generateFixes ───────────────────────────────────────────────────────
240
+
241
+ describe("generateFixes", () => {
242
+ const defaultOptions: FixOptions = {
243
+ mainaDir: ".maina",
244
+ cwd: "/project",
245
+ contextText: "const computeValue = () => 42;",
246
+ };
247
+
248
+ beforeEach(() => {
249
+ mockGenerate.mockClear();
250
+ mockBuildSystemPrompt.mockClear();
251
+ mockCacheGet.mockClear();
252
+ mockCacheSet.mockClear();
253
+ mockCacheHas.mockClear();
254
+ mockCreateCacheManager.mockClear();
255
+
256
+ // Reset default implementations
257
+ mockCacheGet.mockImplementation(() => null);
258
+ mockGenerate.mockImplementation(() =>
259
+ Promise.resolve({
260
+ text: sampleAiResponse,
261
+ cached: false,
262
+ model: "test-model",
263
+ tokens: { input: 100, output: 50 },
264
+ }),
265
+ );
266
+ });
267
+
268
+ it("should return empty suggestions for empty findings", async () => {
269
+ const result = await generateFixes([], defaultOptions);
270
+
271
+ expect(result.suggestions.length).toBe(0);
272
+ expect(result.cached).toBe(false);
273
+ expect(mockGenerate).not.toHaveBeenCalled();
274
+ });
275
+
276
+ it("should call buildSystemPrompt with 'fix' task", async () => {
277
+ await generateFixes([sampleFinding], defaultOptions);
278
+
279
+ expect(mockBuildSystemPrompt).toHaveBeenCalledWith(
280
+ "fix",
281
+ ".maina",
282
+ expect.objectContaining({
283
+ findings: expect.any(String),
284
+ source: expect.any(String),
285
+ }),
286
+ );
287
+ });
288
+
289
+ it("should call generate with assembled prompt", async () => {
290
+ await generateFixes([sampleFinding], defaultOptions);
291
+
292
+ expect(mockGenerate).toHaveBeenCalledTimes(1);
293
+ expect(mockGenerate).toHaveBeenCalledWith(
294
+ expect.objectContaining({
295
+ task: "fix",
296
+ systemPrompt: expect.any(String),
297
+ userPrompt: expect.any(String),
298
+ mainaDir: ".maina",
299
+ }),
300
+ );
301
+ });
302
+
303
+ it("should return parsed suggestions from AI response", async () => {
304
+ const result = await generateFixes([sampleFinding], defaultOptions);
305
+
306
+ expect(result.suggestions.length).toBe(1);
307
+ expect(result.suggestions[0]?.finding).toBe(sampleFinding);
308
+ expect(result.suggestions[0]?.confidence).toBe("high");
309
+ expect(result.cached).toBe(false);
310
+ expect(result.model).toBe("test-model");
311
+ });
312
+
313
+ it("should check cache before calling AI", async () => {
314
+ // Simulate cache hit
315
+ const cachedResult: FixResult = {
316
+ suggestions: [
317
+ {
318
+ finding: sampleFinding,
319
+ diff: "cached diff",
320
+ explanation: "cached explanation",
321
+ confidence: "high",
322
+ },
323
+ ],
324
+ cached: true,
325
+ model: "cached-model",
326
+ };
327
+
328
+ mockCacheGet.mockImplementation(() => ({
329
+ key: "test-key",
330
+ value: JSON.stringify(cachedResult),
331
+ createdAt: Date.now(),
332
+ ttl: 0,
333
+ }));
334
+
335
+ const result = await generateFixes([sampleFinding], defaultOptions);
336
+
337
+ expect(result.cached).toBe(true);
338
+ expect(result.suggestions.length).toBe(1);
339
+ expect(result.suggestions[0]?.diff).toBe("cached diff");
340
+ // AI generate should NOT have been called
341
+ expect(mockGenerate).not.toHaveBeenCalled();
342
+ });
343
+
344
+ it("should cache the result after AI call", async () => {
345
+ await generateFixes([sampleFinding], defaultOptions);
346
+
347
+ expect(mockCacheSet).toHaveBeenCalledTimes(1);
348
+ expect(mockCacheSet).toHaveBeenCalledWith(
349
+ expect.any(String), // cache key
350
+ expect.any(String), // JSON stringified result
351
+ expect.objectContaining({
352
+ ttl: expect.any(Number),
353
+ }),
354
+ );
355
+ });
356
+
357
+ it("should return same fix on second call via cache", async () => {
358
+ // First call: AI generates
359
+ const result1 = await generateFixes([sampleFinding], defaultOptions);
360
+ expect(result1.cached).toBe(false);
361
+ expect(mockGenerate).toHaveBeenCalledTimes(1);
362
+
363
+ // Set up cache to return what was stored
364
+ const calls = mockCacheSet.mock.calls as unknown[][];
365
+ const storedValue = (calls[0]?.[1] ?? "") as string;
366
+ mockCacheGet.mockImplementation(() => ({
367
+ key: "test-key",
368
+ value: storedValue,
369
+ createdAt: Date.now(),
370
+ ttl: 0,
371
+ }));
372
+
373
+ // Second call: should hit cache
374
+ const result2 = await generateFixes([sampleFinding], defaultOptions);
375
+ expect(result2.cached).toBe(true);
376
+ // AI should not be called a second time
377
+ expect(mockGenerate).toHaveBeenCalledTimes(1);
378
+ });
379
+
380
+ it("should batch multiple findings into a single AI call", async () => {
381
+ mockGenerate.mockImplementation(() =>
382
+ Promise.resolve({
383
+ text: multiFixAiResponse,
384
+ cached: false,
385
+ model: "test-model",
386
+ tokens: { input: 200, output: 100 },
387
+ }),
388
+ );
389
+
390
+ const result = await generateFixes(
391
+ [sampleFinding, sampleFinding2],
392
+ defaultOptions,
393
+ );
394
+
395
+ // Only one AI call for multiple findings
396
+ expect(mockGenerate).toHaveBeenCalledTimes(1);
397
+ expect(result.suggestions.length).toBe(2);
398
+ });
399
+
400
+ it("should handle AI call returning empty/unparseable response", async () => {
401
+ mockGenerate.mockImplementation(() =>
402
+ Promise.resolve({
403
+ text: "Sorry, I cannot fix these issues.",
404
+ cached: false,
405
+ model: "test-model",
406
+ tokens: { input: 100, output: 10 },
407
+ }),
408
+ );
409
+
410
+ const result = await generateFixes([sampleFinding], defaultOptions);
411
+
412
+ expect(result.suggestions.length).toBe(0);
413
+ expect(result.cached).toBe(false);
414
+ });
415
+
416
+ it("should use contextText in the prompt when provided", async () => {
417
+ await generateFixes([sampleFinding], {
418
+ ...defaultOptions,
419
+ contextText: "function computeValue() { return 42; }",
420
+ });
421
+
422
+ expect(mockBuildSystemPrompt).toHaveBeenCalledWith(
423
+ "fix",
424
+ ".maina",
425
+ expect.objectContaining({
426
+ source: expect.stringContaining("computeValue"),
427
+ }),
428
+ );
429
+ });
430
+
431
+ it("should handle missing contextText gracefully", async () => {
432
+ await generateFixes([sampleFinding], {
433
+ mainaDir: ".maina",
434
+ });
435
+
436
+ expect(mockBuildSystemPrompt).toHaveBeenCalledWith(
437
+ "fix",
438
+ ".maina",
439
+ expect.objectContaining({
440
+ source: expect.any(String),
441
+ }),
442
+ );
443
+ });
444
+ });
445
+
446
+ // ─── FixSuggestion type ──────────────────────────────────────────────────
447
+
448
+ describe("FixSuggestion type", () => {
449
+ it("should have all required fields", () => {
450
+ const suggestion: FixSuggestion = {
451
+ finding: sampleFinding,
452
+ diff: "--- a/file\n+++ b/file",
453
+ explanation: "Fix explanation",
454
+ confidence: "high",
455
+ };
456
+
457
+ expect(suggestion.finding).toBe(sampleFinding);
458
+ expect(suggestion.diff).toBeTruthy();
459
+ expect(suggestion.explanation).toBeTruthy();
460
+ expect(suggestion.confidence).toBe("high");
461
+ });
462
+ });
463
+
464
+ // ─── FixResult type ──────────────────────────────────────────────────────
465
+
466
+ describe("FixResult type", () => {
467
+ it("should have suggestions, cached flag, and optional model", () => {
468
+ const result: FixResult = {
469
+ suggestions: [],
470
+ cached: false,
471
+ model: "gpt-4",
472
+ };
473
+
474
+ expect(Array.isArray(result.suggestions)).toBe(true);
475
+ expect(typeof result.cached).toBe("boolean");
476
+ expect(result.model).toBe("gpt-4");
477
+ });
478
+ });
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { parseClippyOutput } from "../../linters/clippy";
3
+
4
+ describe("parseClippyOutput", () => {
5
+ it("should parse clippy JSON messages", () => {
6
+ const lines = [
7
+ JSON.stringify({
8
+ reason: "compiler-message",
9
+ message: {
10
+ code: { code: "clippy::unwrap_used" },
11
+ level: "warning",
12
+ message: "used `unwrap()` on a `Result` value",
13
+ spans: [
14
+ { file_name: "src/main.rs", line_start: 10, column_start: 5 },
15
+ ],
16
+ },
17
+ }),
18
+ JSON.stringify({ reason: "build-finished", success: true }),
19
+ ].join("\n");
20
+
21
+ const diagnostics = parseClippyOutput(lines);
22
+ expect(diagnostics).toHaveLength(1);
23
+ expect(diagnostics[0]?.file).toBe("src/main.rs");
24
+ expect(diagnostics[0]?.line).toBe(10);
25
+ expect(diagnostics[0]?.severity).toBe("warning");
26
+ });
27
+
28
+ it("should handle empty output", () => {
29
+ expect(parseClippyOutput("")).toHaveLength(0);
30
+ });
31
+
32
+ it("should map error level correctly", () => {
33
+ const line = JSON.stringify({
34
+ reason: "compiler-message",
35
+ message: {
36
+ code: { code: "E0308" },
37
+ level: "error",
38
+ message: "mismatched types",
39
+ spans: [{ file_name: "src/lib.rs", line_start: 5, column_start: 1 }],
40
+ },
41
+ });
42
+ const diagnostics = parseClippyOutput(line);
43
+ expect(diagnostics[0]?.severity).toBe("error");
44
+ });
45
+ });
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { parseGoVetOutput } from "../../linters/go-vet";
3
+
4
+ describe("parseGoVetOutput", () => {
5
+ it("should parse go vet text output", () => {
6
+ const output = `# example.com/pkg
7
+ ./main.go:15:2: unreachable code
8
+ ./utils.go:8:4: loop variable captured by func literal`;
9
+
10
+ const diagnostics = parseGoVetOutput(output);
11
+ expect(diagnostics).toHaveLength(2);
12
+ expect(diagnostics[0]?.file).toBe("./main.go");
13
+ expect(diagnostics[0]?.line).toBe(15);
14
+ expect(diagnostics[0]?.severity).toBe("error");
15
+ expect(diagnostics[0]?.message).toContain("unreachable code");
16
+ });
17
+
18
+ it("should handle empty output", () => {
19
+ expect(parseGoVetOutput("")).toHaveLength(0);
20
+ });
21
+
22
+ it("should skip package header lines", () => {
23
+ const output = `# example.com/pkg
24
+ vet: checking...`;
25
+ expect(parseGoVetOutput(output)).toHaveLength(0);
26
+ });
27
+ });
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { parseRuffOutput } from "../../linters/ruff";
3
+
4
+ describe("parseRuffOutput", () => {
5
+ it("should parse ruff JSON output into SyntaxDiagnostic[]", () => {
6
+ const json = JSON.stringify([
7
+ {
8
+ code: "E501",
9
+ message: "Line too long (120 > 88)",
10
+ filename: "src/app.py",
11
+ location: { row: 10, column: 1 },
12
+ end_location: { row: 10, column: 120 },
13
+ fix: null,
14
+ noqa_row: 10,
15
+ },
16
+ {
17
+ code: "F401",
18
+ message: "os imported but unused",
19
+ filename: "src/utils.py",
20
+ location: { row: 3, column: 1 },
21
+ end_location: { row: 3, column: 10 },
22
+ fix: { applicability: "safe" },
23
+ noqa_row: 3,
24
+ },
25
+ ]);
26
+
27
+ const diagnostics = parseRuffOutput(json);
28
+ expect(diagnostics).toHaveLength(2);
29
+ expect(diagnostics[0]?.file).toBe("src/app.py");
30
+ expect(diagnostics[0]?.line).toBe(10);
31
+ expect(diagnostics[0]?.severity).toBe("warning");
32
+ expect(diagnostics[1]?.file).toBe("src/utils.py");
33
+ });
34
+
35
+ it("should map E-codes to warning and F-codes to error", () => {
36
+ const json = JSON.stringify([
37
+ {
38
+ code: "E501",
39
+ message: "style",
40
+ filename: "a.py",
41
+ location: { row: 1, column: 1 },
42
+ end_location: { row: 1, column: 1 },
43
+ },
44
+ {
45
+ code: "F811",
46
+ message: "redefined",
47
+ filename: "a.py",
48
+ location: { row: 2, column: 1 },
49
+ end_location: { row: 2, column: 1 },
50
+ },
51
+ ]);
52
+ const diagnostics = parseRuffOutput(json);
53
+ expect(diagnostics[0]?.severity).toBe("warning");
54
+ expect(diagnostics[1]?.severity).toBe("error");
55
+ });
56
+
57
+ it("should handle empty array", () => {
58
+ expect(parseRuffOutput("[]")).toHaveLength(0);
59
+ });
60
+
61
+ it("should handle malformed JSON", () => {
62
+ expect(parseRuffOutput("not json")).toHaveLength(0);
63
+ });
64
+ });