Skip to main content

max / makeover-immediate

14.9 KB · 408 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] [pushed-sha]
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 With a pushed sha as well, the run grades TWO views and fails on either:
16
17 working copy what this machine builds today. The historical check.
18 as pushed the same question asked of the repo's manifests AS THEY EXIST
19 AT THAT COMMIT, against the rest of the tree on disk.
20
21 WHY THE SECOND VIEW EXISTS. The first one reads manifests off the filesystem, so
22 an uncommitted forward-fix makes it grade text that git is not publishing. That
23 is not hypothetical: on 2026-08-24 mnw-cli's `synckit-client` requirement had
24 been advanced to "0.9" in the working copy and never committed, this gate printed
25 `internal deps coherent (42 requirements)`, the push went out, and Sando failed
26 to resolve `^0.8` against 0.9.0 minutes later. The gate was checking a tree that
27 was not the tree being published, and nothing distinguished that from real
28 coherence.
29
30 WHAT IT GRADES. Every dependency in the tree that carries both a `git` URL on one
31 of our forges and a `version` requirement, against the version in the working
32 copy of the crate that URL names. That is the pairing cargo enforces and the one
33 that broke: a requirement of "0.11" against a sibling that has moved to 0.14 is
34 not a warning, it is a graph that will not resolve on any machine.
35
36 WHY WORKING COPIES AND NOT REMOTES. `~/Code/.cargo/config.toml` patches every one
37 of these dependencies to the working copy in the tree, so what is on disk here is
38 what every local build reads. A bump that has not been pushed yet breaks its
39 consumers just as thoroughly, and finding that out at push time is the point.
40 That is why the as-pushed view ADDS a check rather than replacing this one:
41 grading only the commit would stop catching the unpushed bump that breaks every
42 build on this machine. The two views answer different questions and both matter.
43
44 The rest of the tree is read from disk in both views, deliberately. Reading other
45 repos' remotes would need a fetch per repo, and the same `[patch]` block means
46 disk is what a local build resolves against anyway.
47
48 WHAT IT DOES NOT GRADE, on purpose:
49
50 crates.io deps the makeover suite and friends resolve from the registry,
51 where working ahead of a release is normal and a tree
52 version above the published one is not a finding. The
53 sweep's `coherence` check grades those against the index.
54 path deps no version requirement to be wrong about.
55 ranges and wildcards `>=`, `<`, `*` and comma lists are deliberate statements
56 about a span, not a pin that drifts. Counted as unchecked.
57 """
58
59 import os
60 import re
61 import subprocess
62 import sys
63 import tomllib
64
65 # The forges that make a git URL ours. A dependency on somebody else's git repo
66 # is not something this tree can forward-fix.
67 OURS = re.compile(r"(makenot\.work|git\.sr\.ht/~maxmj)", re.I)
68
69 # Directories that hold code we do not grade: retired, staged for deletion, or
70 # not ours. Mirrors the sweep's exclusions rather than inventing a second list.
71 SKIP_DIRS = {
72 "target", ".git", "node_modules", "dist", "vendor",
73 "_archive", "_scratch", "trash", "_meta", "vtebench",
74 }
75 MAX_DEPTH = 4
76
77 DEP_SECTIONS = ("dependencies", "dev-dependencies", "build-dependencies")
78
79
80 def manifests(root):
81 """Every Cargo.toml in the tree, shallow-walked."""
82 out = []
83 stack = [(root, 0)]
84 while stack:
85 d, depth = stack.pop()
86 try:
87 entries = list(os.scandir(d))
88 except OSError:
89 continue
90 for e in entries:
91 if e.is_file() and e.name == "Cargo.toml":
92 out.append(e.path)
93 elif e.is_dir() and e.name not in SKIP_DIRS and depth < MAX_DEPTH:
94 stack.append((e.path, depth + 1))
95 return out
96
97
98 def load(path):
99 try:
100 with open(path, "rb") as fh:
101 return tomllib.load(fh)
102 except (OSError, tomllib.TOMLDecodeError):
103 return None
104
105
106 def dep_tables(doc):
107 """Every dependency table in a manifest, including per-target and workspace."""
108 for section in DEP_SECTIONS:
109 table = doc.get(section)
110 if isinstance(table, dict):
111 yield table
112 for cfg in (doc.get("target") or {}).values():
113 if not isinstance(cfg, dict):
114 continue
115 for section in DEP_SECTIONS:
116 table = cfg.get(section)
117 if isinstance(table, dict):
118 yield table
119 ws = doc.get("workspace") or {}
120 table = ws.get("dependencies")
121 if isinstance(table, dict):
122 yield table
123
124
125 def parse_version(v):
126 """A version as a 3-tuple, prerelease dropped. Junk sorts as (0, 0, 0)."""
127 core = str(v).split("+")[0].split("-")[0]
128 parts = []
129 for piece in core.split(".")[:3]:
130 try:
131 parts.append(int(piece))
132 except ValueError:
133 parts.append(0)
134 while len(parts) < 3:
135 parts.append(0)
136 return tuple(parts)
137
138
139 def satisfies(req, version):
140 """Cargo's default (caret) requirement semantics. None means 'not graded'.
141
142 The rule that matters here is the 0.x one: under 0.1.0 and above, the MINOR
143 is the compatibility boundary, which is why a 0.11 requirement rejects 0.14
144 outright rather than treating it as a newer patch.
145 """
146 req = req.strip()
147 if not req or any(c in req for c in "<>*,~"):
148 return None
149 # A prerelease satisfies nothing that does not ask for a prerelease of the
150 # same version, so a plain requirement rejects it. This is the shape the
151 # maturity ladder produces at beta entry: a sibling at 1.0.0-beta.1 does not
152 # resolve for a consumer requiring "1.0", and cargo says so.
153 if "-" in str(version).split("+")[0] and "-" not in req:
154 return False
155 exact = req.startswith("=")
156 req = req.lstrip("^=").strip()
157 if not req:
158 return None
159 given = req.split(".")
160 try:
161 r = [int(p) for p in given[:3]]
162 except ValueError:
163 return None
164 v = parse_version(version)
165 if exact:
166 return tuple(v[: len(r)]) == tuple(r)
167 if r[0] > 0:
168 return v[0] == r[0] and v[1:] >= tuple(r[1:] + [0] * (2 - len(r[1:])))
169 if len(r) == 1:
170 return v[0] == 0
171 if r[1] > 0:
172 return v[0] == 0 and v[1] == r[1] and v[2] >= (r[2] if len(r) > 2 else 0)
173 # 0.0.x: every patch is its own compatibility island.
174 if len(r) > 2:
175 return v[:3] == (0, 0, r[2])
176 return v[0] == 0 and v[1] == 0
177
178
179 def git_lines(repo, *args):
180 """Run git in `repo` and return stdout lines, or None if it failed."""
181 try:
182 out = subprocess.run(
183 ["git", "-C", repo, *args],
184 capture_output=True, text=True, check=True,
185 )
186 except (OSError, subprocess.CalledProcessError):
187 return None
188 return out.stdout.splitlines()
189
190
191 def git_manifests(repo, sha):
192 """Repo-relative paths of every Cargo.toml at `sha`, or None if unreadable."""
193 lines = git_lines(repo, "ls-tree", "-r", "--name-only", sha)
194 if lines is None:
195 return None
196 out = []
197 for rel in lines:
198 if os.path.basename(rel) != "Cargo.toml":
199 continue
200 if any(part in SKIP_DIRS for part in rel.split("/")):
201 continue
202 out.append(rel)
203 return out
204
205
206 def load_at(repo, sha, rel):
207 """One manifest as it exists at `sha`. None if missing or unparseable."""
208 lines = git_lines(repo, "show", f"{sha}:{rel}")
209 if lines is None:
210 return None
211 try:
212 return tomllib.loads("\n".join(lines))
213 except tomllib.TOMLDecodeError:
214 return None
215
216
217 def pushed_view(docs, repo, sha):
218 """`docs` with everything under `repo` replaced by its content at `sha`.
219
220 The rest of the tree stays as it is on disk, which is what a local build
221 resolves against either way. Returns None if the commit cannot be read, so
222 the caller can skip the view rather than invent a verdict about it.
223 """
224 rels = git_manifests(repo, sha)
225 if rels is None:
226 return None
227 out = {k: v for k, v in docs.items() if not k.startswith(repo + os.sep)}
228 for rel in rels:
229 out[os.path.join(repo, rel)] = load_at(repo, sha, rel)
230 return out
231
232
233 def analyze(docs, tree):
234 """Grade every in-house git+version pair in `docs`.
235
236 Returns (broken, unchecked, absent, graded), where a broken entry is
237 (consumer manifest, crate, requirement, version found, provider manifest).
238 """
239 # Workspace versions first: a member saying `version.workspace = true` gets
240 # its number from the root, and reporting it as 0.0.0 would be a false break.
241 ws_version = {}
242 for p, doc in docs.items():
243 if not doc:
244 continue
245 v = ((doc.get("workspace") or {}).get("package") or {}).get("version")
246 if isinstance(v, str):
247 ws_version[os.path.dirname(p)] = v
248
249 def resolve_version(manifest_path, pkg):
250 v = pkg.get("version")
251 if isinstance(v, str):
252 return v
253 d = os.path.dirname(manifest_path)
254 while d.startswith(tree):
255 if d in ws_version:
256 return ws_version[d]
257 d = os.path.dirname(d)
258 return None
259
260 # crate name -> (version, manifest path)
261 versions = {}
262 for p, doc in docs.items():
263 if not doc:
264 continue
265 pkg = doc.get("package")
266 if not isinstance(pkg, dict) or not isinstance(pkg.get("name"), str):
267 continue
268 v = resolve_version(p, pkg)
269 if v:
270 versions[pkg["name"]] = (v, p)
271
272 broken, unchecked, absent, graded = [], 0, set(), 0
273 for p, doc in docs.items():
274 if not doc:
275 continue
276 for table in dep_tables(doc):
277 for key, spec in table.items():
278 if not isinstance(spec, dict):
279 continue
280 git = spec.get("git")
281 req = spec.get("version")
282 if not isinstance(git, str) or not isinstance(req, str):
283 continue
284 if not OURS.search(git):
285 continue
286 name = spec.get("package") if isinstance(spec.get("package"), str) else key
287 known = versions.get(name)
288 if known is None:
289 # A repo that is not on this machine (ripgrow lives on mbp
290 # only). Not a finding: nothing here can be wrong about it.
291 absent.add(name)
292 continue
293 verdict = satisfies(req, known[0])
294 if verdict is None:
295 unchecked += 1
296 continue
297 graded += 1
298 if not verdict:
299 broken.append((p, name, req, known[0], known[1]))
300 return broken, unchecked, absent, graded
301
302
303 def split_blame(broken, repo):
304 """Breaks this push owns, and breaks that were already there."""
305 ours, theirs = [], []
306 for item in broken:
307 consumer_manifest, _name, _req, _have, provider_manifest = item
308 mine = repo is not None and (
309 consumer_manifest.startswith(repo + os.sep)
310 or provider_manifest.startswith(repo + os.sep)
311 )
312 (ours if mine else theirs).append(item)
313 return ours, theirs
314
315
316 def report(broken, repo, tree, label):
317 """Print one view's breaks. Returns True if this push has to be refused."""
318 ours, theirs = split_blame(broken, repo)
319
320 def rel(path):
321 return os.path.relpath(path, tree)
322
323 for consumer_manifest, name, req, have, provider_manifest in ours + theirs:
324 print(
325 f" [{label}] {rel(consumer_manifest)}: requires {name} \"{req}\", "
326 f"the tree has {have} ({rel(provider_manifest)})",
327 file=sys.stderr,
328 )
329 if repo is None:
330 return bool(broken)
331 if not ours:
332 # Somebody else's skew. Worth seeing, never worth blocking this push on:
333 # a gate that fails for a reason the pusher cannot fix is a gate that
334 # gets bypassed by reflex, and then it is not a gate.
335 if theirs:
336 print(
337 f"pre-push: [{label}] {len(theirs)} unresolvable requirements "
338 "elsewhere in the tree (listed above, not this push's).",
339 )
340 return False
341 return True
342
343
344 def main():
345 if len(sys.argv) < 2:
346 print(__doc__.strip(), file=sys.stderr)
347 return 2
348 tree = os.path.realpath(sys.argv[1])
349 repo = os.path.realpath(sys.argv[2]) if len(sys.argv) > 2 else None
350 sha = sys.argv[3] if len(sys.argv) > 3 else None
351
352 docs = {p: load(p) for p in manifests(tree)}
353
354 views = [("working copy", docs)]
355 skipped_push_view = False
356 if repo and sha:
357 pushed = pushed_view(docs, repo, sha)
358 if pushed is None:
359 skipped_push_view = True
360 else:
361 views.append(("as pushed", pushed))
362
363 refuse = False
364 summaries = []
365 for label, view in views:
366 broken, unchecked, absent, graded = analyze(view, tree)
367 summaries.append((label, graded, unchecked, absent, bool(broken)))
368 if broken and report(broken, repo, tree, label):
369 refuse = True
370
371 if refuse:
372 print("", file=sys.stderr)
373 print(
374 "pre-push: this push leaves a dependency that cannot resolve.\n"
375 " A version requirement states which major a consumer was written against,\n"
376 " so bumping a library and fixing its consumers is one pass (CLAUDE.md,\n"
377 " \"a breaking bump of an in-house crate is forward-fixed, in the same pass\").\n"
378 " Fix: bump the requirement in the manifests above, make the consumers\n"
379 " compile, and push them with this one.",
380 file=sys.stderr,
381 )
382 clean = [lbl for lbl, _g, _u, _a, bad in summaries if not bad]
383 if clean:
384 # The whole point of the second view. Saying which one passed is what
385 # turns "it worked on my machine" into a diagnosis.
386 print(
387 f" Note: the {clean[0]} view is clean, so the difference is what is\n"
388 " committed. An uncommitted manifest edit is the usual cause.",
389 file=sys.stderr,
390 )
391 return 1
392
393 for label, graded, unchecked, absent, _bad in summaries:
394 print(
395 f"pre-push: internal deps coherent [{label}] ({graded} requirements"
396 + (f", {unchecked} unchecked" if unchecked else "")
397 + (f", {len(absent)} crates not in this tree" if absent else "")
398 + ")."
399 )
400 if skipped_push_view:
401 # Never silently: a view that did not run must not read as one that passed.
402 print("pre-push: could not read the pushed commit; graded the working copy only.")
403 return 0
404
405
406 if __name__ == "__main__":
407 sys.exit(main())
408