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:
Krzysztof Rudnicki 2026-08-28 13:00:35 +02:00
parent 777c7cb93a
commit 2a2f18ca56
49 changed files with 3863 additions and 0 deletions

View 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

View 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
View 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
View 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
View 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
View 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
View 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

View 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
View 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
View 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
View 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())

View 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.
"""

View 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)

View 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),
)]

View 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

View 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),
)]

View 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

View 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),
)]

View 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

View 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.
"""

View 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)

View 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)

View 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")

View 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

View 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())

View 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)

View 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)

View 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
View 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
View 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)

View File

@ -0,0 +1 @@
"""Tests for the shared dependency-freshness gate."""

View 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

View 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)

View 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)

View 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

View 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() {}")) == []

View 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

View 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

View 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") == []

View 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

View 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"

View 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

View 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"

View 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

View 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")

View 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")

View 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
View 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")

View 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 "$@"