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/deps.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dependency Diff Review — detects new dependencies added in a session.
|
|
3
|
+
|
|
4
|
+
AI agents frequently add unnecessary dependencies. This module compares
|
|
5
|
+
current dependency files with the committed versions, showing what's new
|
|
6
|
+
with registry metadata (package age, downloads) to help evaluate risk.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import time
|
|
15
|
+
import urllib.request
|
|
16
|
+
import urllib.error
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
DEPS_CACHE_FILE = Path.home() / ".deadpush" / "deps_cache.json"
|
|
23
|
+
DEPS_CACHE_MAX_AGE = 86400 # 24 hours
|
|
24
|
+
REGISTRY_TIMEOUT = 5
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Dependency:
|
|
29
|
+
"""A single dependency entry."""
|
|
30
|
+
name: str
|
|
31
|
+
version: str
|
|
32
|
+
source_file: str # e.g., "pyproject.toml", "package.json"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class DepDiff:
|
|
37
|
+
"""Result of comparing dependency files."""
|
|
38
|
+
added: list[Dependency] = field(default_factory=list)
|
|
39
|
+
removed: list[Dependency] = field(default_factory=list)
|
|
40
|
+
changed: list[tuple[Dependency, Dependency]] = field(default_factory=list) # (old, new)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DepsReviewer:
|
|
44
|
+
"""Reviews dependencies by comparing current files with HEAD and checking registries."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, repo_root: Path):
|
|
47
|
+
self.repo_root = repo_root
|
|
48
|
+
self._cache: dict[str, Any] = {}
|
|
49
|
+
self._load_cache()
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------------
|
|
52
|
+
# Cache for registry lookups
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
def _load_cache(self):
|
|
55
|
+
if DEPS_CACHE_FILE.exists():
|
|
56
|
+
try:
|
|
57
|
+
data = json.loads(DEPS_CACHE_FILE.read_text(encoding="utf-8"))
|
|
58
|
+
now = time.time()
|
|
59
|
+
self._cache = {
|
|
60
|
+
k: v for k, v in data.items()
|
|
61
|
+
if now - v.get("cached_at", 0) < DEPS_CACHE_MAX_AGE
|
|
62
|
+
}
|
|
63
|
+
except Exception:
|
|
64
|
+
self._cache = {}
|
|
65
|
+
|
|
66
|
+
def _save_cache(self):
|
|
67
|
+
try:
|
|
68
|
+
DEPS_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
DEPS_CACHE_FILE.write_text(
|
|
70
|
+
json.dumps(self._cache, indent=2, default=str),
|
|
71
|
+
encoding="utf-8",
|
|
72
|
+
)
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
# Parsing dependency files
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
def _parse_pyproject(self, content: str) -> list[Dependency]:
|
|
80
|
+
"""Parse pyproject.toml dependencies."""
|
|
81
|
+
deps: list[Dependency] = []
|
|
82
|
+
try:
|
|
83
|
+
import tomllib
|
|
84
|
+
data = tomllib.loads(content)
|
|
85
|
+
except Exception:
|
|
86
|
+
# Fallback to regex
|
|
87
|
+
for m in re.finditer(r'"([^"]+)"\s*=\s*"([^"]*)"', content):
|
|
88
|
+
deps.append(Dependency(name=m.group(1), version=m.group(2), source_file="pyproject.toml"))
|
|
89
|
+
return deps
|
|
90
|
+
|
|
91
|
+
for section in ("project.dependencies", "project.optional-dependencies"):
|
|
92
|
+
keys = section.split(".")
|
|
93
|
+
d = data
|
|
94
|
+
for k in keys:
|
|
95
|
+
d = d.get(k, {}) if isinstance(d, dict) else {}
|
|
96
|
+
if isinstance(d, list):
|
|
97
|
+
for dep_str in d:
|
|
98
|
+
if isinstance(dep_str, str):
|
|
99
|
+
m = re.match(r'^([\w.-]+)\s*(.+)?', dep_str)
|
|
100
|
+
if m:
|
|
101
|
+
deps.append(Dependency(name=m.group(1), version=(m.group(2) or "").strip(), source_file="pyproject.toml"))
|
|
102
|
+
return deps
|
|
103
|
+
|
|
104
|
+
def _parse_requirements(self, content: str) -> list[Dependency]:
|
|
105
|
+
"""Parse requirements.txt."""
|
|
106
|
+
deps: list[Dependency] = []
|
|
107
|
+
for line in content.splitlines():
|
|
108
|
+
line = line.strip()
|
|
109
|
+
if not line or line.startswith("#") or line.startswith("-"):
|
|
110
|
+
continue
|
|
111
|
+
m = re.match(r'^([\w.-]+)\s*([><=!]+\s*\S+)?', line)
|
|
112
|
+
if m:
|
|
113
|
+
deps.append(Dependency(name=m.group(1), version=(m.group(2) or "").strip(), source_file="requirements.txt"))
|
|
114
|
+
return deps
|
|
115
|
+
|
|
116
|
+
def _parse_package_json(self, content: str) -> list[Dependency]:
|
|
117
|
+
"""Parse package.json dependencies."""
|
|
118
|
+
deps: list[Dependency] = []
|
|
119
|
+
try:
|
|
120
|
+
data = json.loads(content)
|
|
121
|
+
except Exception:
|
|
122
|
+
return deps
|
|
123
|
+
|
|
124
|
+
for section in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
|
|
125
|
+
for name, version in data.get(section, {}).items():
|
|
126
|
+
deps.append(Dependency(name=name, version=str(version), source_file="package.json"))
|
|
127
|
+
return deps
|
|
128
|
+
|
|
129
|
+
def _parse_cargo(self, content: str) -> list[Dependency]:
|
|
130
|
+
"""Parse Cargo.toml dependencies."""
|
|
131
|
+
deps: list[Dependency] = []
|
|
132
|
+
try:
|
|
133
|
+
import tomllib
|
|
134
|
+
data = tomllib.loads(content)
|
|
135
|
+
except Exception:
|
|
136
|
+
for m in re.finditer(r'(\w+)\s*=\s*\{?\s*version\s*=\s*"([^"]+)"', content):
|
|
137
|
+
deps.append(Dependency(name=m.group(1), version=m.group(2), source_file="Cargo.toml"))
|
|
138
|
+
return deps
|
|
139
|
+
|
|
140
|
+
for name, info in data.get("dependencies", {}).items():
|
|
141
|
+
if isinstance(info, str):
|
|
142
|
+
deps.append(Dependency(name=name, version=info, source_file="Cargo.toml"))
|
|
143
|
+
elif isinstance(info, dict):
|
|
144
|
+
deps.append(Dependency(name=name, version=info.get("version", "*"), source_file="Cargo.toml"))
|
|
145
|
+
return deps
|
|
146
|
+
|
|
147
|
+
def _parse_gomod(self, content: str) -> list[Dependency]:
|
|
148
|
+
"""Parse go.mod dependencies."""
|
|
149
|
+
deps: list[Dependency] = []
|
|
150
|
+
for line in content.splitlines():
|
|
151
|
+
line = line.strip()
|
|
152
|
+
if line.startswith("require (") or line.startswith(")"):
|
|
153
|
+
continue
|
|
154
|
+
m = re.match(r'^([\w./-]+)\s+v?([\w.+-]+)', line)
|
|
155
|
+
if m and not m.group(1).startswith("go ") and m.group(1) != "require":
|
|
156
|
+
deps.append(Dependency(name=m.group(1), version="v" + m.group(2).lstrip("v"), source_file="go.mod"))
|
|
157
|
+
return deps
|
|
158
|
+
|
|
159
|
+
def _parse_file(self, content: str, path: Path) -> list[Dependency]:
|
|
160
|
+
"""Dispatch to the appropriate parser based on filename."""
|
|
161
|
+
name = path.name
|
|
162
|
+
if name == "pyproject.toml":
|
|
163
|
+
return self._parse_pyproject(content)
|
|
164
|
+
elif name == "requirements.txt" or name.endswith("-requirements.txt"):
|
|
165
|
+
return self._parse_requirements(content)
|
|
166
|
+
elif name == "package.json":
|
|
167
|
+
return self._parse_package_json(content)
|
|
168
|
+
elif name == "Cargo.toml":
|
|
169
|
+
return self._parse_cargo(content)
|
|
170
|
+
elif name == "go.mod":
|
|
171
|
+
return self._parse_gomod(content)
|
|
172
|
+
return []
|
|
173
|
+
|
|
174
|
+
# ------------------------------------------------------------------
|
|
175
|
+
# Git comparison
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
def _get_head_version(self, rel_path: str) -> str | None:
|
|
178
|
+
"""Get the committed version of a file from git HEAD."""
|
|
179
|
+
try:
|
|
180
|
+
result = subprocess.run(
|
|
181
|
+
["git", "show", f"HEAD:{rel_path}"],
|
|
182
|
+
capture_output=True, text=True, timeout=5,
|
|
183
|
+
cwd=self.repo_root,
|
|
184
|
+
)
|
|
185
|
+
if result.returncode == 0:
|
|
186
|
+
return result.stdout
|
|
187
|
+
except Exception:
|
|
188
|
+
pass
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
# ------------------------------------------------------------------
|
|
192
|
+
# Registry metadata
|
|
193
|
+
# ------------------------------------------------------------------
|
|
194
|
+
def _lookup_pypi(self, package: str) -> dict[str, Any] | None:
|
|
195
|
+
"""Look up package metadata from PyPI."""
|
|
196
|
+
cache_key = f"pypi:{package.lower()}"
|
|
197
|
+
if cache_key in self._cache:
|
|
198
|
+
return self._cache[cache_key]["data"]
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
url = f"https://pypi.org/pypi/{package}/json"
|
|
202
|
+
req = urllib.request.Request(url)
|
|
203
|
+
req.add_header("User-Agent", "deadpush/0.2.0")
|
|
204
|
+
resp = urllib.request.urlopen(req, timeout=REGISTRY_TIMEOUT)
|
|
205
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
206
|
+
info = data.get("info", {})
|
|
207
|
+
releases = data.get("releases", {})
|
|
208
|
+
first_release = min(releases.keys()) if releases else None
|
|
209
|
+
result = {
|
|
210
|
+
"name": info.get("name", package),
|
|
211
|
+
"latest_version": info.get("version", ""),
|
|
212
|
+
"summary": (info.get("summary", "") or "")[:120],
|
|
213
|
+
"first_release": first_release,
|
|
214
|
+
"home_page": info.get("home_page") or info.get("project_urls", {}).get("Homepage", ""),
|
|
215
|
+
}
|
|
216
|
+
self._cache[cache_key] = {"data": result, "cached_at": time.time()}
|
|
217
|
+
self._save_cache()
|
|
218
|
+
return result
|
|
219
|
+
except Exception:
|
|
220
|
+
self._cache[cache_key] = {"data": None, "cached_at": time.time()}
|
|
221
|
+
self._save_cache()
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
def _lookup_npm(self, package: str) -> dict[str, Any] | None:
|
|
225
|
+
"""Look up package metadata from npm."""
|
|
226
|
+
cache_key = f"npm:{package.lower()}"
|
|
227
|
+
if cache_key in self._cache:
|
|
228
|
+
return self._cache[cache_key]["data"]
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
url = f"https://registry.npmjs.org/{package}/latest"
|
|
232
|
+
req = urllib.request.Request(url)
|
|
233
|
+
req.add_header("User-Agent", "deadpush/0.2.0")
|
|
234
|
+
resp = urllib.request.urlopen(req, timeout=REGISTRY_TIMEOUT)
|
|
235
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
236
|
+
result = {
|
|
237
|
+
"name": data.get("name", package),
|
|
238
|
+
"latest_version": data.get("version", ""),
|
|
239
|
+
"description": (data.get("description", "") or "")[:120],
|
|
240
|
+
"home_page": data.get("homepage", ""),
|
|
241
|
+
}
|
|
242
|
+
self._cache[cache_key] = {"data": result, "cached_at": time.time()}
|
|
243
|
+
self._save_cache()
|
|
244
|
+
return result
|
|
245
|
+
except Exception:
|
|
246
|
+
self._cache[cache_key] = {"data": None, "cached_at": time.time()}
|
|
247
|
+
self._save_cache()
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
# ------------------------------------------------------------------
|
|
251
|
+
# Public API
|
|
252
|
+
# ------------------------------------------------------------------
|
|
253
|
+
def get_dep_files(self) -> list[Path]:
|
|
254
|
+
"""Find dependency files in the repo."""
|
|
255
|
+
dep_files = []
|
|
256
|
+
for name in ("pyproject.toml", "requirements.txt", "package.json", "Cargo.toml", "go.mod"):
|
|
257
|
+
p = self.repo_root / name
|
|
258
|
+
if p.exists():
|
|
259
|
+
dep_files.append(p)
|
|
260
|
+
return dep_files
|
|
261
|
+
|
|
262
|
+
def get_current_deps(self) -> list[Dependency]:
|
|
263
|
+
"""Parse all current dependency files."""
|
|
264
|
+
all_deps: list[Dependency] = []
|
|
265
|
+
for path in self.get_dep_files():
|
|
266
|
+
try:
|
|
267
|
+
content = path.read_text(encoding="utf-8", errors="ignore")
|
|
268
|
+
all_deps.extend(self._parse_file(content, path))
|
|
269
|
+
except Exception:
|
|
270
|
+
pass
|
|
271
|
+
return all_deps
|
|
272
|
+
|
|
273
|
+
def diff_with_head(self) -> DepDiff:
|
|
274
|
+
"""Compare current dependencies with the committed versions."""
|
|
275
|
+
current = self.get_current_deps()
|
|
276
|
+
current_set = {(d.name, d.source_file) for d in current}
|
|
277
|
+
|
|
278
|
+
# Parse HEAD versions
|
|
279
|
+
head_deps: list[Dependency] = []
|
|
280
|
+
for path in self.get_dep_files():
|
|
281
|
+
rel = path.relative_to(self.repo_root).as_posix()
|
|
282
|
+
head_content = self._get_head_version(rel)
|
|
283
|
+
if head_content is not None:
|
|
284
|
+
head_deps.extend(self._parse_file(head_content, path))
|
|
285
|
+
|
|
286
|
+
head_set = {(d.name, d.source_file) for d in head_deps}
|
|
287
|
+
head_by_key = {(d.name, d.source_file): d for d in head_deps}
|
|
288
|
+
current_by_key = {(d.name, d.source_file): d for d in current}
|
|
289
|
+
|
|
290
|
+
added_keys = current_set - head_set
|
|
291
|
+
removed_keys = head_set - current_set
|
|
292
|
+
common_keys = current_set & head_set
|
|
293
|
+
|
|
294
|
+
added = [current_by_key[k] for k in added_keys]
|
|
295
|
+
removed = [head_by_key[k] for k in removed_keys]
|
|
296
|
+
changed = []
|
|
297
|
+
for k in common_keys:
|
|
298
|
+
old = head_by_key[k]
|
|
299
|
+
new = current_by_key[k]
|
|
300
|
+
if old.version != new.version:
|
|
301
|
+
changed.append((old, new))
|
|
302
|
+
|
|
303
|
+
return DepDiff(added=added, removed=removed, changed=changed)
|
|
304
|
+
|
|
305
|
+
def review_added(self, added: list[Dependency]) -> list[dict[str, Any]]:
|
|
306
|
+
"""Look up registry metadata for newly added dependencies."""
|
|
307
|
+
reviews: list[dict[str, Any]] = []
|
|
308
|
+
for dep in added:
|
|
309
|
+
info = None
|
|
310
|
+
source = dep.source_file
|
|
311
|
+
if "pyproject" in source or "requirements" in source:
|
|
312
|
+
info = self._lookup_pypi(dep.name)
|
|
313
|
+
elif "package" in source:
|
|
314
|
+
info = self._lookup_npm(dep.name)
|
|
315
|
+
|
|
316
|
+
review = {
|
|
317
|
+
"name": dep.name,
|
|
318
|
+
"version": dep.version,
|
|
319
|
+
"source_file": dep.source_file,
|
|
320
|
+
"registry_info": info,
|
|
321
|
+
}
|
|
322
|
+
reviews.append(review)
|
|
323
|
+
return reviews
|