smart-review 1.0.3 → 1.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/README.en-US.md +70 -6
- package/README.md +73 -14
- package/bin/install.js +181 -14
- package/bin/review.js +425 -251
- package/lib/ai-client-pool.js +37 -7
- package/lib/ai-client.js +1141 -73
- package/lib/default-config.js +10 -2
- package/lib/reviewer.js +1564 -1532
- package/lib/utils/i18n.js +75 -5
- package/package.json +29 -4
- package/templates/smart-review.json +10 -2
- package/templates/rules/best-practices.js +0 -111
- package/templates/rules/performance.js +0 -123
- package/templates/rules/security.js +0 -311
package/README.en-US.md
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
## ✨ Features
|
|
8
8
|
|
|
9
9
|
- Static rule checks for Security, Performance, Best Practices
|
|
10
|
-
- AI analysis
|
|
10
|
+
- AI analysis with unified OpenAI / Anthropic / Gemini integration
|
|
11
|
+
- Skill-driven review orchestration to enforce detailed risk and fix outputs
|
|
11
12
|
- Smart batching and chunked processing for large files
|
|
12
13
|
- Git Hook integration (pre-commit)
|
|
13
14
|
- Highly configurable and multilingual output
|
|
@@ -124,6 +125,11 @@ Add to `package.json` if you use Husky:
|
|
|
124
125
|
}
|
|
125
126
|
```
|
|
126
127
|
|
|
128
|
+
#### Interrupt & Terminal Compatibility
|
|
129
|
+
- Works in Git Bash, CMD, and PowerShell
|
|
130
|
+
- Press `q` or `Esc` during review to interrupt and print completed results
|
|
131
|
+
- Interruptions do not fail the review; only blocking risks stop the commit
|
|
132
|
+
|
|
127
133
|
## ⚙️ Config
|
|
128
134
|
|
|
129
135
|
Main config `.smart-review/smart-review.json` example:
|
|
@@ -132,6 +138,7 @@ Main config `.smart-review/smart-review.json` example:
|
|
|
132
138
|
{
|
|
133
139
|
"ai": {
|
|
134
140
|
"enabled": true,
|
|
141
|
+
"provider": "openai",
|
|
135
142
|
"model": "deepseek-chat",
|
|
136
143
|
"baseURL": "https://api.deepseek.com/v1",
|
|
137
144
|
"reviewOnlyChanges": true,
|
|
@@ -141,6 +148,37 @@ Main config `.smart-review/smart-review.json` example:
|
|
|
141
148
|
"useStaticHints": true,
|
|
142
149
|
"maxRequestTokens": 8000,
|
|
143
150
|
"temperature": 0,
|
|
151
|
+
"skills": {
|
|
152
|
+
"enabled": true,
|
|
153
|
+
"strict": false,
|
|
154
|
+
"maxSkillsPerRequest": 4,
|
|
155
|
+
"required": ["DIFF_RISK_GUARD", "EVIDENCE_ENFORCER"],
|
|
156
|
+
"optional": ["SECURITY_DEEP", "LOGIC_CORRECTNESS", "API_CONTRACT"],
|
|
157
|
+
"routes": [
|
|
158
|
+
{
|
|
159
|
+
"match": ["**/auth/**", "**/security/**"],
|
|
160
|
+
"modes": ["diff", "batch", "segment"],
|
|
161
|
+
"add": ["SECURITY_DEEP", "API_CONTRACT"]
|
|
162
|
+
}
|
|
163
|
+
]
|
|
164
|
+
},
|
|
165
|
+
"tools": {
|
|
166
|
+
"enabled": false,
|
|
167
|
+
"maxCalls": 2,
|
|
168
|
+
"maxReadLines": 400,
|
|
169
|
+
"maxSearchMatches": 50,
|
|
170
|
+
"maxSearchFiles": 120,
|
|
171
|
+
"maxListFiles": 200,
|
|
172
|
+
"allow": [
|
|
173
|
+
"read_file",
|
|
174
|
+
"get_staged_diff",
|
|
175
|
+
"list_files",
|
|
176
|
+
"search_in_file",
|
|
177
|
+
"get_file_outline",
|
|
178
|
+
"search_in_repo",
|
|
179
|
+
"list_changed_files"
|
|
180
|
+
]
|
|
181
|
+
},
|
|
144
182
|
"concurrency": 3
|
|
145
183
|
},
|
|
146
184
|
"riskLevels": {
|
|
@@ -158,8 +196,9 @@ Main config `.smart-review/smart-review.json` example:
|
|
|
158
196
|
|
|
159
197
|
#### AI (`ai`)
|
|
160
198
|
- `enabled`: Enable AI analysis
|
|
161
|
-
- `
|
|
162
|
-
- `
|
|
199
|
+
- `provider`: Model provider, supports `openai`, `anthropic`, `gemini`
|
|
200
|
+
- `model`: Model name for the selected provider
|
|
201
|
+
- `apiKey`: Unified API key field (or use environment variables)
|
|
163
202
|
- `baseURL`: API base URL
|
|
164
203
|
- `reviewOnlyChanges`: Enable Git Diff incremental review; true analyzes only changed lines
|
|
165
204
|
- `maxResponseTokens`: Max tokens in AI response
|
|
@@ -173,7 +212,28 @@ Main config `.smart-review/smart-review.json` example:
|
|
|
173
212
|
- `includeStaticHints`: Include rule hints in AI analysis
|
|
174
213
|
- `temperature`: Model creativity; 0 favors deterministic outputs
|
|
175
214
|
- `concurrency`: Number of concurrent AI requests
|
|
176
|
-
|
|
215
|
+
- `skills.enabled`: Enable review skill orchestration
|
|
216
|
+
- `skills.strict`: Enforce output constraints (path/snippet/reason/suggestion)
|
|
217
|
+
- `skills.maxSkillsPerRequest`: Max skills applied in one request
|
|
218
|
+
- `skills.required`: Required skill list
|
|
219
|
+
- `skills.optional`: Optional skill list (mode-based supplement)
|
|
220
|
+
- `skills.routes`: Dynamically append skills by file path and mode (`match/modes/add`)
|
|
221
|
+
- `tools.enabled`: Enable local read-only tool calling (see tool list below)
|
|
222
|
+
- `tools.maxCalls`: Max tool-call rounds per request
|
|
223
|
+
- `tools.maxReadLines`: Max lines for single `read_file` call
|
|
224
|
+
- `tools.maxSearchMatches`: Max returned matches for `search_in_file` / `search_in_repo`
|
|
225
|
+
- `tools.maxSearchFiles`: Max scanned files for one `search_in_repo` call
|
|
226
|
+
- `tools.maxListFiles`: Max returned files for one `list_files` call
|
|
227
|
+
- `tools.allow`: Tool allowlist (only listed tools can be called by the model)
|
|
228
|
+
|
|
229
|
+
#### AI Read-Only Tool List (`ai.tools.allow`)
|
|
230
|
+
- `read_file`: Read specific line ranges from one file
|
|
231
|
+
- `get_staged_diff`: Get staged Git diff (optionally filtered by path)
|
|
232
|
+
- `list_files`: Recursively list repository files (supports subdir and keyword/wildcard filtering)
|
|
233
|
+
- `search_in_file`: Search text or regex in a single file
|
|
234
|
+
- `get_file_outline`: Extract lightweight file outline (class/function/method signatures)
|
|
235
|
+
- `search_in_repo`: Search text or regex across repository files (with scan and result caps)
|
|
236
|
+
- `list_changed_files`: List changed Git files (supports staged/unstaged and status filtering)
|
|
177
237
|
#### Risk Levels (`riskLevels`)
|
|
178
238
|
- `critical` / `high` / `medium` / `low` / `suggestion`
|
|
179
239
|
- Each level supports `block` to decide whether to block commits
|
|
@@ -530,7 +590,11 @@ To add a new language (e.g., `ja-JP`), create `templates/rules/ja-JP/` with the
|
|
|
530
590
|
## 🌍 Environment Variables
|
|
531
591
|
|
|
532
592
|
```bash
|
|
593
|
+
export AI_API_KEY="your-api-key"
|
|
533
594
|
export OPENAI_API_KEY="your-api-key"
|
|
595
|
+
export ANTHROPIC_API_KEY="your-api-key"
|
|
596
|
+
export GEMINI_API_KEY="your-api-key"
|
|
597
|
+
export GOOGLE_API_KEY="your-api-key"
|
|
534
598
|
export DEBUG_SMART_REVIEW=true
|
|
535
599
|
export SMART_REVIEW_LOCALE=en-US
|
|
536
600
|
```
|
|
@@ -539,7 +603,7 @@ To use a custom OpenAI-compatible endpoint, set `ai.baseURL` in `.smart-review/s
|
|
|
539
603
|
|
|
540
604
|
```json
|
|
541
605
|
{
|
|
542
|
-
"ai": { "baseURL": "https://api.openai.com/v1" }
|
|
606
|
+
"ai": { "provider": "openai", "baseURL": "https://api.openai.com/v1" }
|
|
543
607
|
}
|
|
544
608
|
```
|
|
545
609
|
|
|
@@ -577,4 +641,4 @@ smart-review --staged --debug
|
|
|
577
641
|
4. Push the branch (`git push origin feature/amazing-feature`)
|
|
578
642
|
5. Open a Pull Request
|
|
579
643
|
|
|
580
|
-
⭐ If this project helps you, please star the repo!
|
|
644
|
+
⭐ If this project helps you, please star the repo!
|
package/README.md
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
## ✨ 特性
|
|
8
8
|
|
|
9
9
|
- 🔍 **静态规则检测** - 内置安全、性能、最佳实践规则
|
|
10
|
-
- 🧠 **AI智能分析** -
|
|
10
|
+
- 🧠 **AI智能分析** - 支持 OpenAI / Anthropic / Gemini 的统一对接
|
|
11
|
+
- 🧩 **审查技能编排** - 支持 Skills 约束输出,强制细化风险分析与修复建议
|
|
11
12
|
- ⚡ **Git Diff增量审查** - 智能识别变更内容,只审查修改的代码行,大幅提升审查效率
|
|
12
13
|
- 🚀 **智能分批处理** - 自动优化大文件处理,支持分段分析
|
|
13
14
|
- 📊 **大文件支持** - 智能分段处理超大文件,突破token限制
|
|
@@ -131,6 +132,11 @@ node bin/review.js --files test/src/large-test-file.js
|
|
|
131
132
|
}
|
|
132
133
|
```
|
|
133
134
|
|
|
135
|
+
#### 中断与终端兼容
|
|
136
|
+
- 支持在 Git Bash、CMD、PowerShell 中进行交互中断
|
|
137
|
+
- 审查过程中输入 `q` 或按 `Esc` 可中断审查并输出已完成结果
|
|
138
|
+
- 中断不会被视为审查失败,只有存在阻断风险才会阻止提交
|
|
139
|
+
|
|
134
140
|
## ⚙️ 配置文件
|
|
135
141
|
|
|
136
142
|
### 主配置文件 `.smart-review/smart-review.json`
|
|
@@ -139,6 +145,7 @@ node bin/review.js --files test/src/large-test-file.js
|
|
|
139
145
|
{
|
|
140
146
|
"ai": {
|
|
141
147
|
"enabled": true,
|
|
148
|
+
"provider": "openai",
|
|
142
149
|
"model": "deepseek-chat",
|
|
143
150
|
"apiKey": "your-api-key",
|
|
144
151
|
"baseURL": "https://api.deepseek.com/v1",
|
|
@@ -153,6 +160,37 @@ node bin/review.js --files test/src/large-test-file.js
|
|
|
153
160
|
"tokenRatio": 4,
|
|
154
161
|
"chunkOverlapLines": 5,
|
|
155
162
|
"includeStaticHints": true,
|
|
163
|
+
"skills": {
|
|
164
|
+
"enabled": true,
|
|
165
|
+
"strict": false,
|
|
166
|
+
"maxSkillsPerRequest": 4,
|
|
167
|
+
"required": ["DIFF_RISK_GUARD", "EVIDENCE_ENFORCER"],
|
|
168
|
+
"optional": ["SECURITY_DEEP", "LOGIC_CORRECTNESS", "API_CONTRACT"],
|
|
169
|
+
"routes": [
|
|
170
|
+
{
|
|
171
|
+
"match": ["**/auth/**", "**/security/**"],
|
|
172
|
+
"modes": ["diff", "batch", "segment"],
|
|
173
|
+
"add": ["SECURITY_DEEP", "API_CONTRACT"]
|
|
174
|
+
}
|
|
175
|
+
]
|
|
176
|
+
},
|
|
177
|
+
"tools": {
|
|
178
|
+
"enabled": false,
|
|
179
|
+
"maxCalls": 2,
|
|
180
|
+
"maxReadLines": 400,
|
|
181
|
+
"maxSearchMatches": 50,
|
|
182
|
+
"maxSearchFiles": 120,
|
|
183
|
+
"maxListFiles": 200,
|
|
184
|
+
"allow": [
|
|
185
|
+
"read_file",
|
|
186
|
+
"get_staged_diff",
|
|
187
|
+
"list_files",
|
|
188
|
+
"search_in_file",
|
|
189
|
+
"get_file_outline",
|
|
190
|
+
"search_in_repo",
|
|
191
|
+
"list_changed_files"
|
|
192
|
+
]
|
|
193
|
+
},
|
|
156
194
|
"temperature": 0,
|
|
157
195
|
"concurrency": 3
|
|
158
196
|
},
|
|
@@ -183,8 +221,9 @@ node bin/review.js --files test/src/large-test-file.js
|
|
|
183
221
|
|
|
184
222
|
#### AI 配置 (`ai`)
|
|
185
223
|
- `enabled`: 是否启用AI分析
|
|
186
|
-
- `
|
|
187
|
-
- `
|
|
224
|
+
- `provider`: 模型提供方,支持 `openai`、`anthropic`、`gemini`
|
|
225
|
+
- `model`: 对应提供方的模型名称
|
|
226
|
+
- `apiKey`: 统一 API 密钥字段(也可通过环境变量注入)
|
|
188
227
|
- `baseURL`: API基础URL
|
|
189
228
|
- `reviewOnlyChanges`: 是否启用Git Diff增量审查模式。`true`时只审查变更的代码行,`false`时审查整个文件内容。默认为`true`,大幅提升审查效率
|
|
190
229
|
- `maxResponseTokens`: AI响应最大token数
|
|
@@ -199,6 +238,28 @@ node bin/review.js --files test/src/large-test-file.js
|
|
|
199
238
|
- `includeStaticHints`: 是否在AI分析中包含静态规则提示
|
|
200
239
|
- `temperature`: AI模型的创造性参数,0表示最确定性的输出
|
|
201
240
|
- `concurrency`: 并发AI请求数量,默认3个。设置为1或更小时使用串行处理,大于1时启用并发处理以提升性能
|
|
241
|
+
- `skills.enabled`: 是否启用审查技能编排
|
|
242
|
+
- `skills.strict`: 是否强制检查输出是否满足“路径/片段/原因/建议”约束
|
|
243
|
+
- `skills.maxSkillsPerRequest`: 单次请求最多启用的技能数量
|
|
244
|
+
- `skills.required`: 必选技能列表
|
|
245
|
+
- `skills.optional`: 可选技能列表(按模式补充)
|
|
246
|
+
- `skills.routes`: 按文件路径和模式动态追加技能(`match/modes/add`)
|
|
247
|
+
- `tools.enabled`: 启用本地只读工具调用(见下方工具清单)
|
|
248
|
+
- `tools.maxCalls`: 单次请求最多工具调用轮次
|
|
249
|
+
- `tools.maxReadLines`: `read_file` 单次读取最大行数
|
|
250
|
+
- `tools.maxSearchMatches`: `search_in_file` / `search_in_repo` 单次最多返回匹配条数
|
|
251
|
+
- `tools.maxSearchFiles`: `search_in_repo` 单次最多扫描文件数
|
|
252
|
+
- `tools.maxListFiles`: `list_files` 单次最多返回文件数
|
|
253
|
+
- `tools.allow`: 工具白名单(仅允许模型调用白名单中的工具)
|
|
254
|
+
|
|
255
|
+
#### AI 只读工具清单 (`ai.tools.allow`)
|
|
256
|
+
- `read_file`: 读取指定文件的行区间
|
|
257
|
+
- `get_staged_diff`: 获取暂存区 diff(可按文件路径过滤)
|
|
258
|
+
- `list_files`: 递归列出仓库内文件(支持子目录和关键字/通配符过滤)
|
|
259
|
+
- `search_in_file`: 在单个文件内按文本或正则搜索
|
|
260
|
+
- `get_file_outline`: 提取文件结构轮廓(类/函数/方法等)
|
|
261
|
+
- `search_in_repo`: 在仓库范围内按文本或正则搜索(支持扫描文件数和结果数上限)
|
|
262
|
+
- `list_changed_files`: 获取 Git 变更文件列表(支持 staged/unstaged 和状态过滤)
|
|
202
263
|
|
|
203
264
|
#### 风险等级配置 (`riskLevels`)
|
|
204
265
|
- `critical`: 致命风险
|
|
@@ -560,8 +621,15 @@ const reviewer = new CodeReviewer(customConfig, defaultRules);
|
|
|
560
621
|
可通过环境变量配置:
|
|
561
622
|
|
|
562
623
|
```bash
|
|
563
|
-
#
|
|
624
|
+
# 统一 API 配置(最高优先级)
|
|
625
|
+
export AI_API_KEY="your-api-key"
|
|
626
|
+
|
|
627
|
+
# 按 Provider 配置
|
|
564
628
|
export OPENAI_API_KEY="your-api-key"
|
|
629
|
+
export ANTHROPIC_API_KEY="your-api-key"
|
|
630
|
+
export GEMINI_API_KEY="your-api-key"
|
|
631
|
+
# 或 Google 生态变量
|
|
632
|
+
export GOOGLE_API_KEY="your-api-key"
|
|
565
633
|
|
|
566
634
|
# 调试模式
|
|
567
635
|
export DEBUG_SMART_REVIEW=true
|
|
@@ -578,7 +646,7 @@ export SMART_REVIEW_LOCALE=zh-CN # 或 en-US
|
|
|
578
646
|
|
|
579
647
|
```json
|
|
580
648
|
{
|
|
581
|
-
"ai": { "baseURL": "https://api.openai.com/v1" }
|
|
649
|
+
"ai": { "provider": "openai", "baseURL": "https://api.openai.com/v1" }
|
|
582
650
|
}
|
|
583
651
|
```
|
|
584
652
|
|
|
@@ -740,13 +808,4 @@ smart-review --staged --debug
|
|
|
740
808
|
4. 推送到分支 (`git push origin feature/amazing-feature`)
|
|
741
809
|
5. 开启 Pull Request
|
|
742
810
|
|
|
743
|
-
## 📞 支持
|
|
744
|
-
|
|
745
|
-
- 📧 邮箱: zlife@vip.qq.com
|
|
746
|
-
- 🐛 问题反馈: [GitHub Issues](https://github.com/vlinr/smart-review/issues)
|
|
747
|
-
|
|
748
|
-
---
|
|
749
|
-
|
|
750
811
|
⭐ 如果这个项目对你有帮助,请给个 Star!
|
|
751
|
-
|
|
752
|
-
|
package/bin/install.js
CHANGED
|
@@ -264,6 +264,8 @@ class Installer {
|
|
|
264
264
|
|
|
265
265
|
echo "${t(loc, 'hook_start_review')}"
|
|
266
266
|
|
|
267
|
+
trap 'echo "${t(loc, 'interrupt_cancelled')}"; exit 0' INT TERM
|
|
268
|
+
|
|
267
269
|
# 获取暂存区文件
|
|
268
270
|
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
|
|
269
271
|
|
|
@@ -283,9 +285,14 @@ ROOT_BIN="$REPO_ROOT/node_modules/.bin/smart-review"
|
|
|
283
285
|
|
|
284
286
|
FOUND_CMD=""
|
|
285
287
|
FOUND_IS_ENTRY=0
|
|
288
|
+
FOUND_ENTRY_CMD=""
|
|
286
289
|
|
|
287
290
|
if [ -f "$ROOT_BIN" ]; then
|
|
288
291
|
FOUND_CMD="$ROOT_BIN"
|
|
292
|
+
ROOT_ENTRY="$REPO_ROOT/node_modules/smart-review/bin/review.js"
|
|
293
|
+
if [ -f "$ROOT_ENTRY" ]; then
|
|
294
|
+
FOUND_ENTRY_CMD="$ROOT_ENTRY"
|
|
295
|
+
fi
|
|
289
296
|
else
|
|
290
297
|
MAX_ASCEND=6
|
|
291
298
|
while IFS= read -r file; do
|
|
@@ -296,9 +303,13 @@ else
|
|
|
296
303
|
candidate_bin="$REPO_ROOT/$dir/node_modules/.bin/smart-review"
|
|
297
304
|
candidate_entry="$REPO_ROOT/$dir/node_modules/smart-review/bin/review.js"
|
|
298
305
|
if [ -f "$candidate_bin" ]; then
|
|
299
|
-
FOUND_CMD="$candidate_bin"; FOUND_IS_ENTRY=0;
|
|
306
|
+
FOUND_CMD="$candidate_bin"; FOUND_IS_ENTRY=0;
|
|
307
|
+
if [ -f "$candidate_entry" ]; then
|
|
308
|
+
FOUND_ENTRY_CMD="$candidate_entry"
|
|
309
|
+
fi
|
|
310
|
+
break 2
|
|
300
311
|
elif [ -f "$candidate_entry" ]; then
|
|
301
|
-
FOUND_CMD="$candidate_entry"; FOUND_IS_ENTRY=1; break 2
|
|
312
|
+
FOUND_CMD="$candidate_entry"; FOUND_IS_ENTRY=1; FOUND_ENTRY_CMD="$candidate_entry"; break 2
|
|
302
313
|
fi
|
|
303
314
|
dir=$(dirname "$dir")
|
|
304
315
|
depth=$((depth + 1))
|
|
@@ -319,13 +330,89 @@ if [ -z "$FOUND_CMD" ]; then
|
|
|
319
330
|
fi
|
|
320
331
|
|
|
321
332
|
echo "${t(loc, 'hook_use_command_prefix')} $FOUND_CMD --staged"
|
|
322
|
-
|
|
323
|
-
|
|
333
|
+
USE_WINPTY=0
|
|
334
|
+
if command -v uname >/dev/null 2>&1; then
|
|
335
|
+
KERNEL=$(uname -s)
|
|
324
336
|
else
|
|
325
|
-
"
|
|
337
|
+
KERNEL=""
|
|
326
338
|
fi
|
|
327
|
-
|
|
339
|
+
IS_MSYS=0
|
|
340
|
+
if [[ "$KERNEL" == MINGW* || "$KERNEL" == MSYS* || -n "$MSYSTEM" ]]; then
|
|
341
|
+
IS_MSYS=1
|
|
342
|
+
fi
|
|
343
|
+
HAS_TTY=0
|
|
344
|
+
if [ -t 0 ] || [ -t 1 ]; then
|
|
345
|
+
HAS_TTY=1
|
|
346
|
+
fi
|
|
347
|
+
TTY_DEVICE=""
|
|
348
|
+
if [ $HAS_TTY -eq 1 ] && [ -r /dev/tty ]; then
|
|
349
|
+
TTY_DEVICE="/dev/tty"
|
|
350
|
+
elif [ $HAS_TTY -eq 1 ] && [ -r "CONIN$" ]; then
|
|
351
|
+
TTY_DEVICE="CONIN$"
|
|
352
|
+
elif [ -r /dev/tty ]; then
|
|
353
|
+
TTY_DEVICE="/dev/tty"
|
|
354
|
+
elif [ -r "CONIN$" ]; then
|
|
355
|
+
TTY_DEVICE="CONIN$"
|
|
356
|
+
fi
|
|
357
|
+
if [ -n "$TTY_DEVICE" ]; then
|
|
358
|
+
export SMART_REVIEW_TTY="$TTY_DEVICE"
|
|
359
|
+
fi
|
|
360
|
+
if [ $IS_MSYS -eq 1 ] && [ $HAS_TTY -eq 1 ]; then
|
|
361
|
+
if command -v winpty >/dev/null 2>&1; then
|
|
362
|
+
USE_WINPTY=1
|
|
363
|
+
fi
|
|
364
|
+
fi
|
|
365
|
+
run_direct() {
|
|
366
|
+
if [ $USE_WINPTY -eq 1 ]; then
|
|
367
|
+
if [ -n "$FOUND_ENTRY_CMD" ]; then
|
|
368
|
+
winpty node "$FOUND_ENTRY_CMD" --staged
|
|
369
|
+
else
|
|
370
|
+
winpty "$FOUND_CMD" --staged
|
|
371
|
+
fi
|
|
372
|
+
else
|
|
373
|
+
if [ -n "$FOUND_ENTRY_CMD" ]; then
|
|
374
|
+
node "$FOUND_ENTRY_CMD" --staged
|
|
375
|
+
else
|
|
376
|
+
"$FOUND_CMD" --staged
|
|
377
|
+
fi
|
|
378
|
+
fi
|
|
379
|
+
}
|
|
380
|
+
run_with_device() {
|
|
381
|
+
if [ -n "$TTY_DEVICE" ]; then
|
|
382
|
+
"$@" < "$TTY_DEVICE"
|
|
383
|
+
else
|
|
384
|
+
"$@"
|
|
385
|
+
fi
|
|
386
|
+
}
|
|
387
|
+
TMP_ERR=""
|
|
388
|
+
if command -v mktemp >/dev/null 2>&1; then
|
|
389
|
+
TMP_ERR=$(mktemp -t smart-review-err.XXXXXX)
|
|
390
|
+
else
|
|
391
|
+
TMP_ERR="/tmp/smart-review-err-$$"
|
|
392
|
+
fi
|
|
393
|
+
run_direct 2> "$TMP_ERR"
|
|
328
394
|
EXIT_CODE=$?
|
|
395
|
+
if [ $EXIT_CODE -ne 0 ] && [ -s "$TMP_ERR" ] && grep -qi "stdin is not a tty" "$TMP_ERR"; then
|
|
396
|
+
if [ $USE_WINPTY -eq 1 ]; then
|
|
397
|
+
if [ -n "$FOUND_ENTRY_CMD" ]; then
|
|
398
|
+
run_with_device winpty node "$FOUND_ENTRY_CMD" --staged
|
|
399
|
+
else
|
|
400
|
+
run_with_device winpty "$FOUND_CMD" --staged
|
|
401
|
+
fi
|
|
402
|
+
else
|
|
403
|
+
if [ -n "$FOUND_ENTRY_CMD" ]; then
|
|
404
|
+
run_with_device node "$FOUND_ENTRY_CMD" --staged
|
|
405
|
+
else
|
|
406
|
+
run_with_device "$FOUND_CMD" --staged
|
|
407
|
+
fi
|
|
408
|
+
fi
|
|
409
|
+
EXIT_CODE=$?
|
|
410
|
+
fi
|
|
411
|
+
rm -f "$TMP_ERR"
|
|
412
|
+
if [ $EXIT_CODE -eq 130 ] || [ $EXIT_CODE -eq 143 ]; then
|
|
413
|
+
echo "${t(loc, 'interrupt_cancelled')}"
|
|
414
|
+
exit 0
|
|
415
|
+
fi
|
|
329
416
|
if [ $EXIT_CODE -ne 0 ]; then
|
|
330
417
|
echo "${t(loc, 'hook_review_fail')}"
|
|
331
418
|
exit 1
|
|
@@ -336,18 +423,98 @@ fi
|
|
|
336
423
|
`;
|
|
337
424
|
|
|
338
425
|
fs.writeFileSync(preCommitHook, hookContent);
|
|
339
|
-
|
|
426
|
+
const escapeCmdText = (value) => String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
427
|
+
const cmdMessages = {
|
|
428
|
+
start: escapeCmdText(t(loc, 'hook_start_review')),
|
|
429
|
+
noStaged: escapeCmdText(t(loc, 'hook_no_staged')),
|
|
430
|
+
foundHeader: escapeCmdText(t(loc, 'hook_found_staged_header')),
|
|
431
|
+
cdFail: escapeCmdText(t(loc, 'hook_cd_repo_fail')),
|
|
432
|
+
cmdNotFound1: escapeCmdText(t(loc, 'hook_cmd_not_found1')),
|
|
433
|
+
cmdNotFound2: escapeCmdText(t(loc, 'hook_cmd_not_found2')),
|
|
434
|
+
cmdMissingContinue: escapeCmdText(t(loc, 'hook_cmd_missing_continue')),
|
|
435
|
+
useCmdPrefix: escapeCmdText(t(loc, 'hook_use_command_prefix')),
|
|
436
|
+
reviewFail: escapeCmdText(t(loc, 'hook_review_fail')),
|
|
437
|
+
reviewPass: escapeCmdText(t(loc, 'hook_review_pass')),
|
|
438
|
+
interruptCancelled: escapeCmdText(t(loc, 'interrupt_cancelled'))
|
|
439
|
+
};
|
|
440
|
+
const cmdNodeScript = [
|
|
441
|
+
"const { execSync, spawnSync } = require('child_process');",
|
|
442
|
+
"const fs = require('fs');",
|
|
443
|
+
"const path = require('path');",
|
|
444
|
+
`const MSG = { start: '${cmdMessages.start}', noStaged: '${cmdMessages.noStaged}', foundHeader: '${cmdMessages.foundHeader}', cdFail: '${cmdMessages.cdFail}', cmdNotFound1: '${cmdMessages.cmdNotFound1}', cmdNotFound2: '${cmdMessages.cmdNotFound2}', cmdMissingContinue: '${cmdMessages.cmdMissingContinue}', useCmdPrefix: '${cmdMessages.useCmdPrefix}', reviewFail: '${cmdMessages.reviewFail}', reviewPass: '${cmdMessages.reviewPass}', interruptCancelled: '${cmdMessages.interruptCancelled}' };`,
|
|
445
|
+
"const log = (value) => { if (value) console.log(value); };",
|
|
446
|
+
"log(MSG.start);",
|
|
447
|
+
"let staged = '';",
|
|
448
|
+
"try { staged = execSync('git diff --cached --name-only --diff-filter=ACM', { encoding: 'utf8' }); } catch (e) {}",
|
|
449
|
+
"const stagedFiles = staged.split(/\\r?\\n/).filter(Boolean);",
|
|
450
|
+
"if (!stagedFiles.length) { log(MSG.noStaged); process.exit(0); }",
|
|
451
|
+
"log(MSG.foundHeader);",
|
|
452
|
+
"log(stagedFiles.join('\\n'));",
|
|
453
|
+
"let repoRoot = '';",
|
|
454
|
+
"try { repoRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim(); } catch (e) {}",
|
|
455
|
+
"if (!repoRoot) { log(MSG.cdFail); process.exit(1); }",
|
|
456
|
+
"const rootBin = path.join(repoRoot, 'node_modules', '.bin', 'smart-review');",
|
|
457
|
+
"const rootEntry = path.join(repoRoot, 'node_modules', 'smart-review', 'bin', 'review.js');",
|
|
458
|
+
"let foundCmd = '';",
|
|
459
|
+
"let foundEntry = '';",
|
|
460
|
+
"if (fs.existsSync(rootBin)) { foundCmd = rootBin; if (fs.existsSync(rootEntry)) { foundEntry = rootEntry; } }",
|
|
461
|
+
"if (!foundCmd) {",
|
|
462
|
+
" const maxAscend = 6;",
|
|
463
|
+
" outer: for (const file of stagedFiles) {",
|
|
464
|
+
" let dir = path.dirname(file);",
|
|
465
|
+
" let depth = 0;",
|
|
466
|
+
" while (dir && dir !== '.' && depth < maxAscend) {",
|
|
467
|
+
" const candidateBin = path.join(repoRoot, dir, 'node_modules', '.bin', 'smart-review');",
|
|
468
|
+
" const candidateEntry = path.join(repoRoot, dir, 'node_modules', 'smart-review', 'bin', 'review.js');",
|
|
469
|
+
" if (fs.existsSync(candidateBin)) { foundCmd = candidateBin; if (fs.existsSync(candidateEntry)) { foundEntry = candidateEntry; } break outer; }",
|
|
470
|
+
" if (fs.existsSync(candidateEntry)) { foundCmd = candidateEntry; foundEntry = candidateEntry; break outer; }",
|
|
471
|
+
" dir = path.dirname(dir);",
|
|
472
|
+
" depth++;",
|
|
473
|
+
" }",
|
|
474
|
+
" }",
|
|
475
|
+
"}",
|
|
476
|
+
"if (!foundCmd) {",
|
|
477
|
+
" try { execSync('where smart-review', { stdio: 'ignore' }); foundCmd = 'smart-review'; } catch (e) {}",
|
|
478
|
+
"}",
|
|
479
|
+
"if (!foundCmd) { log(MSG.cmdNotFound1); log(MSG.cmdNotFound2); log(MSG.cmdMissingContinue); process.exit(0); }",
|
|
480
|
+
"log(MSG.useCmdPrefix + ' ' + foundCmd + ' --staged');",
|
|
481
|
+
"const hasTty = !!(process.stdin && process.stdin.isTTY) || !!(process.stdout && process.stdout.isTTY);",
|
|
482
|
+
"const tryTty = (p) => { try { const fd = fs.openSync(p, 'r'); fs.closeSync(fd); return p; } catch (e) { return ''; } };",
|
|
483
|
+
"const ensureTty = () => {",
|
|
484
|
+
" if (process.env.SMART_REVIEW_TTY) return;",
|
|
485
|
+
" let tty = '';",
|
|
486
|
+
" if (hasTty) { tty = tryTty('\\\\\\\\.\\\\CONIN$') || tryTty('CONIN$'); }",
|
|
487
|
+
" if (!tty) { tty = tryTty('\\\\\\\\.\\\\CONIN$') || tryTty('CONIN$'); }",
|
|
488
|
+
" if (tty) { process.env.SMART_REVIEW_TTY = tty; process.env.SMART_REVIEW_FORCE_TTY = '1'; }",
|
|
489
|
+
"};",
|
|
490
|
+
"const args = ['--staged'];",
|
|
491
|
+
"const runOnce = (capture) => {",
|
|
492
|
+
" if (foundEntry) {",
|
|
493
|
+
" return spawnSync(process.execPath, [foundEntry, ...args], { stdio: capture ? 'pipe' : 'inherit' });",
|
|
494
|
+
" }",
|
|
495
|
+
" return spawnSync(foundCmd, args, { stdio: capture ? 'pipe' : 'inherit', shell: true });",
|
|
496
|
+
"};",
|
|
497
|
+
"let result = runOnce(true);",
|
|
498
|
+
"if (result.stdout) process.stdout.write(result.stdout);",
|
|
499
|
+
"if (result.stderr) process.stderr.write(result.stderr);",
|
|
500
|
+
"let code = Number.isInteger(result.status) ? result.status : (Number.isInteger(result.code) ? result.code : 0);",
|
|
501
|
+
"const errText = result.stderr ? String(result.stderr) : '';",
|
|
502
|
+
"if (code !== 0 && /stdin is not a tty/i.test(errText)) {",
|
|
503
|
+
" ensureTty();",
|
|
504
|
+
" result = runOnce(false);",
|
|
505
|
+
" code = Number.isInteger(result.status) ? result.status : (Number.isInteger(result.code) ? result.code : 0);",
|
|
506
|
+
"}",
|
|
507
|
+
"if (code === 130 || code === 143) { log(MSG.interruptCancelled); process.exit(0); }",
|
|
508
|
+
"if (code !== 0) { log(MSG.reviewFail); process.exit(1); }",
|
|
509
|
+
"log(MSG.reviewPass); process.exit(0);"
|
|
510
|
+
].join('');
|
|
511
|
+
// Windows 兼容:提供 CMD 包装器,避免 bash 在 CMD 下的 TTY 问题
|
|
340
512
|
try {
|
|
341
513
|
const preCommitCmd = path.join(gitHooksDir, 'pre-commit.cmd');
|
|
342
514
|
const cmdContent = [
|
|
343
515
|
'@echo off',
|
|
344
516
|
'SETLOCAL',
|
|
345
|
-
|
|
346
|
-
'if not exist "%HOOK%" (',
|
|
347
|
-
' echo [smart-review] pre-commit hook missing.',
|
|
348
|
-
' exit /b 1',
|
|
349
|
-
')',
|
|
350
|
-
'bash "%HOOK%"',
|
|
517
|
+
`node -e "${cmdNodeScript}"`,
|
|
351
518
|
'exit /b %ERRORLEVEL%\r\n'
|
|
352
519
|
].join('\r\n');
|
|
353
520
|
fs.writeFileSync(preCommitCmd, cmdContent);
|
|
@@ -416,4 +583,4 @@ fi
|
|
|
416
583
|
|
|
417
584
|
// 运行安装
|
|
418
585
|
const installer = new Installer();
|
|
419
|
-
(async () => { await installer.install(); })();
|
|
586
|
+
(async () => { await installer.install(); })();
|