|
4 | 4 | - Local version info reading |
5 | 5 | - Remote version check (lightweight, fail-open) |
6 | 6 | - Rule content hash verification |
7 | | -- Rule staleness warning based on age since last verification |
| 7 | +- Rule staleness check against ndcourts.gov (cached, 90-day refresh) |
8 | 8 | """ |
9 | 9 |
|
10 | 10 | from __future__ import annotations |
11 | 11 |
|
12 | 12 | import hashlib |
13 | 13 | import json |
| 14 | +import re |
| 15 | +import sys |
14 | 16 | from datetime import date, datetime |
15 | 17 | from pathlib import Path |
16 | 18 | from typing import Optional |
| 19 | +from urllib.request import Request, urlopen |
17 | 20 |
|
18 | 21 | PROJECT_DIR = Path(__file__).resolve().parent.parent |
19 | 22 | VERSION_FILE = PROJECT_DIR / "version.json" |
20 | 23 | RULES_DIR = PROJECT_DIR / "references" / "rules" |
| 24 | +STALENESS_CACHE = Path.home() / ".cache" / "jetbriefcheck" / "rule_staleness.json" |
| 25 | +STALENESS_MAX_AGE_DAYS = 90 |
| 26 | + |
| 27 | +# Map rule file stems to ndcourts.gov URLs |
| 28 | +RULE_URLS = { |
| 29 | + "rule-14": "https://www.ndcourts.gov/legal-resources/rules/ndrappp/14", |
| 30 | + "rule-21": "https://www.ndcourts.gov/legal-resources/rules/ndrappp/21", |
| 31 | + "rule-28": "https://www.ndcourts.gov/legal-resources/rules/ndrappp/28", |
| 32 | + "rule-29": "https://www.ndcourts.gov/legal-resources/rules/ndrappp/29", |
| 33 | + "rule-30": "https://www.ndcourts.gov/legal-resources/rules/ndrappp/30", |
| 34 | + "rule-32": "https://www.ndcourts.gov/legal-resources/rules/ndrappp/32", |
| 35 | + "rule-34": "https://www.ndcourts.gov/legal-resources/rules/ndrappp/34", |
| 36 | + "rule-3.4": "https://www.ndcourts.gov/legal-resources/rules/ndrct/3-4", |
| 37 | + "rule-11.6": "https://www.ndcourts.gov/legal-resources/rules/ndrct/11-6", |
| 38 | +} |
| 39 | + |
| 40 | +# Effective dates at the time rules were last bundled (update when rules are refreshed) |
| 41 | +BUNDLED_EFFECTIVE_DATES = { |
| 42 | + "rule-14": "2020-03-01", |
| 43 | + "rule-21": "2022-03-01", |
| 44 | + "rule-28": "2025-06-01", |
| 45 | + "rule-29": "2022-03-01", |
| 46 | + "rule-30": "2023-01-25", |
| 47 | + "rule-32": "2024-04-01", |
| 48 | + "rule-34": "2025-09-01", |
| 49 | + "rule-3.4": "2025-03-01", |
| 50 | + "rule-11.6": "2025-03-01", |
| 51 | +} |
| 52 | + |
| 53 | +EFFECTIVE_DATE_RE = re.compile(r"Effective\s+Date[:\s]+(\d{1,2}/\d{1,2}/\d{4})") |
21 | 54 |
|
22 | 55 |
|
23 | 56 | def load_local_version() -> dict: |
@@ -68,30 +101,98 @@ def check_rule_hashes(local_version: dict) -> list[str]: |
68 | 101 | return warnings |
69 | 102 |
|
70 | 103 |
|
71 | | -def check_rule_staleness(local_version: dict) -> Optional[str]: |
72 | | - """Check if rules are older than the configured freshness threshold. |
| 104 | +def _load_staleness_cache() -> dict: |
| 105 | + """Load the cached staleness result. Returns empty dict if missing/corrupt.""" |
| 106 | + try: |
| 107 | + return json.loads(STALENESS_CACHE.read_text(encoding="utf-8")) |
| 108 | + except (FileNotFoundError, json.JSONDecodeError, OSError): |
| 109 | + return {} |
73 | 110 |
|
74 | | - Returns a warning string if stale, None otherwise. |
75 | | - """ |
76 | | - verified_str = local_version.get("rules_verified") |
77 | | - if not verified_str: |
| 111 | + |
| 112 | +def _save_staleness_cache(data: dict) -> None: |
| 113 | + """Write staleness cache, creating parent dirs as needed.""" |
| 114 | + try: |
| 115 | + STALENESS_CACHE.parent.mkdir(parents=True, exist_ok=True) |
| 116 | + STALENESS_CACHE.write_text(json.dumps(data, indent=2), encoding="utf-8") |
| 117 | + except OSError: |
| 118 | + pass # fail silently — cache is advisory |
| 119 | + |
| 120 | + |
| 121 | +def _fetch_effective_date(url: str, timeout: float = 10.0) -> Optional[str]: |
| 122 | + """Fetch a rule page on ndcourts.gov and extract the effective date as YYYY-MM-DD.""" |
| 123 | + try: |
| 124 | + req = Request(url, headers={"User-Agent": "jetbriefcheck-freshness-check"}) |
| 125 | + with urlopen(req, timeout=timeout) as resp: |
| 126 | + html = resp.read().decode("utf-8", errors="replace") |
| 127 | + except Exception: |
| 128 | + return None |
| 129 | + |
| 130 | + match = EFFECTIVE_DATE_RE.search(html) |
| 131 | + if not match: |
78 | 132 | return None |
79 | 133 |
|
80 | 134 | try: |
81 | | - verified_date = datetime.strptime(verified_str, "%Y-%m-%d").date() |
| 135 | + dt = datetime.strptime(match.group(1), "%m/%d/%Y") |
| 136 | + return dt.strftime("%Y-%m-%d") |
82 | 137 | except ValueError: |
83 | 138 | return None |
84 | 139 |
|
85 | | - max_days = local_version.get("rules_freshness_days", 90) |
86 | | - age_days = (date.today() - verified_date).days |
87 | 140 |
|
88 | | - if age_days > max_days: |
89 | | - return ( |
90 | | - f"Bundled rules were last verified {age_days} days ago " |
91 | | - f"({verified_str}). Consider checking ndcourts.gov for amendments " |
92 | | - f"to Rules 28, 29, 30, 32, 34, and 3.4." |
93 | | - ) |
94 | | - return None |
| 141 | +def _check_rules_live() -> list[str]: |
| 142 | + """Fetch effective dates from ndcourts.gov and compare against bundled dates. |
| 143 | +
|
| 144 | + Returns a list of warning strings for any stale rules. Saves results to cache. |
| 145 | + """ |
| 146 | + stale = [] |
| 147 | + live_dates = {} |
| 148 | + |
| 149 | + for rule, url in RULE_URLS.items(): |
| 150 | + bundled = BUNDLED_EFFECTIVE_DATES.get(rule) |
| 151 | + if not bundled: |
| 152 | + continue |
| 153 | + live = _fetch_effective_date(url) |
| 154 | + if live: |
| 155 | + live_dates[rule] = live |
| 156 | + if live != bundled: |
| 157 | + stale.append( |
| 158 | + f"Rule {rule} may be outdated: bundled effective date " |
| 159 | + f"{bundled}, ndcourts.gov shows {live}. " |
| 160 | + f"Check {url}" |
| 161 | + ) |
| 162 | + |
| 163 | + _save_staleness_cache({ |
| 164 | + "last_checked": date.today().isoformat(), |
| 165 | + "live_dates": live_dates, |
| 166 | + "stale_rules": [s.split(":")[0].replace("Rule ", "") for s in stale], |
| 167 | + "warnings": stale, |
| 168 | + }) |
| 169 | + |
| 170 | + return stale |
| 171 | + |
| 172 | + |
| 173 | +def check_rule_staleness(local_version: dict) -> list[str]: |
| 174 | + """Check bundled rules against ndcourts.gov effective dates (cached). |
| 175 | +
|
| 176 | + Uses a local cache at ~/.cache/jetbriefcheck/rule_staleness.json. |
| 177 | + Re-checks live every 90 days. Returns a list of warning strings. |
| 178 | + """ |
| 179 | + cache = _load_staleness_cache() |
| 180 | + last_checked_str = cache.get("last_checked") |
| 181 | + |
| 182 | + if last_checked_str: |
| 183 | + try: |
| 184 | + last_checked = datetime.strptime(last_checked_str, "%Y-%m-%d").date() |
| 185 | + age = (date.today() - last_checked).days |
| 186 | + if age < STALENESS_MAX_AGE_DAYS: |
| 187 | + return cache.get("warnings", []) |
| 188 | + except ValueError: |
| 189 | + pass |
| 190 | + |
| 191 | + # Cache is missing, expired, or corrupt — check live (fail-open) |
| 192 | + try: |
| 193 | + return _check_rules_live() |
| 194 | + except Exception: |
| 195 | + return [] |
95 | 196 |
|
96 | 197 |
|
97 | 198 | def fetch_remote_version(timeout: float = 2.0) -> Optional[dict]: |
@@ -162,10 +263,8 @@ def get_version_warnings(check_remote: bool = True, timeout: float = 2.0) -> lis |
162 | 263 | # Rule hash integrity check |
163 | 264 | warnings.extend(check_rule_hashes(local)) |
164 | 265 |
|
165 | | - # Rule staleness check |
166 | | - staleness = check_rule_staleness(local) |
167 | | - if staleness: |
168 | | - warnings.append(staleness) |
| 266 | + # Rule staleness check (cached, checks ndcourts.gov every 90 days) |
| 267 | + warnings.extend(check_rule_staleness(local)) |
169 | 268 |
|
170 | 269 | # Remote version check (fail-open) |
171 | 270 | if check_remote: |
|
0 commit comments