#!/usr/bin/env python3"""retirement_audit.py — 書かれているモデル ID を、廃止表の「状態」と「日付の意味」で仕分ける。使い方: python3 retirement_audit.py <dir> --table retirement_table.json [--today YYYY-MM-DD]終了コード: 0=要対応なし / 1=退役済み・退役予定あり / 2=表が古い(判定を信用しない)"""import re, sys, json, pathlib, datetime as dt, collectionsID_RE = re.compile(r"claude-(?:sonnet|opus|haiku|fable)(?:-\d+){1,3}(?:-\d{8})?(?![\w.])" r"|claude-3(?:-[57])?-(?:sonnet|haiku)(?:-\d{8})?(?![\w.])")FENCE = re.compile(r"```.*?```", re.S)SKIP = {"node_modules", ".git", ".next", "dist"}def classify(entry, today, notice_days): """返り値: (区分, 日数)。active の日付は『これより早くは終わらない下限』であって予定日ではない。""" d = dt.date.fromisoformat(entry["date"]) left = (d - today).days if entry["status"] == "retired": return "RETIRED", left if entry["status"] == "deprecated": return ("RETIRED" if left <= 0 else "DEPRECATED"), left # active: 告知が無い限り、今日から数えて notice_days 日より前には止まらない earliest = max(d, today + dt.timedelta(days=notice_days)) if left < 0: return "FLOOR_PASSED", (earliest - today).days # 下限は過ぎた。止まるのは早くても告知の60日後 return ("FLOOR_NEAR" if left <= notice_days else "OK"), leftdef scan(root, table, today): models, notice = table["models"], table["notice_days"] seen = collections.defaultdict(lambda: {"any": set(), "code": set()}) for p in pathlib.Path(root).rglob("*"): if not p.is_file() or p.suffix not in {".mdx", ".md", ".py", ".ts", ".js", ".mjs", ".json", ".yaml", ".yml", ".sh"}: continue if SKIP & set(p.parts): continue t = p.read_text(encoding="utf-8", errors="ignore") code = "\n".join(FENCE.findall(t)) if p.suffix in {".mdx", ".md"} else t for m in ID_RE.finditer(t): seen[m.group(0)]["any"].add(str(p)) for m in ID_RE.finditer(code): seen[m.group(0)]["code"].add(str(p)) rows = [] for mid, f in seen.items(): if mid in models: cls, n = classify(models[mid], today, notice) else: cls, n = "NOT_IN_TABLE", None rows.append({"id": mid, "class": cls, "days": n, "files": len(f["any"]), "code_files": len(f["code"])}) return rowsdef main(): root = sys.argv[1] table = json.load(open(sys.argv[sys.argv.index("--table") + 1])) today = dt.date.fromisoformat(sys.argv[sys.argv.index("--today") + 1]) if "--today" in sys.argv else dt.date.today() age = (today - dt.date.fromisoformat(table["fetched"])).days if age > 14: print(f"STALE: 廃止表の取得から {age} 日。公式ページを開き直してください"); sys.exit(2) rows = scan(root, table, today) order = ["RETIRED", "DEPRECATED", "FLOOR_PASSED", "FLOOR_NEAR", "NOT_IN_TABLE", "OK"] rows.sort(key=lambda r: (order.index(r["class"]), -r["code_files"])) for r in rows: if r["class"] == "NOT_IN_TABLE": continue d = "" if r["days"] is None else f'{r["days"]:+d}d' print(f'{r["class"]:<13}{r["id"]:<30}any={r["files"]:<4}code={r["code_files"]:<4}{d}') bad = sum(r["code_files"] for r in rows if r["class"] in ("RETIRED", "DEPRECATED")) sys.exit(1 if bad else 0)if __name__ == "__main__": main()
読み方は、上から順に優先度の高い順です。退役済みの 4 種と Deprecated の 1 種は、本文の書き直しが必要な候補になります。ただし、記事のなかには「この ID は退役しました」と説明するために書いた箇所も含まれていて、any と code の差はその分です。一括で置換する前に、一件ずつ文脈を見る必要があります。この判断についてはモデル ID を一括置換する前に引くカタログにも書き残しています。
Haiku 4.5 の 76 ファイルを、どう扱うか
Haiku 4.5 に絞って中身を見ると、model: の行で日付つきの ID をそのまま固定していたものが 72 行、日付なしの別名(claude-haiku-4-5)が 28 行ありました。別名は公式が指す先を動かせますが、日付つきは固定です。ここで効くのは、書き直しの優先度ではなく、止まる日が来たときに誰が自分で直すかという視点でした。
状況
今日やること
理由
日付つき ID を本番で固定している
置き換え先の候補を決めて、検証用の呼び出しを 1 本通しておく
通知から退役まで最低 60 日。慌てる必要はないが、通知後に初めて調べると 2 か月が短い
別名を使っている
何もしない(廃止表の日付は見続ける)
指す先の更新は提供側が管理している
記事・文書に例として書いている
次に触るときに直す
実行されない。ただし読者はコピペする
個人開発でサイトを運営していると、記事のコード例は私自身が書いた「本番コード」です。読者はそれをコピペして動かします。だからこそ、この順番で扱うことにしました。止まる日が決まっていない ID を、告知の前に書き換えると、後から「なぜ変えたのか」を説明できなくなるからです。変える理由が公式の通知になった時点で、一括の書き換えに進みます。