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