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.
Files changed (53) hide show
  1. dash_devtools/__init__.py +8 -0
  2. dash_devtools/__main__.py +11 -0
  3. dash_devtools/ai_engine.py +441 -0
  4. dash_devtools/browser.py +541 -0
  5. dash_devtools/cli.py +1452 -0
  6. dash_devtools/database.py +338 -0
  7. dash_devtools/dbdiagram.py +183 -0
  8. dash_devtools/e2e.py +329 -0
  9. dash_devtools/fixers/__init__.py +57 -0
  10. dash_devtools/fixers/migration_fixer.py +115 -0
  11. dash_devtools/fixers/ux_fixer.py +106 -0
  12. dash_devtools/fixers/version_bumper.py +115 -0
  13. dash_devtools/gas_mes_test.py +1241 -0
  14. dash_devtools/generators/__init__.py +84 -0
  15. dash_devtools/health.py +476 -0
  16. dash_devtools/hooks/__init__.py +250 -0
  17. dash_devtools/hooks/pre_commit.py +161 -0
  18. dash_devtools/hooks/pre_push.py +275 -0
  19. dash_devtools/init_test.py +352 -0
  20. dash_devtools/markdown_report.py +309 -0
  21. dash_devtools/migrators/__init__.py +21 -0
  22. dash_devtools/perf.py +321 -0
  23. dash_devtools/report.py +667 -0
  24. dash_devtools/reporters/__init__.py +11 -0
  25. dash_devtools/spec.py +230 -0
  26. dash_devtools/stats.py +355 -0
  27. dash_devtools/test_suite.py +690 -0
  28. dash_devtools/testing.py +416 -0
  29. dash_devtools/validators/__init__.py +157 -0
  30. dash_devtools/validators/backend/__init__.py +12 -0
  31. dash_devtools/validators/backend/nodejs.py +245 -0
  32. dash_devtools/validators/backend/python.py +439 -0
  33. dash_devtools/validators/code_quality.py +243 -0
  34. dash_devtools/validators/common/__init__.py +11 -0
  35. dash_devtools/validators/common/quality.py +319 -0
  36. dash_devtools/validators/common/security.py +270 -0
  37. dash_devtools/validators/common/spec.py +273 -0
  38. dash_devtools/validators/detector.py +394 -0
  39. dash_devtools/validators/frontend/__init__.py +14 -0
  40. dash_devtools/validators/frontend/angular.py +245 -0
  41. dash_devtools/validators/frontend/gas.py +310 -0
  42. dash_devtools/validators/frontend/vite.py +539 -0
  43. dash_devtools/validators/migration.py +292 -0
  44. dash_devtools/validators/performance.py +167 -0
  45. dash_devtools/validators/security.py +205 -0
  46. dash_devtools/vision/__init__.py +368 -0
  47. dash_devtools/watch.py +266 -0
  48. dash_devtools/word_report.py +690 -0
  49. dash_devtools-1.0.0.dist-info/METADATA +834 -0
  50. dash_devtools-1.0.0.dist-info/RECORD +53 -0
  51. dash_devtools-1.0.0.dist-info/WHEEL +5 -0
  52. dash_devtools-1.0.0.dist-info/entry_points.txt +2 -0
  53. 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
+ }