Skip to main content

max / audiofiles

10.0 KB · 285 lines History Blame Raw
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())
285