diff --git a/dep_freshness/_tables.py b/dep_freshness/_tables.py index ed000ff..79a73b7 100644 --- a/dep_freshness/_tables.py +++ b/dep_freshness/_tables.py @@ -18,13 +18,27 @@ CARGO: Final = "cargo" GOMOD: Final = "gomod" GITTAG: Final = "gittag" MAVEN: Final = "maven" +# A JitPack artifact pinned to a commit hash instead of a tag. "Newest" is +# the source repository's default-branch HEAD: a hash pin has no release to +# wait for, so the only reading of "current" it admits is "at HEAD". +GITCOMMIT: Final = "gitcommit" TOOLCHAIN: Final = "toolchain" # The constraint a workflow parser writes for a version MATRIX. Standing # decision (kuhy, 2026-08-28): one toolchain version per repo, always newest, # so a matrix is a finding to delete rather than a range to satisfy. MATRIX: Final = "matrix" -ECOSYSTEMS: Final = (PUB, PYPI, NPM, CARGO, GOMOD, GITTAG, MAVEN, TOOLCHAIN) +ECOSYSTEMS: Final = ( + PUB, + PYPI, + NPM, + CARGO, + GOMOD, + GITTAG, + MAVEN, + GITCOMMIT, + TOOLCHAIN, +) # --- Registry endpoints ----------------------------------------------------- @@ -34,19 +48,25 @@ 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" + "https://storage.googleapis.com/flutter_infra_release/releases/releases_linux.json" ) NODE_RELEASES: Final = "https://nodejs.org/dist/index.json" GRADLE_RELEASES: Final = "https://services.gradle.org/versions/current" # Asked in order; a 404 moves on. Google first because AndroidX and the # Android Gradle plugin exist nowhere else, and Central answers 404 fast. +# JitPack last: it serves tag metadata for `com.github.*` artifacts that +# were never published anywhere else (PhotoView, DirectionalViewPager, ...). MAVEN_REPOS: Final = ( "https://dl.google.com/dl/android/maven2/", "https://repo1.maven.org/maven2/", "https://plugins.gradle.org/m2/", + "https://jitpack.io/", ) UTILS_TAG_REMOTE: Final = "https://github.com/kuhyx/utils" +# JitPack coordinates: `com.github.:` or +# `com.github..:`; the GitHub repo is what "HEAD" means. +JITPACK_GROUP_PREFIX: Final = "com.github." +GITHUB_REMOTE: Final = "https://github.com/{repo}" # pnpm 11 quarantines packages younger than this before it will install them, # to blunt the window in which a compromised publish is live. The gate matches @@ -67,7 +87,7 @@ 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 +TTL_GITTAG_SECONDS: Final = 24 * 3600 # also gitcommit: both cost a subprocess HTTP_TIMEOUT: Final = 10.0 PROBE_TIMEOUT: Final = 2.0 MAX_WORKERS: Final = 8 @@ -101,28 +121,66 @@ REQUIREMENTS_PATTERN: Final = r"^requirements.*\.txt$" # repo being checked. Without it `--all` walks utils' own manifests and fails # the consuming repo for staleness in a directory that repo does not own -- # which is exactly how punchme's first green local run turned red in CI. -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", ".utils", -}) +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", + ".utils", + } +) # --- 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", -}) +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", -}) +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" diff --git a/dep_freshness/cache.py b/dep_freshness/cache.py index 83845a8..27020ff 100644 --- a/dep_freshness/cache.py +++ b/dep_freshness/cache.py @@ -8,17 +8,18 @@ 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 dataclasses import dataclass +from pathlib import Path from dep_freshness._tables import ( CACHE_FILE, CACHE_PATH_ENV, DEFAULT_CACHE_DIR, + GITCOMMIT, GITTAG, TTL_GITTAG_SECONDS, TTL_SECONDS, @@ -32,8 +33,8 @@ def cache_dir() -> Path: 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 + """Git refs move rarely and cost a subprocess, so they cache longer.""" + return TTL_GITTAG_SECONDS if ecosystem in (GITTAG, GITCOMMIT) else TTL_SECONDS @dataclass diff --git a/dep_freshness/check.py b/dep_freshness/check.py index e665349..ee9fd3f 100644 --- a/dep_freshness/check.py +++ b/dep_freshness/check.py @@ -15,9 +15,9 @@ 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 pathlib import Path from dep_freshness import report from dep_freshness._tables import NPM @@ -213,10 +213,13 @@ def main(argv: list[str] | None = None) -> int: print(report.as_json(failing, entries, code)) return code + # Both sections print when both apply: an undeterminable package hidden + # behind a stale one only surfaces after the bump, as a fresh red run. + if unknown or resolver.degraded: + report.degraded(resolver.degraded or [f.label for f in unknown]) if real: report.violations(real, root) return 1 if unknown or resolver.degraded: - report.degraded(resolver.degraded or [f.label for f in unknown]) return 3 if args.strict else 0 return 0 diff --git a/dep_freshness/evaluate.py b/dep_freshness/evaluate.py index 8e91446..a2200dd 100644 --- a/dep_freshness/evaluate.py +++ b/dep_freshness/evaluate.py @@ -15,12 +15,31 @@ purpose: from __future__ import annotations -from dep_freshness._tables import MATRIX, TOOLCHAIN +from dep_freshness._tables import GITCOMMIT, MATRIX, 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 +# What JitPack accepts as a version, and what a bump pastes into the catalog. +_SHORT_SHA = 10 + + +def _commit(dep: Dep, head: str) -> Finding | None: + """A hash pin is current when the default branch's HEAD still starts with it.""" + short = head[:_SHORT_SHA] + if not dep.pinned: + return Finding(dep, Severity.UNPINNED, short, detail=f"pin a commit ({short})") + if head.lower().startswith(dep.pinned.lower()): + return None + return Finding( + dep, + Severity.STALE, + short, + detail="the source repository's default branch has moved on; a commit " + "pin has no release to wait for", + ) + def _toolchain(dep: Dep, latest: str) -> Finding | None: if dep.pinned: @@ -86,6 +105,8 @@ def judge(dep: Dep, answer: Answer) -> Finding | None: if dep.ecosystem == TOOLCHAIN: return _toolchain(dep, latest) + if dep.ecosystem == GITCOMMIT: + return _commit(dep, latest) if dep.peer: # A peerDependency declares what a CONSUMER may bring, so exact-pinning diff --git a/dep_freshness/parsers/gradle.py b/dep_freshness/parsers/gradle.py index ce3fb0a..c8106b4 100644 --- a/dep_freshness/parsers/gradle.py +++ b/dep_freshness/parsers/gradle.py @@ -33,7 +33,7 @@ from typing import Any import tomllib -from dep_freshness._tables import MAVEN, TOOLCHAIN +from dep_freshness._tables import GITCOMMIT, JITPACK_GROUP_PREFIX, MAVEN, TOOLCHAIN from dep_freshness.models import Dep from dep_freshness.parsers._lines import index from dep_freshness.versions import exact_pin @@ -43,6 +43,15 @@ WRAPPER_NAME = "gradle-wrapper.properties" _DISTRIBUTION = re.compile( r"gradle-(?P\d+(?:\.\d+)*(?:-[0-9A-Za-z.-]+?)?)-(?:bin|all)\.zip" ) +# JitPack accepts any commit as a version; 7 to 40 hex digits and nothing +# else is one. `1.0.0` never matches (dots), and a tag like `v2.3.0` has a +# letter outside a-f. +_COMMIT = re.compile(r"^[0-9a-f]{7,40}$") + + +def _commit_pin(name: str, version: str) -> bool: + """True for `com.github.*` at a commit hash: a gitcommit dep, not maven.""" + return name.startswith(JITPACK_GROUP_PREFIX) and bool(_COMMIT.match(version)) def _rich(value: Any) -> str: @@ -104,14 +113,17 @@ def parse_catalog(path: Path) -> list[Dep]: continue # BOM-managed, or a dangling ref the build itself rejects if plugin: name = f"{name}:{name}.gradle.plugin" + ecosystem, pinned = MAVEN, exact_pin(version) + if _commit_pin(name, version): + ecosystem, pinned = GITCOMMIT, version.strip() deps.append( Dep( - ecosystem=MAVEN, + ecosystem=ecosystem, name=name, constraint=version, path=path, line=line, - pinned=exact_pin(version), + pinned=pinned, ) ) return deps diff --git a/dep_freshness/registries/_git.py b/dep_freshness/registries/_git.py new file mode 100644 index 0000000..dd1eede --- /dev/null +++ b/dep_freshness/registries/_git.py @@ -0,0 +1,38 @@ +"""`git ls-remote` as the one subprocess the registries share. + +Deliberately not the GitHub REST API: that allows 60 unauthenticated requests +an hour, which ~20 consuming manifests would exhaust on a single `--all` run. +`ls-remote` is unmetered and answers tags and HEAD alike. +""" + +from __future__ import annotations + +import subprocess + +from dep_freshness.registries.http import Offline, host_reachable + + +def ls_remote(remote: str, *args: str) -> list[str]: + """Every `\\t` line `git ls-remote` prints for `remote`. + + `--options` go before the URL and ref patterns (`HEAD`) after it: git + reads the first positional as the repository, so `ls-remote HEAD ` + fails with "'HEAD' does not appear to be a git repository". + """ + if not host_reachable(remote): + raise Offline(remote) + options = [a for a in args if a.startswith("-")] + patterns = [a for a in args if not a.startswith("-")] + try: + result = subprocess.run( + ["git", "ls-remote", *options, remote, *patterns], + 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}") + return [line.strip() for line in result.stdout.splitlines() if line.strip()] diff --git a/dep_freshness/registries/cargo.py b/dep_freshness/registries/cargo.py index 02ceced..416e60d 100644 --- a/dep_freshness/registries/cargo.py +++ b/dep_freshness/registries/cargo.py @@ -8,7 +8,7 @@ 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 +from dep_freshness.versions import reference def latest(name: str) -> str | None: @@ -19,7 +19,5 @@ def latest(name: str) -> str | None: 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) + live = [v.get("num") for v in payload.get("versions") or [] if not v.get("yanked")] + return reference(live) diff --git a/dep_freshness/registries/gitcommit.py b/dep_freshness/registries/gitcommit.py new file mode 100644 index 0000000..47a0175 --- /dev/null +++ b/dep_freshness/registries/gitcommit.py @@ -0,0 +1,47 @@ +"""JitPack artifacts pinned to a commit: "newest" is the default branch's HEAD. + +Seven of TachiyomiSY's dependencies are `com.github.:` at a +short hash. No registry lists a "latest" for those, and a hash is not a +version, so the gate's question becomes the only one a commit pin admits: +is the pinned commit still what the source repository's default branch +points at? (kuhy, 2026-09-12: chosen over never-expiring allowlist entries.) + +The answer is the full HEAD sha; `evaluate` compares by prefix so a 7-, 10- +or 40-character pin all work, and reports the first ten characters, which +is what JitPack accepts as a version. +""" + +from __future__ import annotations + +from dep_freshness._tables import GITHUB_REMOTE, JITPACK_GROUP_PREFIX +from dep_freshness.registries._git import ls_remote + + +def source_repo(name: str) -> str | None: + """`owner/repo` behind a JitPack coordinate, or None for anything else. + + `com.github.mihonapp:image-decoder` -> `mihonapp/image-decoder`; + `com.github.arkon.FlexibleAdapter:flexible-adapter` -> `arkon/FlexibleAdapter` + (a multi-module build names the repo in the group, the module after the + colon). + """ + group, sep, artifact = name.partition(":") + if not sep or not artifact or not group.startswith(JITPACK_GROUP_PREFIX): + return None + parts = group[len(JITPACK_GROUP_PREFIX) :].split(".") + if not parts[0]: + return None + repo = parts[1] if len(parts) > 1 and parts[1] else artifact + return f"{parts[0]}/{repo}" + + +def latest(name: str) -> str | None: + """The default branch's HEAD sha, or None when `name` is not a JitPack pin.""" + repo = source_repo(name) + if repo is None: + return None + for line in ls_remote(GITHUB_REMOTE.format(repo=repo), "HEAD"): + sha, _, ref = line.partition("\t") + if ref.strip() == "HEAD" and sha: + return sha.strip() + return None diff --git a/dep_freshness/registries/gittag.py b/dep_freshness/registries/gittag.py index 9c54a45..d1c01f7 100644 --- a/dep_freshness/registries/gittag.py +++ b/dep_freshness/registries/gittag.py @@ -1,18 +1,15 @@ """Shared libraries consumed by git tag out of `kuhyx/utils`. "Newest" here is the newest tag matching `-vX.Y.Z`, not a registry -answer. Deliberately `git ls-remote` rather than the GitHub REST API: that API -allows 60 unauthenticated requests an hour, which ~20 consuming manifests would -exhaust on a single `--all` run. +answer. The transport is `git ls-remote` (see `_git.py`), never the REST API. """ 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.registries._git import ls_remote from dep_freshness.versions import newest_stable _TAG = re.compile(r"refs/tags/(?P[A-Za-z0-9_.-]+)-v(?P[0-9][^\s]*)$") @@ -20,23 +17,9 @@ _TAG = re.compile(r"refs/tags/(?P[A-Za-z0-9_.-]+)-v(?P[0-9][^\s]* def latest(package: str, remote: str = UTILS_TAG_REMOTE) -> str | None: """Highest semver tag prefixed with `-v`, or None if there is none.""" - if not host_reachable(remote): - raise Offline(remote) - try: - result = subprocess.run( - ["git", "ls-remote", "--tags", "--refs", remote], - capture_output=True, - text=True, - timeout=20, - check=False, - ) - except (OSError, subprocess.SubprocessError) as exc: - raise Offline(f"{remote}: {exc}") from exc - if result.returncode != 0: - raise Offline(f"{remote}: git ls-remote exit {result.returncode}") found = [] - for line in result.stdout.splitlines(): - match = _TAG.search(line.strip()) + for line in ls_remote(remote, "--tags", "--refs"): + match = _TAG.search(line) if match and match.group("name") == package: found.append(match.group("version")) return newest_stable(found) diff --git a/dep_freshness/registries/maven.py b/dep_freshness/registries/maven.py index f99bafe..be73ec5 100644 --- a/dep_freshness/registries/maven.py +++ b/dep_freshness/registries/maven.py @@ -1,14 +1,19 @@ """Maven repositories: newest stable of `group:artifact`, from maven-metadata. -Three repositories, asked in order until one knows the artifact: Google +Four repositories, asked in order until one knows the artifact: Google Maven (every `androidx.*` and `com.android.*` artifact lives ONLY there), -Maven Central, and the Gradle plugin portal (plugin marker artifacts -`:.gradle.plugin`). A 404 means "not here", not "not anywhere", so -it moves on; anything else surfaces as `Offline` from the HTTP layer. +Maven Central, the Gradle plugin portal (plugin marker artifacts +`:.gradle.plugin`) and JitPack (tag metadata for `com.github.*` +artifacts published nowhere else). A 404 means "not here", not "not +anywhere", so it moves on; anything else surfaces as `Offline` from the HTTP +layer. `` and `` in the metadata are not trusted: AndroidX writes `1.2.0-alpha03` into `` routinely. The full `` list goes -through `newest_stable` like every other ecosystem. +through `newest_stable` like every other ecosystem. Only when NO repository +has a stable release at all does the newest pre-release become the reference +(`versions.reference`), which is how `androidx.biometric:biometric-ktx`, +alpha-only since 2021, gets a checkable answer. """ from __future__ import annotations @@ -17,7 +22,7 @@ from xml.etree import ElementTree from dep_freshness._tables import MAVEN_REPOS from dep_freshness.registries.http import get_text -from dep_freshness.versions import newest_stable +from dep_freshness.versions import newest_stable, reference def metadata_url(repo: str, name: str) -> str | None: @@ -42,6 +47,7 @@ def versions_in(xml: str) -> list[str]: def latest(name: str) -> str | None: + seen: list[str] = [] for repo in MAVEN_REPOS: url = metadata_url(repo, name) if url is None: @@ -49,7 +55,9 @@ def latest(name: str) -> str | None: body = get_text(url) if body is None: continue - stable = newest_stable(versions_in(body)) + versions = versions_in(body) + stable = newest_stable(versions) if stable is not None: return stable - return None + seen.extend(versions) + return reference(seen) diff --git a/dep_freshness/registries/npmjs.py b/dep_freshness/registries/npmjs.py index b6f7714..d7d2c9a 100644 --- a/dep_freshness/registries/npmjs.py +++ b/dep_freshness/registries/npmjs.py @@ -9,7 +9,7 @@ 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 +from dep_freshness.versions import is_prerelease, reference def latest(name: str) -> str | None: @@ -20,4 +20,4 @@ def latest(name: str) -> str | 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()) + return reference((payload.get("versions") or {}).keys()) diff --git a/dep_freshness/registries/pub.py b/dep_freshness/registries/pub.py index 9827110..b20e48b 100644 --- a/dep_freshness/registries/pub.py +++ b/dep_freshness/registries/pub.py @@ -4,7 +4,7 @@ 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 +from dep_freshness.versions import newest_stable, reference def latest(name: str) -> str | None: @@ -20,4 +20,4 @@ def latest(name: str) -> str | None: if candidate and newest_stable([candidate]): return candidate versions = [v.get("version") for v in payload.get("versions") or []] - return newest_stable(versions) + return reference(versions) diff --git a/dep_freshness/registries/pypi.py b/dep_freshness/registries/pypi.py index 8297acd..4a58de9 100644 --- a/dep_freshness/registries/pypi.py +++ b/dep_freshness/registries/pypi.py @@ -10,7 +10,7 @@ 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 +from dep_freshness.versions import reference _SUFFIXES = (".tar.gz", ".zip", ".whl", ".tar.bz2", ".egg") @@ -44,5 +44,5 @@ def latest(name: str) -> str | None: if found: live.add(found) if live: - return newest_stable(live) - return newest_stable(versions) + return reference(live) + return reference(versions) diff --git a/dep_freshness/resolve.py b/dep_freshness/resolve.py index c74e30f..5ec74c6 100644 --- a/dep_freshness/resolve.py +++ b/dep_freshness/resolve.py @@ -12,6 +12,7 @@ from dataclasses import dataclass from dep_freshness._tables import ( CARGO, + GITCOMMIT, GITTAG, GOMOD, MAVEN, @@ -23,7 +24,16 @@ from dep_freshness._tables import ( ) from dep_freshness.cache import Cache from dep_freshness.models import Dep -from dep_freshness.registries import cargo, gittag, gomod, maven, npmjs, pub, pypi +from dep_freshness.registries import ( + cargo, + gitcommit, + gittag, + gomod, + maven, + npmjs, + pub, + pypi, +) from dep_freshness.registries import toolchain as tc from dep_freshness.registries.http import Offline @@ -35,6 +45,7 @@ _LOOKUP = { GOMOD: gomod.latest, GITTAG: gittag.latest, MAVEN: maven.latest, + GITCOMMIT: gitcommit.latest, } @@ -43,8 +54,8 @@ 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 + 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: diff --git a/dep_freshness/tests/test_check.py b/dep_freshness/tests/test_check.py index 5728545..4de51cb 100644 --- a/dep_freshness/tests/test_check.py +++ b/dep_freshness/tests/test_check.py @@ -111,3 +111,23 @@ def _offline(_ecosystem, _name): def test_the_cache_directory_is_honoured(cache_dir): assert os.environ["DEP_FRESHNESS_CACHE"] == str(cache_dir) + + +def test_an_undeterminable_package_is_reported_beside_a_stale_one( + repo, run, capsys, monkeypatch +): + """Hidden behind the stale finding it only surfaced after the bump, as a + fresh red run nobody could have planned for.""" + from dep_freshness.registries.http import Offline + + def fetch(ecosystem, package): + if package == "http": + return "1.7.0" + raise Offline(package) + + monkeypatch.setattr("dep_freshness.resolve._fetch", fetch) + write(repo, "pubspec.yaml", STALE_PUBSPEC) + assert run("--all", "--strict") == 1 + err = capsys.readouterr().err + assert "pub:http" in err + assert "DEGRADED" in err diff --git a/dep_freshness/tests/test_gitcommit.py b/dep_freshness/tests/test_gitcommit.py new file mode 100644 index 0000000..70608b1 --- /dev/null +++ b/dep_freshness/tests/test_gitcommit.py @@ -0,0 +1,175 @@ +"""JitPack commit pins: "newest" is the source repo's default-branch HEAD. + +The fail-green case here is a hash the version parser cannot read: `behind` +returns False for unparseable input, so without its own ecosystem a moved +default branch would pass silently. Every path below is pinned down. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from dep_freshness.cache import ttl_for +from dep_freshness.evaluate import judge +from dep_freshness.models import Dep, Severity +from dep_freshness.parsers import gradle +from dep_freshness.registries import _git, gitcommit +from dep_freshness.registries.http import Offline +from dep_freshness.resolve import _LOOKUP, Answer +from dep_freshness.tests.conftest import write + +HEAD = "844a07002c3d2f2b68a6b26a25e8e401777c73e7" + + +@pytest.fixture +def ls_remote(monkeypatch): + monkeypatch.setattr(_git, "host_reachable", lambda _url: True) + calls: list[list[str]] = [] + + def install(stdout="", returncode=0, error=None): + def fake_run(args, **_kwargs): + calls.append(args) + if error: + raise error + return subprocess.CompletedProcess(args, returncode, stdout, "") + + monkeypatch.setattr(subprocess, "run", fake_run) + return calls + + return install + + +@pytest.mark.parametrize( + "name,expected", + [ + ("com.github.mihonapp:image-decoder", "mihonapp/image-decoder"), + ("com.github.arkon.FlexibleAdapter:flexible-adapter", "arkon/FlexibleAdapter"), + ("com.github.zhanghai.quickjs-java:quickjs-android", "zhanghai/quickjs-java"), + ("com.github.owner.repo.deeper:module", "owner/repo"), + ("com.github.owner.:module", "owner/module"), + ("io.coil-kt:coil", None), + ("com.github.:module", None), + ("com.github.owner", None), + ("com.github.owner:", None), + ], +) +def test_the_github_repo_behind_a_jitpack_coordinate(name, expected): + assert gitcommit.source_repo(name) == expected + + +def test_latest_is_the_default_branch_head(ls_remote): + calls = ls_remote(f"{HEAD}\tHEAD\n{HEAD}\trefs/heads/master\n") + assert gitcommit.latest("com.github.arkon.FlexibleAdapter:flexible-adapter") == HEAD + assert calls[-1] == [ + "git", + "ls-remote", + "https://github.com/arkon/FlexibleAdapter", + "HEAD", + ] + + +def test_no_head_line_means_no_answer(ls_remote): + ls_remote("\n \nabc\trefs/heads/dev\n") + assert gitcommit.latest("com.github.mihonapp:image-decoder") is None + + +def test_a_non_jitpack_coordinate_never_touches_git(ls_remote): + calls = ls_remote(f"{HEAD}\tHEAD\n") + assert gitcommit.latest("io.coil-kt:coil") is None + assert calls == [] + + +def test_ls_remote_failures_degrade_to_offline(ls_remote, monkeypatch): + ls_remote(returncode=128) + with pytest.raises(Offline): + gitcommit.latest("com.github.mihon:unifile") + ls_remote(error=OSError("no git")) + with pytest.raises(Offline): + gitcommit.latest("com.github.mihon:unifile") + monkeypatch.setattr(_git, "host_reachable", lambda _url: False) + with pytest.raises(Offline): + gitcommit.latest("com.github.mihon:unifile") + + +CATALOG = """\ +[versions] +unifile = "08f224c8f9" +photoView = "2.3.0" +tagged = "abc1234" + +[libraries] +unifile = { module = "com.github.mihon:unifile", version.ref = "unifile" } +photoView = { module = "com.github.chrisbanes:PhotoView", version.ref = "photoView" } +inline = "com.github.mihonapp:image-decoder:e03b81e18a" +central = { module = "io.coil-kt:coil", version = "deadbeef" } +short = { module = "com.github.o:r", version = "abc12" } +tagged = { module = "org.example:lib", version.ref = "tagged" } +""" + + +def test_the_catalog_parser_tells_commit_pins_from_versions(tmp_path): + deps = { + d.name: d + for d in gradle.parse_catalog(write(tmp_path, "libs.versions.toml", CATALOG)) + } + assert ( + deps["com.github.mihon:unifile"].ecosystem, + deps["com.github.mihon:unifile"].pinned, + ) == ( + "gitcommit", + "08f224c8f9", + ) + assert deps["com.github.mihonapp:image-decoder"].ecosystem == "gitcommit" + assert deps["com.github.chrisbanes:PhotoView"].ecosystem == "maven" + assert deps["com.github.chrisbanes:PhotoView"].pinned == "2.3.0" + # A hash-shaped version outside com.github.* is still a Maven version + # (an unpinned one, since it is not exact), and six hex digits is too few. + assert deps["io.coil-kt:coil"].ecosystem == "maven" + assert deps["io.coil-kt:coil"].pinned is None + assert deps["com.github.o:r"].ecosystem == "maven" + assert deps["org.example:lib"].ecosystem == "maven" + + +def dep(pinned: str | None = "844a07002c") -> Dep: + return Dep( + ecosystem="gitcommit", + name="com.github.arkon.FlexibleAdapter:flexible-adapter", + constraint=pinned or "", + path=Path("libs.versions.toml"), + line=1, + pinned=pinned, + ) + + +def test_a_pin_at_head_passes_whatever_its_length_or_case(): + assert judge(dep("844a07002c"), Answer(HEAD)) is None + assert judge(dep("844A070"), Answer(HEAD)) is None + assert judge(dep(HEAD), Answer(HEAD)) is None + + +def test_a_pin_behind_head_is_stale_and_reports_the_short_sha(): + finding = judge(dep("c8013533"), Answer(HEAD)) + assert finding.severity is Severity.STALE + assert finding.latest == "844a07002c" + assert "default branch has moved" in finding.detail + + +def test_a_missing_pin_is_reported_unpinned_with_head_to_use(): + finding = judge(dep(None), Answer(HEAD)) + assert finding.severity is Severity.UNPINNED + assert "844a07002c" in finding.detail + + +def test_no_head_at_all_is_unknown_not_a_pass(): + assert judge(dep(), Answer(None)).severity is Severity.UNKNOWN + + +def test_commit_answers_cache_as_long_as_tags(): + assert ttl_for("gitcommit") == ttl_for("gittag") > ttl_for("maven") + + +def test_the_resolver_routes_gitcommit_to_the_git_registry(): + assert _LOOKUP["gitcommit"] is gitcommit.latest diff --git a/dep_freshness/tests/test_gradle_registry.py b/dep_freshness/tests/test_gradle_registry.py index 6271624..90303a4 100644 --- a/dep_freshness/tests/test_gradle_registry.py +++ b/dep_freshness/tests/test_gradle_registry.py @@ -1,4 +1,4 @@ -"""Maven metadata across three repositories, and the Gradle release feed.""" +"""Maven metadata across four repositories, and the Gradle release feed.""" from __future__ import annotations @@ -13,7 +13,7 @@ from dep_freshness.registries.http import Offline from dep_freshness.resolve import _toolchain_latest from dep_freshness.tests.conftest import write -GOOGLE, CENTRAL, PORTAL = maven.MAVEN_REPOS +GOOGLE, CENTRAL, PORTAL, JITPACK = maven.MAVEN_REPOS def metadata(*versions: str, latest: str | None = None) -> str: @@ -68,6 +68,31 @@ def test_a_repo_with_only_prereleases_does_not_stop_the_search(repos): assert maven.latest("g:a") == "0.9.0" +def test_jitpack_is_asked_last_and_reads_tag_metadata(repos): + """PhotoView was never published anywhere but JitPack; `v`-tags and bare + tags both appear and the newest wins.""" + name = "com.github.chrisbanes:PhotoView" + repos[maven.metadata_url(JITPACK, name)] = metadata("v1.3.1", "2.2.0", "2.3.0") + assert maven.latest(name) == "2.3.0" + + +def test_a_project_named_tag_is_read_as_its_version(repos): + """`java-nat-sort` is tagged `natural-comparator-1.1`, not `1.1`.""" + name = "com.github.gpanther:java-nat-sort" + repos[maven.metadata_url(JITPACK, name)] = metadata("natural-comparator-1.1") + assert maven.latest(name) == "natural-comparator-1.1" + + +def test_a_package_with_no_stable_anywhere_falls_back_to_newest_prerelease(repos): + """biometric-ktx has been alpha-only since 2021: the newest alpha is the + reference, and a stable release in ANY repo would still win over it.""" + name = "androidx.biometric:biometric-ktx" + repos[maven.metadata_url(GOOGLE, name)] = metadata( + "1.2.0-alpha05", "1.4.0-alpha02", "1.3.0-alpha01" + ) + assert maven.latest(name) == "1.4.0-alpha02" + + def test_unknown_everywhere_or_malformed_name_is_none(repos): assert maven.latest("g:nowhere") is None assert maven.latest("bare") is None diff --git a/dep_freshness/tests/test_toolchain_registry.py b/dep_freshness/tests/test_toolchain_registry.py index a7da669..3768958 100644 --- a/dep_freshness/tests/test_toolchain_registry.py +++ b/dep_freshness/tests/test_toolchain_registry.py @@ -6,7 +6,7 @@ import subprocess import pytest -from dep_freshness.registries import gittag +from dep_freshness.registries import _git, gittag from dep_freshness.registries import toolchain as tc from dep_freshness.registries.http import Offline @@ -75,7 +75,7 @@ LS_REMOTE = ( @pytest.fixture def ls_remote(monkeypatch): - monkeypatch.setattr(gittag, "host_reachable", lambda _url: True) + monkeypatch.setattr(_git, "host_reachable", lambda _url: True) def install(stdout="", returncode=0, error=None): def fake_run(*_args, **_kwargs): @@ -114,6 +114,6 @@ def test_a_missing_git_binary_degrades_to_offline(ls_remote): def test_an_unreachable_remote_degrades_to_offline(monkeypatch): - monkeypatch.setattr(gittag, "host_reachable", lambda _url: False) + monkeypatch.setattr(_git, "host_reachable", lambda _url: False) with pytest.raises(Offline): gittag.latest("crdt_sync_dart") diff --git a/dep_freshness/tests/test_versions.py b/dep_freshness/tests/test_versions.py index 70f5204..d988267 100644 --- a/dep_freshness/tests/test_versions.py +++ b/dep_freshness/tests/test_versions.py @@ -4,7 +4,14 @@ from __future__ import annotations import pytest -from dep_freshness.versions import behind, exact_pin, is_prerelease, newest_stable +from dep_freshness.versions import ( + behind, + exact_pin, + is_prerelease, + newest_stable, + reference, + split_tag_prefix, +) @pytest.mark.parametrize("raw", [ @@ -58,3 +65,49 @@ 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") + + +@pytest.mark.parametrize("raw,expected", [ + ("natural-comparator-1.1", ("natural-comparator-", "1.1")), + ("release-v2.0", ("release-", "v2.0")), + ("2.3.0-rc1", ("", "2.3.0-rc1")), + ("v1.0.0", ("", "v1.0.0")), + ("alpha", ("", "alpha")), +]) +def test_a_project_named_tag_splits_into_prefix_and_version(raw, expected): + """JitPack serves java-nat-sort only as `natural-comparator-1.1`.""" + assert split_tag_prefix(raw) == expected + + +def test_a_project_named_tag_is_a_stable_version_with_its_prefix_kept(): + assert not is_prerelease("natural-comparator-1.1") + assert is_prerelease("natural-comparator-1.2-rc1") + assert newest_stable(["natural-comparator-1.1", "natural-comparator-1.0"]) == ( + "natural-comparator-1.1" + ) + assert behind("natural-comparator-1.0", "natural-comparator-1.1") + assert exact_pin("natural-comparator-1.1") == "natural-comparator-1.1" + assert exact_pin("version-v1.2") == "version-1.2" + + +def test_reference_is_newest_stable_when_one_exists(): + assert reference(["1.0.0", "2.0.0-alpha01"]) == "1.0.0" + + +def test_reference_falls_back_to_newest_prerelease_only_when_nothing_is_stable(): + """androidx.biometric:biometric-ktx: alpha-only since 2021.""" + assert reference(["1.2.0-alpha05", "1.4.0-alpha02", "1.3.0-alpha01"]) == "1.4.0-alpha02" + assert reference(iter(["1.4.0-alpha02", "junk"])) == "1.4.0-alpha02" + assert reference([]) is None + assert reference(["junk", ""]) is None + + +def test_an_api_generation_is_not_a_project_name(): + """Google's `v3-rev197-1.25.0`: splitting on `v3-rev197-` compared the + Drive v3 client against a v2 artifact and called v2 newer.""" + assert split_tag_prefix("v3-rev197-1.25.0") == ("", "v3-rev197-1.25.0") + assert is_prerelease("v3-rev197-1.25.0") + assert newest_stable(["v3-rev197-1.25.0", "v2-rev20220709-2.0.0"]) is None + # ... and the no-stable fallback must not pick one up either: loosely + # parsed, every v3 revision is "3" and the 2019 pin reads as current. + assert reference(["v3-rev197-1.25.0", "v3-rev20250723-2.0.0"]) is None diff --git a/dep_freshness/versions.py b/dep_freshness/versions.py index 1929424..b402eb6 100644 --- a/dep_freshness/versions.py +++ b/dep_freshness/versions.py @@ -3,7 +3,8 @@ 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`. +"latest" that leaves this package goes through `newest_stable` or, for a +package that has never shipped a stable release, `reference`. """ from __future__ import annotations @@ -19,15 +20,38 @@ _SEMVER = re.compile( r"(?:-(?P
[0-9A-Za-z.-]+))?"
     r"(?:\+(?P[0-9A-Za-z.-]+))?$"
 )
