|
1 | 1 | #!/usr/bin/env julia |
2 | 2 |
|
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. |
10 | 12 | # |
11 | 13 | # Usage: |
12 | 14 | # julia check_compat_bounds.jl [workspace-root] |
|
24 | 26 | const STDLIB_UUIDS = Set(keys(Pkg.Types.stdlibs())) |
25 | 27 | is_stdlib(uuid::Base.UUID) = uuid in STDLIB_UUIDS |
26 | 28 |
|
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) |
32 | 42 | root_toml = joinpath(root, "Project.toml") |
33 | 43 | isfile(root_toml) || error("No Project.toml at $root") |
34 | 44 | 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" |
40 | 54 | end |
41 | 55 | 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 |
51 | 102 | end |
52 | | - return proj |
| 103 | + return entries |
53 | 104 | end |
54 | 105 |
|
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) |
60 | 110 | end |
61 | 111 |
|
62 | 112 | function manifest_version(manifest, uuid::Base.UUID) |
63 | 113 | uuid_str = string(uuid) |
| 114 | + # Julia 1.7+ manifest format nests packages under "deps"; older nests at top. |
64 | 115 | pkg_groups = get(manifest, "deps", manifest) |
65 | 116 | for (_, entries) in pkg_groups |
66 | 117 | entries isa AbstractVector || continue |
@@ -98,92 +149,112 @@ function max_satisfying(versions, spec::Pkg.Types.VersionSpec) |
98 | 149 | return m |
99 | 150 | end |
100 | 151 |
|
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 |
112 | 168 | catch |
113 | 169 | "" |
114 | 170 | end |
115 | 171 | end |
116 | 172 |
|
117 | 173 | function main(args) |
118 | 174 | root = parse_args(args) |
119 | | - println("Checking compat upper bounds (core only) for: $root") |
| 175 | + println("Checking compat upper bounds for workspace at: $root") |
120 | 176 |
|
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 |
125 | 182 |
|
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) |
128 | 187 |
|
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 |
143 | 191 |
|
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 |
146 | 198 |
|
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 |
149 | 201 |
|
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) |
157 | 206 |
|
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)) |
162 | 213 | end |
| 214 | + end |
163 | 215 |
|
| 216 | + if isempty(issues) |
164 | 217 | 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) |
176 | 246 | end |
177 | | - else |
178 | | - println(" - $(i.name): compat \"$(i.spec)\" matches no registered version (resolved $(i.resolved))") |
179 | 247 | end |
180 | 248 | 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 |
186 | 249 | 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 |
187 | 258 | end |
188 | 259 |
|
189 | 260 | exit(main(ARGS)) |
0 commit comments