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/session.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Vibe Session Tracking — tags guardian activity and file changes into named sessions.
|
|
3
|
+
|
|
4
|
+
Vibe coding sessions are periods of continuous AI-assisted development. This module
|
|
5
|
+
lets users explicitly start/stop sessions, and the guardian tags all interventions
|
|
6
|
+
with the active session. At session end, a rollup summary is generated showing
|
|
7
|
+
what changed, what went wrong, and what the safety impact was.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
SESSION_DIR = Path.home() / ".deadpush" / "sessions"
|
|
21
|
+
ACTIVE_SESSION_FILE = Path.home() / ".deadpush" / "active_session.json"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class VibeSession:
|
|
26
|
+
"""Represents a single vibe coding session."""
|
|
27
|
+
id: str
|
|
28
|
+
label: str
|
|
29
|
+
start_time: str
|
|
30
|
+
end_time: str | None = None
|
|
31
|
+
files_changed: list[str] = field(default_factory=list)
|
|
32
|
+
incidents: list[dict[str, Any]] = field(default_factory=list)
|
|
33
|
+
safety_score_start: int = 100
|
|
34
|
+
safety_score_end: int | None = None
|
|
35
|
+
total_debris_found: int = 0
|
|
36
|
+
total_dead_symbols: int = 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SessionManager:
|
|
40
|
+
"""Manages vibe coding sessions — start, end, status, history."""
|
|
41
|
+
|
|
42
|
+
def __init__(self):
|
|
43
|
+
SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
|
|
45
|
+
# ------------------------------------------------------------------
|
|
46
|
+
# Session lifecycle
|
|
47
|
+
# ------------------------------------------------------------------
|
|
48
|
+
def start_session(self, label: str = "") -> VibeSession:
|
|
49
|
+
"""Start a new vibe session. Returns the session object."""
|
|
50
|
+
now = datetime.now()
|
|
51
|
+
session_id = now.strftime("%Y%m%d_%H%M%S")
|
|
52
|
+
|
|
53
|
+
session = VibeSession(
|
|
54
|
+
id=session_id,
|
|
55
|
+
label=label or f"Vibe session {session_id}",
|
|
56
|
+
start_time=now.isoformat(),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
ACTIVE_SESSION_FILE.write_text(
|
|
60
|
+
json.dumps(self._session_to_dict(session), indent=2, default=str),
|
|
61
|
+
encoding="utf-8",
|
|
62
|
+
)
|
|
63
|
+
return session
|
|
64
|
+
|
|
65
|
+
def end_session(self, safety_score: int | None = None) -> VibeSession | None:
|
|
66
|
+
"""End the active session. Returns the completed session or None."""
|
|
67
|
+
active = self.get_active_session()
|
|
68
|
+
if active is None:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
now = datetime.now()
|
|
72
|
+
active.end_time = now.isoformat()
|
|
73
|
+
active.safety_score_end = safety_score or active.safety_score_start
|
|
74
|
+
|
|
75
|
+
# Save to history
|
|
76
|
+
history_path = SESSION_DIR / f"{active.id}.json"
|
|
77
|
+
history_path.write_text(
|
|
78
|
+
json.dumps(self._session_to_dict(active), indent=2, default=str),
|
|
79
|
+
encoding="utf-8",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Clear active
|
|
83
|
+
if ACTIVE_SESSION_FILE.exists():
|
|
84
|
+
ACTIVE_SESSION_FILE.unlink(missing_ok=True)
|
|
85
|
+
|
|
86
|
+
# Clean up old sessions (keep last 50)
|
|
87
|
+
self._cleanup_old_sessions()
|
|
88
|
+
|
|
89
|
+
return active
|
|
90
|
+
|
|
91
|
+
def get_active_session(self) -> VibeSession | None:
|
|
92
|
+
"""Get the currently active session, if any."""
|
|
93
|
+
if not ACTIVE_SESSION_FILE.exists():
|
|
94
|
+
return None
|
|
95
|
+
try:
|
|
96
|
+
data = json.loads(ACTIVE_SESSION_FILE.read_text(encoding="utf-8"))
|
|
97
|
+
return self._dict_to_session(data)
|
|
98
|
+
except Exception:
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
def get_session_history(self, limit: int = 20) -> list[VibeSession]:
|
|
102
|
+
"""Get recent completed sessions."""
|
|
103
|
+
if not SESSION_DIR.exists():
|
|
104
|
+
return []
|
|
105
|
+
|
|
106
|
+
sessions: list[VibeSession] = []
|
|
107
|
+
for f in sorted(SESSION_DIR.iterdir(), reverse=True):
|
|
108
|
+
if f.suffix == ".json":
|
|
109
|
+
try:
|
|
110
|
+
data = json.loads(f.read_text(encoding="utf-8"))
|
|
111
|
+
sessions.append(self._dict_to_session(data))
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
if len(sessions) >= limit:
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
return sessions
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
# Session tracking helpers (used by guardian)
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
def record_file_change(self, filepath: str):
|
|
123
|
+
"""Record a file change in the active session."""
|
|
124
|
+
active = self.get_active_session()
|
|
125
|
+
if active is None:
|
|
126
|
+
return
|
|
127
|
+
if filepath not in active.files_changed:
|
|
128
|
+
active.files_changed.append(filepath)
|
|
129
|
+
self._save_active(active)
|
|
130
|
+
|
|
131
|
+
def record_incident(self, incident: dict[str, Any]):
|
|
132
|
+
"""Record a guardian incident in the active session."""
|
|
133
|
+
active = self.get_active_session()
|
|
134
|
+
if active is None:
|
|
135
|
+
return
|
|
136
|
+
active.incidents.append(incident)
|
|
137
|
+
self._save_active(active)
|
|
138
|
+
|
|
139
|
+
def update_safety_score(self, score: int):
|
|
140
|
+
"""Update the running safety score for the session."""
|
|
141
|
+
active = self.get_active_session()
|
|
142
|
+
if active is None:
|
|
143
|
+
return
|
|
144
|
+
active.safety_score_end = score
|
|
145
|
+
self._save_active(active)
|
|
146
|
+
|
|
147
|
+
def get_session_summary(self, session: VibeSession) -> str:
|
|
148
|
+
"""Generate a human-readable summary of a session."""
|
|
149
|
+
duration = ""
|
|
150
|
+
if session.end_time:
|
|
151
|
+
start = datetime.fromisoformat(session.start_time)
|
|
152
|
+
end = datetime.fromisoformat(session.end_time)
|
|
153
|
+
delta = end - start
|
|
154
|
+
mins = int(delta.total_seconds() / 60)
|
|
155
|
+
duration = f"{mins}min"
|
|
156
|
+
else:
|
|
157
|
+
start = datetime.fromisoformat(session.start_time)
|
|
158
|
+
elapsed = int((datetime.now() - start).total_seconds() / 60)
|
|
159
|
+
duration = f"{elapsed}min (active)"
|
|
160
|
+
|
|
161
|
+
score_delta = ""
|
|
162
|
+
if session.safety_score_end is not None:
|
|
163
|
+
diff = session.safety_score_end - session.safety_score_start
|
|
164
|
+
if diff < 0:
|
|
165
|
+
score_delta = f" ↓ {abs(diff)} pts"
|
|
166
|
+
else:
|
|
167
|
+
score_delta = f" ↑ {diff} pts"
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
f"Session: {session.label}\n"
|
|
171
|
+
f" Duration: {duration}\n"
|
|
172
|
+
f" Files touched: {len(session.files_changed)}\n"
|
|
173
|
+
f" Incidents: {len(session.incidents)}\n"
|
|
174
|
+
f" Safety: {session.safety_score_start} → {session.safety_score_end or '?'}{score_delta}\n"
|
|
175
|
+
f" Debris found: {session.total_debris_found}\n"
|
|
176
|
+
f" Dead symbols: {session.total_dead_symbols}"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# ------------------------------------------------------------------
|
|
180
|
+
# Internal
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
def _session_to_dict(self, session: VibeSession) -> dict[str, Any]:
|
|
183
|
+
return {
|
|
184
|
+
"id": session.id,
|
|
185
|
+
"label": session.label,
|
|
186
|
+
"start_time": session.start_time,
|
|
187
|
+
"end_time": session.end_time,
|
|
188
|
+
"files_changed": session.files_changed,
|
|
189
|
+
"incidents": session.incidents,
|
|
190
|
+
"safety_score_start": session.safety_score_start,
|
|
191
|
+
"safety_score_end": session.safety_score_end,
|
|
192
|
+
"total_debris_found": session.total_debris_found,
|
|
193
|
+
"total_dead_symbols": session.total_dead_symbols,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
def _dict_to_session(self, data: dict[str, Any]) -> VibeSession:
|
|
197
|
+
return VibeSession(
|
|
198
|
+
id=data.get("id", ""),
|
|
199
|
+
label=data.get("label", ""),
|
|
200
|
+
start_time=data.get("start_time", ""),
|
|
201
|
+
end_time=data.get("end_time"),
|
|
202
|
+
files_changed=data.get("files_changed", []),
|
|
203
|
+
incidents=data.get("incidents", []),
|
|
204
|
+
safety_score_start=data.get("safety_score_start", 100),
|
|
205
|
+
safety_score_end=data.get("safety_score_end"),
|
|
206
|
+
total_debris_found=data.get("total_debris_found", 0),
|
|
207
|
+
total_dead_symbols=data.get("total_dead_symbols", 0),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def _save_active(self, session: VibeSession):
|
|
211
|
+
ACTIVE_SESSION_FILE.write_text(
|
|
212
|
+
json.dumps(self._session_to_dict(session), indent=2, default=str),
|
|
213
|
+
encoding="utf-8",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def _cleanup_old_sessions(self, keep: int = 50):
|
|
217
|
+
if not SESSION_DIR.exists():
|
|
218
|
+
return
|
|
219
|
+
all_sessions = sorted(SESSION_DIR.iterdir(), reverse=True)
|
|
220
|
+
for f in all_sessions[keep:]:
|
|
221
|
+
try:
|
|
222
|
+
f.unlink()
|
|
223
|
+
except Exception:
|
|
224
|
+
pass
|
deadpush/tests.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test Quality Analyzer — detects weak, trivial, or sabotaged AI-generated tests.
|
|
3
|
+
|
|
4
|
+
AI coding agents frequently produce tests that pass trivially:
|
|
5
|
+
- No assertions (test runs but verifies nothing)
|
|
6
|
+
- Tautological assertions (assert True, assert 1 == 1)
|
|
7
|
+
- Overly broad exception catching (test never fails)
|
|
8
|
+
- Implementation mirroring (test reimplements the logic it's testing)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import ast
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
ASSERT_KEYWORDS = {
|
|
21
|
+
"assert", "assertEqual", "assertEquals", "assertTrue", "assertFalse",
|
|
22
|
+
"assertIs", "assertIsNot", "assertIsNone", "assertIsNotNone",
|
|
23
|
+
"assertIn", "assertNotIn", "assertRaises", "assertAlmostEqual",
|
|
24
|
+
"assertGreater", "assertLess", "assertRegex", "assertCountEqual",
|
|
25
|
+
"assertDictEqual", "assertListEqual", "assertSetEqual", "assertTupleEqual",
|
|
26
|
+
"assertMultiLineEqual", "assertSequenceEqual",
|
|
27
|
+
"expect", "toHaveBeenCalled", "toHaveBeenCalledWith",
|
|
28
|
+
"toBe", "toEqual", "toMatch", "toContain", "toBeTruthy",
|
|
29
|
+
"toBeFalsy", "toBeNull", "toBeUndefined", "toBeDefined",
|
|
30
|
+
"toThrow", "toThrowError", "toStrictEqual", "toHaveProperty",
|
|
31
|
+
"should", "should.equal", "should.eql", "should.be",
|
|
32
|
+
"expectThat", "assertThat", "verify",
|
|
33
|
+
"assert_not_called", "assert_called_once", "assert_called_with",
|
|
34
|
+
"assert_has_calls", "assert_any_call", "assert_not_awaited",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
TAUTOLOGY_PATTERNS = [
|
|
38
|
+
re.compile(r'assert\s+True\b'),
|
|
39
|
+
re.compile(r'assert\s+False\b'),
|
|
40
|
+
re.compile(r'assert\s+1\s*==\s*1'),
|
|
41
|
+
re.compile(r'assert\s+0\s*==\s*0'),
|
|
42
|
+
re.compile(r'assert\s+"[^"]*"\s*==\s*"[^"]*"'),
|
|
43
|
+
re.compile(r'assertEqual\(True,\s*True\)'),
|
|
44
|
+
re.compile(r'assertEqual\(False,\s*False\)'),
|
|
45
|
+
re.compile(r'\.toBe\(true\)'),
|
|
46
|
+
re.compile(r'\.toBe\(false\)'),
|
|
47
|
+
re.compile(r'\.toEqual\(\{[^}]*\},\s*\{[^}]*\}\)'),
|
|
48
|
+
re.compile(r'assert\s+is\s+None\s*\n'),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
BROAD_CATCH_PATTERNS = [
|
|
53
|
+
re.compile(r'except\s+Exception\s*:'),
|
|
54
|
+
re.compile(r'except\s*:'),
|
|
55
|
+
re.compile(r'catch\s*\(\s*(err|e|error)\s*\)\s*\{'),
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class TestIssue:
|
|
61
|
+
"""A quality issue found in a test file."""
|
|
62
|
+
file: str
|
|
63
|
+
line: int
|
|
64
|
+
issue_type: str # "no_assertions" | "tautology" | "broad_catch" | "empty_test"
|
|
65
|
+
description: str
|
|
66
|
+
confidence: float = 0.9
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TestAnalyzer:
|
|
70
|
+
"""Analyzes test files for quality issues common in AI-generated code."""
|
|
71
|
+
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
# Python-specific: AST-based analysis
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
def _analyze_python_test(self, path: Path, rel_path: str) -> list[TestIssue]:
|
|
76
|
+
"""Deep analysis of Python test files using AST."""
|
|
77
|
+
issues: list[TestIssue] = []
|
|
78
|
+
try:
|
|
79
|
+
source = path.read_text(encoding="utf-8", errors="ignore")
|
|
80
|
+
tree = ast.parse(source, filename=str(path))
|
|
81
|
+
except (SyntaxError, Exception):
|
|
82
|
+
return issues
|
|
83
|
+
|
|
84
|
+
for node in ast.walk(tree):
|
|
85
|
+
# Find test functions (def test_* or def *_test)
|
|
86
|
+
if isinstance(node, ast.FunctionDef) and (node.name.startswith("test_") or node.name.endswith("_test")):
|
|
87
|
+
self._check_python_test_function(node, source, path, rel_path, issues)
|
|
88
|
+
|
|
89
|
+
# Find test classes (class Test*)
|
|
90
|
+
if isinstance(node, ast.ClassDef) and node.name.startswith("Test"):
|
|
91
|
+
for item in node.body:
|
|
92
|
+
if isinstance(item, ast.FunctionDef) and (item.name.startswith("test_") or item.name.endswith("_test")):
|
|
93
|
+
self._check_python_test_function(item, source, path, rel_path, issues)
|
|
94
|
+
|
|
95
|
+
return issues
|
|
96
|
+
|
|
97
|
+
def _check_python_test_function(self, node: ast.FunctionDef, source: str, path: Path, rel_path: str, issues: list[TestIssue]):
|
|
98
|
+
"""Check a single Python test function for quality issues."""
|
|
99
|
+
func_source = ast.get_source_segment(source, node) or ""
|
|
100
|
+
|
|
101
|
+
# Count assertion calls
|
|
102
|
+
has_assertion = False
|
|
103
|
+
has_tautology = False
|
|
104
|
+
for child in ast.walk(node):
|
|
105
|
+
# Direct assert statements
|
|
106
|
+
if isinstance(child, ast.Assert):
|
|
107
|
+
has_assertion = True
|
|
108
|
+
# Check for tautologies
|
|
109
|
+
if isinstance(child.test, ast.Constant):
|
|
110
|
+
if child.test.value is True or child.test.value is False:
|
|
111
|
+
has_tautology = True
|
|
112
|
+
issues.append(TestIssue(
|
|
113
|
+
file=rel_path,
|
|
114
|
+
line=child.lineno or node.lineno,
|
|
115
|
+
issue_type="tautology",
|
|
116
|
+
description=f"Tautological assertion 'assert {child.test.value}' in test '{node.name}'",
|
|
117
|
+
confidence=0.95,
|
|
118
|
+
))
|
|
119
|
+
elif isinstance(child.test, ast.Compare):
|
|
120
|
+
if self._is_self_comparison(child.test):
|
|
121
|
+
has_tautology = True
|
|
122
|
+
issues.append(TestIssue(
|
|
123
|
+
file=rel_path,
|
|
124
|
+
line=child.lineno or node.lineno,
|
|
125
|
+
issue_type="tautology",
|
|
126
|
+
description=f"Self-comparison assertion in test '{node.name}'",
|
|
127
|
+
confidence=0.92,
|
|
128
|
+
))
|
|
129
|
+
|
|
130
|
+
# assertEqual/assertTrue/etc method calls
|
|
131
|
+
elif isinstance(child, ast.Call) and isinstance(child.func, ast.Attribute):
|
|
132
|
+
if child.func.attr in ("assertEqual", "assertEquals") and len(child.args) == 2:
|
|
133
|
+
has_assertion = True
|
|
134
|
+
if self._is_self_comparison_of_args(child.args[0], child.args[1]):
|
|
135
|
+
has_tautology = True
|
|
136
|
+
issues.append(TestIssue(
|
|
137
|
+
file=rel_path,
|
|
138
|
+
line=child.lineno or node.lineno,
|
|
139
|
+
issue_type="tautology",
|
|
140
|
+
description=f"Self-comparison assertEqual in test '{node.name}'",
|
|
141
|
+
confidence=0.92,
|
|
142
|
+
))
|
|
143
|
+
elif child.func.attr in ("assertTrue", "assertFalse"):
|
|
144
|
+
has_assertion = True
|
|
145
|
+
if child.args and isinstance(child.args[0], ast.Constant) and isinstance(child.args[0].value, bool):
|
|
146
|
+
has_tautology = True
|
|
147
|
+
issues.append(TestIssue(
|
|
148
|
+
file=rel_path,
|
|
149
|
+
line=child.lineno or node.lineno,
|
|
150
|
+
issue_type="tautology",
|
|
151
|
+
description=f"Tautological assert{child.func.attr} in test '{node.name}'",
|
|
152
|
+
confidence=0.95,
|
|
153
|
+
))
|
|
154
|
+
|
|
155
|
+
# Check for broad exception catching
|
|
156
|
+
for child in ast.walk(node):
|
|
157
|
+
if isinstance(child, ast.ExceptHandler):
|
|
158
|
+
if child.type is None:
|
|
159
|
+
issues.append(TestIssue(
|
|
160
|
+
file=rel_path,
|
|
161
|
+
line=child.lineno or node.lineno,
|
|
162
|
+
issue_type="broad_catch",
|
|
163
|
+
description=f"Bare 'except:' in test '{node.name}' — test will never fail",
|
|
164
|
+
confidence=0.93,
|
|
165
|
+
))
|
|
166
|
+
|
|
167
|
+
# Check if test is completely empty or just pass/docstring
|
|
168
|
+
body_lines = [b for b in node.body if not isinstance(b, (ast.Expr, ast.Pass, ast.Constant))]
|
|
169
|
+
if not [b for b in node.body if not isinstance(b, (ast.Expr, ast.Pass))] and not has_assertion:
|
|
170
|
+
issues.append(TestIssue(
|
|
171
|
+
file=rel_path,
|
|
172
|
+
line=node.lineno,
|
|
173
|
+
issue_type="empty_test",
|
|
174
|
+
description=f"Test '{node.name}' is empty (only docstring/pass)",
|
|
175
|
+
confidence=0.98,
|
|
176
|
+
))
|
|
177
|
+
|
|
178
|
+
# No assertions at all
|
|
179
|
+
if not has_assertion and not has_tautology:
|
|
180
|
+
# Only flag if it has real code (not just pass/docstring)
|
|
181
|
+
has_code = any(
|
|
182
|
+
isinstance(b, (ast.Assign, ast.Call, ast.With, ast.For, ast.While))
|
|
183
|
+
for b in node.body
|
|
184
|
+
)
|
|
185
|
+
if has_code:
|
|
186
|
+
issues.append(TestIssue(
|
|
187
|
+
file=rel_path,
|
|
188
|
+
line=node.lineno,
|
|
189
|
+
issue_type="no_assertions",
|
|
190
|
+
description=f"Test '{node.name}' has no assertions — verifies nothing",
|
|
191
|
+
confidence=0.90,
|
|
192
|
+
))
|
|
193
|
+
|
|
194
|
+
def _is_self_comparison(self, compare: ast.Compare) -> bool:
|
|
195
|
+
"""Check if comparison is against itself (e.g., x == x)."""
|
|
196
|
+
if len(compare.ops) == 1 and len(compare.comparators) == 1:
|
|
197
|
+
left = compare.left
|
|
198
|
+
right = compare.comparators[0]
|
|
199
|
+
if isinstance(left, ast.Name) and isinstance(right, ast.Name):
|
|
200
|
+
return left.id == right.id
|
|
201
|
+
if isinstance(left, ast.Constant) and isinstance(right, ast.Constant):
|
|
202
|
+
return left.value == right.value
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
def _is_self_comparison_of_args(self, arg1, arg2) -> bool:
|
|
206
|
+
"""Check if two AST nodes represent the same expression."""
|
|
207
|
+
if isinstance(arg1, ast.Name) and isinstance(arg2, ast.Name):
|
|
208
|
+
return arg1.id == arg2.id
|
|
209
|
+
if isinstance(arg1, ast.Constant) and isinstance(arg2, ast.Constant):
|
|
210
|
+
return arg1.value == arg2.value
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
# ------------------------------------------------------------------
|
|
214
|
+
# Generic (JS/TS/Go/Rust): regex-based analysis
|
|
215
|
+
# ------------------------------------------------------------------
|
|
216
|
+
def _analyze_generic_test(self, path: Path, rel_path: str) -> list[TestIssue]:
|
|
217
|
+
"""Regex-based test quality check for non-Python languages."""
|
|
218
|
+
issues: list[TestIssue] = []
|
|
219
|
+
try:
|
|
220
|
+
source = path.read_text(encoding="utf-8", errors="ignore")
|
|
221
|
+
lines = source.splitlines()
|
|
222
|
+
except Exception:
|
|
223
|
+
return issues
|
|
224
|
+
|
|
225
|
+
# Find test functions by common patterns
|
|
226
|
+
test_func_pattern = re.compile(
|
|
227
|
+
r'(?:it|test|describe)\s*\(?\s*[\'"]([^\'"]+)[\'"]\s*,?\s*(?:function\s*\(|\(|async\s*\(|=>)'
|
|
228
|
+
)
|
|
229
|
+
# Jest/Vitest: test('name', () => { ... })
|
|
230
|
+
# Go: func Test*(t *testing.T)
|
|
231
|
+
# Rust: #[test] fn test_*
|
|
232
|
+
|
|
233
|
+
# For JS/TS, find test blocks and check for assertions
|
|
234
|
+
in_test = False
|
|
235
|
+
test_name = ""
|
|
236
|
+
test_start = 0
|
|
237
|
+
open_parens = 0
|
|
238
|
+
has_assert = False
|
|
239
|
+
has_tautology = False
|
|
240
|
+
|
|
241
|
+
for i, line in enumerate(lines):
|
|
242
|
+
stripped = line.strip()
|
|
243
|
+
|
|
244
|
+
# Detect test function start
|
|
245
|
+
match = test_func_pattern.search(line)
|
|
246
|
+
if match and ("=>" in stripped or "function" in stripped):
|
|
247
|
+
if in_test:
|
|
248
|
+
# Previous test had no assertions
|
|
249
|
+
if not has_assert:
|
|
250
|
+
issues.append(TestIssue(
|
|
251
|
+
file=rel_path,
|
|
252
|
+
line=test_start + 1,
|
|
253
|
+
issue_type="no_assertions",
|
|
254
|
+
description=f"Test '{test_name}' has no assertions",
|
|
255
|
+
confidence=0.85,
|
|
256
|
+
))
|
|
257
|
+
test_name = match.group(1)
|
|
258
|
+
test_start = i
|
|
259
|
+
in_test = True
|
|
260
|
+
has_assert = False
|
|
261
|
+
|
|
262
|
+
# Count braces for scope tracking
|
|
263
|
+
open_parens = stripped.count("{") - stripped.count("}")
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
if in_test:
|
|
267
|
+
# Track braces for end of test
|
|
268
|
+
open_parens += stripped.count("{") - stripped.count("}")
|
|
269
|
+
if open_parens <= 0:
|
|
270
|
+
in_test = False
|
|
271
|
+
if not has_assert:
|
|
272
|
+
issues.append(TestIssue(
|
|
273
|
+
file=rel_path,
|
|
274
|
+
line=test_start + 1,
|
|
275
|
+
issue_type="no_assertions",
|
|
276
|
+
description=f"Test '{test_name}' has no assertions",
|
|
277
|
+
confidence=0.85,
|
|
278
|
+
))
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
# Check for assertions
|
|
282
|
+
if any(kw in stripped for kw in ASSERT_KEYWORDS):
|
|
283
|
+
has_assert = True
|
|
284
|
+
|
|
285
|
+
# Check for tautologies
|
|
286
|
+
for pat in TAUTOLOGY_PATTERNS:
|
|
287
|
+
if pat.search(stripped):
|
|
288
|
+
has_tautology = True
|
|
289
|
+
issues.append(TestIssue(
|
|
290
|
+
file=rel_path,
|
|
291
|
+
line=i + 1,
|
|
292
|
+
issue_type="tautology",
|
|
293
|
+
description=f"Tautological assertion in test '{test_name}': {stripped[:60]}",
|
|
294
|
+
confidence=0.92,
|
|
295
|
+
))
|
|
296
|
+
|
|
297
|
+
return issues
|
|
298
|
+
|
|
299
|
+
# ------------------------------------------------------------------
|
|
300
|
+
# Public API
|
|
301
|
+
# ------------------------------------------------------------------
|
|
302
|
+
def analyze_file(self, path: Path, rel_path: str) -> list[TestIssue]:
|
|
303
|
+
"""Analyze a single source file for test quality issues."""
|
|
304
|
+
# Only analyze test files
|
|
305
|
+
rel_str = rel_path.lower()
|
|
306
|
+
is_test_file = (
|
|
307
|
+
rel_str.startswith("test") or "/test" in rel_str or "\\test" in rel_str
|
|
308
|
+
or rel_str.startswith("spec") or "/spec" in rel_str
|
|
309
|
+
or rel_str.endswith("_test.go") or rel_str.endswith("_test.rs")
|
|
310
|
+
or rel_str.endswith(".test.js") or rel_str.endswith(".test.ts")
|
|
311
|
+
or rel_str.endswith(".spec.js") or rel_str.endswith(".spec.ts")
|
|
312
|
+
or rel_str.endswith("_test.py") or rel_str.endswith("_test.rs")
|
|
313
|
+
)
|
|
314
|
+
if not is_test_file:
|
|
315
|
+
return []
|
|
316
|
+
|
|
317
|
+
if path.suffix == ".py":
|
|
318
|
+
return self._analyze_python_test(path, rel_path)
|
|
319
|
+
else:
|
|
320
|
+
return self._analyze_generic_test(path, rel_path)
|
|
321
|
+
|
|
322
|
+
def analyze_batch(self, files: list[Any]) -> list[TestIssue]:
|
|
323
|
+
"""Analyze all test files in a batch."""
|
|
324
|
+
all_issues: list[TestIssue] = []
|
|
325
|
+
for f in files:
|
|
326
|
+
if not getattr(f, "is_text", True):
|
|
327
|
+
continue
|
|
328
|
+
try:
|
|
329
|
+
issues = self.analyze_file(f.path, str(getattr(f, "rel_path", f.path)))
|
|
330
|
+
all_issues.extend(issues)
|
|
331
|
+
except Exception:
|
|
332
|
+
pass
|
|
333
|
+
return all_issues
|