mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:27:15 +01:00
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
247 lines
9.6 KiB
Python
247 lines
9.6 KiB
Python
"""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
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from dep_freshness import report
|
|
from dep_freshness._tables import NPM
|
|
from dep_freshness.allowlist import AllowlistError, load, shared_path
|
|
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.quarantine import installable_latest
|
|
from dep_freshness.registries import http
|
|
from dep_freshness.registries.http import Offline
|
|
from dep_freshness.resolve import Answer, Resolver
|
|
from dep_freshness.upstream import still_pins
|
|
|
|
|
|
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, shared: Path | None = None,
|
|
whole_repo: bool = True, root: Path | None = None,
|
|
degraded: list[str] | None = None,
|
|
) -> tuple[list[Finding], list, dict[str, bool]]:
|
|
"""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.
|
|
|
|
Inherited entries are exempt from that rot check everywhere except the
|
|
repo that owns the shared file. A fleet-wide hold on `typescript`
|
|
legitimately excuses nothing in a Flutter app, and failing there would
|
|
make every repo without that dependency uncommittable.
|
|
|
|
`whole_repo` is False for a file-scoped run -- what pre-commit does, since
|
|
it passes only the staged manifests. Such a run has seen a SUBSET of the
|
|
repo by construction, so "no finding mentioned this package" carries no
|
|
information about whether the entry is still needed. Judging rot there
|
|
fails the commit over a manifest the commit never touched: staging two
|
|
pubspec.yaml files in utils reported the fleet-wide typescript hold as
|
|
dead, because no package.json was in scope to be found stale.
|
|
"""
|
|
by_label = {f"{e.ecosystem}:{e.package}": e for e in entries}
|
|
used: set[str] = set()
|
|
blocking: dict[str, bool] = {}
|
|
failing: list[Finding] = []
|
|
excused: list[Finding] = []
|
|
for finding in findings:
|
|
entry = by_label.get(finding.label)
|
|
if entry is not None:
|
|
used.add(finding.label) # the entry applies here, even if it
|
|
# does not excuse this particular pin -- that is a stale pin to
|
|
# fix, not allowlist rot to delete.
|
|
blocking[finding.label] = True
|
|
if entry is None or not _covers(entry, finding):
|
|
failing.append(finding)
|
|
continue
|
|
if entry.upstream and not _upstream_holds(entry, finding, root, degraded):
|
|
blocking[finding.label] = False
|
|
failing.append(Finding(
|
|
finding.dep, finding.severity, finding.latest,
|
|
f"{entry.upstream_repo} no longer pins {entry.pinned}: drop the "
|
|
"allowlist entry and take the bump",
|
|
))
|
|
continue
|
|
excused.append(Finding(
|
|
finding.dep, finding.severity, finding.latest, finding.detail,
|
|
excused=entry.reason,
|
|
))
|
|
dead = [
|
|
e for e in entries
|
|
if whole_repo
|
|
and f"{e.ecosystem}:{e.package}" not in used
|
|
and (shared is None or e.source != shared)
|
|
]
|
|
return failing, dead, blocking
|
|
|
|
|
|
def _upstream_holds(entry, finding: Finding, root, degraded) -> bool:
|
|
"""Offline, an `upstream:` entry is trusted and the run is marked degraded."""
|
|
try:
|
|
return still_pins(entry, finding, root or Path.cwd())
|
|
except Offline as exc:
|
|
if degraded is not None:
|
|
degraded.append(f"{entry.blocked_by} unverified: {exc}")
|
|
return True
|
|
|
|
|
|
def _covers(entry, finding: Finding) -> bool:
|
|
"""Does this exception excuse THIS pin, or only the one it names?
|
|
|
|
`pinned` is a required field: an entry is an argument about ONE version,
|
|
and excusing any version of the package turns a narrow, reasoned hold into
|
|
a blanket exemption. The fleet-wide typescript entry pins 6.0.3 for a
|
|
reason specific to 6.0.3, while dufs-cloud/web sat on `~5.8.3` -- two
|
|
majors older, for no stated reason -- and the gate called it current
|
|
because the package name matched.
|
|
"""
|
|
return entry.pinned in (finding.dep.pinned, finding.dep.constraint)
|
|
|
|
|
|
def _unquarantine(finding: Finding) -> Finding | None:
|
|
"""Re-judge an npm finding against what pnpm will actually install.
|
|
|
|
pnpm 11 refuses a package published inside its quarantine window, so a
|
|
repo pinned to the newest *installable* version is not behind in any sense
|
|
a commit could fix. Only npm findings reach here, and only after a finding
|
|
already exists, so the expensive full-document fetch never happens on a
|
|
clean run.
|
|
"""
|
|
if finding.dep.ecosystem != NPM or finding.latest is None:
|
|
return finding
|
|
installable = installable_latest(finding.dep.name, finding.latest)
|
|
if installable is None or installable == finding.latest:
|
|
return finding
|
|
return judge(finding.dep, Answer(installable))
|
|
|
|
|
|
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 None:
|
|
continue
|
|
finding = _unquarantine(finding)
|
|
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)
|
|
shared = shared_path()
|
|
failing, dead, still_blocking = _excuse(
|
|
findings, entries,
|
|
shared=None if root == shared.parent else shared,
|
|
whole_repo=args.all, root=root, degraded=resolver.degraded,
|
|
)
|
|
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
|
|
|
|
# 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:
|
|
return 3 if args.strict else 0
|
|
return 0
|