hermes-agent/scripts/lints/uv_stale_exclude.py

427 lines
15 KiB
Python

"""``= false`` and stale entries in uv's ``exclude-newer-package``.
``[tool.uv] exclude-newer = "N days"`` age-gates dependency resolution
(supply-chain quarantine, the Python twin of npm's min-release-age).
``exclude-newer-package`` carves per-package exceptions — but a
``pkg = false`` entry disables the gate for that package FOREVER, an
unbounded hole. The uv-documented form is an explicit timestamp
(``pkg = "2026-08-04T00:00:00Z"``): it admits the one release that
motivated the exception and nothing newer.
Two findings, both autofixable:
- ``pkg = false`` -> replace with the locked version's newest
``upload-time`` (from uv.lock) + 1 day, so the
exception admits exactly the release it was
added for.
- stale entry -> remove, when every locked version of the
package is already older than the global
``exclude-newer`` span (+1 day boundary
grace) — the gate no longer needs the
exception. Entries matching no locked package
are stale too.
No network: publish dates come from uv.lock's ``upload-time`` fields.
Versions with no recorded upload-time fail open (count as young).
The fixer edits BOTH files — the ``exclude-newer-package`` table in
pyproject.toml (inline ``{ ... }`` or ``[tool.uv.exclude-newer-package]``
section form) and the mirrored ``[options.exclude-newer-package]``
section in uv.lock (so ``uv lock --check`` stays green without
re-resolution; the admitted locked versions are unchanged by
construction). Reading is plain tomllib; writing is line surgery
(tomllib has no writer) so comments and formatting survive. Before
writing, a structural verifier parses old and new with tomllib and
refuses the write unless the ONLY semantic difference in either file
is the exclude-newer-package data.
Opt-out: ``# lint: keep`` in the comment block directly above the
``exclude-newer-package`` line (or section header) exempts the table.
"""
from __future__ import annotations
import re
import tomllib
from datetime import datetime, timedelta, timezone
from pathlib import Path
from lints import REPO_ROOT, Finding, Lint
KEEP_MARKER = re.compile(r"#\s*lint:\s*keep\b", re.IGNORECASE)
GRACE = timedelta(days=1)
_INLINE_RE = re.compile(
r"^(?P<prefix>\s*exclude-newer-package\s*=\s*\{)(?P<body>.*)(?P<suffix>\}\s*)$"
)
_SPAN_RE = re.compile(r"^(?P<days>\d+)\s*days?$")
# One `key = value` line inside a [..exclude-newer-package] section
# (uv.lock's options section or pyproject's table form). Quoted keys OK.
_SECTION_ENTRY_RE = re.compile(
r'^(?P<key>[A-Za-z0-9._-]+|"[^"]+")\s*=\s*(?P<val>false|"[^"]*")\s*$'
)
_LOCK_SECTION_HEADER = "[options.exclude-newer-package]"
_PYPROJECT_SECTION_HEADER = "[tool.uv.exclude-newer-package]"
def _normalize(name: str) -> str:
"""PEP 503 normalization, matching uv.lock's package names."""
return re.sub(r"[-_.]+", "-", name.strip("\"'")).lower()
def _parse_iso(stamp: str) -> datetime | None:
try:
parsed = datetime.fromisoformat(stamp.replace("Z", "+00:00"))
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
def parse_span(pyproject_text: str) -> timedelta | None:
"""The global exclude-newer span, from the ``"N days"`` form."""
uv = tomllib.loads(pyproject_text).get("tool", {}).get("uv", {})
raw = uv.get("exclude-newer")
if not isinstance(raw, str):
return None
m = _SPAN_RE.match(raw.strip())
return timedelta(days=int(m.group("days"))) if m else None
def _lock_info(lock_text: str) -> tuple[dict[str, int], dict[str, list[datetime]]]:
"""Per normalized name: locked-version count, and the newest known
upload-time of each version that has one.
A version whose artifacts carry no parseable upload-time is counted
but contributes no stamp — the staleness check requires a known
stamp per locked version, so unknown age fails open (young).
"""
data = tomllib.loads(lock_text)
counts: dict[str, int] = {}
stamps: dict[str, list[datetime]] = {}
for pkg in data.get("package", []):
name = _normalize(pkg.get("name", ""))
if not name:
continue
counts[name] = counts.get(name, 0) + 1
times: list[datetime] = []
sdist = pkg.get("sdist") or {}
if isinstance(sdist, dict) and (t := _parse_iso(str(sdist.get("upload-time", "")))):
times.append(t)
for wheel in pkg.get("wheels", []) or []:
if isinstance(wheel, dict) and (t := _parse_iso(str(wheel.get("upload-time", "")))):
times.append(t)
if times:
stamps.setdefault(name, []).append(max(times))
return counts, stamps
def _keep_marker_above(lines: list[str], idx: int) -> bool:
"""True when the contiguous comment block directly above ``idx``
carries ``# lint: keep``."""
j = idx - 1
while j >= 0 and lines[j].lstrip().startswith("#"):
if KEEP_MARKER.search(lines[j]):
return True
j -= 1
return False
def locate_table(pyproject_text: str) -> tuple[str | None, int, bool]:
"""Find the exclude-newer-package table in either TOML form.
Returns ``(form, line_idx, keep)`` where form is ``"inline"`` (a
one-line ``exclude-newer-package = { ... }``), ``"section"`` (a
``[tool.uv.exclude-newer-package]`` header), or None when absent.
"""
lines = pyproject_text.splitlines()
for i, line in enumerate(lines):
if _INLINE_RE.match(line):
return "inline", i, _keep_marker_above(lines, i)
if line.strip() == _PYPROJECT_SECTION_HEADER:
return "section", i, _keep_marker_above(lines, i)
return None, -1, False
def table_entries(pyproject_text: str) -> dict[str, object]:
"""The exclude-newer-package entries, read as plain TOML —
form-independent (tomllib parses both shapes identically)."""
uv = tomllib.loads(pyproject_text).get("tool", {}).get("uv", {})
table = uv.get("exclude-newer-package", {})
return table if isinstance(table, dict) else {}
def plan(
pyproject_text: str, lock_text: str, now: datetime | None = None
) -> list[tuple[str, str, str, str | None]]:
"""Compute actions as ``(key, action, reason, replacement)`` where
action is ``remove`` or ``replace`` (replacement = new date string)."""
now = now or datetime.now(timezone.utc)
form, _, keep = locate_table(pyproject_text)
if form is None or keep:
return []
span = parse_span(pyproject_text)
counts, stamps = _lock_info(lock_text)
threshold = (now - span - GRACE) if span else None
actions: list[tuple[str, str, str, str | None]] = []
for key, value in table_entries(pyproject_text).items():
norm = _normalize(key)
n_locked = counts.get(norm, 0)
known = stamps.get(norm, [])
if n_locked == 0:
actions.append(
(key, "remove", "matches no package in uv.lock — nothing to exempt", None)
)
continue
# Stale: every locked version has a KNOWN upload-time older than
# the threshold. Any unknown-age version fails open (young).
if (
threshold is not None
and len(known) == n_locked
and all(t < threshold for t in known)
):
actions.append(
(
key,
"remove",
"every locked version is older than the exclude-newer "
"span — the age gate no longer needs this exception",
None,
)
)
continue
if value is False:
if not known:
continue # no dated artifacts to anchor a stamp — fail open
stamp = (max(known) + timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%SZ")
actions.append(
(
key,
"replace",
"`= false` disables the age gate for this package "
"indefinitely — pin an explicit timestamp instead",
stamp,
)
)
return actions
def _apply_to_section(
text: str, header: str, actions: list[tuple[str, str, str, str | None]]
) -> str:
"""Edit one ``key = value`` per-line section (uv.lock's options
section or pyproject's table form). Drops the header, and the
comment block above it, when the section empties."""
lines = text.splitlines()
header_idx = next(
(i for i, line in enumerate(lines) if line.strip() == header), None
)
if header_idx is None:
return text
end = header_idx + 1
while end < len(lines) and not lines[end].strip().startswith("["):
end += 1
by_norm = {
_normalize(key): (action, replacement)
for key, action, _, replacement in actions
}
section: list[str] = []
for line in lines[header_idx + 1 : end]:
lm = _SECTION_ENTRY_RE.match(line.strip())
if not lm:
section.append(line)
continue
planned = by_norm.get(_normalize(lm.group("key")))
if planned is None:
section.append(line)
elif planned[0] == "replace":
section.append(f'{lm.group("key")} = "{planned[1]}"')
# remove: drop the line
if any(_SECTION_ENTRY_RE.match(line.strip()) for line in section):
new_lines = lines[: header_idx + 1] + section + lines[end:]
else:
# Section emptied — drop the header and its comment block, then
# collapse any doubled blank left behind.
start = header_idx
while start - 1 >= 0 and lines[start - 1].lstrip().startswith("#"):
start -= 1
new_lines = lines[:start] + lines[end:]
collapsed: list[str] = []
for line in new_lines:
if line.strip() == "" and collapsed and collapsed[-1].strip() == "":
continue
collapsed.append(line)
new_lines = collapsed
result = "\n".join(new_lines)
if text.endswith("\n") and not result.endswith("\n"):
result += "\n"
return result
def apply_to_pyproject(text: str, actions: list[tuple[str, str, str, str | None]]) -> str:
form, line_idx, _ = locate_table(text)
if form is None:
return text
if form == "section":
return _apply_to_section(text, _PYPROJECT_SECTION_HEADER, actions)
m = _INLINE_RE.match(text.splitlines()[line_idx])
assert m is not None # locate_table just matched it
body = m.group("body")
removed_all = False
for key, action, _, replacement in actions:
# (?<![\w.-]) anchors the key's left edge: without it, removing `h2`
# eats the tail of `python-h2` and emits `{ python- }`, unparseable
# TOML. Bare \b is not enough — it does not fire between `-` and `h`.
key_re = rf"(?<![\w.-]){re.escape(key)}\s*=\s*"
if action == "replace":
body = re.sub(
rf"({key_re})false",
lambda mm: f'{mm.group(1)}"{replacement}"',
body,
)
else:
body = re.sub(rf"\s*{key_re}(?:false|\"[^\"]*\")\s*,?", "", body)
body = body.strip().strip(",").strip()
lines = text.splitlines()
if body:
lines[line_idx] = f"{m.group('prefix').rstrip('{')}{{ {body} }}".replace("{ {", "{")
lines[line_idx] = re.sub(r",\s*}", " }", lines[line_idx])
else:
removed_all = True
del lines[line_idx]
# Drop the contiguous comment block that described the (now
# empty) table, npmrc-style.
while line_idx - 1 >= 0 and lines[line_idx - 1].lstrip().startswith("#"):
del lines[line_idx - 1]
line_idx -= 1
out: list[str] = []
for line in lines:
if removed_all and line.strip() == "" and out and out[-1].strip() == "":
continue
out.append(line)
result = "\n".join(out)
if text.endswith("\n") and not result.endswith("\n"):
result += "\n"
return result
def apply_to_lock(text: str, actions: list[tuple[str, str, str, str | None]]) -> str:
return _apply_to_section(text, _LOCK_SECTION_HEADER, actions)
def _strip_age_data(parsed: dict, path: tuple[str, ...]) -> dict:
node = parsed
for part in path[:-1]:
node = node.get(part, {})
if not isinstance(node, dict):
return parsed
if isinstance(node, dict):
node.pop(path[-1], None)
return parsed
def verify_only_age_changed(old: str, new: str, path: tuple[str, ...], label: str) -> None:
"""Parse old and new; everything except the exclude-newer-package
data must be structurally identical, or the write is refused."""
old_parsed = _strip_age_data(tomllib.loads(old), path)
new_parsed = _strip_age_data(tomllib.loads(new), path)
if old_parsed != new_parsed:
raise RuntimeError(
f"uv-stale-exclude: fix to {label} changed data outside "
"exclude-newer-package — refusing to write"
)
def _pyproject_path() -> Path:
return REPO_ROOT / "pyproject.toml"
def _uvlock_path() -> Path:
return REPO_ROOT / "uv.lock"
def _current_plan() -> list[tuple[str, str, str, str | None]]:
pyproject = _pyproject_path()
lock = _uvlock_path()
if not pyproject.exists() or not lock.exists():
return []
return plan(
pyproject.read_text(encoding="utf-8"), lock.read_text(encoding="utf-8")
)
def check() -> list[Finding]:
findings = []
text = _pyproject_path().read_text(encoding="utf-8") if _pyproject_path().exists() else ""
line_idx = None
if text:
form, idx, _ = locate_table(text)
line_idx = idx + 1 if form else None
for key, action, reason, replacement in _current_plan():
detail = f"replace with `\"{replacement}\"`" if action == "replace" else "remove it"
findings.append(
Finding(
lint_id="uv-stale-exclude",
path="pyproject.toml",
line=line_idx,
message=(
f"exclude-newer-package entry `{key}`: {reason} — {detail}. "
"Add `# lint: keep` above the table if intentional."
),
fixable=True,
)
)
return findings
def fix() -> list[str]:
actions = _current_plan()
if not actions:
return []
changed: list[str] = []
pyproject = _pyproject_path()
old_py = pyproject.read_text(encoding="utf-8")
new_py = apply_to_pyproject(old_py, actions)
if new_py != old_py:
verify_only_age_changed(
old_py, new_py, ("tool", "uv", "exclude-newer-package"), "pyproject.toml"
)
pyproject.write_text(new_py, encoding="utf-8")
changed.append("pyproject.toml")
lock = _uvlock_path()
old_lock = lock.read_text(encoding="utf-8")
new_lock = apply_to_lock(old_lock, actions)
if new_lock != old_lock:
verify_only_age_changed(
old_lock, new_lock, ("options", "exclude-newer-package"), "uv.lock"
)
lock.write_text(new_lock, encoding="utf-8")
changed.append("uv.lock")
return changed
LINT = Lint(
id="uv-stale-exclude",
description=(
"exclude-newer-package entries must use explicit timestamps, never "
"`= false`, and go away once the locked versions outgrow the span."
),
severity="blocking",
autofix=True,
check=check,
fix=fix,
network=False,
fix_touches=("pyproject.toml", "uv.lock"),
)