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/report.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
from .models import ReportData
|
|
5
|
+
|
|
6
|
+
from typing import Dict, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def print_project_tree(data, c_func):
|
|
10
|
+
print(c_func("PROJECT TREE", "1"))
|
|
11
|
+
print(c_func("────────────────────────────────────────────────────────────", "90"))
|
|
12
|
+
paths = [f.relative_path.replace("\\", "/") for f in data.files]
|
|
13
|
+
tree = {}
|
|
14
|
+
for p in paths:
|
|
15
|
+
parts = p.split("/")
|
|
16
|
+
curr = tree
|
|
17
|
+
for part in parts:
|
|
18
|
+
if part not in curr:
|
|
19
|
+
curr[part] = {}
|
|
20
|
+
curr = curr[part]
|
|
21
|
+
|
|
22
|
+
lines = []
|
|
23
|
+
def traverse(node, prefix=""):
|
|
24
|
+
if len(lines) > 50: return
|
|
25
|
+
keys = sorted(list(node.keys()))
|
|
26
|
+
for i, key in enumerate(keys):
|
|
27
|
+
is_last = (i == len(keys) - 1)
|
|
28
|
+
lines.append(prefix + ("└── " if is_last else "├── ") + key)
|
|
29
|
+
traverse(node[key], prefix + (" " if is_last else "│ "))
|
|
30
|
+
|
|
31
|
+
traverse(tree)
|
|
32
|
+
if len(lines) > 50: lines.append("... (tree truncated)")
|
|
33
|
+
print("\n".join(lines))
|
|
34
|
+
print()
|
|
35
|
+
|
|
36
|
+
def print_terminal_report(data: ReportData, use_color: bool = True, large_file_threshold: int = 500, deltas: Optional[Dict[str, int]] = None, exec_time: Optional[float] = None, show_tree: bool = False) -> None:
|
|
37
|
+
def c(text, code):
|
|
38
|
+
return f"\033[{code}m{text}\033[0m" if use_color else text
|
|
39
|
+
print()
|
|
40
|
+
print(f"Repository: {data.name}")
|
|
41
|
+
print(f"Path: {data.path}")
|
|
42
|
+
if deltas:
|
|
43
|
+
print(c("Baseline comparison activated.", "36"))
|
|
44
|
+
print()
|
|
45
|
+
|
|
46
|
+
if show_tree:
|
|
47
|
+
print_project_tree(data, c)
|
|
48
|
+
|
|
49
|
+
print(c("SUMMARY", "1"))
|
|
50
|
+
print("────────────────────────────────────────────────────────────")
|
|
51
|
+
|
|
52
|
+
files_str = f"{len(data.files)}{fmt_delta(deltas['files']) if deltas else ''}"
|
|
53
|
+
print(f"Files scanned: {files_str}")
|
|
54
|
+
|
|
55
|
+
total_lines = sum(f.lines for f in data.files)
|
|
56
|
+
lines_str = f"{total_lines:,}{fmt_delta(deltas['lines']) if deltas else ''}"
|
|
57
|
+
print(f"Lines of code: {lines_str}")
|
|
58
|
+
|
|
59
|
+
languages = set(f.language for f in data.files if f.language != "Unknown")
|
|
60
|
+
lang_lines = {}
|
|
61
|
+
for f in data.files:
|
|
62
|
+
if f.language != "Unknown":
|
|
63
|
+
lang_lines[f.language] = lang_lines.get(f.language, 0) + f.lines
|
|
64
|
+
|
|
65
|
+
total_lang_lines = sum(lang_lines.values())
|
|
66
|
+
if total_lang_lines > 0:
|
|
67
|
+
print(f"Languages detected:")
|
|
68
|
+
for lang, llines in sorted(lang_lines.items(), key=lambda x: x[1], reverse=True):
|
|
69
|
+
pct = (llines / total_lang_lines) * 100
|
|
70
|
+
bar_len = int(pct / 5)
|
|
71
|
+
bar = "█ " * bar_len
|
|
72
|
+
print(f" {lang:<18} {bar}{pct:.1f}%\n")
|
|
73
|
+
else:
|
|
74
|
+
print(f"Languages detected: None")
|
|
75
|
+
|
|
76
|
+
if data.score:
|
|
77
|
+
score_str = f"{data.score.score}/100{fmt_delta(deltas['score']) if deltas else ''}"
|
|
78
|
+
print(f"Health score: {score_str}")
|
|
79
|
+
print()
|
|
80
|
+
|
|
81
|
+
print(c("MAINTAINABILITY", "1"))
|
|
82
|
+
print("────────────────────────────────────────────────────────────")
|
|
83
|
+
large_files = sum(1 for f in data.files if f.lines > large_file_threshold)
|
|
84
|
+
print(f"Large files: {large_files}")
|
|
85
|
+
long_functions = sum(f.metrics.num_functions for f in data.files if f.metrics)
|
|
86
|
+
print(f"Long functions: {long_functions}")
|
|
87
|
+
high_nesting = sum(1 for f in data.files if f.metrics and f.metrics.max_nesting > 4)
|
|
88
|
+
print(f"High nesting: {high_nesting}")
|
|
89
|
+
|
|
90
|
+
total_smells = sum(len(f.code_smells) for f in data.files if f.code_smells)
|
|
91
|
+
print(f"Code smells (Linting): {total_smells}")
|
|
92
|
+
|
|
93
|
+
todos_str = f"{len(data.todos)}{fmt_delta(deltas['todos'], inverted=True) if deltas else ''}"
|
|
94
|
+
print(f"TODO/FIXME items: {todos_str}")
|
|
95
|
+
|
|
96
|
+
dups_str = f"{len(data.duplicates)}{fmt_delta(deltas['duplicates'], inverted=True) if deltas else ''}"
|
|
97
|
+
print(f"Duplicate blocks: {dups_str}")
|
|
98
|
+
if data.top_words:
|
|
99
|
+
words_str = ", ".join([f"{w} ({c})" for w, c in data.top_words])
|
|
100
|
+
print(f"Top vocabulary: {words_str}")
|
|
101
|
+
|
|
102
|
+
sorted_files = sorted(data.files, key=lambda f: f.lines, reverse=True)
|
|
103
|
+
if sorted_files and sorted_files[0].lines > 0:
|
|
104
|
+
print("\nHeaviest Files:")
|
|
105
|
+
for i, f in enumerate(sorted_files[:3], 1):
|
|
106
|
+
if f.lines > 0:
|
|
107
|
+
print(f" {i}. {f.relative_path} ({f.lines:,} lines)")
|
|
108
|
+
print()
|
|
109
|
+
|
|
110
|
+
print(c("SECURITY", "1"))
|
|
111
|
+
print("────────────────────────────────────────────────────────────")
|
|
112
|
+
sec_str = f"{len(data.security)}{fmt_delta(deltas['secrets'], inverted=True) if deltas else ''}"
|
|
113
|
+
print(f"Potential secrets: {sec_str}")
|
|
114
|
+
if data.security:
|
|
115
|
+
for sec in data.security:
|
|
116
|
+
print(f" {sec.filepath}:{sec.line_number} - {sec.category} (Confidence: {sec.confidence})")
|
|
117
|
+
print(f" Value: {sec.redacted_value}")
|
|
118
|
+
print()
|
|
119
|
+
|
|
120
|
+
print(c("PROJECT HEALTH", "1"))
|
|
121
|
+
print("────────────────────────────────────────────────────────────")
|
|
122
|
+
for key, val in data.structure.items():
|
|
123
|
+
icon = "✓" if val == "PASS" else ("✗" if val == "FAIL" else ("⚠" if val == "WARN" else "-"))
|
|
124
|
+
print(f"{key:<23} {icon}")
|
|
125
|
+
print()
|
|
126
|
+
|
|
127
|
+
if data.mood:
|
|
128
|
+
print(f"Project Mood: {data.mood}")
|
|
129
|
+
if data.clone_exposer:
|
|
130
|
+
print(f"👯♂️ Clone Exposer: {data.clone_exposer}")
|
|
131
|
+
print()
|
|
132
|
+
|
|
133
|
+
print(c("GIT", "1"))
|
|
134
|
+
print("────────────────────────────────────────────────────────────")
|
|
135
|
+
if data.git.available:
|
|
136
|
+
print(f"Branch: {data.git.branch}")
|
|
137
|
+
print(f"Uncommitted changes: {data.git.uncommitted_changes}")
|
|
138
|
+
print(f"Commits: {data.git.commits}")
|
|
139
|
+
if data.git.top_contributor:
|
|
140
|
+
print(f"Top Contributor: {data.git.top_contributor}")
|
|
141
|
+
if data.git.hotspot:
|
|
142
|
+
print(f"🔥 Hotspot file: {data.git.hotspot}")
|
|
143
|
+
else:
|
|
144
|
+
print("Git repository: Not available")
|
|
145
|
+
print()
|
|
146
|
+
|
|
147
|
+
if data.score:
|
|
148
|
+
print("────────────────────────────────────────────────────────────")
|
|
149
|
+
print(c(f"Health Score: {data.score.score}/100", "92;1" if data.score.score > 80 else "91;1"))
|
|
150
|
+
print(c("Guide: 90+ (Excellent) | 70-89 (Good) | <70 (Needs Work)", "36"))
|
|
151
|
+
print("────────────────────────────────────────────────────────────")
|
|
152
|
+
for reason, change in data.score.breakdown:
|
|
153
|
+
sign = "+" if change > 0 else ""
|
|
154
|
+
print(f"{reason:<30} {sign}{change}")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if exec_time is not None:
|
|
158
|
+
print(c(f"\n⚡ Scan completed in {exec_time:.2f} seconds", "90"))
|
|
159
|
+
|
|
160
|
+
def get_json_report(data: ReportData, large_file_threshold: int = 500) -> str:
|
|
161
|
+
total_lines = sum(f.lines for f in data.files)
|
|
162
|
+
large_files = sum(1 for f in data.files if f.lines > large_file_threshold)
|
|
163
|
+
|
|
164
|
+
out = {
|
|
165
|
+
"repository": {
|
|
166
|
+
"path": data.path,
|
|
167
|
+
"name": data.name
|
|
168
|
+
},
|
|
169
|
+
"summary": {
|
|
170
|
+
"files": len(data.files),
|
|
171
|
+
"lines": total_lines,
|
|
172
|
+
"health_score": data.score.score if data.score else None
|
|
173
|
+
},
|
|
174
|
+
"security": {
|
|
175
|
+
"potential_secrets": len(data.security),
|
|
176
|
+
"findings": [
|
|
177
|
+
{
|
|
178
|
+
"file": s.filepath,
|
|
179
|
+
"line": s.line_number,
|
|
180
|
+
"category": s.category,
|
|
181
|
+
"confidence": s.confidence,
|
|
182
|
+
"explanation": s.explanation,
|
|
183
|
+
# Deliberately omitting full secret, just showing redacted
|
|
184
|
+
"redacted_value": s.redacted_value
|
|
185
|
+
} for s in data.security
|
|
186
|
+
]
|
|
187
|
+
},
|
|
188
|
+
"maintainability": {
|
|
189
|
+
"large_files": large_files,
|
|
190
|
+
"todos": len(data.todos),
|
|
191
|
+
"duplicates": len(data.duplicates)
|
|
192
|
+
},
|
|
193
|
+
"git": {
|
|
194
|
+
"available": data.git.available,
|
|
195
|
+
"branch": data.git.branch,
|
|
196
|
+
"commits": data.git.commits,
|
|
197
|
+
"uncommitted_changes": data.git.uncommitted_changes
|
|
198
|
+
},
|
|
199
|
+
"structure": data.structure
|
|
200
|
+
}
|
|
201
|
+
return json.dumps(out, indent=2)
|
|
202
|
+
|
|
203
|
+
def generate_html_report(data: ReportData, large_file_threshold: int = 500) -> str:
|
|
204
|
+
total_lines = sum(f.lines for f in data.files)
|
|
205
|
+
large_files = sum(1 for f in data.files if f.lines > large_file_threshold)
|
|
206
|
+
long_functions = sum(f.metrics.num_functions for f in data.files if f.metrics)
|
|
207
|
+
high_nesting = sum(1 for f in data.files if f.metrics and f.metrics.max_nesting > 4)
|
|
208
|
+
total_smells = sum(len(f.code_smells) for f in data.files if f.code_smells)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# HTML additions
|
|
212
|
+
sorted_files = sorted(data.files, key=lambda x: x.lines, reverse=True)
|
|
213
|
+
heaviest_files = sorted_files[:3]
|
|
214
|
+
|
|
215
|
+
heavy_html = "".join(f"<li><span>{f.path}</span> <strong>{f.lines:,} lines</strong></li>" for f in heaviest_files) if heaviest_files else "<li>None</li>"
|
|
216
|
+
|
|
217
|
+
security_html = ""
|
|
218
|
+
if data.security:
|
|
219
|
+
security_html = "".join(f"<tr><td>{s.filepath}:{s.line_number}</td><td><span class='badge fail'>{s.category}</span></td><td>{s.redacted_value}</td></tr>" for s in data.security)
|
|
220
|
+
else:
|
|
221
|
+
security_html = "<tr><td colspan='3' style='text-align:center;'>No secrets detected! 🎉</td></tr>"
|
|
222
|
+
|
|
223
|
+
html = f'''<!DOCTYPE html>
|
|
224
|
+
<html lang="en">
|
|
225
|
+
<head>
|
|
226
|
+
<meta charset="utf-8">
|
|
227
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
228
|
+
<title>RepoDoctor Dashboard - {data.name}</title>
|
|
229
|
+
<style>
|
|
230
|
+
:root {{
|
|
231
|
+
--bg-main: #121212;
|
|
232
|
+
--text-main: #e0e0e0;
|
|
233
|
+
--text-muted: #888888;
|
|
234
|
+
--border: #444444;
|
|
235
|
+
--bg-card: #1a1a1a;
|
|
236
|
+
}}
|
|
237
|
+
body {{
|
|
238
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
239
|
+
background-color: var(--bg-main);
|
|
240
|
+
color: var(--text-main);
|
|
241
|
+
margin: 0;
|
|
242
|
+
padding: 40px 20px;
|
|
243
|
+
line-height: 1.5;
|
|
244
|
+
font-size: 14px;
|
|
245
|
+
}}
|
|
246
|
+
.container {{ max-width: 900px; margin: 0 auto; }}
|
|
247
|
+
.header {{ margin-bottom: 40px; border-bottom: 2px dashed var(--border); padding-bottom: 20px; }}
|
|
248
|
+
.header h1 {{
|
|
249
|
+
font-size: 24px;
|
|
250
|
+
margin: 0 0 10px 0;
|
|
251
|
+
color: var(--text-main);
|
|
252
|
+
text-transform: uppercase;
|
|
253
|
+
letter-spacing: 2px;
|
|
254
|
+
}}
|
|
255
|
+
.target-path {{
|
|
256
|
+
color: var(--text-muted);
|
|
257
|
+
margin: 0;
|
|
258
|
+
}}
|
|
259
|
+
.grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-bottom: 40px; }}
|
|
260
|
+
.card {{
|
|
261
|
+
background: var(--bg-card);
|
|
262
|
+
border: 1px solid var(--border);
|
|
263
|
+
padding: 20px;
|
|
264
|
+
}}
|
|
265
|
+
.card h3 {{ margin: 0 0 10px 0; font-size: 12px; color: var(--text-muted); text-transform: uppercase; font-weight: normal; }}
|
|
266
|
+
.card .val {{ font-size: 24px; color: var(--text-main); }}
|
|
267
|
+
|
|
268
|
+
.section-wrapper {{ display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px; }}
|
|
269
|
+
@media (max-width: 768px) {{ .section-wrapper {{ grid-template-columns: 1fr; }} }}
|
|
270
|
+
|
|
271
|
+
.section {{ background: var(--bg-card); padding: 20px; border: 1px solid var(--border); }}
|
|
272
|
+
.section.full {{ grid-column: 1 / -1; margin-bottom: 20px; }}
|
|
273
|
+
.section h2 {{ margin: 0 0 20px 0; font-size: 14px; text-transform: uppercase; color: var(--text-main); border-bottom: 1px dashed var(--border); padding-bottom: 10px; font-weight: normal; }}
|
|
274
|
+
|
|
275
|
+
ul.feature-list {{ list-style: none; padding: 0; margin: 0; }}
|
|
276
|
+
ul.feature-list li {{ padding: 10px 0; border-bottom: 1px dashed var(--border); display: flex; justify-content: space-between; }}
|
|
277
|
+
ul.feature-list li:last-child {{ border-bottom: none; padding-bottom: 0; }}
|
|
278
|
+
|
|
279
|
+
table {{ width: 100%; border-collapse: collapse; margin-top: 10px; }}
|
|
280
|
+
th, td {{ padding: 12px 10px; border-bottom: 1px dashed var(--border); text-align: left; font-weight: normal; }}
|
|
281
|
+
th {{ color: var(--text-muted); text-transform: uppercase; font-size: 12px; }}
|
|
282
|
+
|
|
283
|
+
.badge {{ padding: 2px 6px; font-size: 12px; text-transform: uppercase; border: 1px solid var(--text-main); color: var(--text-main); }}
|
|
284
|
+
</style>
|
|
285
|
+
</head>
|
|
286
|
+
<body>
|
|
287
|
+
<div class="container">
|
|
288
|
+
<div class="header">
|
|
289
|
+
<h1>RepoDoctor</h1>
|
|
290
|
+
<p class="target-path">{data.path}</p>
|
|
291
|
+
</div>
|
|
292
|
+
|
|
293
|
+
<div class="grid">
|
|
294
|
+
<div class="card">
|
|
295
|
+
<h3>Health Score</h3>
|
|
296
|
+
<div class="val">{data.score.score if data.score else 'N/A'}</div>
|
|
297
|
+
</div>
|
|
298
|
+
<div class="card">
|
|
299
|
+
<h3>Files Scanned</h3>
|
|
300
|
+
<div class="val">{len(data.files)}</div>
|
|
301
|
+
</div>
|
|
302
|
+
<div class="card">
|
|
303
|
+
<h3>Lines of Code</h3>
|
|
304
|
+
<div class="val">{total_lines:,}</div>
|
|
305
|
+
</div>
|
|
306
|
+
<div class="card">
|
|
307
|
+
<h3>Security Secrets</h3>
|
|
308
|
+
<div class="val">{len(data.security)}</div>
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
|
|
312
|
+
<div class="section-wrapper">
|
|
313
|
+
<div class="section">
|
|
314
|
+
<h2>Maintainability</h2>
|
|
315
|
+
<ul class="feature-list">
|
|
316
|
+
<li><span>High Complexity</span> <strong>{high_nesting} funcs</strong></li>
|
|
317
|
+
<li><span>Duplicate Blocks</span> <strong>{len(data.duplicates)}</strong></li>
|
|
318
|
+
<li><span>Code Smells</span> <strong>{total_smells}</strong></li>
|
|
319
|
+
<li><span>TODO / FIXME</span> <strong>{len(data.todos)}</strong></li>
|
|
320
|
+
</ul>
|
|
321
|
+
</div>
|
|
322
|
+
|
|
323
|
+
<div class="section">
|
|
324
|
+
<h2>AI & Git Analytics</h2>
|
|
325
|
+
<ul class="feature-list">
|
|
326
|
+
<li><span>Developer Mood</span> <strong>{data.mood if data.mood else 'N/A'}</strong></li>
|
|
327
|
+
<li><span>Clone Exposer</span> <strong>{data.clone_exposer if data.clone_exposer else 'N/A'}</strong></li>
|
|
328
|
+
<li><span>Top Contributor</span> <strong>{data.git.top_contributor if data.git.available and data.git.top_contributor else "N/A"}</strong></li>
|
|
329
|
+
<li><span>Git Hotspot</span> <strong>{data.git.hotspot if data.git.available and data.git.hotspot else "N/A"}</strong></li>
|
|
330
|
+
</ul>
|
|
331
|
+
</div>
|
|
332
|
+
|
|
333
|
+
<div class="section full">
|
|
334
|
+
<h2>Project Structure Validation</h2>
|
|
335
|
+
<table>
|
|
336
|
+
<tr><th>Requirement</th><th>Status</th></tr>
|
|
337
|
+
{''.join(f"<tr><td>{k}</td><td><span class='badge'>{v}</span></td></tr>" for k, v in data.structure.items())}
|
|
338
|
+
</table>
|
|
339
|
+
</div>
|
|
340
|
+
|
|
341
|
+
<div class="section full">
|
|
342
|
+
<h2>Top 3 Heaviest Files</h2>
|
|
343
|
+
<ul class="feature-list">
|
|
344
|
+
{heavy_html}
|
|
345
|
+
</ul>
|
|
346
|
+
</div>
|
|
347
|
+
|
|
348
|
+
<div class="section full" style="margin-bottom: 40px;">
|
|
349
|
+
<h2>Security Findings</h2>
|
|
350
|
+
<table>
|
|
351
|
+
<tr><th>Location</th><th>Category</th><th>Redacted Value</th></tr>
|
|
352
|
+
{security_html}
|
|
353
|
+
</table>
|
|
354
|
+
</div>
|
|
355
|
+
</div>
|
|
356
|
+
|
|
357
|
+
</div>
|
|
358
|
+
</body>
|
|
359
|
+
</html>'''
|
|
360
|
+
return html
|
|
361
|
+
|
repodoctor/scanner.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""
|
|
2
|
+
scanner.py — Repository file walker with optional parallel scanning.
|
|
3
|
+
|
|
4
|
+
Parallel mode (--parallel / -j):
|
|
5
|
+
Uses concurrent.futures.ThreadPoolExecutor with worker count =
|
|
6
|
+
min(32, os.cpu_count() + 4) — the same heuristic CPython uses internally
|
|
7
|
+
for I/O-bound thread pools. No third-party libraries required.
|
|
8
|
+
|
|
9
|
+
Animation:
|
|
10
|
+
When stdout is a TTY and --no-animation is not set, a live ProgressBar
|
|
11
|
+
is shown during scanning. Automatically suppressed in CI / file-redirect
|
|
12
|
+
environments.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import concurrent.futures
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import List, Optional
|
|
19
|
+
|
|
20
|
+
from .models import FileInfo
|
|
21
|
+
from .spinner import ProgressBar
|
|
22
|
+
|
|
23
|
+
DEFAULT_IGNORES = {
|
|
24
|
+
".git", "node_modules", "__pycache__", ".venv", "venv",
|
|
25
|
+
"env", "dist", "build", "target", "coverage", ".cache"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Helpers
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
def is_binary_file(filepath: str, chunk_size: int = 1024) -> bool:
|
|
34
|
+
"""Lightweight heuristic to detect if a file is binary."""
|
|
35
|
+
try:
|
|
36
|
+
with open(filepath, 'rb') as f:
|
|
37
|
+
chunk = f.read(chunk_size)
|
|
38
|
+
if b'\0' in chunk:
|
|
39
|
+
return True
|
|
40
|
+
# Also check if it cannot be decoded as utf-8
|
|
41
|
+
try:
|
|
42
|
+
chunk.decode('utf-8')
|
|
43
|
+
except UnicodeDecodeError:
|
|
44
|
+
return True
|
|
45
|
+
except Exception:
|
|
46
|
+
# If we can't read it, assume binary or unreadable to be safe
|
|
47
|
+
return True
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def count_lines(filepath: str) -> int:
|
|
52
|
+
"""Count lines in a text file efficiently."""
|
|
53
|
+
lines = 0
|
|
54
|
+
try:
|
|
55
|
+
with open(filepath, 'rb') as f:
|
|
56
|
+
for _ in f:
|
|
57
|
+
lines += 1
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
return lines
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _process_file(filepath: str, root_path: str) -> Optional[FileInfo]:
|
|
64
|
+
"""
|
|
65
|
+
Worker function: stat + classify one file.
|
|
66
|
+
Runs inside a ThreadPoolExecutor worker when parallel=True,
|
|
67
|
+
or called directly in sequential mode.
|
|
68
|
+
Returns None on any unrecoverable error so callers can skip it.
|
|
69
|
+
"""
|
|
70
|
+
# Avoid broken symlinks
|
|
71
|
+
if not os.path.exists(filepath):
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
stat_result = os.stat(filepath)
|
|
76
|
+
size = stat_result.st_size
|
|
77
|
+
except OSError:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
rel_path = os.path.relpath(filepath, root_path)
|
|
81
|
+
_, ext = os.path.splitext(os.path.basename(filepath))
|
|
82
|
+
|
|
83
|
+
is_binary = is_binary_file(filepath)
|
|
84
|
+
lines = 0 if is_binary else count_lines(filepath)
|
|
85
|
+
|
|
86
|
+
return FileInfo(
|
|
87
|
+
path=filepath,
|
|
88
|
+
filename=os.path.basename(filepath),
|
|
89
|
+
extension=ext.lower(),
|
|
90
|
+
size=size,
|
|
91
|
+
lines=lines,
|
|
92
|
+
is_binary=is_binary,
|
|
93
|
+
language="Unknown", # populated later by detect_languages()
|
|
94
|
+
relative_path=rel_path,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# Main entry point
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def scan_repository(
|
|
103
|
+
root_path: str,
|
|
104
|
+
custom_ignores: Optional[List[str]] = None,
|
|
105
|
+
parallel: bool = False,
|
|
106
|
+
show_animation: bool = True,
|
|
107
|
+
) -> List[FileInfo]:
|
|
108
|
+
"""
|
|
109
|
+
Walk *root_path* and return a list of FileInfo objects.
|
|
110
|
+
|
|
111
|
+
Parameters
|
|
112
|
+
----------
|
|
113
|
+
root_path : Absolute or relative path to the repository root.
|
|
114
|
+
custom_ignores : Extra directory / file names to skip.
|
|
115
|
+
parallel : If True, use ThreadPoolExecutor for I/O parallelism.
|
|
116
|
+
show_animation : If True (and stdout is a TTY), render a progress bar.
|
|
117
|
+
"""
|
|
118
|
+
ignores = set(DEFAULT_IGNORES)
|
|
119
|
+
if custom_ignores:
|
|
120
|
+
ignores.update(custom_ignores)
|
|
121
|
+
|
|
122
|
+
root_path = os.path.abspath(root_path)
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------ #
|
|
125
|
+
# Phase 1: collect all file paths (fast, sequential walk)
|
|
126
|
+
# ------------------------------------------------------------------ #
|
|
127
|
+
all_paths: List[str] = []
|
|
128
|
+
for dirpath, dirnames, filenames in os.walk(root_path, followlinks=False):
|
|
129
|
+
dirnames[:] = [d for d in dirnames if d not in ignores]
|
|
130
|
+
for filename in filenames:
|
|
131
|
+
if filename in ignores:
|
|
132
|
+
continue
|
|
133
|
+
all_paths.append(os.path.join(dirpath, filename))
|
|
134
|
+
|
|
135
|
+
total = len(all_paths)
|
|
136
|
+
if total == 0:
|
|
137
|
+
return []
|
|
138
|
+
|
|
139
|
+
# ------------------------------------------------------------------ #
|
|
140
|
+
# Phase 2: stat + classify files (sequential or parallel)
|
|
141
|
+
# ------------------------------------------------------------------ #
|
|
142
|
+
bar = ProgressBar(
|
|
143
|
+
total=total,
|
|
144
|
+
label="Scanning files",
|
|
145
|
+
colour=show_animation,
|
|
146
|
+
) if show_animation else None
|
|
147
|
+
|
|
148
|
+
files_info: List[FileInfo] = []
|
|
149
|
+
|
|
150
|
+
if parallel:
|
|
151
|
+
# Worker count: same heuristic as CPython's default I/O pool
|
|
152
|
+
max_workers = min(32, (os.cpu_count() or 1) + 4)
|
|
153
|
+
|
|
154
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
155
|
+
future_to_path = {
|
|
156
|
+
executor.submit(_process_file, p, root_path): p
|
|
157
|
+
for p in all_paths
|
|
158
|
+
}
|
|
159
|
+
for future in concurrent.futures.as_completed(future_to_path):
|
|
160
|
+
result = future.result()
|
|
161
|
+
if result is not None:
|
|
162
|
+
files_info.append(result)
|
|
163
|
+
if bar:
|
|
164
|
+
bar.advance()
|
|
165
|
+
else:
|
|
166
|
+
# Sequential path — unchanged behaviour for small repos / CI
|
|
167
|
+
for filepath in all_paths:
|
|
168
|
+
result = _process_file(filepath, root_path)
|
|
169
|
+
if result is not None:
|
|
170
|
+
files_info.append(result)
|
|
171
|
+
if bar:
|
|
172
|
+
bar.advance()
|
|
173
|
+
|
|
174
|
+
if bar:
|
|
175
|
+
bar.done()
|
|
176
|
+
|
|
177
|
+
return files_info
|
repodoctor/scoring.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from .models import ReportData, HealthScore
|
|
2
|
+
|
|
3
|
+
def calculate_score(data: ReportData, large_file_threshold: int = 500) -> HealthScore:
|
|
4
|
+
base = 85
|
|
5
|
+
breakdown = []
|
|
6
|
+
|
|
7
|
+
if data.structure.get("README") == "PASS":
|
|
8
|
+
base += 5
|
|
9
|
+
breakdown.append(("README present", 5))
|
|
10
|
+
|
|
11
|
+
if data.structure.get("Tests") == "PASS":
|
|
12
|
+
base += 5
|
|
13
|
+
breakdown.append(("Tests detected", 5))
|
|
14
|
+
|
|
15
|
+
if data.structure.get(".gitignore") == "PASS":
|
|
16
|
+
base += 5
|
|
17
|
+
breakdown.append((".gitignore present", 5))
|
|
18
|
+
|
|
19
|
+
# Penalties
|
|
20
|
+
large_files = sum(1 for f in data.files if f.lines > large_file_threshold)
|
|
21
|
+
if large_files > 0:
|
|
22
|
+
penalty = min(15, large_files * 3) # max -15
|
|
23
|
+
base -= penalty
|
|
24
|
+
breakdown.append(("Large files", -penalty))
|
|
25
|
+
|
|
26
|
+
if len(data.todos) > 0:
|
|
27
|
+
penalty = min(10, len(data.todos))
|
|
28
|
+
base -= penalty
|
|
29
|
+
breakdown.append(("TODO/FIXME count", -penalty))
|
|
30
|
+
|
|
31
|
+
if len(data.security) > 0:
|
|
32
|
+
penalty = min(30, len(data.security) * 15)
|
|
33
|
+
base -= penalty
|
|
34
|
+
breakdown.append(("Potential secrets", -penalty))
|
|
35
|
+
|
|
36
|
+
if len(data.duplicates) > 0:
|
|
37
|
+
penalty = min(20, len(data.duplicates) * 5)
|
|
38
|
+
base -= penalty
|
|
39
|
+
breakdown.append(("Duplicate blocks", -penalty))
|
|
40
|
+
|
|
41
|
+
# High complexity (nesting > 4)
|
|
42
|
+
high_complexity = sum(1 for f in data.files if f.metrics and f.metrics.max_nesting > 4)
|
|
43
|
+
if high_complexity > 0:
|
|
44
|
+
penalty = min(10, high_complexity * 2)
|
|
45
|
+
base -= penalty
|
|
46
|
+
breakdown.append(("High complexity", -penalty))
|
|
47
|
+
|
|
48
|
+
base = max(0, min(100, base))
|
|
49
|
+
return HealthScore(score=base, breakdown=breakdown)
|
repodoctor/security.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import List, Tuple
|
|
3
|
+
from .models import FileInfo, SecurityFinding
|
|
4
|
+
|
|
5
|
+
PATTERNS = [
|
|
6
|
+
# (Regex, Category, Confidence, Explanation)
|
|
7
|
+
(re.compile(r'(?i)(?:api_?key|secret|token|password)[\s:=]+[\'"]([A-Za-z0-9_\-]{16,})[\'"]'), "API Key or Token", "HIGH", "A variable name suggests an API key or token was hardcoded."),
|
|
8
|
+
(re.compile(r'-----BEGIN [A-Z]+ PRIVATE KEY-----'), "Private Key", "HIGH", "A private cryptographic key is present."),
|
|
9
|
+
(re.compile(r'https?://[a-zA-Z0-9_\-]+:[a-zA-Z0-9_\-]+@[a-zA-Z0-9_\-\.]+'), "Credential URL", "HIGH", "A URL contains embedded basic authentication credentials."),
|
|
10
|
+
(re.compile(r'(sk-[a-zA-Z0-9]{20,})'), "Potential API Key", "HIGH", "Pattern matches common cloud API keys (e.g., sk-...).")
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
def redact(value: str) -> str:
|
|
14
|
+
if len(value) <= 5:
|
|
15
|
+
return "***"
|
|
16
|
+
return value[:3] + "..." + value[-2:]
|
|
17
|
+
|
|
18
|
+
def scan_security(files: List[FileInfo]) -> List[SecurityFinding]:
|
|
19
|
+
findings = []
|
|
20
|
+
|
|
21
|
+
for f in files:
|
|
22
|
+
if f.is_binary:
|
|
23
|
+
continue
|
|
24
|
+
|
|
25
|
+
# Check .env
|
|
26
|
+
if f.filename.startswith(".env"):
|
|
27
|
+
findings.append(SecurityFinding(
|
|
28
|
+
filepath=f.relative_path,
|
|
29
|
+
line_number=0,
|
|
30
|
+
category="Environment File",
|
|
31
|
+
confidence="HIGH",
|
|
32
|
+
explanation="An environment file (e.g., .env) is checked in. This often contains secrets.",
|
|
33
|
+
redacted_value="N/A"
|
|
34
|
+
))
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
with open(f.path, 'r', encoding='utf-8', errors='ignore') as file:
|
|
38
|
+
for line_idx, line in enumerate(file):
|
|
39
|
+
for pattern, category, confidence, explanation in PATTERNS:
|
|
40
|
+
match = pattern.search(line)
|
|
41
|
+
if match:
|
|
42
|
+
# For private key header, the match is the whole header
|
|
43
|
+
val_to_redact = match.group(1) if len(match.groups()) > 0 else match.group(0)
|
|
44
|
+
|
|
45
|
+
findings.append(SecurityFinding(
|
|
46
|
+
filepath=f.relative_path,
|
|
47
|
+
line_number=line_idx + 1,
|
|
48
|
+
category=category,
|
|
49
|
+
confidence=confidence,
|
|
50
|
+
explanation=explanation,
|
|
51
|
+
redacted_value=redact(val_to_redact)
|
|
52
|
+
))
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
return findings
|