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.
Files changed (121) hide show
  1. package/dist/adr.d.ts +50 -0
  2. package/dist/adr.d.ts.map +1 -0
  3. package/dist/adr.js +156 -0
  4. package/dist/adr.js.map +1 -0
  5. package/dist/adr.test.d.ts +8 -0
  6. package/dist/adr.test.d.ts.map +1 -0
  7. package/dist/adr.test.js +256 -0
  8. package/dist/adr.test.js.map +1 -0
  9. package/dist/analysis.d.ts +24 -0
  10. package/dist/analysis.d.ts.map +1 -0
  11. package/dist/analysis.js +281 -0
  12. package/dist/analysis.js.map +1 -0
  13. package/dist/analysis.test.d.ts +8 -0
  14. package/dist/analysis.test.d.ts.map +1 -0
  15. package/dist/analysis.test.js +351 -0
  16. package/dist/analysis.test.js.map +1 -0
  17. package/dist/commands/analyze.d.ts +14 -0
  18. package/dist/commands/analyze.d.ts.map +1 -0
  19. package/dist/commands/analyze.js +56 -0
  20. package/dist/commands/analyze.js.map +1 -0
  21. package/dist/commands/init.d.ts +12 -0
  22. package/dist/commands/init.d.ts.map +1 -0
  23. package/dist/commands/init.js +93 -0
  24. package/dist/commands/init.js.map +1 -0
  25. package/dist/commands/init.test.d.ts +2 -0
  26. package/dist/commands/init.test.d.ts.map +1 -0
  27. package/dist/commands/init.test.js +56 -0
  28. package/dist/commands/init.test.js.map +1 -0
  29. package/dist/config.d.ts +40 -0
  30. package/dist/config.d.ts.map +1 -0
  31. package/dist/config.js +208 -0
  32. package/dist/config.js.map +1 -0
  33. package/dist/config.test.d.ts +2 -0
  34. package/dist/config.test.d.ts.map +1 -0
  35. package/dist/config.test.js +97 -0
  36. package/dist/config.test.js.map +1 -0
  37. package/dist/git.d.ts +42 -0
  38. package/dist/git.d.ts.map +1 -1
  39. package/dist/git.js +157 -0
  40. package/dist/git.js.map +1 -1
  41. package/dist/index.d.ts +2 -3
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +78 -62
  44. package/dist/index.js.map +1 -1
  45. package/dist/index.test.d.ts +2 -0
  46. package/dist/index.test.d.ts.map +1 -0
  47. package/dist/index.test.js +51 -0
  48. package/dist/index.test.js.map +1 -0
  49. package/dist/llm.d.ts +73 -0
  50. package/dist/llm.d.ts.map +1 -0
  51. package/dist/llm.js +263 -0
  52. package/dist/llm.js.map +1 -0
  53. package/dist/llm.test.d.ts +2 -0
  54. package/dist/llm.test.d.ts.map +1 -0
  55. package/dist/llm.test.js +592 -0
  56. package/dist/llm.test.js.map +1 -0
  57. package/dist/logger.d.ts.map +1 -1
  58. package/dist/logger.js +5 -3
  59. package/dist/logger.js.map +1 -1
  60. package/dist/logger.test.d.ts +2 -0
  61. package/dist/logger.test.d.ts.map +1 -0
  62. package/dist/logger.test.js +78 -0
  63. package/dist/logger.test.js.map +1 -0
  64. package/dist/prompts.d.ts +49 -0
  65. package/dist/prompts.d.ts.map +1 -0
  66. package/dist/prompts.js +195 -0
  67. package/dist/prompts.js.map +1 -0
  68. package/dist/prompts.test.d.ts +2 -0
  69. package/dist/prompts.test.d.ts.map +1 -0
  70. package/dist/prompts.test.js +427 -0
  71. package/dist/prompts.test.js.map +1 -0
  72. package/dist/providers/gemini.d.ts +3 -0
  73. package/dist/providers/gemini.d.ts.map +1 -0
  74. package/dist/providers/gemini.js +39 -0
  75. package/dist/providers/gemini.js.map +1 -0
  76. package/dist/providers/index.d.ts +2 -0
  77. package/dist/providers/index.d.ts.map +1 -0
  78. package/dist/providers/index.js +6 -0
  79. package/dist/providers/index.js.map +1 -0
  80. package/dist/providers/openai.d.ts +3 -0
  81. package/dist/providers/openai.d.ts.map +1 -0
  82. package/dist/providers/openai.js +25 -0
  83. package/dist/providers/openai.js.map +1 -0
  84. package/dist/providers/registry.d.ts +4 -0
  85. package/dist/providers/registry.d.ts.map +1 -0
  86. package/dist/providers/registry.js +16 -0
  87. package/dist/providers/registry.js.map +1 -0
  88. package/dist/providers/types.d.ts +12 -0
  89. package/dist/providers/types.d.ts.map +1 -0
  90. package/dist/providers/types.js +5 -0
  91. package/dist/providers/types.js.map +1 -0
  92. package/dist/version.test.d.ts +3 -0
  93. package/dist/version.test.d.ts.map +1 -0
  94. package/dist/version.test.js +25 -0
  95. package/dist/version.test.js.map +1 -0
  96. package/package.json +14 -5
  97. package/src/adr.test.ts +278 -0
  98. package/src/adr.ts +136 -0
  99. package/src/analysis.test.ts +396 -0
  100. package/src/analysis.ts +262 -0
  101. package/src/commands/analyze.ts +56 -0
  102. package/src/commands/init.test.ts +27 -0
  103. package/src/commands/init.ts +99 -0
  104. package/src/config.test.ts +79 -0
  105. package/src/config.ts +214 -0
  106. package/src/git.ts +240 -0
  107. package/src/index.test.ts +59 -0
  108. package/src/index.ts +80 -60
  109. package/src/llm.test.ts +701 -0
  110. package/src/llm.ts +344 -0
  111. package/src/logger.test.ts +90 -0
  112. package/src/logger.ts +6 -3
  113. package/src/prompts.test.ts +515 -0
  114. package/src/prompts.ts +174 -0
  115. package/src/providers/gemini.ts +41 -0
  116. package/src/providers/index.ts +1 -0
  117. package/src/providers/openai.ts +22 -0
  118. package/src/providers/registry.ts +16 -0
  119. package/src/providers/types.ts +14 -0
  120. package/src/version.test.ts +29 -0
  121. package/bin/cadr.js +0 -16
@@ -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
+