diff --git a/sys/check_outdated_subprojects.py b/sys/check_outdated_subprojects.py new file mode 100755 index 0000000000..3a9d8664ff --- /dev/null +++ b/sys/check_outdated_subprojects.py @@ -0,0 +1,768 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 RizinOrg +# SPDX-License-Identifier: LGPL-3.0-only +""" +check_outdated_subprojects.py + +Check whether Rizin's Meson subproject wraps (subprojects/*.wrap) are outdated. + +It understands the three ways a wrap can pin an upstream: + + * [wrap-file] -> a versioned tarball (e.g. zstd-1.5.7). The current + version is read from the wrap and compared against + the newest upstream tag. + * [wrap-git] + tag -> e.g. revision = v1.11. Compared against newest tag. + * [wrap-git] + commit -> a 40-char SHA. Compared against the tip of the + tracked branch (newer commits == "behind"). + * [wrap-git] + branch -> e.g. revision = main. Always builds the branch tip, + so it is reported as "tracking" (never "outdated"). + +Exit status is 1 if any wrap is OUTDATED (a newer release exists), else 0, +which makes it usable as a CI check. "behind"/"tracking"/"unknown" do not fail. + +Usage: + python3 check_outdated_subprojects.py [PATH] [options] + + PATH may be a rizin checkout, its subprojects/ dir, or omitted (auto-detect). + +Options: + --json Emit machine-readable JSON instead of a report. + --include-prerelease Flag newer pre-releases (alpha/beta/rc) as updates + even when the current pin is a stable release. + --jobs N Parallel ls-remote workers (default 8). + --timeout S Per-repo ls-remote timeout in seconds (default 30). + --no-color Disable ANSI colors. + --self-test Run the built-in unit tests (no network) and exit. +""" + +import argparse +import concurrent.futures +import configparser +import json +import os +import re +import subprocess +import sys + +GIT = "git" + +# Upstreams whose wrap does not reference a git host we can ls-remote +# (e.g. zlib points only at tarball mirrors). Map wrap stem -> git URL. +UPSTREAM_OVERRIDES = { + "zlib": "https://github.com/madler/zlib.git", +} + +HEX40 = re.compile(r"^[0-9a-fA-F]{40}$") +GITHUB_RE = re.compile(r"github\.com[/:]+([^/]+)/([^/#?]+?)(?:\.git)?(?:[/#?]|$)") + +# Matches the leading numeric, dot-separated part of a version string, e.g. +# the "5.8.1" in "5.8.1-Alpha9". It is anchored at the start of the (already +# prefix-stripped) core and has no nested or overlapping quantifiers, so it +# always runs in linear time. +_REL_RE = re.compile(r"\d+(?:\.\d+)*") +_ARCHIVE_EXTS = ( + ".tar.gz", + ".tar.xz", + ".tar.bz2", + ".tar.zst", + ".tgz", + ".txz", + ".tbz2", + ".zip", + ".gz", + ".xz", + ".bz2", +) + + +# --------------------------------------------------------------------------- # +# Version parsing & comparison +# --------------------------------------------------------------------------- # +def _chunks(s): + """Split a pre-release string into comparable (is_alpha, num, text) units.""" + out = [] + for part in re.findall(r"\d+|[^\d]+", s): + if part.isdigit(): + out.append((0, int(part), "")) + else: + out.append((1, 0, part)) + return tuple(out) + + +def _version_core(tag): + """Return the version core of *tag* with any name prefix and leading "v" + removed, e.g. "xz-5.8.1" -> "5.8.1", "v1.3" -> "1.3", "6.0.0-Alpha9" + unchanged. Returns None if *tag* has no version core. + + The name prefix is stripped with a plain left-to-right string scan rather + than a regular expression, so this is guaranteed to run in linear time and + cannot suffer from catastrophic backtracking (ReDoS). + """ + if not tag: + return None + s = tag.strip() + start = 0 + if not (s[:1].isdigit() or (s[:1] in "vV" and s[1:2].isdigit())): + # There is a name prefix. The version begins right after the first + # "-"/"_" that is followed by an optional "v" and a digit; everything + # before it (e.g. "tree-sitter-") is the name. Stopping at the *first* + # such separator mirrors the original regex, whose name segments each + # had to start with a letter -- so a dash-separated numeric tail like + # "rel-0-11-18" keeps its leading "0" rather than collapsing to "18". + boundary = -1 + for i, ch in enumerate(s): + if ch in "-_": + j = i + 1 + if j < len(s) and s[j] in "vV": + j += 1 + if j < len(s) and s[j].isdigit(): + boundary = i + break + if boundary < 0: + return None + start = boundary + 1 + core = s[start:] + if core[:1] in "vV" and core[1:2].isdigit(): + core = core[1:] + return core if core[:1].isdigit() else None + + +def parse_version(tag): + """Return (release_tuple, is_final, prerelease_chunks) or None if not a version.""" + core = _version_core(tag) + if core is None: + return None + m = _REL_RE.match(core) + if not m: + return None + release = tuple(int(x) for x in m.group(0).split(".")) + pre = core[m.end() :].lstrip("-._").lower() + return (release, pre == "", _chunks(pre) if pre else ()) + + +def version_key(parsed): + """Sort key: higher == newer. A final release outranks its pre-releases.""" + release, is_final, pre = parsed + return (release, 1 if is_final else 0, pre) + + +def version_display(tag): + """Human-friendly version core (drops name prefixes / leading v).""" + core = _version_core(tag) + return core if core is not None else tag + + +def best_tag(tags, include_pre): + """Return (raw_tag, key) for the newest version-like tag, or (None, None).""" + best, best_key = None, None + for t in tags: + p = parse_version(t) + if p is None: + continue + if not include_pre and not p[1]: # skip pre-releases unless asked + continue + k = version_key(p) + if best_key is None or k > best_key: + best, best_key = t, k + return best, best_key + + +def compare_to_tags(current_raw, tags, include_pre): + """Compare a current version string against a set of upstream tags.""" + cur = parse_version(current_raw) + if cur is None: + return "unknown", None, "could not parse current version %r" % current_raw + inc = (not cur[1]) or include_pre # if current is a pre-release, consider all + best, best_key = best_tag(tags, inc) + if best is None: # no stable tags? fall back to anything parseable + best, best_key = best_tag(tags, True) + if best is None: + return "unknown", None, "no version-like tags found upstream" + if best_key > version_key(cur): + return "outdated", best, None + return "up-to-date", best, None + + +# --------------------------------------------------------------------------- # +# git ls-remote +# --------------------------------------------------------------------------- # +def remote_refs(url, timeout): + """Return {'heads':{}, 'tags':{}, 'default':str, 'head':sha} or {'error':...}.""" + try: + p = subprocess.run( + [GIT, "ls-remote", "--symref", url], + capture_output=True, + text=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + return {"error": "timeout after %ss" % timeout} + except FileNotFoundError: + return {"error": "the `git` binary was not found"} + if p.returncode != 0: + tail = (p.stderr or "").strip().splitlines() + return {"error": tail[-1] if tail else "git ls-remote rc=%d" % p.returncode} + + heads, tags, default, head = {}, {}, None, None + for line in p.stdout.splitlines(): + line = line.rstrip() + if not line: + continue + if line.startswith("ref: "): + try: + ref, name = line[5:].split("\t", 1) + except ValueError: + continue + if name == "HEAD" and ref.startswith("refs/heads/"): + default = ref[len("refs/heads/") :] + continue + parts = line.split("\t") + if len(parts) != 2: + continue + sha, ref = parts + if ref == "HEAD": + head = sha + elif ref.startswith("refs/heads/"): + heads[ref[len("refs/heads/") :]] = sha + elif ref.startswith("refs/tags/"): + name = ref[len("refs/tags/") :] + peeled = name.endswith("^{}") + if peeled: + name = name[:-3] + if peeled or name not in tags: # prefer the dereferenced commit + tags[name] = sha + if default is None and head is not None: + for n, s in heads.items(): + if s == head: + default = n + break + return {"heads": heads, "tags": tags, "default": default, "head": head} + + +# --------------------------------------------------------------------------- # +# Wrap parsing & analysis +# --------------------------------------------------------------------------- # +def load_wrap(path): + cp = configparser.ConfigParser(strict=False, delimiters=("=",), interpolation=None) + cp.optionxform = str # keep key case as-is + cp.read(path, encoding="utf-8") + return cp + + +def strip_archive_ext(name): + low = name.lower() + for ext in _ARCHIVE_EXTS: + if low.endswith(ext): + return name[: -len(ext)] + return name + + +def github_url(urls): + for u in urls: + if not u: + continue + m = GITHUB_RE.search(u) + if m: + return "https://github.com/%s/%s.git" % ( + m.group(1), + m.group(2), + ), "%s/%s" % (m.group(1), m.group(2)) + return None, None + + +def current_version_from_file(sec): + """Best-effort current version for a [wrap-file].""" + candidates = [ + sec.get("directory", ""), + strip_archive_ext(sec.get("source_filename", "")), + ] + for c in candidates: + if parse_version(c): + return c + # Fall back to a version-looking path segment in the URLs + # (e.g. zydis: .../download/v4.1.0/zydis-amalgamated.tar.gz). + for key in ("source_url", "source_fallback_url"): + for part in sec.get(key, "").split("/"): + if parse_version(part): + return part + return "" + + +def preferred_branch(stem, heads, default): + """Pick the branch a commit-pinned wrap most likely tracks.""" + chosen = default + for seg in re.split(r"[-_]", stem): + if seg in heads: + chosen = seg # last matching segment wins (e.g. capstone-"next") + if chosen in heads: + return chosen + return default if default in heads else None + + +def short(sha): + return sha[:10] if sha else sha + + +def analyze(path, args): + """Return a result dict for a single wrap file.""" + stem = os.path.basename(path)[:-5] # drop ".wrap" + res = { + "name": stem, + "repo": None, + "category": None, + "current": None, + "latest": None, + "status": "unknown", + "detail": None, + } + try: + cp = load_wrap(path) + except configparser.Error as e: + res["detail"] = "failed to parse wrap: %s" % e + return res + + if cp.has_section("wrap-file"): + sec = cp["wrap-file"] + res["category"] = "version" + res["current"] = version_display(current_version_from_file(sec)) + url, repo = github_url( + [sec.get("source_url", ""), sec.get("source_fallback_url", "")] + ) + if url is None and stem in UPSTREAM_OVERRIDES: + url = UPSTREAM_OVERRIDES[stem] + repo = re.sub(r"^https?://github\.com/|\.git$", "", url) + res["repo"] = repo or "(unknown upstream)" + if url is None: + res["detail"] = "no upstream git repo found in wrap" + return res + refs = remote_refs(url, args.timeout) + if "error" in refs: + res["detail"] = refs["error"] + return res + status, best, detail = compare_to_tags( + current_version_from_file(sec), set(refs["tags"]), args.include_prerelease + ) + res["status"] = status + res["detail"] = detail + if best is not None: + res["latest"] = version_display(best) + return res + + if cp.has_section("wrap-git"): + sec = cp["wrap-git"] + url = sec.get("url", "").strip() + revision = sec.get("revision", "").strip() + res["repo"] = re.sub(r"^https?://(?:github\.com/)?|\.git$", "", url) or url + if not url or not revision: + res["detail"] = "wrap-git missing url or revision" + return res + refs = remote_refs(url, args.timeout) + if "error" in refs: + res["detail"] = refs["error"] + return res + + if HEX40.match(revision): # pinned commit + res["category"] = "commit" + res["current"] = short(revision) + branch = preferred_branch(stem, refs["heads"], refs["default"]) + if branch is None: + res["detail"] = "could not determine a branch to compare against" + return res + tip = refs["heads"][branch] + res["latest"] = short(tip) + if tip.lower() == revision.lower(): + res["status"] = "up-to-date" + res["detail"] = "at tip of %s" % branch + else: + res["status"] = "behind" + res["detail"] = "pinned %s, %s tip is %s" % ( + short(revision), + branch, + short(tip), + ) + return res + + if revision in refs["heads"]: # tracks a moving branch + res["category"] = "branch" + res["current"] = revision + res["latest"] = short(refs["heads"][revision]) + res["status"] = "tracking" + res["detail"] = "builds latest commit of branch %r" % revision + return res + + # otherwise treat as a tag (it is in refs/tags, or just looks like a version) + res["category"] = "tag" + res["current"] = version_display(revision) + if revision not in refs["tags"] and parse_version(revision) is None: + res["detail"] = "revision %r is not a known tag/branch/commit" % revision + return res + status, best, detail = compare_to_tags( + revision, set(refs["tags"]), args.include_prerelease + ) + res["status"] = status + res["detail"] = detail + if best is not None: + res["latest"] = version_display(best) + return res + + res["detail"] = "no [wrap-file] or [wrap-git] section" + return res + + +# --------------------------------------------------------------------------- # +# Discovery, reporting, CLI +# --------------------------------------------------------------------------- # +def find_subprojects_dir(path): + if path is None: + for cand in ("subprojects", "."): + if os.path.isdir(cand) and any( + f.endswith(".wrap") for f in os.listdir(cand) + ): + return cand + return None + sub = os.path.join(path, "subprojects") + if os.path.isdir(sub): + return sub + if os.path.isdir(path) and any(f.endswith(".wrap") for f in os.listdir(path)): + return path + return None + + +# status -> (symbol, label, color) +_STATUS = { + "outdated": ("[!]", "OUTDATED", "\033[31m"), # red + "behind": ("[~]", "BEHIND", "\033[33m"), # yellow + "tracking": ("[>]", "TRACKING", "\033[36m"), # cyan + "up-to-date": ("[+]", "UP TO DATE", "\033[32m"), # green + "unknown": ("[?]", "UNKNOWN", "\033[35m"), # magenta +} +_ORDER = ["outdated", "behind", "unknown", "tracking", "up-to-date"] +_RESET = "\033[0m" + + +def render(results, use_color): + def c(text, code): + return "%s%s%s" % (code, text, _RESET) if use_color else text + + width = max((len(r["name"]) for r in results), default=4) + by_status = {} + for r in results: + by_status.setdefault(r["status"], []).append(r) + + lines = [] + for status in _ORDER: + group = by_status.get(status) + if not group: + continue + sym, label, color = _STATUS[status] + lines.append("") + lines.append(c("%s %s" % (sym, label), color)) + for r in sorted(group, key=lambda x: x["name"]): + cur = r["current"] or "?" + if r["status"] in ("outdated",): + ver = "%s -> %s" % (cur, r["latest"] or "?") + elif r["status"] == "behind": + ver = "%s -> %s" % (cur, r["latest"] or "?") + elif r["status"] in ("up-to-date", "tracking"): + ver = cur + else: + ver = cur + detail = " (%s)" % r["detail"] if r["detail"] else "" + lines.append( + " %-*s %-22s %s%s" + % (width, r["name"], ver, r["repo"] or "", c(detail, "\033[90m")) + ) + + n_out = len(by_status.get("outdated", [])) + n_behind = len(by_status.get("behind", [])) + n_unknown = len(by_status.get("unknown", [])) + lines.append("") + lines.append("-" * 60) + summary = "%d outdated, %d behind, %d tracking, %d up-to-date, %d unknown" % ( + n_out, + n_behind, + len(by_status.get("tracking", [])), + len(by_status.get("up-to-date", [])), + n_unknown, + ) + lines.append(c(summary, "\033[1m") if use_color else summary) + return "\n".join(lines), n_out + + +# --------------------------------------------------------------------------- # +# Built-in unit tests (run with --self-test; no network required) +# --------------------------------------------------------------------------- # +def run_self_tests(): + """Exercise the offline version/wrap logic with inline unit tests. + + Only the pure helpers are covered (parsing, comparison, classification, + URL/version extraction); the ls-remote layer needs the network and is left + out. Returns 0 if every check passes and 1 otherwise, so it can be wired + into CI as ``check_outdated_subprojects.py --self-test``. + """ + import time + + checks = [] + + def eq(label, got, want): + checks.append(got == want) + if got != want: + print("FAIL %s" % label) + print(" got: %r" % (got,)) + print(" want: %r" % (want,)) + + def ok(label, cond): + checks.append(bool(cond)) + if not cond: + print("FAIL %s" % label) + + # -- parse_version: name prefixes, leading "v", pre-releases, non-versions -- + eq("parse xz-5.8.1", parse_version("xz-5.8.1"), ((5, 8, 1), True, ())) + eq("parse v1.3.1", parse_version("v1.3.1"), ((1, 3, 1), True, ())) + eq("parse zstd-1.5.7", parse_version("zstd-1.5.7"), ((1, 5, 7), True, ())) + eq( + "parse tree-sitter-0.25.3", + parse_version("tree-sitter-0.25.3"), + ((0, 25, 3), True, ()), + ) + eq("parse pcre2-10.47", parse_version("pcre2-10.47"), ((10, 47), True, ())) + eq("parse v1.11", parse_version("v1.11"), ((1, 11), True, ())) + eq( + "parse capstone-6.0.0-Alpha9", + parse_version("capstone-6.0.0-Alpha9"), + ((6, 0, 0), False, ((1, 0, "alpha"), (0, 9, ""))), + ) + # A dash-separated numeric tail (libzip's rel-* tags) must keep its leading + # number instead of collapsing to the last one -- regression guard. + eq( + "parse rel-0-11-18", + parse_version("rel-0-11-18"), + ((0,), False, ((0, 11, ""), (1, 0, "-"), (0, 18, ""))), + ) + ok("1.0.0 is final", parse_version("1.0.0")[1] is True) + ok("1.0.0-rc1 is not final", parse_version("1.0.0-rc1")[1] is False) + for nonver in ("main", "dev", "master", "", None, "zydis-amalgamated.tar.gz"): + eq("parse %r -> None" % (nonver,), parse_version(nonver), None) + + # -- version_display -- + eq("display xz-5.8.1", version_display("xz-5.8.1"), "5.8.1") + eq("display v5.8.3", version_display("v5.8.3"), "5.8.3") + eq( + "display capstone-6.0.0-Alpha9", + version_display("capstone-6.0.0-Alpha9"), + "6.0.0-Alpha9", + ) + eq("display passthrough", version_display("main"), "main") + + # -- ordering: numeric (not lexical) and final outranks pre-release -- + def newer(a, b): + return version_key(parse_version(a)) > version_key(parse_version(b)) + + ok("5.8.3 > 5.8.1", newer("5.8.3", "5.8.1")) + ok("1.10.0 > 1.9.2 (numeric, not string)", newer("1.10.0", "1.9.2")) + ok("1.0.1 > 1.0", newer("1.0.1", "1.0")) + ok("6.0.0 > 6.0.0-Alpha9 (final beats pre-release)", newer("6.0.0", "6.0.0-Alpha9")) + ok("Alpha9 > Alpha2", newer("6.0.0-Alpha9", "6.0.0-Alpha2")) + + # -- best_tag -- + eq( + "best_tag newest", + best_tag(["v1.0.0", "v1.2.0", "v1.10.0", "main"], False)[0], + "v1.10.0", + ) + eq( + "best_tag skips pre-release by default", + best_tag(["1.0.0", "2.0.0-rc1"], False)[0], + "1.0.0", + ) + eq( + "best_tag includes pre-release on request", + best_tag(["1.0.0", "2.0.0-rc1"], True)[0], + "2.0.0-rc1", + ) + eq("best_tag no version-like tags", best_tag(["main", "dev"], True), (None, None)) + + # -- compare_to_tags: the OUTDATED / UP-TO-DATE / UNKNOWN decision -- + def status(cur, tags, inc=False): + return compare_to_tags(cur, set(tags), inc)[0] + + eq("compare outdated", status("5.8.1", ["v5.8.1", "v5.8.3"]), "outdated") + eq( + "compare reports newest tag", + compare_to_tags("5.8.1", {"v5.8.1", "v5.8.3"}, False)[1], + "v5.8.3", + ) + eq( + "compare up-to-date (numeric)", + status("1.10.0", ["v1.9.2", "v1.10.0"]), + "up-to-date", + ) + eq("compare unparseable current -> unknown", status("main", ["v1.0.0"]), "unknown") + eq("compare no upstream tags -> unknown", status("1.0.0", []), "unknown") + eq( + "compare stable pin ignores newer pre-release", + status("1.0.0", ["1.0.0", "2.0.0-rc1"], False), + "up-to-date", + ) + eq( + "compare --include-prerelease flags it", + status("1.0.0", ["1.0.0", "2.0.0-rc1"], True), + "outdated", + ) + eq( + "compare pre-release pin sees newer pre-release", + status("6.0.0-Alpha2", ["6.0.0-Alpha2", "6.0.0-Alpha9"]), + "outdated", + ) + + # -- github_url: owner/repo extraction from assorted URL shapes -- + eq( + "github_url release tarball", + github_url( + ["https://github.com/tukaani-project/xz/releases/download/v5.8.1/xz.tar.gz"] + )[1], + "tukaani-project/xz", + ) + eq( + "github_url .git suffix", + github_url(["https://github.com/zyantific/zydis.git"])[1], + "zyantific/zydis", + ) + eq( + "github_url scp syntax", + github_url(["git@github.com:foo/bar.git"])[1], + "foo/bar", + ) + eq("github_url skips empties", github_url(["", "https://github.com/a/b"])[1], "a/b") + eq( + "github_url non-github", + github_url(["https://zlib.net/zlib.tar.gz"]), + (None, None), + ) + + # -- strip_archive_ext -- + eq("strip .tar.gz", strip_archive_ext("xz-5.8.1.tar.gz"), "xz-5.8.1") + eq("strip .zip", strip_archive_ext("zydis-amalgamated.zip"), "zydis-amalgamated") + eq("strip .tar.xz", strip_archive_ext("file.tar.xz"), "file") + eq("strip none", strip_archive_ext("plain"), "plain") + + # -- current_version_from_file (a plain dict stands in for a wrap section) -- + eq( + "cvff directory", + current_version_from_file({"directory": "xz-5.8.1"}), + "xz-5.8.1", + ) + eq( + "cvff filename fallback", + current_version_from_file( + {"directory": "", "source_filename": "lz4-1.10.0.tgz"} + ), + "lz4-1.10.0", + ) + eq( + "cvff url fallback (zydis amalgamated)", + current_version_from_file( + { + "directory": "zydis", + "source_filename": "zydis-amalgamated.tar.gz", + "source_url": "https://github.com/zyantific/zydis/releases/download/v4.1.0/zydis-amalgamated.tar.gz", + } + ), + "v4.1.0", + ) + + # -- preferred_branch -- + heads = {"master": "a", "next": "b", "main": "c"} + eq( + "branch segment match", + preferred_branch("capstone-next", heads, "master"), + "next", + ) + eq("branch default fallback", preferred_branch("blake3", heads, "master"), "master") + eq("branch none available", preferred_branch("foo", {"main": "c"}, "master"), None) + + # -- ReDoS guard: adversarial input must still parse in linear time -- + evil = "a" + "_a" * 10000 + t0 = time.perf_counter() + parsed_evil = parse_version(evil) + dt = time.perf_counter() - t0 + eq("adversarial input -> None", parsed_evil, None) + ok("adversarial input parsed in <1s (took %.4fs)" % dt, dt < 1.0) + + total = len(checks) + failed = sum(1 for c in checks if not c) + print("") + if failed: + print("self-test: %d of %d checks FAILED" % (failed, total)) + return 1 + print("self-test: all %d checks passed" % total) + return 0 + + +def main(argv=None): + ap = argparse.ArgumentParser( + description="Check whether Rizin's Meson subproject wraps are outdated.", + epilog="Exit code is 1 if any wrap is OUTDATED, else 0. " + "BEHIND/TRACKING/UNKNOWN do not affect the exit code.", + ) + ap.add_argument( + "path", + nargs="?", + default=None, + help="rizin checkout or its subprojects/ dir (auto-detected if omitted)", + ) + ap.add_argument("--json", action="store_true", help="emit JSON") + ap.add_argument( + "--include-prerelease", + action="store_true", + help="flag newer alpha/beta/rc releases even for stable pins", + ) + ap.add_argument("--jobs", type=int, default=8, help="parallel workers (default 8)") + ap.add_argument( + "--timeout", + type=int, + default=30, + help="per-repo ls-remote timeout in seconds (default 30)", + ) + ap.add_argument("--no-color", action="store_true", help="disable ANSI colors") + ap.add_argument( + "--self-test", + action="store_true", + help="run the built-in unit tests (no network) and exit", + ) + args = ap.parse_args(argv) + + if args.self_test: + return run_self_tests() + + sub = find_subprojects_dir(args.path) + if sub is None: + sys.stderr.write( + "error: could not find a subprojects/ directory with " + "*.wrap files. Pass the rizin path explicitly.\n" + ) + return 2 + wraps = sorted(os.path.join(sub, f) for f in os.listdir(sub) if f.endswith(".wrap")) + if not wraps: + sys.stderr.write("error: no *.wrap files in %s\n" % sub) + return 2 + + if not args.json: + sys.stderr.write("Checking %d wraps in %s ...\n" % (len(wraps), sub)) + + workers = max(1, min(args.jobs, len(wraps))) + with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex: + results = list(ex.map(lambda p: analyze(p, args), wraps)) + + if args.json: + print(json.dumps(results, indent=2)) + return 1 if any(r["status"] == "outdated" for r in results) else 0 + + use_color = (not args.no_color) and sys.stdout.isatty() + report, n_out = render(results, use_color) + print(report) + return 1 if n_out else 0 + + +if __name__ == "__main__": + sys.exit(main())