mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:27:16 +01:00
Add the shared dependency-freshness gate
"Are we on the newest version?" was not mechanically answerable across these repos: constraint styles had drifted per-ecosystem and a caret range is "newest-compatible" by definition, which gives a checker nothing to compare. This gate makes it answerable and then enforces the answer. Checks two things at once -- that every dependency carries an exact pin, and that the pin equals its registry's latest STABLE release -- across pub.dev, PyPI, npm, crates.io, the Go proxy, kuhyx/utils git tags, and the Flutter / Node / Python toolchains. Three deliberate design points: * Pre-releases are rejected per-ecosystem, because every ecosystem leaks them differently and each leak fails GREEN. npm lets a maintainer point `dist-tags.latest` at an rc; crates.io's `max_version` includes them; PyPI's legacy `info.version` includes yanked releases. Unit-tested with fixtures, since an end-to-end run cannot surface a wrong "up to date". * Offline, the gate serves an expired cache entry and PASSES with a DEGRADED banner (exit 3 only under --strict, which is CI). `--no-verify` is banned here, so a hook that hard-failed offline would leave no way to commit at all. * The allowlist has two entry classes. `blocked_by: transitive:<pkg>@<ver>` is a predicate that clears itself when upstream moves and must NOT carry a date; everything else is discretionary and requires `expires`, capped at 90 days. Exact pins plus latest-everywhere is provably unsatisfiable when a dependency internally constrains a shared transitive, and expiring the predicate class on a calendar would turn the gate red for a reason no commit could fix. An entry with nothing left to excuse is itself an error. One hook id, one shim, one shared implementation -- always delegating, so it cannot fork the way the 250-line cap forked into four ids, two of which were independent reimplementations. 256 tests, 100% branch coverage on every module of the gate. Also adds the workflow that runs them, which incidentally gives md_naming's 71 tests their first CI run. Not yet installed in any repo: a repo that gets the gate before its deps are bumped is instantly and permanently red, so the gate and the upgrade land together, per repo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
This commit is contained in:
parent
777c7cb93a
commit
2a2f18ca56
40
.github/workflows/shared-gates-tests.yml
vendored
Normal file
40
.github/workflows/shared-gates-tests.yml
vendored
Normal file
@ -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
|
||||
6
dep_freshness/__init__.py
Normal file
6
dep_freshness/__init__.py
Normal file
@ -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).
|
||||
"""
|
||||
10
dep_freshness/__main__.py
Normal file
10
dep_freshness/__main__.py
Normal file
@ -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())
|
||||
108
dep_freshness/_tables.py
Normal file
108
dep_freshness/_tables.py
Normal file
@ -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:"
|
||||
117
dep_freshness/allowlist.py
Normal file
117
dep_freshness/allowlist.py
Normal file
@ -0,0 +1,117 @@
|
||||
"""Parse and validate `dependency-freshness.allowlist.yaml`.
|
||||
|
||||
Two entry classes, and the distinction is the whole point:
|
||||
|
||||
* `blocked_by: transitive:<pkg>@<ver>` 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:<package>@<version>"
|
||||
)
|
||||
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
|
||||
112
dep_freshness/cache.py
Normal file
112
dep_freshness/cache.py
Normal file
@ -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
|
||||
158
dep_freshness/check.py
Normal file
158
dep_freshness/check.py
Normal file
@ -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
|
||||
84
dep_freshness/constraints.py
Normal file
84
dep_freshness/constraints.py
Normal file
@ -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)
|
||||
73
dep_freshness/discover.py
Normal file
73
dep_freshness/discover.py
Normal file
@ -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)
|
||||
88
dep_freshness/evaluate.py
Normal file
88
dep_freshness/evaluate.py
Normal file
@ -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
|
||||
92
dep_freshness/models.py
Normal file
92
dep_freshness/models.py
Normal file
@ -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())
|
||||
6
dep_freshness/parsers/__init__.py
Normal file
6
dep_freshness/parsers/__init__.py
Normal file
@ -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.
|
||||
"""
|
||||
39
dep_freshness/parsers/_lines.py
Normal file
39
dep_freshness/parsers/_lines.py
Normal file
@ -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<indent>\s*)-?\s*["\']?(?P<key>[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)
|
||||
33
dep_freshness/parsers/fvm.py
Normal file
33
dep_freshness/parsers/fvm.py
Normal file
@ -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),
|
||||
)]
|
||||
53
dep_freshness/parsers/golang.py
Normal file
53
dep_freshness/parsers/golang.py
Normal file
@ -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<module>[^\s()]+)\s+(?P<version>v\S+)\s*(?P<rest>.*)$")
|
||||
|
||||
|
||||
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
|
||||
72
dep_freshness/parsers/javascript.py
Normal file
72
dep_freshness/parsers/javascript.py
Normal file
@ -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),
|
||||
)]
|
||||
101
dep_freshness/parsers/pubspec.py
Normal file
101
dep_freshness/parsers/pubspec.py
Normal file
@ -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
|
||||
99
dep_freshness/parsers/python.py
Normal file
99
dep_freshness/parsers/python.py
Normal file
@ -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<name>[A-Za-z0-9][A-Za-z0-9._-]*)"
|
||||
r"(?:\[(?P<extras>[^\]]*)\])?"
|
||||
r"\s*(?P<constraint>.*)$"
|
||||
)
|
||||
_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),
|
||||
)]
|
||||
51
dep_freshness/parsers/rust.py
Normal file
51
dep_freshness/parsers/rust.py
Normal file
@ -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
|
||||
5
dep_freshness/registries/__init__.py
Normal file
5
dep_freshness/registries/__init__.py
Normal file
@ -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.
|
||||
"""
|
||||
25
dep_freshness/registries/cargo.py
Normal file
25
dep_freshness/registries/cargo.py
Normal file
@ -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)
|
||||
42
dep_freshness/registries/gittag.py
Normal file
42
dep_freshness/registries/gittag.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""Shared libraries consumed by git tag out of `kuhyx/utils`.
|
||||
|
||||
"Newest" here is the newest tag matching `<package>-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<name>[A-Za-z0-9_.-]+)-v(?P<version>[0-9][^\s]*)$")
|
||||
|
||||
|
||||
def latest(package: str, remote: str = UTILS_TAG_REMOTE) -> str | None:
|
||||
"""Highest semver tag prefixed with `<package>-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)
|
||||
17
dep_freshness/registries/gomod.py
Normal file
17
dep_freshness/registries/gomod.py
Normal file
@ -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")
|
||||
79
dep_freshness/registries/http.py
Normal file
79
dep_freshness/registries/http.py
Normal file
@ -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
|
||||
23
dep_freshness/registries/npmjs.py
Normal file
23
dep_freshness/registries/npmjs.py
Normal file
@ -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())
|
||||
23
dep_freshness/registries/pub.py
Normal file
23
dep_freshness/registries/pub.py
Normal file
@ -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)
|
||||
48
dep_freshness/registries/pypi.py
Normal file
48
dep_freshness/registries/pypi.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""PyPI: latest stable via the modern simple index.
|
||||
|
||||
The legacy `/pypi/<name>/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)
|
||||
51
dep_freshness/registries/toolchain.py
Normal file
51
dep_freshness/registries/toolchain.py
Normal file
@ -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()
|
||||
161
dep_freshness/report.py
Normal file
161
dep_freshness/report.py
Normal file
@ -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)
|
||||
108
dep_freshness/resolve.py
Normal file
108
dep_freshness/resolve.py
Normal file
@ -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)
|
||||
1
dep_freshness/tests/__init__.py
Normal file
1
dep_freshness/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the shared dependency-freshness gate."""
|
||||
61
dep_freshness/tests/conftest.py
Normal file
61
dep_freshness/tests/conftest.py
Normal file
@ -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
|
||||
154
dep_freshness/tests/test_allowlist.py
Normal file
154
dep_freshness/tests/test_allowlist.py
Normal file
@ -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:<package>@<version>"):
|
||||
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)
|
||||
188
dep_freshness/tests/test_check.py
Normal file
188
dep_freshness/tests/test_check.py
Normal file
@ -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)
|
||||
63
dep_freshness/tests/test_constraints.py
Normal file
63
dep_freshness/tests/test_constraints.py
Normal file
@ -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
|
||||
73
dep_freshness/tests/test_discover.py
Normal file
73
dep_freshness/tests/test_discover.py
Normal file
@ -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() {}")) == []
|
||||
95
dep_freshness/tests/test_edges.py
Normal file
95
dep_freshness/tests/test_edges.py
Normal file
@ -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
|
||||
121
dep_freshness/tests/test_evaluate.py
Normal file
121
dep_freshness/tests/test_evaluate.py
Normal file
@ -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
|
||||
47
dep_freshness/tests/test_golang.py
Normal file
47
dep_freshness/tests/test_golang.py
Normal file
@ -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") == []
|
||||
122
dep_freshness/tests/test_http.py
Normal file
122
dep_freshness/tests/test_http.py
Normal file
@ -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
|
||||
147
dep_freshness/tests/test_parsers.py
Normal file
147
dep_freshness/tests/test_parsers.py
Normal file
@ -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"
|
||||
96
dep_freshness/tests/test_parsers_edges.py
Normal file
96
dep_freshness/tests/test_parsers_edges.py
Normal file
@ -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
|
||||
105
dep_freshness/tests/test_registries.py
Normal file
105
dep_freshness/tests/test_registries.py
Normal file
@ -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"
|
||||
209
dep_freshness/tests/test_report.py
Normal file
209
dep_freshness/tests/test_report.py
Normal file
@ -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
|
||||
147
dep_freshness/tests/test_resolve.py
Normal file
147
dep_freshness/tests/test_resolve.py
Normal file
@ -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")
|
||||
119
dep_freshness/tests/test_toolchain_registry.py
Normal file
119
dep_freshness/tests/test_toolchain_registry.py
Normal file
@ -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")
|
||||
57
dep_freshness/tests/test_versions.py
Normal file
57
dep_freshness/tests/test_versions.py
Normal file
@ -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")
|
||||
92
dep_freshness/versions.py
Normal file
92
dep_freshness/versions.py
Normal file
@ -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<core>\d+(?:\.\d+){0,2})"
|
||||
r"(?:-(?P<pre>[0-9A-Za-z.-]+))?"
|
||||
r"(?:\+(?P<build>[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")
|
||||
42
scripts/check_dependency_freshness.sh
Executable file
42
scripts/check_dependency_freshness.sh
Executable file
@ -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 <file> [<file> ...] # 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") <file>... | --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 "$@"
|
||||
Loading…
Reference in New Issue
Block a user