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

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

Three deliberate design points:

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

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

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

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

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

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

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

74 lines
2.5 KiB
Python

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