cadr-cli 0.0.1 → 1.9.2
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/dist/adr.d.ts +50 -0
- package/dist/adr.d.ts.map +1 -0
- package/dist/adr.js +156 -0
- package/dist/adr.js.map +1 -0
- package/dist/adr.test.d.ts +8 -0
- package/dist/adr.test.d.ts.map +1 -0
- package/dist/adr.test.js +256 -0
- package/dist/adr.test.js.map +1 -0
- package/dist/analysis.d.ts +24 -0
- package/dist/analysis.d.ts.map +1 -0
- package/dist/analysis.js +281 -0
- package/dist/analysis.js.map +1 -0
- package/dist/analysis.test.d.ts +8 -0
- package/dist/analysis.test.d.ts.map +1 -0
- package/dist/analysis.test.js +351 -0
- package/dist/analysis.test.js.map +1 -0
- package/dist/commands/analyze.d.ts +14 -0
- package/dist/commands/analyze.d.ts.map +1 -0
- package/dist/commands/analyze.js +56 -0
- package/dist/commands/analyze.js.map +1 -0
- package/dist/commands/init.d.ts +12 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +93 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/init.test.d.ts +2 -0
- package/dist/commands/init.test.d.ts.map +1 -0
- package/dist/commands/init.test.js +56 -0
- package/dist/commands/init.test.js.map +1 -0
- package/dist/config.d.ts +40 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +208 -0
- package/dist/config.js.map +1 -0
- package/dist/config.test.d.ts +2 -0
- package/dist/config.test.d.ts.map +1 -0
- package/dist/config.test.js +97 -0
- package/dist/config.test.js.map +1 -0
- package/dist/git.d.ts +42 -0
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +157 -0
- package/dist/git.js.map +1 -1
- package/dist/index.d.ts +2 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +78 -62
- package/dist/index.js.map +1 -1
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +51 -0
- package/dist/index.test.js.map +1 -0
- package/dist/llm.d.ts +73 -0
- package/dist/llm.d.ts.map +1 -0
- package/dist/llm.js +263 -0
- package/dist/llm.js.map +1 -0
- package/dist/llm.test.d.ts +2 -0
- package/dist/llm.test.d.ts.map +1 -0
- package/dist/llm.test.js +592 -0
- package/dist/llm.test.js.map +1 -0
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +5 -3
- package/dist/logger.js.map +1 -1
- package/dist/logger.test.d.ts +2 -0
- package/dist/logger.test.d.ts.map +1 -0
- package/dist/logger.test.js +78 -0
- package/dist/logger.test.js.map +1 -0
- package/dist/prompts.d.ts +49 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +195 -0
- package/dist/prompts.js.map +1 -0
- package/dist/prompts.test.d.ts +2 -0
- package/dist/prompts.test.d.ts.map +1 -0
- package/dist/prompts.test.js +427 -0
- package/dist/prompts.test.js.map +1 -0
- package/dist/providers/gemini.d.ts +3 -0
- package/dist/providers/gemini.d.ts.map +1 -0
- package/dist/providers/gemini.js +39 -0
- package/dist/providers/gemini.js.map +1 -0
- package/dist/providers/index.d.ts +2 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +6 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/openai.d.ts +3 -0
- package/dist/providers/openai.d.ts.map +1 -0
- package/dist/providers/openai.js +25 -0
- package/dist/providers/openai.js.map +1 -0
- package/dist/providers/registry.d.ts +4 -0
- package/dist/providers/registry.d.ts.map +1 -0
- package/dist/providers/registry.js +16 -0
- package/dist/providers/registry.js.map +1 -0
- package/dist/providers/types.d.ts +12 -0
- package/dist/providers/types.d.ts.map +1 -0
- package/dist/providers/types.js +5 -0
- package/dist/providers/types.js.map +1 -0
- package/dist/version.test.d.ts +3 -0
- package/dist/version.test.d.ts.map +1 -0
- package/dist/version.test.js +25 -0
- package/dist/version.test.js.map +1 -0
- package/package.json +14 -5
- package/src/adr.test.ts +278 -0
- package/src/adr.ts +136 -0
- package/src/analysis.test.ts +396 -0
- package/src/analysis.ts +262 -0
- package/src/commands/analyze.ts +56 -0
- package/src/commands/init.test.ts +27 -0
- package/src/commands/init.ts +99 -0
- package/src/config.test.ts +79 -0
- package/src/config.ts +214 -0
- package/src/git.ts +240 -0
- package/src/index.test.ts +59 -0
- package/src/index.ts +80 -60
- package/src/llm.test.ts +701 -0
- package/src/llm.ts +344 -0
- package/src/logger.test.ts +90 -0
- package/src/logger.ts +6 -3
- package/src/prompts.test.ts +515 -0
- package/src/prompts.ts +174 -0
- package/src/providers/gemini.ts +41 -0
- package/src/providers/index.ts +1 -0
- package/src/providers/openai.ts +22 -0
- package/src/providers/registry.ts +16 -0
- package/src/providers/types.ts +14 -0
- package/src/version.test.ts +29 -0
- package/bin/cadr.js +0 -16
package/src/llm.test.ts
ADDED
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
import { analyzeChanges, AnalysisRequest, generateADRContent, GenerationRequest } from './llm';
|
|
2
|
+
import { AnalysisConfig } from './config';
|
|
3
|
+
import OpenAI from 'openai';
|
|
4
|
+
import { GoogleGenerativeAI } from '@google/generative-ai';
|
|
5
|
+
|
|
6
|
+
// Mock OpenAI module
|
|
7
|
+
jest.mock('openai');
|
|
8
|
+
jest.mock('@google/generative-ai');
|
|
9
|
+
|
|
10
|
+
describe('LLM Client Module', () => {
|
|
11
|
+
const mockConfig: AnalysisConfig = {
|
|
12
|
+
provider: 'openai',
|
|
13
|
+
analysis_model: 'gpt-4',
|
|
14
|
+
api_key_env: 'OPENAI_API_KEY',
|
|
15
|
+
timeout_seconds: 15
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const mockRequest: AnalysisRequest = {
|
|
19
|
+
file_paths: ['src/auth.ts', 'src/user.ts'],
|
|
20
|
+
diff_content: `
|
|
21
|
+
diff --git a/src/auth.ts b/src/auth.ts
|
|
22
|
+
+export function authenticateUser(token: string) {
|
|
23
|
+
+ return jwt.verify(token);
|
|
24
|
+
+}
|
|
25
|
+
`,
|
|
26
|
+
repository_context: 'my-app',
|
|
27
|
+
analysis_prompt: 'Analyze these changes...'
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
jest.clearAllMocks();
|
|
32
|
+
process.env.OPENAI_API_KEY = 'test-api-key';
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
delete process.env.OPENAI_API_KEY;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('analyzeChanges', () => {
|
|
40
|
+
test('analyzes changes successfully with significant result', async () => {
|
|
41
|
+
const mockResponse = {
|
|
42
|
+
choices: [{
|
|
43
|
+
message: {
|
|
44
|
+
content: JSON.stringify({
|
|
45
|
+
is_significant: true,
|
|
46
|
+
reason: 'Introduces new authentication system'
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
}]
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const mockCreate = jest.fn().mockResolvedValue(mockResponse);
|
|
53
|
+
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => ({
|
|
54
|
+
chat: {
|
|
55
|
+
completions: {
|
|
56
|
+
create: mockCreate
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} as unknown as jest.Mocked<OpenAI>));
|
|
60
|
+
|
|
61
|
+
const response = await analyzeChanges(mockConfig, mockRequest);
|
|
62
|
+
|
|
63
|
+
expect(response.result).not.toBeNull();
|
|
64
|
+
expect(response.error).toBeUndefined();
|
|
65
|
+
expect(response.result?.is_significant).toBe(true);
|
|
66
|
+
expect(response.result?.reason).toContain('authentication');
|
|
67
|
+
expect(response.result?.timestamp).toBeDefined();
|
|
68
|
+
expect(mockCreate).toHaveBeenCalledWith(
|
|
69
|
+
expect.objectContaining({
|
|
70
|
+
model: 'gpt-4',
|
|
71
|
+
messages: expect.any(Array)
|
|
72
|
+
}),
|
|
73
|
+
expect.objectContaining({
|
|
74
|
+
timeout: 15000
|
|
75
|
+
})
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('analyzes changes successfully with non-significant result', async () => {
|
|
80
|
+
const mockResponse = {
|
|
81
|
+
choices: [{
|
|
82
|
+
message: {
|
|
83
|
+
content: JSON.stringify({
|
|
84
|
+
is_significant: false,
|
|
85
|
+
reason: 'Minor documentation updates'
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
}]
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const mockCreate = jest.fn().mockResolvedValue(mockResponse);
|
|
92
|
+
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => ({
|
|
93
|
+
chat: {
|
|
94
|
+
completions: {
|
|
95
|
+
create: mockCreate
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} as unknown as jest.Mocked<OpenAI>));
|
|
99
|
+
|
|
100
|
+
const response = await analyzeChanges(mockConfig, mockRequest);
|
|
101
|
+
|
|
102
|
+
expect(response.result).not.toBeNull();
|
|
103
|
+
expect(response.error).toBeUndefined();
|
|
104
|
+
expect(response.result?.is_significant).toBe(false);
|
|
105
|
+
expect(response.result?.reason).toContain('documentation');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('accepts empty reason when change is not significant', async () => {
|
|
109
|
+
const mockResponse = {
|
|
110
|
+
choices: [{
|
|
111
|
+
message: {
|
|
112
|
+
content: JSON.stringify({
|
|
113
|
+
is_significant: false,
|
|
114
|
+
reason: ''
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
}]
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const mockCreate = jest.fn().mockResolvedValue(mockResponse);
|
|
121
|
+
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => ({
|
|
122
|
+
chat: {
|
|
123
|
+
completions: {
|
|
124
|
+
create: mockCreate
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} as unknown as jest.Mocked<OpenAI>));
|
|
128
|
+
|
|
129
|
+
const response = await analyzeChanges(mockConfig, mockRequest);
|
|
130
|
+
|
|
131
|
+
expect(response.result).not.toBeNull();
|
|
132
|
+
expect(response.error).toBeUndefined();
|
|
133
|
+
expect(response.result?.is_significant).toBe(false);
|
|
134
|
+
expect(response.result?.reason).toBe('');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('rejects empty reason when change is significant', async () => {
|
|
138
|
+
const mockResponse = {
|
|
139
|
+
choices: [{
|
|
140
|
+
message: {
|
|
141
|
+
content: JSON.stringify({
|
|
142
|
+
is_significant: true,
|
|
143
|
+
reason: ''
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
}]
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const mockCreate = jest.fn().mockResolvedValue(mockResponse);
|
|
150
|
+
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => ({
|
|
151
|
+
chat: {
|
|
152
|
+
completions: {
|
|
153
|
+
create: mockCreate
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} as unknown as jest.Mocked<OpenAI>));
|
|
157
|
+
|
|
158
|
+
const response = await analyzeChanges(mockConfig, mockRequest);
|
|
159
|
+
|
|
160
|
+
expect(response.result).toBeNull();
|
|
161
|
+
expect(response.error).toBeDefined();
|
|
162
|
+
expect(response.error).toContain('no reason');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('handles API failures gracefully (fail-open)', async () => {
|
|
166
|
+
const mockCreate = jest.fn().mockRejectedValue(new Error('API Error'));
|
|
167
|
+
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => ({
|
|
168
|
+
chat: {
|
|
169
|
+
completions: {
|
|
170
|
+
create: mockCreate
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} as unknown as jest.Mocked<OpenAI>));
|
|
174
|
+
|
|
175
|
+
const response = await analyzeChanges(mockConfig, mockRequest);
|
|
176
|
+
|
|
177
|
+
// Fail-open: should return error, not throw
|
|
178
|
+
expect(response.result).toBeNull();
|
|
179
|
+
expect(response.error).toBeDefined();
|
|
180
|
+
expect(response.error).toContain('API error');
|
|
181
|
+
expect(mockCreate).toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('handles rate limiting gracefully', async () => {
|
|
185
|
+
const rateLimitError = new Error('Rate limit exceeded');
|
|
186
|
+
(rateLimitError as Error & { status: number }).status = 429;
|
|
187
|
+
|
|
188
|
+
const mockCreate = jest.fn().mockRejectedValue(rateLimitError);
|
|
189
|
+
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => ({
|
|
190
|
+
chat: {
|
|
191
|
+
completions: {
|
|
192
|
+
create: mockCreate
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} as unknown as jest.Mocked<OpenAI>));
|
|
196
|
+
|
|
197
|
+
const response = await analyzeChanges(mockConfig, mockRequest);
|
|
198
|
+
|
|
199
|
+
expect(response.result).toBeNull();
|
|
200
|
+
expect(response.error).toBeDefined();
|
|
201
|
+
expect(response.error).toContain('Rate limit exceeded');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('handles timeout gracefully', async () => {
|
|
205
|
+
const timeoutError = new Error('Request timeout');
|
|
206
|
+
(timeoutError as Error & { code: string }).code = 'ETIMEDOUT';
|
|
207
|
+
|
|
208
|
+
const mockCreate = jest.fn().mockRejectedValue(timeoutError);
|
|
209
|
+
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => ({
|
|
210
|
+
chat: {
|
|
211
|
+
completions: {
|
|
212
|
+
create: mockCreate
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} as unknown as jest.Mocked<OpenAI>));
|
|
216
|
+
|
|
217
|
+
const response = await analyzeChanges(mockConfig, mockRequest);
|
|
218
|
+
|
|
219
|
+
expect(response.result).toBeNull();
|
|
220
|
+
expect(response.error).toBeDefined();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('validates response format and rejects invalid JSON', async () => {
|
|
224
|
+
const mockResponse = {
|
|
225
|
+
choices: [{
|
|
226
|
+
message: {
|
|
227
|
+
content: 'This is not valid JSON'
|
|
228
|
+
}
|
|
229
|
+
}]
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const mockCreate = jest.fn().mockResolvedValue(mockResponse);
|
|
233
|
+
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => ({
|
|
234
|
+
chat: {
|
|
235
|
+
completions: {
|
|
236
|
+
create: mockCreate
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
} as unknown as jest.Mocked<OpenAI>));
|
|
240
|
+
|
|
241
|
+
const response = await analyzeChanges(mockConfig, mockRequest);
|
|
242
|
+
|
|
243
|
+
expect(response.result).toBeNull();
|
|
244
|
+
expect(response.error).toBeDefined();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('validates response format and rejects missing fields', async () => {
|
|
248
|
+
const mockResponse = {
|
|
249
|
+
choices: [{
|
|
250
|
+
message: {
|
|
251
|
+
content: JSON.stringify({
|
|
252
|
+
is_significant: true
|
|
253
|
+
// Missing 'reason' field
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
}]
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const mockCreate = jest.fn().mockResolvedValue(mockResponse);
|
|
260
|
+
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => ({
|
|
261
|
+
chat: {
|
|
262
|
+
completions: {
|
|
263
|
+
create: mockCreate
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
} as unknown as jest.Mocked<OpenAI>));
|
|
267
|
+
|
|
268
|
+
const response = await analyzeChanges(mockConfig, mockRequest);
|
|
269
|
+
|
|
270
|
+
expect(response.result).toBeNull();
|
|
271
|
+
expect(response.error).toBeDefined();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('respects timeout configuration', async () => {
|
|
275
|
+
const customConfig = { ...mockConfig, timeout_seconds: 5 };
|
|
276
|
+
|
|
277
|
+
const mockCreate = jest.fn().mockResolvedValue({
|
|
278
|
+
choices: [{
|
|
279
|
+
message: {
|
|
280
|
+
content: JSON.stringify({
|
|
281
|
+
is_significant: true,
|
|
282
|
+
reason: 'Test'
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
}]
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => ({
|
|
289
|
+
chat: {
|
|
290
|
+
completions: {
|
|
291
|
+
create: mockCreate
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
} as unknown as jest.Mocked<OpenAI>));
|
|
295
|
+
|
|
296
|
+
await analyzeChanges(customConfig, mockRequest);
|
|
297
|
+
|
|
298
|
+
// Verify timeout was passed to OpenAI client
|
|
299
|
+
expect(mockCreate).toHaveBeenCalledWith(
|
|
300
|
+
expect.objectContaining({
|
|
301
|
+
model: 'gpt-4',
|
|
302
|
+
messages: expect.any(Array)
|
|
303
|
+
}),
|
|
304
|
+
expect.objectContaining({
|
|
305
|
+
timeout: 5000 // milliseconds
|
|
306
|
+
})
|
|
307
|
+
);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('returns null when API key is missing', async () => {
|
|
311
|
+
delete process.env.OPENAI_API_KEY;
|
|
312
|
+
|
|
313
|
+
const response = await analyzeChanges(mockConfig, mockRequest);
|
|
314
|
+
|
|
315
|
+
expect(response.result).toBeNull();
|
|
316
|
+
expect(response.error).toBeDefined();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('includes confidence score when provided by LLM', async () => {
|
|
320
|
+
const mockResponse = {
|
|
321
|
+
choices: [{
|
|
322
|
+
message: {
|
|
323
|
+
content: JSON.stringify({
|
|
324
|
+
is_significant: true,
|
|
325
|
+
reason: 'Major change',
|
|
326
|
+
confidence: 0.95
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
}]
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const mockCreate = jest.fn().mockResolvedValue(mockResponse);
|
|
333
|
+
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => ({
|
|
334
|
+
chat: {
|
|
335
|
+
completions: {
|
|
336
|
+
create: mockCreate
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
} as unknown as jest.Mocked<OpenAI>));
|
|
340
|
+
|
|
341
|
+
const response = await analyzeChanges(mockConfig, mockRequest);
|
|
342
|
+
|
|
343
|
+
expect(response.result).not.toBeNull();
|
|
344
|
+
expect(response.error).toBeUndefined();
|
|
345
|
+
expect(response.result?.confidence).toBe(0.95);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('supports Gemini provider with valid JSON response', async () => {
|
|
349
|
+
const geminiConfig: AnalysisConfig = {
|
|
350
|
+
provider: 'gemini',
|
|
351
|
+
analysis_model: 'gemini-1.5-pro',
|
|
352
|
+
api_key_env: 'GEMINI_API_KEY',
|
|
353
|
+
timeout_seconds: 10,
|
|
354
|
+
};
|
|
355
|
+
(GoogleGenerativeAI as unknown as jest.Mock).mockImplementation(() => ({
|
|
356
|
+
getGenerativeModel: () => ({
|
|
357
|
+
generateContent: jest.fn().mockResolvedValue({
|
|
358
|
+
response: {
|
|
359
|
+
text: () => JSON.stringify({ is_significant: false, reason: '' }),
|
|
360
|
+
},
|
|
361
|
+
}),
|
|
362
|
+
}),
|
|
363
|
+
}));
|
|
364
|
+
|
|
365
|
+
process.env.GEMINI_API_KEY = 'test-api-key';
|
|
366
|
+
const response = await analyzeChanges(geminiConfig, mockRequest);
|
|
367
|
+
expect(response.result).not.toBeNull();
|
|
368
|
+
expect(response.result?.is_significant).toBe(false);
|
|
369
|
+
delete process.env.GEMINI_API_KEY;
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test('handles Gemini timeout gracefully', async () => {
|
|
373
|
+
const geminiConfig: AnalysisConfig = {
|
|
374
|
+
provider: 'gemini',
|
|
375
|
+
analysis_model: 'gemini-1.5-pro',
|
|
376
|
+
api_key_env: 'GEMINI_API_KEY',
|
|
377
|
+
timeout_seconds: 1,
|
|
378
|
+
};
|
|
379
|
+
type GeminiResponse = { response: { text: () => string } };
|
|
380
|
+
const slowPromise: Promise<GeminiResponse> = new Promise((resolve) => setTimeout(() => resolve({
|
|
381
|
+
response: { text: () => JSON.stringify({ is_significant: true, reason: 'Test' }) }
|
|
382
|
+
}), 3000));
|
|
383
|
+
(GoogleGenerativeAI as unknown as jest.Mock).mockImplementation(() => ({
|
|
384
|
+
getGenerativeModel: () => ({
|
|
385
|
+
generateContent: jest.fn().mockReturnValue(slowPromise),
|
|
386
|
+
}),
|
|
387
|
+
}));
|
|
388
|
+
|
|
389
|
+
process.env.GEMINI_API_KEY = 'test-api-key';
|
|
390
|
+
const response = await analyzeChanges(geminiConfig, mockRequest);
|
|
391
|
+
expect(response.result).toBeNull();
|
|
392
|
+
expect(response.error).toMatch(/timeout/i);
|
|
393
|
+
delete process.env.GEMINI_API_KEY;
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
describe('Prompt Construction', () => {
|
|
398
|
+
test('constructs proper prompt with file paths and diff', async () => {
|
|
399
|
+
const mockCreate = jest.fn().mockResolvedValue({
|
|
400
|
+
choices: [{
|
|
401
|
+
message: {
|
|
402
|
+
content: JSON.stringify({
|
|
403
|
+
is_significant: false,
|
|
404
|
+
reason: 'Test'
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
}]
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => ({
|
|
411
|
+
chat: {
|
|
412
|
+
completions: {
|
|
413
|
+
create: mockCreate
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
} as unknown as jest.Mocked<OpenAI>));
|
|
417
|
+
|
|
418
|
+
await analyzeChanges(mockConfig, mockRequest);
|
|
419
|
+
|
|
420
|
+
expect(mockCreate).toHaveBeenCalled();
|
|
421
|
+
const callArgs = mockCreate.mock.calls[0][0];
|
|
422
|
+
expect(callArgs.messages).toBeDefined();
|
|
423
|
+
expect(callArgs.messages.length).toBeGreaterThan(0);
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
describe('generateADRContent', () => {
|
|
428
|
+
const mockGenerationRequest: GenerationRequest = {
|
|
429
|
+
file_paths: ['src/database.ts'],
|
|
430
|
+
diff_content: `
|
|
431
|
+
diff --git a/src/database.ts b/src/database.ts
|
|
432
|
+
+import pg from 'pg';
|
|
433
|
+
+export const database = new pg.Pool();
|
|
434
|
+
`,
|
|
435
|
+
reason: 'Introduces PostgreSQL as primary datastore',
|
|
436
|
+
generation_prompt: 'Generate an ADR for the following changes...'
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
beforeEach(() => {
|
|
440
|
+
process.env.OPENAI_API_KEY = 'test-api-key';
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
afterEach(() => {
|
|
444
|
+
delete process.env.OPENAI_API_KEY;
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test('generates ADR content successfully', async () => {
|
|
448
|
+
const mockADRContent = `# Use PostgreSQL as Primary Datastore
|
|
449
|
+
|
|
450
|
+
* Status: accepted
|
|
451
|
+
* Date: 2025-10-21
|
|
452
|
+
|
|
453
|
+
## Context and Problem Statement
|
|
454
|
+
|
|
455
|
+
We need a reliable database for our application.
|
|
456
|
+
|
|
457
|
+
## Decision Drivers
|
|
458
|
+
|
|
459
|
+
* Need for ACID compliance
|
|
460
|
+
* Strong community support
|
|
461
|
+
* Excellent performance
|
|
462
|
+
|
|
463
|
+
## Considered Options
|
|
464
|
+
|
|
465
|
+
* PostgreSQL
|
|
466
|
+
* MySQL
|
|
467
|
+
* MongoDB
|
|
468
|
+
|
|
469
|
+
## Decision Outcome
|
|
470
|
+
|
|
471
|
+
Chosen option: "PostgreSQL", because it provides strong ACID guarantees and excellent JSON support.
|
|
472
|
+
|
|
473
|
+
### Consequences
|
|
474
|
+
|
|
475
|
+
* Good, because we get reliable transactions
|
|
476
|
+
* Good, because we have strong typing
|
|
477
|
+
* Bad, because requires more setup than NoSQL`;
|
|
478
|
+
|
|
479
|
+
const mockResponse = {
|
|
480
|
+
choices: [{
|
|
481
|
+
message: {
|
|
482
|
+
content: mockADRContent
|
|
483
|
+
}
|
|
484
|
+
}]
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const mockCreate = jest.fn().mockResolvedValue(mockResponse);
|
|
488
|
+
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => ({
|
|
489
|
+
chat: {
|
|
490
|
+
completions: {
|
|
491
|
+
create: mockCreate
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
} as unknown as jest.Mocked<OpenAI>));
|
|
495
|
+
|
|
496
|
+
const response = await generateADRContent(mockConfig, mockGenerationRequest);
|
|
497
|
+
|
|
498
|
+
expect(response.result).not.toBeNull();
|
|
499
|
+
expect(response.error).toBeUndefined();
|
|
500
|
+
expect(response.result?.content).toBe(mockADRContent);
|
|
501
|
+
expect(response.result?.title).toBe('Use PostgreSQL as Primary Datastore');
|
|
502
|
+
expect(response.result?.timestamp).toBeDefined();
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test('extracts title from markdown heading', async () => {
|
|
506
|
+
const mockADRContent = `# Switch to TypeScript
|
|
507
|
+
|
|
508
|
+
* Status: accepted
|
|
509
|
+
|
|
510
|
+
Some content...`;
|
|
511
|
+
|
|
512
|
+
const mockResponse = {
|
|
513
|
+
choices: [{
|
|
514
|
+
message: {
|
|
515
|
+
content: mockADRContent
|
|
516
|
+
}
|
|
517
|
+
}]
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const mockCreate = jest.fn().mockResolvedValue(mockResponse);
|
|
521
|
+
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => ({
|
|
522
|
+
chat: {
|
|
523
|
+
completions: {
|
|
524
|
+
create: mockCreate
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
} as unknown as jest.Mocked<OpenAI>));
|
|
528
|
+
|
|
529
|
+
const response = await generateADRContent(mockConfig, mockGenerationRequest);
|
|
530
|
+
|
|
531
|
+
expect(response.result?.title).toBe('Switch to TypeScript');
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test('handles missing title gracefully', async () => {
|
|
535
|
+
const mockADRContent = `Some content without a title heading
|
|
536
|
+
|
|
537
|
+
* Status: accepted`;
|
|
538
|
+
|
|
539
|
+
const mockResponse = {
|
|
540
|
+
choices: [{
|
|
541
|
+
message: {
|
|
542
|
+
content: mockADRContent
|
|
543
|
+
}
|
|
544
|
+
}]
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
const mockCreate = jest.fn().mockResolvedValue(mockResponse);
|
|
548
|
+
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => ({
|
|
549
|
+
chat: {
|
|
550
|
+
completions: {
|
|
551
|
+
create: mockCreate
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
} as unknown as jest.Mocked<OpenAI>));
|
|
555
|
+
|
|
556
|
+
const response = await generateADRContent(mockConfig, mockGenerationRequest);
|
|
557
|
+
|
|
558
|
+
expect(response.result?.title).toBe('Untitled Decision');
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test('removes markdown code fences if present', async () => {
|
|
562
|
+
const mockADRContent = `# My Decision
|
|
563
|
+
|
|
564
|
+
* Status: accepted`;
|
|
565
|
+
|
|
566
|
+
const mockResponse = {
|
|
567
|
+
choices: [{
|
|
568
|
+
message: {
|
|
569
|
+
content: '```markdown\n' + mockADRContent + '\n```'
|
|
570
|
+
}
|
|
571
|
+
}]
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
const mockCreate = jest.fn().mockResolvedValue(mockResponse);
|
|
575
|
+
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => ({
|
|
576
|
+
chat: {
|
|
577
|
+
completions: {
|
|
578
|
+
create: mockCreate
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
} as unknown as jest.Mocked<OpenAI>));
|
|
582
|
+
|
|
583
|
+
const response = await generateADRContent(mockConfig, mockGenerationRequest);
|
|
584
|
+
|
|
585
|
+
expect(response.result?.content).toBe(mockADRContent);
|
|
586
|
+
expect(response.result?.content).not.toContain('```');
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test('handles API failures gracefully', async () => {
|
|
590
|
+
const mockCreate = jest.fn().mockRejectedValue(new Error('API Error'));
|
|
591
|
+
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => ({
|
|
592
|
+
chat: {
|
|
593
|
+
completions: {
|
|
594
|
+
create: mockCreate
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
} as unknown as jest.Mocked<OpenAI>));
|
|
598
|
+
|
|
599
|
+
const response = await generateADRContent(mockConfig, mockGenerationRequest);
|
|
600
|
+
|
|
601
|
+
expect(response.result).toBeNull();
|
|
602
|
+
expect(response.error).toBeDefined();
|
|
603
|
+
expect(response.error).toContain('API error');
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test('handles missing API key', async () => {
|
|
607
|
+
delete process.env.OPENAI_API_KEY;
|
|
608
|
+
|
|
609
|
+
const response = await generateADRContent(mockConfig, mockGenerationRequest);
|
|
610
|
+
|
|
611
|
+
expect(response.result).toBeNull();
|
|
612
|
+
expect(response.error).toContain('API key not found');
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test('handles timeout errors', async () => {
|
|
616
|
+
const mockCreate = jest.fn().mockRejectedValue({ code: 'ETIMEDOUT' });
|
|
617
|
+
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => ({
|
|
618
|
+
chat: {
|
|
619
|
+
completions: {
|
|
620
|
+
create: mockCreate
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
} as unknown as jest.Mocked<OpenAI>));
|
|
624
|
+
|
|
625
|
+
const response = await generateADRContent(mockConfig, mockGenerationRequest);
|
|
626
|
+
|
|
627
|
+
expect(response.result).toBeNull();
|
|
628
|
+
expect(response.error).toMatch(/timeout/i);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
test('follows fail-open principle on all errors', async () => {
|
|
632
|
+
const mockCreate = jest.fn().mockRejectedValue(new Error('Unexpected error'));
|
|
633
|
+
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => ({
|
|
634
|
+
chat: {
|
|
635
|
+
completions: {
|
|
636
|
+
create: mockCreate
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
} as unknown as jest.Mocked<OpenAI>));
|
|
640
|
+
|
|
641
|
+
// Should not throw, should return error response
|
|
642
|
+
await expect(generateADRContent(mockConfig, mockGenerationRequest)).resolves.toEqual({
|
|
643
|
+
result: null,
|
|
644
|
+
error: expect.any(String)
|
|
645
|
+
});
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
test('respects timeout configuration', async () => {
|
|
649
|
+
const mockCreate = jest.fn().mockResolvedValue({
|
|
650
|
+
choices: [{
|
|
651
|
+
message: {
|
|
652
|
+
content: '# Test\n\nContent'
|
|
653
|
+
}
|
|
654
|
+
}]
|
|
655
|
+
});
|
|
656
|
+
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => ({
|
|
657
|
+
chat: {
|
|
658
|
+
completions: {
|
|
659
|
+
create: mockCreate
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
} as unknown as jest.Mocked<OpenAI>));
|
|
663
|
+
|
|
664
|
+
await generateADRContent(mockConfig, mockGenerationRequest);
|
|
665
|
+
|
|
666
|
+
expect(mockCreate).toHaveBeenCalledWith(
|
|
667
|
+
expect.any(Object),
|
|
668
|
+
expect.objectContaining({
|
|
669
|
+
timeout: 15000 // 15 seconds from config
|
|
670
|
+
})
|
|
671
|
+
);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test('uses same model as analysis', async () => {
|
|
675
|
+
const mockCreate = jest.fn().mockResolvedValue({
|
|
676
|
+
choices: [{
|
|
677
|
+
message: {
|
|
678
|
+
content: '# Test\n\nContent'
|
|
679
|
+
}
|
|
680
|
+
}]
|
|
681
|
+
});
|
|
682
|
+
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => ({
|
|
683
|
+
chat: {
|
|
684
|
+
completions: {
|
|
685
|
+
create: mockCreate
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
} as unknown as jest.Mocked<OpenAI>));
|
|
689
|
+
|
|
690
|
+
await generateADRContent(mockConfig, mockGenerationRequest);
|
|
691
|
+
|
|
692
|
+
expect(mockCreate).toHaveBeenCalledWith(
|
|
693
|
+
expect.objectContaining({
|
|
694
|
+
model: 'gpt-4' // Same as analysis_model
|
|
695
|
+
}),
|
|
696
|
+
expect.any(Object)
|
|
697
|
+
);
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
});
|
|
701
|
+
|