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,245 @@
1
+ """
2
+ Node.js 後端驗證器
3
+
4
+ 檢查項目:
5
+ 1. API 回應格式
6
+ 2. 錯誤處理
7
+ 3. 認證中介層
8
+ 4. 稽核日誌
9
+ 5. Vercel Serverless 設定
10
+ """
11
+
12
+ import re
13
+ import json
14
+ from pathlib import Path
15
+
16
+
17
+ class NodejsValidator:
18
+ """Node.js 後端驗證器"""
19
+
20
+ name = 'nodejs'
21
+
22
+ # 忽略目錄
23
+ IGNORE_DIRS = [
24
+ 'node_modules', '.git', 'dist', 'build', '.next', '.vercel'
25
+ ]
26
+
27
+ def __init__(self, project_path):
28
+ self.project_path = Path(project_path)
29
+ self.project_name = self.project_path.name
30
+ self.api_path = self.project_path / 'api'
31
+ self.result = {
32
+ 'name': self.name,
33
+ 'passed': True,
34
+ 'errors': [],
35
+ 'warnings': [],
36
+ 'checks': {}
37
+ }
38
+
39
+ def run(self):
40
+ """執行所有驗證"""
41
+ if not self.project_path.exists():
42
+ self.result['passed'] = False
43
+ self.result['errors'].append(f'專案路徑不存在: {self.project_path}')
44
+ return self.result
45
+
46
+ self.check_api_structure()
47
+ self.check_response_format()
48
+ self.check_error_handling()
49
+ self.check_auth_middleware()
50
+ self.check_vercel_config()
51
+
52
+ return self.result
53
+
54
+ def check_api_structure(self):
55
+ """檢查 API 目錄結構"""
56
+ has_api_dir = self.api_path.exists()
57
+
58
+ if has_api_dir:
59
+ api_files = list(self.api_path.rglob('*.js'))
60
+ self.result['checks']['api_structure'] = {
61
+ 'has_api_dir': True,
62
+ 'api_count': len(api_files)
63
+ }
64
+ else:
65
+ self.result['checks']['api_structure'] = {
66
+ 'has_api_dir': False,
67
+ 'skipped': '無 api 目錄'
68
+ }
69
+
70
+ def check_response_format(self):
71
+ """檢查 API 回應格式是否符合規範"""
72
+ if not self.api_path.exists():
73
+ return
74
+
75
+ issues = []
76
+ good_patterns = 0
77
+
78
+ # 標準回應格式: { success: boolean, data?: T, error?: { code, message } }
79
+ success_pattern = r'success:\s*(true|false)'
80
+ json_response = r'res\.json\s*\('
81
+
82
+ for file_path in self.api_path.rglob('*.js'):
83
+ if self._should_skip(file_path):
84
+ continue
85
+ try:
86
+ content = file_path.read_text(encoding='utf-8')
87
+ rel_path = str(file_path.relative_to(self.project_path))
88
+
89
+ # 檢查是否有 res.json()
90
+ json_calls = len(re.findall(json_response, content))
91
+ success_calls = len(re.findall(success_pattern, content))
92
+
93
+ if json_calls > 0:
94
+ if success_calls < json_calls:
95
+ issues.append({
96
+ 'file': rel_path,
97
+ 'issue': f'回應格式不一致 ({json_calls} 個回應,{success_calls} 個使用 success)'
98
+ })
99
+ else:
100
+ good_patterns += json_calls
101
+
102
+ except Exception:
103
+ pass
104
+
105
+ self.result['checks']['response_format'] = {
106
+ 'issues_count': len(issues),
107
+ 'good_patterns': good_patterns,
108
+ 'issues': issues
109
+ }
110
+
111
+ if issues:
112
+ for issue in issues[:3]:
113
+ self.result['warnings'].append(f"{issue['file']}: {issue['issue']}")
114
+
115
+ def check_error_handling(self):
116
+ """檢查錯誤處理"""
117
+ if not self.api_path.exists():
118
+ return
119
+
120
+ issues = []
121
+
122
+ # 檢查是否有 try-catch
123
+ try_catch_pattern = r'try\s*\{'
124
+ catch_pattern = r'catch\s*\([^)]*\)\s*\{'
125
+
126
+ for file_path in self.api_path.rglob('*.js'):
127
+ if self._should_skip(file_path):
128
+ continue
129
+ try:
130
+ content = file_path.read_text(encoding='utf-8')
131
+ rel_path = str(file_path.relative_to(self.project_path))
132
+
133
+ try_count = len(re.findall(try_catch_pattern, content))
134
+ catch_count = len(re.findall(catch_pattern, content))
135
+
136
+ # 如果有 async function 但沒有 try-catch
137
+ has_async = 'async ' in content
138
+ if has_async and try_count == 0:
139
+ issues.append({
140
+ 'file': rel_path,
141
+ 'issue': 'async 函數缺少 try-catch 錯誤處理'
142
+ })
143
+
144
+ # 檢查 catch 是否有正確處理錯誤
145
+ if catch_count > 0:
146
+ # 檢查是否有 console.error 或記錄錯誤
147
+ has_error_log = 'console.error' in content or 'logSystemAudit' in content
148
+ if not has_error_log:
149
+ issues.append({
150
+ 'file': rel_path,
151
+ 'issue': '錯誤處理缺少日誌記錄'
152
+ })
153
+
154
+ except Exception:
155
+ pass
156
+
157
+ self.result['checks']['error_handling'] = {
158
+ 'count': len(issues),
159
+ 'issues': issues
160
+ }
161
+
162
+ for issue in issues[:5]:
163
+ self.result['warnings'].append(f"{issue['file']}: {issue['issue']}")
164
+
165
+ def check_auth_middleware(self):
166
+ """檢查認證中介層"""
167
+ if not self.api_path.exists():
168
+ return
169
+
170
+ issues = []
171
+ protected_count = 0
172
+
173
+ # 檢查是否使用 withApiAuth
174
+ auth_pattern = r'withApiAuth'
175
+
176
+ for file_path in self.api_path.rglob('*.js'):
177
+ if self._should_skip(file_path):
178
+ continue
179
+ try:
180
+ content = file_path.read_text(encoding='utf-8')
181
+ rel_path = str(file_path.relative_to(self.project_path))
182
+
183
+ has_auth = auth_pattern in content
184
+
185
+ # 判斷是否為需要認證的 API
186
+ is_public = any(pub in rel_path for pub in ['health', 'public', 'webhook'])
187
+
188
+ if has_auth:
189
+ protected_count += 1
190
+ elif not is_public:
191
+ # 非公開 API 但沒有認證
192
+ issues.append({
193
+ 'file': rel_path,
194
+ 'issue': '可能缺少認證保護 (withApiAuth)'
195
+ })
196
+
197
+ except Exception:
198
+ pass
199
+
200
+ self.result['checks']['auth_middleware'] = {
201
+ 'protected_count': protected_count,
202
+ 'issues_count': len(issues),
203
+ 'issues': issues
204
+ }
205
+
206
+ if issues:
207
+ self.result['warnings'].append(f'{len(issues)} 個 API 可能缺少認證保護')
208
+
209
+ def check_vercel_config(self):
210
+ """檢查 Vercel 設定"""
211
+ vercel_json = self.project_path / 'vercel.json'
212
+
213
+ if not vercel_json.exists():
214
+ self.result['checks']['vercel_config'] = {'skipped': '無 vercel.json'}
215
+ return
216
+
217
+ try:
218
+ config = json.loads(vercel_json.read_text(encoding='utf-8'))
219
+
220
+ has_functions = 'functions' in config
221
+ has_routes = 'routes' in config or 'rewrites' in config
222
+
223
+ self.result['checks']['vercel_config'] = {
224
+ 'has_functions': has_functions,
225
+ 'has_routes': has_routes,
226
+ 'config': config
227
+ }
228
+
229
+ # 檢查函數設定
230
+ if has_functions:
231
+ for func_path, func_config in config.get('functions', {}).items():
232
+ if func_config.get('maxDuration', 10) > 60:
233
+ self.result['warnings'].append(
234
+ f"函數 {func_path} 設定 maxDuration > 60s (可能影響成本)"
235
+ )
236
+
237
+ except Exception as e:
238
+ self.result['checks']['vercel_config'] = {
239
+ 'error': str(e)
240
+ }
241
+ self.result['warnings'].append('vercel.json 格式錯誤')
242
+
243
+ def _should_skip(self, file_path):
244
+ """檢查是否應該跳過該檔案"""
245
+ return any(ignore in str(file_path) for ignore in self.IGNORE_DIRS)
@@ -0,0 +1,439 @@
1
+ """
2
+ Python 後端驗證器 v2.0
3
+
4
+ 支援:
5
+ - FastAPI 專案結構驗證
6
+ - Ruff 整合 (lint + format)
7
+ - 程式碼風格檢查
8
+
9
+ 檢查項目:
10
+ 1. FastAPI 結構 (main.py, routers/, etc.)
11
+ 2. Ruff lint/format 檢查
12
+ 3. 依賴管理 (requirements.txt / pyproject.toml)
13
+ 4. 模型權重檔案
14
+ 5. 虛擬環境設定
15
+ """
16
+
17
+ import re
18
+ import subprocess
19
+ from pathlib import Path
20
+
21
+
22
+ class PythonValidator:
23
+ """Python 後端驗證器 (支援 FastAPI + Ruff)"""
24
+
25
+ name = 'python'
26
+
27
+ IGNORE_DIRS = [
28
+ '__pycache__', '.git', 'venv', '.venv', 'env', '.env',
29
+ 'dist', 'build', '.eggs', '*.egg-info', '.pytest_cache'
30
+ ]
31
+
32
+ MODEL_EXTENSIONS = ['.pt', '.pth', '.onnx', '.h5', '.pkl', '.joblib', '.safetensors']
33
+
34
+ AI_PACKAGES = {
35
+ 'torch': 'PyTorch',
36
+ 'tensorflow': 'TensorFlow',
37
+ 'ultralytics': 'YOLO',
38
+ 'transformers': 'Hugging Face Transformers',
39
+ 'opencv-python': 'OpenCV',
40
+ 'scikit-learn': 'Scikit-learn',
41
+ }
42
+
43
+ def __init__(self, project_path):
44
+ self.project_path = Path(project_path)
45
+ self.project_name = self.project_path.name
46
+ self.result = {
47
+ 'name': self.name,
48
+ 'passed': True,
49
+ 'errors': [],
50
+ 'warnings': [],
51
+ 'checks': {}
52
+ }
53
+ # 偵測框架
54
+ self.framework = self._detect_framework()
55
+
56
+ def _detect_framework(self) -> str | None:
57
+ """偵測 Python 框架"""
58
+ requirements = self.project_path / 'requirements.txt'
59
+ pyproject = self.project_path / 'pyproject.toml'
60
+
61
+ content = ''
62
+ if requirements.exists():
63
+ content = requirements.read_text(encoding='utf-8').lower()
64
+ elif pyproject.exists():
65
+ content = pyproject.read_text(encoding='utf-8').lower()
66
+
67
+ if 'fastapi' in content:
68
+ return 'fastapi'
69
+ if 'flask' in content:
70
+ return 'flask'
71
+ if 'django' in content:
72
+ return 'django'
73
+ if 'streamlit' in content:
74
+ return 'streamlit'
75
+ return None
76
+
77
+ def run(self):
78
+ """執行所有驗證"""
79
+ if not self.project_path.exists():
80
+ self.result['passed'] = False
81
+ self.result['errors'].append(f'專案路徑不存在: {self.project_path}')
82
+ return self.result
83
+
84
+ # 框架特定檢查
85
+ if self.framework == 'fastapi':
86
+ self.check_fastapi_structure()
87
+
88
+ # 通用檢查
89
+ self.check_ruff()
90
+ self.check_dependencies()
91
+ self.check_model_files()
92
+ self.check_virtual_env()
93
+ self.check_migrations()
94
+
95
+ return self.result
96
+
97
+ def check_fastapi_structure(self):
98
+ """檢查 FastAPI 專案結構"""
99
+ issues = []
100
+
101
+ # 檢查入口點
102
+ main_py = self.project_path / 'main.py'
103
+ app_main_py = self.project_path / 'app' / 'main.py'
104
+
105
+ has_main = main_py.exists() or app_main_py.exists()
106
+ main_file = main_py if main_py.exists() else app_main_py
107
+
108
+ if not has_main:
109
+ issues.append('缺少 main.py 入口點')
110
+
111
+ # 檢查 main.py 內容
112
+ if has_main:
113
+ try:
114
+ content = main_file.read_text(encoding='utf-8')
115
+
116
+ # 檢查 FastAPI 實例
117
+ if 'FastAPI()' not in content and 'FastAPI(' not in content:
118
+ issues.append('main.py 中未找到 FastAPI 實例')
119
+
120
+ # 檢查 CORS 設定
121
+ if 'CORSMiddleware' not in content:
122
+ issues.append('建議加入 CORS 設定')
123
+
124
+ # 檢查全域錯誤處理
125
+ if '@app.exception_handler' not in content and 'exception_handler' not in content:
126
+ issues.append('建議加入全域錯誤處理')
127
+
128
+ except Exception:
129
+ pass
130
+
131
+ # 檢查 requirements.txt
132
+ requirements = self.project_path / 'requirements.txt'
133
+ if not requirements.exists():
134
+ issues.append('缺少 requirements.txt')
135
+
136
+ # 檢查目錄結構
137
+ app_dir = self.project_path / 'app'
138
+ routers_dir = app_dir / 'routers' if app_dir.exists() else self.project_path / 'routers'
139
+
140
+ self.result['checks']['fastapi_structure'] = {
141
+ 'has_main': has_main,
142
+ 'has_requirements': requirements.exists(),
143
+ 'has_app_dir': app_dir.exists(),
144
+ 'has_routers': routers_dir.exists(),
145
+ 'issues': issues
146
+ }
147
+
148
+ for issue in issues:
149
+ if '缺少' in issue:
150
+ self.result['errors'].append(f'FastAPI: {issue}')
151
+ else:
152
+ self.result['warnings'].append(f'FastAPI: {issue}')
153
+
154
+ def check_ruff(self):
155
+ """執行 Ruff lint 和 format 檢查"""
156
+ # 檢查 ruff 是否安裝
157
+ try:
158
+ result = subprocess.run(
159
+ ['ruff', '--version'],
160
+ capture_output=True,
161
+ text=True
162
+ )
163
+ if result.returncode != 0:
164
+ self.result['checks']['ruff'] = {'skipped': 'Ruff 未安裝'}
165
+ return
166
+ except FileNotFoundError:
167
+ self.result['checks']['ruff'] = {'skipped': 'Ruff 未安裝'}
168
+ return
169
+
170
+ lint_issues = []
171
+ format_issues = []
172
+
173
+ # 執行 ruff check
174
+ try:
175
+ result = subprocess.run(
176
+ ['ruff', 'check', str(self.project_path), '--output-format=json'],
177
+ capture_output=True,
178
+ text=True,
179
+ timeout=60
180
+ )
181
+
182
+ if result.stdout:
183
+ import json
184
+ try:
185
+ issues = json.loads(result.stdout)
186
+ for issue in issues[:10]: # 只取前 10 個
187
+ lint_issues.append({
188
+ 'file': issue.get('filename', ''),
189
+ 'line': issue.get('location', {}).get('row', 0),
190
+ 'code': issue.get('code', ''),
191
+ 'message': issue.get('message', '')
192
+ })
193
+ except json.JSONDecodeError:
194
+ pass
195
+
196
+ except subprocess.TimeoutExpired:
197
+ self.result['checks']['ruff'] = {'error': 'Ruff check 執行逾時'}
198
+ return
199
+ except Exception as e:
200
+ self.result['checks']['ruff'] = {'error': str(e)}
201
+ return
202
+
203
+ # 執行 ruff format --check
204
+ try:
205
+ result = subprocess.run(
206
+ ['ruff', 'format', '--check', str(self.project_path)],
207
+ capture_output=True,
208
+ text=True,
209
+ timeout=60
210
+ )
211
+
212
+ if result.returncode != 0:
213
+ # 解析需要格式化的檔案
214
+ for line in result.stdout.splitlines():
215
+ if line.strip().startswith('Would reformat'):
216
+ file_name = line.replace('Would reformat', '').strip()
217
+ format_issues.append(file_name)
218
+ elif line.strip() and not line.startswith('Oh no!') and not line.startswith('1 file'):
219
+ # 其他格式的輸出
220
+ format_issues.append(line.strip())
221
+
222
+ except subprocess.TimeoutExpired:
223
+ pass
224
+ except Exception:
225
+ pass
226
+
227
+ self.result['checks']['ruff'] = {
228
+ 'lint_issues': len(lint_issues),
229
+ 'format_issues': len(format_issues),
230
+ 'lint_details': lint_issues[:5],
231
+ 'format_details': format_issues[:5]
232
+ }
233
+
234
+ if lint_issues:
235
+ self.result['warnings'].append(f'Ruff lint: {len(lint_issues)} 個問題')
236
+ for issue in lint_issues[:3]:
237
+ self.result['warnings'].append(
238
+ f" {issue['file']}:{issue['line']} [{issue['code']}] {issue['message'][:50]}"
239
+ )
240
+
241
+ if format_issues:
242
+ self.result['warnings'].append(f'Ruff format: {len(format_issues)} 個檔案需要格式化')
243
+
244
+ def check_dependencies(self):
245
+ """檢查依賴管理"""
246
+ requirements = self.project_path / 'requirements.txt'
247
+ pyproject = self.project_path / 'pyproject.toml'
248
+ setup_py = self.project_path / 'setup.py'
249
+
250
+ has_deps = requirements.exists() or pyproject.exists() or setup_py.exists()
251
+
252
+ self.result['checks']['dependencies'] = {
253
+ 'has_requirements': requirements.exists(),
254
+ 'has_pyproject': pyproject.exists(),
255
+ 'has_setup_py': setup_py.exists()
256
+ }
257
+
258
+ if not has_deps:
259
+ self.result['warnings'].append('缺少依賴管理檔案 (requirements.txt 或 pyproject.toml)')
260
+
261
+ # 檢查 requirements.txt 版本鎖定
262
+ if requirements.exists():
263
+ content = requirements.read_text(encoding='utf-8')
264
+ lines = [l.strip() for l in content.splitlines() if l.strip() and not l.startswith('#')]
265
+ unpinned = []
266
+
267
+ for line in lines:
268
+ if line.startswith('-') or line.startswith('git+') or '://' in line:
269
+ continue
270
+ if '==' not in line and '>=' not in line and '<=' not in line:
271
+ pkg_name = re.split(r'[<>=\[]', line)[0]
272
+ unpinned.append(pkg_name)
273
+
274
+ if unpinned:
275
+ self.result['checks']['dependencies']['unpinned'] = unpinned[:5]
276
+ self.result['warnings'].append(
277
+ f'部分依賴未鎖定版本: {", ".join(unpinned[:3])}...'
278
+ )
279
+
280
+ def check_model_files(self):
281
+ """檢查模型權重檔案"""
282
+ issues = []
283
+
284
+ for ext in self.MODEL_EXTENSIONS:
285
+ for f in self.project_path.rglob(f'*{ext}'):
286
+ if self._should_skip(f):
287
+ continue
288
+ if not self._is_gitignored(f):
289
+ issues.append({
290
+ 'file': str(f.relative_to(self.project_path)),
291
+ 'type': ext
292
+ })
293
+
294
+ self.result['checks']['model_files'] = {
295
+ 'count': len(issues),
296
+ 'files': issues
297
+ }
298
+
299
+ if issues:
300
+ self.result['warnings'].append(
301
+ f'模型權重檔案未忽略: {len(issues)} 個 (建議加入 .gitignore)'
302
+ )
303
+
304
+ def check_virtual_env(self):
305
+ """檢查虛擬環境設定"""
306
+ venv_dirs = ['venv', '.venv', 'env', '.env']
307
+ has_venv = any((self.project_path / d).exists() for d in venv_dirs)
308
+
309
+ gitignore = self.project_path / '.gitignore'
310
+ venv_ignored = False
311
+
312
+ if gitignore.exists():
313
+ content = gitignore.read_text(encoding='utf-8')
314
+ venv_ignored = any(d in content for d in venv_dirs)
315
+
316
+ self.result['checks']['virtual_env'] = {
317
+ 'has_venv': has_venv,
318
+ 'venv_ignored': venv_ignored
319
+ }
320
+
321
+ if has_venv and not venv_ignored:
322
+ self.result['warnings'].append('虛擬環境目錄未加入 .gitignore')
323
+
324
+ def check_migrations(self):
325
+ """檢查資料庫遷移狀態"""
326
+ alembic_ini = self.project_path / 'alembic.ini'
327
+ alembic_dir = self.project_path / 'alembic'
328
+
329
+ # 跳過非 Alembic 專案
330
+ if not alembic_ini.exists():
331
+ self.result['checks']['migrations'] = {'skipped': 'Alembic 未初始化'}
332
+ return
333
+
334
+ versions_dir = alembic_dir / 'versions'
335
+ has_migrations = versions_dir.exists() and any(versions_dir.glob('*.py'))
336
+
337
+ # 檢查 Model 檔案
338
+ models_files = []
339
+ for pattern in ['**/models.py', '**/models/*.py', '**/models/**/*.py']:
340
+ models_files.extend(self.project_path.glob(pattern))
341
+
342
+ # 過濾掉 alembic/versions 目錄
343
+ models_files = [f for f in models_files if 'alembic' not in str(f)]
344
+
345
+ # 取得最新 migration 時間
346
+ latest_migration_time = 0
347
+ if versions_dir.exists():
348
+ for f in versions_dir.glob('*.py'):
349
+ if f.stat().st_mtime > latest_migration_time:
350
+ latest_migration_time = f.stat().st_mtime
351
+
352
+ # 取得最新 model 修改時間
353
+ latest_model_time = 0
354
+ for f in models_files:
355
+ if f.stat().st_mtime > latest_model_time:
356
+ latest_model_time = f.stat().st_mtime
357
+
358
+ # 比較時間
359
+ needs_migration = latest_model_time > latest_migration_time and latest_migration_time > 0
360
+
361
+ self.result['checks']['migrations'] = {
362
+ 'has_alembic': True,
363
+ 'has_migrations': has_migrations,
364
+ 'models_count': len(models_files),
365
+ 'needs_migration': needs_migration
366
+ }
367
+
368
+ if not has_migrations:
369
+ self.result['warnings'].append('Alembic 已初始化但尚未有遷移檔,請執行 dash db generate')
370
+
371
+ if needs_migration:
372
+ self.result['warnings'].append(
373
+ 'Model 檔案比最新遷移更新,可能需要產生新遷移 (dash db generate -m "描述")'
374
+ )
375
+
376
+ def _should_skip(self, file_path):
377
+ """檢查是否應該跳過該檔案"""
378
+ file_str = str(file_path)
379
+ return any(ignore in file_str for ignore in self.IGNORE_DIRS)
380
+
381
+ def _is_gitignored(self, file_path):
382
+ """檢查檔案是否在 .gitignore 中"""
383
+ gitignore = self.project_path / '.gitignore'
384
+ if not gitignore.exists():
385
+ return False
386
+
387
+ content = gitignore.read_text(encoding='utf-8')
388
+ rel_path = str(file_path.relative_to(self.project_path))
389
+
390
+ ext = file_path.suffix
391
+ if f'*{ext}' in content:
392
+ return True
393
+
394
+ for line in content.splitlines():
395
+ line = line.strip()
396
+ if not line or line.startswith('#'):
397
+ continue
398
+ if line in rel_path or rel_path.startswith(line):
399
+ return True
400
+
401
+ return False
402
+
403
+
404
+ def run_ruff_check(project_path) -> dict:
405
+ """獨立函數:執行 Ruff 檢查(供 Hook 使用)"""
406
+ try:
407
+ # Lint 檢查
408
+ lint_result = subprocess.run(
409
+ ['ruff', 'check', str(project_path)],
410
+ capture_output=True,
411
+ text=True,
412
+ timeout=60
413
+ )
414
+
415
+ # Format 檢查
416
+ format_result = subprocess.run(
417
+ ['ruff', 'format', '--check', str(project_path)],
418
+ capture_output=True,
419
+ text=True,
420
+ timeout=60
421
+ )
422
+
423
+ lint_passed = lint_result.returncode == 0
424
+ format_passed = format_result.returncode == 0
425
+
426
+ return {
427
+ 'passed': lint_passed and format_passed,
428
+ 'lint_passed': lint_passed,
429
+ 'format_passed': format_passed,
430
+ 'lint_output': lint_result.stdout[:500] if lint_result.stdout else '',
431
+ 'format_output': format_result.stdout[:500] if format_result.stdout else ''
432
+ }
433
+
434
+ except FileNotFoundError:
435
+ return {'passed': True, 'skipped': 'Ruff 未安裝'}
436
+ except subprocess.TimeoutExpired:
437
+ return {'passed': False, 'error': '執行逾時'}
438
+ except Exception as e:
439
+ return {'passed': False, 'error': str(e)}