#!/usr/bin/env python3
"""Do the tree's in-house `version` requirements still resolve?

DO NOT EDIT IN PLACE. The master is _private/infra/bootstrap/githooks/internal-deps.py.

Usage:
    internal-deps.py <tree-root> [repo-root] [pushed-sha]

With a repo root, only pairs that repo is on either side of can fail the run;
everything else is reported as a note. Without one, every pair is graded, which
is the whole-tree report:

    python3 internal-deps.py ~/Code

The run grades THREE views and fails on any of them:

  working copy   what this machine builds today. The historical check.
  as pushed      the same question asked of the pushing repo's manifests AS THEY
                 EXIST AT THAT COMMIT, against the rest of the tree on disk.
                 Needs a pushed sha, so it is skipped in the by-hand report.
  as published   every requirement against the version the provider has actually
                 PUSHED, read from its last-fetched remote-tracking ref. This is
                 the only view that predicts a build on a machine that is not
                 this one.

WHY THE SECOND VIEW EXISTS. The first one reads manifests off the filesystem, so
an uncommitted forward-fix makes it grade text that git is not publishing. That
is not hypothetical: on 2026-08-24 mnw-cli's `synckit-client` requirement had
been advanced to "0.9" in the working copy and never committed, this gate printed
`internal deps coherent (42 requirements)`, the push went out, and Sando failed
to resolve `^0.8` against 0.9.0 minutes later. The gate was checking a tree that
was not the tree being published, and nothing distinguished that from real
coherence.

WHAT IT GRADES. Every dependency in the tree that carries both a `git` URL on one
of our forges and a `version` requirement, against the version in the working
copy of the crate that URL names. That is the pairing cargo enforces and the one
that broke: a requirement of "0.11" against a sibling that has moved to 0.14 is
not a warning, it is a graph that will not resolve on any machine.

WHY DISK AND REMOTES BOTH. `~/Code/.cargo/config.toml` patches every one of these
dependencies to the working copy in the tree, so what is on disk here is what
every local build reads: a bump that has not been pushed yet breaks its consumers
on this machine just as thoroughly, and finding that out at push time is the
point. But cargo resolves a git dependency against the branch head at the URL, so
the `[patch]` block also HIDES an unpushed sibling from every local check. That
gap cost a production build on 2026-08-26 (infra `5c4928c1`): MNW required quasi
"^0.63", quasi's working copy was 0.63.0 and its `mnw/main` was 0.56.0, both disk
views were clean, and Sando could not resolve. The three views answer three
different questions and all of them matter.

NETWORK. None on the happy path. The published view reads the last-fetched
remote-tracking ref, and only when a requirement FAILS against it does it fetch
that one repo's one branch and re-check, so a ref nobody has fetched since the
sibling was pushed cannot refuse a good push. A repo with no fetched remote at
all is reported as ungraded, never as passing.

WHAT IT DOES NOT GRADE, on purpose:

  crates.io deps      the makeover suite and friends resolve from the registry,
                      where working ahead of a release is normal and a tree
                      version above the published one is not a finding. The
                      sweep's `coherence` check grades those against the index.
  path deps           no version requirement to be wrong about.
  ranges and wildcards  `>=`, `<`, `*` and comma lists are deliberate statements
                      about a span, not a pin that drifts. Counted as unchecked.
"""

import os
import re
import subprocess
import sys
import tomllib

# The forges that make a git URL ours. A dependency on somebody else's git repo
# is not something this tree can forward-fix.
OURS = re.compile(r"(makenot\.work|git\.sr\.ht/~maxmj)", re.I)

# Directories that hold code we do not grade: retired, staged for deletion, or
# not ours. Mirrors the sweep's exclusions rather than inventing a second list.
SKIP_DIRS = {
    "target", ".git", "node_modules", "dist", "vendor",
    "_archive", "_scratch", "trash", "_meta", "vtebench",
}
MAX_DEPTH = 4

DEP_SECTIONS = ("dependencies", "dev-dependencies", "build-dependencies")


def manifests(root):
    """Every Cargo.toml in the tree, shallow-walked."""
    out = []
    stack = [(root, 0)]
    while stack:
        d, depth = stack.pop()
        try:
            entries = list(os.scandir(d))
        except OSError:
            continue
        for e in entries:
            if e.is_file() and e.name == "Cargo.toml":
                out.append(e.path)
            elif e.is_dir() and e.name not in SKIP_DIRS and depth < MAX_DEPTH:
                stack.append((e.path, depth + 1))
    return out


