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/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
deadpush/churn.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git Churn Analytics — detects files that are being rewritten excessively.
|
|
3
|
+
|
|
4
|
+
In vibe coding sessions, AI agents frequently rewrite the same files multiple
|
|
5
|
+
times in slightly different ways, creating instability. High churn is a strong
|
|
6
|
+
signal that a file is being "thrashed" and needs architectural attention.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import subprocess
|
|
13
|
+
import time
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
CHURN_CACHE_FILE = Path.home() / ".deadpush" / "churn_cache.json"
|
|
22
|
+
CHURN_CACHE_MAX_AGE = 3600
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class FileChurn:
|
|
27
|
+
"""Churn metrics for a single file."""
|
|
28
|
+
path: str
|
|
29
|
+
commit_count: int
|
|
30
|
+
author_count: int
|
|
31
|
+
first_commit: str
|
|
32
|
+
last_commit: str
|
|
33
|
+
churn_score: float # 0-1 normalized
|
|
34
|
+
flag_reason: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class ChurnReport:
|
|
39
|
+
"""Complete churn analysis report."""
|
|
40
|
+
total_files_analyzed: int
|
|
41
|
+
high_churn_files: list[FileChurn] = field(default_factory=list)
|
|
42
|
+
total_commits_in_window: int = 0
|
|
43
|
+
generated_at: str = ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ChurnAnalyzer:
|
|
47
|
+
"""Analyzes git churn for a repository."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, repo_root: Path, window_days: int = 30):
|
|
50
|
+
self.repo_root = repo_root
|
|
51
|
+
self.window_days = window_days
|
|
52
|
+
self._cache: dict[str, Any] = {}
|
|
53
|
+
self._load_cache()
|
|
54
|
+
|
|
55
|
+
def _load_cache(self):
|
|
56
|
+
if CHURN_CACHE_FILE.exists():
|
|
57
|
+
try:
|
|
58
|
+
data = json.loads(CHURN_CACHE_FILE.read_text(encoding="utf-8"))
|
|
59
|
+
now = time.time()
|
|
60
|
+
self._cache = {
|
|
61
|
+
k: v for k, v in data.items()
|
|
62
|
+
if now - v.get("cached_at", 0) < CHURN_CACHE_MAX_AGE
|
|
63
|
+
}
|
|
64
|
+
except Exception:
|
|
65
|
+
self._cache = {}
|
|
66
|
+
|
|
67
|
+
def _save_cache(self, key: str, data: Any):
|
|
68
|
+
try:
|
|
69
|
+
CHURN_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
self._cache[key] = {"data": data, "cached_at": time.time()}
|
|
71
|
+
CHURN_CACHE_FILE.write_text(
|
|
72
|
+
json.dumps(self._cache, indent=2, default=str),
|
|
73
|
+
encoding="utf-8",
|
|
74
|
+
)
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
def _run_git_log(self) -> list[dict[str, Any]]:
|
|
79
|
+
"""Run git log and return per-file commit stats."""
|
|
80
|
+
cache_key = f"churn_{self.repo_root}_{self.window_days}"
|
|
81
|
+
if cache_key in self._cache:
|
|
82
|
+
return self._cache[cache_key]["data"]
|
|
83
|
+
|
|
84
|
+
since = f"--since={self.window_days}.days"
|
|
85
|
+
try:
|
|
86
|
+
result = subprocess.run(
|
|
87
|
+
["git", "log", "--name-only", "--pretty=format:%H|%an|%ai", since],
|
|
88
|
+
capture_output=True, text=True, timeout=30,
|
|
89
|
+
cwd=self.repo_root,
|
|
90
|
+
)
|
|
91
|
+
if result.returncode != 0:
|
|
92
|
+
return []
|
|
93
|
+
self._save_cache(cache_key, result.stdout)
|
|
94
|
+
return self._parse_git_log(result.stdout)
|
|
95
|
+
except Exception:
|
|
96
|
+
return []
|
|
97
|
+
|
|
98
|
+
def _parse_git_log(self, output: str) -> list[dict[str, Any]]:
|
|
99
|
+
"""Parse git log --name-only output into structured records."""
|
|
100
|
+
records: list[dict[str, Any]] = []
|
|
101
|
+
current_commit: dict[str, Any] | None = None
|
|
102
|
+
|
|
103
|
+
for line in output.splitlines():
|
|
104
|
+
line = line.strip()
|
|
105
|
+
if not line:
|
|
106
|
+
continue
|
|
107
|
+
if "|" in line and len(line.split("|")) == 3:
|
|
108
|
+
parts = line.split("|")
|
|
109
|
+
current_commit = {
|
|
110
|
+
"hash": parts[0],
|
|
111
|
+
"author": parts[1],
|
|
112
|
+
"date": parts[2],
|
|
113
|
+
"files": [],
|
|
114
|
+
}
|
|
115
|
+
records.append(current_commit)
|
|
116
|
+
elif current_commit is not None and line:
|
|
117
|
+
current_commit["files"].append(line)
|
|
118
|
+
|
|
119
|
+
return records
|
|
120
|
+
|
|
121
|
+
def analyze(self) -> ChurnReport:
|
|
122
|
+
"""Run churn analysis on the repository."""
|
|
123
|
+
commits = self._run_git_log()
|
|
124
|
+
if not commits:
|
|
125
|
+
return ChurnReport(total_files_analyzed=0)
|
|
126
|
+
|
|
127
|
+
# Per-file stats
|
|
128
|
+
file_stats: dict[str, dict[str, Any]] = {}
|
|
129
|
+
for commit in commits:
|
|
130
|
+
for filepath in commit.get("files", []):
|
|
131
|
+
if filepath not in file_stats:
|
|
132
|
+
file_stats[filepath] = {
|
|
133
|
+
"commit_count": 0,
|
|
134
|
+
"authors": set(),
|
|
135
|
+
"first_commit": commit["date"],
|
|
136
|
+
"last_commit": commit["date"],
|
|
137
|
+
}
|
|
138
|
+
stat = file_stats[filepath]
|
|
139
|
+
stat["commit_count"] += 1
|
|
140
|
+
stat["authors"].add(commit["author"])
|
|
141
|
+
if commit["date"] < stat["first_commit"]:
|
|
142
|
+
stat["first_commit"] = commit["date"]
|
|
143
|
+
if commit["date"] > stat["last_commit"]:
|
|
144
|
+
stat["last_commit"] = commit["date"]
|
|
145
|
+
|
|
146
|
+
# Compute churn scores
|
|
147
|
+
if not file_stats:
|
|
148
|
+
return ChurnReport(total_files_analyzed=0)
|
|
149
|
+
|
|
150
|
+
max_commits = max(s["commit_count"] for s in file_stats.values())
|
|
151
|
+
if max_commits == 0:
|
|
152
|
+
return ChurnReport(total_files_analyzed=len(file_stats))
|
|
153
|
+
|
|
154
|
+
high_churn: list[FileChurn] = []
|
|
155
|
+
for filepath, stat in file_stats.items():
|
|
156
|
+
# Normalize churn score: commits / max_commits in repo
|
|
157
|
+
raw_score = stat["commit_count"] / max_commits
|
|
158
|
+
|
|
159
|
+
# Boost score for files with many unique authors (many people touching = unstable)
|
|
160
|
+
author_boost = min(len(stat["authors"]) / 5.0, 0.3)
|
|
161
|
+
churn_score = min(raw_score + author_boost, 1.0)
|
|
162
|
+
|
|
163
|
+
flag_reason = None
|
|
164
|
+
if churn_score > 0.7:
|
|
165
|
+
flag_reason = f"Very high modification frequency ({stat['commit_count']} changes by {len(stat['authors'])} author(s) in {self.window_days} days)"
|
|
166
|
+
elif churn_score > 0.5:
|
|
167
|
+
flag_reason = f"Elevated modification frequency ({stat['commit_count']} changes in {self.window_days} days)"
|
|
168
|
+
|
|
169
|
+
fc = FileChurn(
|
|
170
|
+
path=filepath,
|
|
171
|
+
commit_count=stat["commit_count"],
|
|
172
|
+
author_count=len(stat["authors"]),
|
|
173
|
+
first_commit=stat["first_commit"],
|
|
174
|
+
last_commit=stat["last_commit"],
|
|
175
|
+
churn_score=round(churn_score, 3),
|
|
176
|
+
flag_reason=flag_reason,
|
|
177
|
+
)
|
|
178
|
+
if flag_reason:
|
|
179
|
+
high_churn.append(fc)
|
|
180
|
+
|
|
181
|
+
# Sort by churn score descending
|
|
182
|
+
high_churn.sort(key=lambda x: x.churn_score, reverse=True)
|
|
183
|
+
|
|
184
|
+
return ChurnReport(
|
|
185
|
+
total_files_analyzed=len(file_stats),
|
|
186
|
+
high_churn_files=high_churn,
|
|
187
|
+
total_commits_in_window=len(commits),
|
|
188
|
+
generated_at=datetime.now().isoformat(),
|
|
189
|
+
)
|