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,292 @@
|
|
|
1
|
+
"""
|
|
2
|
+
UI 框架驗證器
|
|
3
|
+
|
|
4
|
+
檢查項目:
|
|
5
|
+
1. Shoelace 元件正確使用
|
|
6
|
+
2. 禁止使用 Emoji 作為圖示(應用 sl-icon)
|
|
7
|
+
3. 重複 class 屬性
|
|
8
|
+
4. Shoelace CSS 變數正確使用
|
|
9
|
+
5. 不完整 HTML 標籤
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# 常見 Emoji 圖示(這些應該用 sl-icon 取代)
|
|
17
|
+
ICON_EMOJI_PATTERNS = [
|
|
18
|
+
# 工具與操作
|
|
19
|
+
r'[\U0001F527\U0001F528\U0001F529]', # 🔧🔨🔩 wrench/hammer
|
|
20
|
+
r'[\U0001F504\U0001F503]', # 🔄🔃 refresh
|
|
21
|
+
r'[\U0001F50D\U0001F50E]', # 🔍🔎 search
|
|
22
|
+
r'[\u2699\uFE0F]?', # ⚙️ gear
|
|
23
|
+
r'[\U0001F5D1\uFE0F]?', # 🗑️ trash
|
|
24
|
+
r'[\u270F\uFE0F]?', # ✏️ pencil
|
|
25
|
+
r'[\u2795]', # ➕ plus
|
|
26
|
+
r'[\u2796]', # ➖ minus
|
|
27
|
+
# 狀態指示
|
|
28
|
+
r'[\u2705]', # ✅ check mark
|
|
29
|
+
r'[\u274C]', # ❌ cross mark
|
|
30
|
+
r'[\u26A0\uFE0F]?', # ⚠️ warning
|
|
31
|
+
r'[\U0001F6A8]', # 🚨 alert
|
|
32
|
+
r'[\u2139\uFE0F]?', # ℹ️ info
|
|
33
|
+
# 物件與符號
|
|
34
|
+
r'[\U0001F3E2]', # 🏢 building
|
|
35
|
+
r'[\U0001F4CB]', # 📋 clipboard
|
|
36
|
+
r'[\U0001F4C5\U0001F4C6]', # 📅📆 calendar
|
|
37
|
+
r'[\U0001F464\U0001F465]', # 👤👥 person/people
|
|
38
|
+
r'[\U0001F512\U0001F513]', # 🔒🔓 lock
|
|
39
|
+
r'[\U0001F510]', # 🔐 key lock
|
|
40
|
+
r'[\u231B]', # ⏳ hourglass
|
|
41
|
+
r'[\u23F3]', # ⏳ hourglass flowing
|
|
42
|
+
# 數字圓圈(應用 sl-icon 的 1-circle 等)
|
|
43
|
+
r'[1-9]\uFE0F?\u20E3', # 1️⃣ 2️⃣ etc
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class MigrationValidator:
|
|
48
|
+
"""UI 框架驗證器"""
|
|
49
|
+
|
|
50
|
+
name = 'migration'
|
|
51
|
+
|
|
52
|
+
def __init__(self, project_path):
|
|
53
|
+
self.project_path = Path(project_path)
|
|
54
|
+
self.project_name = self.project_path.name
|
|
55
|
+
self.src_path = self.project_path / 'src'
|
|
56
|
+
self.result = {
|
|
57
|
+
'name': self.name,
|
|
58
|
+
'passed': True,
|
|
59
|
+
'errors': [],
|
|
60
|
+
'warnings': [],
|
|
61
|
+
'checks': {}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
def run(self):
|
|
65
|
+
"""執行所有驗證"""
|
|
66
|
+
if not self.project_path.exists():
|
|
67
|
+
self.result['passed'] = False
|
|
68
|
+
self.result['errors'].append(f'專案路徑不存在: {self.project_path}')
|
|
69
|
+
return self.result
|
|
70
|
+
|
|
71
|
+
# 判斷專案類型
|
|
72
|
+
is_angular = (self.project_path / 'angular.json').exists()
|
|
73
|
+
|
|
74
|
+
if is_angular:
|
|
75
|
+
# Angular 專案使用 PrimeNG,跳過 Shoelace 檢查
|
|
76
|
+
self.result['checks']['framework'] = 'Angular + PrimeNG'
|
|
77
|
+
else:
|
|
78
|
+
# 非 Angular 專案應使用 Shoelace
|
|
79
|
+
self.check_shoelace_usage()
|
|
80
|
+
self.check_emoji_icons()
|
|
81
|
+
|
|
82
|
+
# 通用檢查
|
|
83
|
+
self.check_duplicate_classes()
|
|
84
|
+
self.check_incomplete_html_tags()
|
|
85
|
+
self.check_empty_buttons()
|
|
86
|
+
self.check_empty_event_handlers()
|
|
87
|
+
|
|
88
|
+
return self.result
|
|
89
|
+
|
|
90
|
+
def check_shoelace_usage(self):
|
|
91
|
+
"""檢查 Shoelace 是否正確使用"""
|
|
92
|
+
# 檢查 index.html 是否有 Shoelace CDN
|
|
93
|
+
index_html = self.project_path / 'index.html'
|
|
94
|
+
has_shoelace_css = False
|
|
95
|
+
has_shoelace_js = False
|
|
96
|
+
|
|
97
|
+
if index_html.exists():
|
|
98
|
+
content = index_html.read_text(encoding='utf-8')
|
|
99
|
+
has_shoelace_css = 'shoelace' in content and '.css' in content
|
|
100
|
+
has_shoelace_js = 'shoelace' in content and '.js' in content
|
|
101
|
+
|
|
102
|
+
# 檢查 package.json
|
|
103
|
+
pkg_path = self.project_path / 'package.json'
|
|
104
|
+
has_shoelace_dep = False
|
|
105
|
+
|
|
106
|
+
if pkg_path.exists():
|
|
107
|
+
content = pkg_path.read_text(encoding='utf-8')
|
|
108
|
+
has_shoelace_dep = '@shoelace-style/shoelace' in content
|
|
109
|
+
|
|
110
|
+
self.result['checks']['shoelace_usage'] = {
|
|
111
|
+
'has_css': has_shoelace_css,
|
|
112
|
+
'has_js': has_shoelace_js,
|
|
113
|
+
'has_dependency': has_shoelace_dep
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# Shoelace 是預期的框架,缺少才是問題
|
|
117
|
+
if not has_shoelace_css and not has_shoelace_dep:
|
|
118
|
+
self.result['warnings'].append('未偵測到 Shoelace(非 Angular 專案建議使用)')
|
|
119
|
+
|
|
120
|
+
def check_emoji_icons(self):
|
|
121
|
+
"""檢查 Emoji 圖示(應用 sl-icon 取代)"""
|
|
122
|
+
if not self.src_path.exists():
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
total_count = 0
|
|
126
|
+
file_issues = {}
|
|
127
|
+
|
|
128
|
+
# 合併所有 emoji 模式
|
|
129
|
+
combined_pattern = '|'.join(ICON_EMOJI_PATTERNS)
|
|
130
|
+
|
|
131
|
+
for ext in ['*.js', '*.html']:
|
|
132
|
+
for file_path in self.src_path.rglob(ext):
|
|
133
|
+
try:
|
|
134
|
+
content = file_path.read_text(encoding='utf-8')
|
|
135
|
+
# 排除 console.log 中的 emoji(允許 log 用 emoji)
|
|
136
|
+
# 只檢查 HTML 樣板字串中的 emoji
|
|
137
|
+
template_content = self._extract_template_strings(content)
|
|
138
|
+
|
|
139
|
+
matches = re.findall(combined_pattern, template_content)
|
|
140
|
+
if matches:
|
|
141
|
+
rel_path = str(file_path.relative_to(self.project_path))
|
|
142
|
+
file_issues[rel_path] = len(matches)
|
|
143
|
+
total_count += len(matches)
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
self.result['checks']['emoji_icons'] = {
|
|
148
|
+
'count': total_count,
|
|
149
|
+
'files': file_issues
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if total_count > 0:
|
|
153
|
+
self.result['warnings'].append(
|
|
154
|
+
f'Emoji 圖示: {total_count} 個(建議改用 sl-icon)'
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def _extract_template_strings(self, content):
|
|
158
|
+
"""提取 HTML 樣板字串內容"""
|
|
159
|
+
# 匹配 `...` 樣板字串
|
|
160
|
+
template_matches = re.findall(r'`[^`]*`', content, re.DOTALL)
|
|
161
|
+
return '\n'.join(template_matches)
|
|
162
|
+
|
|
163
|
+
def check_duplicate_classes(self):
|
|
164
|
+
"""檢查重複 class 屬性"""
|
|
165
|
+
if not self.src_path.exists():
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
pattern = r'class="[^"]*"\s+class="'
|
|
169
|
+
total_count = 0
|
|
170
|
+
file_issues = {}
|
|
171
|
+
|
|
172
|
+
for file_path in self.src_path.rglob('*.js'):
|
|
173
|
+
try:
|
|
174
|
+
content = file_path.read_text(encoding='utf-8')
|
|
175
|
+
matches = re.findall(pattern, content)
|
|
176
|
+
if matches:
|
|
177
|
+
rel_path = str(file_path.relative_to(self.project_path))
|
|
178
|
+
file_issues[rel_path] = len(matches)
|
|
179
|
+
total_count += len(matches)
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
self.result['checks']['duplicate_classes'] = {
|
|
184
|
+
'count': total_count,
|
|
185
|
+
'files': file_issues
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if total_count > 0:
|
|
189
|
+
self.result['passed'] = False
|
|
190
|
+
self.result['errors'].append(f'重複 class: {total_count} 個')
|
|
191
|
+
|
|
192
|
+
def check_incomplete_html_tags(self):
|
|
193
|
+
"""檢查不完整的 HTML 標籤"""
|
|
194
|
+
if not self.src_path.exists():
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
tags_to_check = ['select', 'textarea', 'table', 'ul', 'ol']
|
|
198
|
+
issues = []
|
|
199
|
+
|
|
200
|
+
for file_path in self.src_path.rglob('*.js'):
|
|
201
|
+
try:
|
|
202
|
+
content = file_path.read_text(encoding='utf-8')
|
|
203
|
+
rel_path = str(file_path.relative_to(self.project_path))
|
|
204
|
+
|
|
205
|
+
for tag in tags_to_check:
|
|
206
|
+
open_count = len(re.findall(rf'<{tag}[^>]*>', content))
|
|
207
|
+
close_count = len(re.findall(rf'</{tag}>', content))
|
|
208
|
+
|
|
209
|
+
if open_count > close_count:
|
|
210
|
+
diff = open_count - close_count
|
|
211
|
+
issues.append({
|
|
212
|
+
'file': rel_path,
|
|
213
|
+
'tag': tag,
|
|
214
|
+
'missing': diff
|
|
215
|
+
})
|
|
216
|
+
except Exception:
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
self.result['checks']['incomplete_html'] = {
|
|
220
|
+
'count': len(issues),
|
|
221
|
+
'issues': issues
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if issues:
|
|
225
|
+
self.result['passed'] = False
|
|
226
|
+
for issue in issues[:5]:
|
|
227
|
+
self.result['errors'].append(
|
|
228
|
+
f"HTML 標籤不完整: {issue['file']} 缺少 {issue['missing']} 個 </{issue['tag']}>"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def check_empty_buttons(self):
|
|
232
|
+
"""檢查空白按鈕內容"""
|
|
233
|
+
if not self.src_path.exists():
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
pattern = r'<button[^>]*>\s*\n?\s*</button>'
|
|
237
|
+
issues = []
|
|
238
|
+
|
|
239
|
+
for file_path in self.src_path.rglob('*.js'):
|
|
240
|
+
try:
|
|
241
|
+
content = file_path.read_text(encoding='utf-8')
|
|
242
|
+
matches = re.findall(pattern, content)
|
|
243
|
+
if matches:
|
|
244
|
+
rel_path = str(file_path.relative_to(self.project_path))
|
|
245
|
+
issues.append({
|
|
246
|
+
'file': rel_path,
|
|
247
|
+
'count': len(matches)
|
|
248
|
+
})
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
self.result['checks']['empty_buttons'] = {
|
|
253
|
+
'count': sum(i['count'] for i in issues),
|
|
254
|
+
'files': issues
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if issues:
|
|
258
|
+
total = sum(i['count'] for i in issues)
|
|
259
|
+
self.result['warnings'].append(f'空白按鈕: {total} 個')
|
|
260
|
+
|
|
261
|
+
def check_empty_event_handlers(self):
|
|
262
|
+
"""檢查空白事件處理器"""
|
|
263
|
+
if not self.src_path.exists():
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
pattern = r"addEventListener\s*\(\s*['\"]['\"]"
|
|
267
|
+
issues = []
|
|
268
|
+
|
|
269
|
+
for file_path in self.src_path.rglob('*.js'):
|
|
270
|
+
try:
|
|
271
|
+
content = file_path.read_text(encoding='utf-8')
|
|
272
|
+
matches = re.findall(pattern, content)
|
|
273
|
+
if matches:
|
|
274
|
+
rel_path = str(file_path.relative_to(self.project_path))
|
|
275
|
+
issues.append({
|
|
276
|
+
'file': rel_path,
|
|
277
|
+
'count': len(matches)
|
|
278
|
+
})
|
|
279
|
+
except Exception:
|
|
280
|
+
pass
|
|
281
|
+
|
|
282
|
+
self.result['checks']['empty_event_handlers'] = {
|
|
283
|
+
'count': sum(i['count'] for i in issues) if issues else 0,
|
|
284
|
+
'files': issues
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if issues:
|
|
288
|
+
self.result['passed'] = False
|
|
289
|
+
for issue in issues:
|
|
290
|
+
self.result['errors'].append(
|
|
291
|
+
f"空白事件處理器: {issue['file']} 有 {issue['count']} 個"
|
|
292
|
+
)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""
|
|
2
|
+
效能驗證器
|
|
3
|
+
|
|
4
|
+
檢查項目:
|
|
5
|
+
1. Bundle 大小限制
|
|
6
|
+
2. 圖片優化
|
|
7
|
+
3. 未使用的依賴
|
|
8
|
+
4. CSS 編譯狀態
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import subprocess
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PerformanceValidator:
|
|
17
|
+
"""效能驗證器"""
|
|
18
|
+
|
|
19
|
+
name = 'performance'
|
|
20
|
+
|
|
21
|
+
# 限制設定
|
|
22
|
+
LIMITS = {
|
|
23
|
+
'max_css_kb': 200, # CSS 最大 200KB
|
|
24
|
+
'min_css_kb': 30, # CSS 最小 30KB (確保 Tailwind 編譯)
|
|
25
|
+
'max_js_kb': 500, # JS 最大 500KB
|
|
26
|
+
'max_image_kb': 500, # 圖片最大 500KB
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
def __init__(self, project_path):
|
|
30
|
+
self.project_path = Path(project_path)
|
|
31
|
+
self.project_name = self.project_path.name
|
|
32
|
+
self.dist_path = self.project_path / 'dist'
|
|
33
|
+
self.result = {
|
|
34
|
+
'name': self.name,
|
|
35
|
+
'passed': True,
|
|
36
|
+
'errors': [],
|
|
37
|
+
'warnings': [],
|
|
38
|
+
'checks': {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
def run(self):
|
|
42
|
+
"""執行所有驗證"""
|
|
43
|
+
if not self.project_path.exists():
|
|
44
|
+
self.result['passed'] = False
|
|
45
|
+
self.result['errors'].append(f'專案路徑不存在: {self.project_path}')
|
|
46
|
+
return self.result
|
|
47
|
+
|
|
48
|
+
self.check_bundle_size()
|
|
49
|
+
self.check_image_sizes()
|
|
50
|
+
self.check_unused_dependencies()
|
|
51
|
+
|
|
52
|
+
return self.result
|
|
53
|
+
|
|
54
|
+
def check_bundle_size(self):
|
|
55
|
+
"""檢查 Bundle 大小"""
|
|
56
|
+
if not self.dist_path.exists():
|
|
57
|
+
self.result['warnings'].append('dist 目錄不存在,跳過 bundle 檢查')
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
css_files = list(self.dist_path.rglob('*.css'))
|
|
61
|
+
js_files = list(self.dist_path.rglob('*.js'))
|
|
62
|
+
|
|
63
|
+
# CSS 檢查
|
|
64
|
+
total_css_size = sum(f.stat().st_size for f in css_files)
|
|
65
|
+
css_kb = total_css_size / 1024
|
|
66
|
+
|
|
67
|
+
self.result['checks']['css_bundle'] = {
|
|
68
|
+
'size_kb': round(css_kb, 1),
|
|
69
|
+
'files': len(css_files)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if css_kb < self.LIMITS['min_css_kb']:
|
|
73
|
+
self.result['warnings'].append(
|
|
74
|
+
f'CSS 過小 ({css_kb:.1f} KB),可能 Tailwind 未正確編譯'
|
|
75
|
+
)
|
|
76
|
+
elif css_kb > self.LIMITS['max_css_kb']:
|
|
77
|
+
self.result['warnings'].append(
|
|
78
|
+
f'CSS 過大 ({css_kb:.1f} KB),考慮優化'
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# JS 檢查
|
|
82
|
+
total_js_size = sum(f.stat().st_size for f in js_files)
|
|
83
|
+
js_kb = total_js_size / 1024
|
|
84
|
+
|
|
85
|
+
self.result['checks']['js_bundle'] = {
|
|
86
|
+
'size_kb': round(js_kb, 1),
|
|
87
|
+
'files': len(js_files)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if js_kb > self.LIMITS['max_js_kb']:
|
|
91
|
+
self.result['warnings'].append(
|
|
92
|
+
f'JS 過大 ({js_kb:.1f} KB),考慮 code splitting'
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def check_image_sizes(self):
|
|
96
|
+
"""檢查圖片大小"""
|
|
97
|
+
public_path = self.project_path / 'public'
|
|
98
|
+
src_path = self.project_path / 'src'
|
|
99
|
+
|
|
100
|
+
large_images = []
|
|
101
|
+
extensions = ['*.png', '*.jpg', '*.jpeg', '*.gif', '*.webp', '*.svg']
|
|
102
|
+
|
|
103
|
+
for path in [public_path, src_path]:
|
|
104
|
+
if not path.exists():
|
|
105
|
+
continue
|
|
106
|
+
for ext in extensions:
|
|
107
|
+
for img in path.rglob(ext):
|
|
108
|
+
size_kb = img.stat().st_size / 1024
|
|
109
|
+
if size_kb > self.LIMITS['max_image_kb']:
|
|
110
|
+
large_images.append({
|
|
111
|
+
'file': str(img.relative_to(self.project_path)),
|
|
112
|
+
'size_kb': round(size_kb, 1)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
self.result['checks']['images'] = {
|
|
116
|
+
'large_count': len(large_images),
|
|
117
|
+
'files': large_images
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if large_images:
|
|
121
|
+
for img in large_images[:3]:
|
|
122
|
+
self.result['warnings'].append(
|
|
123
|
+
f"圖片過大: {img['file']} ({img['size_kb']} KB)"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def check_unused_dependencies(self):
|
|
127
|
+
"""檢查未使用的依賴"""
|
|
128
|
+
pkg_path = self.project_path / 'package.json'
|
|
129
|
+
if not pkg_path.exists():
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
pkg = json.loads(pkg_path.read_text(encoding='utf-8'))
|
|
134
|
+
deps = list(pkg.get('dependencies', {}).keys())
|
|
135
|
+
|
|
136
|
+
# 簡單檢查:搜尋 src 中是否有 import
|
|
137
|
+
src_path = self.project_path / 'src'
|
|
138
|
+
if not src_path.exists():
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
all_content = ''
|
|
142
|
+
for f in src_path.rglob('*.js'):
|
|
143
|
+
try:
|
|
144
|
+
all_content += f.read_text(encoding='utf-8')
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
unused = []
|
|
149
|
+
for dep in deps:
|
|
150
|
+
# 跳過一些特殊依賴
|
|
151
|
+
if dep.startswith('@types/') or dep in ['vite', 'tailwindcss', 'daisyui']:
|
|
152
|
+
continue
|
|
153
|
+
if dep not in all_content and f"'{dep}'" not in all_content:
|
|
154
|
+
unused.append(dep)
|
|
155
|
+
|
|
156
|
+
self.result['checks']['unused_deps'] = {
|
|
157
|
+
'count': len(unused),
|
|
158
|
+
'packages': unused[:10] # 只顯示前 10 個
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if unused:
|
|
162
|
+
self.result['warnings'].append(
|
|
163
|
+
f'可能未使用的依賴: {", ".join(unused[:5])}'
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""
|
|
2
|
+
安全性驗證器
|
|
3
|
+
|
|
4
|
+
檢查項目:
|
|
5
|
+
1. API Key / Token 外洩
|
|
6
|
+
2. 密碼硬編碼
|
|
7
|
+
3. .env 檔案提交
|
|
8
|
+
4. 敏感資料暴露
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SecurityValidator:
|
|
16
|
+
"""安全性驗證器"""
|
|
17
|
+
|
|
18
|
+
name = 'security'
|
|
19
|
+
|
|
20
|
+
# 敏感資料正則表達式
|
|
21
|
+
SENSITIVE_PATTERNS = [
|
|
22
|
+
(r'(?i)(api[_-]?key|apikey)\s*[=:]\s*["\']?[a-zA-Z0-9_-]{20,}', 'API Key'),
|
|
23
|
+
(r'(?i)(secret|token)\s*[=:]\s*["\']?[a-zA-Z0-9_-]{20,}', 'Secret/Token'),
|
|
24
|
+
(r'(?i)password\s*[=:]\s*["\'][^"\']+["\']', '密碼'),
|
|
25
|
+
(r'sk-[a-zA-Z0-9]{48}', 'OpenAI API Key'),
|
|
26
|
+
(r'sk_live_[a-zA-Z0-9]{24,}', 'Stripe Live Key'),
|
|
27
|
+
(r'ghp_[a-zA-Z0-9]{36}', 'GitHub Token'),
|
|
28
|
+
# Clerk 只檢查 secret key (sk_),publishable key (pk_) 是公開的
|
|
29
|
+
(r'CLERK_SECRET_KEY\s*=\s*["\']?sk_[a-zA-Z0-9_-]{20,}', 'Clerk Secret Key'),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
# 敏感檔案
|
|
33
|
+
SENSITIVE_FILES = [
|
|
34
|
+
'.env',
|
|
35
|
+
'.env.local',
|
|
36
|
+
'.env.production',
|
|
37
|
+
'credentials.json',
|
|
38
|
+
'service-account.json',
|
|
39
|
+
'private.key',
|
|
40
|
+
'*.pem',
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
# 忽略目錄
|
|
44
|
+
IGNORE_DIRS = [
|
|
45
|
+
'node_modules', '.git', 'dist', 'build', '.next', '__pycache__',
|
|
46
|
+
'.angular', 'venv', '.venv', '.cache', 'coverage'
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
def __init__(self, project_path):
|
|
50
|
+
self.project_path = Path(project_path)
|
|
51
|
+
self.project_name = self.project_path.name
|
|
52
|
+
self.result = {
|
|
53
|
+
'name': self.name,
|
|
54
|
+
'passed': True,
|
|
55
|
+
'errors': [],
|
|
56
|
+
'warnings': [],
|
|
57
|
+
'checks': {}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
def run(self):
|
|
61
|
+
"""執行所有驗證"""
|
|
62
|
+
if not self.project_path.exists():
|
|
63
|
+
self.result['passed'] = False
|
|
64
|
+
self.result['errors'].append(f'專案路徑不存在: {self.project_path}')
|
|
65
|
+
return self.result
|
|
66
|
+
|
|
67
|
+
self.check_sensitive_files()
|
|
68
|
+
self.check_hardcoded_secrets()
|
|
69
|
+
self.check_gitignore()
|
|
70
|
+
|
|
71
|
+
return self.result
|
|
72
|
+
|
|
73
|
+
def check_sensitive_files(self):
|
|
74
|
+
"""檢查敏感檔案是否被追蹤"""
|
|
75
|
+
issues = []
|
|
76
|
+
|
|
77
|
+
# 找出所有巢狀的 git repo (要跳過)
|
|
78
|
+
nested_repos = []
|
|
79
|
+
for git_dir in self.project_path.rglob('.git'):
|
|
80
|
+
if git_dir.parent != self.project_path:
|
|
81
|
+
nested_repos.append(str(git_dir.parent))
|
|
82
|
+
|
|
83
|
+
for pattern in self.SENSITIVE_FILES:
|
|
84
|
+
if '*' in pattern:
|
|
85
|
+
files = list(self.project_path.rglob(pattern))
|
|
86
|
+
else:
|
|
87
|
+
files = [self.project_path / pattern]
|
|
88
|
+
|
|
89
|
+
for f in files:
|
|
90
|
+
if f.exists() and f.is_file():
|
|
91
|
+
# 跳過巢狀 git repo
|
|
92
|
+
if any(str(f).startswith(repo) for repo in nested_repos):
|
|
93
|
+
continue
|
|
94
|
+
# 跳過忽略目錄
|
|
95
|
+
if any(ignore in str(f) for ignore in self.IGNORE_DIRS):
|
|
96
|
+
continue
|
|
97
|
+
# 檢查是否在 .gitignore 中
|
|
98
|
+
if not self._is_gitignored(f):
|
|
99
|
+
issues.append(str(f.relative_to(self.project_path)))
|
|
100
|
+
|
|
101
|
+
self.result['checks']['sensitive_files'] = {
|
|
102
|
+
'count': len(issues),
|
|
103
|
+
'files': issues
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if issues:
|
|
107
|
+
self.result['passed'] = False
|
|
108
|
+
for f in issues:
|
|
109
|
+
self.result['errors'].append(f'敏感檔案未忽略: {f}')
|
|
110
|
+
|
|
111
|
+
def check_hardcoded_secrets(self):
|
|
112
|
+
"""檢查硬編碼的敏感資料"""
|
|
113
|
+
issues = []
|
|
114
|
+
|
|
115
|
+
for file_path in self._get_source_files():
|
|
116
|
+
try:
|
|
117
|
+
content = file_path.read_text(encoding='utf-8')
|
|
118
|
+
rel_path = str(file_path.relative_to(self.project_path))
|
|
119
|
+
|
|
120
|
+
for pattern, desc in self.SENSITIVE_PATTERNS:
|
|
121
|
+
matches = re.findall(pattern, content)
|
|
122
|
+
if matches:
|
|
123
|
+
issues.append({
|
|
124
|
+
'file': rel_path,
|
|
125
|
+
'type': desc,
|
|
126
|
+
'count': len(matches)
|
|
127
|
+
})
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
self.result['checks']['hardcoded_secrets'] = {
|
|
132
|
+
'count': len(issues),
|
|
133
|
+
'issues': issues
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if issues:
|
|
137
|
+
self.result['passed'] = False
|
|
138
|
+
for issue in issues:
|
|
139
|
+
self.result['errors'].append(
|
|
140
|
+
f"發現 {issue['type']} 在 {issue['file']}"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def check_gitignore(self):
|
|
144
|
+
"""檢查 .gitignore 設定"""
|
|
145
|
+
gitignore = self.project_path / '.gitignore'
|
|
146
|
+
required_patterns = ['.env', 'node_modules', '*.log']
|
|
147
|
+
missing = []
|
|
148
|
+
|
|
149
|
+
if gitignore.exists():
|
|
150
|
+
content = gitignore.read_text(encoding='utf-8')
|
|
151
|
+
for pattern in required_patterns:
|
|
152
|
+
if pattern not in content:
|
|
153
|
+
missing.append(pattern)
|
|
154
|
+
else:
|
|
155
|
+
missing = required_patterns
|
|
156
|
+
|
|
157
|
+
self.result['checks']['gitignore'] = {
|
|
158
|
+
'exists': gitignore.exists(),
|
|
159
|
+
'missing_patterns': missing
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if missing:
|
|
163
|
+
for pattern in missing:
|
|
164
|
+
self.result['warnings'].append(f'.gitignore 缺少: {pattern}')
|
|
165
|
+
|
|
166
|
+
def _get_source_files(self):
|
|
167
|
+
"""取得所有原始碼檔案"""
|
|
168
|
+
extensions = ['*.js', '*.ts', '*.jsx', '*.tsx', '*.py', '*.json', '*.yaml', '*.yml']
|
|
169
|
+
files = []
|
|
170
|
+
|
|
171
|
+
# 找出所有巢狀的 git repo (要跳過)
|
|
172
|
+
nested_repos = []
|
|
173
|
+
for git_dir in self.project_path.rglob('.git'):
|
|
174
|
+
if git_dir.parent != self.project_path:
|
|
175
|
+
nested_repos.append(str(git_dir.parent))
|
|
176
|
+
|
|
177
|
+
for ext in extensions:
|
|
178
|
+
for f in self.project_path.rglob(ext):
|
|
179
|
+
# 跳過忽略目錄
|
|
180
|
+
if any(ignore in str(f) for ignore in self.IGNORE_DIRS):
|
|
181
|
+
continue
|
|
182
|
+
# 跳過巢狀 git repo
|
|
183
|
+
if any(str(f).startswith(repo) for repo in nested_repos):
|
|
184
|
+
continue
|
|
185
|
+
files.append(f)
|
|
186
|
+
|
|
187
|
+
return files
|
|
188
|
+
|
|
189
|
+
def _is_gitignored(self, file_path):
|
|
190
|
+
"""檢查檔案是否在 .gitignore 中"""
|
|
191
|
+
gitignore = self.project_path / '.gitignore'
|
|
192
|
+
if not gitignore.exists():
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
content = gitignore.read_text(encoding='utf-8')
|
|
196
|
+
rel_path = str(file_path.relative_to(self.project_path))
|
|
197
|
+
|
|
198
|
+
for line in content.splitlines():
|
|
199
|
+
line = line.strip()
|
|
200
|
+
if not line or line.startswith('#'):
|
|
201
|
+
continue
|
|
202
|
+
if line in rel_path or rel_path.startswith(line):
|
|
203
|
+
return True
|
|
204
|
+
|
|
205
|
+
return False
|