Skip to main content

max / makeover-touch

35.9 KB · 905 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 The run grades FOUR views and fails on any of them:
16
17 working copy what this machine builds today. The historical check.
18 as pushed the same question asked of the pushing repo's manifests AS THEY
19 EXIST AT THAT COMMIT, against the rest of the tree on disk.
20 Needs a pushed sha, so it is skipped in the by-hand report.
21 as published every requirement against the version the provider has actually
22 PUSHED, read from its last-fetched remote-tracking ref. This is
23 the only view that predicts a build on a machine that is not
24 this one.
25 as published to crates.io
26 the same question for the siblings that are consumed from the
27 REGISTRY rather than by git URL: the makeover suite, alloy_tui,
28 pter. A requirement only the local checkout satisfies is a
29 finding, and the remedy is to publish rather than to push.
30
31 WHY THE SECOND VIEW EXISTS. The first one reads manifests off the filesystem, so
32 an uncommitted forward-fix makes it grade text that git is not publishing. That
33 is not hypothetical: on 2026-08-24 mnw-cli's `synckit-client` requirement had
34 been advanced to "0.9" in the working copy and never committed, this gate printed
35 `internal deps coherent (42 requirements)`, the push went out, and Sando failed
36 to resolve `^0.8` against 0.9.0 minutes later. The gate was checking a tree that
37 was not the tree being published, and nothing distinguished that from real
38 coherence.
39
40 WHAT IT GRADES. Every dependency in the tree that carries both a `git` URL on one
41 of our forges and a `version` requirement, against the version in the working
42 copy of the crate that URL names. That is the pairing cargo enforces and the one
43 that broke: a requirement of "0.11" against a sibling that has moved to 0.14 is
44 not a warning, it is a graph that will not resolve on any machine.
45
46 WHY DISK AND REMOTES BOTH. `~/Code/.cargo/config.toml` patches every one of these
47 dependencies to the working copy in the tree, so what is on disk here is what
48 every local build reads: a bump that has not been pushed yet breaks its consumers
49 on this machine just as thoroughly, and finding that out at push time is the
50 point. But cargo resolves a git dependency against the branch head at the URL, so
51 the `[patch]` block also HIDES an unpushed sibling from every local check. That
52 gap cost a production build on 2026-08-26 (infra `5c4928c1`): MNW required quasi
53 "^0.63", quasi's working copy was 0.63.0 and its `mnw/main` was 0.56.0, both disk
54 views were clean, and Sando could not resolve. The three views answer three
55 different questions and all of them matter.
56
57 WHY THE FOURTH VIEW EXISTS. The three above all pair a requirement against the
58 sibling repo its git URL names, and a makeover crate has no git URL in a consumer
59 manifest: it is consumed from crates.io. So the failure class this gate exists to
60 stop -- bump a library, forget its consumers -- had a second half that was not
61 merely ungraded but confidently reported coherent. On 2026-08-28 quasi-basics
62 required makeover-layout "^0.38.0" while the index's newest was 0.37.0 and 0.38.0
63 existed in one working directory; `cargo check` could not select a version and
64 this gate printed `internal deps coherent [as published] (42 requirements)`. The
65 same afternoon, stripping the [patch.crates-io] blocks mid-publish put MNW/server
66 on makeover "^3.1" against an index whose newest was 3.0.0, with the same verdict.
67 That window -- manifest bumped, crate not yet on the index -- is a normal stage of
68 any correct publish order, short when the publish succeeds and unbounded when it
69 fails partway across a seven-crate suite. makeover-layout carries `links`, so one
70 unpublished crate in the suite takes down every consumer in the graph at once.
71
72 Blame for this view is CONSUMER-side only. Pushing the provider is how a crate
73 reaches the index in the first place (Bento publishes from a pushed tag), so
74 refusing that push because its own new version is unpublished would deadlock the
75 release. What is worth refusing is a push that carries a requirement nothing on
76 the index satisfies.
77
78 NETWORK. None on the happy path. The published view reads the last-fetched
79 remote-tracking ref, and only when a requirement FAILS against it does it fetch
80 that one repo's one branch and re-check, so a ref nobody has fetched since the
81 sibling was pushed cannot refuse a good push. A repo with no fetched remote at
82 all is reported as ungraded, never as passing. The crates.io view works the same
83 way one layer over: it reads cargo's own sparse-index cache under
84 ~/.cargo/registry/index, and only a requirement that fails against the cache
85 costs one HTTPS fetch of that crate's index file, so a cache older than the
86 sibling's release cannot refuse a good push either. A crate whose index file can
87 be read from neither place is reported as ungraded.
88
89 WHAT IT DOES NOT GRADE, on purpose:
90
91 third-party deps a version requirement on somebody else's crate is not
92 something this tree can forward-fix.
93 path deps no version requirement to be wrong about.
94 ranges and wildcards `>=`, `<`, `*` and comma lists are deliberate statements
95 about a span, not a pin that drifts. Counted as unchecked.
96 """
97
98 import glob
99 import json
100 import os
101 import re
102 import subprocess
103 import sys
104 import tomllib
105 import urllib.error
106 import urllib.request
107
108 # The forges that make a git URL ours. A dependency on somebody else's git repo
109 # is not something this tree can forward-fix.
110 OURS = re.compile(r"(makenot\.work|git\.sr\.ht/~maxmj)", re.I)
111
112 # Directories that hold code we do not grade: retired, staged for deletion, or
113 # not ours. Mirrors the sweep's exclusions rather than inventing a second list.
114 SKIP_DIRS = {
115 "target", ".git", "node_modules", "dist", "vendor",
116 "_archive", "_scratch", "trash", "_meta", "vtebench",
117 }
118 MAX_DEPTH = 4
119
120 DEP_SECTIONS = ("dependencies", "dev-dependencies", "build-dependencies")
121
122 # The crates.io view's label. Long on purpose: "as published" already means the
123 # git-URL siblings, and the remedy for the two is different.
124 REGISTRY_LABEL = "as published to crates.io"
125
126
127 def manifests(root):
128 """Every Cargo.toml in the tree, shallow-walked."""
129 out = []
130 stack = [(root, 0)]
131 while stack:
132 d, depth = stack.pop()
133 try:
134 entries = list(os.scandir(d))
135 except OSError:
136 continue
137 for e in entries:
138 if e.is_file() and e.name == "Cargo.toml":
139 out.append(e.path)
140 elif e.is_dir() and e.name not in SKIP_DIRS and depth < MAX_DEPTH:
141 stack.append((e.path, depth + 1))
142 return out
143
144
145 def load(path):
146 try:
147 with open(path, "rb") as fh:
148 return tomllib.load(fh)
149 except (OSError, tomllib.TOMLDecodeError):
150 return None
151
152
153 def dep_tables(doc):
154 """Every dependency table in a manifest, including per-target and workspace."""
155 for section in DEP_SECTIONS:
156 table = doc.get(section)
157 if isinstance(table, dict):
158 yield table
159 for cfg in (doc.get("target") or {}).values():
160 if not isinstance(cfg, dict):
161 continue
162 for section in DEP_SECTIONS:
163 table = cfg.get(section)
164 if isinstance(table, dict):
165 yield table
166 ws = doc.get("workspace") or {}
167 table = ws.get("dependencies")
168 if isinstance(table, dict):
169 yield table
170
171
172 def parse_version(v):
173 """A version as a 3-tuple, prerelease dropped. Junk sorts as (0, 0, 0)."""
174 core = str(v).split("+")[0].split("-")[0]
175 parts = []
176 for piece in core.split(".")[:3]:
177 try:
178 parts.append(int(piece))
179 except ValueError:
180 parts.append(0)
181 while len(parts) < 3:
182 parts.append(0)
183 return tuple(parts)
184
185
186 def satisfies(req, version):
187 """Cargo's default (caret) requirement semantics. None means 'not graded'.
188
189 The rule that matters here is the 0.x one: under 0.1.0 and above, the MINOR
190 is the compatibility boundary, which is why a 0.11 requirement rejects 0.14
191 outright rather than treating it as a newer patch.
192 """
193 req = req.strip()
194 if not req or any(c in req for c in "<>*,~"):
195 return None
196 # A prerelease satisfies nothing that does not ask for a prerelease of the
197 # same version, so a plain requirement rejects it. This is the shape the
198 # maturity ladder produces at beta entry: a sibling at 1.0.0-beta.1 does not
199 # resolve for a consumer requiring "1.0", and cargo says so.
200 if "-" in str(version).split("+")[0] and "-" not in req:
201 return False
202 exact = req.startswith("=")
203 req = req.lstrip("^=").strip()
204 if not req:
205 return None
206 given = req.split(".")
207 try:
208 r = [int(p) for p in given[:3]]
209 except ValueError:
210 return None
211 v = parse_version(version)
212 if exact:
213 return tuple(v[: len(r)]) == tuple(r)
214 if r[0] > 0:
215 return v[0] == r[0] and v[1:] >= tuple(r[1:] + [0] * (2 - len(r[1:])))
216 if len(r) == 1:
217 return v[0] == 0
218 if r[1] > 0:
219 return v[0] == 0 and v[1] == r[1] and v[2] >= (r[2] if len(r) > 2 else 0)
220 # 0.0.x: every patch is its own compatibility island.
221 if len(r) > 2:
222 return v[:3] == (0, 0, r[2])
223 return v[0] == 0 and v[1] == 0
224
225
226 def git_lines(repo, *args):
227 """Run git in `repo` and return stdout lines, or None if it failed."""
228 try:
229 out = subprocess.run(
230 ["git", "-C", repo, *args],
231 capture_output=True, text=True, check=True,
232 )
233 except (OSError, subprocess.CalledProcessError):
234 return None
235 return out.stdout.splitlines()
236
237
238 def git_manifests(repo, sha):
239 """Repo-relative paths of every Cargo.toml at `sha`, or None if unreadable."""
240 lines = git_lines(repo, "ls-tree", "-r", "--name-only", sha)
241 if lines is None:
242 return None
243 out = []
244 for rel in lines:
245 if os.path.basename(rel) != "Cargo.toml":
246 continue
247 if any(part in SKIP_DIRS for part in rel.split("/")):
248 continue
249 out.append(rel)
250 return out
251
252
253 def load_at(repo, sha, rel):
254 """One manifest as it exists at `sha`. None if missing or unparseable."""
255 lines = git_lines(repo, "show", f"{sha}:{rel}")
256 if lines is None:
257 return None
258 try:
259 return tomllib.loads("\n".join(lines))
260 except tomllib.TOMLDecodeError:
261 return None
262
263
264 def pushed_view(docs, repo, sha):
265 """`docs` with everything under `repo` replaced by its content at `sha`.
266
267 The rest of the tree stays as it is on disk, which is what a local build
268 resolves against either way. Returns None if the commit cannot be read, so
269 the caller can skip the view rather than invent a verdict about it.
270 """
271 rels = git_manifests(repo, sha)
272 if rels is None:
273 return None
274 out = {k: v for k, v in docs.items() if not k.startswith(repo + os.sep)}
275 for rel in rels:
276 out[os.path.join(repo, rel)] = load_at(repo, sha, rel)
277 return out
278
279
280 def repo_of(path, tree):
281 """The git repo `path` belongs to, or None if it is not in one under `tree`."""
282 d = os.path.dirname(path)
283 while d.startswith(tree):
284 if os.path.exists(os.path.join(d, ".git")):
285 return d
286 if d == tree:
287 break
288 parent = os.path.dirname(d)
289 if parent == d:
290 break
291 d = parent
292 return None
293
294
295 def publishing_ref(repo, cache):
296 """The remote-tracking ref a git dependency on `repo` would resolve against.
297
298 Cargo reads the branch head at the URL, and every in-house dependency URL is
299 on one of our forges (CLAUDE.md, "what each remote is for": `mnw` is the
300 public face, `srht` a backup, `astra` the private mirror). So prefer `mnw`,
301 then any other remote whose URL is ours, and fall back to `origin`.
302
303 Returns `<remote>/<branch>` or None when the repo has no such remote or the
304 ref has never been fetched. None is not a verdict: the caller reports the
305 repo as ungraded rather than inventing one.
306 """
307 if repo in cache:
308 return cache[repo]
309 ref = None
310 lines = git_lines(repo, "remote", "-v") or []
311 urls = {}
312 for line in lines:
313 parts = line.split()
314 if len(parts) >= 2:
315 urls.setdefault(parts[0], parts[1])
316 order = [r for r in ("mnw",) if r in urls]
317 order += [r for r, u in urls.items() if r not in order and OURS.search(u)]
318 order += [r for r in ("origin",) if r in urls and r not in order]
319 for remote in order:
320 head = git_lines(repo, "symbolic-ref", "--quiet", f"refs/remotes/{remote}/HEAD")
321 candidates = []
322 if head:
323 candidates.append(head[0].rsplit("/", 1)[-1])
324 candidates += ["main", "master"]
325 for branch in candidates:
326 if git_lines(repo, "rev-parse", "--verify", "--quiet",
327 f"refs/remotes/{remote}/{branch}"):
328 ref = f"{remote}/{branch}"
329 break
330 if ref:
331 break
332 cache[repo] = ref
333 return ref
334
335
336 def version_at(repo, ref, rel, cache):
337 """A crate's version in `repo` at `ref`, following a workspace inheritance.
338
339 `rel` is the manifest's path relative to the repo. Returns None when the
340 manifest is not at that ref at all, which is what a crate added since the
341 last push looks like.
342 """
343 key = (repo, ref, rel)
344 if key in cache:
345 return cache[key]
346 version = None
347 doc = load_at(repo, ref, rel)
348 if doc:
349 pkg = doc.get("package")
350 if isinstance(pkg, dict):
351 v = pkg.get("version")
352 if isinstance(v, str):
353 version = v
354 elif isinstance(v, dict) and v.get("workspace") is True:
355 # Walk up to the workspace root as it exists at the same ref.
356 d = os.path.dirname(rel)
357 while True:
358 root_rel = os.path.join(d, "Cargo.toml") if d else "Cargo.toml"
359 root = load_at(repo, ref, root_rel) if root_rel != rel else None
360 inherited = (
361 ((root or {}).get("workspace") or {}).get("package") or {}
362 ).get("version")
363 if isinstance(inherited, str):
364 version = inherited
365 break
366 if not d:
367 break
368 d = os.path.dirname(d)
369 cache[key] = version
370 return version
371
372
373 def analyze_published(docs, disk_docs, tree, repo, sha):
374 """Grade every requirement against what its provider has actually PUSHED.
375
376 This is the view that predicts a build somewhere other than this machine.
377 The other two read the provider's version off the filesystem, and the
378 `[patch]` block in ~/Code/.cargo/config.toml means that is what a local
379 build resolves -- but a git dependency resolves against the branch head at
380 the URL, so an unpushed sibling passes both of them and fails everywhere
381 else. That is exactly what happened on 2026-08-26: MNW required quasi
382 "^0.63", quasi's working copy was 0.63.0 and `mnw/main` was 0.56.0, both
383 existing views were clean, and Sando build 72 could not resolve.
384
385 The repo being pushed is read at `sha` rather than at its remote, since what
386 it is about to publish is the thing to grade. Every other repo is read at
387 its last-fetched remote ref: no network on the happy path. A break is
388 re-checked after fetching that one repo, so a stale ref cannot refuse a push
389 on its own.
390
391 Returns (broken, graded, ungraded), where ungraded maps a repo to why.
392 """
393 ref_cache, version_cache, fetched = {}, {}, set()
394 versions_on_disk = crate_index(disk_docs, tree)
395 broken, graded, ungraded = [], 0, {}
396
397 for consumer_manifest, name, req in requirements(docs):
398 known = versions_on_disk.get(name)
399 if known is None:
400 continue # Not in this tree; the disk views already say so.
401 provider_manifest = known[1]
402 provider_repo = repo_of(provider_manifest, tree)
403 if provider_repo is None:
404 ungraded.setdefault(os.path.dirname(provider_manifest), "not a git repo")
405 continue
406 if repo is not None and provider_repo == repo and sha:
407 # The repo under the hook: what it is about to publish is `sha`,
408 # which the "as pushed" view already read off disk into `docs`.
409 continue
410 ref = publishing_ref(provider_repo, ref_cache)
411 if ref is None:
412 ungraded.setdefault(provider_repo, "no fetched remote to read")
413 continue
414 rel = os.path.relpath(provider_manifest, provider_repo)
415 have = version_at(provider_repo, ref, rel, version_cache)
416 if have is None:
417 ungraded.setdefault(provider_repo, f"{name} is not at {ref} yet")
418 continue
419 verdict = satisfies(req, have)
420 if verdict is None:
421 continue
422 if not verdict and provider_repo not in fetched:
423 # Only now, and only for this one repo: a ref nobody has fetched
424 # since the sibling was pushed would otherwise refuse a good push.
425 fetched.add(provider_repo)
426 remote, branch = ref.split("/", 1)
427 git_lines(provider_repo, "fetch", "--quiet", remote, branch)
428 version_cache.pop((provider_repo, ref, rel), None)
429 have = version_at(provider_repo, ref, rel, version_cache) or have
430 verdict = satisfies(req, have)
431 graded += 1
432 if not verdict:
433 broken.append(
434 (consumer_manifest, name, req, have, provider_manifest, ref)
435 )
436 return broken, graded, ungraded
437
438
439 def crate_index(docs, tree):
440 """Crate name -> (version, manifest path), workspace inheritance resolved.
441
442 A member saying `version.workspace = true` gets its number from the root,
443 and reporting it as 0.0.0 would be a false break.
444 """
445 ws_version = {}
446 for p, doc in docs.items():
447 if not doc:
448 continue
449 v = ((doc.get("workspace") or {}).get("package") or {}).get("version")
450 if isinstance(v, str):
451 ws_version[os.path.dirname(p)] = v
452
453 def resolve_version(manifest_path, pkg):
454 v = pkg.get("version")
455 if isinstance(v, str):
456 return v
457 d = os.path.dirname(manifest_path)
458 while d.startswith(tree):
459 if d in ws_version:
460 return ws_version[d]
461 parent = os.path.dirname(d)
462 if parent == d:
463 break
464 d = parent
465 return None
466
467 versions = {}
468 for p, doc in docs.items():
469 if not doc:
470 continue
471 pkg = doc.get("package")
472 if not isinstance(pkg, dict) or not isinstance(pkg.get("name"), str):
473 continue
474 v = resolve_version(p, pkg)
475 if v:
476 versions[pkg["name"]] = (v, p)
477 return versions
478
479
480 def requirements(docs):
481 """Every in-house git+version pair: (consumer manifest, crate, requirement).
482
483 A dependency qualifies when it carries both a `git` URL on one of our forges
484 and a `version`. That is the pairing cargo enforces and the one that breaks:
485 a requirement of "0.11" against a sibling that has moved to 0.14 is not a
486 warning, it is a graph that will not resolve on any machine.
487 """
488 for p, doc in docs.items():
489 if not doc:
490 continue
491 for table in dep_tables(doc):
492 for key, spec in table.items():
493 if not isinstance(spec, dict):
494 continue
495 git = spec.get("git")
496 req = spec.get("version")
497 if not isinstance(git, str) or not isinstance(req, str):
498 continue
499 if not OURS.search(git):
500 continue
501 name = spec.get("package") if isinstance(spec.get("package"), str) else key
502 yield p, name, req
503
504
505 # --- the crates.io view -------------------------------------------------------
506
507 # Cargo's own sparse-index cache. One file per crate, holding the same
508 # newline-delimited JSON the registry serves, prefixed by a format byte and an
509 # etag and separated by NULs. Reading it is why the happy path costs no network.
510 INDEX_CACHE_GLOB = os.path.expanduser(
511 "~/.cargo/registry/index/index.crates.io-*/.cache"
512 )
513 INDEX_URL = "https://index.crates.io"
514 INDEX_TIMEOUT = 5
515
516
517 def index_prefix(name):
518 """The registry's directory prefix for a crate name (cargo's own scheme)."""
519 n = name.lower()
520 if len(n) == 1:
521 return "1"
522 if len(n) == 2:
523 return "2"
524 if len(n) == 3:
525 return os.path.join("3", n[0])
526 return os.path.join(n[:2], n[2:4])
527
528
529 def parse_index_blob(blob):
530 """Every non-yanked version in an index file, newest first.
531
532 Takes the cache format and the wire format both: the cache is the wire
533 format with a header and NUL separators, so splitting on NUL and newline
534 and keeping whatever parses as a version record covers each of them.
535 """
536 if isinstance(blob, bytes):
537 blob = blob.decode("utf-8", "replace")
538 out = []
539 for chunk in blob.replace("\x00", "\n").splitlines():
540 chunk = chunk.strip()
541 if not chunk.startswith("{"):
542 continue
543 try:
544 rec = json.loads(chunk)
545 except ValueError:
546 continue
547 vers = rec.get("vers")
548 if isinstance(vers, str) and not rec.get("yanked"):
549 out.append(vers)
550 out.sort(key=parse_version, reverse=True)
551 return out
552
553
554 def index_from_cache(name):
555 """Published versions from cargo's sparse-index cache, or None if absent."""
556 for cache in glob.glob(INDEX_CACHE_GLOB):
557 path = os.path.join(cache, index_prefix(name), name.lower())
558 try:
559 with open(path, "rb") as fh:
560 return parse_index_blob(fh.read())
561 except OSError:
562 continue
563 return None
564
565
566 def index_from_network(name):
567 """Published versions from the sparse index itself, or None if unreachable."""
568 url = f"{INDEX_URL}/{index_prefix(name)}/{name.lower()}"
569 try:
570 with urllib.request.urlopen(url, timeout=INDEX_TIMEOUT) as resp:
571 return parse_index_blob(resp.read())
572 except (urllib.error.URLError, OSError, ValueError):
573 return None
574
575
576 def published_versions(name, cache, refresh=False):
577 """Every non-yanked published version of `name`, newest first.
578
579 Reads cargo's cache first and goes to the network only when the caller says
580 the cached answer was not good enough, which is the same escalation the
581 remote-ref view does: a cache older than the sibling's release must not be
582 able to refuse a good push.
583 """
584 if not refresh and name in cache:
585 return cache[name]
586 versions = None if refresh else index_from_cache(name)
587 if versions is None:
588 versions = index_from_network(name)
589 cache[name] = versions
590 return versions
591
592
593 def publishable_index(docs, tree):
594 """In-house crates that go to crates.io: name -> (version, manifest path).
595
596 `publish = false` is the marker for everything that does not (quasi, shop,
597 everycycle, wam), and it inherits from the workspace root the same way
598 `version` does, so resolve it the same way rather than reading the member
599 alone.
600 """
601 ws_publish = {}
602 for p, doc in docs.items():
603 if not doc:
604 continue
605 v = ((doc.get("workspace") or {}).get("package") or {}).get("publish")
606 if isinstance(v, bool):
607 ws_publish[os.path.dirname(p)] = v
608
609 def publishes(manifest_path, pkg):
610 v = pkg.get("publish")
611 if isinstance(v, bool):
612 return v
613 if isinstance(v, list):
614 return bool(v)
615 if isinstance(v, dict) and v.get("workspace") is True:
616 d = os.path.dirname(manifest_path)
617 while d.startswith(tree):
618 if d in ws_publish:
619 return ws_publish[d]
620 parent = os.path.dirname(d)
621 if parent == d:
622 break
623 d = parent
624 return True
625
626 out = {}
627 for name, (version, manifest) in crate_index(docs, tree).items():
628 doc = docs.get(manifest) or {}
629 pkg = doc.get("package")
630 if isinstance(pkg, dict) and publishes(manifest, pkg):
631 out[name] = (version, manifest)
632 return out
633
634
635 def registry_requirements(docs, publishable):
636 """Every requirement on an in-house crate taken from the registry.
637
638 The pairing is the mirror image of `requirements()`: a `version` and NO git
639 URL, naming a crate this tree both holds and publishes. A path dependency is
640 excluded for the same reason it is everywhere else here -- what it resolves
641 against is the file next to it, not a release.
642 """
643 for p, doc in docs.items():
644 if not doc:
645 continue
646 for table in dep_tables(doc):
647 for key, spec in table.items():
648 req = None
649 if isinstance(spec, str):
650 req = spec
651 elif isinstance(spec, dict):
652 if spec.get("git") or spec.get("path"):
653 continue
654 if isinstance(spec.get("version"), str):
655 req = spec["version"]
656 if req is None:
657 continue
658 name = key
659 if isinstance(spec, dict) and isinstance(spec.get("package"), str):
660 name = spec["package"]
661 if name in publishable:
662 yield p, name, req
663
664
665 def analyze_registry(docs, tree):
666 """Grade every registry requirement on an in-house crate against the index.
667
668 The escalation is deliberate and is what keeps the happy path free: the
669 local version is tried first, and the index is consulted only for a
670 requirement the checkout is the one thing satisfying (or that nothing
671 satisfies). Working ahead of a release is fine; requiring a version that
672 exists in no published release is not, because no other machine can resolve
673 it and `links` on makeover-layout makes that the whole graph at once.
674
675 Returns (broken, graded, unchecked, ungraded).
676 """
677 publishable = publishable_index(docs, tree)
678 index_cache = {}
679 broken, graded, unchecked, ungraded = [], 0, 0, {}
680
681 for consumer_manifest, name, req in registry_requirements(docs, publishable):
682 local_version, provider_manifest = publishable[name]
683 if satisfies(req, local_version) is None:
684 unchecked += 1
685 continue
686 versions = published_versions(name, index_cache)
687 if versions is None:
688 ungraded.setdefault(name, "no index entry, cached or fetched")
689 continue
690 if not any(satisfies(req, v) for v in versions):
691 # A cache older than the sibling's release would otherwise refuse a
692 # good push, so pay for one fetch before calling it broken.
693 versions = published_versions(name, index_cache, refresh=True) or versions
694 graded += 1
695 if not any(satisfies(req, v) for v in versions):
696 newest = versions[0] if versions else "nothing published"
697 broken.append(
698 (consumer_manifest, name, req, newest, provider_manifest,
699 "the index")
700 )
701 return broken, graded, unchecked, ungraded
702
703
704 def analyze(docs, tree):
705 """Grade every in-house git+version pair in `docs` against the tree on disk.
706
707 Returns (broken, unchecked, absent, graded), where a broken entry is
708 (consumer manifest, crate, requirement, version found, provider manifest,
709 source label). The source label is None here: this view reads the version
710 off a manifest, and naming the manifest already says where it came from.
711 """
712 versions = crate_index(docs, tree)
713 broken, unchecked, absent, graded = [], 0, set(), 0
714 for p, name, req in requirements(docs):
715 known = versions.get(name)
716 if known is None:
717 # A repo that is not on this machine (ripgrow lives on mbp only).
718 # Not a finding: nothing here can be wrong about it.
719 absent.add(name)
720 continue
721 verdict = satisfies(req, known[0])
722 if verdict is None:
723 unchecked += 1
724 continue
725 graded += 1
726 if not verdict:
727 broken.append((p, name, req, known[0], known[1], None))
728 return broken, unchecked, absent, graded
729
730
731 def split_blame(broken, repo, blame_provider=True):
732 """Breaks this push owns, and breaks that were already there.
733
734 `blame_provider` is off for the crates.io view: there, the provider side of
735 a break is a crate that has been bumped and not released yet, and pushing it
736 is how it reaches the index at all (Bento publishes from a pushed tag). Only
737 the consumer side of that view is a push worth refusing.
738 """
739 ours, theirs = [], []
740 for item in broken:
741 consumer_manifest, _name, _req, _have, provider_manifest, _src = item
742 mine = repo is not None and (
743 consumer_manifest.startswith(repo + os.sep)
744 or (blame_provider and provider_manifest.startswith(repo + os.sep))
745 )
746 (ours if mine else theirs).append(item)
747 return ours, theirs
748
749
750 def report(broken, repo, tree, label, blame_provider=True):
751 """Print one view's breaks. Returns True if this push has to be refused."""
752 ours, theirs = split_blame(broken, repo, blame_provider)
753
754 def rel(path):
755 return os.path.relpath(path, tree)
756
757 for consumer_manifest, name, req, have, provider_manifest, src in ours + theirs:
758 where = (
759 f"{src} has {have} ({rel(provider_manifest)})"
760 if src
761 else f"the tree has {have} ({rel(provider_manifest)})"
762 )
763 print(
764 f" [{label}] {rel(consumer_manifest)}: requires {name} \"{req}\", {where}",
765 file=sys.stderr,
766 )
767 if repo is None:
768 return bool(broken)
769 if not ours:
770 # Somebody else's skew. Worth seeing, never worth blocking this push on:
771 # a gate that fails for a reason the pusher cannot fix is a gate that
772 # gets bypassed by reflex, and then it is not a gate.
773 if theirs:
774 print(
775 f"pre-push: [{label}] {len(theirs)} unresolvable requirements "
776 "elsewhere in the tree (listed above, not this push's).",
777 )
778 return False
779 return True
780
781
782 def main():
783 if len(sys.argv) < 2:
784 print(__doc__.strip(), file=sys.stderr)
785 return 2
786 tree = os.path.realpath(sys.argv[1])
787 repo = os.path.realpath(sys.argv[2]) if len(sys.argv) > 2 else None
788 sha = sys.argv[3] if len(sys.argv) > 3 else None
789
790 docs = {p: load(p) for p in manifests(tree)}
791
792 views = [("working copy", docs)]
793 skipped_push_view = False
794 if repo and sha:
795 pushed = pushed_view(docs, repo, sha)
796 if pushed is None:
797 skipped_push_view = True
798 else:
799 views.append(("as pushed", pushed))
800
801 refuse = False
802 summaries = []
803 for label, view in views:
804 broken, unchecked, absent, graded = analyze(view, tree)
805 summaries.append((label, graded, unchecked, absent, bool(broken)))
806 if broken and report(broken, repo, tree, label):
807 refuse = True
808
809 # The third view: what the rest of the tree has actually PUSHED. Graded from
810 # the most authoritative consumer view available, so the requirements read
811 # are the ones about to be published.
812 consumer_view = views[-1][1]
813 pub_broken, pub_graded, ungraded = analyze_published(
814 consumer_view, docs, tree, repo, sha
815 )
816 summaries.append(("as published", pub_graded, 0, set(), bool(pub_broken)))
817 if pub_broken and report(pub_broken, repo, tree, "as published"):
818 refuse = True
819
820 # The fourth view: the siblings that are consumed from crates.io rather than
821 # by git URL. Nothing above can see them, because the pairing every other
822 # view makes is against the repo a git URL names.
823 reg_broken, reg_graded, reg_unchecked, reg_ungraded = analyze_registry(
824 consumer_view, tree
825 )
826 summaries.append(
827 (REGISTRY_LABEL, reg_graded, reg_unchecked, set(), bool(reg_broken))
828 )
829 if reg_broken and report(
830 reg_broken, repo, tree, REGISTRY_LABEL, blame_provider=False
831 ):
832 refuse = True
833
834 if refuse:
835 bad = {lbl for lbl, _g, _u, _a, broke in summaries if broke}
836 clean = [lbl for lbl, _g, _u, _a, broke in summaries if not broke]
837 print("", file=sys.stderr)
838 print(
839 "pre-push: this push leaves a dependency that cannot resolve.\n"
840 " A version requirement states which major a consumer was written against,\n"
841 " so bumping a library and fixing its consumers is one pass (CLAUDE.md,\n"
842 " \"a breaking bump of an in-house crate is forward-fixed, in the same pass\").\n"
843 " Fix: bump the requirement in the manifests above, make the consumers\n"
844 " compile, and push them with this one.",
845 file=sys.stderr,
846 )
847 # Which view broke is the diagnosis, so say what the views disagree
848 # about rather than only that they disagree.
849 if REGISTRY_LABEL in bad:
850 print(
851 " A requirement above resolves against no version on crates.io, so the\n"
852 " fix is to PUBLISH the sibling, not to push it. Until it is on the\n"
853 " index no machine can resolve the graph, this one included once the\n"
854 " [patch.crates-io] block is out of the way.",
855 file=sys.stderr,
856 )
857 if "as published" in bad and "working copy" not in bad:
858 print(
859 " The working copy is fine and the published tree is not, so the\n"
860 " difference is what has been PUSHED: a git dependency resolves against\n"
861 " the branch head at the URL, and ~/Code/.cargo/config.toml's [patch]\n"
862 " block hides that locally. Push the sibling named above first.",
863 file=sys.stderr,
864 )
865 elif "as pushed" in bad and "working copy" not in bad:
866 print(
867 " The working copy is fine and the commit is not, so the difference is\n"
868 " what is COMMITTED. An uncommitted manifest edit is the usual cause.",
869 file=sys.stderr,
870 )
871 elif clean:
872 print(
873 f" Note: the {clean[0]} view is clean, so the views disagree; the one\n"
874 " that failed is named on each line above.",
875 file=sys.stderr,
876 )
877 return 1
878
879 for label, graded, unchecked, absent, bad in summaries:
880 # A view that found a break somewhere else in the tree is not a view
881 # that passed. Reaching here means the break is not this push's to fix,
882 # which is a reason not to refuse and never a reason to print coherent.
883 verdict = "internal deps coherent" if not bad else "internal deps BROKEN elsewhere"
884 print(
885 f"pre-push: {verdict} [{label}] ({graded} requirements"
886 + (f", {unchecked} unchecked" if unchecked else "")
887 + (f", {len(absent)} crates not in this tree" if absent else "")
888 + ")."
889 )
890 for where, why in sorted(ungraded.items()):
891 # Never silently: a repo nobody could read is not a repo that passed.
892 print(
893 f"pre-push: [as published] {os.path.relpath(where, tree)} not graded ({why})."
894 )
895 for name, why in sorted(reg_ungraded.items()):
896 print(f"pre-push: [{REGISTRY_LABEL}] {name} not graded ({why}).")
897 if skipped_push_view:
898 # Never silently: a view that did not run must not read as one that passed.
899 print("pre-push: could not read the pushed commit; graded the working copy only.")
900 return 0
901
902
903 if __name__ == "__main__":
904 sys.exit(main())
905