+# A tag that carries its project name: JitPack serves `java-nat-sort` only as
+# `natural-comparator-1.1`. Only a digit-free name that STARTS with a letter
+# and is followed by the version counts: `2.3.0-rc1` is untouched and stays a
+# pre-release, and Google's `v3-rev197-1.25.0` API clients are NOT split,
+# because `v3-` is an API generation, not a project name, and reading it as
+# one proposed a `v2-...-2.0.0` artifact as "newer".
+_TAG_PREFIX = re.compile(r"^(?P[A-Za-z][A-Za-z_.-]*?-)(?=v?\d)")
 
 
-def parse(raw: str) -> Version | None:
-    """A comparable version, or None when the string is not one."""
-    text = raw.strip().lstrip("v")
+def split_tag_prefix(raw: str) -> tuple[str, str]:
+    """`("natural-comparator-", "1.1")`; an unprefixed version keeps `("", ...)`."""
+    text = raw.strip()
+    match = _TAG_PREFIX.match(text)
+    if not match:
+        return ("", text)
+    return (match.group("prefix"), text[match.end() :])
+
+
+def parse(raw: str, strict: bool = False) -> Version | None:
+    """A comparable version, or None when the string is not one.
+
+    `strict` refuses the semver-core fallback: `v3-rev197-1.25.0` reads as
+    `3` loosely, which is enough to order it but says nothing about what it
+    is, and the pre-release fallback below must not build on that.
+    """
+    tail = split_tag_prefix(raw)[1]
     try:
