claude-skills/scripts/check_model_freshness.py
Claude c118489398
chore(scripts): sync derive_counters docs with 5-source gate, rename EXCLUDED_DIRS to EXCLUDED_NAMES
Two reviewer nits batched: derive_counters.py's module docstring and --check
help still described the pre-#989 three-source coverage (flagged on #989);
check_model_freshness.py's EXCLUDED_DIRS did double duty as a directory AND
filename exclusion set, which the name hid (flagged on #985 and #988's
reviews) — renamed with a comment stating both roles. No behavior change;
both gates re-verified passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qgc6RYXWJPr5oW9DHU7zR4
2026-08-25 06:43:51 +00:00

236 lines
9.1 KiB
Python

#!/usr/bin/env python3
"""check_model_freshness.py — retired-model-identifier linter (gate G7).
Proposed by audit/newgen-2026-06/00-MASTER.md and never built, which is why
retired model IDs and 2024 price tables kept drifting past two later audits.
Flags references to model identifiers that no longer exist, are no longer
current, or are presented as current when they are not. The point is not to
chase whatever is newest: it is to catch the cases that mislead a reader or
break on execution, namely
- script defaults and config values naming a retired model
- cost/pricing tables keyed on a retired model
- copy-pasteable CLI examples pinning a retired versioned ID
Dated citations are legitimate and must NOT be flagged. A reference is treated
as a citation when the same line carries a year, an arXiv ID, the words
"paper"/"technical report"/"model card"/"as of"/"historical", or a
"snapshot"/"deprecated"/"retired" marker. Anything else needs an entry in
scripts/check_model_freshness_allowlist.txt with a reason.
Exit codes: 0 = clean, 1 = at least one unexplained retired identifier.
Usage:
python3 scripts/check_model_freshness.py --all
python3 scripts/check_model_freshness.py FILE [FILE ...]
python3 scripts/check_model_freshness.py --all --json
python3 scripts/check_model_freshness.py --list-patterns
"""
import argparse
import fnmatch
import json
import os
import re
import sys
# Excludes both directory names (pruned during walk) and individual filenames
# (e.g. CHANGELOG.md) — membership is checked against dirnames AND filenames.
EXCLUDED_NAMES = {
".git", ".codex", ".gemini", ".hermes", ".vibe", "node_modules",
"docs", # generated mirror; fix the source instead
"audit", # audit records quote stale IDs on purpose, that is their job
"CHANGELOG.md",
}
SCAN_EXTENSIONS = (".md", ".py", ".json", ".yaml", ".yml", ".sh", ".txt")
# This linter and its allowlist necessarily name retired models; scanning them
# would just require self-referential allowlist entries.
SELF_FILES = {"check_model_freshness.py", "check_model_freshness_allowlist.txt"}
# Retired or superseded identifiers, as (regex, label) pairs. Word-ish bounded
# so `claude-3` does not match `claude-3x-something` unintentionally.
RETIRED_PATTERNS = [
(r"\bclaude-instant\b", "claude-instant (retired)"),
(r"\bclaude-2(?:\.\d+)?\b", "Claude 2 family (retired)"),
(r"\bclaude-3(?:[.-]\d+)?(?:-(?:opus|sonnet|haiku))?(?:-\d{8})?\b",
"Claude 3 family (retired)"),
(r"\bClaude\s+3(?:\.\d+)?\s+(?:Opus|Sonnet|Haiku)\b", "Claude 3 family (retired)"),
# Haiku 4.5 (claude-haiku-4-5-20251001) is still current, so it is
# excluded from the Claude 4 sweep rather than allowlisted per-file.
(r"\bclaude-(?:opus|sonnet)-4(?:[.-]\d+)?(?:-\d{8})?\b",
"Claude 4 family (superseded by Claude 5)"),
(r"\bclaude-haiku-4(?!\W*5)(?:[.-]\d+)?(?:-\d{8})?\b",
"Claude 4 family (superseded by Claude 5)"),
(r"\bClaude\s+(?:Opus|Sonnet)\s+4(?:\.\d+)?\b",
"Claude 4 family (superseded by Claude 5)"),
(r"\bClaude\s+Haiku\s+4(?!\.5)(?:\.\d+)?\b",
"Claude 4 family (superseded by Claude 5)"),
(r"\banthropic/claude-[a-z]+-4[.-]\d+\b", "Claude 4 family (superseded by Claude 5)"),
(r"\bgpt-3\.5(?:-turbo)?\b", "gpt-3.5 (retired)"),
# Longer variants first: alternation is ordered, so `o-mini` must precede `o`
# or `gpt-4o-mini` reports as `gpt-4o`.
(r"\bgpt-4(?:-32k|o-mini|o)?\b", "gpt-4 family (superseded)"),
(r"\bGPT-4(?:o|-32k)?\b", "gpt-4 family (superseded)"),
(r"\btext-embedding-ada-002\b", "ada-002 embeddings (superseded)"),
(r"\bgemini-1\.5(?:-[a-z]+)?\b", "Gemini 1.5 (superseded)"),
]
# Signals that a line is quoting a source or explicitly labelling age.
CITATION_HINTS = (
"paper", "technical report", "model card", "as of", "historical",
"snapshot", "deprecated", "retired", "superseded", "arxiv", "et al",
"changelog", "was ", "formerly", "legacy", "pre-", "no longer",
)
YEAR_RE = re.compile(r"\b(19|20)\d{2}\b")
# Lines that are much more likely to be a live default than prose.
EXECUTABLE_HINTS = (
"model=", "model =", '"model"', "'model'", "model:", "--model",
"-m ", "default=", "MODEL", "input\":", "output\":",
)
def load_allowlist(repo_root):
"""<file-glob> :: <substring> — one per line, '#' comments."""
path = os.path.join(repo_root, "scripts", "check_model_freshness_allowlist.txt")
entries = []
if not os.path.exists(path):
return entries
with open(path, encoding="utf-8") as handle:
for line in handle:
line = line.strip()
if not line or line.startswith("#") or "::" not in line:
continue
file_glob, needle = (part.strip() for part in line.split("::", 1))
entries.append((file_glob, needle))
return entries
def allowlisted(rel_file, line_text, allowlist):
posix = rel_file.replace(os.sep, "/")
return any(fnmatch.fnmatch(posix, glob) and needle in line_text
for glob, needle in allowlist)
def is_citation(line_text):
lowered = line_text.lower()
if YEAR_RE.search(line_text):
return True
return any(hint in lowered for hint in CITATION_HINTS)
def looks_executable(line_text):
return any(hint in line_text for hint in EXECUTABLE_HINTS)
def scan_file(path, repo_root, allowlist):
rel = os.path.relpath(path, repo_root)
findings = []
try:
text = open(path, encoding="utf-8", errors="replace").read()
except OSError:
return findings
for lineno, line in enumerate(text.splitlines(), 1):
if allowlisted(rel, line, allowlist):
continue
for pattern, label in RETIRED_PATTERNS:
match = re.search(pattern, line)
if not match:
continue
# An executable default outweighs a citation hint on the same line:
# `model: str = "claude-3-opus" # 2024 default` still breaks.
if is_citation(line) and not looks_executable(line):
continue
findings.append({
"line": lineno,
"match": match.group(0),
"label": label,
"executable": looks_executable(line),
"text": line.strip()[:160],
})
break
return findings
def collect(repo_root):
targets = []
for dirpath, dirnames, filenames in os.walk(repo_root):
dirnames[:] = [d for d in dirnames if d not in EXCLUDED_NAMES]
for fn in filenames:
if fn in EXCLUDED_NAMES or fn in SELF_FILES:
continue
if fn.endswith(SCAN_EXTENSIONS):
targets.append(os.path.join(dirpath, fn))
return sorted(targets)
def main():
ap = argparse.ArgumentParser(
description="Flag retired model identifiers presented as current."
)
ap.add_argument("files", nargs="*", help="Specific files to scan")
ap.add_argument("--all", action="store_true", help="Scan the canonical tree")
ap.add_argument("--json", action="store_true", help="Emit JSON")
ap.add_argument("--list-patterns", action="store_true",
help="Print the retired-identifier deny-list and exit")
ap.add_argument("--executable-only", action="store_true",
help="Report only script defaults, config values and CLI examples")
ap.add_argument("--root", default=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
help="Repo root (default: parent of this script)")
args = ap.parse_args()
if args.list_patterns:
for pattern, label in RETIRED_PATTERNS:
print(f"{label}\n {pattern}")
return 0
repo_root = os.path.abspath(args.root)
if args.all:
targets = collect(repo_root)
elif args.files:
targets = [os.path.abspath(f) for f in args.files]
else:
ap.print_help()
return 0
allowlist = load_allowlist(repo_root)
findings = {}
for path in targets:
hits = scan_file(path, repo_root, allowlist)
if args.executable_only:
hits = [h for h in hits if h["executable"]]
if hits:
findings[os.path.relpath(path, repo_root)] = hits
total = sum(len(v) for v in findings.values())
n_exec = sum(1 for v in findings.values() for h in v if h["executable"])
if args.json:
print(json.dumps({
"files_scanned": len(targets),
"total_findings": total,
"executable_findings": n_exec,
"findings": findings,
}, indent=2))
else:
for rel in sorted(findings):
print(f"{rel}:")
for hit in findings[rel]:
tag = "EXEC" if hit["executable"] else "PROSE"
print(f" {tag} L{hit['line']}: {hit['match']}{hit['label']}")
print(f" {hit['text']}")
print(f"\nScanned {len(targets)} files; {total} retired-identifier references "
f"({n_exec} in executable positions).")
if total:
print("Fix the reference, or add an allowlist entry with a reason to "
"scripts/check_model_freshness_allowlist.txt")
return 1 if total else 0
if __name__ == "__main__":
sys.exit(main())