codeflow-hook 2.1.0 → 2.2.0

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.
@@ -0,0 +1,422 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const http = require('http');
5
+ const https = require('https');
6
+
7
+ /**
8
+ * AI Code Review Module
9
+ *
10
+ * Supports multiple AI providers:
11
+ * - gemini: Google Gemini API (cloud, requires API key)
12
+ * - openai: OpenAI API (cloud, requires API key)
13
+ * - claude: Anthropic Claude API (cloud, requires API key)
14
+ * - ollama: Ollama local models (self-hosted, no API key needed)
15
+ *
16
+ * Falls back to the deterministic heuristic analyzer if:
17
+ * - No provider is configured or enabled
18
+ * - API call fails (network, timeout, invalid key)
19
+ * - Response cannot be parsed
20
+ *
21
+ * This ensures the pre-commit hook never blocks development due to
22
+ * missing or broken AI configuration.
23
+ */
24
+
25
+ const DEFAULT_CONFIG_PATH = path.join(os.homedir(), '.codeflow-hook', 'config.json');
26
+
27
+ const AI_REVIEW_PROMPT = `You are "Codeflow", a Principal Engineer performing a rigorous code review on the provided git diff.
28
+
29
+ Your response MUST be a single, valid JSON object with no markdown, no code blocks, no extra text.
30
+
31
+ The JSON must have this exact structure:
32
+ {
33
+ "overallStatus": "PASS" or "FAIL",
34
+ "score": integer 1-10,
35
+ "summary": "one sentence executive summary",
36
+ "files": [
37
+ {
38
+ "fileName": "path/to/file",
39
+ "status": "PASS" or "FAIL",
40
+ "issues": [
41
+ { "line": number, "type": "Security"|"Bug"|"Performance"|"Quality"|"Best Practice", "description": "clear description" }
42
+ ],
43
+ "suggestions": ["actionable suggestion"]
44
+ }
45
+ ]
46
+ }
47
+
48
+ Rules:
49
+ - overallStatus must be "FAIL" if ANY Security, Bug, or critical Performance issues exist
50
+ - score: 1-10 (10 = production-ready, 1 = fundamentally broken)
51
+ - Be constructive and precise
52
+ - Focus on: security vulnerabilities, bugs, performance issues, code quality
53
+
54
+ Git diff to review:
55
+ `;
56
+
57
+ /**
58
+ * Load AI provider configuration from ~/.codeflow-hook/config.json
59
+ * @returns {{ provider: string, apiKey: string, apiUrl: string, model: string, ollama: { enabled: boolean, url: string } } | null}
60
+ */
61
+ function loadConfig() {
62
+ try {
63
+ if (fs.existsSync(DEFAULT_CONFIG_PATH)) {
64
+ const raw = fs.readFileSync(DEFAULT_CONFIG_PATH, 'utf8');
65
+ const config = JSON.parse(raw);
66
+ // Ensure ollama section exists with defaults
67
+ if (!config.ollama) {
68
+ config.ollama = { enabled: false, url: 'http://localhost:11434' };
69
+ }
70
+ return config;
71
+ }
72
+ } catch (e) {
73
+ // Config file exists but is invalid — fall back to heuristic
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /**
79
+ * Call Ollama local API with the diff and return structured review.
80
+ * Uses /api/generate endpoint with format: "json" for structured output.
81
+ * @param {string} diff - Git diff content
82
+ * @param {{ model: string, url: string }} config
83
+ * @returns {Promise<string>} - Raw text response from Ollama
84
+ */
85
+ async function callOllamaAPI(diff, config) {
86
+ const prompt = AI_REVIEW_PROMPT + diff;
87
+ const baseUrl = config.url || 'http://localhost:11434';
88
+ const urlObj = new URL(`${baseUrl}/api/generate`);
89
+
90
+ const payload = JSON.stringify({
91
+ model: config.model || 'qwen2.5-coder',
92
+ prompt,
93
+ stream: false,
94
+ format: 'json',
95
+ options: {
96
+ temperature: 0.2,
97
+ num_ctx: 8192,
98
+ }
99
+ });
100
+
101
+ const isHttps = urlObj.protocol === 'https:';
102
+ const client = isHttps ? https : http;
103
+
104
+ return new Promise((resolve, reject) => {
105
+ const req = client.request(urlObj, {
106
+ method: 'POST',
107
+ headers: {
108
+ 'Content-Type': 'application/json',
109
+ 'Content-Length': Buffer.byteLength(payload)
110
+ },
111
+ timeout: 120000 // 2 minute timeout for local models
112
+ }, (res) => {
113
+ let data = '';
114
+ res.on('data', (chunk) => { data += chunk; });
115
+ res.on('end', () => {
116
+ if (res.statusCode !== 200) {
117
+ return reject(new Error(`Ollama returned ${res.statusCode}: ${data.substring(0, 200)}`));
118
+ }
119
+ try {
120
+ const response = JSON.parse(data);
121
+ const text = response.response;
122
+ if (!text) {
123
+ return reject(new Error('Ollama returned no text response'));
124
+ }
125
+ resolve(text);
126
+ } catch (e) {
127
+ reject(new Error(`Failed to parse Ollama response: ${e.message}`));
128
+ }
129
+ });
130
+ });
131
+
132
+ req.on('error', (e) => reject(e));
133
+ req.on('timeout', () => {
134
+ req.destroy();
135
+ reject(new Error('Ollama request timed out after 120s — is the model loaded?'));
136
+ });
137
+
138
+ req.write(payload);
139
+ req.end();
140
+ });
141
+ }
142
+
143
+ /**
144
+ * List available Ollama models by calling /api/tags
145
+ * @param {string} baseUrl - Ollama URL (default: http://localhost:11434)
146
+ * @returns {Promise<string[]>} - Array of model names
147
+ */
148
+ async function listOllamaModels(baseUrl) {
149
+ const url = `${baseUrl || 'http://localhost:11434'}/api/tags`;
150
+ const urlObj = new URL(url);
151
+ const isHttps = urlObj.protocol === 'https:';
152
+ const client = isHttps ? https : http;
153
+
154
+ return new Promise((resolve, reject) => {
155
+ const req = client.request(urlObj, {
156
+ method: 'GET',
157
+ timeout: 10000
158
+ }, (res) => {
159
+ let data = '';
160
+ res.on('data', (chunk) => { data += chunk; });
161
+ res.on('end', () => {
162
+ if (res.statusCode !== 200) {
163
+ return reject(new Error(`Ollama returned ${res.statusCode}`));
164
+ }
165
+ try {
166
+ const response = JSON.parse(data);
167
+ const models = (response.models || []).map(m => m.name);
168
+ resolve(models);
169
+ } catch (e) {
170
+ reject(new Error(`Failed to parse Ollama model list: ${e.message}`));
171
+ }
172
+ });
173
+ });
174
+
175
+ req.on('error', (e) => reject(e));
176
+ req.on('timeout', () => {
177
+ req.destroy();
178
+ reject(new Error('Ollama connection timed out — is Ollama running?'));
179
+ });
180
+
181
+ req.end();
182
+ });
183
+ }
184
+
185
+ /**
186
+ * Check if Ollama is running and reachable
187
+ * @param {string} baseUrl - Ollama URL
188
+ * @returns {Promise<boolean>}
189
+ */
190
+ async function isOllamaRunning(baseUrl) {
191
+ try {
192
+ const models = await listOllamaModels(baseUrl);
193
+ return models.length > 0;
194
+ } catch {
195
+ return false;
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Call Gemini API with the diff and return structured review.
201
+ * @param {string} diff - Git diff content
202
+ * @param {{ apiKey: string, apiUrl: string, model: string }} config
203
+ * @returns {Promise<string>} - Raw text response
204
+ */
205
+ async function callGeminiAPI(diff, config) {
206
+ const prompt = AI_REVIEW_PROMPT + diff;
207
+
208
+ let url;
209
+ if (config.apiUrl && config.apiUrl.includes('generativelanguage')) {
210
+ url = `${config.apiUrl}/${config.model || 'gemini-2.0-flash'}:generateContent?key=${config.apiKey}`;
211
+ } else if (config.apiUrl) {
212
+ url = config.apiUrl;
213
+ } else {
214
+ url = `https://generativelanguage.googleapis.com/v1beta/models/${config.model || 'gemini-2.0-flash'}:generateContent?key=${config.apiKey}`;
215
+ }
216
+
217
+ const payload = JSON.stringify({
218
+ contents: [{
219
+ parts: [{ text: prompt }]
220
+ }],
221
+ generationConfig: {
222
+ temperature: 0.2,
223
+ maxOutputTokens: 4096,
224
+ }
225
+ });
226
+
227
+ return new Promise((resolve, reject) => {
228
+ const req = https.request(url, {
229
+ method: 'POST',
230
+ headers: {
231
+ 'Content-Type': 'application/json',
232
+ 'Content-Length': Buffer.byteLength(payload)
233
+ },
234
+ timeout: 30000
235
+ }, (res) => {
236
+ let data = '';
237
+ res.on('data', (chunk) => { data += chunk; });
238
+ res.on('end', () => {
239
+ if (res.statusCode !== 200) {
240
+ return reject(new Error(`Gemini API returned ${res.statusCode}: ${data.substring(0, 200)}`));
241
+ }
242
+ try {
243
+ const response = JSON.parse(data);
244
+ const text = response.candidates?.[0]?.content?.parts?.[0]?.text;
245
+ if (!text) {
246
+ return reject(new Error('Gemini returned no text response'));
247
+ }
248
+ resolve(text);
249
+ } catch (e) {
250
+ reject(new Error(`Failed to parse Gemini response: ${e.message}`));
251
+ }
252
+ });
253
+ });
254
+
255
+ req.on('error', (e) => reject(e));
256
+ req.on('timeout', () => {
257
+ req.destroy();
258
+ reject(new Error('Gemini API request timed out after 30s'));
259
+ });
260
+
261
+ req.write(payload);
262
+ req.end();
263
+ });
264
+ }
265
+
266
+ /**
267
+ * Parse AI response text into structured review result.
268
+ * Handles cases where the response is wrapped in markdown code blocks.
269
+ * @param {string} text - Raw AI response text
270
+ * @returns {{ overallStatus: string, score: number, summary: string, files: Array }}
271
+ */
272
+ function parseAIResponse(text) {
273
+ let cleaned = text.trim();
274
+ const codeBlockMatch = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/);
275
+ if (codeBlockMatch) {
276
+ cleaned = codeBlockMatch[1].trim();
277
+ }
278
+
279
+ const jsonMatch = cleaned.match(/\{[\s\S]*\}/);
280
+ if (!jsonMatch) {
281
+ throw new Error('No JSON object found in AI response');
282
+ }
283
+
284
+ const parsed = JSON.parse(jsonMatch[0]);
285
+
286
+ if (!parsed.overallStatus || !parsed.score || !parsed.files) {
287
+ throw new Error('AI response missing required fields (overallStatus, score, files)');
288
+ }
289
+
290
+ const score = Math.max(1, Math.min(10, Math.round(parsed.score)));
291
+
292
+ return {
293
+ overallStatus: parsed.overallStatus.toUpperCase() === 'FAIL' ? 'FAIL' : 'PASS',
294
+ score,
295
+ summary: parsed.summary || 'No summary provided',
296
+ files: Array.isArray(parsed.files) ? parsed.files : []
297
+ };
298
+ }
299
+
300
+ /**
301
+ * Main entry point: review a git diff.
302
+ * Provider priority: Ollama (if enabled) → Cloud provider (Gemini/OpenAI/Claude) → Heuristic fallback
303
+ *
304
+ * @param {string} diff - Git diff content
305
+ * @param {{ minScore?: number }} options
306
+ * @returns {Promise<{ success: boolean, result: object, message: string, usedFallback: boolean, provider: string }>}
307
+ */
308
+ async function reviewDiff(diff, options = {}) {
309
+ const minScore = options.minScore || 3;
310
+ const config = loadConfig();
311
+
312
+ // Determine which provider to use
313
+ const useOllama = config?.ollama?.enabled === true;
314
+ const provider = useOllama ? 'ollama' : (config?.provider || 'none');
315
+
316
+ // Try Ollama if enabled
317
+ if (useOllama) {
318
+ try {
319
+ const ollamaConfig = {
320
+ model: config.model || 'qwen2.5-coder',
321
+ url: config.ollama.url || 'http://localhost:11434'
322
+ };
323
+ const responseText = await callOllamaAPI(diff, ollamaConfig);
324
+ const result = parseAIResponse(responseText);
325
+
326
+ if (result.score < minScore) {
327
+ return {
328
+ success: false,
329
+ result,
330
+ message: `Ollama review score ${result.score}/10 is below threshold ${minScore}/10`,
331
+ usedFallback: false,
332
+ provider: 'ollama'
333
+ };
334
+ }
335
+
336
+ if (result.overallStatus === 'FAIL') {
337
+ return {
338
+ success: false,
339
+ result,
340
+ message: 'Ollama review found critical issues — review required',
341
+ usedFallback: false,
342
+ provider: 'ollama'
343
+ };
344
+ }
345
+
346
+ return {
347
+ success: true,
348
+ result,
349
+ message: `Ollama review passed — score ${result.score}/10 (${ollamaConfig.model})`,
350
+ usedFallback: false,
351
+ provider: 'ollama'
352
+ };
353
+ } catch (error) {
354
+ console.error(`Ollama review failed (${error.message}), trying cloud provider...`);
355
+ }
356
+ }
357
+
358
+ // Try cloud provider (Gemini, OpenAI, Claude)
359
+ if (config?.apiKey) {
360
+ try {
361
+ const responseText = await callGeminiAPI(diff, config);
362
+ const result = parseAIResponse(responseText);
363
+
364
+ if (result.score < minScore) {
365
+ return {
366
+ success: false,
367
+ result,
368
+ message: `${provider} review score ${result.score}/10 is below threshold ${minScore}/10`,
369
+ usedFallback: false,
370
+ provider
371
+ };
372
+ }
373
+
374
+ if (result.overallStatus === 'FAIL') {
375
+ return {
376
+ success: false,
377
+ result,
378
+ message: `${provider} review found critical issues — review required`,
379
+ usedFallback: false,
380
+ provider
381
+ };
382
+ }
383
+
384
+ return {
385
+ success: true,
386
+ result,
387
+ message: `${provider} review passed — score ${result.score}/10`,
388
+ usedFallback: false,
389
+ provider
390
+ };
391
+ } catch (error) {
392
+ console.error(`${provider} review failed (${error.message}), using heuristic fallback`);
393
+ }
394
+ }
395
+
396
+ // Final fallback: heuristic analyzer
397
+ const { analyzeCode } = require('../../../server/analyzer.js');
398
+ const heuristicResult = analyzeCode(diff);
399
+ const score = heuristicResult.files.reduce((min, f) => Math.min(min, f.score), 10);
400
+ const passed = score >= minScore && heuristicResult.overallStatus === 'PASS';
401
+
402
+ return {
403
+ success: passed,
404
+ result: { ...heuristicResult, score },
405
+ message: passed
406
+ ? `Heuristic review passed — score ${score}/10 (AI unavailable)`
407
+ : `Heuristic review failed — score ${score}/10 (AI unavailable)`,
408
+ usedFallback: true,
409
+ provider: 'heuristic'
410
+ };
411
+ }
412
+
413
+ module.exports = {
414
+ reviewDiff,
415
+ loadConfig,
416
+ callGeminiAPI,
417
+ callOllamaAPI,
418
+ listOllamaModels,
419
+ isOllamaRunning,
420
+ parseAIResponse,
421
+ AI_REVIEW_PROMPT
422
+ };