-        return Version(text)
+        return Version(tail.lstrip("v"))
     except InvalidVersion:
-        match = _SEMVER.match(raw.strip())
+        if strict:
+            return None
+        match = _SEMVER.match(tail)
         if not match:
             return None
         # `core` is `\d+(\.\d+){0,2}`, which Version always accepts.
@@ -41,26 +65,53 @@ def is_prerelease(raw: str) -> bool:
     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"):
+    tail = split_tag_prefix(raw)[1]
+    if "-" in tail.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."""
+def _canonical(raw: str) -> str:
+    prefix, tail = split_tag_prefix(raw)
+    return prefix + tail.lstrip("v")
+
+
+def _newest(candidates, prereleases: bool) -> str | None:
     best: tuple[Version, str] | None = None
     for raw in candidates:
-        if not raw or is_prerelease(raw):
+        if not raw or (not prereleases and is_prerelease(raw)):
             continue
-        version = parse(raw)
+        version = parse(raw, strict=prereleases)
         if version is None:
             continue
         if best is None or version > best[0]:
-            best = (version, str(raw).strip().lstrip("v"))
+            best = (version, _canonical(str(raw)))
     return best[1] if best else None
 
 
+def newest_stable(candidates) -> str | None:
+    """The highest non-pre-release version in `candidates`, or None."""
+    return _newest(candidates, prereleases=False)
+
+
+def reference(candidates) -> str | None:
+    """Newest stable, or newest pre-release when no stable was EVER shipped.
+
+    `androidx.biometric:biometric-ktx` has been alpha-only since 2021; holding
+    such a package to "newest stable" is a question with no answer, and an
+    allowlist entry for it would need renewing every 90 days forever. The
+    fallback is a predicate: the day a stable release appears, it wins.
+
+    Only pre-releases PEP 440 can read qualify. Google's Drive client ships
+    `v3-rev197-1.25.0`-style versions that are not pre-releases at all; the
+    loose parser orders them by API generation, and a fallback built on that
+    once declared a 2019 pin current against every newer revision.
+    """
+    candidates = list(candidates)
+    return newest_stable(candidates) or _newest(candidates, prereleases=True)
+
+
 def behind(pinned: str, latest: str) -> bool:
     """True when `pinned` is strictly older than `latest`.
 
@@ -76,8 +127,13 @@ def behind(pinned: str, latest: str) -> bool:
 
 # Any number of numeric release components, not three: PyPI stub packages
 # date-stamp a fourth (`types-requests==2.33.0.20260712`), and capping at
-# three reported an exact pin as unpinned against itself.
-EXACT = re.compile(r"^\s*(?:==\s*)?v?\d+(?:\.\d+)*(?:[-+][0-9A-Za-z.-]+)?\s*$")
+# three reported an exact pin as unpinned against itself. A project-name
+# prefix (`natural-comparator-1.1`) is part of the pin, since it is what the
+# registry's version list spells.
+EXACT = re.compile(
+    r"^\s*(?:==\s*)?(?:[A-Za-z][A-Za-z_.-]*?-(?=v?\d))?"
+    r"v?\d+(?:\.\d+)*(?:[-+][0-9A-Za-z.-]+)?\s*$"
+)
 
 
 def exact_pin(constraint: str) -> str | None:
@@ -92,4 +148,4 @@ def exact_pin(constraint: str) -> str | None:
     text = str(constraint).strip()
     if not EXACT.match(text):
         return None
-    return text.lstrip("=").strip().lstrip("v")
+    return _canonical(text.lstrip("=").strip())