Skip to main content

max / alloy_tui

48.6 KB · 1206 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 A fifth mode, run on a schedule rather than at push:
16
17 python3 internal-deps.py ~/Code --release-tags [--json]
18
19 grades the RELEASE TAGS instead of the branch, one per repo whose own
20 `bento.toml` declares a release: the tag matching the version it is on today.
21 See "the release-tag view" below for why the four views cannot see this. It is
22 never run by the hook -- the answer changes while the repo sits still, and a
23 push cannot fix a tag cut last week.
24
25 The run grades FOUR views and fails on any of them:
26
27 working copy what this machine builds today. The historical check.
28 as pushed the same question asked of the pushing repo's manifests AS THEY
29 EXIST AT THAT COMMIT, against the rest of the tree on disk.
30 Needs a pushed sha, so it is skipped in the by-hand report.
31 as published every requirement against the version the provider has actually
32 PUSHED, read from its last-fetched remote-tracking ref. This is
33 the only view that predicts a build on a machine that is not
34 this one.
35 as published to crates.io
36 the same question for the siblings that are consumed from the
37 REGISTRY rather than by git URL: the makeover suite, alloy_tui,
38 pter. A requirement only the local checkout satisfies is a
39 finding, and the remedy is to publish rather than to push.
40
41 WHY THE SECOND VIEW EXISTS. The first one reads manifests off the filesystem, so
42 an uncommitted forward-fix makes it grade text that git is not publishing. That
43 is not hypothetical: on 2026-08-24 mnw-cli's `synckit-client` requirement had
44 been advanced to "0.9" in the working copy and never committed, this gate printed
45 `internal deps coherent (42 requirements)`, the push went out, and Sando failed
46 to resolve `^0.8` against 0.9.0 minutes later. The gate was checking a tree that
47 was not the tree being published, and nothing distinguished that from real
48 coherence.
49
50 WHAT IT GRADES. Every dependency in the tree that carries both a `git` URL on one
51 of our forges and a `version` requirement, against the version in the working
52 copy of the crate that URL names. That is the pairing cargo enforces and the one
53 that broke: a requirement of "0.11" against a sibling that has moved to 0.14 is
54 not a warning, it is a graph that will not resolve on any machine.
55
56 WHY DISK AND REMOTES BOTH. `~/Code/.cargo/config.toml` patches every one of these
57 dependencies to the working copy in the tree, so what is on disk here is what
58 every local build reads: a bump that has not been pushed yet breaks its consumers
59 on this machine just as thoroughly, and finding that out at push time is the
60 point. But cargo resolves a git dependency against the branch head at the URL, so
61 the `[patch]` block also HIDES an unpushed sibling from every local check. That
62 gap cost a production build on 2026-08-26 (infra `5c4928c1`): MNW required quasi
63 "^0.63", quasi's working copy was 0.63.0 and its `mnw/main` was 0.56.0, both disk
64 views were clean, and Sando could not resolve. The three views answer three
65 different questions and all of them matter.
66
67 WHY THE FOURTH VIEW EXISTS. The three above all pair a requirement against the
68 sibling repo its git URL names, and a makeover crate has no git URL in a consumer
69 manifest: it is consumed from crates.io. So the failure class this gate exists to
70 stop -- bump a library, forget its consumers -- had a second half that was not
71 merely ungraded but confidently reported coherent. On 2026-08-28 quasi-basics
72 required makeover-layout "^0.38.0" while the index's newest was 0.37.0 and 0.38.0
73 existed in one working directory; `cargo check` could not select a version and
74 this gate printed `internal deps coherent [as published] (42 requirements)`. The
75 same afternoon, stripping the [patch.crates-io] blocks mid-publish put MNW/server
76 on makeover "^3.1" against an index whose newest was 3.0.0, with the same verdict.
77 That window -- manifest bumped, crate not yet on the index -- is a normal stage of
78 any correct publish order, short when the publish succeeds and unbounded when it
79 fails partway across a seven-crate suite. makeover-layout carries `links`, so one
80 unpublished crate in the suite takes down every consumer in the graph at once.
81
82 Blame for this view is CONSUMER-side only. Pushing the provider is how a crate
83 reaches the index in the first place (Bento publishes from a pushed tag), so
84 refusing that push because its own new version is unpublished would deadlock the
85 release. What is worth refusing is a push that carries a requirement nothing on
86 the index satisfies.
87
88 NETWORK. None on the happy path. The published view reads the last-fetched
89 remote-tracking ref, and only when a requirement FAILS against it does it fetch
90 that one repo's one branch and re-check, so a ref nobody has fetched since the
91 sibling was pushed cannot refuse a good push. A repo with no fetched remote at
92 all is reported as ungraded, never as passing. The crates.io view works the same
93 way one layer over: it reads cargo's own sparse-index cache under
94 ~/.cargo/registry/index, and only a requirement that fails against the cache
95 costs one HTTPS fetch of that crate's index file, so a cache older than the
96 sibling's release cannot refuse a good push either. A crate whose index file can
97 be read from neither place is reported as ungraded.
98
99 WHAT IT DOES NOT GRADE, on purpose:
100
101 third-party deps a version requirement on somebody else's crate is not
102 something this tree can forward-fix.
103 path deps no version requirement to be wrong about.
104 ranges and wildcards `>=`, `<`, `*` and comma lists are deliberate statements
105 about a span, not a pin that drifts. Counted as unchecked.
106 """
107
108 import glob
109 import json
110 import os
111 import re
112 import subprocess
113 import sys
114 import tomllib
115 import urllib.error
116 import urllib.request
117
118 # The forges that make a git URL ours. A dependency on somebody else's git repo
119 # is not something this tree can forward-fix.
120 OURS = re.compile(r"(makenot\.work|git\.sr\.ht/~maxmj)", re.I)
121
122 # Directories that hold code we do not grade: retired, staged for deletion, or
123 # not ours. Mirrors the sweep's exclusions rather than inventing a second list.
124 SKIP_DIRS = {
125 "target", ".git", "node_modules", "dist", "vendor",
126 "_archive", "_scratch", "trash", "_meta", "vtebench",
127 # Bento's release worktrees. Detached copies of tree code AT TAGS, re-pinned
128 # with --force and never edited by a person, so grading them says nothing
129 # about the tree -- and says it loudly, because a tag legitimately carries
130 # the requirement that was current when it was cut. Measured 2026-08-31:
131 # 11 of 131 manifests in ~/Code were Bento worktrees, and a copy of a
132 # library under here can shadow the real one in the crate index, which turns
133 # noise into a false verdict.
134 ".bento",
135 }
136 MAX_DEPTH = 4
137
138 DEP_SECTIONS = ("dependencies", "dev-dependencies", "build-dependencies")
139
140 # The crates.io view's label. Long on purpose: "as published" already means the
141 # git-URL siblings, and the remedy for the two is different.
142 REGISTRY_LABEL = "as published to crates.io"
143
144
145 def manifests(root):
146 """Every Cargo.toml in the tree, shallow-walked."""
147 out = []
148 stack = [(root, 0)]
149 while stack:
150 d, depth = stack.pop()
151 try:
152 entries = list(os.scandir(d))
153 except OSError:
154 continue
155 for e in entries:
156 if e.is_file() and e.name == "Cargo.toml":
157 out.append(e.path)
158 elif e.is_dir() and e.name not in SKIP_DIRS and depth < MAX_DEPTH:
159 stack.append((e.path, depth + 1))
160 return out
161
162
163 def load(path):
164 try:
165 with open(path, "rb") as fh:
166 return tomllib.load(fh)
167 except (OSError, tomllib.TOMLDecodeError):
168 return None
169
170
171 def dep_tables(doc):
172 """Every dependency table in a manifest, including per-target and workspace."""
173 for section in DEP_SECTIONS:
174 table = doc.get(section)
175 if isinstance(table, dict):
176 yield table
177 for cfg in (doc.get("target") or {}).values():
178 if not isinstance(cfg, dict):
179 continue
180 for section in DEP_SECTIONS:
181 table = cfg.get(section)
182 if isinstance(table, dict):
183 yield table
184 ws = doc.get("workspace") or {}
185 table = ws.get("dependencies")
186 if isinstance(table, dict):
187 yield table
188
189
190 def parse_version(v):
191 """A version as a 3-tuple, prerelease dropped. Junk sorts as (0, 0, 0)."""
192 core = str(v).split("+")[0].split("-")[0]
193 parts = []
194 for piece in core.split(".")[:3]:
195 try:
196 parts.append(int(piece))
197 except ValueError:
198 parts.append(0)
199 while len(parts) < 3:
200 parts.append(0)
201 return tuple(parts)
202
203
204 def satisfies(req, version):
205 """Cargo's default (caret) requirement semantics. None means 'not graded'.
206
207 The rule that matters here is the 0.x one: under 0.1.0 and above, the MINOR
208 is the compatibility boundary, which is why a 0.11 requirement rejects 0.14
209 outright rather than treating it as a newer patch.
210 """
211 req = req.strip()
212 if not req or any(c in req for c in "<>*,~"):
213 return None
214 # A prerelease satisfies nothing that does not ask for a prerelease of the
215 # same version, so a plain requirement rejects it. This is the shape the
216 # maturity ladder produces at beta entry: a sibling at 1.0.0-beta.1 does not
217 # resolve for a consumer requiring "1.0", and cargo says so.
218 if "-" in str(version).split("+")[0] and "-" not in req:
219 return False
220 exact = req.startswith("=")
221 req = req.lstrip("^=").strip()
222 if not req:
223 return None
224 given = req.split(".")
225 try:
226 r = [int(p) for p in given[:3]]
227 except ValueError:
228 return None
229 v = parse_version(version)
230 if exact:
231 return tuple(v[: len(r)]) == tuple(r)
232 if r[0] > 0:
233 return v[0] == r[0] and v[1:] >= tuple(r[1:] + [0] * (2 - len(r[1:])))
234 if len(r) == 1:
235 return v[0] == 0
236 if r[1] > 0:
237 return v[0] == 0 and v[1] == r[1] and v[2] >= (r[2] if len(r) > 2 else 0)
238 # 0.0.x: every patch is its own compatibility island.
239 if len(r) > 2:
240 return v[:3] == (0, 0, r[2])
241 return v[0] == 0 and v[1] == 0
242
243
244 def git_lines(repo, *args):
245 """Run git in `repo` and return stdout lines, or None if it failed."""
246 try:
247 out = subprocess.run(
248 ["git", "-C", repo, *args],
249 capture_output=True, text=True, check=True,
250 )
251 except (OSError, subprocess.CalledProcessError):
252 return None
253 return out.stdout.splitlines()
254
255
256 def git_manifests(repo, sha):
257 """Repo-relative paths of every Cargo.toml at `sha`, or None if unreadable."""
258 lines = git_lines(repo, "ls-tree", "-r", "--name-only", sha)
259 if lines is None:
260 return None
261 out = []
262 for rel in lines:
263 if os.path.basename(rel) != "Cargo.toml":
264 continue
265 if any(part in SKIP_DIRS for part in rel.split("/")):
266 continue
267 out.append(rel)
268 return out
269
270
271 def load_at(repo, sha, rel):
272 """One manifest as it exists at `sha`. None if missing or unparseable."""
273 lines = git_lines(repo, "show", f"{sha}:{rel}")
274 if lines is None:
275 return None
276 try:
277 return tomllib.loads("\n".join(lines))
278 except tomllib.TOMLDecodeError:
279 return None
280
281
282 def pushed_view(docs, repo, sha):
283 """`docs` with everything under `repo` replaced by its content at `sha`.
284
285 The rest of the tree stays as it is on disk, which is what a local build
286 resolves against either way. Returns None if the commit cannot be read, so
287 the caller can skip the view rather than invent a verdict about it.
288 """
289 rels = git_manifests(repo, sha)
290 if rels is None:
291 return None
292 out = {k: v for k, v in docs.items() if not k.startswith(repo + os.sep)}
293 for rel in rels:
294 out[os.path.join(repo, rel)] = load_at(repo, sha, rel)
295 return out
296
297
298 def repo_of(path, tree):
299 """The git repo `path` belongs to, or None if it is not in one under `tree`."""
300 d = os.path.dirname(path)
301 while d.startswith(tree):
302 if os.path.exists(os.path.join(d, ".git")):
303 return d
304 if d == tree:
305 break
306 parent = os.path.dirname(d)
307 if parent == d:
308 break
309 d = parent
310 return None
311
312
313 def publishing_ref(repo, cache):
314 """The remote-tracking ref a git dependency on `repo` would resolve against.
315
316 Cargo reads the branch head at the URL, and every in-house dependency URL is
317 on one of our forges (CLAUDE.md, "what each remote is for": `mnw` is the
318 public face, `srht` a backup, `astra` the private mirror). So prefer `mnw`,
319 then any other remote whose URL is ours, and fall back to `origin`.
320
321 Returns `<remote>/<branch>` or None when the repo has no such remote or the
322 ref has never been fetched. None is not a verdict: the caller reports the
323 repo as ungraded rather than inventing one.
324 """
325 if repo in cache:
326 return cache[repo]
327 ref = None
328 lines = git_lines(repo, "remote", "-v") or []
329 urls = {}
330 for line in lines:
331 parts = line.split()
332 if len(parts) >= 2:
333 urls.setdefault(parts[0], parts[1])
334 order = [r for r in ("mnw",) if r in urls]
335 order += [r for r, u in urls.items() if r not in order and OURS.search(u)]
336 order += [r for r in ("origin",) if r in urls and r not in order]
337 for remote in order:
338 head = git_lines(repo, "symbolic-ref", "--quiet", f"refs/remotes/{remote}/HEAD")
339 candidates = []
340 if head:
341 candidates.append(head[0].rsplit("/", 1)[-1])
342 candidates += ["main", "master"]
343 for branch in candidates:
344 if git_lines(repo, "rev-parse", "--verify", "--quiet",
345 f"refs/remotes/{remote}/{branch}"):
346 ref = f"{remote}/{branch}"
347 break
348 if ref:
349 break
350 cache[repo] = ref
351 return ref
352
353
354 def version_at(repo, ref, rel, cache):
355 """A crate's version in `repo` at `ref`, following a workspace inheritance.
356
357 `rel` is the manifest's path relative to the repo. Returns None when the
358 manifest is not at that ref at all, which is what a crate added since the
359 last push looks like.
360 """
361 key = (repo, ref, rel)
362 if key in cache:
363 return cache[key]
364 version = None
365 doc = load_at(repo, ref, rel)
366 if doc:
367 pkg = doc.get("package")
368 if isinstance(pkg, dict):
369 v = pkg.get("version")
370 if isinstance(v, str):
371 version = v
372 elif isinstance(v, dict) and v.get("workspace") is True:
373 # Walk up to the workspace root as it exists at the same ref.
374 d = os.path.dirname(rel)
375 while True:
376 root_rel = os.path.join(d, "Cargo.toml") if d else "Cargo.toml"
377 root = load_at(repo, ref, root_rel) if root_rel != rel else None
378 inherited = (
379 ((root or {}).get("workspace") or {}).get("package") or {}
380 ).get("version")
381 if isinstance(inherited, str):
382 version = inherited
383 break
384 if not d:
385 break
386 d = os.path.dirname(d)
387 cache[key] = version
388 return version
389
390
391 def analyze_published(docs, disk_docs, tree, repo, sha):
392 """Grade every requirement against what its provider has actually PUSHED.
393
394 This is the view that predicts a build somewhere other than this machine.
395 The other two read the provider's version off the filesystem, and the
396 `[patch]` block in ~/Code/.cargo/config.toml means that is what a local
397 build resolves -- but a git dependency resolves against the branch head at
398 the URL, so an unpushed sibling passes both of them and fails everywhere
399 else. That is exactly what happened on 2026-08-26: MNW required quasi
400 "^0.63", quasi's working copy was 0.63.0 and `mnw/main` was 0.56.0, both
401 existing views were clean, and Sando build 72 could not resolve.
402
403 The repo being pushed is read at `sha` rather than at its remote, since what
404 it is about to publish is the thing to grade. Every other repo is read at
405 its last-fetched remote ref: no network on the happy path. A break is
406 re-checked after fetching that one repo, so a stale ref cannot refuse a push
407 on its own.
408
409 Returns (broken, graded, ungraded), where ungraded maps a repo to why.
410 """
411 ref_cache, version_cache, fetched = {}, {}, set()
412 versions_on_disk = crate_index(disk_docs, tree)
413 broken, graded, ungraded = [], 0, {}
414
415 for consumer_manifest, name, req in requirements(docs):
416 known = versions_on_disk.get(name)
417 if known is None:
418 continue # Not in this tree; the disk views already say so.
419 provider_manifest = known[1]
420 provider_repo = repo_of(provider_manifest, tree)
421 if provider_repo is None:
422 ungraded.setdefault(os.path.dirname(provider_manifest), "not a git repo")
423 continue
424 if repo is not None and provider_repo == repo and sha:
425 # The repo under the hook: what it is about to publish is `sha`,
426 # which the "as pushed" view already read off disk into `docs`.
427 continue
428 ref = publishing_ref(provider_repo, ref_cache)
429 if ref is None:
430 ungraded.setdefault(provider_repo, "no fetched remote to read")
431 continue
432 rel = os.path.relpath(provider_manifest, provider_repo)
433 have = version_at(provider_repo, ref, rel, version_cache)
434 if have is None:
435 ungraded.setdefault(provider_repo, f"{name} is not at {ref} yet")
436 continue
437 verdict = satisfies(req, have)
438 if verdict is None:
439 continue
440 if not verdict and provider_repo not in fetched:
441 # Only now, and only for this one repo: a ref nobody has fetched
442 # since the sibling was pushed would otherwise refuse a good push.
443 fetched.add(provider_repo)
444 remote, branch = ref.split("/", 1)
445 git_lines(provider_repo, "fetch", "--quiet", remote, branch)
446 version_cache.pop((provider_repo, ref, rel), None)
447 have = version_at(provider_repo, ref, rel, version_cache) or have
448 verdict = satisfies(req, have)
449 graded += 1
450 if not verdict:
451 broken.append(
452 (consumer_manifest, name, req, have, provider_manifest, ref)
453 )
454 return broken, graded, ungraded
455
456
457 def crate_index(docs, tree):
458 """Crate name -> (version, manifest path), workspace inheritance resolved.
459
460 A member saying `version.workspace = true` gets its number from the root,
461 and reporting it as 0.0.0 would be a false break.
462 """
463 ws_version = {}
464 for p, doc in docs.items():
465 if not doc:
466 continue
467 v = ((doc.get("workspace") or {}).get("package") or {}).get("version")
468 if isinstance(v, str):
469 ws_version[os.path.dirname(p)] = v
470
471 def resolve_version(manifest_path, pkg):
472 v = pkg.get("version")
473 if isinstance(v, str):
474 return v
475 d = os.path.dirname(manifest_path)
476 while d.startswith(tree):
477 if d in ws_version:
478 return ws_version[d]
479 parent = os.path.dirname(d)
480 if parent == d:
481 break
482 d = parent
483 return None
484
485 versions = {}
486 for p, doc in docs.items():
487 if not doc:
488 continue
489 pkg = doc.get("package")
490 if not isinstance(pkg, dict) or not isinstance(pkg.get("name"), str):
491 continue
492 v = resolve_version(p, pkg)
493 if v:
494 versions[pkg["name"]] = (v, p)
495 return versions
496
497
498 def requirements(docs):
499 """Every in-house git+version pair: (consumer manifest, crate, requirement).
500
501 A dependency qualifies when it carries both a `git` URL on one of our forges
502 and a `version`. That is the pairing cargo enforces and the one that breaks:
503 a requirement of "0.11" against a sibling that has moved to 0.14 is not a
504 warning, it is a graph that will not resolve on any machine.
505 """
506 for p, doc in docs.items():
507 if not doc:
508 continue
509 for table in dep_tables(doc):
510 for key, spec in table.items():
511 if not isinstance(spec, dict):
512 continue
513 git = spec.get("git")
514 req = spec.get("version")
515 if not isinstance(git, str) or not isinstance(req, str):
516 continue
517 if not OURS.search(git):
518 continue
519 name = spec.get("package") if isinstance(spec.get("package"), str) else key
520 yield p, name, req
521
522
523 # --- the release-tag view -----------------------------------------------------
524 #
525 # THE HOLE THIS CLOSES, and it is about WHEN the gate runs rather than what it
526 # checks. Every view above grades a commit once, on the way out, and nothing
527 # re-grades it when a sibling moves underneath it. A tag is the worst case,
528 # because a tag is what a release builds from and a tag never moves.
529 #
530 # Measured 2026-08-31: Bento's release of mnw-cli 0.1.3 failed on astra with
531 #
532 # error: failed to select a version for the requirement `synckit-client = "^0.9"`
533 # candidate versions found which didn't match: 0.10.0
534 #
535 # `mnw-cli-v0.1.3` was cut before the commit that took synckit-client 0.10, so
536 # the tagged tree still required ^0.9 while the pushed synckit was 0.10.0. Every
537 # view was green when MNW was pushed, because ^0.9 was current then. The branch
538 # had the forward fix; the tag did not, and Bento builds from the tag.
539 #
540 # It is invisible locally as well: `~/Code/.cargo/config.toml`'s `[patch]` block
541 # redirects synckit-client to the working copy, so the stale requirement resolves
542 # on fw13 and fails only where the patch is absent.
543 #
544 # WHICH TAGS. One per repo: the tag matching the version its own `bento.toml`
545 # declares today. Grading every tag ever cut is unbounded and mostly pointless --
546 # a stale old tag nobody will rebuild is not a defect. A stale tag at the CURRENT
547 # version is a broken release waiting to happen, and it makes the check answer a
548 # question worth asking: would a release of this, right now, resolve?
549 #
550 # READ FROM THE REPO-SIDE `bento.toml`, not from `~/.config/bento/bento.toml`.
551 # The daemon's config is machine state and is not in the tree, so a tree-level
552 # tool cannot see it; the repo-side file is the durable declaration, is versioned
553 # with the code it describes, and carries both fields this needs.
554 #
555 # A version with no tag yet is NOT a finding. It means the release has not been
556 # cut, and what a release would cut is the branch head, which the views above
557 # already grade.
558
559
560 def bento_configs(tree):
561 """Every repo-side `bento.toml` under `tree`, shallow-walked.
562
563 One per PRODUCT, not one per repo: MNW is a single .git over pom, wam,
564 magicmirror and mnw-cli, and each declares its own release with its own
565 `tag_format`. Walking for the file rather than probing repo roots is what
566 makes the monorepo's four visible.
567 """
568 out = []
569 stack = [(tree, 0)]
570 while stack:
571 d, depth = stack.pop()
572 try:
573 entries = list(os.scandir(d))
574 except OSError:
575 continue
576 for e in entries:
577 if e.is_file() and e.name == "bento.toml":
578 out.append(e.path)
579 elif e.is_dir() and e.name not in SKIP_DIRS and depth < MAX_DEPTH:
580 stack.append((e.path, depth + 1))
581 return out
582
583
584 def bento_version(product, version_path):
585 """A product's release version, following Bento's own resolution rules.
586
587 Mirrors `AppConfig::version_path` in `bento/daemon/src/topology.rs`: unset
588 means `src-tauri/tauri.conf.json` and then the root `Cargo.toml`, a `.json`
589 file is read as a tauri config, and anything else as a `Cargo.toml`. Most
590 products declare nothing here -- the field lives in the daemon's config, not
591 the repo's -- so getting the default right is what makes the check cover the
592 tree rather than the four products that spell it out.
593 """
594 candidates = (
595 [version_path]
596 if isinstance(version_path, str)
597 else ["src-tauri/tauri.conf.json", "Cargo.toml"]
598 )
599 for rel in candidates:
600 full = os.path.join(product, rel)
601 if not os.path.exists(full):
602 continue
603 if rel.endswith(".json"):
604 try:
605 with open(full, encoding="utf-8") as fh:
606 v = json.load(fh).get("version")
607 except (OSError, ValueError):
608 continue
609 if isinstance(v, str):
610 return v
611 continue
612 doc = load(full)
613 pkg = (doc or {}).get("package")
614 if isinstance(pkg, dict) and isinstance(pkg.get("version"), str):
615 return pkg["version"]
616 ws = ((doc or {}).get("workspace") or {}).get("package") or {}
617 if isinstance(ws.get("version"), str):
618 return ws["version"]
619 return None
620
621
622 def bento_release_tag(cfg_path):
623 """The tag a release declared by `cfg_path` would build from today, or None.
624
625 Returns the tag string whether or not it exists; existence is the caller's
626 question, and a version with no tag is not a finding -- it means the release
627 has not been cut, and what a release would cut is the branch head, which the
628 views above already grade. That is also what makes the defaults above safe to
629 apply: a wrong guess names a tag that does not exist, so it can only ever
630 lose coverage, never invent a failure.
631 """
632 try:
633 with open(cfg_path, "rb") as fh:
634 cfg = tomllib.load(fh)
635 except (OSError, tomllib.TOMLDecodeError):
636 return None
637 version = bento_version(os.path.dirname(cfg_path), cfg.get("version_path"))
638 if not version:
639 return None
640 # `v{version}` is Bento's default; a repo holding several products spells it
641 # per product (`mnw-cli-v{version}`), which is why this is read rather than
642 # assumed.
643 fmt = cfg.get("tag_format")
644 if not isinstance(fmt, str):
645 fmt = "v{version}"
646 return fmt.replace("{version}", version)
647
648
649 def release_tags(tree, only=None):
650 """(repo, product, tag) for every declared release whose tag exists, plus notes.
651
652 `only` narrows to the products under one path, which is how a per-repo caller
653 (the sweep) gets a cell about its own repo instead of the whole tree's.
654
655 Notes carry the products that declare a release and have no tag for the
656 version they are on. Not findings -- an uncut release is the normal state
657 between them -- but counted, because "nothing to grade" and "all clear" are
658 different answers and a check that conflates them is not evidence.
659 """
660 pairs, uncut = [], {}
661 for cfg in sorted(bento_configs(tree)):
662 product = os.path.dirname(cfg)
663 if only and not (product == only or product.startswith(only + os.sep)):
664 continue
665 repo = repo_of(cfg, tree)
666 if repo is None:
667 continue
668 tag = bento_release_tag(cfg)
669 if tag is None:
670 continue
671 if git_lines(repo, "rev-parse", "--verify", "--quiet", f"refs/tags/{tag}"):
672 pairs.append((repo, product, tag))
673 else:
674 uncut[product] = tag
675 return pairs, uncut
676
677
678 def analyze_release_tags(disk_docs, tree, only=None):
679 """Grade each release tag's own manifests against what siblings have pushed.
680
681 The consumer view is the tagged tree and nothing else: this asks whether THAT
682 tag resolves, not whether the working copy does, and the working copy is
683 already three views above. Providers are read at their remote refs by
684 `analyze_published`, which is the same machinery and the same no-network
685 happy path.
686
687 Scoped to the PRODUCT's directory inside the tag, not to the whole repo. At
688 `mnw-cli-v0.1.4` the entire MNW tree exists, and grading all of it would
689 report pom's requirements as mnw-cli's release problem.
690 """
691 pairs, uncut = release_tags(tree, only)
692 broken, graded, ungraded = [], 0, {}
693 for repo, product, tag in pairs:
694 rels = git_manifests(repo, tag)
695 if rels is None:
696 ungraded.setdefault(product, f"{tag} is not readable")
697 continue
698 prefix = os.path.relpath(product, repo)
699 prefix = "" if prefix == "." else prefix + os.sep
700 tagged = {}
701 for rel in rels:
702 if prefix and not rel.startswith(prefix):
703 continue
704 tagged[os.path.join(repo, rel)] = load_at(repo, tag, rel)
705 if not tagged:
706 ungraded.setdefault(product, f"{tag} carries no manifest under {prefix or '.'}")
707 continue
708 b, g, u = analyze_published(tagged, disk_docs, tree, None, None)
709 broken += [(*row, tag) for row in b]
710 graded += g
711 ungraded.update(u)
712 return broken, graded, ungraded, pairs, uncut
713
714
715 # --- the crates.io view -------------------------------------------------------
716
717 # Cargo's own sparse-index cache. One file per crate, holding the same
718 # newline-delimited JSON the registry serves, prefixed by a format byte and an
719 # etag and separated by NULs. Reading it is why the happy path costs no network.
720 INDEX_CACHE_GLOB = os.path.expanduser(
721 "~/.cargo/registry/index/index.crates.io-*/.cache"
722 )
723 INDEX_URL = "https://index.crates.io"
724 INDEX_TIMEOUT = 5
725
726
727 def index_prefix(name):
728 """The registry's directory prefix for a crate name (cargo's own scheme)."""
729 n = name.lower()
730 if len(n) == 1:
731 return "1"
732 if len(n) == 2:
733 return "2"
734 if len(n) == 3:
735 return os.path.join("3", n[0])
736 return os.path.join(n[:2], n[2:4])
737
738
739 def parse_index_blob(blob):
740 """Every non-yanked version in an index file, newest first.
741
742 Takes the cache format and the wire format both: the cache is the wire
743 format with a header and NUL separators, so splitting on NUL and newline
744 and keeping whatever parses as a version record covers each of them.
745 """
746 if isinstance(blob, bytes):
747 blob = blob.decode("utf-8", "replace")
748 out = []
749 for chunk in blob.replace("\x00", "\n").splitlines():
750 chunk = chunk.strip()
751 if not chunk.startswith("{"):
752 continue
753 try:
754 rec = json.loads(chunk)
755 except ValueError:
756 continue
757 vers = rec.get("vers")
758 if isinstance(vers, str) and not rec.get("yanked"):
759 out.append(vers)
760 out.sort(key=parse_version, reverse=True)
761 return out
762
763
764 def index_from_cache(name):
765 """Published versions from cargo's sparse-index cache, or None if absent."""
766 for cache in glob.glob(INDEX_CACHE_GLOB):
767 path = os.path.join(cache, index_prefix(name), name.lower())
768 try:
769 with open(path, "rb") as fh:
770 return parse_index_blob(fh.read())
771 except OSError:
772 continue
773 return None
774
775
776 def index_from_network(name):
777 """Published versions from the sparse index itself, or None if unreachable."""
778 url = f"{INDEX_URL}/{index_prefix(name)}/{name.lower()}"
779 try:
780 with urllib.request.urlopen(url, timeout=INDEX_TIMEOUT) as resp:
781 return parse_index_blob(resp.read())
782 except (urllib.error.URLError, OSError, ValueError):
783 return None
784
785
786 def published_versions(name, cache, refresh=False):
787 """Every non-yanked published version of `name`, newest first.
788
789 Reads cargo's cache first and goes to the network only when the caller says
790 the cached answer was not good enough, which is the same escalation the
791 remote-ref view does: a cache older than the sibling's release must not be
792 able to refuse a good push.
793 """
794 if not refresh and name in cache:
795 return cache[name]
796 versions = None if refresh else index_from_cache(name)
797 if versions is None:
798 versions = index_from_network(name)
799 cache[name] = versions
800 return versions
801
802
803 def publishable_index(docs, tree):
804 """In-house crates that go to crates.io: name -> (version, manifest path).
805
806 `publish = false` is the marker for everything that does not (quasi, shop,
807 everycycle, wam), and it inherits from the workspace root the same way
808 `version` does, so resolve it the same way rather than reading the member
809 alone.
810 """
811 ws_publish = {}
812 for p, doc in docs.items():
813 if not doc:
814 continue
815 v = ((doc.get("workspace") or {}).get("package") or {}).get("publish")
816 if isinstance(v, bool):
817 ws_publish[os.path.dirname(p)] = v
818
819 def publishes(manifest_path, pkg):
820 v = pkg.get("publish")
821 if isinstance(v, bool):
822 return v
823 if isinstance(v, list):
824 return bool(v)
825 if isinstance(v, dict) and v.get("workspace") is True:
826 d = os.path.dirname(manifest_path)
827 while d.startswith(tree):
828 if d in ws_publish:
829 return ws_publish[d]
830 parent = os.path.dirname(d)
831 if parent == d:
832 break
833 d = parent
834 return True
835
836 out = {}
837 for name, (version, manifest) in crate_index(docs, tree).items():
838 doc = docs.get(manifest) or {}
839 pkg = doc.get("package")
840 if isinstance(pkg, dict) and publishes(manifest, pkg):
841 out[name] = (version, manifest)
842 return out
843
844
845 def registry_requirements(docs, publishable):
846 """Every requirement on an in-house crate taken from the registry.
847
848 The pairing is the mirror image of `requirements()`: a `version` and NO git
849 URL, naming a crate this tree both holds and publishes. A path dependency is
850 excluded for the same reason it is everywhere else here -- what it resolves
851 against is the file next to it, not a release.
852 """
853 for p, doc in docs.items():
854 if not doc:
855 continue
856 for table in dep_tables(doc):
857 for key, spec in table.items():
858 req = None
859 if isinstance(spec, str):
860 req = spec
861 elif isinstance(spec, dict):
862 if spec.get("git") or spec.get("path"):
863 continue
864 if isinstance(spec.get("version"), str):
865 req = spec["version"]
866 if req is None:
867 continue
868 name = key
869 if isinstance(spec, dict) and isinstance(spec.get("package"), str):
870 name = spec["package"]
871 if name in publishable:
872 yield p, name, req
873
874
875 def analyze_registry(docs, tree):
876 """Grade every registry requirement on an in-house crate against the index.
877
878 The escalation is deliberate and is what keeps the happy path free: the
879 local version is tried first, and the index is consulted only for a
880 requirement the checkout is the one thing satisfying (or that nothing
881 satisfies). Working ahead of a release is fine; requiring a version that
882 exists in no published release is not, because no other machine can resolve
883 it and `links` on makeover-layout makes that the whole graph at once.
884
885 Returns (broken, graded, unchecked, ungraded).
886 """
887 publishable = publishable_index(docs, tree)
888 index_cache = {}
889 broken, graded, unchecked, ungraded = [], 0, 0, {}
890
891 for consumer_manifest, name, req in registry_requirements(docs, publishable):
892 local_version, provider_manifest = publishable[name]
893 if satisfies(req, local_version) is None:
894 unchecked += 1
895 continue
896 versions = published_versions(name, index_cache)
897 if versions is None:
898 ungraded.setdefault(name, "no index entry, cached or fetched")
899 continue
900 if not any(satisfies(req, v) for v in versions):
901 # A cache older than the sibling's release would otherwise refuse a
902 # good push, so pay for one fetch before calling it broken.
903 versions = published_versions(name, index_cache, refresh=True) or versions
904 graded += 1
905 if not any(satisfies(req, v) for v in versions):
906 newest = versions[0] if versions else "nothing published"
907 broken.append(
908 (consumer_manifest, name, req, newest, provider_manifest,
909 "the index")
910 )
911 return broken, graded, unchecked, ungraded
912
913
914 def analyze(docs, tree):
915 """Grade every in-house git+version pair in `docs` against the tree on disk.
916
917 Returns (broken, unchecked, absent, graded), where a broken entry is
918 (consumer manifest, crate, requirement, version found, provider manifest,
919 source label). The source label is None here: this view reads the version
920 off a manifest, and naming the manifest already says where it came from.
921 """
922 versions = crate_index(docs, tree)
923 broken, unchecked, absent, graded = [], 0, set(), 0
924 for p, name, req in requirements(docs):
925 known = versions.get(name)
926 if known is None:
927 # A repo that is not on this machine (ripgrow lives on mbp only).
928 # Not a finding: nothing here can be wrong about it.
929 absent.add(name)
930 continue
931 verdict = satisfies(req, known[0])
932 if verdict is None:
933 unchecked += 1
934 continue
935 graded += 1
936 if not verdict:
937 broken.append((p, name, req, known[0], known[1], None))
938 return broken, unchecked, absent, graded
939
940
941 def split_blame(broken, repo, blame_provider=True):
942 """Breaks this push owns, and breaks that were already there.
943
944 `blame_provider` is off for the crates.io view: there, the provider side of
945 a break is a crate that has been bumped and not released yet, and pushing it
946 is how it reaches the index at all (Bento publishes from a pushed tag). Only
947 the consumer side of that view is a push worth refusing.
948 """
949 ours, theirs = [], []
950 for item in broken:
951 consumer_manifest, _name, _req, _have, provider_manifest, _src = item
952 mine = repo is not None and (
953 consumer_manifest.startswith(repo + os.sep)
954 or (blame_provider and provider_manifest.startswith(repo + os.sep))
955 )
956 (ours if mine else theirs).append(item)
957 return ours, theirs
958
959
960 def report(broken, repo, tree, label, blame_provider=True):
961 """Print one view's breaks. Returns True if this push has to be refused."""
962 ours, theirs = split_blame(broken, repo, blame_provider)
963
964 def rel(path):
965 return os.path.relpath(path, tree)
966
967 for consumer_manifest, name, req, have, provider_manifest, src in ours + theirs:
968 where = (
969 f"{src} has {have} ({rel(provider_manifest)})"
970 if src
971 else f"the tree has {have} ({rel(provider_manifest)})"
972 )
973 print(
974 f" [{label}] {rel(consumer_manifest)}: requires {name} \"{req}\", {where}",
975 file=sys.stderr,
976 )
977 if repo is None:
978 return bool(broken)
979 if not ours:
980 # Somebody else's skew. Worth seeing, never worth blocking this push on:
981 # a gate that fails for a reason the pusher cannot fix is a gate that
982 # gets bypassed by reflex, and then it is not a gate.
983 if theirs:
984 print(
985 f"pre-push: [{label}] {len(theirs)} unresolvable requirements "
986 "elsewhere in the tree (listed above, not this push's).",
987 )
988 return False
989 return True
990
991
992 def release_tag_report(tree, as_json, only=None):
993 """The release-tag view on its own, for the scheduled caller.
994
995 Separate from `main`'s four views and never run at push time. The question is
996 "would a release of this resolve right now", and the answer changes while the
997 repo sits still -- which makes it a nightly's question, not a hook's. A hook
998 that refused a push over a tag cut last week would also be refusing it for
999 something the push cannot fix.
1000 """
1001 docs = {p: load(p) for p in manifests(tree)}
1002 broken, graded, ungraded, pairs, uncut = analyze_release_tags(docs, tree, only)
1003
1004 if as_json:
1005 # witchbroom's `json` capture: one finding per requirement the tag cannot
1006 # resolve, under a key the grid's parser knows.
1007 print(json.dumps({
1008 "release_tags": [
1009 {
1010 "ok": False,
1011 "kind": "tag-unresolvable",
1012 "message": (
1013 f"{tag}: {os.path.relpath(consumer, tree)} requires "
1014 f"{name} {req}, and {ref} has {have}"
1015 ),
1016 "path": os.path.relpath(consumer, tree),
1017 }
1018 for consumer, name, req, have, _provider, ref, tag in broken
1019 ],
1020 "tags_graded": [
1021 {"product": os.path.relpath(prod, tree), "tag": t}
1022 for _repo, prod, t in pairs
1023 ],
1024 # Counted rather than silent, the `coherence` reading exactly: a repo
1025 # between releases has no tag at its current version, and that is a
1026 # different answer from a tag that resolves.
1027 "uncut": [
1028 {"product": os.path.relpath(prod, tree), "tag": t}
1029 for prod, t in sorted(uncut.items())
1030 ],
1031 "requirements_graded": graded,
1032 "ungraded": {os.path.relpath(k, tree): v for k, v in ungraded.items()},
1033 }, indent=2))
1034 return 1 if broken else 0
1035
1036 for consumer, name, req, have, _provider, ref, tag in broken:
1037 print(
1038 f"{tag}: {os.path.relpath(consumer, tree)} requires {name} {req}; "
1039 f"{ref} has {have}",
1040 file=sys.stderr,
1041 )
1042 if broken:
1043 print(
1044 "\nA release cut from one of the tags above cannot resolve. The tag was\n"
1045 "coherent when it was cut and a sibling has moved since; a tag never\n"
1046 "moves, so the fix is a new tag at a version whose manifests carry the\n"
1047 "current requirement, not an edit to this one.",
1048 file=sys.stderr,
1049 )
1050 return 1
1051 print(
1052 f"release tags coherent ({len(pairs)} tags, {graded} requirements"
1053 + (f", {len(uncut)} versions not yet tagged" if uncut else "")
1054 + ")."
1055 )
1056 for where, why in sorted(ungraded.items()):
1057 print(f"[release tags] {os.path.relpath(where, tree)} not graded ({why}).")
1058 return 0
1059
1060
1061 def main():
1062 raw = sys.argv[1:]
1063 args, flags, skip = [], set(), False
1064 for i, a in enumerate(raw):
1065 if skip:
1066 skip = False
1067 continue
1068 if a.startswith("--"):
1069 flags.add(a)
1070 # The one flag that takes a value. Consuming it here keeps its path
1071 # out of the positional list, where it would be read as the tree.
1072 if a == "--only":
1073 skip = True
1074 else:
1075 args.append(a)
1076 if not args:
1077 print(__doc__.strip(), file=sys.stderr)
1078 return 2
1079 tree = os.path.realpath(args[0])
1080 if "--release-tags" in flags:
1081 # `--only <path>` narrows to one repo or product, for a per-repo caller.
1082 only = None
1083 if "--only" in sys.argv:
1084 i = sys.argv.index("--only")
1085 if i + 1 < len(sys.argv):
1086 only = os.path.realpath(sys.argv[i + 1])
1087 return release_tag_report(tree, "--json" in flags, only)
1088 repo = os.path.realpath(args[1]) if len(args) > 1 else None
1089 sha = args[2] if len(args) > 2 else None
1090
1091 docs = {p: load(p) for p in manifests(tree)}
1092
1093 views = [("working copy", docs)]
1094 skipped_push_view = False
1095 if repo and sha:
1096 pushed = pushed_view(docs, repo, sha)
1097 if pushed is None:
1098 skipped_push_view = True
1099 else:
1100 views.append(("as pushed", pushed))
1101
1102 refuse = False
1103 summaries = []
1104 for label, view in views:
1105 broken, unchecked, absent, graded = analyze(view, tree)
1106 summaries.append((label, graded, unchecked, absent, bool(broken)))
1107 if broken and report(broken, repo, tree, label):
1108 refuse = True
1109
1110 # The third view: what the rest of the tree has actually PUSHED. Graded from
1111 # the most authoritative consumer view available, so the requirements read
1112 # are the ones about to be published.
1113 consumer_view = views[-1][1]
1114 pub_broken, pub_graded, ungraded = analyze_published(
1115 consumer_view, docs, tree, repo, sha
1116 )
1117 summaries.append(("as published", pub_graded, 0, set(), bool(pub_broken)))
1118 if pub_broken and report(pub_broken, repo, tree, "as published"):
1119 refuse = True
1120
1121 # The fourth view: the siblings that are consumed from crates.io rather than
1122 # by git URL. Nothing above can see them, because the pairing every other
1123 # view makes is against the repo a git URL names.
1124 reg_broken, reg_graded, reg_unchecked, reg_ungraded = analyze_registry(
1125 consumer_view, tree
1126 )
1127 summaries.append(
1128 (REGISTRY_LABEL, reg_graded, reg_unchecked, set(), bool(reg_broken))
1129 )
1130 if reg_broken and report(
1131 reg_broken, repo, tree, REGISTRY_LABEL, blame_provider=False
1132 ):
1133 refuse = True
1134
1135 if refuse:
1136 bad = {lbl for lbl, _g, _u, _a, broke in summaries if broke}
1137 clean = [lbl for lbl, _g, _u, _a, broke in summaries if not broke]
1138 print("", file=sys.stderr)
1139 print(
1140 "pre-push: this push leaves a dependency that cannot resolve.\n"
1141 " A version requirement states which major a consumer was written against,\n"
1142 " so bumping a library and fixing its consumers is one pass (CLAUDE.md,\n"
1143 " \"a breaking bump of an in-house crate is forward-fixed, in the same pass\").\n"
1144 " Fix: bump the requirement in the manifests above, make the consumers\n"
1145 " compile, and push them with this one.",
1146 file=sys.stderr,
1147 )
1148 # Which view broke is the diagnosis, so say what the views disagree
1149 # about rather than only that they disagree.
1150 if REGISTRY_LABEL in bad:
1151 print(
1152 " A requirement above resolves against no version on crates.io, so the\n"
1153 " fix is to PUBLISH the sibling, not to push it. Until it is on the\n"
1154 " index no machine can resolve the graph, this one included once the\n"
1155 " [patch.crates-io] block is out of the way.",
1156 file=sys.stderr,
1157 )
1158 if "as published" in bad and "working copy" not in bad:
1159 print(
1160 " The working copy is fine and the published tree is not, so the\n"
1161 " difference is what has been PUSHED: a git dependency resolves against\n"
1162 " the branch head at the URL, and ~/Code/.cargo/config.toml's [patch]\n"
1163 " block hides that locally. Push the sibling named above first.",
1164 file=sys.stderr,
1165 )
1166 elif "as pushed" in bad and "working copy" not in bad:
1167 print(
1168 " The working copy is fine and the commit is not, so the difference is\n"
1169 " what is COMMITTED. An uncommitted manifest edit is the usual cause.",
1170 file=sys.stderr,
1171 )
1172 elif clean:
1173 print(
1174 f" Note: the {clean[0]} view is clean, so the views disagree; the one\n"
1175 " that failed is named on each line above.",
1176 file=sys.stderr,
1177 )
1178 return 1
1179
1180 for label, graded, unchecked, absent, bad in summaries:
1181 # A view that found a break somewhere else in the tree is not a view
1182 # that passed. Reaching here means the break is not this push's to fix,
1183 # which is a reason not to refuse and never a reason to print coherent.
1184 verdict = "internal deps coherent" if not bad else "internal deps BROKEN elsewhere"
1185 print(
1186 f"pre-push: {verdict} [{label}] ({graded} requirements"
1187 + (f", {unchecked} unchecked" if unchecked else "")
1188 + (f", {len(absent)} crates not in this tree" if absent else "")
1189 + ")."
1190 )
1191 for where, why in sorted(ungraded.items()):
1192 # Never silently: a repo nobody could read is not a repo that passed.
1193 print(
1194 f"pre-push: [as published] {os.path.relpath(where, tree)} not graded ({why})."
1195 )
1196 for name, why in sorted(reg_ungraded.items()):
1197 print(f"pre-push: [{REGISTRY_LABEL}] {name} not graded ({why}).")
1198 if skipped_push_view:
1199 # Never silently: a view that did not run must not read as one that passed.
1200 print("pre-push: could not read the pushed commit; graded the working copy only.")
1201 return 0
1202
1203
1204 if __name__ == "__main__":
1205 sys.exit(main())
1206