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
package/src/config.test.ts
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
|
-
import { loadConfig, validateConfig, AnalysisConfig } from './config';
|
|
1
|
+
import { loadConfig, validateConfig, getDefaultConfigPath, createConfig, AnalysisConfig } from './config';
|
|
2
2
|
import * as fs from 'fs';
|
|
3
3
|
import * as yaml from 'js-yaml';
|
|
4
|
-
|
|
4
|
+
import * as readline from 'readline';
|
|
5
5
|
// Mock fs and yaml modules
|
|
6
6
|
jest.mock('fs');
|
|
7
7
|
jest.mock('js-yaml');
|
|
8
|
+
jest.mock('readline');
|
|
9
|
+
jest.mock('./logger', () => ({
|
|
10
|
+
loggerInstance: {
|
|
11
|
+
info: jest.fn(),
|
|
12
|
+
warn: jest.fn(),
|
|
13
|
+
error: jest.fn(),
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
import { loggerInstance as logger } from './logger';
|
|
18
|
+
const mockLogger = logger as jest.Mocked<typeof logger>;
|
|
8
19
|
|
|
9
20
|
describe('Configuration Module', () => {
|
|
10
21
|
const mockConfig: AnalysisConfig = {
|
|
@@ -76,4 +87,223 @@ describe('Configuration Module', () => {
|
|
|
76
87
|
expect(result.errors.length).toBeGreaterThan(0);
|
|
77
88
|
});
|
|
78
89
|
});
|
|
90
|
+
|
|
91
|
+
// --- NEW TEST CASES ---
|
|
92
|
+
|
|
93
|
+
describe('loadConfig — additional branches', () => {
|
|
94
|
+
test('returns null when YAML parses to null', async () => {
|
|
95
|
+
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
|
96
|
+
(fs.readFileSync as jest.Mock).mockReturnValue('');
|
|
97
|
+
(yaml.load as jest.Mock).mockReturnValue(null);
|
|
98
|
+
|
|
99
|
+
const result = await loadConfig('/test/cadr.yaml');
|
|
100
|
+
|
|
101
|
+
expect(result).toBeNull();
|
|
102
|
+
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
103
|
+
'Invalid YAML configuration',
|
|
104
|
+
expect.objectContaining({ configPath: '/test/cadr.yaml' })
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('returns null when YAML parses to a string', async () => {
|
|
109
|
+
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
|
110
|
+
(fs.readFileSync as jest.Mock).mockReturnValue('just a string');
|
|
111
|
+
(yaml.load as jest.Mock).mockReturnValue('just a string');
|
|
112
|
+
|
|
113
|
+
const result = await loadConfig('/test/cadr.yaml');
|
|
114
|
+
|
|
115
|
+
expect(result).toBeNull();
|
|
116
|
+
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
117
|
+
'Invalid YAML configuration',
|
|
118
|
+
expect.objectContaining({ configPath: '/test/cadr.yaml' })
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('does not log warning when api_key_env is set in environment', async () => {
|
|
123
|
+
const envKey = 'TEST_API_KEY_PRESENT';
|
|
124
|
+
const configWithEnv = { ...mockConfig, api_key_env: envKey };
|
|
125
|
+
process.env[envKey] = 'some-key-value';
|
|
126
|
+
|
|
127
|
+
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
|
128
|
+
(fs.readFileSync as jest.Mock).mockReturnValue('yaml');
|
|
129
|
+
(yaml.load as jest.Mock).mockReturnValue(configWithEnv);
|
|
130
|
+
|
|
131
|
+
const result = await loadConfig('/test/cadr.yaml');
|
|
132
|
+
|
|
133
|
+
expect(result).toEqual(configWithEnv);
|
|
134
|
+
expect(mockLogger.warn).not.toHaveBeenCalledWith(
|
|
135
|
+
'API key environment variable is not set',
|
|
136
|
+
expect.anything()
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
delete process.env[envKey];
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('logs warning but still returns config when api_key_env is NOT set', async () => {
|
|
143
|
+
const envKey = 'MISSING_API_KEY_XYZ';
|
|
144
|
+
const configWithMissingEnv = { ...mockConfig, api_key_env: envKey };
|
|
145
|
+
delete process.env[envKey];
|
|
146
|
+
|
|
147
|
+
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
|
148
|
+
(fs.readFileSync as jest.Mock).mockReturnValue('yaml');
|
|
149
|
+
(yaml.load as jest.Mock).mockReturnValue(configWithMissingEnv);
|
|
150
|
+
|
|
151
|
+
const result = await loadConfig('/test/cadr.yaml');
|
|
152
|
+
|
|
153
|
+
expect(result).toEqual(configWithMissingEnv);
|
|
154
|
+
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
155
|
+
'API key environment variable is not set',
|
|
156
|
+
{ api_key_env: envKey }
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('returns null when readFileSync throws a permissions error', async () => {
|
|
161
|
+
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
|
162
|
+
(fs.readFileSync as jest.Mock).mockImplementation(() => {
|
|
163
|
+
throw new Error('EACCES: permission denied');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const result = await loadConfig('/test/cadr.yaml');
|
|
167
|
+
|
|
168
|
+
expect(result).toBeNull();
|
|
169
|
+
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
170
|
+
'Failed to load configuration',
|
|
171
|
+
expect.objectContaining({ configPath: '/test/cadr.yaml' })
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('validateConfig — additional cases', () => {
|
|
177
|
+
test('accepts provider gemini as valid', () => {
|
|
178
|
+
const geminiConfig = { ...mockConfig, provider: 'gemini' };
|
|
179
|
+
const result = validateConfig(geminiConfig);
|
|
180
|
+
|
|
181
|
+
expect(result.valid).toBe(true);
|
|
182
|
+
expect(result.errors).toHaveLength(0);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('accepts timeout_seconds: 1 (min boundary) as valid', () => {
|
|
186
|
+
const config = { ...mockConfig, timeout_seconds: 1 };
|
|
187
|
+
const result = validateConfig(config);
|
|
188
|
+
|
|
189
|
+
expect(result.valid).toBe(true);
|
|
190
|
+
expect(result.errors).toHaveLength(0);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('accepts timeout_seconds: 60 (max boundary) as valid', () => {
|
|
194
|
+
const config = { ...mockConfig, timeout_seconds: 60 };
|
|
195
|
+
const result = validateConfig(config);
|
|
196
|
+
|
|
197
|
+
expect(result.valid).toBe(true);
|
|
198
|
+
expect(result.errors).toHaveLength(0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('rejects timeout_seconds: 0 (below min) with error message', () => {
|
|
202
|
+
const config = { ...mockConfig, timeout_seconds: 0 };
|
|
203
|
+
const result = validateConfig(config);
|
|
204
|
+
|
|
205
|
+
expect(result.valid).toBe(false);
|
|
206
|
+
expect(result.errors.join('\n')).toMatch(/timeout_seconds must be at least 1 second/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('rejects timeout_seconds: 61 (above max)', () => {
|
|
210
|
+
const config = { ...mockConfig, timeout_seconds: 61 };
|
|
211
|
+
const result = validateConfig(config);
|
|
212
|
+
|
|
213
|
+
expect(result.valid).toBe(false);
|
|
214
|
+
expect(result.errors.join('\n')).toMatch(/timeout_seconds must not exceed 60 seconds/);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('returns unknown validation error for non-yup errors', () => {
|
|
218
|
+
// We need to force validateSync to throw a non-yup error.
|
|
219
|
+
// We can do this by passing an object with a valueOf that throws.
|
|
220
|
+
const badConfig = {
|
|
221
|
+
get provider() {
|
|
222
|
+
throw new TypeError('unexpected');
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const result = validateConfig(badConfig);
|
|
227
|
+
|
|
228
|
+
expect(result.valid).toBe(false);
|
|
229
|
+
expect(result.errors).toEqual(['Unknown validation error']);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('accepts ignore_patterns as a valid optional array', () => {
|
|
233
|
+
const config = { ...mockConfig, ignore_patterns: ['*.md', '*.json'] };
|
|
234
|
+
const result = validateConfig(config);
|
|
235
|
+
|
|
236
|
+
expect(result.valid).toBe(true);
|
|
237
|
+
expect(result.errors).toHaveLength(0);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('getDefaultConfigPath', () => {
|
|
242
|
+
test('returns cadr.yaml', () => {
|
|
243
|
+
const result = getDefaultConfigPath();
|
|
244
|
+
|
|
245
|
+
expect(result).toBe('cadr.yaml');
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe('createConfig', () => {
|
|
250
|
+
function mockReadline(answers: string[]) {
|
|
251
|
+
let callIndex = 0;
|
|
252
|
+
const mockRl = {
|
|
253
|
+
question: jest.fn((_prompt: string, callback: (answer: string) => void) => {
|
|
254
|
+
callback(answers[callIndex] || '');
|
|
255
|
+
callIndex++;
|
|
256
|
+
}),
|
|
257
|
+
close: jest.fn(),
|
|
258
|
+
};
|
|
259
|
+
(readline.createInterface as jest.Mock).mockReturnValue(mockRl);
|
|
260
|
+
return mockRl;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
beforeEach(() => {
|
|
264
|
+
// yaml.dump needs to return a string for writeFileSync
|
|
265
|
+
(yaml.dump as jest.Mock).mockReturnValue('provider: openai\n');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('creates config with openai defaults and writes to file', async () => {
|
|
269
|
+
// Answers: provider=openai, model=gpt-4, api_key_env=OPENAI_API_KEY, timeout=15, ignore_patterns=*.md
|
|
270
|
+
const mockRl = mockReadline(['openai', 'gpt-4', 'OPENAI_API_KEY', '15', '*.md']);
|
|
271
|
+
|
|
272
|
+
const result = await createConfig('/test/cadr.yaml');
|
|
273
|
+
|
|
274
|
+
expect(result).not.toBeNull();
|
|
275
|
+
expect(result!.provider).toBe('openai');
|
|
276
|
+
expect(result!.analysis_model).toBe('gpt-4');
|
|
277
|
+
expect(result!.api_key_env).toBe('OPENAI_API_KEY');
|
|
278
|
+
expect(result!.timeout_seconds).toBe(15);
|
|
279
|
+
expect(result!.ignore_patterns).toEqual(['*.md']);
|
|
280
|
+
expect(fs.writeFileSync).toHaveBeenCalledWith('/test/cadr.yaml', expect.any(String), 'utf-8');
|
|
281
|
+
expect(mockRl.close).toHaveBeenCalled();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('creates config with gemini defaults when gemini provider is chosen', async () => {
|
|
285
|
+
// Answer provider=gemini, then use defaults for the rest
|
|
286
|
+
mockReadline(['gemini', '', '', '15', '']);
|
|
287
|
+
|
|
288
|
+
// yaml.dump needs to be realistic for this call
|
|
289
|
+
(yaml.dump as jest.Mock).mockReturnValue('provider: gemini\n');
|
|
290
|
+
|
|
291
|
+
const result = await createConfig('/test/cadr.yaml');
|
|
292
|
+
|
|
293
|
+
expect(result).not.toBeNull();
|
|
294
|
+
expect(result!.provider).toBe('gemini');
|
|
295
|
+
expect(result!.analysis_model).toBe('gemini-1.5-pro');
|
|
296
|
+
expect(result!.api_key_env).toBe('GEMINI_API_KEY');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('returns null when validation fails during createConfig', async () => {
|
|
300
|
+
// Use an invalid provider to trigger validation failure
|
|
301
|
+
mockReadline(['invalid_provider', 'model', 'KEY', '15', '']);
|
|
302
|
+
|
|
303
|
+
const result = await createConfig('/test/cadr.yaml');
|
|
304
|
+
|
|
305
|
+
expect(result).toBeNull();
|
|
306
|
+
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
|
307
|
+
});
|
|
308
|
+
});
|
|
79
309
|
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { GitError } from './git.errors';
|
|
2
|
+
|
|
3
|
+
describe('GitError', () => {
|
|
4
|
+
it('should set message, code, and name for NOT_GIT_REPO', () => {
|
|
5
|
+
const error = new GitError('msg', 'NOT_GIT_REPO');
|
|
6
|
+
|
|
7
|
+
expect(error.message).toBe('msg');
|
|
8
|
+
expect(error.code).toBe('NOT_GIT_REPO');
|
|
9
|
+
expect(error.name).toBe('GitError');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('should set code to GIT_NOT_FOUND', () => {
|
|
13
|
+
const error = new GitError('msg', 'GIT_NOT_FOUND');
|
|
14
|
+
|
|
15
|
+
expect(error.code).toBe('GIT_NOT_FOUND');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should set code to GIT_ERROR', () => {
|
|
19
|
+
const error = new GitError('msg', 'GIT_ERROR');
|
|
20
|
+
|
|
21
|
+
expect(error.code).toBe('GIT_ERROR');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should store originalError when provided', () => {
|
|
25
|
+
const original = new Error('original');
|
|
26
|
+
const error = new GitError('msg', 'GIT_ERROR', original);
|
|
27
|
+
|
|
28
|
+
expect(error.originalError).toBe(original);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should have undefined originalError when not provided', () => {
|
|
32
|
+
const error = new GitError('msg', 'GIT_ERROR');
|
|
33
|
+
|
|
34
|
+
expect(error.originalError).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should be an instance of both Error and GitError', () => {
|
|
38
|
+
const error = new GitError('msg', 'NOT_GIT_REPO');
|
|
39
|
+
|
|
40
|
+
expect(error instanceof Error).toBe(true);
|
|
41
|
+
expect(error instanceof GitError).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
const mockExecAsync = jest.fn();
|
|
2
|
+
jest.mock('child_process', () => ({ exec: jest.fn() }));
|
|
3
|
+
jest.mock('util', () => ({
|
|
4
|
+
...jest.requireActual('util'),
|
|
5
|
+
promisify: jest.fn(() => mockExecAsync),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
import { GitError } from './git.errors';
|
|
9
|
+
import {
|
|
10
|
+
getStagedFiles,
|
|
11
|
+
getStagedDiff,
|
|
12
|
+
getAllChanges,
|
|
13
|
+
getAllDiff,
|
|
14
|
+
getChangedFiles,
|
|
15
|
+
getDiff,
|
|
16
|
+
} from './git.operations';
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
jest.clearAllMocks();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
jest.restoreAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// handleGitError (tested indirectly through any git command call)
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
describe('handleGitError', () => {
|
|
30
|
+
it('throws GitError with code NOT_GIT_REPO when exec fails with code 128', async () => {
|
|
31
|
+
const error = Object.assign(new Error('fatal: not a git repository'), { code: 128 });
|
|
32
|
+
mockExecAsync.mockRejectedValue(error);
|
|
33
|
+
|
|
34
|
+
const promise = getStagedFiles();
|
|
35
|
+
await expect(promise).rejects.toThrow(GitError);
|
|
36
|
+
await expect(promise).rejects.toMatchObject({ code: 'NOT_GIT_REPO' });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('throws GitError with code GIT_NOT_FOUND when exec fails with code 127', async () => {
|
|
40
|
+
const error = Object.assign(new Error('git: command not found'), { code: 127 });
|
|
41
|
+
mockExecAsync.mockRejectedValue(error);
|
|
42
|
+
|
|
43
|
+
await expect(getStagedFiles()).rejects.toThrow(GitError);
|
|
44
|
+
await expect(getStagedFiles()).rejects.toMatchObject({ code: 'GIT_NOT_FOUND' });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('throws GitError with code GIT_ERROR when exec fails with other code', async () => {
|
|
48
|
+
const error = Object.assign(new Error('unknown error'), { code: 1 });
|
|
49
|
+
mockExecAsync.mockRejectedValue(error);
|
|
50
|
+
|
|
51
|
+
await expect(getStagedFiles()).rejects.toThrow(GitError);
|
|
52
|
+
await expect(getStagedFiles()).rejects.toMatchObject({ code: 'GIT_ERROR' });
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// getStagedFiles
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
describe('getStagedFiles', () => {
|
|
60
|
+
it('returns array of filenames from stdout', async () => {
|
|
61
|
+
mockExecAsync.mockResolvedValueOnce({ stdout: 'file1.ts\nfile2.ts\nfile3.ts\n' });
|
|
62
|
+
|
|
63
|
+
const result = await getStagedFiles();
|
|
64
|
+
|
|
65
|
+
expect(result).toEqual(['file1.ts', 'file2.ts', 'file3.ts']);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('returns empty array when stdout is empty', async () => {
|
|
69
|
+
mockExecAsync.mockResolvedValueOnce({ stdout: '' });
|
|
70
|
+
|
|
71
|
+
const result = await getStagedFiles();
|
|
72
|
+
|
|
73
|
+
expect(result).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('filters blank lines from stdout', async () => {
|
|
77
|
+
mockExecAsync.mockResolvedValueOnce({ stdout: 'file1.ts\n\nfile2.ts\n\n' });
|
|
78
|
+
|
|
79
|
+
const result = await getStagedFiles();
|
|
80
|
+
|
|
81
|
+
expect(result).toEqual(['file1.ts', 'file2.ts']);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// getStagedDiff
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
describe('getStagedDiff', () => {
|
|
89
|
+
it('returns raw diff string from stdout', async () => {
|
|
90
|
+
const diff = 'diff --git a/file.ts b/file.ts\n+added line\n';
|
|
91
|
+
mockExecAsync.mockResolvedValueOnce({ stdout: diff });
|
|
92
|
+
|
|
93
|
+
const result = await getStagedDiff();
|
|
94
|
+
|
|
95
|
+
expect(result).toBe(diff);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// getAllChanges
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
describe('getAllChanges', () => {
|
|
103
|
+
it('returns array of filenames', async () => {
|
|
104
|
+
mockExecAsync.mockResolvedValueOnce({ stdout: 'a.ts\nb.ts\n' });
|
|
105
|
+
|
|
106
|
+
const result = await getAllChanges();
|
|
107
|
+
|
|
108
|
+
expect(result).toEqual(['a.ts', 'b.ts']);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('runs git diff HEAD --name-only command', async () => {
|
|
112
|
+
mockExecAsync.mockResolvedValueOnce({ stdout: '' });
|
|
113
|
+
|
|
114
|
+
await getAllChanges();
|
|
115
|
+
|
|
116
|
+
expect(mockExecAsync).toHaveBeenCalledWith('git diff HEAD --name-only');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// getAllDiff
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
describe('getAllDiff', () => {
|
|
124
|
+
it('returns raw diff string', async () => {
|
|
125
|
+
const diff = 'diff --git a/x.ts b/x.ts\n-removed\n+added\n';
|
|
126
|
+
mockExecAsync.mockResolvedValueOnce({ stdout: diff });
|
|
127
|
+
|
|
128
|
+
const result = await getAllDiff();
|
|
129
|
+
|
|
130
|
+
expect(result).toBe(diff);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('runs git diff HEAD --unified=1 command', async () => {
|
|
134
|
+
mockExecAsync.mockResolvedValueOnce({ stdout: '' });
|
|
135
|
+
|
|
136
|
+
await getAllDiff();
|
|
137
|
+
|
|
138
|
+
expect(mockExecAsync).toHaveBeenCalledWith('git diff HEAD --unified=1');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// getChangedFiles
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
describe('getChangedFiles', () => {
|
|
146
|
+
it('delegates to getStagedFiles when mode is staged', async () => {
|
|
147
|
+
mockExecAsync.mockResolvedValueOnce({ stdout: 'staged.ts\n' });
|
|
148
|
+
|
|
149
|
+
const result = await getChangedFiles({ mode: 'staged' });
|
|
150
|
+
|
|
151
|
+
expect(mockExecAsync).toHaveBeenCalledWith('git diff --cached --name-only');
|
|
152
|
+
expect(result).toEqual(['staged.ts']);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('delegates to getAllChanges when mode is all', async () => {
|
|
156
|
+
mockExecAsync.mockResolvedValueOnce({ stdout: 'all.ts\n' });
|
|
157
|
+
|
|
158
|
+
const result = await getChangedFiles({ mode: 'all' });
|
|
159
|
+
|
|
160
|
+
expect(mockExecAsync).toHaveBeenCalledWith('git diff HEAD --name-only');
|
|
161
|
+
expect(result).toEqual(['all.ts']);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('delegates to getAllChanges when mode is branch-diff (fallback)', async () => {
|
|
165
|
+
mockExecAsync.mockResolvedValueOnce({ stdout: 'branch.ts\n' });
|
|
166
|
+
|
|
167
|
+
const result = await getChangedFiles({ mode: 'branch-diff' });
|
|
168
|
+
|
|
169
|
+
expect(mockExecAsync).toHaveBeenCalledWith('git diff HEAD --name-only');
|
|
170
|
+
expect(result).toEqual(['branch.ts']);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// getDiff
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
describe('getDiff', () => {
|
|
178
|
+
it('delegates to getStagedDiff when mode is staged', async () => {
|
|
179
|
+
const diff = 'staged diff content';
|
|
180
|
+
mockExecAsync.mockResolvedValueOnce({ stdout: diff });
|
|
181
|
+
|
|
182
|
+
const result = await getDiff({ mode: 'staged' });
|
|
183
|
+
|
|
184
|
+
expect(mockExecAsync).toHaveBeenCalledWith('git diff --cached --unified=1');
|
|
185
|
+
expect(result).toBe(diff);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('delegates to getAllDiff when mode is all', async () => {
|
|
189
|
+
const diff = 'all diff content';
|
|
190
|
+
mockExecAsync.mockResolvedValueOnce({ stdout: diff });
|
|
191
|
+
|
|
192
|
+
const result = await getDiff({ mode: 'all' });
|
|
193
|
+
|
|
194
|
+
expect(mockExecAsync).toHaveBeenCalledWith('git diff HEAD --unified=1');
|
|
195
|
+
expect(result).toBe(diff);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('delegates to getAllDiff when mode is branch-diff (fallback)', async () => {
|
|
199
|
+
const diff = 'branch diff content';
|
|
200
|
+
mockExecAsync.mockResolvedValueOnce({ stdout: diff });
|
|
201
|
+
|
|
202
|
+
const result = await getDiff({ mode: 'branch-diff' });
|
|
203
|
+
|
|
204
|
+
expect(mockExecAsync).toHaveBeenCalledWith('git diff HEAD --unified=1');
|
|
205
|
+
expect(result).toBe(diff);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// parseFileList (tested indirectly)
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
describe('parseFileList (indirect)', () => {
|
|
213
|
+
it('multi-line stdout with trailing newline returns only non-empty trimmed entries', async () => {
|
|
214
|
+
mockExecAsync.mockResolvedValueOnce({
|
|
215
|
+
stdout: ' file1.ts \nfile2.ts\n \n file3.ts\n',
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const result = await getStagedFiles();
|
|
219
|
+
|
|
220
|
+
expect(result).toEqual(['file1.ts', 'file2.ts', 'file3.ts']);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { exec } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import { GitError } from './git.errors';
|
|
4
|
+
|
|
5
|
+
const execAsync = promisify(exec);
|
|
6
|
+
|
|
7
|
+
export interface DiffOptions {
|
|
8
|
+
mode: 'staged' | 'all' | 'branch-diff';
|
|
9
|
+
base?: string;
|
|
10
|
+
head?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function handleGitError(error: unknown, operation: string): never {
|
|
14
|
+
const errorWithCode = error as { code?: number };
|
|
15
|
+
|
|
16
|
+
if (errorWithCode.code === 128) {
|
|
17
|
+
throw new GitError(
|
|
18
|
+
`Not in a Git repository. Please run 'cadr' from within a Git repository.`,
|
|
19
|
+
'NOT_GIT_REPO',
|
|
20
|
+
error instanceof Error ? error : new Error(String(error))
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (errorWithCode.code === 127) {
|
|
25
|
+
throw new GitError(
|
|
26
|
+
'Git is not installed. Please install Git and try again.',
|
|
27
|
+
'GIT_NOT_FOUND',
|
|
28
|
+
error instanceof Error ? error : new Error(String(error))
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
throw new GitError(
|
|
33
|
+
`Unable to ${operation}. Please check repository permissions.`,
|
|
34
|
+
'GIT_ERROR',
|
|
35
|
+
error instanceof Error ? error : new Error(String(error))
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function execGitCommand(command: string): Promise<string> {
|
|
40
|
+
try {
|
|
41
|
+
const { stdout } = await execAsync(command);
|
|
42
|
+
return stdout;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
handleGitError(error, command.split(' ')[1] || 'execute git command');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseFileList(stdout: string): string[] {
|
|
49
|
+
return stdout
|
|
50
|
+
.split('\n')
|
|
51
|
+
.map((file) => file.trim())
|
|
52
|
+
.filter((file) => file.length > 0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function getStagedFiles(): Promise<string[]> {
|
|
56
|
+
const stdout = await execGitCommand('git diff --cached --name-only');
|
|
57
|
+
return parseFileList(stdout);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function getStagedDiff(): Promise<string> {
|
|
61
|
+
return execGitCommand('git diff --cached --unified=1');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function getAllChanges(): Promise<string[]> {
|
|
65
|
+
const stdout = await execGitCommand('git diff HEAD --name-only');
|
|
66
|
+
return parseFileList(stdout);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function getAllDiff(): Promise<string> {
|
|
70
|
+
return execGitCommand('git diff HEAD --unified=1');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function getChangedFiles(options: DiffOptions): Promise<string[]> {
|
|
74
|
+
if (options.mode === 'staged') {
|
|
75
|
+
return getStagedFiles();
|
|
76
|
+
}
|
|
77
|
+
return getAllChanges();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function getDiff(options: DiffOptions): Promise<string> {
|
|
81
|
+
if (options.mode === 'staged') {
|
|
82
|
+
return getStagedDiff();
|
|
83
|
+
}
|
|
84
|
+
return getAllDiff();
|
|
85
|
+
}
|
package/src/git/index.ts
ADDED
package/src/llm/index.ts
ADDED