mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-11 13:47:43 +01:00
"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
162 lines
5.5 KiB
Python
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)
|