dash-devtools 1.0.0__py3-none-any.whl
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.
- dash_devtools/__init__.py +8 -0
- dash_devtools/__main__.py +11 -0
- dash_devtools/ai_engine.py +441 -0
- dash_devtools/browser.py +541 -0
- dash_devtools/cli.py +1452 -0
- dash_devtools/database.py +338 -0
- dash_devtools/dbdiagram.py +183 -0
- dash_devtools/e2e.py +329 -0
- dash_devtools/fixers/__init__.py +57 -0
- dash_devtools/fixers/migration_fixer.py +115 -0
- dash_devtools/fixers/ux_fixer.py +106 -0
- dash_devtools/fixers/version_bumper.py +115 -0
- dash_devtools/gas_mes_test.py +1241 -0
- dash_devtools/generators/__init__.py +84 -0
- dash_devtools/health.py +476 -0
- dash_devtools/hooks/__init__.py +250 -0
- dash_devtools/hooks/pre_commit.py +161 -0
- dash_devtools/hooks/pre_push.py +275 -0
- dash_devtools/init_test.py +352 -0
- dash_devtools/markdown_report.py +309 -0
- dash_devtools/migrators/__init__.py +21 -0
- dash_devtools/perf.py +321 -0
- dash_devtools/report.py +667 -0
- dash_devtools/reporters/__init__.py +11 -0
- dash_devtools/spec.py +230 -0
- dash_devtools/stats.py +355 -0
- dash_devtools/test_suite.py +690 -0
- dash_devtools/testing.py +416 -0
- dash_devtools/validators/__init__.py +157 -0
- dash_devtools/validators/backend/__init__.py +12 -0
- dash_devtools/validators/backend/nodejs.py +245 -0
- dash_devtools/validators/backend/python.py +439 -0
- dash_devtools/validators/code_quality.py +243 -0
- dash_devtools/validators/common/__init__.py +11 -0
- dash_devtools/validators/common/quality.py +319 -0
- dash_devtools/validators/common/security.py +270 -0
- dash_devtools/validators/common/spec.py +273 -0
- dash_devtools/validators/detector.py +394 -0
- dash_devtools/validators/frontend/__init__.py +14 -0
- dash_devtools/validators/frontend/angular.py +245 -0
- dash_devtools/validators/frontend/gas.py +310 -0
- dash_devtools/validators/frontend/vite.py +539 -0
- dash_devtools/validators/migration.py +292 -0
- dash_devtools/validators/performance.py +167 -0
- dash_devtools/validators/security.py +205 -0
- dash_devtools/vision/__init__.py +368 -0
- dash_devtools/watch.py +266 -0
- dash_devtools/word_report.py +690 -0
- dash_devtools-1.0.0.dist-info/METADATA +834 -0
- dash_devtools-1.0.0.dist-info/RECORD +53 -0
- dash_devtools-1.0.0.dist-info/WHEEL +5 -0
- dash_devtools-1.0.0.dist-info/entry_points.txt +2 -0
- dash_devtools-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git Hooks 整合 v2.0
|
|
3
|
+
|
|
4
|
+
提供 pre-commit 和 pre-push 安全檢查
|
|
5
|
+
支援:
|
|
6
|
+
- Vue 3 + Vite + DaisyUI 專案
|
|
7
|
+
- Python FastAPI 專案 (Ruff 整合)
|
|
8
|
+
- 純 Proxy 閘道專案
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .pre_commit import run_pre_commit_check
|
|
12
|
+
from .pre_push import run_pre_push_check
|
|
13
|
+
|
|
14
|
+
__all__ = ['run_pre_commit_check', 'run_pre_push_check', 'install_hooks']
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Pre-push hook 腳本 v2.0(支援 Vue 3 + Python)
|
|
18
|
+
PRE_PUSH_HOOK = '''#!/bin/bash
|
|
19
|
+
# DashAI DevTools Pre-push Hook v2.0
|
|
20
|
+
# 支援:Vue 3 + Vite、Python FastAPI、純 Proxy 閘道
|
|
21
|
+
|
|
22
|
+
PROJECT_ROOT="$(git rev-parse --show-toplevel)"
|
|
23
|
+
|
|
24
|
+
echo ""
|
|
25
|
+
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
26
|
+
echo "[i] DashAI DevTools Pre-push 檢查"
|
|
27
|
+
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
28
|
+
echo ""
|
|
29
|
+
|
|
30
|
+
# 步驟 0: 檢查 Emoji
|
|
31
|
+
echo "[>] 步驟 0/3: 檢查 Emoji..."
|
|
32
|
+
EMOJI_FILES=$(git diff --cached --name-only --diff-filter=ACM | xargs grep -l '[\\x{1F300}-\\x{1F9FF}]' 2>/dev/null || true)
|
|
33
|
+
if [ -n "$EMOJI_FILES" ]; then
|
|
34
|
+
echo "[x] 發現 Emoji,請移除:"
|
|
35
|
+
echo "$EMOJI_FILES"
|
|
36
|
+
exit 1
|
|
37
|
+
fi
|
|
38
|
+
echo "[v] 無 Emoji"
|
|
39
|
+
echo ""
|
|
40
|
+
|
|
41
|
+
# 步驟 1: 掃描機敏資料
|
|
42
|
+
echo "[>] 步驟 1/3: 掃描機敏資料..."
|
|
43
|
+
dash scan "$PROJECT_ROOT"
|
|
44
|
+
if [ $? -ne 0 ]; then
|
|
45
|
+
echo ""
|
|
46
|
+
echo "[x] 安全檢查失敗,推送已取消"
|
|
47
|
+
exit 1
|
|
48
|
+
fi
|
|
49
|
+
echo ""
|
|
50
|
+
|
|
51
|
+
# 步驟 2: 驗證專案規範
|
|
52
|
+
echo "[>] 步驟 2/3: 驗證專案..."
|
|
53
|
+
|
|
54
|
+
# 偵測專案類型
|
|
55
|
+
IS_FRONTEND=false
|
|
56
|
+
IS_PYTHON=false
|
|
57
|
+
IS_PROXY_ONLY=false
|
|
58
|
+
|
|
59
|
+
if [ -f "$PROJECT_ROOT/package.json" ]; then
|
|
60
|
+
IS_FRONTEND=true
|
|
61
|
+
|
|
62
|
+
# 檢查是否為純 Proxy 閘道(無 api/ 目錄)
|
|
63
|
+
if [ -f "$PROJECT_ROOT/vercel.json" ] && [ ! -d "$PROJECT_ROOT/api" ]; then
|
|
64
|
+
if grep -q "https://" "$PROJECT_ROOT/vercel.json" 2>/dev/null; then
|
|
65
|
+
IS_PROXY_ONLY=true
|
|
66
|
+
echo " 偵測到:純 Proxy 閘道專案"
|
|
67
|
+
fi
|
|
68
|
+
fi
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
if [ -f "$PROJECT_ROOT/requirements.txt" ] || [ -f "$PROJECT_ROOT/pyproject.toml" ]; then
|
|
72
|
+
IS_PYTHON=true
|
|
73
|
+
echo " 偵測到:Python 專案"
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
# 執行驗證
|
|
77
|
+
if [ "$IS_FRONTEND" = true ]; then
|
|
78
|
+
dash validate "$PROJECT_ROOT" --check smart 2>/dev/null || true
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
# Python Ruff 檢查
|
|
82
|
+
if [ "$IS_PYTHON" = true ]; then
|
|
83
|
+
if command -v ruff &> /dev/null; then
|
|
84
|
+
echo " [ruff] 檢查程式碼..."
|
|
85
|
+
ruff check "$PROJECT_ROOT" --quiet || echo " [!] Ruff 發現問題(警告)"
|
|
86
|
+
ruff format --check "$PROJECT_ROOT" --quiet 2>/dev/null || echo " [!] 有檔案需要格式化"
|
|
87
|
+
else
|
|
88
|
+
echo " (Ruff 未安裝,跳過 Python lint)"
|
|
89
|
+
fi
|
|
90
|
+
fi
|
|
91
|
+
|
|
92
|
+
if [ "$IS_FRONTEND" = false ] && [ "$IS_PYTHON" = false ]; then
|
|
93
|
+
echo " (未偵測到前端或 Python 專案,跳過驗證)"
|
|
94
|
+
fi
|
|
95
|
+
echo ""
|
|
96
|
+
|
|
97
|
+
# 步驟 3: 執行測試
|
|
98
|
+
echo "[>] 步驟 3/4: 執行測試..."
|
|
99
|
+
TEST_RESULT=0
|
|
100
|
+
|
|
101
|
+
if [ "$IS_FRONTEND" = true ]; then
|
|
102
|
+
# 檢查是否有測試腳本
|
|
103
|
+
if grep -q '"test"' "$PROJECT_ROOT/package.json" 2>/dev/null; then
|
|
104
|
+
cd "$PROJECT_ROOT"
|
|
105
|
+
|
|
106
|
+
# 偵測測試框架並執行
|
|
107
|
+
if grep -q '"vitest"' package.json 2>/dev/null; then
|
|
108
|
+
echo " [vitest] 執行測試..."
|
|
109
|
+
npx vitest run --passWithNoTests 2>&1 || TEST_RESULT=$?
|
|
110
|
+
elif grep -q '"jest"' package.json 2>/dev/null; then
|
|
111
|
+
echo " [jest] 執行測試..."
|
|
112
|
+
npx jest --passWithNoTests 2>&1 || TEST_RESULT=$?
|
|
113
|
+
elif grep -q '"karma"' package.json 2>/dev/null || grep -q '"@angular-devkit"' package.json 2>/dev/null; then
|
|
114
|
+
echo " [karma] 執行測試..."
|
|
115
|
+
npm test -- --no-watch --browsers=ChromeHeadless 2>&1 || TEST_RESULT=$?
|
|
116
|
+
else
|
|
117
|
+
echo " (無已知測試框架,跳過)"
|
|
118
|
+
fi
|
|
119
|
+
else
|
|
120
|
+
echo " (無測試腳本,跳過)"
|
|
121
|
+
fi
|
|
122
|
+
fi
|
|
123
|
+
|
|
124
|
+
if [ "$IS_PYTHON" = true ]; then
|
|
125
|
+
if [ -d "$PROJECT_ROOT/tests" ] || [ -f "$PROJECT_ROOT/pytest.ini" ] || [ -f "$PROJECT_ROOT/pyproject.toml" ]; then
|
|
126
|
+
if command -v pytest &> /dev/null; then
|
|
127
|
+
echo " [pytest] 執行測試..."
|
|
128
|
+
cd "$PROJECT_ROOT"
|
|
129
|
+
python -m pytest -q --tb=short 2>&1 || TEST_RESULT=$?
|
|
130
|
+
else
|
|
131
|
+
echo " (pytest 未安裝,跳過)"
|
|
132
|
+
fi
|
|
133
|
+
fi
|
|
134
|
+
fi
|
|
135
|
+
|
|
136
|
+
if [ $TEST_RESULT -ne 0 ]; then
|
|
137
|
+
if [ "$DASH_STRICT_TEST" = "1" ]; then
|
|
138
|
+
echo ""
|
|
139
|
+
echo "[x] 測試失敗,推送已取消 (嚴格模式)"
|
|
140
|
+
exit 1
|
|
141
|
+
else
|
|
142
|
+
echo ""
|
|
143
|
+
echo "[!] 測試失敗,但繼續推送 (警告模式)"
|
|
144
|
+
echo " 使用 --strict 安裝 hook 可強制測試通過"
|
|
145
|
+
fi
|
|
146
|
+
fi
|
|
147
|
+
echo ""
|
|
148
|
+
|
|
149
|
+
# 步驟 4: E2E 煙霧測試 (如果已設定)
|
|
150
|
+
if [ -n "$DASH_E2E_URL" ]; then
|
|
151
|
+
echo "[>] 步驟 4/4: E2E 煙霧測試..."
|
|
152
|
+
echo " 測試網址: $DASH_E2E_URL"
|
|
153
|
+
|
|
154
|
+
# 桌面版測試
|
|
155
|
+
dash e2e "$DASH_E2E_URL" --timeout 45000
|
|
156
|
+
E2E_RESULT=$?
|
|
157
|
+
|
|
158
|
+
# 手機版測試 (如果啟用)
|
|
159
|
+
if [ "$DASH_MOBILE_E2E" = "1" ] && [ $E2E_RESULT -eq 0 ]; then
|
|
160
|
+
echo ""
|
|
161
|
+
echo " [手機版測試] 375x812"
|
|
162
|
+
dash e2e "$DASH_E2E_URL" --timeout 45000 --mobile
|
|
163
|
+
E2E_RESULT=$?
|
|
164
|
+
fi
|
|
165
|
+
|
|
166
|
+
if [ $E2E_RESULT -ne 0 ]; then
|
|
167
|
+
if [ "$DASH_STRICT_E2E" = "1" ]; then
|
|
168
|
+
echo ""
|
|
169
|
+
echo "[x] E2E 測試失敗,推送已取消"
|
|
170
|
+
exit 1
|
|
171
|
+
else
|
|
172
|
+
echo ""
|
|
173
|
+
echo "[!] E2E 測試失敗,但繼續推送 (警告模式)"
|
|
174
|
+
echo " 使用 --strict-e2e 安裝 hook 可強制 E2E 通過"
|
|
175
|
+
fi
|
|
176
|
+
fi
|
|
177
|
+
echo ""
|
|
178
|
+
else
|
|
179
|
+
echo "[>] 步驟 4/4: E2E 煙霧測試..."
|
|
180
|
+
echo " (未設定 E2E 網址,跳過)"
|
|
181
|
+
echo " 使用 --e2e <URL> 安裝 hook 可啟用 E2E 測試"
|
|
182
|
+
echo " 使用 --mobile-e2e 可同時檢查手機版水平溢出"
|
|
183
|
+
echo ""
|
|
184
|
+
fi
|
|
185
|
+
|
|
186
|
+
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
187
|
+
echo "[v] 所有檢查通過,繼續推送..."
|
|
188
|
+
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
189
|
+
'''
|
|
190
|
+
|
|
191
|
+
# Pre-commit hook 腳本
|
|
192
|
+
PRE_COMMIT_HOOK = '''#!/bin/bash
|
|
193
|
+
# DashAI DevTools Pre-commit Hook
|
|
194
|
+
PROJECT_ROOT="$(git rev-parse --show-toplevel)"
|
|
195
|
+
|
|
196
|
+
echo "掃描機敏資料..."
|
|
197
|
+
dash scan "$PROJECT_ROOT"
|
|
198
|
+
'''
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def install_hooks(project_path, strict_test: bool = False, e2e_url: str = None, strict_e2e: bool = False, mobile_e2e: bool = False):
|
|
202
|
+
"""安裝 git hooks 到專案
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
project_path: 專案路徑
|
|
206
|
+
strict_test: 是否啟用嚴格測試模式(測試失敗會阻止推送)
|
|
207
|
+
e2e_url: E2E 測試網址(設定後每次推送會執行煙霧測試)
|
|
208
|
+
strict_e2e: 是否啟用嚴格 E2E 模式(E2E 失敗會阻止推送)
|
|
209
|
+
mobile_e2e: 是否同時執行手機版 E2E 測試(檢查水平溢出)
|
|
210
|
+
"""
|
|
211
|
+
from pathlib import Path
|
|
212
|
+
import stat
|
|
213
|
+
|
|
214
|
+
hooks_dir = Path(project_path) / '.git' / 'hooks'
|
|
215
|
+
if not hooks_dir.exists():
|
|
216
|
+
return {'success': False, 'error': '.git/hooks 目錄不存在'}
|
|
217
|
+
|
|
218
|
+
# Pre-commit hook
|
|
219
|
+
pre_commit = hooks_dir / 'pre-commit'
|
|
220
|
+
pre_commit.write_text(PRE_COMMIT_HOOK, encoding='utf-8')
|
|
221
|
+
pre_commit.chmod(pre_commit.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
222
|
+
|
|
223
|
+
# Pre-push hook
|
|
224
|
+
pre_push = hooks_dir / 'pre-push'
|
|
225
|
+
env_vars = []
|
|
226
|
+
|
|
227
|
+
# 如果啟用嚴格模式,加入環境變數
|
|
228
|
+
if strict_test:
|
|
229
|
+
env_vars.append('export DASH_STRICT_TEST=1')
|
|
230
|
+
|
|
231
|
+
# E2E 設定
|
|
232
|
+
if e2e_url:
|
|
233
|
+
env_vars.append(f'export DASH_E2E_URL="{e2e_url}"')
|
|
234
|
+
if strict_e2e:
|
|
235
|
+
env_vars.append('export DASH_STRICT_E2E=1')
|
|
236
|
+
if mobile_e2e:
|
|
237
|
+
env_vars.append('export DASH_MOBILE_E2E=1')
|
|
238
|
+
|
|
239
|
+
hook_content = '\n'.join(env_vars) + '\n' + PRE_PUSH_HOOK if env_vars else PRE_PUSH_HOOK
|
|
240
|
+
|
|
241
|
+
pre_push.write_text(hook_content, encoding='utf-8')
|
|
242
|
+
pre_push.chmod(pre_push.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
'success': True,
|
|
246
|
+
'strict_test': strict_test,
|
|
247
|
+
'e2e_url': e2e_url,
|
|
248
|
+
'strict_e2e': strict_e2e,
|
|
249
|
+
'mobile_e2e': mobile_e2e
|
|
250
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pre-commit 檢查
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from fnmatch import fnmatch
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# 敏感資料正則表達式
|
|
11
|
+
# 注意:要避免假陽性,模式需要精確匹配硬編碼的值,而非變數賦值
|
|
12
|
+
SENSITIVE_PATTERNS = [
|
|
13
|
+
# API Key: 必須有引號包住的值(排除函數呼叫)
|
|
14
|
+
(r'(?i)(api[_-]?key|apikey)\s*[=:]\s*["\'][a-zA-Z0-9_-]{20,}["\']', 'API Key'),
|
|
15
|
+
# Secret/Token: 特定命名且有引號包住的值
|
|
16
|
+
(r'(?i)(client_?secret|api_?secret|secret_?key)\s*[=:]\s*["\'][a-zA-Z0-9_-]{20,}["\']', 'Secret/Token'),
|
|
17
|
+
# 密碼: 需要引號且長度 >= 8
|
|
18
|
+
(r'(?i)password\s*[=:]\s*["\'][^"\']{8,}["\']', '密碼'),
|
|
19
|
+
# 特定格式的 Key (這些格式明確,不會有假陽性)
|
|
20
|
+
(r'sk-[a-zA-Z0-9]{48}', 'OpenAI API Key'),
|
|
21
|
+
(r'sk_live_[a-zA-Z0-9]{24,}', 'Stripe Live Key'),
|
|
22
|
+
(r'ghp_[a-zA-Z0-9]{36}', 'GitHub Token'),
|
|
23
|
+
(r'CLERK_SECRET_KEY\s*=\s*["\']?sk_[a-zA-Z0-9_-]{20,}', 'Clerk Secret Key'),
|
|
24
|
+
(r'-----BEGIN (RSA )?PRIVATE KEY-----', '私鑰'),
|
|
25
|
+
(r'AKIA[0-9A-Z]{16}', 'AWS Access Key'),
|
|
26
|
+
# Neon Database API Key (napi_ 開頭)
|
|
27
|
+
(r'napi_[a-zA-Z0-9]{60,}', 'Neon API Key'),
|
|
28
|
+
# PostgreSQL 連線字串 (含密碼)
|
|
29
|
+
(r'postgres(?:ql)?://[^:<]+:[^@<]+@[^\s"\']+', 'PostgreSQL 連線字串'),
|
|
30
|
+
# Neon PostgreSQL 密碼 (npg_ 開頭)
|
|
31
|
+
(r'npg_[a-zA-Z0-9]{10,}', 'Neon PostgreSQL 密碼'),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_scanignore(project_path):
|
|
36
|
+
"""解析 .scanignore 檔案
|
|
37
|
+
|
|
38
|
+
格式:
|
|
39
|
+
- # 開頭為註解
|
|
40
|
+
- 空行忽略
|
|
41
|
+
- path/to/file 或 path/to/dir/ - 排除檔案或目錄
|
|
42
|
+
- [pattern:名稱] path/glob - 只排除特定 pattern
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
dict: {
|
|
46
|
+
'paths': [str, ...], # 全排除的路徑 glob
|
|
47
|
+
'pattern_paths': [(pattern_name, glob), ...] # 特定 pattern 排除
|
|
48
|
+
}
|
|
49
|
+
"""
|
|
50
|
+
scanignore_file = Path(project_path) / '.scanignore'
|
|
51
|
+
result = {'paths': [], 'pattern_paths': []}
|
|
52
|
+
|
|
53
|
+
if not scanignore_file.exists():
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
for line in scanignore_file.read_text(encoding='utf-8').splitlines():
|
|
58
|
+
line = line.strip()
|
|
59
|
+
if not line or line.startswith('#'):
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
# [pattern:名稱] path/glob
|
|
63
|
+
pattern_match = re.match(r'^\[pattern:(.+?)\]\s+(.+)$', line)
|
|
64
|
+
if pattern_match:
|
|
65
|
+
pattern_name = pattern_match.group(1).strip()
|
|
66
|
+
path_glob = pattern_match.group(2).strip()
|
|
67
|
+
result['pattern_paths'].append((pattern_name, path_glob))
|
|
68
|
+
else:
|
|
69
|
+
result['paths'].append(line)
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_scan_ignored(rel_path, pattern_name, scanignore):
|
|
77
|
+
"""檢查檔案是否在 .scanignore 中
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
rel_path: 相對於專案根目錄的路徑 (str)
|
|
81
|
+
pattern_name: 目前檢測的 pattern 名稱 (str),None 表示檢查所有 pattern
|
|
82
|
+
scanignore: parse_scanignore() 的回傳值
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
bool: True 表示應跳過
|
|
86
|
+
"""
|
|
87
|
+
# 全排除路徑
|
|
88
|
+
for glob_pattern in scanignore['paths']:
|
|
89
|
+
# 目錄匹配 (pattern 以 / 結尾或不含 .)
|
|
90
|
+
if glob_pattern.endswith('/'):
|
|
91
|
+
if rel_path.startswith(glob_pattern) or rel_path.startswith(glob_pattern.rstrip('/')):
|
|
92
|
+
return True
|
|
93
|
+
# glob 匹配
|
|
94
|
+
if fnmatch(rel_path, glob_pattern):
|
|
95
|
+
return True
|
|
96
|
+
# 前綴匹配 (目錄)
|
|
97
|
+
if rel_path.startswith(glob_pattern.rstrip('/') + '/'):
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
# 特定 pattern 排除
|
|
101
|
+
if pattern_name:
|
|
102
|
+
for p_name, p_glob in scanignore['pattern_paths']:
|
|
103
|
+
if p_name == pattern_name:
|
|
104
|
+
if fnmatch(rel_path, p_glob):
|
|
105
|
+
return True
|
|
106
|
+
if p_glob.endswith('/') and rel_path.startswith(p_glob.rstrip('/') + '/'):
|
|
107
|
+
return True
|
|
108
|
+
if rel_path.startswith(p_glob.rstrip('/') + '/'):
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def run_pre_commit_check(project_path):
|
|
115
|
+
"""執行 pre-commit 檢查"""
|
|
116
|
+
project = Path(project_path)
|
|
117
|
+
issues = []
|
|
118
|
+
scanignore = parse_scanignore(project_path)
|
|
119
|
+
|
|
120
|
+
# 檢查 staged 檔案
|
|
121
|
+
import subprocess
|
|
122
|
+
result = subprocess.run(
|
|
123
|
+
['git', 'diff', '--cached', '--name-only'],
|
|
124
|
+
cwd=project,
|
|
125
|
+
capture_output=True,
|
|
126
|
+
text=True
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
staged_files = result.stdout.strip().split('\n') if result.stdout.strip() else []
|
|
130
|
+
|
|
131
|
+
for file_name in staged_files:
|
|
132
|
+
file_path = project / file_name
|
|
133
|
+
if not file_path.exists() or not file_path.is_file():
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
# 跳過二進制檔案
|
|
137
|
+
if file_path.suffix in ['.png', '.jpg', '.gif', '.ico', '.pdf', '.zip']:
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
# 跳過 .scanignore 全排除的檔案
|
|
141
|
+
if is_scan_ignored(file_name, None, scanignore):
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
content = file_path.read_text(encoding='utf-8')
|
|
146
|
+
for pattern, desc in SENSITIVE_PATTERNS:
|
|
147
|
+
# 跳過 .scanignore 特定 pattern 排除
|
|
148
|
+
if is_scan_ignored(file_name, desc, scanignore):
|
|
149
|
+
continue
|
|
150
|
+
if re.search(pattern, content):
|
|
151
|
+
issues.append({
|
|
152
|
+
'file': file_name,
|
|
153
|
+
'type': desc
|
|
154
|
+
})
|
|
155
|
+
except Exception:
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
'passed': len(issues) == 0,
|
|
160
|
+
'issues': issues
|
|
161
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pre-push 檢查
|
|
3
|
+
|
|
4
|
+
支援功能:
|
|
5
|
+
1. GitGuardian (ggshield) 機敏資料掃描
|
|
6
|
+
2. 測試執行 (Vitest / Jest / Pytest)
|
|
7
|
+
3. 強制門檻:測試失敗禁止推送
|
|
8
|
+
|
|
9
|
+
使用 --strict 選項強制測試通過才能推送
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .pre_commit import run_pre_commit_check, SENSITIVE_PATTERNS, parse_scanignore, is_scan_ignored
|
|
13
|
+
import re
|
|
14
|
+
import os
|
|
15
|
+
import subprocess
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run_tests(project_path, strict=False):
|
|
20
|
+
"""
|
|
21
|
+
執行專案測試
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
project_path: 專案路徑
|
|
25
|
+
strict: 嚴格模式 - 測試失敗時禁止推送
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
dict: {passed: bool, engine: str, message: str}
|
|
29
|
+
"""
|
|
30
|
+
project = Path(project_path)
|
|
31
|
+
package_json = project / 'package.json'
|
|
32
|
+
pytest_ini = project / 'pytest.ini'
|
|
33
|
+
pyproject = project / 'pyproject.toml'
|
|
34
|
+
|
|
35
|
+
results = []
|
|
36
|
+
|
|
37
|
+
# 1. Node.js 專案測試 (Vitest / Jest / Karma)
|
|
38
|
+
if package_json.exists():
|
|
39
|
+
try:
|
|
40
|
+
import json
|
|
41
|
+
pkg = json.loads(package_json.read_text())
|
|
42
|
+
scripts = pkg.get('scripts', {})
|
|
43
|
+
|
|
44
|
+
if 'test' in scripts:
|
|
45
|
+
result = subprocess.run(
|
|
46
|
+
['npm', 'run', 'test'],
|
|
47
|
+
capture_output=True,
|
|
48
|
+
text=True,
|
|
49
|
+
cwd=project_path,
|
|
50
|
+
timeout=300 # 5 分鐘超時
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
test_engine = 'Vitest'
|
|
54
|
+
if 'jest' in scripts.get('test', ''):
|
|
55
|
+
test_engine = 'Jest'
|
|
56
|
+
elif 'karma' in scripts.get('test', ''):
|
|
57
|
+
test_engine = 'Karma'
|
|
58
|
+
|
|
59
|
+
results.append({
|
|
60
|
+
'engine': test_engine,
|
|
61
|
+
'passed': result.returncode == 0,
|
|
62
|
+
'output': result.stdout + result.stderr
|
|
63
|
+
})
|
|
64
|
+
except subprocess.TimeoutExpired:
|
|
65
|
+
results.append({
|
|
66
|
+
'engine': 'npm test',
|
|
67
|
+
'passed': False,
|
|
68
|
+
'output': '測試執行超時 (5 分鐘)'
|
|
69
|
+
})
|
|
70
|
+
except Exception as e:
|
|
71
|
+
results.append({
|
|
72
|
+
'engine': 'npm test',
|
|
73
|
+
'passed': True, # 無法執行時不阻擋
|
|
74
|
+
'output': f'跳過: {str(e)}'
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
# 2. Python 專案測試 (pytest)
|
|
78
|
+
if pytest_ini.exists() or (pyproject.exists() and '[tool.pytest' in pyproject.read_text()):
|
|
79
|
+
tests_dir = project / 'tests'
|
|
80
|
+
if tests_dir.exists():
|
|
81
|
+
try:
|
|
82
|
+
result = subprocess.run(
|
|
83
|
+
['python3', '-m', 'pytest', 'tests/', '-v', '--tb=short'],
|
|
84
|
+
capture_output=True,
|
|
85
|
+
text=True,
|
|
86
|
+
cwd=project_path,
|
|
87
|
+
timeout=300
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
results.append({
|
|
91
|
+
'engine': 'Pytest',
|
|
92
|
+
'passed': result.returncode == 0,
|
|
93
|
+
'output': result.stdout + result.stderr
|
|
94
|
+
})
|
|
95
|
+
except subprocess.TimeoutExpired:
|
|
96
|
+
results.append({
|
|
97
|
+
'engine': 'Pytest',
|
|
98
|
+
'passed': False,
|
|
99
|
+
'output': '測試執行超時 (5 分鐘)'
|
|
100
|
+
})
|
|
101
|
+
except Exception as e:
|
|
102
|
+
results.append({
|
|
103
|
+
'engine': 'Pytest',
|
|
104
|
+
'passed': True,
|
|
105
|
+
'output': f'跳過: {str(e)}'
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
# 彙總結果
|
|
109
|
+
if not results:
|
|
110
|
+
return {
|
|
111
|
+
'passed': True,
|
|
112
|
+
'engine': 'None',
|
|
113
|
+
'message': '未偵測到測試框架'
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
all_passed = all(r['passed'] for r in results)
|
|
117
|
+
engines = [r['engine'] for r in results]
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
'passed': all_passed or not strict,
|
|
121
|
+
'strict_failed': not all_passed and strict,
|
|
122
|
+
'engine': ', '.join(engines),
|
|
123
|
+
'message': '所有測試通過' if all_passed else '測試失敗',
|
|
124
|
+
'details': results
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def run_ggshield_scan(project_path):
|
|
129
|
+
"""使用 GitGuardian ggshield 掃描"""
|
|
130
|
+
try:
|
|
131
|
+
# 檢查是否有 API Key
|
|
132
|
+
if not os.environ.get('GITGUARDIAN_API_KEY'):
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
# 檢查 ggshield 是否安裝
|
|
136
|
+
result = subprocess.run(
|
|
137
|
+
['ggshield', '--version'],
|
|
138
|
+
capture_output=True,
|
|
139
|
+
text=True
|
|
140
|
+
)
|
|
141
|
+
if result.returncode != 0:
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
# 執行掃描 (忽略 .claude 本地設定和 node_modules)
|
|
145
|
+
result = subprocess.run(
|
|
146
|
+
['ggshield', 'secret', 'scan', 'path', str(project_path),
|
|
147
|
+
'--recursive', '--exit-zero', '--json', '--yes',
|
|
148
|
+
'--ignore-path', '.claude/',
|
|
149
|
+
'--ignore-path', 'node_modules/',
|
|
150
|
+
'--ignore-path', '.env',
|
|
151
|
+
'--ignore-path', '.env.local'],
|
|
152
|
+
capture_output=True,
|
|
153
|
+
text=True,
|
|
154
|
+
cwd=project_path
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# 解析結果
|
|
158
|
+
import json
|
|
159
|
+
try:
|
|
160
|
+
data = json.loads(result.stdout)
|
|
161
|
+
issues = []
|
|
162
|
+
|
|
163
|
+
# ggshield 輸出格式
|
|
164
|
+
if 'entities_with_incidents' in data:
|
|
165
|
+
for entity in data.get('entities_with_incidents', []):
|
|
166
|
+
filename = entity.get('filename', 'unknown')
|
|
167
|
+
for incident in entity.get('incidents', []):
|
|
168
|
+
issues.append({
|
|
169
|
+
'file': filename,
|
|
170
|
+
'type': incident.get('type', 'Secret'),
|
|
171
|
+
'count': 1
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
'passed': len(issues) == 0,
|
|
176
|
+
'issues': issues,
|
|
177
|
+
'engine': 'GitGuardian'
|
|
178
|
+
}
|
|
179
|
+
except json.JSONDecodeError:
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
except Exception:
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def run_pre_push_check(project_path):
|
|
187
|
+
"""執行 pre-push 檢查 (掃描整個專案)"""
|
|
188
|
+
project = Path(project_path)
|
|
189
|
+
|
|
190
|
+
# 優先使用 GitGuardian
|
|
191
|
+
gg_result = run_ggshield_scan(project_path)
|
|
192
|
+
if gg_result is not None:
|
|
193
|
+
return gg_result
|
|
194
|
+
|
|
195
|
+
# 備援:使用本地正則表達式
|
|
196
|
+
issues = []
|
|
197
|
+
|
|
198
|
+
# 忽略的目錄和檔案
|
|
199
|
+
ignore_dirs = [
|
|
200
|
+
'node_modules', '.git', 'dist', 'build', '.next', '__pycache__',
|
|
201
|
+
'venv', '.venv', '.angular', '.cache', 'coverage'
|
|
202
|
+
]
|
|
203
|
+
ignore_files = ['.env.example', '.env.sample', '.env.template']
|
|
204
|
+
|
|
205
|
+
# 讀取 .scanignore
|
|
206
|
+
scanignore = parse_scanignore(project_path)
|
|
207
|
+
|
|
208
|
+
# 讀取 .gitignore 檔案
|
|
209
|
+
gitignore_patterns = []
|
|
210
|
+
gitignore_file = project / '.gitignore'
|
|
211
|
+
if gitignore_file.exists():
|
|
212
|
+
try:
|
|
213
|
+
for line in gitignore_file.read_text().splitlines():
|
|
214
|
+
line = line.strip()
|
|
215
|
+
if line and not line.startswith('#'):
|
|
216
|
+
gitignore_patterns.append(line)
|
|
217
|
+
except Exception:
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
def is_gitignored(file_path):
|
|
221
|
+
"""檢查檔案是否在 .gitignore 中"""
|
|
222
|
+
rel_path = str(file_path.relative_to(project))
|
|
223
|
+
file_name = file_path.name
|
|
224
|
+
for pattern in gitignore_patterns:
|
|
225
|
+
# 簡單匹配:完全匹配或 pattern 在路徑中
|
|
226
|
+
if pattern == file_name or pattern == rel_path:
|
|
227
|
+
return True
|
|
228
|
+
if pattern.startswith('*.') and file_name.endswith(pattern[1:]):
|
|
229
|
+
return True
|
|
230
|
+
if pattern.endswith('/') and pattern[:-1] in rel_path.split('/'):
|
|
231
|
+
return True
|
|
232
|
+
if pattern in rel_path:
|
|
233
|
+
return True
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
# 掃描所有檔案(不含 .env,因為應該都在 .gitignore)
|
|
237
|
+
extensions = ['*.js', '*.ts', '*.jsx', '*.tsx', '*.py', '*.json', '*.yaml', '*.yml']
|
|
238
|
+
|
|
239
|
+
for ext in extensions:
|
|
240
|
+
for file_path in project.rglob(ext):
|
|
241
|
+
rel_path = str(file_path.relative_to(project))
|
|
242
|
+
|
|
243
|
+
# 跳過忽略的目錄
|
|
244
|
+
if any(ignore in str(file_path) for ignore in ignore_dirs):
|
|
245
|
+
continue
|
|
246
|
+
# 跳過範例檔案
|
|
247
|
+
if file_path.name in ignore_files:
|
|
248
|
+
continue
|
|
249
|
+
# 跳過 .gitignore 中的檔案
|
|
250
|
+
if is_gitignored(file_path):
|
|
251
|
+
continue
|
|
252
|
+
# 跳過 .scanignore 全排除的檔案
|
|
253
|
+
if is_scan_ignored(rel_path, None, scanignore):
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
content = file_path.read_text(encoding='utf-8')
|
|
258
|
+
for pattern, desc in SENSITIVE_PATTERNS:
|
|
259
|
+
# 跳過 .scanignore 特定 pattern 排除
|
|
260
|
+
if is_scan_ignored(rel_path, desc, scanignore):
|
|
261
|
+
continue
|
|
262
|
+
matches = re.findall(pattern, content)
|
|
263
|
+
if matches:
|
|
264
|
+
issues.append({
|
|
265
|
+
'file': rel_path,
|
|
266
|
+
'type': desc,
|
|
267
|
+
'count': len(matches)
|
|
268
|
+
})
|
|
269
|
+
except Exception:
|
|
270
|
+
pass
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
'passed': len(issues) == 0,
|
|
274
|
+
'issues': issues
|
|
275
|
+
}
|