stalecrate 0.1.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.
- stalecrate/__init__.py +1 -0
- stalecrate/apidiff.py +285 -0
- stalecrate/cache.py +10 -0
- stalecrate/cli.py +175 -0
- stalecrate/cutoffs.py +48 -0
- stalecrate/guides.py +171 -0
- stalecrate/notes.py +140 -0
- stalecrate/registry.py +124 -0
- stalecrate/rustdoc.py +62 -0
- stalecrate/usage.py +82 -0
- stalecrate-0.1.0.dist-info/METADATA +101 -0
- stalecrate-0.1.0.dist-info/RECORD +16 -0
- stalecrate-0.1.0.dist-info/WHEEL +4 -0
- stalecrate-0.1.0.dist-info/entry_points.txt +2 -0
- stalecrate-0.1.0.dist-info/licenses/LICENSE-APACHE +202 -0
- stalecrate-0.1.0.dist-info/licenses/LICENSE-MIT +21 -0
stalecrate/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
stalecrate/apidiff.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Diff the public API of crates between two versions, from rustdoc JSON.
|
|
3
|
+
|
|
4
|
+
Prints removed, added and changed public items. Items are keyed by path
|
|
5
|
+
(`crate::module::Type::method`). Re-exports through `prelude` modules are
|
|
6
|
+
dropped so each item is counted once where possible.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
RECORD_KINDS = {"struct", "enum", "union", "trait", "function", "type_alias",
|
|
12
|
+
"constant", "static", "macro", "proc_macro", "trait_alias"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def strip_ids(x):
|
|
16
|
+
"""Remove rustdoc ids (they differ between builds) and reduce type paths to
|
|
17
|
+
their last segment: `bevy_color::Color` and `Color` are the same type
|
|
18
|
+
spelled through different re-exports, not an API change."""
|
|
19
|
+
if isinstance(x, dict):
|
|
20
|
+
return {k: (v.rsplit("::", 1)[-1] if k == "path" and isinstance(v, str) else strip_ids(v))
|
|
21
|
+
for k, v in x.items() if k not in ("id", "span")}
|
|
22
|
+
if isinstance(x, list):
|
|
23
|
+
return [strip_ids(v) for v in x]
|
|
24
|
+
return x
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def erase_lifetimes(x):
|
|
28
|
+
"""Callers almost never name lifetimes, so renaming `'a` to `'w` is not a break."""
|
|
29
|
+
if isinstance(x, dict):
|
|
30
|
+
return {k: ("'_" if k == "lifetime" and isinstance(v, str) else erase_lifetimes(v))
|
|
31
|
+
for k, v in x.items()}
|
|
32
|
+
if isinstance(x, list):
|
|
33
|
+
return [erase_lifetimes(v) for v in x]
|
|
34
|
+
return x
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def fingerprint(item):
|
|
38
|
+
kind, inner = next(iter(item["inner"].items()))
|
|
39
|
+
if kind == "function":
|
|
40
|
+
return json.dumps(strip_ids({"sig": inner["sig"], "generics": inner["generics"]}), sort_keys=True)
|
|
41
|
+
if kind in ("struct_field", "type_alias", "constant", "static"):
|
|
42
|
+
return json.dumps(strip_ids(inner), sort_keys=True)
|
|
43
|
+
if kind == "variant":
|
|
44
|
+
return json.dumps(strip_ids(inner.get("kind")), sort_keys=True)
|
|
45
|
+
if kind in ("struct", "enum", "union", "trait"):
|
|
46
|
+
return json.dumps(strip_ids(inner.get("generics")), sort_keys=True)
|
|
47
|
+
return kind
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def shape(item):
|
|
51
|
+
"""Coarse fingerprint without trait bounds, where-clauses or lifetime names:
|
|
52
|
+
parameter and return types plus the kinds of the type/const generic
|
|
53
|
+
parameters. Two items with equal shapes but different fingerprints differ
|
|
54
|
+
only in bounds or lifetimes, which rarely breaks callers."""
|
|
55
|
+
kind, inner = next(iter(item["inner"].items()))
|
|
56
|
+
gen = inner.get("generics") if isinstance(inner, dict) else None
|
|
57
|
+
params = [next(iter(p["kind"])) for p in (gen or {}).get("params", [])
|
|
58
|
+
if next(iter(p["kind"])) != "lifetime"
|
|
59
|
+
and not (p["kind"].get("type") or {}).get("is_synthetic")]
|
|
60
|
+
if kind == "function":
|
|
61
|
+
sig = inner["sig"]
|
|
62
|
+
return json.dumps(erase_lifetimes(strip_ids([[t for _, t in sig["inputs"]], sig["output"], params])),
|
|
63
|
+
sort_keys=True)
|
|
64
|
+
if kind in ("struct", "enum", "union", "trait"):
|
|
65
|
+
return json.dumps(params)
|
|
66
|
+
if kind in ("variant", "struct_field", "type_alias", "constant", "static"):
|
|
67
|
+
return json.dumps(erase_lifetimes(json.loads(fingerprint(item))), sort_keys=True)
|
|
68
|
+
return fingerprint(item)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def ty(t):
|
|
72
|
+
"""Render a rustdoc JSON type as compact Rust syntax."""
|
|
73
|
+
if not isinstance(t, dict):
|
|
74
|
+
return "_"
|
|
75
|
+
k, v = next(iter(t.items()))
|
|
76
|
+
if k == "resolved_path":
|
|
77
|
+
args = v.get("args") or {}
|
|
78
|
+
ab = args.get("angle_bracketed") if isinstance(args, dict) else None
|
|
79
|
+
inner = [ty(a["type"]) if "type" in a else (a.get("lifetime") or "_")
|
|
80
|
+
for a in (ab or {}).get("args", []) if isinstance(a, dict) and ("type" in a or "lifetime" in a)]
|
|
81
|
+
inner = [a for a in inner if a != "'_"]
|
|
82
|
+
return v["path"].rsplit("::", 1)[-1] + (f"<{', '.join(inner)}>" if inner else "")
|
|
83
|
+
if k == "primitive":
|
|
84
|
+
return v
|
|
85
|
+
if k == "generic":
|
|
86
|
+
return v
|
|
87
|
+
if k == "borrowed_ref":
|
|
88
|
+
return "&" + ("mut " if v.get("is_mutable") else "") + ty(v["type"])
|
|
89
|
+
if k == "tuple":
|
|
90
|
+
return "(" + ", ".join(ty(x) for x in v) + ")"
|
|
91
|
+
if k == "slice":
|
|
92
|
+
return f"[{ty(v)}]"
|
|
93
|
+
if k == "array":
|
|
94
|
+
return f"[{ty(v['type'])}; {v.get('len')}]"
|
|
95
|
+
if k in ("impl_trait", "dyn_trait"):
|
|
96
|
+
bounds = v if k == "impl_trait" else v.get("traits", [])
|
|
97
|
+
names = [(b.get("trait_bound") or b).get("trait", {}).get("path", "?").rsplit("::", 1)[-1]
|
|
98
|
+
for b in bounds if isinstance(b, dict) and ("trait_bound" in b or "trait" in b)]
|
|
99
|
+
return ("impl " if k == "impl_trait" else "dyn ") + " + ".join(names or ["?"])
|
|
100
|
+
if k == "qualified_path":
|
|
101
|
+
return f"{ty(v.get('self_type'))}::{v.get('name')}"
|
|
102
|
+
if k == "raw_pointer":
|
|
103
|
+
return ("*mut " if v.get("is_mutable") else "*const ") + ty(v["type"])
|
|
104
|
+
return k
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def render(item, name):
|
|
108
|
+
"""One-line signature for notes: `fn new(radius: f32) -> Self`, a field type, a variant."""
|
|
109
|
+
kind, inner = next(iter(item["inner"].items()))
|
|
110
|
+
if kind == "function":
|
|
111
|
+
sig = inner["sig"]
|
|
112
|
+
args = ", ".join(("self" if n == "self" else f"{n}: {ty(t)}") for n, t in sig["inputs"])
|
|
113
|
+
out = f" -> {ty(sig['output'])}" if sig.get("output") else ""
|
|
114
|
+
return f"fn {name}({args}){out}"
|
|
115
|
+
if kind == "struct_field":
|
|
116
|
+
return f"{name}: {ty(inner)}"
|
|
117
|
+
if kind == "variant":
|
|
118
|
+
vk = inner.get("kind")
|
|
119
|
+
if isinstance(vk, dict) and "tuple" in vk:
|
|
120
|
+
return f"{name}(...{len(vk['tuple'])} fields)"
|
|
121
|
+
if isinstance(vk, dict) and "struct" in vk:
|
|
122
|
+
return f"{name} {{ ... }}"
|
|
123
|
+
return name
|
|
124
|
+
if kind in ("struct", "enum", "trait", "union"):
|
|
125
|
+
params = [p["name"] for p in inner.get("generics", {}).get("params", [])
|
|
126
|
+
if next(iter(p["kind"])) != "lifetime" and not (p["kind"].get("type") or {}).get("is_synthetic")]
|
|
127
|
+
return f"{kind} {name}" + (f"<{', '.join(params)}>" if params else "")
|
|
128
|
+
return f"{kind} {name}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Api:
|
|
132
|
+
def __init__(self, path):
|
|
133
|
+
d = json.load(open(path))
|
|
134
|
+
self.crate = d["index"][str(d["root"])]["name"]
|
|
135
|
+
self.version = d.get("crate_version")
|
|
136
|
+
self.index = d["index"]
|
|
137
|
+
self.items = {} # path -> {kind, fp, deprecation, docs}
|
|
138
|
+
self._seen = set()
|
|
139
|
+
self.walk(d["root"], self.crate)
|
|
140
|
+
# the raw index is only needed while walking; a big crate's is hundreds of MB
|
|
141
|
+
del self.index, self._seen
|
|
142
|
+
|
|
143
|
+
def get(self, i):
|
|
144
|
+
return self.index.get(str(i)) if i is not None else None
|
|
145
|
+
|
|
146
|
+
def record(self, path, item, kind=None):
|
|
147
|
+
kind = kind or next(iter(item["inner"]))
|
|
148
|
+
self.items.setdefault(path, {
|
|
149
|
+
"kind": kind,
|
|
150
|
+
"fp": fingerprint(item),
|
|
151
|
+
"shape": shape(item),
|
|
152
|
+
"sig": render(item, re.split(r"::|\.", path)[-1]),
|
|
153
|
+
"deprecation": item.get("deprecation"),
|
|
154
|
+
"docs": (item.get("docs") or "").split("\n\n")[0][:300],
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
def members(self, path, item):
|
|
158
|
+
kind, inner = next(iter(item["inner"].items()))
|
|
159
|
+
if kind == "struct":
|
|
160
|
+
k = inner["kind"]
|
|
161
|
+
fields = k.get("plain", {}).get("fields", []) if isinstance(k, dict) else []
|
|
162
|
+
for f in fields:
|
|
163
|
+
fi = self.get(f)
|
|
164
|
+
if fi and fi.get("visibility") == "public" and fi.get("name"):
|
|
165
|
+
self.record(f"{path}.{fi['name']}", fi)
|
|
166
|
+
if kind == "enum":
|
|
167
|
+
for v in inner.get("variants", []):
|
|
168
|
+
vi = self.get(v)
|
|
169
|
+
if vi:
|
|
170
|
+
self.record(f"{path}::{vi['name']}", vi)
|
|
171
|
+
if kind == "trait":
|
|
172
|
+
for m in inner.get("items", []):
|
|
173
|
+
mi = self.get(m)
|
|
174
|
+
if mi and mi.get("name"):
|
|
175
|
+
self.record(f"{path}::{mi['name']}", mi)
|
|
176
|
+
if kind in ("struct", "enum", "union"):
|
|
177
|
+
for imp in inner.get("impls", []):
|
|
178
|
+
ii = self.get(imp)
|
|
179
|
+
if not ii:
|
|
180
|
+
continue
|
|
181
|
+
im = ii["inner"]["impl"]
|
|
182
|
+
if im.get("trait") is not None or im.get("is_synthetic") or im.get("blanket_impl"):
|
|
183
|
+
continue
|
|
184
|
+
for m in im.get("items", []):
|
|
185
|
+
mi = self.get(m)
|
|
186
|
+
if mi and mi.get("visibility") == "public" and mi.get("name"):
|
|
187
|
+
self.record(f"{path}::{mi['name']}", mi)
|
|
188
|
+
|
|
189
|
+
def walk(self, mod_id, prefix):
|
|
190
|
+
key = (mod_id, prefix)
|
|
191
|
+
if key in self._seen or prefix.count("::") > 12:
|
|
192
|
+
return
|
|
193
|
+
self._seen.add(key)
|
|
194
|
+
mod = self.get(mod_id)
|
|
195
|
+
for cid in mod["inner"]["module"]["items"]:
|
|
196
|
+
it = self.get(cid)
|
|
197
|
+
if not it or it.get("visibility") != "public":
|
|
198
|
+
continue
|
|
199
|
+
kind, inner = next(iter(it["inner"].items()))
|
|
200
|
+
if kind == "use":
|
|
201
|
+
target = self.get(inner.get("id"))
|
|
202
|
+
if inner["is_glob"]:
|
|
203
|
+
if target and "module" in target["inner"]:
|
|
204
|
+
self.walk(target["id"], prefix)
|
|
205
|
+
continue
|
|
206
|
+
p = f"{prefix}::{inner['name']}"
|
|
207
|
+
if target is None:
|
|
208
|
+
self.items.setdefault(p, {"kind": "reexport", "fp": inner["source"], "shape": inner["source"],
|
|
209
|
+
"deprecation": None, "docs": ""})
|
|
210
|
+
elif "module" in target["inner"]:
|
|
211
|
+
self.walk(target["id"], p)
|
|
212
|
+
elif next(iter(target["inner"])) in RECORD_KINDS:
|
|
213
|
+
self.record(p, target)
|
|
214
|
+
self.members(p, target)
|
|
215
|
+
continue
|
|
216
|
+
if kind == "module":
|
|
217
|
+
self.walk(cid, f"{prefix}::{it['name']}")
|
|
218
|
+
elif kind in RECORD_KINDS:
|
|
219
|
+
p = f"{prefix}::{it['name']}"
|
|
220
|
+
self.record(p, it)
|
|
221
|
+
self.members(p, it)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def public(paths):
|
|
225
|
+
return {p for p in paths if "::prelude::" not in p}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def move_key(p, items):
|
|
229
|
+
"""Key for move detection: item name for top-level items, `Owner::member`
|
|
230
|
+
for members, so a removed `Foo::new` does not "move" to every other `new`."""
|
|
231
|
+
segs = re.split(r"::|\.", p)
|
|
232
|
+
member = items[p]["kind"] in ("function", "struct_field", "variant", "assoc_const", "assoc_type") \
|
|
233
|
+
and len(segs) > 2 and segs[-2][:1].isupper()
|
|
234
|
+
return "::".join(segs[-2:]) if member else segs[-1]
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def diff(old, new, new_everywhere, all_new_names):
|
|
238
|
+
"""Diff one crate. `new_everywhere` maps move_key -> paths across every crate
|
|
239
|
+
of the new version, so items that moved to a new crate count as moves."""
|
|
240
|
+
o, n = public(old.items), public(new.items)
|
|
241
|
+
removed = sorted(o - n)
|
|
242
|
+
added = sorted(n - o)
|
|
243
|
+
changed = sorted(p for p in o & n if old.items[p]["fp"] != new.items[p]["fp"])
|
|
244
|
+
newly_deprecated = sorted(p for p in o & n
|
|
245
|
+
if new.items[p]["deprecation"] and not old.items[p]["deprecation"])
|
|
246
|
+
moved = {p: new_everywhere[move_key(p, old.items)] for p in removed
|
|
247
|
+
if move_key(p, old.items) in new_everywhere}
|
|
248
|
+
return {
|
|
249
|
+
"crate": old.crate, "old": old.version, "new": new.version,
|
|
250
|
+
"counts": {"old_public": len(o), "new_public": len(n), "removed": len(removed),
|
|
251
|
+
"moved": len(moved), "added": len(added), "changed": len(changed),
|
|
252
|
+
"newly_deprecated": len(newly_deprecated)},
|
|
253
|
+
"removed": [{"path": p, "kind": old.items[p]["kind"], "moved_to": moved.get(p),
|
|
254
|
+
# no item of this name is left in any crate, so any use of it breaks;
|
|
255
|
+
# if the name survives elsewhere (`bevy_ui::Node`), a bare match is ambiguous
|
|
256
|
+
"name_gone": re.split(r"::|\.", p)[-1] not in all_new_names,
|
|
257
|
+
"old_sig": old.items[p].get("sig"),
|
|
258
|
+
# members the same owner gained: the likely replacement for a removed field/method
|
|
259
|
+
"siblings_added": sorted(re.split(r"::|\.", q)[-1] for q in added
|
|
260
|
+
if re.split(r"(?:::|\.)[^:.]+$", q)[0] == re.split(r"(?:::|\.)[^:.]+$", p)[0])[:60]}
|
|
261
|
+
for p in removed],
|
|
262
|
+
"changed": [{"path": p, "kind": old.items[p]["kind"],
|
|
263
|
+
"old_sig": old.items[p].get("sig"), "new_sig": new.items[p].get("sig"),
|
|
264
|
+
"tier": "bounds" if old.items[p]["shape"] == new.items[p]["shape"] else "signature"}
|
|
265
|
+
for p in changed],
|
|
266
|
+
"newly_deprecated": [{"path": p, "kind": new.items[p]["kind"], "note": new.items[p]["deprecation"]}
|
|
267
|
+
for p in newly_deprecated],
|
|
268
|
+
"added": [{"path": p, "kind": new.items[p]["kind"], "docs": new.items[p]["docs"]} for p in added],
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def diff_all(old_paths, new_paths):
|
|
273
|
+
"""Diff every crate in `old_paths` ({crate: rustdoc JSON path}) against the
|
|
274
|
+
same crate in `new_paths`. All new crates are searched for move targets, so
|
|
275
|
+
an item that moved into a brand-new crate (`bevy_shape`) counts as moved.
|
|
276
|
+
Returns one report per crate present on both sides."""
|
|
277
|
+
news = {c: Api(p) for c, p in new_paths.items()}
|
|
278
|
+
olds = {c: Api(p) for c, p in old_paths.items()}
|
|
279
|
+
old_all = set().union(set(), *(public(a.items) for a in olds.values()))
|
|
280
|
+
all_new_names = {re.split(r"::|\.", p)[-1] for a in news.values() for p in a.items}
|
|
281
|
+
everywhere = {}
|
|
282
|
+
for api in news.values():
|
|
283
|
+
for p in public(api.items) - old_all:
|
|
284
|
+
everywhere.setdefault(move_key(p, api.items), []).append(p)
|
|
285
|
+
return [diff(old, news[c], everywhere, all_new_names) for c, old in olds.items() if c in news]
|
stalecrate/cache.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pathlib
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def cache_dir(*parts):
|
|
6
|
+
base = pathlib.Path(os.environ.get("STALECRATE_CACHE")
|
|
7
|
+
or pathlib.Path(os.environ.get("XDG_CACHE_HOME", pathlib.Path.home() / ".cache")) / "stalecrate")
|
|
8
|
+
d = base.joinpath(*parts)
|
|
9
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
10
|
+
return d
|
stalecrate/cli.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""stalecrate: tell your coding agent which Rust crates changed after its training cutoff.
|
|
2
|
+
|
|
3
|
+
stalecrate scan list in-scope dependencies released after the cutoff
|
|
4
|
+
stalecrate sync diff their APIs, find what your code uses, write notes to CLAUDE.md / AGENTS.md
|
|
5
|
+
stalecrate status exit 1 if the notes are missing or older than Cargo.lock (for CI / pre-commit)
|
|
6
|
+
"""
|
|
7
|
+
import argparse
|
|
8
|
+
import hashlib
|
|
9
|
+
import pathlib
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from . import __version__, apidiff, cutoffs, guides, notes, registry, rustdoc, usage
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def log(msg):
|
|
16
|
+
print(msg, file=sys.stderr, flush=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve_cutoff(a):
|
|
20
|
+
if a.cutoff:
|
|
21
|
+
c, label = cutoffs.parse_cutoff(a.cutoff), f"your training cutoff ({a.cutoff})"
|
|
22
|
+
else:
|
|
23
|
+
model = a.model or cutoffs.detect_model(a.path)
|
|
24
|
+
c = cutoffs.cutoff_for(model)
|
|
25
|
+
if c is None:
|
|
26
|
+
sys.exit(f"stalecrate: don't know the training cutoff of model {model!r}. "
|
|
27
|
+
f"Pass --cutoff YYYY-MM (or --model, one of: {', '.join(cutoffs.KNOWN)}).")
|
|
28
|
+
label = f"your training cutoff ({model}: {c[0]}-{c[1]:02d})"
|
|
29
|
+
return cutoffs.known_until(c, a.margin_days), label
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def plan(a):
|
|
33
|
+
"""In-scope crates with releases after the cutoff, grouped by family."""
|
|
34
|
+
known_until, label = resolve_cutoff(a)
|
|
35
|
+
pkgs = registry.lock_packages(a.path)
|
|
36
|
+
direct = registry.direct_deps(a.path)
|
|
37
|
+
rows = []
|
|
38
|
+
for name, pinned in sorted(pkgs.items()):
|
|
39
|
+
if not registry.related(name, direct):
|
|
40
|
+
continue
|
|
41
|
+
try:
|
|
42
|
+
base, newer = registry.baseline(name, pinned, known_until, a.offline)
|
|
43
|
+
except OSError as e:
|
|
44
|
+
log(f" crates.io lookup failed for {name}: {e}")
|
|
45
|
+
continue
|
|
46
|
+
if not newer:
|
|
47
|
+
continue
|
|
48
|
+
family = name if name in direct else max((d for d in direct if name.startswith(d)), key=len)
|
|
49
|
+
rows.append({"name": name, "pinned": pinned, "base": base, "newer": newer, "family": family,
|
|
50
|
+
"breaking": base is None or registry.is_breaking(base, pinned)})
|
|
51
|
+
return known_until, label, rows
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def cmd_scan(a):
|
|
55
|
+
known_until, label, rows = plan(a)
|
|
56
|
+
print(f"{label[0].upper()}{label[1:]}: releases up to {known_until} count as known")
|
|
57
|
+
if not rows:
|
|
58
|
+
print("no in-scope dependency has a release after that date")
|
|
59
|
+
return
|
|
60
|
+
w = max(len(r["name"]) for r in rows)
|
|
61
|
+
print(f"{'crate':{w}} {'pinned':>10} {'known':>10} releases since breaking")
|
|
62
|
+
for r in rows:
|
|
63
|
+
if a.all or r["breaking"]:
|
|
64
|
+
print(f"{r['name']:{w}} {r['pinned']:>10} {r['base'] or 'new crate':>10} "
|
|
65
|
+
f"{len(r['newer']):>14} {'yes' if r['breaking'] else 'no'}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def inputs_hash(label, rows):
|
|
69
|
+
h = hashlib.sha256(label.encode())
|
|
70
|
+
for r in rows:
|
|
71
|
+
h.update(f"{r['name']}@{r['base']}->{r['pinned']};".encode())
|
|
72
|
+
return h.hexdigest()[:12]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def target_file(a):
|
|
76
|
+
if a.file:
|
|
77
|
+
return pathlib.Path(a.path) / a.file
|
|
78
|
+
for n in ("CLAUDE.md", "AGENTS.md"):
|
|
79
|
+
if (pathlib.Path(a.path) / n).exists():
|
|
80
|
+
return pathlib.Path(a.path) / n
|
|
81
|
+
return pathlib.Path(a.path) / "AGENTS.md"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def cmd_sync(a):
|
|
85
|
+
known_until, label, rows = plan(a)
|
|
86
|
+
rows = [r for r in rows if a.all or r["breaking"]]
|
|
87
|
+
path = target_file(a)
|
|
88
|
+
if not rows:
|
|
89
|
+
log("no in-scope dependency changed after the cutoff; nothing to write")
|
|
90
|
+
return
|
|
91
|
+
log(f"model knows releases up to {known_until}; {len(rows)} crates changed after it")
|
|
92
|
+
log(f"rustdoc: {rustdoc.check_toolchain()}")
|
|
93
|
+
families = {}
|
|
94
|
+
for r in rows:
|
|
95
|
+
families.setdefault(r["family"], []).append(r)
|
|
96
|
+
old_paths, new_paths = {}, {}
|
|
97
|
+
for fam, members in families.items():
|
|
98
|
+
# a facade (`bevy`) only re-exports its sub-crates; the sub-crates carry the API
|
|
99
|
+
build = [m for m in members if not (m["name"] == fam and len(members) > 1)
|
|
100
|
+
and not registry.is_glue(m["name"])]
|
|
101
|
+
for m in build:
|
|
102
|
+
try:
|
|
103
|
+
new_paths[m["name"]] = rustdoc.build(m["name"], m["pinned"], log)
|
|
104
|
+
if m["base"]:
|
|
105
|
+
old_paths[m["name"]] = rustdoc.build(m["name"], m["base"], log)
|
|
106
|
+
except rustdoc.RustdocError as e:
|
|
107
|
+
log(f" skipped: {e}")
|
|
108
|
+
new_paths.pop(m["name"], None)
|
|
109
|
+
log("diffing APIs")
|
|
110
|
+
reports = apidiff.diff_all(old_paths, new_paths)
|
|
111
|
+
files = usage.index(usage.source_files(a.path))
|
|
112
|
+
hits = usage.scan(files, reports)
|
|
113
|
+
log(f"{len(files)} source files; {len({h['path'] for h in hits})} changed items used")
|
|
114
|
+
fams = []
|
|
115
|
+
for fam, members in families.items():
|
|
116
|
+
names = {m["name"] for m in members}
|
|
117
|
+
root = next((m for m in members if m["name"] == fam), members[0])
|
|
118
|
+
secs = []
|
|
119
|
+
for m in ([root] + [m for m in members if m is not root]):
|
|
120
|
+
try:
|
|
121
|
+
secs += guides.sections(m["name"], m["base"], m["newer"], m["pinned"], log)
|
|
122
|
+
except OSError as e:
|
|
123
|
+
log(f" no upgrade notes for {m['name']}: {e}")
|
|
124
|
+
fams.append({"name": fam, "base": root["base"] or "new", "pinned": root["pinned"],
|
|
125
|
+
"hits": [h for h in hits if h["crate"] in names], "sections": secs})
|
|
126
|
+
block = notes.render(fams, label, a.max_tokens, inputs_hash(label, rows))
|
|
127
|
+
if a.dry_run:
|
|
128
|
+
print(block)
|
|
129
|
+
return
|
|
130
|
+
changed = notes.write_block(path, block)
|
|
131
|
+
log(f"{'wrote' if changed else 'unchanged'}: {path} (~{notes.tokens(block)} tokens)")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def cmd_status(a):
|
|
135
|
+
_, label, rows = plan(a)
|
|
136
|
+
rows = [r for r in rows if a.all or r["breaking"]]
|
|
137
|
+
path = target_file(a)
|
|
138
|
+
text = path.read_text() if path.exists() else ""
|
|
139
|
+
want = inputs_hash(label, rows)
|
|
140
|
+
if not rows:
|
|
141
|
+
print("ok: no dependency changed after the cutoff")
|
|
142
|
+
return 0
|
|
143
|
+
if f"inputs={want}" not in text:
|
|
144
|
+
print(f"stale: {path.name} has no notes for the current Cargo.lock; run `stalecrate sync`")
|
|
145
|
+
return 1
|
|
146
|
+
print(f"ok: {path.name} is up to date")
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def main(argv=None):
|
|
151
|
+
ap = argparse.ArgumentParser(prog="stalecrate", description=__doc__.split("\n")[0])
|
|
152
|
+
ap.add_argument("--version", action="version", version=f"stalecrate {__version__}")
|
|
153
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
154
|
+
for name in ("scan", "sync", "status"):
|
|
155
|
+
p = sub.add_parser(name)
|
|
156
|
+
p.add_argument("--path", default=".", help="project root (with Cargo.lock)")
|
|
157
|
+
p.add_argument("--model", help="model id, to look up its training cutoff")
|
|
158
|
+
p.add_argument("--cutoff", help="training cutoff as YYYY-MM (overrides --model)")
|
|
159
|
+
p.add_argument("--margin-days", type=int, default=30,
|
|
160
|
+
help="treat releases from the last N days before the cutoff as unknown (default 30)")
|
|
161
|
+
p.add_argument("--all", action="store_true", help="include non-breaking releases too")
|
|
162
|
+
p.add_argument("--offline", action="store_true", help="use cached crates.io data only")
|
|
163
|
+
if name != "scan":
|
|
164
|
+
p.add_argument("--file", help="notes file (default: CLAUDE.md if present, else AGENTS.md)")
|
|
165
|
+
if name == "sync":
|
|
166
|
+
p.add_argument("--max-tokens", type=int, default=4000)
|
|
167
|
+
p.add_argument("--dry-run", action="store_true", help="print the notes instead of writing them")
|
|
168
|
+
a = ap.parse_args(argv)
|
|
169
|
+
if not (pathlib.Path(a.path) / "Cargo.lock").exists():
|
|
170
|
+
sys.exit(f"stalecrate: no Cargo.lock in {a.path} (run `cargo generate-lockfile` first)")
|
|
171
|
+
return {"scan": cmd_scan, "sync": cmd_sync, "status": cmd_status}[a.cmd](a) or 0
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
if __name__ == "__main__":
|
|
175
|
+
sys.exit(main())
|
stalecrate/cutoffs.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Training cutoffs for coding models, and finding which model the project uses.
|
|
2
|
+
|
|
3
|
+
Only cutoffs that the model vendor states are listed. For anything else pass
|
|
4
|
+
--cutoff YYYY-MM explicitly.
|
|
5
|
+
"""
|
|
6
|
+
import calendar
|
|
7
|
+
import datetime as dt
|
|
8
|
+
import json
|
|
9
|
+
import pathlib
|
|
10
|
+
|
|
11
|
+
# model id prefix -> (year, month) of the stated training-data cutoff
|
|
12
|
+
KNOWN = {
|
|
13
|
+
"claude-opus-5-5": (2026, 6),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_cutoff(text):
|
|
18
|
+
y, m = (int(x) for x in text.split("-")[:2])
|
|
19
|
+
return y, m
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def known_until(cutoff, margin_days):
|
|
23
|
+
"""Last release date the model plausibly learned: the end of the cutoff month
|
|
24
|
+
minus a margin, since releases from the final weeks are thinly covered."""
|
|
25
|
+
y, m = cutoff
|
|
26
|
+
end = dt.date(y, m, calendar.monthrange(y, m)[1])
|
|
27
|
+
return end - dt.timedelta(days=margin_days)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def detect_model(project):
|
|
31
|
+
"""The model a Claude Code project is set up with, from its settings files."""
|
|
32
|
+
root = pathlib.Path(project)
|
|
33
|
+
for f in [root / ".claude" / "settings.local.json", root / ".claude" / "settings.json",
|
|
34
|
+
pathlib.Path.home() / ".claude" / "settings.json"]:
|
|
35
|
+
try:
|
|
36
|
+
model = json.loads(f.read_text()).get("model")
|
|
37
|
+
except (OSError, ValueError):
|
|
38
|
+
continue
|
|
39
|
+
if model:
|
|
40
|
+
return model
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def cutoff_for(model):
|
|
45
|
+
for prefix, c in KNOWN.items():
|
|
46
|
+
if model and model.startswith(prefix):
|
|
47
|
+
return c
|
|
48
|
+
return None
|