def load(path):
    try:
        with open(path, "rb") as fh:
            return tomllib.load(fh)
    except (OSError, tomllib.TOMLDecodeError):
        return None


def dep_tables(doc):
    """Every dependency table in a manifest, including per-target and workspace."""
    for section in DEP_SECTIONS:
        table = doc.get(section)
        if isinstance(table, dict):
            yield table
    for cfg in (doc.get("target") or {}).values():
        if not isinstance(cfg, dict):
            continue
        for section in DEP_SECTIONS:
            table = cfg.get(section)
            if isinstance(table, dict):
                yield table
    ws = doc.get("workspace") or {}
    table = ws.get("dependencies")
    if isinstance(table, dict):
        yield table


def parse_version(v):
    """A version as a 3-tuple, prerelease dropped. Junk sorts as (0, 0, 0)."""
    core = str(v).split("+")[0].split("-")[0]
    parts = []
    for piece in core.split(".")[:3]:
        try:
            parts.append(int(piece))
        except ValueError:
            parts.append(0)
    while len(parts) < 3:
        parts.append(0)
    return tuple(parts)


def satisfies(req, version):
    """Cargo's default (caret) requirement semantics. None means 'not graded'.

    The rule that matters here is the 0.x one: under 0.1.0 and above, the MINOR
    is the compatibility boundary, which is why a 0.11 requirement rejects 0.14
    outright rather than treating it as a newer patch.
    """
    req = req.strip()
    if not req or any(c in req for c in "<>*,~"):
        return None
    # A prerelease satisfies nothing that does not ask for a prerelease of the
    # same version, so a plain requirement rejects it. This is the shape the
    # maturity ladder produces at beta entry: a sibling at 1.0.0-beta.1 does not
    # resolve for a consumer requiring "1.0", and cargo says so.
    if "-" in str(version).split("+")[0] and "-" not in req:
        return False
    exact = req.startswith("=")
    req = req.lstrip("^=").strip()
    if not req:
        return None
    given = req.split(".")
    try:
        r = [int(p) for p in given[:3]]
    except ValueError:
        return None
    v = parse_version(version)
    if exact:
        return tuple(v[: len(r)]) == tuple(r)
    if r[0] > 0:
        return v[0] == r[0] and v[1:] >= tuple(r[1:] + [0] * (2 - len(r[1:])))
    if len(r) == 1:
        return v[0] == 0
    if r[1] > 0:
        return v[0] == 0 and v[1] == r[1] and v[2] >= (r[2] if len(r) > 2 else 0)
    # 0.0.x: every patch is its own compatibility island.
    if len(r) > 2:
        return v[:3] == (0, 0, r[2])
    return v[0] == 0 and v[1] == 0


def git_lines(repo, *args):
    """Run git in `repo` and return stdout lines, or None if it failed."""
    try:
        out = subprocess.run(
            ["git", "-C", repo, *args],
            capture_output=True, text=True, check=True,
        )
    except (OSError, subprocess.CalledProcessError):
        return None
    return out.stdout.splitlines()


def git_manifests(repo, sha):
    """Repo-relative paths of every Cargo.toml at `sha`, or None if unreadable."""
    lines = git_lines(repo, "ls-tree", "-r", "--name-only", sha)
    if lines is None:
        return None
    out = []
    for rel in lines:
        if os.path.basename(rel) != "Cargo.toml":
            continue
        if any(part in SKIP_DIRS for part in rel.split("/")):
            continue
        out.append(rel)
    return out


def load_at(repo, sha, rel):
    """One manifest as it exists at `sha`. None if missing or unparseable."""
    lines = git_lines(repo, "show", f"{sha}:{rel}")
    if lines is None:
        return None
    try:
        return tomllib.loads("\n".join(lines))
    except tomllib.TOMLDecodeError:
        return None


def pushed_view(docs, repo, sha):
    """`docs` with everything under `repo` replaced by its content at `sha`.

    The rest of the tree stays as it is on disk, which is what a local build
    resolves against either way. Returns None if the commit cannot be read, so
    the caller can skip the view rather than invent a verdict about it.
    """
    rels = git_manifests(repo, sha)
    if rels is None:
        return None
    out = {k: v for k, v in docs.items() if not k.startswith(repo + os.sep)}
    for rel in rels:
        out[os.path.join(repo, rel)] = load_at(repo, sha, rel)
    return out


def repo_of(path, tree):
    """The git repo `path` belongs to, or None if it is not in one under `tree`."""
    d = os.path.dirname(path)
    while d.startswith(tree):
        if os.path.exists(os.path.join(d, ".git")):
            return d
        if d == tree:
            break
        parent = os.path.dirname(d)
        if parent == d:
            break
        d = parent
    return None


