From 4253f1fb3044f9db0eb654ef979f637cd1c676c5 Mon Sep 17 00:00:00 2001 From: Matt Fishman Date: Thu, 23 Apr 2026 15:02:39 -0400 Subject: [PATCH] check-compat-bounds: restore full-workspace resolution + bucket rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the core-only resolution introduced in #78 in favor of a more targeted rule that addresses the original "too pedantic" complaint without sacrificing honesty about what the workspace can actually reach. The new rule resolves against the full workspace (weakdeps + subdir projects included, same as pre-#78) and compares the *breaking bucket* of the resolved version against the bucket of the max-allowed version. Breaking buckets: - v >= 1.0 -> (major,) - 0.1 <= v < 1 -> (0, minor) - 0 < v < 0.1 -> (0, 0, patch) Within-bucket gaps (e.g. compat "0.6" resolved at 0.6.4 while 0.6.5 exists) are allowed and no longer flag. Those gaps resolve themselves on the next upstream release and don't change what the package claims to support at the API-break level. Cross-bucket claims (e.g. "0.18, 0.19" resolved at 0.18.22 because a weakdep pins DataStructures) still flag — that's the aspirational-claim case the check is meant to catch. Tested locally against ITensorNetworks (flags DataStructures 0.18/0.19 cross-bucket — legitimate), ITensorFormatter (flags test JuliaSyntax 0.4.10/1 cross-bucket — legitimate; no longer flags the within-bucket Runic "1.5.1" vs 1.7.0 gap), and BlockSparseArrays (flags TensorAlgebra 0.7/0.9 cross-bucket — legitimate; no longer flags the within-bucket MAK 0.6.4/0.6.5 gap, which was the original motivation for the overly-pedantic-check complaint). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../check_compat_bounds.jl | 265 +++++++++++------- 1 file changed, 168 insertions(+), 97 deletions(-) diff --git a/.github/actions/check-compat-bounds/check_compat_bounds.jl b/.github/actions/check-compat-bounds/check_compat_bounds.jl index 93c9b6c..fd14df9 100644 --- a/.github/actions/check-compat-bounds/check_compat_bounds.jl +++ b/.github/actions/check-compat-bounds/check_compat_bounds.jl @@ -1,12 +1,14 @@ #!/usr/bin/env julia -# Checks that the root package's `[compat]` entries don't claim support for -# versions the resolver can't actually reach. Resolves against the root -# package in isolation — `[weakdeps]`, `[extras]`, and workspace sub-projects -# (test/, docs/, examples/) are ignored. The primary claim a package makes is -# about its core deps when installed on its own; if an extension or test-only -# dep happens to constrain the workspace manifest, that's a secondary concern -# that shouldn't block the core claim from being honest. +# Checks that every package with a compat entry across this workspace is +# resolvable to a version in the same *breaking bucket* (semver major for +# >= 1.0, minor for 0.x) as the highest allowed by compat. Fails (exit 1) if +# a compat entry claims support for a breaking-version bucket the resolver +# can't actually reach — typically because a transitive dependency pins the +# package into an older bucket. Within-bucket gaps (e.g. compat "0.6" +# resolved at 0.6.4 while 0.6.5 is available) are ignored, because those +# gaps don't change what the package claims to support at the API-break +# level and they resolve themselves on the next upstream release. # # Usage: # julia check_compat_bounds.jl [workspace-root] @@ -24,43 +26,92 @@ end const STDLIB_UUIDS = Set(keys(Pkg.Types.stdlibs())) is_stdlib(uuid::Base.UUID) = uuid in STDLIB_UUIDS -# Build a standalone "core-only" copy of the root Project.toml at `dest`: -# keep `[deps]` and `[compat]` (filtered to deps entries + `julia`); drop -# `[weakdeps]`, `[extensions]`, `[extras]`, `[targets]`, and `[workspace]`. -# Returns the parsed root project dict. -function write_core_project(root, dest) +# The "breaking bucket" of a version under Julia's semver-with-caret rules: +# v >= 1.0 → (v.major,) +# 0.1 ≤ v < 1 → (0, v.minor) +# 0 < v < 0.1 → (0, 0, v.patch) +# Two versions are breaking-compatible iff their buckets are equal. This +# mirrors how bare "X.Y.Z" compat entries expand to caret ranges. +function breaking_bucket(v::VersionNumber) + v.major >= 1 && return (Int(v.major),) + v.minor >= 1 && return (0, Int(v.minor)) + return (0, 0, Int(v.patch)) +end + +function workspace_projects(root) root_toml = joinpath(root, "Project.toml") isfile(root_toml) || error("No Project.toml at $root") proj = TOML.parsefile(root_toml) - deps = get(proj, "deps", Dict{String, Any}()) - compat = Dict{String, Any}() - for (name, spec) in get(proj, "compat", Dict{String, Any}()) - if name == "julia" || haskey(deps, name) - compat[name] = spec + tomls = [root_toml] + for rel in get(get(proj, "workspace", Dict{String, Any}()), "projects", String[]) + candidate = joinpath(root, rel, "Project.toml") + if isfile(candidate) + push!(tomls, candidate) + elseif isfile(joinpath(root, rel)) + push!(tomls, joinpath(root, rel)) + else + @warn "Workspace project path does not exist: $rel" end end - # Intentionally omit name/uuid/version so Pkg treats this as an anonymous - # environment, not a full package it should try to precompile. - core = Dict{String, Any}( - "deps" => deps, - "compat" => compat, - ) - mkpath(dest) - open(joinpath(dest, "Project.toml"), "w") do io - return TOML.print(io, core; sorted = true) + return tomls +end + +function collect_uuids(projects) + uuids = Dict{String, Base.UUID}() + for path in projects + proj = TOML.parsefile(path) + for key in ("deps", "weakdeps", "extras") + for (name, uuid_str) in get(proj, key, Dict{String, String}()) + uuids[name] = Base.UUID(uuid_str) + end + end + end + return uuids +end + +# Versions declared by the workspace itself. A package bumping its own version +# in a PR won't appear in the registry yet, so we merge these into the set of +# candidate versions so in-workspace compat entries (e.g. a test/Project.toml +# pinning the root package) don't spuriously fail the check. +function workspace_versions(projects) + versions = Dict{Base.UUID, VersionNumber}() + for path in projects + proj = TOML.parsefile(path) + uuid_str = get(proj, "uuid", nothing) + version_str = get(proj, "version", nothing) + uuid_str === nothing && continue + version_str === nothing && continue + versions[Base.UUID(uuid_str)] = VersionNumber(version_str) + end + return versions +end + +function collect_compat(projects, uuids) + entries = NamedTuple[] + for path in projects + proj = TOML.parsefile(path) + for (name, spec_str) in get(proj, "compat", Dict{String, String}()) + name == "julia" && continue + uuid = get(uuids, name, nothing) + if uuid === nothing + @warn "Compat entry for '$name' in $path has no matching UUID in any workspace project's deps/weakdeps/extras; skipping." + continue + end + push!(entries, (; name, spec = spec_str, source = path, uuid)) + end end - return proj + return entries end -function instantiate_core(core_dir) - cmd = `$(Base.julia_cmd()) --color=no --startup-file=no --project=$(core_dir) - -e "using Pkg; Pkg.instantiate()"` - run(cmd) - return TOML.parsefile(joinpath(core_dir, "Manifest.toml")) +function read_manifest(root) + manifest = joinpath(root, "Manifest.toml") + isfile(manifest) || error("No Manifest.toml at $root — run Pkg.instantiate() first.") + return TOML.parsefile(manifest) end function manifest_version(manifest, uuid::Base.UUID) uuid_str = string(uuid) + # Julia 1.7+ manifest format nests packages under "deps"; older nests at top. pkg_groups = get(manifest, "deps", manifest) for (_, entries) in pkg_groups entries isa AbstractVector || continue @@ -98,17 +149,22 @@ function max_satisfying(versions, spec::Pkg.Types.VersionSpec) return m end -# Best-effort explanation for an :outdated entry: in the already-prepared -# core-only temp project, force-pin the target version and return whatever -# the resolver prints (minus the Julia stacktrace). -function explain_outdated(core_dir, dep_name, target::VersionNumber) - try - cmd = `$(Base.julia_cmd()) --color=no --startup-file=no --project=$(core_dir) - -e "using Pkg; Pkg.add(Pkg.PackageSpec(name=\"$dep_name\", version=v\"$target\"))"` - buf = IOBuffer() - run(pipeline(ignorestatus(cmd); stdout = buf, stderr = buf)) - output = String(take!(buf)) - return strip(split(output, "\nStacktrace:"; limit = 2)[1]) +# Best-effort explanation for an :outdated entry: in a throwaway copy of +# the workspace, force-pin the target version and return whatever the +# resolver prints. The per-entry caller includes this in the report. +function explain_outdated(workspace_root, dep_name, target::VersionNumber) + return try + mktempdir() do tmp + pkg_dir = joinpath(tmp, "pkg") + cp(workspace_root, pkg_dir; force = true) + cmd = `$(Base.julia_cmd()) --color=no --startup-file=no --project=$(pkg_dir) + -e "using Pkg; Pkg.add(Pkg.PackageSpec(name=\"$dep_name\", version=v\"$target\"))"` + buf = IOBuffer() + run(pipeline(ignorestatus(cmd); stdout = buf, stderr = buf)) + output = String(take!(buf)) + # Drop the Julia stacktrace; keep only the resolver's conflict log. + return strip(split(output, "\nStacktrace:"; limit = 2)[1]) + end catch "" end @@ -116,74 +172,89 @@ end function main(args) root = parse_args(args) - println("Checking compat upper bounds (core only) for: $root") + println("Checking compat upper bounds for workspace at: $root") - return mktempdir() do tmp - core_dir = joinpath(tmp, "core") - proj = write_core_project(root, core_dir) - manifest = instantiate_core(core_dir) + projects = workspace_projects(root) + println("Workspace projects:") + for p in projects + println(" - $(relpath(p, root))") + end - deps = get(proj, "deps", Dict{String, Any}()) - compat = get(proj, "compat", Dict{String, Any}()) + uuids = collect_uuids(projects) + entries = collect_compat(projects, uuids) + manifest = read_manifest(root) + ws_versions = workspace_versions(projects) - issues = NamedTuple[] - for (name, spec_str) in compat - name == "julia" && continue - uuid_str = get(deps, name, nothing) - uuid_str === nothing && continue - uuid = Base.UUID(uuid_str) - is_stdlib(uuid) && continue - - spec = try - Pkg.Types.semver_spec(spec_str) - catch err - @warn "Could not parse compat spec '$spec_str' for $name: $err" - continue - end + issues = NamedTuple[] + for entry in entries + is_stdlib(entry.uuid) && continue - resolved = manifest_version(manifest, uuid) - resolved === nothing && continue + spec = try + Pkg.Types.semver_spec(entry.spec) + catch err + @warn "Could not parse compat spec '$(entry.spec)' for $(entry.name) in $(entry.source): $err" + continue + end - versions = registry_versions(uuid) - isempty(versions) && continue + resolved = manifest_version(manifest, entry.uuid) + resolved === nothing && continue # extras-only packages may not be resolved here - max_allowed = max_satisfying(versions, spec) - if max_allowed === nothing - push!(issues, (; name, spec = spec_str, resolved, max_allowed, kind = :no_match)) - elseif resolved < max_allowed - push!(issues, (; name, spec = spec_str, resolved, max_allowed, kind = :outdated)) - end - end + versions = registry_versions(entry.uuid) + ws_version = get(ws_versions, entry.uuid, nothing) + ws_version === nothing || ws_version in versions || push!(versions, ws_version) + isempty(versions) && continue # unregistered (e.g. local [sources] deps) - if isempty(issues) - println() - println("All core compat entries are resolved to their highest allowed version.") - return 0 + max_allowed = max_satisfying(versions, spec) + if max_allowed === nothing + push!(issues, (; entry..., resolved, max_allowed, kind = :no_match)) + elseif resolved < max_allowed && + breaking_bucket(resolved) != breaking_bucket(max_allowed) + push!(issues, (; entry..., resolved, max_allowed, kind = :outdated)) end + end + if isempty(issues) println() - println("Found $(length(issues)) compat entr$(length(issues) == 1 ? "y" : "ies") not matching the latest allowed version:") - println() - for i in issues - if i.kind == :outdated - println(" - $(i.name): resolved $(i.resolved), compat \"$(i.spec)\" allows up to $(i.max_allowed)") - explanation = explain_outdated(core_dir, i.name, i.max_allowed) - if !isempty(explanation) - println(" resolver output when forcing $(i.name) = $(i.max_allowed):") - for line in split(explanation, '\n') - println(" ", line) - end + println( + "All workspace compat entries resolve to their declared breaking-version bucket." + ) + return 0 + end + + println() + println( + "Found $(length(issues)) compat entr$(length(issues) == 1 ? "y" : "ies") claiming breaking-version support the resolver cannot reach:" + ) + println() + for i in issues + if i.kind == :outdated + println( + " - $(i.name): resolved $(i.resolved), compat \"$(i.spec)\" claims up to $(i.max_allowed) (different breaking bucket)" + ) + else + println( + " - $(i.name): compat \"$(i.spec)\" matches no registered version (resolved $(i.resolved))" + ) + end + println(" declared in $(relpath(i.source, root))") + if i.kind == :outdated + explanation = explain_outdated(root, i.name, i.max_allowed) + if !isempty(explanation) + println(" resolver output when forcing $(i.name) = $(i.max_allowed):") + for line in split(explanation, '\n') + println(" ", line) end - else - println(" - $(i.name): compat \"$(i.spec)\" matches no registered version (resolved $(i.resolved))") end end - println() - println("Narrow the package's own `[compat]` to match what the resolver reaches,") - println("or widen the upstream constraint that is holding it back.") - - return 1 end + println() + println("This means a compat entry claims support for a breaking-version bucket") + println("(semver major for >=1.0, minor for 0.x) that the workspace can't resolve to.") + println("Either narrow compat to drop the unreachable bucket, or widen/fix the") + println("upstream constraint so the newer bucket becomes reachable. Within-bucket") + println("gaps (e.g. compat \"0.6\" resolved at 0.6.4 while 0.6.5 exists) are allowed.") + + return 1 end exit(main(ARGS))