ai-code-review-kit 1.1.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/README.md +275 -0
- package/README.zh-CN.md +268 -0
- package/package.json +37 -0
- package/src/AIError.js +7 -0
- package/src/ai-review-cli.js +79 -0
- package/src/ai-review.js +392 -0
- package/src/cli.js +282 -0
- package/src/core.js +420 -0
- package/src/index.js +5 -0
- package/src/kb-index.js +251 -0
- package/src/prompts.js +63 -0
- package/src/providers/adapters/ollama.js +61 -0
- package/src/providers/adapters/openai.js +144 -0
- package/src/providers/base.js +120 -0
- package/src/providers/index.js +11 -0
- package/src/rag/embeddings.js +168 -0
- package/src/rag/fs.js +97 -0
- package/src/rag/index.js +121 -0
- package/src/rag/lancedb.js +14 -0
- package/src/rag/text.js +18 -0
- package/src/utils/openai.js +50 -0
package/src/ai-review.js
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { execFileSync } from 'child_process'
|
|
4
|
+
import chalk from 'chalk'
|
|
5
|
+
import fs from 'fs'
|
|
6
|
+
import path from 'path'
|
|
7
|
+
import CodeReviewer from './core.js'
|
|
8
|
+
import AIError from './AIError.js'
|
|
9
|
+
import { fileURLToPath } from 'url'
|
|
10
|
+
|
|
11
|
+
const defaultConfig = {
|
|
12
|
+
providerType: 'OPENAI',
|
|
13
|
+
model: '',
|
|
14
|
+
baseURL: '',
|
|
15
|
+
useResponsesApi: false,
|
|
16
|
+
|
|
17
|
+
timeoutMs: 120000,
|
|
18
|
+
concurrency: 2,
|
|
19
|
+
maxRetries: 1,
|
|
20
|
+
retryDelayMs: 500,
|
|
21
|
+
|
|
22
|
+
ignoreDeletions: true,
|
|
23
|
+
stripUnchangedCommentLines: true,
|
|
24
|
+
|
|
25
|
+
diffContextLines: 3,
|
|
26
|
+
maxChunkSize: 12000,
|
|
27
|
+
temperature: 0.2,
|
|
28
|
+
language: 'chinese',
|
|
29
|
+
strict: true,
|
|
30
|
+
skipReview: false,
|
|
31
|
+
showNormal: false,
|
|
32
|
+
correctedResult: true,
|
|
33
|
+
customPrompts: '',
|
|
34
|
+
enabledFileExtensions: '.html, .js, .jsx, .ts, .tsx, .vue',
|
|
35
|
+
|
|
36
|
+
checkSecurity: true,
|
|
37
|
+
checkPerformance: true,
|
|
38
|
+
checkStyle: false,
|
|
39
|
+
|
|
40
|
+
enableRag: false,
|
|
41
|
+
knowledgeBasePaths: '',
|
|
42
|
+
knowledgeBaseIndexDir: '.ai-reviewer-cache/lancedb',
|
|
43
|
+
knowledgeBaseTable: 'project_kb',
|
|
44
|
+
ragTopK: 6,
|
|
45
|
+
ragMaxChars: 8000,
|
|
46
|
+
embeddingsProviderType: '',
|
|
47
|
+
embeddingsBaseURL: '',
|
|
48
|
+
embeddingsApiKey: '',
|
|
49
|
+
embeddingsModel: '',
|
|
50
|
+
embeddingsDimensions: null,
|
|
51
|
+
kbChunkSize: 1500,
|
|
52
|
+
kbChunkOverlap: 200,
|
|
53
|
+
kbMaxFileSizeBytes: 524288
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function runGit(args, options = {}) {
|
|
57
|
+
return execFileSync('git', args, { encoding: 'utf8', ...options }).trim()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getGitRoot() {
|
|
61
|
+
try {
|
|
62
|
+
return runGit(['rev-parse', '--show-toplevel'], { cwd: process.cwd() })
|
|
63
|
+
} catch {
|
|
64
|
+
handleError('Git error:', 'Not a git repository (or any of the parent directories).')
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function stripOuterQuotes(value) {
|
|
69
|
+
if (typeof value !== 'string') return value
|
|
70
|
+
const trimmed = value.trim()
|
|
71
|
+
const first = trimmed[0]
|
|
72
|
+
const last = trimmed[trimmed.length - 1]
|
|
73
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
74
|
+
return trimmed.slice(1, -1)
|
|
75
|
+
}
|
|
76
|
+
return trimmed
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseEnvFile(envContent) {
|
|
80
|
+
const config = {}
|
|
81
|
+
envContent.split(/\r?\n/).forEach((line) => {
|
|
82
|
+
const trimmed = line.trim()
|
|
83
|
+
if (!trimmed || trimmed.startsWith('#')) return
|
|
84
|
+
|
|
85
|
+
const normalized = trimmed.startsWith('export ') ? trimmed.slice('export '.length) : trimmed
|
|
86
|
+
const idx = normalized.indexOf('=')
|
|
87
|
+
if (idx === -1) return
|
|
88
|
+
|
|
89
|
+
const key = normalized.slice(0, idx).trim()
|
|
90
|
+
const rawValue = normalized.slice(idx + 1).trim()
|
|
91
|
+
if (!key) return
|
|
92
|
+
|
|
93
|
+
config[key] = stripOuterQuotes(rawValue)
|
|
94
|
+
})
|
|
95
|
+
return config
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function canonicalizeKey(key) {
|
|
99
|
+
return String(key || '')
|
|
100
|
+
.toLowerCase()
|
|
101
|
+
.replace(/[^a-z0-9]/g, '')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildCanonicalKeyMap(knownKeys) {
|
|
105
|
+
const map = new Map()
|
|
106
|
+
for (const key of knownKeys) {
|
|
107
|
+
map.set(canonicalizeKey(key), key)
|
|
108
|
+
}
|
|
109
|
+
return map
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function normalizeConfigKeys(rawConfig, knownKeys, keyMap) {
|
|
113
|
+
const config = rawConfig && typeof rawConfig === 'object' ? rawConfig : {}
|
|
114
|
+
const normalized = {}
|
|
115
|
+
for (const [rawKey, rawValue] of Object.entries(config)) {
|
|
116
|
+
if (knownKeys.has(rawKey)) {
|
|
117
|
+
normalized[rawKey] = rawValue
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
const mapped = keyMap.get(canonicalizeKey(rawKey))
|
|
121
|
+
if (mapped) {
|
|
122
|
+
normalized[mapped] = rawValue
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
normalized[rawKey] = rawValue
|
|
126
|
+
}
|
|
127
|
+
return normalized
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function pickConfigFromProcessEnv(knownKeys, keyMap) {
|
|
131
|
+
const picked = {}
|
|
132
|
+
const prefixes = ['AI_REVIEW_', 'AI_CODE_REVIEW_KIT_']
|
|
133
|
+
for (const [rawKey, rawValue] of Object.entries(process.env || {})) {
|
|
134
|
+
if (rawValue == null || rawValue === '') continue
|
|
135
|
+
|
|
136
|
+
// Backward compatible: allow exact, case-sensitive config keys
|
|
137
|
+
if (knownKeys.has(rawKey)) {
|
|
138
|
+
picked[rawKey] = rawValue
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const prefix = prefixes.find((p) => rawKey.startsWith(p))
|
|
143
|
+
if (!prefix) continue
|
|
144
|
+
|
|
145
|
+
const suffix = rawKey.slice(prefix.length)
|
|
146
|
+
const mapped = keyMap.get(canonicalizeKey(suffix))
|
|
147
|
+
if (!mapped) continue
|
|
148
|
+
picked[mapped] = rawValue
|
|
149
|
+
}
|
|
150
|
+
return picked
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function coerceConfigTypes(config) {
|
|
154
|
+
const next = { ...config }
|
|
155
|
+
const numberKeys = [
|
|
156
|
+
'maxChunkSize',
|
|
157
|
+
'temperature',
|
|
158
|
+
'diffContextLines',
|
|
159
|
+
'timeoutMs',
|
|
160
|
+
'concurrency',
|
|
161
|
+
'maxRetries',
|
|
162
|
+
'retryDelayMs',
|
|
163
|
+
'ragTopK',
|
|
164
|
+
'ragMaxChars',
|
|
165
|
+
'embeddingsDimensions',
|
|
166
|
+
'kbChunkSize',
|
|
167
|
+
'kbChunkOverlap',
|
|
168
|
+
'kbMaxFileSizeBytes',
|
|
169
|
+
]
|
|
170
|
+
const booleanKeys = [
|
|
171
|
+
'strict',
|
|
172
|
+
'skipReview',
|
|
173
|
+
'showNormal',
|
|
174
|
+
'correctedResult',
|
|
175
|
+
'ignoreDeletions',
|
|
176
|
+
'stripUnchangedCommentLines',
|
|
177
|
+
'checkSecurity',
|
|
178
|
+
'checkPerformance',
|
|
179
|
+
'checkStyle',
|
|
180
|
+
'enableRag',
|
|
181
|
+
'useResponsesApi',
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
for (const key of booleanKeys) {
|
|
185
|
+
if (typeof next[key] === 'string') {
|
|
186
|
+
const value = next[key].trim().toLowerCase()
|
|
187
|
+
if (value === 'true' || value === '1') next[key] = true
|
|
188
|
+
if (value === 'false' || value === '0') next[key] = false
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const key of numberKeys) {
|
|
193
|
+
if (typeof next[key] === 'string' && next[key].trim() !== '') {
|
|
194
|
+
const num = Number(next[key])
|
|
195
|
+
if (!Number.isNaN(num)) next[key] = num
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return next
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function loadConfig(gitRoot) {
|
|
202
|
+
try {
|
|
203
|
+
const envPath = path.join(gitRoot, '.env')
|
|
204
|
+
const pkgPath = path.join(gitRoot, 'package.json')
|
|
205
|
+
|
|
206
|
+
let pkgConfig = {}
|
|
207
|
+
if (fs.existsSync(pkgPath)) {
|
|
208
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
|
|
209
|
+
if (pkg.aiCheckConfig && typeof pkg.aiCheckConfig === 'object') {
|
|
210
|
+
pkgConfig = pkg.aiCheckConfig
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let envConfig = {}
|
|
215
|
+
if (fs.existsSync(envPath)) {
|
|
216
|
+
const envContent = fs.readFileSync(envPath, 'utf8')
|
|
217
|
+
envConfig = parseEnvFile(envContent)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const knownKeys = new Set([...Object.keys(defaultConfig), 'apiKey'])
|
|
221
|
+
const keyMap = buildCanonicalKeyMap(knownKeys)
|
|
222
|
+
|
|
223
|
+
pkgConfig = normalizeConfigKeys(pkgConfig, knownKeys, keyMap)
|
|
224
|
+
envConfig = normalizeConfigKeys(envConfig, knownKeys, keyMap)
|
|
225
|
+
const processEnvConfig = pickConfigFromProcessEnv(knownKeys, keyMap)
|
|
226
|
+
|
|
227
|
+
// Precedence: package.json < .env < process.env
|
|
228
|
+
const merged = coerceConfigTypes({ ...pkgConfig, ...envConfig, ...processEnvConfig })
|
|
229
|
+
if (Object.keys(merged).length === 0) {
|
|
230
|
+
throw new Error('No ai-code-review-kit configuration found in process.env, .env or package.json')
|
|
231
|
+
}
|
|
232
|
+
return merged
|
|
233
|
+
} catch (err) {
|
|
234
|
+
handleError('Configuration error:', err.message)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function getStagedChanges(fileExtensions, gitRoot) {
|
|
239
|
+
try {
|
|
240
|
+
const output = runGit(['diff', '--cached', '--name-only'], { cwd: gitRoot })
|
|
241
|
+
if (!output) {
|
|
242
|
+
console.log(chalk.yellow('No staged changes found.'))
|
|
243
|
+
process.exit(1)
|
|
244
|
+
}
|
|
245
|
+
const changedFiles = output.split('\n').filter(Boolean)
|
|
246
|
+
// 过滤指定扩展名的文件
|
|
247
|
+
const filteredFiles = changedFiles.filter(file => {
|
|
248
|
+
const ext = path.extname(file).toLowerCase()
|
|
249
|
+
return fileExtensions.includes(ext)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
if (filteredFiles.length === 0) {
|
|
253
|
+
console.log(chalk.yellow(`No changes found in specified file types: ${fileExtensions.join(', ')}`))
|
|
254
|
+
process.exit(0)
|
|
255
|
+
}
|
|
256
|
+
return filteredFiles
|
|
257
|
+
} catch (err) {
|
|
258
|
+
handleError('Error checking staged changes:', err.message)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function getDiffContent(filteredFiles, config, gitRoot) {
|
|
263
|
+
try {
|
|
264
|
+
const diffArgs = ['diff', '--cached', '--no-color', `--unified=${config.diffContextLines}`]
|
|
265
|
+
if (filteredFiles.length) {
|
|
266
|
+
diffArgs.push('--', ...filteredFiles)
|
|
267
|
+
}
|
|
268
|
+
const diff = runGit(diffArgs, { cwd: gitRoot })
|
|
269
|
+
|
|
270
|
+
if (!diff) {
|
|
271
|
+
console.log(chalk.yellow('No diff content found for files:'), filteredFiles.join(', '));
|
|
272
|
+
process.exit(0);
|
|
273
|
+
}
|
|
274
|
+
return diff;
|
|
275
|
+
} catch (err) {
|
|
276
|
+
handleError('Error getting diff:', err.message);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function handleError (text, message) {
|
|
281
|
+
console.error(chalk.red(text), message)
|
|
282
|
+
process.exit(1)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function summarizeIssues(list) {
|
|
286
|
+
const counts = { high: 0, medium: 0, low: 0 }
|
|
287
|
+
const items = Array.isArray(list) ? list : []
|
|
288
|
+
for (const item of items) {
|
|
289
|
+
if (item?.severity === 'high') counts.high += 1
|
|
290
|
+
else if (item?.severity === 'medium') counts.medium += 1
|
|
291
|
+
else if (item?.severity === 'low') counts.low += 1
|
|
292
|
+
}
|
|
293
|
+
return counts
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function handleReviewResult(result, completeConfig) {
|
|
297
|
+
let exitCode = 0
|
|
298
|
+
if(result.result === 'NO') {
|
|
299
|
+
console.log(chalk.redBright('X Code review was not passed.Please fix the following high-level issues and try again.'))
|
|
300
|
+
exitCode = 1
|
|
301
|
+
} else {
|
|
302
|
+
console.log(chalk.green('√ Code review passed.'))
|
|
303
|
+
exitCode = 0
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const counts = summarizeIssues(result.list)
|
|
307
|
+
const sessions = result?.meta?.sessions
|
|
308
|
+
const durationMs = result?.meta?.durationMs
|
|
309
|
+
const providerType = result?.meta?.providerType || completeConfig.providerType
|
|
310
|
+
const model = result?.meta?.model || completeConfig.model
|
|
311
|
+
const baseURL = result?.meta?.baseURL || completeConfig.baseURL
|
|
312
|
+
const ragEnabled = Boolean(result?.meta?.ragEnabled)
|
|
313
|
+
const ragUsed = Boolean(result?.meta?.ragUsed)
|
|
314
|
+
|
|
315
|
+
console.log(
|
|
316
|
+
chalk.gray(
|
|
317
|
+
`Summary: sessions=${sessions ?? 'n/a'}, issues(high/medium/low)=${counts.high}/${counts.medium}/${counts.low}, errors=${result.errors?.length || 0}, duration=${Number.isFinite(durationMs) ? `${durationMs}ms` : 'n/a'}`
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
console.log(
|
|
321
|
+
chalk.gray(
|
|
322
|
+
`Provider: ${String(providerType || '').toUpperCase()}${model ? ` model=${model}` : ''}${baseURL ? ` baseURL=${baseURL}` : ''} | RAG: ${ragEnabled ? (ragUsed ? 'used' : 'enabled (no hits)') : 'off'}`
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
const printList = result.list.filter(issue => {
|
|
327
|
+
if(issue.severity === 'high') {
|
|
328
|
+
return true
|
|
329
|
+
}
|
|
330
|
+
return completeConfig.showNormal
|
|
331
|
+
})
|
|
332
|
+
if(printList.length) {
|
|
333
|
+
console.log('\n' + chalk.gray('-'.repeat(50)) + '\n');
|
|
334
|
+
printList.forEach(issue => {
|
|
335
|
+
const color = issue.severity === 'high' ? chalk.red : issue.severity === 'medium' ? chalk.yellow : chalk.green
|
|
336
|
+
console.log(color(`- ${issue.location}(${issue.perspective}/${issue.severity}): ${issue.description}\n`) + chalk.blue(`- suggestion: ${issue.suggestion}\n`))
|
|
337
|
+
|
|
338
|
+
})
|
|
339
|
+
if(result.errors.length) {
|
|
340
|
+
console.log(chalk.redBright('some content failed to be reviewed, please check the error message above.'))
|
|
341
|
+
result.errors.forEach(error => {
|
|
342
|
+
console.log(chalk.redBright(error))
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
} else if(result.errors.length) {
|
|
346
|
+
console.log(chalk.redBright(result.errors[0]))
|
|
347
|
+
}
|
|
348
|
+
process.exit(exitCode)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function getFileExtensions(config) {
|
|
352
|
+
try {
|
|
353
|
+
const extensions = config.enabledFileExtensions.split(',').map(ext => ext.trim().toLowerCase())
|
|
354
|
+
return extensions
|
|
355
|
+
} catch (err) {
|
|
356
|
+
handleError('Error getting file extensions:', err.message)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
360
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
361
|
+
console.log(`Usage: ai-review run\n\nRuns AI code review for staged git changes.`)
|
|
362
|
+
process.exit(0)
|
|
363
|
+
}
|
|
364
|
+
const gitRoot = getGitRoot()
|
|
365
|
+
const config = loadConfig(gitRoot)
|
|
366
|
+
const completeConfig = Object.assign({}, defaultConfig, config, { repoRoot: gitRoot });
|
|
367
|
+
if (completeConfig.skipReview) {
|
|
368
|
+
console.log(chalk.yellow('AI review skipped (skipReview=true).'))
|
|
369
|
+
process.exit(0)
|
|
370
|
+
}
|
|
371
|
+
const fileExtensions = getFileExtensions(completeConfig)
|
|
372
|
+
const changesFiles = getStagedChanges(fileExtensions, gitRoot)
|
|
373
|
+
console.log(`Find ${chalk.cyan(changesFiles.length)} changed files...`)
|
|
374
|
+
const diffConent = getDiffContent(changesFiles, completeConfig, gitRoot)
|
|
375
|
+
try {
|
|
376
|
+
const reviewer = new CodeReviewer(completeConfig)
|
|
377
|
+
const result = await reviewer.review(diffConent, fileExtensions)
|
|
378
|
+
handleReviewResult(result, completeConfig)
|
|
379
|
+
} catch (err) {
|
|
380
|
+
if(err instanceof AIError && err.options.type === 'API_ERROR' && !completeConfig.strict) {
|
|
381
|
+
console.log(chalk.red('Code review failed.Please check your AI server and try again.'))
|
|
382
|
+
console.log(chalk.red('Error message:'), err.message)
|
|
383
|
+
process.exit(0)
|
|
384
|
+
}
|
|
385
|
+
handleError('Code review error:', err.message)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
390
|
+
if (isMain) {
|
|
391
|
+
main()
|
|
392
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { execFileSync } from 'child_process'
|
|
4
|
+
import chalk from 'chalk'
|
|
5
|
+
import fs from 'fs'
|
|
6
|
+
import path from 'path'
|
|
7
|
+
import { fileURLToPath } from 'url'
|
|
8
|
+
|
|
9
|
+
function parseArgs(argv) {
|
|
10
|
+
const args = {
|
|
11
|
+
target: 'git', // auto | git | husky
|
|
12
|
+
check: false,
|
|
13
|
+
uninstall: false,
|
|
14
|
+
force: false,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
for (let i = 0; i < argv.length; i++) {
|
|
18
|
+
const arg = argv[i]
|
|
19
|
+
if (arg === '--check' || arg === '-c') args.check = true
|
|
20
|
+
else if (arg === '--uninstall' || arg === '--remove') args.uninstall = true
|
|
21
|
+
else if (arg === '--force' || arg === '-f') args.force = true
|
|
22
|
+
else if (arg === '--target' || arg === '-t') {
|
|
23
|
+
const value = argv[i + 1]
|
|
24
|
+
if (value) {
|
|
25
|
+
args.target = String(value).toLowerCase()
|
|
26
|
+
i += 1
|
|
27
|
+
}
|
|
28
|
+
} else if (arg.startsWith('--target=')) {
|
|
29
|
+
args.target = String(arg.split('=')[1] || '').toLowerCase()
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!['auto', 'git', 'husky'].includes(args.target)) {
|
|
34
|
+
console.error(chalk.red(`Invalid --target: ${args.target}. Use auto|git|husky.`))
|
|
35
|
+
process.exit(1)
|
|
36
|
+
}
|
|
37
|
+
return args
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function shEscape(value) {
|
|
41
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function runGit(args, options = {}) {
|
|
45
|
+
return execFileSync('git', args, { encoding: 'utf8', ...options }).trim()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getGitRoot() {
|
|
49
|
+
try {
|
|
50
|
+
return runGit(['rev-parse', '--show-toplevel'], { cwd: process.cwd() })
|
|
51
|
+
} catch {
|
|
52
|
+
console.error(chalk.red('Not a git repository (or any of the parent directories).'))
|
|
53
|
+
process.exit(1)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolveGitPath(gitRoot, gitPath) {
|
|
58
|
+
return path.isAbsolute(gitPath) ? gitPath : path.join(gitRoot, gitPath)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getGitHooksPath(gitRoot) {
|
|
62
|
+
const hooksPath = runGit(['rev-parse', '--git-path', 'hooks'], { cwd: gitRoot })
|
|
63
|
+
return resolveGitPath(gitRoot, hooksPath)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getTargetForInstall(gitRoot, target) {
|
|
67
|
+
if (target === 'git') return 'git'
|
|
68
|
+
if (target === 'husky') return 'husky'
|
|
69
|
+
|
|
70
|
+
// auto
|
|
71
|
+
const huskyDir = path.join(gitRoot, '.husky')
|
|
72
|
+
if (fs.existsSync(huskyDir) && fs.statSync(huskyDir).isDirectory()) {
|
|
73
|
+
return 'husky'
|
|
74
|
+
}
|
|
75
|
+
return 'git'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function removeHookBlock(content, markerStart, markerEnd) {
|
|
79
|
+
let output = content
|
|
80
|
+
while (true) {
|
|
81
|
+
const startIdx = output.indexOf(markerStart)
|
|
82
|
+
if (startIdx === -1) return output
|
|
83
|
+
|
|
84
|
+
const endIdx = output.indexOf(markerEnd, startIdx)
|
|
85
|
+
if (endIdx !== -1) {
|
|
86
|
+
const afterEnd = output.indexOf('\n', endIdx)
|
|
87
|
+
output = output.slice(0, startIdx).trimEnd() + '\n' + output.slice(afterEnd === -1 ? output.length : afterEnd + 1)
|
|
88
|
+
} else {
|
|
89
|
+
// Backward compatible uninstall: older versions only had a start marker.
|
|
90
|
+
output = output.slice(0, startIdx).trimEnd() + '\n'
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function hasMarker(filePath, markerStart) {
|
|
96
|
+
if (!fs.existsSync(filePath)) return false
|
|
97
|
+
const content = fs.readFileSync(filePath, 'utf8')
|
|
98
|
+
return content.includes(markerStart)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function writeExecutable(filePath, content) {
|
|
102
|
+
fs.writeFileSync(filePath, content, 'utf8')
|
|
103
|
+
fs.chmodSync(filePath, '755')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getInstallPaths(gitRoot) {
|
|
107
|
+
const huskyPreCommitPath = path.join(gitRoot, '.husky', 'pre-commit')
|
|
108
|
+
const gitHooksPath = getGitHooksPath(gitRoot)
|
|
109
|
+
const gitPreCommitPath = path.join(gitHooksPath, 'pre-commit')
|
|
110
|
+
return { huskyPreCommitPath, gitHooksPath, gitPreCommitPath }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function buildHookBlock({ gitRoot, nodePath, aiReviewPath, markerStart, markerEnd }) {
|
|
114
|
+
// Prefer repo-local install for portability; fallback to current absolute path.
|
|
115
|
+
const repoBin = '"$git_root/node_modules/.bin/ai-review"'
|
|
116
|
+
const repoMain = '"$git_root/node_modules/ai-code-review-kit/src/ai-review.js"'
|
|
117
|
+
const pnpCjs = '"$git_root/.pnp.cjs"'
|
|
118
|
+
return `${markerStart}
|
|
119
|
+
run_node() {
|
|
120
|
+
if command -v node >/dev/null 2>&1; then
|
|
121
|
+
node "$@"
|
|
122
|
+
else
|
|
123
|
+
${shEscape(nodePath)} "$@"
|
|
124
|
+
fi
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
git_root="$(git rev-parse --show-toplevel 2>/dev/null)"
|
|
128
|
+
if [ -n "$git_root" ]; then
|
|
129
|
+
# Yarn PnP (no node_modules). Prefer running via yarn.
|
|
130
|
+
if [ -f ${pnpCjs} ]; then
|
|
131
|
+
if command -v yarn >/dev/null 2>&1; then
|
|
132
|
+
yarn -s ai-review run
|
|
133
|
+
exit $?
|
|
134
|
+
elif command -v corepack >/dev/null 2>&1; then
|
|
135
|
+
corepack yarn -s ai-review run
|
|
136
|
+
exit $?
|
|
137
|
+
fi
|
|
138
|
+
fi
|
|
139
|
+
|
|
140
|
+
# npm/yarn (node_modules)
|
|
141
|
+
if [ -f ${repoMain} ]; then
|
|
142
|
+
run_node ${repoMain}
|
|
143
|
+
exit $?
|
|
144
|
+
fi
|
|
145
|
+
if [ -f ${repoBin} ]; then
|
|
146
|
+
${repoBin} run
|
|
147
|
+
exit $?
|
|
148
|
+
fi
|
|
149
|
+
fi
|
|
150
|
+
|
|
151
|
+
run_node ${shEscape(aiReviewPath)}
|
|
152
|
+
${markerEnd}
|
|
153
|
+
`
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function ensureDir(dirPath) {
|
|
157
|
+
if (!fs.existsSync(dirPath)) {
|
|
158
|
+
fs.mkdirSync(dirPath, { recursive: true })
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function installToGitHooks({ gitRoot, gitHooksPath, preCommitPath, hookBlock, markerStart, markerEnd, force }) {
|
|
163
|
+
ensureDir(gitHooksPath)
|
|
164
|
+
if (fs.existsSync(preCommitPath)) {
|
|
165
|
+
const hookContent = fs.readFileSync(preCommitPath, 'utf8')
|
|
166
|
+
if (hookContent.includes(markerStart)) {
|
|
167
|
+
if (!force) {
|
|
168
|
+
console.log(chalk.yellow('Hook already exists.'))
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
const cleaned = removeHookBlock(hookContent, markerStart, markerEnd)
|
|
172
|
+
const next = `${cleaned.trimEnd()}\n\n${hookBlock}`
|
|
173
|
+
writeExecutable(preCommitPath, next)
|
|
174
|
+
console.log(chalk.green('Hook reinstalled successfully.'))
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
const newHookContent = `${hookContent.trimEnd()}\n\n${hookBlock}`
|
|
178
|
+
writeExecutable(preCommitPath, newHookContent)
|
|
179
|
+
} else {
|
|
180
|
+
const hookContent = `#!/bin/sh\n${hookBlock}`
|
|
181
|
+
writeExecutable(preCommitPath, hookContent)
|
|
182
|
+
}
|
|
183
|
+
console.log(chalk.green('Hook installed successfully.'))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function installToHusky({ gitRoot, huskyPreCommitPath, hookBlock, markerStart, markerEnd, force }) {
|
|
187
|
+
const huskyDir = path.join(gitRoot, '.husky')
|
|
188
|
+
if (!fs.existsSync(huskyDir) || !fs.statSync(huskyDir).isDirectory()) {
|
|
189
|
+
console.error(chalk.red('Husky directory not found. Please install husky first or use --target git.'))
|
|
190
|
+
process.exit(1)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const huskyShim = path.join(huskyDir, '_', 'husky.sh')
|
|
194
|
+
const header = fs.existsSync(huskyShim)
|
|
195
|
+
? `#!/bin/sh\n. "$(dirname \"$0\")/_/husky.sh"\n`
|
|
196
|
+
: `#!/bin/sh\n`
|
|
197
|
+
|
|
198
|
+
if (fs.existsSync(huskyPreCommitPath)) {
|
|
199
|
+
const hookContent = fs.readFileSync(huskyPreCommitPath, 'utf8')
|
|
200
|
+
if (hookContent.includes(markerStart)) {
|
|
201
|
+
if (!force) {
|
|
202
|
+
console.log(chalk.yellow('Hook already exists.'))
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
const cleaned = removeHookBlock(hookContent, markerStart, markerEnd)
|
|
206
|
+
const next = `${cleaned.trimEnd()}\n\n${hookBlock}`
|
|
207
|
+
writeExecutable(huskyPreCommitPath, next)
|
|
208
|
+
console.log(chalk.green('Hook reinstalled successfully.'))
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
const next = `${hookContent.trimEnd()}\n\n${hookBlock}`
|
|
212
|
+
writeExecutable(huskyPreCommitPath, next)
|
|
213
|
+
} else {
|
|
214
|
+
const hookContent = `${header}${hookBlock}`
|
|
215
|
+
writeExecutable(huskyPreCommitPath, hookContent)
|
|
216
|
+
}
|
|
217
|
+
console.log(chalk.green('Hook installed successfully.'))
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function uninstallFromFile(filePath, markerStart, markerEnd) {
|
|
221
|
+
if (!fs.existsSync(filePath)) return false
|
|
222
|
+
const content = fs.readFileSync(filePath, 'utf8')
|
|
223
|
+
if (!content.includes(markerStart)) return false
|
|
224
|
+
const cleaned = removeHookBlock(content, markerStart, markerEnd)
|
|
225
|
+
writeExecutable(filePath, cleaned.trimEnd() + '\n')
|
|
226
|
+
return true
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function main(argv = process.argv.slice(2)) {
|
|
230
|
+
const args = parseArgs(argv)
|
|
231
|
+
|
|
232
|
+
const gitRoot = getGitRoot()
|
|
233
|
+
const { huskyPreCommitPath, gitHooksPath, gitPreCommitPath } = getInstallPaths(gitRoot)
|
|
234
|
+
|
|
235
|
+
const aiReviewPath = fileURLToPath(new URL('./ai-review.js', import.meta.url))
|
|
236
|
+
const nodePath = process.execPath
|
|
237
|
+
const markerStart = '# ai-code-review-kit'
|
|
238
|
+
const markerEnd = '# /ai-code-review-kit'
|
|
239
|
+
const hookBlock = buildHookBlock({ gitRoot, nodePath, aiReviewPath, markerStart, markerEnd })
|
|
240
|
+
|
|
241
|
+
if (args.check) {
|
|
242
|
+
const targets = args.target === 'auto' ? ['husky', 'git'] : [args.target]
|
|
243
|
+
const checks = targets.map((t) => {
|
|
244
|
+
const filePath = t === 'husky' ? huskyPreCommitPath : gitPreCommitPath
|
|
245
|
+
return { target: t, installed: hasMarker(filePath, markerStart), filePath }
|
|
246
|
+
})
|
|
247
|
+
const anyInstalled = checks.some((c) => c.installed)
|
|
248
|
+
checks.forEach((c) => {
|
|
249
|
+
const status = c.installed ? chalk.green('installed') : chalk.gray('not installed')
|
|
250
|
+
console.log(`${chalk.cyan(c.target)}: ${status} (${c.filePath})`)
|
|
251
|
+
})
|
|
252
|
+
process.exit(anyInstalled ? 0 : 1)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (args.uninstall) {
|
|
256
|
+
const order = args.target === 'auto' ? ['husky', 'git'] : [args.target]
|
|
257
|
+
let removed = false
|
|
258
|
+
for (const t of order) {
|
|
259
|
+
const filePath = t === 'husky' ? huskyPreCommitPath : gitPreCommitPath
|
|
260
|
+
if (uninstallFromFile(filePath, markerStart, markerEnd)) {
|
|
261
|
+
console.log(chalk.green(`Hook removed from ${t}: ${filePath}`))
|
|
262
|
+
removed = true
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (!removed) {
|
|
266
|
+
console.log(chalk.yellow('Hook not found.'))
|
|
267
|
+
}
|
|
268
|
+
process.exit(0)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const target = getTargetForInstall(gitRoot, args.target)
|
|
272
|
+
if (target === 'husky') {
|
|
273
|
+
installToHusky({ gitRoot, huskyPreCommitPath, hookBlock, markerStart, markerEnd, force: args.force })
|
|
274
|
+
} else {
|
|
275
|
+
installToGitHooks({ gitRoot, gitHooksPath, preCommitPath: gitPreCommitPath, hookBlock, markerStart, markerEnd, force: args.force })
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
280
|
+
if (isMain) {
|
|
281
|
+
main()
|
|
282
|
+
}
|