def publishing_ref(repo, cache):
    """The remote-tracking ref a git dependency on `repo` would resolve against.

    Cargo reads the branch head at the URL, and every in-house dependency URL is
    on one of our forges (CLAUDE.md, "what each remote is for": `mnw` is the
    public face, `srht` a backup, `astra` the private mirror). So prefer `mnw`,
    then any other remote whose URL is ours, and fall back to `origin`.

    Returns `<remote>/<branch>` or None when the repo has no such remote or the
    ref has never been fetched. None is not a verdict: the caller reports the
    repo as ungraded rather than inventing one.
    """
    if repo in cache:
        return cache[repo]
    ref = None
    lines = git_lines(repo, "remote", "-v") or []
    urls = {}
    for line in lines:
        parts = line.split()
        if len(parts) >= 2:
            urls.setdefault(parts[0], parts[1])
    order = [r for r in ("mnw",) if r in urls]
    order += [r for r, u in urls.items() if r not in order and OURS.search(u)]
    order += [r for r in ("origin",) if r in urls and r not in order]
    for remote in order:
        head = git_lines(repo, "symbolic-ref", "--quiet", f"refs/remotes/{remote}/HEAD")
        candidates = []
        if head:
            candidates.append(head[0].rsplit("/", 1)[-1])
        candidates += ["main", "master"]
        for branch in candidates:
            if git_lines(repo, "rev-parse", "--verify", "--quiet",
                         f"refs/remotes/{remote}/{branch}"):
                ref = f"{remote}/{branch}"
                break
        if ref:
            break
    cache[repo] = ref
    return ref


def version_at(repo, ref, rel, cache):
    """A crate's version in `repo` at `ref`, following a workspace inheritance.

    `rel` is the manifest's path relative to the repo. Returns None when the
    manifest is not at that ref at all, which is what a crate added since the
    last push looks like.
    """
    key = (repo, ref, rel)
    if key in cache:
        return cache[key]
    version = None
    doc = load_at(repo, ref, rel)
    if doc:
        pkg = doc.get("package")
        if isinstance(pkg, dict):
            v = pkg.get("version")
            if isinstance(v, str):
                version = v
            elif isinstance(v, dict) and v.get("workspace") is True:
                # Walk up to the workspace root as it exists at the same ref.
                d = os.path.dirname(rel)
                while True:
                    root_rel = os.path.join(d, "Cargo.toml") if d else "Cargo.toml"
                    root = load_at(repo, ref, root_rel) if root_rel != rel else None
                    inherited = (
                        ((root or {}).get("workspace") or {}).get("package") or {}
                    ).get("version")
                    if isinstance(inherited, str):
                        version = inherited
                        break
                    if not d:
                        break
                    d = os.path.dirname(d)
    cache[key] = version
    return version


def analyze_published(docs, disk_docs, tree, repo, sha):
    """Grade every requirement against what its provider has actually PUSHED.

    This is the view that predicts a build somewhere other than this machine.
    The other two read the provider's version off the filesystem, and the
    `[patch]` block in ~/Code/.cargo/config.toml means that is what a local
    build resolves -- but a git dependency resolves against the branch head at
    the URL, so an unpushed sibling passes both of them and fails everywhere
    else. That is exactly what happened on 2026-08-26: MNW required quasi
    "^0.63", quasi's working copy was 0.63.0 and `mnw/main` was 0.56.0, both
    existing views were clean, and Sando build 72 could not resolve.

    The repo being pushed is read at `sha` rather than at its remote, since what
    it is about to publish is the thing to grade. Every other repo is read at
    its last-fetched remote ref: no network on the happy path. A break is
    re-checked after fetching that one repo, so a stale ref cannot refuse a push
    on its own.

    Returns (broken, graded, ungraded), where ungraded maps a repo to why.
    """
    ref_cache, version_cache, fetched = {}, {}, set()
    versions_on_disk = crate_index(disk_docs, tree)
    broken, graded, ungraded = [], 0, {}

    for consumer_manifest, name, req in requirements(docs):
        known = versions_on_disk.get(name)
        if known is None:
            continue  # Not in this tree; the disk views already say so.
        provider_manifest = known[1]
        provider_repo = repo_of(provider_manifest, tree)
        if provider_repo is None:
            ungraded.setdefault(os.path.dirname(provider_manifest), "not a git repo")
            continue
        if repo is not None and provider_repo == repo and sha:
            # The repo under the hook: what it is about to publish is `sha`,
            # which the "as pushed" view already read off disk into `docs`.
            continue
        ref = publishing_ref(provider_repo, ref_cache)
        if ref is None:
            ungraded.setdefault(provider_repo, "no fetched remote to read")
            continue
        rel = os.path.relpath(provider_manifest, provider_repo)
        have = version_at(provider_repo, ref, rel, version_cache)
        if have is None:
            ungraded.setdefault(provider_repo, f"{name} is not at {ref} yet")
            continue
        verdict = satisfies(req, have)
        if verdict is None:
            continue
        if not verdict and provider_repo not in fetched:
            # Only now, and only for this one repo: a ref nobody has fetched
            # since the sibling was pushed would otherwise refuse a good push.
            fetched.add(provider_repo)
            remote, branch = ref.split("/", 1)
            git_lines(provider_repo, "fetch", "--quiet", remote, branch)
            version_cache.pop((provider_repo, ref, rel), None)
            have = version_at(provider_repo, ref, rel, version_cache) or have
            verdict = satisfies(req, have)
        graded += 1
        if not verdict:
            broken.append(
                (consumer_manifest, name, req, have, provider_manifest, ref)
            )
    return broken, graded, ungraded


