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/linter.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import ast
|
|
3
|
+
|
|
4
|
+
def run_micro_linters(file_info, content):
|
|
5
|
+
smells = []
|
|
6
|
+
lines = content.splitlines()
|
|
7
|
+
|
|
8
|
+
if not lines:
|
|
9
|
+
return smells
|
|
10
|
+
|
|
11
|
+
# 14. Empty File Check
|
|
12
|
+
if not content.strip():
|
|
13
|
+
smells.append("Completely empty file")
|
|
14
|
+
|
|
15
|
+
# 15. Banned words (Profanity / Slurs / etc.)
|
|
16
|
+
if re.search(r'\b(fuck|shit|crap|bitch)\b', content, re.IGNORECASE):
|
|
17
|
+
smells.append("Profanity found in code")
|
|
18
|
+
|
|
19
|
+
# 16. TODO without owner
|
|
20
|
+
if re.search(r'//\s*TODO(?![(\[])', content) or re.search(r'#\s*TODO(?![(\[])', content):
|
|
21
|
+
smells.append("TODO without owner/ticket")
|
|
22
|
+
|
|
23
|
+
# Language Specific Extensions
|
|
24
|
+
if file_info.language in ("JavaScript", "TypeScript"):
|
|
25
|
+
# 17. eval() usage
|
|
26
|
+
if re.search(r'\beval\s*\(', content):
|
|
27
|
+
smells.append("Dangerous eval() usage")
|
|
28
|
+
# 18. Missing strict mode (for pure JS)
|
|
29
|
+
if file_info.language == "JavaScript" and not re.search(r'["\']use strict["\']', content):
|
|
30
|
+
smells.append("Missing \"use strict\" in JS")
|
|
31
|
+
# 19. console.error/warn
|
|
32
|
+
if re.search(r'\bconsole\.(error|warn)\s*\(', content):
|
|
33
|
+
smells.append("console.error/warn left in code")
|
|
34
|
+
|
|
35
|
+
elif file_info.language == "Python":
|
|
36
|
+
# 20. eval() / exec()
|
|
37
|
+
if re.search(r'\b(eval|exec)\s*\(', content):
|
|
38
|
+
smells.append("Dangerous eval()/exec() usage")
|
|
39
|
+
try:
|
|
40
|
+
tree = ast.parse(content)
|
|
41
|
+
for n in ast.walk(tree):
|
|
42
|
+
# 21. Wildcard imports
|
|
43
|
+
if isinstance(n, ast.ImportFrom) and any(alias.name == '*' for alias in n.names):
|
|
44
|
+
if "Wildcard import (import *)" not in smells: smells.append("Wildcard import (import *)")
|
|
45
|
+
# 22. Bare exceptions
|
|
46
|
+
if isinstance(n, ast.ExceptHandler) and n.type is None:
|
|
47
|
+
if "Bare except: block" not in smells: smells.append("Bare except: block")
|
|
48
|
+
# 23. Mutable default arguments
|
|
49
|
+
if isinstance(n, ast.arguments):
|
|
50
|
+
for d in n.defaults:
|
|
51
|
+
if isinstance(d, (ast.List, ast.Dict, ast.Set)):
|
|
52
|
+
if "Mutable default argument ([] or {})" not in smells: smells.append("Mutable default argument ([] or {})")
|
|
53
|
+
# 24. sys.exit()
|
|
54
|
+
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute):
|
|
55
|
+
if isinstance(n.func.value, ast.Name) and n.func.value.id == "sys" and n.func.attr == "exit":
|
|
56
|
+
if "Hard sys.exit() found" not in smells: smells.append("Hard sys.exit() found")
|
|
57
|
+
except:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
elif file_info.language == "CSS":
|
|
61
|
+
# 25. Empty rulesets
|
|
62
|
+
if re.search(r'\{[^}]*\}', content) and not re.search(r'\{[^a-zA-Z]*[a-zA-Z-]+\s*:[^}]*\}', content):
|
|
63
|
+
smells.append("Empty CSS ruleset")
|
|
64
|
+
# 26. Deep nesting (heuristic for uncompiled CSS/SCSS)
|
|
65
|
+
if content.count('>') > (len(lines) // 10):
|
|
66
|
+
smells.append("High CSS child combinator density")
|
|
67
|
+
|
|
68
|
+
elif file_info.language == "HTML":
|
|
69
|
+
# 27. Inline CSS (style="...")
|
|
70
|
+
if re.search(r'\bstyle\s*=\s*["\']', content):
|
|
71
|
+
smells.append("Inline CSS (style=...) used")
|
|
72
|
+
# 28. Inline JS (onclick="...")
|
|
73
|
+
if re.search(r'\bon(click|load|submit|mouseover|change)\s*=\s*["\']', content):
|
|
74
|
+
smells.append("Inline JavaScript (onclick=...) used")
|
|
75
|
+
|
|
76
|
+
elif file_info.language == "JSON":
|
|
77
|
+
# 29. Giant JSON files
|
|
78
|
+
if len(lines) > 2000:
|
|
79
|
+
smells.append("Massive JSON configuration (>2000 lines)")
|
|
80
|
+
|
|
81
|
+
elif file_info.language == "Markdown":
|
|
82
|
+
# 30. Missing H1 title at start
|
|
83
|
+
if lines and not lines[0].startswith('# '):
|
|
84
|
+
smells.append("Markdown missing # H1 Title at start")
|
|
85
|
+
|
|
86
|
+
return smells
|
|
87
|
+
|
|
88
|
+
# 1. Trailing whitespace
|
|
89
|
+
if any(l.rstrip('\n\r').endswith((' ', '\t')) for l in lines):
|
|
90
|
+
smells.append("Trailing whitespace")
|
|
91
|
+
|
|
92
|
+
# 2. Missing EOF Newline
|
|
93
|
+
if content and not content.endswith('\n'):
|
|
94
|
+
smells.append("Missing EOF newline")
|
|
95
|
+
|
|
96
|
+
# 3. Line Length > 120
|
|
97
|
+
if any(len(l) > 120 for l in lines):
|
|
98
|
+
smells.append("Lines > 120 chars")
|
|
99
|
+
|
|
100
|
+
# 4. Mixed Tabs & Spaces
|
|
101
|
+
has_tabs = any('\t' in l for l in lines)
|
|
102
|
+
has_spaces = any(l.startswith(' ') for l in lines)
|
|
103
|
+
if has_tabs and has_spaces:
|
|
104
|
+
smells.append("Mixed tabs and spaces")
|
|
105
|
+
|
|
106
|
+
# 5. Localhost Hardcoding
|
|
107
|
+
if re.search(r'http://localhost|http://127\.0\.0\.1', content):
|
|
108
|
+
smells.append("Hardcoded localhost URL")
|
|
109
|
+
|
|
110
|
+
# Language Specific
|
|
111
|
+
if file_info.language in ("JavaScript", "TypeScript"):
|
|
112
|
+
# 6. console.log
|
|
113
|
+
if re.search(r'\bconsole\.log\s*\(', content):
|
|
114
|
+
smells.append("console.log() found")
|
|
115
|
+
# 7. debugger
|
|
116
|
+
if re.search(r'\bdebugger\s*;?', content):
|
|
117
|
+
smells.append("debugger statement found")
|
|
118
|
+
|
|
119
|
+
elif file_info.language == "Python":
|
|
120
|
+
# 8. print statements
|
|
121
|
+
if re.search(r'\bprint\s*\(', content):
|
|
122
|
+
smells.append("print() statement found")
|
|
123
|
+
try:
|
|
124
|
+
tree = ast.parse(content)
|
|
125
|
+
for n in ast.walk(tree):
|
|
126
|
+
# 9. Too many args
|
|
127
|
+
if isinstance(n, ast.FunctionDef):
|
|
128
|
+
if len(n.args.args) > 6:
|
|
129
|
+
if "Function with > 6 args" not in smells: smells.append("Function with > 6 args")
|
|
130
|
+
# 10. Missing docstring
|
|
131
|
+
if not ast.get_docstring(n):
|
|
132
|
+
if "Missing docstring" not in smells: smells.append("Missing docstring")
|
|
133
|
+
# 11. Swallowed errors
|
|
134
|
+
if isinstance(n, ast.ExceptHandler):
|
|
135
|
+
if not n.body or (len(n.body) == 1 and isinstance(n.body[0], ast.Pass)):
|
|
136
|
+
if "Empty except block" not in smells: smells.append("Empty except block")
|
|
137
|
+
except:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
elif file_info.language == "CSS":
|
|
141
|
+
# 12. CSS !important
|
|
142
|
+
if "!important" in content:
|
|
143
|
+
smells.append("CSS !important used")
|
|
144
|
+
|
|
145
|
+
elif file_info.language == "HTML":
|
|
146
|
+
# 13. Missing alt text
|
|
147
|
+
if re.search(r'<img\b(?![^>]*\balt=)[^>]*>', content):
|
|
148
|
+
smells.append("<img> missing alt attribute")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# 14. Empty File Check
|
|
152
|
+
if not content.strip():
|
|
153
|
+
smells.append("Completely empty file")
|
|
154
|
+
|
|
155
|
+
# 15. Banned words (Profanity / Slurs / etc.)
|
|
156
|
+
if re.search(r'\b(fuck|shit|crap|bitch)\b', content, re.IGNORECASE):
|
|
157
|
+
smells.append("Profanity found in code")
|
|
158
|
+
|
|
159
|
+
# 16. TODO without owner
|
|
160
|
+
if re.search(r'//\s*TODO(?![(\[])', content) or re.search(r'#\s*TODO(?![(\[])', content):
|
|
161
|
+
smells.append("TODO without owner/ticket")
|
|
162
|
+
|
|
163
|
+
# Language Specific Extensions
|
|
164
|
+
if file_info.language in ("JavaScript", "TypeScript"):
|
|
165
|
+
# 17. eval() usage
|
|
166
|
+
if re.search(r'\beval\s*\(', content):
|
|
167
|
+
smells.append("Dangerous eval() usage")
|
|
168
|
+
# 18. Missing strict mode (for pure JS)
|
|
169
|
+
if file_info.language == "JavaScript" and not re.search(r'["\']use strict["\']', content):
|
|
170
|
+
smells.append("Missing \"use strict\" in JS")
|
|
171
|
+
# 19. console.error/warn
|
|
172
|
+
if re.search(r'\bconsole\.(error|warn)\s*\(', content):
|
|
173
|
+
smells.append("console.error/warn left in code")
|
|
174
|
+
|
|
175
|
+
elif file_info.language == "Python":
|
|
176
|
+
# 20. eval() / exec()
|
|
177
|
+
if re.search(r'\b(eval|exec)\s*\(', content):
|
|
178
|
+
smells.append("Dangerous eval()/exec() usage")
|
|
179
|
+
try:
|
|
180
|
+
tree = ast.parse(content)
|
|
181
|
+
for n in ast.walk(tree):
|
|
182
|
+
# 21. Wildcard imports
|
|
183
|
+
if isinstance(n, ast.ImportFrom) and any(alias.name == '*' for alias in n.names):
|
|
184
|
+
if "Wildcard import (import *)" not in smells: smells.append("Wildcard import (import *)")
|
|
185
|
+
# 22. Bare exceptions
|
|
186
|
+
if isinstance(n, ast.ExceptHandler) and n.type is None:
|
|
187
|
+
if "Bare except: block" not in smells: smells.append("Bare except: block")
|
|
188
|
+
# 23. Mutable default arguments
|
|
189
|
+
if isinstance(n, ast.arguments):
|
|
190
|
+
for d in n.defaults:
|
|
191
|
+
if isinstance(d, (ast.List, ast.Dict, ast.Set)):
|
|
192
|
+
if "Mutable default argument ([] or {})" not in smells: smells.append("Mutable default argument ([] or {})")
|
|
193
|
+
# 24. sys.exit()
|
|
194
|
+
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute):
|
|
195
|
+
if isinstance(n.func.value, ast.Name) and n.func.value.id == "sys" and n.func.attr == "exit":
|
|
196
|
+
if "Hard sys.exit() found" not in smells: smells.append("Hard sys.exit() found")
|
|
197
|
+
except:
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
elif file_info.language == "CSS":
|
|
201
|
+
# 25. Empty rulesets
|
|
202
|
+
if re.search(r'\{[^}]*\}', content) and not re.search(r'\{[^a-zA-Z]*[a-zA-Z-]+\s*:[^}]*\}', content):
|
|
203
|
+
smells.append("Empty CSS ruleset")
|
|
204
|
+
# 26. Deep nesting (heuristic for uncompiled CSS/SCSS)
|
|
205
|
+
if content.count('>') > (len(lines) // 10):
|
|
206
|
+
smells.append("High CSS child combinator density")
|
|
207
|
+
|
|
208
|
+
elif file_info.language == "HTML":
|
|
209
|
+
# 27. Inline CSS (style="...")
|
|
210
|
+
if re.search(r'\bstyle\s*=\s*["\']', content):
|
|
211
|
+
smells.append("Inline CSS (style=...) used")
|
|
212
|
+
# 28. Inline JS (onclick="...")
|
|
213
|
+
if re.search(r'\bon(click|load|submit|mouseover|change)\s*=\s*["\']', content):
|
|
214
|
+
smells.append("Inline JavaScript (onclick=...) used")
|
|
215
|
+
|
|
216
|
+
elif file_info.language == "JSON":
|
|
217
|
+
# 29. Giant JSON files
|
|
218
|
+
if len(lines) > 2000:
|
|
219
|
+
smells.append("Massive JSON configuration (>2000 lines)")
|
|
220
|
+
|
|
221
|
+
elif file_info.language == "Markdown":
|
|
222
|
+
# 30. Missing H1 title at start
|
|
223
|
+
if lines and not lines[0].startswith('# '):
|
|
224
|
+
smells.append("Markdown missing # H1 Title at start")
|
|
225
|
+
|
|
226
|
+
return smells
|
repodoctor/metrics.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import re
|
|
3
|
+
from typing import List
|
|
4
|
+
from .models import FileInfo, FileMetrics
|
|
5
|
+
|
|
6
|
+
def analyze_python_ast(source: str, metrics: FileMetrics):
|
|
7
|
+
try:
|
|
8
|
+
tree = ast.parse(source)
|
|
9
|
+
for node in ast.walk(tree):
|
|
10
|
+
if isinstance(node, ast.FunctionDef) or isinstance(node, ast.AsyncFunctionDef):
|
|
11
|
+
metrics.num_functions += 1
|
|
12
|
+
elif isinstance(node, ast.ClassDef):
|
|
13
|
+
metrics.num_classes += 1
|
|
14
|
+
except SyntaxError:
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
def analyze_metrics(files: List[FileInfo]) -> None:
|
|
18
|
+
for f in files:
|
|
19
|
+
if f.is_binary:
|
|
20
|
+
continue
|
|
21
|
+
|
|
22
|
+
metrics = FileMetrics()
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
with open(f.path, 'r', encoding='utf-8', errors='ignore') as file:
|
|
26
|
+
lines = file.readlines()
|
|
27
|
+
except Exception:
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
metrics.code_lines = 0
|
|
31
|
+
metrics.blank_lines = 0
|
|
32
|
+
metrics.comment_lines = 0
|
|
33
|
+
|
|
34
|
+
source_text = "".join(lines)
|
|
35
|
+
|
|
36
|
+
for line in lines:
|
|
37
|
+
line_len = len(line.rstrip('\n'))
|
|
38
|
+
if line_len > metrics.longest_line:
|
|
39
|
+
metrics.longest_line = line_len
|
|
40
|
+
|
|
41
|
+
stripped = line.strip()
|
|
42
|
+
if not stripped:
|
|
43
|
+
metrics.blank_lines += 1
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
# Very basic comment heuristic
|
|
47
|
+
if stripped.startswith('#') or stripped.startswith('//') or stripped.startswith('/*') or stripped.startswith('*'):
|
|
48
|
+
metrics.comment_lines += 1
|
|
49
|
+
else:
|
|
50
|
+
metrics.code_lines += 1
|
|
51
|
+
|
|
52
|
+
# Heuristic nesting
|
|
53
|
+
leading_spaces = len(line) - len(line.lstrip(' '))
|
|
54
|
+
leading_tabs = len(line) - len(line.lstrip('\t'))
|
|
55
|
+
# Assume 4 spaces = 1 depth, 1 tab = 1 depth
|
|
56
|
+
depth = max(leading_spaces // 4, leading_tabs)
|
|
57
|
+
if depth > metrics.max_nesting:
|
|
58
|
+
metrics.max_nesting = depth
|
|
59
|
+
|
|
60
|
+
if f.extension == '.py':
|
|
61
|
+
analyze_python_ast(source_text, metrics)
|
|
62
|
+
else:
|
|
63
|
+
# Heuristic function/class counts for non-Python
|
|
64
|
+
for line in lines:
|
|
65
|
+
stripped = line.strip()
|
|
66
|
+
if re.match(r'^(public\s+|private\s+|protected\s+)?(class|struct)\s+\w+', stripped):
|
|
67
|
+
metrics.num_classes += 1
|
|
68
|
+
elif re.match(r'^(public\s+|private\s+|protected\s+)?(static\s+)?\w+\s+\w+\s*\(', stripped) and not stripped.endswith(';'):
|
|
69
|
+
metrics.num_functions += 1
|
|
70
|
+
elif re.match(r'^(function|func|def)\s+\w+', stripped):
|
|
71
|
+
metrics.num_functions += 1
|
|
72
|
+
|
|
73
|
+
f.metrics = metrics
|
repodoctor/models.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Optional, List, Tuple, Dict, Any
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class HealthScore:
|
|
6
|
+
score: int
|
|
7
|
+
breakdown: List[Tuple[str, int]]
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ReportData:
|
|
11
|
+
path: str
|
|
12
|
+
name: str
|
|
13
|
+
files: List['FileInfo']
|
|
14
|
+
todos: List['TodoItem']
|
|
15
|
+
security: List['SecurityFinding']
|
|
16
|
+
duplicates: List['DuplicateBlock']
|
|
17
|
+
structure: Dict[str, str]
|
|
18
|
+
git: 'GitInfo'
|
|
19
|
+
top_words: List[Tuple[str, int]] = None
|
|
20
|
+
mood: str = None
|
|
21
|
+
clone_exposer: str = None
|
|
22
|
+
score: Optional[HealthScore] = None
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class GitInfo:
|
|
26
|
+
available: bool
|
|
27
|
+
branch: str = ""
|
|
28
|
+
uncommitted_changes: int = 0
|
|
29
|
+
commits: int = 0
|
|
30
|
+
top_contributor: str = ""
|
|
31
|
+
hotspot: str = ""
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class DuplicateBlock:
|
|
35
|
+
filepaths: List[str]
|
|
36
|
+
lines: Tuple[int, int]
|
|
37
|
+
similarity: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class SecurityFinding:
|
|
42
|
+
filepath: str
|
|
43
|
+
line_number: int
|
|
44
|
+
category: str
|
|
45
|
+
confidence: str
|
|
46
|
+
explanation: str
|
|
47
|
+
redacted_value: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class TodoItem:
|
|
52
|
+
filepath: str
|
|
53
|
+
line_number: int
|
|
54
|
+
text: str
|
|
55
|
+
marker: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class FileMetrics:
|
|
60
|
+
blank_lines: int = 0
|
|
61
|
+
comment_lines: int = 0
|
|
62
|
+
code_lines: int = 0
|
|
63
|
+
longest_line: int = 0
|
|
64
|
+
num_functions: int = 0
|
|
65
|
+
num_classes: int = 0
|
|
66
|
+
max_nesting: int = 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class FileInfo:
|
|
71
|
+
path: str
|
|
72
|
+
filename: str
|
|
73
|
+
extension: str
|
|
74
|
+
size: int
|
|
75
|
+
lines: int
|
|
76
|
+
is_binary: bool
|
|
77
|
+
language: str
|
|
78
|
+
relative_path: str
|
|
79
|
+
metrics: Optional[FileMetrics] = None
|
|
80
|
+
code_smells: List[str] = None
|
|
81
|
+
|