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