@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.
- package/README.md +31 -0
- package/package.json +37 -0
- package/src/ai/__tests__/ai.test.ts +207 -0
- package/src/ai/__tests__/design-approaches.test.ts +192 -0
- package/src/ai/__tests__/spec-questions.test.ts +191 -0
- package/src/ai/__tests__/tiers.test.ts +110 -0
- package/src/ai/commit-msg.ts +28 -0
- package/src/ai/design-approaches.ts +76 -0
- package/src/ai/index.ts +205 -0
- package/src/ai/pr-summary.ts +60 -0
- package/src/ai/spec-questions.ts +74 -0
- package/src/ai/tiers.ts +52 -0
- package/src/ai/try-generate.ts +89 -0
- package/src/ai/validate.ts +66 -0
- package/src/benchmark/__tests__/reporter.test.ts +525 -0
- package/src/benchmark/__tests__/runner.test.ts +113 -0
- package/src/benchmark/__tests__/story-loader.test.ts +152 -0
- package/src/benchmark/reporter.ts +332 -0
- package/src/benchmark/runner.ts +91 -0
- package/src/benchmark/story-loader.ts +88 -0
- package/src/benchmark/types.ts +95 -0
- package/src/cache/__tests__/keys.test.ts +97 -0
- package/src/cache/__tests__/manager.test.ts +312 -0
- package/src/cache/__tests__/ttl.test.ts +94 -0
- package/src/cache/keys.ts +44 -0
- package/src/cache/manager.ts +231 -0
- package/src/cache/ttl.ts +77 -0
- package/src/config/__tests__/config.test.ts +376 -0
- package/src/config/index.ts +198 -0
- package/src/context/__tests__/budget.test.ts +179 -0
- package/src/context/__tests__/engine.test.ts +163 -0
- package/src/context/__tests__/episodic.test.ts +291 -0
- package/src/context/__tests__/relevance.test.ts +323 -0
- package/src/context/__tests__/retrieval.test.ts +143 -0
- package/src/context/__tests__/selector.test.ts +174 -0
- package/src/context/__tests__/semantic.test.ts +252 -0
- package/src/context/__tests__/treesitter.test.ts +229 -0
- package/src/context/__tests__/working.test.ts +236 -0
- package/src/context/budget.ts +130 -0
- package/src/context/engine.ts +394 -0
- package/src/context/episodic.ts +251 -0
- package/src/context/relevance.ts +325 -0
- package/src/context/retrieval.ts +325 -0
- package/src/context/selector.ts +93 -0
- package/src/context/semantic.ts +331 -0
- package/src/context/treesitter.ts +216 -0
- package/src/context/working.ts +192 -0
- package/src/db/__tests__/db.test.ts +151 -0
- package/src/db/index.ts +211 -0
- package/src/db/schema.ts +84 -0
- package/src/design/__tests__/design.test.ts +310 -0
- package/src/design/__tests__/generate-hld-lld.test.ts +109 -0
- package/src/design/__tests__/review.test.ts +561 -0
- package/src/design/index.ts +297 -0
- package/src/design/review.ts +327 -0
- package/src/explain/__tests__/explain.test.ts +173 -0
- package/src/explain/index.ts +181 -0
- package/src/features/__tests__/analyzer.test.ts +358 -0
- package/src/features/__tests__/checklist.test.ts +454 -0
- package/src/features/__tests__/numbering.test.ts +319 -0
- package/src/features/__tests__/quality.test.ts +295 -0
- package/src/features/__tests__/traceability.test.ts +147 -0
- package/src/features/analyzer.ts +445 -0
- package/src/features/checklist.ts +366 -0
- package/src/features/index.ts +18 -0
- package/src/features/numbering.ts +404 -0
- package/src/features/quality.ts +349 -0
- package/src/features/test-stubs.ts +157 -0
- package/src/features/traceability.ts +260 -0
- package/src/feedback/__tests__/async-feedback.test.ts +52 -0
- package/src/feedback/__tests__/collector.test.ts +219 -0
- package/src/feedback/__tests__/compress.test.ts +150 -0
- package/src/feedback/__tests__/preferences.test.ts +169 -0
- package/src/feedback/collector.ts +135 -0
- package/src/feedback/compress.ts +92 -0
- package/src/feedback/preferences.ts +108 -0
- package/src/git/__tests__/git.test.ts +62 -0
- package/src/git/index.ts +110 -0
- package/src/hooks/__tests__/runner.test.ts +266 -0
- package/src/hooks/index.ts +8 -0
- package/src/hooks/runner.ts +130 -0
- package/src/index.ts +356 -0
- package/src/init/__tests__/init.test.ts +228 -0
- package/src/init/index.ts +364 -0
- package/src/language/__tests__/detect.test.ts +77 -0
- package/src/language/__tests__/profile.test.ts +51 -0
- package/src/language/detect.ts +70 -0
- package/src/language/profile.ts +110 -0
- package/src/prompts/__tests__/defaults.test.ts +52 -0
- package/src/prompts/__tests__/engine.test.ts +183 -0
- package/src/prompts/__tests__/evolution-resolve.test.ts +169 -0
- package/src/prompts/__tests__/evolution.test.ts +187 -0
- package/src/prompts/__tests__/loader.test.ts +105 -0
- package/src/prompts/candidates/review-v2.md +55 -0
- package/src/prompts/defaults/ai-review.md +49 -0
- package/src/prompts/defaults/commit.md +30 -0
- package/src/prompts/defaults/context.md +26 -0
- package/src/prompts/defaults/design-approaches.md +57 -0
- package/src/prompts/defaults/design-hld-lld.md +55 -0
- package/src/prompts/defaults/design.md +53 -0
- package/src/prompts/defaults/explain.md +31 -0
- package/src/prompts/defaults/fix.md +32 -0
- package/src/prompts/defaults/index.ts +38 -0
- package/src/prompts/defaults/review.md +41 -0
- package/src/prompts/defaults/spec-questions.md +59 -0
- package/src/prompts/defaults/tests.md +72 -0
- package/src/prompts/engine.ts +137 -0
- package/src/prompts/evolution.ts +409 -0
- package/src/prompts/loader.ts +71 -0
- package/src/review/__tests__/review.test.ts +288 -0
- package/src/review/comprehensive.ts +362 -0
- package/src/review/index.ts +417 -0
- package/src/stats/__tests__/tracker.test.ts +323 -0
- package/src/stats/index.ts +11 -0
- package/src/stats/tracker.ts +492 -0
- package/src/ticket/__tests__/ticket.test.ts +273 -0
- package/src/ticket/index.ts +185 -0
- package/src/utils.ts +87 -0
- package/src/verify/__tests__/ai-review.test.ts +242 -0
- package/src/verify/__tests__/coverage.test.ts +83 -0
- package/src/verify/__tests__/detect.test.ts +175 -0
- package/src/verify/__tests__/diff-filter.test.ts +338 -0
- package/src/verify/__tests__/fix.test.ts +478 -0
- package/src/verify/__tests__/linters/clippy.test.ts +45 -0
- package/src/verify/__tests__/linters/go-vet.test.ts +27 -0
- package/src/verify/__tests__/linters/ruff.test.ts +64 -0
- package/src/verify/__tests__/mutation.test.ts +141 -0
- package/src/verify/__tests__/pipeline.test.ts +553 -0
- package/src/verify/__tests__/proof.test.ts +97 -0
- package/src/verify/__tests__/secretlint.test.ts +190 -0
- package/src/verify/__tests__/semgrep.test.ts +217 -0
- package/src/verify/__tests__/slop.test.ts +366 -0
- package/src/verify/__tests__/sonar.test.ts +113 -0
- package/src/verify/__tests__/syntax-guard.test.ts +227 -0
- package/src/verify/__tests__/trivy.test.ts +191 -0
- package/src/verify/__tests__/visual.test.ts +139 -0
- package/src/verify/ai-review.ts +276 -0
- package/src/verify/coverage.ts +134 -0
- package/src/verify/detect.ts +171 -0
- package/src/verify/diff-filter.ts +183 -0
- package/src/verify/fix.ts +317 -0
- package/src/verify/linters/clippy.ts +52 -0
- package/src/verify/linters/go-vet.ts +32 -0
- package/src/verify/linters/ruff.ts +47 -0
- package/src/verify/mutation.ts +143 -0
- package/src/verify/pipeline.ts +328 -0
- package/src/verify/proof.ts +277 -0
- package/src/verify/secretlint.ts +168 -0
- package/src/verify/semgrep.ts +170 -0
- package/src/verify/slop.ts +493 -0
- package/src/verify/sonar.ts +146 -0
- package/src/verify/syntax-guard.ts +251 -0
- package/src/verify/trivy.ts +161 -0
- package/src/verify/visual.ts +460 -0
- package/src/workflow/__tests__/context.test.ts +110 -0
- 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
|
+
});
|