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