def crate_index(docs, tree):
    """Crate name -> (version, manifest path), workspace inheritance resolved.

    A member saying `version.workspace = true` gets its number from the root,
    and reporting it as 0.0.0 would be a false break.
    """
    ws_version = {}
    for p, doc in docs.items():
        if not doc:
            continue
        v = ((doc.get("workspace") or {}).get("package") or {}).get("version")
        if isinstance(v, str):
            ws_version[os.path.dirname(p)] = v

    def resolve_version(manifest_path, pkg):
        v = pkg.get("version")
        if isinstance(v, str):
            return v
        d = os.path.dirname(manifest_path)
        while d.startswith(tree):
            if d in ws_version:
                return ws_version[d]
            parent = os.path.dirname(d)
            if parent == d:
                break
            d = parent
        return None

    versions = {}
    for p, doc in docs.items():
        if not doc:
            continue
        pkg = doc.get("package")
        if not isinstance(pkg, dict) or not isinstance(pkg.get("name"), str):
            continue
        v = resolve_version(p, pkg)
        if v:
            versions[pkg["name"]] = (v, p)
    return versions


def requirements(docs):
    """Every in-house git+version pair: (consumer manifest, crate, requirement).

    A dependency qualifies when it carries both a `git` URL on one of our forges
    and a `version`. That is the pairing cargo enforces and the one that breaks:
    a requirement of "0.11" against a sibling that has moved to 0.14 is not a
    warning, it is a graph that will not resolve on any machine.
    """
    for p, doc in docs.items():
        if not doc:
            continue
        for table in dep_tables(doc):
            for key, spec in table.items():
                if not isinstance(spec, dict):
                    continue
                git = spec.get("git")
                req = spec.get("version")
                if not isinstance(git, str) or not isinstance(req, str):
                    continue
                if not OURS.search(git):
                    continue
                name = spec.get("package") if isinstance(spec.get("package"), str) else key
                yield p, name, req


def analyze(docs, tree):
    """Grade every in-house git+version pair in `docs` against the tree on disk.

    Returns (broken, unchecked, absent, graded), where a broken entry is
    (consumer manifest, crate, requirement, version found, provider manifest,
    source label). The source label is None here: this view reads the version
    off a manifest, and naming the manifest already says where it came from.
    """
    versions = crate_index(docs, tree)
    broken, unchecked, absent, graded = [], 0, set(), 0
    for p, name, req in requirements(docs):
        known = versions.get(name)
        if known is None:
            # A repo that is not on this machine (ripgrow lives on mbp only).
            # Not a finding: nothing here can be wrong about it.
            absent.add(name)
            continue
        verdict = satisfies(req, known[0])
        if verdict is None:
            unchecked += 1
            continue
        graded += 1
        if not verdict:
            broken.append((p, name, req, known[0], known[1], None))
    return broken, unchecked, absent, graded


def split_blame(broken, repo):
    """Breaks this push owns, and breaks that were already there."""
    ours, theirs = [], []
    for item in broken:
        consumer_manifest, _name, _req, _have, provider_manifest, _src = item
        mine = repo is not None and (
            consumer_manifest.startswith(repo + os.sep)
            or provider_manifest.startswith(repo + os.sep)
        )
        (ours if mine else theirs).append(item)
    return ours, theirs


