Skip to content

Commit e9999c6

Browse files
mtfishmanclaude
andauthored
check-compat-bounds: restore full-workspace resolution + bucket rule (#79)
## Summary Before: the compat-bounds check failed whenever a package's compat entry allowed a version that the resolver could not actually install — even if the gap was only at the patch or minor level within the same breaking-version range. After: the check only fails when the gap crosses a breaking-version boundary (a major bump for `v >= 1.0`, a minor bump for `0.x`). If the resolver lands on a version in the same breaking range as the max-allowed version, the check passes. This targets the case the check is really meant to catch: a package claims support for a breaking version of a dependency that it cannot actually exercise, because something else in the workspace holds it in an earlier range. Smaller within-range gaps close on their own as upstreams release patches and no longer produce failing checks. This also restores the original full-workspace resolution (including weakdeps and subdir projects), which #78 had narrowed to root deps only. With the bucket-based comparison in place, the narrower scope is no longer needed to keep the check from being noisy, and the wider scope catches aspirational claims involving optional extensions or test-only deps that #78 missed. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 31b7b74 commit e9999c6

1 file changed

Lines changed: 168 additions & 97 deletions

File tree

Lines changed: 168 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
#!/usr/bin/env julia
22

3-
# Checks that the root package's `[compat]` entries don't claim support for
4-
# versions the resolver can't actually reach. Resolves against the root
5-
# package in isolation — `[weakdeps]`, `[extras]`, and workspace sub-projects
6-
# (test/, docs/, examples/) are ignored. The primary claim a package makes is
7-
# about its core deps when installed on its own; if an extension or test-only
8-
# dep happens to constrain the workspace manifest, that's a secondary concern
9-
# that shouldn't block the core claim from being honest.
3+
# Checks that every package with a compat entry across this workspace is
4+
# resolvable to a version in the same *breaking bucket* (semver major for
5+
# >= 1.0, minor for 0.x) as the highest allowed by compat. Fails (exit 1) if
6+
# a compat entry claims support for a breaking-version bucket the resolver
7+
# can't actually reach — typically because a transitive dependency pins the
8+
# package into an older bucket. Within-bucket gaps (e.g. compat "0.6"
9+
# resolved at 0.6.4 while 0.6.5 is available) are ignored, because those
10+
# gaps don't change what the package claims to support at the API-break
11+
# level and they resolve themselves on the next upstream release.
1012
#
1113
# Usage:
1214
# julia check_compat_bounds.jl [workspace-root]
@@ -24,43 +26,92 @@ end
2426
const STDLIB_UUIDS = Set(keys(Pkg.Types.stdlibs()))
2527
is_stdlib(uuid::Base.UUID) = uuid in STDLIB_UUIDS
2628

27-
# Build a standalone "core-only" copy of the root Project.toml at `dest`:
28-
# keep `[deps]` and `[compat]` (filtered to deps entries + `julia`); drop
29-
# `[weakdeps]`, `[extensions]`, `[extras]`, `[targets]`, and `[workspace]`.
30-
# Returns the parsed root project dict.
31-
function write_core_project(root, dest)
29+
# The "breaking bucket" of a version under Julia's semver-with-caret rules:
30+
# v >= 1.0 → (v.major,)
31+
# 0.1 ≤ v < 1 → (0, v.minor)
32+
# 0 < v < 0.1 → (0, 0, v.patch)
33+
# Two versions are breaking-compatible iff their buckets are equal. This
34+
# mirrors how bare "X.Y.Z" compat entries expand to caret ranges.
35+
function breaking_bucket(v::VersionNumber)
36+
v.major >= 1 && return (Int(v.major),)
37+
v.minor >= 1 && return (0, Int(v.minor))
38+
return (0, 0, Int(v.patch))
39+
end
40+
41+
function workspace_projects(root)
3242
root_toml = joinpath(root, "Project.toml")
3343
isfile(root_toml) || error("No Project.toml at $root")
3444
proj = TOML.parsefile(root_toml)
35-
deps = get(proj, "deps", Dict{String, Any}())
36-
compat = Dict{String, Any}()
37-
for (name, spec) in get(proj, "compat", Dict{String, Any}())
38-
if name == "julia" || haskey(deps, name)
39-
compat[name] = spec
45+
tomls = [root_toml]
46+
for rel in get(get(proj, "workspace", Dict{String, Any}()), "projects", String[])
47+
candidate = joinpath(root, rel, "Project.toml")
48+
if isfile(candidate)
49+
push!(tomls, candidate)
50+
elseif isfile(joinpath(root, rel))
51+
push!(tomls, joinpath(root, rel))
52+
else
53+
@warn "Workspace project path does not exist: $rel"
4054
end
4155
end
42-
# Intentionally omit name/uuid/version so Pkg treats this as an anonymous
43-
# environment, not a full package it should try to precompile.
44-
core = Dict{String, Any}(
45-
"deps" => deps,
46-
"compat" => compat,
47-
)
48-
mkpath(dest)
49-
open(joinpath(dest, "Project.toml"), "w") do io
50-
return TOML.print(io, core; sorted = true)
56+
return tomls
57+
end
58+
59+
function collect_uuids(projects)
60+
uuids = Dict{String, Base.UUID}()
61+
for path in projects
62+
proj = TOML.parsefile(path)
63+
for key in ("deps", "weakdeps", "extras")
64+
for (name, uuid_str) in get(proj, key, Dict{String, String}())
65+
uuids[name] = Base.UUID(uuid_str)
66+
end
67+
end
68+
end
69+
return uuids
70+
end
71+
72+
# Versions declared by the workspace itself. A package bumping its own version
73+
# in a PR won't appear in the registry yet, so we merge these into the set of
74+
# candidate versions so in-workspace compat entries (e.g. a test/Project.toml
75+
# pinning the root package) don't spuriously fail the check.
76+
function workspace_versions(projects)
77+
versions = Dict{Base.UUID, VersionNumber}()
78+
for path in projects
79+
proj = TOML.parsefile(path)
80+
uuid_str = get(proj, "uuid", nothing)
81+
version_str = get(proj, "version", nothing)
82+
uuid_str === nothing && continue
83+
version_str === nothing && continue
84+
versions[Base.UUID(uuid_str)] = VersionNumber(version_str)
85+
end
86+
return versions
87+
end
88+
89+
function collect_compat(projects, uuids)
90+
entries = NamedTuple[]
91+
for path in projects
92+
proj = TOML.parsefile(path)
93+
for (name, spec_str) in get(proj, "compat", Dict{String, String}())
94+
name == "julia" && continue
95+
uuid = get(uuids, name, nothing)
96+
if uuid === nothing
97+
@warn "Compat entry for '$name' in $path has no matching UUID in any workspace project's deps/weakdeps/extras; skipping."
98+
continue
99+
end
100+
push!(entries, (; name, spec = spec_str, source = path, uuid))
101+
end
51102
end
52-
return proj
103+
return entries
53104
end
54105

55-
function instantiate_core(core_dir)
56-
cmd = `$(Base.julia_cmd()) --color=no --startup-file=no --project=$(core_dir)
57-
-e "using Pkg; Pkg.instantiate()"`
58-
run(cmd)
59-
return TOML.parsefile(joinpath(core_dir, "Manifest.toml"))
106+
function read_manifest(root)
107+
manifest = joinpath(root, "Manifest.toml")
108+
isfile(manifest) || error("No Manifest.toml at $root — run Pkg.instantiate() first.")
109+
return TOML.parsefile(manifest)
60110
end
61111

62112
function manifest_version(manifest, uuid::Base.UUID)
63113
uuid_str = string(uuid)
114+
# Julia 1.7+ manifest format nests packages under "deps"; older nests at top.
64115
pkg_groups = get(manifest, "deps", manifest)
65116
for (_, entries) in pkg_groups
66117
entries isa AbstractVector || continue
@@ -98,92 +149,112 @@ function max_satisfying(versions, spec::Pkg.Types.VersionSpec)
98149
return m
99150
end
100151

101-
# Best-effort explanation for an :outdated entry: in the already-prepared
102-
# core-only temp project, force-pin the target version and return whatever
103-
# the resolver prints (minus the Julia stacktrace).
104-
function explain_outdated(core_dir, dep_name, target::VersionNumber)
105-
try
106-
cmd = `$(Base.julia_cmd()) --color=no --startup-file=no --project=$(core_dir)
107-
-e "using Pkg; Pkg.add(Pkg.PackageSpec(name=\"$dep_name\", version=v\"$target\"))"`
108-
buf = IOBuffer()
109-
run(pipeline(ignorestatus(cmd); stdout = buf, stderr = buf))
110-
output = String(take!(buf))
111-
return strip(split(output, "\nStacktrace:"; limit = 2)[1])
152+
# Best-effort explanation for an :outdated entry: in a throwaway copy of
153+
# the workspace, force-pin the target version and return whatever the
154+
# resolver prints. The per-entry caller includes this in the report.
155+
function explain_outdated(workspace_root, dep_name, target::VersionNumber)
156+
return try
157+
mktempdir() do tmp
158+
pkg_dir = joinpath(tmp, "pkg")
159+
cp(workspace_root, pkg_dir; force = true)
160+
cmd = `$(Base.julia_cmd()) --color=no --startup-file=no --project=$(pkg_dir)
161+
-e "using Pkg; Pkg.add(Pkg.PackageSpec(name=\"$dep_name\", version=v\"$target\"))"`
162+
buf = IOBuffer()
163+
run(pipeline(ignorestatus(cmd); stdout = buf, stderr = buf))
164+
output = String(take!(buf))
165+
# Drop the Julia stacktrace; keep only the resolver's conflict log.
166+
return strip(split(output, "\nStacktrace:"; limit = 2)[1])
167+
end
112168
catch
113169
""
114170
end
115171
end
116172

117173
function main(args)
118174
root = parse_args(args)
119-
println("Checking compat upper bounds (core only) for: $root")
175+
println("Checking compat upper bounds for workspace at: $root")
120176

121-
return mktempdir() do tmp
122-
core_dir = joinpath(tmp, "core")
123-
proj = write_core_project(root, core_dir)
124-
manifest = instantiate_core(core_dir)
177+
projects = workspace_projects(root)
178+
println("Workspace projects:")
179+
for p in projects
180+
println(" - $(relpath(p, root))")
181+
end
125182

126-
deps = get(proj, "deps", Dict{String, Any}())
127-
compat = get(proj, "compat", Dict{String, Any}())
183+
uuids = collect_uuids(projects)
184+
entries = collect_compat(projects, uuids)
185+
manifest = read_manifest(root)
186+
ws_versions = workspace_versions(projects)
128187

129-
issues = NamedTuple[]
130-
for (name, spec_str) in compat
131-
name == "julia" && continue
132-
uuid_str = get(deps, name, nothing)
133-
uuid_str === nothing && continue
134-
uuid = Base.UUID(uuid_str)
135-
is_stdlib(uuid) && continue
136-
137-
spec = try
138-
Pkg.Types.semver_spec(spec_str)
139-
catch err
140-
@warn "Could not parse compat spec '$spec_str' for $name: $err"
141-
continue
142-
end
188+
issues = NamedTuple[]
189+
for entry in entries
190+
is_stdlib(entry.uuid) && continue
143191

144-
resolved = manifest_version(manifest, uuid)
145-
resolved === nothing && continue
192+
spec = try
193+
Pkg.Types.semver_spec(entry.spec)
194+
catch err
195+
@warn "Could not parse compat spec '$(entry.spec)' for $(entry.name) in $(entry.source): $err"
196+
continue
197+
end
146198

147-
versions = registry_versions(uuid)
148-
isempty(versions) && continue
199+
resolved = manifest_version(manifest, entry.uuid)
200+
resolved === nothing && continue # extras-only packages may not be resolved here
149201

150-
max_allowed = max_satisfying(versions, spec)
151-
if max_allowed === nothing
152-
push!(issues, (; name, spec = spec_str, resolved, max_allowed, kind = :no_match))
153-
elseif resolved < max_allowed
154-
push!(issues, (; name, spec = spec_str, resolved, max_allowed, kind = :outdated))
155-
end
156-
end
202+
versions = registry_versions(entry.uuid)
203+
ws_version = get(ws_versions, entry.uuid, nothing)
204+
ws_version === nothing || ws_version in versions || push!(versions, ws_version)
205+
isempty(versions) && continue # unregistered (e.g. local [sources] deps)
157206

158-
if isempty(issues)
159-
println()
160-
println("All core compat entries are resolved to their highest allowed version.")
161-
return 0
207+
max_allowed = max_satisfying(versions, spec)
208+
if max_allowed === nothing
209+
push!(issues, (; entry..., resolved, max_allowed, kind = :no_match))
210+
elseif resolved < max_allowed &&
211+
breaking_bucket(resolved) != breaking_bucket(max_allowed)
212+
push!(issues, (; entry..., resolved, max_allowed, kind = :outdated))
162213
end
214+
end
163215

216+
if isempty(issues)
164217
println()
165-
println("Found $(length(issues)) compat entr$(length(issues) == 1 ? "y" : "ies") not matching the latest allowed version:")
166-
println()
167-
for i in issues
168-
if i.kind == :outdated
169-
println(" - $(i.name): resolved $(i.resolved), compat \"$(i.spec)\" allows up to $(i.max_allowed)")
170-
explanation = explain_outdated(core_dir, i.name, i.max_allowed)
171-
if !isempty(explanation)
172-
println(" resolver output when forcing $(i.name) = $(i.max_allowed):")
173-
for line in split(explanation, '\n')
174-
println(" ", line)
175-
end
218+
println(
219+
"All workspace compat entries resolve to their declared breaking-version bucket."
220+
)
221+
return 0
222+
end
223+
224+
println()
225+
println(
226+
"Found $(length(issues)) compat entr$(length(issues) == 1 ? "y" : "ies") claiming breaking-version support the resolver cannot reach:"
227+
)
228+
println()
229+
for i in issues
230+
if i.kind == :outdated
231+
println(
232+
" - $(i.name): resolved $(i.resolved), compat \"$(i.spec)\" claims up to $(i.max_allowed) (different breaking bucket)"
233+
)
234+
else
235+
println(
236+
" - $(i.name): compat \"$(i.spec)\" matches no registered version (resolved $(i.resolved))"
237+
)
238+
end
239+
println(" declared in $(relpath(i.source, root))")
240+
if i.kind == :outdated
241+
explanation = explain_outdated(root, i.name, i.max_allowed)
242+
if !isempty(explanation)
243+
println(" resolver output when forcing $(i.name) = $(i.max_allowed):")
244+
for line in split(explanation, '\n')
245+
println(" ", line)
176246
end
177-
else
178-
println(" - $(i.name): compat \"$(i.spec)\" matches no registered version (resolved $(i.resolved))")
179247
end
180248
end
181-
println()
182-
println("Narrow the package's own `[compat]` to match what the resolver reaches,")
183-
println("or widen the upstream constraint that is holding it back.")
184-
185-
return 1
186249
end
250+
println()
251+
println("This means a compat entry claims support for a breaking-version bucket")
252+
println("(semver major for >=1.0, minor for 0.x) that the workspace can't resolve to.")
253+
println("Either narrow compat to drop the unreachable bucket, or widen/fix the")
254+
println("upstream constraint so the newer bucket becomes reachable. Within-bucket")
255+
println("gaps (e.g. compat \"0.6\" resolved at 0.6.4 while 0.6.5 exists) are allowed.")
256+
257+
return 1
187258
end
188259

189260
exit(main(ARGS))

0 commit comments

Comments
 (0)