evolver-tools 1.4.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.
- evolver_tools/__init__.py +2 -0
- evolver_tools/__main__.py +3 -0
- evolver_tools/cli.py +89 -0
- evolver_tools/vendor/b64/__init__.py +2 -0
- evolver_tools/vendor/b64/b64.py +176 -0
- evolver_tools/vendor/cal_tool/__init__.py +1 -0
- evolver_tools/vendor/cal_tool/cli.py +234 -0
- evolver_tools/vendor/chart_cli/__init__.py +444 -0
- evolver_tools/vendor/chart_cli/__main__.py +3 -0
- evolver_tools/vendor/colors/__init__.py +5 -0
- evolver_tools/vendor/colors/__main__.py +97 -0
- evolver_tools/vendor/csv_stats/__init__.py +5 -0
- evolver_tools/vendor/csv_stats/__main__.py +4 -0
- evolver_tools/vendor/csv_stats/analyzer.py +258 -0
- evolver_tools/vendor/csv_stats/cli.py +45 -0
- evolver_tools/vendor/dirsize/__init__.py +183 -0
- evolver_tools/vendor/envcheck/__init__.py +426 -0
- evolver_tools/vendor/ff/__init__.py +427 -0
- evolver_tools/vendor/ff/__main__.py +3 -0
- evolver_tools/vendor/find_dups/__init__.py +7 -0
- evolver_tools/vendor/find_dups/cli.py +392 -0
- evolver_tools/vendor/hashsum/__init__.py +211 -0
- evolver_tools/vendor/hashsum/__main__.py +5 -0
- evolver_tools/vendor/http_live/__init__.py +265 -0
- evolver_tools/vendor/http_live/__main__.py +2 -0
- evolver_tools/vendor/ipinfo/__init__.py +3 -0
- evolver_tools/vendor/ipinfo/__main__.py +30 -0
- evolver_tools/vendor/jq_lite/__init__.py +257 -0
- evolver_tools/vendor/jq_lite/__main__.py +5 -0
- evolver_tools/vendor/json2csv/__init__.py +3 -0
- evolver_tools/vendor/json2csv/__main__.py +82 -0
- evolver_tools/vendor/jsonql/__init__.py +326 -0
- evolver_tools/vendor/jsonql/__main__.py +5 -0
- evolver_tools/vendor/license_cli/__init__.py +1 -0
- evolver_tools/vendor/license_cli/__main__.py +4 -0
- evolver_tools/vendor/license_cli/cli.py +289 -0
- evolver_tools/vendor/markdown_check/__init__.py +211 -0
- evolver_tools/vendor/nb/__init__.py +319 -0
- evolver_tools/vendor/nb/__main__.py +3 -0
- evolver_tools/vendor/passgen/__init__.py +224 -0
- evolver_tools/vendor/portcheck/__init__.py +2 -0
- evolver_tools/vendor/portcheck/__main__.py +66 -0
- evolver_tools/vendor/project_doctor/__init__.py +412 -0
- evolver_tools/vendor/project_doctor/__main__.py +3 -0
- evolver_tools/vendor/ren/__init__.py +283 -0
- evolver_tools/vendor/ren/__main__.py +3 -0
- evolver_tools/vendor/siege_lite/__init__.py +250 -0
- evolver_tools/vendor/siege_lite/__main__.py +3 -0
- evolver_tools/vendor/smellfinder/__init__.py +376 -0
- evolver_tools/vendor/smellfinder/__main__.py +3 -0
- evolver_tools/vendor/sqlite_cli/__init__.py +326 -0
- evolver_tools/vendor/sqlite_cli/__main__.py +5 -0
- evolver_tools/vendor/sysmon/__init__.py +299 -0
- evolver_tools/vendor/sysmon/__main__.py +3 -0
- evolver_tools/vendor/timer/__init__.py +127 -0
- evolver_tools/vendor/treedir/__init__.py +2 -0
- evolver_tools/vendor/treedir/__main__.py +128 -0
- evolver_tools/vendor/urlparse_tool/__init__.py +3 -0
- evolver_tools/vendor/urlparse_tool/cli.py +212 -0
- evolver_tools/vendor/web_summary/__init__.py +341 -0
- evolver_tools/vendor/web_summary/__main__.py +3 -0
- evolver_tools/vendor/wordcount/__init__.py +2 -0
- evolver_tools/vendor/wordcount/__main__.py +101 -0
- evolver_tools-1.4.0.dist-info/METADATA +107 -0
- evolver_tools-1.4.0.dist-info/RECORD +69 -0
- evolver_tools-1.4.0.dist-info/WHEEL +5 -0
- evolver_tools-1.4.0.dist-info/entry_points.txt +34 -0
- evolver_tools-1.4.0.dist-info/licenses/LICENSE +21 -0
- evolver_tools-1.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
project-doctor — 项目健康检查工具
|
|
4
|
+
扫描项目目录,检查基础设施完整性,评分并提建议。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
def red(s): return f"\033[91m{s}\033[0m"
|
|
14
|
+
def green(s): return f"\033[92m{s}\033[0m"
|
|
15
|
+
def yellow(s): return f"\033[93m{s}\033[0m"
|
|
16
|
+
def cyan(s): return f"\033[96m{s}\033[0m"
|
|
17
|
+
def dim(s): return f"\033[2m{s}\033[0m"
|
|
18
|
+
def bold(s): return f"\033[1m{s}\033[0m"
|
|
19
|
+
|
|
20
|
+
class ProjectDoctor:
|
|
21
|
+
"""Project health checker"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, path, verbose=False):
|
|
24
|
+
self.root = Path(path).resolve()
|
|
25
|
+
self.verbose = verbose
|
|
26
|
+
self.checks = []
|
|
27
|
+
self.score = 0
|
|
28
|
+
self.max_score = 0
|
|
29
|
+
self.findings = [] # (category, severity, message, weight)
|
|
30
|
+
|
|
31
|
+
def check(self, name, weight, fn):
|
|
32
|
+
"""Run a check function"""
|
|
33
|
+
self.max_score += weight
|
|
34
|
+
try:
|
|
35
|
+
result = fn()
|
|
36
|
+
if result:
|
|
37
|
+
self.score += weight
|
|
38
|
+
self.findings.append(('pass', 'ok', f"{name}: {result}", 0))
|
|
39
|
+
else:
|
|
40
|
+
self.findings.append(('fail', 'warn', f"{name}: {red('未通过')}", weight))
|
|
41
|
+
return result
|
|
42
|
+
except Exception as e:
|
|
43
|
+
if self.verbose:
|
|
44
|
+
self.findings.append(('error', 'error', f"{name}: 检查出错 ({e})", 0))
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
def check_file(self, *paths):
|
|
48
|
+
"""Check if a file exists (relative to project root)"""
|
|
49
|
+
return any((self.root / p).is_file() for p in paths)
|
|
50
|
+
|
|
51
|
+
def check_dir(self, path):
|
|
52
|
+
"""Check if directory exists"""
|
|
53
|
+
return (self.root / path).is_dir()
|
|
54
|
+
|
|
55
|
+
def read_file(self, path):
|
|
56
|
+
"""Read a file if it exists"""
|
|
57
|
+
f = self.root / path
|
|
58
|
+
if f.is_file():
|
|
59
|
+
try:
|
|
60
|
+
return f.read_text(encoding='utf-8', errors='replace')
|
|
61
|
+
except:
|
|
62
|
+
return None
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
def scan(self):
|
|
66
|
+
"""Run all checks"""
|
|
67
|
+
|
|
68
|
+
# ---- 1. 元文件检查 ----
|
|
69
|
+
self.findings.append(('section', 'info', bold('\n📦 元文件'), 0))
|
|
70
|
+
|
|
71
|
+
# README
|
|
72
|
+
self.check('README', 10, lambda:
|
|
73
|
+
green(f"✓ {self._find_file('README*')}") if self._find_file('README*') else None)
|
|
74
|
+
|
|
75
|
+
# LICENSE
|
|
76
|
+
self.check('LICENSE', 5, lambda:
|
|
77
|
+
green(f"✓ {self._find_file('LICENSE*')}") if self._find_file('LICENSE*') else None)
|
|
78
|
+
|
|
79
|
+
# .gitignore
|
|
80
|
+
self.check('.gitignore', 8, lambda:
|
|
81
|
+
self._check_gitignore() if self.check_file('.gitignore') else None)
|
|
82
|
+
|
|
83
|
+
# .git
|
|
84
|
+
self.check('.git (版本控制)', 10, lambda:
|
|
85
|
+
green(f"✓ {self._check_git_health()}") if self.check_dir('.git') else None)
|
|
86
|
+
|
|
87
|
+
# CHANGELOG
|
|
88
|
+
self.check('CHANGELOG', 3, lambda:
|
|
89
|
+
green(f"✓ {self._find_file('CHANGELOG*')}") if self._find_file('CHANGELOG*') else None)
|
|
90
|
+
|
|
91
|
+
# ---- 2. Python 项目检查 ----
|
|
92
|
+
has_python = self._has_python_files()
|
|
93
|
+
if has_python:
|
|
94
|
+
self.findings.append(('section', 'info', bold('\n🐍 Python 项目'), 0))
|
|
95
|
+
|
|
96
|
+
# requirements.txt / pyproject.toml
|
|
97
|
+
self.check('依赖管理', 8, lambda:
|
|
98
|
+
green(f"✓ {self._find_file('requirements.txt') or self._find_file('pyproject.toml') or self._find_file('setup.py') or self._find_file('Pipfile')}")
|
|
99
|
+
if self._find_file('requirements.txt') or self._find_file('pyproject.toml') or self._find_file('setup.py') or self._find_file('Pipfile') else None)
|
|
100
|
+
|
|
101
|
+
# venv / .venv
|
|
102
|
+
self.check('虚拟环境', 6, lambda:
|
|
103
|
+
green(f"✓ {self._find_dir('venv') or self._find_dir('.venv')}") if self.check_dir('venv') or self.check_dir('.venv') else yellow("⚠ 建议: 创建虚拟环境"))
|
|
104
|
+
|
|
105
|
+
# __init__.py in packages
|
|
106
|
+
packages = list(self.root.rglob('__init__.py'))
|
|
107
|
+
self.check('包结构', 3, lambda:
|
|
108
|
+
cyan(f"{len(packages)} 个包") if packages else yellow("无 Python 包"))
|
|
109
|
+
|
|
110
|
+
# Python version pinning
|
|
111
|
+
py_version = self.read_file('.python-version')
|
|
112
|
+
if py_version:
|
|
113
|
+
self.findings.append(('pass', 'info', f" Python 版本固定: {py_version.strip()}", 0))
|
|
114
|
+
|
|
115
|
+
# ---- 3. JavaScript/Node 项目检查 ----
|
|
116
|
+
has_js = self.check_file('package.json')
|
|
117
|
+
if has_js:
|
|
118
|
+
self.findings.append(('section', 'info', bold('\n📦 Node.js 项目'), 0))
|
|
119
|
+
|
|
120
|
+
# package.json validity
|
|
121
|
+
pkg = self._read_json('package.json')
|
|
122
|
+
if pkg:
|
|
123
|
+
scripts = pkg.get('scripts', {})
|
|
124
|
+
has_test = 'test' in scripts
|
|
125
|
+
has_build = 'build' in scripts
|
|
126
|
+
deps = len(pkg.get('dependencies', {}))
|
|
127
|
+
dev_deps = len(pkg.get('devDependencies', {}))
|
|
128
|
+
self.findings.append(('pass', 'info',
|
|
129
|
+
f" 依赖: {deps} 生产 + {dev_deps} 开发", 0))
|
|
130
|
+
if has_test:
|
|
131
|
+
self.score += 2
|
|
132
|
+
self.findings.append(('pass', 'ok', f" test 脚本: {green('✓')}", 0))
|
|
133
|
+
if has_build:
|
|
134
|
+
self.score += 2
|
|
135
|
+
self.findings.append(('pass', 'ok', f" build 脚本: {green('✓')}", 0))
|
|
136
|
+
|
|
137
|
+
# node_modules
|
|
138
|
+
nm = self.check_dir('node_modules')
|
|
139
|
+
if nm:
|
|
140
|
+
self.findings.append(('pass', 'info', f" node_modules: {green('已安装')}", 0))
|
|
141
|
+
else:
|
|
142
|
+
self.findings.append(('pass', 'info', f" node_modules: {yellow('未安装 (需先 npm install)')}", 0))
|
|
143
|
+
|
|
144
|
+
# .nvmrc
|
|
145
|
+
if self.check_file('.nvmrc'):
|
|
146
|
+
nvm = self.read_file('.nvmrc')
|
|
147
|
+
self.findings.append(('pass', 'info', f" Node 版本: {nvm.strip()}", 0))
|
|
148
|
+
|
|
149
|
+
# ---- 4. 项目结构检查 ----
|
|
150
|
+
self.findings.append(('section', 'info', bold('\n📁 项目结构'), 0))
|
|
151
|
+
|
|
152
|
+
# Entry point detection
|
|
153
|
+
entry = self._find_entry_point()
|
|
154
|
+
if entry:
|
|
155
|
+
self.check('入口文件', 5, lambda: green(entry))
|
|
156
|
+
|
|
157
|
+
# Test directory
|
|
158
|
+
test_dirs = ['tests', 'test', '__tests__', 'spec']
|
|
159
|
+
found_test = False
|
|
160
|
+
for td in test_dirs:
|
|
161
|
+
if self.check_dir(td):
|
|
162
|
+
test_count = len(list((self.root / td).rglob('*.py' if has_python else '*.js')))
|
|
163
|
+
self.findings.append(('pass', 'info', f" 测试目录: {green(td)} ({test_count} 个测试文件)", 0))
|
|
164
|
+
found_test = True
|
|
165
|
+
break
|
|
166
|
+
if not found_test:
|
|
167
|
+
self.findings.append(('fail', 'warn', f" 测试目录: {yellow('⚠ 未发现 (建议: 创建 tests/)')}", 3))
|
|
168
|
+
|
|
169
|
+
# CI config
|
|
170
|
+
ci_files = ['.github/workflows', '.gitlab-ci.yml', '.circleci', 'Jenkinsfile',
|
|
171
|
+
'.travis.yml', 'azure-pipelines.yml']
|
|
172
|
+
self.check('CI/CD 配置', 5, lambda:
|
|
173
|
+
green(f"✓ {self._find_first_ci()}") if self._find_first_ci() else None)
|
|
174
|
+
|
|
175
|
+
# Docker
|
|
176
|
+
if self.check_file('Dockerfile', 'docker-compose.yml', 'compose.yaml'):
|
|
177
|
+
self.findings.append(('pass', 'info', f" Docker: {green('已配置')}", 0))
|
|
178
|
+
|
|
179
|
+
# Makefile
|
|
180
|
+
if self.check_file('Makefile', 'makefile', 'Justfile', 'Taskfile.yml'):
|
|
181
|
+
self.findings.append(('pass', 'info', f" 构建工具: {green('已配置')}", 0))
|
|
182
|
+
|
|
183
|
+
# ---- 5. 代码质量检查 ----
|
|
184
|
+
self.findings.append(('section', 'info', bold('\n🔍 代码质量'), 0))
|
|
185
|
+
|
|
186
|
+
# Linter config
|
|
187
|
+
lint_configs = ['.pylintrc', '.flake8', '.eslintrc*', '.eslintrc.json',
|
|
188
|
+
'.eslintrc.js', '.prettierrc', 'pyproject.toml',
|
|
189
|
+
'.ruff.toml', 'ruff.toml']
|
|
190
|
+
self.check('Linter 配置', 5, lambda:
|
|
191
|
+
green(f"✓ {self._find_any(lint_configs)}") if self._find_any(lint_configs) else None)
|
|
192
|
+
|
|
193
|
+
# Editor config
|
|
194
|
+
if self.check_file('.editorconfig'):
|
|
195
|
+
self.findings.append(('pass', 'info', f" EditorConfig: {green('✓')}", 0))
|
|
196
|
+
|
|
197
|
+
# File count and sizes
|
|
198
|
+
all_files = list(self.root.rglob('*'))
|
|
199
|
+
file_count = len([f for f in all_files if f.is_file()])
|
|
200
|
+
dir_count = len([f for f in all_files if f.is_dir()])
|
|
201
|
+
|
|
202
|
+
# Largest files
|
|
203
|
+
py_files = list(self.root.rglob('*.py'))
|
|
204
|
+
if py_files:
|
|
205
|
+
total_loc = sum(len(f.read_text().splitlines()) for f in py_files if f.is_file())
|
|
206
|
+
self.findings.append(('pass', 'info',
|
|
207
|
+
f" Python 代码: {len(py_files)} 个文件, {total_loc} 行", 0))
|
|
208
|
+
|
|
209
|
+
self.findings.append(('pass', 'info',
|
|
210
|
+
f" 项目结构: {file_count} 个文件, {dir_count} 个目录", 0))
|
|
211
|
+
|
|
212
|
+
# ---- Summary ----
|
|
213
|
+
return {
|
|
214
|
+
'root': str(self.root),
|
|
215
|
+
'score': self.score,
|
|
216
|
+
'max_score': self.max_score,
|
|
217
|
+
'percentage': round(self.score / max(1, self.max_score) * 100, 1),
|
|
218
|
+
'findings': self.findings,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
def _find_file(self, pattern):
|
|
222
|
+
"""Find a file by glob pattern"""
|
|
223
|
+
matches = list(self.root.glob(pattern))
|
|
224
|
+
if matches:
|
|
225
|
+
return matches[0].name
|
|
226
|
+
# Try with case-insensitive
|
|
227
|
+
for f in self.root.iterdir():
|
|
228
|
+
if f.is_file() and f.name.lower().startswith(pattern.lower().replace('*', '')):
|
|
229
|
+
return f.name
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
def _find_dir(self, name):
|
|
233
|
+
"""Find a directory"""
|
|
234
|
+
d = self.root / name
|
|
235
|
+
if d.is_dir():
|
|
236
|
+
return name
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
def _has_python_files(self):
|
|
240
|
+
return len(list(self.root.glob('*.py'))) > 0
|
|
241
|
+
|
|
242
|
+
def _read_json(self, path):
|
|
243
|
+
try:
|
|
244
|
+
content = self.read_file(path)
|
|
245
|
+
if content:
|
|
246
|
+
return json.loads(content)
|
|
247
|
+
except:
|
|
248
|
+
pass
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
def _find_entry_point(self):
|
|
252
|
+
"""Detect main entry point"""
|
|
253
|
+
candidates = ['main.py', 'app.py', 'cli.py', 'index.js', 'index.ts',
|
|
254
|
+
'server.py', 'run.py', 'manage.py', 'src/main.py',
|
|
255
|
+
'cmd/main.go']
|
|
256
|
+
for c in candidates:
|
|
257
|
+
if self.check_file(c):
|
|
258
|
+
return c
|
|
259
|
+
# Check for __main__.py
|
|
260
|
+
if self._find_any(['__main__.py', '*/__main__.py']):
|
|
261
|
+
return '__main__.py'
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
def _check_gitignore(self):
|
|
265
|
+
content = self.read_file('.gitignore')
|
|
266
|
+
if not content:
|
|
267
|
+
return "文件为空"
|
|
268
|
+
lines = [l.strip() for l in content.split('\n') if l.strip() and not l.startswith('#')]
|
|
269
|
+
return f"{len(lines)} 条规则"
|
|
270
|
+
|
|
271
|
+
def _check_git_health(self):
|
|
272
|
+
"""Check git repo health"""
|
|
273
|
+
try:
|
|
274
|
+
import subprocess
|
|
275
|
+
# Count commits
|
|
276
|
+
result = subprocess.run(
|
|
277
|
+
['git', '-C', str(self.root), 'rev-list', '--count', 'HEAD'],
|
|
278
|
+
capture_output=True, text=True, timeout=5
|
|
279
|
+
)
|
|
280
|
+
commits = result.stdout.strip()
|
|
281
|
+
# Check for recent commit
|
|
282
|
+
result2 = subprocess.run(
|
|
283
|
+
['git', '-C', str(self.root), 'log', '-1', '--format=%cr'],
|
|
284
|
+
capture_output=True, text=True, timeout=5
|
|
285
|
+
)
|
|
286
|
+
recent = result2.stdout.strip()
|
|
287
|
+
return f"{commits} 次提交 (最近: {recent})"
|
|
288
|
+
except:
|
|
289
|
+
return "存在 .git 目录"
|
|
290
|
+
|
|
291
|
+
def _find_first_ci(self):
|
|
292
|
+
"""Find CI/CD config"""
|
|
293
|
+
ci_map = {
|
|
294
|
+
'.github/workflows/': '.github/workflows',
|
|
295
|
+
'.gitlab-ci.yml': '.gitlab-ci.yml',
|
|
296
|
+
'.circleci/config.yml': '.circleci/config.yml',
|
|
297
|
+
'Jenkinsfile': 'Jenkinsfile',
|
|
298
|
+
'.travis.yml': '.travis.yml',
|
|
299
|
+
}
|
|
300
|
+
for name, path in ci_map.items():
|
|
301
|
+
if self.check_file(path) or self.check_dir(path):
|
|
302
|
+
return name.rstrip('/')
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
def _find_any(self, patterns):
|
|
306
|
+
"""Find first matching pattern"""
|
|
307
|
+
for p in patterns:
|
|
308
|
+
matches = list(self.root.glob(p))
|
|
309
|
+
if matches:
|
|
310
|
+
return matches[0].name
|
|
311
|
+
# Also check as exact name
|
|
312
|
+
if self.check_file(p):
|
|
313
|
+
return p
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def print_report(result):
|
|
318
|
+
"""Print formatted health report"""
|
|
319
|
+
root = result['root']
|
|
320
|
+
score = result['score']
|
|
321
|
+
max_score = result['max_score']
|
|
322
|
+
pct = result['percentage']
|
|
323
|
+
|
|
324
|
+
# Header
|
|
325
|
+
print(f"\n {bold('🏥 项目健康检查报告')}")
|
|
326
|
+
print(f" {dim('─' * 55)}")
|
|
327
|
+
print(f" {dim('项目:')} {root}")
|
|
328
|
+
print(f" {dim('时间:')} {__import__('datetime').datetime.now().strftime('%Y-%m-%d %H:%M')}")
|
|
329
|
+
print(f" {dim('─' * 55)}")
|
|
330
|
+
|
|
331
|
+
# Score bar
|
|
332
|
+
if pct >= 80:
|
|
333
|
+
score_color = green
|
|
334
|
+
grade = '优秀'
|
|
335
|
+
elif pct >= 60:
|
|
336
|
+
score_color = yellow
|
|
337
|
+
grade = '良好'
|
|
338
|
+
elif pct >= 40:
|
|
339
|
+
score_color = yellow
|
|
340
|
+
grade = '需改进'
|
|
341
|
+
else:
|
|
342
|
+
score_color = red
|
|
343
|
+
grade = '不健康'
|
|
344
|
+
|
|
345
|
+
bar_w = 30
|
|
346
|
+
filled = int(bar_w * pct / 100)
|
|
347
|
+
bar = '█' * filled + '░' * (bar_w - filled)
|
|
348
|
+
print(f"\n {bold('健康评分:')} {score_color(f'{score}/{max_score} ({pct}%)')} {bold(grade)}")
|
|
349
|
+
print(f" {score_color(bar)}")
|
|
350
|
+
print()
|
|
351
|
+
|
|
352
|
+
# Findings by category
|
|
353
|
+
current_section = ''
|
|
354
|
+
warnings = []
|
|
355
|
+
suggestions = []
|
|
356
|
+
|
|
357
|
+
for finding in result['findings']:
|
|
358
|
+
ftype, severity, msg, weight = finding
|
|
359
|
+
|
|
360
|
+
if ftype == 'section':
|
|
361
|
+
print(msg)
|
|
362
|
+
continue
|
|
363
|
+
|
|
364
|
+
if ftype == 'pass':
|
|
365
|
+
if severity == 'ok':
|
|
366
|
+
print(f" {green('✓')} {msg}")
|
|
367
|
+
elif severity == 'info':
|
|
368
|
+
print(f" {cyan('ℹ')} {dim(msg)}")
|
|
369
|
+
|
|
370
|
+
elif ftype == 'fail':
|
|
371
|
+
if weight > 0:
|
|
372
|
+
warnings.append((msg, weight))
|
|
373
|
+
print(f" {yellow('⚠')} {msg}")
|
|
374
|
+
|
|
375
|
+
# Suggestions section
|
|
376
|
+
if warnings:
|
|
377
|
+
print(f"\n {bold('💡 改进建议')}")
|
|
378
|
+
print(f" {dim('─' * 55)}")
|
|
379
|
+
for msg, weight in sorted(warnings, key=lambda x: -x[1]):
|
|
380
|
+
icon = {10: '🔴', 8: '🟠', 5: '🟡', 3: '🟢'}.get(weight, '⚪')
|
|
381
|
+
print(f" {icon} {msg} (权重: {weight})")
|
|
382
|
+
print()
|
|
383
|
+
|
|
384
|
+
# Legend
|
|
385
|
+
print(f" {dim('评分标准: ≥80 优秀 | ≥60 良好 | ≥40 需改进 | <40 不健康')}")
|
|
386
|
+
print()
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def main():
|
|
390
|
+
import argparse
|
|
391
|
+
parser = argparse.ArgumentParser(description='项目健康检查工具')
|
|
392
|
+
parser.add_argument('path', nargs='?', default='.', help='项目目录路径 (默认: 当前目录)')
|
|
393
|
+
parser.add_argument('-v', '--verbose', action='store_true', help='详细输出')
|
|
394
|
+
parser.add_argument('--json', action='store_true', help='JSON 格式输出')
|
|
395
|
+
args = parser.parse_args()
|
|
396
|
+
|
|
397
|
+
path = Path(args.path).resolve()
|
|
398
|
+
if not path.is_dir():
|
|
399
|
+
print(f"{red('✗ 目录不存在:')} {path}")
|
|
400
|
+
sys.exit(1)
|
|
401
|
+
|
|
402
|
+
doctor = ProjectDoctor(path, verbose=args.verbose)
|
|
403
|
+
result = doctor.scan()
|
|
404
|
+
|
|
405
|
+
if args.json:
|
|
406
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
407
|
+
else:
|
|
408
|
+
print_report(result)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
if __name__ == '__main__':
|
|
412
|
+
main()
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
ren — 批量文件重命名工具
|
|
4
|
+
安全、可预览、可撤销的文件重命名。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
def green(s): return f"\033[92m{s}\033[0m"
|
|
14
|
+
def red(s): return f"\033[91m{s}\033[0m"
|
|
15
|
+
def yellow(s): return f"\033[93m{s}\033[0m"
|
|
16
|
+
def cyan(s): return f"\033[96m{s}\033[0m"
|
|
17
|
+
def dim(s): return f"\033[2m{s}\033[0m"
|
|
18
|
+
|
|
19
|
+
def collect_files(patterns, recursive=False):
|
|
20
|
+
"""Collect files matching glob patterns"""
|
|
21
|
+
files = []
|
|
22
|
+
for pattern in patterns:
|
|
23
|
+
p = Path(pattern)
|
|
24
|
+
if p.is_dir():
|
|
25
|
+
# Directory mode: collect all files in dir
|
|
26
|
+
for f in p.iterdir() if not recursive else p.rglob('*'):
|
|
27
|
+
if f.is_file():
|
|
28
|
+
files.append(f)
|
|
29
|
+
elif '*' in pattern or '?' in pattern:
|
|
30
|
+
# Glob pattern
|
|
31
|
+
matched = list(Path('.').glob(pattern))
|
|
32
|
+
if not matched:
|
|
33
|
+
# Try parent glob
|
|
34
|
+
matched = list(Path('.').glob(pattern))
|
|
35
|
+
files.extend(matched)
|
|
36
|
+
else:
|
|
37
|
+
# Exact path
|
|
38
|
+
p = Path(pattern)
|
|
39
|
+
if p.exists():
|
|
40
|
+
files.append(p)
|
|
41
|
+
return sorted(set(files), key=lambda f: f.name)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def apply_operation(file, op, args):
|
|
45
|
+
"""Apply a rename operation, returns (original, new_name, applied)"""
|
|
46
|
+
stem = file.stem
|
|
47
|
+
suffix = file.suffix
|
|
48
|
+
|
|
49
|
+
if op == 'prefix':
|
|
50
|
+
prefix = args[0]
|
|
51
|
+
new_stem = prefix + stem
|
|
52
|
+
elif op == 'suffix':
|
|
53
|
+
suffix_text = args[0]
|
|
54
|
+
new_stem = stem + suffix_text
|
|
55
|
+
elif op == 'replace':
|
|
56
|
+
old, new = args[0], args[1] if len(args) > 1 else ''
|
|
57
|
+
new_stem = stem.replace(old, new)
|
|
58
|
+
elif op == 'regex':
|
|
59
|
+
pattern, repl = args[0], args[1] if len(args) > 1 else ''
|
|
60
|
+
new_stem = re.sub(pattern, repl, stem)
|
|
61
|
+
elif op == 'lower':
|
|
62
|
+
new_stem = stem.lower()
|
|
63
|
+
elif op == 'upper':
|
|
64
|
+
new_stem = stem.upper()
|
|
65
|
+
elif op == 'number':
|
|
66
|
+
fmt = args[0] if args else '{:03d}'
|
|
67
|
+
# Need index info - handled in main loop
|
|
68
|
+
return None
|
|
69
|
+
elif op == 'strip':
|
|
70
|
+
chars_to_strip = args[0] if args else ' _-'
|
|
71
|
+
new_stem = stem.strip(chars_to_strip)
|
|
72
|
+
elif op == 'truncate':
|
|
73
|
+
max_len = int(args[0]) if args else 40
|
|
74
|
+
new_stem = stem[:max_len]
|
|
75
|
+
elif op == 'date':
|
|
76
|
+
fmt = args[0] if args else '%Y%m%d'
|
|
77
|
+
new_stem = datetime.now().strftime(fmt) + '_' + stem
|
|
78
|
+
elif op == 'ext':
|
|
79
|
+
ext = args[0] if args else ''
|
|
80
|
+
if not ext.startswith('.'):
|
|
81
|
+
ext = '.' + ext
|
|
82
|
+
new_path = file.parent / (stem + ext)
|
|
83
|
+
return (file, new_path, True)
|
|
84
|
+
else:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
new_path = file.parent / (new_stem + suffix)
|
|
88
|
+
# Don't rename to same name
|
|
89
|
+
if new_path == file:
|
|
90
|
+
return (file, new_path, False)
|
|
91
|
+
return (file, new_path, True)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def show_help():
|
|
95
|
+
print("ren — 批量文件重命名工具")
|
|
96
|
+
print()
|
|
97
|
+
print("用法: ren <glob/文件>... <操作> [参数]")
|
|
98
|
+
print()
|
|
99
|
+
print("操作:")
|
|
100
|
+
print(" --prefix <text> 添加前缀")
|
|
101
|
+
print(" --suffix <text> 添加后缀(扩展名前)")
|
|
102
|
+
print(" --replace <old> <new> 替换文件名中的文本")
|
|
103
|
+
print(" --regex <pat> <repl> 正则替换文件名")
|
|
104
|
+
print(" --lower 转为小写")
|
|
105
|
+
print(" --upper 转为大写")
|
|
106
|
+
print(" --number [fmt] 编号 (默认 {:03d})")
|
|
107
|
+
print(" --strip [chars] 去除字符 (默认 ' _-')")
|
|
108
|
+
print(" --truncate <n> 截断到 n 个字符")
|
|
109
|
+
print(" --date [fmt] 添加日期前缀 (默认 %%Y%%m%%d)")
|
|
110
|
+
print(" --ext <ext> 更改扩展名")
|
|
111
|
+
print()
|
|
112
|
+
print("选项:")
|
|
113
|
+
print(" --dry-run 仅预览,不执行")
|
|
114
|
+
print(" --recursive / -r 递归子目录")
|
|
115
|
+
print(" --verbose / -v 详细信息")
|
|
116
|
+
print()
|
|
117
|
+
print("示例:")
|
|
118
|
+
print(" ren *.txt --prefix draft- # 所有 txt 加 draft- 前缀")
|
|
119
|
+
print(" ren *.jpg --replace IMG_ photo_ # 替换文件名文本")
|
|
120
|
+
print(" ren *.md --number Chapter- # 编号 Chapter-01.md 等")
|
|
121
|
+
print(" ren . --lower --dry-run # 预览当前目录小写化")
|
|
122
|
+
print(" ren log.txt --ext md # 改扩展名 .txt → .md")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def main():
|
|
126
|
+
if not sys.argv[1:] or sys.argv[1] in ('--help', '-h', 'help'):
|
|
127
|
+
show_help()
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
args = sys.argv[1:]
|
|
131
|
+
|
|
132
|
+
# Parse operations and options
|
|
133
|
+
ops = []
|
|
134
|
+
patterns = []
|
|
135
|
+
options = {
|
|
136
|
+
'dry_run': False,
|
|
137
|
+
'recursive': False,
|
|
138
|
+
'verbose': False,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
i = 0
|
|
142
|
+
while i < len(args):
|
|
143
|
+
a = args[i]
|
|
144
|
+
if a.startswith('--') and a not in ('--dry-run', '--recursive', '--verbose', '-v', '-r'):
|
|
145
|
+
# Operation
|
|
146
|
+
op_name = a[2:] # --prefix -> prefix
|
|
147
|
+
op_args = []
|
|
148
|
+
i += 1
|
|
149
|
+
# Collect arguments until next option
|
|
150
|
+
while i < len(args) and not args[i].startswith('--') and args[i] not in ('-v', '-r'):
|
|
151
|
+
op_args.append(args[i])
|
|
152
|
+
i += 1
|
|
153
|
+
ops.append((op_name, op_args))
|
|
154
|
+
continue
|
|
155
|
+
elif a == '--dry-run':
|
|
156
|
+
options['dry_run'] = True
|
|
157
|
+
elif a in ('--recursive', '-r'):
|
|
158
|
+
options['recursive'] = True
|
|
159
|
+
elif a in ('--verbose', '-v'):
|
|
160
|
+
options['verbose'] = True
|
|
161
|
+
else:
|
|
162
|
+
patterns.append(a)
|
|
163
|
+
i += 1
|
|
164
|
+
|
|
165
|
+
if not patterns:
|
|
166
|
+
print("错误: 未指定文件模式")
|
|
167
|
+
sys.exit(1)
|
|
168
|
+
|
|
169
|
+
if not ops:
|
|
170
|
+
print("错误: 未指定操作")
|
|
171
|
+
sys.exit(1)
|
|
172
|
+
|
|
173
|
+
# Collect files
|
|
174
|
+
files = collect_files(patterns, options['recursive'])
|
|
175
|
+
if not files:
|
|
176
|
+
print("(未找到匹配的文件)")
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
print(f"找到 {len(files)} 个文件\n" if options['verbose'] else "", end="")
|
|
180
|
+
|
|
181
|
+
# Generate rename plan
|
|
182
|
+
rename_plan = [] # [(old_path, new_path, applied)]
|
|
183
|
+
number_op = None
|
|
184
|
+
other_ops = []
|
|
185
|
+
|
|
186
|
+
for op_name, op_args in ops:
|
|
187
|
+
if op_name == 'number':
|
|
188
|
+
number_op = op_args
|
|
189
|
+
else:
|
|
190
|
+
other_ops.append((op_name, op_args))
|
|
191
|
+
|
|
192
|
+
if number_op:
|
|
193
|
+
fmt = number_op[0] if number_op else '{:03d}'
|
|
194
|
+
# Also check for prefix/suffix in format
|
|
195
|
+
prefix_text = ''
|
|
196
|
+
suffix_text = ''
|
|
197
|
+
if ',' in fmt:
|
|
198
|
+
parts = fmt.split(',')
|
|
199
|
+
fmt = parts[0]
|
|
200
|
+
if len(parts) > 1:
|
|
201
|
+
prefix_text = parts[1]
|
|
202
|
+
else:
|
|
203
|
+
# Check if fmt contains non-format characters
|
|
204
|
+
pass
|
|
205
|
+
for idx, file in enumerate(files, 1):
|
|
206
|
+
stem = file.stem
|
|
207
|
+
suffix = file.suffix
|
|
208
|
+
num = fmt.format(idx)
|
|
209
|
+
new_stem = prefix_text + num + suffix_text + stem
|
|
210
|
+
# But wait, I want to insert the number somewhere
|
|
211
|
+
# Simpler: number replaces stem or prefixes
|
|
212
|
+
if prefix_text:
|
|
213
|
+
new_stem = prefix_text + num + '_' + stem
|
|
214
|
+
else:
|
|
215
|
+
new_stem = num + '_' + stem
|
|
216
|
+
new_path = file.parent / (new_stem + suffix)
|
|
217
|
+
rename_plan.append((file, new_path, new_path != file))
|
|
218
|
+
else:
|
|
219
|
+
for file in files:
|
|
220
|
+
current = file
|
|
221
|
+
applied = True
|
|
222
|
+
for op_name, op_args in other_ops:
|
|
223
|
+
result = apply_operation(current, op_name, op_args)
|
|
224
|
+
if result is None:
|
|
225
|
+
continue
|
|
226
|
+
_, new_path, was_applied = result
|
|
227
|
+
if not was_applied:
|
|
228
|
+
applied = False
|
|
229
|
+
break
|
|
230
|
+
current = new_path
|
|
231
|
+
rename_plan.append((file, current, applied))
|
|
232
|
+
|
|
233
|
+
# Filter unchanged
|
|
234
|
+
rename_plan = [(o, n, a) for o, n, a in rename_plan if a and o != n]
|
|
235
|
+
|
|
236
|
+
if not rename_plan:
|
|
237
|
+
print("(无需重命名)")
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
# Show plan
|
|
241
|
+
print(f"\n将重命名 {len(rename_plan)} 个文件:\n")
|
|
242
|
+
for old, new, _ in rename_plan:
|
|
243
|
+
if old.parent == new.parent:
|
|
244
|
+
print(f" {dim(old.name)} → {green(new.name)}")
|
|
245
|
+
else:
|
|
246
|
+
print(f" {old} → {green(new)}")
|
|
247
|
+
|
|
248
|
+
if options['dry_run']:
|
|
249
|
+
print(f"\n{yellow('干跑模式 — 未执行任何操作')}")
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
# Confirm
|
|
253
|
+
print()
|
|
254
|
+
try:
|
|
255
|
+
confirm = input(f"执行以上 {len(rename_plan)} 个重命名? (y/N) ").lower()
|
|
256
|
+
except (EOFError, KeyboardInterrupt):
|
|
257
|
+
print()
|
|
258
|
+
print("取消")
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
if confirm != 'y':
|
|
262
|
+
print("取消")
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
# Execute
|
|
266
|
+
renamed = 0
|
|
267
|
+
errors = 0
|
|
268
|
+
for old, new, _ in rename_plan:
|
|
269
|
+
try:
|
|
270
|
+
new.parent.mkdir(parents=True, exist_ok=True)
|
|
271
|
+
old.rename(new)
|
|
272
|
+
if options['verbose']:
|
|
273
|
+
print(f" ✓ {old.name} → {new.name}")
|
|
274
|
+
renamed += 1
|
|
275
|
+
except OSError as e:
|
|
276
|
+
print(f" ✗ {old.name}: {e}")
|
|
277
|
+
errors += 1
|
|
278
|
+
|
|
279
|
+
print(f"\n✓ 已重命名 {renamed} 个文件" + (f", {errors} 个错误" if errors else ""))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
if __name__ == '__main__':
|
|
283
|
+
main()
|