repodoctor-cli 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.
- repodoctor/__init__.py +1 -0
- repodoctor/__main__.py +370 -0
- repodoctor/baseline.py +42 -0
- repodoctor/cli.py +47 -0
- repodoctor/duplicates.py +69 -0
- repodoctor/git.py +67 -0
- repodoctor/languages.py +33 -0
- repodoctor/linter.py +226 -0
- repodoctor/metrics.py +73 -0
- repodoctor/models.py +81 -0
- repodoctor/report.py +361 -0
- repodoctor/scanner.py +177 -0
- repodoctor/scoring.py +49 -0
- repodoctor/security.py +56 -0
- repodoctor/spinner.py +174 -0
- repodoctor/structure.py +40 -0
- repodoctor/todos.py +32 -0
- repodoctor_cli-1.0.0.dist-info/METADATA +137 -0
- repodoctor_cli-1.0.0.dist-info/RECORD +22 -0
- repodoctor_cli-1.0.0.dist-info/WHEEL +5 -0
- repodoctor_cli-1.0.0.dist-info/entry_points.txt +2 -0
- repodoctor_cli-1.0.0.dist-info/top_level.txt +1 -0
repodoctor/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""RepoDoctor package"""
|
repodoctor/__main__.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
import re
|
|
5
|
+
import concurrent.futures
|
|
6
|
+
import collections
|
|
7
|
+
import io
|
|
8
|
+
import contextlib
|
|
9
|
+
|
|
10
|
+
# Force utf-8 output to avoid cp1252 encoding errors on Windows
|
|
11
|
+
if sys.stdout.encoding != 'utf-8':
|
|
12
|
+
try:
|
|
13
|
+
sys.stdout.reconfigure(encoding='utf-8')
|
|
14
|
+
except Exception:
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
from .cli import parse_args
|
|
18
|
+
from .scanner import scan_repository
|
|
19
|
+
from .languages import detect_languages
|
|
20
|
+
from .metrics import analyze_metrics
|
|
21
|
+
from .todos import scan_todos
|
|
22
|
+
from .security import scan_security
|
|
23
|
+
from .duplicates import scan_duplicates
|
|
24
|
+
from .structure import check_project_structure
|
|
25
|
+
from .git import get_git_info
|
|
26
|
+
from .scoring import calculate_score
|
|
27
|
+
from .report import print_terminal_report, get_json_report, generate_html_report
|
|
28
|
+
from .baseline import compare_baseline
|
|
29
|
+
from .models import ReportData
|
|
30
|
+
from .spinner import Spinner
|
|
31
|
+
|
|
32
|
+
def process_single_repo(root_path, args, idx, custom_ignores, use_parallel, show_animation, start_time):
|
|
33
|
+
repo_start_time = time.time()
|
|
34
|
+
# Determine if we should suppress live spinner output (if scanning multiple repos)
|
|
35
|
+
silent = len(args.path) > 1
|
|
36
|
+
|
|
37
|
+
# 1. Scan files
|
|
38
|
+
files = scan_repository(
|
|
39
|
+
root_path,
|
|
40
|
+
custom_ignores,
|
|
41
|
+
parallel=use_parallel,
|
|
42
|
+
show_animation=show_animation and not silent,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# 2. Run analysis phases
|
|
46
|
+
with Spinner(f"Detecting languages ({root_path})", colour=show_animation, silent=silent):
|
|
47
|
+
detect_languages(files)
|
|
48
|
+
|
|
49
|
+
with Spinner(f"Analysing metrics ({root_path})", colour=show_animation, silent=silent):
|
|
50
|
+
analyze_metrics(files)
|
|
51
|
+
|
|
52
|
+
with Spinner(f"Scanning TODOs ({root_path})", colour=show_animation, silent=silent):
|
|
53
|
+
todos = scan_todos(files)
|
|
54
|
+
|
|
55
|
+
with Spinner(f"Scanning security patterns ({root_path})", colour=show_animation, silent=silent):
|
|
56
|
+
security = scan_security(files)
|
|
57
|
+
|
|
58
|
+
with Spinner(f"Detecting duplicates ({root_path})", colour=show_animation, silent=silent):
|
|
59
|
+
duplicates = scan_duplicates(files, args.duplicate_lines)
|
|
60
|
+
|
|
61
|
+
with Spinner(f"Checking project structure ({root_path})", colour=show_animation, silent=silent):
|
|
62
|
+
structure = check_project_structure(root_path)
|
|
63
|
+
|
|
64
|
+
with Spinner(f"Reading Git info ({root_path})", colour=show_animation, silent=silent):
|
|
65
|
+
git_info = get_git_info(root_path)
|
|
66
|
+
|
|
67
|
+
repo_name = os.path.basename(os.path.abspath(root_path)) or "Unknown"
|
|
68
|
+
|
|
69
|
+
# AI & Advanced analytics (computed just in time)
|
|
70
|
+
all_words = []
|
|
71
|
+
for f in files:
|
|
72
|
+
try:
|
|
73
|
+
with open(f.path, 'r', encoding='utf-8', errors='ignore') as file_handle:
|
|
74
|
+
content = file_handle.read()
|
|
75
|
+
f._words = re.findall(r'\b[a-zA-Z_]{3,}\b', content)
|
|
76
|
+
all_words.extend(f._words)
|
|
77
|
+
except Exception:
|
|
78
|
+
f._words = []
|
|
79
|
+
|
|
80
|
+
positive_words = {"awesome", "great", "excellent", "amazing", "good", "perfect", "wow", "love", "thanks", "beautiful", "brilliant", "clean", "elegant", "smart"}
|
|
81
|
+
negative_words = {"fuck", "shit", "crap", "bitch", "damn", "hate", "ugly", "stupid", "terrible", "awful", "horrible", "mess", "hack", "fixme", "gross", "disgusting", "wtf"}
|
|
82
|
+
|
|
83
|
+
pos_count = sum(1 for f in files for w in getattr(f, "_words", []) if w.lower() in positive_words)
|
|
84
|
+
neg_count = sum(1 for f in files for w in getattr(f, "_words", []) if w.lower() in negative_words)
|
|
85
|
+
|
|
86
|
+
if pos_count == 0 and neg_count == 0:
|
|
87
|
+
mood_str = "Neutral 😐 (0 positive, 0 negative words)"
|
|
88
|
+
elif pos_count > neg_count * 2:
|
|
89
|
+
mood_str = f"Highly Motivated 🚀 ({pos_count} positive, {neg_count} negative words)"
|
|
90
|
+
elif neg_count > pos_count * 2:
|
|
91
|
+
mood_str = f"Severely Frustrated 😡 ({pos_count} positive, {neg_count} negative words)"
|
|
92
|
+
else:
|
|
93
|
+
mood_str = f"Balanced ⚖️ ({pos_count} positive, {neg_count} negative words)"
|
|
94
|
+
|
|
95
|
+
clone_str = "No major clones detected 👏"
|
|
96
|
+
if len(files) > 1:
|
|
97
|
+
try:
|
|
98
|
+
import difflib
|
|
99
|
+
texts = [(f, " ".join(getattr(f, "_words", []))) for f in files if len(getattr(f, "_words", [])) > 50]
|
|
100
|
+
if len(texts) > 1:
|
|
101
|
+
texts.sort(key=lambda x: len(x[1]), reverse=True)
|
|
102
|
+
top_files = texts[:10]
|
|
103
|
+
best_ratio = 0
|
|
104
|
+
best_pair = None
|
|
105
|
+
for i in range(len(top_files)):
|
|
106
|
+
for j in range(i+1, len(top_files)):
|
|
107
|
+
ratio = difflib.SequenceMatcher(None, top_files[i][1], top_files[j][1]).quick_ratio()
|
|
108
|
+
if ratio > best_ratio:
|
|
109
|
+
best_ratio = ratio
|
|
110
|
+
best_pair = (top_files[i][0].path, top_files[j][0].path)
|
|
111
|
+
if best_ratio > 0.8:
|
|
112
|
+
clone_str = f"{best_pair[0]} & {best_pair[1]} ({int(best_ratio*100)}% identical)"
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
stop_words = {"the", "and", "but", "for", "with", "was", "were", "been", "being", "have", "has", "had", "will", "would", "shall", "should", "can", "could", "may", "might", "must", "then", "else", "while", "def", "class", "return", "import", "from", "print", "self", "None", "True", "False"}
|
|
117
|
+
filtered_words = [w for w in all_words if len(w) > 3 and w.lower() not in stop_words]
|
|
118
|
+
top_words = collections.Counter(filtered_words).most_common(5)
|
|
119
|
+
|
|
120
|
+
data = ReportData(
|
|
121
|
+
path=os.path.abspath(root_path),
|
|
122
|
+
name=repo_name,
|
|
123
|
+
files=files,
|
|
124
|
+
todos=todos,
|
|
125
|
+
security=security,
|
|
126
|
+
duplicates=duplicates,
|
|
127
|
+
structure=structure,
|
|
128
|
+
git=git_info,
|
|
129
|
+
score=None
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
data.mood = mood_str
|
|
133
|
+
data.clone_exposer = clone_str
|
|
134
|
+
data.top_words = top_words
|
|
135
|
+
|
|
136
|
+
score = calculate_score(data)
|
|
137
|
+
data.score = score
|
|
138
|
+
|
|
139
|
+
local_exit_code = 0
|
|
140
|
+
if score and score.score < getattr(args, "fail_under", 0):
|
|
141
|
+
local_exit_code = 1
|
|
142
|
+
|
|
143
|
+
deltas = None
|
|
144
|
+
if getattr(args, "baseline", None) and os.path.exists(args.baseline):
|
|
145
|
+
try:
|
|
146
|
+
import json
|
|
147
|
+
with open(args.baseline, "r") as bf:
|
|
148
|
+
base_data = json.load(bf)
|
|
149
|
+
if "score" in base_data and data.score:
|
|
150
|
+
deltas = {"score": data.score.score - base_data["score"]}
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
# Generate badge SVG
|
|
155
|
+
badge_svg = None
|
|
156
|
+
badge_path = None
|
|
157
|
+
if getattr(args, "badge", None):
|
|
158
|
+
color = "#4c1" if score.score >= 90 else ("#dfb317" if score.score >= 70 else "#e05d44")
|
|
159
|
+
badge_svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="140" height="20">
|
|
160
|
+
<linearGradient id="b" x2="0" y2="100%">
|
|
161
|
+
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
|
162
|
+
<stop offset="1" stop-opacity=".1"/>
|
|
163
|
+
</linearGradient>
|
|
164
|
+
<mask id="a">
|
|
165
|
+
<rect width="140" height="20" rx="3" fill="#fff"/>
|
|
166
|
+
</mask>
|
|
167
|
+
<g mask="url(#a)">
|
|
168
|
+
<path fill="#555" d="M0 0h80v20H0z"/>
|
|
169
|
+
<path fill="{color}" d="M80 0h60v20H0z"/>
|
|
170
|
+
<path fill="url(#b)" d="M0 0h140v20H0z"/>
|
|
171
|
+
</g>
|
|
172
|
+
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
|
|
173
|
+
<text x="40" y="15" fill="#010101" fill-opacity=".3">RepoDoctor</text>
|
|
174
|
+
<text x="40" y="14">RepoDoctor</text>
|
|
175
|
+
<text x="109" y="15" fill="#010101" fill-opacity=".3">{score.score}/100</text>
|
|
176
|
+
<text x="109" y="14">{score.score}/100</text>
|
|
177
|
+
</g>
|
|
178
|
+
</svg>'''
|
|
179
|
+
badge_path = args.badge
|
|
180
|
+
if len(args.path) > 1:
|
|
181
|
+
base, ext = os.path.splitext(badge_path)
|
|
182
|
+
badge_path = f"{base}_{idx+1}{ext}"
|
|
183
|
+
|
|
184
|
+
# Capture terminal report
|
|
185
|
+
terminal_report = ""
|
|
186
|
+
repo_duration = time.time() - repo_start_time
|
|
187
|
+
if not args.json:
|
|
188
|
+
f_buf = io.StringIO()
|
|
189
|
+
with contextlib.redirect_stdout(f_buf):
|
|
190
|
+
use_color = not args.no_color and sys.stdout.isatty()
|
|
191
|
+
print_terminal_report(data, use_color, args.large_file_lines, deltas, repo_duration, getattr(args, 'tree', False))
|
|
192
|
+
terminal_report = f_buf.getvalue()
|
|
193
|
+
|
|
194
|
+
# Generate JSON
|
|
195
|
+
json_report = None
|
|
196
|
+
if args.json:
|
|
197
|
+
from .report import get_json_report
|
|
198
|
+
import json
|
|
199
|
+
json_report = json.loads(get_json_report(data, args.large_file_lines))
|
|
200
|
+
|
|
201
|
+
# Generate HTML
|
|
202
|
+
html_report = None
|
|
203
|
+
if args.html:
|
|
204
|
+
html_report = generate_html_report(data, args.large_file_lines)
|
|
205
|
+
|
|
206
|
+
# Generate LLM Export
|
|
207
|
+
llm_report = None
|
|
208
|
+
if args.export_prompt:
|
|
209
|
+
prompt_chunk = f"=== REPOSITORY: {repo_name} ===\n\n"
|
|
210
|
+
for file_info in files:
|
|
211
|
+
prompt_chunk += f"--- {file_info.path} ---\n"
|
|
212
|
+
try:
|
|
213
|
+
with open(file_info.path, "r", encoding="utf-8", errors="ignore") as src:
|
|
214
|
+
prompt_chunk += src.read() + "\n\n"
|
|
215
|
+
except Exception:
|
|
216
|
+
prompt_chunk += "[Error reading file contents]\n\n"
|
|
217
|
+
llm_report = prompt_chunk
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
"idx": idx,
|
|
221
|
+
"repo_name": repo_name,
|
|
222
|
+
"exit_code": local_exit_code,
|
|
223
|
+
"badge_svg": badge_svg,
|
|
224
|
+
"badge_path": badge_path,
|
|
225
|
+
"terminal_report": terminal_report,
|
|
226
|
+
"json_report": json_report,
|
|
227
|
+
"html_report": html_report,
|
|
228
|
+
"llm_report": llm_report,
|
|
229
|
+
"duration": repo_duration,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
def main():
|
|
233
|
+
start_time = time.time()
|
|
234
|
+
args = parse_args()
|
|
235
|
+
|
|
236
|
+
# 1. Print Banner & Greeting
|
|
237
|
+
use_color = not args.no_color and sys.stdout.isatty()
|
|
238
|
+
def c(text, code):
|
|
239
|
+
return f"\033[{code}m{text}\033[0m" if use_color else text
|
|
240
|
+
|
|
241
|
+
if not args.json:
|
|
242
|
+
print()
|
|
243
|
+
print(c("╔════════════════════════════════════════════════════════════╗", "94;1"))
|
|
244
|
+
print(c("║ ", "94;1"), end="")
|
|
245
|
+
for char in "REPO DOCTOR":
|
|
246
|
+
print(c(char, "96;1"), end="")
|
|
247
|
+
sys.stdout.flush()
|
|
248
|
+
time.sleep(0.05)
|
|
249
|
+
print(c(" ║", "94;1"))
|
|
250
|
+
print(c("╚════════════════════════════════════════════════════════════╝", "94;1"))
|
|
251
|
+
print(c("Welcome to RepoDoctor! 🩺", "92;1"))
|
|
252
|
+
print(c("Initializing zero-dependency static analysis engine...", "90;1"))
|
|
253
|
+
print()
|
|
254
|
+
time.sleep(0.5)
|
|
255
|
+
|
|
256
|
+
root_paths = args.path
|
|
257
|
+
if not root_paths:
|
|
258
|
+
root_paths = ['.']
|
|
259
|
+
|
|
260
|
+
# Validate all directories first
|
|
261
|
+
for rp in root_paths:
|
|
262
|
+
if not os.path.isdir(rp):
|
|
263
|
+
print(f"Error: {rp} is not a directory.")
|
|
264
|
+
sys.exit(2)
|
|
265
|
+
|
|
266
|
+
custom_ignores = args.ignore.split(",") if args.ignore else []
|
|
267
|
+
show_animation = not getattr(args, "no_animation", False)
|
|
268
|
+
use_parallel = getattr(args, "parallel", False)
|
|
269
|
+
|
|
270
|
+
html_outputs = []
|
|
271
|
+
json_outputs = []
|
|
272
|
+
llm_outputs = []
|
|
273
|
+
exit_code = 0
|
|
274
|
+
|
|
275
|
+
# If scanning multiple repositories, notify the user we are processing them in parallel
|
|
276
|
+
if len(root_paths) > 1 and not args.json:
|
|
277
|
+
print(c(f"Starting parallel analysis on {len(root_paths)} repositories...", "96"))
|
|
278
|
+
print()
|
|
279
|
+
|
|
280
|
+
# Use ThreadPoolExecutor to run analyses in parallel
|
|
281
|
+
analysis_start_time = time.time()
|
|
282
|
+
max_workers = min(len(root_paths), (os.cpu_count() or 1) + 4)
|
|
283
|
+
results = []
|
|
284
|
+
|
|
285
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
286
|
+
futures = {
|
|
287
|
+
executor.submit(
|
|
288
|
+
process_single_repo, rp, args, idx, custom_ignores, use_parallel, show_animation, start_time
|
|
289
|
+
): rp
|
|
290
|
+
for idx, rp in enumerate(root_paths)
|
|
291
|
+
}
|
|
292
|
+
for future in concurrent.futures.as_completed(futures):
|
|
293
|
+
rp = futures[future]
|
|
294
|
+
try:
|
|
295
|
+
res = future.result()
|
|
296
|
+
results.append(res)
|
|
297
|
+
if len(root_paths) > 1 and not args.json:
|
|
298
|
+
print(c(f"✔ Completed analysis of {res['repo_name']}", "92"))
|
|
299
|
+
except Exception as e:
|
|
300
|
+
print(f"Error analyzing {rp}: {e}", file=sys.stderr)
|
|
301
|
+
exit_code = max(exit_code, 1)
|
|
302
|
+
|
|
303
|
+
if len(root_paths) > 1 and not args.json:
|
|
304
|
+
print()
|
|
305
|
+
print(c("All analyses completed. Generating reports...", "90"))
|
|
306
|
+
print()
|
|
307
|
+
|
|
308
|
+
# Sort results by their original path order to keep output deterministic
|
|
309
|
+
results.sort(key=lambda x: x["idx"])
|
|
310
|
+
|
|
311
|
+
for res in results:
|
|
312
|
+
# Update exit code
|
|
313
|
+
exit_code = max(exit_code, res["exit_code"])
|
|
314
|
+
|
|
315
|
+
# Write Badge
|
|
316
|
+
if res["badge_path"] and res["badge_svg"]:
|
|
317
|
+
try:
|
|
318
|
+
with open(res["badge_path"], "w", encoding="utf-8") as bf:
|
|
319
|
+
bf.write(res["badge_svg"])
|
|
320
|
+
except Exception:
|
|
321
|
+
pass
|
|
322
|
+
|
|
323
|
+
# Print Terminal Report
|
|
324
|
+
if not args.json and res["terminal_report"]:
|
|
325
|
+
print(res["terminal_report"])
|
|
326
|
+
|
|
327
|
+
# Accumulate reports
|
|
328
|
+
if res["json_report"] is not None:
|
|
329
|
+
json_outputs.append(res["json_report"])
|
|
330
|
+
if res["html_report"] is not None:
|
|
331
|
+
html_outputs.append(res["html_report"])
|
|
332
|
+
if res["llm_report"] is not None:
|
|
333
|
+
llm_outputs.append(res["llm_report"])
|
|
334
|
+
|
|
335
|
+
if args.json and json_outputs:
|
|
336
|
+
import json
|
|
337
|
+
if len(json_outputs) == 1:
|
|
338
|
+
print(json.dumps(json_outputs[0], indent=2))
|
|
339
|
+
else:
|
|
340
|
+
print(json.dumps(json_outputs, indent=2))
|
|
341
|
+
|
|
342
|
+
if args.html and html_outputs:
|
|
343
|
+
try:
|
|
344
|
+
with open(args.html, "w", encoding="utf-8") as f:
|
|
345
|
+
f.write("\n<hr>\n<br><br>\n".join(html_outputs))
|
|
346
|
+
print(f"HTML report successfully written to {args.html}")
|
|
347
|
+
except Exception as e:
|
|
348
|
+
print(f"Failed to write HTML report: {e}")
|
|
349
|
+
sys.exit(3)
|
|
350
|
+
|
|
351
|
+
if args.export_prompt and llm_outputs:
|
|
352
|
+
try:
|
|
353
|
+
with open(args.export_prompt, "w", encoding="utf-8") as f:
|
|
354
|
+
f.write("\n\n".join(llm_outputs))
|
|
355
|
+
print(f"LLM prompt successfully exported to {args.export_prompt}")
|
|
356
|
+
except Exception as e:
|
|
357
|
+
print(f"Failed to export LLM prompt: {e}")
|
|
358
|
+
sys.exit(3)
|
|
359
|
+
|
|
360
|
+
if len(root_paths) > 1 and not args.json:
|
|
361
|
+
total_analysis_time = time.time() - analysis_start_time
|
|
362
|
+
print(c("────────────────────────────────────────────────────────────", "90"))
|
|
363
|
+
print(c(f"⚡ Concurrently analyzed {len(root_paths)} repositories in {total_analysis_time:.2f}s", "96;1"))
|
|
364
|
+
print(c("────────────────────────────────────────────────────────────", "90"))
|
|
365
|
+
print()
|
|
366
|
+
|
|
367
|
+
sys.exit(exit_code)
|
|
368
|
+
|
|
369
|
+
if __name__ == "__main__":
|
|
370
|
+
main()
|
repodoctor/baseline.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from typing import Dict, Optional
|
|
4
|
+
from .models import ReportData
|
|
5
|
+
|
|
6
|
+
def compare_baseline(current_data: ReportData, baseline_path: str) -> Optional[Dict[str, int]]:
|
|
7
|
+
if not os.path.exists(baseline_path):
|
|
8
|
+
return None
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
with open(baseline_path, 'r', encoding='utf-8') as f:
|
|
12
|
+
baseline = json.load(f)
|
|
13
|
+
|
|
14
|
+
deltas = {}
|
|
15
|
+
|
|
16
|
+
# Current values
|
|
17
|
+
c_score = current_data.score.score if current_data.score else 0
|
|
18
|
+
c_files = len(current_data.files)
|
|
19
|
+
c_lines = sum(f.lines for f in current_data.files)
|
|
20
|
+
c_todos = len(current_data.todos)
|
|
21
|
+
c_dups = len(current_data.duplicates)
|
|
22
|
+
c_secrets = len(current_data.security)
|
|
23
|
+
|
|
24
|
+
# Baseline values
|
|
25
|
+
b_score = baseline.get("summary", {}).get("health_score", 0)
|
|
26
|
+
b_files = baseline.get("summary", {}).get("files", 0)
|
|
27
|
+
b_lines = baseline.get("summary", {}).get("lines", 0)
|
|
28
|
+
b_todos = baseline.get("maintainability", {}).get("todos", 0)
|
|
29
|
+
b_dups = baseline.get("maintainability", {}).get("duplicates", 0)
|
|
30
|
+
b_secrets = baseline.get("security", {}).get("potential_secrets", 0)
|
|
31
|
+
|
|
32
|
+
deltas["score"] = c_score - (b_score or 0)
|
|
33
|
+
deltas["files"] = c_files - b_files
|
|
34
|
+
deltas["lines"] = c_lines - b_lines
|
|
35
|
+
deltas["todos"] = c_todos - b_todos
|
|
36
|
+
deltas["duplicates"] = c_dups - b_dups
|
|
37
|
+
deltas["secrets"] = c_secrets - b_secrets
|
|
38
|
+
|
|
39
|
+
return deltas
|
|
40
|
+
|
|
41
|
+
except Exception:
|
|
42
|
+
return None
|
repodoctor/cli.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
5
|
+
parser = argparse.ArgumentParser(
|
|
6
|
+
prog="repodoctor",
|
|
7
|
+
description="RepoDoctor diagnoses a codebase for maintainability, security, duplication, project-structure and Git issues using only the language standard library.",
|
|
8
|
+
formatter_class=argparse.RawDescriptionHelpFormatter
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
parser.add_argument(
|
|
12
|
+
"path",
|
|
13
|
+
help="Path to the repository to analyze",
|
|
14
|
+
default=["."],
|
|
15
|
+
nargs="*"
|
|
16
|
+
)
|
|
17
|
+
parser.add_argument("--json", action="store_true", help="Output valid machine-readable JSON")
|
|
18
|
+
parser.add_argument("--html", type=str, help="Output a self-contained HTML report to the specified file", default="")
|
|
19
|
+
parser.add_argument("--baseline", type=str, help="Path to a previous JSON report to compare against", default="")
|
|
20
|
+
parser.add_argument("--no-color", action="store_true", help="Disable ANSI color output")
|
|
21
|
+
parser.add_argument("--badge", type=str, metavar="FILE", help="Generate an SVG health badge", default="")
|
|
22
|
+
parser.add_argument("--tree", action="store_true", help="Print an ASCII project tree")
|
|
23
|
+
parser.add_argument("--export-prompt", type=str, metavar="FILE", help="Export the codebase into a single text file for LLM prompting", default="")
|
|
24
|
+
parser.add_argument("--ignore", type=str, help="Comma-separated list of custom directories to ignore", default="")
|
|
25
|
+
parser.add_argument("--large-file-lines", type=int, help="Threshold for large file lines", default=500)
|
|
26
|
+
parser.add_argument("--duplicate-lines", type=int, help="Minimum lines for duplicate detection", default=8)
|
|
27
|
+
parser.add_argument("--security", action="store_true", help="Focus only on security analysis")
|
|
28
|
+
parser.add_argument("--todos", action="store_true", help="Focus only on TODO/FIXME analysis")
|
|
29
|
+
parser.add_argument("--git", action="store_true", help="Include Git analysis")
|
|
30
|
+
parser.add_argument("--verbose", action="store_true", help="Enable verbose logging")
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--parallel", "-j",
|
|
33
|
+
action="store_true",
|
|
34
|
+
help="Enable parallel file scanning using concurrent.futures.ThreadPoolExecutor (faster on large repos)"
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--no-animation",
|
|
38
|
+
action="store_true",
|
|
39
|
+
help="Disable live CLI spinner / progress bar animation"
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument("--version", action="version", version="%(prog)s 1.0.0")
|
|
42
|
+
|
|
43
|
+
return parser
|
|
44
|
+
|
|
45
|
+
def parse_args(args=None):
|
|
46
|
+
parser = build_parser()
|
|
47
|
+
return parser.parse_args(args)
|
repodoctor/duplicates.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
from typing import List, Dict, Tuple
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from .models import FileInfo, DuplicateBlock
|
|
5
|
+
|
|
6
|
+
def normalize_line(line: str) -> str:
|
|
7
|
+
"""Strip whitespace and ignore if it's too short to be useful code."""
|
|
8
|
+
return line.strip()
|
|
9
|
+
|
|
10
|
+
def scan_duplicates(files: List[FileInfo], min_lines: int = 8) -> List[DuplicateBlock]:
|
|
11
|
+
block_hashes = defaultdict(list)
|
|
12
|
+
duplicates = []
|
|
13
|
+
|
|
14
|
+
for f in files:
|
|
15
|
+
if f.is_binary:
|
|
16
|
+
continue
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
with open(f.path, 'r', encoding='utf-8', errors='ignore') as file:
|
|
20
|
+
lines = file.readlines()
|
|
21
|
+
except Exception:
|
|
22
|
+
continue
|
|
23
|
+
|
|
24
|
+
valid_lines = []
|
|
25
|
+
for idx, line in enumerate(lines):
|
|
26
|
+
norm = normalize_line(line)
|
|
27
|
+
# basic ignore for very short lines, blank lines, or common comments
|
|
28
|
+
if not norm or len(norm) < 4 or norm.startswith('#') or norm.startswith('//'):
|
|
29
|
+
continue
|
|
30
|
+
valid_lines.append((idx + 1, norm))
|
|
31
|
+
|
|
32
|
+
if len(valid_lines) < min_lines:
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
# Create rolling window of hashes
|
|
36
|
+
for i in range(len(valid_lines) - min_lines + 1):
|
|
37
|
+
window = valid_lines[i:i + min_lines]
|
|
38
|
+
start_line = window[0][0]
|
|
39
|
+
end_line = window[-1][0]
|
|
40
|
+
|
|
41
|
+
# Create block text
|
|
42
|
+
block_text = "".join(x[1] for x in window)
|
|
43
|
+
h = hashlib.sha256(block_text.encode('utf-8')).hexdigest()
|
|
44
|
+
|
|
45
|
+
block_hashes[h].append((f.relative_path, start_line, end_line))
|
|
46
|
+
|
|
47
|
+
# Find duplicates
|
|
48
|
+
# Since rolling windows produce overlapping duplicates, we should just report them simply.
|
|
49
|
+
# A true robust algorithm would merge overlapping blocks, but for MVP we just report unique combinations.
|
|
50
|
+
reported_combinations = set()
|
|
51
|
+
|
|
52
|
+
for h, occurrences in block_hashes.items():
|
|
53
|
+
if len(occurrences) > 1:
|
|
54
|
+
paths = [occ[0] for occ in occurrences]
|
|
55
|
+
|
|
56
|
+
# Simple deduplication of reports (e.g. if we have 9 duplicated lines, it will create two 8-line blocks)
|
|
57
|
+
# We just take the first start_line and end_line for simplicity in MVP.
|
|
58
|
+
combo_key = tuple(sorted(paths))
|
|
59
|
+
if combo_key not in reported_combinations:
|
|
60
|
+
# Approximate the lines
|
|
61
|
+
# The format is just showing that these files share duplicate code blocks.
|
|
62
|
+
duplicates.append(DuplicateBlock(
|
|
63
|
+
filepaths=paths,
|
|
64
|
+
lines=(occurrences[0][1], occurrences[0][2]),
|
|
65
|
+
similarity="exact"
|
|
66
|
+
))
|
|
67
|
+
reported_combinations.add(combo_key)
|
|
68
|
+
|
|
69
|
+
return duplicates
|
repodoctor/git.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import os
|
|
3
|
+
from .models import GitInfo
|
|
4
|
+
|
|
5
|
+
def run_git(cmd: list, cwd: str) -> str:
|
|
6
|
+
try:
|
|
7
|
+
result = subprocess.run(
|
|
8
|
+
["git"] + cmd,
|
|
9
|
+
cwd=cwd,
|
|
10
|
+
stdout=subprocess.PIPE,
|
|
11
|
+
stderr=subprocess.DEVNULL,
|
|
12
|
+
text=True,
|
|
13
|
+
check=True
|
|
14
|
+
)
|
|
15
|
+
return result.stdout.strip()
|
|
16
|
+
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
|
|
17
|
+
return ""
|
|
18
|
+
|
|
19
|
+
def get_git_info(root_path: str) -> GitInfo:
|
|
20
|
+
root = os.path.abspath(root_path)
|
|
21
|
+
|
|
22
|
+
is_git_repo = run_git(["rev-parse", "--is-inside-work-tree"], root)
|
|
23
|
+
if is_git_repo != "true":
|
|
24
|
+
return GitInfo(available=False)
|
|
25
|
+
|
|
26
|
+
branch = run_git(["branch", "--show-current"], root)
|
|
27
|
+
if not branch:
|
|
28
|
+
branch = "detached"
|
|
29
|
+
|
|
30
|
+
commits_str = run_git(["rev-list", "--count", "HEAD"], root)
|
|
31
|
+
commits = int(commits_str) if commits_str.isdigit() else 0
|
|
32
|
+
|
|
33
|
+
status_str = run_git(["status", "--porcelain"], root)
|
|
34
|
+
uncommitted = len(status_str.splitlines()) if status_str else 0
|
|
35
|
+
|
|
36
|
+
top_contributor = ""
|
|
37
|
+
try:
|
|
38
|
+
result = subprocess.run(["git", "shortlog", "-sn", "HEAD"], cwd=root, capture_output=True, text=True, check=True)
|
|
39
|
+
lines = result.stdout.splitlines()
|
|
40
|
+
if lines and lines[0]:
|
|
41
|
+
parts = lines[0].strip().split('\t', 1)
|
|
42
|
+
if len(parts) == 2:
|
|
43
|
+
top_contributor = f"{parts[1].strip()} ({parts[0].strip()} commits)"
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
hotspot = ""
|
|
48
|
+
try:
|
|
49
|
+
result = subprocess.run(["git", "log", "--name-only", "--pretty=format:"], cwd=root, capture_output=True, text=True, check=True)
|
|
50
|
+
files = [f for f in result.stdout.split('\n') if f.strip()]
|
|
51
|
+
if files:
|
|
52
|
+
from collections import Counter
|
|
53
|
+
c = Counter(files)
|
|
54
|
+
most_common = c.most_common(1)
|
|
55
|
+
if most_common:
|
|
56
|
+
hotspot = f"{most_common[0][0]} ({most_common[0][1]} edits)"
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
return GitInfo(
|
|
61
|
+
available=True,
|
|
62
|
+
branch=branch,
|
|
63
|
+
uncommitted_changes=uncommitted,
|
|
64
|
+
commits=commits,
|
|
65
|
+
top_contributor=top_contributor,
|
|
66
|
+
hotspot=hotspot
|
|
67
|
+
)
|
repodoctor/languages.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from .models import FileInfo
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
EXTENSION_MAP = {
|
|
5
|
+
".py": "Python",
|
|
6
|
+
".js": "JavaScript",
|
|
7
|
+
".ts": "TypeScript",
|
|
8
|
+
".java": "Java",
|
|
9
|
+
".go": "Go",
|
|
10
|
+
".rs": "Rust",
|
|
11
|
+
".c": "C",
|
|
12
|
+
".h": "C/C++",
|
|
13
|
+
".cpp": "C++",
|
|
14
|
+
".hpp": "C++",
|
|
15
|
+
".cs": "C#",
|
|
16
|
+
".kt": "Kotlin",
|
|
17
|
+
".rb": "Ruby",
|
|
18
|
+
".php": "PHP",
|
|
19
|
+
".html": "HTML",
|
|
20
|
+
".css": "CSS",
|
|
21
|
+
".json": "JSON",
|
|
22
|
+
".yaml": "YAML",
|
|
23
|
+
".yml": "YAML",
|
|
24
|
+
".md": "Markdown",
|
|
25
|
+
".sh": "Shell",
|
|
26
|
+
".sql": "SQL"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
def detect_languages(files: List[FileInfo]) -> None:
|
|
30
|
+
for f in files:
|
|
31
|
+
if f.is_binary:
|
|
32
|
+
continue
|
|
33
|
+
f.language = EXTENSION_MAP.get(f.extension, "Unknown")
|