def report(broken, repo, tree, label):
    """Print one view's breaks. Returns True if this push has to be refused."""
    ours, theirs = split_blame(broken, repo)

    def rel(path):
        return os.path.relpath(path, tree)

    for consumer_manifest, name, req, have, provider_manifest, src in ours + theirs:
        where = (
            f"{src} has {have} ({rel(provider_manifest)})"
            if src
            else f"the tree has {have} ({rel(provider_manifest)})"
        )
        print(
            f"  [{label}] {rel(consumer_manifest)}: requires {name} \"{req}\", {where}",
            file=sys.stderr,
        )
    if repo is None:
        return bool(broken)
    if not ours:
        # Somebody else's skew. Worth seeing, never worth blocking this push on:
        # a gate that fails for a reason the pusher cannot fix is a gate that
        # gets bypassed by reflex, and then it is not a gate.
        if theirs:
            print(
                f"pre-push: [{label}] {len(theirs)} unresolvable requirements "
                "elsewhere in the tree (listed above, not this push's).",
            )
        return False
    return True


def main():
    if len(sys.argv) < 2:
        print(__doc__.strip(), file=sys.stderr)
        return 2
    tree = os.path.realpath(sys.argv[1])
    repo = os.path.realpath(sys.argv[2]) if len(sys.argv) > 2 else None
    sha = sys.argv[3] if len(sys.argv) > 3 else None

    docs = {p: load(p) for p in manifests(tree)}

    views = [("working copy", docs)]
    skipped_push_view = False
    if repo and sha:
        pushed = pushed_view(docs, repo, sha)
        if pushed is None:
            skipped_push_view = True
        else:
            views.append(("as pushed", pushed))

    refuse = False
    summaries = []
    for label, view in views:
        broken, unchecked, absent, graded = analyze(view, tree)
        summaries.append((label, graded, unchecked, absent, bool(broken)))
        if broken and report(broken, repo, tree, label):
            refuse = True

    # The third view: what the rest of the tree has actually PUSHED. Graded from
    # the most authoritative consumer view available, so the requirements read
    # are the ones about to be published.
    consumer_view = views[-1][1]
    pub_broken, pub_graded, ungraded = analyze_published(
        consumer_view, docs, tree, repo, sha
    )
    summaries.append(("as published", pub_graded, 0, set(), bool(pub_broken)))
    if pub_broken and report(pub_broken, repo, tree, "as published"):
        refuse = True

    if refuse:
        bad = {lbl for lbl, _g, _u, _a, broke in summaries if broke}
        clean = [lbl for lbl, _g, _u, _a, broke in summaries if not broke]
        print("", file=sys.stderr)
        print(
            "pre-push: this push leaves a dependency that cannot resolve.\n"
            "  A version requirement states which major a consumer was written against,\n"
            "  so bumping a library and fixing its consumers is one pass (CLAUDE.md,\n"
            "  \"a breaking bump of an in-house crate is forward-fixed, in the same pass\").\n"
            "  Fix: bump the requirement in the manifests above, make the consumers\n"
            "  compile, and push them with this one.",
            file=sys.stderr,
        )
        # Which view broke is the diagnosis, so say what the views disagree
        # about rather than only that they disagree.
        if "as published" in bad and "working copy" not in bad:
            print(
                "  The working copy is fine and the published tree is not, so the\n"
                "  difference is what has been PUSHED: a git dependency resolves against\n"
                "  the branch head at the URL, and ~/Code/.cargo/config.toml's [patch]\n"
                "  block hides that locally. Push the sibling named above first.",
                file=sys.stderr,
            )
        elif "as pushed" in bad and "working copy" not in bad:
            print(
                "  The working copy is fine and the commit is not, so the difference is\n"
                "  what is COMMITTED. An uncommitted manifest edit is the usual cause.",
                file=sys.stderr,
            )
        elif clean:
            print(
                f"  Note: the {clean[0]} view is clean, so the views disagree; the one\n"
                "  that failed is named on each line above.",
                file=sys.stderr,
            )
        return 1

    for label, graded, unchecked, absent, _bad in summaries:
        print(
            f"pre-push: internal deps coherent [{label}] ({graded} requirements"
            + (f", {unchecked} unchecked" if unchecked else "")
            + (f", {len(absent)} crates not in this tree" if absent else "")
            + ")."
        )
    for where, why in sorted(ungraded.items()):
        # Never silently: a repo nobody could read is not a repo that passed.
        print(
            f"pre-push: [as published] {os.path.relpath(where, tree)} not graded ({why})."
        )
    if skipped_push_view:
        # Never silently: a view that did not run must not read as one that passed.
        print("pre-push: could not read the pushed commit; graded the working copy only.")
    return 0


if __name__ == "__main__":
    sys.exit(main())
