depwake 0.3.1__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.
- depwake/__init__.py +3 -0
- depwake/__main__.py +13 -0
- depwake/apply.py +124 -0
- depwake/cache.py +44 -0
- depwake/cli.py +228 -0
- depwake/color.py +42 -0
- depwake/config.py +58 -0
- depwake/manifests.py +226 -0
- depwake/plan.py +137 -0
- depwake/registry.py +157 -0
- depwake/report.py +88 -0
- depwake/semver.py +56 -0
- depwake-0.3.1.dist-info/METADATA +91 -0
- depwake-0.3.1.dist-info/RECORD +18 -0
- depwake-0.3.1.dist-info/WHEEL +5 -0
- depwake-0.3.1.dist-info/entry_points.txt +2 -0
- depwake-0.3.1.dist-info/licenses/LICENSE +21 -0
- depwake-0.3.1.dist-info/top_level.txt +1 -0
depwake/__init__.py
ADDED
depwake/__main__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Entry point. Works as `python -m depwake` AND as `python path/to/__main__.py`."""
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
from .cli import main
|
|
5
|
+
except ImportError: # pragma: no cover - path-invoked fallback
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
10
|
+
from depwake.cli import main
|
|
11
|
+
|
|
12
|
+
if __name__ == "__main__":
|
|
13
|
+
raise SystemExit(main())
|
depwake/apply.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Safe auto-bumps: patch (default) and optionally minor. Never major.
|
|
2
|
+
|
|
3
|
+
Rules, in order:
|
|
4
|
+
1. Only items whose risk is included AND whose current version is exact
|
|
5
|
+
(lockfile or == pin). Range floors are assumptions — listed, not touched.
|
|
6
|
+
2. package.json: keep the range prefix, replace the version triple
|
|
7
|
+
(^4.17.0 -> ^4.17.21). requirements/pyproject: only == pins.
|
|
8
|
+
3. Numbered .bak backups, never clobbered. --dry-run changes nothing.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .plan import Plan
|
|
19
|
+
|
|
20
|
+
TRIPLE = re.compile(r"\d+\.\d+\.\d+[^,\s|]*")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ApplyResult:
|
|
25
|
+
changed: list[str] = field(default_factory=list) # "file: name old -> new"
|
|
26
|
+
skipped: list[str] = field(default_factory=list) # "name: reason"
|
|
27
|
+
backups: list[str] = field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _backup(path: Path) -> Path:
|
|
31
|
+
dest = path.with_suffix(path.suffix + ".bak")
|
|
32
|
+
i = 1
|
|
33
|
+
while dest.exists():
|
|
34
|
+
dest = Path(f"{path}.bak.{i}")
|
|
35
|
+
i += 1
|
|
36
|
+
dest.write_bytes(path.read_bytes())
|
|
37
|
+
return dest
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _bump_npm_range(rng: str, latest: str) -> str | None:
|
|
41
|
+
if not TRIPLE.search(rng):
|
|
42
|
+
return None
|
|
43
|
+
return TRIPLE.sub(latest, rng, count=1)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def apply_plan(root: str | Path, plan: Plan, include: set[str] | None = None,
|
|
47
|
+
dry_run: bool = False) -> ApplyResult:
|
|
48
|
+
include = include or {"patch"}
|
|
49
|
+
root = Path(root)
|
|
50
|
+
res = ApplyResult()
|
|
51
|
+
eligible = [i for i in plan.items
|
|
52
|
+
if i.risk in include
|
|
53
|
+
and i.latest
|
|
54
|
+
and i.dep.current_source in ("lock", "pin")]
|
|
55
|
+
for item in plan.items:
|
|
56
|
+
if item not in eligible:
|
|
57
|
+
if item.risk in ("major",):
|
|
58
|
+
n = len(item.advisories)
|
|
59
|
+
extra = f" — ⚠ {n} open advisor{'y' if n == 1 else 'ies'}, plan it soon" if n else ""
|
|
60
|
+
res.skipped.append(f"{item.dep.name}: major — upgrade deliberately, never auto-bumped{extra}")
|
|
61
|
+
elif item.risk == "unknown":
|
|
62
|
+
res.skipped.append(f"{item.dep.name}: unknown version — resolve first ({item.note})")
|
|
63
|
+
elif item.risk in include:
|
|
64
|
+
res.skipped.append(f"{item.dep.name}: current is a range floor, not exact — add a lockfile")
|
|
65
|
+
continue
|
|
66
|
+
by_file: dict[Path, list] = {}
|
|
67
|
+
for item in eligible:
|
|
68
|
+
if item.dep.ecosystem == "npm":
|
|
69
|
+
by_file.setdefault(root / "package.json", []).append(item)
|
|
70
|
+
else:
|
|
71
|
+
req = root / "requirements.txt"
|
|
72
|
+
proj = root / "pyproject.toml"
|
|
73
|
+
by_file.setdefault(req if req.exists() else proj, []).append(item)
|
|
74
|
+
for path, items in by_file.items():
|
|
75
|
+
if not path.exists():
|
|
76
|
+
for item in items:
|
|
77
|
+
res.skipped.append(f"{item.dep.name}: {path.name} not found")
|
|
78
|
+
continue
|
|
79
|
+
if not dry_run:
|
|
80
|
+
res.backups.append(str(_backup(path)))
|
|
81
|
+
text = path.read_text(encoding="utf-8")
|
|
82
|
+
for item in items:
|
|
83
|
+
d = item.dep
|
|
84
|
+
assert item.latest
|
|
85
|
+
if path.name == "package.json":
|
|
86
|
+
data = json.loads(text)
|
|
87
|
+
done = False
|
|
88
|
+
for section in ("dependencies", "devDependencies",
|
|
89
|
+
"peerDependencies", "optionalDependencies"):
|
|
90
|
+
if d.name in (data.get(section) or {}):
|
|
91
|
+
new = _bump_npm_range(data[section][d.name], item.latest)
|
|
92
|
+
if new and new != data[section][d.name]:
|
|
93
|
+
data[section][d.name] = new
|
|
94
|
+
res.changed.append(f"{path.name}: {d.name} {d.current} -> {item.latest}")
|
|
95
|
+
done = True
|
|
96
|
+
text = json.dumps(data, indent=2) + "\n"
|
|
97
|
+
if not done:
|
|
98
|
+
res.skipped.append(f"{d.name}: range {d.wanted!r} has no version triple to bump")
|
|
99
|
+
else:
|
|
100
|
+
new_text, n = re.subn(
|
|
101
|
+
rf"(?m)^(\s*{re.escape(d.name)}\s*==\s*){re.escape(d.current or '')}\s*$",
|
|
102
|
+
rf"\g<1>{item.latest}", text, count=1)
|
|
103
|
+
if n:
|
|
104
|
+
text = new_text
|
|
105
|
+
res.changed.append(f"{path.name}: {d.name} {d.current} -> {item.latest}")
|
|
106
|
+
else:
|
|
107
|
+
res.skipped.append(f"{d.name}: pin line not found or not ==")
|
|
108
|
+
if not dry_run:
|
|
109
|
+
path.write_text(text, encoding="utf-8")
|
|
110
|
+
return res
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def format_apply(res: ApplyResult, dry_run: bool = False) -> str:
|
|
114
|
+
head = "depwake apply (dry run — nothing written):" if dry_run else "depwake apply:"
|
|
115
|
+
lines = [head]
|
|
116
|
+
for c in res.changed:
|
|
117
|
+
lines.append(f" BUMP {c}")
|
|
118
|
+
for s in res.skipped:
|
|
119
|
+
lines.append(f" SKIP {s}")
|
|
120
|
+
for b in res.backups:
|
|
121
|
+
lines.append(f" backup: {b}")
|
|
122
|
+
if not res.changed:
|
|
123
|
+
lines.append(" nothing to bump.")
|
|
124
|
+
return "\n".join(lines)
|
depwake/cache.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Tiny file cache for registry lookups. Stdlib only.
|
|
2
|
+
|
|
3
|
+
Same TTL for version + advisory data: staleness here costs a delayed
|
|
4
|
+
upgrade notice, never a wrong one (versions only move forward; advisories
|
|
5
|
+
only accumulate — both refresh within the hour).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
TTL = 3600
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def cache_dir() -> Path:
|
|
19
|
+
import os
|
|
20
|
+
base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
|
|
21
|
+
d = Path(base) / "depwake"
|
|
22
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
return d
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _path(key: str) -> Path:
|
|
27
|
+
return cache_dir() / (hashlib.sha256(key.encode()).hexdigest() + ".json")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get(key: str, ttl: float = TTL) -> object | None:
|
|
31
|
+
try:
|
|
32
|
+
p = _path(key)
|
|
33
|
+
if not p.exists() or time.time() - p.stat().st_mtime > ttl:
|
|
34
|
+
return None
|
|
35
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
36
|
+
except (OSError, ValueError):
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def put(key: str, value: object) -> None:
|
|
41
|
+
try:
|
|
42
|
+
_path(key).write_text(json.dumps(value), encoding="utf-8")
|
|
43
|
+
except OSError:
|
|
44
|
+
pass # cache is best-effort; a full disk must never break a plan
|
depwake/cli.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""`depwake` CLI. Stdlib only. Exit codes: 0 clean, 1 findings, 2 usage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .apply import apply_plan, format_apply
|
|
11
|
+
from .cache import get as cache_get
|
|
12
|
+
from .cache import put as cache_put
|
|
13
|
+
from .color import enabled as color_enabled
|
|
14
|
+
from .config import load as load_config
|
|
15
|
+
from .manifests import discover
|
|
16
|
+
from .plan import build
|
|
17
|
+
from .registry import Latest, fetch, fetch_advisories
|
|
18
|
+
from .report import format_json, format_markdown, format_text
|
|
19
|
+
|
|
20
|
+
RISK_ORDER = {"major": 0, "minor": 1, "patch": 2}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _version_fetcher(args: argparse.Namespace):
|
|
24
|
+
def go(eco: str, name: str) -> Latest:
|
|
25
|
+
if args.offline:
|
|
26
|
+
return Latest(None, "", "offline mode (--offline)")
|
|
27
|
+
key = f"latest:{eco}:{name}"
|
|
28
|
+
if not args.no_cache:
|
|
29
|
+
hit = cache_get(key)
|
|
30
|
+
if isinstance(hit, dict) and "version" in hit:
|
|
31
|
+
return Latest(hit.get("version"), hit.get("url", ""), hit.get("error", ""))
|
|
32
|
+
got = fetch(eco, name, args.timeout)
|
|
33
|
+
if not args.no_cache:
|
|
34
|
+
cache_put(key, {"version": got.version, "url": got.url, "error": got.error})
|
|
35
|
+
return got
|
|
36
|
+
return go
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _advisory_fetcher(args: argparse.Namespace):
|
|
40
|
+
def go(deps: list[tuple[str, str, str]]):
|
|
41
|
+
if args.offline or args.no_advisories:
|
|
42
|
+
return {}
|
|
43
|
+
out: dict = {}
|
|
44
|
+
missing = []
|
|
45
|
+
for eco, name, ver in deps:
|
|
46
|
+
key = f"osv:{eco}:{name}:{ver}"
|
|
47
|
+
hit = None if args.no_cache else cache_get(key)
|
|
48
|
+
if isinstance(hit, list):
|
|
49
|
+
from .registry import Advisory
|
|
50
|
+
out[(eco, name)] = [Advisory(**a) for a in hit]
|
|
51
|
+
else:
|
|
52
|
+
missing.append((eco, name, ver))
|
|
53
|
+
if missing:
|
|
54
|
+
fresh = fetch_advisories(missing, args.timeout)
|
|
55
|
+
by_key = {(e, n): v for e, n, v in missing}
|
|
56
|
+
for k, advs in fresh.items():
|
|
57
|
+
out[k] = advs
|
|
58
|
+
if not args.no_cache and k in by_key:
|
|
59
|
+
cache_put(f"osv:{k[0]}:{k[1]}:{by_key[k]}",
|
|
60
|
+
[a.to_dict() for a in advs])
|
|
61
|
+
return out
|
|
62
|
+
return go
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _shared_flags(p: argparse.ArgumentParser) -> None:
|
|
66
|
+
p.add_argument("--timeout", type=float, default=10.0, help="Registry timeout (s).")
|
|
67
|
+
p.add_argument("--no-advisories", action="store_true", help="Skip OSV advisory lookup.")
|
|
68
|
+
p.add_argument("--no-cache", action="store_true", help="Bypass the registry cache.")
|
|
69
|
+
p.add_argument("--offline", action="store_true",
|
|
70
|
+
help="No network: versions/advisories become unknown with reasons.")
|
|
71
|
+
p.add_argument("--color", choices=["auto", "always", "never"], default="auto")
|
|
72
|
+
p.add_argument("--quiet", action="store_true", help="Only the report on stdout.")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _resolve_config(args: argparse.Namespace) -> tuple[list, list, list, str]:
|
|
76
|
+
"""Apply config file: ignore list + fail_on default.
|
|
77
|
+
|
|
78
|
+
Returns (deps, notes, ignored_names, fail_on). Ignored deps vanish from
|
|
79
|
+
counts but always leave a note — silent filtering would be lying by omission.
|
|
80
|
+
"""
|
|
81
|
+
cfg = load_config(args.root)
|
|
82
|
+
deps, notes = discover(args.root)
|
|
83
|
+
ignored = sorted({d.name for d in deps if d.name.lower() in set(cfg.get("ignore", []))})
|
|
84
|
+
if ignored:
|
|
85
|
+
deps = [d for d in deps if d.name not in ignored]
|
|
86
|
+
notes.append(f"ignored {len(ignored)} dep(s) per config: {', '.join(ignored)}")
|
|
87
|
+
fail_on = args.fail_on if getattr(args, "fail_on", None) else cfg.get("fail_on", "never")
|
|
88
|
+
return (deps, notes, ignored, fail_on)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _say(args: argparse.Namespace, msg: str) -> None:
|
|
92
|
+
if not args.quiet:
|
|
93
|
+
print(f"depwake: {msg}", file=sys.stderr)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cmd_plan(args: argparse.Namespace) -> int:
|
|
97
|
+
try:
|
|
98
|
+
deps, notes, _, fail_on = _resolve_config(args)
|
|
99
|
+
except ValueError as exc:
|
|
100
|
+
print(f"depwake: error: {exc}", file=sys.stderr)
|
|
101
|
+
return 2
|
|
102
|
+
color = color_enabled(args.color)
|
|
103
|
+
_say(args, f"checking {len(deps)} dep(s)…")
|
|
104
|
+
t0 = time.time()
|
|
105
|
+
plan = build(deps, fetcher=_version_fetcher(args), timeout=args.timeout,
|
|
106
|
+
advisory_fetcher=_advisory_fetcher(args),
|
|
107
|
+
min_severity=args.min_severity)
|
|
108
|
+
plan.notes.extend(notes)
|
|
109
|
+
_say(args, f"done in {time.time() - t0:.1f}s")
|
|
110
|
+
if args.format == "json":
|
|
111
|
+
rendered = format_json(plan)
|
|
112
|
+
elif args.format == "markdown":
|
|
113
|
+
rendered = format_markdown(plan, args.root)
|
|
114
|
+
else:
|
|
115
|
+
rendered = format_text(plan, color=color)
|
|
116
|
+
if args.output:
|
|
117
|
+
from pathlib import Path
|
|
118
|
+
Path(args.output).write_text(rendered if rendered.endswith("\n") else rendered + "\n",
|
|
119
|
+
encoding="utf-8")
|
|
120
|
+
print(f"wrote {args.output}")
|
|
121
|
+
return 0 if fail_on == "never" else _gate(plan, fail_on)
|
|
122
|
+
if args.format == "markdown":
|
|
123
|
+
print(rendered, end="")
|
|
124
|
+
else:
|
|
125
|
+
print(rendered)
|
|
126
|
+
if fail_on == "never":
|
|
127
|
+
return 0
|
|
128
|
+
return _gate(plan, fail_on)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _gate(plan, fail_on: str) -> int:
|
|
132
|
+
threshold = RISK_ORDER[fail_on]
|
|
133
|
+
bad = [i for i in plan.items if i.risk in RISK_ORDER and RISK_ORDER[i.risk] <= threshold]
|
|
134
|
+
return 1 if bad else 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cmd_apply(args: argparse.Namespace) -> int:
|
|
138
|
+
try:
|
|
139
|
+
deps, notes, _, _ = _resolve_config(args)
|
|
140
|
+
except ValueError as exc:
|
|
141
|
+
print(f"depwake: error: {exc}", file=sys.stderr)
|
|
142
|
+
return 2
|
|
143
|
+
_say(args, f"checking {len(deps)} dep(s)…")
|
|
144
|
+
plan = build(deps, fetcher=_version_fetcher(args),
|
|
145
|
+
timeout=args.timeout, advisory_fetcher=_advisory_fetcher(args))
|
|
146
|
+
include = {"patch"} | ({"minor"} if args.include_minor else set())
|
|
147
|
+
res = apply_plan(args.root, plan, include=include, dry_run=args.dry_run)
|
|
148
|
+
print(format_apply(res, dry_run=args.dry_run))
|
|
149
|
+
if res.changed and not args.dry_run:
|
|
150
|
+
npm = any(c.startswith("package.json") for c in res.changed)
|
|
151
|
+
pypi = any(not c.startswith("package.json") for c in res.changed)
|
|
152
|
+
hints = []
|
|
153
|
+
if npm:
|
|
154
|
+
hints.append("npm install (refresh the lockfile)")
|
|
155
|
+
if pypi:
|
|
156
|
+
hints.append("pip install -r requirements.txt (or reinstall your project)")
|
|
157
|
+
hints.append("re-run depwake plan to confirm")
|
|
158
|
+
print(" next: " + " → ".join(hints))
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def cmd_verify(args: argparse.Namespace) -> int:
|
|
163
|
+
"""How much of this project is exactly known? Facts (lock/pin) vs
|
|
164
|
+
assumptions (range floors) vs blind spots (unknown). No network."""
|
|
165
|
+
try:
|
|
166
|
+
deps, notes = discover(args.root)
|
|
167
|
+
except ValueError as exc:
|
|
168
|
+
print(f"depwake: error: {exc}", file=sys.stderr)
|
|
169
|
+
return 2
|
|
170
|
+
exact = [d for d in deps if d.current_source in ("lock", "pin")]
|
|
171
|
+
floors = [d for d in deps if d.current_source == "floor"]
|
|
172
|
+
unknown = [d for d in deps if d.current_source == "unknown"]
|
|
173
|
+
lines = [f"depwake verify: {len(exact)}/{len(deps)} exact, "
|
|
174
|
+
f"{len(floors)} range-floor assumptions, {len(unknown)} unknown"]
|
|
175
|
+
for d in floors:
|
|
176
|
+
lines.append(f" FLOOR {d.ecosystem:4} {d.name} {d.wanted} (add a lockfile)")
|
|
177
|
+
for d in unknown:
|
|
178
|
+
lines.append(f" UNKNOWN {d.ecosystem:4} {d.name} {d.wanted}")
|
|
179
|
+
for note in notes:
|
|
180
|
+
lines.append(f" note: {note}")
|
|
181
|
+
print("\n".join(lines))
|
|
182
|
+
if args.strict and (floors or unknown):
|
|
183
|
+
return 1
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
188
|
+
p = argparse.ArgumentParser(
|
|
189
|
+
prog="depwake",
|
|
190
|
+
description="Wake your sleeping dependencies — risk-grouped plans, safe auto-bumps.",
|
|
191
|
+
)
|
|
192
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
193
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
194
|
+
|
|
195
|
+
pl = sub.add_parser("plan", help="Risk-grouped upgrade plan (patch/minor/major/unknown).")
|
|
196
|
+
pl.add_argument("root", nargs="?", default=".", help="Project directory.")
|
|
197
|
+
pl.add_argument("--format", choices=["text", "markdown", "json"], default="text")
|
|
198
|
+
_shared_flags(pl)
|
|
199
|
+
pl.add_argument("--fail-on", choices=["major", "minor", "patch", "never"], default=None,
|
|
200
|
+
help="Exit 1 if upgrades at/above this risk are pending "
|
|
201
|
+
"(default: config fail_on, else never).")
|
|
202
|
+
pl.add_argument("-o", "--output", default=None, help="Write report to file instead of stdout.")
|
|
203
|
+
pl.add_argument("--min-severity", choices=["Critical", "High", "Medium", "Low"], default=None,
|
|
204
|
+
help="Only attach advisories at/above this severity (Unscored always shown).")
|
|
205
|
+
pl.set_defaults(func=cmd_plan)
|
|
206
|
+
|
|
207
|
+
ap = sub.add_parser("apply", help="Auto-bump patch (and optionally minor). Never major.")
|
|
208
|
+
ap.add_argument("root", nargs="?", default=".")
|
|
209
|
+
_shared_flags(ap)
|
|
210
|
+
ap.add_argument("--include-minor", action="store_true", help="Also bump minor upgrades.")
|
|
211
|
+
ap.add_argument("--dry-run", action="store_true", help="Show what would change.")
|
|
212
|
+
ap.set_defaults(func=cmd_apply)
|
|
213
|
+
|
|
214
|
+
vf = sub.add_parser("verify", help="Facts vs assumptions: lock/pin coverage, no network.")
|
|
215
|
+
vf.add_argument("root", nargs="?", default=".")
|
|
216
|
+
vf.add_argument("--strict", action="store_true",
|
|
217
|
+
help="Exit 1 unless every dep is exactly known.")
|
|
218
|
+
vf.set_defaults(func=cmd_verify)
|
|
219
|
+
return p
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def main(argv: list[str] | None = None) -> int:
|
|
223
|
+
args = build_parser().parse_args(argv)
|
|
224
|
+
return int(args.func(args))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
raise SystemExit(main())
|
depwake/color.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Terminal colors without dependencies. Respects NO_COLOR and pipes.
|
|
2
|
+
|
|
3
|
+
Rule: color only when the user sees it (TTY) unless forced. CI logs and
|
|
4
|
+
redirects stay clean — a green test suite must never print escapes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
CODES = {
|
|
13
|
+
"red": "\033[31m",
|
|
14
|
+
"yellow": "\033[33m",
|
|
15
|
+
"green": "\033[32m",
|
|
16
|
+
"dim": "\033[2m",
|
|
17
|
+
"bold": "\033[1m",
|
|
18
|
+
"reset": "\033[0m",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
RISK_COLORS = {"major": "red", "minor": "yellow", "patch": "green",
|
|
22
|
+
"same": "green", "unknown": "dim"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def enabled(mode: str = "auto") -> bool:
|
|
26
|
+
"""mode: auto | always | never."""
|
|
27
|
+
if mode == "always":
|
|
28
|
+
return True
|
|
29
|
+
if mode == "never":
|
|
30
|
+
return False
|
|
31
|
+
if os.environ.get("NO_COLOR") is not None:
|
|
32
|
+
return False
|
|
33
|
+
try:
|
|
34
|
+
return sys.stdout.isatty()
|
|
35
|
+
except (AttributeError, ValueError):
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def paint(text: str, color: str, on: bool) -> str:
|
|
40
|
+
if not on or color not in CODES:
|
|
41
|
+
return text
|
|
42
|
+
return f"{CODES[color]}{text}{CODES['reset']}"
|
depwake/config.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Project config: depwake.toml > package.json \"depwake\" key > pyproject [tool.depwake].
|
|
2
|
+
|
|
3
|
+
Keys:
|
|
4
|
+
ignore = ["pkg", ...] deps to exclude from every plan (with a note)
|
|
5
|
+
fail_on = "major" default gate when --fail-on isn't passed
|
|
6
|
+
|
|
7
|
+
Explicit CLI flags always win over config. Unknown keys are ignored, not
|
|
8
|
+
errors — configs must never break a plan.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import tomllib
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load(root: str | Path) -> dict[str, Any]:
|
|
20
|
+
root = Path(root)
|
|
21
|
+
cfg: dict[str, Any] = {}
|
|
22
|
+
toml = root / "depwake.toml"
|
|
23
|
+
if toml.exists():
|
|
24
|
+
try:
|
|
25
|
+
cfg.update(tomllib.loads(toml.read_bytes().decode("utf-8", errors="replace")))
|
|
26
|
+
except (ValueError, OSError):
|
|
27
|
+
pass
|
|
28
|
+
return _clean(cfg)
|
|
29
|
+
pkg = root / "package.json"
|
|
30
|
+
if pkg.exists():
|
|
31
|
+
try:
|
|
32
|
+
data = json.loads(pkg.read_text(encoding="utf-8"))
|
|
33
|
+
if isinstance(data.get("depwake"), dict):
|
|
34
|
+
cfg.update(data["depwake"])
|
|
35
|
+
return _clean(cfg)
|
|
36
|
+
except (ValueError, OSError):
|
|
37
|
+
pass
|
|
38
|
+
proj = root / "pyproject.toml"
|
|
39
|
+
if proj.exists():
|
|
40
|
+
try:
|
|
41
|
+
data = tomllib.loads(proj.read_bytes().decode("utf-8", errors="replace"))
|
|
42
|
+
tool = (data.get("tool") or {}).get("depwake")
|
|
43
|
+
if isinstance(tool, dict):
|
|
44
|
+
cfg.update(tool)
|
|
45
|
+
except (ValueError, OSError):
|
|
46
|
+
pass
|
|
47
|
+
return _clean(cfg)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _clean(cfg: dict[str, Any]) -> dict[str, Any]:
|
|
51
|
+
out: dict[str, Any] = {}
|
|
52
|
+
ignore = cfg.get("ignore", [])
|
|
53
|
+
if isinstance(ignore, list):
|
|
54
|
+
out["ignore"] = sorted({str(x).lower() for x in ignore if str(x).strip()})
|
|
55
|
+
fail_on = cfg.get("fail_on")
|
|
56
|
+
if fail_on in ("major", "minor", "patch", "never"):
|
|
57
|
+
out["fail_on"] = fail_on
|
|
58
|
+
return out
|
depwake/manifests.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Manifest parsing: package.json (+ lock), requirements.txt, pyproject.toml.
|
|
2
|
+
|
|
3
|
+
Every dep becomes a Dep(name, ecosystem, wanted, current, current_source):
|
|
4
|
+
- wanted: the range/pin written in the manifest.
|
|
5
|
+
- current: the actually-installed version (lockfile) or the range floor
|
|
6
|
+
when no lock exists — always labeled via current_source so plans never
|
|
7
|
+
present guesses as facts.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import tomllib
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Dep:
|
|
21
|
+
name: str
|
|
22
|
+
ecosystem: str # "npm" | "pypi"
|
|
23
|
+
wanted: str
|
|
24
|
+
current: str | None # None when unresolvable
|
|
25
|
+
current_source: str # "lock" | "pin" | "floor" | "unknown"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
REQ_LINE = re.compile(r"^\s*([A-Za-z0-9_.\-]+(?:\[[A-Za-z0-9_,\-]+\])?)\s*(.*)$")
|
|
29
|
+
REQ_PIN = re.compile(r"==\s*([^;\s]+)")
|
|
30
|
+
REQ_FLOOR = re.compile(r"(?:>=|~=|>|===)\s*([^;\s,]+)")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _normalize_floor(v: str | None) -> str | None:
|
|
34
|
+
"""Pad partial floors ("2" -> 2.0.0) so >=2,<4 yields a usable floor.
|
|
35
|
+
|
|
36
|
+
Still labeled `floor` downstream — padding is arithmetic, not knowledge.
|
|
37
|
+
"""
|
|
38
|
+
if not v:
|
|
39
|
+
return None
|
|
40
|
+
v = v.strip().rstrip(".*")
|
|
41
|
+
parts = v.split(".")
|
|
42
|
+
if not all(p.isdigit() for p in parts) or len(parts) > 3:
|
|
43
|
+
return None
|
|
44
|
+
return ".".join(parts + ["0"] * (3 - len(parts)))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _floor_npm_range(rng: str) -> str | None:
|
|
48
|
+
"""Best-effort floor of an npm range: ^4.17.0 -> 4.17.0, * -> None."""
|
|
49
|
+
rng = rng.strip()
|
|
50
|
+
if not rng or rng in ("*", "latest", "x"):
|
|
51
|
+
return None
|
|
52
|
+
m = re.search(r"(\d+\.\d+\.\d+[^,\s|]*)", rng)
|
|
53
|
+
return m.group(1) if m else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_package_json(root: Path) -> list[Dep]:
|
|
57
|
+
manifest = json.loads((root / "package.json").read_text(encoding="utf-8"))
|
|
58
|
+
wanted: dict[str, str] = {}
|
|
59
|
+
for section in ("dependencies", "devDependencies",
|
|
60
|
+
"peerDependencies", "optionalDependencies"):
|
|
61
|
+
wanted.update(manifest.get(section, {}) or {})
|
|
62
|
+
locked = _read_npm_lock(root)
|
|
63
|
+
deps = []
|
|
64
|
+
for name, rng in sorted(wanted.items()):
|
|
65
|
+
if name in locked:
|
|
66
|
+
deps.append(Dep(name, "npm", rng, locked[name], "lock"))
|
|
67
|
+
continue
|
|
68
|
+
floor = _floor_npm_range(rng)
|
|
69
|
+
deps.append(Dep(name, "npm", rng, floor,
|
|
70
|
+
"floor" if floor else "unknown"))
|
|
71
|
+
return deps
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _read_npm_lock(root: Path) -> dict[str, str]:
|
|
75
|
+
lock = root / "package-lock.json"
|
|
76
|
+
if not lock.exists():
|
|
77
|
+
return {}
|
|
78
|
+
try:
|
|
79
|
+
data = json.loads(lock.read_text(encoding="utf-8"))
|
|
80
|
+
except json.JSONDecodeError:
|
|
81
|
+
return {}
|
|
82
|
+
out: dict[str, str] = {}
|
|
83
|
+
packages = data.get("packages")
|
|
84
|
+
if isinstance(packages, dict): # lockfileVersion 2/3
|
|
85
|
+
for path, meta in packages.items():
|
|
86
|
+
if not path or not path.startswith("node_modules/"):
|
|
87
|
+
continue
|
|
88
|
+
name = path[len("node_modules/"):]
|
|
89
|
+
if "/" not in name and meta.get("version"):
|
|
90
|
+
out[name] = meta["version"]
|
|
91
|
+
else: # lockfileVersion 1
|
|
92
|
+
for name, meta in (data.get("dependencies") or {}).items():
|
|
93
|
+
if isinstance(meta, dict) and meta.get("version"):
|
|
94
|
+
out[name] = meta["version"]
|
|
95
|
+
# shrinkwrap twin
|
|
96
|
+
shrink = root / "npm-shrinkwrap.json"
|
|
97
|
+
if shrink.exists() and not out:
|
|
98
|
+
try:
|
|
99
|
+
data = json.loads(shrink.read_text(encoding="utf-8"))
|
|
100
|
+
for name, meta in (data.get("dependencies") or {}).items():
|
|
101
|
+
if isinstance(meta, dict) and meta.get("version"):
|
|
102
|
+
out[name] = meta["version"]
|
|
103
|
+
except json.JSONDecodeError:
|
|
104
|
+
pass
|
|
105
|
+
return out
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_requirements(path: Path, _seen: set[Path] | None = None) -> list[Dep]:
|
|
109
|
+
path = Path(path)
|
|
110
|
+
_seen = _seen or set()
|
|
111
|
+
if path in _seen:
|
|
112
|
+
return [] # include cycle — report once, never loop
|
|
113
|
+
_seen.add(path)
|
|
114
|
+
deps: list[Dep] = []
|
|
115
|
+
try:
|
|
116
|
+
raw = path.read_text(encoding="utf-8", errors="replace")
|
|
117
|
+
except OSError:
|
|
118
|
+
return []
|
|
119
|
+
# join continuations before parsing
|
|
120
|
+
lines: list[str] = []
|
|
121
|
+
buf = ""
|
|
122
|
+
for line in raw.splitlines():
|
|
123
|
+
if line.rstrip().endswith("\\"):
|
|
124
|
+
buf += line.rstrip()[:-1]
|
|
125
|
+
continue
|
|
126
|
+
lines.append(buf + line)
|
|
127
|
+
buf = ""
|
|
128
|
+
if buf:
|
|
129
|
+
lines.append(buf)
|
|
130
|
+
for line in lines:
|
|
131
|
+
line = line.strip()
|
|
132
|
+
if not line or line.startswith("#"):
|
|
133
|
+
continue
|
|
134
|
+
if line.startswith(("-r ", "--requirement ", "-r\t", "--requirement=")):
|
|
135
|
+
inc = line.split(None, 1)[1] if " " in line or "\t" in line \
|
|
136
|
+
else line.split("=", 1)[1]
|
|
137
|
+
deps.extend(parse_requirements(path.parent / inc.strip(), _seen))
|
|
138
|
+
continue
|
|
139
|
+
if line.startswith("-"):
|
|
140
|
+
continue # options (-c constraints, --index-url, ...) are out of scope
|
|
141
|
+
m = REQ_LINE.match(line)
|
|
142
|
+
if not m:
|
|
143
|
+
continue
|
|
144
|
+
name, spec = m.group(1), m.group(2).strip()
|
|
145
|
+
# strip environment markers for version parsing ("pkg==1.0; python_version>'3'")
|
|
146
|
+
spec = spec.split(";")[0].strip()
|
|
147
|
+
pin = REQ_PIN.search(spec)
|
|
148
|
+
if pin:
|
|
149
|
+
deps.append(Dep(name, "pypi", spec or "any", pin.group(1), "pin"))
|
|
150
|
+
continue
|
|
151
|
+
mfloor = REQ_FLOOR.search(spec)
|
|
152
|
+
floor = _normalize_floor(mfloor.group(1)) if mfloor else None
|
|
153
|
+
deps.append(Dep(name, "pypi", spec or "any", floor,
|
|
154
|
+
"floor" if floor else "unknown"))
|
|
155
|
+
return sorted(deps, key=lambda d: d.name.lower())
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def parse_pyproject(path: Path) -> tuple[list[Dep], list[str]]:
|
|
159
|
+
"""Returns (deps, notes). Optional-dependency groups are reported in
|
|
160
|
+
notes, not deps: counting uninstalled extras as stale would be noise."""
|
|
161
|
+
try:
|
|
162
|
+
data = tomllib.loads(path.read_bytes().decode("utf-8", errors="replace"))
|
|
163
|
+
except (ValueError, OSError):
|
|
164
|
+
return [], []
|
|
165
|
+
notes: list[str] = []
|
|
166
|
+
optional = data.get("project", {}).get("optional-dependencies", {}) or {}
|
|
167
|
+
if optional:
|
|
168
|
+
n = sum(len(v) for v in optional.values())
|
|
169
|
+
notes.append(f"{n} optional deps ignored (extras: {', '.join(sorted(optional))})")
|
|
170
|
+
reqs = data.get("project", {}).get("dependencies", []) or []
|
|
171
|
+
deps = []
|
|
172
|
+
for req in reqs:
|
|
173
|
+
m = REQ_LINE.match(str(req).strip())
|
|
174
|
+
if not m:
|
|
175
|
+
continue
|
|
176
|
+
name, spec = m.group(1), m.group(2).strip()
|
|
177
|
+
spec = spec.split(";")[0].strip()
|
|
178
|
+
pin = REQ_PIN.search(spec)
|
|
179
|
+
if pin:
|
|
180
|
+
deps.append(Dep(name, "pypi", spec, pin.group(1), "pin"))
|
|
181
|
+
continue
|
|
182
|
+
mfloor = REQ_FLOOR.search(spec)
|
|
183
|
+
floor = _normalize_floor(mfloor.group(1)) if mfloor else None
|
|
184
|
+
deps.append(Dep(name, "pypi", spec or "any", floor,
|
|
185
|
+
"floor" if floor else "unknown"))
|
|
186
|
+
return sorted(deps, key=lambda d: d.name.lower()), notes
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def discover(root: Path) -> tuple[list[Dep], list[str]]:
|
|
190
|
+
"""Collect deps from every supported manifest under root.
|
|
191
|
+
|
|
192
|
+
Returns (deps, notes). Later manifests win on name collisions so the
|
|
193
|
+
most specific pin (e.g. requirements.txt over pyproject) is reported.
|
|
194
|
+
"""
|
|
195
|
+
root = Path(root)
|
|
196
|
+
if not root.exists():
|
|
197
|
+
raise ValueError(f"{root}: no such file or directory")
|
|
198
|
+
found: dict[tuple[str, str], Dep] = {}
|
|
199
|
+
notes: list[str] = []
|
|
200
|
+
if (root / "package.json").exists():
|
|
201
|
+
for d in parse_package_json(root):
|
|
202
|
+
found[("npm", d.name)] = d
|
|
203
|
+
else:
|
|
204
|
+
notes.append("no package.json")
|
|
205
|
+
reqs = root / "requirements.txt"
|
|
206
|
+
if reqs.exists():
|
|
207
|
+
for d in parse_requirements(reqs):
|
|
208
|
+
found[("pypi", d.name)] = d
|
|
209
|
+
else:
|
|
210
|
+
notes.append("no requirements.txt")
|
|
211
|
+
proj = root / "pyproject.toml"
|
|
212
|
+
if proj.exists():
|
|
213
|
+
pdeps, pnotes = parse_pyproject(proj)
|
|
214
|
+
notes.extend(pnotes)
|
|
215
|
+
for d in pdeps:
|
|
216
|
+
found.setdefault(("pypi", d.name), d)
|
|
217
|
+
else:
|
|
218
|
+
notes.append("no pyproject.toml")
|
|
219
|
+
manifests_seen = ((root / "package.json").exists() or reqs.exists()
|
|
220
|
+
or proj.exists())
|
|
221
|
+
if not manifests_seen:
|
|
222
|
+
raise ValueError(f"{root}: no supported manifest found "
|
|
223
|
+
"(package.json, requirements.txt, pyproject.toml)")
|
|
224
|
+
if not found:
|
|
225
|
+
notes.append("manifests present but declare zero dependencies")
|
|
226
|
+
return sorted(found.values(), key=lambda d: (d.ecosystem, d.name.lower())), notes
|
depwake/plan.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Upgrade planning: deps + latest versions -> risk-grouped plan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from .manifests import Dep
|
|
9
|
+
from .registry import Advisory, Latest, fetch, fetch_advisories
|
|
10
|
+
from .semver import RISK_BLURB, classify, parse
|
|
11
|
+
|
|
12
|
+
ORDER = {"major": 0, "minor": 1, "patch": 2, "same": 3, "unknown": 4}
|
|
13
|
+
SEV_ORDER = {"Critical": 0, "High": 1, "Medium": 2, "Low": 3, "Unscored": 4}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Item:
|
|
18
|
+
dep: Dep
|
|
19
|
+
latest: str | None
|
|
20
|
+
url: str
|
|
21
|
+
risk: str # major | minor | patch | same | unknown
|
|
22
|
+
note: str = "" # why this row needs attention (or why we can't tell)
|
|
23
|
+
advisories: list[Advisory] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
def worst_severity(self) -> str:
|
|
26
|
+
if not self.advisories:
|
|
27
|
+
return ""
|
|
28
|
+
return min((a.severity for a in self.advisories),
|
|
29
|
+
key=lambda s: SEV_ORDER.get(s, 9))
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> dict:
|
|
32
|
+
return {"name": self.dep.name, "ecosystem": self.dep.ecosystem,
|
|
33
|
+
"wanted": self.dep.wanted, "current": self.dep.current,
|
|
34
|
+
"current_source": self.dep.current_source,
|
|
35
|
+
"latest": self.latest, "url": self.url,
|
|
36
|
+
"risk": self.risk, "note": self.note,
|
|
37
|
+
"advisories": [a.to_dict() for a in self.advisories]}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Plan:
|
|
42
|
+
items: list[Item]
|
|
43
|
+
notes: list[str] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
def to_dict(self) -> dict:
|
|
46
|
+
counts = self.counts()
|
|
47
|
+
return {"items": [i.to_dict() for i in self.items], "notes": self.notes,
|
|
48
|
+
"counts": counts, "advisory_count": self.advisory_count()}
|
|
49
|
+
|
|
50
|
+
def grouped(self) -> dict[str, list[Item]]:
|
|
51
|
+
groups: dict[str, list[Item]] = {k: [] for k in ORDER}
|
|
52
|
+
for item in sorted(self.items,
|
|
53
|
+
key=lambda i: (ORDER[i.risk], not i.advisories,
|
|
54
|
+
i.dep.ecosystem, i.dep.name.lower())):
|
|
55
|
+
groups[item.risk].append(item)
|
|
56
|
+
return groups
|
|
57
|
+
|
|
58
|
+
def counts(self) -> dict[str, int]:
|
|
59
|
+
out = {k: 0 for k in ORDER}
|
|
60
|
+
for item in self.items:
|
|
61
|
+
out[item.risk] += 1
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
def advisory_count(self) -> int:
|
|
65
|
+
return sum(len(i.advisories) for i in self.items)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _security_note(item: Item) -> str:
|
|
69
|
+
n = len(item.advisories)
|
|
70
|
+
worst = item.worst_severity()
|
|
71
|
+
top = sorted(item.advisories, key=lambda a: SEV_ORDER.get(a.severity, 9))[0]
|
|
72
|
+
fixed = f"fixed in {top.fixed_in}" if top.fixed_in else "no fix published yet"
|
|
73
|
+
return (f"🔒 {n} advisor{'y' if n == 1 else 'ies'} (worst {worst}): "
|
|
74
|
+
f"{top.id} — {fixed}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build(deps: list[Dep],
|
|
78
|
+
fetcher: Callable[[str, str], Latest] = fetch,
|
|
79
|
+
timeout: float = 10.0,
|
|
80
|
+
advisory_fetcher: Callable[[list[tuple[str, str, str]]],
|
|
81
|
+
dict[tuple[str, str], list[Advisory]]]
|
|
82
|
+
| None = fetch_advisories,
|
|
83
|
+
with_advisories: bool = True,
|
|
84
|
+
min_severity: str | None = None) -> Plan:
|
|
85
|
+
items: list[Item] = []
|
|
86
|
+
for dep in deps:
|
|
87
|
+
if dep.current is None:
|
|
88
|
+
items.append(Item(dep, None, "", "unknown",
|
|
89
|
+
"no lockfile pin and no parseable range floor — "
|
|
90
|
+
"add a lockfile for exact tracking"))
|
|
91
|
+
continue
|
|
92
|
+
current = parse(dep.current)
|
|
93
|
+
if current is None:
|
|
94
|
+
items.append(Item(dep, None, "", "unknown",
|
|
95
|
+
f"unparseable current version {dep.current!r}"))
|
|
96
|
+
continue
|
|
97
|
+
try:
|
|
98
|
+
got = fetcher(dep.ecosystem, dep.name)
|
|
99
|
+
except Exception as exc: # a fetcher must never kill a plan
|
|
100
|
+
got = Latest(None, "", f"lookup failed: {exc}")
|
|
101
|
+
if not got.version:
|
|
102
|
+
items.append(Item(dep, None, got.url, "unknown", got.error or "lookup failed"))
|
|
103
|
+
continue
|
|
104
|
+
latest = parse(got.version)
|
|
105
|
+
if latest is None:
|
|
106
|
+
items.append(Item(dep, got.version, got.url, "unknown",
|
|
107
|
+
f"unparseable latest version {got.version!r}"))
|
|
108
|
+
continue
|
|
109
|
+
risk = classify(current, latest)
|
|
110
|
+
note = ""
|
|
111
|
+
if risk == "same" and dep.current_source == "floor":
|
|
112
|
+
note = "range floor looks current, but without a lockfile this is an assumption"
|
|
113
|
+
elif risk != "same" and dep.current_source == "floor":
|
|
114
|
+
note = "computed from range floor (no lockfile) — verify against installed tree"
|
|
115
|
+
items.append(Item(dep, str(latest), got.url, risk, note or RISK_BLURB[risk]))
|
|
116
|
+
plan = Plan(items)
|
|
117
|
+
if with_advisories and advisory_fetcher is not None:
|
|
118
|
+
try:
|
|
119
|
+
adv = advisory_fetcher([(i.dep.ecosystem, i.dep.name, i.dep.current or "")
|
|
120
|
+
for i in items if i.dep.current])
|
|
121
|
+
except Exception:
|
|
122
|
+
adv = {}
|
|
123
|
+
# Unscored advisories always survive the filter: unknown != safe.
|
|
124
|
+
threshold = SEV_ORDER.get(min_severity or "Unscored", 4)
|
|
125
|
+
hidden = 0
|
|
126
|
+
for item in items:
|
|
127
|
+
found = adv.get((item.dep.ecosystem, item.dep.name), [])
|
|
128
|
+
kept = [a for a in found
|
|
129
|
+
if a.severity == "Unscored" or SEV_ORDER.get(a.severity, 9) <= threshold]
|
|
130
|
+
hidden += len(found) - len(kept)
|
|
131
|
+
if kept:
|
|
132
|
+
item.advisories = kept
|
|
133
|
+
item.note = f"{_security_note(item)}. {item.note}".strip()
|
|
134
|
+
if hidden:
|
|
135
|
+
plan.notes.append(f"{hidden} advisor{'y' if hidden == 1 else 'ies'} "
|
|
136
|
+
f"below --min-severity {min_severity} hidden")
|
|
137
|
+
return plan
|
depwake/registry.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Latest-version lookup over plain HTTPS. Stdlib only.
|
|
2
|
+
|
|
3
|
+
Injectable fetch() keeps everything deterministic in tests and lets future
|
|
4
|
+
versions plug caches/mirrors without touching callers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import urllib.request
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
UA = "depwake/0.2.0"
|
|
14
|
+
OSV_ECO = {"npm": "npm", "pypi": "PyPI"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Latest:
|
|
19
|
+
version: str | None
|
|
20
|
+
url: str # registry/homepage link for the "why" column
|
|
21
|
+
error: str = "" # non-empty when lookup failed (offline, 404, ...)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get(url: str, timeout: float) -> tuple[int, bytes]:
|
|
25
|
+
req = urllib.request.Request(url, headers={"User-Agent": "depwake/0.1.0",
|
|
26
|
+
"Accept": "application/json"})
|
|
27
|
+
try:
|
|
28
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
29
|
+
return resp.status, resp.read()
|
|
30
|
+
except Exception as exc: # network, TLS, 404, timeout — all just "unknown"
|
|
31
|
+
return 0, str(exc).encode()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def fetch_npm(name: str, timeout: float = 10.0) -> Latest:
|
|
35
|
+
status, body = _get(f"https://registry.npmjs.org/{name}/latest", timeout)
|
|
36
|
+
if status != 200:
|
|
37
|
+
return Latest(None, f"https://www.npmjs.com/package/{name}",
|
|
38
|
+
f"npm registry: HTTP {status or 'unreachable'}")
|
|
39
|
+
try:
|
|
40
|
+
version = json.loads(body).get("version")
|
|
41
|
+
except json.JSONDecodeError:
|
|
42
|
+
version = None
|
|
43
|
+
if not version:
|
|
44
|
+
return Latest(None, f"https://www.npmjs.com/package/{name}", "no version field")
|
|
45
|
+
return Latest(version, f"https://www.npmjs.com/package/{name}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def fetch_pypi(name: str, timeout: float = 10.0) -> Latest:
|
|
49
|
+
base = name.split("[")[0]
|
|
50
|
+
status, body = _get(f"https://pypi.org/pypi/{base}/json", timeout)
|
|
51
|
+
if status != 200:
|
|
52
|
+
return Latest(None, f"https://pypi.org/project/{base}/",
|
|
53
|
+
f"PyPI: HTTP {status or 'unreachable'}")
|
|
54
|
+
try:
|
|
55
|
+
info = json.loads(body).get("info", {})
|
|
56
|
+
except json.JSONDecodeError:
|
|
57
|
+
return Latest(None, f"https://pypi.org/project/{base}/", "bad JSON")
|
|
58
|
+
version = info.get("version")
|
|
59
|
+
url = info.get("home_page") or info.get("project_url") or f"https://pypi.org/project/{base}/"
|
|
60
|
+
if not version:
|
|
61
|
+
return Latest(None, url, "no version field")
|
|
62
|
+
return Latest(version, url)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def fetch(ecosystem: str, name: str, timeout: float = 10.0) -> Latest:
|
|
66
|
+
if ecosystem == "npm":
|
|
67
|
+
return fetch_npm(name, timeout)
|
|
68
|
+
return fetch_pypi(name, timeout)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class Advisory:
|
|
73
|
+
id: str
|
|
74
|
+
summary: str
|
|
75
|
+
severity: str # Critical | High | Medium | Low | Unscored
|
|
76
|
+
score: float | None
|
|
77
|
+
fixed_in: str | None # None when no fixed version published
|
|
78
|
+
url: str
|
|
79
|
+
|
|
80
|
+
def to_dict(self) -> dict:
|
|
81
|
+
return {"id": self.id, "summary": self.summary, "severity": self.severity,
|
|
82
|
+
"score": self.score, "fixed_in": self.fixed_in, "url": self.url}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _label(score: float | None) -> str:
|
|
86
|
+
if score is None:
|
|
87
|
+
return "Unscored"
|
|
88
|
+
if score >= 9.0:
|
|
89
|
+
return "Critical"
|
|
90
|
+
if score >= 7.0:
|
|
91
|
+
return "High"
|
|
92
|
+
if score >= 4.0:
|
|
93
|
+
return "Medium"
|
|
94
|
+
if score > 0:
|
|
95
|
+
return "Low"
|
|
96
|
+
return "Unscored"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _fixed_in(vuln: dict) -> str | None:
|
|
100
|
+
"""Latest 'fixed' event across affected ranges, or None."""
|
|
101
|
+
fixed: list[str] = []
|
|
102
|
+
for aff in vuln.get("affected", []):
|
|
103
|
+
for rng in aff.get("ranges", []):
|
|
104
|
+
for ev in rng.get("events", []):
|
|
105
|
+
if "fixed" in ev:
|
|
106
|
+
fixed.append(str(ev["fixed"]))
|
|
107
|
+
return sorted(set(fixed))[-1] if fixed else None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _advisory(vuln: dict) -> Advisory:
|
|
111
|
+
scores = [s.get("score") for s in (vuln.get("severity") or [])
|
|
112
|
+
if isinstance(s.get("score"), (int, float))]
|
|
113
|
+
score = max(scores) if scores else None
|
|
114
|
+
summary = (vuln.get("summary") or "")[:160]
|
|
115
|
+
vid = vuln.get("id", "unknown")
|
|
116
|
+
return Advisory(vid, summary, _label(score), score,
|
|
117
|
+
_fixed_in(vuln), f"https://osv.dev/vulnerability/{vid}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def fetch_advisories(deps: list[tuple[str, str, str]],
|
|
121
|
+
timeout: float = 10.0) -> dict[tuple[str, str], list[Advisory]]:
|
|
122
|
+
"""Map (ecosystem, name) -> advisories affecting that exact version.
|
|
123
|
+
|
|
124
|
+
One batch call to find who is affected, then one full-record call per
|
|
125
|
+
affected dep. Any failure degrades to {} — advisories inform, never gate.
|
|
126
|
+
"""
|
|
127
|
+
queries = [{"package": {"name": n.split("[")[0],
|
|
128
|
+
"ecosystem": OSV_ECO.get(e, e)}, "version": v}
|
|
129
|
+
for e, n, v in deps]
|
|
130
|
+
if not queries:
|
|
131
|
+
return {}
|
|
132
|
+
payload = json.dumps({"queries": queries}).encode()
|
|
133
|
+
req = urllib.request.Request("https://api.osv.dev/v1/querybatch", data=payload,
|
|
134
|
+
headers={"Content-Type": "application/json",
|
|
135
|
+
"User-Agent": UA})
|
|
136
|
+
try:
|
|
137
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
138
|
+
batch = json.loads(resp.read()).get("results", [])
|
|
139
|
+
except Exception:
|
|
140
|
+
return {}
|
|
141
|
+
out: dict[tuple[str, str], list[Advisory]] = {}
|
|
142
|
+
for (eco, name, ver), res in zip([(e, n, v) for e, n, v in deps], batch):
|
|
143
|
+
if not res.get("vulns"):
|
|
144
|
+
continue
|
|
145
|
+
single = json.dumps({"package": {"name": name.split("[")[0],
|
|
146
|
+
"ecosystem": OSV_ECO.get(eco, eco)},
|
|
147
|
+
"version": ver}).encode()
|
|
148
|
+
req = urllib.request.Request("https://api.osv.dev/v1/query", data=single,
|
|
149
|
+
headers={"Content-Type": "application/json",
|
|
150
|
+
"User-Agent": UA})
|
|
151
|
+
try:
|
|
152
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
153
|
+
full = json.loads(resp.read()).get("vulns", [])
|
|
154
|
+
out[(eco, name)] = [_advisory(v) for v in full]
|
|
155
|
+
except Exception:
|
|
156
|
+
out[(eco, name)] = []
|
|
157
|
+
return out
|
depwake/report.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Plan rendering: text (human), markdown (PRs/files), json (machines)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from .color import RISK_COLORS, paint
|
|
8
|
+
from .plan import Plan
|
|
9
|
+
|
|
10
|
+
TITLES = {
|
|
11
|
+
"major": "## 🔴 Major — read the changelog, upgrade deliberately",
|
|
12
|
+
"minor": "## 🟡 Minor — backwards compatible, apply this week",
|
|
13
|
+
"patch": "## 🟢 Patch — safe fixes, apply today",
|
|
14
|
+
"same": "## ✅ Up to date",
|
|
15
|
+
"unknown": "## ⚪ Unknown — needs a human look",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def format_text(plan: Plan, color: bool = False) -> str:
|
|
20
|
+
counts = plan.counts()
|
|
21
|
+
total = len(plan.items)
|
|
22
|
+
adv = plan.advisory_count()
|
|
23
|
+
head = (f"depwake plan: {total} dep(s) — "
|
|
24
|
+
f"{counts['major']} major, {counts['minor']} minor, "
|
|
25
|
+
f"{counts['patch']} patch, {counts['unknown']} unknown")
|
|
26
|
+
if adv:
|
|
27
|
+
head += f", 🔒 {adv} advisor{'y' if adv == 1 else 'ies'}"
|
|
28
|
+
lines = [head]
|
|
29
|
+
for risk in ("major", "minor", "patch", "unknown"):
|
|
30
|
+
for item in plan.grouped()[risk]:
|
|
31
|
+
d = item.dep
|
|
32
|
+
arrow = f"{d.current} -> {item.latest}" if item.latest else f"{d.current} -> ?"
|
|
33
|
+
flag = "" if d.current_source in ("lock", "pin") else f" [{d.current_source}]"
|
|
34
|
+
lock = " 🔒" if item.advisories else ""
|
|
35
|
+
label = paint(risk.upper().ljust(7), RISK_COLORS[risk], color)
|
|
36
|
+
lines.append(f" {label} {d.ecosystem:4} {d.name} {arrow}{flag}{lock}")
|
|
37
|
+
if item.note:
|
|
38
|
+
lines.append(f" {item.note}")
|
|
39
|
+
for note in plan.notes:
|
|
40
|
+
lines.append(f" note: {note}")
|
|
41
|
+
return "\n".join(lines)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def format_markdown(plan: Plan, root: str = ".") -> str:
|
|
45
|
+
counts = plan.counts()
|
|
46
|
+
adv = plan.advisory_count()
|
|
47
|
+
head = (f"{len(plan.items)} dependencies: "
|
|
48
|
+
f"**{counts['major']}** major, **{counts['minor']}** minor, "
|
|
49
|
+
f"**{counts['patch']}** patch, {counts['unknown']} unknown.")
|
|
50
|
+
if adv:
|
|
51
|
+
head += f" 🔒 **{adv}** advisor{'y' if adv == 1 else 'ies'}."
|
|
52
|
+
lines = [f"# Depwake plan — `{root}`", "", head, ""]
|
|
53
|
+
for risk in ("major", "minor", "patch", "unknown", "same"):
|
|
54
|
+
items = plan.grouped()[risk]
|
|
55
|
+
if not items:
|
|
56
|
+
continue
|
|
57
|
+
lines.append(TITLES[risk])
|
|
58
|
+
lines.append("")
|
|
59
|
+
lines.append("| Package | Current | Latest | Notes |")
|
|
60
|
+
lines.append("|---|---|---|---|")
|
|
61
|
+
for item in items:
|
|
62
|
+
d = item.dep
|
|
63
|
+
cur = f"{d.current or '?'} ({d.current_source})"
|
|
64
|
+
latest = f"[{item.latest}]({item.url})" if item.latest and item.url else (item.latest or "?")
|
|
65
|
+
note = item.note.replace("|", "\\|")
|
|
66
|
+
lines.append(f"| {d.name} ({d.ecosystem}) | {cur} | {latest} | {note} |")
|
|
67
|
+
lines.append("")
|
|
68
|
+
lines.append("_Generated by depwake — patch today, minor this week, major deliberately._")
|
|
69
|
+
sec = [i for i in plan.items if i.advisories]
|
|
70
|
+
if sec:
|
|
71
|
+
lines.append("")
|
|
72
|
+
lines.append("## 🔒 Security notes")
|
|
73
|
+
lines.append("")
|
|
74
|
+
for item in sorted(sec, key=lambda i: (i.dep.ecosystem, i.dep.name.lower())):
|
|
75
|
+
d = item.dep
|
|
76
|
+
lines.append(f"### {d.name} ({d.ecosystem} {d.current})")
|
|
77
|
+
lines.append("")
|
|
78
|
+
for a in item.advisories:
|
|
79
|
+
fixed = f"fixed in {a.fixed_in}" if a.fixed_in else "no fix published"
|
|
80
|
+
lines.append(f"- [{a.id}]({a.url}) — {a.severity} — {fixed}")
|
|
81
|
+
if a.summary:
|
|
82
|
+
lines.append(f" {a.summary}")
|
|
83
|
+
lines.append("")
|
|
84
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def format_json(plan: Plan) -> str:
|
|
88
|
+
return json.dumps(plan.to_dict(), indent=2)
|
depwake/semver.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Semver parsing + risk classification. No network, no opinions beyond semver."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
VER = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Version:
|
|
13
|
+
major: int
|
|
14
|
+
minor: int
|
|
15
|
+
patch: int
|
|
16
|
+
|
|
17
|
+
def __str__(self) -> str:
|
|
18
|
+
return f"{self.major}.{self.minor}.{self.patch}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse(v: str | None) -> Version | None:
|
|
22
|
+
"""Parse X.Y.Z, tolerating leading v and partial real-world versions
|
|
23
|
+
("3.19" -> 3.19.0, "2" -> 2.0.0). Non-numeric (latest, *) -> None."""
|
|
24
|
+
if not v:
|
|
25
|
+
return None
|
|
26
|
+
v = v.strip()
|
|
27
|
+
m = VER.match(v)
|
|
28
|
+
if m:
|
|
29
|
+
return Version(int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
|
30
|
+
m = re.match(r"^v?(\d+)(?:\.(\d+))?$", v)
|
|
31
|
+
if m:
|
|
32
|
+
return Version(int(m.group(1)), int(m.group(2) or 0), 0)
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def classify(current: Version, latest: Version) -> str:
|
|
37
|
+
"""patch | minor | major | same. Pre-1.0 minors count as major:
|
|
38
|
+
in 0.x, semver says anything may break."""
|
|
39
|
+
if (current.major, current.minor, current.patch) >= \
|
|
40
|
+
(latest.major, latest.minor, latest.patch):
|
|
41
|
+
return "same"
|
|
42
|
+
if current.major != latest.major:
|
|
43
|
+
return "major"
|
|
44
|
+
if current.major == 0 and current.minor != latest.minor:
|
|
45
|
+
return "major"
|
|
46
|
+
if current.minor != latest.minor:
|
|
47
|
+
return "minor"
|
|
48
|
+
return "patch"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
RISK_BLURB = {
|
|
52
|
+
"patch": "Safe fixes only — apply today.",
|
|
53
|
+
"minor": "Features + fixes, backwards compatible — apply this week.",
|
|
54
|
+
"major": "May break — read the changelog, upgrade deliberately.",
|
|
55
|
+
"same": "Up to date.",
|
|
56
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: depwake
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: Wake your sleeping dependencies — risk-grouped upgrade plans, safe auto-bumps, no noise
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/YOUR-USER/depwake
|
|
7
|
+
Project-URL: Issues, https://github.com/YOUR-USER/depwake/issues
|
|
8
|
+
Keywords: dependencies,upgrades,npm,pypi,maintenance
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# Depwake ⏰
|
|
20
|
+
|
|
21
|
+
[](https://github.com/YOUR-USER/depwake/actions/workflows/ci.yml)
|
|
22
|
+
[](https://scorecard.dev/viewer/?uri=github.com/YOUR-USER/depwake)
|
|
23
|
+
|
|
24
|
+
**Wake your sleeping dependencies.**
|
|
25
|
+
|
|
26
|
+
Your side project has 47 outdated packages, 3 security advisories, and a `package.json` from 2023. Dependabot spams you with 40 context-free PRs. Depwake does the opposite: one risk-grouped plan — patch today, minor this week, major deliberately — and safe auto-bumps for the boring parts.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install depwake
|
|
30
|
+
depwake plan ./my-project # risk-grouped plan, Markdown/JSON too
|
|
31
|
+
depwake apply ./my-project --dry-run # preview safe bumps (patch only)
|
|
32
|
+
depwake apply ./my-project # bump patches, .bak backups, majors never touched
|
|
33
|
+
depwake verify ./my-project --strict # facts vs assumptions, no network
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
> ⭐ If dependency hell has ever eaten your weekend, star this — it helps other devs find it.
|
|
37
|
+
|
|
38
|
+
Advisories included: every plan queries OSV and sorts 🔒 rows first —
|
|
39
|
+
urllib3 1.26 alone carries 20 advisories ([measured](benchmarks/BENCH.md)).
|
|
40
|
+
|
|
41
|
+
## Before / after
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
$ depwake plan ./my-project
|
|
45
|
+
depwake plan: 4 dep(s) — 1 major, 3 minor, 0 patch, 0 unknown
|
|
46
|
+
MAJOR npm express 4.17.0 -> 5.2.1 [floor]
|
|
47
|
+
MINOR npm lodash 4.17.20 -> 4.18.1 [floor]
|
|
48
|
+
MINOR pypi requests 2.28.0 -> 2.34.2
|
|
49
|
+
MINOR pypi six 1.16.0 -> 1.17.0
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
$ depwake plan ./my-project --format markdown > UPGRADE.md # paste into a PR
|
|
54
|
+
$ depwake plan ./my-project --fail-on major # CI gate: exit 1 while majors pend
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Why not Dependabot / Renovate?
|
|
58
|
+
|
|
59
|
+
| | Depwake | Bots |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| Unit of work | one grouped plan, you decide the order | N noisy PRs, no prioritization |
|
|
62
|
+
| Risk language | patch today / minor this week / major deliberately | version numbers, you interpret |
|
|
63
|
+
| Honesty | range floors labeled `[floor]`; offline → `unknown`, never guessed | — |
|
|
64
|
+
| Scope | works without installs, lockfiles optional but rewarded | needs full install + config |
|
|
65
|
+
| Agent-native | `SKILL.md` your coding agent can run | docs page, if you're lucky |
|
|
66
|
+
|
|
67
|
+
Dependabot is a fine notifier. Depwake is the Sunday-morning plan *and* the safe pair of hands.
|
|
68
|
+
|
|
69
|
+
## Supported
|
|
70
|
+
|
|
71
|
+
`package.json` (+ `package-lock.json` / `npm-shrinkwrap.json`), `requirements.txt`, `pyproject.toml`. Pre-1.0 minors count as major (in 0.x, anything may break). Needs network for latest versions; offline degrades to `unknown`, never to guesses.
|
|
72
|
+
|
|
73
|
+
## Contributing — built for drive-by PRs
|
|
74
|
+
|
|
75
|
+
- 📦 New manifest format = one parser + fixtures (`good first issue`)
|
|
76
|
+
- 🔌 New ecosystem (crates.io, RubyGems…) = one fetcher + tests
|
|
77
|
+
- 🌍 Translations welcome (`README.<lang>.md`)
|
|
78
|
+
- Rules: stdlib only, every feature ships with a test, never auto-bump a major
|
|
79
|
+
|
|
80
|
+
## Roadmap
|
|
81
|
+
|
|
82
|
+
- [x] v0.1 — plan / apply, 3 manifest formats, Markdown + JSON + CI gate
|
|
83
|
+
- [x] v0.2 — OSV advisories, cache, `--offline`, `verify`, includes/markers/optionals, Action + CI + demo, real-repo validation
|
|
84
|
+
- [x] v0.3 — colors, progress, config file, `-o`, severity filter, JSON counts, apply hints
|
|
85
|
+
- [ ] Advisory reachability (is the vulnerable function even imported?)
|
|
86
|
+
- [ ] `depwake PR` — open the grouped upgrade as draft PRs per risk tier (needs GitHub)
|
|
87
|
+
- [ ] Lockfile generation for lockless projects (`--write-lock`)
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
depwake/__init__.py,sha256=fsa2SI8kg2AjBRaIkD3pLSneVQJIQhuZHapqBkRI0Js,84
|
|
2
|
+
depwake/__main__.py,sha256=Jqwn9-pywCldHtT_k-Ho7oCMUtH93JyWytpPzoG1qRk,385
|
|
3
|
+
depwake/apply.py,sha256=600Z12rvPmaFuVmdn7C9Ppdv50CLbnphU_TL0Ay4Sfc,4949
|
|
4
|
+
depwake/cache.py,sha256=KvBCQaD5yjJZKqyJpDLSUA_AMbPFp_yqsAJxVQlyk-U,1181
|
|
5
|
+
depwake/cli.py,sha256=CIBBCR06MkW6caIOZl-FiSOPEQaVJDej8L3fcVivPjk,9490
|
|
6
|
+
depwake/color.py,sha256=ZLfFfNjO7ZR2GTezAk3fcuf7kUCv2m1QZQb5s6Hw6WM,1057
|
|
7
|
+
depwake/config.py,sha256=-WgzMYHdRoR-fYnaPUmqi7zSCAdQxSkEHXWn-FC9hBk,1865
|
|
8
|
+
depwake/manifests.py,sha256=3PQlpEZ7rpaI6iWgWjKlYSJPT_kH4KuJt9w1vDlIwso,8428
|
|
9
|
+
depwake/plan.py,sha256=MIBFOOjo5dkp7FskKGaqYje8kxhm2gVxSS7mXlWm0ig,5693
|
|
10
|
+
depwake/registry.py,sha256=ieCO9kav6rPdYkfl2Do33Z2JohYQCZoIYExkCnLoNQ4,5961
|
|
11
|
+
depwake/report.py,sha256=dgBhqZzYrDKgB2stUQ3e81cpme6HBBuRjgkQ07Am6iY,3678
|
|
12
|
+
depwake/semver.py,sha256=gZlfoGXAx849uzg3U_QnF2eNtNJIAQFRzTUn5_Izx2k,1654
|
|
13
|
+
depwake-0.3.1.dist-info/licenses/LICENSE,sha256=6Dxufnxzt_eCMwLa09imvNFFdK-zI7Bf6G5fbhrI5vk,1077
|
|
14
|
+
depwake-0.3.1.dist-info/METADATA,sha256=OqqGVWC4erJRDMg9H8snYP4c86gqjki1EKsfBrM7g_g,4235
|
|
15
|
+
depwake-0.3.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
16
|
+
depwake-0.3.1.dist-info/entry_points.txt,sha256=bBvlpkaf3Uz7hKb2Ui5NiUpCspO9NBdoVnjMQdTClzo,45
|
|
17
|
+
depwake-0.3.1.dist-info/top_level.txt,sha256=J6sx11FCDxO6OPXxOG4XuEhR63ULd94bevBY_7FmJec,8
|
|
18
|
+
depwake-0.3.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Depwake Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
depwake
|