max / makeover-timing
1 file changed,
+283 insertions,
-13 deletions
| @@ -12,7 +12,7 @@ | |||
| 12 | 12 | ||
| 13 | 13 | python3 internal-deps.py ~/Code | |
| 14 | 14 | ||
| 15 | - | The run grades THREE views and fails on any of them: | |
| 15 | + | The run grades FOUR views and fails on any of them: | |
| 16 | 16 | ||
| 17 | 17 | working copy what this machine builds today. The historical check. | |
| 18 | 18 | as pushed the same question asked of the pushing repo's manifests AS THEY | |
| @@ -22,6 +22,11 @@ | |||
| 22 | 22 | PUSHED, read from its last-fetched remote-tracking ref. This is | |
| 23 | 23 | the only view that predicts a build on a machine that is not | |
| 24 | 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. | |
| 25 | 30 | ||
| 26 | 31 | WHY THE SECOND VIEW EXISTS. The first one reads manifests off the filesystem, so | |
| 27 | 32 | an uncommitted forward-fix makes it grade text that git is not publishing. That | |
| @@ -49,28 +54,56 @@ | |||
| 49 | 54 | views were clean, and Sando could not resolve. The three views answer three | |
| 50 | 55 | different questions and all of them matter. | |
| 51 | 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 | + | ||
| 52 | 78 | NETWORK. None on the happy path. The published view reads the last-fetched | |
| 53 | 79 | remote-tracking ref, and only when a requirement FAILS against it does it fetch | |
| 54 | 80 | that one repo's one branch and re-check, so a ref nobody has fetched since the | |
| 55 | 81 | sibling was pushed cannot refuse a good push. A repo with no fetched remote at | |
| 56 | - | all is reported as ungraded, never as passing. | |
| 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. | |
| 57 | 88 | ||
| 58 | 89 | WHAT IT DOES NOT GRADE, on purpose: | |
| 59 | 90 | ||
| 60 | - | crates.io deps the makeover suite and friends resolve from the registry, | |
| 61 | - | where working ahead of a release is normal and a tree | |
| 62 | - | version above the published one is not a finding. The | |
| 63 | - | sweep's `coherence` check grades those against the index. | |
| 91 | + | third-party deps a version requirement on somebody else's crate is not | |
| 92 | + | something this tree can forward-fix. | |
| 64 | 93 | path deps no version requirement to be wrong about. | |
| 65 | 94 | ranges and wildcards `>=`, `<`, `*` and comma lists are deliberate statements | |
| 66 | 95 | about a span, not a pin that drifts. Counted as unchecked. | |
| 67 | 96 | """ | |
| 68 | 97 | ||
| 98 | + | import glob | |
| 99 | + | import json | |
| 69 | 100 | import os | |
| 70 | 101 | import re | |
| 71 | 102 | import subprocess | |
| 72 | 103 | import sys | |
| 73 | 104 | import tomllib | |
| 105 | + | import urllib.error | |
| 106 | + | import urllib.request | |
| 74 | 107 | ||
| 75 | 108 | # The forges that make a git URL ours. A dependency on somebody else's git repo | |
| 76 | 109 | # is not something this tree can forward-fix. | |
| @@ -86,6 +119,10 @@ | |||
| 86 | 119 | ||
| 87 | 120 | DEP_SECTIONS = ("dependencies", "dev-dependencies", "build-dependencies") | |
| 88 | 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 | + | ||
| 89 | 126 | ||
| 90 | 127 | def manifests(root): | |
| 91 | 128 | """Every Cargo.toml in the tree, shallow-walked.""" | |
| @@ -465,6 +502,205 @@ | |||
| 465 | 502 | yield p, name, req | |
| 466 | 503 | ||
| 467 | 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 | + | ||
| 468 | 704 | def analyze(docs, tree): | |
| 469 | 705 | """Grade every in-house git+version pair in `docs` against the tree on disk. | |
| 470 | 706 | ||
| @@ -492,22 +728,28 @@ | |||
| 492 | 728 | return broken, unchecked, absent, graded | |
| 493 | 729 | ||
| 494 | 730 | ||
| 495 | - | def split_blame(broken, repo): | |
| 496 | - | """Breaks this push owns, and breaks that were already there.""" | |
| 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 | + | """ | |
| 497 | 739 | ours, theirs = [], [] | |
| 498 | 740 | for item in broken: | |
| 499 | 741 | consumer_manifest, _name, _req, _have, provider_manifest, _src = item | |
| 500 | 742 | mine = repo is not None and ( | |
| 501 | 743 | consumer_manifest.startswith(repo + os.sep) | |
| 502 | - | or provider_manifest.startswith(repo + os.sep) | |
| 744 | + | or (blame_provider and provider_manifest.startswith(repo + os.sep)) | |
| 503 | 745 | ) | |
| 504 | 746 | (ours if mine else theirs).append(item) | |
| 505 | 747 | return ours, theirs | |
| 506 | 748 | ||
| 507 | 749 | ||
| 508 | - | def report(broken, repo, tree, label): | |
| 750 | + | def report(broken, repo, tree, label, blame_provider=True): | |
| 509 | 751 | """Print one view's breaks. Returns True if this push has to be refused.""" | |
| 510 | - | ours, theirs = split_blame(broken, repo) | |
| 752 | + | ours, theirs = split_blame(broken, repo, blame_provider) | |
| 511 | 753 | ||
| 512 | 754 | def rel(path): | |
| 513 | 755 | return os.path.relpath(path, tree) | |
| @@ -575,6 +817,20 @@ | |||
| 575 | 817 | if pub_broken and report(pub_broken, repo, tree, "as published"): | |
| 576 | 818 | refuse = True | |
| 577 | 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 | + | ||
| 578 | 834 | if refuse: | |
| 579 | 835 | bad = {lbl for lbl, _g, _u, _a, broke in summaries if broke} | |
| 580 | 836 | clean = [lbl for lbl, _g, _u, _a, broke in summaries if not broke] | |
| @@ -590,6 +846,14 @@ | |||
| 590 | 846 | ) | |
| 591 | 847 | # Which view broke is the diagnosis, so say what the views disagree | |
| 592 | 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 | + | ) | |
| 593 | 857 | if "as published" in bad and "working copy" not in bad: | |
| 594 | 858 | print( | |
| 595 | 859 | " The working copy is fine and the published tree is not, so the\n" | |
| @@ -612,9 +876,13 @@ | |||
| 612 | 876 | ) | |
| 613 | 877 | return 1 | |
| 614 | 878 | ||
| 615 | - | for label, graded, unchecked, absent, _bad in summaries: | |
| 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" | |
| 616 | 884 | print( | |
| 617 | - | f"pre-push: internal deps coherent [{label}] ({graded} requirements" | |
| 885 | + | f"pre-push: {verdict} [{label}] ({graded} requirements" | |
| 618 | 886 | + (f", {unchecked} unchecked" if unchecked else "") | |
| 619 | 887 | + (f", {len(absent)} crates not in this tree" if absent else "") | |
| 620 | 888 | + ")." | |
| @@ -624,6 +892,8 @@ | |||
| 624 | 892 | print( | |
| 625 | 893 | f"pre-push: [as published] {os.path.relpath(where, tree)} not graded ({why})." | |
| 626 | 894 | ) | |
| 895 | + | for name, why in sorted(reg_ungraded.items()): | |
| 896 | + | print(f"pre-push: [{REGISTRY_LABEL}] {name} not graded ({why}).") | |
| 627 | 897 | if skipped_push_view: | |
| 628 | 898 | # Never silently: a view that did not run must not read as one that passed. | |
| 629 | 899 | print("pre-push: could not read the pushed commit; graded the working copy only.") |