deadpush 0.2.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.
- deadpush/__init__.py +1 -0
- deadpush/churn.py +189 -0
- deadpush/cli.py +1584 -0
- deadpush/comments.py +265 -0
- deadpush/complexity.py +254 -0
- deadpush/config.py +284 -0
- deadpush/crawler.py +133 -0
- deadpush/deadness.py +477 -0
- deadpush/debris.py +729 -0
- deadpush/deps.py +323 -0
- deadpush/deps_guard.py +382 -0
- deadpush/entrypoints.py +193 -0
- deadpush/graph.py +401 -0
- deadpush/guard.py +1386 -0
- deadpush/hooks.py +369 -0
- deadpush/importgraph.py +122 -0
- deadpush/imports.py +239 -0
- deadpush/intercept.py +995 -0
- deadpush/languages/__init__.py +143 -0
- deadpush/languages/base.py +70 -0
- deadpush/languages/cpp.py +150 -0
- deadpush/languages/go_.py +177 -0
- deadpush/languages/java.py +185 -0
- deadpush/languages/javascript.py +202 -0
- deadpush/languages/python_.py +278 -0
- deadpush/languages/rust.py +147 -0
- deadpush/languages/typescript.py +192 -0
- deadpush/layers.py +197 -0
- deadpush/mcp_server.py +1061 -0
- deadpush/reachability.py +183 -0
- deadpush/registration.py +280 -0
- deadpush/report.py +113 -0
- deadpush/rules.py +190 -0
- deadpush/sarif.py +123 -0
- deadpush/scorer.py +151 -0
- deadpush/security.py +187 -0
- deadpush/session.py +224 -0
- deadpush/tests.py +333 -0
- deadpush/ui.py +156 -0
- deadpush/verifier.py +168 -0
- deadpush/watch.py +103 -0
- deadpush-0.2.0.dist-info/METADATA +230 -0
- deadpush-0.2.0.dist-info/RECORD +46 -0
- deadpush-0.2.0.dist-info/WHEEL +4 -0
- deadpush-0.2.0.dist-info/entry_points.txt +2 -0
- deadpush-0.2.0.dist-info/licenses/LICENSE +21 -0
deadpush/comments.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Stale Comment Detection — finds docstrings that reference non-existent parameters.
|
|
3
|
+
|
|
4
|
+
AI agents frequently write verbose documentation that drifts from the actual
|
|
5
|
+
implementation. This module compares documented parameters in docstrings with
|
|
6
|
+
actual function signatures, flagging mismatches.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
PARAM_SECTION_PATTERNS = [
|
|
19
|
+
re.compile(r'Args:\s*', re.MULTILINE),
|
|
20
|
+
re.compile(r'Params?:\s*', re.MULTILINE),
|
|
21
|
+
re.compile(r'Parameters\s*[-:]\s*', re.MULTILINE),
|
|
22
|
+
re.compile(r'Keyword\s+Args?:\s*', re.MULTILINE),
|
|
23
|
+
re.compile(r'Keyword\s+Parameters:\s*', re.MULTILINE),
|
|
24
|
+
re.compile(r'Attributes:\s*', re.MULTILINE),
|
|
25
|
+
re.compile(r'Fields:\s*', re.MULTILINE),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
DOC_PARAM_PATTERN = re.compile(r'^\s{4}(\w+)\s*[(:]', re.MULTILINE)
|
|
29
|
+
PARAM_TAG_PATTERN = re.compile(r'@param\s+\{?\w*\}?\s*(\w+)')
|
|
30
|
+
COLON_PARAM_PATTERN = re.compile(r':param\s+(\w+)\s*:')
|
|
31
|
+
RETURN_TAG_PATTERN = re.compile(r'@returns?\s+\{?\w*\}?\s*(.*)')
|
|
32
|
+
RETURN_SECTION_PATTERN = re.compile(r'Returns:\s*\n((?:\s{4}.+\n?)*)', re.MULTILINE)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class StaleDocIssue:
|
|
37
|
+
"""A documentation mismatch issue."""
|
|
38
|
+
file: str
|
|
39
|
+
line: int
|
|
40
|
+
function: str
|
|
41
|
+
issue_type: str # "missing_doc" | "stale_param" | "missing_return_doc"
|
|
42
|
+
description: str
|
|
43
|
+
confidence: float = 0.85
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class StaleCommentDetector:
|
|
47
|
+
"""Detects stale documentation by comparing docstrings with actual code."""
|
|
48
|
+
|
|
49
|
+
# ------------------------------------------------------------------
|
|
50
|
+
# Python: AST-based analysis
|
|
51
|
+
# ------------------------------------------------------------------
|
|
52
|
+
def _extract_python_params(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]:
|
|
53
|
+
"""Extract actual parameter names from a function definition."""
|
|
54
|
+
params: set[str] = set()
|
|
55
|
+
for arg in node.args.args:
|
|
56
|
+
if arg.arg not in ("self", "cls"):
|
|
57
|
+
params.add(arg.arg)
|
|
58
|
+
if node.args.vararg:
|
|
59
|
+
params.add(f"*{node.args.vararg.arg}")
|
|
60
|
+
if node.args.kwarg:
|
|
61
|
+
params.add(f"**{node.args.kwarg.arg}")
|
|
62
|
+
for arg in node.args.kwonlyargs:
|
|
63
|
+
params.add(arg.arg)
|
|
64
|
+
for arg in node.args.posonlyargs:
|
|
65
|
+
if arg.arg not in ("self", "cls"):
|
|
66
|
+
params.add(arg.arg)
|
|
67
|
+
return params
|
|
68
|
+
|
|
69
|
+
def _extract_doc_params_python(self, docstring: str) -> set[str]:
|
|
70
|
+
"""Extract documented parameter names from a Python docstring."""
|
|
71
|
+
doc_params: set[str] = set()
|
|
72
|
+
|
|
73
|
+
# Google-style: Args:\n param_name: description
|
|
74
|
+
for section_pat in PARAM_SECTION_PATTERNS:
|
|
75
|
+
section_match = section_pat.search(docstring)
|
|
76
|
+
if section_match:
|
|
77
|
+
rest = docstring[section_match.end():]
|
|
78
|
+
for m in DOC_PARAM_PATTERN.finditer(rest):
|
|
79
|
+
doc_params.add(m.group(1))
|
|
80
|
+
|
|
81
|
+
# RST-style: :param name: description
|
|
82
|
+
doc_params.update(COLON_PARAM_PATTERN.findall(docstring))
|
|
83
|
+
|
|
84
|
+
# Epydoc/Google: @param name: description
|
|
85
|
+
doc_params.update(PARAM_TAG_PATTERN.findall(docstring))
|
|
86
|
+
|
|
87
|
+
return doc_params
|
|
88
|
+
|
|
89
|
+
def _check_python_file(self, path: Path, rel_path: str) -> list[StaleDocIssue]:
|
|
90
|
+
"""Analyze a single Python file for stale docstring issues."""
|
|
91
|
+
issues: list[StaleDocIssue] = []
|
|
92
|
+
try:
|
|
93
|
+
source = path.read_text(encoding="utf-8", errors="ignore")
|
|
94
|
+
tree = ast.parse(source, filename=str(path))
|
|
95
|
+
except (SyntaxError, Exception):
|
|
96
|
+
return issues
|
|
97
|
+
|
|
98
|
+
for node in ast.walk(tree):
|
|
99
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
100
|
+
docstring = ast.get_docstring(node) or ""
|
|
101
|
+
actual_params = self._extract_python_params(node)
|
|
102
|
+
doc_params = self._extract_doc_params_python(docstring) if docstring else set()
|
|
103
|
+
|
|
104
|
+
if not docstring and node.name != "__init__":
|
|
105
|
+
# Only flag public functions without docs
|
|
106
|
+
if not node.name.startswith("_"):
|
|
107
|
+
issues.append(StaleDocIssue(
|
|
108
|
+
file=rel_path,
|
|
109
|
+
line=node.lineno,
|
|
110
|
+
function=node.name,
|
|
111
|
+
issue_type="missing_doc",
|
|
112
|
+
description=f"Function '{node.name}' has no docstring",
|
|
113
|
+
confidence=0.80,
|
|
114
|
+
))
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
if not docstring:
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
# Stale params: documented but not in actual signature
|
|
121
|
+
stale = doc_params - actual_params
|
|
122
|
+
for param in stale:
|
|
123
|
+
issues.append(StaleDocIssue(
|
|
124
|
+
file=rel_path,
|
|
125
|
+
line=node.lineno,
|
|
126
|
+
function=node.name,
|
|
127
|
+
issue_type="stale_param",
|
|
128
|
+
description=f"Parameter '{param}' documented in '{node.name}' but not in actual signature",
|
|
129
|
+
confidence=0.90,
|
|
130
|
+
))
|
|
131
|
+
|
|
132
|
+
# Missing docs: actual params not documented (only for non-trivial functions)
|
|
133
|
+
if actual_params and not node.name.startswith("_"):
|
|
134
|
+
undocumented = actual_params - doc_params
|
|
135
|
+
if undocumented and len(actual_params) > 1:
|
|
136
|
+
issues.append(StaleDocIssue(
|
|
137
|
+
file=rel_path,
|
|
138
|
+
line=node.lineno,
|
|
139
|
+
function=node.name,
|
|
140
|
+
issue_type="missing_doc",
|
|
141
|
+
description=f"Parameters undocumented in '{node.name}': {', '.join(sorted(undocumented))}",
|
|
142
|
+
confidence=0.75,
|
|
143
|
+
))
|
|
144
|
+
|
|
145
|
+
# Check return documentation
|
|
146
|
+
has_return = any(
|
|
147
|
+
isinstance(child, (ast.Return, ast.Yield, ast.YieldFrom))
|
|
148
|
+
for child in ast.walk(node)
|
|
149
|
+
)
|
|
150
|
+
if has_return and docstring:
|
|
151
|
+
returns_doc = (
|
|
152
|
+
"@return" in docstring
|
|
153
|
+
or "@returns" in docstring
|
|
154
|
+
or "Returns:" in docstring
|
|
155
|
+
or ":return:" in docstring
|
|
156
|
+
)
|
|
157
|
+
if not returns_doc and node.name != "__init__":
|
|
158
|
+
issues.append(StaleDocIssue(
|
|
159
|
+
file=rel_path,
|
|
160
|
+
line=node.lineno,
|
|
161
|
+
function=node.name,
|
|
162
|
+
issue_type="missing_return_doc",
|
|
163
|
+
description=f"Function '{node.name}' has return statement(s) but no @return documented",
|
|
164
|
+
confidence=0.78,
|
|
165
|
+
))
|
|
166
|
+
|
|
167
|
+
return issues
|
|
168
|
+
|
|
169
|
+
# ------------------------------------------------------------------
|
|
170
|
+
# JS/TS: regex-based analysis
|
|
171
|
+
# ------------------------------------------------------------------
|
|
172
|
+
def _check_js_like_file(self, path: Path, rel_path: str) -> list[StaleDocIssue]:
|
|
173
|
+
"""Analyze JS/TS file for stale JSDoc issues using regex."""
|
|
174
|
+
issues: list[StaleDocIssue] = []
|
|
175
|
+
try:
|
|
176
|
+
source = path.read_text(encoding="utf-8", errors="ignore")
|
|
177
|
+
except Exception:
|
|
178
|
+
return issues
|
|
179
|
+
|
|
180
|
+
# Find function definitions with JSDoc comments
|
|
181
|
+
# Pattern: /** ... */ then function name(...)
|
|
182
|
+
func_pattern = re.compile(
|
|
183
|
+
r'/\*\*\s*\n((?:[^*]|\*[^/])*)\*/\s*\n(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\('
|
|
184
|
+
)
|
|
185
|
+
arrow_pattern = re.compile(
|
|
186
|
+
r'/\*\*\s*\n((?:[^*]|\*[^/])*)\*/\s*\n(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:function\s*)?\('
|
|
187
|
+
)
|
|
188
|
+
method_pattern = re.compile(
|
|
189
|
+
r'/\*\*\s*\n((?:[^*]|\*[^/])*)\*/\s*\n\s*(\w+)\s*\(\s*[^)]*\)\s*\{'
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
for pattern, label in [(func_pattern, "function"), (arrow_pattern, "arrow"), (method_pattern, "method")]:
|
|
193
|
+
for match in pattern.finditer(source):
|
|
194
|
+
jsdoc = match.group(1)
|
|
195
|
+
func_name = match.group(2) if label != "method" else match.group(2)
|
|
196
|
+
if not func_name:
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
# Extract @param tags from JSDoc
|
|
200
|
+
doc_params = set(PARAM_TAG_PATTERN.findall(jsdoc))
|
|
201
|
+
|
|
202
|
+
# We can't easily get actual params from regex, so just flag stale @param names
|
|
203
|
+
# that are misspelled or don't match common patterns
|
|
204
|
+
# For JS/TS, we look for @param names that are very short or unusual
|
|
205
|
+
for param in doc_params:
|
|
206
|
+
if len(param) <= 1:
|
|
207
|
+
issues.append(StaleDocIssue(
|
|
208
|
+
file=rel_path,
|
|
209
|
+
line=source[:match.start()].count("\n") + 1,
|
|
210
|
+
function=func_name,
|
|
211
|
+
issue_type="stale_param",
|
|
212
|
+
description=f"Suspicious @param '{param}' in JSDoc for '{func_name}' — very short name",
|
|
213
|
+
confidence=0.70,
|
|
214
|
+
))
|
|
215
|
+
elif not re.match(r'^[a-z_]\w*$', param, re.IGNORECASE):
|
|
216
|
+
issues.append(StaleDocIssue(
|
|
217
|
+
file=rel_path,
|
|
218
|
+
line=source[:match.start()].count("\n") + 1,
|
|
219
|
+
function=func_name,
|
|
220
|
+
issue_type="stale_param",
|
|
221
|
+
description=f"Unusual @param '{param}' in JSDoc for '{func_name}'",
|
|
222
|
+
confidence=0.65,
|
|
223
|
+
))
|
|
224
|
+
|
|
225
|
+
# Check for @returns
|
|
226
|
+
has_return_tag = bool(RETURN_TAG_PATTERN.search(jsdoc))
|
|
227
|
+
if not has_return_tag and "function" in label:
|
|
228
|
+
# Estimate if function has return by checking for 'return' keyword
|
|
229
|
+
func_body_start = match.end()
|
|
230
|
+
func_body = source[func_body_start:func_body_start + 500]
|
|
231
|
+
if "return " in func_body and not has_return_tag:
|
|
232
|
+
issues.append(StaleDocIssue(
|
|
233
|
+
file=rel_path,
|
|
234
|
+
line=source[:match.start()].count("\n") + 1,
|
|
235
|
+
function=func_name,
|
|
236
|
+
issue_type="missing_return_doc",
|
|
237
|
+
description=f"Function '{func_name}' has 'return' but no @returns in JSDoc",
|
|
238
|
+
confidence=0.74,
|
|
239
|
+
))
|
|
240
|
+
|
|
241
|
+
return issues
|
|
242
|
+
|
|
243
|
+
# ------------------------------------------------------------------
|
|
244
|
+
# Public API
|
|
245
|
+
# ------------------------------------------------------------------
|
|
246
|
+
def analyze_file(self, path: Path, rel_path: str) -> list[StaleDocIssue]:
|
|
247
|
+
"""Analyze a single file for stale documentation."""
|
|
248
|
+
if path.suffix == ".py":
|
|
249
|
+
return self._check_python_file(path, rel_path)
|
|
250
|
+
elif path.suffix in (".js", ".jsx", ".ts", ".tsx", ".mjs", ".mts", ".cjs", ".cts"):
|
|
251
|
+
return self._check_js_like_file(path, rel_path)
|
|
252
|
+
return []
|
|
253
|
+
|
|
254
|
+
def analyze_batch(self, files: list[Any]) -> list[StaleDocIssue]:
|
|
255
|
+
"""Analyze all source files for stale documentation issues."""
|
|
256
|
+
all_issues: list[StaleDocIssue] = []
|
|
257
|
+
for f in files:
|
|
258
|
+
if not getattr(f, "is_text", True):
|
|
259
|
+
continue
|
|
260
|
+
try:
|
|
261
|
+
issues = self.analyze_file(f.path, str(getattr(f, "rel_path", f.path)))
|
|
262
|
+
all_issues.extend(issues)
|
|
263
|
+
except Exception:
|
|
264
|
+
pass
|
|
265
|
+
return all_issues
|
deadpush/complexity.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Complexity Gate — tracks cyclomatic complexity per file and alerts on spikes.
|
|
3
|
+
|
|
4
|
+
Vibe coding sessions can silently balloon complexity as AI agents add features
|
|
5
|
+
without considering maintainability. This module computes McCabe cyclomatic
|
|
6
|
+
complexity per file, caches a baseline, and warns when complexity increases
|
|
7
|
+
beyond a threshold (default: 20%).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import re
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
COMPLEXITY_CACHE_FILE = Path.home() / ".deadpush" / "complexity_cache.json"
|
|
22
|
+
COMPLEXITY_CACHE_MAX_AGE = 604800 # 1 week
|
|
23
|
+
DEFAULT_THRESHOLD_PCT = 20
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Cyclomatic Complexity Calculators
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def _compute_python_complexity(source: str) -> int:
|
|
31
|
+
"""Compute McCabe cyclomatic complexity for Python source using AST."""
|
|
32
|
+
complexity = 1 # base
|
|
33
|
+
try:
|
|
34
|
+
tree = ast.parse(source)
|
|
35
|
+
except SyntaxError:
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
for node in ast.walk(tree):
|
|
39
|
+
# Decision points
|
|
40
|
+
if isinstance(node, (ast.If, ast.While, ast.For, ast.AsyncFor, ast.With, ast.AsyncWith)):
|
|
41
|
+
complexity += 1
|
|
42
|
+
elif isinstance(node, ast.ExceptHandler):
|
|
43
|
+
complexity += 1
|
|
44
|
+
elif isinstance(node, ast.Assert):
|
|
45
|
+
complexity += 1
|
|
46
|
+
# Boolean operators increase paths
|
|
47
|
+
elif isinstance(node, ast.BoolOp):
|
|
48
|
+
complexity += len(node.values) - 1
|
|
49
|
+
# ternary / if-expressions
|
|
50
|
+
elif isinstance(node, ast.IfExp):
|
|
51
|
+
complexity += 1
|
|
52
|
+
# match/case (Python 3.10+)
|
|
53
|
+
elif isinstance(node, ast.Match):
|
|
54
|
+
complexity += len(node.cases)
|
|
55
|
+
# comprehensions
|
|
56
|
+
elif isinstance(node, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)):
|
|
57
|
+
complexity += len(node.generators)
|
|
58
|
+
|
|
59
|
+
return complexity
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _compute_js_like_complexity(source: str) -> int:
|
|
63
|
+
"""Compute approximate complexity for JS/TS using regex pattern counting."""
|
|
64
|
+
complexity = 1
|
|
65
|
+
patterns = [
|
|
66
|
+
r'\bif\s*\(', r'\belse\s+if\b', r'\belse\b',
|
|
67
|
+
r'\bfor\s*\(', r'\bwhile\s*\(', r'\bdo\s*\{',
|
|
68
|
+
r'\bswitch\s*\(', r'\bcase\s+',
|
|
69
|
+
r'\bcatch\s*\(', r'\bfinally\b',
|
|
70
|
+
r'\b&&\b', r'\b\|\|\b',
|
|
71
|
+
r'\?.*:.*',
|
|
72
|
+
]
|
|
73
|
+
for p in patterns:
|
|
74
|
+
complexity += len(re.findall(p, source))
|
|
75
|
+
return complexity
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _compute_go_complexity(source: str) -> int:
|
|
79
|
+
complexity = 1
|
|
80
|
+
patterns = [
|
|
81
|
+
r'\bif\b', r'\belse\b', r'\bfor\b', r'\brange\b',
|
|
82
|
+
r'\bswitch\b', r'\bcase\b', r'\bselect\b',
|
|
83
|
+
]
|
|
84
|
+
for p in patterns:
|
|
85
|
+
complexity += len(re.findall(p, source))
|
|
86
|
+
return complexity
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _compute_rust_complexity(source: str) -> int:
|
|
90
|
+
complexity = 1
|
|
91
|
+
patterns = [
|
|
92
|
+
r'\bif\b', r'\belse\b', r'\bfor\b', r'\bwhile\b',
|
|
93
|
+
r'\bmatch\b', r'=>', r'\bif let\b', r'\bwhile let\b',
|
|
94
|
+
]
|
|
95
|
+
for p in patterns:
|
|
96
|
+
complexity += len(re.findall(p, source))
|
|
97
|
+
return complexity
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _compute_cpp_complexity(source: str) -> int:
|
|
101
|
+
complexity = 1
|
|
102
|
+
patterns = [
|
|
103
|
+
r'\bif\s*\(', r'\belse\b', r'\bfor\s*\(', r'\bwhile\s*\(',
|
|
104
|
+
r'\bswitch\s*\(', r'\bcase\b', r'\bcatch\s*\(',
|
|
105
|
+
r'\b&&\b', r'\b\|\|\b', r'\?',
|
|
106
|
+
]
|
|
107
|
+
for p in patterns:
|
|
108
|
+
complexity += len(re.findall(p, source))
|
|
109
|
+
return complexity
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _compute_java_complexity(source: str) -> int:
|
|
113
|
+
complexity = 1
|
|
114
|
+
patterns = [
|
|
115
|
+
r'\bif\s*\(', r'\belse\b', r'\bfor\s*\(', r'\bwhile\s*\(',
|
|
116
|
+
r'\bdo\s*\{', r'\bswitch\s*\(', r'\bcase\b',
|
|
117
|
+
r'\bcatch\s*\(', r'\bfinally\b', r'\b&&\b', r'\b\|\|\b',
|
|
118
|
+
r'\?', r'\binstanceof\b',
|
|
119
|
+
]
|
|
120
|
+
for p in patterns:
|
|
121
|
+
complexity += len(re.findall(p, source))
|
|
122
|
+
return complexity
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
_COMPLEXITY_FUNCS: dict[str, Any] = {
|
|
126
|
+
".py": _compute_python_complexity,
|
|
127
|
+
".js": _compute_js_like_complexity,
|
|
128
|
+
".jsx": _compute_js_like_complexity,
|
|
129
|
+
".mjs": _compute_js_like_complexity,
|
|
130
|
+
".cjs": _compute_js_like_complexity,
|
|
131
|
+
".ts": _compute_js_like_complexity,
|
|
132
|
+
".tsx": _compute_js_like_complexity,
|
|
133
|
+
".mts": _compute_js_like_complexity,
|
|
134
|
+
".cts": _compute_js_like_complexity,
|
|
135
|
+
".go": _compute_go_complexity,
|
|
136
|
+
".rs": _compute_rust_complexity,
|
|
137
|
+
".c": _compute_cpp_complexity,
|
|
138
|
+
".cpp": _compute_cpp_complexity,
|
|
139
|
+
".cc": _compute_cpp_complexity,
|
|
140
|
+
".cxx": _compute_cpp_complexity,
|
|
141
|
+
".h": _compute_cpp_complexity,
|
|
142
|
+
".hpp": _compute_cpp_complexity,
|
|
143
|
+
".java": _compute_java_complexity,
|
|
144
|
+
".kt": _compute_java_complexity,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def compute_complexity(path: Path) -> int | None:
|
|
149
|
+
"""Compute cyclomatic complexity for a source file. Returns None on failure."""
|
|
150
|
+
suffix = path.suffix.lower()
|
|
151
|
+
func = _COMPLEXITY_FUNCS.get(suffix)
|
|
152
|
+
if func is None:
|
|
153
|
+
return None
|
|
154
|
+
try:
|
|
155
|
+
source = path.read_text(encoding="utf-8", errors="ignore")
|
|
156
|
+
return func(source)
|
|
157
|
+
except Exception:
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# Complexity Tracker
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
class ComplexityTracker:
|
|
166
|
+
"""Tracks complexity baseline per file and detects significant increases."""
|
|
167
|
+
|
|
168
|
+
def __init__(self, threshold_pct: int = DEFAULT_THRESHOLD_PCT):
|
|
169
|
+
self.threshold_pct = threshold_pct
|
|
170
|
+
self.cache_file = COMPLEXITY_CACHE_FILE
|
|
171
|
+
self._cache: dict[str, dict[str, Any]] = {}
|
|
172
|
+
self._load_cache()
|
|
173
|
+
|
|
174
|
+
def _load_cache(self):
|
|
175
|
+
if self.cache_file.exists():
|
|
176
|
+
try:
|
|
177
|
+
data = json.loads(self.cache_file.read_text(encoding="utf-8"))
|
|
178
|
+
now = time.time()
|
|
179
|
+
self._cache = {
|
|
180
|
+
k: v for k, v in data.items()
|
|
181
|
+
if now - v.get("baseline_at", 0) < COMPLEXITY_CACHE_MAX_AGE
|
|
182
|
+
}
|
|
183
|
+
except Exception:
|
|
184
|
+
self._cache = {}
|
|
185
|
+
|
|
186
|
+
def _save_cache(self):
|
|
187
|
+
try:
|
|
188
|
+
self.cache_file.parent.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
self.cache_file.write_text(
|
|
190
|
+
json.dumps(self._cache, indent=2, default=str),
|
|
191
|
+
encoding="utf-8",
|
|
192
|
+
)
|
|
193
|
+
except Exception:
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
def get_baseline(self, file_path: str) -> int | None:
|
|
197
|
+
"""Get cached baseline complexity for a file."""
|
|
198
|
+
entry = self._cache.get(file_path)
|
|
199
|
+
if entry:
|
|
200
|
+
return entry.get("complexity")
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
def check_complexity(self, file_path: str, path: Path) -> dict[str, Any] | None:
|
|
204
|
+
"""Check file complexity against baseline. Returns alert dict if threshold exceeded."""
|
|
205
|
+
current = compute_complexity(path)
|
|
206
|
+
if current is None:
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
baseline = self.get_baseline(file_path)
|
|
210
|
+
|
|
211
|
+
if baseline is not None and baseline > 0:
|
|
212
|
+
increase = current - baseline
|
|
213
|
+
pct_increase = (increase / baseline) * 100
|
|
214
|
+
increase_ratio = current / baseline
|
|
215
|
+
|
|
216
|
+
if pct_increase >= self.threshold_pct:
|
|
217
|
+
return {
|
|
218
|
+
"file": file_path,
|
|
219
|
+
"baseline": baseline,
|
|
220
|
+
"current": current,
|
|
221
|
+
"increase": increase,
|
|
222
|
+
"pct_increase": round(pct_increase, 1),
|
|
223
|
+
"threshold_pct": self.threshold_pct,
|
|
224
|
+
"exceeded": True,
|
|
225
|
+
}
|
|
226
|
+
else:
|
|
227
|
+
# First time seeing this file — set baseline
|
|
228
|
+
self._cache[file_path] = {
|
|
229
|
+
"complexity": current,
|
|
230
|
+
"baseline_at": time.time(),
|
|
231
|
+
}
|
|
232
|
+
self._save_cache()
|
|
233
|
+
|
|
234
|
+
if current > 30:
|
|
235
|
+
return {
|
|
236
|
+
"file": file_path,
|
|
237
|
+
"baseline": None,
|
|
238
|
+
"current": current,
|
|
239
|
+
"increase": None,
|
|
240
|
+
"pct_increase": None,
|
|
241
|
+
"threshold_pct": self.threshold_pct,
|
|
242
|
+
"exceeded": False,
|
|
243
|
+
"note": f"Initial complexity is high ({current}). Consider refactoring.",
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
def update_baseline(self, file_path: str, complexity: int):
|
|
249
|
+
"""Update baseline after confirming the change is intentional."""
|
|
250
|
+
self._cache[file_path] = {
|
|
251
|
+
"complexity": complexity,
|
|
252
|
+
"baseline_at": time.time(),
|
|
253
|
+
}
|
|
254
|
+
self._save_cache()
|