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/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