|
1 |
+ |
#!/usr/bin/env python3
|
|
2 |
+ |
"""Do the tree's in-house `version` requirements still resolve?
|
|
3 |
+ |
|
|
4 |
+ |
DO NOT EDIT IN PLACE. The master is _private/infra/bootstrap/githooks/internal-deps.py.
|
|
5 |
+ |
|
|
6 |
+ |
Usage:
|
|
7 |
+ |
internal-deps.py <tree-root> [repo-root]
|
|
8 |
+ |
|
|
9 |
+ |
With a repo root, only pairs that repo is on either side of can fail the run;
|
|
10 |
+ |
everything else is reported as a note. Without one, every pair is graded, which
|
|
11 |
+ |
is the whole-tree report:
|
|
12 |
+ |
|
|
13 |
+ |
python3 internal-deps.py ~/Code
|
|
14 |
+ |
|
|
15 |
+ |
WHAT IT GRADES. Every dependency in the tree that carries both a `git` URL on one
|
|
16 |
+ |
of our forges and a `version` requirement, against the version in the working
|
|
17 |
+ |
copy of the crate that URL names. That is the pairing cargo enforces and the one
|
|
18 |
+ |
that broke: a requirement of "0.11" against a sibling that has moved to 0.14 is
|
|
19 |
+ |
not a warning, it is a graph that will not resolve on any machine.
|
|
20 |
+ |
|
|
21 |
+ |
WHY WORKING COPIES AND NOT REMOTES. `~/Code/.cargo/config.toml` patches every one
|
|
22 |
+ |
of these dependencies to the working copy in the tree, so what is on disk here is
|
|
23 |
+ |
what every local build reads. A bump that has not been pushed yet breaks its
|
|
24 |
+ |
consumers just as thoroughly, and finding that out at push time is the point.
|
|
25 |
+ |
|
|
26 |
+ |
WHAT IT DOES NOT GRADE, on purpose:
|
|
27 |
+ |
|
|
28 |
+ |
crates.io deps the makeover suite and friends resolve from the registry,
|
|
29 |
+ |
where working ahead of a release is normal and a tree
|
|
30 |
+ |
version above the published one is not a finding. The
|
|
31 |
+ |
sweep's `coherence` check grades those against the index.
|
|
32 |
+ |
path deps no version requirement to be wrong about.
|
|
33 |
+ |
ranges and wildcards `>=`, `<`, `*` and comma lists are deliberate statements
|
|
34 |
+ |
about a span, not a pin that drifts. Counted as unchecked.
|
|
35 |
+ |
"""
|
|
36 |
+ |
|
|
37 |
+ |
import os
|
|
38 |
+ |
import re
|
|
39 |
+ |
import sys
|
|
40 |
+ |
import tomllib
|
|
41 |
+ |
|
|
42 |
+ |
# The forges that make a git URL ours. A dependency on somebody else's git repo
|
|
43 |
+ |
# is not something this tree can forward-fix.
|
|
44 |
+ |
OURS = re.compile(r"(makenot\.work|git\.sr\.ht/~maxmj)", re.I)
|
|
45 |
+ |
|
|
46 |
+ |
# Directories that hold code we do not grade: retired, staged for deletion, or
|
|
47 |
+ |
# not ours. Mirrors the sweep's exclusions rather than inventing a second list.
|
|
48 |
+ |
SKIP_DIRS = {
|
|
49 |
+ |
"target", ".git", "node_modules", "dist", "vendor",
|
|
50 |
+ |
"_archive", "_scratch", "trash", "_meta", "vtebench",
|
|
51 |
+ |
}
|
|
52 |
+ |
MAX_DEPTH = 4
|
|
53 |
+ |
|
|
54 |
+ |
DEP_SECTIONS = ("dependencies", "dev-dependencies", "build-dependencies")
|
|
55 |
+ |
|
|
56 |
+ |
|
|
57 |
+ |
def manifests(root):
|
|
58 |
+ |
"""Every Cargo.toml in the tree, shallow-walked."""
|
|
59 |
+ |
out = []
|
|
60 |
+ |
stack = [(root, 0)]
|
|
61 |
+ |
while stack:
|
|
62 |
+ |
d, depth = stack.pop()
|
|
63 |
+ |
try:
|
|
64 |
+ |
entries = list(os.scandir(d))
|
|
65 |
+ |
except OSError:
|
|
66 |
+ |
continue
|
|
67 |
+ |
for e in entries:
|
|
68 |
+ |
if e.is_file() and e.name == "Cargo.toml":
|
|
69 |
+ |
out.append(e.path)
|
|
70 |
+ |
elif e.is_dir() and e.name not in SKIP_DIRS and depth < MAX_DEPTH:
|
|
71 |
+ |
stack.append((e.path, depth + 1))
|
|
72 |
+ |
return out
|
|
73 |
+ |
|
|
74 |
+ |
|
|
75 |
+ |
def load(path):
|
|
76 |
+ |
try:
|
|
77 |
+ |
with open(path, "rb") as fh:
|
|
78 |
+ |
return tomllib.load(fh)
|
|
79 |
+ |
except (OSError, tomllib.TOMLDecodeError):
|
|
80 |
+ |
return None
|
|
81 |
+ |
|
|
82 |
+ |
|
|
83 |
+ |
def dep_tables(doc):
|
|
84 |
+ |
"""Every dependency table in a manifest, including per-target and workspace."""
|
|
85 |
+ |
for section in DEP_SECTIONS:
|
|
86 |
+ |
table = doc.get(section)
|
|
87 |
+ |
if isinstance(table, dict):
|
|
88 |
+ |
yield table
|
|
89 |
+ |
for cfg in (doc.get("target") or {}).values():
|
|
90 |
+ |
if not isinstance(cfg, dict):
|
|
91 |
+ |
continue
|
|
92 |
+ |
for section in DEP_SECTIONS:
|
|
93 |
+ |
table = cfg.get(section)
|
|
94 |
+ |
if isinstance(table, dict):
|
|
95 |
+ |
yield table
|
|
96 |
+ |
ws = doc.get("workspace") or {}
|
|
97 |
+ |
table = ws.get("dependencies")
|
|
98 |
+ |
if isinstance(table, dict):
|
|
99 |
+ |
yield table
|
|
100 |
+ |
|
|
101 |
+ |
|
|
102 |
+ |
def parse_version(v):
|
|
103 |
+ |
"""A version as a 3-tuple, prerelease dropped. Junk sorts as (0, 0, 0)."""
|
|
104 |
+ |
core = str(v).split("+")[0].split("-")[0]
|
|
105 |
+ |
parts = []
|
|
106 |
+ |
for piece in core.split(".")[:3]:
|
|
107 |
+ |
try:
|
|
108 |
+ |
parts.append(int(piece))
|
|
109 |
+ |
except ValueError:
|
|
110 |
+ |
parts.append(0)
|
|
111 |
+ |
while len(parts) < 3:
|
|
112 |
+ |
parts.append(0)
|
|
113 |
+ |
return tuple(parts)
|
|
114 |
+ |
|
|
115 |
+ |
|
|
116 |
+ |
def satisfies(req, version):
|
|
117 |
+ |
"""Cargo's default (caret) requirement semantics. None means 'not graded'.
|
|
118 |
+ |
|
|
119 |
+ |
The rule that matters here is the 0.x one: under 0.1.0 and above, the MINOR
|
|
120 |
+ |
is the compatibility boundary, which is why a 0.11 requirement rejects 0.14
|
|
121 |
+ |
outright rather than treating it as a newer patch.
|
|
122 |
+ |
"""
|
|
123 |
+ |
req = req.strip()
|
|
124 |
+ |
if not req or any(c in req for c in "<>*,~"):
|
|
125 |
+ |
return None
|
|
126 |
+ |
# A prerelease satisfies nothing that does not ask for a prerelease of the
|
|
127 |
+ |
# same version, so a plain requirement rejects it. This is the shape the
|
|
128 |
+ |
# maturity ladder produces at beta entry: a sibling at 1.0.0-beta.1 does not
|
|
129 |
+ |
# resolve for a consumer requiring "1.0", and cargo says so.
|
|
130 |
+ |
if "-" in str(version).split("+")[0] and "-" not in req:
|
|
131 |
+ |
return False
|
|
132 |
+ |
exact = req.startswith("=")
|
|
133 |
+ |
req = req.lstrip("^=").strip()
|
|
134 |
+ |
if not req:
|
|
135 |
+ |
return None
|
|
136 |
+ |
given = req.split(".")
|
|
137 |
+ |
try:
|
|
138 |
+ |
r = [int(p) for p in given[:3]]
|
|
139 |
+ |
except ValueError:
|
|
140 |
+ |
return None
|
|
141 |
+ |
v = parse_version(version)
|
|
142 |
+ |
if exact:
|
|
143 |
+ |
return tuple(v[: len(r)]) == tuple(r)
|
|
144 |
+ |
if r[0] > 0:
|
|
145 |
+ |
return v[0] == r[0] and v[1:] >= tuple(r[1:] + [0] * (2 - len(r[1:])))
|
|
146 |
+ |
if len(r) == 1:
|
|
147 |
+ |
return v[0] == 0
|
|
148 |
+ |
if r[1] > 0:
|
|
149 |
+ |
return v[0] == 0 and v[1] == r[1] and v[2] >= (r[2] if len(r) > 2 else 0)
|
|
150 |
+ |
# 0.0.x: every patch is its own compatibility island.
|
|
151 |
+ |
if len(r) > 2:
|
|
152 |
+ |
return v[:3] == (0, 0, r[2])
|
|
153 |
+ |
return v[0] == 0 and v[1] == 0
|
|
154 |
+ |
|
|
155 |
+ |
|
|
156 |
+ |
def main():
|
|
157 |
+ |
if len(sys.argv) < 2:
|
|
158 |
+ |
print(__doc__.strip(), file=sys.stderr)
|
|
159 |
+ |
return 2
|
|
160 |
+ |
tree = os.path.realpath(sys.argv[1])
|
|
161 |
+ |
repo = os.path.realpath(sys.argv[2]) if len(sys.argv) > 2 else None
|
|
162 |
+ |
|
|
163 |
+ |
paths = manifests(tree)
|
|
164 |
+ |
docs = {p: load(p) for p in paths}
|
|
165 |
+ |
|
|
166 |
+ |
# Workspace versions first: a member saying `version.workspace = true` gets
|
|
167 |
+ |
# its number from the root, and reporting it as 0.0.0 would be a false break.
|
|
168 |
+ |
ws_version = {}
|
|
169 |
+ |
for p, doc in docs.items():
|
|
170 |
+ |
if not doc:
|
|
171 |
+ |
continue
|
|
172 |
+ |
v = ((doc.get("workspace") or {}).get("package") or {}).get("version")
|
|
173 |
+ |
if isinstance(v, str):
|
|
174 |
+ |
ws_version[os.path.dirname(p)] = v
|
|
175 |
+ |
|
|
176 |
+ |
def resolve_version(manifest_path, pkg):
|
|
177 |
+ |
v = pkg.get("version")
|
|
178 |
+ |
if isinstance(v, str):
|
|
179 |
+ |
return v
|
|
180 |
+ |
d = os.path.dirname(manifest_path)
|
|
181 |
+ |
while d.startswith(tree):
|
|
182 |
+ |
if d in ws_version:
|
|
183 |
+ |
return ws_version[d]
|
|
184 |
+ |
d = os.path.dirname(d)
|
|
185 |
+ |
return None
|
|
186 |
+ |
|
|
187 |
+ |
# crate name -> (version, manifest path)
|
|
188 |
+ |
versions = {}
|
|
189 |
+ |
for p, doc in docs.items():
|
|
190 |
+ |
if not doc:
|
|
191 |
+ |
continue
|
|
192 |
+ |
pkg = doc.get("package")
|
|
193 |
+ |
if not isinstance(pkg, dict) or not isinstance(pkg.get("name"), str):
|
|
194 |
+ |
continue
|
|
195 |
+ |
v = resolve_version(p, pkg)
|
|
196 |
+ |
if v:
|
|
197 |
+ |
versions[pkg["name"]] = (v, p)
|
|
198 |
+ |
|
|
199 |
+ |
broken, unchecked, absent, graded = [], 0, set(), 0
|
|
200 |
+ |
for p, doc in docs.items():
|
|
201 |
+ |
if not doc:
|
|
202 |
+ |
continue
|
|
203 |
+ |
for table in dep_tables(doc):
|
|
204 |
+ |
for key, spec in table.items():
|
|
205 |
+ |
if not isinstance(spec, dict):
|
|
206 |
+ |
continue
|
|
207 |
+ |
git = spec.get("git")
|
|
208 |
+ |
req = spec.get("version")
|
|
209 |
+ |
if not isinstance(git, str) or not isinstance(req, str):
|
|
210 |
+ |
continue
|
|
211 |
+ |
if not OURS.search(git):
|
|
212 |
+ |
continue
|
|
213 |
+ |
name = spec.get("package") if isinstance(spec.get("package"), str) else key
|
|
214 |
+ |
known = versions.get(name)
|
|
215 |
+ |
if known is None:
|
|
216 |
+ |
# A repo that is not on this machine (ripgrow lives on mbp
|
|
217 |
+ |
# only). Not a finding: nothing here can be wrong about it.
|
|
218 |
+ |
absent.add(name)
|
|
219 |
+ |
continue
|
|
220 |
+ |
verdict = satisfies(req, known[0])
|
|
221 |
+ |
if verdict is None:
|
|
222 |
+ |
unchecked += 1
|
|
223 |
+ |
continue
|
|
224 |
+ |
graded += 1
|
|
225 |
+ |
if not verdict:
|
|
226 |
+ |
broken.append((p, name, req, known[0], known[1]))
|
|
227 |
+ |
|
|
228 |
+ |
if not broken:
|
|
229 |
+ |
print(
|
|
230 |
+ |
f"pre-push: internal deps coherent ({graded} requirements"
|
|
231 |
+ |
+ (f", {unchecked} unchecked" if unchecked else "")
|
|
232 |
+ |
+ (f", {len(absent)} crates not in this tree" if absent else "")
|
|
233 |
+ |
+ ")."
|
|
234 |
+ |
)
|
|
235 |
+ |
return 0
|
|
236 |
+ |
|
|
237 |
+ |
def rel(path):
|
|
238 |
+ |
return os.path.relpath(path, tree)
|
|
239 |
+ |
|
|
240 |
+ |
ours, theirs = [], []
|
|
241 |
+ |
for item in broken:
|
|
242 |
+ |
consumer_manifest, name, req, have, provider_manifest = item
|
|
243 |
+ |
mine = repo is not None and (
|
|
244 |
+ |
consumer_manifest.startswith(repo + os.sep)
|
|
245 |
+ |
or provider_manifest.startswith(repo + os.sep)
|
|
246 |
+ |
)
|
|
247 |
+ |
(ours if mine else theirs).append(item)
|
|
248 |
+ |
|
|
249 |
+ |
for consumer_manifest, name, req, have, provider_manifest in ours + theirs:
|
|
250 |
+ |
print(
|
|
251 |
+ |
f" {rel(consumer_manifest)}: requires {name} \"{req}\", "
|
|
252 |
+ |
f"the tree has {have} ({rel(provider_manifest)})",
|
|
253 |
+ |
file=sys.stderr,
|
|
254 |
+ |
)
|
|
255 |
+ |
|
|
256 |
+ |
if repo is None:
|
|
257 |
+ |
print(f"internal deps: {len(broken)} unresolvable requirements.", file=sys.stderr)
|
|
258 |
+ |
return 1
|
|
259 |
+ |
|
|
260 |
+ |
if not ours:
|
|
261 |
+ |
# Somebody else's skew. Worth seeing, never worth blocking this push on:
|
|
262 |
+ |
# a gate that fails for a reason the pusher cannot fix is a gate that
|
|
263 |
+ |
# gets bypassed by reflex, and then it is not a gate.
|
|
264 |
+ |
print(
|
|
265 |
+ |
f"pre-push: {len(theirs)} unresolvable requirements elsewhere in the "
|
|
266 |
+ |
"tree (listed above, not this push's).",
|
|
267 |
+ |
)
|
|
268 |
+ |
return 0
|
|
269 |
+ |
|
|
270 |
+ |
print("", file=sys.stderr)
|
|
271 |
+ |
print(
|
|
272 |
+ |
"pre-push: this push leaves a dependency that cannot resolve.\n"
|
|
273 |
+ |
" A version requirement states which major a consumer was written against,\n"
|
|
274 |
+ |
" so bumping a library and fixing its consumers is one pass (CLAUDE.md,\n"
|
|
275 |
+ |
" \"a breaking bump of an in-house crate is forward-fixed, in the same pass\").\n"
|
|
276 |
+ |
" Fix: bump the requirement in the manifests above, make the consumers\n"
|
|
277 |
+ |
" compile, and push them with this one.",
|
|
278 |
+ |
file=sys.stderr,
|
|
279 |
+ |
)
|
|
280 |
+ |
return 1
|
|
281 |
+ |
|
|
282 |
+ |
|
|
283 |
+ |
if __name__ == "__main__":
|
|
284 |
+ |
sys.exit(main())
|