From afc6ed5ca8afddca4344047090dbe2a4c2c4667b Mon Sep 17 00:00:00 2001 From: Larry Gritz Date: Wed, 30 Sep 2026 01:06:57 -0700 Subject: [PATCH] build: add check-dependency-minmax agent skill This is my attempt to build an agent skill that cross-references - The versions of all dependencies we actually use in CI (by checking the workflow logs), across all tests - The range we claim to support and test as documented in INSTALL.md - The minimum versions we accept, enforced in externalpackages.cmake - The actual latest versions available (as near as we can determine, looking at those projects' release tags in their own repos) and reconciles any differences, for example: bump the "latest" CI jobs to new versions of deps that have come out, ensure INSTALL reflects the actual range that we support and test, etc. It writes a report summarizing all the findings, and makes suggestions for what to adjust in the project. I've been testing this for a while and it works great and saves me a lot of tedious checking of different dependency projects, and ensures that the various files listed above aren't drifting away from each other. This is something I need to do as the project maintainer frequently, at least monthly so that the patch releases are tested on the real latest. I want to check it into the repo both so that it can be versioned and always lives with the project, but also so that people other than me can also perform these checks. Assisted-by: Claude Code / Sonnet 5 Signed-off-by: Larry Gritz --- .agents/.gitignore | 1 + .../skills/check-dependency-minmax/SKILL.md | 344 ++++++++++++++++++ .../scripts/fetch_logs.sh | 25 ++ .../scripts/parse_buildinfo.py | 87 +++++ 4 files changed, 457 insertions(+) create mode 100644 .agents/skills/check-dependency-minmax/SKILL.md create mode 100644 .agents/skills/check-dependency-minmax/scripts/fetch_logs.sh create mode 100644 .agents/skills/check-dependency-minmax/scripts/parse_buildinfo.py diff --git a/.agents/.gitignore b/.agents/.gitignore index 71dac709df..54f81ca15f 100644 --- a/.agents/.gitignore +++ b/.agents/.gitignore @@ -14,3 +14,4 @@ skills/* !skills/oiio-*/ !skills/prepare-patch-release/ !skills/release-notes-update/ +!skills/check-dependency-minmax/ diff --git a/.agents/skills/check-dependency-minmax/SKILL.md b/.agents/skills/check-dependency-minmax/SKILL.md new file mode 100644 index 0000000000..fa304c3b2c --- /dev/null +++ b/.agents/skills/check-dependency-minmax/SKILL.md @@ -0,0 +1,344 @@ +--- +name: check-dependency-minmax +description: Audit which dependency versions CI really tests (from a ci.yml run's job summaries) against INSTALL.md claims and ci.yml job descriptions. Explicit slash-command use only. +argument-hint: [ci-run-url-or-id] +disable-model-invocation: true +--- + +Audit the dependency versions tested in practice by our CI against what +`INSTALL.md` documents and what the `ci.yml` job descriptions and comments +claim, and against the local build. The skill has three phases: + +1. Gather data, run all checks, save the dated report (steps 1-6). +2. Make the simple, behavior-neutral doc fixes without asking (step 7): + `ci.yml` descriptions/comments and `INSTALL.md` tested maxima. +3. Offer fixes that change code or workflows, applied only one at a time + as the user selects them, with no commit without asking (step 8). + +This is a LIVING DOCUMENT. When we discover a new thing worth checking, a +better way to do a step, or the right fix for a problem the audit finds, +update this file (and the scripts next to it) as part of that work. + +Arguments: `$ARGUMENTS` +- First argument (optional): a GitHub Actions run URL or numeric run id for + the `ci.yml` workflow. If omitted, use the latest SUCCESSFUL `ci.yml` run + on the current git branch (see step 1). + +## Ground truth + +- Each `ci.yml` job runs `src/build-scripts/ci-test.bash`, which runs + `oiiotool --buildinfo` and appends it to the job's step summary + (`$GITHUB_STEP_SUMMARY`). That output (compiler, C++ standard, and a + `Dependencies:` list) is the ground truth for what a job used. The same + text is in the job log. The step summary itself is not available through + the API, so use the job logs. +- `INSTALL.md` documents, per dependency, the minimum supported version and + the maximum version tested (sometimes "and main/master"). +- `ci.yml` matrix entries have a `desc:` (which becomes the job name) and + `#` comments that state intent, such as "oldest" or "bleeding edge". + +## Step 0: environment + +- `gh` needs the sandbox disabled here (TLS error otherwise); run every `gh` + and `curl` call with `dangerouslyDisableSandbox: true`. +- Work in the session scratchpad dir, not the repo. Put the report in the + repo root as `./dependency-report-YYYY-MM-DD.md` (untracked; do not + commit it). +- Never hard-code a run URL in this skill or in files you edit. + +## Step 1: choose the run + +- If an argument names a run, use it (`gh run view --json ...`). +- Otherwise: + `gh run list --workflow ci.yml --branch "$(git branch --show-current)" --status success --limit 1 --json databaseId,url,headSha,createdAt` + If nothing, say so and ask; do not silently fall back to another branch. + Runs on forks live in the fork's repo; if the branch has no runs in the + default repo, check `git remote -v` and pass `--repo`. +- Report the run URL, head SHA, and whether that SHA matches local HEAD + (if not, the report may not match the local ci.yml; say so, or read + ci.yml at that SHA with `git show :.github/workflows/ci.yml`). + +## Step 2: fetch job logs and parse buildinfo + +`gh run view --log` fails ("too many API requests"); fetch per job. + +1. `bash scripts/fetch_logs.sh [--repo owner/name]` + downloads every job's log in parallel to `/logs/.log` + (and a job list to `/jobs.tsv`). Paths are relative to this + skill's directory. +2. `python3 scripts/parse_buildinfo.py [dep ...]` writes + `/parsed.json` (per job: name, compiler, C++ std, deps), + `/versions.txt` (all distinct versions per dependency, including + `CMake`; the direct input for table A), and prints a per-dependency pivot + (version -> jobs). + +Notes on the data: +- Log lines are interleaved with shell trace (`+ echo ...`), and long + `Dependencies:` lines can wrap onto indented continuation lines. The + parser handles this; if the format of `--buildinfo` changes, fix the + parser first. +- Jobs with no buildinfo (ABI checks, clang-format, anything with + `skip_tests`) are excluded; say so in the report. +- Duplicate entries in `Dependencies:` (libdeflate, zstd, ZLIB, Ptex) are + normal; the first wins. +- `NONE` means looked for and not found; a dependency that is absent from + the list was not requested or disabled. `Ptex present` means the version + was not detected. `JPEG 80` is the libjpeg API version, not a release + (so it says nothing about jpeg 9 or turbo version). +- buildinfo does not report CMake; the parser takes it from the + `-- CMake X.Y.Z` (or `-- Using CMake version X`) status line early in the + build step of the log and + adds it to deps as `CMake`. +- buildinfo does not report NumPy. It labels `__cplusplus` 202400 as C++23 + even for C++26 builds (cosmetic). +- A version reported for a main/master build is that tree's own version + string (e.g., libtiff master may still say the last release), so + "master" cannot be confirmed from the summary alone. Check the job log + for `git checkout master`/`main` lines when it matters. + +## Step 2b: the local build + +Also gather what the current local tree builds with, since the developer's +machine may have compilers or dependencies newer than CI can get. + +- Find built tools: + `find dist build -maxdepth 4 -name oiiotool -type f 2>/dev/null`. If + several, or none, ask which to use (or whether to build first). +- Run ` --version` and ` --buildinfo`; parse the same + way as CI (compiler line and `Dependencies:`). Record the binary's path + and modification time, and compare its version to HEAD; warn if it + looks stale. +- `/CMakeCache.txt` gives compiler paths, `CMAKE_CXX_STANDARD`, and + `*_ROOT`/`*_DIR` hints if a version is ambiguous; `cmake --version` gives + the local CMake. +- Local Python and NumPy versions (`python3 -c 'import sys,numpy'`) if the + bindings were built. +- Treat the local build as unverified evidence: it may raise a documented + maximum only after user confirmation (step 7). + +## Step 3: read the claims + +- `INSTALL.md` "Dependencies" section: min, and "tested through" (and + main/master) per dependency and per compiler/CMake. +- `.github/workflows/ci.yml`: every matrix entry's `desc`, comments, and the + version-pinning env vars in `setenvs` (e.g. `LIBPNG_VERSION`, + `LIBTIFF_VERSION`, `PTEX_VERSION`, `*_GIT_TAG`, `*_BUILD_VERSION`, + `*_ver:` inputs, `container:`, `runner:`). +- Defaults for locally built deps live in `src/cmake/build_*.cmake` + (`*_BUILD_VERSION`) and `src/build-scripts/build_*.bash`. Check that each + pin in `ci.yml` uses the variable name the script actually reads. + +## Step 4: find latest upstream releases + +Query real upstream state; do not trust memory. Use tags (many projects +have no GitHub "releases"), filter out rc/beta/dev, `sort -V | tail`. +Also confirm with `gh api repos//releases --jq` where releases exist. + +| Dependency | Where to look | +|---|---| +| fmt | fmtlib/fmt | +| Imath, OpenEXR, OCIO, OpenVDB | AcademySoftwareFoundation/{Imath,openexr,OpenColorIO,openvdb} | +| libtiff | GitLab: `https://gitlab.com/api/v4/projects/libtiff%2Flibtiff/repository/tags` | +| libjpeg-turbo | libjpeg-turbo/libjpeg-turbo (tags also include jpeg-9x/10; ignore) | +| zlib | madler/zlib | +| robin-map | Tessil/robin-map | +| pugixml | zeux/pugixml | +| nanobind | wjakob/nanobind (drop `-dev` tags) | +| pybind11 | pybind/pybind11 | +| libpng | pnggroup/libpng (tags only, `^v1\.6\.[0-9]+$`) | +| LibRaw | LibRaw/LibRaw | +| OpenJPEG | uclouvain/openjpeg (tags `^v[0-9]`) | +| libjxl | libjxl/libjxl | +| libheif | strukturag/libheif | +| DCMTK | DCMTK/dcmtk (`DCMTK-x.y.z`) | +| WebP | webmproject/libwebp | +| Ptex | wdas/ptex | +| Freetype | freetype/freetype (`^VER-2-[0-9]+-[0-9]+$`) | +| libultrahdr | google/libultrahdr | +| OpenJPH | aous72/OpenJPH | +| TBB | uxlfoundation/oneTBB | +| OpenCV | opencv/opencv (numeric tags) | +| ffmpeg | FFmpeg/FFmpeg (`^n[0-9.]+$` tags) | +| giflib | `https://sourceforge.net/projects/giflib/best_release.json` | +| Qt | qt/qtbase (`^v6\.[0-9]+\.[0-9]+$`; Qt5 is EOL, use INSTALL) | +| pystring | imageworks/pystring | +| NumPy | numpy/numpy | +| Python | python.org; CI uses runner/container Pythons | + +## Step 5: analyses to perform + +A. Per-dependency table, with exactly these columns: + + ``` + | Dependency | INSTALL min/max | CI versions | Latest | + |---|---|---|---| + | libFoo | 1.5 - 3.2 | 1.5.2, 1.8.4, 2.0.5, 3.0.1, 3.2.1, main | 3.2.1 | + | libBar | *3.0* - *4.0* | 3.1, 3.2, 3.8 | *4.2* | + ``` + + - "INSTALL min/max": the documented minimum and maximum tested. + Bold the min or max (`*x*` in the example above; use `**x**` in + the real report) if it does NOT reflect what CI tests as its min or + max, respectively. + - If INSTALL gives only major.minor, any tested patch release of that + major.minor satisfies it (INSTALL min 3.1 is met by CI 3.1.x). If + INSTALL gives a full major.minor.patch, the table must flag it + (bold) when CI does not test that exact release. + - "CI versions": every distinct version tested by any CI job (from the + buildinfo pivot), ascending, with `main`/`master` last if a job + really builds from it (see step 2 notes). Include compilers (gcc, + clang, Apple clang, MSVC, icx), CMake, Python. CMake versions come + from `parsed.json`/`versions.txt` (key `CMake`, taken from the build + log; see step 2 notes), so list them like any other dependency and + never report CMake as "not verified". Ignore jobs that do not build + (clang-format). + - "Latest": the highest tagged release upstream (step 4), if it can be + detected. Bold it if it is newer than anything CI tests. + +B. "oldest" jobs (`desc` starts with `oldest`, and `hobbled`): is every + dependency at the INSTALL minimum? List each that is not, and why + (system package, pin ignored, pin commented out, dependency not + installed, different pins between the gcc and clang variants). + +C. "latest releases" jobs: is every dependency at the newest tagged + release from step 4? List each that is not. Mark ones that are apt or + Homebrew system packages versus pinned by us. + +D. "bleeding edge" job: which dependencies are on main/master, which fall + back to the latest tag, which are neither (system packages), and which + are disabled. + +E. Description check: for every matrix entry, compare the `desc` and the + comments against the summary (compiler and version, C++ std, Python, + OpenEXR, OCIO, Qt, simd, and any "main"/"oldest"/"latest" claim). + Runner-image compilers drift (Apple clang, gcc from apt), so the + summary, not the name, is right. Also check `nametag`s for stale + version strings. + +F. INSTALL.md claims of "main"/"master": each must correspond to a job that + really builds that dependency from main/master. + +G. INSTALL.md minimum claims that no job exercises, and jobs that pass + below the documented minimum (e.g., ASWF containers shipping an older + system library). + +H. Pin sanity: every version-pinning variable in `ci.yml` must be one the + build scripts read (see step 3), and must show up in the summary. + +I. Local build (from step 2b): for each compiler, CMake, or dependency + where the local tree's build is NEWER than the newest CI job achieves, + list it as a candidate documented maximum (needs user confirmation before + INSTALL.md is changed, see step 7). Also note anything local that is + older than INSTALL's minimum. + +Add to this list when we think of new checks. + +## Step 6: report (phase 1 ends here) + +Write `./dependency-report-YYYY-MM-DD.md` (today's date) with: run URL and +SHA; local build info (step 2b); table A; sections B-I; a "Not verified" +list (things buildinfo cannot show). Show a concise version to the user. +Be concrete: name job, variable, version. Do not publish externally unless +asked. Do not commit the report. + +## Step 7: simple fixes, done unconditionally (phase 2) + +These do not change what CI does, and are trivially revertible by the user +before committing, so make them without asking. Do not commit them. + +1. `ci.yml` `desc:` strings, `nametag:` version strings, and `#` comments + must reflect what each job actually tests per its summary (E). Say in + your final message that `desc` is the job name, so renaming can break + required-status-check rules. +2. `INSTALL.md` must reflect the actual maximum tested version: + - Where CI tests something newer than INSTALL's "tested through", + update it. + - Where INSTALL says "and main/master", keep it only if a job really + builds that dependency from main/master; otherwise remove it. Add + "and main/master" where a job really does. + - Local-build maxima (step 2b, analysis I): FIRST list every case where + the local tree's build uses a compiler or dependency newer than the + newest CI achieves, and ask the user to confirm that these should be + recorded as the documented maximum on the assumption that they work. + Edit INSTALL.md only for the confirmed ones. (Do the CI-based edits + without waiting for this answer.) + - Do NOT lower claims that CI merely fails to support (a documented max + newer than any tested build, e.g. ffmpeg) unless the user asks; list + them in the summary instead. + +## Step 8: fixes that change code or workflows (phase 3) + +These change behavior of CI or the build. Never batch them and never apply +them without a decision. + +1. Build a numbered list of candidate fixes from analyses B, C, D, G, H (for + example: pin variable with the wrong name, missing or commented-out + pin, job not at its stated minimum or latest, system package instead of + a pinned version, disable flag that has no effect). For each: what is + wrong, evidence (job, variable, log line), proposed change, risk. +2. Present the list and let the user pick items ONE AT A TIME (use + AskUserQuestion, or ask in prose). Apply only the chosen item, then + show the diff before moving to the next. +3. Ask before committing anything. Follow the repo and user commit rules + (only an `Assisted-by:` trailer, terse messages). One commit per fix, + separate from the phase 2 doc edits unless the user says otherwise. +4. Record any newly understood "right way to fix it" in the "Known issues" + section of this file. + +## Known issues from past audits (update this list) + +From the 2026-09-29 audit (all still to be resolved unless noted): +- `oldest` jobs used to set `BUILD_PNG_VERSION=1.6.0`, which nothing reads, + so system libpng is used. Renaming it to `LIBPNG_VERSION=v1.6.0` made + CI fail (building libpng 1.6.0 broke other dependencies on those + machines), so the useless line was just deleted (2026-10). libpng in + oldest/hobbled stays the system version (1.6.37); do not try to pin it. +- `hobbled` sets `USE_JPEGTURBO=0` yet libjpeg-turbo still appears in the + summary. +- ASWF container jobs use libtiff 4.0.9, below INSTALL's minimum of 4.1. +- Latest-release jobs use Ubuntu apt Imath 3.1.9 with OpenEXR 3.5.x, and + apt ffmpeg/giflib/libheif/TBB/OpenCV/zlib/Python that are far older + than the newest releases. +- `bleeding edge` briefly pinned OpenEXR to a tag (lj2k bug); restored to + `main` on 2026-09-30. Verify on the next audit that the summary shows a + main-built OpenEXR, and whether Imath really follows main (the 09-29 run + found system Imath 3.1.9 even with OpenEXR built from a tag, since + `build_openexr.bash` may use a system Imath if found). +- Imath: `build_openexr.bash` (used when a job sets `openexr_ver`) now + builds the Imath that OpenEXR prefers (`OPENEXR_FORCE_INTERNAL_IMATH=ON`, + installed next to OpenEXR) and exports `Imath_ROOT` so OIIO uses it. + What "prefers" means varies: v3.1.0 -> Imath v3.1.1, v3.4.x -> v3.2.2, + v3.5.x and main -> Imath `main` (override with + `-DOPENEXR_IMATH_TAG=...` in `OPENEXR_CMAKE_FLAGS`). Imath main reports + its version as 3.2.0. Jobs with no `openexr_ver` (ASWF containers) still + use the container's Imath. Added 2026-09-30; confirm on the next audit + that summaries show the expected Imath. A separate `IMATH_VERSION` + selector was tried and dropped as unnecessary for now. +- OIIO's own OpenEXR auto-build (`build_OpenEXR.cmake`) passes + `-D Imath_DIR=...` so OpenEXR uses the same Imath as OIIO. +- OpenJPH is always the `build_openjph.cmake` default; no job tests an + older OpenJPH. +- Minimums not tested by any job: libjpeg 9, libjpeg-turbo 2.1, zlib + 1.2.7 to 1.2.10, OpenJPEG below 2.4, OpenVDB 9, TBB 2018 to 2019, + giflib 5.0, libheif below 1.19, libjxl below 0.11.1, DCMTK below 3.7, + Qt5 below 5.15, ffmpeg below 4.4, clang 10, MSVS 2017/2019. + +## Lessons learned (update this list) + +- The sandbox blocks Bash writes (chmod, heredocs) under `.agents/skills`; + edit skill files with the Edit/Write tools and run scripts via + `bash`/`python3` (no exec bit needed). + +- To confirm "bleeding edge" really builds main/master: grep the job log + with `grep -o -E "git clone [^ ]+ [^ ]+( -b;[a-z]+)?|git checkout [a-zA-Z0-9._-]+ --force"` + (strip the log prefix first). A reported version equal to the last + release is normal (the 2026-10-03 check: LibRaw master still says + 0.22.0 because 0.22.x patch releases come from a branch; pugixml, + libtiff, Freetype, OpenJPEG, pybind11 master headers also match their + latest tag). Compare against the upstream master's version header. + Never pinned to main there: libjxl, robin-map, OpenJPH (tags/defaults). +- `gh run view --log` for a whole run fails; use `--job ` per job. +- Grep the tag lists carefully: naive `sort -V | tail` on libjpeg-turbo, + freetype, openjpeg, ffmpeg, nanobind returns non-release tags. +- Version strings in job names (`desc`) and the `nametag` drift whenever a + runner image or default dependency version changes. diff --git a/.agents/skills/check-dependency-minmax/scripts/fetch_logs.sh b/.agents/skills/check-dependency-minmax/scripts/fetch_logs.sh new file mode 100644 index 0000000000..c517f6ce35 --- /dev/null +++ b/.agents/skills/check-dependency-minmax/scripts/fetch_logs.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Download every job log of a GitHub Actions run, in parallel. +# Usage: fetch_logs.sh [--repo owner/name] +# Logs land in /logs/.log. Needs `gh` (run outside the sandbox). +set -u +run="${1:?run id or url}"; out="${2:?outdir}"; shift 2 +repo_args=("$@") +run="${run##*/runs/}"; run="${run%%/*}" # accept a full URL +mkdir -p "$out/logs" +ids=$(gh run view "$run" "${repo_args[@]}" --json jobs --jq '.jobs[].databaseId') || exit 1 +gh run view "$run" "${repo_args[@]}" --json jobs \ + --jq '.jobs[] | "\(.databaseId)\t\(.conclusion)\t\(.name)"' > "$out/jobs.tsv" +for id in $ids; do + gh run view "${repo_args[@]}" --job "$id" --log > "$out/logs/$id.log" 2> "$out/logs/$id.err" & +done +wait +# Parallel fetches race on gh's zip cache ("not a valid zip file"); retry +# any empty log serially. +for id in $ids; do + for try in 1 2 3; do + [ -s "$out/logs/$id.log" ] && break + gh run view "${repo_args[@]}" --job "$id" --log > "$out/logs/$id.log" 2> "$out/logs/$id.err" + done +done +echo "fetched $(ls "$out"/logs/*.log | wc -l) logs into $out/logs (job list: $out/jobs.tsv)" diff --git a/.agents/skills/check-dependency-minmax/scripts/parse_buildinfo.py b/.agents/skills/check-dependency-minmax/scripts/parse_buildinfo.py new file mode 100644 index 0000000000..fe9629a08d --- /dev/null +++ b/.agents/skills/check-dependency-minmax/scripts/parse_buildinfo.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Extract `oiiotool --buildinfo` from downloaded CI job logs. + +Usage: parse_buildinfo.py [dep ...] + +Reads /logs/*.log (see fetch_logs.sh), writes /parsed.json +(job id -> name, compiler, cxx std, deps), and prints a pivot per dependency +(version -> jobs). With dep names given, only those are pivoted. +Jobs with no buildinfo (skip_tests jobs) get an empty deps dict. +""" +import glob, json, os, re, sys + +out = sys.argv[1] +want = sys.argv[2:] +res = {} +for f in sorted(glob.glob(os.path.join(out, 'logs', '*.log'))): + jid = os.path.basename(f)[:-4] + name, blk, on, cmake = None, [], False, None + for line in open(f, errors='replace').read().split('\n'): + p = line.split('\t', 2) + if len(p) < 3: + continue + name = p[0] + mc = re.search(r'-- (?:Using )?CMake (?:version )?(\d+\.\d+\.\d+)', p[2]) + if mc and not cmake: + cmake = mc.group(1) + t = re.sub(r'^\S*Z ', '', p[2]) # strip timestamp + if re.match(r'OIIO \d', t): + on = True + if on: + if 'Results of oiiotool brief help' in t and not t.startswith('+'): + break + if t.startswith('+') or not t.strip(): + continue # shell trace interleaved by tee + blk.append(t) + txt = ' '.join(x.strip() for x in blk) + m = re.search(r'Build compiler: (.*?) \| (C\+\+\d+)', txt) + comp, std = (m.group(1), m.group(2)) if m else ('?', '?') + m = re.search(r'Dependencies: (.*)$', txt) + deps = {} + if m: + for item in m.group(1).split(', '): + item = item.strip() + mm = re.match(r'(\S+) (.*)', item) + if mm: + deps.setdefault(mm.group(1), mm.group(2)) # first dup wins + elif item: + deps.setdefault(item, 'present') + if cmake and deps: # CMake is not in buildinfo; take it from the build log + deps['CMake'] = cmake + res[jid] = dict(name=name, compiler=comp, std=std, deps=deps) + +json.dump(res, open(os.path.join(out, 'parsed.json'), 'w'), indent=1) + +print('== jobs') +for j, v in res.items(): + print(f"{j} {v['name'][:70]:70} {v['compiler']} {v['std']} deps={len(v['deps'])}") + +def vkey(s): + return [int(x) if x.isdigit() else x for x in re.split(r'[.\-_ ]', s)] + +# Compact table-A input: all distinct versions per dependency, ascending +# (plus compilers). +with open(os.path.join(out, 'versions.txt'), 'w') as vf: + allv = {} + for v in res.values(): + if v['compiler'] != '?': + allv.setdefault('compiler', set()).add(f"{v['compiler']} {v['std']}") + for d, ver in v['deps'].items(): + allv.setdefault(d, set()).add(ver) + for d in sorted(allv, key=str.lower): + try: + vs = sorted(allv[d], key=vkey) + except TypeError: + vs = sorted(allv[d]) + vf.write(f"{d}: {', '.join(vs)}\n") + +alldeps = sorted({d for v in res.values() for d in v['deps']}, key=str.lower) +for d in (want or alldeps): + print('##', d) + vals = {} + for j, v in res.items(): + if not v['deps']: + continue + vals.setdefault(v['deps'].get(d, '-'), []).append(v['name'].split(' / ')[0]) + for ver, jobs in sorted(vals.items()): + print(f' {ver:12} <- {"; ".join(jobs)}')