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.
- package/dist/adr/adr.d.ts +17 -0
- package/dist/adr/adr.d.ts.map +1 -0
- package/dist/{adr.js → adr/adr.js} +4 -44
- package/dist/adr/adr.js.map +1 -0
- package/dist/adr/adr.test.d.ts +5 -0
- package/dist/{adr.test.d.ts.map → adr/adr.test.d.ts.map} +1 -1
- package/dist/{adr.test.js → adr/adr.test.js} +0 -14
- package/dist/adr/adr.test.js.map +1 -0
- package/dist/adr/index.d.ts +2 -0
- package/dist/adr/index.d.ts.map +1 -0
- package/dist/adr/index.js +18 -0
- package/dist/adr/index.js.map +1 -0
- package/dist/analysis/analysis.orchestrator.d.ts +14 -0
- package/dist/analysis/analysis.orchestrator.d.ts.map +1 -0
- package/dist/analysis/analysis.orchestrator.js +175 -0
- package/dist/analysis/analysis.orchestrator.js.map +1 -0
- package/dist/analysis/analysis.orchestrator.test.d.ts +2 -0
- package/dist/analysis/analysis.orchestrator.test.d.ts.map +1 -0
- package/dist/analysis/analysis.orchestrator.test.js +177 -0
- package/dist/analysis/analysis.orchestrator.test.js.map +1 -0
- package/dist/analysis/strategies/git-strategy.d.ts +22 -0
- package/dist/analysis/strategies/git-strategy.d.ts.map +1 -0
- package/dist/analysis/strategies/git-strategy.js +114 -0
- package/dist/analysis/strategies/git-strategy.js.map +1 -0
- package/dist/analysis/strategies/git-strategy.test.d.ts +2 -0
- package/dist/analysis/strategies/git-strategy.test.d.ts.map +1 -0
- package/dist/analysis/strategies/git-strategy.test.js +147 -0
- package/dist/analysis/strategies/git-strategy.test.js.map +1 -0
- package/dist/commands/analyze.js +3 -3
- package/dist/commands/analyze.js.map +1 -1
- package/dist/commands/analyze.test.d.ts +2 -0
- package/dist/commands/analyze.test.d.ts.map +1 -0
- package/dist/commands/analyze.test.js +70 -0
- package/dist/commands/analyze.test.js.map +1 -0
- package/dist/commands/init.test.js +128 -2
- package/dist/commands/init.test.js.map +1 -1
- package/dist/config.test.js +167 -0
- package/dist/config.test.js.map +1 -1
- package/dist/git/git.errors.d.ts +6 -0
- package/dist/git/git.errors.d.ts.map +1 -0
- package/dist/git/git.errors.js +15 -0
- package/dist/git/git.errors.js.map +1 -0
- package/dist/git/git.errors.test.d.ts +2 -0
- package/dist/git/git.errors.test.d.ts.map +1 -0
- package/dist/git/git.errors.test.js +34 -0
- package/dist/git/git.errors.test.js.map +1 -0
- package/dist/git/git.operations.d.ts +12 -0
- package/dist/git/git.operations.d.ts.map +1 -0
- package/dist/git/git.operations.js +64 -0
- package/dist/git/git.operations.js.map +1 -0
- package/dist/git/git.operations.test.d.ts +2 -0
- package/dist/git/git.operations.test.d.ts.map +1 -0
- package/dist/git/git.operations.test.js +164 -0
- package/dist/git/git.operations.test.js.map +1 -0
- package/dist/git/index.d.ts +4 -0
- package/dist/git/index.d.ts.map +1 -0
- package/dist/git/index.js +19 -0
- package/dist/git/index.js.map +1 -0
- package/dist/llm/index.d.ts +3 -0
- package/dist/llm/index.d.ts.map +1 -0
- package/dist/llm/index.js +19 -0
- package/dist/llm/index.js.map +1 -0
- package/dist/llm/llm.d.ts +35 -0
- package/dist/llm/llm.d.ts.map +1 -0
- package/dist/{llm.js → llm/llm.js} +16 -58
- package/dist/llm/llm.js.map +1 -0
- package/dist/{llm.test.d.ts.map → llm/llm.test.d.ts.map} +1 -1
- package/dist/llm/llm.test.js +224 -0
- package/dist/llm/llm.test.js.map +1 -0
- package/dist/{prompts.d.ts → llm/prompts.d.ts} +1 -38
- package/dist/llm/prompts.d.ts.map +1 -0
- package/dist/{prompts.js → llm/prompts.js} +9 -54
- package/dist/llm/prompts.js.map +1 -0
- package/dist/llm/response-parser.d.ts +9 -0
- package/dist/llm/response-parser.d.ts.map +1 -0
- package/dist/llm/response-parser.js +67 -0
- package/dist/llm/response-parser.js.map +1 -0
- package/dist/llm/response-parser.test.d.ts +2 -0
- package/dist/llm/response-parser.test.d.ts.map +1 -0
- package/dist/llm/response-parser.test.js +134 -0
- package/dist/llm/response-parser.test.js.map +1 -0
- package/dist/presenters/console-presenter.d.ts +35 -0
- package/dist/presenters/console-presenter.d.ts.map +1 -0
- package/dist/presenters/console-presenter.js +114 -0
- package/dist/presenters/console-presenter.js.map +1 -0
- package/dist/presenters/console-presenter.test.d.ts +2 -0
- package/dist/presenters/console-presenter.test.d.ts.map +1 -0
- package/dist/presenters/console-presenter.test.js +227 -0
- package/dist/presenters/console-presenter.test.js.map +1 -0
- package/dist/version.test.d.ts +1 -2
- package/dist/version.test.d.ts.map +1 -1
- package/dist/version.test.js +29 -16
- package/dist/version.test.js.map +1 -1
- package/package.json +1 -1
- package/src/{adr.test.ts → adr/adr.test.ts} +10 -23
- package/src/{adr.ts → adr/adr.ts} +7 -48
- package/src/adr/index.ts +1 -0
- package/src/analysis/analysis.orchestrator.test.ts +237 -0
- package/src/analysis/analysis.orchestrator.ts +175 -0
- package/src/analysis/strategies/git-strategy.test.ts +210 -0
- package/src/analysis/strategies/git-strategy.ts +106 -0
- package/src/commands/analyze.test.ts +91 -0
- package/src/commands/analyze.ts +8 -9
- package/src/commands/init.test.ts +200 -5
- package/src/config.test.ts +232 -2
- package/src/git/git.errors.test.ts +43 -0
- package/src/git/git.errors.ts +10 -0
- package/src/git/git.operations.test.ts +222 -0
- package/src/git/git.operations.ts +85 -0
- package/src/git/index.ts +3 -0
- package/src/llm/index.ts +2 -0
- package/src/llm/llm.test.ts +315 -0
- package/src/{llm.ts → llm/llm.ts} +46 -107
- package/src/{prompts.ts → llm/prompts.ts} +30 -72
- package/src/llm/response-parser.test.ts +170 -0
- package/src/llm/response-parser.ts +90 -0
- package/src/presenters/console-presenter.test.ts +259 -0
- package/src/presenters/console-presenter.ts +152 -0
- package/src/version.test.ts +30 -16
- package/dist/adr.d.ts +0 -50
- package/dist/adr.d.ts.map +0 -1
- package/dist/adr.js.map +0 -1
- package/dist/adr.test.d.ts +0 -8
- package/dist/adr.test.js.map +0 -1
- package/dist/analysis.d.ts +0 -24
- package/dist/analysis.d.ts.map +0 -1
- package/dist/analysis.js +0 -281
- package/dist/analysis.js.map +0 -1
- package/dist/analysis.test.d.ts +0 -8
- package/dist/analysis.test.d.ts.map +0 -1
- package/dist/analysis.test.js +0 -351
- package/dist/analysis.test.js.map +0 -1
- package/dist/git.d.ts +0 -54
- package/dist/git.d.ts.map +0 -1
- package/dist/git.js +0 -204
- package/dist/git.js.map +0 -1
- package/dist/llm.d.ts +0 -73
- package/dist/llm.d.ts.map +0 -1
- package/dist/llm.js.map +0 -1
- package/dist/llm.test.js +0 -592
- package/dist/llm.test.js.map +0 -1
- package/dist/prompts.d.ts.map +0 -1
- package/dist/prompts.js.map +0 -1
- package/dist/prompts.test.d.ts +0 -2
- package/dist/prompts.test.d.ts.map +0 -1
- package/dist/prompts.test.js +0 -427
- package/dist/prompts.test.js.map +0 -1
- package/src/analysis.test.ts +0 -396
- package/src/analysis.ts +0 -262
- package/src/git.ts +0 -300
- package/src/llm.test.ts +0 -701
- package/src/prompts.test.ts +0 -515
- /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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 =
|
|
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,
|
|
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
|
|