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