cadr-cli 2.0.0 → 2.0.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 (153) hide show
  1. package/dist/adr/adr.d.ts +17 -0
  2. package/dist/adr/adr.d.ts.map +1 -0
  3. package/dist/{adr.js → adr/adr.js} +4 -44
  4. package/dist/adr/adr.js.map +1 -0
  5. package/dist/adr/adr.test.d.ts +5 -0
  6. package/dist/{adr.test.d.ts.map → adr/adr.test.d.ts.map} +1 -1
  7. package/dist/{adr.test.js → adr/adr.test.js} +0 -14
  8. package/dist/adr/adr.test.js.map +1 -0
  9. package/dist/adr/index.d.ts +2 -0
  10. package/dist/adr/index.d.ts.map +1 -0
  11. package/dist/adr/index.js +18 -0
  12. package/dist/adr/index.js.map +1 -0
  13. package/dist/analysis/analysis.orchestrator.d.ts +14 -0
  14. package/dist/analysis/analysis.orchestrator.d.ts.map +1 -0
  15. package/dist/analysis/analysis.orchestrator.js +175 -0
  16. package/dist/analysis/analysis.orchestrator.js.map +1 -0
  17. package/dist/analysis/analysis.orchestrator.test.d.ts +2 -0
  18. package/dist/analysis/analysis.orchestrator.test.d.ts.map +1 -0
  19. package/dist/analysis/analysis.orchestrator.test.js +177 -0
  20. package/dist/analysis/analysis.orchestrator.test.js.map +1 -0
  21. package/dist/analysis/strategies/git-strategy.d.ts +22 -0
  22. package/dist/analysis/strategies/git-strategy.d.ts.map +1 -0
  23. package/dist/analysis/strategies/git-strategy.js +114 -0
  24. package/dist/analysis/strategies/git-strategy.js.map +1 -0
  25. package/dist/analysis/strategies/git-strategy.test.d.ts +2 -0
  26. package/dist/analysis/strategies/git-strategy.test.d.ts.map +1 -0
  27. package/dist/analysis/strategies/git-strategy.test.js +147 -0
  28. package/dist/analysis/strategies/git-strategy.test.js.map +1 -0
  29. package/dist/commands/analyze.js +3 -3
  30. package/dist/commands/analyze.js.map +1 -1
  31. package/dist/commands/analyze.test.d.ts +2 -0
  32. package/dist/commands/analyze.test.d.ts.map +1 -0
  33. package/dist/commands/analyze.test.js +70 -0
  34. package/dist/commands/analyze.test.js.map +1 -0
  35. package/dist/commands/init.test.js +128 -2
  36. package/dist/commands/init.test.js.map +1 -1
  37. package/dist/config.test.js +167 -0
  38. package/dist/config.test.js.map +1 -1
  39. package/dist/git/git.errors.d.ts +6 -0
  40. package/dist/git/git.errors.d.ts.map +1 -0
  41. package/dist/git/git.errors.js +15 -0
  42. package/dist/git/git.errors.js.map +1 -0
  43. package/dist/git/git.errors.test.d.ts +2 -0
  44. package/dist/git/git.errors.test.d.ts.map +1 -0
  45. package/dist/git/git.errors.test.js +34 -0
  46. package/dist/git/git.errors.test.js.map +1 -0
  47. package/dist/git/git.operations.d.ts +12 -0
  48. package/dist/git/git.operations.d.ts.map +1 -0
  49. package/dist/git/git.operations.js +64 -0
  50. package/dist/git/git.operations.js.map +1 -0
  51. package/dist/git/git.operations.test.d.ts +2 -0
  52. package/dist/git/git.operations.test.d.ts.map +1 -0
  53. package/dist/git/git.operations.test.js +164 -0
  54. package/dist/git/git.operations.test.js.map +1 -0
  55. package/dist/git/index.d.ts +4 -0
  56. package/dist/git/index.d.ts.map +1 -0
  57. package/dist/git/index.js +19 -0
  58. package/dist/git/index.js.map +1 -0
  59. package/dist/llm/index.d.ts +3 -0
  60. package/dist/llm/index.d.ts.map +1 -0
  61. package/dist/llm/index.js +19 -0
  62. package/dist/llm/index.js.map +1 -0
  63. package/dist/llm/llm.d.ts +35 -0
  64. package/dist/llm/llm.d.ts.map +1 -0
  65. package/dist/{llm.js → llm/llm.js} +16 -58
  66. package/dist/llm/llm.js.map +1 -0
  67. package/dist/{llm.test.d.ts.map → llm/llm.test.d.ts.map} +1 -1
  68. package/dist/llm/llm.test.js +224 -0
  69. package/dist/llm/llm.test.js.map +1 -0
  70. package/dist/{prompts.d.ts → llm/prompts.d.ts} +1 -38
  71. package/dist/llm/prompts.d.ts.map +1 -0
  72. package/dist/{prompts.js → llm/prompts.js} +9 -54
  73. package/dist/llm/prompts.js.map +1 -0
  74. package/dist/llm/response-parser.d.ts +9 -0
  75. package/dist/llm/response-parser.d.ts.map +1 -0
  76. package/dist/llm/response-parser.js +67 -0
  77. package/dist/llm/response-parser.js.map +1 -0
  78. package/dist/llm/response-parser.test.d.ts +2 -0
  79. package/dist/llm/response-parser.test.d.ts.map +1 -0
  80. package/dist/llm/response-parser.test.js +134 -0
  81. package/dist/llm/response-parser.test.js.map +1 -0
  82. package/dist/presenters/console-presenter.d.ts +35 -0
  83. package/dist/presenters/console-presenter.d.ts.map +1 -0
  84. package/dist/presenters/console-presenter.js +114 -0
  85. package/dist/presenters/console-presenter.js.map +1 -0
  86. package/dist/presenters/console-presenter.test.d.ts +2 -0
  87. package/dist/presenters/console-presenter.test.d.ts.map +1 -0
  88. package/dist/presenters/console-presenter.test.js +227 -0
  89. package/dist/presenters/console-presenter.test.js.map +1 -0
  90. package/dist/version.test.d.ts +1 -2
  91. package/dist/version.test.d.ts.map +1 -1
  92. package/dist/version.test.js +29 -16
  93. package/dist/version.test.js.map +1 -1
  94. package/package.json +1 -1
  95. package/src/{adr.test.ts → adr/adr.test.ts} +10 -23
  96. package/src/{adr.ts → adr/adr.ts} +7 -48
  97. package/src/adr/index.ts +1 -0
  98. package/src/analysis/analysis.orchestrator.test.ts +237 -0
  99. package/src/analysis/analysis.orchestrator.ts +175 -0
  100. package/src/analysis/strategies/git-strategy.test.ts +210 -0
  101. package/src/analysis/strategies/git-strategy.ts +106 -0
  102. package/src/commands/analyze.test.ts +91 -0
  103. package/src/commands/analyze.ts +8 -9
  104. package/src/commands/init.test.ts +200 -5
  105. package/src/config.test.ts +232 -2
  106. package/src/git/git.errors.test.ts +43 -0
  107. package/src/git/git.errors.ts +10 -0
  108. package/src/git/git.operations.test.ts +222 -0
  109. package/src/git/git.operations.ts +85 -0
  110. package/src/git/index.ts +3 -0
  111. package/src/llm/index.ts +2 -0
  112. package/src/llm/llm.test.ts +315 -0
  113. package/src/{llm.ts → llm/llm.ts} +46 -107
  114. package/src/{prompts.ts → llm/prompts.ts} +30 -72
  115. package/src/llm/response-parser.test.ts +170 -0
  116. package/src/llm/response-parser.ts +90 -0
  117. package/src/presenters/console-presenter.test.ts +259 -0
  118. package/src/presenters/console-presenter.ts +152 -0
  119. package/src/version.test.ts +30 -16
  120. package/dist/adr.d.ts +0 -50
  121. package/dist/adr.d.ts.map +0 -1
  122. package/dist/adr.js.map +0 -1
  123. package/dist/adr.test.d.ts +0 -8
  124. package/dist/adr.test.js.map +0 -1
  125. package/dist/analysis.d.ts +0 -24
  126. package/dist/analysis.d.ts.map +0 -1
  127. package/dist/analysis.js +0 -281
  128. package/dist/analysis.js.map +0 -1
  129. package/dist/analysis.test.d.ts +0 -8
  130. package/dist/analysis.test.d.ts.map +0 -1
  131. package/dist/analysis.test.js +0 -351
  132. package/dist/analysis.test.js.map +0 -1
  133. package/dist/git.d.ts +0 -54
  134. package/dist/git.d.ts.map +0 -1
  135. package/dist/git.js +0 -204
  136. package/dist/git.js.map +0 -1
  137. package/dist/llm.d.ts +0 -73
  138. package/dist/llm.d.ts.map +0 -1
  139. package/dist/llm.js.map +0 -1
  140. package/dist/llm.test.js +0 -592
  141. package/dist/llm.test.js.map +0 -1
  142. package/dist/prompts.d.ts.map +0 -1
  143. package/dist/prompts.js.map +0 -1
  144. package/dist/prompts.test.d.ts +0 -2
  145. package/dist/prompts.test.d.ts.map +0 -1
  146. package/dist/prompts.test.js +0 -427
  147. package/dist/prompts.test.js.map +0 -1
  148. package/src/analysis.test.ts +0 -396
  149. package/src/analysis.ts +0 -262
  150. package/src/git.ts +0 -300
  151. package/src/llm.test.ts +0 -701
  152. package/src/prompts.test.ts +0 -515
  153. /package/dist/{llm.test.d.ts → llm/llm.test.d.ts} +0 -0
