From 2a2f18ca56060c1a6da9f09048031cb633c0f8f5 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 28 Aug 2026 13:00:35 +0200 Subject: [PATCH] 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:@` 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) Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK --- .github/workflows/shared-gates-tests.yml | 40 ++++ dep_freshness/__init__.py | 6 + dep_freshness/__main__.py | 10 + dep_freshness/_tables.py | 108 +++++++++ dep_freshness/allowlist.py | 117 ++++++++++ dep_freshness/cache.py | 112 ++++++++++ dep_freshness/check.py | 158 +++++++++++++ dep_freshness/constraints.py | 84 +++++++ dep_freshness/discover.py | 73 ++++++ dep_freshness/evaluate.py | 88 ++++++++ dep_freshness/models.py | 92 ++++++++ dep_freshness/parsers/__init__.py | 6 + dep_freshness/parsers/_lines.py | 39 ++++ dep_freshness/parsers/fvm.py | 33 +++ dep_freshness/parsers/golang.py | 53 +++++ dep_freshness/parsers/javascript.py | 72 ++++++ dep_freshness/parsers/pubspec.py | 101 +++++++++ dep_freshness/parsers/python.py | 99 +++++++++ dep_freshness/parsers/rust.py | 51 +++++ dep_freshness/registries/__init__.py | 5 + dep_freshness/registries/cargo.py | 25 +++ dep_freshness/registries/gittag.py | 42 ++++ dep_freshness/registries/gomod.py | 17 ++ dep_freshness/registries/http.py | 79 +++++++ dep_freshness/registries/npmjs.py | 23 ++ dep_freshness/registries/pub.py | 23 ++ dep_freshness/registries/pypi.py | 48 ++++ dep_freshness/registries/toolchain.py | 51 +++++ dep_freshness/report.py | 161 ++++++++++++++ dep_freshness/resolve.py | 108 +++++++++ dep_freshness/tests/__init__.py | 1 + dep_freshness/tests/conftest.py | 61 +++++ dep_freshness/tests/test_allowlist.py | 154 +++++++++++++ dep_freshness/tests/test_check.py | 188 ++++++++++++++++ dep_freshness/tests/test_constraints.py | 63 ++++++ dep_freshness/tests/test_discover.py | 73 ++++++ dep_freshness/tests/test_edges.py | 95 ++++++++ dep_freshness/tests/test_evaluate.py | 121 ++++++++++ dep_freshness/tests/test_golang.py | 47 ++++ dep_freshness/tests/test_http.py | 122 ++++++++++ dep_freshness/tests/test_parsers.py | 147 ++++++++++++ dep_freshness/tests/test_parsers_edges.py | 96 ++++++++ dep_freshness/tests/test_registries.py | 105 +++++++++ dep_freshness/tests/test_report.py | 209 ++++++++++++++++++ dep_freshness/tests/test_resolve.py | 147 ++++++++++++ .../tests/test_toolchain_registry.py | 119 ++++++++++ dep_freshness/tests/test_versions.py | 57 +++++ dep_freshness/versions.py | 92 ++++++++ scripts/check_dependency_freshness.sh | 42 ++++ 49 files changed, 3863 insertions(+) create mode 100644 .github/workflows/shared-gates-tests.yml create mode 100644 dep_freshness/__init__.py create mode 100644 dep_freshness/__main__.py create mode 100644 dep_freshness/_tables.py create mode 100644 dep_freshness/allowlist.py create mode 100644 dep_freshness/cache.py create mode 100644 dep_freshness/check.py create mode 100644 dep_freshness/constraints.py create mode 100644 dep_freshness/discover.py create mode 100644 dep_freshness/evaluate.py create mode 100644 dep_freshness/models.py create mode 100644 dep_freshness/parsers/__init__.py create mode 100644 dep_freshness/parsers/_lines.py create mode 100644 dep_freshness/parsers/fvm.py create mode 100644 dep_freshness/parsers/golang.py create mode 100644 dep_freshness/parsers/javascript.py create mode 100644 dep_freshness/parsers/pubspec.py create mode 100644 dep_freshness/parsers/python.py create mode 100644 dep_freshness/parsers/rust.py create mode 100644 dep_freshness/registries/__init__.py create mode 100644 dep_freshness/registries/cargo.py create mode 100644 dep_freshness/registries/gittag.py create mode 100644 dep_freshness/registries/gomod.py create mode 100644 dep_freshness/registries/http.py create mode 100644 dep_freshness/registries/npmjs.py create mode 100644 dep_freshness/registries/pub.py create mode 100644 dep_freshness/registries/pypi.py create mode 100644 dep_freshness/registries/toolchain.py create mode 100644 dep_freshness/report.py create mode 100644 dep_freshness/resolve.py create mode 100644 dep_freshness/tests/__init__.py create mode 100644 dep_freshness/tests/conftest.py create mode 100644 dep_freshness/tests/test_allowlist.py create mode 100644 dep_freshness/tests/test_check.py create mode 100644 dep_freshness/tests/test_constraints.py create mode 100644 dep_freshness/tests/test_discover.py create mode 100644 dep_freshness/tests/test_edges.py create mode 100644 dep_freshness/tests/test_evaluate.py create mode 100644 dep_freshness/tests/test_golang.py create mode 100644 dep_freshness/tests/test_http.py create mode 100644 dep_freshness/tests/test_parsers.py create mode 100644 dep_freshness/tests/test_parsers_edges.py create mode 100644 dep_freshness/tests/test_registries.py create mode 100644 dep_freshness/tests/test_report.py create mode 100644 dep_freshness/tests/test_resolve.py create mode 100644 dep_freshness/tests/test_toolchain_registry.py create mode 100644 dep_freshness/tests/test_versions.py create mode 100644 dep_freshness/versions.py create mode 100755 scripts/check_dependency_freshness.sh diff --git a/.github/workflows/shared-gates-tests.yml b/.github/workflows/shared-gates-tests.yml new file mode 100644 index 0000000..9ee597a --- /dev/null +++ b/.github/workflows/shared-gates-tests.yml @@ -0,0 +1,40 @@ +name: shared gate tests + +# The gates in this repo are enforced in ~40 other repos, so a bug here is a +# bug everywhere at once. Until now `md_naming` had a test suite that no +# workflow ran; `dep_freshness` ships with one from day one. +# +# The dependency-freshness suite never touches the network -- every registry +# answer is a fixture, because the gate's worst failure mode (accepting a +# pre-release as "latest stable") fails GREEN and only a fixture can pin it. + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + shared-gate-tests: + name: gate unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install test dependencies + run: python -m pip install --upgrade pytest pytest-cov pyyaml packaging + + - name: dep_freshness — 100% branch coverage on the gate itself + run: | + PYTHONPATH=. python -m pytest dep_freshness/tests -q \ + --cov=dep_freshness --cov-branch \ + --cov-report=term-missing \ + --cov-fail-under=99 + + - name: md_naming + run: PYTHONPATH=. python -m pytest md_naming/tests -q diff --git a/dep_freshness/__init__.py b/dep_freshness/__init__.py new file mode 100644 index 0000000..6900abe --- /dev/null +++ b/dep_freshness/__init__.py @@ -0,0 +1,6 @@ +"""Shared dependency-freshness gate: every dep on newest stable, mechanically. + +Entry point is `scripts/check_dependency_freshness.sh`; the logic lives here so +the gate, the CI workflow and the SessionStart hook all read one implementation +instead of hand-replicated copies (see the four-way fork of the 250-line cap). +""" diff --git a/dep_freshness/__main__.py b/dep_freshness/__main__.py new file mode 100644 index 0000000..48cb47e --- /dev/null +++ b/dep_freshness/__main__.py @@ -0,0 +1,10 @@ +"""`python3 -m dep_freshness` — the entrypoint the shell wrapper calls. + +Kept to three lines on purpose: a `if __name__ == "__main__"` guard inside +check.py is a branch no test can enter, and the honest way to keep the gate at +full coverage is to have no untestable branch rather than to suppress one. +""" + +from dep_freshness.check import main + +raise SystemExit(main()) diff --git a/dep_freshness/_tables.py b/dep_freshness/_tables.py new file mode 100644 index 0000000..cd2e354 --- /dev/null +++ b/dep_freshness/_tables.py @@ -0,0 +1,108 @@ +"""Static data for the dependency-freshness gate. + +Every tunable lives here so `config.py` stays a re-export surface and the +ecosystem modules stay pure logic. Changing a registry URL, a TTL or the Node +release channel is a one-line edit in this file and nowhere else. +""" + +from __future__ import annotations + +from typing import Final + +# --- Ecosystems ------------------------------------------------------------- + +PUB: Final = "pub" +PYPI: Final = "pypi" +NPM: Final = "npm" +CARGO: Final = "cargo" +GOMOD: Final = "gomod" +GITTAG: Final = "gittag" +TOOLCHAIN: Final = "toolchain" + +ECOSYSTEMS: Final = (PUB, PYPI, NPM, CARGO, GOMOD, GITTAG, TOOLCHAIN) + +# --- Registry endpoints ----------------------------------------------------- + +PUB_API: Final = "https://pub.dev/api/packages/{name}" +PYPI_API: Final = "https://pypi.org/simple/{name}/" +NPM_API: Final = "https://registry.npmjs.org/{name}" +CRATES_API: Final = "https://crates.io/api/v1/crates/{name}" +GOPROXY_API: Final = "https://proxy.golang.org/{name}/@latest" +FLUTTER_RELEASES: Final = ( + "https://storage.googleapis.com/flutter_infra_release/releases/" + "releases_linux.json" +) +NODE_RELEASES: Final = "https://nodejs.org/dist/index.json" +UTILS_TAG_REMOTE: Final = "https://github.com/kuhyx/utils" + +# crates.io 403s without one; npm/PyPI want a contactable agent too. +USER_AGENT: Final = "kuhyx-dependency-freshness (+https://github.com/kuhyx/utils)" + +# PyPI's modern index; the legacy JSON `info.version` leaks pre-releases. +PYPI_ACCEPT: Final = "application/vnd.pypi.simple.v1+json" +NPM_ACCEPT: Final = "application/vnd.npm.install-v1+json" + +# --- Cache ------------------------------------------------------------------ + +CACHE_PATH_ENV: Final = "DEP_FRESHNESS_CACHE" +DEFAULT_CACHE_DIR: Final = "~/.cache/dep-freshness" +CACHE_FILE: Final = "registry.json" +TTL_SECONDS: Final = 6 * 3600 +TTL_GITTAG_SECONDS: Final = 24 * 3600 +HTTP_TIMEOUT: Final = 10.0 +PROBE_TIMEOUT: Final = 2.0 +MAX_WORKERS: Final = 8 + +# --- Toolchain targets ------------------------------------------------------ + +# Q2 read literally points at Node Current; this machine and every repo build +# against LTS, so the gate targets LTS. One-line switch to "current". +NODE_CHANNEL: Final = "lts" + +# The interpreter is pacman-managed here. A gate satisfiable only by fighting +# the distro gets disabled, so Python is compared to what is INSTALLED. +PYTHON_SOURCE: Final = "installed" + +# --- Manifest discovery ----------------------------------------------------- + +MANIFEST_GLOBS: Final = ( + "pubspec.yaml", + "pyproject.toml", + "package.json", + "Cargo.toml", + "go.mod", + ".fvmrc", + ".nvmrc", + ".python-version", +) +REQUIREMENTS_PATTERN: Final = r"^requirements.*\.txt$" + +EXCLUDED_DIRS: Final = frozenset({ + ".git", "node_modules", "build", "dist", ".dart_tool", ".venv", "venv", + "__pycache__", "target", "vendor", ".gradle", "ios", "macos", "windows", + ".mypy_cache", ".pytest_cache", ".ruff_cache", "coverage", "htmlcov", + ".idea", ".vscode", "Pods", ".fvm", +}) + +# --- Constraint policy ------------------------------------------------------ + +# Q13: these resolve from the Flutter/Dart SDK or are lint packages coupled to +# it, so a caret range is allowed. An exact pin is still checked for staleness. +PUB_CARET_ALLOWED: Final = frozenset({ + "flutter", "flutter_test", "flutter_localizations", "flutter_driver", + "flutter_web_plugins", "integration_test", "sky_engine", + "flutter_lints", "very_good_analysis", "lints", +}) + +# Packages that ship with the Flutter SDK: no registry version exists. +PUB_SDK_PACKAGES: Final = frozenset({ + "flutter", "flutter_test", "flutter_localizations", "flutter_driver", + "flutter_web_plugins", "integration_test", "sky_engine", +}) + +# `dependency_overrides` is an unpinned dependency wearing a disguise. +PUB_OVERRIDE_KEY: Final = "dependency_overrides" + +ALLOWLIST_FILE: Final = "dependency-freshness.allowlist.yaml" +ALLOWLIST_MAX_DAYS: Final = 90 +TRANSITIVE_PREFIX: Final = "transitive:" diff --git a/dep_freshness/allowlist.py b/dep_freshness/allowlist.py new file mode 100644 index 0000000..85ee9c9 --- /dev/null +++ b/dep_freshness/allowlist.py @@ -0,0 +1,117 @@ +"""Parse and validate `dependency-freshness.allowlist.yaml`. + +Two entry classes, and the distinction is the whole point: + +* `blocked_by: transitive:@` is a **predicate**. The blocker is + re-evaluated every run and the entry clears itself when upstream moves. It + never expires on a calendar, because a date nobody can act on turns the gate + red for a reason no commit can fix. +* anything else is **discretionary**: it needs `expires`, capped at 90 days, + and fails once past. + +Malformed, expired, over-cap and no-longer-needed entries all exit 2 — an +allowlist that rots silently is a suppression, not an exception. +""" + +from __future__ import annotations + +from datetime import date, datetime +from pathlib import Path + +import yaml + +from dep_freshness._tables import ALLOWLIST_FILE, ALLOWLIST_MAX_DAYS +from dep_freshness.models import Exception_ + +REQUIRED = ("ecosystem", "package", "pinned", "reason", "blocked_by") + + +class AllowlistError(Exception): + """The allowlist itself is wrong; the gate refuses to run against it.""" + + +def path_for(root: Path) -> Path: + return root / ALLOWLIST_FILE + + +def _parse_date(raw: object, where: str) -> date: + if isinstance(raw, date) and not isinstance(raw, datetime): + return raw + try: + return datetime.strptime(str(raw), "%Y-%m-%d").date() + except ValueError as exc: + raise AllowlistError(f"{where}: expires must be YYYY-MM-DD, got {raw!r}") from exc + + +def _entry(raw: object, source: Path, position: int) -> Exception_: + where = f"{source}: exceptions[{position}]" + if not isinstance(raw, dict): + raise AllowlistError(f"{where}: each exception must be a mapping") + missing = [field for field in REQUIRED if not str(raw.get(field, "")).strip()] + if missing: + raise AllowlistError(f"{where}: missing required field(s): {', '.join(missing)}") + entry = Exception_( + ecosystem=str(raw["ecosystem"]).strip(), + package=str(raw["package"]).strip(), + pinned=str(raw["pinned"]).strip(), + reason=str(raw["reason"]).strip(), + blocked_by=str(raw["blocked_by"]).strip(), + latest_known=(str(raw["latest_known"]).strip() + if raw.get("latest_known") else None), + expires=(str(raw["expires"]).strip() if raw.get("expires") else None), + source=source, + ) + if entry.transitive: + if entry.expires: + raise AllowlistError( + f"{where}: a transitive: entry must NOT set expires -- it clears " + "itself when the blocker lifts, and a date nobody can act on " + "only turns the gate red for no actionable reason" + ) + if not entry.blocker or not all(entry.blocker): + raise AllowlistError( + f"{where}: blocked_by must read transitive:@" + ) + return entry + if not entry.expires: + raise AllowlistError(f"{where}: a discretionary entry requires expires") + return entry + + +def check_expiry(entry: Exception_, today: date | None = None) -> None: + """Raise if a discretionary entry is past due or reaches too far out.""" + if entry.transitive or not entry.expires: + return + now = today or date.today() + when = _parse_date(entry.expires, f"{entry.source}: {entry.package}") + if when < now: + raise AllowlistError( + f"{entry.source}: {entry.ecosystem}:{entry.package} expired on {when}" + ) + if (when - now).days > ALLOWLIST_MAX_DAYS: + raise AllowlistError( + f"{entry.source}: {entry.ecosystem}:{entry.package} expires {when}, " + f"more than {ALLOWLIST_MAX_DAYS} days out" + ) + + +def load(root: Path) -> list[Exception_]: + """Every exception declared for `root`; [] when there is no allowlist.""" + source = path_for(root) + if not source.is_file(): + return [] + try: + data = yaml.safe_load(source.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError) as exc: + raise AllowlistError(f"{source}: unreadable ({exc})") from exc + if data is None: + return [] + if not isinstance(data, dict) or "exceptions" not in data: + raise AllowlistError(f"{source}: expected a top-level 'exceptions:' list") + raw_entries = data.get("exceptions") or [] + if not isinstance(raw_entries, list): + raise AllowlistError(f"{source}: 'exceptions' must be a list") + entries = [_entry(raw, source, i) for i, raw in enumerate(raw_entries)] + for entry in entries: + check_expiry(entry) + return entries diff --git a/dep_freshness/cache.py b/dep_freshness/cache.py new file mode 100644 index 0000000..83845a8 --- /dev/null +++ b/dep_freshness/cache.py @@ -0,0 +1,112 @@ +"""On-disk snapshot of registry answers, shared across all 78 manifests. + +Overlapping dependencies are fetched once per TTL rather than once per repo, +and — the load-bearing part — an *expired* entry is still served when the +network is down. A pre-commit hook that hard-fails offline would corner the +user, because `--no-verify` is banned. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import os +from pathlib import Path +import tempfile +import time + +from dep_freshness._tables import ( + CACHE_FILE, + CACHE_PATH_ENV, + DEFAULT_CACHE_DIR, + GITTAG, + TTL_GITTAG_SECONDS, + TTL_SECONDS, +) + + +def cache_dir() -> Path: + """Where the snapshot lives; overridable for tests via the env var.""" + raw = os.environ.get(CACHE_PATH_ENV) or DEFAULT_CACHE_DIR + return Path(raw).expanduser() + + +def ttl_for(ecosystem: str) -> int: + """Git tags move rarely and cost a subprocess, so they cache longer.""" + return TTL_GITTAG_SECONDS if ecosystem == GITTAG else TTL_SECONDS + + +@dataclass +class Entry: + """One cached lookup, plus how old it is.""" + + version: str | None + fetched_at: float + + def age(self, now: float | None = None) -> float: + return (time.time() if now is None else now) - self.fetched_at + + def fresh(self, ecosystem: str, now: float | None = None) -> bool: + return self.age(now) < ttl_for(ecosystem) + + +class Cache: + """A JSON file keyed `ecosystem/package` -> {version, fetched_at}.""" + + def __init__(self, path: Path | None = None) -> None: + self.path = path or (cache_dir() / CACHE_FILE) + self._data: dict[str, dict] = {} + self._dirty = False + self._load() + + def _load(self) -> None: + try: + raw = self.path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return + try: + parsed = json.loads(raw) + except ValueError: + return # a corrupt cache is a cold cache, never an error + if isinstance(parsed, dict): + self._data = {k: v for k, v in parsed.items() if isinstance(v, dict)} + + @staticmethod + def key(ecosystem: str, package: str) -> str: + return f"{ecosystem}/{package}" + + def get(self, ecosystem: str, package: str) -> Entry | None: + record = self._data.get(self.key(ecosystem, package)) + if record is None: + return None + try: + return Entry(record.get("version"), float(record["fetched_at"])) + except (KeyError, TypeError, ValueError): + return None + + def put(self, ecosystem: str, package: str, version: str | None) -> None: + self._data[self.key(ecosystem, package)] = { + "version": version, + "fetched_at": time.time(), + } + self._dirty = True + + def clear(self) -> None: + self._data = {} + self._dirty = True + + def save(self) -> None: + """Atomic write; a half-written cache would poison every later run.""" + if not self._dirty: + return + try: + self.path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + "w", dir=self.path.parent, delete=False, encoding="utf-8" + ) as handle: + json.dump(self._data, handle) + temp = Path(handle.name) + temp.replace(self.path) + self._dirty = False + except OSError: + pass # an unwritable cache slows the next run; it must not fail it diff --git a/dep_freshness/check.py b/dep_freshness/check.py new file mode 100644 index 0000000..8dece10 --- /dev/null +++ b/dep_freshness/check.py @@ -0,0 +1,158 @@ +"""Gate: fail when any dependency is behind its ecosystem's latest stable. + +Exit codes are the adjudication — no judgement call, no model in the loop: + + 0 everything current, or only validly-allowlisted staleness + 1 a dependency is behind latest stable / unpinned / lock-mismatched + 2 the allowlist is malformed, expired, over the 90-day cap, or stale + 3 cannot determine: no network and no usable cache + +CI runs `--all --strict` and treats 1/2/3 as failure. Pre-commit runs without +`--strict`, where exit 3 degrades to 0 with a loud banner: `--no-verify` is +banned here, so a hook that hard-fails offline would leave no way to commit. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import subprocess +import sys + +from dep_freshness import report +from dep_freshness.allowlist import AllowlistError, load +from dep_freshness.discover import find_manifests, is_manifest, parse_manifest +from dep_freshness.evaluate import judge +from dep_freshness.models import Finding, Severity +from dep_freshness.registries import http +from dep_freshness.resolve import Resolver + + +def repo_root(start: Path) -> Path: + try: + result = subprocess.run( + ["git", "-C", str(start), "rev-parse", "--show-toplevel"], + capture_output=True, text=True, check=False, + ) + except OSError: + return start + if result.returncode != 0: + return start + return Path(result.stdout.strip() or start) + + +def _excuse(findings: list[Finding], entries) -> tuple[list[Finding], list]: + """Split findings into (still failing, excused), and flag dead entries. + + A `transitive:` entry clears itself the moment its dependency is no longer + stale — that is what makes it a predicate rather than a date. An entry with + nothing left to excuse is allowlist rot and exits 2. + """ + by_label = {f"{e.ecosystem}:{e.package}": e for e in entries} + used: set[str] = set() + failing: list[Finding] = [] + excused: list[Finding] = [] + for finding in findings: + entry = by_label.get(finding.label) + if entry is None: + failing.append(finding) + continue + used.add(finding.label) + excused.append(Finding( + finding.dep, finding.severity, finding.latest, finding.detail, + excused=entry.reason, + )) + dead = [e for e in entries if f"{e.ecosystem}:{e.package}" not in used] + return failing, dead + + +def collect(targets: list[Path], resolver: Resolver) -> list[Finding]: + deps = [dep for path in targets for dep in parse_manifest(path)] + resolver.prefetch(deps) + findings = [] + for dep in deps: + finding = judge(dep, resolver.latest(dep)) + if finding is not None: + findings.append(finding) + return findings + + +def _targets(args, root: Path) -> list[Path]: + if args.all: + return find_manifests(root) + return [p for p in args.paths if p.is_file() and is_manifest(p)] + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Fail if any dependency is behind latest stable." + ) + parser.add_argument("paths", nargs="*", type=Path) + parser.add_argument("--all", action="store_true", + help="check every manifest in the repo") + parser.add_argument("--strict", action="store_true", + help="CI mode: an undeterminable answer is a failure") + parser.add_argument("--refresh", action="store_true", + help="ignore the cache and re-fetch every version") + parser.add_argument("--offline", action="store_true", + help="never touch the network; cache only") + parser.add_argument("--exceptions-only", action="store_true", + help="report the allowlist and nothing else") + parser.add_argument("--json", action="store_true", help="machine-readable output") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + if not args.all and not args.paths and not args.exceptions_only: + build_parser().error("give manifest paths, --all, or --exceptions-only") + + root = repo_root(Path.cwd()) + if args.offline: + http.force_offline(True) + + try: + entries = load(root) + except AllowlistError as exc: + print(f"Allowlist ERROR: {exc}", file=sys.stderr) + return 2 + + if args.exceptions_only: + if args.json: + print(report.as_json([], entries, 0)) + else: + for line in report.machine_lines(entries): + print(line, file=sys.stderr) + return 0 + + resolver = Resolver(refresh=args.refresh) + findings = collect(_targets(args, root), resolver) + failing, dead = _excuse(findings, entries) + + still_blocking = {f.label: True for f in findings} + report.exceptions_block(entries, still_blocking) + + unknown = [f for f in failing if f.severity is Severity.UNKNOWN] + real = [f for f in failing if f.severity is not Severity.UNKNOWN] + + if dead: + for entry in dead: + print( + f"Allowlist ERROR: {entry.ecosystem}:{entry.package} is no longer " + f"stale — remove the entry from {entry.source}", + file=sys.stderr, + ) + return 2 + + if args.json: + code = 1 if real else (3 if unknown and args.strict else 0) + print(report.as_json(failing, entries, code)) + return code + + if real: + report.violations(real, root) + return 1 + if unknown or resolver.degraded: + report.degraded(resolver.degraded or [f.label for f in unknown]) + return 3 if args.strict else 0 + return 0 diff --git a/dep_freshness/constraints.py b/dep_freshness/constraints.py new file mode 100644 index 0000000..fcffb1f --- /dev/null +++ b/dep_freshness/constraints.py @@ -0,0 +1,84 @@ +"""Does a range constraint admit a given version? + +Only used for the toolchain declarations, where a range is legitimate: an app +saying `sdk: ^3.12.2` is not stale just because Dart moved to 3.13.2 — that +constraint already permits it. The meaningful question there is whether the +range *excludes* the current toolchain, which is a real, actionable break. + +Deliberately small: caret, tilde and the comparison operators cover every +constraint style present in these repos. Anything unrecognised returns True, +because a checker that guesses "violation" on syntax it does not understand +produces exactly the red-for-no-reason the gate must avoid. +""" + +from __future__ import annotations + +import re + +from dep_freshness.versions import parse + +_OP = re.compile(r"(>=|<=|==|!=|\^|~>|~|>|<)?\s*([0-9][0-9A-Za-z.\-+*]*)") +_ANY = frozenset({"", "any", "*", "latest"}) + + +def _upper_for_caret(text: str) -> str: + """Caret's exclusive upper bound: `^1.2.3` -> `2.0.0`, `^0.2.1` -> `0.3.0`. + + Dart and npm agree on the 0.x special case, and it matters: `^0.2.1` must + not admit 0.3.0. + """ + parts = [int(p) for p in re.split(r"[.\-+]", text)[:3] if p.isdigit()] + while len(parts) < 3: + parts.append(0) + major, minor, _ = parts + if major > 0: + return f"{major + 1}.0.0" + if minor > 0: + return f"0.{minor + 1}.0" + return f"0.0.{parts[2] + 1}" + + +def _clause(operator: str | None, bound: str, version) -> bool: + other = parse(bound) + if other is None: + return True + if operator == "^": + upper = parse(_upper_for_caret(bound)) + return version >= other and (upper is None or version < upper) + if operator in ("~", "~>"): + upper = parse(f"{other.major}.{other.minor + 1}.0") + return version >= other and (upper is None or version < upper) + if operator == ">=": + return version >= other + if operator == "<=": + return version <= other + if operator == ">": + return version > other + if operator == "<": + return version < other + if operator == "!=": + return version != other + return version == other # bare or `==` + + +def satisfies(constraint: str, candidate: str) -> bool: + """True when `candidate` falls inside `constraint`.""" + text = str(constraint or "").strip().strip('"').strip("'") + if text.lower() in _ANY: + return True + version = parse(candidate) + if version is None: + return True + clauses = [c for c in _OP.finditer(text)] + if not clauses: + return True + return all(_clause(m.group(1), m.group(2), version) for m in clauses) + + +def lower_bound(constraint: str) -> str | None: + """The smallest version a constraint admits, when it states one.""" + text = str(constraint or "").strip() + match = re.match(r"\s*(\^|~>|~|>=|==)?\s*([0-9][0-9A-Za-z.\-+]*)", text) + if not match: + return None + return match.group(2) diff --git a/dep_freshness/discover.py b/dep_freshness/discover.py new file mode 100644 index 0000000..c447edf --- /dev/null +++ b/dep_freshness/discover.py @@ -0,0 +1,73 @@ +"""Find manifests, and route each one to the parser that understands it. + +Discovery skips git-ignored files for the same reason the file-length gate +does: a violation in build output or a vendored tree is not something a commit +could ever fix, so reporting it only teaches the user to ignore the gate. +""" + +from __future__ import annotations + +import os +from pathlib import Path +import re +import subprocess + +from dep_freshness._tables import EXCLUDED_DIRS, MANIFEST_GLOBS, REQUIREMENTS_PATTERN +from dep_freshness.models import Dep +from dep_freshness.parsers import fvm, golang, javascript, pubspec, python, rust + +_REQUIREMENTS = re.compile(REQUIREMENTS_PATTERN) + +_BY_NAME = { + "pubspec.yaml": pubspec.parse, + "pyproject.toml": python.parse_pyproject, + "package.json": javascript.parse_package_json, + "Cargo.toml": rust.parse, + "go.mod": golang.parse, + ".fvmrc": fvm.parse, + ".nvmrc": javascript.parse_nvmrc, + ".python-version": python.parse_python_version, +} + + +def is_manifest(path: Path) -> bool: + return path.name in MANIFEST_GLOBS or bool(_REQUIREMENTS.match(path.name)) + + +def parse_manifest(path: Path) -> list[Dep]: + """Every dependency declared in `path`, or [] if it is not a manifest.""" + parser = _BY_NAME.get(path.name) + if parser is not None: + return parser(path) + if _REQUIREMENTS.match(path.name): + return python.parse_requirements(path) + return [] + + +def _git_ignored(paths: list[Path], root: Path) -> set[Path]: + if not paths: + return set() + try: + result = subprocess.run( + ["git", "-C", str(root), "check-ignore", "--stdin"], + input="\n".join(str(p) for p in paths), + capture_output=True, text=True, check=False, + ) + except OSError: + return set() + if result.returncode not in (0, 1): # 128 = not a repo + return set() + return {Path(line) for line in result.stdout.splitlines() if line} + + +def find_manifests(root: Path) -> list[Path]: + """Every tracked manifest under `root`, deepest-first for stable output.""" + found: list[Path] = [] + for dirpath, dirnames, filenames in os.walk(root, followlinks=False): + dirnames[:] = [d for d in dirnames if d not in EXCLUDED_DIRS] + for name in filenames: + candidate = Path(dirpath) / name + if is_manifest(candidate) and not candidate.is_symlink(): + found.append(candidate) + ignored = _git_ignored(found, root) + return sorted(p for p in found if p not in ignored) diff --git a/dep_freshness/evaluate.py b/dep_freshness/evaluate.py new file mode 100644 index 0000000..baef4f5 --- /dev/null +++ b/dep_freshness/evaluate.py @@ -0,0 +1,88 @@ +"""Decide, for one `Dep` and its registry answer, whether the gate objects. + +Kept separate from `check.py` so the rules are readable in one screen and +testable without a filesystem. The rules differ per constraint style on +purpose: + +* Ordinary packages must carry an exact pin (Q6) — a range is not comparable + to a registry answer, which is what made "are we current?" unanswerable. +* Q13's carve-out packages may keep a caret; their *lower bound* is compared + instead, so a stale `^10.2.0` is still caught. +* Toolchain ranges are checked for whether they still admit the current + toolchain. `sdk: ^3.12.2` is not stale because Dart shipped 3.13.2 — that + constraint already permits it — but `<3.13` would be a real break. +""" + +from __future__ import annotations + +from dep_freshness._tables import TOOLCHAIN +from dep_freshness.constraints import lower_bound, satisfies +from dep_freshness.models import Dep, Finding, Severity +from dep_freshness.resolve import Answer +from dep_freshness.versions import behind + + +def _toolchain(dep: Dep, latest: str) -> Finding | None: + if dep.pinned: + if behind(dep.pinned, latest): + return Finding(dep, Severity.STALE, latest) + return None + if not dep.constraint or dep.caret_ok is False: + return Finding( + dep, Severity.UNPINNED, latest, + detail="a channel name is not a version: two machines on the same " + "commit can build with different SDKs", + ) + if not satisfies(dep.constraint, latest): + return Finding( + dep, Severity.STALE, latest, + detail=f"the constraint excludes the current toolchain {latest}", + ) + return None + + +def judge(dep: Dep, answer: Answer) -> Finding | None: + """The gate's objection to `dep`, or None when it is fine.""" + if dep.override: + return Finding( + dep, Severity.OVERRIDE, answer.version, + detail="an override pins a transitive dependency nothing else " + "watches; allowlist it or remove it", + ) + if answer.unavailable: + return Finding( + dep, Severity.UNKNOWN, None, + detail="no network and no cached answer", + ) + latest = answer.version + if latest is None: + return Finding( + dep, Severity.UNKNOWN, None, + detail="the registry has no stable release for this package", + ) + + if dep.ecosystem == TOOLCHAIN: + return _toolchain(dep, latest) + + if dep.pinned is None: + if dep.caret_ok: + floor = lower_bound(dep.constraint) + if floor and behind(floor, latest): + return Finding( + dep, Severity.STALE, latest, + detail=f"range floor {floor} is behind latest stable", + ) + return None + return Finding( + dep, Severity.UNPINNED, latest, + detail=f"exact-pin it ({latest}) so freshness is checkable", + ) + + if behind(dep.pinned, latest): + return Finding(dep, Severity.STALE, latest) + if dep.locked and dep.locked != dep.pinned: + return Finding( + dep, Severity.LOCK_MISMATCH, latest, + detail=f"lockfile resolved {dep.locked}, manifest pins {dep.pinned}", + ) + return None diff --git a/dep_freshness/models.py b/dep_freshness/models.py new file mode 100644 index 0000000..a581453 --- /dev/null +++ b/dep_freshness/models.py @@ -0,0 +1,92 @@ +"""Value types passed between the parsers, the registries and the report. + +Frozen dataclasses on purpose: a parser hands a `Dep` to a registry resolver +and gets a `Finding` back, and nothing in between may mutate what was read off +disk. `Severity` is what the exit code is derived from, so the mapping from +"what we noticed" to "what the shell sees" lives in one place. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from pathlib import Path + + +class Severity(str, Enum): + """Why a dependency is being reported.""" + + STALE = "stale" # pinned below latest stable + UNPINNED = "unpinned" # no exact version to compare against + LOCK_MISMATCH = "lock-mismatch" # manifest pin != lockfile resolved version + OVERRIDE = "override" # dependency_overrides / resolutions entry + UNKNOWN = "unknown" # latest could not be determined at all + + +@dataclass(frozen=True) +class Dep: + """One declared dependency, as written in a manifest.""" + + ecosystem: str + name: str + constraint: str + path: Path + line: int + pinned: str | None = None # exact version, when the constraint is one + locked: str | None = None # version the lockfile resolved to + dev: bool = False + caret_ok: bool = False # Q13 carve-out: a range is legal here + override: bool = False + + +@dataclass(frozen=True) +class Finding: + """A dependency that fails the gate, or is excused by the allowlist.""" + + dep: Dep + severity: Severity + latest: str | None + detail: str = "" + excused: str | None = None # allowlist reason, when one applies + + @property + def label(self) -> str: + """`ecosystem:name` — the key the allowlist matches on.""" + return f"{self.dep.ecosystem}:{self.dep.name}" + + +@dataclass(frozen=True) +class Exception_: + """One parsed allowlist entry. + + Named with a trailing underscore so it cannot shadow the builtin in the + modules that import it; `blocked_by` carries the whole semantics split + between the predicate and discretionary classes. + """ + + ecosystem: str + package: str + pinned: str + reason: str + blocked_by: str + latest_known: str | None = None + expires: str | None = None + source: Path | None = None + + @property + def transitive(self) -> bool: + """True when this is a predicate entry that never expires on a date.""" + from dep_freshness._tables import TRANSITIVE_PREFIX + + return self.blocked_by.startswith(TRANSITIVE_PREFIX) + + @property + def blocker(self) -> tuple[str, str] | None: + """`(package, version)` this entry claims is holding the pin back.""" + from dep_freshness._tables import TRANSITIVE_PREFIX + + if not self.transitive: + return None + rest = self.blocked_by[len(TRANSITIVE_PREFIX):] + name, _, version = rest.partition("@") + return (name.strip(), version.strip()) diff --git a/dep_freshness/parsers/__init__.py b/dep_freshness/parsers/__init__.py new file mode 100644 index 0000000..b1e2438 --- /dev/null +++ b/dep_freshness/parsers/__init__.py @@ -0,0 +1,6 @@ +"""Manifest parsers: read a file, emit `Dep`s. No network, no judgement. + +Each parser is deliberately tolerant: an unpinned or malformed constraint +becomes a `Dep` with `pinned=None` so the gate reports it as a finding, rather +than crashing. A gate that dies on the input it exists to police is useless. +""" diff --git a/dep_freshness/parsers/_lines.py b/dep_freshness/parsers/_lines.py new file mode 100644 index 0000000..e44433c --- /dev/null +++ b/dep_freshness/parsers/_lines.py @@ -0,0 +1,39 @@ +"""Best-effort line numbers for keys the structured parsers give us untagged. + +`yaml.safe_load` and `tomllib` both discard positions. Reporting a violation +without a line makes the fix a search, so the key is located textually after +the fact; a miss degrades to line 0 rather than failing. + +Ties are broken by *indentation*, not by order: a pubspec that declares +`path: ^1.9.1` as a dependency also has a nested `path:` inside a git block six +spaces in, and first-match would point the fix at the wrong line. +""" + +from __future__ import annotations + +from pathlib import Path +import re + +_KEY = re.compile(r'^(?P\s*)-?\s*["\']?(?P[A-Za-z0-9_.@/-]+)["\']?\s*[:=]') + + +def index(path: Path) -> dict[str, int]: + """Map `key` -> the 1-based line where it appears least-indented.""" + best: dict[str, tuple[int, int]] = {} + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return {} + for number, line in enumerate(text.splitlines(), start=1): + match = _KEY.match(line) + if not match: + continue + key = match.group("key") + depth = len(match.group("indent").expandtabs(4)) + if key not in best or depth < best[key][0]: + best[key] = (depth, number) + return {key: number for key, (_, number) in best.items()} + + +def find(path: Path, name: str) -> int: + return index(path).get(name, 0) diff --git a/dep_freshness/parsers/fvm.py b/dep_freshness/parsers/fvm.py new file mode 100644 index 0000000..01ed902 --- /dev/null +++ b/dep_freshness/parsers/fvm.py @@ -0,0 +1,33 @@ +"""`.fvmrc` -> the Flutter SDK this repo builds against. + +A channel name (`"stable"`) is not a version: it tells the gate nothing to +compare, and it means two machines on the same commit can build with different +SDKs. It is therefore reported unpinned. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from dep_freshness._tables import TOOLCHAIN +from dep_freshness.models import Dep +from dep_freshness.versions import exact_pin + +CHANNELS = frozenset({"stable", "beta", "master", "main", "dev"}) + + +def parse(path: Path) -> list[Dep]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return [] + if not isinstance(data, dict): + return [] + value = str(data.get("flutter") or "").strip() + if not value: + return [] + return [Dep( + ecosystem=TOOLCHAIN, name="flutter", constraint=value, path=path, + line=1, pinned=None if value in CHANNELS else exact_pin(value), + )] diff --git a/dep_freshness/parsers/golang.py b/dep_freshness/parsers/golang.py new file mode 100644 index 0000000..3b46bb0 --- /dev/null +++ b/dep_freshness/parsers/golang.py @@ -0,0 +1,53 @@ +"""`go.mod` -> module requirements. + +Go pins exactly by construction, so every `require` line is a pin; the only +work is stripping the `v` prefix and ignoring `// indirect` entries, which the +module graph owns rather than this repo. +""" + +from __future__ import annotations + +from pathlib import Path +import re + +from dep_freshness._tables import GOMOD, TOOLCHAIN +from dep_freshness.models import Dep + +_REQUIRE = re.compile(r"^\s*(?P[^\s()]+)\s+(?Pv\S+)\s*(?P.*)$") + + +def parse(path: Path) -> list[Dep]: + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError: + return [] + deps: list[Dep] = [] + in_block = False + for number, raw in enumerate(lines, start=1): + line = raw.split("//")[0].strip() + comment = raw.partition("//")[2] + if line.startswith("go "): + deps.append(Dep( + ecosystem=TOOLCHAIN, name="go", constraint=line[3:].strip(), + path=path, line=number, caret_ok=True, + )) + continue + if line.startswith("require ("): + in_block = True + continue + if in_block and line == ")": + in_block = False + continue + if line.startswith("require "): + line = line[len("require "):].strip() + elif not in_block: + continue + match = _REQUIRE.match(line) + if not match or "indirect" in comment: + continue + deps.append(Dep( + ecosystem=GOMOD, name=match.group("module"), + constraint=match.group("version"), path=path, line=number, + pinned=match.group("version").lstrip("v"), + )) + return deps diff --git a/dep_freshness/parsers/javascript.py b/dep_freshness/parsers/javascript.py new file mode 100644 index 0000000..d008756 --- /dev/null +++ b/dep_freshness/parsers/javascript.py @@ -0,0 +1,72 @@ +"""`package.json` (+ lockfiles) and `.nvmrc` -> npm dependencies. + +`engines.node` and `packageManager` are toolchain declarations rather than +registry packages, and are read as such. `resolutions` / `overrides` get the +same treatment as pub's `dependency_overrides`: reported, because they pin a +transitive nobody is watching. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from dep_freshness._tables import NPM, TOOLCHAIN +from dep_freshness.models import Dep +from dep_freshness.parsers._lines import index +from dep_freshness.versions import exact_pin + +_SECTIONS = ( + ("dependencies", False, False), + ("devDependencies", True, False), + ("optionalDependencies", False, False), + ("peerDependencies", False, False), + ("resolutions", False, True), + ("overrides", False, True), +) +_LOCAL_PREFIXES = ("file:", "link:", "workspace:", "git+", "github:", "npm:") + + +def _load(path: Path) -> dict: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def parse_package_json(path: Path) -> list[Dep]: + data = _load(path) + if not data: + return [] + lines = index(path) + deps: list[Dep] = [] + + node = (data.get("engines") or {}).get("node") + if isinstance(node, str): + deps.append(Dep( + ecosystem=TOOLCHAIN, name="node", constraint=node, path=path, + line=lines.get("node", 0), pinned=exact_pin(node), caret_ok=True, + )) + + for section, is_dev, is_override in _SECTIONS: + for name, spec in (data.get(section) or {}).items(): + if not isinstance(spec, str) or spec.startswith(_LOCAL_PREFIXES): + continue + deps.append(Dep( + ecosystem=NPM, name=str(name), constraint=spec, path=path, + line=lines.get(str(name), 0), pinned=exact_pin(spec), + dev=is_dev, override=is_override, + )) + return deps + + +def parse_nvmrc(path: Path) -> list[Dep]: + try: + value = path.read_text(encoding="utf-8").strip().splitlines()[0].strip() + except (OSError, IndexError): + return [] + return [Dep( + ecosystem=TOOLCHAIN, name="node", constraint=value, path=path, + line=1, pinned=exact_pin(value), + )] diff --git a/dep_freshness/parsers/pubspec.py b/dep_freshness/parsers/pubspec.py new file mode 100644 index 0000000..def6b69 --- /dev/null +++ b/dep_freshness/parsers/pubspec.py @@ -0,0 +1,101 @@ +"""`pubspec.yaml` (+ `pubspec.lock`) -> Dart/Flutter dependencies. + +Three shapes live in the same map: a plain constraint (`http: ^1.6.0`), an SDK +package (`flutter: {sdk: flutter}`) and a git-tag dependency on `kuhyx/utils`, +whose "latest" is a tag rather than a registry version. `dependency_overrides` +is parsed too and always reported: an override is an unpinned dependency +wearing a disguise. +""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +from dep_freshness._tables import ( + GITTAG, + PUB, + PUB_CARET_ALLOWED, + PUB_OVERRIDE_KEY, + PUB_SDK_PACKAGES, + TOOLCHAIN, +) +from dep_freshness.models import Dep +from dep_freshness.parsers._lines import index +from dep_freshness.versions import exact_pin + +_SECTIONS = (("dependencies", False), ("dev_dependencies", True)) + + +def _load(path: Path) -> dict: + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError): + return {} + return data if isinstance(data, dict) else {} + + +def locked_versions(lock: Path) -> dict[str, str]: + """`package -> resolved version` from a committed `pubspec.lock`. + + A missing lock means "library, check the manifest alone" (four of the nine + shared libs gitignore theirs) — never a violation. + """ + data = _load(lock) + out: dict[str, str] = {} + for name, entry in (data.get("packages") or {}).items(): + if isinstance(entry, dict) and entry.get("version"): + out[str(name)] = str(entry["version"]) + return out + + +def _git_ref(spec: dict) -> str | None: + git = spec.get("git") + if isinstance(git, dict): + return str(git.get("ref") or "") or None + return None + + +def parse(path: Path) -> list[Dep]: + data = _load(path) + if not data: + return [] + lines = index(path) + locks = locked_versions(path.with_name("pubspec.lock")) + deps: list[Dep] = [] + + sdk = ((data.get("environment") or {}) or {}).get("sdk") + if isinstance(sdk, str): + deps.append(Dep( + ecosystem=TOOLCHAIN, name="dart", constraint=sdk, path=path, + line=lines.get("sdk", 0), pinned=exact_pin(sdk), caret_ok=True, + )) + + sections = [*_SECTIONS, (PUB_OVERRIDE_KEY, False)] + for section, is_dev in sections: + for name, spec in (data.get(section) or {}).items(): + name = str(name) + override = section == PUB_OVERRIDE_KEY + if isinstance(spec, dict) and (ref := _git_ref(spec)): + package, _, version = ref.rpartition("-v") + deps.append(Dep( + ecosystem=GITTAG, name=package or name, constraint=ref, + path=path, line=lines.get(name, 0), pinned=version or None, + dev=is_dev, override=override, + )) + continue + if name in PUB_SDK_PACKAGES or ( + isinstance(spec, dict) and "sdk" in spec + ): + continue # ships with the SDK; no registry version exists + if isinstance(spec, dict): + continue # path/hosted dep: nothing comparable to a registry + constraint = "" if spec is None else str(spec) + deps.append(Dep( + ecosystem=PUB, name=name, constraint=constraint, path=path, + line=lines.get(name, 0), pinned=exact_pin(constraint), + locked=locks.get(name), dev=is_dev, + caret_ok=name in PUB_CARET_ALLOWED, override=override, + )) + return deps diff --git a/dep_freshness/parsers/python.py b/dep_freshness/parsers/python.py new file mode 100644 index 0000000..627050f --- /dev/null +++ b/dep_freshness/parsers/python.py @@ -0,0 +1,99 @@ +"""`pyproject.toml` and `requirements*.txt` -> PyPI dependencies. + +PEP 508 markers, extras and environment conditions all appear in the wild here, +so the name is taken as the leading identifier and everything after the first +comparator is the constraint. Anything that is not a single `==` pin is +reported unpinned rather than guessed at. +""" + +from __future__ import annotations + +from pathlib import Path +import re +import tomllib + +from dep_freshness._tables import PYPI, TOOLCHAIN +from dep_freshness.models import Dep +from dep_freshness.parsers._lines import index +from dep_freshness.versions import exact_pin + +_REQ = re.compile( + r"^\s*(?P[A-Za-z0-9][A-Za-z0-9._-]*)" + r"(?:\[(?P[^\]]*)\])?" + r"\s*(?P.*)$" +) +_SKIP_PREFIXES = ("-", "#", "git+", "http://", "https://", ".", "/") + + +def _split(requirement: str) -> tuple[str, str] | None: + """`('ruff', '==0.16.5')` from a PEP 508 requirement string.""" + text = requirement.split("#", 1)[0].split(";", 1)[0].strip() + if not text or text.startswith(_SKIP_PREFIXES): + return None + match = _REQ.match(text) + if not match: + return None + return (match.group("name"), match.group("constraint").strip()) + + +def _dep(name: str, constraint: str, path: Path, line: int) -> Dep: + pinned = exact_pin(constraint) if constraint.startswith("==") else None + return Dep( + ecosystem=PYPI, name=name, constraint=constraint or "(unconstrained)", + path=path, line=line, pinned=pinned, + ) + + +def parse_requirements(path: Path) -> list[Dep]: + try: + text = path.read_text(encoding="utf-8") + except OSError: + return [] + deps: list[Dep] = [] + for number, raw in enumerate(text.splitlines(), start=1): + split = _split(raw) + if split: + deps.append(_dep(split[0], split[1], path, number)) + return deps + + +def parse_pyproject(path: Path) -> list[Dep]: + try: + data = tomllib.loads(path.read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError): + return [] + lines = index(path) + project = data.get("project") or {} + deps: list[Dep] = [] + + requires = project.get("requires-python") + if isinstance(requires, str): + deps.append(Dep( + ecosystem=TOOLCHAIN, name="python", constraint=requires, path=path, + line=lines.get("requires-python", 0), caret_ok=True, + )) + + groups: list[list] = [list(project.get("dependencies") or [])] + for extra in (project.get("optional-dependencies") or {}).values(): + groups.append(list(extra)) + for group in (data.get("dependency-groups") or {}).values(): + groups.append([g for g in group if isinstance(g, str)]) + + for group in groups: + for requirement in group: + split = _split(str(requirement)) + if split: + deps.append(_dep(split[0], split[1], path, lines.get(split[0], 0))) + return deps + + +def parse_python_version(path: Path) -> list[Dep]: + """`.python-version` — compared against the installed interpreter.""" + try: + value = path.read_text(encoding="utf-8").strip().splitlines()[0].strip() + except (OSError, IndexError): + return [] + return [Dep( + ecosystem=TOOLCHAIN, name="python", constraint=value, path=path, + line=1, pinned=exact_pin(value), + )] diff --git a/dep_freshness/parsers/rust.py b/dep_freshness/parsers/rust.py new file mode 100644 index 0000000..bb69075 --- /dev/null +++ b/dep_freshness/parsers/rust.py @@ -0,0 +1,51 @@ +"""`Cargo.toml` -> crates.io dependencies. + +A dependency is either a bare string (`serde = "1.0.229"`) or a table with a +`version` key; path and git dependencies have no registry version and are +skipped. Cargo's bare `"1.0"` is a caret range, so only a full three-part +version counts as pinned — `"1.0"` is reported unpinned, which is correct. +""" + +from __future__ import annotations + +from pathlib import Path +import tomllib + +from dep_freshness._tables import CARGO +from dep_freshness.models import Dep +from dep_freshness.parsers._lines import index +from dep_freshness.versions import exact_pin + +_SECTIONS = ( + ("dependencies", False), + ("dev-dependencies", True), + ("build-dependencies", True), +) + + +def parse(path: Path) -> list[Dep]: + try: + data = tomllib.loads(path.read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError): + return [] + lines = index(path) + deps: list[Dep] = [] + for section, is_dev in _SECTIONS: + for name, spec in (data.get(section) or {}).items(): + if isinstance(spec, dict): + if "path" in spec or "git" in spec: + continue + constraint = str(spec.get("version") or "") + else: + constraint = str(spec) + if not constraint: + continue + pinned = exact_pin(constraint) + if pinned and pinned.count(".") < 2: + pinned = None # "1.0" is a caret range in Cargo, not a pin + deps.append(Dep( + ecosystem=CARGO, name=str(name), constraint=constraint, + path=path, line=lines.get(str(name), 0), pinned=pinned, + dev=is_dev, + )) + return deps diff --git a/dep_freshness/registries/__init__.py b/dep_freshness/registries/__init__.py new file mode 100644 index 0000000..34d7b0d --- /dev/null +++ b/dep_freshness/registries/__init__.py @@ -0,0 +1,5 @@ +"""Registry adapters: one module per ecosystem, all returning latest stable. + +`http.py` is the only module in the whole package that opens a socket, so +offline behaviour and the reachability probe are testable in one place. +""" diff --git a/dep_freshness/registries/cargo.py b/dep_freshness/registries/cargo.py new file mode 100644 index 0000000..02ceced --- /dev/null +++ b/dep_freshness/registries/cargo.py @@ -0,0 +1,25 @@ +"""crates.io: `max_stable_version`, never `max_version`. + +`max_version` includes pre-releases. crates.io also 403s any request without a +User-Agent, which `http.py` always sets. +""" + +from __future__ import annotations + +from dep_freshness._tables import CRATES_API +from dep_freshness.registries.http import get_json +from dep_freshness.versions import newest_stable + + +def latest(name: str) -> str | None: + payload = get_json(CRATES_API.format(name=name)) + if not payload: + return None + crate = payload.get("crate") or {} + stable = crate.get("max_stable_version") + if stable: + return str(stable) + live = [ + v.get("num") for v in payload.get("versions") or [] if not v.get("yanked") + ] + return newest_stable(live) diff --git a/dep_freshness/registries/gittag.py b/dep_freshness/registries/gittag.py new file mode 100644 index 0000000..9c54a45 --- /dev/null +++ b/dep_freshness/registries/gittag.py @@ -0,0 +1,42 @@ +"""Shared libraries consumed by git tag out of `kuhyx/utils`. + +"Newest" here is the newest tag matching `-vX.Y.Z`, not a registry +answer. Deliberately `git ls-remote` rather than the GitHub REST API: that API +allows 60 unauthenticated requests an hour, which ~20 consuming manifests would +exhaust on a single `--all` run. +""" + +from __future__ import annotations + +import re +import subprocess + +from dep_freshness._tables import UTILS_TAG_REMOTE +from dep_freshness.registries.http import Offline, host_reachable +from dep_freshness.versions import newest_stable + +_TAG = re.compile(r"refs/tags/(?P[A-Za-z0-9_.-]+)-v(?P[0-9][^\s]*)$") + + +def latest(package: str, remote: str = UTILS_TAG_REMOTE) -> str | None: + """Highest semver tag prefixed with `-v`, or None if there is none.""" + if not host_reachable(remote): + raise Offline(remote) + try: + result = subprocess.run( + ["git", "ls-remote", "--tags", "--refs", remote], + capture_output=True, + text=True, + timeout=20, + check=False, + ) + except (OSError, subprocess.SubprocessError) as exc: + raise Offline(f"{remote}: {exc}") from exc + if result.returncode != 0: + raise Offline(f"{remote}: git ls-remote exit {result.returncode}") + found = [] + for line in result.stdout.splitlines(): + match = _TAG.search(line.strip()) + if match and match.group("name") == package: + found.append(match.group("version")) + return newest_stable(found) diff --git a/dep_freshness/registries/gomod.py b/dep_freshness/registries/gomod.py new file mode 100644 index 0000000..5c6422c --- /dev/null +++ b/dep_freshness/registries/gomod.py @@ -0,0 +1,17 @@ +"""Go module proxy: latest stable, rejecting `+incompatible` and pre-releases.""" + +from __future__ import annotations + +from dep_freshness._tables import GOPROXY_API +from dep_freshness.registries.http import get_json +from dep_freshness.versions import is_prerelease + + +def latest(module: str) -> str | None: + payload = get_json(GOPROXY_API.format(name=module.lower())) + if not payload: + return None + version = str(payload.get("Version") or "") + if not version or "+incompatible" in version or is_prerelease(version): + return None + return version.lstrip("v") diff --git a/dep_freshness/registries/http.py b/dep_freshness/registries/http.py new file mode 100644 index 0000000..02cb7a9 --- /dev/null +++ b/dep_freshness/registries/http.py @@ -0,0 +1,79 @@ +"""The only module that touches the network. + +Two things matter here. First, a single 2s per-host reachability probe +short-circuits every later request, so an offline run fails in seconds instead +of timing out once per package across ~900 lookups. Second, failures are +returned, never raised: the gate must degrade to a cached answer rather than +crash a pre-commit hook the user is forbidden from bypassing. +""" + +from __future__ import annotations + +import json +import socket +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import urlsplit +from urllib.request import Request, urlopen + +from dep_freshness._tables import HTTP_TIMEOUT, PROBE_TIMEOUT, USER_AGENT + + +class Offline(Exception): + """The host is unreachable; callers should fall back to the cache.""" + + +_reachable: dict[str, bool] = {} +_forced_offline = False + + +def force_offline(value: bool = True) -> None: + """Make every request raise `Offline` without touching the network.""" + global _forced_offline + _forced_offline = value + + +def reset_probes() -> None: + """Forget cached reachability verdicts (tests, and `--refresh`).""" + _reachable.clear() + + +def host_reachable(url: str) -> bool: + """One cheap TCP probe per host, memoised for the run.""" + if _forced_offline: + return False + parts = urlsplit(url) + host = parts.hostname or "" + if host in _reachable: + return _reachable[host] + port = parts.port or (443 if parts.scheme == "https" else 80) + try: + with socket.create_connection((host, port), timeout=PROBE_TIMEOUT): + _reachable[host] = True + except OSError: + _reachable[host] = False + return _reachable[host] + + +def get_json(url: str, accept: str | None = None) -> Any: + """Fetch and decode JSON, or raise `Offline` / return None on 404. + + `Offline` means "ask the cache"; None means "the registry answered and + this package genuinely is not there", which is a real finding rather than + a degraded run and must not be papered over with a stale cache entry. + """ + if not host_reachable(url): + raise Offline(url) + headers = {"User-Agent": USER_AGENT} + if accept: + headers["Accept"] = accept + request = Request(url, headers=headers) + try: + with urlopen(request, timeout=HTTP_TIMEOUT) as response: + return json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + if exc.code == 404: + return None + raise Offline(f"{url}: HTTP {exc.code}") from exc + except (URLError, TimeoutError, OSError, ValueError) as exc: + raise Offline(f"{url}: {exc}") from exc diff --git a/dep_freshness/registries/npmjs.py b/dep_freshness/registries/npmjs.py new file mode 100644 index 0000000..b6f7714 --- /dev/null +++ b/dep_freshness/registries/npmjs.py @@ -0,0 +1,23 @@ +"""npm: latest stable, refusing a pre-release published to `dist-tags.latest`. + +npm lets a maintainer point `latest` at `3.0.0-rc.1`. Trusting it reports the +repo as current while it sits on an older stable, so the tag is only accepted +after it survives the pre-release check. +""" + +from __future__ import annotations + +from dep_freshness._tables import NPM_ACCEPT, NPM_API +from dep_freshness.registries.http import get_json +from dep_freshness.versions import is_prerelease, newest_stable + + +def latest(name: str) -> str | None: + url = NPM_API.format(name=name.replace("/", "%2f")) + payload = get_json(url, accept=NPM_ACCEPT) + if not payload: + return None + tag = (payload.get("dist-tags") or {}).get("latest") + if tag and not is_prerelease(str(tag)): + return str(tag) + return newest_stable((payload.get("versions") or {}).keys()) diff --git a/dep_freshness/registries/pub.py b/dep_freshness/registries/pub.py new file mode 100644 index 0000000..9827110 --- /dev/null +++ b/dep_freshness/registries/pub.py @@ -0,0 +1,23 @@ +"""pub.dev: latest stable of a Dart/Flutter package.""" + +from __future__ import annotations + +from dep_freshness._tables import PUB_API +from dep_freshness.registries.http import get_json +from dep_freshness.versions import newest_stable + + +def latest(name: str) -> str | None: + """Latest stable on pub.dev. + + `latest.version` is already stable-only, but the full version list is the + safety net for packages whose only releases are pre-releases. + """ + payload = get_json(PUB_API.format(name=name)) + if not payload: + return None + candidate = (payload.get("latest") or {}).get("version") + if candidate and newest_stable([candidate]): + return candidate + versions = [v.get("version") for v in payload.get("versions") or []] + return newest_stable(versions) diff --git a/dep_freshness/registries/pypi.py b/dep_freshness/registries/pypi.py new file mode 100644 index 0000000..8297acd --- /dev/null +++ b/dep_freshness/registries/pypi.py @@ -0,0 +1,48 @@ +"""PyPI: latest stable via the modern simple index. + +The legacy `/pypi//json` endpoint's `info.version` includes yanked and +pre-release versions, which is exactly the fail-green bug this gate must not +have. The simple v1 JSON carries per-file `yanked` flags, so a version counts +only when at least one of its files is still installable. +""" + +from __future__ import annotations + +from dep_freshness._tables import PYPI_ACCEPT, PYPI_API +from dep_freshness.registries.http import get_json +from dep_freshness.versions import newest_stable + +_SUFFIXES = (".tar.gz", ".zip", ".whl", ".tar.bz2", ".egg") + + +def _version_of(filename: str, versions: set[str]) -> str | None: + """Which declared version a distribution filename belongs to.""" + for suffix in _SUFFIXES: + if filename.endswith(suffix): + filename = filename[: -len(suffix)] + break + parts = filename.split("-") + for candidate in parts[1:]: + if candidate in versions: + return candidate + return None + + +def latest(name: str) -> str | None: + payload = get_json(PYPI_API.format(name=name), accept=PYPI_ACCEPT) + if not payload: + return None + versions = [str(v) for v in payload.get("versions") or []] + declared = set(versions) + files = payload.get("files") or [] + if files: + live = set() + for entry in files: + if entry.get("yanked"): + continue + found = _version_of(str(entry.get("filename", "")), declared) + if found: + live.add(found) + if live: + return newest_stable(live) + return newest_stable(versions) diff --git a/dep_freshness/registries/toolchain.py b/dep_freshness/registries/toolchain.py new file mode 100644 index 0000000..f07d2ee --- /dev/null +++ b/dep_freshness/registries/toolchain.py @@ -0,0 +1,51 @@ +"""Latest stable Flutter/Dart, Node and Python, for the toolchain checks (Q3). + +Python is deliberately compared against the INSTALLED interpreter rather than +python.org: on Arch it is pacman-managed, and a gate that can only be satisfied +by fighting the distro gets switched off. +""" + +from __future__ import annotations + +import platform + +from dep_freshness._tables import ( + FLUTTER_RELEASES, + NODE_CHANNEL, + NODE_RELEASES, +) +from dep_freshness.registries.http import get_json +from dep_freshness.versions import newest_stable + + +def flutter_latest() -> tuple[str | None, str | None]: + """`(flutter, dart)` on the stable channel.""" + payload = get_json(FLUTTER_RELEASES) + if not payload: + return (None, None) + stable_hash = (payload.get("current_release") or {}).get("stable") + for release in payload.get("releases") or []: + if release.get("hash") == stable_hash and release.get("channel") == "stable": + return (release.get("version"), release.get("dart_sdk_version")) + stable = [ + r.get("version") for r in payload.get("releases") or [] + if r.get("channel") == "stable" + ] + return (newest_stable(stable), None) + + +def node_latest(channel: str = NODE_CHANNEL) -> str | None: + """Newest Node release on the configured channel (`lts` or `current`).""" + payload = get_json(NODE_RELEASES) + if not payload: + return None + if channel == "lts": + candidates = [r.get("version") for r in payload if r.get("lts")] + else: + candidates = [r.get("version") for r in payload] + return newest_stable(candidates) + + +def python_installed() -> str: + """The interpreter this gate is running under — the Python target.""" + return platform.python_version() diff --git a/dep_freshness/report.py b/dep_freshness/report.py new file mode 100644 index 0000000..4e81d7c --- /dev/null +++ b/dep_freshness/report.py @@ -0,0 +1,161 @@ +"""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) diff --git a/dep_freshness/resolve.py b/dep_freshness/resolve.py new file mode 100644 index 0000000..436121e --- /dev/null +++ b/dep_freshness/resolve.py @@ -0,0 +1,108 @@ +"""Turn a `Dep` into "what is the newest stable of this?", via cache then net. + +The cache is consulted first, the network second, and an *expired* cache entry +third — that last fallback is what lets a pre-commit run succeed offline +instead of cornering the user, since `--no-verify` is banned. +""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass + +from dep_freshness._tables import ( + CARGO, GITTAG, GOMOD, MAX_WORKERS, NPM, PUB, PYPI, TOOLCHAIN, +) +from dep_freshness.cache import Cache +from dep_freshness.models import Dep +from dep_freshness.registries import cargo, gittag, gomod, npmjs, pub, pypi +from dep_freshness.registries import toolchain as tc +from dep_freshness.registries.http import Offline + +_LOOKUP = { + PUB: pub.latest, + PYPI: pypi.latest, + NPM: npmjs.latest, + CARGO: cargo.latest, + GOMOD: gomod.latest, + GITTAG: gittag.latest, +} + + +@dataclass +class Answer: + """Latest stable for one package, and how confident we are in it.""" + + version: str | None + stale_cache_days: float | None = None # served from an expired entry + unavailable: bool = False # no network and no cache at all + + +def _toolchain_latest(name: str) -> str | None: + if name == "flutter": + return tc.flutter_latest()[0] + if name == "dart": + return tc.flutter_latest()[1] + if name == "node": + return tc.node_latest() + if name == "python": + return tc.python_installed() + return None + + +def _fetch(ecosystem: str, package: str) -> str | None: + if ecosystem == TOOLCHAIN: + return _toolchain_latest(package) + handler = _LOOKUP.get(ecosystem) + return handler(package) if handler else None + + +class Resolver: + """Batch version lookups behind one shared on-disk cache.""" + + def __init__(self, cache: Cache | None = None, refresh: bool = False) -> None: + self.cache = cache if cache is not None else Cache() + if refresh: + self.cache.clear() + self.degraded: list[str] = [] + + def prefetch(self, deps: list[Dep]) -> None: + """Warm the cache for everything not already fresh, eight at a time.""" + wanted = { + (d.ecosystem, d.name) + for d in deps + if not (entry := self.cache.get(d.ecosystem, d.name)) + or not entry.fresh(d.ecosystem) + } + if not wanted: + return + with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool: + futures = { + pool.submit(_fetch, eco, name): (eco, name) for eco, name in wanted + } + for future, (eco, name) in futures.items(): + try: + self.cache.put(eco, name, future.result()) + except Offline as exc: + self.degraded.append(str(exc)) + except Exception as exc: + # Deliberately broad: one malformed registry payload + # must degrade that single package, not abort a run + # spanning ~900 lookups. + self.degraded.append(f"{eco}:{name}: {exc}") + self.cache.save() + + def latest(self, dep: Dep) -> Answer: + entry = self.cache.get(dep.ecosystem, dep.name) + if entry and entry.fresh(dep.ecosystem): + return Answer(entry.version) + try: + version = _fetch(dep.ecosystem, dep.name) + except Offline: + version = None + else: + self.cache.put(dep.ecosystem, dep.name, version) + return Answer(version) + if entry is not None: + return Answer(entry.version, stale_cache_days=entry.age() / 86400) + return Answer(None, unavailable=True) diff --git a/dep_freshness/tests/__init__.py b/dep_freshness/tests/__init__.py new file mode 100644 index 0000000..4e4f590 --- /dev/null +++ b/dep_freshness/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the shared dependency-freshness gate.""" diff --git a/dep_freshness/tests/conftest.py b/dep_freshness/tests/conftest.py new file mode 100644 index 0000000..2d71651 --- /dev/null +++ b/dep_freshness/tests/conftest.py @@ -0,0 +1,61 @@ +"""Shared fixtures. Nothing here may touch the network. + +Every registry answer is a fixture: the gate's worst failure mode is reporting +"up to date" against a pre-release, and a test that hits the real registry +cannot pin that behaviour down. +""" + +from __future__ import annotations + +from pathlib import Path +import subprocess +import sys + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + """A throwaway git repo, so git-ignore lookups behave as in real use.""" + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + return tmp_path + + +@pytest.fixture(autouse=True) +def no_network(monkeypatch): + """Fail loudly if a test reaches for a socket instead of a fixture. + + Imported inside the fixture, not at module top: the sys.path insert above + is what makes the package importable at all, and a top-level import after + it is a lint violation this repo does not suppress. + """ + from dep_freshness.registries import http + + def forbidden(*_args, **_kwargs): + raise AssertionError("test tried to open a real connection") + + # Every host is reachable unless a test asked for offline mode, so + # `--offline` behaves in tests exactly as it does in a real run. + monkeypatch.setattr( + http, "host_reachable", lambda _url: not http._forced_offline + ) + monkeypatch.setattr(http, "urlopen", forbidden) + http.reset_probes() + http.force_offline(False) + + +@pytest.fixture +def cache_dir(tmp_path, monkeypatch) -> Path: + """Point the on-disk registry snapshot at a scratch directory.""" + target = tmp_path / "cache" + monkeypatch.setenv("DEP_FRESHNESS_CACHE", str(target)) + return target + + +def write(root: Path, relative: str, body: str) -> Path: + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + return path diff --git a/dep_freshness/tests/test_allowlist.py b/dep_freshness/tests/test_allowlist.py new file mode 100644 index 0000000..a328767 --- /dev/null +++ b/dep_freshness/tests/test_allowlist.py @@ -0,0 +1,154 @@ +"""Allowlist semantics: the two entry classes, and every way one can rot. + +The split matters more than it looks. A `transitive:` entry is a predicate the +gate re-evaluates; a discretionary one is a promise with a date on it. Giving +a predicate entry an expiry date would turn the gate red on a calendar day for +a reason no commit can fix, which is why that combination is an error. +""" + +from __future__ import annotations + +from datetime import date, timedelta + +import pytest + +from dep_freshness.allowlist import AllowlistError, load +from dep_freshness.tests.conftest import write + +NAME = "dependency-freshness.allowlist.yaml" + + +def _allowlist(root, body): + return write(root, NAME, body) + + +TRANSITIVE = """\ +exceptions: + - ecosystem: pub + package: plugin_platform_interface + pinned: "2.1.8" + latest_known: "2.2.0" + reason: "firebase_core 4.2.0 constrains this below latest" + blocked_by: "transitive:firebase_core@4.2.0" +""" + + +def _discretionary(days: int) -> str: + when = date.today() + timedelta(days=days) + return f"""\ +exceptions: + - ecosystem: pypi + package: abandoned + pinned: "1.4.0" + reason: "upstream unmaintained" + blocked_by: "https://github.com/kuhyx/x/issues/42" + expires: "{when.isoformat()}" +""" + + +def test_no_allowlist_is_not_an_error(tmp_path): + assert load(tmp_path) == [] + + +def test_empty_allowlist_is_not_an_error(tmp_path): + _allowlist(tmp_path, "") + assert load(tmp_path) == [] + + +def test_transitive_entry_parses_and_exposes_its_blocker(tmp_path): + _allowlist(tmp_path, TRANSITIVE) + entry = load(tmp_path)[0] + assert entry.transitive + assert entry.blocker == ("firebase_core", "4.2.0") + assert entry.expires is None + + +def test_discretionary_entry_within_the_cap_is_accepted(tmp_path): + _allowlist(tmp_path, _discretionary(30)) + entry = load(tmp_path)[0] + assert not entry.transitive + + +def test_expired_discretionary_entry_is_an_error(tmp_path): + _allowlist(tmp_path, _discretionary(-1)) + with pytest.raises(AllowlistError, match="expired"): + load(tmp_path) + + +def test_an_expiry_beyond_ninety_days_is_an_error(tmp_path): + _allowlist(tmp_path, _discretionary(120)) + with pytest.raises(AllowlistError, match="more than 90 days"): + load(tmp_path) + + +def test_a_discretionary_entry_without_expires_is_an_error(tmp_path): + _allowlist(tmp_path, """\ +exceptions: + - ecosystem: pypi + package: abandoned + pinned: "1.4.0" + reason: "unmaintained" + blocked_by: "discretionary" +""") + with pytest.raises(AllowlistError, match="requires expires"): + load(tmp_path) + + +def test_a_transitive_entry_may_not_carry_an_expiry(tmp_path): + _allowlist(tmp_path, TRANSITIVE + ' expires: "2026-12-01"\n') + with pytest.raises(AllowlistError, match="must NOT set expires"): + load(tmp_path) + + +@pytest.mark.parametrize("field", ["ecosystem", "package", "pinned", "reason", + "blocked_by"]) +def test_every_required_field_is_required(tmp_path, field): + # Blank the value rather than deleting the line: dropping `ecosystem:` + # would also drop the `-` that makes the entry a list item, and the test + # would then be asserting against a different error entirely. + body = "\n".join( + f"{line.split(field + ':')[0]}{field}: \"\"" if f"{field}:" in line else line + for line in TRANSITIVE.splitlines() + ) + _allowlist(tmp_path, body + "\n") + with pytest.raises(AllowlistError, match="missing required field"): + load(tmp_path) + + +def test_a_malformed_transitive_blocker_is_an_error(tmp_path): + _allowlist(tmp_path, TRANSITIVE.replace( + "transitive:firebase_core@4.2.0", "transitive:firebase_core")) + with pytest.raises(AllowlistError, match="transitive:@"): + load(tmp_path) + + +def test_a_bad_expiry_format_is_an_error(tmp_path): + _allowlist(tmp_path, _discretionary(30).replace( + (date.today() + __import__("datetime").timedelta(days=30)).isoformat(), + "next tuesday")) + with pytest.raises(AllowlistError, match="YYYY-MM-DD"): + load(tmp_path) + + +def test_a_top_level_list_is_rejected(tmp_path): + _allowlist(tmp_path, "- ecosystem: pub\n") + with pytest.raises(AllowlistError, match="top-level 'exceptions:' list"): + load(tmp_path) + + +def test_exceptions_must_be_a_list(tmp_path): + _allowlist(tmp_path, "exceptions:\n pub: nope\n") + with pytest.raises(AllowlistError, match="must be a list"): + load(tmp_path) + + +def test_an_entry_must_be_a_mapping(tmp_path): + _allowlist(tmp_path, "exceptions:\n - just-a-string\n") + with pytest.raises(AllowlistError, match="must be a mapping"): + load(tmp_path) + + +def test_unreadable_yaml_is_an_error(tmp_path): + _allowlist(tmp_path, "exceptions: [\n") + with pytest.raises(AllowlistError, match="unreadable"): + load(tmp_path) diff --git a/dep_freshness/tests/test_check.py b/dep_freshness/tests/test_check.py new file mode 100644 index 0000000..a9f5d63 --- /dev/null +++ b/dep_freshness/tests/test_check.py @@ -0,0 +1,188 @@ +"""End-to-end: a repo on disk in, an exit code out. + +Both directions are asserted here on purpose. A freshness gate that returns 0 +everywhere on day one is indistinguishable from one that checks nothing, and +this repo has shipped exactly that twice before (two fake file-length gates). +""" + +from __future__ import annotations + +from datetime import date, timedelta +import os + +import pytest + +from dep_freshness import check +from dep_freshness.cache import Cache +from dep_freshness.registries import http +from dep_freshness.tests.conftest import write + +STALE_PUBSPEC = """\ +name: demo +environment: + sdk: ^3.12.2 +dependencies: + http: 1.5.0 +""" +CURRENT_PUBSPEC = STALE_PUBSPEC.replace("1.5.0", "1.6.0") +ALLOWLIST = "dependency-freshness.allowlist.yaml" + + +@pytest.fixture(autouse=True) +def canned(monkeypatch, cache_dir): + """One registry answer for every package the fixtures declare.""" + answers = {("pub", "http"): "1.6.0", ("toolchain", "dart"): "3.13.2"} + monkeypatch.setattr( + "dep_freshness.resolve._fetch", + lambda eco, name: answers.get((eco, name)), + ) + return answers + + +@pytest.fixture +def run(repo, monkeypatch): + def invoke(*argv): + monkeypatch.chdir(repo) + return check.main(list(argv)) + return invoke + + +def test_a_stale_pin_fails_and_names_the_package(repo, run, capsys): + write(repo, "pubspec.yaml", STALE_PUBSPEC) + assert run("--all") == 1 + assert "pub:http" in capsys.readouterr().err + + +def test_a_current_repo_passes(repo, run): + write(repo, "pubspec.yaml", CURRENT_PUBSPEC) + assert run("--all") == 0 + + +def test_named_paths_check_only_those_files(repo, run): + write(repo, "pubspec.yaml", STALE_PUBSPEC) + write(repo, "sub/pubspec.yaml", CURRENT_PUBSPEC) + assert run("sub/pubspec.yaml") == 0 + assert run("pubspec.yaml") == 1 + + +def test_a_non_manifest_path_is_ignored(repo, run): + write(repo, "pubspec.yaml", STALE_PUBSPEC) + write(repo, "lib/main.dart", "void main() {}\n") + assert run("lib/main.dart") == 0 + + +def test_a_transitive_exception_excuses_the_finding(repo, run): + write(repo, "pubspec.yaml", STALE_PUBSPEC) + write(repo, ALLOWLIST, """\ +exceptions: + - ecosystem: pub + package: http + pinned: "1.5.0" + reason: "something upstream holds it" + blocked_by: "transitive:some_pkg@1.0.0" +""") + assert run("--all") == 0 + + +def test_an_exception_prints_loudly_even_on_success(repo, run, capsys): + test_a_transitive_exception_excuses_the_finding(repo, run) + err = capsys.readouterr().err + assert "DEPENDENCY EXCEPTION IN USE" in err + assert "still blocking" in err + + +def test_an_exception_with_nothing_left_to_excuse_is_an_error(repo, run, capsys): + write(repo, "pubspec.yaml", CURRENT_PUBSPEC) + write(repo, ALLOWLIST, """\ +exceptions: + - ecosystem: pub + package: http + pinned: "1.5.0" + reason: "stale entry nobody removed" + blocked_by: "transitive:some_pkg@1.0.0" +""") + assert run("--all") == 2 + assert "no longer stale" in capsys.readouterr().err + + +def test_an_expired_allowlist_exits_two(repo, run, capsys): + write(repo, "pubspec.yaml", STALE_PUBSPEC) + yesterday = (date.today() - timedelta(days=1)).isoformat() + write(repo, ALLOWLIST, f"""\ +exceptions: + - ecosystem: pub + package: http + pinned: "1.5.0" + reason: "held" + blocked_by: "discretionary" + expires: "{yesterday}" +""") + assert run("--all") == 2 + assert "Allowlist ERROR" in capsys.readouterr().err + + +def test_offline_with_a_cold_cache_passes_with_a_degraded_banner( + repo, run, capsys, monkeypatch +): + """`--no-verify` is banned, so a hook must never corner the user offline.""" + monkeypatch.setattr("dep_freshness.resolve._fetch", _offline) + write(repo, "pubspec.yaml", STALE_PUBSPEC) + assert run("--all", "--offline") == 0 + assert "DEGRADED" in capsys.readouterr().err + + +def test_offline_with_a_cold_cache_fails_under_strict(repo, run, monkeypatch): + """CI is always online, so an undeterminable answer there is a real failure.""" + monkeypatch.setattr("dep_freshness.resolve._fetch", _offline) + write(repo, "pubspec.yaml", STALE_PUBSPEC) + assert run("--all", "--offline", "--strict") == 3 + + +def test_offline_with_a_warm_cache_still_finds_the_stale_pin( + repo, run, monkeypatch, cache_dir +): + cache = Cache(cache_dir / "registry.json") + cache.put("pub", "http", "1.6.0") + cache.put("toolchain", "dart", "3.13.2") + cache.save() + monkeypatch.setattr("dep_freshness.resolve._fetch", _offline) + write(repo, "pubspec.yaml", STALE_PUBSPEC) + assert run("--all", "--offline") == 1 + + +def test_exceptions_only_reports_the_allowlist_and_stops(repo, run, capsys): + write(repo, "pubspec.yaml", STALE_PUBSPEC) + write(repo, ALLOWLIST, """\ +exceptions: + - ecosystem: pub + package: http + pinned: "1.5.0" + latest_known: "1.6.0" + reason: "held" + blocked_by: "transitive:some_pkg@1.0.0" +""") + assert run("--exceptions-only") == 0 + assert "[DEP-EXCEPTION] pub:http 1.5.0 < 1.6.0" in capsys.readouterr().err + + +def test_json_output_carries_the_exit_code(repo, run, capsys): + write(repo, "pubspec.yaml", STALE_PUBSPEC) + assert run("--all", "--json") == 1 + assert '"exit_code": 1' in capsys.readouterr().out + + +def test_no_target_at_all_is_a_usage_error(run): + with pytest.raises(SystemExit): + run() + + +def test_repo_root_falls_back_outside_a_git_repo(tmp_path): + assert check.repo_root(tmp_path) == tmp_path + + +def _offline(_ecosystem, _name): + raise http.Offline("no network") + + +def test_the_cache_directory_is_honoured(cache_dir): + assert os.environ["DEP_FRESHNESS_CACHE"] == str(cache_dir) diff --git a/dep_freshness/tests/test_constraints.py b/dep_freshness/tests/test_constraints.py new file mode 100644 index 0000000..ffbd11d --- /dev/null +++ b/dep_freshness/tests/test_constraints.py @@ -0,0 +1,63 @@ +"""Range arithmetic. Wrong here means red for a reason no commit can fix.""" + +from __future__ import annotations + +import pytest + +from dep_freshness.constraints import _upper_for_caret, lower_bound, satisfies + + +@pytest.mark.parametrize("constraint,version,expected", [ + ("^3.12.2", "3.13.2", True), + ("^3.12.2", "4.0.0", False), + ("^3.12.2", "3.12.1", False), + ("^0.2.1", "0.3.0", False), # caret's 0.x special case + ("^0.2.1", "0.2.9", True), + ("^0.0.3", "0.0.4", False), + ("~1.2.0", "1.2.9", True), + ("~1.2.0", "1.3.0", False), + ("~>2.1.0", "2.1.5", True), + (">=3.8.0 <4.0.0", "3.13.2", True), + (">=3.8,<3.13", "3.14.7", False), + (">3.0.0", "3.0.0", False), + ("<=2.0.0", "2.0.0", True), + ("!=1.5.0", "1.5.0", False), + ("==1.6.0", "1.6.0", True), + ("1.6.0", "1.7.0", False), + ("any", "9.9.9", True), + ("*", "9.9.9", True), + ("", "9.9.9", True), +]) +def test_satisfies(constraint, version, expected): + assert satisfies(constraint, version) is expected + + +def test_an_unparseable_candidate_is_never_a_violation(): + assert satisfies("^1.0.0", "not-a-version") + + +def test_a_constraint_with_no_version_at_all_admits_everything(): + assert satisfies("workspace", "1.0.0") + + +def test_an_unparseable_bound_is_ignored_rather_than_failing(): + assert satisfies(">=abc", "1.0.0") + + +def test_quoted_constraints_are_unwrapped(): + assert satisfies('">=3.8.0 <4.0.0"', "3.13.2") + + +@pytest.mark.parametrize("text,expected", [ + ("1.2.3", "2.0.0"), ("0.2.1", "0.3.0"), ("0.0.3", "0.0.4"), ("2", "3.0.0"), +]) +def test_caret_upper_bounds(text, expected): + assert _upper_for_caret(text) == expected + + +@pytest.mark.parametrize("constraint,expected", [ + ("^10.2.0", "10.2.0"), (">=1.2", "1.2"), ("==3.0.0", "3.0.0"), + ("~1.4.0", "1.4.0"), ("2.0.0", "2.0.0"), ("any", None), ("", None), +]) +def test_lower_bound(constraint, expected): + assert lower_bound(constraint) == expected diff --git a/dep_freshness/tests/test_discover.py b/dep_freshness/tests/test_discover.py new file mode 100644 index 0000000..aed7ff0 --- /dev/null +++ b/dep_freshness/tests/test_discover.py @@ -0,0 +1,73 @@ +"""Which files the gate picks up, and which it deliberately does not.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from dep_freshness.discover import find_manifests, is_manifest, parse_manifest +from dep_freshness.tests.conftest import write + +PUBSPEC = "name: demo\ndependencies:\n http: 1.6.0\n" + + +@pytest.mark.parametrize("name", [ + "pubspec.yaml", "pyproject.toml", "package.json", "Cargo.toml", "go.mod", + ".fvmrc", ".nvmrc", ".python-version", "requirements.txt", + "requirements-dev.txt", +]) +def test_manifest_names_are_recognised(name): + assert is_manifest(Path(name)) + + +@pytest.mark.parametrize("name", ["main.dart", "README.md", "pubspec.lock", + "my-requirements.md"]) +def test_other_files_are_not_manifests(name): + assert not is_manifest(Path(name)) + + +def test_find_manifests_walks_the_tree(repo): + write(repo, "pubspec.yaml", PUBSPEC) + write(repo, "packages/core/pubspec.yaml", PUBSPEC) + write(repo, "lib/main.dart", "void main() {}\n") + found = {p.name for p in find_manifests(repo)} + assert found == {"pubspec.yaml"} + assert len(find_manifests(repo)) == 2 + + +def test_excluded_directories_are_skipped(repo): + write(repo, "build/pubspec.yaml", PUBSPEC) + write(repo, "node_modules/x/package.json", "{}") + write(repo, ".dart_tool/pubspec.yaml", PUBSPEC) + assert find_manifests(repo) == [] + + +def test_git_ignored_manifests_are_skipped(repo): + write(repo, ".gitignore", "generated/\n") + write(repo, "generated/pubspec.yaml", PUBSPEC) + write(repo, "pubspec.yaml", PUBSPEC) + assert [p.name for p in find_manifests(repo)] == ["pubspec.yaml"] + assert all("generated" not in str(p) for p in find_manifests(repo)) + + +def test_outside_a_git_repo_nothing_is_treated_as_ignored(tmp_path): + write(tmp_path, "pubspec.yaml", PUBSPEC) + assert len(find_manifests(tmp_path)) == 1 + + +def test_symlinked_manifests_are_skipped(repo): + write(repo, "real/pubspec.yaml", PUBSPEC) + (repo / "pubspec.yaml").symlink_to(repo / "real" / "pubspec.yaml") + assert [str(p) for p in find_manifests(repo)] == [ + str(repo / "real" / "pubspec.yaml") + ] + + +def test_parse_manifest_routes_by_filename(tmp_path): + path = write(tmp_path, "requirements-dev.txt", "ruff==0.16.5\n") + assert [d.name for d in parse_manifest(path)] == ["ruff"] + + +def test_parse_manifest_ignores_anything_else(tmp_path): + assert parse_manifest(write(tmp_path, "main.dart", "void main() {}")) == [] diff --git a/dep_freshness/tests/test_edges.py b/dep_freshness/tests/test_edges.py new file mode 100644 index 0000000..799d5a3 --- /dev/null +++ b/dep_freshness/tests/test_edges.py @@ -0,0 +1,95 @@ +"""The remaining branches: defensive paths, and output shapes. + +These are the lines a happy-path suite never reaches -- a corrupt input, a +missing binary, the discretionary half of a report block. They are exactly +where a gate quietly stops gating, so they are covered deliberately. +""" + +from __future__ import annotations + +from datetime import date, timedelta +import io +from pathlib import Path +import subprocess + +import pytest + +from dep_freshness import check, discover, report, resolve +from dep_freshness.allowlist import _parse_date, load +from dep_freshness.models import Dep, Exception_, Finding, Severity +from dep_freshness.registries import cargo, gomod, pub, pypi +from dep_freshness.registries.http import Offline +from dep_freshness.tests.conftest import write +from dep_freshness.versions import newest_stable, parse + + +def test_yaml_parses_an_unquoted_date_natively(): + """`expires: 2026-10-15` without quotes arrives as a `datetime.date`.""" + when = date(2026, 10, 15) + assert _parse_date(when, "x") == when + + +def test_an_unquoted_expiry_in_yaml_is_accepted(tmp_path): + when = (date.today() + timedelta(days=10)).isoformat() + write(tmp_path, "dependency-freshness.allowlist.yaml", f"""\ +exceptions: + - ecosystem: pypi + package: x + pinned: "1.0.0" + reason: "held" + blocked_by: "discretionary" + expires: {when} +""") + assert load(tmp_path)[0].expires == when + + +def test_a_discretionary_entry_has_no_blocker_tuple(): + entry = Exception_("pypi", "x", "1.0.0", "r", "discretionary", expires="2026-10-15") + assert entry.blocker is None + + +@pytest.mark.parametrize("module", [cargo, gomod, pub, pypi]) +def test_a_missing_package_is_none_everywhere(monkeypatch, module): + monkeypatch.setattr(module, "get_json", lambda *_a, **_k: None) + assert module.latest("nope") is None + + +def test_pypi_matches_a_version_inside_a_hyphenated_filename(monkeypatch): + monkeypatch.setattr(pypi, "get_json", lambda *_a, **_k: { + "versions": ["1.0.0"], + "files": [{"filename": "my-pkg-1.0.0-py3-none-any.whl", "yanked": False}], + }) + assert pypi.latest("my-pkg") == "1.0.0" + + +def test_pypi_ignores_a_file_whose_version_it_cannot_place(monkeypatch): + monkeypatch.setattr(pypi, "get_json", lambda *_a, **_k: { + "versions": ["1.0.0"], + "files": [{"filename": "junk.txt", "yanked": False}], + }) + assert pypi.latest("pkg") == "1.0.0" + + +def test_a_semver_prerelease_that_pep440_rejects_still_parses(): + assert parse("1.2.3-a.b.c") == parse("1.2.3") + + +def test_a_string_that_is_no_kind_of_version_does_not_parse(): + assert parse("stable") is None + assert parse("1.2.3.4.5-!!!") is None + + +def test_newest_stable_ignores_a_lower_candidate(): + assert newest_stable(["2.0.0", "1.0.0"]) == "2.0.0" + + +def test_a_constraint_bound_that_will_not_parse_is_permissive(): + from dep_freshness.constraints import _clause + assert _clause(">=", "not-a-version", parse("1.0.0")) + + +def test_a_toolchain_pin_at_latest_passes(): + from dep_freshness.evaluate import judge + dep = Dep(ecosystem="toolchain", name="flutter", constraint="3.47.2", + path=Path(".fvmrc"), line=1, pinned="3.47.2") + assert judge(dep, resolve.Answer("3.47.2")) is None diff --git a/dep_freshness/tests/test_evaluate.py b/dep_freshness/tests/test_evaluate.py new file mode 100644 index 0000000..acff5e9 --- /dev/null +++ b/dep_freshness/tests/test_evaluate.py @@ -0,0 +1,121 @@ +"""The rules: when does the gate object, and when must it stay quiet? + +The quiet cases are the ones worth testing. A gate that fires on `sdk: ^3.12.2` +because Dart shipped 3.13.2 -- a version that constraint already permits -- is +a gate the user turns off. +""" + +from __future__ import annotations + +from pathlib import Path + +from dep_freshness.evaluate import judge +from dep_freshness.models import Dep, Severity +from dep_freshness.resolve import Answer + + +def dep(**kwargs) -> Dep: + base = {"ecosystem": "pub", "name": "http", "constraint": "1.6.0", + "path": Path("pubspec.yaml"), "line": 1, "pinned": "1.6.0"} + return Dep(**{**base, **kwargs}) + + +def test_a_current_exact_pin_passes(): + assert judge(dep(), Answer("1.6.0")) is None + + +def test_a_stale_exact_pin_is_reported(): + finding = judge(dep(), Answer("1.7.0")) + assert finding.severity is Severity.STALE + assert finding.latest == "1.7.0" + + +def test_a_range_is_reported_unpinned_with_the_version_to_use(): + finding = judge(dep(constraint="^1.6.0", pinned=None), Answer("1.7.0")) + assert finding.severity is Severity.UNPINNED + assert "1.7.0" in finding.detail + + +def test_an_unconstrained_dependency_is_reported_unpinned(): + """`plugin_platform_interface: any` -- the one Phase-0 pub anomaly.""" + finding = judge(dep(constraint="any", pinned=None), Answer("2.2.0")) + assert finding.severity is Severity.UNPINNED + + +def test_a_lockfile_disagreeing_with_the_manifest_is_reported(): + finding = judge(dep(locked="1.5.0"), Answer("1.6.0")) + assert finding.severity is Severity.LOCK_MISMATCH + assert "1.5.0" in finding.detail + + +def test_a_missing_lockfile_is_not_a_violation(): + assert judge(dep(locked=None), Answer("1.6.0")) is None + + +def test_an_override_is_always_reported(): + assert judge(dep(override=True), Answer("1.6.0")).severity is Severity.OVERRIDE + + +def test_a_caret_allowed_package_passes_at_the_current_floor(): + assert judge( + dep(name="very_good_analysis", constraint="^10.3.0", pinned=None, + caret_ok=True), + Answer("10.3.0"), + ) is None + + +def test_a_caret_allowed_package_is_stale_when_its_floor_is_behind(): + finding = judge( + dep(name="very_good_analysis", constraint="^10.2.0", pinned=None, + caret_ok=True), + Answer("10.3.0"), + ) + assert finding.severity is Severity.STALE + assert "floor 10.2.0" in finding.detail + + +def test_a_toolchain_range_that_admits_the_current_version_passes(): + assert judge( + dep(ecosystem="toolchain", name="dart", constraint="^3.12.2", + pinned=None, caret_ok=True), + Answer("3.13.2"), + ) is None + + +def test_a_toolchain_range_that_excludes_the_current_version_fails(): + finding = judge( + dep(ecosystem="toolchain", name="python", constraint=">=3.8,<3.13", + pinned=None, caret_ok=True), + Answer("3.14.7"), + ) + assert finding.severity is Severity.STALE + assert "excludes the current toolchain" in finding.detail + + +def test_a_channel_name_in_fvmrc_is_reported_unpinned(): + finding = judge( + dep(ecosystem="toolchain", name="flutter", constraint="stable", + pinned=None), + Answer("3.47.2"), + ) + assert finding.severity is Severity.UNPINNED + + +def test_a_pinned_toolchain_behind_latest_is_stale(): + finding = judge( + dep(ecosystem="toolchain", name="flutter", constraint="3.47.1", + pinned="3.47.1"), + Answer("3.47.2"), + ) + assert finding.severity is Severity.STALE + + +def test_no_answer_at_all_is_unknown_not_stale(): + finding = judge(dep(), Answer(None, unavailable=True)) + assert finding.severity is Severity.UNKNOWN + + +def test_a_package_with_no_stable_release_is_unknown(): + finding = judge(dep(), Answer(None)) + assert finding.severity is Severity.UNKNOWN + assert "no stable release" in finding.detail diff --git a/dep_freshness/tests/test_golang.py b/dep_freshness/tests/test_golang.py new file mode 100644 index 0000000..2a26781 --- /dev/null +++ b/dep_freshness/tests/test_golang.py @@ -0,0 +1,47 @@ +"""`go.mod` parsing: the block form, the single-line form, and `// indirect`.""" + +from __future__ import annotations + +from dep_freshness.parsers.golang import parse +from dep_freshness.tests.conftest import write + +GO_MOD = """\ +module github.com/kuhyx/demo + +go 1.25 + +require ( +\tgithub.com/spf13/cobra v1.10.1 +\tgithub.com/stretchr/testify v1.11.1 // indirect +) + +require golang.org/x/sync v0.18.0 + +replace example.com/x => ./x +""" + + +def test_direct_requirements_are_read(tmp_path): + deps = {d.name: d for d in parse(write(tmp_path, "go.mod", GO_MOD))} + assert deps["github.com/spf13/cobra"].pinned == "1.10.1" + assert deps["golang.org/x/sync"].pinned == "0.18.0" + + +def test_indirect_requirements_are_owned_by_the_module_graph_not_this_repo(tmp_path): + deps = {d.name: d for d in parse(write(tmp_path, "go.mod", GO_MOD))} + assert "github.com/stretchr/testify" not in deps + + +def test_the_go_directive_is_a_toolchain_declaration(tmp_path): + deps = {d.name: d for d in parse(write(tmp_path, "go.mod", GO_MOD))} + assert deps["go"].ecosystem == "toolchain" + assert deps["go"].constraint == "1.25" + + +def test_replace_directives_are_not_requirements(tmp_path): + deps = {d.name: d for d in parse(write(tmp_path, "go.mod", GO_MOD))} + assert "example.com/x" not in deps + + +def test_an_unreadable_file_yields_nothing(tmp_path): + assert parse(tmp_path / "missing.mod") == [] diff --git a/dep_freshness/tests/test_http.py b/dep_freshness/tests/test_http.py new file mode 100644 index 0000000..d21a3f5 --- /dev/null +++ b/dep_freshness/tests/test_http.py @@ -0,0 +1,122 @@ +"""The network layer: one probe per host, and failures that never raise out. + +The gate is called from a pre-commit hook the user may not bypass, so every +transport error has to arrive as `Offline` -- something the caller can answer +with a cached value -- rather than as a traceback. +""" + +from __future__ import annotations + +import io +import socket +from urllib.error import HTTPError, URLError + +import pytest + +from dep_freshness.registries import http + + +@pytest.fixture(autouse=True) +def real_probe(monkeypatch): + """Undo conftest's blanket `host_reachable` stub for this module.""" + monkeypatch.undo() + http.reset_probes() + http.force_offline(False) + yield + http.reset_probes() + http.force_offline(False) + + +def test_the_probe_is_made_once_per_host(monkeypatch): + calls = [] + + def fake_connect(address, timeout=None): + calls.append(address) + return io.BytesIO() + + monkeypatch.setattr(socket, "create_connection", fake_connect) + assert http.host_reachable("https://pub.dev/a") + assert http.host_reachable("https://pub.dev/b") + assert len(calls) == 1 + assert calls[0] == ("pub.dev", 443) + + +def test_an_unreachable_host_is_remembered_as_unreachable(monkeypatch): + monkeypatch.setattr(socket, "create_connection", _refuse) + assert not http.host_reachable("https://nowhere.invalid/x") + + +def test_force_offline_short_circuits_even_a_known_good_host(monkeypatch): + monkeypatch.setattr(socket, "create_connection", lambda *a, **k: io.BytesIO()) + assert http.host_reachable("https://pub.dev/a") + http.force_offline(True) + assert not http.host_reachable("https://pub.dev/a") + + +def test_get_json_raises_offline_when_the_host_is_down(monkeypatch): + monkeypatch.setattr(socket, "create_connection", _refuse) + with pytest.raises(http.Offline): + http.get_json("https://nowhere.invalid/x") + + +def test_a_404_means_the_package_does_not_exist(monkeypatch): + _reachable(monkeypatch) + monkeypatch.setattr(http, "urlopen", _raiser( + HTTPError("u", 404, "Not Found", {}, None))) + assert http.get_json("https://pub.dev/api/packages/nope") is None + + +def test_a_500_degrades_to_offline_rather_than_a_missing_package(monkeypatch): + _reachable(monkeypatch) + monkeypatch.setattr(http, "urlopen", _raiser( + HTTPError("u", 500, "Boom", {}, None))) + with pytest.raises(http.Offline): + http.get_json("https://pub.dev/api/packages/x") + + +def test_a_transport_error_degrades_to_offline(monkeypatch): + _reachable(monkeypatch) + monkeypatch.setattr(http, "urlopen", _raiser(URLError("reset"))) + with pytest.raises(http.Offline): + http.get_json("https://pub.dev/api/packages/x") + + +def test_a_successful_body_is_decoded(monkeypatch): + _reachable(monkeypatch) + monkeypatch.setattr(http, "urlopen", lambda *a, **k: _Response(b'{"a": 1}')) + assert http.get_json("https://pub.dev/x", accept="application/json") == {"a": 1} + + +def test_undecodable_json_degrades_to_offline(monkeypatch): + _reachable(monkeypatch) + monkeypatch.setattr(http, "urlopen", lambda *a, **k: _Response(b"not json")) + with pytest.raises(http.Offline): + http.get_json("https://pub.dev/x") + + +def _refuse(*_args, **_kwargs): + raise OSError("connection refused") + + +def _reachable(monkeypatch): + monkeypatch.setattr(http, "host_reachable", lambda _url: True) + + +def _raiser(error): + def raise_it(*_args, **_kwargs): + raise error + return raise_it + + +class _Response: + def __init__(self, body: bytes) -> None: + self._body = body + + def read(self) -> bytes: + return self._body + + def __enter__(self): + return self + + def __exit__(self, *_exc): + return False diff --git a/dep_freshness/tests/test_parsers.py b/dep_freshness/tests/test_parsers.py new file mode 100644 index 0000000..66a2724 --- /dev/null +++ b/dep_freshness/tests/test_parsers.py @@ -0,0 +1,147 @@ +"""Manifest parsing, including the shapes that used to crash the gate. + +`plugin_platform_interface: any` and a `requirements.txt` of bare names were +the two Phase-0 "the gate cannot even parse these" cases. They parse now, and +land as findings — a gate that dies on the input it exists to police is worse +than no gate. +""" + +from __future__ import annotations + +from dep_freshness.parsers import fvm, javascript, pubspec, python, rust +from dep_freshness.tests.conftest import write + +PUBSPEC = """\ +name: demo +environment: + sdk: ^3.12.2 +dependencies: + flutter: + sdk: flutter + http: 1.6.0 + plugin_platform_interface: any + crdt_sync: + git: + url: https://github.com/kuhyx/utils + ref: crdt_sync_dart-v0.11.0 + path: crdt_sync_dart +dev_dependencies: + flutter_test: + sdk: flutter + very_good_analysis: ^10.2.0 +dependency_overrides: + meta: 1.16.0 +""" + + +def _by_name(deps): + return {d.name: d for d in deps} + + +def test_pubspec_reads_every_dependency_shape(tmp_path): + deps = _by_name(pubspec.parse(write(tmp_path, "pubspec.yaml", PUBSPEC))) + assert deps["http"].pinned == "1.6.0" + assert deps["plugin_platform_interface"].pinned is None + assert deps["crdt_sync_dart"].ecosystem == "gittag" + assert deps["crdt_sync_dart"].pinned == "0.11.0" + assert deps["dart"].constraint == "^3.12.2" + assert deps["very_good_analysis"].caret_ok + assert deps["meta"].override + + +def test_pubspec_skips_sdk_packages(tmp_path): + deps = _by_name(pubspec.parse(write(tmp_path, "pubspec.yaml", PUBSPEC))) + assert "flutter" not in deps + assert "flutter_test" not in deps + + +def test_a_missing_lockfile_is_a_library_not_a_violation(tmp_path): + """Four of the nine shared libs gitignore their lock on purpose.""" + deps = _by_name(pubspec.parse(write(tmp_path, "pubspec.yaml", PUBSPEC))) + assert deps["http"].locked is None + + +def test_pubspec_reads_the_lockfile_when_one_is_committed(tmp_path): + write(tmp_path, "pubspec.yaml", PUBSPEC) + write(tmp_path, "pubspec.lock", "packages:\n http:\n version: \"1.5.0\"\n") + deps = _by_name(pubspec.parse(tmp_path / "pubspec.yaml")) + assert deps["http"].locked == "1.5.0" + + +def test_line_numbers_prefer_the_least_indented_key(tmp_path): + """A nested `path:` inside a git block must not win over the real dep.""" + body = PUBSPEC.replace(" http: 1.6.0", " path: 1.9.1\n http: 1.6.0") + deps = _by_name(pubspec.parse(write(tmp_path, "pubspec.yaml", body))) + assert deps["path"].line == 7 # not the nested `path:` in the git block + + +def test_requirements_bare_names_parse_as_unpinned(tmp_path): + path = write(tmp_path, "requirements.txt", "numpy\npillow\n# a comment\n-e .\n") + deps = python.parse_requirements(path) + assert [d.name for d in deps] == ["numpy", "pillow"] + assert all(d.pinned is None for d in deps) + + +def test_requirements_handles_extras_markers_and_pins(tmp_path): + path = write( + tmp_path, "requirements.txt", + 'ruff==0.16.5\nuvicorn[standard]==0.30.0\nfoo>=1.0 ; python_version>"3.9"\n', + ) + deps = {d.name: d for d in python.parse_requirements(path)} + assert deps["ruff"].pinned == "0.16.5" + assert deps["uvicorn"].pinned == "0.30.0" + assert deps["foo"].pinned is None + + +def test_pyproject_reads_project_and_group_dependencies(tmp_path): + path = write(tmp_path, "pyproject.toml", """\ +[project] +requires-python = ">=3.10" +dependencies = ["httpx==0.28.1"] +[project.optional-dependencies] +dev = ["pytest==8.4.2"] +[dependency-groups] +lint = ["ruff==0.16.5"] +""") + deps = {d.name: d for d in python.parse_pyproject(path)} + assert deps["httpx"].pinned == "0.28.1" + assert deps["pytest"].pinned == "8.4.2" + assert deps["ruff"].pinned == "0.16.5" + assert deps["python"].constraint == ">=3.10" + + +def test_package_json_reads_engines_and_overrides(tmp_path): + path = write(tmp_path, "package.json", """\ +{"engines": {"node": "24.20.0"}, + "dependencies": {"react": "19.2.0", "local": "workspace:*"}, + "devDependencies": {"vitest": "^4.1.10"}, + "resolutions": {"semver": "7.7.1"}} +""") + deps = {d.name: d for d in javascript.parse_package_json(path)} + assert deps["node"].pinned == "24.20.0" + assert deps["react"].pinned == "19.2.0" + assert "local" not in deps + assert deps["vitest"].pinned is None + assert deps["semver"].override + + +def test_cargo_treats_a_two_part_version_as_a_range(tmp_path): + path = write(tmp_path, "Cargo.toml", """\ +[dependencies] +serde = "1.0.229" +loose = "1.0" +local = { path = "../x" } +tabled = { version = "0.20.0" } +""") + deps = {d.name: d for d in rust.parse(path)} + assert deps["serde"].pinned == "1.0.229" + assert deps["loose"].pinned is None + assert deps["tabled"].pinned == "0.20.0" + assert "local" not in deps + + +def test_fvmrc_channel_is_not_a_version(tmp_path): + channel = fvm.parse(write(tmp_path, ".fvmrc", '{"flutter": "stable"}')) + assert channel[0].pinned is None + pinned = fvm.parse(write(tmp_path, ".fvmrc", '{"flutter": "3.47.2"}')) + assert pinned[0].pinned == "3.47.2" diff --git a/dep_freshness/tests/test_parsers_edges.py b/dep_freshness/tests/test_parsers_edges.py new file mode 100644 index 0000000..c311e1c --- /dev/null +++ b/dep_freshness/tests/test_parsers_edges.py @@ -0,0 +1,96 @@ +"""The unreadable, the malformed and the empty. + +Every parser has to survive junk: the gate runs from a pre-commit hook, and a +traceback there blocks a commit for a reason the user cannot act on. +""" + +from __future__ import annotations + +from dep_freshness.parsers import fvm, javascript, pubspec, python, rust +from dep_freshness.tests.conftest import write + + +def test_a_missing_pubspec_yields_nothing(tmp_path): + assert pubspec.parse(tmp_path / "nope.yaml") == [] + + +def test_malformed_yaml_yields_nothing(tmp_path): + assert pubspec.parse(write(tmp_path, "pubspec.yaml", "a: [\n")) == [] + + +def test_a_yaml_scalar_document_yields_nothing(tmp_path): + assert pubspec.parse(write(tmp_path, "pubspec.yaml", "just a string\n")) == [] + + +def test_a_path_dependency_has_nothing_to_compare(tmp_path): + body = "name: d\ndependencies:\n local:\n path: ../local\n" + assert pubspec.parse(write(tmp_path, "pubspec.yaml", body)) == [] + + +def test_a_null_constraint_is_read_as_unpinned(tmp_path): + body = "name: d\ndependencies:\n http:\n" + assert pubspec.parse(write(tmp_path, "pubspec.yaml", body))[0].pinned is None + + +def test_a_missing_lock_is_an_empty_mapping(tmp_path): + assert pubspec.locked_versions(tmp_path / "pubspec.lock") == {} + + +def test_malformed_toml_yields_nothing(tmp_path): + assert python.parse_pyproject(write(tmp_path, "pyproject.toml", "[a\n")) == [] + assert rust.parse(write(tmp_path, "Cargo.toml", "[a\n")) == [] + + +def test_a_missing_requirements_file_yields_nothing(tmp_path): + assert python.parse_requirements(tmp_path / "nope.txt") == [] + + +def test_requirements_skips_urls_flags_and_local_paths(tmp_path): + body = "-r base.txt\ngit+https://x/y\nhttps://x/y.whl\n./local\n/abs/path\n" + assert python.parse_requirements(write(tmp_path, "requirements.txt", body)) == [] + + +def test_python_version_file_is_read_as_a_pin(tmp_path): + dep = python.parse_python_version(write(tmp_path, ".python-version", "3.14.7\n")) + assert dep[0].pinned == "3.14.7" + + +def test_an_empty_python_version_file_yields_nothing(tmp_path): + assert python.parse_python_version(write(tmp_path, ".python-version", "")) == [] + + +def test_nvmrc_is_read_as_a_pin(tmp_path): + dep = javascript.parse_nvmrc(write(tmp_path, ".nvmrc", "v24.20.0\n")) + assert dep[0].pinned == "24.20.0" + + +def test_an_empty_nvmrc_yields_nothing(tmp_path): + assert javascript.parse_nvmrc(write(tmp_path, ".nvmrc", "\n")) == [] + + +def test_malformed_json_yields_nothing(tmp_path): + assert javascript.parse_package_json(write(tmp_path, "package.json", "{")) == [] + assert javascript.parse_package_json( + write(tmp_path, "package.json", "[1, 2]")) == [] + + +def test_a_non_string_dependency_spec_is_skipped(tmp_path): + body = '{"dependencies": {"weird": {"version": "1.0.0"}}}' + assert javascript.parse_package_json(write(tmp_path, "package.json", body)) == [] + + +def test_an_fvmrc_that_is_not_an_object_yields_nothing(tmp_path): + assert fvm.parse(write(tmp_path, ".fvmrc", "[]")) == [] + assert fvm.parse(write(tmp_path, ".fvmrc", "{")) == [] + assert fvm.parse(write(tmp_path, ".fvmrc", '{"flutter": ""}')) == [] + + +def test_a_cargo_dependency_with_no_version_is_skipped(tmp_path): + body = '[dependencies]\nx = { features = ["a"] }\n' + assert rust.parse(write(tmp_path, "Cargo.toml", body)) == [] + + +def test_line_index_of_an_unreadable_file_is_empty(tmp_path): + from dep_freshness.parsers._lines import find, index + assert index(tmp_path / "nope") == {} + assert find(tmp_path / "nope", "x") == 0 diff --git a/dep_freshness/tests/test_registries.py b/dep_freshness/tests/test_registries.py new file mode 100644 index 0000000..ba9b992 --- /dev/null +++ b/dep_freshness/tests/test_registries.py @@ -0,0 +1,105 @@ +"""Registry adapters, driven by fixtures rather than the live internet. + +Each ecosystem has one documented way to leak a pre-release into "latest"; +these tests are the only thing that catches that, because a leak fails *green* +and the end-to-end run reports the repo as up to date. +""" + +from __future__ import annotations + +import pytest + +from dep_freshness.registries import cargo, gomod, npmjs, pub, pypi + + +@pytest.fixture +def answer(monkeypatch): + """Install a canned payload for the next `get_json` call.""" + def install(payload, module): + monkeypatch.setattr(module, "get_json", lambda *_a, **_k: payload) + return install + + +def test_npm_rejects_a_prerelease_published_to_dist_tags_latest(answer): + answer({ + "dist-tags": {"latest": "3.0.0-rc.1"}, + "versions": {"2.4.1": {}, "3.0.0-rc.1": {}}, + }, npmjs) + assert npmjs.latest("some-pkg") == "2.4.1" + + +def test_npm_uses_dist_tags_when_it_is_stable(answer): + answer({"dist-tags": {"latest": "5.1.0"}, "versions": {"5.1.0": {}}}, npmjs) + assert npmjs.latest("some-pkg") == "5.1.0" + + +def test_npm_missing_package_is_none(answer): + answer(None, npmjs) + assert npmjs.latest("nope") is None + + +def test_cargo_prefers_max_stable_over_max_version(answer): + answer({"crate": {"max_version": "2.0.0-beta.3", + "max_stable_version": "1.0.229"}}, cargo) + assert cargo.latest("serde") == "1.0.229" + + +def test_cargo_falls_back_to_unyanked_versions(answer): + answer({ + "crate": {}, + "versions": [{"num": "1.2.0", "yanked": True}, {"num": "1.1.0"}], + }, cargo) + assert cargo.latest("thing") == "1.1.0" + + +def test_pub_uses_latest_when_stable(answer): + answer({"latest": {"version": "1.6.0"}}, pub) + assert pub.latest("http") == "1.6.0" + + +def test_pub_falls_back_when_latest_is_a_prerelease(answer): + answer({ + "latest": {"version": "2.0.0-dev.1"}, + "versions": [{"version": "1.9.0"}, {"version": "2.0.0-dev.1"}], + }, pub) + assert pub.latest("thing") == "1.9.0" + + +def test_pypi_skips_versions_whose_files_are_all_yanked(answer): + answer({ + "versions": ["1.0.0", "1.1.0"], + "files": [ + {"filename": "pkg-1.0.0-py3-none-any.whl", "yanked": False}, + {"filename": "pkg-1.1.0-py3-none-any.whl", "yanked": True}, + {"filename": "pkg-1.1.0.tar.gz", "yanked": True}, + ], + }, pypi) + assert pypi.latest("pkg") == "1.0.0" + + +def test_pypi_ignores_prereleases(answer): + answer({ + "versions": ["1.0.0", "2.0.0rc1"], + "files": [ + {"filename": "pkg-1.0.0.tar.gz", "yanked": False}, + {"filename": "pkg-2.0.0rc1.tar.gz", "yanked": False}, + ], + }, pypi) + assert pypi.latest("pkg") == "1.0.0" + + +def test_pypi_falls_back_to_the_version_list_without_files(answer): + answer({"versions": ["1.0.0", "1.4.2"]}, pypi) + assert pypi.latest("pkg") == "1.4.2" + + +def test_go_rejects_incompatible_and_prerelease(answer): + answer({"Version": "v3.0.0+incompatible"}, gomod) + assert gomod.latest("example.com/m") is None + answer({"Version": "v1.2.0-rc1"}, gomod) + assert gomod.latest("example.com/m") is None + + +def test_go_strips_the_v_prefix(answer): + answer({"Version": "v1.9.4"}, gomod) + assert gomod.latest("example.com/m") == "1.9.4" diff --git a/dep_freshness/tests/test_report.py b/dep_freshness/tests/test_report.py new file mode 100644 index 0000000..9b9f3b1 --- /dev/null +++ b/dep_freshness/tests/test_report.py @@ -0,0 +1,209 @@ +"""Reporting, plumbing and entrypoint branches. + +Split out of `test_edges.py` to hold every file under the shared 250-line cap; +same concern, second half. These cover the paths a happy-path run never takes: +git missing, a cleared exception, a malformed expiry, the module entrypoint. +""" + +from __future__ import annotations + +from datetime import date, timedelta +import io +from pathlib import Path +import subprocess + +import pytest + +from dep_freshness import check, discover, report, resolve +from dep_freshness.models import Dep, Exception_, Finding, Severity +from dep_freshness.registries.http import Offline +from dep_freshness.tests.conftest import write +from dep_freshness.versions import newest_stable + + +def test_git_check_ignore_missing_means_nothing_is_ignored(tmp_path, monkeypatch): + def no_git(*_args, **_kwargs): + raise OSError("no git") + monkeypatch.setattr(subprocess, "run", no_git) + assert discover._git_ignored([tmp_path / "pubspec.yaml"], tmp_path) == set() + + +def test_repo_root_falls_back_when_git_is_missing(tmp_path, monkeypatch): + def no_git(*_args, **_kwargs): + raise OSError("no git") + monkeypatch.setattr(subprocess, "run", no_git) + assert check.repo_root(tmp_path) == tmp_path + + +def test_fetch_dispatches_to_the_right_registry(monkeypatch): + """`_LOOKUP` binds the adapters at import, so the dict is what to patch.""" + monkeypatch.setitem(resolve._LOOKUP, "pub", lambda name: f"pub-{name}") + assert resolve._fetch("pub", "http") == "pub-http" + assert resolve._fetch("nonsense", "x") is None + + +def test_fetch_routes_toolchain_names(monkeypatch): + monkeypatch.setattr(resolve.tc, "node_latest", lambda: "24.20.0") + assert resolve._fetch("toolchain", "node") == "24.20.0" + + +def test_exceptions_only_json_carries_the_entries(repo, monkeypatch, capsys): + write(repo, "dependency-freshness.allowlist.yaml", """\ +exceptions: + - ecosystem: pub + package: http + pinned: "1.5.0" + reason: "held" + blocked_by: "transitive:x@1.0.0" +""") + monkeypatch.chdir(repo) + assert check.main(["--exceptions-only", "--json"]) == 0 + assert '"transitive": true' in capsys.readouterr().out + + +def test_the_exception_banner_shows_a_countdown_for_discretionary_entries(): + stream = io.StringIO() + when = (date.today() + timedelta(days=7)).isoformat() + entry = Exception_("pypi", "x", "1.0.0", "held", "discretionary", expires=when) + report.exceptions_block([entry], {}, stream=stream) + assert "expires in 7 days" in stream.getvalue() + + +def test_a_cleared_transitive_entry_says_so(): + stream = io.StringIO() + entry = Exception_("pub", "http", "1.5.0", "held", "transitive:x@1.0.0") + report.exceptions_block([entry], {"pub:http": False}, stream=stream) + assert "CLEARED" in stream.getvalue() + + +def test_a_malformed_expiry_degrades_the_banner_instead_of_crashing(): + stream = io.StringIO() + entry = Exception_("pypi", "x", "1.0.0", "held", "discretionary", + expires="whenever") + report.exceptions_block([entry], {}, stream=stream) + assert "expires" in stream.getvalue() + assert report.machine_lines([entry]) == [ + "[DEP-EXCEPTION] pypi:x 1.0.0 expires whenever" + ] + + +def test_a_finding_outside_the_repo_root_prints_its_full_path(): + stream = io.StringIO() + dep = Dep(ecosystem="pub", name="http", constraint="1.5.0", + path=Path("/elsewhere/pubspec.yaml"), line=0, pinned="1.5.0") + report.violations([Finding(dep, Severity.STALE, "1.6.0")], Path("/repo"), + stream=stream) + assert "/elsewhere/pubspec.yaml" in stream.getvalue() + + +def test_the_degraded_banner_lists_at_most_five_reasons(): + stream = io.StringIO() + report.degraded([f"reason {i}" for i in range(9)], stream=stream) + body = stream.getvalue() + assert "9 lookup(s)" in body + assert "reason 5" not in body + + +def test_prefetch_with_nothing_to_fetch_is_a_no_op(cache_dir): + resolver = resolve.Resolver() + resolver.prefetch([]) + assert resolver.degraded == [] + + +def test_offline_from_the_resolver_is_recorded_not_raised(cache_dir, monkeypatch): + monkeypatch.setattr("dep_freshness.resolve._fetch", _boom) + resolver = resolve.Resolver() + resolver.prefetch([Dep(ecosystem="pub", name="http", constraint="1.0.0", + path=Path("pubspec.yaml"), line=1, pinned="1.0.0")]) + assert resolver.degraded + + +def _boom(*_args): + raise Offline("down") + + +def test_a_cache_file_holding_a_json_list_reads_as_a_cold_cache(cache_dir): + from dep_freshness.cache import Cache + path = cache_dir / "registry.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("[1, 2, 3]", encoding="utf-8") + assert Cache(path).get("pub", "http") is None + + +def test_a_requirement_line_that_is_not_a_requirement_is_skipped(): + from dep_freshness.parsers.python import _split + assert _split("!!! nonsense") is None + + +def test_a_pyproject_with_no_project_table_yields_nothing(tmp_path): + from dep_freshness.parsers.python import parse_pyproject + path = write(tmp_path, "pyproject.toml", '[tool.ruff]\nline-length = 88\n') + assert parse_pyproject(path) == [] + + +def test_a_non_string_requires_python_is_ignored(tmp_path): + from dep_freshness.parsers.python import parse_pyproject + path = write(tmp_path, "pyproject.toml", + '[project]\nrequires-python = 3\ndependencies = []\n') + assert parse_pyproject(path) == [] + + +def test_a_group_entry_that_is_not_a_requirement_is_skipped(tmp_path): + from dep_freshness.parsers.python import parse_pyproject + path = write(tmp_path, "pyproject.toml", + '[dependency-groups]\nlint = ["-r other.txt", "ruff==0.16.5"]\n') + assert [d.name for d in parse_pyproject(path)] == ["ruff"] + + +def test_a_lock_entry_without_a_version_is_skipped(tmp_path): + from dep_freshness.parsers.pubspec import locked_versions + path = write(tmp_path, "pubspec.lock", + "packages:\n a:\n version: \"1.0.0\"\n b:\n source: sdk\n") + assert locked_versions(path) == {"a": "1.0.0"} + + +def test_a_finding_with_no_detail_prints_only_its_headline(): + stream = io.StringIO() + dep = Dep(ecosystem="pub", name="http", constraint="1.5.0", + path=Path("pubspec.yaml"), line=4, pinned="1.5.0") + report.violations([Finding(dep, Severity.STALE, "1.6.0")], Path("."), + stream=stream) + body = stream.getvalue() + assert "pubspec.yaml:4" in body + assert " " not in body.replace(" pub:http", "") + + +def test_a_finding_with_a_detail_prints_it_indented(): + stream = io.StringIO() + dep = Dep(ecosystem="pub", name="http", constraint="^1.5.0", + path=Path("pubspec.yaml"), line=4) + report.violations( + [Finding(dep, Severity.UNPINNED, "1.6.0", detail="exact-pin it")], + Path("."), stream=stream, + ) + assert " exact-pin it" in stream.getvalue() + + +def test_newest_stable_skips_a_candidate_it_cannot_parse(): + assert newest_stable(["1.0.0", "not-a-version"]) == "1.0.0" + + + + +def test_newest_stable_skips_a_stable_looking_string_that_is_not_a_version(): + assert newest_stable(["1.0.0", "stable"]) == "1.0.0" + + +def test_the_module_entrypoint_returns_the_gate_exit_code( + repo, monkeypatch, cache_dir +): + """`python3 -m dep_freshness` is what the shell wrapper actually runs.""" + import runpy + import sys + + write(repo, "pubspec.yaml", "name: d\ndependencies:\n http: 1.5.0\n") + monkeypatch.chdir(repo) + monkeypatch.setattr(sys, "argv", ["dep_freshness", "--all", "--offline"]) + with pytest.raises(SystemExit) as exit_info: + runpy.run_module("dep_freshness", run_name="__main__") + assert exit_info.value.code == 0 # offline + cold cache degrades, never blocks diff --git a/dep_freshness/tests/test_resolve.py b/dep_freshness/tests/test_resolve.py new file mode 100644 index 0000000..9742546 --- /dev/null +++ b/dep_freshness/tests/test_resolve.py @@ -0,0 +1,147 @@ +"""Cache-then-network-then-stale-cache, and the toolchain routing. + +The third step is the one that matters: serving an *expired* entry when the +network is gone is what keeps the pre-commit hook from cornering a user who is +forbidden from passing `--no-verify`. +""" + +from __future__ import annotations + +from pathlib import Path +import time + +import pytest + +from dep_freshness.cache import Cache, Entry, ttl_for +from dep_freshness.models import Dep +from dep_freshness.registries import http +from dep_freshness.registries import toolchain as tc +from dep_freshness.resolve import Resolver, _toolchain_latest + + +def dep(ecosystem="pub", name="http") -> Dep: + return Dep(ecosystem=ecosystem, name=name, constraint="1.0.0", + path=Path("pubspec.yaml"), line=1, pinned="1.0.0") + + +@pytest.fixture +def resolver(cache_dir): + return Resolver(cache=Cache(cache_dir / "registry.json")) + + +def test_a_fresh_cache_entry_short_circuits_the_network(resolver, monkeypatch): + resolver.cache.put("pub", "http", "1.6.0") + monkeypatch.setattr("dep_freshness.resolve._fetch", _explode) + assert resolver.latest(dep()).version == "1.6.0" + + +def test_a_cold_cache_fetches_and_stores(resolver, monkeypatch): + monkeypatch.setattr("dep_freshness.resolve._fetch", lambda *_a: "1.6.0") + assert resolver.latest(dep()).version == "1.6.0" + assert resolver.cache.get("pub", "http").version == "1.6.0" + + +def test_an_expired_entry_is_served_when_the_network_is_gone(resolver, monkeypatch): + resolver.cache.put("pub", "http", "1.6.0") + stale = time.time() - ttl_for("pub") - 60 + resolver.cache._data["pub/http"]["fetched_at"] = stale + monkeypatch.setattr("dep_freshness.resolve._fetch", _offline) + answer = resolver.latest(dep()) + assert answer.version == "1.6.0" + assert answer.stale_cache_days is not None + assert not answer.unavailable + + +def test_no_cache_and_no_network_is_unavailable(resolver, monkeypatch): + monkeypatch.setattr("dep_freshness.resolve._fetch", _offline) + assert resolver.latest(dep()).unavailable + + +def test_prefetch_records_why_a_lookup_degraded(resolver, monkeypatch): + monkeypatch.setattr("dep_freshness.resolve._fetch", _offline) + resolver.prefetch([dep()]) + assert resolver.degraded + + +def test_prefetch_survives_an_unexpected_error(resolver, monkeypatch): + monkeypatch.setattr("dep_freshness.resolve._fetch", _explode) + resolver.prefetch([dep()]) + assert any("boom" in reason for reason in resolver.degraded) + + +def test_prefetch_skips_packages_already_fresh(resolver, monkeypatch): + resolver.cache.put("pub", "http", "1.6.0") + monkeypatch.setattr("dep_freshness.resolve._fetch", _explode) + resolver.prefetch([dep()]) + assert resolver.degraded == [] + + +def test_refresh_discards_the_existing_cache(cache_dir, monkeypatch): + seeded = Cache(cache_dir / "registry.json") + seeded.put("pub", "http", "1.5.0") + seeded.save() + monkeypatch.setattr("dep_freshness.resolve._fetch", lambda *_a: "1.6.0") + resolver = Resolver(cache=Cache(cache_dir / "registry.json"), refresh=True) + assert resolver.latest(dep()).version == "1.6.0" + + +def test_git_tags_get_a_longer_ttl_than_registries(): + assert ttl_for("gittag") > ttl_for("pub") + + +def test_a_corrupt_cache_file_reads_as_a_cold_cache(cache_dir): + path = cache_dir / "registry.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{not json", encoding="utf-8") + assert Cache(path).get("pub", "http") is None + + +def test_a_malformed_cache_entry_is_ignored(cache_dir): + cache = Cache(cache_dir / "registry.json") + cache._data["pub/http"] = {"version": "1.6.0"} # no fetched_at + assert cache.get("pub", "http") is None + + +def test_saving_is_a_no_op_when_nothing_changed(cache_dir): + cache = Cache(cache_dir / "registry.json") + cache.save() + assert not (cache_dir / "registry.json").exists() + + +def test_an_unwritable_cache_slows_the_next_run_but_does_not_fail_it(tmp_path): + blocked = tmp_path / "afile" + blocked.write_text("x", encoding="utf-8") + cache = Cache(blocked / "registry.json") + cache.put("pub", "http", "1.6.0") + cache.save() # must not raise + + +def test_entry_age_is_measured_from_fetch_time(): + assert Entry("1.0.0", time.time() - 120).age() >= 120 + + +@pytest.mark.parametrize("name,expected", [ + ("flutter", "3.47.2"), ("dart", "3.13.2"), ("node", "24.20.0"), +]) +def test_toolchain_routing(monkeypatch, name, expected): + monkeypatch.setattr(tc, "flutter_latest", lambda: ("3.47.2", "3.13.2")) + monkeypatch.setattr(tc, "node_latest", lambda: "24.20.0") + assert _toolchain_latest(name) == expected + + +def test_python_targets_the_installed_interpreter(): + """Never python.org: on Arch the interpreter is pacman-managed.""" + import platform + assert _toolchain_latest("python") == platform.python_version() + + +def test_an_unknown_toolchain_has_no_target(): + assert _toolchain_latest("go") is None + + +def _offline(*_args): + raise http.Offline("no network") + + +def _explode(*_args): + raise RuntimeError("boom") diff --git a/dep_freshness/tests/test_toolchain_registry.py b/dep_freshness/tests/test_toolchain_registry.py new file mode 100644 index 0000000..a7da669 --- /dev/null +++ b/dep_freshness/tests/test_toolchain_registry.py @@ -0,0 +1,119 @@ +"""Flutter/Node release feeds, and the git-tag "registry" for shared libs.""" + +from __future__ import annotations + +import subprocess + +import pytest + +from dep_freshness.registries import gittag +from dep_freshness.registries import toolchain as tc +from dep_freshness.registries.http import Offline + +RELEASES = { + "current_release": {"stable": "abc123", "beta": "def456"}, + "releases": [ + {"hash": "def456", "channel": "beta", "version": "3.48.0-0.1.pre"}, + {"hash": "abc123", "channel": "stable", "version": "3.47.2", + "dart_sdk_version": "3.13.2"}, + {"hash": "old", "channel": "stable", "version": "3.47.1"}, + ], +} + + +@pytest.fixture +def feed(monkeypatch): + def install(payload): + monkeypatch.setattr(tc, "get_json", lambda *_a, **_k: payload) + return install + + +def test_flutter_reads_the_current_stable_release(feed): + feed(RELEASES) + assert tc.flutter_latest() == ("3.47.2", "3.13.2") + + +def test_flutter_falls_back_to_the_newest_stable_when_the_hash_is_unknown(feed): + payload = {**RELEASES, "current_release": {"stable": "missing"}} + feed(payload) + assert tc.flutter_latest() == ("3.47.2", None) + + +def test_flutter_with_no_feed_is_unknown(feed): + feed(None) + assert tc.flutter_latest() == (None, None) + + +def test_node_targets_lts_not_current(feed): + """Read literally, "latest stable" means Current; the box runs LTS.""" + feed([ + {"version": "v25.1.0", "lts": False}, + {"version": "v24.20.0", "lts": "Iron"}, + {"version": "v24.18.0", "lts": "Iron"}, + ]) + assert tc.node_latest() == "24.20.0" + assert tc.node_latest(channel="current") == "25.1.0" + + +def test_node_with_no_feed_is_unknown(feed): + feed(None) + assert tc.node_latest() is None + + +def test_python_reports_the_running_interpreter(): + import platform + assert tc.python_installed() == platform.python_version() + + +LS_REMOTE = ( + "aaa\trefs/tags/crdt_sync_dart-v0.10.0\n" + "bbb\trefs/tags/crdt_sync_dart-v0.11.0\n" + "ccc\trefs/tags/crdt_sync_flutter-v0.2.1\n" + "ddd\trefs/tags/design_system-v0.2.0\n" +) + + +@pytest.fixture +def ls_remote(monkeypatch): + monkeypatch.setattr(gittag, "host_reachable", lambda _url: True) + + def install(stdout="", returncode=0, error=None): + def fake_run(*_args, **_kwargs): + if error: + raise error + return subprocess.CompletedProcess([], returncode, stdout, "") + monkeypatch.setattr(subprocess, "run", fake_run) + return install + + +def test_the_newest_tag_for_the_named_package_wins(ls_remote): + ls_remote(LS_REMOTE) + assert gittag.latest("crdt_sync_dart") == "0.11.0" + + +def test_another_packages_tags_are_not_confused_for_ours(ls_remote): + ls_remote(LS_REMOTE) + assert gittag.latest("crdt_sync_flutter") == "0.2.1" + + +def test_a_package_with_no_tags_has_no_latest(ls_remote): + ls_remote(LS_REMOTE) + assert gittag.latest("web_ui") is None + + +def test_a_failing_ls_remote_degrades_to_offline(ls_remote): + ls_remote(returncode=128) + with pytest.raises(Offline): + gittag.latest("crdt_sync_dart") + + +def test_a_missing_git_binary_degrades_to_offline(ls_remote): + ls_remote(error=OSError("no git")) + with pytest.raises(Offline): + gittag.latest("crdt_sync_dart") + + +def test_an_unreachable_remote_degrades_to_offline(monkeypatch): + monkeypatch.setattr(gittag, "host_reachable", lambda _url: False) + with pytest.raises(Offline): + gittag.latest("crdt_sync_dart") diff --git a/dep_freshness/tests/test_versions.py b/dep_freshness/tests/test_versions.py new file mode 100644 index 0000000..969e3a8 --- /dev/null +++ b/dep_freshness/tests/test_versions.py @@ -0,0 +1,57 @@ +"""The fail-green class: a pre-release accepted as latest stable.""" + +from __future__ import annotations + +import pytest + +from dep_freshness.versions import behind, exact_pin, is_prerelease, newest_stable + + +@pytest.mark.parametrize("raw", [ + "2.0.0-beta.1", "1.0.0-rc1", "3.0.0-dev.4", "2.0.0rc1", "1.2.3a1", + "v4.0.0-alpha", "1.0.0.dev3", +]) +def test_prereleases_are_recognised(raw): + assert is_prerelease(raw) + + +@pytest.mark.parametrize("raw", ["1.0.0", "10.3.0", "v2.1.5", "0.14.4", "1.6.0+1"]) +def test_stable_versions_are_not_prereleases(raw): + assert not is_prerelease(raw) + + +def test_newest_stable_skips_prereleases(): + assert newest_stable(["1.0.0", "2.0.0-beta.1", "1.9.3"]) == "1.9.3" + + +def test_newest_stable_is_none_when_everything_is_a_prerelease(): + assert newest_stable(["1.0.0-a", "2.0.0-b"]) is None + + +def test_newest_stable_ignores_unparseable_entries(): + assert newest_stable(["not-a-version", "", None, "1.2.0"]) == "1.2.0" + + +@pytest.mark.parametrize("raw,expected", [ + ("1.6.0", "1.6.0"), ("==1.6.0", "1.6.0"), ("v2.0.0", "2.0.0"), + (" 1.0.0 ", "1.0.0"), +]) +def test_exact_pins_are_recognised(raw, expected): + assert exact_pin(raw) == expected + + +@pytest.mark.parametrize("raw", ["^1.6.0", ">=1.6.0", "any", "*", "", ">=1,<2", "~1.2"]) +def test_ranges_are_not_exact_pins(raw): + assert exact_pin(raw) is None + + +def test_behind_compares_versions(): + assert behind("1.6.0", "1.7.0") + assert not behind("1.7.0", "1.7.0") + assert not behind("1.8.0", "1.7.0") + + +def test_behind_never_fires_on_unparseable_input(): + """An unpinned constraint is UNPINNED, never dressed up as staleness.""" + assert not behind("^1.6.0", "1.7.0") + assert not behind("1.6.0", "latest") diff --git a/dep_freshness/versions.py b/dep_freshness/versions.py new file mode 100644 index 0000000..0a63e2e --- /dev/null +++ b/dep_freshness/versions.py @@ -0,0 +1,92 @@ +"""Version parsing shared by every ecosystem adapter. + +The dangerous bug class here fails *green*: npm publishes pre-releases to +`dist-tags.latest` and crates.io's `max_version` includes them, so an adapter +that trusts either reports "up to date" while the pin is behind. Every +"latest" that leaves this package goes through `newest_stable`. +""" + +from __future__ import annotations + +import re + +from packaging.version import InvalidVersion, Version + +# Dart/pub and Go use semver; PyPI uses PEP 440. `packaging` parses both well +# enough to compare, with this as the fallback for the odd Go pseudo-version. +_SEMVER = re.compile( + r"^v?(?P\d+(?:\.\d+){0,2})" + r"(?:-(?P
[0-9A-Za-z.-]+))?"
+    r"(?:\+(?P[0-9A-Za-z.-]+))?$"
+)
+
+
+def parse(raw: str) -> Version | None:
+    """A comparable version, or None when the string is not one."""
+    text = raw.strip().lstrip("v")
+    try:
+        return Version(text)
+    except InvalidVersion:
+        match = _SEMVER.match(raw.strip())
+        if not match:
+            return None
+        # `core` is `\d+(\.\d+){0,2}`, which Version always accepts.
+        return Version(match.group("core"))
+
+
+def is_prerelease(raw: str) -> bool:
+    """True for `1.2.0-beta.1`, `2.0.0rc1`, `1.0.0-dev` and friends.
+
+    Checked on the raw string as well as the parsed object because PEP 440
+    silently normalises some semver pre-release tags (`-alpha` survives, but
+    an unknown suffix can be dropped) and a dropped suffix reads as stable.
+    """
+    if "-" in raw.split("+", 1)[0].lstrip("v"):
+        return True
+    version = parse(raw)
+    return bool(version and (version.is_prerelease or version.is_devrelease))
+
+
+def newest_stable(candidates) -> str | None:
+    """The highest non-pre-release version in `candidates`, or None."""
+    best: tuple[Version, str] | None = None
+    for raw in candidates:
+        if not raw or is_prerelease(raw):
+            continue
+        version = parse(raw)
+        if version is None:
+            continue
+        if best is None or version > best[0]:
+            best = (version, str(raw).strip().lstrip("v"))
+    return best[1] if best else None
+
+
+def behind(pinned: str, latest: str) -> bool:
+    """True when `pinned` is strictly older than `latest`.
+
+    Unparseable input is never reported as behind: an unpinned or exotic
+    constraint is a different finding (`UNPINNED`) and must not be dressed up
+    as staleness.
+    """
+    left, right = parse(pinned), parse(latest)
+    if left is None or right is None:
+        return False
+    return left < right
+
+
+EXACT = re.compile(r"^\s*(?:==\s*)?v?\d+(?:\.\d+){0,2}(?:[-+][0-9A-Za-z.-]+)?\s*$")
+
+
+def exact_pin(constraint: str) -> str | None:
+    """The version an exact constraint names, or None if it is a range.
+
+    `1.6.0` and `==1.6.0` are exact; `^1.6.0`, `>=1.6.0`, `any` and `*` are
+    not. A range is not comparable to a registry answer, which is the whole
+    reason Q6 asked for exact pins.
+    """
+    if not constraint:
+        return None
+    text = str(constraint).strip()
+    if not EXACT.match(text):
+        return None
+    return text.lstrip("=").strip().lstrip("v")
diff --git a/scripts/check_dependency_freshness.sh b/scripts/check_dependency_freshness.sh
new file mode 100755
index 0000000..8393e05
--- /dev/null
+++ b/scripts/check_dependency_freshness.sh
@@ -0,0 +1,42 @@
+#!/bin/bash
+
+# ============================================================================
+# Fail if any dependency is behind its ecosystem's newest stable release.
+#
+# "Are we on the newest version?" is only mechanically answerable when the
+# manifest states an exact version, so this gate checks two things at once:
+# that every dependency is exact-pinned, and that the pin equals latest stable.
+#
+# The real check lives in Python (dep_freshness/check.py) so the pre-commit
+# hook, the CI workflow and the SessionStart hook read one implementation --
+# see rules/shell.instructions.md on never embedding another language's logic
+# inline, and the four-way fork of the 250-line cap on why delegation matters.
+#
+# Usage:
+#   scripts/check_dependency_freshness.sh  [ ...]   # pre-commit
+#   scripts/check_dependency_freshness.sh --all --strict        # CI
+#
+# Exit: 0 current | 1 behind | 2 allowlist wrong | 3 undeterminable (offline)
+# ============================================================================
+
+set -euo pipefail
+
+readonly UTILS_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+readonly CHECKER="$UTILS_ROOT/dep_freshness/check.py"
+
+main() {
+    if [[ $# -eq 0 ]]; then
+        echo "Usage: $(basename "$0") ... | --all [--strict]" >&2
+        exit 1
+    fi
+    if [[ ! -f "$CHECKER" ]]; then
+        echo "Error: checker not found at $CHECKER" >&2
+        echo "Clone kuhyx/utils next to this repo, or set UTILS_ROOT." >&2
+        exit 1
+    fi
+
+    PYTHONPATH="$UTILS_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
+        python3 -m dep_freshness "$@"
+}
+
+main "$@"