gitreins 0.1.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.
engine/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ # GitReins Engine
2
+ from engine.version import __version__
engine/dead_code.py ADDED
@@ -0,0 +1,280 @@
1
+ """Dead Code Detector — AST-based static analysis for unreachable and unused code.
2
+
3
+ Catches:
4
+ 1. Unreachable code — statements after return/raise/break/continue in same block
5
+ 2. Unused functions — defined but never called anywhere in the project
6
+ 3. Unused imports — modules imported but never referenced
7
+ 4. Empty functions — defined with pass/... only, no body
8
+ """
9
+
10
+ import ast
11
+ import os
12
+ from dataclasses import dataclass, field
13
+
14
+
15
+ @dataclass
16
+ class DeadCodeFinding:
17
+ file: str
18
+ line: int
19
+ category: str # unreachable | unused_function | unused_import | empty_function
20
+ message: str
21
+
22
+
23
+ @dataclass
24
+ class DeadCodeReport:
25
+ findings: list[DeadCodeFinding] = field(default_factory=list)
26
+
27
+ @property
28
+ def passed(self) -> bool:
29
+ return len(self.findings) == 0
30
+
31
+ @property
32
+ def summary(self) -> str:
33
+ if not self.findings:
34
+ return "No dead code found"
35
+ lines = []
36
+ by_cat: dict[str, list[DeadCodeFinding]] = {}
37
+ for f in self.findings:
38
+ by_cat.setdefault(f.category, []).append(f)
39
+ for cat, finds in sorted(by_cat.items()):
40
+ lines.append(f"\n {cat.upper()} ({len(finds)}):")
41
+ for f in finds[:10]:
42
+ lines.append(f" {f.file}:{f.line} — {f.message}")
43
+ if len(finds) > 10:
44
+ lines.append(f" ... and {len(finds) - 10} more")
45
+ return "\n".join(lines)
46
+
47
+
48
+ class DeadCodeDetector:
49
+ """AST-based dead code analysis for Python projects."""
50
+
51
+ WHITELIST_FUNCTIONS = {
52
+ # Standard dunder methods
53
+ "__init__", "__repr__", "__str__", "__eq__", "__hash__", "__lt__",
54
+ "__le__", "__gt__", "__ge__", "__add__", "__sub__", "__mul__",
55
+ "__call__", "__getitem__", "__setitem__", "__delitem__",
56
+ "__enter__", "__exit__", "__iter__", "__next__", "__len__",
57
+ "__contains__", "__getattr__", "__setattr__", "__delattr__",
58
+ "__post_init__", "__new__",
59
+ # Test functions
60
+ "setUp", "tearDown", "setUpClass", "tearDownClass",
61
+ # Common framework hooks
62
+ "main", "run", "handle", "process", "execute", "dispatch",
63
+ }
64
+ # Decorators that mean a function IS called (just not via Call AST node)
65
+ CALLED_VIA_DECORATOR = {"property", "cached_property", "staticmethod", "classmethod"}
66
+ # Decorator qualifiers that mark fixture/test functions called by frameworks
67
+ FRAMEWORK_DECORATORS = {"pytest.fixture", "fixture"}
68
+
69
+ def __init__(self, workdir: str = "."):
70
+ self.workdir = os.path.abspath(workdir)
71
+ self._func_defs: dict[str, list[tuple[str, int]]] = {} # func_name -> [(file, line)]
72
+ self._func_calls: set[str] = set()
73
+ self._imports: dict[str, set[str]] = {} # file -> {import names}
74
+
75
+ def scan(self, files: list[str] | None = None) -> DeadCodeReport:
76
+ """Scan project for dead code. If files is None, scans all Python files."""
77
+ report = DeadCodeReport()
78
+
79
+ if files is None:
80
+ files = self._find_python_files()
81
+
82
+ # Phase 1: Build symbol table (definitions, imports)
83
+ for fpath in files:
84
+ self._collect_symbols(fpath)
85
+
86
+ # Phase 2: Collect all function calls across the project
87
+ for fpath in files:
88
+ self._collect_calls(fpath)
89
+
90
+ # Phase 3: Detect dead code per file
91
+ for fpath in files:
92
+ findings = self._analyze_file(fpath)
93
+ report.findings.extend(findings)
94
+
95
+ return report
96
+
97
+ def _find_python_files(self) -> list[str]:
98
+ """Find all Python files in the project (excluding venv, node_modules, etc.)."""
99
+ py_files = []
100
+ skip_dirs = {".git", "__pycache__", ".venv", "venv", "node_modules",
101
+ ".tox", ".eggs", "build", "dist", ".pytest_cache",
102
+ ".gitreins", "temporal-vector"}
103
+ for root, dirs, filenames in os.walk(self.workdir):
104
+ dirs[:] = [d for d in dirs if d not in skip_dirs]
105
+ for fname in filenames:
106
+ if fname.endswith(".py"):
107
+ py_files.append(os.path.join(root, fname))
108
+ return py_files
109
+
110
+ def _relpath(self, abspath: str) -> str:
111
+ try:
112
+ return os.path.relpath(abspath, self.workdir)
113
+ except ValueError:
114
+ return abspath
115
+
116
+ def _collect_symbols(self, fpath: str) -> None:
117
+ """Collect function definitions and imports from a file."""
118
+ try:
119
+ with open(fpath, "r") as f:
120
+ source = f.read()
121
+ tree = ast.parse(source, filename=fpath)
122
+ except (SyntaxError, UnicodeDecodeError, FileNotFoundError, PermissionError):
123
+ return
124
+
125
+ rel = self._relpath(fpath)
126
+
127
+ for node in ast.walk(tree):
128
+ # Function definitions
129
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
130
+ name = node.name
131
+ if name not in self._func_defs:
132
+ self._func_defs[name] = []
133
+ self._func_defs[name].append((rel, node.lineno))
134
+ # Functions decorated with @property, @staticmethod etc. are
135
+ # accessed via attribute access, not Call AST — treat as "called"
136
+ for decorator in node.decorator_list:
137
+ dec_name = None
138
+ if isinstance(decorator, ast.Name):
139
+ dec_name = decorator.id
140
+ elif isinstance(decorator, ast.Attribute):
141
+ dec_name = decorator.attr
142
+ # Check for pytest.fixture style
143
+ if isinstance(decorator.value, ast.Name):
144
+ qualified = f"{decorator.value.id}.{dec_name}"
145
+ if qualified in self.FRAMEWORK_DECORATORS:
146
+ self._func_calls.add(name)
147
+ break
148
+ if dec_name and dec_name in self.CALLED_VIA_DECORATOR:
149
+ self._func_calls.add(name)
150
+ break
151
+ if dec_name in self.FRAMEWORK_DECORATORS:
152
+ self._func_calls.add(name)
153
+ break
154
+
155
+ # Imports
156
+ if isinstance(node, ast.Import):
157
+ for alias in node.names:
158
+ name = alias.asname or alias.name
159
+ self._imports.setdefault(rel, set()).add(name)
160
+ elif isinstance(node, ast.ImportFrom):
161
+ for alias in node.names:
162
+ name = alias.asname or alias.name
163
+ if name != "*":
164
+ self._imports.setdefault(rel, set()).add(name)
165
+
166
+ def _collect_calls(self, fpath: str) -> None:
167
+ """Collect all function calls across the project."""
168
+ try:
169
+ with open(fpath, "r") as f:
170
+ source = f.read()
171
+ tree = ast.parse(source, filename=fpath)
172
+ except (SyntaxError, UnicodeDecodeError, FileNotFoundError, PermissionError):
173
+ return
174
+
175
+ for node in ast.walk(tree):
176
+ if isinstance(node, ast.Call):
177
+ if isinstance(node.func, ast.Name):
178
+ self._func_calls.add(node.func.id)
179
+ elif isinstance(node.func, ast.Attribute):
180
+ self._func_calls.add(node.func.attr)
181
+
182
+ def _analyze_file(self, fpath: str) -> list[DeadCodeFinding]:
183
+ """Analyze a single file for dead code."""
184
+ findings: list[DeadCodeFinding] = []
185
+ try:
186
+ with open(fpath, "r") as f:
187
+ source = f.read()
188
+ tree = ast.parse(source, filename=fpath)
189
+ lines = source.split("\n")
190
+ except (SyntaxError, UnicodeDecodeError, FileNotFoundError, PermissionError):
191
+ return findings
192
+
193
+ rel = self._relpath(fpath)
194
+
195
+ # --- UNREACHABLE CODE (function bodies only) ---
196
+ for node in ast.walk(tree):
197
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
198
+ continue
199
+ body = node.body
200
+ for i, child in enumerate(body):
201
+ if isinstance(child, (ast.Return, ast.Raise, ast.Break, ast.Continue)):
202
+ if i + 1 < len(body):
203
+ next_sib = body[i + 1]
204
+ if isinstance(next_sib, ast.Expr) and isinstance(next_sib.value, ast.Constant):
205
+ continue # Skip docstrings
206
+ findings.append(DeadCodeFinding(
207
+ file=rel, line=next_sib.lineno,
208
+ category="unreachable",
209
+ message=f"Code after {type(child).__name__.lower()} on line {child.lineno} is unreachable",
210
+ ))
211
+
212
+ # --- EMPTY FUNCTIONS ---
213
+ for node in ast.walk(tree):
214
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
215
+ body = node.body
216
+ # Strip docstrings
217
+ if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant):
218
+ body = body[1:]
219
+ if len(body) == 0 or (len(body) == 1 and isinstance(body[0], ast.Pass)):
220
+ findings.append(DeadCodeFinding(
221
+ file=rel, line=node.lineno,
222
+ category="empty_function",
223
+ message=f"Function '{node.name}' has no implementation (empty body)",
224
+ ))
225
+
226
+ # --- UNUSED IMPORTS ---
227
+ if rel in self._imports:
228
+ used_names = set()
229
+ for node in ast.walk(tree):
230
+ if isinstance(node, ast.Name):
231
+ used_names.add(node.id)
232
+ elif isinstance(node, ast.Attribute):
233
+ if isinstance(node.value, ast.Name):
234
+ used_names.add(node.value.id)
235
+
236
+ for imp_name in self._imports[rel]:
237
+ # Split dotted imports to check root
238
+ root = imp_name.split(".")[0]
239
+ if root not in used_names and imp_name not in used_names:
240
+ # Find the import line
241
+ for node in ast.walk(tree):
242
+ if isinstance(node, ast.Import):
243
+ for alias in node.names:
244
+ name = alias.asname or alias.name
245
+ if name == imp_name:
246
+ findings.append(DeadCodeFinding(
247
+ file=rel, line=node.lineno,
248
+ category="unused_import",
249
+ message=f"Import '{imp_name}' is never used",
250
+ ))
251
+ elif isinstance(node, ast.ImportFrom):
252
+ for alias in node.names:
253
+ name = alias.asname or alias.name
254
+ if name == imp_name:
255
+ findings.append(DeadCodeFinding(
256
+ file=rel, line=node.lineno,
257
+ category="unused_import",
258
+ message=f"Import '{imp_name}' is never used",
259
+ ))
260
+
261
+ return findings
262
+
263
+ def find_unused_functions(self) -> list[DeadCodeFinding]:
264
+ """Post-scan: identify functions defined but never called project-wide."""
265
+ findings: list[DeadCodeFinding] = []
266
+ for func_name, defs in self._func_defs.items():
267
+ if func_name.startswith("_"):
268
+ continue # Private functions are often unused by design
269
+ if func_name.startswith("test_"):
270
+ continue # Test functions are called by pytest, not other code
271
+ if func_name in self.WHITELIST_FUNCTIONS:
272
+ continue
273
+ if func_name not in self._func_calls:
274
+ for file, line in defs:
275
+ findings.append(DeadCodeFinding(
276
+ file=file, line=line,
277
+ category="unused_function",
278
+ message=f"Function '{func_name}' is defined but never called in the project",
279
+ ))
280
+ return findings