@@ -0,0 +1,315 @@
1
+ jest.mock('../providers', () => ({
2
+ getProvider: jest.fn(),
3
+ }));
4
+
5
+ jest.mock('../logger', () => ({
6
+ loggerInstance: {
7
+ info: jest.fn(),
8
+ warn: jest.fn(),
9
+ error: jest.fn(),
10
+ debug: jest.fn(),
11
+ },
12
+ }));
13
+
14
+ import { getProvider } from '../providers';
15
+ import { loggerInstance as logger } from '../logger';
16
+ import { analyzeChanges, generateADRContent } from './llm';
17
+ import type { AnalysisConfig } from '../config';
18
+ import type { LLMProvider } from '../providers/types';
19
+
20
+ const mockedGetProvider = getProvider as jest.MockedFunction<typeof getProvider>;
21
+ const mockedLogger = logger as jest.Mocked<typeof logger>;
22
+
23
+ const config: AnalysisConfig = {
24
+ provider: 'openai',
25
+ analysis_model: 'gpt-4',
26
+ api_key_env: 'TEST_API_KEY',
27
+ timeout_seconds: 15,
28
+ };
29
+
30
+ const baseAnalysisRequest = {
31
+ file_paths: ['src/index.ts'],
32
+ diff_content: 'diff --git a/src/index.ts',
33
+ repository_context: 'test repo',
34
+ analysis_prompt: 'Analyze these changes',
35
+ };
36
+
37
+ const baseGenerationRequest = {
38
+ file_paths: ['src/index.ts'],
39
+ diff_content: 'diff --git a/src/index.ts',
40
+ reason: 'significant architectural change',
41
+ generation_prompt: 'Generate ADR for these changes',
42
+ };
43
+
44
+ function createMockProvider(analyzeFn: jest.Mock): LLMProvider {
45
+ return {
46
+ name: 'openai',
47
+ analyze: analyzeFn,
48
+ };
49
+ }
50
+
51
+ beforeEach(() => {
52
+ jest.clearAllMocks();
53
+ process.env.TEST_API_KEY = 'test-key';
54
+ });
55
+
56
+ afterEach(() => {
57
+ delete process.env.TEST_API_KEY;
58
+ jest.restoreAllMocks();
59
+ });
60
+
61
+ describe('analyzeChanges', () => {
62
+ it('should return error when API key is not set', async () => {
63
+ delete process.env.TEST_API_KEY;
64
+
65
+ const response = await analyzeChanges(config, baseAnalysisRequest);
66
+
67
+ expect(response.result).toBeNull();
68
+ expect(response.error).toContain('TEST_API_KEY');
69
+ });
70
+
71
+ it('should return successful result when provider returns clean JSON', async () => {
72
+ const mockAnalyze = jest.fn().mockResolvedValue(
73
+ JSON.stringify({ is_significant: true, reason: 'big' })
74
+ );
75
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
76
+
77
+ const response = await analyzeChanges(config, baseAnalysisRequest);
78
+
79
+ expect(response.result).not.toBeNull();
80
+ expect(response.result!.is_significant).toBe(true);
81
+ expect(response.result!.reason).toBe('big');
82
+ expect(response.result!.timestamp).toBeDefined();
83
+ expect(response.error).toBeUndefined();
84
+ });
85
+
86
+ it('should parse JSON wrapped in a code block', async () => {
87
+ const mockAnalyze = jest.fn().mockResolvedValue(
88
+ '```json\n{"is_significant": true, "reason": "refactored"}\n```'
89
+ );
90
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
91
+
92
+ const response = await analyzeChanges(config, baseAnalysisRequest);
93
+
94
+ expect(response.result).not.toBeNull();
95
+ expect(response.result!.is_significant).toBe(true);
96
+ expect(response.result!.reason).toBe('refactored');
97
+ });
98
+
99
+ it('should return error when provider returns undefined', async () => {
100
+ const mockAnalyze = jest.fn().mockResolvedValue(undefined);
101
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
102
+
103
+ const response = await analyzeChanges(config, baseAnalysisRequest);
104
+
105
+ expect(response.result).toBeNull();
106
+ expect(response.error).toBe('No response content from LLM');
107
+ });
108
+
109
+ it('should return error when provider returns invalid JSON', async () => {
110
+ const mockAnalyze = jest.fn().mockResolvedValue('not valid json at all');
111
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
112
+
113
+ const response = await analyzeChanges(config, baseAnalysisRequest);
114
+
115
+ expect(response.result).toBeNull();
116
+ expect(response.error).toContain('Failed to parse LLM response as JSON');
117
+ });
118
+
119
+ it('should return error when is_significant is not a boolean', async () => {
120
+ const mockAnalyze = jest.fn().mockResolvedValue(
121
+ JSON.stringify({ is_significant: 'yes', reason: 'something' })
122
+ );
123
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
124
+
125
+ const response = await analyzeChanges(config, baseAnalysisRequest);
126
+
127
+ expect(response.result).toBeNull();
128
+ expect(response.error).toContain('Invalid response format');
129
+ });
130
+
131
+ it('should return error when is_significant is true but reason is empty', async () => {
132
+ const mockAnalyze = jest.fn().mockResolvedValue(
133
+ JSON.stringify({ is_significant: true, reason: '' })
134
+ );
135
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
136
+
137
+ const response = await analyzeChanges(config, baseAnalysisRequest);
138
+
139
+ expect(response.result).toBeNull();
140
+ expect(response.error).toContain('no reason');
141
+ });
142
+
143
+ it('should include confidence when value is within valid range', async () => {
144
+ const mockAnalyze = jest.fn().mockResolvedValue(
145
+ JSON.stringify({ is_significant: true, reason: 'big change', confidence: 0.85 })
146
+ );
147
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
148
+
149
+ const response = await analyzeChanges(config, baseAnalysisRequest);
150
+
151
+ expect(response.result).not.toBeNull();
152
+ expect(response.result!.confidence).toBe(0.85);
153
+ });
154
+
155
+ it('should not include confidence when value is out of range', async () => {
156
+ const mockAnalyze = jest.fn().mockResolvedValue(
157
+ JSON.stringify({ is_significant: true, reason: 'big change', confidence: 1.5 })
158
+ );
159
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
160
+
161
+ const response = await analyzeChanges(config, baseAnalysisRequest);
162
+
163
+ expect(response.result).not.toBeNull();
164
+ expect(response.result!.confidence).toBeUndefined();
165
+ });
166
+
167
+ it('should log warning when estimated tokens exceed 7000', async () => {
168
+ const longPrompt = 'x'.repeat(28004);
169
+ const mockAnalyze = jest.fn().mockResolvedValue(
170
+ JSON.stringify({ is_significant: false, reason: 'minor' })
171
+ );
172
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
173
+
174
+ const request = { ...baseAnalysisRequest, analysis_prompt: longPrompt };
175
+ await analyzeChanges(config, request);
176
+
177
+ expect(mockedLogger.warn).toHaveBeenCalledWith(
178
+ 'High token count detected',
179
+ expect.objectContaining({ estimated_tokens: expect.any(Number) })
180
+ );
181
+ });
182
+
183
+ it('should return auth error when provider throws with status 401', async () => {
184
+ const mockAnalyze = jest.fn().mockRejectedValue({ status: 401, message: 'Unauthorized' });
185
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
186
+
187
+ const response = await analyzeChanges(config, baseAnalysisRequest);
188
+
189
+ expect(response.result).toBeNull();
190
+ expect(response.error).toContain('Invalid API key');
191
+ });
192
+
193
+ it('should return diff-too-large error when provider throws status 400 with context length message', async () => {
194
+ const mockAnalyze = jest.fn().mockRejectedValue({
195
+ status: 400,
196
+ message: 'maximum context length exceeded with 50000 tokens',
197
+ });
198
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
199
+
200
+ const response = await analyzeChanges(config, baseAnalysisRequest);
201
+
202
+ expect(response.result).toBeNull();
203
+ expect(response.error).toContain('Diff too large');
204
+ });
205
+
206
+ it('should return rate limit error when provider throws with status 429', async () => {
207
+ const mockAnalyze = jest.fn().mockRejectedValue({ status: 429, message: 'Too many requests' });
208
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
209
+
210
+ const response = await analyzeChanges(config, baseAnalysisRequest);
211
+
212
+ expect(response.result).toBeNull();
213
+ expect(response.error).toContain('Rate limit exceeded');
214
+ });
215
+
216
+ it('should return timeout error when provider throws with code ETIMEDOUT', async () => {
217
+ const mockAnalyze = jest.fn().mockRejectedValue({ code: 'ETIMEDOUT', message: 'connection timed out' });
218
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
219
+
220
+ const response = await analyzeChanges(config, baseAnalysisRequest);
221
+
222
+ expect(response.result).toBeNull();
223
+ expect(response.error).toContain('timeout');
224
+ expect(response.error).toContain('15s');
225
+ });
226
+
227
+ it('should return timeout error when provider throws with timeout in message', async () => {
228
+ const mockAnalyze = jest.fn().mockRejectedValue({ message: 'Request timeout after 15000ms' });
229
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
230
+
231
+ const response = await analyzeChanges(config, baseAnalysisRequest);
232
+
233
+ expect(response.result).toBeNull();
234
+ expect(response.error).toContain('timeout');
235
+ });
236
+
237
+ it('should return network error when provider throws with code ENOTFOUND', async () => {
238
+ const mockAnalyze = jest.fn().mockRejectedValue({ code: 'ENOTFOUND', message: 'getaddrinfo ENOTFOUND' });
239
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
240
+
241
+ const response = await analyzeChanges(config, baseAnalysisRequest);
242
+
243
+ expect(response.result).toBeNull();
244
+ expect(response.error).toContain('Network error');
245
+ });
246
+
247
+ it('should return generic API error for unknown errors', async () => {
248
+ const mockAnalyze = jest.fn().mockRejectedValue({ message: 'Something unexpected' });
249
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
250
+
251
+ const response = await analyzeChanges(config, baseAnalysisRequest);
252
+
253
+ expect(response.result).toBeNull();
254
+ expect(response.error).toBe('API error: Something unexpected');
255
+ });
256
+ });
257
+
258
+ describe('generateADRContent', () => {
259
+ it('should return error when API key is not set', async () => {
260
+ delete process.env.TEST_API_KEY;
261
+
262
+ const response = await generateADRContent(config, baseGenerationRequest);
263
+
264
+ expect(response.result).toBeNull();
265
+ expect(response.error).toContain('TEST_API_KEY');
266
+ });
267
+
268
+ it('should extract title from markdown h1 and return cleaned content', async () => {
269
+ const markdown = '# Adopt New Database\n\n## Context\nWe need a new DB.';
270
+ const mockAnalyze = jest.fn().mockResolvedValue(markdown);
271
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
272
+
273
+ const response = await generateADRContent(config, baseGenerationRequest);
274
+
275
+ expect(response.result).not.toBeNull();
276
+ expect(response.result!.title).toBe('Adopt New Database');
277
+ expect(response.result!.content).toBe(markdown);
278
+ expect(response.result!.timestamp).toBeDefined();
279
+ expect(response.error).toBeUndefined();
280
+ });
281
+
282
+ it('should strip code block wrapper from markdown response', async () => {
283
+ const inner = '# My Decision\n\n## Context\nSome context.';
284
+ const wrapped = '```markdown\n' + inner + '\n```';
285
+ const mockAnalyze = jest.fn().mockResolvedValue(wrapped);
286
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
287
+
288
+ const response = await generateADRContent(config, baseGenerationRequest);
289
+
290
+ expect(response.result).not.toBeNull();
291
+ expect(response.result!.title).toBe('My Decision');
292
+ expect(response.result!.content).toBe(inner);
293
+ });
294
+
295
+ it('should use Untitled Decision when markdown has no h1', async () => {
296
+ const markdown = '## Context\nSome context without a title.';
297
+ const mockAnalyze = jest.fn().mockResolvedValue(markdown);
298
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
299
+
300
+ const response = await generateADRContent(config, baseGenerationRequest);
301
+
302
+ expect(response.result).not.toBeNull();
303
+ expect(response.result!.title).toBe('Untitled Decision');
304
+ });
305
+
306
+ it('should return auth error when provider throws with status 401', async () => {
307
+ const mockAnalyze = jest.fn().mockRejectedValue({ status: 401, message: 'Unauthorized' });
308
+ mockedGetProvider.mockReturnValue(createMockProvider(mockAnalyze));
309
+
310
+ const response = await generateADRContent(config, baseGenerationRequest);
311
+
312
+ expect(response.result).toBeNull();
313
+ expect(response.error).toContain('Invalid API key');
314
+ });
315
+ });
@@ -1,17 +1,7 @@
1
- /**
2
- * LLM Client Module
3
- *
4
- * Provider-based wrapper for analyzing code changes.
5
- * Implements fail-open error handling per constitution requirements.
6
- */
7
-
8
- import { getProvider } from './providers';
9
- import { AnalysisConfig } from './config';
10
- import { loggerInstance as logger } from './logger';
11
-
12
- /**
13
- * Analysis request data structure
14
- */
1
+ import { getProvider } from '../providers';
2
+ import { AnalysisConfig } from '../config';
3
+ import { loggerInstance as logger } from '../logger';
4
+
15
5
  export interface AnalysisRequest {
16
6
  file_paths: string[];
17
7
  diff_content: string;
@@ -19,17 +9,6 @@ export interface AnalysisRequest {
19
9
  analysis_prompt: string;
20
10
  }
21
11
 
22
- /**
23
- * Rough token estimation (1 token ≈ 4 characters for English text)
24
- * This is a conservative estimate
25
- */
26
- function estimateTokens(text: string): number {
27
- return Math.ceil(text.length / 4);
28
- }
29
-
30
- /**
31
- * Analysis result from LLM
32
- */
33
12
  export interface AnalysisResult {
34
13
  is_significant: boolean;
35
14
  reason: string;
@@ -37,27 +16,38 @@ export interface AnalysisResult {
37
16
  timestamp: string;
38
17
  }
39
18
 
40
- /**
41
- * Analysis response including potential errors
42
- */
43
19
  export interface AnalysisResponse {
44
20
  result: AnalysisResult | null;
45
21
  error?: string;
46
22
  }
47
23
 
48
- /**
49
- * Analyze staged changes using OpenAI LLM
50
- *
51
- * @param config - Analysis configuration with API settings
52
- * @param request - Analysis request with code changes
53
- * @returns Promise resolving to analysis response with result or error
54
- */
24
+ export interface GenerationRequest {
25
+ file_paths: string[];
26
+ diff_content: string;
27
+ reason: string;
28
+ generation_prompt: string;
29
+ }
30
+
31
+ export interface GenerationResult {
32
+ content: string;
33
+ title: string;
34
+ timestamp: string;
35
+ }
36
+
37
+ export interface GenerationResponse {
38
+ result: GenerationResult | null;
39
+ error?: string;
40
+ }
41
+
42
+ function estimateTokens(text: string): number {
43
+ return Math.ceil(text.length / 4);
44
+ }
45
+
55
46
  export async function analyzeChanges(
56
47
  config: AnalysisConfig,
57
48
  request: AnalysisRequest
58
49
  ): Promise<AnalysisResponse> {
59
50
  try {
60
- // Check if API key is available
61
51
  const apiKey = process.env[config.api_key_env];
62
52
  if (!apiKey) {
63
53
  logger.warn('API key not found in environment', {
@@ -65,21 +55,19 @@ export async function analyzeChanges(
65
55
  });
66
56
  return {
67
57
  result: null,
68
- error: `API key not found: ${config.api_key_env} environment variable is not set`
58
+ error: `API key not found: ${config.api_key_env} environment variable is not set`,
69
59
  };
70
60
  }
71
61
 
72
- // Estimate tokens for logging and validation
73
62
  const estimatedTokens = estimateTokens(request.analysis_prompt);
74
-
63
+
75
64
  logger.info('Sending analysis request to LLM', {
76
65
  provider: config.provider,
77
66
  model: config.analysis_model,
78
67
  file_count: request.file_paths.length,
79
68
  estimated_tokens: estimatedTokens,
80
69
  });
81
-
82
- // Warn if token estimate is high (most models have 8k-32k limits)
70
+
83
71
  if (estimatedTokens > 7000) {
84
72
  logger.warn('High token count detected', {
85
73
  estimated_tokens: estimatedTokens,
@@ -99,28 +87,24 @@ export async function analyzeChanges(
99
87
  logger.warn('No response content from LLM', { provider: config.provider });
100
88
  return {
101
89
  result: null,
102
- error: 'No response content from LLM'
90
+ error: 'No response content from LLM',
103
91
  };
104
92
  }
105
93
 
106
- // Parse JSON response - handle markdown-wrapped JSON
107
94
  let parsedResponse: { is_significant: boolean; reason: string; confidence?: number };
108
95
  try {
109
- // Try to extract JSON from markdown code blocks if present
110
96
  let jsonContent = responseContent.trim();
111
-
112
- // Remove markdown code block if present: ```json ... ``` or ``` ... ```
97
+
113
98
  const codeBlockMatch = jsonContent.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
114
99
  if (codeBlockMatch) {
115
100
  jsonContent = codeBlockMatch[1].trim();
116
101
  }
117
-
118
- // Try to find JSON object if there's surrounding text
102
+
119
103
  const jsonMatch = jsonContent.match(/\{[\s\S]*\}/);
120
104
  if (jsonMatch) {
121
105
  jsonContent = jsonMatch[0];
122
106
  }
123
-
107
+
124
108
  parsedResponse = JSON.parse(jsonContent);
125
109
  } catch (parseError) {
126
110
  logger.warn('Failed to parse LLM response as JSON', {
@@ -129,11 +113,10 @@ export async function analyzeChanges(
129
113
  });
130
114
  return {
131
115
  result: null,
132
- error: `Failed to parse LLM response as JSON. Response was:\n${responseContent.substring(0, 200)}...`
116
+ error: `Failed to parse LLM response as JSON. Response was:\n${responseContent.substring(0, 200)}...`,
133
117
  };
134
118
  }
135
119
 
136
- // Validate response format
137
120
  if (
138
121
  typeof parsedResponse.is_significant !== 'boolean' ||
139
122
  typeof parsedResponse.reason !== 'string'
@@ -143,29 +126,26 @@ export async function analyzeChanges(
143
126
  });
144
127
  return {
145
128
  result: null,
146
- error: `Invalid response format from LLM. Expected {is_significant: boolean, reason: string}, got: ${JSON.stringify(parsedResponse).substring(0, 150)}...`
129
+ error: `Invalid response format from LLM. Expected {is_significant: boolean, reason: string}, got: ${JSON.stringify(parsedResponse).substring(0, 150)}...`,
147
130
  };
148
131
  }
149
-
150
- // Reason is required when is_significant is true, but can be empty when false
132
+
151
133
  if (parsedResponse.is_significant && !parsedResponse.reason) {
152
134
  logger.warn('Missing reason for significant change', {
153
135
  response: parsedResponse,
154
136
  });
155
137
  return {
156
138
  result: null,
157
- error: 'LLM indicated significant change but provided no reason'
139
+ error: 'LLM indicated significant change but provided no reason',
158
140
  };
159
141
  }
160
142
 
161
- // Build result with timestamp
162
143
  const result: AnalysisResult = {
163
144
  is_significant: parsedResponse.is_significant,
164
145
  reason: parsedResponse.reason,
165
146
  timestamp: new Date().toISOString(),
166
147
  };
167
148
 
168
- // Include confidence if provided
169
149
  if (
170
150
  typeof parsedResponse.confidence === 'number' &&
171
151
  parsedResponse.confidence >= 0 &&
@@ -181,22 +161,20 @@ export async function analyzeChanges(
181
161
 
182
162
  return { result, error: undefined };
183
163
  } catch (error) {
184
- // Fail-open: log error and return descriptive error message
185
164
  const errorObj = error as { status?: number; code?: string; message?: string };
186
165
  let errorMessage: string;
187
166
 
188
- // Check for specific error types and provide helpful messages
189
167
  if (errorObj.status === 401) {
190
168
  errorMessage = 'Invalid API key - please check your API key configuration';
191
169
  logger.warn('LLM API authentication failed', { error: errorObj });
192
170
  } else if (errorObj.status === 400 && errorObj.message?.includes('maximum context length')) {
193
- // Extract token counts from error message if available
194
171
  const tokenMatch = errorObj.message.match(/(\d+)\s+tokens/g);
195
- errorMessage = 'Diff too large for model context window. Try:\n' +
172
+ errorMessage =
173
+ 'Diff too large for model context window. Try:\n' +
196
174
  ' • Stage fewer files at once\n' +
197
175
  ' • Use a model with larger context window in cadr.yaml\n' +
198
176
  ' • Add ignore patterns to filter large files';
199
- logger.warn('LLM context length exceeded', {
177
+ logger.warn('LLM context length exceeded', {
200
178
  error: errorObj,
201
179
  tokens: tokenMatch,
202
180
  });
@@ -218,46 +196,11 @@ export async function analyzeChanges(
218
196
  }
219
197
  }
220
198
 
221
- /**
222
- * Generation request data structure
223
- */
224
- export interface GenerationRequest {
225
- file_paths: string[];
226
- diff_content: string;
227
- reason: string;
228
- generation_prompt: string;
229
- }
230
-
231
- /**
232
- * Generation result from LLM
233
- */
234
- export interface GenerationResult {
235
- content: string;
236
- title: string;
237
- timestamp: string;
238
- }
239
-
240
- /**
241
- * Generation response including potential errors
242
- */
243
- export interface GenerationResponse {
244
- result: GenerationResult | null;
245
- error?: string;
246
- }
247
-
248
- /**
249
- * Generate ADR content using LLM
250
- *
251
- * @param config - Analysis configuration with API settings
252
- * @param request - Generation request with code changes
253
- * @returns Promise resolving to generation response with result or error
254
- */
255
199
  export async function generateADRContent(
256
200
  config: AnalysisConfig,
257
201
  request: GenerationRequest
258
202
  ): Promise<GenerationResponse> {
259
203
  try {
260
- // Check if API key is available
261
204
  const apiKey = process.env[config.api_key_env];
262
205
  if (!apiKey) {
263
206
  logger.warn('API key not found in environment for generation', {
@@ -265,7 +208,7 @@ export async function generateADRContent(
265
208
  });
266
209
  return {
267
210
  result: null,
268
- error: `API key not found: ${config.api_key_env} environment variable is not set`
211
+ error: `API key not found: ${config.api_key_env} environment variable is not set`,
269
212
  };
270
213
  }
271
214
 
@@ -278,30 +221,27 @@ export async function generateADRContent(
278
221
  const provider = getProvider(config.provider);
279
222
  const responseContent = await provider.analyze(request.generation_prompt, {
280
223
  apiKey,
281
- model: config.analysis_model, // Using same model per user request
224
+ model: config.analysis_model,
282
225
  timeoutMs: config.timeout_seconds * 1000,
283
226
  });
284
227
 
285
228
  if (!responseContent) {
286
- logger.warn('No response content from LLM for generation', {
287
- provider: config.provider
229
+ logger.warn('No response content from LLM for generation', {
230
+ provider: config.provider,
288
231
  });
289
232
  return {
290
233
  result: null,
291
- error: 'No response content from LLM'
234
+ error: 'No response content from LLM',
292
235
  };
293
236
  }
294
237
 
295
- // Clean up the response - remove markdown code fences if LLM added them
296
238
  let cleanedContent = responseContent.trim();
297
-
298
- // Remove markdown code block if present
239
+
299
240
  const codeBlockMatch = cleanedContent.match(/```(?:markdown|md)?\s*\n?([\s\S]*?)\n?```/);
300
241
  if (codeBlockMatch) {
301
242
  cleanedContent = codeBlockMatch[1].trim();
302
243
  }
303
244
 
304
- // Extract title from first line (should be # Title)
305
245
  const titleMatch = cleanedContent.match(/^#\s+(.+)$/m);
306
246
  const title = titleMatch ? titleMatch[1].trim() : 'Untitled Decision';
307
247
 
@@ -318,7 +258,6 @@ export async function generateADRContent(
318
258
 
319
259
  return { result, error: undefined };
320
260
  } catch (error) {
321
- // Fail-open: log error and return descriptive error message
322
261
  const errorObj = error as { status?: number; code?: string; message?: string };
323
262
  let errorMessage: string;
324
263