diff --git a/.github/workflows/compat-prune-monthly.yml b/.github/workflows/compat-prune-monthly.yml
new file mode 100644
index 0000000..4e8c7b4
--- /dev/null
+++ b/.github/workflows/compat-prune-monthly.yml
@@ -0,0 +1,32 @@
+name: Compat prune (monthly)
+
+# The organization's monthly [compat] prune: every CT* package audited together, one PR per
+# repository with something to drop, one tracking issue here in CTActions. The logic is the
+# reusable compat-prune.yml; see docs/WORKFLOWS.md §3.2.
+
+on:
+ schedule:
+ - cron: '0 4 1 * *'
+ workflow_dispatch:
+ inputs:
+ dry_run:
+ description: 'Only audit and report: no PR, no issue'
+ type: boolean
+ default: true
+
+concurrency:
+ group: compat-prune-monthly
+ cancel-in-progress: false
+
+jobs:
+ prune:
+ uses: ./.github/workflows/compat-prune.yml
+ with:
+ # Dry run on every trigger until the GitHub App is set up; then the schedule
+ # publishes: `github.event_name == 'workflow_dispatch' && inputs.dry_run`.
+ dry_run: ${{ github.event_name != 'workflow_dispatch' || inputs.dry_run }}
+ # the scripts of the same commit as this workflow
+ ctactions_ref: ${{ github.sha }}
+ secrets:
+ CT_APP_CLIENT_ID: ${{ secrets.CT_APP_CLIENT_ID }}
+ CT_APP_PRIVATE_KEY: ${{ secrets.CT_APP_PRIVATE_KEY }}
diff --git a/.github/workflows/compat-prune-tests.yml b/.github/workflows/compat-prune-tests.yml
new file mode 100644
index 0000000..e2f434f
--- /dev/null
+++ b/.github/workflows/compat-prune-tests.yml
@@ -0,0 +1,51 @@
+name: Compat prune tests
+
+# Self-test of compat-prune, in CTActions itself: the test suite of scripts/compat-prune on
+# every audited Julia version (unit, non-regression and integration tests), then a real
+# dry-run audit of the ecosystem through the reusable workflow. Runs when the scripts or the
+# workflows change; nothing is written anywhere (dry run).
+
+on:
+ pull_request:
+ paths:
+ - 'scripts/compat-prune/**'
+ - '.github/workflows/compat-prune*.yml'
+ push:
+ branches: [main]
+ paths:
+ - 'scripts/compat-prune/**'
+ - '.github/workflows/compat-prune*.yml'
+ workflow_dispatch:
+
+concurrency:
+ group: compat-prune-tests-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ tests:
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ permissions:
+ contents: read
+ strategy:
+ fail-fast: false
+ matrix:
+ version: ['1.10', '1.12', '1.13']
+ steps:
+ - uses: actions/checkout@v6
+ - uses: julia-actions/setup-julia@v2
+ with:
+ version: ${{ matrix.version }}
+ - name: Add the General registry
+ run: julia --startup-file=no -e 'using Pkg; Pkg.Registry.add("General")'
+ - name: Tests (with integration)
+ env:
+ COMPAT_PRUNE_INTEGRATION: '1'
+ run: julia --startup-file=no scripts/compat-prune/test/runtests.jl
+
+ dry-run:
+ needs: tests
+ uses: ./.github/workflows/compat-prune.yml
+ with:
+ dry_run: true
+ ctactions_ref: ${{ github.event.pull_request.head.sha || github.sha }}
diff --git a/.github/workflows/compat-prune.yml b/.github/workflows/compat-prune.yml
new file mode 100644
index 0000000..bee0417
--- /dev/null
+++ b/.github/workflows/compat-prune.yml
@@ -0,0 +1,185 @@
+name: Compat prune
+
+# Finds the [compat] specifiers that no supported Julia version ever selects across the
+# control-toolbox packages, proves that dropping them leaves every resolved manifest
+# unchanged, and (unless dry_run) opens one PR per affected repository plus one tracking
+# issue (in CTActions by default), both updated in place by later runs. The prune is the
+# organization's: it is called monthly by `compat-prune-monthly.yml`, here in CTActions, and
+# by the self-test `compat-prune-tests.yml`; see docs/WORKFLOWS.md §3.2.
+#
+# The `audit` job is read-only: it resolves the joint, test and docs environments of every
+# package on every Julia version (Pkg's resolver only: nothing is downloaded or installed),
+# classifies the multi-specifier entries, validates the prunes, applies them to fresh
+# checkouts of the default branches, and uploads the result (`compat-prune` artifact,
+# report in the job summary). Scripts and tests: scripts/compat-prune/.
+#
+# The `publish` job (skipped on dry runs) is the only one that writes, with a token of the
+# organization's GitHub App (secrets CT_APP_CLIENT_ID, CT_APP_PRIVATE_KEY; permissions:
+# contents, pull requests, issues). It re-applies the prunes to fresh checkouts, then keeps
+# one branch `ct/compat-prune` and one PR per repository, and one tracking issue in
+# `tracking_repo`: a rerun updates them in place, and closes what has nothing left.
+
+on:
+ workflow_call:
+ inputs:
+ packages:
+ description: 'JSON list of the audited packages (registered names)'
+ required: false
+ type: string
+ default: '["CTBase","CTParser","CTModels","CTLie","CTFlows","CTDirect","CTSolvers","OptimalControl"]'
+ julia_versions:
+ description: 'JSON list of the Julia versions (juliaup channels); the last one is the newest'
+ required: false
+ type: string
+ default: '["1.10","1.12","1.13"]'
+ dry_run:
+ description: 'Only audit and report: no PR, no issue'
+ required: false
+ type: boolean
+ default: true
+ ctactions_ref:
+ description: 'Ref of CTActions the scripts are checked out from'
+ required: false
+ type: string
+ default: main
+ tracking_repo:
+ description: 'Repository of the tracking issue'
+ required: false
+ type: string
+ default: control-toolbox/CTActions
+ secrets:
+ CT_APP_CLIENT_ID:
+ description: 'Client ID of the GitHub App that publishes (not needed for a dry run)'
+ required: false
+ CT_APP_PRIVATE_KEY:
+ description: 'Private key of that GitHub App'
+ required: false
+
+jobs:
+ audit:
+ runs-on: ubuntu-latest
+ timeout-minutes: 120
+ permissions:
+ contents: read
+ env:
+ PACKAGES: ${{ inputs.packages }}
+ JULIA_VERSIONS: ${{ inputs.julia_versions }}
+ SCRIPTS: ctactions/scripts/compat-prune
+ OUT: ${{ github.workspace }}/compat-prune
+ GH_TOKEN: ${{ github.token }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ repository: control-toolbox/CTActions
+ ref: ${{ inputs.ctactions_ref }}
+ path: ctactions
+
+ - uses: julia-actions/install-juliaup@v3
+ with:
+ channel: release
+
+ - name: Install the audited Julia versions
+ run: |
+ for v in $(jq -r '.[]' <<< "$JULIA_VERSIONS"); do juliaup add "$v"; done
+ juliaup status
+
+ # ct-registry is public: added over HTTPS, no SSH key needed. The registries are
+ # updated once more by audit.jl, then frozen for the whole audit.
+ - name: Add the registries
+ run: |
+ julia --startup-file=no -e 'using Pkg
+ Pkg.Registry.add("General")
+ Pkg.Registry.add(url="https://github.com/control-toolbox/ct-registry.git")'
+
+ - name: Test the scripts
+ run: julia --startup-file=no "$SCRIPTS/test/runtests.jl"
+
+ - name: Audit
+ run: julia --startup-file=no "$SCRIPTS/audit.jl" "$PACKAGES" "$JULIA_VERSIONS" "$OUT"
+
+ - name: Apply the prunes to fresh checkouts of the default branches
+ run: |
+ "$SCRIPTS/checkout.sh" "$OUT" checkouts
+ julia --startup-file=no "$SCRIPTS/edit.jl" "$OUT" checkouts
+ for d in checkouts/*/; do
+ git -C "$d" diff --src-prefix="a/$(basename "$d")/" --dst-prefix="b/$(basename "$d")/"
+ done > "$OUT/edits.diff"
+
+ - name: Report
+ run: |
+ RUN_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
+ julia --startup-file=no "$SCRIPTS/report.jl" "$OUT"
+ {
+ cat "$OUT/report.md"
+ echo
+ echo "Edits on the default branches (diff)
"
+ echo
+ echo '```diff'
+ cat "$OUT/edits.diff"
+ echo '```'
+ echo " "
+ } >> "$GITHUB_STEP_SUMMARY"
+
+ - uses: actions/upload-artifact@v7
+ with:
+ name: compat-prune
+ path: compat-prune/
+ retention-days: 30
+
+ publish:
+ needs: audit
+ if: ${{ !inputs.dry_run }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ # Two publications at once would race on the same branches and issue.
+ concurrency:
+ group: compat-prune-publish
+ cancel-in-progress: false
+ permissions:
+ contents: read
+ env:
+ SCRIPTS: ctactions/scripts/compat-prune
+ OUT: ${{ github.workspace }}/compat-prune
+ steps:
+ - name: Check the App credentials
+ env:
+ CLIENT_ID: ${{ secrets.CT_APP_CLIENT_ID }}
+ PRIVATE_KEY: ${{ secrets.CT_APP_PRIVATE_KEY }}
+ run: |
+ if [ -z "$CLIENT_ID" ] || [ -z "$PRIVATE_KEY" ]; then
+ echo "::error::Publishing needs the secrets CT_APP_CLIENT_ID and CT_APP_PRIVATE_KEY (or run with dry_run: true)."
+ exit 1
+ fi
+
+ - uses: actions/checkout@v6
+ with:
+ repository: control-toolbox/CTActions
+ ref: ${{ inputs.ctactions_ref }}
+ path: ctactions
+
+ - uses: actions/download-artifact@v8
+ with:
+ name: compat-prune
+ path: compat-prune
+
+ - uses: julia-actions/install-juliaup@v3
+ with:
+ channel: release
+
+ - uses: actions/create-github-app-token@v3
+ id: app
+ with:
+ client-id: ${{ secrets.CT_APP_CLIENT_ID }}
+ private-key: ${{ secrets.CT_APP_PRIVATE_KEY }}
+ owner: control-toolbox
+
+ - name: Publish
+ env:
+ GH_TOKEN: ${{ steps.app.outputs.token }}
+ TRACKING_REPO: ${{ inputs.tracking_repo }}
+ run: |
+ gh auth setup-git
+ "$SCRIPTS/checkout.sh" "$OUT" checkouts
+ julia --startup-file=no "$SCRIPTS/edit.jl" "$OUT" checkouts
+ RUN_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
+ julia --startup-file=no "$SCRIPTS/publish.jl" "$OUT" checkouts "$TRACKING_REPO" | tee -a "$GITHUB_STEP_SUMMARY"
diff --git a/README.md b/README.md
index 2bdcb1e..cf0dd40 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,7 @@ repository and for org-wide updates — is described in
|---|---|---|
| `occidata-runner-maintenance.yml` | Weekly (Mon 02:30 UTC) | Purge Julia cache, update TeXLive, rotate logs on self-hosted runner |
| `occidata-runner-watchdog.yml` | Daily (03:15 UTC) | Restart, through SSH, the Occidata runner jobs (one per organization) that are neither pending nor running |
+| `compat-prune-monthly.yml` | Monthly (1st, 04:00 UTC) | Drop the `[compat]` specifiers no supported Julia version selects, across all CT\* packages: one PR per repository, one tracking issue here (logic: reusable `compat-prune.yml`, scripts and tests in `scripts/compat-prune/`) |
The Occidata watchdog requires these CTActions repository or organization secrets:
diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md
index 9302bd4..d04b62e 100644
--- a/docs/WORKFLOWS.md
+++ b/docs/WORKFLOWS.md
@@ -336,15 +336,18 @@ jobs:
### 3.2 Maintenance workflows that live *in* `CTActions` (not called)
-These run on `CTActions` itself, on a schedule, to keep the **self-hosted runners**
-healthy. They are not `workflow_call` reusables.
+These run on `CTActions` itself: on a schedule, to keep the **self-hosted runners**
+healthy or to act on the whole organization, or on its own PRs, to test the scripts behind
+a workflow. No package calls them.
| Workflow (`CTActions`) | Role | Trigger |
| --- | --- | --- |
| `occidata-runner-maintenance.yml` | Purge Julia compile cache, update TeXLive, rotate logs on the `occidata` self-hosted runner | weekly cron, `workflow_dispatch` |
| `occidata-runner-watchdog.yml` | Over SSH, run `scripts/start_runners.sh` (control-toolbox/occidata), which restarts every Occidata runner instance whose SLURM job is neither pending nor running | daily cron, `workflow_dispatch` |
+| `compat-prune-monthly.yml` | The organization's `[compat]` prune: runs the reusable `compat-prune.yml` on every CT\* package; one PR per repository with something to drop, one tracking issue in CTActions | monthly cron (1st, 04:00 UTC), `workflow_dispatch` (`dry_run`) |
+| `compat-prune-tests.yml` | Self-test of `compat-prune`: the test suite of `scripts/compat-prune` on every audited Julia version, then a real dry-run audit through `compat-prune.yml` | PR or push touching `scripts/compat-prune/` or `compat-prune*.yml`, `workflow_dispatch` |
-`occidata` started as the target of this maintenance workflow only; since CTFlows.jl's
+`occidata` started as the target of the maintenance workflow only; since CTFlows.jl's
`occidata-runner` job (§3.1, added 2026-08-24) it is also a live GPU CI target for every
package's test matrix that needs one, and (2026-09-02, control-toolbox/OptimalControl.jl#885)
a documentation-build target too — not just a runner kept healthy for its own sake. Two
@@ -381,6 +384,82 @@ organization. It runs in `cpu` mode: no `$OCCIDATA_GPU_RUN`, so no GPU ever. Ins
their modes and how to add one are documented in control-toolbox/occidata,
`docs/gha-runner-config.md` § 9.
+**`[compat]` entries are pruned for the whole organization, once a month.**
+`compat-helper.yml` runs with `KeepEntry()`: a new major is added next to the old ones
+(`JET = "0.9, 0.11, 0.12"`), and
+`ci.yml` tests the newest on CompatHelper PRs (`force_latest_compatible_version=auto`,
+except on Julia 1.10, CTActions#93). Old specifiers therefore pile up, and none can be
+dropped from inside one package: `JET 0.9` is still what Julia 1.10 selects, and
+CTSolvers' `CUDSS 0.7` is what OptimalControl's tests select. The prune is therefore the
+organization's, decided for all CT\* packages at once (control-toolbox/OptimalControl.jl#986),
+and lives here: `compat-prune-monthly.yml` runs the reusable `compat-prune.yml` (also run
+by the self-test) on the 1st of every month, and keeps its tracking issue in CTActions
+(input `tracking_repo`). No package carries a caller.
+
+- **What is audited.** The version of each package that the joint resolution of all of
+ them selects, read from its registered tree (not from a branch), and 17 environments: the
+ joint one, plus the test environment (`test/Project.toml` or `[targets] test`) and the
+ docs environment of every package, each on every Julia version of `julia_versions`.
+ Resolution calls Pkg's resolver only: nothing is downloaded or installed, and one
+ registry snapshot serves the whole audit (about 4 minutes on a runner).
+- **What is pruned.** In an entry with several specifiers, those older than the newest one
+ in use, that no environment selects on any Julia version, counting only the environments
+ where the entry applies (where its package is at its audited version, or the very `test/`
+ or `docs/` environment of the file). Every prune is then simulated, by intersecting the
+ compat of the environments where it applies, and kept only if all manifests stay
+ identical. A prune therefore changes nothing that is resolved: the PRs can be merged in
+ any order and need no release.
+- **What is reported.** The tracking issue (hidden marker ``)
+ opens with the record of the audit (environments resolved per Julia version, entries
+ examined by status, registry snapshot, and, folded, the entries kept or idle with why),
+ then lists the prunes per repository with their PR, those merged but not yet released, the
+ *drifts* (the default branch no longer has the audited line: untouched), the prunes
+ rejected by the simulation, the *withheld* ones (an environment failed to resolve: nothing
+ is pruned that run), and the *blocked* entries (the newest specifier is never selected,
+ with the packages that cap it: information only).
+- **Idempotence.** One branch `ct/compat-prune` and one PR per repository, one issue: a
+ rerun rewrites them in place (the branch is pushed only when its content changes), closes
+ the PR and deletes the branch of a repository with nothing left, and closes the issue
+ (reopened, never recreated) when nothing is left at all. The edits are re-applied to the
+ current default branch, one `[compat]` line at a time, never copied from the audit.
+- **Dry run.** `dry_run: true` (the default) runs the read-only `audit` job only: report
+ and diff in the job summary, everything in the `compat-prune` artifact. The `publish`
+ job writes with a token of the organization's GitHub App (`CT_APP_CLIENT_ID`,
+ `CT_APP_PRIVATE_KEY`; permissions: contents, pull requests, issues, on the CT\*
+ repositories) and is the only one that does.
+
+`ct-registry` is added over HTTPS (it is public), so no `SSH_KEY` is needed. The scripts
+live in `scripts/compat-prune/` and run locally the same way (juliaup channels):
+
+```bash
+julia scripts/compat-prune/audit.jl '["CTBase","CTParser","CTModels","CTLie","CTFlows","CTDirect","CTSolvers","OptimalControl"]' '["1.10","1.12","1.13"]' /tmp/audit
+COMPAT_PRUNE_INTEGRATION=1 julia scripts/compat-prune/test/runtests.jl # unit, non-regression, integration
+```
+
+The monthly workflow:
+
+```yaml
+# .github/workflows/compat-prune-monthly.yml (CTActions)
+on:
+ schedule:
+ - cron: '0 4 1 * *'
+ workflow_dispatch:
+ inputs:
+ dry_run: { type: boolean, default: true }
+jobs:
+ prune:
+ uses: ./.github/workflows/compat-prune.yml
+ with:
+ dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
+ ctactions_ref: ${{ github.sha }}
+ secrets:
+ CT_APP_CLIENT_ID: ${{ secrets.CT_APP_CLIENT_ID }}
+ CT_APP_PRIVATE_KEY: ${{ secrets.CT_APP_PRIVATE_KEY }}
+```
+
+Until the GitHub App is set up, every trigger is a dry run
+(`dry_run: ${{ github.event_name != 'workflow_dispatch' || inputs.dry_run }}`).
+
### 3.3 Non-centralized / per-repo special workflows
| Workflow | Where | Why it isn't centralized |
@@ -495,6 +574,7 @@ runner is the one maintained by the scheduled `CTActions` maintenance workflows
| `DOCUMENTER_KEY` | Documentation, CompatHelper, TagBot | Deploy docs / sign tags (Documenter deploy key) |
| `CODECOV_TOKEN` → `codecov-secret` | Coverage | Upload coverage to Codecov |
| `PROJECT_TOKEN` → `project_token` | AddToProject | Write to the org project board |
+| `CT_APP_CLIENT_ID`, `CT_APP_PRIVATE_KEY` | `compat-prune-monthly.yml` in CTActions (`publish` job) | Token of the organization's GitHub App: push `ct/compat-prune` and open PRs in the CT\* repositories, keep the tracking issue |
| `GITHUB_TOKEN` | (built-in) most | Default repo-scoped token |
Use `secrets: inherit` (as `UpdateReadme` does) only when the reusable workflow needs
diff --git a/scripts/compat-prune/audit.jl b/scripts/compat-prune/audit.jl
new file mode 100644
index 0000000..e79ecab
--- /dev/null
+++ b/scripts/compat-prune/audit.jl
@@ -0,0 +1,136 @@
+# Run a whole audit: one registry snapshot, every Julia version.
+#
+# usage: julia audit.jl '' ''
+#
+# Each Julia version is run as `julia +` (juliaup). Steps:
+# 1. update the registries once; no later step updates them (see `resolve_env`)
+# 2. collect.jl and envs.jl, on the last (newest) Julia version
+# 3. resolve.jl: the baseline of every environment, on each Julia version
+# 4. classify.jl, from all baselines
+# 5. validate.jl on each Julia version, repeated with the rejected prunes removed until no
+# version rejects anything new (a smaller set of prunes is re-checked as a whole)
+# Output: /audit.toml (accepted prunes, rejected prunes, blockers, other entries, and
+# the record of the audit itself: registries, coverage, entries), next to the intermediate
+# files of every step.
+
+using Dates, Pkg, TOML
+
+include(joinpath(@__DIR__, "common.jl"))
+
+short(v) = join(split(v, ".")[1:2], ".")
+
+"""
+ registry_snapshot() -> Vector{Dict}
+
+Name and state of every reachable registry: the tree hash of a registry installed from a
+tarball (General, through the package server), the commit of one cloned with git.
+"""
+function registry_snapshot()
+ out = Dict{String,Any}[]
+ for reg in Pkg.Registry.reachable_registries()
+ state = if reg.tree_info !== nothing
+ "tree $(reg.tree_info)"
+ elseif isdir(joinpath(reg.path, ".git"))
+ "commit " * readchomp(`git -C $(reg.path) rev-parse HEAD`)
+ else
+ "unknown"
+ end
+ push!(out, Dict("name" => reg.name, "state" => state))
+ end
+ return sort(out; by=r -> r["name"])
+end
+
+"""
+ coverage(baselines) -> Vector{Dict}
+
+For each Julia version (`julia` => parsed baseline), how many environments resolved, and
+which failed.
+"""
+coverage(baselines) = [Dict("julia" => j, "resolved" => count(r -> r["ok"], values(b["env"])), "total" => length(b["env"]),
+ "failed" => sort([n for (n, r) in b["env"] if !r["ok"]]))
+ for (j, b) in sort(collect(baselines); by=first)]
+
+"""
+ summarize(; date, julia, index, classified, rejected, blocked, registries, coverage) -> Dict
+
+The content of audit.toml: the accepted prunes (classified minus rejected), the rejected
+ones, the blocker probes, the other entries (keep, idle, withheld), and the record of the
+audit: registries, coverage, and how many multi-specifier entries were examined, with one
+status each.
+"""
+function summarize(; date, julia, index, classified, rejected, blocked, registries, coverage)
+ gone = Set(entry_key.(rejected))
+ prunes = [p for p in classified["prune"] if !(entry_key(p) in gone)]
+ others = [r for r in classified["other"] if r["status"] != "blocked"]
+ # one status per entry: a blocked entry may also carry a prune of its obsolete lanes
+ status = Dict{Any,String}()
+ for p in classified["prune"]
+ status[entry_key(p)] = entry_key(p) in gone ? "rejected" : "prune"
+ end
+ for r in classified["other"]; status[entry_key(r)] = r["status"]; end
+ entries = Dict{String,Any}(s => count(==(s), values(status)) for s in ("prune", "rejected", "keep", "blocked", "idle", "withheld"))
+ entries["examined"] = length(status)
+ return Dict{String,Any}(
+ "date" => date, "julia" => julia, "packages" => index["packages"],
+ "prune" => prunes, "rejected" => rejected, "blocked" => blocked, "other" => others,
+ "record" => Dict{String,Any}("registries" => registries, "coverage" => coverage, "entries" => entries),
+ )
+end
+
+function run_step(v, script, args...)
+ println("\n\$ julia +$v $script ", join(args, " "))
+ run(`julia +$v --startup-file=no $(joinpath(@__DIR__, script)) $args`)
+end
+
+if abspath(PROGRAM_FILE) == @__FILE__
+ packages, versions, out = ARGS[1], ARGS[2], abspath(ARGS[3])
+ jlv = parse_list(versions)
+
+ Pkg.Registry.update()
+ registries = registry_snapshot()
+ mkpath(out)
+
+ run_step(last(jlv), "collect.jl", packages, out)
+ run_step(last(jlv), "envs.jl", out)
+ for v in jlv
+ run_step(v, "resolve.jl", out, joinpath(out, "baseline-$(short(v)).toml"))
+ end
+ run_step(last(jlv), "classify.jl", out, joinpath(out, "classified.toml"),
+ (joinpath(out, "baseline-$(short(v)).toml") for v in jlv)...)
+
+ rejected = Dict{String,Any}[]
+ blocked = Dict{String,Any}[]
+ for round in 1:5
+ open(io -> TOML.print(io, Dict("rejected" => rejected)), joinpath(out, "rejected.toml"), "w")
+ new = Dict{String,Any}[]
+ empty!(blocked)
+ for v in jlv
+ res = joinpath(out, "validated-$(short(v)).toml")
+ blockers = v == last(jlv) ? ("--blockers",) : ()
+ run_step(v, "validate.jl", out, joinpath(out, "classified.toml"),
+ joinpath(out, "baseline-$(short(v)).toml"), res, joinpath(out, "rejected.toml"), blockers...)
+ r = TOML.parsefile(res)
+ append!(new, r["rejected"])
+ append!(blocked, r["blocked"])
+ end
+ known = Set(entry_key.(rejected))
+ fresh = [p for p in new if !(entry_key(p) in known)]
+ isempty(fresh) && break
+ round == 5 && error("validation did not settle after 5 rounds")
+ # one record per prune, the first reason found
+ for p in fresh
+ entry_key(p) in known || (push!(rejected, p); push!(known, entry_key(p)))
+ end
+ end
+
+ baselines = Dict(short(v) => TOML.parsefile(joinpath(out, "baseline-$(short(v)).toml")) for v in jlv)
+ audit = summarize(; date=Dates.format(now(UTC), "yyyy-mm-dd HH:MM") * " UTC", julia=jlv,
+ index=TOML.parsefile(joinpath(out, "index.toml")),
+ classified=TOML.parsefile(joinpath(out, "classified.toml")),
+ rejected, blocked, registries, coverage=coverage(baselines))
+ open(io -> TOML.print(io, audit), joinpath(out, "audit.toml"), "w")
+ e = audit["record"]["entries"]
+ println("\n", e["examined"], " entries examined: ", length(audit["prune"]), " prune(s) accepted, ",
+ length(rejected), " rejected, ", e["blocked"], " blocked, ", e["keep"], " kept, ", e["idle"], " idle, ",
+ e["withheld"], " withheld")
+end
diff --git a/scripts/compat-prune/checkout.sh b/scripts/compat-prune/checkout.sh
new file mode 100755
index 0000000..47e8a3d
--- /dev/null
+++ b/scripts/compat-prune/checkout.sh
@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+# Shallow clone of the default branch of every audited package.
+#
+# usage: checkout.sh
+#
+# Reads /index.toml; clones $COMPAT_PRUNE_GIT_BASE/.git (default
+# https://github.com; tests point it at local repositories) into /.
+# Pushing later relies on git's credentials (`gh auth setup-git` in the publish job).
+set -euo pipefail
+audit=$1
+checkouts=$2
+mkdir -p "$checkouts"
+julia --startup-file=no -e 'using TOML
+ for (n, p) in sort(collect(TOML.parsefile(ARGS[1])["packages"]); by=first)
+ println(n, " ", p["repo"], " ", p["default_branch"])
+ end' "$audit/index.toml" |
+while read -r name repo branch; do
+ git clone --quiet --depth 1 --branch "$branch" "${COMPAT_PRUNE_GIT_BASE:-https://github.com}/$repo.git" "$checkouts/$name"
+done
diff --git a/scripts/compat-prune/classify.jl b/scripts/compat-prune/classify.jl
new file mode 100644
index 0000000..2e654af
--- /dev/null
+++ b/scripts/compat-prune/classify.jl
@@ -0,0 +1,130 @@
+# Classify every multi-specifier compat entry from the baselines of all Julia versions.
+#
+# usage: julia classify.jl ...
+#
+# A specifier of the entry of `owner` on `dep` is *used* when, in an environment where that
+# entry applies (see `applies` in common.jl) and on some Julia version, the resolved `dep`
+# falls in it. Then, ordering specifiers by their lower bound:
+# prune unused specifiers older than the newest used one: obsolete lanes;
+# blocked the newest specifier is unused: something caps `dep` below it (reported, the
+# blocker is identified by validate.jl);
+# idle `dep` is resolved nowhere the entry applies (e.g. a weak dependency no audited
+# environment loads): nothing can be proven, nothing is touched.
+# Unused specifiers between the newest used one and the newest declared one are blocked
+# lanes too, never pruned. If any baseline environment failed to resolve, the lanes it would
+# have used are unknown: every prune is then withheld, and the failure is reported.
+
+include("common.jl")
+
+"Specifiers of a compat string, in declaration order."
+specifiers(s) = String.(strip.(split(s, ",")))
+
+"Lower bound of one specifier, for ordering (`\"0.7 - 0.8\"` → v0.7, `\"^1.2\"` → v1.2)."
+lower(s) = VersionNumber(match(r"\d+(\.\d+){0,2}", s).match)
+
+"""
+ classify_entry(owner, file, dep, compat, results, audited) -> Dict
+
+`results` maps `"@"` to the parsed result of that environment. Returns a record
+with `status` (`prune`, `blocked`, `keep`, `idle`), the specifiers used, and the evidence:
+where each used specifier was selected.
+"""
+function classify_entry(owner, file, dep, compat, results, audited)
+ parts = specifiers(compat)
+ evidence = Dict(p => String[] for p in parts)
+ for (key, r) in sort(collect(results); by=first)
+ r["ok"] || continue
+ name = first(split(key, "@"))
+ v = get(r["versions"], dep, nothing)
+ (v === nothing || !applies(owner, file, name, r["versions"], audited)) && continue
+ for p in parts
+ VersionNumber(v) in spec(p) && push!(evidence[p], "$key=$v")
+ end
+ end
+ record = Dict{String,Any}("owner" => owner, "file" => file, "dep" => dep, "before" => compat)
+ used = [p for p in parts if !isempty(evidence[p])]
+ record["evidence"] = Dict(p => evidence[p] for p in used)
+ isempty(used) && return merge(record, Dict("status" => "idle"))
+ newest_used = maximum(lower, used)
+ obsolete = [p for p in parts if isempty(evidence[p]) && lower(p) < newest_used]
+ if maximum(lower, parts) > newest_used
+ record["status"] = "blocked"
+ record["newest"] = parts[argmax(lower.(parts))]
+ record["applies_in"] = sort(unique(first.(split.(reduce(vcat, values(evidence)), "@"))))
+ # obsolete lanes of a blocked entry are still obsolete: report both
+ isempty(obsolete) || (record["prunable"] = obsolete)
+ return record
+ end
+ isempty(obsolete) && return merge(record, Dict("status" => "keep"))
+ return merge(record, Dict("status" => "prune", "removed" => obsolete,
+ "after" => join(setdiff(parts, obsolete), ", ")))
+end
+
+"Every multi-specifier entry of the audited files, classified."
+function classify(root, results, audited)
+ records = Dict{String,Any}[]
+ for owner in sort(collect(keys(audited))), file in AUDITED_FILES
+ path = joinpath(root, "registered", owner, file)
+ isfile(path) || continue
+ for (dep, compat) in sort(collect(get(TOML.parsefile(path), "compat", Dict())))
+ dep == "julia" && continue
+ length(specifiers(compat)) > 1 || continue
+ push!(records, classify_entry(owner, file, dep, compat, results, audited))
+ end
+ end
+ return records
+end
+
+"""
+ withhold(prunes, results) -> (prunes, withheld)
+
+Withhold every prune when a baseline environment (any `"@"` of `results`) failed.
+"""
+function withhold(prunes, results)
+ failed = sort([k for (k, r) in results if !r["ok"]])
+ isempty(failed) && return prunes, Dict{String,Any}[]
+ why = "baseline failed in $(join(failed, ", ")): lanes used there are unknown"
+ return Dict{String,Any}[], [merge(p, Dict("status" => "withheld", "reason" => why)) for p in prunes]
+end
+
+"Baseline results of every Julia version, keyed `\"@\"`."
+function load_results(files)
+ results = Dict{String,Any}()
+ for f in files
+ b = TOML.parsefile(f)
+ j = join(split(b["julia"], ".")[1:2], ".")
+ for (name, r) in b["env"]; results["$name@$j"] = r; end
+ end
+ return results
+end
+
+"""
+ classify_all(root, results) -> (prunes, others)
+
+Classify every entry; obsolete lanes of blocked entries become prunes of their own; every
+prune is withheld when a baseline failed.
+"""
+function classify_all(root, results)
+ _, audited, _ = load_collected(root)
+ records = classify(root, results, audited)
+ prunes = [r for r in records if r["status"] == "prune"]
+ for r in records
+ r["status"] == "blocked" && haskey(r, "prunable") || continue
+ parts = specifiers(r["before"])
+ push!(prunes, merge(r, Dict("status" => "prune", "removed" => r["prunable"],
+ "after" => join(setdiff(parts, r["prunable"]), ", "))))
+ end
+ prunes, withheld = withhold(prunes, results)
+ others = vcat([r for r in records if r["status"] != "prune"], withheld)
+ return prunes, others
+end
+
+if abspath(PROGRAM_FILE) == @__FILE__
+ root, outfile = ARGS[1], ARGS[2]
+ prunes, others = classify_all(root, load_results(ARGS[3:end]))
+ open(io -> TOML.print(io, Dict("prune" => prunes, "other" => others)), outfile, "w")
+ for r in sort(vcat(prunes, others); by=r -> (r["owner"], r["file"], r["dep"]))
+ println(rpad("$(r["owner"]) $(r["file"])", 34), rpad(r["dep"], 26), rpad(r["before"], 18), r["status"],
+ haskey(r, "after") ? " → $(r["after"])" : "")
+ end
+end
diff --git a/scripts/compat-prune/collect.jl b/scripts/compat-prune/collect.jl
new file mode 100644
index 0000000..31245c7
--- /dev/null
+++ b/scripts/compat-prune/collect.jl
@@ -0,0 +1,62 @@
+# Collect the audited files of every package.
+#
+# usage: julia collect.jl ''
+#
+# The audited version of each package is the one the joint environment (all packages
+# together) selects on the running Julia, i.e. what a user of the root package gets. Its
+# files are read from the registered tree (manifest `git-tree-sha1`), not from a tag or a
+# branch: default branches may have moved on, or a version may have been registered from
+# another branch. The same files are also read from the default branch, to detect drift
+# before editing anything.
+#
+# Output:
+# /index.toml name => uuid, version, repo, tree, default_branch
+# /registered// files of the registered tree
+# /default// files of the default branch
+
+include("common.jl")
+
+"Download the audited files present in tree `treeish` of `repo` into `dest`; return their paths."
+function fetch_files(repo, treeish, dest)
+ jq = ".tree[] | select(.type == \"blob\") | .path + \" \" + .sha"
+ listing = gh_jq("repos/$repo/git/trees/$treeish?recursive=1", jq)
+ blobs = Dict(Tuple(split(l)) for l in split(listing, '\n') if !isempty(l))
+ found = String[]
+ for f in AUDITED_FILES
+ haskey(blobs, f) || continue
+ path = joinpath(dest, f)
+ mkpath(dirname(path))
+ write(path, gh_raw("repos/$repo/git/blobs/$(blobs[f])"))
+ push!(found, f)
+ end
+ return found
+end
+
+"""
+ collect_files(out, lookup, versions, trees) -> Dict
+
+Fetch the registered and default-branch files of every package of `lookup` (name =>
+(uuid, repo)) into `out`, write `/index.toml` and return the index.
+"""
+function collect_files(out, lookup, versions, trees)
+ index = Dict{String,Any}()
+ for n in sort(collect(keys(lookup)))
+ uuid, repo = lookup[n]
+ branch = gh_jq("repos/$repo", ".default_branch")
+ registered = fetch_files(repo, trees[n], joinpath(out, "registered", n))
+ default = fetch_files(repo, branch, joinpath(out, "default", n))
+ index[n] = Dict("uuid" => uuid, "version" => versions[n], "repo" => repo, "tree" => trees[n],
+ "default_branch" => branch, "files" => registered, "default_files" => default)
+ println(rpad(n, 16), rpad(versions[n], 14), repo, " [", join(registered, ", "), "]")
+ end
+ open(io -> TOML.print(io, Dict("julia" => string(VERSION), "packages" => index)), joinpath(out, "index.toml"), "w")
+ return index
+end
+
+if abspath(PROGRAM_FILE) == @__FILE__
+ packages, out = parse_list(ARGS[1]), ARGS[2]
+ lookup = Dict(n => registry_lookup(n) for n in packages)
+ joint = resolve_env(Dict(n => lookup[n][1] for n in packages), Dict())
+ joint["ok"] || error("The joint environment does not resolve on Julia $VERSION:\n$(joint["error"])")
+ collect_files(out, lookup, joint["versions"], joint["trees"])
+end
diff --git a/scripts/compat-prune/common.jl b/scripts/compat-prune/common.jl
new file mode 100644
index 0000000..53b322e
--- /dev/null
+++ b/scripts/compat-prune/common.jl
@@ -0,0 +1,167 @@
+# Helpers shared by the compat-prune scripts. Stdlib only.
+
+using Pkg, TOML
+
+# Resolution only: environments are resolved by calling Pkg's resolver directly (see
+# `resolve_env`), which never downloads, installs, precompiles, or updates a registry. An
+# audit therefore sees one registry snapshot from start to end: `audit.jl` updates the
+# registries once, before anything else.
+
+# The command runner behind `gh`: a function of the argument vector returning standard
+# output. Tests replace it with a fake to run without network; a second include keeps it.
+@isdefined(GH) || global GH = Ref{Any}(args -> read(`gh $args`, String))
+
+"Run `gh` with `args` and return its standard output."
+gh(args...) = GH[](collect(String, args))
+
+"`gh api` call returning the raw body (blobs, file contents)."
+gh_raw(path) = gh("api", path, "-H", "Accept: application/vnd.github.raw")
+
+"`gh api` call returning one field extracted with `--jq`."
+gh_jq(path, jq) = strip(gh("api", path, "--jq", jq))
+
+"""
+ resolve_env(deps, compat) -> Dict
+
+Resolve a throwaway environment whose `[deps]` are `deps` (name => uuid) and whose
+`[compat]` is `compat` (name => specifier), on the running Julia. Returns
+`Dict("ok" => true, "versions" => …, "trees" => …)` (name => version string, name =>
+git-tree-sha1 of the registered packages), or `Dict("ok" => false, "error" => …)` when the
+requirements are unsatisfiable.
+
+`Pkg.resolve` is not used: it downloads every source and artifact of the result (GBs of CUDA
+on a fresh runner), and `Pkg.offline(true)` would restrict it to the versions already
+installed. `Pkg.Operations.resolve_versions!` only resolves; it is internal, but its
+signature is the same from Julia 1.10 to 1.13 (checked by the integration tests).
+"""
+function resolve_env(deps::AbstractDict, compat::AbstractDict)
+ dir = mktempdir()
+ open(joinpath(dir, "Project.toml"), "w") do io
+ TOML.print(io, Dict("deps" => deps, "compat" => compat))
+ end
+ current = Base.active_project()
+ try
+ Pkg.activate(dir; io=devnull)
+ ctx = Pkg.Types.Context()
+ pkgs = Pkg.Operations.load_direct_deps(ctx.env)
+ Pkg.Operations.resolve_versions!(ctx.env, ctx.registries, pkgs, ctx.julia_version, false)
+ return Dict("ok" => true,
+ "versions" => Dict(p.name => string(p.version) for p in pkgs),
+ "trees" => Dict(p.name => string(p.tree_hash) for p in pkgs if p.tree_hash !== nothing))
+ catch e
+ # Only an unsatisfiable resolution is a result; anything else is a bug of the caller.
+ e isa Pkg.Resolve.ResolverError || rethrow()
+ return Dict("ok" => false, "error" => sprint(showerror, e))
+ finally
+ Pkg.activate(current; io=devnull)
+ rm(dir; recursive=true, force=true)
+ end
+end
+
+"""
+ registry_lookup(name) -> (uuid, repo)
+
+UUID and GitHub `owner/repo` of a registered package, read from the first reachable registry
+that knows it.
+"""
+function registry_lookup(name)
+ for reg in Pkg.Registry.reachable_registries()
+ uuids = Pkg.Registry.uuids_from_name(reg, name)
+ isempty(uuids) && continue
+ entry = reg[uuids[1]]
+ # Signature changed in Julia 1.13: registry_info(entry) → registry_info(reg, entry).
+ info = applicable(Pkg.Registry.registry_info, reg, entry) ?
+ Pkg.Registry.registry_info(reg, entry) : Pkg.Registry.registry_info(entry)
+ m = match(r"github\.com[:/]([^/]+/[^/]+?)(?:\.git)?$", info.repo)
+ m === nothing && error("$name: repository $(info.repo) is not on GitHub")
+ return (string(uuids[1]), String(m[1]))
+ end
+ error("$name is not in any reachable registry")
+end
+
+"The files audited in every repository."
+const AUDITED_FILES = ("Project.toml", "test/Project.toml", "docs/Project.toml")
+
+"Identity of a compat entry: the owner, the file it is declared in, the dependency."
+entry_key(p) = (p["owner"], p["file"], p["dep"])
+
+const spec = Pkg.Types.semver_spec
+
+"Render a VersionSpec back into a compat string (bounds carry no prerelease, so `-` only separates)."
+render(vs) = join([replace(string(r), r"\s*-\s*" => " - ") for r in vs.ranges], ", ")
+
+"""
+ applies(owner, file, name, versions, audited) -> Bool
+
+Does the compat entry of `owner` read from `file` constrain environment `name`, whose
+resolved versions are `versions`? An entry of `Project.toml` applies wherever `owner` is
+resolved at its audited version (another version carries other entries); an entry of
+`test/` or `docs/Project.toml` applies only in that very environment.
+"""
+function applies(owner, file, name, versions, audited)
+ file == "Project.toml" && return get(versions, owner, nothing) == audited[owner]
+ return name == "$owner/$(first(splitpath(file)))"
+end
+
+"""
+ simulate(name, env, narrowings, uuids; resolver=resolve_env) -> Dict
+
+Resolve environment `name` (an entry of `envs.toml`) with each `dep => specifier` of
+`narrowings` intersected into its compat. Pkg intersects the compat of every package of an
+environment, so this is exactly what dropping specifiers from an entry that applies here
+would do, without touching any file. Returns `Dict("ok" => true, "versions" => …)` or
+`Dict("ok" => false, "error" => …)`; an empty intersection or a pinned package resolved at
+another version than the audited one are failures too. `resolver(deps, compat)` is
+`resolve_env`, replaced in tests.
+"""
+function simulate(name, env, narrowings, uuids; resolver=resolve_env)
+ deps, compat = copy(env["deps"]), copy(env["compat"])
+ for (dep, s) in narrowings
+ compat[dep] = render(spec(get(compat, dep, ">= 0")) ∩ spec(s))
+ deps[dep] = uuids[dep] # a transitive dep must be listed to carry a compat entry
+ end
+ empty = sort([k for (k, v) in compat if isempty(v)])
+ isempty(empty) || return Dict("ok" => false, "error" => "empty compat intersection for $(join(empty, ", "))")
+ res = resolver(deps, compat)
+ res["ok"] || return res
+ versions = res["versions"]
+ off = ["$k $(versions[k]) (audited $v)" for (k, v) in env["pins"] if versions[k] != v]
+ isempty(off) || return Dict("ok" => false, "error" => "pinned versions not selected: $(join(off, ", "))")
+ return Dict("ok" => true, "versions" => versions)
+end
+
+"""
+ narrowings(prunes, name, versions, audited) -> Dict
+
+The `dep => kept specifiers` that `prunes` impose on environment `name` (baseline
+`versions`): intersection over every prune whose entry applies here and whose dep is present.
+"""
+function narrowings(prunes, name, versions, audited)
+ out = Dict{String,String}()
+ for p in prunes
+ haskey(versions, p["dep"]) && applies(p["owner"], p["file"], name, versions, audited) || continue
+ out[p["dep"]] = haskey(out, p["dep"]) ? render(spec(out[p["dep"]]) ∩ spec(p["after"])) : p["after"]
+ end
+ return out
+end
+
+"""
+ load_collected(root) -> (envs, audited, uuids)
+
+`envs.toml`, the audited versions, and the uuid of every package named in an audited file
+(`[deps]`, `[weakdeps]`, `[extras]`): a pruned dep is always declared by its owner, even
+when it is only a transitive dependency of the environments.
+"""
+function load_collected(root)
+ envs = TOML.parsefile(joinpath(root, "envs.toml"))["env"]
+ audited = Dict(n => p["version"] for (n, p) in TOML.parsefile(joinpath(root, "index.toml"))["packages"])
+ uuids = Dict{String,Any}()
+ for (dir, _, files) in walkdir(joinpath(root, "registered")), f in files
+ t = TOML.parsefile(joinpath(dir, f))
+ for s in ("deps", "weakdeps", "extras"); merge!(uuids, get(t, s, Dict())); end
+ end
+ return envs, audited, uuids
+end
+
+"Parse a JSON list of strings such as `[\"a\",\"b\"]` (inputs of the reusable workflow)."
+parse_list(s) = String[m[1] for m in eachmatch(r"\"([^\"]+)\"", s)]
diff --git a/scripts/compat-prune/edit.jl b/scripts/compat-prune/edit.jl
new file mode 100644
index 0000000..2fbec1e
--- /dev/null
+++ b/scripts/compat-prune/edit.jl
@@ -0,0 +1,98 @@
+# Apply the accepted prunes to checkouts of the default branches, textually.
+#
+# usage: julia edit.jl
+#
+# / is a fresh checkout of the default branch of package . The
+# prunes of /audit.toml are applied in place, line by line, to the files as they
+# are *now*: never by copying a collected file, which would revert whatever the branch
+# received since the audit read it. The outcome of every prune goes to
+# /edits.toml:
+# edited the line on the default branch is the audited one: it is rewritten;
+# already the default branch already carries the pruned value (merged, awaiting release);
+# drift the line differs, or the entry, the file or the checkout is missing: untouched.
+# Only the value of the one `[compat]` line changes: order, quoting, spacing and comments of
+# the file are kept, and the result is re-parsed to check that nothing else moved.
+
+include("common.jl")
+
+"Specifiers of a compat string, normalised for comparison."
+normalised(s) = sort(String.(strip.(split(s, ","))))
+
+const SECTION = r"^\s*\[\s*([^\[\]]+?)\s*\]\s*(#.*)?$"
+
+"""
+ edit_compat(text, dep, before, after) -> (text, status, detail)
+
+Rewrite the `[compat]` entry of `dep` from `before` to `after` in the TOML source `text`.
+`status` is `"edited"`, `"already"` or `"drift"`; `text` is unchanged unless `"edited"`.
+"""
+function edit_compat(text, dep, before, after)
+ lines = split(text, '\n'; keepempty=true)
+ name = replace(dep, r"[^A-Za-z0-9_]" => s"\\\0")
+ entry = Regex("^(\\s*\"?$name\"?\\s*=\\s*)([\"'])(.*?)\\2(.*)\$")
+ section = ""
+ for (i, l) in enumerate(lines)
+ m = match(SECTION, l)
+ m === nothing || (section = m[1]; continue)
+ section == "compat" || continue
+ m = match(entry, l)
+ m === nothing && continue
+ value = m[3]
+ normalised(value) == normalised(after) && return (text, "already", "already `$value`")
+ normalised(value) == normalised(before) ||
+ return (text, "drift", "`$value` on the default branch, `$before` audited")
+ lines[i] = m[1] * m[2] * after * m[2] * m[4]
+ edited = join(lines, '\n')
+ check_edit(text, edited, dep, after)
+ return (edited, "edited", "")
+ end
+ return (text, "drift", "no `$dep` entry in `[compat]` on the default branch")
+end
+
+"Assert that `edited` differs from `original` by the compat entry of `dep` only."
+function check_edit(original, edited, dep, after)
+ a, b = TOML.parse(original), TOML.parse(edited)
+ b["compat"][dep] == after || error("edit of $dep did not produce `$after`")
+ a["compat"][dep] = after
+ a == b || error("editing $dep changed more than its compat entry")
+ return nothing
+end
+
+"""
+ plan_edits(audit, read_file) -> (records, texts)
+
+Apply every accepted prune of `audit` to the files returned by `read_file(owner, file)`
+(`nothing` when absent). `records` are the prunes with their `status` and `detail`; `texts`
+maps `(owner, file)` to the edited text of every file with at least one edit.
+"""
+function plan_edits(audit, read_file)
+ texts = Dict{Tuple{String,String},String}()
+ records = Dict{String,Any}[]
+ for p in sort(audit["prune"]; by=entry_key)
+ k = (p["owner"], p["file"])
+ text = haskey(texts, k) ? texts[k] : read_file(k...)
+ if text === nothing
+ push!(records, merge(p, Dict("status" => "drift", "detail" => "file not on the default branch")))
+ continue
+ end
+ edited, status, detail = edit_compat(text, p["dep"], p["before"], p["after"])
+ status == "edited" && (texts[k] = edited)
+ push!(records, merge(p, Dict("status" => status, "detail" => detail)))
+ end
+ return records, texts
+end
+
+"Read `//`, or `nothing` when absent."
+read_checkout(dir) = (owner, file) -> (path = joinpath(dir, owner, file); isfile(path) ? read(path, String) : nothing)
+
+if abspath(PROGRAM_FILE) == @__FILE__
+ root, checkouts = ARGS[1], ARGS[2]
+ records, texts = plan_edits(TOML.parsefile(joinpath(root, "audit.toml")), read_checkout(checkouts))
+ for ((owner, file), text) in texts
+ write(joinpath(checkouts, owner, file), text)
+ end
+ open(io -> TOML.print(io, Dict("edit" => records)), joinpath(root, "edits.toml"), "w")
+ for s in ("edited", "already", "drift")
+ println(rpad(s, 8), count(r -> r["status"] == s, records))
+ end
+end
diff --git a/scripts/compat-prune/envs.jl b/scripts/compat-prune/envs.jl
new file mode 100644
index 0000000..bc0def4
--- /dev/null
+++ b/scripts/compat-prune/envs.jl
@@ -0,0 +1,77 @@
+# Build the audited environments from the collected files.
+#
+# usage: julia envs.jl (writes /envs.toml)
+#
+# Environments:
+# joint every package together (what a user of the root package resolves)
+# /test the test environment of a package: `test/Project.toml` when the package has
+# one, else the package plus its `[targets] test` with the package's own compat
+# /docs `docs/Project.toml` plus the package itself (developed by `docs/make.jl`)
+#
+# The package that owns an environment (every package, for `joint`) is pinned to its audited
+# version, so that the files read are exactly the ones resolved. Other packages float within
+# the environment's own compat: `CTParser/test` may well require an older `CTModels`.
+# Compat cannot name a prerelease (`=1.1.8-beta` does not parse), but `=1.1.8` admits
+# `1.1.8-beta`: the pin drops the suffix and `resolve.jl` checks that the audited version is
+# the one selected.
+
+include("common.jl")
+
+"Keep the compat entries that apply to `deps` (plus `julia`)."
+restrict(compat, deps) = Dict(k => v for (k, v) in compat if k == "julia" || haskey(deps, k))
+
+function test_env(name, uuid, dir)
+ file = joinpath(dir, "test", "Project.toml")
+ if isfile(file)
+ t = TOML.parsefile(file)
+ deps = merge(Dict(name => uuid), get(t, "deps", Dict()))
+ return deps, restrict(get(t, "compat", Dict()), deps)
+ end
+ t = TOML.parsefile(joinpath(dir, "Project.toml"))
+ known = merge(get(t, "deps", Dict()), get(t, "weakdeps", Dict()), get(t, "extras", Dict()))
+ targets = get(get(t, "targets", Dict()), "test", collect(keys(get(t, "extras", Dict()))))
+ deps = merge(Dict(name => uuid), Dict(k => known[k] for k in targets))
+ return deps, restrict(get(t, "compat", Dict()), deps)
+end
+
+function docs_env(name, uuid, dir)
+ file = joinpath(dir, "docs", "Project.toml")
+ isfile(file) || return nothing
+ t = TOML.parsefile(file)
+ deps = merge(Dict(name => uuid), get(t, "deps", Dict()))
+ return deps, restrict(get(t, "compat", Dict()), deps)
+end
+
+"""
+ build_envs(root) -> Dict
+
+The audited environments of a collected directory, as written to `envs.toml`: name =>
+`owner`, `kind`, `deps`, `compat`, `pins` (the packages pinned to their audited version).
+"""
+function build_envs(root)
+ index = TOML.parsefile(joinpath(root, "index.toml"))["packages"]
+ pins = Dict(n => VersionNumber(p["version"]) for (n, p) in index)
+ envs = Dict{String,Any}()
+ envs["joint"] = (Dict(n => p["uuid"] for (n, p) in index), Dict{String,Any}())
+ for (n, p) in index
+ dir = joinpath(root, "registered", n)
+ envs["$n/test"] = test_env(n, p["uuid"], dir)
+ docs = docs_env(n, p["uuid"], dir)
+ docs === nothing || (envs["$n/docs"] = docs)
+ end
+ out = Dict{String,Any}()
+ for (name, (deps, compat)) in envs
+ owner, kind = name == "joint" ? ("", "joint") : split(name, "/")
+ pinned = Dict(string(k) => string(pins[k]) for k in (kind == "joint" ? keys(pins) : (owner,)))
+ compat = merge(compat, Dict(k => (v = VersionNumber(v); "=$(v.major).$(v.minor).$(v.patch)") for (k, v) in pinned))
+ out[name] = Dict("owner" => owner, "kind" => kind, "deps" => deps, "compat" => compat, "pins" => pinned)
+ end
+ return out
+end
+
+if abspath(PROGRAM_FILE) == @__FILE__
+ root = ARGS[1]
+ out = build_envs(root)
+ open(io -> TOML.print(io, Dict("env" => out)), joinpath(root, "envs.toml"), "w")
+ println(length(out), " environments: ", join(sort(collect(keys(out))), ", "))
+end
diff --git a/scripts/compat-prune/publish.jl b/scripts/compat-prune/publish.jl
new file mode 100644
index 0000000..045c051
--- /dev/null
+++ b/scripts/compat-prune/publish.jl
@@ -0,0 +1,142 @@
+# Publish an audit: one PR per repository with edits, one tracking issue.
+#
+# usage: julia publish.jl
+#
+# Run after edit.jl on fresh checkouts (/, cloned with a token allowed
+# to push and to open PRs and issues). Idempotent, so that a rerun changes nothing:
+# - the tracking issue of is the one whose body carries MARKER, open or
+# closed: its body is rewritten, it is reopened when there is something to prune and
+# closed (with a comment) when there is nothing left; it is never created twice;
+# - each repository has one bot-owned branch, BRANCH, rebuilt from its default branch: it
+# is pushed only when its content would change; its open PR is updated, or created;
+# when the repository has nothing left to prune, the PR is closed and the branch deleted.
+# Every GitHub call goes through `gh` (GH runner, faked in tests); git runs for real.
+
+include("common.jl")
+include("report.jl")
+
+const BRANCH = "ct/compat-prune"
+const ISSUE_TITLE = "[compat] prune report"
+const PR_TITLE = "chore(compat): drop [compat] specifiers no supported Julia version selects"
+const BOT = ("github-actions[bot]", "41898282+github-actions[bot]@users.noreply.github.com")
+
+"Run git in `dir` and return its standard output."
+git(dir, args...) = read(`git -C $dir $args`, String)
+
+"Tree of `rev` in `dir`, or `nothing` when it does not exist."
+function tree(dir, rev)
+ t = readchomp(ignorestatus(`git -C $dir rev-parse --verify --quiet $("$rev^{tree}")`))
+ return isempty(t) ? nothing : t
+end
+
+"""
+ prepare_branch(dir, message) -> Bool
+
+Commit the working-tree changes of checkout `dir` on BRANCH (from its current HEAD) and
+tell whether the remote BRANCH differs, i.e. whether a push is needed.
+"""
+function prepare_branch(dir, message)
+ git(dir, "checkout", "--quiet", "-B", BRANCH)
+ git(dir, "add", "--all")
+ git(dir, "-c", "user.name=$(BOT[1])", "-c", "user.email=$(BOT[2])", "commit", "--quiet", "-m", message)
+ remote = remote_tree(dir)
+ return remote != tree(dir, "HEAD")
+end
+
+"Tree of the remote BRANCH of checkout `dir`, or `nothing` when it does not exist."
+function remote_tree(dir)
+ isempty(strip(git(dir, "ls-remote", "--heads", "origin", BRANCH))) && return nothing
+ git(dir, "fetch", "--quiet", "origin", "$BRANCH:refs/remotes/origin/$BRANCH")
+ return tree(dir, "refs/remotes/origin/$BRANCH")
+end
+
+"Commit message of the edits of `owner`."
+commit_message(owner, edits) = join(vcat("chore(compat): drop [compat] specifiers no supported Julia version selects", "",
+ sort(["$(e["file"]): $(e["dep"]) = \"$(e["before"])\" → \"$(e["after"])\"" for e in edits if e["owner"] == owner && e["status"] == "edited"])), "\n")
+
+"Number and URL of the open PR from BRANCH in `repo`, or `nothing`."
+function find_pr(repo)
+ out = strip(gh("pr", "list", "--repo", repo, "--head", BRANCH, "--state", "open", "--json", "number,url",
+ "--jq", ".[] | \"\\(.number) \\(.url)\""))
+ isempty(out) && return nothing
+ number, url = split(first(split(out, '\n')))
+ return (number, url)
+end
+
+"Number and state (`open`/`closed`) of the tracking issue of `repo`, or `nothing`."
+function find_issue(repo)
+ jq = ".[] | select(.pull_request == null) | select((.body // \"\") | contains(\"$MARKER\")) | \"\\(.number) \\(.state)\""
+ out = strip(gh("api", "--paginate", "repos/$repo/issues?state=all&per_page=100", "--jq", jq))
+ isempty(out) && return nothing
+ number, state = split(first(split(out, '\n')))
+ return (number, state)
+end
+
+"""
+ publish(root, checkouts, tracking; run_url="") -> Dict
+
+Publish the audit of `root`, whose edits.toml was produced by edit.jl on `checkouts`.
+Returns what was done: `prs` (owner => URL), `pushed`, `closed` (owners), `issue`.
+"""
+function publish(root, checkouts, tracking; run_url="")
+ audit = TOML.parsefile(joinpath(root, "audit.toml"))
+ edits = TOML.parsefile(joinpath(root, "edits.toml"))["edit"]
+ index = audit["packages"]
+ done = Dict{String,Any}("prs" => Dict{String,String}(), "pushed" => String[], "closed" => String[])
+ with_edits = Set(e["owner"] for e in edits if e["status"] == "edited")
+
+ # The issue first: the PRs link to it.
+ issue = find_issue(tracking)
+ if issue === nothing && !isempty(with_edits)
+ url = strip(gh("issue", "create", "--repo", tracking, "--title", ISSUE_TITLE,
+ "--body", render_report(audit, edits; run_url)))
+ issue = (last(split(url, "/")), "open")
+ end
+ issue_url = issue === nothing ? "" : "https://github.com/$tracking/issues/$(issue[1])"
+
+ for owner in sort(collect(keys(index)))
+ repo, base = index[owner]["repo"], index[owner]["default_branch"]
+ pr = find_pr(repo)
+ if !(owner in with_edits)
+ if pr !== nothing
+ gh("pr", "close", pr[1], "--repo", repo, "--delete-branch",
+ "--comment", "Nothing left to prune in this repository$(isempty(issue_url) ? "" : " (see $issue_url)").")
+ push!(done["closed"], owner)
+ end
+ continue
+ end
+ dir = joinpath(checkouts, owner)
+ if prepare_branch(dir, commit_message(owner, edits))
+ git(dir, "push", "--quiet", "--force", "origin", BRANCH)
+ push!(done["pushed"], owner)
+ end
+ body = render_pr_body(owner, edits; issue_url, run_url)
+ if pr === nothing
+ url = strip(gh("pr", "create", "--repo", repo, "--base", base, "--head", BRANCH, "--title", PR_TITLE, "--body", body))
+ else
+ gh("pr", "edit", pr[1], "--repo", repo, "--body", body)
+ url = pr[2]
+ end
+ done["prs"][owner] = url
+ end
+
+ if issue !== nothing
+ gh("issue", "edit", issue[1], "--repo", tracking, "--body", render_report(audit, edits; prs=done["prs"], run_url))
+ if isempty(with_edits) && issue[2] == "open"
+ gh("issue", "close", issue[1], "--repo", tracking, "--comment", "Nothing left to prune.")
+ elseif !isempty(with_edits) && issue[2] == "closed"
+ gh("issue", "reopen", issue[1], "--repo", tracking)
+ end
+ end
+ done["issue"] = issue_url
+ return done
+end
+
+if abspath(PROGRAM_FILE) == @__FILE__
+ root, checkouts, tracking = ARGS
+ done = publish(root, checkouts, tracking; run_url=get(ENV, "RUN_URL", ""))
+ open(io -> TOML.print(io, done), joinpath(root, "published.toml"), "w")
+ println("issue: ", isempty(done["issue"]) ? "none" : done["issue"])
+ for (owner, url) in sort(collect(done["prs"])); println("PR ", rpad(owner, 16), url, owner in done["pushed"] ? " (pushed)" : ""); end
+ for owner in done["closed"]; println("PR ", rpad(owner, 16), "closed"); end
+end
diff --git a/scripts/compat-prune/report.jl b/scripts/compat-prune/report.jl
new file mode 100644
index 0000000..e85680a
--- /dev/null
+++ b/scripts/compat-prune/report.jl
@@ -0,0 +1,159 @@
+# Render the audit as the Markdown body of the tracking issue.
+#
+# usage: julia report.jl [] (writes /report.md)
+#
+# , written by the publishing step, maps a package name to the URL of its PR. The
+# output is deterministic (everything sorted) so that an unchanged audit gives an unchanged
+# body, and the hidden marker lets the publishing step find the issue again.
+
+include("common.jl")
+
+const MARKER = ""
+const WORKFLOW_URL = "https://github.com/control-toolbox/CTActions/blob/main/.github/workflows/compat-prune.yml"
+
+"Markdown table from a header and rows of strings."
+table(header, rows) = join(["| " * join(header, " | ") * " |", "| " * join(fill("---", length(header)), " | ") * " |",
+ ("| " * join(r, " | ") * " |" for r in rows)...], "\n")
+
+"`0.12.3 on 1.12, 1.13; 0.9.18 on 1.10`: the versions selected for the kept lanes, per Julia version."
+function selected(evidence)
+ on = Dict{String,Set{String}}()
+ for list in values(evidence), e in list
+ env_julia, v = split(e, "=")
+ push!(get!(on, v, Set{String}()), last(split(env_julia, "@")))
+ end
+ vs = sort(collect(keys(on)); by=VersionNumber, rev=true)
+ join(["$v on $(join(sort(collect(on[v])), ", "))" for v in vs], "; ")
+end
+
+code(s) = "`$s`"
+entry(r) = "$(r["owner"]) $(code(r["file"])) $(code(r["dep"]))"
+
+"Short form of a registry state: `tree 0123456789ab`."
+short_state(s) = (m = match(r"^(\w+) ([0-9a-f]{12})[0-9a-f]*$", s); m === nothing ? s : "$(m[1]) $(m[2])")
+
+"""
+ render_record(record, others) -> String
+
+The `## Audit` section: environments resolved per Julia version, entries examined and their
+status, registry snapshot, and (folded) the entries left as they are, with why.
+"""
+function render_record(record, others)
+ cov = [[c["julia"], "$(c["resolved"]) / $(c["total"])" * (isempty(c["failed"]) ? "" : " (failed: $(join(c["failed"], ", ")))")]
+ for c in record["coverage"]]
+ e = record["entries"]
+ entries = "$(e["examined"]) multi-specifier `[compat]` entries examined: $(e["prune"]) with lanes to prune, " *
+ "$(e["rejected"]) rejected, $(e["blocked"]) blocked, $(e["keep"]) kept (every lane in use), " *
+ "$(e["idle"]) idle (resolved nowhere they apply), $(e["withheld"]) withheld."
+ regs = "Registries: " * join(["$(r["name"]) ($(short_state(r["state"])))" for r in record["registries"]], ", ") * "."
+ left = sort([r for r in others if r["status"] in ("keep", "idle")]; by=entry_key)
+ parts = ["## Audit", table(["Julia", "environments resolved"], cov), entries, regs]
+ if !isempty(left)
+ why(r) = r["status"] == "keep" ? "every lane in use: " * selected(r["evidence"]) : "not resolved where the entry applies"
+ push!(parts, "Entries left as they are ($(length(left)))
\n\n" *
+ table(["entry", "compat", "status", "why"], [[entry(r), code(r["before"]), r["status"], why(r)] for r in left]) *
+ "\n\n ")
+ end
+ return join(parts, "\n\n")
+end
+
+"""
+ render_report(audit, edits; prs=Dict(), run_url="") -> String
+
+Markdown body of the tracking issue: what can be pruned (per repository, with its PR), what
+waits for a release, what drifted, what was rejected or withheld, and the blockers.
+"""
+function render_report(audit, edits; prs=Dict(), run_url="")
+ by_status(s) = sort([e for e in edits if e["status"] == s]; by=entry_key)
+ edited, already, drift = by_status("edited"), by_status("already"), by_status("drift")
+ rejected = sort(get(audit, "rejected", []); by=entry_key)
+ withheld = sort([r for r in get(audit, "other", []) if r["status"] == "withheld"]; by=entry_key)
+ blocked = get(audit, "blocked", [])
+ out = String[MARKER]
+
+ run = isempty(run_url) ? "" : " ([run]($run_url))"
+ push!(out, """
+ Audit of the `[compat]` entries of the control-toolbox packages, by the
+ [`compat-prune`]($WORKFLOW_URL) workflow on $(audit["date"])$run, for Julia \
+ $(join(audit["julia"], ", ")). This body is rewritten by every run: do not edit it.
+
+ A prune drops `[compat]` specifiers that no environment selects, on no Julia version, and
+ leaves every resolved manifest (joint, tests and docs of every package) unchanged. PRs can
+ therefore be merged in any order, and need no release of their own.""")
+
+ haskey(audit, "record") && push!(out, render_record(audit["record"], get(audit, "other", [])))
+
+ push!(out, "## Summary\n\n" * table(["", "entries"], [
+ ["to prune (open PRs)", string(length(edited))],
+ ["merged, awaiting release", string(length(already))],
+ ["drift (not edited)", string(length(drift))],
+ ["rejected by validation", string(length(rejected))],
+ ["withheld", string(length(withheld))],
+ ["blocked (information)", string(length(unique(entry_key.(blocked))))],
+ ]))
+
+ if !isempty(edited)
+ sections = String["## To prune"]
+ for owner in sort(unique(e["owner"] for e in edited))
+ pr = haskey(prs, owner) ? " — $(prs[owner])" : ""
+ rows = [[code(e["file"]), code(e["dep"]), code(e["before"]), code(e["after"]), selected(e["evidence"])]
+ for e in edited if e["owner"] == owner]
+ push!(sections, "### $owner$pr\n\n" * table(["file", "dependency", "before", "after", "selected"], rows))
+ end
+ push!(out, join(sections, "\n\n"))
+ end
+ isempty(already) || push!(out, "## Merged, awaiting release\n\n" *
+ table(["entry", "after"], [[entry(e), code(e["after"])] for e in already]))
+ isempty(drift) || push!(out, "## Drift\n\nThe default branch differs from the registered version: not edited.\n\n" *
+ table(["entry", "audited", "detail"], [[entry(e), code(e["before"]), e["detail"]] for e in drift]))
+ isempty(rejected) || push!(out, "## Rejected\n\n" *
+ table(["entry", "where", "reason"], [[entry(r), r["where"], replace(r["reason"], "\n" => " ", "|" => "\\|")] for r in rejected]))
+ isempty(withheld) || push!(out, "## Withheld\n\n$(uppercasefirst(first(withheld)["reason"])).\n\n" *
+ table(["entry", "would drop"], [[entry(r), join(code.(r["removed"]), ", ")] for r in withheld]))
+ if !isempty(blocked)
+ rows = Vector{String}[]
+ for k in sort(unique(entry_key.(blocked)))
+ probes = [b for b in blocked if entry_key(b) == k]
+ names = sort(unique(reduce(vcat, [b["blockers"] for b in probes])))
+ b = first(probes)
+ push!(rows, [entry(b), code(b["newest"]), isempty(names) ? "resolver preference" : join(names, ", ")])
+ end
+ push!(out, "## Blocked\n\nThe newest specifier is never selected: information only, nothing is edited.\n\n" *
+ table(["entry", "newest", "capped by"], rows))
+ end
+
+ versions = [[n, p["version"], "[$(p["repo"])](https://github.com/$(p["repo"]))"] for (n, p) in sort(collect(audit["packages"]); by=first)]
+ push!(out, "## Audited versions\n\n" * table(["package", "version", "repository"], versions))
+ return join(out, "\n\n") * "\n"
+end
+
+"""
+ render_pr_body(owner, edits; issue_url="", run_url="") -> String
+
+Body of the PR of `owner`: its edited entries, why they can go, where the audit is.
+"""
+function render_pr_body(owner, edits; issue_url="", run_url="")
+ mine = sort([e for e in edits if e["owner"] == owner && e["status"] == "edited"]; by=entry_key)
+ rows = [[code(e["file"]), code(e["dep"]), code(e["before"]), code(e["after"]), selected(e["evidence"])] for e in mine]
+ links = String[]
+ isempty(issue_url) || push!(links, "tracking issue: $issue_url")
+ isempty(run_url) || push!(links, "[audit run]($run_url)")
+ return join([MARKER, """
+ Drops the `[compat]` specifiers that no supported Julia version selects, in any
+ environment of the control-toolbox packages (joint, tests and docs of every package).
+ Without them every audited manifest is unchanged: this PR changes no version that
+ these environments resolve, needs no release of its own, and can be merged in any
+ order with the PRs of the other packages.""",
+ table(["file", "dependency", "before", "after", "selected"], rows),
+ "Opened by the [`compat-prune`]($WORKFLOW_URL) workflow" * (isempty(links) ? "" : " ($(join(links, ", ")))") *
+ ". The branch is rewritten by every run: push nothing to it."], "\n\n") * "\n"
+end
+
+if abspath(PROGRAM_FILE) == @__FILE__
+ root = ARGS[1]
+ prs = length(ARGS) ≥ 2 ? TOML.parsefile(ARGS[2]) : Dict()
+ audit = TOML.parsefile(joinpath(root, "audit.toml"))
+ edits = TOML.parsefile(joinpath(root, "edits.toml"))["edit"]
+ write(joinpath(root, "report.md"), render_report(audit, edits; prs, run_url=get(ENV, "RUN_URL", "")))
+ println("report written to ", joinpath(root, "report.md"))
+end
diff --git a/scripts/compat-prune/resolve.jl b/scripts/compat-prune/resolve.jl
new file mode 100644
index 0000000..4651c23
--- /dev/null
+++ b/scripts/compat-prune/resolve.jl
@@ -0,0 +1,38 @@
+# Resolve every audited environment on the running Julia.
+#
+# usage: julia resolve.jl [ ]
+#
+# Without prunes this is the baseline. With prunes, they are simulated all together (see
+# `simulate` and `narrowings` in common.jl); environments that failed at baseline are copied
+# as they are, there is nothing to compare them with.
+
+include("common.jl")
+
+"""
+ resolve_all(root; prunes=[], baseline=Dict(), sim=simulate) -> Dict
+
+Result of every environment of a collected directory, the baseline when `prunes` is empty.
+"""
+function resolve_all(root; prunes=[], baseline=Dict(), sim=simulate)
+ envs, audited, uuids = load_collected(root)
+ results = Dict{String,Any}()
+ for name in sort(collect(keys(envs)))
+ if isempty(prunes)
+ r = sim(name, envs[name], Dict(), uuids)
+ else
+ base = baseline[name]
+ r = base["ok"] ? sim(name, envs[name], narrowings(prunes, name, base["versions"], audited), uuids) : base
+ end
+ results[name] = r
+ println(VERSION, " ", rpad(name, 24), r["ok"] ? "ok" : "UNSAT")
+ end
+ return results
+end
+
+if abspath(PROGRAM_FILE) == @__FILE__
+ root, outfile = ARGS[1], ARGS[2]
+ prunes = length(ARGS) ≥ 4 ? TOML.parsefile(ARGS[3])["prune"] : []
+ baseline = length(ARGS) ≥ 4 ? TOML.parsefile(ARGS[4])["env"] : Dict()
+ results = resolve_all(root; prunes, baseline)
+ open(io -> TOML.print(io, Dict("julia" => string(VERSION), "env" => results)), outfile, "w")
+end
diff --git a/scripts/compat-prune/test/fixtures/collected/default/Alpha/Project.toml b/scripts/compat-prune/test/fixtures/collected/default/Alpha/Project.toml
new file mode 100644
index 0000000..1b46810
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/collected/default/Alpha/Project.toml
@@ -0,0 +1,12 @@
+name = "Alpha"
+uuid = "aaaaaaaa-0000-4000-8000-000000000001"
+version = "0.3.2"
+
+[deps]
+Beta = "bbbbbbbb-0000-4000-8000-000000000002"
+Dep1 = "11111111-0000-4000-8000-000000000001"
+
+[compat]
+Beta = "1"
+Dep1 = "0.5, 0.6, 0.7" # lanes kept by CompatHelper
+julia = "1.10"
diff --git a/scripts/compat-prune/test/fixtures/collected/default/Alpha/docs/Project.toml b/scripts/compat-prune/test/fixtures/collected/default/Alpha/docs/Project.toml
new file mode 100644
index 0000000..c5518e8
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/collected/default/Alpha/docs/Project.toml
@@ -0,0 +1,5 @@
+[deps]
+Doc = "33333333-0000-4000-8000-000000000003"
+
+[compat]
+Doc = "0.9, 1"
diff --git a/scripts/compat-prune/test/fixtures/collected/default/Beta/Project.toml b/scripts/compat-prune/test/fixtures/collected/default/Beta/Project.toml
new file mode 100644
index 0000000..4033244
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/collected/default/Beta/Project.toml
@@ -0,0 +1,24 @@
+name = "Beta"
+uuid = "bbbbbbbb-0000-4000-8000-000000000002"
+version = "1.2.0-beta"
+
+[deps]
+Dep1 = "11111111-0000-4000-8000-000000000001"
+
+[weakdeps]
+Weak = "44444444-0000-4000-8000-000000000004"
+
+[extras]
+Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+Tool = "22222222-0000-4000-8000-000000000002"
+Unused = "55555555-0000-4000-8000-000000000005"
+
+[compat]
+Dep1 = "0.7"
+Tool = "2"
+Unused = "9"
+Weak = "0.1, 0.2"
+julia = "1.10"
+
+[targets]
+test = ["Test", "Tool", "Weak"]
diff --git a/scripts/compat-prune/test/fixtures/collected/index.toml b/scripts/compat-prune/test/fixtures/collected/index.toml
new file mode 100644
index 0000000..f5d57ce
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/collected/index.toml
@@ -0,0 +1,19 @@
+julia = "1.13.1"
+
+[packages.Alpha]
+uuid = "aaaaaaaa-0000-4000-8000-000000000001"
+version = "0.3.1"
+repo = "example-org/Alpha.jl"
+tree = "1111111111111111111111111111111111111111"
+default_branch = "main"
+files = ["Project.toml", "test/Project.toml", "docs/Project.toml"]
+default_files = ["Project.toml", "docs/Project.toml"]
+
+[packages.Beta]
+uuid = "bbbbbbbb-0000-4000-8000-000000000002"
+version = "1.2.0-beta"
+repo = "example-org/Beta.jl"
+tree = "2222222222222222222222222222222222222222"
+default_branch = "dev"
+files = ["Project.toml"]
+default_files = ["Project.toml"]
diff --git a/scripts/compat-prune/test/fixtures/collected/registered/Alpha/Project.toml b/scripts/compat-prune/test/fixtures/collected/registered/Alpha/Project.toml
new file mode 100644
index 0000000..0dbbcb2
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/collected/registered/Alpha/Project.toml
@@ -0,0 +1,12 @@
+name = "Alpha"
+uuid = "aaaaaaaa-0000-4000-8000-000000000001"
+version = "0.3.1"
+
+[deps]
+Beta = "bbbbbbbb-0000-4000-8000-000000000002"
+Dep1 = "11111111-0000-4000-8000-000000000001"
+
+[compat]
+Beta = "1"
+Dep1 = "0.5, 0.6, 0.7"
+julia = "1.10"
diff --git a/scripts/compat-prune/test/fixtures/collected/registered/Alpha/docs/Project.toml b/scripts/compat-prune/test/fixtures/collected/registered/Alpha/docs/Project.toml
new file mode 100644
index 0000000..c5518e8
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/collected/registered/Alpha/docs/Project.toml
@@ -0,0 +1,5 @@
+[deps]
+Doc = "33333333-0000-4000-8000-000000000003"
+
+[compat]
+Doc = "0.9, 1"
diff --git a/scripts/compat-prune/test/fixtures/collected/registered/Alpha/test/Project.toml b/scripts/compat-prune/test/fixtures/collected/registered/Alpha/test/Project.toml
new file mode 100644
index 0000000..8b24135
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/collected/registered/Alpha/test/Project.toml
@@ -0,0 +1,7 @@
+[deps]
+Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+Tool = "22222222-0000-4000-8000-000000000002"
+
+[compat]
+Tool = "1, 2"
+Unlisted = "3"
diff --git a/scripts/compat-prune/test/fixtures/collected/registered/Beta/Project.toml b/scripts/compat-prune/test/fixtures/collected/registered/Beta/Project.toml
new file mode 100644
index 0000000..1a314c0
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/collected/registered/Beta/Project.toml
@@ -0,0 +1,24 @@
+name = "Beta"
+uuid = "bbbbbbbb-0000-4000-8000-000000000002"
+version = "1.2.0-beta"
+
+[deps]
+Dep1 = "11111111-0000-4000-8000-000000000001"
+
+[weakdeps]
+Weak = "44444444-0000-4000-8000-000000000004"
+
+[extras]
+Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+Tool = "22222222-0000-4000-8000-000000000002"
+Unused = "55555555-0000-4000-8000-000000000005"
+
+[compat]
+Dep1 = "0.6, 0.7"
+Tool = "2"
+Unused = "9"
+Weak = "0.1, 0.2"
+julia = "1.10"
+
+[targets]
+test = ["Test", "Tool", "Weak"]
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/baseline-1.10.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/baseline-1.10.toml
new file mode 100644
index 0000000..e902fe4
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/baseline-1.10.toml
@@ -0,0 +1,699 @@
+julia = "1.10.11"
+
+[env."CTParser/docs"]
+ok = true
+
+ [env."CTParser/docs".versions]
+ CTParser = "0.9.5-beta"
+ OrderedCollections = "2.0.3"
+ CTBase = "0.30.10"
+ MLStyle = "0.4.17"
+ Markdown = "stdlib"
+ Printf = "stdlib"
+ Test = "stdlib"
+ Random = "stdlib"
+ MarkdownAST = "0.1.3"
+ DocStringExtensions = "0.9.5"
+ ADTypes = "1.24.0"
+ Parameters = "0.13.1"
+ Logging = "stdlib"
+ Documenter = "1.19.0"
+ Unicode = "stdlib"
+
+[env."CTModels/docs"]
+ok = true
+
+ [env."CTModels/docs".versions]
+ RecipesBase = "1.3.4"
+ CommonSolve = "0.2.14"
+ LinearAlgebra = "stdlib"
+ Printf = "stdlib"
+ DocumenterVitepress = "0.3.8"
+ Logging = "stdlib"
+ Colors = "0.13.3"
+ MacroTools = "0.5.16"
+ CTModels = "0.19.8"
+ OrderedCollections = "2.0.3"
+ MLStyle = "0.4.17"
+ Markdown = "stdlib"
+ Random = "stdlib"
+ Plots = "1.41.7"
+ DocumenterInterLinks = "1.1.0"
+ MarkdownAST = "0.1.3"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ Parameters = "0.13.1"
+ Interpolations = "0.16.3"
+ CTBase = "0.30.10"
+ JLD2 = "0.6.7"
+ JSON3 = "1.14.3"
+ Makie = "0.24.16"
+ JET = "0.9.18"
+ Test = "stdlib"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ Documenter = "1.19.0"
+ CairoMakie = "0.15.16"
+ LiveServer = "1.6.0"
+ SparseArrays = "1.10.0"
+ Unicode = "stdlib"
+
+[env."CTLie/test"]
+ok = true
+
+ [env."CTLie/test".versions]
+ ForwardDiff = "1.4.6"
+ OrderedCollections = "2.0.3"
+ CTBase = "0.30.10"
+ Markdown = "stdlib"
+ CTLie = "0.2.3"
+ LinearAlgebra = "stdlib"
+ JET = "0.9.18"
+ Mooncake = "0.5.64"
+ Printf = "stdlib"
+ Test = "stdlib"
+ Random = "stdlib"
+ BenchmarkTools = "1.8.0"
+ Aqua = "0.8.18"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ ADTypes = "1.24.0"
+ Logging = "stdlib"
+ Reexport = "1.2.2"
+ CUDA = "6.4.2"
+ DifferentiationInterface = "0.7.21"
+ GPUArraysCore = "0.2.1"
+ SparseArrays = "1.10.0"
+ Unicode = "stdlib"
+ MacroTools = "0.5.16"
+ KernelAbstractions = "0.9.45"
+
+[env."CTDirect/test"]
+ok = true
+
+ [env."CTDirect/test".versions]
+ RecipesBase = "1.3.4"
+ SolverCore = "0.3.10"
+ CommonSolve = "0.2.14"
+ LinearAlgebra = "stdlib"
+ Printf = "stdlib"
+ SparseConnectivityTracer = "1.2.4"
+ Logging = "stdlib"
+ GPUArraysCore = "0.2.1"
+ MacroTools = "0.5.16"
+ CTModels = "0.19.8"
+ CTParser = "0.9.5-beta"
+ OrderedCollections = "2.0.3"
+ MLStyle = "0.4.17"
+ Markdown = "stdlib"
+ CUDSS = "0.8.1"
+ Random = "stdlib"
+ NLPModelsIpopt = "0.11.3"
+ ADNLPModels = "0.8.15"
+ CUDA = "6.3.1"
+ MadNLP = "0.10.1"
+ SplitApplyCombine = "1.3.0"
+ KernelAbstractions = "0.9.45"
+ ExaModels = "0.12.1"
+ AMDGPU = "2.8.0"
+ CTSolvers = "0.5.8"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ Parameters = "0.13.1"
+ DifferentiationInterface = "0.7.21"
+ NLPModels = "0.21.12"
+ MadNLPGPU = "0.10.2"
+ CTDirect = "1.1.8-beta"
+ ForwardDiff = "1.4.6"
+ CTBase = "0.30.10"
+ Test = "stdlib"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ SparseArrays = "1.10.0"
+ Unicode = "stdlib"
+
+[env."CTDirect/docs"]
+ok = true
+
+ [env."CTDirect/docs".versions]
+ ExaModels = "0.12.1"
+ CTDirect = "1.1.8-beta"
+ CTParser = "0.9.5-beta"
+ ForwardDiff = "1.4.6"
+ OrderedCollections = "2.0.3"
+ CTBase = "0.30.10"
+ RecipesBase = "1.3.4"
+ SolverCore = "0.3.10"
+ CommonSolve = "0.2.14"
+ Markdown = "stdlib"
+ MLStyle = "0.4.17"
+ LinearAlgebra = "stdlib"
+ Printf = "stdlib"
+ Test = "stdlib"
+ Random = "stdlib"
+ SparseConnectivityTracer = "1.2.4"
+ MarkdownAST = "0.1.3"
+ CTSolvers = "0.5.8"
+ DocumenterMermaid = "0.2.1"
+ ADNLPModels = "0.8.15"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ ADTypes = "1.24.0"
+ Parameters = "0.13.1"
+ Logging = "stdlib"
+ Reexport = "1.2.2"
+ Documenter = "1.19.0"
+ DifferentiationInterface = "0.7.21"
+ SparseArrays = "1.10.0"
+ Unicode = "stdlib"
+ MacroTools = "0.5.16"
+ NLPModels = "0.21.12"
+ CTModels = "0.19.8"
+ KernelAbstractions = "0.9.45"
+
+[env."CTFlows/docs"]
+ok = true
+
+ [env."CTFlows/docs".versions]
+ RecipesBase = "1.3.4"
+ SolverCore = "0.3.10"
+ CommonSolve = "0.2.14"
+ LinearAlgebra = "stdlib"
+ Printf = "stdlib"
+ DocumenterVitepress = "0.3.8"
+ Logging = "stdlib"
+ DiffEqBase = "7.21.4"
+ GPUArraysCore = "0.2.1"
+ SciMLBase = "3.58.0"
+ Colors = "0.13.3"
+ MacroTools = "0.5.16"
+ CTModels = "0.19.8"
+ OrderedCollections = "2.0.3"
+ MLStyle = "0.4.17"
+ Markdown = "stdlib"
+ OrdinaryDiffEqRosenbrock = "2.7.5"
+ Random = "stdlib"
+ Plots = "1.41.7"
+ KernelAbstractions = "0.9.45"
+ DocumenterInterLinks = "1.1.0"
+ MarkdownAST = "0.1.3"
+ CTSolvers = "0.5.8"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ Parameters = "0.13.1"
+ Interpolations = "0.16.3"
+ DifferentiationInterface = "0.7.21"
+ ForwardDiff = "1.4.6"
+ CTFlows = "0.18.1"
+ CTBase = "0.30.10"
+ Makie = "0.24.16"
+ Test = "stdlib"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ Documenter = "1.19.0"
+ OrdinaryDiffEqTsit5 = "2.1.5"
+ CairoMakie = "0.15.16"
+ LiveServer = "1.6.0"
+ SparseArrays = "1.10.0"
+ Unicode = "stdlib"
+
+[env."OptimalControl/docs"]
+ok = true
+
+ [env."OptimalControl/docs".versions]
+ RecipesBase = "1.3.4"
+ SolverCore = "0.3.10"
+ CommonSolve = "0.2.14"
+ NLPModelsKnitro = "0.10.6"
+ LinearAlgebra = "stdlib"
+ Printf = "stdlib"
+ NonlinearSolve = "4.32.0"
+ SparseConnectivityTracer = "1.2.4"
+ DocumenterVitepress = "0.3.8"
+ Logging = "stdlib"
+ DiffEqBase = "7.21.4"
+ GPUArraysCore = "0.2.1"
+ SciMLBase = "3.58.0"
+ Colors = "0.13.3"
+ MacroTools = "0.5.16"
+ CTModels = "0.19.8"
+ CTParser = "0.9.5-beta"
+ OrderedCollections = "2.0.3"
+ MLStyle = "0.4.17"
+ Markdown = "stdlib"
+ OrdinaryDiffEqVerner = "2.4.2"
+ OrdinaryDiffEqRosenbrock = "2.7.5"
+ CUDSS = "0.7.0"
+ Random = "stdlib"
+ NLPModelsIpopt = "0.11.3"
+ Plots = "1.41.7"
+ ADNLPModels = "0.8.15"
+ CUDA = "6.2.0"
+ MadNLP = "0.10.1"
+ KernelAbstractions = "0.9.45"
+ ExaModels = "0.12.1"
+ Literate = "2.21.0"
+ OrdinaryDiffEq = "7.8.1"
+ CTLie = "0.2.3"
+ UnoSolver = "0.3.8"
+ DocumenterInterLinks = "1.1.0"
+ MarkdownAST = "0.1.3"
+ CTSolvers = "0.5.8"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ Parameters = "0.13.1"
+ OptimalControl = "2.2.1-beta"
+ Interpolations = "0.16.3"
+ DifferentiationInterface = "0.7.21"
+ NLPModels = "0.21.12"
+ MadNLPGPU = "0.10.2"
+ CTDirect = "1.1.8-beta"
+ ForwardDiff = "1.4.6"
+ CTFlows = "0.18.1"
+ CTBase = "0.30.10"
+ JLD2 = "0.6.7"
+ JSON3 = "1.14.3"
+ Makie = "0.24.16"
+ MadNCL = "0.2.3"
+ Test = "stdlib"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ Documenter = "1.19.0"
+ OrdinaryDiffEqTsit5 = "2.1.5"
+ CairoMakie = "0.15.16"
+ LiveServer = "1.6.0"
+ SparseArrays = "1.10.0"
+ Unicode = "stdlib"
+
+[env."CTModels/test"]
+ok = true
+
+ [env."CTModels/test".versions]
+ RecipesBase = "1.3.4"
+ CommonSolve = "0.2.14"
+ LinearAlgebra = "stdlib"
+ Printf = "stdlib"
+ Aqua = "0.8.18"
+ Logging = "stdlib"
+ Colors = "0.13.3"
+ MacroTools = "0.5.16"
+ CTModels = "0.19.8"
+ OrderedCollections = "2.0.3"
+ MLStyle = "0.4.17"
+ Markdown = "stdlib"
+ Random = "stdlib"
+ BenchmarkTools = "1.8.0"
+ Plots = "1.41.7"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ Parameters = "0.13.1"
+ Interpolations = "0.16.3"
+ CTBase = "0.30.10"
+ JLD2 = "0.6.7"
+ JSON3 = "1.14.3"
+ Makie = "0.24.16"
+ JET = "0.9.18"
+ Test = "stdlib"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ CairoMakie = "0.15.16"
+ SparseArrays = "1.10.0"
+ Unicode = "stdlib"
+
+[env."CTLie/docs"]
+ok = true
+
+ [env."CTLie/docs".versions]
+ ForwardDiff = "1.4.6"
+ CTBase = "0.30.10"
+ Markdown = "stdlib"
+ CTLie = "0.2.3"
+ LinearAlgebra = "stdlib"
+ Printf = "stdlib"
+ Test = "stdlib"
+ DocumenterInterLinks = "1.1.0"
+ Random = "stdlib"
+ MarkdownAST = "0.1.3"
+ DocStringExtensions = "0.9.5"
+ DocumenterVitepress = "0.3.8"
+ ADTypes = "1.24.0"
+ Logging = "stdlib"
+ Documenter = "1.19.0"
+ DifferentiationInterface = "0.7.21"
+ LiveServer = "1.6.0"
+ Unicode = "stdlib"
+ MacroTools = "0.5.16"
+
+[env."CTSolvers/test"]
+ok = true
+
+ [env."CTSolvers/test".versions]
+ RecipesBase = "1.3.4"
+ Zygote = "0.7.13"
+ SolverCore = "0.3.10"
+ CommonSolve = "0.2.14"
+ LinearAlgebra = "stdlib"
+ Printf = "stdlib"
+ SparseConnectivityTracer = "1.2.4"
+ Aqua = "0.8.18"
+ Logging = "stdlib"
+ DiffEqBase = "7.21.4"
+ GPUArraysCore = "0.2.1"
+ SciMLBase = "3.58.0"
+ MacroTools = "0.5.16"
+ CTModels = "0.19.8"
+ OrderedCollections = "2.0.3"
+ MLStyle = "0.4.17"
+ Markdown = "stdlib"
+ CUDSS = "0.8.1"
+ Random = "stdlib"
+ BenchmarkTools = "1.8.0"
+ NLPModelsIpopt = "0.11.3"
+ ADNLPModels = "0.8.15"
+ CUDA = "6.4.1"
+ MadNLP = "0.10.1"
+ KernelAbstractions = "0.9.45"
+ ExaModels = "0.12.1"
+ UnoSolver = "0.3.8"
+ CTSolvers = "0.5.8"
+ DocStringExtensions = "0.9.5"
+ Enzyme = "0.13.216"
+ StaticArrays = "1.9.23"
+ Parameters = "0.13.1"
+ DifferentiationInterface = "0.7.21"
+ NLPModels = "0.21.12"
+ MadNLPGPU = "0.10.2"
+ ForwardDiff = "1.4.6"
+ CTBase = "0.30.10"
+ MadNCL = "0.2.3"
+ JET = "0.9.18"
+ Test = "stdlib"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ OrdinaryDiffEqTsit5 = "2.1.5"
+ SparseArrays = "1.10.0"
+ Unicode = "stdlib"
+
+[env."CTParser/test"]
+ok = true
+
+ [env."CTParser/test".versions]
+ ExaModels = "0.12.1"
+ CTParser = "0.9.5-beta"
+ OrderedCollections = "2.0.3"
+ CTBase = "0.29.3"
+ RecipesBase = "1.3.4"
+ MLStyle = "0.4.17"
+ Markdown = "stdlib"
+ SolverCore = "0.3.10"
+ LinearAlgebra = "stdlib"
+ Printf = "stdlib"
+ CUDSS = "0.8.1"
+ Test = "stdlib"
+ Random = "stdlib"
+ BenchmarkTools = "1.8.0"
+ Aqua = "0.8.18"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ ADTypes = "1.24.0"
+ Parameters = "0.13.1"
+ Logging = "stdlib"
+ Reexport = "1.2.2"
+ CUDA = "6.4.2"
+ MadNLP = "0.10.1"
+ Interpolations = "0.16.3"
+ GPUArraysCore = "0.2.1"
+ SparseArrays = "1.10.0"
+ Unicode = "stdlib"
+ MacroTools = "0.5.16"
+ NLPModels = "0.21.12"
+ CTModels = "0.18.0"
+ KernelAbstractions = "0.9.45"
+ MadNLPGPU = "0.10.2"
+
+[env."OptimalControl/test"]
+ok = true
+
+ [env."OptimalControl/test".versions]
+ RecipesBase = "1.3.4"
+ SolverCore = "0.3.10"
+ CommonSolve = "0.2.14"
+ LinearAlgebra = "stdlib"
+ Printf = "stdlib"
+ NonlinearSolve = "4.32.0"
+ SparseConnectivityTracer = "1.2.4"
+ Logging = "stdlib"
+ DiffEqBase = "7.21.4"
+ GPUArraysCore = "0.2.1"
+ SciMLBase = "3.58.0"
+ MacroTools = "0.5.16"
+ CTModels = "0.19.8"
+ CTParser = "0.9.5-beta"
+ OrderedCollections = "2.0.3"
+ MLStyle = "0.4.17"
+ Markdown = "stdlib"
+ OrdinaryDiffEqVerner = "2.4.2"
+ OrdinaryDiffEqRosenbrock = "2.7.5"
+ CUDSS = "0.7.0"
+ Random = "stdlib"
+ BenchmarkTools = "1.8.0"
+ NLPModelsIpopt = "0.11.3"
+ ADNLPModels = "0.8.15"
+ CUDA = "6.2.0"
+ MadNLP = "0.10.1"
+ SplitApplyCombine = "1.3.0"
+ KernelAbstractions = "0.9.45"
+ ExaModels = "0.12.1"
+ OrdinaryDiffEq = "7.8.1"
+ CTLie = "0.2.3"
+ UnoSolver = "0.3.8"
+ CTSolvers = "0.5.8"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ Parameters = "0.13.1"
+ OptimalControl = "2.2.1-beta"
+ DifferentiationInterface = "0.7.21"
+ NLPModels = "0.21.12"
+ MadNLPGPU = "0.10.2"
+ CTDirect = "1.1.8-beta"
+ ForwardDiff = "1.4.6"
+ CTFlows = "0.18.1"
+ CTBase = "0.30.10"
+ MadNCL = "0.2.3"
+ Test = "stdlib"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ OrdinaryDiffEqTsit5 = "2.1.5"
+ SparseArrays = "1.10.0"
+ Unicode = "stdlib"
+
+[env."CTBase/test"]
+ok = true
+
+ [env."CTBase/test".versions]
+ RecipesBase = "1.3.4"
+ CommonSolve = "0.2.14"
+ LinearAlgebra = "stdlib"
+ Printf = "stdlib"
+ Aqua = "0.8.18"
+ Logging = "stdlib"
+ Colors = "0.13.3"
+ MacroTools = "0.5.16"
+ OrderedCollections = "2.0.3"
+ Markdown = "stdlib"
+ Random = "stdlib"
+ BenchmarkTools = "1.8.0"
+ Plots = "1.41.7"
+ Coverage = "1.8.2"
+ MarkdownAST = "0.1.3"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ Interpolations = "0.16.3"
+ DifferentiationInterface = "0.7.21"
+ ForwardDiff = "1.4.6"
+ CTBase = "0.30.10"
+ Makie = "0.24.16"
+ JET = "0.9.18"
+ Test = "stdlib"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ Documenter = "1.19.0"
+ CairoMakie = "0.15.16"
+ SparseArrays = "1.10.0"
+ Unicode = "stdlib"
+
+[env.joint]
+ok = true
+
+ [env.joint.versions]
+ ExaModels = "0.12.1"
+ CTDirect = "1.1.8-beta"
+ CTParser = "0.9.5-beta"
+ ForwardDiff = "1.4.6"
+ CTFlows = "0.18.1"
+ OrderedCollections = "2.0.3"
+ CTBase = "0.30.10"
+ RecipesBase = "1.3.4"
+ SolverCore = "0.3.10"
+ CommonSolve = "0.2.14"
+ MLStyle = "0.4.17"
+ Markdown = "stdlib"
+ CTLie = "0.2.3"
+ LinearAlgebra = "stdlib"
+ Printf = "stdlib"
+ Random = "stdlib"
+ SparseConnectivityTracer = "1.2.4"
+ CTSolvers = "0.5.8"
+ ADNLPModels = "0.8.15"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ ADTypes = "1.24.0"
+ Parameters = "0.13.1"
+ OptimalControl = "2.2.1-beta"
+ Reexport = "1.2.2"
+ DifferentiationInterface = "0.7.21"
+ GPUArraysCore = "0.2.1"
+ SparseArrays = "1.10.0"
+ Unicode = "stdlib"
+ MacroTools = "0.5.16"
+ NLPModels = "0.21.12"
+ CTModels = "0.19.8"
+ KernelAbstractions = "0.9.45"
+
+[env."CTBase/docs"]
+ok = true
+
+ [env."CTBase/docs".versions]
+ RecipesBase = "1.3.4"
+ CommonSolve = "0.2.14"
+ LinearAlgebra = "stdlib"
+ Printf = "stdlib"
+ DocumenterVitepress = "0.3.8"
+ Logging = "stdlib"
+ Colors = "0.13.3"
+ MacroTools = "0.5.16"
+ OrderedCollections = "2.0.3"
+ Markdown = "stdlib"
+ Random = "stdlib"
+ Plots = "1.41.7"
+ Coverage = "1.8.2"
+ DocumenterInterLinks = "1.1.0"
+ MarkdownAST = "0.1.3"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ Interpolations = "0.16.3"
+ DifferentiationInterface = "0.7.21"
+ ForwardDiff = "1.4.6"
+ CTBase = "0.30.10"
+ Makie = "0.24.16"
+ JET = "0.9.18"
+ Test = "stdlib"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ Documenter = "1.19.0"
+ CairoMakie = "0.15.16"
+ LiveServer = "1.5.0"
+ SparseArrays = "1.10.0"
+ Unicode = "stdlib"
+
+[env."CTSolvers/docs"]
+ok = true
+
+ [env."CTSolvers/docs".versions]
+ RecipesBase = "1.3.4"
+ Zygote = "0.7.13"
+ SolverCore = "0.3.10"
+ CommonSolve = "0.2.14"
+ NLPModelsKnitro = "0.10.6"
+ LinearAlgebra = "stdlib"
+ Printf = "stdlib"
+ SparseConnectivityTracer = "1.2.4"
+ DocumenterVitepress = "0.3.8"
+ Logging = "stdlib"
+ DiffEqBase = "7.21.4"
+ GPUArraysCore = "0.2.1"
+ SciMLBase = "3.58.0"
+ MacroTools = "0.5.16"
+ CTModels = "0.19.8"
+ OrderedCollections = "2.0.3"
+ MLStyle = "0.4.17"
+ Markdown = "stdlib"
+ OrdinaryDiffEqVerner = "2.4.2"
+ CUDSS = "0.8.1"
+ Random = "stdlib"
+ NLPModelsIpopt = "0.11.3"
+ ADNLPModels = "0.8.15"
+ CUDA = "6.4.1"
+ MadNLP = "0.10.1"
+ KernelAbstractions = "0.9.45"
+ ExaModels = "0.12.1"
+ UnoSolver = "0.3.8"
+ DocumenterInterLinks = "1.1.0"
+ MarkdownAST = "0.1.3"
+ CTSolvers = "0.5.8"
+ DocStringExtensions = "0.9.5"
+ Enzyme = "0.13.216"
+ StaticArrays = "1.9.23"
+ Parameters = "0.13.1"
+ DifferentiationInterface = "0.7.21"
+ NLPModels = "0.21.12"
+ MadNLPGPU = "0.10.2"
+ ForwardDiff = "1.4.6"
+ CTBase = "0.30.10"
+ MadNCL = "0.2.3"
+ JET = "0.9.18"
+ Test = "stdlib"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ Documenter = "1.19.0"
+ OrdinaryDiffEqTsit5 = "2.1.5"
+ LiveServer = "1.6.0"
+ SparseArrays = "1.10.0"
+ Unicode = "stdlib"
+
+[env."CTFlows/test"]
+ok = true
+
+ [env."CTFlows/test".versions]
+ RecipesBase = "1.3.4"
+ Zygote = "0.7.13"
+ SolverCore = "0.3.10"
+ CommonSolve = "0.2.14"
+ LinearAlgebra = "stdlib"
+ DiffEqGPU = "3.21.5"
+ Printf = "stdlib"
+ NonlinearSolve = "4.32.0"
+ Aqua = "0.8.18"
+ Logging = "stdlib"
+ DiffEqBase = "7.21.4"
+ GPUArraysCore = "0.2.1"
+ SciMLBase = "3.58.0"
+ Colors = "0.13.3"
+ MacroTools = "0.5.16"
+ CTModels = "0.19.8"
+ OrderedCollections = "2.0.3"
+ MLStyle = "0.4.17"
+ Markdown = "stdlib"
+ Random = "stdlib"
+ Plots = "1.41.7"
+ CUDA = "6.4.2"
+ KernelAbstractions = "0.9.45"
+ CTLie = "0.2.3"
+ Mooncake = "0.5.64"
+ CTSolvers = "0.5.8"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ Parameters = "0.13.1"
+ Interpolations = "0.16.3"
+ DifferentiationInterface = "0.7.21"
+ ForwardDiff = "1.4.6"
+ CTFlows = "0.18.1"
+ CTBase = "0.30.10"
+ Makie = "0.24.16"
+ Test = "stdlib"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ OrdinaryDiffEqTsit5 = "2.1.5"
+ CairoMakie = "0.15.16"
+ SparseArrays = "1.10.0"
+ Unicode = "stdlib"
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/baseline-1.12.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/baseline-1.12.toml
new file mode 100644
index 0000000..440566b
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/baseline-1.12.toml
@@ -0,0 +1,699 @@
+julia = "1.12.6"
+
+[env."CTParser/docs"]
+ok = true
+
+ [env."CTParser/docs".versions]
+ CTParser = "0.9.5-beta"
+ OrderedCollections = "2.0.3"
+ CTBase = "0.30.10"
+ MLStyle = "0.4.17"
+ Markdown = "1.11.0"
+ Printf = "1.11.0"
+ Test = "1.11.0"
+ Random = "1.11.0"
+ MarkdownAST = "0.1.3"
+ DocStringExtensions = "0.9.5"
+ ADTypes = "1.24.0"
+ Parameters = "0.13.1"
+ Logging = "1.11.0"
+ Documenter = "1.19.0"
+ Unicode = "1.11.0"
+
+[env."CTModels/docs"]
+ok = true
+
+ [env."CTModels/docs".versions]
+ RecipesBase = "1.4.0"
+ CommonSolve = "0.2.14"
+ LinearAlgebra = "1.12.0"
+ Printf = "1.11.0"
+ DocumenterVitepress = "0.3.8"
+ Logging = "1.11.0"
+ Colors = "0.13.3"
+ MacroTools = "0.5.16"
+ CTModels = "0.19.8"
+ OrderedCollections = "2.0.3"
+ MLStyle = "0.4.17"
+ Markdown = "1.11.0"
+ Random = "1.11.0"
+ Plots = "1.41.7"
+ DocumenterInterLinks = "1.1.0"
+ MarkdownAST = "0.1.3"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ Parameters = "0.13.1"
+ Interpolations = "0.16.3"
+ CTBase = "0.30.10"
+ JLD2 = "0.6.7"
+ JSON3 = "1.14.3"
+ Makie = "0.24.16"
+ JET = "0.12.3"
+ Test = "1.11.0"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ Documenter = "1.19.0"
+ CairoMakie = "0.15.16"
+ LiveServer = "1.6.0"
+ SparseArrays = "1.12.0"
+ Unicode = "1.11.0"
+
+[env."CTLie/test"]
+ok = true
+
+ [env."CTLie/test".versions]
+ ForwardDiff = "1.4.6"
+ OrderedCollections = "2.0.3"
+ CTBase = "0.30.10"
+ Markdown = "1.11.0"
+ CTLie = "0.2.3"
+ LinearAlgebra = "1.12.0"
+ JET = "0.12.3"
+ Mooncake = "0.5.64"
+ Printf = "1.11.0"
+ Test = "1.11.0"
+ Random = "1.11.0"
+ BenchmarkTools = "1.8.0"
+ Aqua = "0.8.18"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ ADTypes = "1.24.0"
+ Logging = "1.11.0"
+ Reexport = "1.2.2"
+ CUDA = "6.4.2"
+ DifferentiationInterface = "0.7.21"
+ GPUArraysCore = "0.2.1"
+ SparseArrays = "1.12.0"
+ Unicode = "1.11.0"
+ MacroTools = "0.5.16"
+ KernelAbstractions = "0.9.45"
+
+[env."CTDirect/test"]
+ok = true
+
+ [env."CTDirect/test".versions]
+ RecipesBase = "1.4.0"
+ SolverCore = "0.3.10"
+ CommonSolve = "0.2.14"
+ LinearAlgebra = "1.12.0"
+ Printf = "1.11.0"
+ SparseConnectivityTracer = "1.2.4"
+ Logging = "1.11.0"
+ GPUArraysCore = "0.2.1"
+ MacroTools = "0.5.16"
+ CTModels = "0.19.8"
+ CTParser = "0.9.5-beta"
+ OrderedCollections = "2.0.3"
+ MLStyle = "0.4.17"
+ Markdown = "1.11.0"
+ CUDSS = "0.8.1"
+ Random = "1.11.0"
+ NLPModelsIpopt = "0.11.3"
+ ADNLPModels = "0.8.15"
+ CUDA = "6.3.1"
+ MadNLP = "0.10.1"
+ SplitApplyCombine = "1.3.0"
+ KernelAbstractions = "0.9.45"
+ ExaModels = "0.12.1"
+ AMDGPU = "2.8.0"
+ CTSolvers = "0.5.8"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ Parameters = "0.13.1"
+ DifferentiationInterface = "0.7.21"
+ NLPModels = "0.21.12"
+ MadNLPGPU = "0.10.2"
+ CTDirect = "1.1.8-beta"
+ ForwardDiff = "1.4.6"
+ CTBase = "0.30.10"
+ Test = "1.11.0"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ SparseArrays = "1.12.0"
+ Unicode = "1.11.0"
+
+[env."CTDirect/docs"]
+ok = true
+
+ [env."CTDirect/docs".versions]
+ ExaModels = "0.12.1"
+ CTDirect = "1.1.8-beta"
+ CTParser = "0.9.5-beta"
+ ForwardDiff = "1.4.6"
+ OrderedCollections = "2.0.3"
+ CTBase = "0.30.10"
+ RecipesBase = "1.4.0"
+ SolverCore = "0.3.10"
+ CommonSolve = "0.2.14"
+ Markdown = "1.11.0"
+ MLStyle = "0.4.17"
+ LinearAlgebra = "1.12.0"
+ Printf = "1.11.0"
+ Test = "1.11.0"
+ Random = "1.11.0"
+ SparseConnectivityTracer = "1.2.4"
+ MarkdownAST = "0.1.3"
+ CTSolvers = "0.5.8"
+ DocumenterMermaid = "0.2.1"
+ ADNLPModels = "0.8.15"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ ADTypes = "1.24.0"
+ Parameters = "0.13.1"
+ Logging = "1.11.0"
+ Reexport = "1.2.2"
+ Documenter = "1.19.0"
+ DifferentiationInterface = "0.7.21"
+ SparseArrays = "1.12.0"
+ Unicode = "1.11.0"
+ MacroTools = "0.5.16"
+ NLPModels = "0.21.12"
+ CTModels = "0.19.8"
+ KernelAbstractions = "0.9.45"
+
+[env."CTFlows/docs"]
+ok = true
+
+ [env."CTFlows/docs".versions]
+ RecipesBase = "1.4.0"
+ SolverCore = "0.3.10"
+ CommonSolve = "0.2.14"
+ LinearAlgebra = "1.12.0"
+ Printf = "1.11.0"
+ DocumenterVitepress = "0.3.8"
+ Logging = "1.11.0"
+ DiffEqBase = "7.21.4"
+ GPUArraysCore = "0.2.1"
+ SciMLBase = "3.58.0"
+ Colors = "0.13.3"
+ MacroTools = "0.5.16"
+ CTModels = "0.19.8"
+ OrderedCollections = "2.0.3"
+ MLStyle = "0.4.17"
+ Markdown = "1.11.0"
+ OrdinaryDiffEqRosenbrock = "2.7.5"
+ Random = "1.11.0"
+ Plots = "1.41.7"
+ KernelAbstractions = "0.9.45"
+ DocumenterInterLinks = "1.1.0"
+ MarkdownAST = "0.1.3"
+ CTSolvers = "0.5.8"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ Parameters = "0.13.1"
+ Interpolations = "0.16.3"
+ DifferentiationInterface = "0.7.21"
+ ForwardDiff = "1.4.6"
+ CTFlows = "0.18.1"
+ CTBase = "0.30.10"
+ Makie = "0.24.16"
+ Test = "1.11.0"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ Documenter = "1.19.0"
+ OrdinaryDiffEqTsit5 = "2.1.5"
+ CairoMakie = "0.15.16"
+ LiveServer = "1.6.0"
+ SparseArrays = "1.12.0"
+ Unicode = "1.11.0"
+
+[env."OptimalControl/docs"]
+ok = true
+
+ [env."OptimalControl/docs".versions]
+ RecipesBase = "1.4.0"
+ SolverCore = "0.3.10"
+ CommonSolve = "0.2.14"
+ NLPModelsKnitro = "0.10.6"
+ LinearAlgebra = "1.12.0"
+ Printf = "1.11.0"
+ NonlinearSolve = "4.32.0"
+ SparseConnectivityTracer = "1.2.4"
+ DocumenterVitepress = "0.3.8"
+ Logging = "1.11.0"
+ DiffEqBase = "7.21.4"
+ GPUArraysCore = "0.2.1"
+ SciMLBase = "3.58.0"
+ Colors = "0.13.3"
+ MacroTools = "0.5.16"
+ CTModels = "0.19.8"
+ CTParser = "0.9.5-beta"
+ OrderedCollections = "2.0.3"
+ MLStyle = "0.4.17"
+ Markdown = "1.11.0"
+ OrdinaryDiffEqVerner = "2.4.2"
+ OrdinaryDiffEqRosenbrock = "2.7.5"
+ CUDSS = "0.7.0"
+ Random = "1.11.0"
+ NLPModelsIpopt = "0.11.3"
+ Plots = "1.41.7"
+ ADNLPModels = "0.8.15"
+ CUDA = "6.2.0"
+ MadNLP = "0.10.1"
+ KernelAbstractions = "0.9.45"
+ ExaModels = "0.12.1"
+ Literate = "2.21.0"
+ OrdinaryDiffEq = "7.8.1"
+ CTLie = "0.2.3"
+ UnoSolver = "0.3.8"
+ DocumenterInterLinks = "1.1.0"
+ MarkdownAST = "0.1.3"
+ CTSolvers = "0.5.8"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ Parameters = "0.13.1"
+ OptimalControl = "2.2.1-beta"
+ Interpolations = "0.16.3"
+ DifferentiationInterface = "0.7.21"
+ NLPModels = "0.21.12"
+ MadNLPGPU = "0.10.2"
+ CTDirect = "1.1.8-beta"
+ ForwardDiff = "1.4.6"
+ CTFlows = "0.18.1"
+ CTBase = "0.30.10"
+ JLD2 = "0.6.7"
+ JSON3 = "1.14.3"
+ Makie = "0.24.16"
+ MadNCL = "0.2.3"
+ Test = "1.11.0"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ Documenter = "1.19.0"
+ OrdinaryDiffEqTsit5 = "2.1.5"
+ CairoMakie = "0.15.16"
+ LiveServer = "1.6.0"
+ SparseArrays = "1.12.0"
+ Unicode = "1.11.0"
+
+[env."CTModels/test"]
+ok = true
+
+ [env."CTModels/test".versions]
+ RecipesBase = "1.4.0"
+ CommonSolve = "0.2.14"
+ LinearAlgebra = "1.12.0"
+ Printf = "1.11.0"
+ Aqua = "0.8.18"
+ Logging = "1.11.0"
+ Colors = "0.13.3"
+ MacroTools = "0.5.16"
+ CTModels = "0.19.8"
+ OrderedCollections = "2.0.3"
+ MLStyle = "0.4.17"
+ Markdown = "1.11.0"
+ Random = "1.11.0"
+ BenchmarkTools = "1.8.0"
+ Plots = "1.41.7"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ Parameters = "0.13.1"
+ Interpolations = "0.16.3"
+ CTBase = "0.30.10"
+ JLD2 = "0.6.7"
+ JSON3 = "1.14.3"
+ Makie = "0.24.16"
+ JET = "0.12.3"
+ Test = "1.11.0"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ CairoMakie = "0.15.16"
+ SparseArrays = "1.12.0"
+ Unicode = "1.11.0"
+
+[env."CTLie/docs"]
+ok = true
+
+ [env."CTLie/docs".versions]
+ ForwardDiff = "1.4.6"
+ CTBase = "0.30.10"
+ Markdown = "1.11.0"
+ CTLie = "0.2.3"
+ LinearAlgebra = "1.12.0"
+ Printf = "1.11.0"
+ Test = "1.11.0"
+ DocumenterInterLinks = "1.1.0"
+ Random = "1.11.0"
+ MarkdownAST = "0.1.3"
+ DocStringExtensions = "0.9.5"
+ DocumenterVitepress = "0.3.8"
+ ADTypes = "1.24.0"
+ Logging = "1.11.0"
+ Documenter = "1.19.0"
+ DifferentiationInterface = "0.7.21"
+ LiveServer = "1.6.0"
+ Unicode = "1.11.0"
+ MacroTools = "0.5.16"
+
+[env."CTSolvers/test"]
+ok = true
+
+ [env."CTSolvers/test".versions]
+ RecipesBase = "1.4.0"
+ Zygote = "0.7.13"
+ SolverCore = "0.3.10"
+ CommonSolve = "0.2.14"
+ LinearAlgebra = "1.12.0"
+ Printf = "1.11.0"
+ SparseConnectivityTracer = "1.2.4"
+ Aqua = "0.8.18"
+ Logging = "1.11.0"
+ DiffEqBase = "7.21.4"
+ GPUArraysCore = "0.2.1"
+ SciMLBase = "3.58.0"
+ MacroTools = "0.5.16"
+ CTModels = "0.19.8"
+ OrderedCollections = "2.0.3"
+ MLStyle = "0.4.17"
+ Markdown = "1.11.0"
+ CUDSS = "0.8.1"
+ Random = "1.11.0"
+ BenchmarkTools = "1.8.0"
+ NLPModelsIpopt = "0.11.3"
+ ADNLPModels = "0.8.15"
+ CUDA = "6.4.1"
+ MadNLP = "0.10.1"
+ KernelAbstractions = "0.9.45"
+ ExaModels = "0.12.1"
+ UnoSolver = "0.3.8"
+ CTSolvers = "0.5.8"
+ DocStringExtensions = "0.9.5"
+ Enzyme = "0.13.216"
+ StaticArrays = "1.9.23"
+ Parameters = "0.13.1"
+ DifferentiationInterface = "0.7.21"
+ NLPModels = "0.21.12"
+ MadNLPGPU = "0.10.2"
+ ForwardDiff = "1.4.6"
+ CTBase = "0.30.10"
+ MadNCL = "0.2.3"
+ JET = "0.12.3"
+ Test = "1.11.0"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ OrdinaryDiffEqTsit5 = "2.1.5"
+ SparseArrays = "1.12.0"
+ Unicode = "1.11.0"
+
+[env."CTParser/test"]
+ok = true
+
+ [env."CTParser/test".versions]
+ ExaModels = "0.12.1"
+ CTParser = "0.9.5-beta"
+ OrderedCollections = "2.0.3"
+ CTBase = "0.29.3"
+ RecipesBase = "1.4.0"
+ MLStyle = "0.4.17"
+ Markdown = "1.11.0"
+ SolverCore = "0.3.10"
+ LinearAlgebra = "1.12.0"
+ Printf = "1.11.0"
+ CUDSS = "0.8.1"
+ Test = "1.11.0"
+ Random = "1.11.0"
+ BenchmarkTools = "1.8.0"
+ Aqua = "0.8.18"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ ADTypes = "1.24.0"
+ Parameters = "0.13.1"
+ Logging = "1.11.0"
+ Reexport = "1.2.2"
+ CUDA = "6.4.2"
+ MadNLP = "0.10.1"
+ Interpolations = "0.16.3"
+ GPUArraysCore = "0.2.1"
+ SparseArrays = "1.12.0"
+ Unicode = "1.11.0"
+ MacroTools = "0.5.16"
+ NLPModels = "0.21.12"
+ CTModels = "0.18.0"
+ KernelAbstractions = "0.9.45"
+ MadNLPGPU = "0.10.2"
+
+[env."OptimalControl/test"]
+ok = true
+
+ [env."OptimalControl/test".versions]
+ RecipesBase = "1.4.0"
+ SolverCore = "0.3.10"
+ CommonSolve = "0.2.14"
+ LinearAlgebra = "1.12.0"
+ Printf = "1.11.0"
+ NonlinearSolve = "4.32.0"
+ SparseConnectivityTracer = "1.2.4"
+ Logging = "1.11.0"
+ DiffEqBase = "7.21.4"
+ GPUArraysCore = "0.2.1"
+ SciMLBase = "3.58.0"
+ MacroTools = "0.5.16"
+ CTModels = "0.19.8"
+ CTParser = "0.9.5-beta"
+ OrderedCollections = "2.0.3"
+ MLStyle = "0.4.17"
+ Markdown = "1.11.0"
+ OrdinaryDiffEqVerner = "2.4.2"
+ OrdinaryDiffEqRosenbrock = "2.7.5"
+ CUDSS = "0.7.0"
+ Random = "1.11.0"
+ BenchmarkTools = "1.8.0"
+ NLPModelsIpopt = "0.11.3"
+ ADNLPModels = "0.8.15"
+ CUDA = "6.2.0"
+ MadNLP = "0.10.1"
+ SplitApplyCombine = "1.3.0"
+ KernelAbstractions = "0.9.45"
+ ExaModels = "0.12.1"
+ OrdinaryDiffEq = "7.8.1"
+ CTLie = "0.2.3"
+ UnoSolver = "0.3.8"
+ CTSolvers = "0.5.8"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ Parameters = "0.13.1"
+ OptimalControl = "2.2.1-beta"
+ DifferentiationInterface = "0.7.21"
+ NLPModels = "0.21.12"
+ MadNLPGPU = "0.10.2"
+ CTDirect = "1.1.8-beta"
+ ForwardDiff = "1.4.6"
+ CTFlows = "0.18.1"
+ CTBase = "0.30.10"
+ MadNCL = "0.2.3"
+ Test = "1.11.0"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ OrdinaryDiffEqTsit5 = "2.1.5"
+ SparseArrays = "1.12.0"
+ Unicode = "1.11.0"
+
+[env."CTBase/test"]
+ok = true
+
+ [env."CTBase/test".versions]
+ RecipesBase = "1.4.0"
+ CommonSolve = "0.2.14"
+ LinearAlgebra = "1.12.0"
+ Printf = "1.11.0"
+ Aqua = "0.8.18"
+ Logging = "1.11.0"
+ Colors = "0.13.3"
+ MacroTools = "0.5.16"
+ OrderedCollections = "2.0.3"
+ Markdown = "1.11.0"
+ Random = "1.11.0"
+ BenchmarkTools = "1.8.0"
+ Plots = "1.41.7"
+ Coverage = "1.8.2"
+ MarkdownAST = "0.1.3"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ Interpolations = "0.16.3"
+ DifferentiationInterface = "0.7.21"
+ ForwardDiff = "1.4.6"
+ CTBase = "0.30.10"
+ Makie = "0.24.16"
+ JET = "0.12.3"
+ Test = "1.11.0"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ Documenter = "1.19.0"
+ CairoMakie = "0.15.16"
+ SparseArrays = "1.12.0"
+ Unicode = "1.11.0"
+
+[env.joint]
+ok = true
+
+ [env.joint.versions]
+ ExaModels = "0.12.1"
+ CTDirect = "1.1.8-beta"
+ CTParser = "0.9.5-beta"
+ ForwardDiff = "1.4.6"
+ CTFlows = "0.18.1"
+ OrderedCollections = "2.0.3"
+ CTBase = "0.30.10"
+ RecipesBase = "1.4.0"
+ SolverCore = "0.3.10"
+ CommonSolve = "0.2.14"
+ MLStyle = "0.4.17"
+ Markdown = "1.11.0"
+ CTLie = "0.2.3"
+ LinearAlgebra = "1.12.0"
+ Printf = "1.11.0"
+ Random = "1.11.0"
+ SparseConnectivityTracer = "1.2.4"
+ CTSolvers = "0.5.8"
+ ADNLPModels = "0.8.15"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ ADTypes = "1.24.0"
+ Parameters = "0.13.1"
+ OptimalControl = "2.2.1-beta"
+ Reexport = "1.2.2"
+ DifferentiationInterface = "0.7.21"
+ GPUArraysCore = "0.2.1"
+ SparseArrays = "1.12.0"
+ Unicode = "1.11.0"
+ MacroTools = "0.5.16"
+ NLPModels = "0.21.12"
+ CTModels = "0.19.8"
+ KernelAbstractions = "0.9.45"
+
+[env."CTBase/docs"]
+ok = true
+
+ [env."CTBase/docs".versions]
+ RecipesBase = "1.4.0"
+ CommonSolve = "0.2.14"
+ LinearAlgebra = "1.12.0"
+ Printf = "1.11.0"
+ DocumenterVitepress = "0.3.8"
+ Logging = "1.11.0"
+ Colors = "0.13.3"
+ MacroTools = "0.5.16"
+ OrderedCollections = "2.0.3"
+ Markdown = "1.11.0"
+ Random = "1.11.0"
+ Plots = "1.41.7"
+ Coverage = "1.8.2"
+ DocumenterInterLinks = "1.1.0"
+ MarkdownAST = "0.1.3"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ Interpolations = "0.16.3"
+ DifferentiationInterface = "0.7.21"
+ ForwardDiff = "1.4.6"
+ CTBase = "0.30.10"
+ Makie = "0.24.16"
+ JET = "0.12.3"
+ Test = "1.11.0"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ Documenter = "1.19.0"
+ CairoMakie = "0.15.16"
+ LiveServer = "1.5.0"
+ SparseArrays = "1.12.0"
+ Unicode = "1.11.0"
+
+[env."CTSolvers/docs"]
+ok = true
+
+ [env."CTSolvers/docs".versions]
+ RecipesBase = "1.4.0"
+ Zygote = "0.7.13"
+ SolverCore = "0.3.10"
+ CommonSolve = "0.2.14"
+ NLPModelsKnitro = "0.10.6"
+ LinearAlgebra = "1.12.0"
+ Printf = "1.11.0"
+ SparseConnectivityTracer = "1.2.4"
+ DocumenterVitepress = "0.3.8"
+ Logging = "1.11.0"
+ DiffEqBase = "7.21.4"
+ GPUArraysCore = "0.2.1"
+ SciMLBase = "3.58.0"
+ MacroTools = "0.5.16"
+ CTModels = "0.19.8"
+ OrderedCollections = "2.0.3"
+ MLStyle = "0.4.17"
+ Markdown = "1.11.0"
+ OrdinaryDiffEqVerner = "2.4.2"
+ CUDSS = "0.8.1"
+ Random = "1.11.0"
+ NLPModelsIpopt = "0.11.3"
+ ADNLPModels = "0.8.15"
+ CUDA = "6.4.1"
+ MadNLP = "0.10.1"
+ KernelAbstractions = "0.9.45"
+ ExaModels = "0.12.1"
+ UnoSolver = "0.3.8"
+ DocumenterInterLinks = "1.1.0"
+ MarkdownAST = "0.1.3"
+ CTSolvers = "0.5.8"
+ DocStringExtensions = "0.9.5"
+ Enzyme = "0.13.216"
+ StaticArrays = "1.9.23"
+ Parameters = "0.13.1"
+ DifferentiationInterface = "0.7.21"
+ NLPModels = "0.21.12"
+ MadNLPGPU = "0.10.2"
+ ForwardDiff = "1.4.6"
+ CTBase = "0.30.10"
+ MadNCL = "0.2.3"
+ JET = "0.12.3"
+ Test = "1.11.0"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ Documenter = "1.19.0"
+ OrdinaryDiffEqTsit5 = "2.1.5"
+ LiveServer = "1.6.0"
+ SparseArrays = "1.12.0"
+ Unicode = "1.11.0"
+
+[env."CTFlows/test"]
+ok = true
+
+ [env."CTFlows/test".versions]
+ RecipesBase = "1.4.0"
+ Zygote = "0.7.13"
+ SolverCore = "0.3.10"
+ CommonSolve = "0.2.14"
+ LinearAlgebra = "1.12.0"
+ DiffEqGPU = "3.21.5"
+ Printf = "1.11.0"
+ NonlinearSolve = "4.32.0"
+ Aqua = "0.8.18"
+ Logging = "1.11.0"
+ DiffEqBase = "7.21.4"
+ GPUArraysCore = "0.2.1"
+ SciMLBase = "3.58.0"
+ Colors = "0.13.3"
+ MacroTools = "0.5.16"
+ CTModels = "0.19.8"
+ OrderedCollections = "2.0.3"
+ MLStyle = "0.4.17"
+ Markdown = "1.11.0"
+ Random = "1.11.0"
+ Plots = "1.41.7"
+ CUDA = "6.4.2"
+ KernelAbstractions = "0.9.45"
+ CTLie = "0.2.3"
+ Mooncake = "0.5.64"
+ CTSolvers = "0.5.8"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ Parameters = "0.13.1"
+ Interpolations = "0.16.3"
+ DifferentiationInterface = "0.7.21"
+ ForwardDiff = "1.4.6"
+ CTFlows = "0.18.1"
+ CTBase = "0.30.10"
+ Makie = "0.24.16"
+ Test = "1.11.0"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ OrdinaryDiffEqTsit5 = "2.1.5"
+ CairoMakie = "0.15.16"
+ SparseArrays = "1.12.0"
+ Unicode = "1.11.0"
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/baseline-1.13.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/baseline-1.13.toml
new file mode 100644
index 0000000..2bb8d9e
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/baseline-1.13.toml
@@ -0,0 +1,699 @@
+julia = "1.13.1"
+
+[env."CTParser/docs"]
+ok = true
+
+ [env."CTParser/docs".versions]
+ CTParser = "0.9.5-beta"
+ OrderedCollections = "2.0.3"
+ CTBase = "0.30.10"
+ MLStyle = "0.4.17"
+ Markdown = "1.11.0"
+ Printf = "1.11.0"
+ Test = "1.11.0"
+ Random = "1.11.0"
+ MarkdownAST = "0.1.3"
+ DocStringExtensions = "0.9.5"
+ ADTypes = "1.24.0"
+ Parameters = "0.13.1"
+ Logging = "1.11.0"
+ Documenter = "1.19.0"
+ Unicode = "1.11.0"
+
+[env."CTModels/docs"]
+ok = true
+
+ [env."CTModels/docs".versions]
+ RecipesBase = "1.4.0"
+ CommonSolve = "0.2.14"
+ LinearAlgebra = "1.13.0"
+ Printf = "1.11.0"
+ DocumenterVitepress = "0.3.8"
+ Logging = "1.11.0"
+ Colors = "0.13.3"
+ MacroTools = "0.5.16"
+ CTModels = "0.19.8"
+ OrderedCollections = "2.0.3"
+ MLStyle = "0.4.17"
+ Markdown = "1.11.0"
+ Random = "1.11.0"
+ Plots = "1.41.7"
+ DocumenterInterLinks = "1.1.0"
+ MarkdownAST = "0.1.3"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ Parameters = "0.13.1"
+ Interpolations = "0.16.3"
+ CTBase = "0.30.10"
+ JLD2 = "0.6.7"
+ JSON3 = "1.14.3"
+ Makie = "0.24.16"
+ JET = "0.12.3"
+ Test = "1.11.0"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ Documenter = "1.19.0"
+ CairoMakie = "0.15.16"
+ SparseArrays = "1.13.0"
+ LiveServer = "1.6.0"
+ Unicode = "1.11.0"
+
+[env."CTLie/test"]
+ok = true
+
+ [env."CTLie/test".versions]
+ ForwardDiff = "1.4.6"
+ OrderedCollections = "2.0.3"
+ CTBase = "0.30.10"
+ Markdown = "1.11.0"
+ CTLie = "0.2.3"
+ LinearAlgebra = "1.13.0"
+ JET = "0.12.3"
+ Mooncake = "0.5.64"
+ Printf = "1.11.0"
+ Test = "1.11.0"
+ Random = "1.11.0"
+ BenchmarkTools = "1.8.0"
+ Aqua = "0.8.18"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ ADTypes = "1.24.0"
+ Logging = "1.11.0"
+ Reexport = "1.2.2"
+ CUDA = "6.4.2"
+ DifferentiationInterface = "0.7.21"
+ GPUArraysCore = "0.2.1"
+ SparseArrays = "1.13.0"
+ Unicode = "1.11.0"
+ MacroTools = "0.5.16"
+ KernelAbstractions = "0.9.45"
+
+[env."CTDirect/test"]
+ok = true
+
+ [env."CTDirect/test".versions]
+ RecipesBase = "1.4.0"
+ SolverCore = "0.3.10"
+ CommonSolve = "0.2.14"
+ LinearAlgebra = "1.13.0"
+ Printf = "1.11.0"
+ SparseConnectivityTracer = "1.2.4"
+ Logging = "1.11.0"
+ GPUArraysCore = "0.2.1"
+ MacroTools = "0.5.16"
+ CTModels = "0.19.8"
+ CTParser = "0.9.5-beta"
+ OrderedCollections = "2.0.3"
+ MLStyle = "0.4.17"
+ Markdown = "1.11.0"
+ CUDSS = "0.8.1"
+ Random = "1.11.0"
+ NLPModelsIpopt = "0.11.3"
+ ADNLPModels = "0.8.15"
+ CUDA = "6.3.1"
+ MadNLP = "0.10.1"
+ SplitApplyCombine = "1.3.0"
+ KernelAbstractions = "0.9.45"
+ ExaModels = "0.12.1"
+ AMDGPU = "2.8.0"
+ CTSolvers = "0.5.8"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ Parameters = "0.13.1"
+ DifferentiationInterface = "0.7.21"
+ NLPModels = "0.21.12"
+ MadNLPGPU = "0.10.2"
+ CTDirect = "1.1.8-beta"
+ ForwardDiff = "1.4.6"
+ CTBase = "0.30.10"
+ Test = "1.11.0"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ SparseArrays = "1.13.0"
+ Unicode = "1.11.0"
+
+[env."CTDirect/docs"]
+ok = true
+
+ [env."CTDirect/docs".versions]
+ ExaModels = "0.12.1"
+ CTDirect = "1.1.8-beta"
+ CTParser = "0.9.5-beta"
+ ForwardDiff = "1.4.6"
+ OrderedCollections = "2.0.3"
+ CTBase = "0.30.10"
+ RecipesBase = "1.4.0"
+ SolverCore = "0.3.10"
+ MLStyle = "0.4.17"
+ CommonSolve = "0.2.14"
+ Markdown = "1.11.0"
+ LinearAlgebra = "1.13.0"
+ Printf = "1.11.0"
+ Test = "1.11.0"
+ Random = "1.11.0"
+ SparseConnectivityTracer = "1.2.4"
+ MarkdownAST = "0.1.3"
+ CTSolvers = "0.5.8"
+ DocumenterMermaid = "0.2.1"
+ ADNLPModels = "0.8.15"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ ADTypes = "1.24.0"
+ Parameters = "0.13.1"
+ Logging = "1.11.0"
+ Reexport = "1.2.2"
+ Documenter = "1.19.0"
+ DifferentiationInterface = "0.7.21"
+ SparseArrays = "1.13.0"
+ Unicode = "1.11.0"
+ MacroTools = "0.5.16"
+ NLPModels = "0.21.12"
+ CTModels = "0.19.8"
+ KernelAbstractions = "0.9.45"
+
+[env."CTFlows/docs"]
+ok = true
+
+ [env."CTFlows/docs".versions]
+ RecipesBase = "1.4.0"
+ SolverCore = "0.3.10"
+ CommonSolve = "0.2.14"
+ LinearAlgebra = "1.13.0"
+ Printf = "1.11.0"
+ DocumenterVitepress = "0.3.8"
+ Logging = "1.11.0"
+ DiffEqBase = "7.21.4"
+ GPUArraysCore = "0.2.1"
+ SciMLBase = "3.58.0"
+ Colors = "0.13.3"
+ MacroTools = "0.5.16"
+ CTModels = "0.19.8"
+ OrderedCollections = "2.0.3"
+ MLStyle = "0.4.17"
+ Markdown = "1.11.0"
+ OrdinaryDiffEqRosenbrock = "2.7.5"
+ Random = "1.11.0"
+ Plots = "1.41.7"
+ KernelAbstractions = "0.9.45"
+ DocumenterInterLinks = "1.1.0"
+ MarkdownAST = "0.1.3"
+ CTSolvers = "0.5.8"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ Parameters = "0.13.1"
+ Interpolations = "0.16.3"
+ DifferentiationInterface = "0.7.21"
+ ForwardDiff = "1.4.6"
+ CTFlows = "0.18.1"
+ CTBase = "0.30.10"
+ Makie = "0.24.16"
+ Test = "1.11.0"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ OrdinaryDiffEqTsit5 = "2.1.5"
+ Documenter = "1.19.0"
+ CairoMakie = "0.15.16"
+ SparseArrays = "1.13.0"
+ LiveServer = "1.6.0"
+ Unicode = "1.11.0"
+
+[env."OptimalControl/docs"]
+ok = true
+
+ [env."OptimalControl/docs".versions]
+ RecipesBase = "1.4.0"
+ SolverCore = "0.3.10"
+ CommonSolve = "0.2.14"
+ NLPModelsKnitro = "0.10.6"
+ LinearAlgebra = "1.13.0"
+ Printf = "1.11.0"
+ NonlinearSolve = "4.32.0"
+ SparseConnectivityTracer = "1.2.4"
+ DocumenterVitepress = "0.3.8"
+ Logging = "1.11.0"
+ DiffEqBase = "7.21.4"
+ GPUArraysCore = "0.2.1"
+ SciMLBase = "3.58.0"
+ Colors = "0.13.3"
+ MacroTools = "0.5.16"
+ CTModels = "0.19.8"
+ CTParser = "0.9.5-beta"
+ OrderedCollections = "2.0.3"
+ MLStyle = "0.4.17"
+ Markdown = "1.11.0"
+ OrdinaryDiffEqVerner = "2.4.2"
+ OrdinaryDiffEqRosenbrock = "2.7.5"
+ CUDSS = "0.7.0"
+ Random = "1.11.0"
+ NLPModelsIpopt = "0.11.3"
+ Plots = "1.41.7"
+ ADNLPModels = "0.8.15"
+ CUDA = "6.2.0"
+ MadNLP = "0.10.1"
+ KernelAbstractions = "0.9.45"
+ ExaModels = "0.12.1"
+ Literate = "2.21.0"
+ OrdinaryDiffEq = "7.8.1"
+ CTLie = "0.2.3"
+ UnoSolver = "0.3.8"
+ DocumenterInterLinks = "1.1.0"
+ MarkdownAST = "0.1.3"
+ CTSolvers = "0.5.8"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ Parameters = "0.13.1"
+ OptimalControl = "2.2.1-beta"
+ Interpolations = "0.16.3"
+ DifferentiationInterface = "0.7.21"
+ NLPModels = "0.21.12"
+ MadNLPGPU = "0.10.2"
+ CTDirect = "1.1.8-beta"
+ ForwardDiff = "1.4.6"
+ CTFlows = "0.18.1"
+ CTBase = "0.30.10"
+ JLD2 = "0.6.7"
+ JSON3 = "1.14.3"
+ Makie = "0.24.16"
+ MadNCL = "0.2.3"
+ Test = "1.11.0"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ OrdinaryDiffEqTsit5 = "2.1.5"
+ Documenter = "1.19.0"
+ CairoMakie = "0.15.16"
+ SparseArrays = "1.13.0"
+ LiveServer = "1.6.0"
+ Unicode = "1.11.0"
+
+[env."CTModels/test"]
+ok = true
+
+ [env."CTModels/test".versions]
+ RecipesBase = "1.4.0"
+ CommonSolve = "0.2.14"
+ LinearAlgebra = "1.13.0"
+ Printf = "1.11.0"
+ Aqua = "0.8.18"
+ Logging = "1.11.0"
+ Colors = "0.13.3"
+ MacroTools = "0.5.16"
+ CTModels = "0.19.8"
+ OrderedCollections = "2.0.3"
+ MLStyle = "0.4.17"
+ Markdown = "1.11.0"
+ Random = "1.11.0"
+ BenchmarkTools = "1.8.0"
+ Plots = "1.41.7"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ Parameters = "0.13.1"
+ Interpolations = "0.16.3"
+ CTBase = "0.30.10"
+ JLD2 = "0.6.7"
+ JSON3 = "1.14.3"
+ Makie = "0.24.16"
+ JET = "0.12.3"
+ Test = "1.11.0"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ CairoMakie = "0.15.16"
+ SparseArrays = "1.13.0"
+ Unicode = "1.11.0"
+
+[env."CTSolvers/test"]
+ok = true
+
+ [env."CTSolvers/test".versions]
+ RecipesBase = "1.4.0"
+ Zygote = "0.7.13"
+ SolverCore = "0.3.10"
+ CommonSolve = "0.2.14"
+ LinearAlgebra = "1.13.0"
+ Printf = "1.11.0"
+ SparseConnectivityTracer = "1.2.4"
+ Aqua = "0.8.18"
+ Logging = "1.11.0"
+ DiffEqBase = "7.21.4"
+ GPUArraysCore = "0.2.1"
+ SciMLBase = "3.58.0"
+ MacroTools = "0.5.16"
+ CTModels = "0.19.8"
+ OrderedCollections = "2.0.3"
+ MLStyle = "0.4.17"
+ Markdown = "1.11.0"
+ CUDSS = "0.8.1"
+ Random = "1.11.0"
+ BenchmarkTools = "1.8.0"
+ NLPModelsIpopt = "0.11.3"
+ ADNLPModels = "0.8.15"
+ CUDA = "6.4.1"
+ MadNLP = "0.10.1"
+ KernelAbstractions = "0.9.45"
+ ExaModels = "0.12.1"
+ UnoSolver = "0.3.8"
+ CTSolvers = "0.5.8"
+ DocStringExtensions = "0.9.5"
+ Enzyme = "0.13.216"
+ StaticArrays = "1.9.23"
+ Parameters = "0.13.1"
+ DifferentiationInterface = "0.7.21"
+ NLPModels = "0.21.12"
+ MadNLPGPU = "0.10.2"
+ ForwardDiff = "1.4.6"
+ CTBase = "0.30.10"
+ MadNCL = "0.2.3"
+ JET = "0.12.3"
+ Test = "1.11.0"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ OrdinaryDiffEqTsit5 = "2.1.5"
+ SparseArrays = "1.13.0"
+ Unicode = "1.11.0"
+
+[env."CTLie/docs"]
+ok = true
+
+ [env."CTLie/docs".versions]
+ ForwardDiff = "1.4.6"
+ CTBase = "0.30.10"
+ Markdown = "1.11.0"
+ CTLie = "0.2.3"
+ LinearAlgebra = "1.13.0"
+ Printf = "1.11.0"
+ Test = "1.11.0"
+ DocumenterInterLinks = "1.1.0"
+ Random = "1.11.0"
+ MarkdownAST = "0.1.3"
+ DocStringExtensions = "0.9.5"
+ DocumenterVitepress = "0.3.8"
+ ADTypes = "1.24.0"
+ Logging = "1.11.0"
+ Documenter = "1.19.0"
+ DifferentiationInterface = "0.7.21"
+ LiveServer = "1.6.0"
+ Unicode = "1.11.0"
+ MacroTools = "0.5.16"
+
+[env."CTParser/test"]
+ok = true
+
+ [env."CTParser/test".versions]
+ ExaModels = "0.12.1"
+ CTParser = "0.9.5-beta"
+ OrderedCollections = "2.0.3"
+ CTBase = "0.29.3"
+ RecipesBase = "1.4.0"
+ SolverCore = "0.3.10"
+ MLStyle = "0.4.17"
+ Markdown = "1.11.0"
+ LinearAlgebra = "1.13.0"
+ Printf = "1.11.0"
+ CUDSS = "0.8.1"
+ Test = "1.11.0"
+ Random = "1.11.0"
+ BenchmarkTools = "1.8.0"
+ Aqua = "0.8.18"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ ADTypes = "1.24.0"
+ Parameters = "0.13.1"
+ Logging = "1.11.0"
+ Reexport = "1.2.2"
+ CUDA = "6.4.2"
+ MadNLP = "0.10.1"
+ Interpolations = "0.16.3"
+ GPUArraysCore = "0.2.1"
+ SparseArrays = "1.13.0"
+ Unicode = "1.11.0"
+ MacroTools = "0.5.16"
+ NLPModels = "0.21.12"
+ CTModels = "0.18.0"
+ KernelAbstractions = "0.9.45"
+ MadNLPGPU = "0.10.2"
+
+[env."OptimalControl/test"]
+ok = true
+
+ [env."OptimalControl/test".versions]
+ RecipesBase = "1.4.0"
+ SolverCore = "0.3.10"
+ CommonSolve = "0.2.14"
+ LinearAlgebra = "1.13.0"
+ Printf = "1.11.0"
+ NonlinearSolve = "4.32.0"
+ SparseConnectivityTracer = "1.2.4"
+ Logging = "1.11.0"
+ DiffEqBase = "7.21.4"
+ GPUArraysCore = "0.2.1"
+ SciMLBase = "3.58.0"
+ MacroTools = "0.5.16"
+ CTModels = "0.19.8"
+ CTParser = "0.9.5-beta"
+ OrderedCollections = "2.0.3"
+ MLStyle = "0.4.17"
+ Markdown = "1.11.0"
+ OrdinaryDiffEqVerner = "2.4.2"
+ OrdinaryDiffEqRosenbrock = "2.7.5"
+ CUDSS = "0.7.0"
+ Random = "1.11.0"
+ BenchmarkTools = "1.8.0"
+ NLPModelsIpopt = "0.11.3"
+ ADNLPModels = "0.8.15"
+ CUDA = "6.2.0"
+ MadNLP = "0.10.1"
+ SplitApplyCombine = "1.3.0"
+ KernelAbstractions = "0.9.45"
+ ExaModels = "0.12.1"
+ OrdinaryDiffEq = "7.8.1"
+ CTLie = "0.2.3"
+ UnoSolver = "0.3.8"
+ CTSolvers = "0.5.8"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ Parameters = "0.13.1"
+ OptimalControl = "2.2.1-beta"
+ DifferentiationInterface = "0.7.21"
+ NLPModels = "0.21.12"
+ MadNLPGPU = "0.10.2"
+ CTDirect = "1.1.8-beta"
+ ForwardDiff = "1.4.6"
+ CTFlows = "0.18.1"
+ CTBase = "0.30.10"
+ MadNCL = "0.2.3"
+ Test = "1.11.0"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ OrdinaryDiffEqTsit5 = "2.1.5"
+ SparseArrays = "1.13.0"
+ Unicode = "1.11.0"
+
+[env."CTBase/test"]
+ok = true
+
+ [env."CTBase/test".versions]
+ RecipesBase = "1.4.0"
+ CommonSolve = "0.2.14"
+ LinearAlgebra = "1.13.0"
+ Printf = "1.11.0"
+ Aqua = "0.8.18"
+ Logging = "1.11.0"
+ Colors = "0.13.3"
+ MacroTools = "0.5.16"
+ OrderedCollections = "2.0.3"
+ Markdown = "1.11.0"
+ Random = "1.11.0"
+ BenchmarkTools = "1.8.0"
+ Plots = "1.41.7"
+ Coverage = "1.8.2"
+ MarkdownAST = "0.1.3"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ Interpolations = "0.16.3"
+ DifferentiationInterface = "0.7.21"
+ ForwardDiff = "1.4.6"
+ CTBase = "0.30.10"
+ Makie = "0.24.16"
+ JET = "0.12.3"
+ Test = "1.11.0"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ Documenter = "1.19.0"
+ CairoMakie = "0.15.16"
+ SparseArrays = "1.13.0"
+ Unicode = "1.11.0"
+
+[env.joint]
+ok = true
+
+ [env.joint.versions]
+ ExaModels = "0.12.1"
+ CTDirect = "1.1.8-beta"
+ CTParser = "0.9.5-beta"
+ ForwardDiff = "1.4.6"
+ CTFlows = "0.18.1"
+ OrderedCollections = "2.0.3"
+ CTBase = "0.30.10"
+ RecipesBase = "1.4.0"
+ SolverCore = "0.3.10"
+ MLStyle = "0.4.17"
+ CommonSolve = "0.2.14"
+ Markdown = "1.11.0"
+ CTLie = "0.2.3"
+ LinearAlgebra = "1.13.0"
+ Printf = "1.11.0"
+ Random = "1.11.0"
+ SparseConnectivityTracer = "1.2.4"
+ CTSolvers = "0.5.8"
+ ADNLPModels = "0.8.15"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ ADTypes = "1.24.0"
+ Parameters = "0.13.1"
+ Reexport = "1.2.2"
+ OptimalControl = "2.2.1-beta"
+ DifferentiationInterface = "0.7.21"
+ GPUArraysCore = "0.2.1"
+ SparseArrays = "1.13.0"
+ Unicode = "1.11.0"
+ MacroTools = "0.5.16"
+ NLPModels = "0.21.12"
+ CTModels = "0.19.8"
+ KernelAbstractions = "0.9.45"
+
+[env."CTBase/docs"]
+ok = true
+
+ [env."CTBase/docs".versions]
+ RecipesBase = "1.4.0"
+ CommonSolve = "0.2.14"
+ LinearAlgebra = "1.13.0"
+ Printf = "1.11.0"
+ DocumenterVitepress = "0.3.8"
+ Logging = "1.11.0"
+ Colors = "0.13.3"
+ MacroTools = "0.5.16"
+ OrderedCollections = "2.0.3"
+ Markdown = "1.11.0"
+ Random = "1.11.0"
+ Plots = "1.41.7"
+ Coverage = "1.8.2"
+ DocumenterInterLinks = "1.1.0"
+ MarkdownAST = "0.1.3"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ Interpolations = "0.16.3"
+ DifferentiationInterface = "0.7.21"
+ ForwardDiff = "1.4.6"
+ CTBase = "0.30.10"
+ Makie = "0.24.16"
+ JET = "0.12.3"
+ Test = "1.11.0"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ Documenter = "1.19.0"
+ CairoMakie = "0.15.16"
+ SparseArrays = "1.13.0"
+ LiveServer = "1.5.0"
+ Unicode = "1.11.0"
+
+[env."CTSolvers/docs"]
+ok = true
+
+ [env."CTSolvers/docs".versions]
+ RecipesBase = "1.4.0"
+ Zygote = "0.7.13"
+ SolverCore = "0.3.10"
+ CommonSolve = "0.2.14"
+ NLPModelsKnitro = "0.10.6"
+ LinearAlgebra = "1.13.0"
+ Printf = "1.11.0"
+ SparseConnectivityTracer = "1.2.4"
+ DocumenterVitepress = "0.3.8"
+ Logging = "1.11.0"
+ DiffEqBase = "7.21.4"
+ GPUArraysCore = "0.2.1"
+ SciMLBase = "3.58.0"
+ MacroTools = "0.5.16"
+ CTModels = "0.19.8"
+ OrderedCollections = "2.0.3"
+ MLStyle = "0.4.17"
+ Markdown = "1.11.0"
+ OrdinaryDiffEqVerner = "2.4.2"
+ CUDSS = "0.8.1"
+ Random = "1.11.0"
+ NLPModelsIpopt = "0.11.3"
+ ADNLPModels = "0.8.15"
+ CUDA = "6.4.1"
+ MadNLP = "0.10.1"
+ KernelAbstractions = "0.9.45"
+ ExaModels = "0.12.1"
+ UnoSolver = "0.3.8"
+ DocumenterInterLinks = "1.1.0"
+ MarkdownAST = "0.1.3"
+ CTSolvers = "0.5.8"
+ DocStringExtensions = "0.9.5"
+ Enzyme = "0.13.216"
+ StaticArrays = "1.9.23"
+ Parameters = "0.13.1"
+ DifferentiationInterface = "0.7.21"
+ NLPModels = "0.21.12"
+ MadNLPGPU = "0.10.2"
+ ForwardDiff = "1.4.6"
+ CTBase = "0.30.10"
+ MadNCL = "0.2.3"
+ JET = "0.12.3"
+ Test = "1.11.0"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ OrdinaryDiffEqTsit5 = "2.1.5"
+ Documenter = "1.19.0"
+ SparseArrays = "1.13.0"
+ LiveServer = "1.6.0"
+ Unicode = "1.11.0"
+
+[env."CTFlows/test"]
+ok = true
+
+ [env."CTFlows/test".versions]
+ RecipesBase = "1.4.0"
+ Zygote = "0.7.13"
+ SolverCore = "0.3.10"
+ CommonSolve = "0.2.14"
+ LinearAlgebra = "1.13.0"
+ DiffEqGPU = "3.21.5"
+ Printf = "1.11.0"
+ NonlinearSolve = "4.32.0"
+ Aqua = "0.8.18"
+ Logging = "1.11.0"
+ DiffEqBase = "7.21.4"
+ GPUArraysCore = "0.2.1"
+ SciMLBase = "3.58.0"
+ Colors = "0.13.3"
+ MacroTools = "0.5.16"
+ CTModels = "0.19.8"
+ OrderedCollections = "2.0.3"
+ MLStyle = "0.4.17"
+ Markdown = "1.11.0"
+ Random = "1.11.0"
+ Plots = "1.41.7"
+ CUDA = "6.4.2"
+ KernelAbstractions = "0.9.45"
+ CTLie = "0.2.3"
+ Mooncake = "0.5.64"
+ CTSolvers = "0.5.8"
+ DocStringExtensions = "0.9.5"
+ StaticArrays = "1.9.23"
+ Parameters = "0.13.1"
+ Interpolations = "0.16.3"
+ DifferentiationInterface = "0.7.21"
+ ForwardDiff = "1.4.6"
+ CTFlows = "0.18.1"
+ CTBase = "0.30.10"
+ Makie = "0.24.16"
+ Test = "1.11.0"
+ ADTypes = "1.24.0"
+ Reexport = "1.2.2"
+ OrdinaryDiffEqTsit5 = "2.1.5"
+ CairoMakie = "0.15.16"
+ SparseArrays = "1.13.0"
+ Unicode = "1.11.0"
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTBase/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTBase/Project.toml
new file mode 100644
index 0000000..aecdb99
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTBase/Project.toml
@@ -0,0 +1,64 @@
+name = "CTBase"
+uuid = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+version = "0.30.10"
+authors = ["Olivier Cots ", "Jean-Baptiste Caillau "]
+
+[deps]
+ADTypes = "47edcb42-4c32-4615-8424-f2b9edc5f35b"
+DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae"
+
+[weakdeps]
+Coverage = "a2441757-f6aa-5fb2-8edb-039e3f45d037"
+DifferentiationInterface = "a0c0ee7d-e4b9-4e03-894e-1c5f64a51d63"
+Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
+Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a"
+Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a"
+MarkdownAST = "d0879d2d-cac2-40c8-9cee-1863dc0c7391"
+Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
+Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+
+[extensions]
+CTBaseDifferentiationInterface = ["DifferentiationInterface"]
+CTBaseMakie = ["Makie"]
+CTBasePlots = ["Plots"]
+CoveragePostprocessing = ["Coverage"]
+DocumenterReference = ["Documenter", "Markdown", "MarkdownAST"]
+TestRunner = ["Test"]
+
+[compat]
+ADTypes = "1"
+Aqua = "0.8"
+BenchmarkTools = "1"
+CairoMakie = "0.15"
+Coverage = "1"
+DifferentiationInterface = "0.7"
+DocStringExtensions = "0.9, 0.10"
+Documenter = "1"
+ForwardDiff = "0.10, 1"
+JET = "0.9, 0.11, 0.12"
+Logging = "1"
+Makie = "0.24"
+Markdown = "1"
+MarkdownAST = "0.1"
+Plots = "1"
+Test = "1"
+julia = "1.10"
+
+[extras]
+ADTypes = "47edcb42-4c32-4615-8424-f2b9edc5f35b"
+Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595"
+BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf"
+CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0"
+Coverage = "a2441757-f6aa-5fb2-8edb-039e3f45d037"
+DifferentiationInterface = "a0c0ee7d-e4b9-4e03-894e-1c5f64a51d63"
+Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
+ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210"
+JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b"
+Logging = "56ddb016-857b-54e1-b83d-db4d58db5568"
+Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a"
+MarkdownAST = "d0879d2d-cac2-40c8-9cee-1863dc0c7391"
+Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
+Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+
+[targets]
+test = ["Aqua", "BenchmarkTools", "CairoMakie", "Coverage", "DifferentiationInterface", "Documenter", "ForwardDiff", "JET", "Logging", "Markdown", "MarkdownAST", "Plots", "Test"]
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTBase/docs/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTBase/docs/Project.toml
new file mode 100644
index 0000000..848cbe1
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTBase/docs/Project.toml
@@ -0,0 +1,28 @@
+[deps]
+ADTypes = "47edcb42-4c32-4615-8424-f2b9edc5f35b"
+CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0"
+Coverage = "a2441757-f6aa-5fb2-8edb-039e3f45d037"
+DifferentiationInterface = "a0c0ee7d-e4b9-4e03-894e-1c5f64a51d63"
+Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
+DocumenterInterLinks = "d12716ef-a0f6-4df4-a9f1-a5a34e75c656"
+DocumenterVitepress = "4710194d-e776-4893-9690-8d956a29c365"
+ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210"
+JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b"
+LiveServer = "16fef848-5104-11e9-1b77-fb7a48bbb589"
+MarkdownAST = "d0879d2d-cac2-40c8-9cee-1863dc0c7391"
+Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
+
+[compat]
+ADTypes = "1"
+CairoMakie = "0.15"
+Coverage = "1"
+DifferentiationInterface = "0.7"
+Documenter = "1"
+DocumenterInterLinks = "1"
+DocumenterVitepress = "0.3.5"
+ForwardDiff = "0.10, 1"
+JET = "0.9, 0.11, 0.12"
+LiveServer = "1"
+MarkdownAST = "0.1"
+Plots = "1"
+julia = "1.10"
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTDirect/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTDirect/Project.toml
new file mode 100644
index 0000000..2749473
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTDirect/Project.toml
@@ -0,0 +1,58 @@
+name = "CTDirect"
+uuid = "790bbbee-bee9-49ee-8912-a9de031322d5"
+version = "1.1.8-beta"
+authors = ["Pierre Martinon "]
+
+[workspace]
+projects = ["test", "docs"]
+
+[deps]
+ADNLPModels = "54578032-b7ea-4c30-94aa-7cbd1cce6c9a"
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+CTModels = "34c4fa32-2049-4079-8329-de33c2a22e2d"
+CTParser = "32681960-a1b1-40db-9bff-a1ca817385d1"
+CTSolvers = "d3e8d392-8e4b-4d9b-8e92-d7d4e3650ef6"
+DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae"
+ExaModels = "1037b233-b668-4ce9-9b63-f9f681f55dd2"
+SolverCore = "ff4d7338-4cf1-434d-91df-b86cb86fb843"
+SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf"
+SparseConnectivityTracer = "9f842d2f-2579-4b1d-911e-f412cf18a3f5"
+
+[compat]
+ADNLPModels = "0.8"
+AMDGPU = "2"
+CTBase = "0.29, 0.30"
+CTModels = "0.18, 0.19"
+CTParser = "0.9"
+CTSolvers = "0.5"
+CUDA = "6"
+CUDSS = "0.8"
+CommonSolve = "0.2"
+DocStringExtensions = "0.9"
+ExaModels = "0.12"
+MadNLP = "0.10"
+MadNLPGPU = "0.10"
+NLPModels = "0.21"
+NLPModelsIpopt = "0.11"
+SolverCore = "0.3"
+SparseArrays = "1"
+SparseConnectivityTracer = "1.2.1"
+SplitApplyCombine = "1"
+Test = "1"
+julia = "1.10"
+
+[extras]
+AMDGPU = "21141c5a-9bdb-4563-92ae-f87d6854732e"
+CTParser = "32681960-a1b1-40db-9bff-a1ca817385d1"
+CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba"
+CUDSS = "45b445bb-4962-46a0-9369-b4df9d0f772e"
+CommonSolve = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2"
+MadNLP = "2621e9c9-9eb4-46b1-8089-e8c72242dfb6"
+MadNLPGPU = "d72a61cc-809d-412f-99be-fd81f4b8a598"
+NLPModels = "a4795742-8479-5a88-8948-cc11e1c8c1a6"
+NLPModelsIpopt = "f4238b75-b362-5c4c-b852-0801c9a21d71"
+SplitApplyCombine = "03a91e81-4c3e-53e1-a0a4-9c0c8f19dd66"
+Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+
+[targets]
+test = ["AMDGPU", "CTParser", "CUDA", "CUDSS", "CommonSolve", "MadNLP", "MadNLPGPU", "NLPModels", "NLPModelsIpopt", "SplitApplyCombine", "Test"]
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTDirect/docs/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTDirect/docs/Project.toml
new file mode 100644
index 0000000..50c9b08
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTDirect/docs/Project.toml
@@ -0,0 +1,13 @@
+[deps]
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
+DocumenterMermaid = "a078cd44-4d9c-4618-b545-3ab9d77f9177"
+Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a"
+MarkdownAST = "d0879d2d-cac2-40c8-9cee-1863dc0c7391"
+
+[compat]
+CTBase = "0.29, 0.30"
+Documenter = "1"
+DocumenterMermaid = "0.2"
+Markdown = "1"
+MarkdownAST = "0.1"
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTFlows/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTFlows/Project.toml
new file mode 100644
index 0000000..bd35945
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTFlows/Project.toml
@@ -0,0 +1,76 @@
+name = "CTFlows"
+uuid = "1c39547c-7794-42f7-af83-d98194f657c2"
+version = "0.18.1"
+authors = ["Olivier Cots "]
+
+[deps]
+ADTypes = "47edcb42-4c32-4615-8424-f2b9edc5f35b"
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+CTModels = "34c4fa32-2049-4079-8329-de33c2a22e2d"
+CTSolvers = "d3e8d392-8e4b-4d9b-8e92-d7d4e3650ef6"
+CommonSolve = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2"
+DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae"
+GPUArraysCore = "46192b85-c4d5-4398-a991-12ede77f4527"
+RecipesBase = "3cdcf5f2-1ef4-517c-9805-6587b60abb01"
+
+[weakdeps]
+Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a"
+Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
+SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462"
+StaticArrays = "90137ffa-7385-5640-81b9-e52037218182"
+
+[extensions]
+CTFlowsMakie = ["Makie"]
+CTFlowsPlots = ["Plots"]
+CTFlowsSciMLFlows = ["SciMLBase"]
+CTFlowsSciMLIntegrator = ["SciMLBase"]
+CTFlowsStaticArrays = ["StaticArrays"]
+
+[compat]
+ADTypes = "1"
+Aqua = "0.8"
+CTBase = "0.30.8"
+CTLie = "0.3"
+CTModels = "0.19"
+CTSolvers = "0.6"
+CUDA = "5, 6"
+CairoMakie = "0.15"
+CommonSolve = "0.2"
+DiffEqBase = "6, 7"
+DiffEqGPU = "3"
+DifferentiationInterface = "0.7"
+DocStringExtensions = "0.9"
+ForwardDiff = "0.10, 1"
+GPUArraysCore = "0.1, 0.2"
+Makie = "0.24"
+Mooncake = "0.5"
+NonlinearSolve = "4"
+OrdinaryDiffEqTsit5 = "2"
+Plots = "1"
+RecipesBase = "1"
+SciMLBase = "3"
+StaticArrays = "1"
+Test = "1"
+Zygote = "0.7"
+julia = "1.10"
+
+[extras]
+Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595"
+CTLie = "6880e05b-3a7d-4cac-887c-30cb52c5fdde"
+CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba"
+CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0"
+DiffEqBase = "2b5f629d-d688-5b77-993f-72d75c75574e"
+DiffEqGPU = "071ae1c0-96b5-11e9-1965-c90190d839ea"
+DifferentiationInterface = "a0c0ee7d-e4b9-4e03-894e-1c5f64a51d63"
+ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210"
+Mooncake = "da2b9cff-9c12-43a0-ae48-6db2b0edb7d6"
+NonlinearSolve = "8913a72c-1f9b-4ce2-8d82-65094dcecaec"
+OrdinaryDiffEqTsit5 = "b1df2697-797e-41e3-8120-5422d3b24e4a"
+Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
+SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462"
+StaticArrays = "90137ffa-7385-5640-81b9-e52037218182"
+Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+Zygote = "e88e6eb3-aa80-5325-afca-941959d7151f"
+
+[targets]
+test = ["Aqua", "CTLie", "CUDA", "CairoMakie", "DiffEqBase", "DiffEqGPU", "DifferentiationInterface", "ForwardDiff", "Mooncake", "NonlinearSolve", "OrdinaryDiffEqTsit5", "Plots", "SciMLBase", "StaticArrays", "Test", "Zygote"]
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTFlows/docs/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTFlows/docs/Project.toml
new file mode 100644
index 0000000..bf59804
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTFlows/docs/Project.toml
@@ -0,0 +1,38 @@
+[deps]
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+CTModels = "34c4fa32-2049-4079-8329-de33c2a22e2d"
+CTSolvers = "d3e8d392-8e4b-4d9b-8e92-d7d4e3650ef6"
+CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0"
+DiffEqBase = "2b5f629d-d688-5b77-993f-72d75c75574e"
+DifferentiationInterface = "a0c0ee7d-e4b9-4e03-894e-1c5f64a51d63"
+Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
+DocumenterInterLinks = "d12716ef-a0f6-4df4-a9f1-a5a34e75c656"
+DocumenterVitepress = "4710194d-e776-4893-9690-8d956a29c365"
+ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210"
+LiveServer = "16fef848-5104-11e9-1b77-fb7a48bbb589"
+MarkdownAST = "d0879d2d-cac2-40c8-9cee-1863dc0c7391"
+OrdinaryDiffEqRosenbrock = "43230ef6-c299-4910-a778-202eb28ce4ce"
+OrdinaryDiffEqTsit5 = "b1df2697-797e-41e3-8120-5422d3b24e4a"
+Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
+SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462"
+StaticArrays = "90137ffa-7385-5640-81b9-e52037218182"
+
+[compat]
+CTBase = "0.30"
+CTModels = "0.19"
+CTSolvers = "0.6"
+CairoMakie = "0.15"
+DiffEqBase = "7"
+DifferentiationInterface = "0.6, 0.7"
+Documenter = "1"
+DocumenterInterLinks = "1"
+DocumenterVitepress = "0.3"
+ForwardDiff = "0.10, 1"
+LiveServer = "1"
+MarkdownAST = "0.1"
+OrdinaryDiffEqRosenbrock = "2"
+OrdinaryDiffEqTsit5 = "2"
+Plots = "1"
+SciMLBase = "3"
+StaticArrays = "1"
+julia = "1.10"
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTLie/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTLie/Project.toml
new file mode 100644
index 0000000..c680bdc
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTLie/Project.toml
@@ -0,0 +1,38 @@
+name = "CTLie"
+uuid = "6880e05b-3a7d-4cac-887c-30cb52c5fdde"
+authors = ["Olivier Cots"]
+version = "0.3.0"
+
+[deps]
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae"
+MacroTools = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09"
+
+[compat]
+ADTypes = "1"
+Aqua = "0.8"
+BenchmarkTools = "1"
+CTBase = "0.30"
+CUDA = "5, 6"
+DifferentiationInterface = "0.7"
+DocStringExtensions = "0.9"
+ForwardDiff = "0.10, 1"
+JET = "0.9, 0.11, 0.12"
+MacroTools = "0.5"
+Mooncake = "0.5"
+Test = "1"
+julia = "1.10"
+
+[extras]
+ADTypes = "47edcb42-4c32-4615-8424-f2b9edc5f35b"
+Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595"
+BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf"
+CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba"
+DifferentiationInterface = "a0c0ee7d-e4b9-4e03-894e-1c5f64a51d63"
+ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210"
+JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b"
+Mooncake = "da2b9cff-9c12-43a0-ae48-6db2b0edb7d6"
+Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+
+[targets]
+test = ["ADTypes", "Aqua", "BenchmarkTools", "CUDA", "DifferentiationInterface", "ForwardDiff", "JET", "Mooncake", "Test"]
\ No newline at end of file
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTLie/docs/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTLie/docs/Project.toml
new file mode 100644
index 0000000..e1509c8
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTLie/docs/Project.toml
@@ -0,0 +1,22 @@
+[deps]
+ADTypes = "47edcb42-4c32-4615-8424-f2b9edc5f35b"
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+DifferentiationInterface = "a0c0ee7d-e4b9-4e03-894e-1c5f64a51d63"
+Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
+DocumenterInterLinks = "d12716ef-a0f6-4df4-a9f1-a5a34e75c656"
+DocumenterVitepress = "4710194d-e776-4893-9690-8d956a29c365"
+ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210"
+LiveServer = "16fef848-5104-11e9-1b77-fb7a48bbb589"
+MarkdownAST = "d0879d2d-cac2-40c8-9cee-1863dc0c7391"
+
+[compat]
+ADTypes = "1"
+CTBase = "0.30"
+DifferentiationInterface = "0.7"
+Documenter = "1"
+DocumenterInterLinks = "1"
+DocumenterVitepress = "0.3"
+ForwardDiff = "0.10, 1"
+LiveServer = "1"
+MarkdownAST = "0.1"
+julia = "1.10"
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTModels/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTModels/Project.toml
new file mode 100644
index 0000000..a26f0e9
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTModels/Project.toml
@@ -0,0 +1,61 @@
+name = "CTModels"
+uuid = "34c4fa32-2049-4079-8329-de33c2a22e2d"
+version = "0.19.8"
+authors = ["Olivier Cots "]
+
+[deps]
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae"
+LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
+MLStyle = "d8e11817-5142-5d16-987a-aa16d5891078"
+MacroTools = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09"
+OrderedCollections = "bac558e1-5e72-5ebc-8fee-abe8a469f55d"
+Parameters = "d96e819e-fc66-5662-9728-84c9c7592b0a"
+RecipesBase = "3cdcf5f2-1ef4-517c-9805-6587b60abb01"
+
+[weakdeps]
+JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819"
+JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1"
+Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a"
+Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
+
+[extensions]
+CTModelsJLD = "JLD2"
+CTModelsJSON = "JSON3"
+CTModelsMakie = "Makie"
+CTModelsPlots = "Plots"
+
+[compat]
+Aqua = "0.8"
+BenchmarkTools = "1"
+CTBase = "0.30"
+CairoMakie = "0.15"
+DocStringExtensions = "0.9"
+JET = "0.9, 0.11, 0.12"
+JLD2 = "0.6"
+JSON3 = "1"
+LinearAlgebra = "1"
+MLStyle = "0.4"
+Makie = "0.24"
+MacroTools = "0.5"
+OrderedCollections = "1, 2"
+Parameters = "0.13"
+Plots = "1"
+Random = "1"
+RecipesBase = "1"
+Test = "1"
+julia = "1.10"
+
+[extras]
+Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595"
+BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf"
+CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0"
+JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b"
+JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819"
+JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1"
+Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
+Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
+Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+
+[targets]
+test = ["Aqua", "BenchmarkTools", "CairoMakie", "JET", "JLD2", "JSON3", "Plots", "Random", "Test"]
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTModels/docs/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTModels/docs/Project.toml
new file mode 100644
index 0000000..6a4b442
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTModels/docs/Project.toml
@@ -0,0 +1,26 @@
+[deps]
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0"
+Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
+DocumenterInterLinks = "d12716ef-a0f6-4df4-a9f1-a5a34e75c656"
+DocumenterVitepress = "4710194d-e776-4893-9690-8d956a29c365"
+JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b"
+JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819"
+JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1"
+LiveServer = "16fef848-5104-11e9-1b77-fb7a48bbb589"
+MarkdownAST = "d0879d2d-cac2-40c8-9cee-1863dc0c7391"
+Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
+
+[compat]
+CTBase = "0.30"
+CairoMakie = "0.15"
+Documenter = "1"
+DocumenterInterLinks = "1"
+DocumenterVitepress = "0.3.5"
+JET = "0.9, 0.11, 0.12"
+JLD2 = "0.6"
+JSON3 = "1"
+LiveServer = "1"
+MarkdownAST = "0.1"
+Plots = "1"
+julia = "1.10"
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTParser/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTParser/Project.toml
new file mode 100644
index 0000000..8e8b5cc
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTParser/Project.toml
@@ -0,0 +1,21 @@
+name = "CTParser"
+uuid = "32681960-a1b1-40db-9bff-a1ca817385d1"
+version = "0.8.17-beta"
+authors = ["Jean-Baptiste Caillau "]
+
+[deps]
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae"
+MLStyle = "d8e11817-5142-5d16-987a-aa16d5891078"
+OrderedCollections = "bac558e1-5e72-5ebc-8fee-abe8a469f55d"
+Parameters = "d96e819e-fc66-5662-9728-84c9c7592b0a"
+Unicode = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5"
+
+[compat]
+CTBase = "0.18, 0.27, 0.28"
+DocStringExtensions = "0.9"
+MLStyle = "0.4"
+OrderedCollections = "1"
+Parameters = "0.12, 0.13"
+Unicode = "1"
+julia = "1.10"
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTParser/docs/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTParser/docs/Project.toml
new file mode 100644
index 0000000..631245d
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTParser/docs/Project.toml
@@ -0,0 +1,12 @@
+[deps]
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
+Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a"
+MarkdownAST = "d0879d2d-cac2-40c8-9cee-1863dc0c7391"
+
+[compat]
+CTBase = "0.18, 0.27, 0.28"
+Documenter = "1"
+Markdown = "1"
+MarkdownAST = "0.1"
+julia = "1.10"
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTParser/test/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTParser/test/Project.toml
new file mode 100644
index 0000000..6214d50
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTParser/test/Project.toml
@@ -0,0 +1,32 @@
+[deps]
+Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595"
+BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf"
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+CTModels = "34c4fa32-2049-4079-8329-de33c2a22e2d"
+CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba"
+ExaModels = "1037b233-b668-4ce9-9b63-f9f681f55dd2"
+Interpolations = "a98d9a8b-a2ab-59e6-89dd-64a1c18fca59"
+KernelAbstractions = "63c18a36-062a-441e-b654-da1e3ab1ce7c"
+LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
+MadNLP = "2621e9c9-9eb4-46b1-8089-e8c72242dfb6"
+MadNLPGPU = "d72a61cc-809d-412f-99be-fd81f4b8a598"
+NLPModels = "a4795742-8479-5a88-8948-cc11e1c8c1a6"
+OrderedCollections = "bac558e1-5e72-5ebc-8fee-abe8a469f55d"
+Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+
+[compat]
+Aqua = "0.8"
+BenchmarkTools = "1"
+CTBase = "0.18, 0.27, 0.28"
+CTModels = "0.10, 0.14, 0.15"
+CUDA = "5"
+ExaModels = "0.9"
+Interpolations = "0.16"
+KernelAbstractions = "0.9"
+LinearAlgebra = "1"
+MadNLP = "0.9"
+MadNLPGPU = "0.8"
+NLPModels = "0.21"
+OrderedCollections = "1.8"
+Test = "1.10"
+julia = "1.10"
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTSolvers/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTSolvers/Project.toml
new file mode 100644
index 0000000..9065356
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTSolvers/Project.toml
@@ -0,0 +1,107 @@
+name = "CTSolvers"
+uuid = "d3e8d392-8e4b-4d9b-8e92-d7d4e3650ef6"
+version = "0.6.0"
+authors = ["Olivier Cots "]
+
+[deps]
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+CTModels = "34c4fa32-2049-4079-8329-de33c2a22e2d"
+CommonSolve = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2"
+DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae"
+KernelAbstractions = "63c18a36-062a-441e-b654-da1e3ab1ce7c"
+SolverCore = "ff4d7338-4cf1-434d-91df-b86cb86fb843"
+
+[weakdeps]
+ADNLPModels = "54578032-b7ea-4c30-94aa-7cbd1cce6c9a"
+CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba"
+CUDSS = "45b445bb-4962-46a0-9369-b4df9d0f772e"
+DiffEqBase = "2b5f629d-d688-5b77-993f-72d75c75574e"
+Enzyme = "7da242da-08ed-463a-9acd-ee780be4f1d9"
+ExaModels = "1037b233-b668-4ce9-9b63-f9f681f55dd2"
+ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210"
+MadNCL = "434a0bcb-5a7c-42b2-a9d3-9e3f760e7af0"
+MadNLP = "2621e9c9-9eb4-46b1-8089-e8c72242dfb6"
+MadNLPGPU = "d72a61cc-809d-412f-99be-fd81f4b8a598"
+NLPModels = "a4795742-8479-5a88-8948-cc11e1c8c1a6"
+NLPModelsIpopt = "f4238b75-b362-5c4c-b852-0801c9a21d71"
+NLPModelsKnitro = "bec4dd0d-7755-52d5-9a02-22f0ffc7efcb"
+OrdinaryDiffEqTsit5 = "b1df2697-797e-41e3-8120-5422d3b24e4a"
+SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462"
+UnoSolver = "1baa60ac-02f7-4b39-a7a8-2f4f58486b05"
+Zygote = "e88e6eb3-aa80-5325-afca-941959d7151f"
+
+[extensions]
+CTSolversADNLPModels = "ADNLPModels"
+CTSolversCUDA = "CUDA"
+CTSolversEnzyme = "Enzyme"
+CTSolversExaModels = ["ExaModels"]
+CTSolversForwardDiff = "ForwardDiff"
+CTSolversIpopt = ["NLPModels", "NLPModelsIpopt"]
+CTSolversKnitro = ["NLPModels", "NLPModelsKnitro"]
+CTSolversMadNCL = ["MadNCL", "MadNLP", "NLPModels"]
+CTSolversMadNLP = ["MadNLP", "NLPModels"]
+CTSolversMadNLPGPU = ["MadNLPGPU", "CUDA", "CUDSS"]
+CTSolversOrdinaryDiffEqTsit5 = "OrdinaryDiffEqTsit5"
+CTSolversSciMLIntegrator = ["DiffEqBase", "SciMLBase"]
+CTSolversUno = ["NLPModels", "UnoSolver"]
+CTSolversZygote = "Zygote"
+
+[compat]
+ADNLPModels = "0.8"
+Aqua = "0.8"
+BenchmarkTools = "1"
+CTBase = "0.30"
+CTModels = "0.19"
+CUDA = "5, 6"
+CUDSS = "0.6, 0.7, 0.8"
+CommonSolve = "0.2"
+DiffEqBase = "6, 7"
+DocStringExtensions = "0.9, 0.10"
+Enzyme = "0.13"
+ExaModels = "0.12"
+ForwardDiff = "0.10, 1"
+JET = "0.9, 0.11, 0.12"
+KernelAbstractions = "0.9"
+MadNCL = "0.2"
+MadNLP = "0.9, 0.10"
+MadNLPGPU = "0.8, 0.10"
+NLPModels = "0.21"
+NLPModelsIpopt = "0.11"
+NLPModelsKnitro = "0.10"
+OrderedCollections = "1, 2"
+OrdinaryDiffEqTsit5 = "2"
+Random = "1"
+SciMLBase = "3"
+SolverCore = "0.3"
+Test = "1"
+UnoSolver = "0.3"
+Zygote = "0.7"
+julia = "1.10"
+
+[extras]
+ADNLPModels = "54578032-b7ea-4c30-94aa-7cbd1cce6c9a"
+Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595"
+BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf"
+CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba"
+CUDSS = "45b445bb-4962-46a0-9369-b4df9d0f772e"
+DiffEqBase = "2b5f629d-d688-5b77-993f-72d75c75574e"
+Enzyme = "7da242da-08ed-463a-9acd-ee780be4f1d9"
+ExaModels = "1037b233-b668-4ce9-9b63-f9f681f55dd2"
+ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210"
+JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b"
+MadNCL = "434a0bcb-5a7c-42b2-a9d3-9e3f760e7af0"
+MadNLP = "2621e9c9-9eb4-46b1-8089-e8c72242dfb6"
+MadNLPGPU = "d72a61cc-809d-412f-99be-fd81f4b8a598"
+NLPModels = "a4795742-8479-5a88-8948-cc11e1c8c1a6"
+NLPModelsIpopt = "f4238b75-b362-5c4c-b852-0801c9a21d71"
+NLPModelsKnitro = "bec4dd0d-7755-52d5-9a02-22f0ffc7efcb"
+OrderedCollections = "bac558e1-5e72-5ebc-8fee-abe8a469f55d"
+OrdinaryDiffEqTsit5 = "b1df2697-797e-41e3-8120-5422d3b24e4a"
+Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
+SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462"
+Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+UnoSolver = "1baa60ac-02f7-4b39-a7a8-2f4f58486b05"
+Zygote = "e88e6eb3-aa80-5325-afca-941959d7151f"
+
+[targets]
+test = ["ADNLPModels", "Aqua", "BenchmarkTools", "CUDA", "CUDSS", "DiffEqBase", "Enzyme", "ExaModels", "ForwardDiff", "JET", "MadNCL", "MadNLP", "MadNLPGPU", "NLPModels", "NLPModelsIpopt", "OrderedCollections", "OrdinaryDiffEqTsit5", "Random", "SciMLBase", "Test", "UnoSolver", "Zygote"]
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTSolvers/docs/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTSolvers/docs/Project.toml
new file mode 100644
index 0000000..48e650d
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/CTSolvers/docs/Project.toml
@@ -0,0 +1,59 @@
+[deps]
+ADNLPModels = "54578032-b7ea-4c30-94aa-7cbd1cce6c9a"
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba"
+CUDSS = "45b445bb-4962-46a0-9369-b4df9d0f772e"
+CommonSolve = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2"
+DiffEqBase = "2b5f629d-d688-5b77-993f-72d75c75574e"
+Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
+DocumenterInterLinks = "d12716ef-a0f6-4df4-a9f1-a5a34e75c656"
+DocumenterVitepress = "4710194d-e776-4893-9690-8d956a29c365"
+Enzyme = "7da242da-08ed-463a-9acd-ee780be4f1d9"
+ExaModels = "1037b233-b668-4ce9-9b63-f9f681f55dd2"
+ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210"
+JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b"
+LiveServer = "16fef848-5104-11e9-1b77-fb7a48bbb589"
+Logging = "56ddb016-857b-54e1-b83d-db4d58db5568"
+MadNCL = "434a0bcb-5a7c-42b2-a9d3-9e3f760e7af0"
+MadNLP = "2621e9c9-9eb4-46b1-8089-e8c72242dfb6"
+MadNLPGPU = "d72a61cc-809d-412f-99be-fd81f4b8a598"
+Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a"
+MarkdownAST = "d0879d2d-cac2-40c8-9cee-1863dc0c7391"
+NLPModels = "a4795742-8479-5a88-8948-cc11e1c8c1a6"
+NLPModelsIpopt = "f4238b75-b362-5c4c-b852-0801c9a21d71"
+NLPModelsKnitro = "bec4dd0d-7755-52d5-9a02-22f0ffc7efcb"
+OrdinaryDiffEqTsit5 = "b1df2697-797e-41e3-8120-5422d3b24e4a"
+OrdinaryDiffEqVerner = "79d7bb75-1356-48c1-b8c0-6832512096c2"
+SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462"
+Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+UnoSolver = "1baa60ac-02f7-4b39-a7a8-2f4f58486b05"
+Zygote = "e88e6eb3-aa80-5325-afca-941959d7151f"
+
+[compat]
+ADNLPModels = "0.8"
+CTBase = "0.30.10"
+CUDA = "5, 6"
+CUDSS = "0.6, 0.7, 0.8"
+CommonSolve = "0.2"
+DiffEqBase = "6, 7"
+Documenter = "1"
+DocumenterInterLinks = "1"
+DocumenterVitepress = "0.3.5"
+Enzyme = "0.13"
+ExaModels = "0.12"
+ForwardDiff = "0.10, 1"
+JET = "0.9, 0.11, 0.12"
+LiveServer = "1"
+MadNCL = "0.2"
+MadNLP = "0.9, 0.10"
+MadNLPGPU = "0.8, 0.10"
+MarkdownAST = "0.1"
+NLPModels = "0.21"
+NLPModelsIpopt = "0.11"
+NLPModelsKnitro = "0.10"
+OrdinaryDiffEqTsit5 = "2"
+OrdinaryDiffEqVerner = "2"
+SciMLBase = "3"
+UnoSolver = "0.3"
+Zygote = "0.7"
+julia = "1.10"
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/OptimalControl/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/OptimalControl/Project.toml
new file mode 100644
index 0000000..ad776d9
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/OptimalControl/Project.toml
@@ -0,0 +1,80 @@
+name = "OptimalControl"
+uuid = "5f98b655-cc9a-415a-b60e-744165666948"
+version = "2.2.1-beta"
+authors = ["Olivier Cots "]
+
+[deps]
+ADNLPModels = "54578032-b7ea-4c30-94aa-7cbd1cce6c9a"
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+CTDirect = "790bbbee-bee9-49ee-8912-a9de031322d5"
+CTFlows = "1c39547c-7794-42f7-af83-d98194f657c2"
+CTLie = "6880e05b-3a7d-4cac-887c-30cb52c5fdde"
+CTModels = "34c4fa32-2049-4079-8329-de33c2a22e2d"
+CTParser = "32681960-a1b1-40db-9bff-a1ca817385d1"
+CTSolvers = "d3e8d392-8e4b-4d9b-8e92-d7d4e3650ef6"
+CommonSolve = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2"
+DifferentiationInterface = "a0c0ee7d-e4b9-4e03-894e-1c5f64a51d63"
+DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae"
+ExaModels = "1037b233-b668-4ce9-9b63-f9f681f55dd2"
+ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210"
+LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
+RecipesBase = "3cdcf5f2-1ef4-517c-9805-6587b60abb01"
+Reexport = "189a3867-3050-52da-a836-e630ba90ab69"
+
+[compat]
+ADNLPModels = "0.8"
+BenchmarkTools = "1"
+CTBase = "0.30"
+CTDirect = "1"
+CTFlows = "0.18"
+CTLie = "0.2"
+CTModels = "0.19"
+CTParser = "0.9"
+CTSolvers = "0.5"
+CUDA = "6"
+CUDSS = "0.7"
+CommonSolve = "0.2"
+DiffEqBase = "7"
+DifferentiationInterface = "0.7"
+DocStringExtensions = "0.9"
+ExaModels = "0.12"
+ForwardDiff = "1"
+LinearAlgebra = "1"
+MadNCL = "0.2"
+MadNLP = "0.10"
+MadNLPGPU = "0.10"
+NLPModelsIpopt = "0.11"
+NonlinearSolve = "4"
+OrdinaryDiffEq = "7"
+OrdinaryDiffEqTsit5 = "2"
+Printf = "1"
+RecipesBase = "1"
+Reexport = "1"
+SciMLBase = "3"
+SolverCore = "0.3"
+SplitApplyCombine = "1"
+Test = "1"
+UnoSolver = "0.3"
+julia = "1.10"
+
+[extras]
+BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf"
+CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba"
+CUDSS = "45b445bb-4962-46a0-9369-b4df9d0f772e"
+DiffEqBase = "2b5f629d-d688-5b77-993f-72d75c75574e"
+MadNCL = "434a0bcb-5a7c-42b2-a9d3-9e3f760e7af0"
+MadNLP = "2621e9c9-9eb4-46b1-8089-e8c72242dfb6"
+MadNLPGPU = "d72a61cc-809d-412f-99be-fd81f4b8a598"
+NLPModelsIpopt = "f4238b75-b362-5c4c-b852-0801c9a21d71"
+NonlinearSolve = "8913a72c-1f9b-4ce2-8d82-65094dcecaec"
+OrdinaryDiffEq = "1dea7af3-3e70-54e6-95c3-0bf5283fa5ed"
+OrdinaryDiffEqTsit5 = "b1df2697-797e-41e3-8120-5422d3b24e4a"
+Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7"
+SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462"
+SolverCore = "ff4d7338-4cf1-434d-91df-b86cb86fb843"
+SplitApplyCombine = "03a91e81-4c3e-53e1-a0a4-9c0c8f19dd66"
+Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+UnoSolver = "1baa60ac-02f7-4b39-a7a8-2f4f58486b05"
+
+[targets]
+test = ["BenchmarkTools", "CUDA", "CUDSS", "DiffEqBase", "MadNCL", "MadNLP", "MadNLPGPU", "NLPModelsIpopt", "NonlinearSolve", "OrdinaryDiffEq", "OrdinaryDiffEqTsit5", "Printf", "SciMLBase", "SolverCore", "SplitApplyCombine", "Test", "UnoSolver"]
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/OptimalControl/docs/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/OptimalControl/docs/Project.toml
new file mode 100644
index 0000000..30d7aef
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/default/OptimalControl/docs/Project.toml
@@ -0,0 +1,73 @@
+[deps]
+ADNLPModels = "54578032-b7ea-4c30-94aa-7cbd1cce6c9a"
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+CTDirect = "790bbbee-bee9-49ee-8912-a9de031322d5"
+CTFlows = "1c39547c-7794-42f7-af83-d98194f657c2"
+CTLie = "6880e05b-3a7d-4cac-887c-30cb52c5fdde"
+CTModels = "34c4fa32-2049-4079-8329-de33c2a22e2d"
+CTParser = "32681960-a1b1-40db-9bff-a1ca817385d1"
+CTSolvers = "d3e8d392-8e4b-4d9b-8e92-d7d4e3650ef6"
+CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba"
+CUDSS = "45b445bb-4962-46a0-9369-b4df9d0f772e"
+CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0"
+Colors = "5ae59095-9a9b-59fe-a467-6f913c188581"
+CommonSolve = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2"
+DifferentiationInterface = "a0c0ee7d-e4b9-4e03-894e-1c5f64a51d63"
+Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
+DocumenterInterLinks = "d12716ef-a0f6-4df4-a9f1-a5a34e75c656"
+DocumenterVitepress = "4710194d-e776-4893-9690-8d956a29c365"
+ExaModels = "1037b233-b668-4ce9-9b63-f9f681f55dd2"
+ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210"
+JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819"
+JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1"
+Literate = "98b081ad-f1c9-55d3-8b20-4c87d4299306"
+LiveServer = "16fef848-5104-11e9-1b77-fb7a48bbb589"
+MadNCL = "434a0bcb-5a7c-42b2-a9d3-9e3f760e7af0"
+MadNLP = "2621e9c9-9eb4-46b1-8089-e8c72242dfb6"
+MadNLPGPU = "d72a61cc-809d-412f-99be-fd81f4b8a598"
+MarkdownAST = "d0879d2d-cac2-40c8-9cee-1863dc0c7391"
+NLPModelsIpopt = "f4238b75-b362-5c4c-b852-0801c9a21d71"
+NLPModelsKnitro = "bec4dd0d-7755-52d5-9a02-22f0ffc7efcb"
+NonlinearSolve = "8913a72c-1f9b-4ce2-8d82-65094dcecaec"
+OrdinaryDiffEq = "1dea7af3-3e70-54e6-95c3-0bf5283fa5ed"
+OrdinaryDiffEqTsit5 = "b1df2697-797e-41e3-8120-5422d3b24e4a"
+OrdinaryDiffEqVerner = "79d7bb75-1356-48c1-b8c0-6832512096c2"
+Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
+UnoSolver = "1baa60ac-02f7-4b39-a7a8-2f4f58486b05"
+
+[compat]
+ADNLPModels = "0.8"
+CTBase = "0.30"
+CTDirect = "1"
+CTFlows = "0.18"
+CTLie = "0.2"
+CTModels = "0.19"
+CTParser = "0.9"
+CTSolvers = "0.5"
+CUDA = "6"
+CUDSS = "0.7"
+CairoMakie = "0.15"
+Colors = "0.13"
+CommonSolve = "0.2"
+DifferentiationInterface = "0.7"
+Documenter = "1"
+DocumenterInterLinks = "1"
+DocumenterVitepress = "0.3"
+ExaModels = "0.12"
+ForwardDiff = "1"
+JLD2 = "0.6"
+JSON3 = "1"
+Literate = "2"
+LiveServer = "1"
+MadNCL = "0.2"
+MadNLP = "0.10"
+MadNLPGPU = "0.10"
+MarkdownAST = "0.1"
+NLPModelsIpopt = "0.11"
+NLPModelsKnitro = "0.10"
+NonlinearSolve = "4"
+OrdinaryDiffEq = "7"
+OrdinaryDiffEqTsit5 = "2"
+Plots = "1"
+UnoSolver = "0.3"
+julia = "1.10"
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/index.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/index.toml
new file mode 100644
index 0000000..fe2d3a8
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/index.toml
@@ -0,0 +1,73 @@
+julia = "1.13.1"
+
+[packages.CTBase]
+repo = "control-toolbox/CTBase.jl"
+version = "0.30.10"
+default_branch = "main"
+files = ["Project.toml", "docs/Project.toml"]
+tree = "cafe2bf5cbd697c5b239517aa8a699369ced443c"
+default_files = ["Project.toml", "docs/Project.toml"]
+uuid = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+
+[packages.CTModels]
+repo = "control-toolbox/CTModels.jl"
+version = "0.19.8"
+default_branch = "main"
+files = ["Project.toml", "docs/Project.toml"]
+tree = "25682b75dd2149f0abea4d4264a83e92c6171114"
+default_files = ["Project.toml", "docs/Project.toml"]
+uuid = "34c4fa32-2049-4079-8329-de33c2a22e2d"
+
+[packages.CTFlows]
+repo = "control-toolbox/CTFlows.jl"
+version = "0.18.1"
+default_branch = "main"
+files = ["Project.toml", "docs/Project.toml"]
+tree = "d82116ac4932b8494e7f6f3deb312dce44753969"
+default_files = ["Project.toml", "docs/Project.toml"]
+uuid = "1c39547c-7794-42f7-af83-d98194f657c2"
+
+[packages.CTDirect]
+repo = "control-toolbox/CTDirect.jl"
+version = "1.1.8-beta"
+default_branch = "main"
+files = ["Project.toml", "docs/Project.toml"]
+tree = "38f6fbb168b844b7ceac71678b42c96daa20bba5"
+default_files = ["Project.toml", "docs/Project.toml"]
+uuid = "790bbbee-bee9-49ee-8912-a9de031322d5"
+
+[packages.OptimalControl]
+repo = "control-toolbox/OptimalControl.jl"
+version = "2.2.1-beta"
+default_branch = "main"
+files = ["Project.toml", "docs/Project.toml"]
+tree = "07c2d96cde77525ad4c354547a34a068132f314d"
+default_files = ["Project.toml", "docs/Project.toml"]
+uuid = "5f98b655-cc9a-415a-b60e-744165666948"
+
+[packages.CTParser]
+repo = "control-toolbox/CTParser.jl"
+version = "0.9.5-beta"
+default_branch = "main"
+files = ["Project.toml", "test/Project.toml", "docs/Project.toml"]
+tree = "3f745c73919729ca654d02e1091fce5583facacc"
+default_files = ["Project.toml", "test/Project.toml", "docs/Project.toml"]
+uuid = "32681960-a1b1-40db-9bff-a1ca817385d1"
+
+[packages.CTSolvers]
+repo = "control-toolbox/CTSolvers.jl"
+version = "0.5.8"
+default_branch = "main"
+files = ["Project.toml", "docs/Project.toml"]
+tree = "50e6edf7726eb7d620323fb4cc3c2e87392022c3"
+default_files = ["Project.toml", "docs/Project.toml"]
+uuid = "d3e8d392-8e4b-4d9b-8e92-d7d4e3650ef6"
+
+[packages.CTLie]
+repo = "control-toolbox/CTLie.jl"
+version = "0.2.3"
+default_branch = "main"
+files = ["Project.toml", "docs/Project.toml"]
+tree = "049bbb24dfa8b5d54cff2736352d76ce9e7782ae"
+default_files = ["Project.toml", "docs/Project.toml"]
+uuid = "6880e05b-3a7d-4cac-887c-30cb52c5fdde"
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTBase/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTBase/Project.toml
new file mode 100644
index 0000000..aecdb99
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTBase/Project.toml
@@ -0,0 +1,64 @@
+name = "CTBase"
+uuid = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+version = "0.30.10"
+authors = ["Olivier Cots ", "Jean-Baptiste Caillau "]
+
+[deps]
+ADTypes = "47edcb42-4c32-4615-8424-f2b9edc5f35b"
+DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae"
+
+[weakdeps]
+Coverage = "a2441757-f6aa-5fb2-8edb-039e3f45d037"
+DifferentiationInterface = "a0c0ee7d-e4b9-4e03-894e-1c5f64a51d63"
+Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
+Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a"
+Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a"
+MarkdownAST = "d0879d2d-cac2-40c8-9cee-1863dc0c7391"
+Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
+Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+
+[extensions]
+CTBaseDifferentiationInterface = ["DifferentiationInterface"]
+CTBaseMakie = ["Makie"]
+CTBasePlots = ["Plots"]
+CoveragePostprocessing = ["Coverage"]
+DocumenterReference = ["Documenter", "Markdown", "MarkdownAST"]
+TestRunner = ["Test"]
+
+[compat]
+ADTypes = "1"
+Aqua = "0.8"
+BenchmarkTools = "1"
+CairoMakie = "0.15"
+Coverage = "1"
+DifferentiationInterface = "0.7"
+DocStringExtensions = "0.9, 0.10"
+Documenter = "1"
+ForwardDiff = "0.10, 1"
+JET = "0.9, 0.11, 0.12"
+Logging = "1"
+Makie = "0.24"
+Markdown = "1"
+MarkdownAST = "0.1"
+Plots = "1"
+Test = "1"
+julia = "1.10"
+
+[extras]
+ADTypes = "47edcb42-4c32-4615-8424-f2b9edc5f35b"
+Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595"
+BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf"
+CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0"
+Coverage = "a2441757-f6aa-5fb2-8edb-039e3f45d037"
+DifferentiationInterface = "a0c0ee7d-e4b9-4e03-894e-1c5f64a51d63"
+Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
+ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210"
+JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b"
+Logging = "56ddb016-857b-54e1-b83d-db4d58db5568"
+Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a"
+MarkdownAST = "d0879d2d-cac2-40c8-9cee-1863dc0c7391"
+Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
+Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+
+[targets]
+test = ["Aqua", "BenchmarkTools", "CairoMakie", "Coverage", "DifferentiationInterface", "Documenter", "ForwardDiff", "JET", "Logging", "Markdown", "MarkdownAST", "Plots", "Test"]
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTBase/docs/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTBase/docs/Project.toml
new file mode 100644
index 0000000..848cbe1
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTBase/docs/Project.toml
@@ -0,0 +1,28 @@
+[deps]
+ADTypes = "47edcb42-4c32-4615-8424-f2b9edc5f35b"
+CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0"
+Coverage = "a2441757-f6aa-5fb2-8edb-039e3f45d037"
+DifferentiationInterface = "a0c0ee7d-e4b9-4e03-894e-1c5f64a51d63"
+Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
+DocumenterInterLinks = "d12716ef-a0f6-4df4-a9f1-a5a34e75c656"
+DocumenterVitepress = "4710194d-e776-4893-9690-8d956a29c365"
+ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210"
+JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b"
+LiveServer = "16fef848-5104-11e9-1b77-fb7a48bbb589"
+MarkdownAST = "d0879d2d-cac2-40c8-9cee-1863dc0c7391"
+Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
+
+[compat]
+ADTypes = "1"
+CairoMakie = "0.15"
+Coverage = "1"
+DifferentiationInterface = "0.7"
+Documenter = "1"
+DocumenterInterLinks = "1"
+DocumenterVitepress = "0.3.5"
+ForwardDiff = "0.10, 1"
+JET = "0.9, 0.11, 0.12"
+LiveServer = "1"
+MarkdownAST = "0.1"
+Plots = "1"
+julia = "1.10"
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTDirect/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTDirect/Project.toml
new file mode 100644
index 0000000..2749473
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTDirect/Project.toml
@@ -0,0 +1,58 @@
+name = "CTDirect"
+uuid = "790bbbee-bee9-49ee-8912-a9de031322d5"
+version = "1.1.8-beta"
+authors = ["Pierre Martinon "]
+
+[workspace]
+projects = ["test", "docs"]
+
+[deps]
+ADNLPModels = "54578032-b7ea-4c30-94aa-7cbd1cce6c9a"
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+CTModels = "34c4fa32-2049-4079-8329-de33c2a22e2d"
+CTParser = "32681960-a1b1-40db-9bff-a1ca817385d1"
+CTSolvers = "d3e8d392-8e4b-4d9b-8e92-d7d4e3650ef6"
+DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae"
+ExaModels = "1037b233-b668-4ce9-9b63-f9f681f55dd2"
+SolverCore = "ff4d7338-4cf1-434d-91df-b86cb86fb843"
+SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf"
+SparseConnectivityTracer = "9f842d2f-2579-4b1d-911e-f412cf18a3f5"
+
+[compat]
+ADNLPModels = "0.8"
+AMDGPU = "2"
+CTBase = "0.29, 0.30"
+CTModels = "0.18, 0.19"
+CTParser = "0.9"
+CTSolvers = "0.5"
+CUDA = "6"
+CUDSS = "0.8"
+CommonSolve = "0.2"
+DocStringExtensions = "0.9"
+ExaModels = "0.12"
+MadNLP = "0.10"
+MadNLPGPU = "0.10"
+NLPModels = "0.21"
+NLPModelsIpopt = "0.11"
+SolverCore = "0.3"
+SparseArrays = "1"
+SparseConnectivityTracer = "1.2.1"
+SplitApplyCombine = "1"
+Test = "1"
+julia = "1.10"
+
+[extras]
+AMDGPU = "21141c5a-9bdb-4563-92ae-f87d6854732e"
+CTParser = "32681960-a1b1-40db-9bff-a1ca817385d1"
+CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba"
+CUDSS = "45b445bb-4962-46a0-9369-b4df9d0f772e"
+CommonSolve = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2"
+MadNLP = "2621e9c9-9eb4-46b1-8089-e8c72242dfb6"
+MadNLPGPU = "d72a61cc-809d-412f-99be-fd81f4b8a598"
+NLPModels = "a4795742-8479-5a88-8948-cc11e1c8c1a6"
+NLPModelsIpopt = "f4238b75-b362-5c4c-b852-0801c9a21d71"
+SplitApplyCombine = "03a91e81-4c3e-53e1-a0a4-9c0c8f19dd66"
+Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+
+[targets]
+test = ["AMDGPU", "CTParser", "CUDA", "CUDSS", "CommonSolve", "MadNLP", "MadNLPGPU", "NLPModels", "NLPModelsIpopt", "SplitApplyCombine", "Test"]
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTDirect/docs/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTDirect/docs/Project.toml
new file mode 100644
index 0000000..50c9b08
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTDirect/docs/Project.toml
@@ -0,0 +1,13 @@
+[deps]
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
+DocumenterMermaid = "a078cd44-4d9c-4618-b545-3ab9d77f9177"
+Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a"
+MarkdownAST = "d0879d2d-cac2-40c8-9cee-1863dc0c7391"
+
+[compat]
+CTBase = "0.29, 0.30"
+Documenter = "1"
+DocumenterMermaid = "0.2"
+Markdown = "1"
+MarkdownAST = "0.1"
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTFlows/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTFlows/Project.toml
new file mode 100644
index 0000000..b51e704
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTFlows/Project.toml
@@ -0,0 +1,76 @@
+name = "CTFlows"
+uuid = "1c39547c-7794-42f7-af83-d98194f657c2"
+version = "0.18.1"
+authors = ["Olivier Cots "]
+
+[deps]
+ADTypes = "47edcb42-4c32-4615-8424-f2b9edc5f35b"
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+CTModels = "34c4fa32-2049-4079-8329-de33c2a22e2d"
+CTSolvers = "d3e8d392-8e4b-4d9b-8e92-d7d4e3650ef6"
+CommonSolve = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2"
+DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae"
+GPUArraysCore = "46192b85-c4d5-4398-a991-12ede77f4527"
+RecipesBase = "3cdcf5f2-1ef4-517c-9805-6587b60abb01"
+
+[weakdeps]
+Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a"
+Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
+SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462"
+StaticArrays = "90137ffa-7385-5640-81b9-e52037218182"
+
+[extensions]
+CTFlowsMakie = ["Makie"]
+CTFlowsPlots = ["Plots"]
+CTFlowsSciMLFlows = ["SciMLBase"]
+CTFlowsSciMLIntegrator = ["SciMLBase"]
+CTFlowsStaticArrays = ["StaticArrays"]
+
+[compat]
+ADTypes = "1"
+Aqua = "0.8"
+CTBase = "0.30"
+CTLie = "0.2"
+CTModels = "0.19"
+CTSolvers = "0.5"
+CUDA = "5, 6"
+CairoMakie = "0.15"
+CommonSolve = "0.2"
+DiffEqBase = "6, 7"
+DiffEqGPU = "3"
+DifferentiationInterface = "0.7"
+DocStringExtensions = "0.9"
+ForwardDiff = "0.10, 1"
+GPUArraysCore = "0.1, 0.2"
+Makie = "0.24"
+Mooncake = "0.5"
+NonlinearSolve = "4"
+OrdinaryDiffEqTsit5 = "2"
+Plots = "1"
+RecipesBase = "1"
+SciMLBase = "3"
+StaticArrays = "1"
+Test = "1"
+Zygote = "0.7"
+julia = "1.10"
+
+[extras]
+Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595"
+CTLie = "6880e05b-3a7d-4cac-887c-30cb52c5fdde"
+CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba"
+CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0"
+DiffEqBase = "2b5f629d-d688-5b77-993f-72d75c75574e"
+DiffEqGPU = "071ae1c0-96b5-11e9-1965-c90190d839ea"
+DifferentiationInterface = "a0c0ee7d-e4b9-4e03-894e-1c5f64a51d63"
+ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210"
+Mooncake = "da2b9cff-9c12-43a0-ae48-6db2b0edb7d6"
+NonlinearSolve = "8913a72c-1f9b-4ce2-8d82-65094dcecaec"
+OrdinaryDiffEqTsit5 = "b1df2697-797e-41e3-8120-5422d3b24e4a"
+Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
+SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462"
+StaticArrays = "90137ffa-7385-5640-81b9-e52037218182"
+Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+Zygote = "e88e6eb3-aa80-5325-afca-941959d7151f"
+
+[targets]
+test = ["Aqua", "CTLie", "CUDA", "CairoMakie", "DiffEqBase", "DiffEqGPU", "DifferentiationInterface", "ForwardDiff", "Mooncake", "NonlinearSolve", "OrdinaryDiffEqTsit5", "Plots", "SciMLBase", "StaticArrays", "Test", "Zygote"]
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTFlows/docs/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTFlows/docs/Project.toml
new file mode 100644
index 0000000..f2767df
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTFlows/docs/Project.toml
@@ -0,0 +1,38 @@
+[deps]
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+CTModels = "34c4fa32-2049-4079-8329-de33c2a22e2d"
+CTSolvers = "d3e8d392-8e4b-4d9b-8e92-d7d4e3650ef6"
+CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0"
+DiffEqBase = "2b5f629d-d688-5b77-993f-72d75c75574e"
+DifferentiationInterface = "a0c0ee7d-e4b9-4e03-894e-1c5f64a51d63"
+Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
+DocumenterInterLinks = "d12716ef-a0f6-4df4-a9f1-a5a34e75c656"
+DocumenterVitepress = "4710194d-e776-4893-9690-8d956a29c365"
+ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210"
+LiveServer = "16fef848-5104-11e9-1b77-fb7a48bbb589"
+MarkdownAST = "d0879d2d-cac2-40c8-9cee-1863dc0c7391"
+OrdinaryDiffEqRosenbrock = "43230ef6-c299-4910-a778-202eb28ce4ce"
+OrdinaryDiffEqTsit5 = "b1df2697-797e-41e3-8120-5422d3b24e4a"
+Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
+SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462"
+StaticArrays = "90137ffa-7385-5640-81b9-e52037218182"
+
+[compat]
+CTBase = "0.30"
+CTModels = "0.19"
+CTSolvers = "0.5"
+CairoMakie = "0.15"
+DiffEqBase = "7"
+DifferentiationInterface = "0.6, 0.7"
+Documenter = "1"
+DocumenterInterLinks = "1"
+DocumenterVitepress = "0.3"
+ForwardDiff = "0.10, 1"
+LiveServer = "1"
+MarkdownAST = "0.1"
+OrdinaryDiffEqRosenbrock = "2"
+OrdinaryDiffEqTsit5 = "2"
+Plots = "1"
+SciMLBase = "3"
+StaticArrays = "1"
+julia = "1.10"
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTLie/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTLie/Project.toml
new file mode 100644
index 0000000..d2860cd
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTLie/Project.toml
@@ -0,0 +1,38 @@
+name = "CTLie"
+uuid = "6880e05b-3a7d-4cac-887c-30cb52c5fdde"
+authors = ["Olivier Cots"]
+version = "0.2.3"
+
+[deps]
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae"
+MacroTools = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09"
+
+[compat]
+ADTypes = "1"
+Aqua = "0.8"
+BenchmarkTools = "1"
+CTBase = "0.30"
+CUDA = "5, 6"
+DifferentiationInterface = "0.7"
+DocStringExtensions = "0.9"
+ForwardDiff = "0.10, 1"
+JET = "0.9, 0.11, 0.12"
+MacroTools = "0.5"
+Mooncake = "0.5"
+Test = "1"
+julia = "1.10"
+
+[extras]
+ADTypes = "47edcb42-4c32-4615-8424-f2b9edc5f35b"
+Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595"
+BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf"
+CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba"
+DifferentiationInterface = "a0c0ee7d-e4b9-4e03-894e-1c5f64a51d63"
+ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210"
+JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b"
+Mooncake = "da2b9cff-9c12-43a0-ae48-6db2b0edb7d6"
+Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+
+[targets]
+test = ["ADTypes", "Aqua", "BenchmarkTools", "CUDA", "DifferentiationInterface", "ForwardDiff", "JET", "Mooncake", "Test"]
\ No newline at end of file
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTLie/docs/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTLie/docs/Project.toml
new file mode 100644
index 0000000..e1509c8
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTLie/docs/Project.toml
@@ -0,0 +1,22 @@
+[deps]
+ADTypes = "47edcb42-4c32-4615-8424-f2b9edc5f35b"
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+DifferentiationInterface = "a0c0ee7d-e4b9-4e03-894e-1c5f64a51d63"
+Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
+DocumenterInterLinks = "d12716ef-a0f6-4df4-a9f1-a5a34e75c656"
+DocumenterVitepress = "4710194d-e776-4893-9690-8d956a29c365"
+ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210"
+LiveServer = "16fef848-5104-11e9-1b77-fb7a48bbb589"
+MarkdownAST = "d0879d2d-cac2-40c8-9cee-1863dc0c7391"
+
+[compat]
+ADTypes = "1"
+CTBase = "0.30"
+DifferentiationInterface = "0.7"
+Documenter = "1"
+DocumenterInterLinks = "1"
+DocumenterVitepress = "0.3"
+ForwardDiff = "0.10, 1"
+LiveServer = "1"
+MarkdownAST = "0.1"
+julia = "1.10"
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTModels/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTModels/Project.toml
new file mode 100644
index 0000000..a26f0e9
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTModels/Project.toml
@@ -0,0 +1,61 @@
+name = "CTModels"
+uuid = "34c4fa32-2049-4079-8329-de33c2a22e2d"
+version = "0.19.8"
+authors = ["Olivier Cots "]
+
+[deps]
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae"
+LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
+MLStyle = "d8e11817-5142-5d16-987a-aa16d5891078"
+MacroTools = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09"
+OrderedCollections = "bac558e1-5e72-5ebc-8fee-abe8a469f55d"
+Parameters = "d96e819e-fc66-5662-9728-84c9c7592b0a"
+RecipesBase = "3cdcf5f2-1ef4-517c-9805-6587b60abb01"
+
+[weakdeps]
+JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819"
+JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1"
+Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a"
+Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
+
+[extensions]
+CTModelsJLD = "JLD2"
+CTModelsJSON = "JSON3"
+CTModelsMakie = "Makie"
+CTModelsPlots = "Plots"
+
+[compat]
+Aqua = "0.8"
+BenchmarkTools = "1"
+CTBase = "0.30"
+CairoMakie = "0.15"
+DocStringExtensions = "0.9"
+JET = "0.9, 0.11, 0.12"
+JLD2 = "0.6"
+JSON3 = "1"
+LinearAlgebra = "1"
+MLStyle = "0.4"
+Makie = "0.24"
+MacroTools = "0.5"
+OrderedCollections = "1, 2"
+Parameters = "0.13"
+Plots = "1"
+Random = "1"
+RecipesBase = "1"
+Test = "1"
+julia = "1.10"
+
+[extras]
+Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595"
+BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf"
+CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0"
+JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b"
+JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819"
+JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1"
+Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
+Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
+Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+
+[targets]
+test = ["Aqua", "BenchmarkTools", "CairoMakie", "JET", "JLD2", "JSON3", "Plots", "Random", "Test"]
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTModels/docs/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTModels/docs/Project.toml
new file mode 100644
index 0000000..6a4b442
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTModels/docs/Project.toml
@@ -0,0 +1,26 @@
+[deps]
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0"
+Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
+DocumenterInterLinks = "d12716ef-a0f6-4df4-a9f1-a5a34e75c656"
+DocumenterVitepress = "4710194d-e776-4893-9690-8d956a29c365"
+JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b"
+JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819"
+JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1"
+LiveServer = "16fef848-5104-11e9-1b77-fb7a48bbb589"
+MarkdownAST = "d0879d2d-cac2-40c8-9cee-1863dc0c7391"
+Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
+
+[compat]
+CTBase = "0.30"
+CairoMakie = "0.15"
+Documenter = "1"
+DocumenterInterLinks = "1"
+DocumenterVitepress = "0.3.5"
+JET = "0.9, 0.11, 0.12"
+JLD2 = "0.6"
+JSON3 = "1"
+LiveServer = "1"
+MarkdownAST = "0.1"
+Plots = "1"
+julia = "1.10"
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTParser/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTParser/Project.toml
new file mode 100644
index 0000000..56fa680
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTParser/Project.toml
@@ -0,0 +1,30 @@
+name = "CTParser"
+uuid = "32681960-a1b1-40db-9bff-a1ca817385d1"
+version = "0.9.5-beta"
+authors = ["Jean-Baptiste Caillau "]
+
+[deps]
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae"
+MLStyle = "d8e11817-5142-5d16-987a-aa16d5891078"
+OrderedCollections = "bac558e1-5e72-5ebc-8fee-abe8a469f55d"
+Parameters = "d96e819e-fc66-5662-9728-84c9c7592b0a"
+Unicode = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5"
+
+[weakdeps]
+ExaModels = "1037b233-b668-4ce9-9b63-f9f681f55dd2"
+LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
+
+[extensions]
+CTParserExaModels = ["ExaModels", "LinearAlgebra"]
+
+[compat]
+CTBase = "0.29, 0.30"
+DocStringExtensions = "0.9"
+ExaModels = "0.12"
+LinearAlgebra = "1"
+MLStyle = "0.4"
+OrderedCollections = "1, 2"
+Parameters = "0.13"
+Unicode = "1"
+julia = "1.10"
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTParser/docs/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTParser/docs/Project.toml
new file mode 100644
index 0000000..6b1c7b4
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTParser/docs/Project.toml
@@ -0,0 +1,12 @@
+[deps]
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
+Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a"
+MarkdownAST = "d0879d2d-cac2-40c8-9cee-1863dc0c7391"
+
+[compat]
+CTBase = "0.29, 0.30"
+Documenter = "1"
+Markdown = "1"
+MarkdownAST = "0.1"
+julia = "1.10"
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTParser/test/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTParser/test/Project.toml
new file mode 100644
index 0000000..8d16399
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTParser/test/Project.toml
@@ -0,0 +1,34 @@
+[deps]
+Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595"
+BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf"
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+CTModels = "34c4fa32-2049-4079-8329-de33c2a22e2d"
+CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba"
+CUDSS = "45b445bb-4962-46a0-9369-b4df9d0f772e"
+ExaModels = "1037b233-b668-4ce9-9b63-f9f681f55dd2"
+Interpolations = "a98d9a8b-a2ab-59e6-89dd-64a1c18fca59"
+KernelAbstractions = "63c18a36-062a-441e-b654-da1e3ab1ce7c"
+LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
+MadNLP = "2621e9c9-9eb4-46b1-8089-e8c72242dfb6"
+MadNLPGPU = "d72a61cc-809d-412f-99be-fd81f4b8a598"
+NLPModels = "a4795742-8479-5a88-8948-cc11e1c8c1a6"
+OrderedCollections = "bac558e1-5e72-5ebc-8fee-abe8a469f55d"
+Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+
+[compat]
+Aqua = "0.8"
+BenchmarkTools = "1"
+CTBase = "0.29, 0.30"
+CTModels = "0.18"
+CUDA = "5, 6"
+CUDSS = "0.6, 0.7, 0.8"
+ExaModels = "0.12"
+Interpolations = "0.16"
+KernelAbstractions = "0.9"
+LinearAlgebra = "1"
+MadNLP = "0.9, 0.10"
+MadNLPGPU = "0.8, 0.10"
+NLPModels = "0.21"
+OrderedCollections = "1.8, 2"
+Test = "1.10"
+julia = "1.10"
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTSolvers/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTSolvers/Project.toml
new file mode 100644
index 0000000..e18b255
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTSolvers/Project.toml
@@ -0,0 +1,107 @@
+name = "CTSolvers"
+uuid = "d3e8d392-8e4b-4d9b-8e92-d7d4e3650ef6"
+version = "0.5.8"
+authors = ["Olivier Cots "]
+
+[deps]
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+CTModels = "34c4fa32-2049-4079-8329-de33c2a22e2d"
+CommonSolve = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2"
+DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae"
+KernelAbstractions = "63c18a36-062a-441e-b654-da1e3ab1ce7c"
+SolverCore = "ff4d7338-4cf1-434d-91df-b86cb86fb843"
+
+[weakdeps]
+ADNLPModels = "54578032-b7ea-4c30-94aa-7cbd1cce6c9a"
+CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba"
+CUDSS = "45b445bb-4962-46a0-9369-b4df9d0f772e"
+DiffEqBase = "2b5f629d-d688-5b77-993f-72d75c75574e"
+Enzyme = "7da242da-08ed-463a-9acd-ee780be4f1d9"
+ExaModels = "1037b233-b668-4ce9-9b63-f9f681f55dd2"
+ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210"
+MadNCL = "434a0bcb-5a7c-42b2-a9d3-9e3f760e7af0"
+MadNLP = "2621e9c9-9eb4-46b1-8089-e8c72242dfb6"
+MadNLPGPU = "d72a61cc-809d-412f-99be-fd81f4b8a598"
+NLPModels = "a4795742-8479-5a88-8948-cc11e1c8c1a6"
+NLPModelsIpopt = "f4238b75-b362-5c4c-b852-0801c9a21d71"
+NLPModelsKnitro = "bec4dd0d-7755-52d5-9a02-22f0ffc7efcb"
+OrdinaryDiffEqTsit5 = "b1df2697-797e-41e3-8120-5422d3b24e4a"
+SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462"
+UnoSolver = "1baa60ac-02f7-4b39-a7a8-2f4f58486b05"
+Zygote = "e88e6eb3-aa80-5325-afca-941959d7151f"
+
+[extensions]
+CTSolversADNLPModels = "ADNLPModels"
+CTSolversCUDA = "CUDA"
+CTSolversEnzyme = "Enzyme"
+CTSolversExaModels = ["ExaModels"]
+CTSolversForwardDiff = "ForwardDiff"
+CTSolversIpopt = ["NLPModels", "NLPModelsIpopt"]
+CTSolversKnitro = ["NLPModels", "NLPModelsKnitro"]
+CTSolversMadNCL = ["MadNCL", "MadNLP", "NLPModels"]
+CTSolversMadNLP = ["MadNLP", "NLPModels"]
+CTSolversMadNLPGPU = ["MadNLPGPU", "CUDA", "CUDSS"]
+CTSolversOrdinaryDiffEqTsit5 = "OrdinaryDiffEqTsit5"
+CTSolversSciMLIntegrator = ["DiffEqBase", "SciMLBase"]
+CTSolversUno = ["NLPModels", "UnoSolver"]
+CTSolversZygote = "Zygote"
+
+[compat]
+ADNLPModels = "0.8"
+Aqua = "0.8"
+BenchmarkTools = "1"
+CTBase = "0.30"
+CTModels = "0.19"
+CUDA = "5, 6"
+CUDSS = "0.6, 0.7, 0.8"
+CommonSolve = "0.2"
+DiffEqBase = "6, 7"
+DocStringExtensions = "0.9"
+Enzyme = "0.13"
+ExaModels = "0.12"
+ForwardDiff = "0.10, 1"
+JET = "0.9, 0.11, 0.12"
+KernelAbstractions = "0.9"
+MadNCL = "0.2"
+MadNLP = "0.9, 0.10"
+MadNLPGPU = "0.8, 0.10"
+NLPModels = "0.21"
+NLPModelsIpopt = "0.11"
+NLPModelsKnitro = "0.10"
+OrderedCollections = "1, 2"
+OrdinaryDiffEqTsit5 = "2"
+Random = "1"
+SciMLBase = "3"
+SolverCore = "0.3"
+Test = "1"
+UnoSolver = "0.3"
+Zygote = "0.7"
+julia = "1.10"
+
+[extras]
+ADNLPModels = "54578032-b7ea-4c30-94aa-7cbd1cce6c9a"
+Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595"
+BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf"
+CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba"
+CUDSS = "45b445bb-4962-46a0-9369-b4df9d0f772e"
+DiffEqBase = "2b5f629d-d688-5b77-993f-72d75c75574e"
+Enzyme = "7da242da-08ed-463a-9acd-ee780be4f1d9"
+ExaModels = "1037b233-b668-4ce9-9b63-f9f681f55dd2"
+ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210"
+JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b"
+MadNCL = "434a0bcb-5a7c-42b2-a9d3-9e3f760e7af0"
+MadNLP = "2621e9c9-9eb4-46b1-8089-e8c72242dfb6"
+MadNLPGPU = "d72a61cc-809d-412f-99be-fd81f4b8a598"
+NLPModels = "a4795742-8479-5a88-8948-cc11e1c8c1a6"
+NLPModelsIpopt = "f4238b75-b362-5c4c-b852-0801c9a21d71"
+NLPModelsKnitro = "bec4dd0d-7755-52d5-9a02-22f0ffc7efcb"
+OrderedCollections = "bac558e1-5e72-5ebc-8fee-abe8a469f55d"
+OrdinaryDiffEqTsit5 = "b1df2697-797e-41e3-8120-5422d3b24e4a"
+Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
+SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462"
+Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+UnoSolver = "1baa60ac-02f7-4b39-a7a8-2f4f58486b05"
+Zygote = "e88e6eb3-aa80-5325-afca-941959d7151f"
+
+[targets]
+test = ["ADNLPModels", "Aqua", "BenchmarkTools", "CUDA", "CUDSS", "DiffEqBase", "Enzyme", "ExaModels", "ForwardDiff", "JET", "MadNCL", "MadNLP", "MadNLPGPU", "NLPModels", "NLPModelsIpopt", "OrderedCollections", "OrdinaryDiffEqTsit5", "Random", "SciMLBase", "Test", "UnoSolver", "Zygote"]
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTSolvers/docs/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTSolvers/docs/Project.toml
new file mode 100644
index 0000000..48ed89a
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/CTSolvers/docs/Project.toml
@@ -0,0 +1,56 @@
+[deps]
+ADNLPModels = "54578032-b7ea-4c30-94aa-7cbd1cce6c9a"
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba"
+CUDSS = "45b445bb-4962-46a0-9369-b4df9d0f772e"
+CommonSolve = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2"
+DiffEqBase = "2b5f629d-d688-5b77-993f-72d75c75574e"
+Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
+DocumenterInterLinks = "d12716ef-a0f6-4df4-a9f1-a5a34e75c656"
+DocumenterVitepress = "4710194d-e776-4893-9690-8d956a29c365"
+Enzyme = "7da242da-08ed-463a-9acd-ee780be4f1d9"
+ExaModels = "1037b233-b668-4ce9-9b63-f9f681f55dd2"
+ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210"
+JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b"
+LiveServer = "16fef848-5104-11e9-1b77-fb7a48bbb589"
+MadNCL = "434a0bcb-5a7c-42b2-a9d3-9e3f760e7af0"
+MadNLP = "2621e9c9-9eb4-46b1-8089-e8c72242dfb6"
+MadNLPGPU = "d72a61cc-809d-412f-99be-fd81f4b8a598"
+MarkdownAST = "d0879d2d-cac2-40c8-9cee-1863dc0c7391"
+NLPModels = "a4795742-8479-5a88-8948-cc11e1c8c1a6"
+NLPModelsIpopt = "f4238b75-b362-5c4c-b852-0801c9a21d71"
+NLPModelsKnitro = "bec4dd0d-7755-52d5-9a02-22f0ffc7efcb"
+OrdinaryDiffEqTsit5 = "b1df2697-797e-41e3-8120-5422d3b24e4a"
+OrdinaryDiffEqVerner = "79d7bb75-1356-48c1-b8c0-6832512096c2"
+SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462"
+UnoSolver = "1baa60ac-02f7-4b39-a7a8-2f4f58486b05"
+Zygote = "e88e6eb3-aa80-5325-afca-941959d7151f"
+
+[compat]
+ADNLPModels = "0.8"
+CTBase = "0.30"
+CUDA = "5, 6"
+CUDSS = "0.6, 0.7, 0.8"
+CommonSolve = "0.2"
+DiffEqBase = "6, 7"
+Documenter = "1"
+DocumenterInterLinks = "1"
+DocumenterVitepress = "0.3"
+Enzyme = "0.13"
+ExaModels = "0.12"
+ForwardDiff = "0.10, 1"
+JET = "0.9, 0.11, 0.12"
+LiveServer = "1"
+MadNCL = "0.2"
+MadNLP = "0.9, 0.10"
+MadNLPGPU = "0.8, 0.10"
+MarkdownAST = "0.1"
+NLPModels = "0.21"
+NLPModelsIpopt = "0.11"
+NLPModelsKnitro = "0.10"
+OrdinaryDiffEqTsit5 = "2"
+OrdinaryDiffEqVerner = "2"
+SciMLBase = "3"
+UnoSolver = "0.3"
+Zygote = "0.7"
+julia = "1.10"
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/OptimalControl/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/OptimalControl/Project.toml
new file mode 100644
index 0000000..ad776d9
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/OptimalControl/Project.toml
@@ -0,0 +1,80 @@
+name = "OptimalControl"
+uuid = "5f98b655-cc9a-415a-b60e-744165666948"
+version = "2.2.1-beta"
+authors = ["Olivier Cots "]
+
+[deps]
+ADNLPModels = "54578032-b7ea-4c30-94aa-7cbd1cce6c9a"
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+CTDirect = "790bbbee-bee9-49ee-8912-a9de031322d5"
+CTFlows = "1c39547c-7794-42f7-af83-d98194f657c2"
+CTLie = "6880e05b-3a7d-4cac-887c-30cb52c5fdde"
+CTModels = "34c4fa32-2049-4079-8329-de33c2a22e2d"
+CTParser = "32681960-a1b1-40db-9bff-a1ca817385d1"
+CTSolvers = "d3e8d392-8e4b-4d9b-8e92-d7d4e3650ef6"
+CommonSolve = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2"
+DifferentiationInterface = "a0c0ee7d-e4b9-4e03-894e-1c5f64a51d63"
+DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae"
+ExaModels = "1037b233-b668-4ce9-9b63-f9f681f55dd2"
+ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210"
+LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
+RecipesBase = "3cdcf5f2-1ef4-517c-9805-6587b60abb01"
+Reexport = "189a3867-3050-52da-a836-e630ba90ab69"
+
+[compat]
+ADNLPModels = "0.8"
+BenchmarkTools = "1"
+CTBase = "0.30"
+CTDirect = "1"
+CTFlows = "0.18"
+CTLie = "0.2"
+CTModels = "0.19"
+CTParser = "0.9"
+CTSolvers = "0.5"
+CUDA = "6"
+CUDSS = "0.7"
+CommonSolve = "0.2"
+DiffEqBase = "7"
+DifferentiationInterface = "0.7"
+DocStringExtensions = "0.9"
+ExaModels = "0.12"
+ForwardDiff = "1"
+LinearAlgebra = "1"
+MadNCL = "0.2"
+MadNLP = "0.10"
+MadNLPGPU = "0.10"
+NLPModelsIpopt = "0.11"
+NonlinearSolve = "4"
+OrdinaryDiffEq = "7"
+OrdinaryDiffEqTsit5 = "2"
+Printf = "1"
+RecipesBase = "1"
+Reexport = "1"
+SciMLBase = "3"
+SolverCore = "0.3"
+SplitApplyCombine = "1"
+Test = "1"
+UnoSolver = "0.3"
+julia = "1.10"
+
+[extras]
+BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf"
+CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba"
+CUDSS = "45b445bb-4962-46a0-9369-b4df9d0f772e"
+DiffEqBase = "2b5f629d-d688-5b77-993f-72d75c75574e"
+MadNCL = "434a0bcb-5a7c-42b2-a9d3-9e3f760e7af0"
+MadNLP = "2621e9c9-9eb4-46b1-8089-e8c72242dfb6"
+MadNLPGPU = "d72a61cc-809d-412f-99be-fd81f4b8a598"
+NLPModelsIpopt = "f4238b75-b362-5c4c-b852-0801c9a21d71"
+NonlinearSolve = "8913a72c-1f9b-4ce2-8d82-65094dcecaec"
+OrdinaryDiffEq = "1dea7af3-3e70-54e6-95c3-0bf5283fa5ed"
+OrdinaryDiffEqTsit5 = "b1df2697-797e-41e3-8120-5422d3b24e4a"
+Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7"
+SciMLBase = "0bca4576-84f4-4d90-8ffe-ffa030f20462"
+SolverCore = "ff4d7338-4cf1-434d-91df-b86cb86fb843"
+SplitApplyCombine = "03a91e81-4c3e-53e1-a0a4-9c0c8f19dd66"
+Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+UnoSolver = "1baa60ac-02f7-4b39-a7a8-2f4f58486b05"
+
+[targets]
+test = ["BenchmarkTools", "CUDA", "CUDSS", "DiffEqBase", "MadNCL", "MadNLP", "MadNLPGPU", "NLPModelsIpopt", "NonlinearSolve", "OrdinaryDiffEq", "OrdinaryDiffEqTsit5", "Printf", "SciMLBase", "SolverCore", "SplitApplyCombine", "Test", "UnoSolver"]
diff --git a/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/OptimalControl/docs/Project.toml b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/OptimalControl/docs/Project.toml
new file mode 100644
index 0000000..0a0a850
--- /dev/null
+++ b/scripts/compat-prune/test/fixtures/snapshot-2026-10-15/registered/OptimalControl/docs/Project.toml
@@ -0,0 +1,72 @@
+[deps]
+ADNLPModels = "54578032-b7ea-4c30-94aa-7cbd1cce6c9a"
+CTBase = "54762871-cc72-4466-b8e8-f6c8b58076cd"
+CTDirect = "790bbbee-bee9-49ee-8912-a9de031322d5"
+CTFlows = "1c39547c-7794-42f7-af83-d98194f657c2"
+CTLie = "6880e05b-3a7d-4cac-887c-30cb52c5fdde"
+CTModels = "34c4fa32-2049-4079-8329-de33c2a22e2d"
+CTParser = "32681960-a1b1-40db-9bff-a1ca817385d1"
+CTSolvers = "d3e8d392-8e4b-4d9b-8e92-d7d4e3650ef6"
+CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba"
+CUDSS = "45b445bb-4962-46a0-9369-b4df9d0f772e"
+CairoMakie = "13f3f980-e62b-5c42-98c6-ff1f3baf88f0"
+Colors = "5ae59095-9a9b-59fe-a467-6f913c188581"
+CommonSolve = "38540f10-b2f7-11e9-35d8-d573e4eb0ff2"
+DifferentiationInterface = "a0c0ee7d-e4b9-4e03-894e-1c5f64a51d63"
+Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
+DocumenterInterLinks = "d12716ef-a0f6-4df4-a9f1-a5a34e75c656"
+DocumenterVitepress = "4710194d-e776-4893-9690-8d956a29c365"
+ExaModels = "1037b233-b668-4ce9-9b63-f9f681f55dd2"
+ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210"
+JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819"
+JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1"
+Literate = "98b081ad-f1c9-55d3-8b20-4c87d4299306"
+LiveServer = "16fef848-5104-11e9-1b77-fb7a48bbb589"
+MadNCL = "434a0bcb-5a7c-42b2-a9d3-9e3f760e7af0"
+MadNLP = "2621e9c9-9eb4-46b1-8089-e8c72242dfb6"
+MadNLPGPU = "d72a61cc-809d-412f-99be-fd81f4b8a598"
+MarkdownAST = "d0879d2d-cac2-40c8-9cee-1863dc0c7391"
+NLPModelsIpopt = "f4238b75-b362-5c4c-b852-0801c9a21d71"
+NLPModelsKnitro = "bec4dd0d-7755-52d5-9a02-22f0ffc7efcb"
+NonlinearSolve = "8913a72c-1f9b-4ce2-8d82-65094dcecaec"
+OrdinaryDiffEq = "1dea7af3-3e70-54e6-95c3-0bf5283fa5ed"
+OrdinaryDiffEqTsit5 = "b1df2697-797e-41e3-8120-5422d3b24e4a"
+Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80"
+UnoSolver = "1baa60ac-02f7-4b39-a7a8-2f4f58486b05"
+
+[compat]
+ADNLPModels = "0.8"
+CTBase = "0.30"
+CTDirect = "1"
+CTFlows = "0.18"
+CTLie = "0.2"
+CTModels = "0.19"
+CTParser = "0.9"
+CTSolvers = "0.5"
+CUDA = "6"
+CUDSS = "0.7"
+CairoMakie = "0.15"
+Colors = "0.13"
+CommonSolve = "0.2"
+DifferentiationInterface = "0.7"
+Documenter = "1"
+DocumenterInterLinks = "1"
+DocumenterVitepress = "0.3"
+ExaModels = "0.12"
+ForwardDiff = "1"
+JLD2 = "0.6"
+JSON3 = "1"
+Literate = "2"
+LiveServer = "1"
+MadNCL = "0.2"
+MadNLP = "0.10"
+MadNLPGPU = "0.10"
+MarkdownAST = "0.1"
+NLPModelsIpopt = "0.11"
+NLPModelsKnitro = "0.10"
+NonlinearSolve = "4"
+OrdinaryDiffEq = "7"
+OrdinaryDiffEqTsit5 = "2"
+Plots = "1"
+UnoSolver = "0.3"
+julia = "1.10"
diff --git a/scripts/compat-prune/test/golden/classified-2026-10-15.txt b/scripts/compat-prune/test/golden/classified-2026-10-15.txt
new file mode 100644
index 0000000..ea1d6f8
--- /dev/null
+++ b/scripts/compat-prune/test/golden/classified-2026-10-15.txt
@@ -0,0 +1,45 @@
+CTBase | Project.toml | DocStringExtensions | blocked | newest 0.10
+CTBase | Project.toml | ForwardDiff | prune | 1
+CTBase | Project.toml | JET | prune | 0.9, 0.12
+CTBase | docs/Project.toml | ForwardDiff | prune | 1
+CTBase | docs/Project.toml | JET | prune | 0.9, 0.12
+CTDirect | Project.toml | CTBase | prune | 0.30
+CTDirect | Project.toml | CTModels | prune | 0.19
+CTDirect | docs/Project.toml | CTBase | prune | 0.30
+CTFlows | Project.toml | CUDA | prune | 6
+CTFlows | Project.toml | DiffEqBase | prune | 7
+CTFlows | Project.toml | ForwardDiff | prune | 1
+CTFlows | Project.toml | GPUArraysCore | prune | 0.2
+CTFlows | docs/Project.toml | DifferentiationInterface | prune | 0.7
+CTFlows | docs/Project.toml | ForwardDiff | prune | 1
+CTLie | Project.toml | CUDA | prune | 6
+CTLie | Project.toml | ForwardDiff | prune | 1
+CTLie | Project.toml | JET | prune | 0.9, 0.12
+CTLie | docs/Project.toml | ForwardDiff | prune | 1
+CTModels | Project.toml | JET | prune | 0.9, 0.12
+CTModels | Project.toml | OrderedCollections | prune | 2
+CTModels | docs/Project.toml | JET | prune | 0.9, 0.12
+CTParser | Project.toml | CTBase | keep |
+CTParser | Project.toml | OrderedCollections | prune | 2
+CTParser | docs/Project.toml | CTBase | prune | 0.30
+CTParser | test/Project.toml | CTBase | blocked | newest 0.30
+CTParser | test/Project.toml | CUDA | prune | 6
+CTParser | test/Project.toml | CUDSS | prune | 0.8
+CTParser | test/Project.toml | MadNLP | prune | 0.10
+CTParser | test/Project.toml | MadNLPGPU | prune | 0.10
+CTParser | test/Project.toml | OrderedCollections | prune | 2
+CTSolvers | Project.toml | CUDA | prune | 6
+CTSolvers | Project.toml | CUDSS | prune | 0.7, 0.8
+CTSolvers | Project.toml | DiffEqBase | prune | 7
+CTSolvers | Project.toml | ForwardDiff | prune | 1
+CTSolvers | Project.toml | JET | prune | 0.9, 0.12
+CTSolvers | Project.toml | MadNLP | prune | 0.10
+CTSolvers | Project.toml | MadNLPGPU | prune | 0.10
+CTSolvers | Project.toml | OrderedCollections | prune | 2
+CTSolvers | docs/Project.toml | CUDA | prune | 6
+CTSolvers | docs/Project.toml | CUDSS | prune | 0.8
+CTSolvers | docs/Project.toml | DiffEqBase | prune | 7
+CTSolvers | docs/Project.toml | ForwardDiff | prune | 1
+CTSolvers | docs/Project.toml | JET | prune | 0.9, 0.12
+CTSolvers | docs/Project.toml | MadNLP | prune | 0.10
+CTSolvers | docs/Project.toml | MadNLPGPU | prune | 0.10
diff --git a/scripts/compat-prune/test/golden/edits-2026-10-15.txt b/scripts/compat-prune/test/golden/edits-2026-10-15.txt
new file mode 100644
index 0000000..570e322
--- /dev/null
+++ b/scripts/compat-prune/test/golden/edits-2026-10-15.txt
@@ -0,0 +1,42 @@
+CTBase | Project.toml | ForwardDiff | edited |
+CTBase | Project.toml | JET | edited |
+CTBase | docs/Project.toml | ForwardDiff | edited |
+CTBase | docs/Project.toml | JET | edited |
+CTDirect | Project.toml | CTBase | edited |
+CTDirect | Project.toml | CTModels | edited |
+CTDirect | docs/Project.toml | CTBase | edited |
+CTFlows | Project.toml | CUDA | edited |
+CTFlows | Project.toml | DiffEqBase | edited |
+CTFlows | Project.toml | ForwardDiff | edited |
+CTFlows | Project.toml | GPUArraysCore | edited |
+CTFlows | docs/Project.toml | DifferentiationInterface | edited |
+CTFlows | docs/Project.toml | ForwardDiff | edited |
+CTLie | Project.toml | CUDA | edited |
+CTLie | Project.toml | ForwardDiff | edited |
+CTLie | Project.toml | JET | edited |
+CTLie | docs/Project.toml | ForwardDiff | edited |
+CTModels | Project.toml | JET | edited |
+CTModels | Project.toml | OrderedCollections | edited |
+CTModels | docs/Project.toml | JET | edited |
+CTParser | Project.toml | OrderedCollections | drift | `1` on the default branch, `1, 2` audited
+CTParser | docs/Project.toml | CTBase | drift | `0.18, 0.27, 0.28` on the default branch, `0.29, 0.30` audited
+CTParser | test/Project.toml | CUDA | drift | `5` on the default branch, `5, 6` audited
+CTParser | test/Project.toml | CUDSS | drift | no `CUDSS` entry in `[compat]` on the default branch
+CTParser | test/Project.toml | MadNLP | drift | `0.9` on the default branch, `0.9, 0.10` audited
+CTParser | test/Project.toml | MadNLPGPU | drift | `0.8` on the default branch, `0.8, 0.10` audited
+CTParser | test/Project.toml | OrderedCollections | drift | `1.8` on the default branch, `1.8, 2` audited
+CTSolvers | Project.toml | CUDA | edited |
+CTSolvers | Project.toml | CUDSS | edited |
+CTSolvers | Project.toml | DiffEqBase | edited |
+CTSolvers | Project.toml | ForwardDiff | edited |
+CTSolvers | Project.toml | JET | edited |
+CTSolvers | Project.toml | MadNLP | edited |
+CTSolvers | Project.toml | MadNLPGPU | edited |
+CTSolvers | Project.toml | OrderedCollections | edited |
+CTSolvers | docs/Project.toml | CUDA | edited |
+CTSolvers | docs/Project.toml | CUDSS | edited |
+CTSolvers | docs/Project.toml | DiffEqBase | edited |
+CTSolvers | docs/Project.toml | ForwardDiff | edited |
+CTSolvers | docs/Project.toml | JET | edited |
+CTSolvers | docs/Project.toml | MadNLP | edited |
+CTSolvers | docs/Project.toml | MadNLPGPU | edited |
diff --git a/scripts/compat-prune/test/golden/envs-2026-10-15.txt b/scripts/compat-prune/test/golden/envs-2026-10-15.txt
new file mode 100644
index 0000000..e6dc0ca
--- /dev/null
+++ b/scripts/compat-prune/test/golden/envs-2026-10-15.txt
@@ -0,0 +1,17 @@
+CTBase/docs: deps=ADTypes,CTBase,CairoMakie,Coverage,DifferentiationInterface,Documenter,DocumenterInterLinks,DocumenterVitepress,ForwardDiff,JET,LiveServer,MarkdownAST,Plots pins=CTBase=0.30.10
+CTBase/test: deps=Aqua,BenchmarkTools,CTBase,CairoMakie,Coverage,DifferentiationInterface,Documenter,ForwardDiff,JET,Logging,Markdown,MarkdownAST,Plots,Test pins=CTBase=0.30.10
+CTDirect/docs: deps=CTBase,CTDirect,Documenter,DocumenterMermaid,Markdown,MarkdownAST pins=CTDirect=1.1.8-beta
+CTDirect/test: deps=AMDGPU,CTDirect,CTParser,CUDA,CUDSS,CommonSolve,MadNLP,MadNLPGPU,NLPModels,NLPModelsIpopt,SplitApplyCombine,Test pins=CTDirect=1.1.8-beta
+CTFlows/docs: deps=CTBase,CTFlows,CTModels,CTSolvers,CairoMakie,DiffEqBase,DifferentiationInterface,Documenter,DocumenterInterLinks,DocumenterVitepress,ForwardDiff,LiveServer,MarkdownAST,OrdinaryDiffEqRosenbrock,OrdinaryDiffEqTsit5,Plots,SciMLBase,StaticArrays pins=CTFlows=0.18.1
+CTFlows/test: deps=Aqua,CTFlows,CTLie,CUDA,CairoMakie,DiffEqBase,DiffEqGPU,DifferentiationInterface,ForwardDiff,Mooncake,NonlinearSolve,OrdinaryDiffEqTsit5,Plots,SciMLBase,StaticArrays,Test,Zygote pins=CTFlows=0.18.1
+CTLie/docs: deps=ADTypes,CTBase,CTLie,DifferentiationInterface,Documenter,DocumenterInterLinks,DocumenterVitepress,ForwardDiff,LiveServer,MarkdownAST pins=CTLie=0.2.3
+CTLie/test: deps=ADTypes,Aqua,BenchmarkTools,CTLie,CUDA,DifferentiationInterface,ForwardDiff,JET,Mooncake,Test pins=CTLie=0.2.3
+CTModels/docs: deps=CTBase,CTModels,CairoMakie,Documenter,DocumenterInterLinks,DocumenterVitepress,JET,JLD2,JSON3,LiveServer,MarkdownAST,Plots pins=CTModels=0.19.8
+CTModels/test: deps=Aqua,BenchmarkTools,CTModels,CairoMakie,JET,JLD2,JSON3,Plots,Random,Test pins=CTModels=0.19.8
+CTParser/docs: deps=CTBase,CTParser,Documenter,Markdown,MarkdownAST pins=CTParser=0.9.5-beta
+CTParser/test: deps=Aqua,BenchmarkTools,CTBase,CTModels,CTParser,CUDA,CUDSS,ExaModels,Interpolations,KernelAbstractions,LinearAlgebra,MadNLP,MadNLPGPU,NLPModels,OrderedCollections,Test pins=CTParser=0.9.5-beta
+CTSolvers/docs: deps=ADNLPModels,CTBase,CTSolvers,CUDA,CUDSS,CommonSolve,DiffEqBase,Documenter,DocumenterInterLinks,DocumenterVitepress,Enzyme,ExaModels,ForwardDiff,JET,LiveServer,MadNCL,MadNLP,MadNLPGPU,MarkdownAST,NLPModels,NLPModelsIpopt,NLPModelsKnitro,OrdinaryDiffEqTsit5,OrdinaryDiffEqVerner,SciMLBase,UnoSolver,Zygote pins=CTSolvers=0.5.8
+CTSolvers/test: deps=ADNLPModels,Aqua,BenchmarkTools,CTSolvers,CUDA,CUDSS,DiffEqBase,Enzyme,ExaModels,ForwardDiff,JET,MadNCL,MadNLP,MadNLPGPU,NLPModels,NLPModelsIpopt,OrderedCollections,OrdinaryDiffEqTsit5,Random,SciMLBase,Test,UnoSolver,Zygote pins=CTSolvers=0.5.8
+OptimalControl/docs: deps=ADNLPModels,CTBase,CTDirect,CTFlows,CTLie,CTModels,CTParser,CTSolvers,CUDA,CUDSS,CairoMakie,Colors,CommonSolve,DifferentiationInterface,Documenter,DocumenterInterLinks,DocumenterVitepress,ExaModels,ForwardDiff,JLD2,JSON3,Literate,LiveServer,MadNCL,MadNLP,MadNLPGPU,MarkdownAST,NLPModelsIpopt,NLPModelsKnitro,NonlinearSolve,OptimalControl,OrdinaryDiffEq,OrdinaryDiffEqTsit5,Plots,UnoSolver pins=OptimalControl=2.2.1-beta
+OptimalControl/test: deps=BenchmarkTools,CUDA,CUDSS,DiffEqBase,MadNCL,MadNLP,MadNLPGPU,NLPModelsIpopt,NonlinearSolve,OptimalControl,OrdinaryDiffEq,OrdinaryDiffEqTsit5,Printf,SciMLBase,SolverCore,SplitApplyCombine,Test,UnoSolver pins=OptimalControl=2.2.1-beta
+joint: deps=CTBase,CTDirect,CTFlows,CTLie,CTModels,CTParser,CTSolvers,OptimalControl pins=CTBase=0.30.10,CTDirect=1.1.8-beta,CTFlows=0.18.1,CTLie=0.2.3,CTModels=0.19.8,CTParser=0.9.5-beta,CTSolvers=0.5.8,OptimalControl=2.2.1-beta
diff --git a/scripts/compat-prune/test/golden/pr-body.md b/scripts/compat-prune/test/golden/pr-body.md
new file mode 100644
index 0000000..919b37d
--- /dev/null
+++ b/scripts/compat-prune/test/golden/pr-body.md
@@ -0,0 +1,14 @@
+
+
+Drops the `[compat]` specifiers that no supported Julia version selects, in any
+environment of the control-toolbox packages (joint, tests and docs of every package).
+Without them every audited manifest is unchanged: this PR changes no version that
+these environments resolve, needs no release of its own, and can be merged in any
+order with the PRs of the other packages.
+
+| file | dependency | before | after | selected |
+| --- | --- | --- | --- | --- |
+| `Project.toml` | `Dep1` | `0.5, 0.6, 0.7` | `0.6, 0.7` | 0.7.2 on 1.12; 0.6.4 on 1.10 |
+| `docs/Project.toml` | `Doc` | `0.9, 1` | `1` | |
+
+Opened by the [`compat-prune`](https://github.com/control-toolbox/CTActions/blob/main/.github/workflows/compat-prune.yml) workflow (tracking issue: https://github.com/example-org/Root.jl/issues/100, [audit run](https://github.com/example-org/Root.jl/actions/runs/1)). The branch is rewritten by every run: push nothing to it.
diff --git a/scripts/compat-prune/test/golden/report.md b/scripts/compat-prune/test/golden/report.md
new file mode 100644
index 0000000..a767a9f
--- /dev/null
+++ b/scripts/compat-prune/test/golden/report.md
@@ -0,0 +1,98 @@
+
+
+Audit of the `[compat]` entries of the control-toolbox packages, by the
+[`compat-prune`](https://github.com/control-toolbox/CTActions/blob/main/.github/workflows/compat-prune.yml) workflow on 2026-10-15 19:48 UTC ([run](https://github.com/example-org/Root.jl/actions/runs/1)), for Julia 1.10, 1.12, 1.13. This body is rewritten by every run: do not edit it.
+
+A prune drops `[compat]` specifiers that no environment selects, on no Julia version, and
+leaves every resolved manifest (joint, tests and docs of every package) unchanged. PRs can
+therefore be merged in any order, and need no release of their own.
+
+## Audit
+
+| Julia | environments resolved |
+| --- | --- |
+| 1.10 | 3 / 4 (failed: joint) |
+| 1.12 | 4 / 4 |
+
+9 multi-specifier `[compat]` entries examined: 4 with lanes to prune, 1 rejected, 2 blocked, 1 kept (every lane in use), 1 idle (resolved nowhere they apply), 1 withheld.
+
+Registries: General (tree aff17d8a1e3e), ct-registry (commit 0123456789ab).
+
+Entries left as they are (2)
+
+| entry | compat | status | why |
+| --- | --- | --- | --- |
+| Alpha `test/Project.toml` `Gpu` | `5, 6` | idle | not resolved where the entry applies |
+| Beta `Project.toml` `Dep3` | `0.9, 1` | keep | every lane in use: 1.2.0 on 1.12; 0.9.4 on 1.10 |
+
+
+
+## Summary
+
+| | entries |
+| --- | --- |
+| to prune (open PRs) | 3 |
+| merged, awaiting release | 1 |
+| drift (not edited) | 1 |
+| rejected by validation | 1 |
+| withheld | 1 |
+| blocked (information) | 2 |
+
+## To prune
+
+### Alpha — https://github.com/example-org/Alpha.jl/pull/12
+
+| file | dependency | before | after | selected |
+| --- | --- | --- | --- | --- |
+| `Project.toml` | `Dep1` | `0.5, 0.6, 0.7` | `0.6, 0.7` | 0.7.2 on 1.12; 0.6.4 on 1.10 |
+| `test/Project.toml` | `Tool` | `1, 2` | `2` | 2.0.0 on 1.10, 1.12 |
+
+### Beta
+
+| file | dependency | before | after | selected |
+| --- | --- | --- | --- | --- |
+| `Project.toml` | `Dep1` | `0.6, 0.7` | `0.7` | 0.7.2 on 1.12 |
+
+## Merged, awaiting release
+
+| entry | after |
+| --- | --- |
+| Beta `Project.toml` `Tool` | `2` |
+
+## Drift
+
+The default branch differs from the registered version: not edited.
+
+| entry | audited | detail |
+| --- | --- | --- |
+| Alpha `docs/Project.toml` `Doc` | `0.8, 0.9` | `0.9, 1` on the default branch, `0.8, 0.9` audited |
+
+## Rejected
+
+| entry | where | reason |
+| --- | --- | --- |
+| Beta `Project.toml` `Weak` | Beta/test@1.10 | manifest differs: A 1.0.0 → 1.1.0 \| odd second line |
+
+## Withheld
+
+Baseline failed in joint@1.10: lanes used there are unknown.
+
+| entry | would drop |
+| --- | --- |
+| Alpha `Project.toml` `Dep2` | `1` |
+
+## Blocked
+
+The newest specifier is never selected: information only, nothing is edited.
+
+| entry | newest | capped by |
+| --- | --- | --- |
+| Alpha `docs/Project.toml` `Doc` | `1` | Beta, Documenter |
+| Beta `Project.toml` `Weak` | `0.2` | resolver preference |
+
+## Audited versions
+
+| package | version | repository |
+| --- | --- | --- |
+| Alpha | 0.3.1 | [example-org/Alpha.jl](https://github.com/example-org/Alpha.jl) |
+| Beta | 1.2.0-beta | [example-org/Beta.jl](https://github.com/example-org/Beta.jl) |
diff --git a/scripts/compat-prune/test/helpers.jl b/scripts/compat-prune/test/helpers.jl
new file mode 100644
index 0000000..cb19db7
--- /dev/null
+++ b/scripts/compat-prune/test/helpers.jl
@@ -0,0 +1,88 @@
+# Shared by every test file: the scripts (their `main` blocks do not run when included),
+# fixture paths, and small builders.
+
+const SCRIPTS = joinpath(@__DIR__, "..")
+for f in ("collect.jl", "envs.jl", "resolve.jl", "classify.jl", "validate.jl", "edit.jl", "report.jl", "audit.jl")
+ include(joinpath(SCRIPTS, f))
+end
+
+const FIXTURES = joinpath(@__DIR__, "fixtures")
+const COLLECTED = joinpath(FIXTURES, "collected")
+const GOLDEN = joinpath(@__DIR__, "golden")
+const UPDATE_GOLDEN = get(ENV, "COMPAT_PRUNE_UPDATE_GOLDEN", "") == "1"
+
+"A resolved environment result with the given name => version pairs."
+ok(versions...) = Dict("ok" => true, "versions" => Dict{String,Any}(versions...))
+
+"A failed environment result."
+failed(msg="unsatisfiable") = Dict("ok" => false, "error" => msg)
+
+"A prune record."
+prune(owner, file, dep, before, after) =
+ Dict{String,Any}("owner" => owner, "file" => file, "dep" => dep, "before" => before, "after" => after,
+ "removed" => setdiff(specifiers(before), specifiers(after)), "status" => "prune",
+ "evidence" => Dict{String,Any}())
+
+"Compare `text` with the golden file `name`, or rewrite it under COMPAT_PRUNE_UPDATE_GOLDEN=1."
+function golden(name, text)
+ path = joinpath(GOLDEN, name)
+ if UPDATE_GOLDEN || !isfile(path)
+ mkpath(dirname(path))
+ write(path, text)
+ @info "golden file written: $name"
+ end
+ return text == read(path, String)
+end
+
+"Run `f` with `GH` replaced by `fake`, restoring the real runner afterwards."
+function with_gh(f, fake)
+ real = GH[]
+ GH[] = fake
+ try
+ f()
+ finally
+ GH[] = real
+ end
+end
+
+# Captured from real runs (2026-10-15): DocStringExtensions restricted to its newest 0.10.
+const CONFLICT_ON_DEP = """
+Unsatisfiable requirements detected for package DocStringExtensions [ffbed154]:
+ DocStringExtensions [ffbed154] log:
+ ├─possible versions are: 0.9.3 - 0.10.0 or uninstalled
+ ├─restricted to versions 0.10 by project [4e53ef5c], leaving only versions: 0.10.0
+ │ └─project [4e53ef5c] log:
+ │ ├─possible versions are: 0.0.0 or uninstalled
+ │ └─project [4e53ef5c] is fixed to version 0.0.0
+ └─restricted by compatibility requirements with SciMLBase [0bca4576] to versions: 0.9.5 — no versions left
+ └─SciMLBase [0bca4576] log:
+ ├─possible versions are: 2.77.2 - 3.58.0 or uninstalled
+ └─restricted by compatibility requirements with Deeper [00000000] to versions: 3
+"""
+const CONFLICT_ON_OTHER = """
+Unsatisfiable requirements detected for package DocumenterVitepress [4710194d]:
+ DocumenterVitepress [4710194d] log:
+ ├─possible versions are: 0.3.4 - 0.3.8 or uninstalled
+ └─restricted by compatibility requirements with DocStringExtensions [ffbed154] to versions: uninstalled — no versions left
+ └─DocStringExtensions [ffbed154] log:
+ └─restricted to versions 0.10 by project [064cce67], leaving only versions: 0.10.0
+"""
+
+"Run `f(root)` on a temporary copy of the collected fixture, with its `envs.toml` built."
+function with_collected(f)
+ mktempdir() do dir
+ root = joinpath(dir, "collected")
+ cp(COLLECTED, root)
+ open(io -> TOML.print(io, Dict("env" => build_envs(root))), joinpath(root, "envs.toml"), "w")
+ f(root)
+ end
+end
+
+"Baseline results of the collected fixture, keyed `@`."
+fixture_results() = Dict{String,Any}(
+ "joint@1.10" => ok("Alpha" => "0.3.1", "Beta" => "1.2.0-beta", "Dep1" => "0.6.4"),
+ "joint@1.12" => ok("Alpha" => "0.3.1", "Beta" => "1.2.0-beta", "Dep1" => "0.7.2"),
+ "Alpha/test@1.12" => ok("Alpha" => "0.3.1", "Beta" => "1.2.0-beta", "Dep1" => "0.7.2", "Tool" => "2.0.0"),
+ "Alpha/docs@1.12" => ok("Alpha" => "0.3.1", "Beta" => "1.2.0-beta", "Dep1" => "0.7.2", "Doc" => "0.9.3"),
+ "Beta/test@1.12" => ok("Beta" => "1.2.0-beta", "Dep1" => "0.7.2", "Tool" => "2.1.0"),
+)
diff --git a/scripts/compat-prune/test/runtests.jl b/scripts/compat-prune/test/runtests.jl
new file mode 100644
index 0000000..efe63d3
--- /dev/null
+++ b/scripts/compat-prune/test/runtests.jl
@@ -0,0 +1,32 @@
+# Tests of compat-prune.
+#
+# usage: julia scripts/compat-prune/test/runtests.jl
+#
+# COMPAT_PRUNE_INTEGRATION=1 also run the integration tests (real Pkg resolutions against
+# the local General registry, nothing downloaded; seconds)
+# COMPAT_PRUNE_UPDATE_GOLDEN=1 rewrite the golden files instead of comparing to them
+#
+# Unit tests run on fixtures and fakes (no network, no resolution): the `gh` runner (`GH`)
+# and the resolver of `simulate` are replaced by functions recording their calls.
+
+using Test
+
+include("helpers.jl")
+
+@testset "compat-prune" begin
+ include("test_common.jl")
+ include("test_collect.jl")
+ include("test_envs.jl")
+ include("test_classify.jl")
+ include("test_validate.jl")
+ include("test_audit.jl")
+ include("test_edit.jl")
+ include("test_report.jl")
+ include("test_publish.jl")
+ include("test_regression.jl")
+ if get(ENV, "COMPAT_PRUNE_INTEGRATION", "") == "1"
+ include("test_integration.jl")
+ else
+ @info "Integration tests skipped (set COMPAT_PRUNE_INTEGRATION=1)"
+ end
+end
diff --git a/scripts/compat-prune/test/test_audit.jl b/scripts/compat-prune/test/test_audit.jl
new file mode 100644
index 0000000..ab52963
--- /dev/null
+++ b/scripts/compat-prune/test/test_audit.jl
@@ -0,0 +1,48 @@
+# audit.jl: coverage of the baselines, and the record summarizing an audit.
+
+@testset "audit" begin
+ @testset "coverage" begin
+ baselines = Dict(
+ "1.12" => Dict("env" => Dict("joint" => ok(), "A/test" => ok(), "A/docs" => ok())),
+ "1.10" => Dict("env" => Dict("joint" => ok(), "A/test" => failed(), "A/docs" => failed())),
+ )
+ c = coverage(baselines)
+ @test [x["julia"] for x in c] == ["1.10", "1.12"]
+ @test c[1]["resolved"] == 1 && c[1]["total"] == 3 && c[1]["failed"] == ["A/docs", "A/test"]
+ @test c[2]["resolved"] == 3 && isempty(c[2]["failed"])
+ end
+
+ @testset "summarize" begin
+ p(owner, dep, status="prune") = merge(prune(owner, "Project.toml", dep, "1, 2", "2"), Dict("status" => status))
+ classified = Dict(
+ "prune" => [p("A", "D1"), p("A", "D2"), p("B", "D3"),
+ p("B", "D5")], # obsolete lane of a blocked entry
+ "other" => [merge(p("B", "D4"), Dict("status" => "keep")),
+ merge(p("B", "D5"), Dict("status" => "blocked", "newest" => "3")),
+ merge(p("B", "D6"), Dict("status" => "idle")),
+ merge(p("A", "D7"), Dict("status" => "withheld"))],
+ )
+ rejected = [Dict("owner" => "A", "file" => "Project.toml", "dep" => "D2", "where" => "joint@1.12", "reason" => "x")]
+ blocked = [Dict("owner" => "B", "file" => "Project.toml", "dep" => "D5", "newest" => "3", "where" => "joint@1.12",
+ "blockers" => ["Z"], "detail" => "")]
+ a = summarize(; date="2026-11-01 04:00 UTC", julia=["1.10", "1.12"], index=Dict("packages" => Dict()),
+ classified, rejected, blocked, registries=[Dict("name" => "General", "state" => "tree abc")],
+ coverage=[Dict("julia" => "1.12", "resolved" => 2, "total" => 2, "failed" => String[])])
+ # accepted prunes: classified minus rejected, the blocked entry's obsolete lane included
+ @test sort(entry_key.(a["prune"])) == [("A", "Project.toml", "D1"), ("B", "Project.toml", "D3"), ("B", "Project.toml", "D5")]
+ @test a["rejected"] == rejected && a["blocked"] == blocked
+ # blocked records live in `blocked` (probes), not in `other`
+ @test sort([r["status"] for r in a["other"]]) == ["idle", "keep", "withheld"]
+ # one status per entry
+ @test a["record"]["entries"] == Dict("examined" => 7, "prune" => 2, "rejected" => 1, "keep" => 1,
+ "blocked" => 1, "idle" => 1, "withheld" => 1)
+ @test a["record"]["registries"] == [Dict("name" => "General", "state" => "tree abc")]
+ @test only(a["record"]["coverage"])["resolved"] == 2
+ # the record survives a TOML round trip
+ mktempdir() do dir
+ path = joinpath(dir, "audit.toml")
+ open(io -> TOML.print(io, a), path, "w")
+ @test TOML.parsefile(path)["record"] == a["record"]
+ end
+ end
+end
diff --git a/scripts/compat-prune/test/test_classify.jl b/scripts/compat-prune/test/test_classify.jl
new file mode 100644
index 0000000..03ba483
--- /dev/null
+++ b/scripts/compat-prune/test/test_classify.jl
@@ -0,0 +1,141 @@
+# classify.jl: which specifiers are used, and what becomes of each entry.
+
+const AUDITED_C = Dict("CTSolvers" => "0.5.8", "CTParser" => "0.9.5-beta", "CTBase" => "0.30.9")
+
+@testset "classify" begin
+ @testset "specifiers and lower bounds" begin
+ @test specifiers("0.9, 0.11,0.12") == ["0.9", "0.11", "0.12"]
+ @test specifiers("1") == ["1"]
+ @test lower("0.7 - 0.8") == v"0.7"
+ @test lower("^1.2") == v"1.2"
+ @test lower("~0.3.1") == v"0.3.1"
+ @test lower("=1.1.8") == v"1.1.8"
+ @test lower("1") == v"1"
+ end
+
+ @testset "owner scoping keeps CTSolvers' CUDSS 0.7" begin
+ # OptimalControl's tests need CUDSS 0.7, CTDirect's take 0.8: both are CTSolvers users.
+ results = Dict(
+ "OptimalControl/test@1.12" => ok("CTSolvers" => "0.5.8", "CUDSS" => "0.7.0"),
+ "CTDirect/test@1.12" => ok("CTSolvers" => "0.5.8", "CUDSS" => "0.8.1"),
+ # an older CTSolvers selecting CUDSS 0.6 is no evidence for the audited file
+ "Old/test@1.12" => ok("CTSolvers" => "0.4.0", "CUDSS" => "0.6.3"),
+ )
+ r = classify_entry("CTSolvers", "Project.toml", "CUDSS", "0.6, 0.7, 0.8", results, AUDITED_C)
+ @test r["status"] == "prune"
+ @test r["removed"] == ["0.6"]
+ @test r["after"] == "0.7, 0.8"
+ @test r["evidence"] == Dict("0.7" => ["OptimalControl/test@1.12=0.7.0"], "0.8" => ["CTDirect/test@1.12=0.8.1"])
+ end
+
+ @testset "a lane used on Julia 1.10 only is kept" begin
+ results = Dict("CTBase/test@1.10" => ok("CTBase" => "0.30.9", "JET" => "0.9.18"),
+ "CTBase/test@1.12" => ok("CTBase" => "0.30.9", "JET" => "0.12.3"))
+ r = classify_entry("CTBase", "Project.toml", "JET", "0.9, 0.11, 0.12", results, AUDITED_C)
+ @test r["status"] == "prune"
+ @test r["removed"] == ["0.11"]
+ @test r["after"] == "0.9, 0.12"
+ end
+
+ @testset "declaration order is kept in `after`" begin
+ results = Dict("X@1.12" => ok("CTBase" => "0.30.9", "D" => "2.0.0"),
+ "Y@1.12" => ok("CTBase" => "0.30.9", "D" => "1.5.0"))
+ r = classify_entry("CTBase", "Project.toml", "D", "1.5, 0.9, 2", results, AUDITED_C)
+ @test r["after"] == "1.5, 2"
+ end
+
+ @testset "blocked, idle, keep" begin
+ results = Dict("CTParser/test@1.12" => ok("CTParser" => "0.9.5-beta", "CTBase" => "0.29.3", "CUDSS" => "0.7.0"))
+ r = classify_entry("CTParser", "test/Project.toml", "CTBase", "0.29, 0.30", results, AUDITED_C)
+ @test r["status"] == "blocked"
+ @test r["newest"] == "0.30"
+ @test r["applies_in"] == ["CTParser/test"]
+ @test !haskey(r, "prunable")
+ # blocked, with an obsolete lane below the one in use
+ r = classify_entry("CTParser", "test/Project.toml", "CUDSS", "0.6, 0.7, 0.8", results, AUDITED_C)
+ @test r["status"] == "blocked"
+ @test r["prunable"] == ["0.6"]
+ # an unused lane between the used one and the newest is blocked, never pruned
+ r = classify_entry("CTParser", "test/Project.toml", "CUDSS", "0.7, 0.8, 0.9", results, AUDITED_C)
+ @test r["status"] == "blocked" && r["newest"] == "0.9" && !haskey(r, "prunable")
+ # resolved nowhere the entry applies
+ r = classify_entry("CTParser", "test/Project.toml", "CUDA", "5, 6", results, AUDITED_C)
+ @test r["status"] == "idle"
+ # every lane in use
+ results = Dict("CTParser/test@1.10" => ok("CTParser" => "0.9.5-beta", "JET" => "0.9.18"),
+ "CTParser/test@1.12" => ok("CTParser" => "0.9.5-beta", "JET" => "0.12.3"))
+ r = classify_entry("CTParser", "test/Project.toml", "JET", "0.9, 0.12", results, AUDITED_C)
+ @test r["status"] == "keep"
+ end
+
+ @testset "a failed environment is no evidence" begin
+ results = Dict("CTBase/test@1.10" => failed(),
+ "CTBase/test@1.12" => ok("CTBase" => "0.30.9", "JET" => "0.12.3"))
+ r = classify_entry("CTBase", "Project.toml", "JET", "0.9, 0.12", results, AUDITED_C)
+ @test r["status"] == "prune" && r["after"] == "0.12"
+ end
+
+ @testset "withhold" begin
+ r = prune("CTBase", "Project.toml", "JET", "0.9, 0.12", "0.12")
+ results = Dict("CTBase/test@1.10" => failed(), "CTBase/test@1.12" => ok())
+ kept, withheld = withhold([r], results)
+ @test isempty(kept)
+ @test only(withheld)["status"] == "withheld"
+ @test occursin("CTBase/test@1.10", only(withheld)["reason"])
+ kept, withheld = withhold([r], Dict("CTBase/test@1.12" => ok()))
+ @test kept == [r] && isempty(withheld)
+ end
+
+ @testset "classify_all on the fixture" begin
+ with_collected() do root
+ prunes, others = classify_all(root, fixture_results())
+ got = Dict(entry_key(r) => r for r in vcat(prunes, others))
+ # Alpha's Dep1: 0.6 on 1.10 (joint), 0.7 on 1.12 → 0.5 is obsolete
+ p = got[("Alpha", "Project.toml", "Dep1")]
+ @test p["status"] == "prune" && p["after"] == "0.6, 0.7"
+ # test/ entry: Tool 2 only
+ p = got[("Alpha", "test/Project.toml", "Tool")]
+ @test p["status"] == "prune" && p["after"] == "2"
+ # docs/ entry: Doc 1 never selected
+ b = got[("Alpha", "docs/Project.toml", "Doc")]
+ @test b["status"] == "blocked" && b["newest"] == "1" && b["applies_in"] == ["Alpha/docs"]
+ # Beta's Dep1: both lanes in use
+ @test got[("Beta", "Project.toml", "Dep1")]["status"] == "keep"
+ # Beta's weak dep: in no resolved environment
+ @test got[("Beta", "Project.toml", "Weak")]["status"] == "idle"
+ # single-specifier entries are not classified
+ @test !haskey(got, ("Beta", "Project.toml", "Tool"))
+ @test !haskey(got, ("Alpha", "Project.toml", "Beta"))
+ @test length(prunes) == 2 && length(others) == 3
+ end
+ end
+
+ @testset "classify_all: obsolete lane of a blocked entry, failed baseline" begin
+ with_collected() do root
+ results = fixture_results()
+ # Doc: 0.8 lane obsolete, 1 blocked
+ path = joinpath(root, "registered", "Alpha", "docs", "Project.toml")
+ write(path, replace(read(path, String), "Doc = \"0.9, 1\"" => "Doc = \"0.8, 0.9, 1\""))
+ prunes, others = classify_all(root, results)
+ p = only(filter(r -> r["dep"] == "Doc", prunes))
+ @test p["after"] == "0.9, 1" && p["removed"] == ["0.8"]
+ @test any(r -> r["dep"] == "Doc" && r["status"] == "blocked", others)
+ # a failed baseline withholds every prune
+ results["Beta/test@1.10"] = failed()
+ prunes, others = classify_all(root, results)
+ @test isempty(prunes)
+ @test count(r -> r["status"] == "withheld", others) == 3
+ end
+ end
+
+ @testset "load_results" begin
+ mktempdir() do dir
+ for (j, v) in (("1.10.11", "0.6.4"), ("1.12.6", "0.7.2"))
+ open(io -> TOML.print(io, Dict("julia" => j, "env" => Dict("joint" => ok("Dep1" => v)))),
+ joinpath(dir, "b-$j.toml"), "w")
+ end
+ r = load_results(joinpath.(dir, ["b-1.10.11.toml", "b-1.12.6.toml"]))
+ @test r == Dict("joint@1.10" => ok("Dep1" => "0.6.4"), "joint@1.12" => ok("Dep1" => "0.7.2"))
+ end
+ end
+end
diff --git a/scripts/compat-prune/test/test_collect.jl b/scripts/compat-prune/test/test_collect.jl
new file mode 100644
index 0000000..32fec21
--- /dev/null
+++ b/scripts/compat-prune/test/test_collect.jl
@@ -0,0 +1,91 @@
+# collect.jl: files fetched from the registered tree and the default branch (fake `gh`).
+
+"""
+A fake GitHub: `repos` maps "owner/repo" to (default branch, Dict(treeish => Dict(path => text))).
+Returns the runner and the vector of the calls it received.
+"""
+function fake_github(repos)
+ calls = Vector{Vector{String}}()
+ blobs = Dict{String,String}()
+ function runner(args)
+ push!(calls, args)
+ @assert args[1] == "api"
+ path = args[2]
+ m = match(r"^repos/([^/]+/[^/]+)$", path)
+ m === nothing || return repos[m[1]][1] * "\n" # --jq .default_branch
+ m = match(r"^repos/([^/]+/[^/]+)/git/trees/([^?]+)\?recursive=1$", path)
+ if m !== nothing
+ files = repos[m[1]][2][m[2]]
+ lines = String[]
+ for (p, text) in sort(collect(files))
+ sha = string(hash((m[1], m[2], p)); base=16)
+ blobs[sha] = text
+ push!(lines, "$p $sha")
+ end
+ return join(lines, "\n") * "\n"
+ end
+ m = match(r"^repos/[^/]+/[^/]+/git/blobs/(\w+)$", path)
+ m === nothing || return blobs[m[1]]
+ error("unexpected gh call: $args")
+ end
+ return runner, calls
+end
+
+@testset "collect" begin
+ repos = Dict(
+ "org/Alpha.jl" => ("main", Dict(
+ "t-alpha" => Dict("Project.toml" => "name = \"Alpha\"\n", "test/Project.toml" => "[deps]\n",
+ "src/Alpha.jl" => "module Alpha end\n", "docs/make.jl" => ""),
+ "main" => Dict("Project.toml" => "name = \"Alpha\"\nversion = \"0.3.2\"\n"),
+ )),
+ "org/Beta.jl" => ("dev", Dict(
+ "t-beta" => Dict("Project.toml" => "name = \"Beta\"\n", "docs/Project.toml" => "[deps]\n"),
+ "dev" => Dict("Project.toml" => "name = \"Beta\"\n", "docs/Project.toml" => "[deps]\n"),
+ )),
+ )
+ runner, calls = fake_github(repos)
+ mktempdir() do out
+ index = with_gh(runner) do
+ collect_files(out, Dict("Alpha" => ("u-a", "org/Alpha.jl"), "Beta" => ("u-b", "org/Beta.jl")),
+ Dict("Alpha" => "0.3.1", "Beta" => "1.2.0-beta"),
+ Dict("Alpha" => "t-alpha", "Beta" => "t-beta"))
+ end
+ @testset "index" begin
+ @test index["Alpha"]["version"] == "0.3.1"
+ @test index["Alpha"]["tree"] == "t-alpha"
+ @test index["Alpha"]["default_branch"] == "main"
+ @test index["Alpha"]["files"] == ["Project.toml", "test/Project.toml"]
+ @test index["Alpha"]["default_files"] == ["Project.toml"]
+ @test index["Beta"]["files"] == ["Project.toml", "docs/Project.toml"]
+ @test index["Beta"]["default_branch"] == "dev"
+ @test TOML.parsefile(joinpath(out, "index.toml"))["packages"] == index
+ end
+ @testset "files" begin
+ # the registered tree and the default branch are kept apart
+ @test read(joinpath(out, "registered", "Alpha", "Project.toml"), String) == "name = \"Alpha\"\n"
+ @test read(joinpath(out, "default", "Alpha", "Project.toml"), String) ==
+ "name = \"Alpha\"\nversion = \"0.3.2\"\n"
+ @test isfile(joinpath(out, "registered", "Alpha", "test", "Project.toml"))
+ @test !isfile(joinpath(out, "default", "Alpha", "test", "Project.toml"))
+ @test isfile(joinpath(out, "default", "Beta", "docs", "Project.toml"))
+ end
+ @testset "calls" begin
+ # only the audited files are downloaded; the tree is read by its registered sha
+ fetched = [c[2] for c in calls if occursin("/git/blobs/", c[2])]
+ @test length(fetched) == 2 + 1 + 2 + 2
+ @test any(c -> c[2] == "repos/org/Alpha.jl/git/trees/t-alpha?recursive=1", calls)
+ @test any(c -> c[2] == "repos/org/Beta.jl/git/trees/dev?recursive=1", calls)
+ # blobs are fetched raw
+ @test all(c -> c[3:4] == ["-H", "Accept: application/vnd.github.raw"],
+ [c for c in calls if occursin("/git/blobs/", c[2])])
+ end
+ end
+
+ @testset "fetch_files with no audited file" begin
+ runner, _ = fake_github(Dict("org/Empty.jl" => ("main", Dict("t" => Dict("README.md" => "")))))
+ mktempdir() do out
+ @test with_gh(() -> fetch_files("org/Empty.jl", "t", out), runner) == String[]
+ @test isempty(readdir(out))
+ end
+ end
+end
diff --git a/scripts/compat-prune/test/test_common.jl b/scripts/compat-prune/test/test_common.jl
new file mode 100644
index 0000000..e5ec7b0
--- /dev/null
+++ b/scripts/compat-prune/test/test_common.jl
@@ -0,0 +1,104 @@
+# common.jl: specifier arithmetic, where an entry applies, simulation (fake resolver).
+
+const AUDITED = Dict("CTSolvers" => "0.5.8", "CTParser" => "0.9.5-beta", "CTBase" => "0.30.9")
+
+@testset "common" begin
+ @testset "parse_list" begin
+ @test parse_list("[\"CTBase\",\"CTParser\"]") == ["CTBase", "CTParser"]
+ @test parse_list("[ \"1.10\" , \"1.12\" ]") == ["1.10", "1.12"]
+ @test isempty(parse_list("[]"))
+ end
+
+ @testset "render" begin
+ @test render(spec("0.6, 0.7, 0.8") ∩ spec("0.7, 0.8")) == "0.7 - 0.8"
+ @test render(spec("0.9, 0.11, 0.12") ∩ spec("0.9, 0.12")) == "0.9, 0.12"
+ @test render(spec(">= 0") ∩ spec("1")) == "1"
+ @test isempty(render(spec("0.7") ∩ spec("0.8")))
+ # a rendered spec parses back to the same set
+ for s in ("0.6, 0.7, 0.8", "1.8, 2", "0.30.8", "5 - 6")
+ @test spec(render(spec(s))) == spec(s)
+ end
+ end
+
+ @testset "entry_key" begin
+ p = prune("CTSolvers", "Project.toml", "CUDSS", "0.6, 0.7, 0.8", "0.7, 0.8")
+ @test entry_key(p) == ("CTSolvers", "Project.toml", "CUDSS")
+ end
+
+ @testset "applies" begin
+ v = Dict("CTSolvers" => "0.5.8")
+ @test applies("CTSolvers", "Project.toml", "OptimalControl/test", v, AUDITED)
+ @test applies("CTSolvers", "Project.toml", "joint", v, AUDITED)
+ # another version of the owner carries other compat entries
+ @test !applies("CTSolvers", "Project.toml", "X/test", Dict("CTSolvers" => "0.4.0"), AUDITED)
+ @test !applies("CTSolvers", "Project.toml", "X/test", Dict{String,String}(), AUDITED)
+ # test/ and docs/ entries apply in their own environment only
+ @test applies("CTParser", "test/Project.toml", "CTParser/test", v, AUDITED)
+ @test applies("CTParser", "docs/Project.toml", "CTParser/docs", v, AUDITED)
+ @test !applies("CTParser", "test/Project.toml", "CTParser/docs", v, AUDITED)
+ @test !applies("CTParser", "docs/Project.toml", "OptimalControl/docs", v, AUDITED)
+ @test !applies("CTParser", "test/Project.toml", "joint", v, AUDITED)
+ end
+
+ @testset "narrowings" begin
+ prunes = [prune("CTSolvers", "Project.toml", "CUDSS", "0.6, 0.7, 0.8", "0.7, 0.8"),
+ prune("CTSolvers", "docs/Project.toml", "CUDSS", "0.6, 0.7, 0.8", "0.8"),
+ prune("CTBase", "Project.toml", "JET", "0.9, 0.11, 0.12", "0.9, 0.12")]
+ v = Dict("CTSolvers" => "0.5.8", "CUDSS" => "0.8.1", "CTBase" => "0.30.9")
+ # JET absent: nothing to bind; the docs entry intersects with the root one
+ @test narrowings(prunes, "CTSolvers/docs", v, AUDITED) == Dict("CUDSS" => "0.8")
+ @test narrowings(prunes, "CTDirect/test", v, AUDITED) == Dict("CUDSS" => "0.7, 0.8")
+ # the owner is absent: the entry does not apply
+ @test isempty(narrowings(prunes, "CTDirect/test", Dict("CUDSS" => "0.8.1"), AUDITED))
+ @test narrowings(prunes, "CTBase/test", Dict("CTBase" => "0.30.9", "JET" => "0.12.3"), AUDITED) ==
+ Dict("JET" => "0.9, 0.12")
+ @test isempty(narrowings([], "joint", v, AUDITED))
+ end
+
+ @testset "simulate (fake resolver)" begin
+ env = Dict("deps" => Dict("Own" => "u-own", "A" => "u-a"),
+ "compat" => Dict("Own" => "=1.0.0", "A" => "0.6, 0.7, 0.8"),
+ "pins" => Dict("Own" => "1.0.0"))
+ uuids = Dict("A" => "u-a", "T" => "u-t")
+ calls = []
+ resolver(versions) = (deps, compat) -> (push!(calls, (copy(deps), copy(compat)));
+ Dict("ok" => true, "versions" => Dict{String,Any}(versions), "trees" => Dict()))
+ # the narrowed compat is intersected with the environment's own
+ r = simulate("Own/test", env, Dict("A" => "0.7, 0.8"), uuids; resolver=resolver(Dict("Own" => "1.0.0", "A" => "0.8.1")))
+ @test r == Dict("ok" => true, "versions" => Dict("Own" => "1.0.0", "A" => "0.8.1"))
+ @test last(calls)[2]["A"] == "0.7 - 0.8"
+ @test last(calls)[2]["Own"] == "=1.0.0"
+ # a transitive dependency is listed to carry its compat entry
+ simulate("Own/test", env, Dict("T" => "2"), uuids; resolver=resolver(Dict("Own" => "1.0.0", "T" => "2.1.0")))
+ @test last(calls)[1]["T"] == "u-t"
+ @test last(calls)[2]["T"] == "2"
+ # the environment itself is not modified
+ @test !haskey(env["deps"], "T")
+ # an empty intersection fails without calling the resolver
+ n = length(calls)
+ r = simulate("Own/test", env, Dict("A" => "0.5"), uuids; resolver=resolver(Dict()))
+ @test !r["ok"] && occursin("empty compat intersection for A", r["error"])
+ @test length(calls) == n
+ # a pinned package resolved at another version (e.g. a stable release next to the
+ # audited prerelease) is a failure
+ r = simulate("Own/test", env, Dict(), uuids; resolver=resolver(Dict("Own" => "1.0.1")))
+ @test !r["ok"] && occursin("pinned versions not selected: Own 1.0.1 (audited 1.0.0)", r["error"])
+ # a resolver failure is passed through
+ r = simulate("Own/test", env, Dict(), uuids; resolver=(d, c) -> failed("boom"))
+ @test r == failed("boom")
+ end
+
+ @testset "load_collected" begin
+ mktempdir() do dir
+ cp(COLLECTED, joinpath(dir, "c"))
+ root = joinpath(dir, "c")
+ write(joinpath(root, "envs.toml"), "[env]\n")
+ envs, audited, uuids = load_collected(root)
+ @test audited == Dict("Alpha" => "0.3.1", "Beta" => "1.2.0-beta")
+ # every package named in an audited file, from [deps], [weakdeps] and [extras]
+ for n in ("Beta", "Dep1", "Tool", "Doc", "Weak", "Unused", "Test")
+ @test haskey(uuids, n)
+ end
+ end
+ end
+end
diff --git a/scripts/compat-prune/test/test_edit.jl b/scripts/compat-prune/test/test_edit.jl
new file mode 100644
index 0000000..12dff79
--- /dev/null
+++ b/scripts/compat-prune/test/test_edit.jl
@@ -0,0 +1,153 @@
+# edit.jl: textual edit of one [compat] line; outcome of each prune against a checkout.
+
+const PROJECT = """
+name = "Pkg"
+uuid = "aaaaaaaa-0000-4000-8000-000000000001"
+version = "0.6.1"
+
+[deps]
+CUDSS = "45b445bb-4962-46a0-9369-b4df9d0f772e"
+JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b"
+JETLS = "ffffffff-0000-4000-8000-00000000000f"
+
+[compat]
+CUDSS = "0.6, 0.7, 0.8"
+JET = "0.9, 0.11, 0.12"
+JETLS = "0.9, 0.11"
+julia = "1.10"
+
+[extras]
+Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+"""
+
+"`text` with line `old` replaced by `new`, asserting it was there."
+function swap(text, old, new)
+ @assert occursin(old, text) old
+ replace(text, old => new)
+end
+
+@testset "edit" begin
+ @testset "edits the value and nothing else" begin
+ out, status, detail = edit_compat(PROJECT, "JET", "0.9, 0.11, 0.12", "0.9, 0.12")
+ @test status == "edited" && detail == ""
+ @test out == swap(PROJECT, "JET = \"0.9, 0.11, 0.12\"", "JET = \"0.9, 0.12\"")
+ # a key that only starts with the same name is not touched
+ @test occursin("JETLS = \"0.9, 0.11\"", out)
+ end
+
+ @testset "layout is preserved" begin
+ cases = [
+ "JET = \"0.9, 0.11, 0.12\" # kept for Julia 1.10" => "JET = \"0.9, 0.12\" # kept for Julia 1.10",
+ "JET = \"0.9, 0.11, 0.12\"" => "JET = \"0.9, 0.12\"",
+ " JET = \"0.9, 0.11, 0.12\"" => " JET = \"0.9, 0.12\"",
+ "JET = '0.9, 0.11, 0.12'" => "JET = '0.9, 0.12'",
+ "\"JET\" = \"0.9, 0.11, 0.12\"" => "\"JET\" = \"0.9, 0.12\"",
+ ]
+ for (old, new) in cases
+ text = swap(PROJECT, "JET = \"0.9, 0.11, 0.12\"", old)
+ out, status, _ = edit_compat(text, "JET", "0.9, 0.11, 0.12", "0.9, 0.12")
+ @test status == "edited"
+ @test out == swap(text, old, new)
+ end
+ end
+
+ @testset "CRLF line endings are kept" begin
+ text = replace(PROJECT, "\n" => "\r\n")
+ out, status, _ = edit_compat(text, "CUDSS", "0.6, 0.7, 0.8", "0.7, 0.8")
+ @test status == "edited"
+ @test out == replace(swap(PROJECT, "CUDSS = \"0.6, 0.7, 0.8\"", "CUDSS = \"0.7, 0.8\""), "\n" => "\r\n")
+ end
+
+ @testset "spacing inside the value does not matter" begin
+ text = swap(PROJECT, "CUDSS = \"0.6, 0.7, 0.8\"", "CUDSS = \"0.6,0.7, 0.8\"")
+ out, status, _ = edit_compat(text, "CUDSS", "0.6, 0.7, 0.8", "0.7, 0.8")
+ @test status == "edited" && occursin("CUDSS = \"0.7, 0.8\"", out)
+ end
+
+ @testset "only the [compat] section is read" begin
+ # same key in [deps] (a uuid) and in an [extras]-like section after [compat]
+ text = PROJECT * "\n[sources]\nJET = {path = \"../JET\"}\n"
+ out, status, _ = edit_compat(text, "JET", "0.9, 0.11, 0.12", "0.9, 0.12")
+ @test status == "edited"
+ @test occursin("JET = \"c3a54625-cd67-489e-a8e7-0a5a0ff4e31b\"", out)
+ @test occursin("JET = {path = \"../JET\"}", out)
+ # a [compat] entry declared only after a nested table header is not mistaken
+ text = swap(PROJECT, "[compat]", "[compat.extra]")
+ @test edit_compat(text, "JET", "0.9, 0.11, 0.12", "0.9, 0.12")[2] == "drift"
+ end
+
+ @testset "already, drift" begin
+ text = swap(PROJECT, "JET = \"0.9, 0.11, 0.12\"", "JET = \"0.9, 0.12\"")
+ out, status, detail = edit_compat(text, "JET", "0.9, 0.11, 0.12", "0.9, 0.12")
+ @test status == "already" && out == text && occursin("0.9, 0.12", detail)
+ # the branch moved the entry elsewhere
+ text = swap(PROJECT, "JET = \"0.9, 0.11, 0.12\"", "JET = \"0.12\"")
+ out, status, detail = edit_compat(text, "JET", "0.9, 0.11, 0.12", "0.9, 0.12")
+ @test status == "drift" && out == text
+ @test detail == "`0.12` on the default branch, `0.9, 0.11, 0.12` audited"
+ # the entry is gone
+ text = swap(PROJECT, "JET = \"0.9, 0.11, 0.12\"\n", "")
+ out, status, detail = edit_compat(text, "JET", "0.9, 0.11, 0.12", "0.9, 0.12")
+ @test status == "drift" && out == text && occursin("no `JET` entry", detail)
+ # no [compat] at all
+ @test edit_compat("name = \"X\"\n", "JET", "0.9", "0.12")[2] == "drift"
+ end
+
+ @testset "check_edit" begin
+ edited = swap(PROJECT, "JET = \"0.9, 0.11, 0.12\"", "JET = \"0.9, 0.12\"")
+ @test check_edit(PROJECT, edited, "JET", "0.9, 0.12") === nothing
+ @test_throws ErrorException check_edit(PROJECT, swap(edited, "0.6.1", "0.6.0"), "JET", "0.9, 0.12")
+ @test_throws ErrorException check_edit(PROJECT, edited, "JET", "0.12")
+ end
+
+ @testset "plan_edits" begin
+ files = Dict(("Pkg", "Project.toml") => PROJECT,
+ ("Pkg", "docs/Project.toml") => "[compat]\nJET = \"0.12\"\n")
+ read_file(owner, file) = get(files, (owner, file), nothing)
+ audit = Dict("prune" => [
+ prune("Pkg", "Project.toml", "JET", "0.9, 0.11, 0.12", "0.9, 0.12"),
+ prune("Pkg", "Project.toml", "CUDSS", "0.6, 0.7, 0.8", "0.7, 0.8"), # same file, accumulated
+ prune("Pkg", "docs/Project.toml", "JET", "0.11, 0.12", "0.12"), # already
+ prune("Pkg", "test/Project.toml", "JET", "0.11, 0.12", "0.12"), # no such file
+ prune("Pkg", "Project.toml", "JETLS", "0.8, 0.9, 0.11", "0.11"), # drift: the branch has "0.9, 0.11"
+ ])
+ records, texts = plan_edits(audit, read_file)
+ status = Dict(entry_key(r) => r["status"] for r in records)
+ @test status[("Pkg", "Project.toml", "JET")] == "edited"
+ @test status[("Pkg", "Project.toml", "CUDSS")] == "edited"
+ @test status[("Pkg", "docs/Project.toml", "JET")] == "already"
+ @test status[("Pkg", "test/Project.toml", "JET")] == "drift"
+ @test status[("Pkg", "Project.toml", "JETLS")] == "drift"
+ @test collect(keys(texts)) == [("Pkg", "Project.toml")]
+ out = texts[("Pkg", "Project.toml")]
+ @test out == swap(swap(PROJECT, "JET = \"0.9, 0.11, 0.12\"", "JET = \"0.9, 0.12\""),
+ "CUDSS = \"0.6, 0.7, 0.8\"", "CUDSS = \"0.7, 0.8\"")
+ # the version line is never touched (regression: copying a collected file reverted it)
+ @test occursin("version = \"0.6.1\"", out)
+ end
+
+ @testset "against the fixture's default branches" begin
+ audit = Dict("prune" => [
+ prune("Alpha", "Project.toml", "Dep1", "0.5, 0.6, 0.7", "0.6, 0.7"),
+ prune("Beta", "Project.toml", "Dep1", "0.6, 0.7", "0.7"),
+ prune("Alpha", "docs/Project.toml", "Doc", "0.9, 1", "1"),
+ ])
+ records, texts = plan_edits(audit, read_checkout(joinpath(COLLECTED, "default")))
+ status = Dict(entry_key(r) => r["status"] for r in records)
+ # the default branch bumped the version and commented the line: still edited, both kept
+ @test status[("Alpha", "Project.toml", "Dep1")] == "edited"
+ out = texts[("Alpha", "Project.toml")]
+ @test occursin("version = \"0.3.2\"", out)
+ @test occursin("Dep1 = \"0.6, 0.7\" # lanes kept by CompatHelper", out)
+ # Beta's default branch already dropped 0.6
+ @test status[("Beta", "Project.toml", "Dep1")] == "already"
+ @test status[("Alpha", "docs/Project.toml", "Doc")] == "edited"
+ end
+
+ @testset "read_checkout" begin
+ r = read_checkout(joinpath(COLLECTED, "default"))
+ @test startswith(r("Alpha", "Project.toml"), "name = \"Alpha\"")
+ @test r("Alpha", "test/Project.toml") === nothing
+ @test r("Nope", "Project.toml") === nothing
+ end
+end
diff --git a/scripts/compat-prune/test/test_envs.jl b/scripts/compat-prune/test/test_envs.jl
new file mode 100644
index 0000000..4e5e65c
--- /dev/null
+++ b/scripts/compat-prune/test/test_envs.jl
@@ -0,0 +1,69 @@
+# envs.jl: environments built from the synthetic collected fixture.
+
+@testset "envs" begin
+ envs = build_envs(COLLECTED)
+
+ @testset "names" begin
+ # Beta has no docs/Project.toml
+ @test sort(collect(keys(envs))) == ["Alpha/docs", "Alpha/test", "Beta/test", "joint"]
+ @test envs["Alpha/test"]["owner"] == "Alpha" && envs["Alpha/test"]["kind"] == "test"
+ @test envs["joint"]["owner"] == "" && envs["joint"]["kind"] == "joint"
+ end
+
+ @testset "joint" begin
+ e = envs["joint"]
+ @test sort(collect(keys(e["deps"]))) == ["Alpha", "Beta"]
+ # every package is pinned; a prerelease is pinned without its suffix
+ @test e["compat"] == Dict("Alpha" => "=0.3.1", "Beta" => "=1.2.0")
+ @test e["pins"] == Dict("Alpha" => "0.3.1", "Beta" => "1.2.0-beta")
+ end
+
+ @testset "test env from test/Project.toml" begin
+ e = envs["Alpha/test"]
+ @test sort(collect(keys(e["deps"]))) == ["Alpha", "Test", "Tool"]
+ # compat of a dep the file does not list is dropped; only the owner is pinned
+ @test e["compat"] == Dict("Tool" => "1, 2", "Alpha" => "=0.3.1")
+ @test e["pins"] == Dict("Alpha" => "0.3.1")
+ end
+
+ @testset "test env from [targets]" begin
+ e = envs["Beta/test"]
+ # [targets] test, not every [extras]: Unused is left out, the weak dep is in
+ @test sort(collect(keys(e["deps"]))) == ["Beta", "Test", "Tool", "Weak"]
+ @test e["deps"]["Weak"] == "44444444-0000-4000-8000-000000000004"
+ # the package's own compat, restricted to those deps, plus julia
+ @test e["compat"] == Dict("Tool" => "2", "Weak" => "0.1, 0.2", "julia" => "1.10", "Beta" => "=1.2.0")
+ @test e["pins"] == Dict("Beta" => "1.2.0-beta")
+ end
+
+ @testset "docs env" begin
+ e = envs["Alpha/docs"]
+ # the package itself is added: docs/make.jl develops it
+ @test sort(collect(keys(e["deps"]))) == ["Alpha", "Doc"]
+ @test e["compat"] == Dict("Doc" => "0.9, 1", "Alpha" => "=0.3.1")
+ end
+
+ @testset "test env without [targets]" begin
+ mktempdir() do dir
+ write(joinpath(dir, "Project.toml"), """
+ name = "Gamma"
+ [extras]
+ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+ Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595"
+ [compat]
+ Aqua = "0.8"
+ """)
+ deps, compat = test_env("Gamma", "u-g", dir)
+ @test sort(collect(keys(deps))) == ["Aqua", "Gamma", "Test"]
+ @test compat == Dict("Aqua" => "0.8")
+ end
+ end
+
+ @testset "envs.toml round trip" begin
+ mktempdir() do dir
+ path = joinpath(dir, "envs.toml")
+ open(io -> TOML.print(io, Dict("env" => envs)), path, "w")
+ @test TOML.parsefile(path)["env"] == envs
+ end
+ end
+end
diff --git a/scripts/compat-prune/test/test_integration.jl b/scripts/compat-prune/test/test_integration.jl
new file mode 100644
index 0000000..afa470d
--- /dev/null
+++ b/scripts/compat-prune/test/test_integration.jl
@@ -0,0 +1,241 @@
+# Integration: real Pkg resolutions against the local General registry, then the
+# scripts run as commands on a one-package ecosystem: Example 0.5.5, whose docs declare
+# `OrderedCollections = "1, 2"` (only 2 is selectable with nothing else in the way).
+#
+# Run with COMPAT_PRUNE_INTEGRATION=1. Needs General installed (any recent copy).
+
+const EXAMPLE = "7876af07-990d-54b4-ab0e-23690620f79a"
+const ORDERED = "bac558e1-5e72-5ebc-8fee-abe8a469f55d"
+const JULIA = Base.julia_cmd()
+
+"Write a collected directory for Example 0.5.5 into `root`; `docs_compat` is the docs compat of OrderedCollections."
+function example_collected(root; docs_compat="1, 2")
+ reg = joinpath(root, "registered", "Example")
+ mkpath(joinpath(reg, "docs"))
+ write(joinpath(reg, "Project.toml"), """
+ name = "Example"
+ uuid = "$EXAMPLE"
+ version = "0.5.5"
+
+ [compat]
+ julia = "1"
+
+ [extras]
+ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+
+ [targets]
+ test = ["Test"]
+ """)
+ write(joinpath(reg, "docs", "Project.toml"), """
+ [deps]
+ OrderedCollections = "$ORDERED"
+
+ [compat]
+ OrderedCollections = "$docs_compat"
+ """)
+ cp(joinpath(root, "registered"), joinpath(root, "default"))
+ open(joinpath(root, "index.toml"), "w") do io
+ TOML.print(io, Dict("julia" => string(VERSION), "packages" => Dict("Example" => Dict(
+ "uuid" => EXAMPLE, "version" => "0.5.5", "repo" => "JuliaLang/Example.jl", "tree" => "0",
+ "default_branch" => "master", "files" => ["Project.toml", "docs/Project.toml"],
+ "default_files" => ["Project.toml", "docs/Project.toml"]))))
+ end
+ return root
+end
+
+"Run a script of compat-prune with `args` on the running Julia; return its output."
+run_script(script, args...) = read(`$JULIA --startup-file=no $(joinpath(SCRIPTS, script)) $args`, String)
+
+@testset "integration" begin
+ @testset "resolve_env" begin
+ r = resolve_env(Dict("Example" => EXAMPLE), Dict("Example" => "0.5"))
+ @test r["ok"]
+ @test startswith(r["versions"]["Example"], "0.5.")
+ # the registered tree is known, stdlibs have none
+ @test length(r["trees"]["Example"]) == 40
+ r = resolve_env(Dict("Example" => EXAMPLE, "LinearAlgebra" => "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"), Dict())
+ @test haskey(r["versions"], "LinearAlgebra") && !haskey(r["trees"], "LinearAlgebra")
+ # unsatisfiable: a result, not an exception
+ r = resolve_env(Dict("Example" => EXAMPLE), Dict("Example" => "99"))
+ @test !r["ok"] && occursin("Example", r["error"])
+ # anything else is a bug of the caller: thrown
+ @test_throws Exception resolve_env(Dict("Example" => EXAMPLE), Dict("Example" => "not a version"))
+ end
+
+ @testset "registry_snapshot" begin
+ snap = registry_snapshot()
+ @test any(r -> r["name"] == "General", snap)
+ @test issorted([r["name"] for r in snap])
+ @test all(r -> occursin(r"^(tree|commit) [0-9a-f]{40}$|^unknown$", r["state"]), snap)
+ end
+
+ @testset "registry_lookup" begin
+ @test registry_lookup("Example") == (EXAMPLE, "JuliaLang/Example.jl")
+ @test_throws ErrorException registry_lookup("NoSuchPackageForCompatPrune")
+ end
+
+ @testset "collect.jl as a command (fake gh on PATH)" begin
+ mktempdir() do dir
+ # canned answers of `gh api …`, one file per path
+ answers = joinpath(dir, "answers")
+ mkpath(answers)
+ put(path, text) = write(joinpath(answers, replace(path, r"[/?=]" => "_")), text)
+ project = "name = \"Example\"\nuuid = \"$EXAMPLE\"\nversion = \"0.5.5\"\n"
+ tree = resolve_env(Dict("Example" => EXAMPLE), Dict("Example" => "=0.5.5"))["trees"]["Example"]
+ put("repos/JuliaLang/Example.jl", "master\n")
+ put("repos/JuliaLang/Example.jl/git/trees/$tree?recursive=1", "Project.toml b1\nsrc/Example.jl b2\n")
+ put("repos/JuliaLang/Example.jl/git/trees/master?recursive=1", "Project.toml b3\n")
+ put("repos/JuliaLang/Example.jl/git/blobs/b1", project)
+ put("repos/JuliaLang/Example.jl/git/blobs/b3", project * "# moved on\n")
+ bin = joinpath(dir, "bin")
+ mkpath(bin)
+ write(joinpath(bin, "gh"), """
+ #!/bin/sh
+ exec cat "$answers/\$(printf '%s' "\$2" | tr '/?=' '___')"
+ """)
+ chmod(joinpath(bin, "gh"), 0o755)
+ out = joinpath(dir, "audit")
+ withenv("PATH" => bin * ":" * ENV["PATH"]) do
+ run_script("collect.jl", "[\"Example\"]", out)
+ end
+ index = TOML.parsefile(joinpath(out, "index.toml"))["packages"]["Example"]
+ @test index["version"] == "0.5.5" && index["tree"] == tree
+ @test index["repo"] == "JuliaLang/Example.jl" && index["default_branch"] == "master"
+ @test read(joinpath(out, "registered", "Example", "Project.toml"), String) == project
+ @test endswith(read(joinpath(out, "default", "Example", "Project.toml"), String), "# moved on\n")
+ end
+ end
+
+ @testset "checkout.sh (local origins)" begin
+ mktempdir() do dir
+ # two packages, one with a dict-valued index entry each (sorting must not compare them)
+ index = Dict("Beta" => Dict("repo" => "org/Beta.jl", "default_branch" => "dev"),
+ "Alpha" => Dict("repo" => "org/Alpha.jl", "default_branch" => "main"))
+ for (name, p) in index
+ bare = joinpath(dir, "org", "$name.jl.git")
+ work = joinpath(dir, "seed-$name")
+ run(`git -c init.defaultBranch=$(p["default_branch"]) init --quiet $work`)
+ write(joinpath(work, "Project.toml"), "name = \"$name\"\n")
+ run(`git -C $work add --all`)
+ run(`git -C $work -c user.name=t -c user.email=t@example.org commit --quiet -m init`)
+ run(`git clone --quiet --bare $work $bare`)
+ end
+ audit = joinpath(dir, "audit")
+ mkpath(audit)
+ open(io -> TOML.print(io, Dict("packages" => index)), joinpath(audit, "index.toml"), "w")
+ withenv("COMPAT_PRUNE_GIT_BASE" => "file://$dir") do
+ run(`$(joinpath(SCRIPTS, "checkout.sh")) $audit $(joinpath(dir, "checkouts"))`)
+ end
+ for (name, p) in index
+ co = joinpath(dir, "checkouts", name)
+ @test read(joinpath(co, "Project.toml"), String) == "name = \"$name\"\n"
+ @test readchomp(`git -C $co rev-parse --abbrev-ref HEAD`) == p["default_branch"]
+ end
+ end
+ end
+
+ @testset "simulate" begin
+ env = Dict("deps" => Dict("Example" => EXAMPLE), "compat" => Dict("Example" => "=0.5.5"),
+ "pins" => Dict("Example" => "0.5.5"))
+ base = simulate("x", env, Dict(), Dict())
+ @test base["ok"] && base["versions"]["Example"] == "0.5.5"
+ # the pin is checked: =0.5.4 admitted, but the audited version says 0.5.5
+ env4 = Dict("deps" => env["deps"], "compat" => Dict("Example" => "=0.5.4"), "pins" => env["pins"])
+ r = simulate("x", env4, Dict(), Dict())
+ @test !r["ok"] && occursin("pinned versions not selected: Example 0.5.4 (audited 0.5.5)", r["error"])
+ # a narrowing that moves a transitive dependency departs from the baseline
+ free = Dict("deps" => Dict("Example" => EXAMPLE, "OrderedCollections" => ORDERED), "compat" => Dict(), "pins" => Dict())
+ b = simulate("x", free, Dict(), Dict())
+ @test b["versions"]["OrderedCollections"] == "2.0.3" || VersionNumber(b["versions"]["OrderedCollections"]) ≥ v"2"
+ r = simulate("x", free, Dict("OrderedCollections" => "1"), Dict("OrderedCollections" => ORDERED))
+ @test startswith(departure(r, b["versions"]), "manifest differs: OrderedCollections")
+ @test departure(simulate("x", free, Dict("OrderedCollections" => "2"), Dict("OrderedCollections" => ORDERED)), b["versions"]) === nothing
+ end
+
+ @testset "scripts end to end" begin
+ mktempdir() do dir
+ root = example_collected(joinpath(dir, "audit"))
+ out = run_script("envs.jl", root)
+ @test occursin("3 environments: Example/docs, Example/test, joint", out)
+ j = join(split(string(VERSION), ".")[1:2], ".")
+ base = joinpath(root, "baseline-$j.toml")
+ run_script("resolve.jl", root, base)
+ b = TOML.parsefile(base)
+ @test all(r -> r["ok"], values(b["env"]))
+ @test VersionNumber(b["env"]["Example/docs"]["versions"]["OrderedCollections"]) ≥ v"2"
+
+ classified = joinpath(root, "classified.toml")
+ out = run_script("classify.jl", root, classified, base)
+ @test occursin("prune → 2", out)
+ c = TOML.parsefile(classified)
+ p = only(c["prune"])
+ @test entry_key(p) == ("Example", "docs/Project.toml", "OrderedCollections") && p["after"] == "2"
+
+ # validation accepts it
+ empty_rejected = joinpath(root, "rejected.toml")
+ write(empty_rejected, "")
+ validated = joinpath(root, "validated.toml")
+ run_script("validate.jl", root, classified, base, validated, empty_rejected, "--blockers")
+ @test isempty(TOML.parsefile(validated)["rejected"])
+
+ # resolve.jl with the prunes: same manifests
+ pruned = joinpath(root, "pruned.toml")
+ run_script("resolve.jl", root, pruned, classified, base)
+ @test TOML.parsefile(pruned)["env"] == b["env"]
+
+ # a prune that moves the manifest is rejected
+ c["prune"][1]["after"] = "1"
+ bad = joinpath(root, "classified-bad.toml")
+ open(io -> TOML.print(io, c), bad, "w")
+ run_script("validate.jl", root, bad, base, validated, empty_rejected)
+ r = only(TOML.parsefile(validated)["rejected"])
+ @test r["where"] == "Example/docs@$j"
+ @test occursin("OrderedCollections", r["reason"])
+
+ # edit a checkout, then report
+ open(joinpath(root, "audit.toml"), "w") do io
+ TOML.print(io, Dict("date" => "2026-10-15 00:00 UTC", "julia" => [j], "prune" => TOML.parsefile(classified)["prune"],
+ "rejected" => [], "blocked" => [], "other" => [],
+ "packages" => TOML.parsefile(joinpath(root, "index.toml"))["packages"]))
+ end
+ checkouts = joinpath(dir, "checkouts")
+ cp(joinpath(root, "default"), checkouts)
+ out = run_script("edit.jl", root, checkouts)
+ @test occursin(r"edited\s+1", out)
+ @test occursin("OrderedCollections = \"2\"", read(joinpath(checkouts, "Example", "docs", "Project.toml"), String))
+ @test read(joinpath(checkouts, "Example", "Project.toml"), String) ==
+ read(joinpath(root, "default", "Example", "Project.toml"), String)
+ # a second run finds it done
+ out = run_script("edit.jl", root, checkouts)
+ @test occursin(r"already\s+1", out)
+ run_script("report.jl", root)
+ report = read(joinpath(root, "report.md"), String)
+ @test startswith(report, MARKER)
+ @test occursin("| merged, awaiting release | 1 |", report)
+ end
+ end
+
+ @testset "a blocked entry is probed" begin
+ mktempdir() do dir
+ # newest lane 3 does not exist: never selected, the probe cannot resolve it
+ root = example_collected(joinpath(dir, "audit"); docs_compat="1, 2, 3")
+ run_script("envs.jl", root)
+ j = join(split(string(VERSION), ".")[1:2], ".")
+ base = joinpath(root, "baseline-$j.toml")
+ run_script("resolve.jl", root, base)
+ classified = joinpath(root, "classified.toml")
+ run_script("classify.jl", root, classified, base)
+ c = TOML.parsefile(classified)
+ b = only(filter(r -> r["status"] == "blocked", c["other"]))
+ @test b["newest"] == "3" && b["prunable"] == ["1"]
+ # its obsolete lane is still a prune
+ @test only(c["prune"])["after"] == "2, 3"
+ rejected = joinpath(root, "rejected.toml"); write(rejected, "")
+ validated = joinpath(root, "validated.toml")
+ run_script("validate.jl", root, classified, base, validated, rejected, "--blockers")
+ probe = only(TOML.parsefile(validated)["blocked"])
+ @test probe["where"] == "Example/docs@$j"
+ @test occursin("OrderedCollections", probe["detail"])
+ end
+ end
+end
diff --git a/scripts/compat-prune/test/test_publish.jl b/scripts/compat-prune/test/test_publish.jl
new file mode 100644
index 0000000..f1eceef
--- /dev/null
+++ b/scripts/compat-prune/test/test_publish.jl
@@ -0,0 +1,228 @@
+# publish.jl: idempotent PRs and tracking issue, against a stateful fake GitHub and real git
+# repositories (a local bare repository plays `origin` for each package).
+
+include(joinpath(SCRIPTS, "publish.jl"))
+
+"""
+A fake GitHub keeping issues and PRs. `origins` maps a repo ("owner/name") to its bare
+repository, where `pr close --delete-branch` deletes the branch.
+"""
+mutable struct FakeGitHub
+ origins::Dict{String,String}
+ issues::Vector{Dict{String,Any}}
+ prs::Vector{Dict{String,Any}}
+ calls::Vector{Vector{String}}
+end
+FakeGitHub(origins) = FakeGitHub(origins, Dict{String,Any}[], Dict{String,Any}[], Vector{String}[])
+
+opt(args, name) = args[findfirst(==(name), args) + 1]
+
+function (f::FakeGitHub)(args)
+ push!(f.calls, args)
+ cmd = args[1:2]
+ if cmd == ["api", "--paginate"]
+ repo = match(r"^repos/([^/]+/[^/]+)/issues", args[3])[1]
+ hits = [i for i in f.issues if i["repo"] == repo && occursin(MARKER, i["body"])]
+ return join(["$(i["number"]) $(i["state"])" for i in hits], "\n") * (isempty(hits) ? "" : "\n")
+ elseif cmd == ["issue", "create"]
+ repo = opt(args, "--repo")
+ n = length(f.issues) + 100
+ push!(f.issues, Dict("repo" => repo, "number" => n, "state" => "open", "title" => opt(args, "--title"),
+ "body" => opt(args, "--body"), "comments" => String[]))
+ return "https://github.com/$repo/issues/$n\n"
+ elseif cmd[1] == "issue"
+ i = only(filter(i -> string(i["number"]) == args[3] && i["repo"] == opt(args, "--repo"), f.issues))
+ cmd[2] == "edit" && (i["body"] = opt(args, "--body"))
+ cmd[2] == "close" && (i["state"] = "closed"; push!(i["comments"], opt(args, "--comment")))
+ cmd[2] == "reopen" && (i["state"] = "open")
+ return ""
+ elseif cmd == ["pr", "list"]
+ repo, head = opt(args, "--repo"), opt(args, "--head")
+ hits = [p for p in f.prs if p["repo"] == repo && p["head"] == head && p["state"] == "open"]
+ return join(["$(p["number"]) $(p["url"])" for p in hits], "\n")
+ elseif cmd == ["pr", "create"]
+ repo = opt(args, "--repo")
+ n = length(f.prs) + 1
+ url = "https://github.com/$repo/pull/$n"
+ push!(f.prs, Dict("repo" => repo, "number" => n, "url" => url, "state" => "open", "head" => opt(args, "--head"),
+ "base" => opt(args, "--base"), "title" => opt(args, "--title"), "body" => opt(args, "--body")))
+ return url * "\n"
+ elseif cmd[1] == "pr"
+ p = only(filter(p -> string(p["number"]) == args[3] && p["repo"] == opt(args, "--repo"), f.prs))
+ cmd[2] == "edit" && (p["body"] = opt(args, "--body"))
+ if cmd[2] == "close"
+ p["state"] = "closed"
+ "--delete-branch" in args && run(`git -C $(f.origins[p["repo"]]) branch --quiet -D $(p["head"])`)
+ end
+ return ""
+ end
+ error("unexpected gh call: $args")
+end
+
+const GITC = `git -c user.name=test -c user.email=test@example.org -c init.defaultBranch=main`
+
+"A bare origin for each fixture package, holding its default-branch files; returns repo => path."
+function make_origins(dir)
+ index = TOML.parsefile(joinpath(COLLECTED, "index.toml"))["packages"]
+ origins = Dict{String,String}()
+ for (name, p) in index
+ bare = joinpath(dir, "origins", name * ".git")
+ run(`$GITC init --quiet --bare -b $(p["default_branch"]) $bare`)
+ work = joinpath(dir, "seed", name)
+ run(`$GITC clone --quiet $bare $work`)
+ src = joinpath(COLLECTED, "default", name)
+ foreach(f -> cp(joinpath(src, f), joinpath(work, f)), readdir(src))
+ run(`$GITC -C $work checkout --quiet -B $(p["default_branch"])`)
+ run(`$GITC -C $work add --all`)
+ run(`$GITC -C $work commit --quiet -m init`)
+ run(`$GITC -C $work push --quiet origin $(p["default_branch"])`)
+ origins[p["repo"]] = bare
+ end
+ return origins
+end
+
+"Fresh clones of every origin's default branch, then edit.jl's work: writes audit.toml, edits.toml."
+function prepare_run(dir, origins, prunes; tag)
+ index = TOML.parsefile(joinpath(COLLECTED, "index.toml"))["packages"]
+ root = joinpath(dir, "run-$tag")
+ checkouts = joinpath(root, "checkouts")
+ for (name, p) in index
+ run(`$GITC clone --quiet --branch $(p["default_branch"]) $(origins[p["repo"]]) $(joinpath(checkouts, name))`)
+ end
+ audit = Dict{String,Any}("date" => "2026-11-01 04:00 UTC", "julia" => ["1.10", "1.12"], "packages" => index,
+ "prune" => prunes, "rejected" => [], "blocked" => [], "other" => [])
+ open(io -> TOML.print(io, audit), joinpath(root, "audit.toml"), "w")
+ records, texts = plan_edits(audit, read_checkout(checkouts))
+ for ((owner, file), text) in texts
+ write(joinpath(checkouts, owner, file), text)
+ end
+ open(io -> TOML.print(io, Dict("edit" => records)), joinpath(root, "edits.toml"), "w")
+ return root, checkouts
+end
+
+"Commit `text` as `file` on the default branch of `origin` (a maintainer merging the PR)."
+function merge_on_origin(dir, origin, branch, file, text)
+ work = joinpath(dir, "merge-" * string(hash((origin, file, text)); base=16))
+ run(`$GITC clone --quiet --branch $branch $origin $work`)
+ write(joinpath(work, file), text)
+ run(`$GITC -C $work commit --quiet -am merged`)
+ run(`$GITC -C $work push --quiet origin $branch`)
+end
+
+branch_exists(origin) = success(`git -C $origin rev-parse --verify --quiet refs/heads/$BRANCH`)
+
+@testset "publish" begin
+ @testset "helpers" begin
+ evidence = Dict{String,Any}("0.6" => ["joint@1.10=0.6.4"], "0.7" => ["joint@1.12=0.7.2"])
+ edits = [merge(prune("Alpha", "Project.toml", "Dep1", "0.5, 0.6, 0.7", "0.6, 0.7"), Dict("status" => "edited", "evidence" => evidence)),
+ merge(prune("Alpha", "docs/Project.toml", "Doc", "0.9, 1", "1"), Dict("status" => "edited")),
+ merge(prune("Beta", "Project.toml", "Dep1", "0.6, 0.7", "0.7"), Dict("status" => "already"))]
+ @test commit_message("Alpha", edits) == """
+ chore(compat): drop [compat] specifiers no supported Julia version selects
+
+ Project.toml: Dep1 = "0.5, 0.6, 0.7" → "0.6, 0.7"
+ docs/Project.toml: Doc = "0.9, 1" → "1\""""
+ body = render_pr_body("Alpha", edits; issue_url="https://github.com/example-org/Root.jl/issues/100",
+ run_url="https://github.com/example-org/Root.jl/actions/runs/1")
+ @test golden("pr-body.md", body)
+ @test startswith(body, MARKER)
+ @test !occursin("Beta", body)
+ end
+
+ mktempdir() do dir
+ origins = make_origins(dir)
+ alpha, beta = origins["example-org/Alpha.jl"], origins["example-org/Beta.jl"]
+ fake = FakeGitHub(origins)
+ prunes = [prune("Alpha", "Project.toml", "Dep1", "0.5, 0.6, 0.7", "0.6, 0.7"),
+ prune("Alpha", "docs/Project.toml", "Doc", "0.9, 1", "1"),
+ prune("Beta", "Project.toml", "Dep1", "0.6, 0.7", "0.7")] # Beta's branch already has it
+
+ @testset "first run: issue, branch, PR" begin
+ root, checkouts = prepare_run(dir, origins, prunes; tag="1")
+ done = with_gh(() -> publish(root, checkouts, "example-org/Root.jl"; run_url="https://run/1"), fake)
+ @test length(fake.issues) == 1
+ issue = only(fake.issues)
+ @test issue["title"] == ISSUE_TITLE && issue["state"] == "open"
+ @test done["issue"] == "https://github.com/example-org/Root.jl/issues/100"
+ pr = only(fake.prs)
+ @test pr["repo"] == "example-org/Alpha.jl" && pr["head"] == BRANCH && pr["base"] == "main"
+ @test done["prs"] == Dict("Alpha" => pr["url"])
+ @test done["pushed"] == ["Alpha"]
+ # the PR links the issue, the issue links the PR
+ @test occursin(done["issue"], pr["body"])
+ @test occursin("### Alpha — $(pr["url"])", issue["body"])
+ # the branch holds the edits on top of the default branch, nothing else
+ @test branch_exists(alpha) && !branch_exists(beta)
+ diff = read(`git -C $alpha diff main $BRANCH`, String)
+ @test occursin("-Dep1 = \"0.5, 0.6, 0.7\" # lanes kept by CompatHelper", diff)
+ @test occursin("+Dep1 = \"0.6, 0.7\" # lanes kept by CompatHelper", diff)
+ @test occursin("+Doc = \"1\"", diff)
+ @test count(l -> startswith(l, "+") && !startswith(l, "+++"), split(diff, '\n')) == 2
+ @test strip(read(`git -C $alpha log -1 --format=%an $BRANCH`, String)) == BOT[1]
+ end
+
+ @testset "second run: nothing changes" begin
+ before = read(`git -C $alpha rev-parse $BRANCH`, String)
+ n_calls = length(fake.calls)
+ root, checkouts = prepare_run(dir, origins, prunes; tag="2")
+ done = with_gh(() -> publish(root, checkouts, "example-org/Root.jl"; run_url="https://run/2"), fake)
+ @test length(fake.issues) == 1 && length(fake.prs) == 1
+ @test isempty(done["pushed"])
+ @test read(`git -C $alpha rev-parse $BRANCH`, String) == before
+ calls = fake.calls[n_calls+1:end]
+ @test !any(c -> c[1:2] in (["issue", "create"], ["pr", "create"]), calls)
+ @test any(c -> c[1:2] == ["pr", "edit"], calls)
+ # bodies refreshed with the new run
+ @test occursin("https://run/2", only(fake.issues)["body"])
+ end
+
+ @testset "after the merge: PR closed, branch deleted, issue closed" begin
+ # the maintainer merges: the default branch now carries the pruned values
+ alpha_now = read(`git -C $alpha show main:Project.toml`, String)
+ merge_on_origin(dir, alpha, "main", "Project.toml", replace(alpha_now, "Dep1 = \"0.5, 0.6, 0.7\"" => "Dep1 = \"0.6, 0.7\""))
+ docs_now = read(`git -C $alpha show main:docs/Project.toml`, String)
+ merge_on_origin(dir, alpha, "main", "docs/Project.toml", replace(docs_now, "Doc = \"0.9, 1\"" => "Doc = \"1\""))
+ root, checkouts = prepare_run(dir, origins, prunes; tag="3")
+ @test all(e -> e["status"] == "already", TOML.parsefile(joinpath(root, "edits.toml"))["edit"])
+ done = with_gh(() -> publish(root, checkouts, "example-org/Root.jl"), fake)
+ @test done["closed"] == ["Alpha"]
+ @test only(fake.prs)["state"] == "closed"
+ @test !branch_exists(alpha)
+ issue = only(fake.issues)
+ @test issue["state"] == "closed" && issue["comments"] == ["Nothing left to prune."]
+ @test occursin("| merged, awaiting release | 3 |", issue["body"])
+ end
+
+ @testset "nothing to prune and no issue: nothing is created" begin
+ f = FakeGitHub(origins)
+ root, checkouts = prepare_run(dir, origins, prunes; tag="3b")
+ done = with_gh(() -> publish(root, checkouts, "example-org/Other.jl"), f)
+ @test isempty(f.issues) && isempty(f.prs) && done["issue"] == ""
+ end
+
+ @testset "a new prune: the same issue is reopened, a new PR opened" begin
+ again = [prune("Beta", "Project.toml", "Tool", "1, 2", "2")]
+ beta_now = read(`git -C $beta show dev:Project.toml`, String)
+ merge_on_origin(dir, beta, "dev", "Project.toml", replace(beta_now, "Tool = \"2\"" => "Tool = \"1, 2\""))
+ root, checkouts = prepare_run(dir, origins, again; tag="4")
+ done = with_gh(() -> publish(root, checkouts, "example-org/Root.jl"), fake)
+ @test length(fake.issues) == 1 && only(fake.issues)["state"] == "open"
+ @test length(fake.prs) == 2
+ pr = fake.prs[2]
+ @test pr["repo"] == "example-org/Beta.jl" && pr["base"] == "dev" && pr["state"] == "open"
+ @test done["prs"] == Dict("Beta" => pr["url"])
+ @test branch_exists(beta)
+ end
+
+ @testset "find_issue ignores PRs and issues without the marker" begin
+ f = FakeGitHub(origins)
+ push!(f.issues, Dict("repo" => "a/b", "number" => 7, "state" => "open", "body" => "no marker", "comments" => String[]))
+ @test with_gh(() -> find_issue("a/b"), f) === nothing
+ push!(f.issues, Dict("repo" => "a/b", "number" => 8, "state" => "closed", "body" => MARKER * "\nold", "comments" => String[]))
+ @test with_gh(() -> find_issue("a/b"), f) == ("8", "closed")
+ # the jq filter excludes pull requests and empty bodies
+ jq = f.calls[end][end]
+ @test occursin("select(.pull_request == null)", jq) && occursin("(.body // \"\")", jq)
+ end
+ end
+end
diff --git a/scripts/compat-prune/test/test_regression.jl b/scripts/compat-prune/test/test_regression.jl
new file mode 100644
index 0000000..6547026
--- /dev/null
+++ b/scripts/compat-prune/test/test_regression.jl
@@ -0,0 +1,66 @@
+# Non-regression on a real audit (2026-10-15): the collected files of the eight CT packages,
+# their default branches, and the baselines of Julia 1.10, 1.12, 1.13 (trimmed to the audited
+# packages and the dependencies named in a compat entry, which is all classification reads).
+# Environments, classification and edits must stay identical; the golden files are the
+# reviewed outputs of that audit.
+
+const SNAPSHOT = joinpath(FIXTURES, "snapshot-2026-10-15")
+
+"Lines `owner | file | dep | status | detail`, sorted, for a golden file."
+summary_lines(records, detail) =
+ join(sort(["$(r["owner"]) | $(r["file"]) | $(r["dep"]) | $(r["status"]) | $(detail(r))" for r in records]), "\n") * "\n"
+
+@testset "regression 2026-10-15" begin
+ mktempdir() do dir
+ root = joinpath(dir, "snapshot")
+ cp(SNAPSHOT, root)
+ envs = build_envs(root)
+ open(io -> TOML.print(io, Dict("env" => envs)), joinpath(root, "envs.toml"), "w")
+
+ @testset "environments" begin
+ @test length(envs) == 17
+ text = join(sort(["$name: deps=$(join(sort(collect(keys(e["deps"]))), ",")) " *
+ "pins=$(join(sort(["$k=$v" for (k, v) in e["pins"]]), ","))" for (name, e) in envs]), "\n") * "\n"
+ @test golden("envs-2026-10-15.txt", text)
+ # the docs env of every package contains the package itself
+ @test all(e -> haskey(e["deps"], e["owner"]), [e for e in values(envs) if e["kind"] == "docs"])
+ end
+
+ results = load_results([joinpath(root, "baseline-$j.toml") for j in ("1.10", "1.12", "1.13")])
+ prunes, others = classify_all(root, results)
+
+ @testset "classification" begin
+ detail(r) = r["status"] == "prune" ? r["after"] : r["status"] == "blocked" ? "newest " * r["newest"] : ""
+ @test golden("classified-2026-10-15.txt", summary_lines(vcat(prunes, others), detail))
+ got = Dict(entry_key(r) => r for r in vcat(prunes, others))
+ @test length(prunes) == 42
+ # CTSolvers keeps CUDSS 0.7: OptimalControl's tests and docs select it
+ @test got[("CTSolvers", "Project.toml", "CUDSS")]["after"] == "0.7, 0.8"
+ @test got[("CTSolvers", "docs/Project.toml", "CUDSS")]["after"] == "0.8"
+ # JET 0.9 is what Julia 1.10 selects
+ @test got[("CTBase", "Project.toml", "JET")]["after"] == "0.9, 0.12"
+ # CTParser's own CTBase 0.29 lane is used by its test env (CTModels 0.18)
+ @test got[("CTParser", "Project.toml", "CTBase")]["status"] == "keep"
+ @test got[("CTParser", "test/Project.toml", "CTBase")]["status"] == "blocked"
+ @test got[("CTBase", "Project.toml", "DocStringExtensions")]["status"] == "blocked"
+ end
+
+ @testset "edits against the default branches" begin
+ records, texts = plan_edits(Dict("prune" => prunes), read_checkout(joinpath(root, "default")))
+ @test golden("edits-2026-10-15.txt", summary_lines(records, r -> r["detail"]))
+ @test count(r -> r["status"] == "edited", records) == 35
+ drift = [r for r in records if r["status"] == "drift"]
+ # CTParser's default branch is behind its registered version (CTParser#326)
+ @test length(drift) == 7 && all(r -> r["owner"] == "CTParser", drift)
+ # every edited file differs from its default branch by compat lines only
+ for ((owner, file), text) in texts
+ original = read(joinpath(root, "default", owner, file), String)
+ a, b = TOML.parse(original), TOML.parse(text)
+ delete!(a, "compat"); delete!(b, "compat")
+ @test a == b
+ changed = [l for l in split(text, '\n') if !(l in split(original, '\n'))]
+ @test all(l -> occursin(r"^\w+ = \"[^\"]+\"$", l), changed)
+ end
+ end
+ end
+end
diff --git a/scripts/compat-prune/test/test_report.jl b/scripts/compat-prune/test/test_report.jl
new file mode 100644
index 0000000..0b439ba
--- /dev/null
+++ b/scripts/compat-prune/test/test_report.jl
@@ -0,0 +1,121 @@
+# report.jl: Markdown body of the tracking issue (golden file + properties).
+
+"An audit and its edits covering every section of the report."
+function report_fixture()
+ index = TOML.parsefile(joinpath(COLLECTED, "index.toml"))["packages"]
+ ev(pairs...) = Dict{String,Any}(spec => list for (spec, list) in pairs)
+ edited = [
+ merge(prune("Alpha", "Project.toml", "Dep1", "0.5, 0.6, 0.7", "0.6, 0.7"), Dict(
+ "evidence" => ev("0.6" => ["joint@1.10=0.6.4"], "0.7" => ["joint@1.12=0.7.2", "Alpha/test@1.12=0.7.2"]),
+ "status" => "edited", "detail" => "")),
+ merge(prune("Alpha", "test/Project.toml", "Tool", "1, 2", "2"), Dict(
+ "evidence" => ev("2" => ["Alpha/test@1.10=2.0.0", "Alpha/test@1.12=2.0.0"]),
+ "status" => "edited", "detail" => "")),
+ merge(prune("Beta", "Project.toml", "Dep1", "0.6, 0.7", "0.7"), Dict(
+ "evidence" => ev("0.7" => ["Beta/test@1.12=0.7.2"]), "status" => "edited", "detail" => "")),
+ ]
+ already = [merge(prune("Beta", "Project.toml", "Tool", "1, 2", "2"), Dict("status" => "already", "detail" => "already `2`"))]
+ drift = [merge(prune("Alpha", "docs/Project.toml", "Doc", "0.8, 0.9", "0.9"),
+ Dict("status" => "drift", "detail" => "`0.9, 1` on the default branch, `0.8, 0.9` audited"))]
+ audit = Dict{String,Any}(
+ "date" => "2026-10-15 19:48 UTC",
+ "julia" => ["1.10", "1.12", "1.13"],
+ "packages" => index,
+ "prune" => vcat(edited, already, drift),
+ "rejected" => [Dict("owner" => "Beta", "file" => "Project.toml", "dep" => "Weak", "where" => "Beta/test@1.10",
+ "reason" => "manifest differs: A 1.0.0 → 1.1.0 | odd\nsecond line")],
+ "blocked" => [
+ Dict("owner" => "Alpha", "file" => "docs/Project.toml", "dep" => "Doc", "newest" => "1",
+ "where" => "Alpha/docs@1.13", "blockers" => ["Documenter"], "detail" => ""),
+ Dict("owner" => "Alpha", "file" => "docs/Project.toml", "dep" => "Doc", "newest" => "1",
+ "where" => "joint@1.13", "blockers" => ["Beta", "Documenter"], "detail" => ""),
+ Dict("owner" => "Beta", "file" => "Project.toml", "dep" => "Weak", "newest" => "0.2",
+ "where" => "Beta/test@1.13", "blockers" => String[], "detail" => "resolves, but…"),
+ ],
+ "other" => [Dict("owner" => "Alpha", "file" => "Project.toml", "dep" => "Dep2", "before" => "1, 2",
+ "removed" => ["1"], "status" => "withheld",
+ "reason" => "baseline failed in joint@1.10: lanes used there are unknown"),
+ Dict("owner" => "Beta", "file" => "Project.toml", "dep" => "Dep3", "before" => "0.9, 1", "status" => "keep",
+ "evidence" => ev("0.9" => ["joint@1.10=0.9.4"], "1" => ["joint@1.12=1.2.0"])),
+ Dict("owner" => "Alpha", "file" => "test/Project.toml", "dep" => "Gpu", "before" => "5, 6", "status" => "idle",
+ "evidence" => ev())],
+ "record" => Dict(
+ "registries" => [Dict("name" => "General", "state" => "tree aff17d8a1e3ecb7cf9a37f2f40543c57577276fd"),
+ Dict("name" => "ct-registry", "state" => "commit 0123456789abcdef0123456789abcdef01234567")],
+ "coverage" => [Dict("julia" => "1.10", "resolved" => 3, "total" => 4, "failed" => ["joint"]),
+ Dict("julia" => "1.12", "resolved" => 4, "total" => 4, "failed" => String[])],
+ "entries" => Dict("examined" => 9, "prune" => 4, "rejected" => 1, "keep" => 1, "blocked" => 2, "idle" => 1, "withheld" => 1)),
+ )
+ return audit, vcat(edited, already, drift)
+end
+
+@testset "report" begin
+ audit, edits = report_fixture()
+ prs = Dict("Alpha" => "https://github.com/example-org/Alpha.jl/pull/12")
+ body = render_report(audit, edits; prs, run_url="https://github.com/example-org/Root.jl/actions/runs/1")
+
+ @testset "golden" begin
+ @test golden("report.md", body)
+ end
+
+ @testset "marker and sections" begin
+ @test startswith(body, MARKER * "\n")
+ for h in ("## Audit", "## Summary", "## To prune", "### Alpha — https://github.com/example-org/Alpha.jl/pull/12",
+ "### Beta", "## Merged, awaiting release", "## Drift", "## Rejected", "## Withheld",
+ "## Blocked", "## Audited versions")
+ @test occursin("\n" * h * "\n", body)
+ end
+ # the reason of a rejection stays on one table row
+ @test occursin("A 1.0.0 → 1.1.0 \\| odd second line", body)
+ # blockers aggregated over environments; none found → resolver preference
+ @test occursin("| Alpha `docs/Project.toml` `Doc` | `1` | Beta, Documenter |", body)
+ @test occursin("| Beta `Project.toml` `Weak` | `0.2` | resolver preference |", body)
+ @test occursin("| to prune (open PRs) | 3 |", body)
+ @test occursin("| blocked (information) | 2 |", body)
+ end
+
+ @testset "audit record" begin
+ @test occursin("| 1.10 | 3 / 4 (failed: joint) |", body)
+ @test occursin("| 1.12 | 4 / 4 |", body)
+ @test occursin("9 multi-specifier `[compat]` entries examined: 4 with lanes to prune, 1 rejected, 2 blocked, " *
+ "1 kept (every lane in use), 1 idle (resolved nowhere they apply), 1 withheld.", body)
+ @test occursin("Registries: General (tree aff17d8a1e3e), ct-registry (commit 0123456789ab).", body)
+ @test occursin("Entries left as they are (2)", body)
+ @test occursin("| Beta `Project.toml` `Dep3` | `0.9, 1` | keep | every lane in use: 1.2.0 on 1.12; 0.9.4 on 1.10 |", body)
+ @test occursin("| Alpha `test/Project.toml` `Gpu` | `5, 6` | idle | not resolved where the entry applies |", body)
+ # the section comes before the summary
+ @test findfirst("## Audit\n", body).start < findfirst("## Summary", body).start
+ @test short_state("tree aff17d8a1e3ecb7cf9a37f2f40543c57577276fd") == "tree aff17d8a1e3e"
+ @test short_state("unknown") == "unknown"
+ end
+
+ @testset "deterministic" begin
+ shuffled = deepcopy(audit)
+ reverse!(shuffled["blocked"])
+ @test render_report(shuffled, reverse(edits); prs, run_url="https://github.com/example-org/Root.jl/actions/runs/1") == body
+ end
+
+ @testset "empty sections are left out" begin
+ quiet = Dict{String,Any}("date" => "2026-11-01 03:00 UTC", "julia" => ["1.10"], "packages" => audit["packages"],
+ "prune" => [], "rejected" => [], "blocked" => [], "other" => [])
+ # without a record (older audit.toml): no Audit section
+ @test !occursin("## Audit\n", render_report(quiet, []))
+ quiet["record"] = Dict("registries" => [], "coverage" => [], "entries" => Dict(
+ "examined" => 0, "prune" => 0, "rejected" => 0, "keep" => 0, "blocked" => 0, "idle" => 0, "withheld" => 0))
+ b = render_report(quiet, [])
+ @test occursin("## Audit\n", b) && !occursin("", b)
+ @test occursin("| to prune (open PRs) | 0 |", b)
+ for h in ("## To prune", "## Merged", "## Drift", "## Rejected", "## Withheld", "## Blocked")
+ @test !occursin(h, b)
+ end
+ @test !occursin("[run]", b)
+ @test occursin("## Audited versions", b)
+ end
+
+ @testset "helpers" begin
+ @test table(["a", "b"], [["1", "2"]]) == "| a | b |\n| --- | --- |\n| 1 | 2 |"
+ @test selected(Dict("0.9" => ["CTBase/test@1.10=0.9.18"], "0.12" => ["CTBase/test@1.12=0.12.3", "joint@1.13=0.12.3"])) ==
+ "0.12.3 on 1.12, 1.13; 0.9.18 on 1.10"
+ @test selected(Dict{String,Any}()) == ""
+ end
+end
diff --git a/scripts/compat-prune/test/test_validate.jl b/scripts/compat-prune/test/test_validate.jl
new file mode 100644
index 0000000..819fd24
--- /dev/null
+++ b/scripts/compat-prune/test/test_validate.jl
@@ -0,0 +1,107 @@
+# validate.jl: departures, rejection of failing prunes (fake simulator), blockers.
+
+"""
+A fake simulator: the baseline `versions` of each environment, departed from (`Tool` moves to
+9.9.9) when `breaks(name, narrowings)` holds. Records every call.
+"""
+function fake_sim(baseline; breaks=(name, n) -> false, error_for=Dict())
+ calls = []
+ sim = function (name, env, n, uuids)
+ push!(calls, (name, copy(n)))
+ haskey(error_for, name) && return failed(error_for[name])
+ versions = copy(baseline[name]["versions"])
+ breaks(name, n) && (versions["Tool"] = "9.9.9")
+ return Dict("ok" => true, "versions" => versions)
+ end
+ return sim, calls
+end
+
+@testset "validate" begin
+ @testset "departure" begin
+ base = Dict("A" => "1.0.0", "B" => "2.0.0")
+ @test departure(ok("A" => "1.0.0", "B" => "2.0.0"), base) === nothing
+ @test departure(ok("A" => "1.0.0", "B" => "2.1.0"), base) == "manifest differs: B 2.0.0 → 2.1.0"
+ @test departure(ok("A" => "1.0.0"), base) == "manifest differs: B 2.0.0 → —"
+ @test departure(ok("A" => "1.0.0", "B" => "2.0.0", "C" => "3.0.0"), base) == "manifest differs: C — → 3.0.0"
+ @test departure(failed("empty compat intersection for CUDSS"), base) == "empty compat intersection for CUDSS"
+ many = Dict("P$i" => "1.0.$i" for i in 1:9)
+ @test endswith(departure(ok(), many), ", …")
+ end
+
+ @testset "blockers" begin
+ @test blockers(CONFLICT_ON_DEP, "DocStringExtensions") == ["SciMLBase"]
+ @test blockers(CONFLICT_ON_OTHER, "DocStringExtensions") == ["DocumenterVitepress"]
+ @test isempty(blockers("empty compat intersection for CUDSS", "CUDSS"))
+ end
+
+ with_collected() do root
+ envs, audited, uuids = load_collected(root)
+ baseline = Dict(name => r for (k, r) in fixture_results() for name in (first(split(k, "@")),) if endswith(k, "@1.12"))
+ dep1 = prune("Alpha", "Project.toml", "Dep1", "0.5, 0.6, 0.7", "0.6, 0.7")
+ tool = prune("Alpha", "test/Project.toml", "Tool", "1, 2", "2")
+
+ @testset "nothing departs" begin
+ sim, calls = fake_sim(baseline)
+ @test isempty(validate_prunes(envs, audited, uuids, [dep1, tool], baseline, "1.12"; sim))
+ # one combined simulation per environment where a prune applies: Dep1 wherever
+ # Alpha is (not Beta/test), Tool in Alpha/test only
+ @test calls == [("Alpha/docs", Dict("Dep1" => "0.6, 0.7")),
+ ("Alpha/test", Dict("Dep1" => "0.6, 0.7", "Tool" => "2")),
+ ("joint", Dict("Dep1" => "0.6, 0.7"))]
+ end
+
+ @testset "a culprit fails alone" begin
+ sim, calls = fake_sim(baseline; breaks=(name, n) -> get(n, "Tool", "") == "2")
+ rejected = validate_prunes(envs, audited, uuids, [dep1, tool], baseline, "1.12"; sim)
+ @test length(rejected) == 1
+ r = only(rejected)
+ @test entry_key(r) == entry_key(tool)
+ @test r["where"] == "Alpha/test@1.12"
+ @test r["reason"] == "manifest differs: Tool 2.0.0 → 9.9.9"
+ end
+
+ @testset "prunes that fail only together are all rejected" begin
+ sim, _ = fake_sim(baseline; breaks=(name, n) -> haskey(n, "Tool") && haskey(n, "Dep1"))
+ rejected = validate_prunes(envs, audited, uuids, [dep1, tool], baseline, "1.12"; sim)
+ @test sort(entry_key.(rejected)) == sort(entry_key.([dep1, tool]))
+ @test all(r -> startswith(r["reason"], "fails only combined with other prunes"), rejected)
+ end
+
+ @testset "a rejected prune is not simulated again in later environments" begin
+ # Dep1 breaks everywhere: rejected in the first environment (sorted: Alpha/docs),
+ # then absent from the simulations of the next ones.
+ sim, calls = fake_sim(baseline; breaks=(name, n) -> haskey(n, "Dep1"))
+ rejected = validate_prunes(envs, audited, uuids, [dep1], baseline, "1.12"; sim)
+ @test only(rejected)["where"] == "Alpha/docs@1.12"
+ # combined + alone in Alpha/docs, then nothing left to simulate
+ @test length(calls) == 2
+ end
+
+ @testset "environments that failed at baseline are skipped" begin
+ b = copy(baseline)
+ b["joint"] = failed()
+ sim, calls = fake_sim(baseline)
+ validate_prunes(envs, audited, uuids, [dep1], b, "1.12"; sim)
+ @test !("joint" in first.(calls))
+ end
+
+ @testset "probe_blockers" begin
+ blocked = [Dict("owner" => "Alpha", "file" => "docs/Project.toml", "dep" => "Doc",
+ "newest" => "1", "applies_in" => ["Alpha/docs", "Alpha/test"])]
+ msg = replace(CONFLICT_ON_OTHER, "DocStringExtensions" => "Doc")
+ sim, calls = fake_sim(baseline; error_for=Dict("Alpha/docs" => msg))
+ found = probe_blockers(envs, uuids, blocked, baseline, "1.12"; sim)
+ # Alpha/test does not have Doc: not probed
+ @test calls == [("Alpha/docs", Dict("Doc" => "1"))]
+ f = only(found)
+ @test f["where"] == "Alpha/docs@1.12"
+ @test f["blockers"] == ["DocumenterVitepress"]
+ @test f["newest"] == "1"
+ # restricted to the newest, it resolves: the resolver merely prefers other versions
+ sim, _ = fake_sim(baseline)
+ f = only(probe_blockers(envs, uuids, blocked, baseline, "1.12"; sim))
+ @test isempty(f["blockers"])
+ @test f["detail"] == "resolves, but the resolver prefers other versions"
+ end
+ end
+end
diff --git a/scripts/compat-prune/validate.jl b/scripts/compat-prune/validate.jl
new file mode 100644
index 0000000..9bc678d
--- /dev/null
+++ b/scripts/compat-prune/validate.jl
@@ -0,0 +1,119 @@
+# Validate the prunes and identify the blockers, on the running Julia.
+#
+# usage: julia validate.jl [--blockers]
+#
+# Prunes: all of them (minus those already rejected) are simulated together; an environment
+# whose manifest differs from the baseline, or that no longer resolves, is a failure. The
+# prunes that apply in a failing environment are then simulated one by one there: those that
+# fail alone are rejected; if none does, the failure comes from their combination and all of
+# them are rejected. `audit.jl` repeats until no Julia version rejects anything.
+#
+# Blockers (`--blockers`, given by `audit.jl` on the newest Julia version only, as they are
+# information and cost one resolution per environment): for each blocked entry, each
+# environment where it applies is resolved with the dependency restricted to the newest
+# specifier; the resolver's explanation names the packages that cap it.
+
+include("common.jl")
+
+"Short description of how `r` departs from the baseline versions `base`."
+function departure(r, base)
+ r["ok"] || return first(r["error"], 600)
+ d = sort(["$k $(get(base, k, "—")) → $(get(r["versions"], k, "—"))"
+ for k in union(keys(base), keys(r["versions"])) if get(base, k, "") != get(r["versions"], k, "")])
+ isempty(d) ? nothing : "manifest differs: " * join(first(d, 6), ", ") * (length(d) > 6 ? ", …" : "")
+end
+
+"""
+ blockers(msg, dep) -> Vector{String}
+
+Packages that cap `dep`, read from the resolver's error when `dep` is restricted to its newest
+specifier. The resolver reports the first conflict it meets, not every one: when that conflict
+is on another package, that package is the blocker (its compat excludes the newest `dep`);
+when it is on `dep` itself, the blockers are the first-level `restricted by compatibility
+requirements with …` lines of its log (deeper lines explain those blockers, they are not
+blockers of `dep`).
+"""
+function blockers(msg, dep)
+ head = match(r"Unsatisfiable requirements detected for package (\S+) \[", msg)
+ head === nothing && return String[]
+ head[1] == dep || return [String(head[1])]
+ first_level = r"^ [├└]─restricted by compatibility requirements with (\S+) \["m
+ return sort(unique(String[m[1] for m in eachmatch(first_level, msg)]))
+end
+
+
+"""
+ validate_prunes(envs, audited, uuids, prunes, baseline, julia; sim=simulate) -> rejected
+
+Simulate `prunes` together in every environment of `envs` that resolved at `baseline`; in an
+environment that departs from its baseline, simulate the prunes that apply there one by one
+and reject those that fail alone, or all of them when none does. Returns the rejected
+records (`owner`, `file`, `dep`, `where`, `reason`).
+"""
+function validate_prunes(envs, audited, uuids, prunes, baseline, julia; sim=simulate)
+ prunes = copy(prunes)
+ rejected = Dict{String,Any}[]
+ for name in sort(collect(keys(envs)))
+ base = baseline[name]
+ base["ok"] || continue
+ n = narrowings(prunes, name, base["versions"], audited)
+ isempty(n) && continue
+ why = departure(sim(name, envs[name], n, uuids), base["versions"])
+ why === nothing && continue
+ println(julia, " ", rpad(name, 24), "fails together: ", why)
+ suspects = [p for p in prunes if !isempty(narrowings([p], name, base["versions"], audited))]
+ alone = Dict{Any,String}()
+ for p in suspects
+ w = departure(sim(name, envs[name], narrowings([p], name, base["versions"], audited), uuids), base["versions"])
+ w === nothing || (alone[entry_key(p)] = w)
+ end
+ culprits = isempty(alone) ? suspects : [p for p in suspects if haskey(alone, entry_key(p))]
+ for p in culprits
+ reason = get(alone, entry_key(p), "fails only combined with other prunes: $why")
+ push!(rejected, Dict("owner" => p["owner"], "file" => p["file"], "dep" => p["dep"],
+ "where" => "$name@$julia", "reason" => reason))
+ println(" rejected: $(p["owner"]) $(p["file"]) $(p["dep"]) → $(p["after"])")
+ end
+ gone = Set(entry_key.(culprits))
+ filter!(p -> !(entry_key(p) in gone), prunes)
+ end
+ return rejected
+end
+
+"""
+ probe_blockers(envs, uuids, blocked, baseline, julia; sim=simulate) -> probes
+
+For each blocked entry, resolve every environment where it applies with the dependency
+restricted to its newest specifier, and read the blockers from the resolver's error.
+"""
+function probe_blockers(envs, uuids, blocked, baseline, julia; sim=simulate)
+ found = Dict{String,Any}[]
+ for b in blocked, name in b["applies_in"]
+ base = baseline[name]
+ base["ok"] && haskey(base["versions"], b["dep"]) || continue
+ r = sim(name, envs[name], Dict(b["dep"] => b["newest"]), uuids)
+ push!(found, Dict("owner" => b["owner"], "file" => b["file"], "dep" => b["dep"],
+ "newest" => b["newest"], "where" => "$name@$julia",
+ "blockers" => r["ok"] ? String[] : blockers(r["error"], b["dep"]),
+ "detail" => r["ok"] ? "resolves, but the resolver prefers other versions" : first(r["error"], 2000)))
+ println(julia, " blocked: $(b["owner"]) $(b["file"]) $(b["dep"]) $(b["newest"]) in $name, by ",
+ r["ok"] ? "nothing (resolver preference)" : join(found[end]["blockers"], ", "))
+ end
+ return found
+end
+
+if abspath(PROGRAM_FILE) == @__FILE__
+ # usage: … [--blockers]
+ root, classified, basefile, outfile, rejectedfile = ARGS[1:5]
+ rejected_before = Set(entry_key.(get(TOML.parsefile(rejectedfile), "rejected", [])))
+ envs, audited, uuids = load_collected(root)
+ c = TOML.parsefile(classified)
+ prunes = [p for p in c["prune"] if !(entry_key(p) in rejected_before)]
+ baseline = TOML.parsefile(basefile)["env"]
+ julia = join(split(string(VERSION), ".")[1:2], ".")
+ rejected = validate_prunes(envs, audited, uuids, prunes, baseline, julia)
+ blocked = [b for b in get(c, "other", []) if b["status"] == "blocked"]
+ found = "--blockers" in ARGS ? probe_blockers(envs, uuids, blocked, baseline, julia) : Dict{String,Any}[]
+ open(io -> TOML.print(io, Dict("julia" => string(VERSION), "rejected" => rejected, "blocked" => found)), outfile, "w")
+ println(julia, " ", length(rejected), " prune(s) rejected, ", length(found), " blocker probe(s)")
+end