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.
- package/bin/codeflow-hook.js +232 -164
- package/lib/ai-reviewer.cjs +422 -0
- package/lib/cli-integration/src/index.ts +133 -220
- package/lib/cli-integration/src/simulationEngine.ts +118 -4
- package/package.json +2 -2
- package/lib/cli-integration/dist/index.d.ts +0 -128
- package/lib/cli-integration/dist/index.js +0 -585
- package/lib/cli-integration/dist/pipelineConfigs.d.ts +0 -60
- package/lib/cli-integration/dist/pipelineConfigs.js +0 -549
- package/lib/cli-integration/dist/simulationEngine.d.ts +0 -86
- package/lib/cli-integration/dist/simulationEngine.js +0 -475
- package/lib/cli-integration/dist/types.d.ts +0 -156
- package/lib/cli-integration/dist/types.js +0 -15
|
@@ -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
|
+
};
|