#!/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

With a pushed sha as well, the run grades TWO views and fails on either:

  working copy   what this machine builds today. The historical check.
  as pushed      the same question asked of the repo's manifests AS THEY EXIST
                 AT THAT COMMIT, against the rest of the tree on disk.

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 WORKING COPIES AND NOT REMOTES. `~/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 just as thoroughly, and finding that out at push time is the point.
That is why the as-pushed view ADDS a check rather than replacing this one:
grading only the commit would stop catching the unpushed bump that breaks every
build on this machine. The two views answer different questions and both matter.

The rest of the tree is read from disk in both views, deliberately. Reading other
repos' remotes would need a fetch per repo, and the same `[patch]` block means
disk is what a local build resolves against anyway.

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 analyze(docs, tree):
    """Grade every in-house git+version pair in `docs`.

    Returns (broken, unchecked, absent, graded), where a broken entry is
    (consumer manifest, crate, requirement, version found, provider manifest).
    """
    # Workspace versions first: 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]
            d = os.path.dirname(d)
        return None

    # crate name -> (version, manifest path)
    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)

    broken, unchecked, absent, graded = [], 0, set(), 0
    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
                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]))
    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 = 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 in ours + theirs:
        print(
            f"  [{label}] {rel(consumer_manifest)}: requires {name} \"{req}\", "
            f"the tree has {have} ({rel(provider_manifest)})",
            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

    if refuse:
        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,
        )
        clean = [lbl for lbl, _g, _u, _a, bad in summaries if not bad]
        if clean:
            # The whole point of the second view. Saying which one passed is what
            # turns "it worked on my machine" into a diagnosis.
            print(
                f"  Note: the {clean[0]} view is clean, so the difference is what is\n"
                "  committed. An uncommitted manifest edit is the usual cause.",
                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 "")
            + ")."
        )
    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())
