codeflow-hook 2.0.3 → 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 +231 -163
- package/lib/ai-reviewer.cjs +422 -0
- package/lib/cli-integration/src/index.ts +26 -218
- package/package.json +2 -2
package/bin/codeflow-hook.js
CHANGED
|
@@ -11,7 +11,7 @@ import readline from 'readline';
|
|
|
11
11
|
import { orchestrateReview } from './agents.js';
|
|
12
12
|
|
|
13
13
|
// Import CLI integration service
|
|
14
|
-
import { indexProject
|
|
14
|
+
import { indexProject } from '../lib/cli-integration/dist/index.js';
|
|
15
15
|
|
|
16
16
|
// Export for use in agents module
|
|
17
17
|
export { callAIProvider };
|
|
@@ -26,13 +26,16 @@ program
|
|
|
26
26
|
// Configure AI provider settings
|
|
27
27
|
program
|
|
28
28
|
.command('config')
|
|
29
|
-
.description('Configure AI provider settings')
|
|
30
|
-
.option('-p, --provider <provider>', 'AI provider (gemini, openai, claude)', 'gemini')
|
|
31
|
-
.option('-k, --key <key>', 'API key for
|
|
32
|
-
.option('-u, --url <url>', 'Custom API URL (
|
|
33
|
-
.option('-m, --model <model>', 'AI model name (
|
|
29
|
+
.description('Configure AI provider settings (gemini, openai, claude, ollama)')
|
|
30
|
+
.option('-p, --provider <provider>', 'AI provider (gemini, openai, claude, ollama)', 'gemini')
|
|
31
|
+
.option('-k, --key <key>', 'API key for cloud providers (not needed for ollama)')
|
|
32
|
+
.option('-u, --url <url>', 'Custom API URL (for ollama: http://localhost:11434)')
|
|
33
|
+
.option('-m, --model <model>', 'AI model name (for ollama: auto-discovered if not specified)')
|
|
34
|
+
.option('--ollama-enable', 'Enable Ollama as the primary provider')
|
|
35
|
+
.option('--ollama-disable', 'Disable Ollama and use cloud provider instead')
|
|
36
|
+
.option('--ollama-url <url>', 'Ollama server URL (default: http://localhost:11434)')
|
|
37
|
+
.option('--list-models', 'List available Ollama models and exit')
|
|
34
38
|
.action(async (options) => {
|
|
35
|
-
// Use USERPROFILE on Windows instead of HOME which might be undefined
|
|
36
39
|
const homeDir = process.env.HOME || process.env.USERPROFILE;
|
|
37
40
|
const configDir = path.join(homeDir, '.codeflow-hook');
|
|
38
41
|
if (!fs.existsSync(configDir)) {
|
|
@@ -40,164 +43,186 @@ program
|
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
const configPath = path.join(configDir, 'config.json');
|
|
43
|
-
|
|
44
|
-
// Configuration cascade: Check for project-level config first, then fall back to global
|
|
45
|
-
const projectConfigPath = path.join(process.cwd(), '.codeflowrc.json');
|
|
46
|
-
const projectConfig = fs.existsSync(projectConfigPath) ? JSON.parse(fs.readFileSync(projectConfigPath, 'utf8')) : {};
|
|
47
|
-
|
|
48
46
|
const existingConfig = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, 'utf8')) : {};
|
|
49
47
|
|
|
50
|
-
//
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
console.log(chalk.
|
|
54
|
-
if (fs.existsSync(configPath)) {
|
|
55
|
-
console.log(chalk.gray(` Global config: ${configPath}`));
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Determine what the new provider and API key should be
|
|
60
|
-
const requestedProvider = (options.provider || existingConfig.provider || 'gemini').toLowerCase();
|
|
61
|
-
const requestedApiKey = options.key || existingConfig.apiKey;
|
|
62
|
-
|
|
63
|
-
// FIX: Explicit boolean coercion to prevent || operator from returning non-boolean values
|
|
64
|
-
// Step by step to ensure proper boolean evaluation
|
|
65
|
-
const hasNewKey = !!options.key;
|
|
66
|
-
const isFirstTimeSetup = !existingConfig.provider && !existingConfig.apiKey;
|
|
67
|
-
const shouldValidate = hasNewKey || isFirstTimeSetup;
|
|
68
|
-
|
|
69
|
-
console.log('VALIDATION DEBUG - provider:', requestedProvider, 'key_exists:', !!options.key, 'shouldValidate:', shouldValidate);
|
|
70
|
-
|
|
71
|
-
if (shouldValidate && requestedApiKey) {
|
|
72
|
-
console.log(chalk.blue('🔐 Validating API key for provider:', requestedProvider));
|
|
73
|
-
const validationSpinner = ora('Checking key permissions...').start();
|
|
74
|
-
|
|
48
|
+
// List Ollama models mode
|
|
49
|
+
if (options.listModels) {
|
|
50
|
+
const ollamaUrl = options.ollamaUrl || existingConfig.ollama?.url || 'http://localhost:11434';
|
|
51
|
+
console.log(chalk.blue(`🔍 Fetching models from Ollama at ${ollamaUrl}...`));
|
|
75
52
|
try {
|
|
76
|
-
await
|
|
77
|
-
|
|
53
|
+
const { listOllamaModels, isOllamaRunning } = await import('../lib/ai-reviewer.cjs');
|
|
54
|
+
const running = await isOllamaRunning(ollamaUrl);
|
|
55
|
+
if (!running) {
|
|
56
|
+
console.log(chalk.red('❌ Ollama is not running or not reachable'));
|
|
57
|
+
console.log(chalk.yellow(`💡 Start Ollama: ollama serve`));
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
const models = await listOllamaModels(ollamaUrl);
|
|
61
|
+
if (models.length === 0) {
|
|
62
|
+
console.log(chalk.yellow('⚠️ No models found. Pull a model first:'));
|
|
63
|
+
console.log(chalk.gray(' ollama pull qwen2.5-coder'));
|
|
64
|
+
console.log(chalk.gray(' ollama pull deepseek-coder'));
|
|
65
|
+
console.log(chalk.gray(' ollama pull codellama'));
|
|
66
|
+
} else {
|
|
67
|
+
console.log(chalk.green(`✅ Found ${models.length} model(s):`));
|
|
68
|
+
models.forEach((m, i) => console.log(chalk.gray(` ${i + 1}. ${m}`)));
|
|
69
|
+
}
|
|
70
|
+
process.exit(0);
|
|
78
71
|
} catch (error) {
|
|
79
|
-
|
|
80
|
-
console.error(chalk.red(`❌ ${error.message}`));
|
|
81
|
-
console.error(chalk.red(`💡 Make sure you're using a valid ${requestedProvider.toUpperCase()} API key for the ${requestedProvider} provider.`));
|
|
72
|
+
console.log(chalk.red(`❌ Failed to connect to Ollama: ${error.message}`));
|
|
82
73
|
process.exit(1);
|
|
83
74
|
}
|
|
84
|
-
} else {
|
|
85
|
-
console.log(chalk.yellow('⚠️ Validation skipped - no API key validation needed'));
|
|
86
75
|
}
|
|
87
76
|
|
|
88
|
-
|
|
77
|
+
const requestedProvider = (options.provider || existingConfig.provider || 'gemini').toLowerCase();
|
|
78
|
+
const ollamaEnabled = options.ollamaEnable === true ||
|
|
79
|
+
(options.ollamaEnable === undefined && options.ollamaDisable === undefined && existingConfig.ollama?.enabled === true) ||
|
|
80
|
+
requestedProvider === 'ollama';
|
|
81
|
+
|
|
82
|
+
// Initialize config structure
|
|
89
83
|
const config = {
|
|
90
|
-
provider: requestedProvider,
|
|
91
|
-
apiKey:
|
|
84
|
+
provider: requestedProvider === 'ollama' ? 'ollama' : (existingConfig.provider || 'gemini'),
|
|
85
|
+
apiKey: options.key || existingConfig.apiKey,
|
|
92
86
|
apiUrl: options.url || existingConfig.apiUrl,
|
|
93
|
-
model: options.model || existingConfig.model
|
|
87
|
+
model: options.model || existingConfig.model,
|
|
88
|
+
ollama: {
|
|
89
|
+
enabled: ollamaEnabled,
|
|
90
|
+
url: options.ollamaUrl || existingConfig.ollama?.url || 'http://localhost:11434'
|
|
91
|
+
}
|
|
94
92
|
};
|
|
95
93
|
|
|
96
|
-
//
|
|
97
|
-
if (
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const hasNewKey = !!options.key;
|
|
94
|
+
// If Ollama is the primary provider, discover models
|
|
95
|
+
if (config.ollama.enabled) {
|
|
96
|
+
console.log(chalk.blue('🦙 Ollama provider detected'));
|
|
97
|
+
console.log(chalk.gray(` URL: ${config.ollama.url}`));
|
|
101
98
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const selectedModel = await new Promise(resolve => {
|
|
123
|
-
rl.question(chalk.blue(`Enter the model name (or number): `), (input) => {
|
|
124
|
-
rl.close();
|
|
125
|
-
resolve(input.trim());
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
// Check if user entered a number
|
|
130
|
-
const index = parseInt(selectedModel) - 1;
|
|
131
|
-
if (!isNaN(index) && index >= 0 && index < models.length) {
|
|
132
|
-
config.model = models[index];
|
|
99
|
+
try {
|
|
100
|
+
const { listOllamaModels, isOllamaRunning } = await import('../lib/ai-reviewer.cjs');
|
|
101
|
+
const running = await isOllamaRunning(config.ollama.url);
|
|
102
|
+
|
|
103
|
+
if (!running) {
|
|
104
|
+
console.log(chalk.yellow('⚠️ Ollama is not running or not reachable'));
|
|
105
|
+
console.log(chalk.yellow(' Models will not be auto-discovered'));
|
|
106
|
+
if (!config.model) {
|
|
107
|
+
console.log(chalk.gray(' Using default model: qwen2.5-coder'));
|
|
108
|
+
config.model = 'qwen2.5-coder';
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
const models = await listOllamaModels(config.ollama.url);
|
|
112
|
+
if (models.length === 0) {
|
|
113
|
+
console.log(chalk.yellow('⚠️ No Ollama models found'));
|
|
114
|
+
console.log(chalk.gray(' Pull a model: ollama pull qwen2.5-coder'));
|
|
115
|
+
if (!config.model) {
|
|
116
|
+
config.model = 'qwen2.5-coder';
|
|
117
|
+
}
|
|
133
118
|
} else {
|
|
134
|
-
|
|
119
|
+
console.log(chalk.green(`✅ Found ${models.length} model(s):`));
|
|
120
|
+
models.forEach((m, i) => console.log(chalk.gray(` ${i + 1}. ${m}`)));
|
|
121
|
+
|
|
122
|
+
// Auto-select or prompt
|
|
123
|
+
if (!options.model) {
|
|
124
|
+
const defaultModel = models.find(m => m.includes('coder') || m.includes('code')) || models[0];
|
|
125
|
+
console.log(chalk.blue(`\n💡 Recommended: ${defaultModel}`));
|
|
126
|
+
|
|
127
|
+
const rl = readline.createInterface({
|
|
128
|
+
input: process.stdin,
|
|
129
|
+
output: process.stdout
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const input = await new Promise(resolve => {
|
|
133
|
+
rl.question(chalk.blue(`Select model (name or number, Enter for ${defaultModel}): `), (answer) => {
|
|
134
|
+
rl.close();
|
|
135
|
+
resolve(answer.trim());
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (input === '') {
|
|
140
|
+
config.model = defaultModel;
|
|
141
|
+
} else {
|
|
142
|
+
const idx = parseInt(input) - 1;
|
|
143
|
+
config.model = (!isNaN(idx) && idx >= 0 && idx < models.length) ? models[idx] : input;
|
|
144
|
+
}
|
|
145
|
+
console.log(chalk.green(`✓ Selected: ${config.model}`));
|
|
146
|
+
}
|
|
135
147
|
}
|
|
148
|
+
}
|
|
149
|
+
} catch (error) {
|
|
150
|
+
console.log(chalk.yellow(`⚠️ Could not connect to Ollama: ${error.message}`));
|
|
151
|
+
if (!config.model) {
|
|
152
|
+
config.model = 'qwen2.5-coder';
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} else if (options.key || !existingConfig.apiKey) {
|
|
156
|
+
// Cloud provider setup with API key validation
|
|
157
|
+
const hasNewKey = !!options.key;
|
|
158
|
+
const isFirstTime = !existingConfig.provider && !existingConfig.apiKey;
|
|
159
|
+
const shouldValidate = hasNewKey || isFirstTime;
|
|
136
160
|
|
|
137
|
-
|
|
138
|
-
|
|
161
|
+
if (shouldValidate && config.apiKey) {
|
|
162
|
+
console.log(chalk.blue(`🔐 Validating API key for ${config.provider}...`));
|
|
163
|
+
try {
|
|
164
|
+
await validateApiKey(config.provider, config.apiKey);
|
|
165
|
+
console.log(chalk.green('✅ API key validated'));
|
|
139
166
|
} catch (error) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
console.log(chalk.yellow('⚠️ Using fallback model list:'));
|
|
146
|
-
fallbackModels.forEach((model, index) => {
|
|
147
|
-
console.log(chalk.gray(` ${index + 1}. ${model}`));
|
|
148
|
-
});
|
|
167
|
+
console.log(chalk.red(`❌ ${error.message}`));
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
149
171
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
172
|
+
// Set default API URL for cloud providers
|
|
173
|
+
if (!config.apiUrl) {
|
|
174
|
+
switch (config.provider) {
|
|
175
|
+
case 'openai':
|
|
176
|
+
config.apiUrl = 'https://api.openai.com/v1/chat/completions';
|
|
177
|
+
break;
|
|
178
|
+
case 'claude':
|
|
179
|
+
config.apiUrl = 'https://api.anthropic.com/v1/messages';
|
|
180
|
+
break;
|
|
181
|
+
case 'gemini':
|
|
182
|
+
default:
|
|
183
|
+
config.apiUrl = 'https://generativelanguage.googleapis.com/v1beta/models';
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
154
187
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
188
|
+
// Interactive model selection for cloud providers
|
|
189
|
+
if (!options.model) {
|
|
190
|
+
const shouldFetch = !existingConfig.model || existingConfig.provider !== config.provider || hasNewKey;
|
|
191
|
+
if (shouldFetch) {
|
|
192
|
+
console.log(chalk.blue('🔍 Fetching available models...'));
|
|
193
|
+
try {
|
|
194
|
+
const models = await fetchModels(config.provider, config.apiKey);
|
|
195
|
+
console.log(chalk.blue('Available models:'));
|
|
196
|
+
models.forEach((m, i) => console.log(chalk.gray(` ${i + 1}. ${m}`)));
|
|
197
|
+
|
|
198
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
199
|
+
const input = await new Promise(resolve => {
|
|
200
|
+
rl.question(chalk.blue('Enter model name or number: '), (a) => { rl.close(); resolve(a.trim()); });
|
|
159
201
|
});
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
202
|
+
const idx = parseInt(input) - 1;
|
|
203
|
+
config.model = (!isNaN(idx) && idx >= 0 && idx < models.length) ? models[idx] : input;
|
|
204
|
+
} catch {
|
|
205
|
+
const fallbacks = getFallbackModels(config.provider);
|
|
206
|
+
fallbacks.forEach((m, i) => console.log(chalk.gray(` ${i + 1}. ${m}`)));
|
|
207
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
208
|
+
const input = await new Promise(resolve => {
|
|
209
|
+
rl.question(chalk.blue('Select model: '), (a) => { rl.close(); resolve(a.trim()); });
|
|
210
|
+
});
|
|
211
|
+
const idx = parseInt(input) - 1;
|
|
212
|
+
config.model = (!isNaN(idx) && idx >= 0 && idx < fallbacks.length) ? fallbacks[idx] : input;
|
|
167
213
|
}
|
|
168
|
-
|
|
169
|
-
console.log(chalk.green(`✓ Selected fallback model: ${config.model}`));
|
|
170
214
|
}
|
|
171
|
-
} else {
|
|
172
|
-
// Reuse existing model and config
|
|
173
|
-
console.log(chalk.blue(`📚 Using existing configuration (provider: ${existingConfig.provider}, model: ${existingConfig.model})`));
|
|
174
|
-
}
|
|
175
|
-
} else {
|
|
176
|
-
// User explicitly provided model
|
|
177
|
-
console.log(chalk.green(`✓ Using specified model: ${config.model}`));
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Set default API URL if not provided by options or existing config
|
|
181
|
-
if (!config.apiUrl) {
|
|
182
|
-
switch (config.provider) {
|
|
183
|
-
case 'openai':
|
|
184
|
-
config.apiUrl = 'https://api.openai.com/v1/chat/completions';
|
|
185
|
-
break;
|
|
186
|
-
case 'claude':
|
|
187
|
-
config.apiUrl = 'https://api.anthropic.com/v1/messages';
|
|
188
|
-
break;
|
|
189
|
-
case 'gemini':
|
|
190
|
-
default:
|
|
191
|
-
// Updated Gemini API URL to v1 - using a base URL
|
|
192
|
-
config.apiUrl = 'https://generativelanguage.googleapis.com/v1/models';
|
|
193
|
-
break;
|
|
194
215
|
}
|
|
195
216
|
}
|
|
196
217
|
|
|
218
|
+
// Save config
|
|
197
219
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
198
|
-
console.log(chalk.green(
|
|
199
|
-
|
|
200
|
-
|
|
220
|
+
console.log(chalk.green('\n✅ Configuration saved'));
|
|
221
|
+
console.log(chalk.gray(` Provider: ${config.provider}`));
|
|
222
|
+
console.log(chalk.gray(` Model: ${config.model}`));
|
|
223
|
+
console.log(chalk.gray(` Ollama: ${config.ollama.enabled ? 'enabled (' + config.ollama.url + ')' : 'disabled'}`));
|
|
224
|
+
if (config.apiKey) {
|
|
225
|
+
console.log(chalk.gray(` API Key: ${config.apiKey.substring(0, 8)}...`));
|
|
201
226
|
}
|
|
202
227
|
});
|
|
203
228
|
|
|
@@ -293,12 +318,13 @@ exit 0
|
|
|
293
318
|
}
|
|
294
319
|
});
|
|
295
320
|
|
|
296
|
-
// Analyze diff with
|
|
321
|
+
// Analyze diff with AI review + heuristic fallback
|
|
297
322
|
program
|
|
298
323
|
.command('analyze-diff')
|
|
299
|
-
.description('Analyze git diff
|
|
324
|
+
.description('Analyze git diff with AI code review (Gemini API) and heuristic fallback')
|
|
300
325
|
.argument('[diff]', 'Git diff content')
|
|
301
|
-
.option('--
|
|
326
|
+
.option('--min-score <score>', 'Minimum score to pass (1-10, default: 3)', '3')
|
|
327
|
+
.option('--json', 'Output results as JSON only')
|
|
302
328
|
.action(async (diff, options) => {
|
|
303
329
|
try {
|
|
304
330
|
// Read diff content from stdin or argument
|
|
@@ -316,24 +342,48 @@ program
|
|
|
316
342
|
return;
|
|
317
343
|
}
|
|
318
344
|
|
|
319
|
-
|
|
345
|
+
// Guard against huge diffs
|
|
346
|
+
if (diffContent.length > 20000) {
|
|
347
|
+
console.log(chalk.yellow('⚠️ Diff too large for AI review (>20KB), using heuristic'));
|
|
348
|
+
}
|
|
320
349
|
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
outputFormat: 'console'
|
|
324
|
-
});
|
|
350
|
+
const minScore = parseInt(options.minScore, 10);
|
|
351
|
+
const { reviewDiff } = await import('../lib/ai-reviewer.cjs');
|
|
325
352
|
|
|
326
|
-
|
|
327
|
-
console.log(chalk.green(`✅ ${result.message}`));
|
|
328
|
-
displayEKGAnalysisResults(result.analysis);
|
|
353
|
+
const result = await reviewDiff(diffContent, { minScore });
|
|
329
354
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
console.log(chalk.gray(`👥 Similar Repos Found: ${result.stats.similar_repos_found}`));
|
|
333
|
-
console.log(chalk.gray(`⏱️ Analysis Time: ${result.stats.analysis_time}ms`));
|
|
334
|
-
}
|
|
355
|
+
if (options.json) {
|
|
356
|
+
console.log(JSON.stringify(result, null, 2));
|
|
335
357
|
} else {
|
|
336
|
-
|
|
358
|
+
const icon = result.success ? '✅' : '❌';
|
|
359
|
+
const color = result.success ? chalk.green : chalk.red;
|
|
360
|
+
const providerLabel = result.provider === 'ollama' ? '🦙 Ollama' :
|
|
361
|
+
result.provider === 'heuristic' ? '🔍 Heuristic' :
|
|
362
|
+
result.provider === 'gemini' ? '💎 Gemini' :
|
|
363
|
+
result.provider === 'openai' ? '🔵 OpenAI' :
|
|
364
|
+
result.provider === 'claude' ? '🟣 Claude' : result.provider;
|
|
365
|
+
console.log(color(`${icon} ${result.message}`));
|
|
366
|
+
console.log(chalk.gray(` Provider: ${providerLabel}${result.usedFallback ? ' (fallback)' : ''}`));
|
|
367
|
+
|
|
368
|
+
if (result.result.score) {
|
|
369
|
+
console.log(chalk.blue(`📊 Score: ${result.result.score}/10 (threshold: ${minScore}/10)`));
|
|
370
|
+
}
|
|
371
|
+
if (result.result.summary) {
|
|
372
|
+
console.log(chalk.gray(`📝 ${result.result.summary}`));
|
|
373
|
+
}
|
|
374
|
+
if (result.result.files && result.result.files.length > 0) {
|
|
375
|
+
for (const file of result.result.files) {
|
|
376
|
+
if (file.issues && file.issues.length > 0) {
|
|
377
|
+
console.log(chalk.yellow(`\n📁 ${file.fileName}:`));
|
|
378
|
+
for (const issue of file.issues) {
|
|
379
|
+
console.log(chalk.red(` - [${issue.type}] ${issue.description}`));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (!result.success) {
|
|
337
387
|
process.exit(1);
|
|
338
388
|
}
|
|
339
389
|
|
|
@@ -433,11 +483,10 @@ program
|
|
|
433
483
|
program
|
|
434
484
|
.command('status')
|
|
435
485
|
.description('Show installation and configuration status')
|
|
436
|
-
.action(() => {
|
|
486
|
+
.action(async () => {
|
|
437
487
|
console.log(chalk.blue('🔍 Codeflow Hook Status'));
|
|
438
488
|
console.log();
|
|
439
489
|
|
|
440
|
-
// Check configuration cascade
|
|
441
490
|
const globalConfigPath = path.join(os.homedir(), '.codeflow-hook', 'config.json');
|
|
442
491
|
const projectConfigPath = path.join(process.cwd(), '.codeflowrc.json');
|
|
443
492
|
|
|
@@ -451,16 +500,35 @@ program
|
|
|
451
500
|
}
|
|
452
501
|
|
|
453
502
|
if (hasGlobalConfig) {
|
|
503
|
+
const config = JSON.parse(fs.readFileSync(globalConfigPath, 'utf8'));
|
|
454
504
|
console.log(chalk.green('✅ Global Configuration: Found'));
|
|
505
|
+
console.log(chalk.gray(` Provider: ${config.provider || 'gemini'}`));
|
|
506
|
+
console.log(chalk.gray(` Model: ${config.model || '(not set)'}`));
|
|
507
|
+
if (config.ollama?.enabled) {
|
|
508
|
+
console.log(chalk.green(` Ollama: enabled (${config.ollama.url || 'http://localhost:11434'})`));
|
|
509
|
+
try {
|
|
510
|
+
const { isOllamaRunning } = await import('../lib/ai-reviewer.cjs');
|
|
511
|
+
const running = await isOllamaRunning(config.ollama.url);
|
|
512
|
+
console.log(running ? chalk.green(' Ollama Status: running') : chalk.yellow(' Ollama Status: not running'));
|
|
513
|
+
} catch {
|
|
514
|
+
console.log(chalk.yellow(' Ollama Status: unknown'));
|
|
515
|
+
}
|
|
516
|
+
} else {
|
|
517
|
+
console.log(chalk.gray(' Ollama: disabled'));
|
|
518
|
+
}
|
|
519
|
+
if (config.apiKey) {
|
|
520
|
+
console.log(chalk.gray(` API Key: ${config.apiKey.substring(0, 8)}...`));
|
|
521
|
+
}
|
|
455
522
|
} else {
|
|
456
523
|
console.log(chalk.red('❌ Global Configuration: Not found (run: codeflow-hook config)'));
|
|
457
524
|
}
|
|
458
525
|
|
|
459
526
|
if (!hasGlobalConfig && !hasProjectConfig) {
|
|
460
|
-
console.log(chalk.red('❌ No configuration found. Run: codeflow-hook config
|
|
527
|
+
console.log(chalk.red('❌ No configuration found. Run: codeflow-hook config'));
|
|
528
|
+
console.log(chalk.gray(' Cloud: codeflow-hook config -k <api-key> -p gemini'));
|
|
529
|
+
console.log(chalk.gray(' Local: codeflow-hook config --ollama-enable'));
|
|
461
530
|
}
|
|
462
531
|
|
|
463
|
-
// Check git hooks
|
|
464
532
|
const hooksDir = '.git/hooks';
|
|
465
533
|
const preCommitHook = path.join(hooksDir, 'pre-commit');
|
|
466
534
|
const prePushHook = path.join(hooksDir, 'pre-push');
|
|
@@ -479,9 +547,9 @@ program
|
|
|
479
547
|
|
|
480
548
|
console.log();
|
|
481
549
|
console.log(chalk.blue('💡 Tips:'));
|
|
482
|
-
console.log(chalk.gray(' •
|
|
483
|
-
console.log(chalk.gray(' •
|
|
484
|
-
console.log(chalk.gray(' •
|
|
550
|
+
console.log(chalk.gray(' • Use --ollama-enable for local AI (no API key needed)'));
|
|
551
|
+
console.log(chalk.gray(' • List Ollama models: codeflow-hook config --list-models'));
|
|
552
|
+
console.log(chalk.gray(' • Large diffs (>20KB) use heuristic fallback'));
|
|
485
553
|
});
|
|
486
554
|
|
|
487
555
|
|
|
@@ -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
|
+
};
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* CLI Integration Service
|
|
4
|
+
* CLI Integration Service
|
|
5
5
|
*
|
|
6
|
-
* Bridges
|
|
7
|
-
*
|
|
6
|
+
* Bridges CLI commands with local git operations.
|
|
7
|
+
* EKG backend integration (Phase 4) removed — not deployed.
|
|
8
8
|
*
|
|
9
|
-
* Key
|
|
10
|
-
* - `codeflow index` →
|
|
11
|
-
* - `codeflow analyze-diff` →
|
|
9
|
+
* Key operations:
|
|
10
|
+
* - `codeflow index` → Submit repository for indexing
|
|
11
|
+
* - `codeflow analyze-diff` → Local diff parsing only
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import axios from 'axios';
|
|
@@ -235,9 +235,8 @@ export class CLIIntegrationService {
|
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
/**
|
|
238
|
-
* Analyze code diff
|
|
239
|
-
*
|
|
240
|
-
* Sends diff to Query Service for EKG-enhanced analysis instead of local RAG
|
|
238
|
+
* Analyze code diff — simplified to local diff parsing only.
|
|
239
|
+
* EKG backend integration removed (Phase 4 not deployed).
|
|
241
240
|
*/
|
|
242
241
|
async analyzeDiff(diffContent: string, options: {
|
|
243
242
|
legacy?: boolean;
|
|
@@ -263,22 +262,12 @@ export class CLIIntegrationService {
|
|
|
263
262
|
};
|
|
264
263
|
}
|
|
265
264
|
|
|
266
|
-
|
|
267
|
-
// Fallback to local analysis (would integrate with existing agents)
|
|
268
|
-
logger.warn('Legacy mode requested - falling back to local analysis');
|
|
269
|
-
return {
|
|
270
|
-
success: false,
|
|
271
|
-
analysis: null,
|
|
272
|
-
message: 'Legacy mode not yet implemented with EKG integration'
|
|
273
|
-
};
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
logger.info('Analyzing diff with EKG context enhancement', {
|
|
265
|
+
logger.info('Analyzing diff (local only — EKG backend not deployed)', {
|
|
277
266
|
diffSize: diffContent.length,
|
|
278
267
|
lines: diffContent.split('\n').length
|
|
279
268
|
});
|
|
280
269
|
|
|
281
|
-
//
|
|
270
|
+
// Parse diff content locally
|
|
282
271
|
const diffAnalysis = this.analyzeDiffContent(diffContent);
|
|
283
272
|
|
|
284
273
|
if (diffAnalysis.files.length === 0) {
|
|
@@ -289,28 +278,30 @@ export class CLIIntegrationService {
|
|
|
289
278
|
};
|
|
290
279
|
}
|
|
291
280
|
|
|
292
|
-
// Query EKG for context on affected files
|
|
293
|
-
const ekgContext = await this.getEKGContext(diffAnalysis);
|
|
294
|
-
|
|
295
|
-
// Generate enhanced analysis with EKG data
|
|
296
|
-
const enhancedAnalysis = await this.generateEKGEnhancedAnalysis(diffAnalysis, ekgContext);
|
|
297
|
-
|
|
298
281
|
const analysisTime = Date.now() - startTime;
|
|
299
282
|
|
|
300
|
-
logger.info('Diff analysis completed
|
|
283
|
+
logger.info('Diff analysis completed', {
|
|
301
284
|
affectedFiles: diffAnalysis.files.length,
|
|
302
|
-
ekgQueries: ekgContext.queriesMade,
|
|
303
|
-
similarReposFound: ekgContext.similarRepositories?.length || 0,
|
|
304
285
|
analysisTime
|
|
305
286
|
});
|
|
306
287
|
|
|
307
288
|
return {
|
|
308
289
|
success: true,
|
|
309
|
-
analysis:
|
|
310
|
-
|
|
290
|
+
analysis: {
|
|
291
|
+
summary: {
|
|
292
|
+
totalFiles: diffAnalysis.files.length,
|
|
293
|
+
totalAdditions: diffAnalysis.totalAdditions,
|
|
294
|
+
totalDeletions: diffAnalysis.totalDeletions,
|
|
295
|
+
ekgEnhanced: false
|
|
296
|
+
},
|
|
297
|
+
files: diffAnalysis.files,
|
|
298
|
+
issues: [],
|
|
299
|
+
recommendations: []
|
|
300
|
+
},
|
|
301
|
+
message: 'Diff analyzed (local analysis only)',
|
|
311
302
|
stats: {
|
|
312
|
-
ekg_queries:
|
|
313
|
-
similar_repos_found:
|
|
303
|
+
ekg_queries: 0,
|
|
304
|
+
similar_repos_found: 0,
|
|
314
305
|
analysis_time: analysisTime
|
|
315
306
|
}
|
|
316
307
|
};
|
|
@@ -410,178 +401,6 @@ export class CLIIntegrationService {
|
|
|
410
401
|
};
|
|
411
402
|
}
|
|
412
403
|
|
|
413
|
-
/**
|
|
414
|
-
* Query EKG for context on affected files
|
|
415
|
-
*/
|
|
416
|
-
private async getEKGContext(diffAnalysis: any): Promise<{
|
|
417
|
-
queriesMade: number;
|
|
418
|
-
repositoryIntelligence?: any;
|
|
419
|
-
similarRepositories: any[];
|
|
420
|
-
patterns: any[];
|
|
421
|
-
}> {
|
|
422
|
-
let queriesMade = 0;
|
|
423
|
-
|
|
424
|
-
try {
|
|
425
|
-
// Get current repository information
|
|
426
|
-
const repoInfo = await this.getRepositoryInfo();
|
|
427
|
-
const repositoryId = this.generateRepositoryId(repoInfo.fullName);
|
|
428
|
-
|
|
429
|
-
// Query repository intelligence if repository exists in EKG
|
|
430
|
-
let repositoryIntelligence = null;
|
|
431
|
-
try {
|
|
432
|
-
const response = await this.makeGraphQLRequest(
|
|
433
|
-
`
|
|
434
|
-
query GetRepositoryIntelligence($repoId: ID!) {
|
|
435
|
-
repositoryIntelligence(repositoryId: $repoId) {
|
|
436
|
-
repository {
|
|
437
|
-
id name fullName language
|
|
438
|
-
}
|
|
439
|
-
patterns {
|
|
440
|
-
name type confidence category
|
|
441
|
-
}
|
|
442
|
-
dependencies {
|
|
443
|
-
dependencyType currentVersion confidence
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
`,
|
|
448
|
-
{ repoId: repositoryId }
|
|
449
|
-
);
|
|
450
|
-
repositoryIntelligence = response.data?.repositoryIntelligence;
|
|
451
|
-
queriesMade++;
|
|
452
|
-
} catch (error) {
|
|
453
|
-
logger.warn('Repository not found in EKG, continuing analysis', { repositoryId });
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
// Find similar repositories and patterns for context
|
|
457
|
-
let similarRepositories: any[] = [];
|
|
458
|
-
try {
|
|
459
|
-
const response = await this.makeGraphQLRequest(
|
|
460
|
-
`
|
|
461
|
-
query FindSimilarRepositories($repoId: ID!, $limit: Int) {
|
|
462
|
-
similarRepositories(repositoryId: $repoId, limit: $limit) {
|
|
463
|
-
repository { name fullName language }
|
|
464
|
-
similarityScore reasons
|
|
465
|
-
sharedPatterns sizeComparison
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
`,
|
|
469
|
-
{ repoId: repositoryId, limit: 5 }
|
|
470
|
-
);
|
|
471
|
-
similarRepositories = response.data?.similarRepositories || [];
|
|
472
|
-
queriesMade++;
|
|
473
|
-
} catch (error) {
|
|
474
|
-
logger.warn('Could not fetch similar repositories', { error: this.formatError(error) });
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
// Get enterprise-wide patterns that might be relevant
|
|
478
|
-
let patterns: any[] = [];
|
|
479
|
-
try {
|
|
480
|
-
const languages = [...new Set(diffAnalysis.files.map((f: any) => f.language))];
|
|
481
|
-
|
|
482
|
-
const response = await this.makeGraphQLRequest(
|
|
483
|
-
`
|
|
484
|
-
query GetRelevantPatterns($language: String, $limit: Int) {
|
|
485
|
-
patterns(language: $language, minConfidence: 0.7, limit: $limit) {
|
|
486
|
-
name type category confidence observationCount
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
`,
|
|
490
|
-
{ language: languages[0], limit: 10 }
|
|
491
|
-
);
|
|
492
|
-
patterns = response.data?.patterns || [];
|
|
493
|
-
queriesMade++;
|
|
494
|
-
} catch (error) {
|
|
495
|
-
logger.warn('Could not fetch patterns', { error: this.formatError(error) });
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
return {
|
|
499
|
-
queriesMade,
|
|
500
|
-
repositoryIntelligence,
|
|
501
|
-
similarRepositories,
|
|
502
|
-
patterns
|
|
503
|
-
};
|
|
504
|
-
|
|
505
|
-
} catch (error) {
|
|
506
|
-
logger.error('EKG context retrieval failed', { error: this.formatError(error) });
|
|
507
|
-
return {
|
|
508
|
-
queriesMade,
|
|
509
|
-
similarRepositories: [],
|
|
510
|
-
patterns: []
|
|
511
|
-
};
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
/**
|
|
516
|
-
* Generate enhanced analysis using EKG context
|
|
517
|
-
*/
|
|
518
|
-
private async generateEKGEnhancedAnalysis(diffAnalysis: any, ekgContext: any): Promise<any> {
|
|
519
|
-
// Analyze changes with EKG context
|
|
520
|
-
const issues: any[] = [];
|
|
521
|
-
const recommendations: any[] = [];
|
|
522
|
-
|
|
523
|
-
// Check against existing patterns
|
|
524
|
-
if (ekgContext.patterns && ekgContext.patterns.length > 0) {
|
|
525
|
-
for (const file of diffAnalysis.files) {
|
|
526
|
-
const relevantPatterns = ekgContext.patterns.filter((p: any) =>
|
|
527
|
-
p.type === 'security' || p.type === 'architecture'
|
|
528
|
-
);
|
|
529
|
-
|
|
530
|
-
if (relevantPatterns.length > 0) {
|
|
531
|
-
recommendations.push({
|
|
532
|
-
type: 'ekg_pattern_alignment',
|
|
533
|
-
description: `File ${file.path} modified - consider these established patterns: ${relevantPatterns.map((p: any) => p.name).join(', ')}`,
|
|
534
|
-
severity: 'info',
|
|
535
|
-
file: file.path
|
|
536
|
-
});
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
// Compare against similar repositories
|
|
542
|
-
if (ekgContext.similarRepositories && ekgContext.similarRepositories.length > 0) {
|
|
543
|
-
const similarRepoNames = ekgContext.similarRepositories.map((sr: any) => sr.repository.fullName);
|
|
544
|
-
recommendations.push({
|
|
545
|
-
type: 'similar_repositories',
|
|
546
|
-
description: `Changes similar to patterns seen in: ${similarRepoNames.slice(0, 3).join(', ')}`,
|
|
547
|
-
severity: 'info'
|
|
548
|
-
});
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
// Add repository-specific context if available
|
|
552
|
-
if (ekgContext.repositoryIntelligence) {
|
|
553
|
-
const repo = ekgContext.repositoryIntelligence.repository;
|
|
554
|
-
issues.push({
|
|
555
|
-
type: 'repository_context',
|
|
556
|
-
description: `Analyzing changes in repository ${repo.fullName} (${repo.language})`,
|
|
557
|
-
severity: 'info'
|
|
558
|
-
});
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
return {
|
|
562
|
-
summary: {
|
|
563
|
-
totalFiles: diffAnalysis.files.length,
|
|
564
|
-
totalAdditions: diffAnalysis.totalAdditions,
|
|
565
|
-
totalDeletions: diffAnalysis.totalDeletions,
|
|
566
|
-
ekgEnhanced: true
|
|
567
|
-
},
|
|
568
|
-
files: diffAnalysis.files.map((file: any) => ({
|
|
569
|
-
path: file.path,
|
|
570
|
-
language: file.language,
|
|
571
|
-
additions: file.additions,
|
|
572
|
-
deletions: file.deletions,
|
|
573
|
-
isNew: file.isNew
|
|
574
|
-
})),
|
|
575
|
-
issues,
|
|
576
|
-
recommendations,
|
|
577
|
-
ekg_context: {
|
|
578
|
-
patterns_analyzed: ekgContext.patterns?.length || 0,
|
|
579
|
-
similar_repositories_found: ekgContext.similarRepositories?.length || 0,
|
|
580
|
-
repository_known: !!ekgContext.repositoryIntelligence
|
|
581
|
-
}
|
|
582
|
-
};
|
|
583
|
-
}
|
|
584
|
-
|
|
585
404
|
/**
|
|
586
405
|
* Get current repository information
|
|
587
406
|
*/
|
|
@@ -780,18 +599,7 @@ export class CLIIntegrationService {
|
|
|
780
599
|
}
|
|
781
600
|
|
|
782
601
|
/**
|
|
783
|
-
*
|
|
784
|
-
*/
|
|
785
|
-
private async makeGraphQLRequest(query: string, variables: any = {}): Promise<any> {
|
|
786
|
-
return this.makeBackendRequest(
|
|
787
|
-
`${this.config.queryServiceUrl}/graphql`,
|
|
788
|
-
{ query, variables },
|
|
789
|
-
{ 'Content-Type': 'application/json' }
|
|
790
|
-
);
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
/**
|
|
794
|
-
* Generate repository ID (similar to ingestion service)
|
|
602
|
+
* Generate repository ID
|
|
795
603
|
*/
|
|
796
604
|
private generateRepositoryId(fullName: string): string {
|
|
797
605
|
return `${fullName.replace('/', '-')}-${Date.now().toString(36)}`;
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
|
|
2
2
|
{
|
|
3
3
|
"name": "codeflow-hook",
|
|
4
|
-
"version": "2.0
|
|
5
|
-
"description": "
|
|
4
|
+
"version": "2.2.0",
|
|
5
|
+
"description": "Local AI-powered code analysis and pre-push review for development workflows",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "index.js",
|
|
8
8
|
"bin": {
|