utils/dep_freshness/report.py
Krzysztof kuhy Rudnicki 2a2f18ca56 Add the shared dependency-freshness gate
"Are we on the newest version?" was not mechanically answerable across these
repos: constraint styles had drifted per-ecosystem and a caret range is
"newest-compatible" by definition, which gives a checker nothing to compare.
This gate makes it answerable and then enforces the answer.

Checks two things at once -- that every dependency carries an exact pin, and
that the pin equals its registry's latest STABLE release -- across pub.dev,
PyPI, npm, crates.io, the Go proxy, kuhyx/utils git tags, and the Flutter /
Node / Python toolchains.

Three deliberate design points:

* Pre-releases are rejected per-ecosystem, because every ecosystem leaks them
  differently and each leak fails GREEN. npm lets a maintainer point
  `dist-tags.latest` at an rc; crates.io's `max_version` includes them; PyPI's
  legacy `info.version` includes yanked releases. Unit-tested with fixtures,
  since an end-to-end run cannot surface a wrong "up to date".

* Offline, the gate serves an expired cache entry and PASSES with a DEGRADED
  banner (exit 3 only under --strict, which is CI). `--no-verify` is banned
  here, so a hook that hard-failed offline would leave no way to commit at all.

* The allowlist has two entry classes. `blocked_by: transitive:<pkg>@<ver>` is
  a predicate that clears itself when upstream moves and must NOT carry a
  date; everything else is discretionary and requires `expires`, capped at 90
  days. Exact pins plus latest-everywhere is provably unsatisfiable when a
  dependency internally constrains a shared transitive, and expiring the
  predicate class on a calendar would turn the gate red for a reason no commit
  could fix. An entry with nothing left to excuse is itself an error.

One hook id, one shim, one shared implementation -- always delegating, so it
cannot fork the way the 250-line cap forked into four ids, two of which were
independent reimplementations.

256 tests, 100% branch coverage on every module of the gate. Also adds the
workflow that runs them, which incidentally gives md_naming's 71 tests their
first CI run.

Not yet installed in any repo: a repo that gets the gate before its deps are
bumped is instantly and permanently red, so the gate and the upgrade land
together, per repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
2026-08-28 13:00:35 +02:00

162 lines
5.5 KiB
Python

"""Human and machine output. No decisions are made here.
The loud exception block prints on success *as well as* failure (Q17): an
exception the user stops seeing is a suppression, and the whole point of the
allowlist is that every not-latest pin stays visible.
"""
from __future__ import annotations
from datetime import date, datetime
import json
from pathlib import Path
import sys
from dep_freshness.models import Exception_, Finding, Severity
def _err(stream):
"""Resolve stderr at call time.
A `stream=sys.stderr` default binds the stream at import, which silently
bypasses anything that replaces it later -- pytest's capture, and any
caller redirecting output.
"""
return sys.stderr if stream is None else stream
_HEADLINE = {
Severity.STALE: "behind latest stable",
Severity.UNPINNED: "not exact-pinned",
Severity.LOCK_MISMATCH: "manifest and lockfile disagree",
Severity.OVERRIDE: "dependency override",
Severity.UNKNOWN: "could not be determined",
}
def _where(finding: Finding, root: Path) -> str:
try:
rel = finding.dep.path.relative_to(root)
except ValueError:
rel = finding.dep.path
return f"{rel}:{finding.dep.line}" if finding.dep.line else str(rel)
def _days_left(expires: str) -> int | None:
try:
return (datetime.strptime(expires, "%Y-%m-%d").date() - date.today()).days
except ValueError:
return None
def exceptions_block(
entries: list[Exception_], still_blocking: dict[str, bool], stream=None
) -> None:
"""The always-printed banner naming every exception currently in force."""
if not entries:
return
stream = _err(stream)
print(f"\n⚠️ DEPENDENCY EXCEPTION IN USE — {len(entries)} active", file=stream)
for entry in entries:
print(f" {entry.package} {entry.pinned} ({entry.ecosystem})", file=stream)
if entry.transitive:
state = "still blocking" if still_blocking.get(
f"{entry.ecosystem}:{entry.package}", True) else "CLEARED"
print(f" blocked_by: {entry.blocked_by} [{state}]", file=stream)
else:
left = _days_left(entry.expires or "")
suffix = f"expires in {left} days" if left is not None else "expires"
print(f" blocked_by: {entry.blocked_by} {suffix}", file=stream)
print(f" reason: {entry.reason}", file=stream)
def machine_lines(entries: list[Exception_]) -> list[str]:
"""Plain-ASCII `[DEP-EXCEPTION]` lines for the SessionStart hook and CI logs."""
out = []
for entry in entries:
head = (f"[DEP-EXCEPTION] {entry.ecosystem}:{entry.package} {entry.pinned}")
if entry.latest_known:
head += f" < {entry.latest_known}"
if entry.transitive:
out.append(f"{head} blocked_by={entry.blocked_by} [still blocking]")
else:
left = _days_left(entry.expires or "")
days = f" ({left}d)" if left is not None else ""
out.append(f"{head} expires {entry.expires}{days}")
return out
def violations(findings: list[Finding], root: Path, stream=None) -> None:
"""The failure body: every finding, grouped by why it failed."""
stream = _err(stream)
print(
f"\nDependency-freshness gate FAILED: {len(findings)} finding(s)\n",
file=stream,
)
for severity in Severity:
group = [f for f in findings if f.severity is severity]
if not group:
continue
print(f" {_HEADLINE[severity]}:", file=stream)
for finding in sorted(group, key=lambda f: (str(f.dep.path), f.dep.name)):
current = finding.dep.pinned or finding.dep.constraint
latest = f" -> {finding.latest}" if finding.latest else ""
print(
f" {finding.dep.ecosystem}:{finding.dep.name} "
f"{current}{latest} {_where(finding, root)}",
file=stream,
)
if finding.detail:
print(f" {finding.detail}", file=stream)
print("", file=stream)
print(
"Bump them to latest stable, or record an exception in "
"dependency-freshness.allowlist.yaml with reason + blocked_by.",
file=stream,
)
def degraded(reasons: list[str], stream=None) -> None:
stream = _err(stream)
print(
"\nDEGRADED: dependency freshness unverified (offline) — "
f"{len(reasons)} lookup(s) served from cache or skipped.",
file=stream,
)
for reason in reasons[:5]:
print(f" {reason}", file=stream)
def as_json(
findings: list[Finding], entries: list[Exception_], exit_code: int
) -> str:
payload = {
"exit_code": exit_code,
"findings": [
{
"ecosystem": f.dep.ecosystem,
"package": f.dep.name,
"severity": f.severity.value,
"current": f.dep.pinned or f.dep.constraint,
"latest": f.latest,
"path": str(f.dep.path),
"line": f.dep.line,
"detail": f.detail,
"excused": f.excused,
}
for f in findings
],
"exceptions": [
{
"ecosystem": e.ecosystem,
"package": e.package,
"pinned": e.pinned,
"blocked_by": e.blocked_by,
"expires": e.expires,
"reason": e.reason,
"transitive": e.transitive,
}
for e in entries
],
}
return json.dumps(payload, indent=2)