"""``mgf-fed drift-check`` — verify each repo against the canonical scaffolding policy.

Read-only. Turns the ``status`` matrix's presence dots into a concrete divergence
report: missing files, missing CI steps, byte-drift in shared files, and pyproject
gate-policy violations (incl. the ruff format-stability pin). Feeds the later
``scaffold`` (apply) + the fleet-wide ruff-pin task.
"""

from __future__ import annotations

import re
import tomllib
from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from mgf.fed import _pipeline, _woodpecker
from mgf.fed._roster import Member, federation_root, members, print_divergence_banner
from mgf.fed._template import canonical_text, load_policy

_STEP_NAME_RE = re.compile(r"(?m)^\s{2,}-\s+name:\s+(\S+)")

_WOODPECKER = ".woodpecker.yml"

#: `present`-list entries that are PYTHON-PACKAGING artifacts — a member with no Python
#: has no equivalent, so they are required only of `lang == "python"` members. The
#: `policy.present` list is python-shaped (there is no separate zig policy); the default
#: `--shape python` already excuses out-of-shape members the whole scaffolding policy
#: (see `run_drift_check`), but an explicit `--shape zig` / `--shape all` runs the full
#: `check()`, so these must be shape-gated there too or a zig repo is flagged for files
#: it should never carry. PUBLIC_API.json + its generator introspect Python modules;
#: `py_typed` (PEP 561) and the `[pyproject]` checks are python by nature and gated
#: inline.
#:
#: DECLARED IN CANON since mgf-standard 2026-08-14 as `policy.python_only_present`.
#: This constant is now only the COMPATIBILITY FALLBACK for a bench whose mgf-standard
#: predates that key; `_carveout()` prefers the declared list. `test_drift.py` pins it
#: against the sibling policy.toml, so the two cannot drift apart unnoticed.
_PYTHON_ONLY_PRESENT = frozenset({"PUBLIC_API.json", "tools/generate_public_api_json.py"})

#: `present`-list entries that are LIBRARY-TIER artifacts — owed by a member that
#: publishes a contract surface, not by a consumer app (`kind == "app"`), whatever its
#: language. Orthogonal to `_PYTHON_ONLY_PRESENT` above, which asks a *language*
#: question: a Zig LIBRARY still owes SCOPE.md; a Python APP does not owe PUBLIC_API.json.
#:
#: The classification is the canon's, not this file's, and was verified against
#: mgf-standard `docs/standards/` at 0.1.78 before the set was written (PAPER-150(ii)):
#:
#:   - `PUBLIC_API.json` (+ its generator) — DOC-01 v2.11 §2.0.1 is explicit:
#:     "`PUBLIC_API.md` (+ `.json`) | ✅ | ✅ | ❌ | The contract surface for a library.
#:     Apps don't have one." (AP-10 / DOC-08; mirrored in FEDERATION.md §3.)
#:   - `SCOPE.md` — NOT in the DOC-01 universal root-doc baseline at all (neither the
#:     §2.0.1 matrix nor FEDERATION.md §3 lists it), and the rules that mandate it name
#:     their subject: SP-01/SP-02 read "**The library** MUST publish an explicit
#:     IN-scope/OUT-of-scope list", and `docs/standards/SCOPE.md` §1 closes with "Other
#:     **libraries** that follow this standard ship their own `SCOPE.md`." So it is
#:     library-tier, and the fix is three files rather than two.
#:
#: Everything else on the `present` list — DEPS.md, the conformance ledger, .gitleaks —
#: IS universal and stays owed by every member of every tier. This is a narrow
#: correction of a library-shaped policy applied to the app tier (PAPER-150(ii)),
#: keyed on the `kind` field PAPER-151 introduced for exactly this class of question.
#:
#: DECLARED IN CANON since mgf-standard 2026-08-14 as `policy.library_only_present`.
#: Until then this set said "the classification is the canon's, not this file's" while
#: being the only place it existed — canon's machine-readable form (`policy.present`)
#: required all seven files of everyone, and this frozenset silently exempted three.
#: Measured by a fresh-context chair: two members identical but for `kind`, both missing
#: every present file, gave SEVEN findings as a library and FOUR as an app. Now a
#: fallback only; `_carveout()` prefers the declared list.
_LIBRARY_ONLY_PRESENT = frozenset(
    {"PUBLIC_API.json", "tools/generate_public_api_json.py", "SCOPE.md"}
)


def _carveout(policy: dict[str, Any], key: str, fallback: frozenset[str]) -> frozenset[str]:
    """Who owes which `present` files — from CANON when it says, else the fallback.

    Canon is `templates/policy.toml` in mgf-standard. A bench whose mgf-standard
    predates the declaration keeps working on the built-in set, which is why this is a
    `.get`; a bench that HAS the declaration is graded by it, which is the point. The
    two are pinned equal by a test, so "the fallback quietly disagreed with canon" is a
    state that cannot survive a test run.
    """
    declared = policy.get(key)
    if declared is None:
        return fallback
    return frozenset(str(x) for x in declared)


#: Drift kinds produced by `gate_floor` / `ungoverned_repos` — a federation gate that is
#: NOT RUNNING, as opposed to a file that merely diverged from the template.
_GATE_FLOOR_KINDS = frozenset(
    {
        "ci-missing",
        "ci-command",
        "ci-coverage",
        "ci-ungoverned",
        "ci-ui-gate",
        "ci-clone",
        "ci-uv-frozen",
    }
)


@dataclass(frozen=True, slots=True)
class DriftItem:
    # kinds: missing | exact | ci-generated | ci-step | ci-command | ci-missing |
    #        ci-coverage | ci-ui-gate | ci-clone | ci-uv-frozen | ci-ungoverned |
    #        ci-unsafe-install | pyproject
    kind: str
    detail: str


@dataclass(frozen=True, slots=True)
class RepoDrift:
    repo: str
    items: tuple[DriftItem, ...]

    @property
    def ok(self) -> bool:
        return not self.items


def _load_pyproject(repo_dir: Path) -> dict[str, Any]:
    p = repo_dir / "pyproject.toml"
    if not p.is_file():
        return {}
    try:
        return tomllib.loads(p.read_text(encoding="utf-8"))
    except tomllib.TOMLDecodeError:
        return {}


def _ruff_specs(pyproject: dict[str, Any]) -> list[str]:
    opt = (pyproject.get("project", {}) or {}).get("optional-dependencies", {}) or {}
    out: list[str] = []
    for group in opt.values():
        out += [s for s in group if isinstance(s, str) and s.lstrip().lower().startswith("ruff")]
    return out


def _check_pyproject(
    pyproject: dict[str, Any], policy: dict[str, Any], repo: str, exempt: dict[str, Any]
) -> list[DriftItem]:
    items: list[DriftItem] = []
    if not pyproject:
        return [DriftItem("pyproject", "missing or unparseable pyproject.toml")]
    if policy.get("build_backend"):
        got = (pyproject.get("build-system", {}) or {}).get("build-backend")
        if got != policy["build_backend"]:
            items.append(
                DriftItem("pyproject", f"build-backend={got!r} (want {policy['build_backend']!r})")
            )
    tool = pyproject.get("tool", {}) or {}
    if policy.get("mypy_strict") and (tool.get("mypy", {}) or {}).get("strict") is not True:
        items.append(DriftItem("pyproject", "[tool.mypy] strict is not true"))
    if policy.get("pytest_filterwarnings_error"):
        fw = ((tool.get("pytest", {}) or {}).get("ini_options", {}) or {}).get("filterwarnings", [])
        if not (fw and str(fw[0]).startswith("error")):
            items.append(
                DriftItem("pyproject", "[tool.pytest] filterwarnings does not start with 'error'")
            )
    if policy.get("standards_extra") and repo not in exempt.get("standards_extra", []):
        opt = (pyproject.get("project", {}) or {}).get("optional-dependencies", {}) or {}
        if "standards" not in opt:
            items.append(DriftItem("pyproject", "no separate [standards] extra"))
    want = policy.get("ruff_spec")
    if want:
        specs = _ruff_specs(pyproject)
        if not specs:
            items.append(DriftItem("pyproject", "no ruff dev pin found"))
        elif want.replace(" ", "") not in {s.replace(" ", "") for s in specs}:
            items.append(
                DriftItem(
                    "pyproject", f"ruff pin {specs[0]!r} != required {want!r} (format-stability)"
                )
            )
    return items


#: The language-agnostic governance steps EVERY member's pipeline must run,
#: whatever its shape — the AG-03 commit-trailer gate and the secret scan. The full
#: `woodpecker_required_steps` list (typecheck / test-3.x / build / sbom …) is
#: Python-LIBRARY-pipeline-shaped and must NOT be required of a web/assets member
#: whose bespoke pipeline (a deploy, an asset lint) legitimately has none of it, nor
#: of an app-tier member (`Member.kind == "app"`) that publishes no package however
#: it is written; both are held to this universal subset instead. See `check()`.
_UNIVERSAL_REQUIRED_STEPS: tuple[str, ...] = ("commit-trailers", "secret-scan")


def _required_steps_drift(text: str, required: Iterable[str]) -> list[DriftItem]:
    """FALLBACK ci check: every ``required`` step name present (regex, zero-dep).

    Used only when generation is off, or when ``render_woodpecker`` cannot run for this
    member (e.g. an unparseable pyproject.toml) so the generation-currency check is
    unavailable. The generation-currency check supersedes this when a render succeeds.
    ``required`` is the full policy list for a Python LIBRARY, the universal subset for a
    non-Python member or an app-tier one (the caller shape-selects — see `check()`).
    """
    have = set(_STEP_NAME_RE.findall(text))
    missing = [s for s in required if s not in have]
    if missing:
        return [DriftItem("ci-step", f"missing steps: {', '.join(missing)}")]
    return []


def _gitleaks_shape_drift(on_disk: str, want: str) -> str | None:
    """Shape-aware comparison for gitleaks policy files (M2 §3.6; closes PAPER-03).

    The old byte-exact comparison flagged every legitimately-ADDITIVE consumer
    allowlist entry (a repo allowlisting its own fixture files) as drift, training
    maintainers to ignore the check. This compares the parsed TOML **shape**:

    - ``[allowlist]`` keys with LIST values (``paths``/``regexes``/…): every
      template entry must be present in the consumer — a removed/edited standard
      entry still fails; consumer EXTRAS are tolerated. An extra list-valued
      allowlist key is additive too, and tolerated.
    - everything else (``title``, ``[extend]``, rules, allowlist scalars like
      ``description``): must equal the template exactly — additions, removals and
      edits all fail. Comments are not compared (shape, not bytes).

    Returns ``None`` when conformant, else a one-line detail. An unparseable
    consumer file is drift; an unparseable TEMPLATE degrades to the byte-exact
    comparison (never-block, and byte-exact is the stricter original contract).
    """
    try:
        want_data = tomllib.loads(want)
    except tomllib.TOMLDecodeError:  # broken canonical — hold the line byte-exact
        return None if on_disk == want else "differs from the template (byte-exact)"
    try:
        got_data = tomllib.loads(on_disk)
    except tomllib.TOMLDecodeError as exc:
        return f"unparseable TOML ({exc})"

    def _table(data: dict[str, Any]) -> dict[str, Any]:
        allow = data.get("allowlist")
        return allow if isinstance(allow, dict) else {}

    want_allow, got_allow = _table(want_data), _table(got_data)
    for key in sorted(set(want_data) | set(got_data)):
        if key == "allowlist":
            continue
        if want_data.get(key) != got_data.get(key):
            return f"[{key}] differs from the template"
    for key in sorted(set(want_allow) | set(got_allow)):
        w, g = want_allow.get(key), got_allow.get(key)
        if w is None and isinstance(g, list):
            continue  # additive allowlist entry-list — tolerated (PAPER-03)
        if isinstance(w, list) and isinstance(g, list):
            missing = [e for e in w if e not in g]
            if missing:
                shown = ", ".join(str(e) for e in missing[:3])
                return f"allowlist.{key} drops/edits standard entr(y/ies): {shown}"
            continue  # every standard entry present; extras tolerated
        if w != g:
            return f"allowlist.{key} differs from the template"
    return None


def _append_array_entries(text: str, key: str, extras: list[str]) -> str:
    """Append ``extras`` as new items to the TOML array ``key = [ … ]`` in ``text``.

    Formatting follows the template: each extra is emitted as a triple-single-quoted
    literal (``'''…'''`` — the shape gitleaks path/regex entries use, so a
    backslash-bearing regex needs no escaping) on its own line, indented to match the
    array's existing items, inserted immediately before the closing ``]``. Returns
    ``text`` unchanged when ``extras`` is empty or the array is absent (a consumer-only
    allowlist key the template does not define — there is nothing to append to).
    """
    if not extras:
        return text
    # `key = [` up to the line that closes the array. ``body`` consumes whole lines
    # only, so the terminator is the FIRST line that begins (after indent) with ``]``;
    # entry lines begin with ``'''``, never ``]``, so an entry carrying a literal ``]``
    # (e.g. a ``[0-9]`` character class) is never mistaken for the terminator.
    pat = re.compile(
        rf"(?m)^(?P<lead>[ \t]*){re.escape(key)} = \[[ \t]*\n"
        r"(?P<body>(?:.*\n)*?)"
        r"(?P<close>[ \t]*\])"
    )
    m = pat.search(text)
    if m is None:
        return text
    indent = m.group("lead") + "    "  # fallback: array indent + 4 spaces
    for line in m.group("body").splitlines():
        if line.strip():  # adopt the real entry indentation when the array is non-empty
            indent = line[: len(line) - len(line.lstrip())]
            break
    addition = "".join(f"{indent}'''{e}''',\n" for e in extras)
    return text[: m.start("close")] + addition + text[m.start("close") :]


def gitleaks_restore_text(on_disk: str, want: str) -> str:
    """Non-destructive ``.gitleaks.toml`` restore (Finding 1, Option A).

    Return the template ``want`` — so any MISSING standard allowlist entry is re-added
    — but with the consumer's *additive* allowlist entries (list values present in
    ``on_disk`` but not in ``want``) re-appended, so a repo's own fixture carve-outs
    survive a restore that the old skip-or-overwrite guard would have discarded (the
    mgf-fastapi clobber: a file that simultaneously LACKED a new standard entry and
    CARRIED an extra carve-out). ``title``, ``[extend]``, every rule and the allowlist
    scalars follow the template verbatim; only additive entries in a list the template
    already defines are carried over.

    If either side is unparseable, or there are no additive extras, ``want`` is
    returned byte-for-byte (the template is the safe fallback).
    """
    try:
        want_data = tomllib.loads(want)
        got_data = tomllib.loads(on_disk)
    except tomllib.TOMLDecodeError:
        return want

    def _allow(data: dict[str, Any]) -> dict[str, Any]:
        allow = data.get("allowlist")
        return allow if isinstance(allow, dict) else {}

    want_allow, got_allow = _allow(want_data), _allow(got_data)
    out = want
    for key in sorted(want_allow):  # only keys the template defines can be appended to
        w, g = want_allow.get(key), got_allow.get(key)
        if isinstance(w, list) and isinstance(g, list):
            extras = [e for e in g if e not in w]
            if extras:
                out = _append_array_entries(out, key, extras)
    return out


def _iter_steps(text: str) -> list[tuple[str, str]]:
    """Split a ``.woodpecker.yml`` into ``(step-name, step-body)`` blocks (zero-dep)."""
    blocks: list[tuple[str, str]] = []
    name: str | None = None
    buf: list[str] = []
    for line in text.splitlines():
        m = _STEP_NAME_RE.match(line)
        if m:
            if name is not None:
                blocks.append((name, "\n".join(buf)))
            name = m.group(1)
            buf = [line]
        elif name is not None:
            buf.append(line)
    if name is not None:
        blocks.append((name, "\n".join(buf)))
    return blocks


def _ci_install_safety_drift(text: str, pyproject: dict[str, Any]) -> list[DriftItem]:
    """Install-command safety — runs on EVERY ``.woodpecker.yml`` (generated, exempt,
    or generation-off), because neither the byte-render currency check nor the
    step-name fallback inspects what the install COMMANDS actually do. Two hazards
    from the mgf-hrb / mgf-test-supervisor dig (2026-06):

    1. **Dependency-confusion / wrong-version.** A repo that resolves mgf-* deps
       (``extra_index`` on, the default) MUST steer every project/extras
       ``pip install`` at the devpi index — a bare ``pip install -e ".[dev]"``
       resolves the mgf-* deps from public PyPI (stale mirror). ``[tool.mgf-fed]
       extra_index = false`` opts out (cornerstone / editable-pinned repos), as does
       a ``PIP_INDEX_URL`` env anchor that steers every step file-wide.
    2. **vuln-scan pip CVE.** A ``pip-audit`` step that does not ``pip install
       --upgrade pip`` first audits the base image's own outdated pip and reds on
       its CVEs.
    """
    items: list[DriftItem] = []
    extra_index = bool(_woodpecker._mgf_ci(pyproject).get("extra_index", True))
    # An env anchor steers every step — but judge it over REAL content, not raw text
    # (CIR-05): a mere COMMENT naming PIP_INDEX_URL used to silently disable this whole
    # check (the same comment-vs-code class 0.14.2 fixed for the bare-install scan). A
    # real `environment:` KEY line (`PIP_INDEX_URL: …`) is not a COMMAND, so
    # `_pipeline.command_lines()` would miss it — scan the NON-COMMENT lines instead,
    # which keeps a genuine env anchor steered while a comment no longer counts.
    env_steered = any(
        "PIP_INDEX_URL" in line for line in text.splitlines() if not line.lstrip().startswith("#")
    )

    if extra_index and not env_steered:
        bare = [
            s
            for line in text.splitlines()
            if "pip install" in (s := line.strip())
            and not s.startswith("#")  # a comment is not a command (e.g. a "# Why exempt" note)
            and (" -e " in s or ".[" in s)  # the project/extras install (not a tool install)
            and "--index-url" not in s
            and "--extra-index-url" not in s
        ]
        if bare:
            items.append(
                DriftItem(
                    "ci-unsafe-install",
                    f"{len(bare)} bare `pip install` of the project/extras without index "
                    f"steering — resolves mgf-* deps from public PyPI "
                    f"(dependency-confusion / stale-version). e.g. `{bare[0][:64]}`",
                )
            )

    for name, block in _iter_steps(text):
        if "pip-audit" in block and "pip install" in block and "upgrade pip" not in block:
            items.append(
                DriftItem(
                    "ci-unsafe-install",
                    f"step {name!r} runs pip-audit without `pip install --upgrade pip` — "
                    f"audits the base image's outdated pip (base-image-pip CVE time-bomb)",
                )
            )
    return items


def gate_floor(member: Member, policy: dict[str, Any]) -> list[DriftItem]:
    """The federation **gate floor** — the checks that hold for EVERY registry member,
    whatever its language and whether or not it is exempt from generation-currency
    (``mgf.fed._pipeline``; PAPER-121/122).

    The items, each closing a hole a real wave fell into:

    - ``ci-missing`` — no ``.woodpecker.yml`` at all. Every Woodpecker run then errors
      ("could not load config from forge") and NO gate has ever run on the repo
      (mgf-brand). Excused only by an explicit ``[exempt].no_pipeline`` entry.
    - ``ci-clone`` — the pipeline exists but does not carry the hardened clone
      (``partial: false`` + ``use-ssh: true``): the PAPER-109 SSH clone and the WF-09
      full clone. A pipeline with NO clone block silently inherits Woodpecker's default
      (partial clone + OAuth credential) — "an absent block is the most invisible kind
      of drift" (PAPER-128, CIR-03). Asserted for EVERY member, any language, exempt or
      not; excused only by an explicit ``[exempt].clone`` entry.
    - ``ci-uv-frozen`` — a CI ``uv`` verb that re-resolves (``uv run``/``export``/
      ``sync``/``pip install``) without ``--frozen`` rewrites ``uv.lock`` mid-pipeline
      (WF-14; PAPER-125 swapped 9 pins to pypi relics), and an audit ``uv export`` without
      ``--no-emit-project`` chokes on the local project. Asserted for EVERY member, any
      language; a pip-only pipeline (the current fleet) runs no such verb and is silent.
    - ``ci-command`` — the pipeline exists but does not RUN a required gate command.
      Asserted against exempt pipelines too: exempt means "we don't regenerate your
      file", not "you may silently drop a federation gate". Python members only — the
      default command list (`ruff …`) is a Python gate set, and a Zig pipeline
      legitimately has no ruff.
    - ``ci-coverage`` — the supervisor-under-coverage ↔ ``patch = ["subprocess"]``
      coupling: green suite, red build.

    Read-only; no I/O beyond the two files the caller already found.
    """
    d = member.repo_dir
    wp = d / _WOODPECKER
    if not wp.is_file():
        if member.repo in _pipeline.pipeline_missing_exempt(policy):
            return []
        return [
            DriftItem(
                "ci-missing",
                f"no {_WOODPECKER} — every Woodpecker run errors ('could not load config "
                f"from forge') and NO gate has ever run on this repo. Run `mgf-fed scaffold` "
                f"(or add the repo to [exempt].no_pipeline if that is deliberate).",
            )
        ]
    text = wp.read_text(encoding="utf-8")
    items: list[DriftItem] = []
    # ci-clone — the hardened clone, on EVERY member (any language, exempt or not):
    # `partial: false` (WF-09 full clone) + `use-ssh: true` (PAPER-109 SSH clone). An
    # absent/soft clone block silently rides Woodpecker's default partial+OAuth clone
    # (PAPER-128, CIR-03). Excused only by an explicit `[exempt].clone` entry.
    if member.repo not in _pipeline.clone_exempt(policy):
        clone_missing = _pipeline.clone_hardening_gap(text)
        if clone_missing:
            items.append(
                DriftItem(
                    "ci-clone",
                    f"{_WOODPECKER} clone block is missing {', '.join(clone_missing)} — "
                    f"{_pipeline.CLONE_HARDENING_FIX}",
                )
            )
    # ci-uv-frozen — WF-14, on EVERY member (any language): a CI `uv` verb that
    # re-resolves (`uv run`/`export`/`sync`/`pip install`) MUST carry `--frozen`, or it
    # rewrites `uv.lock` mid-pipeline (PAPER-125 swapped 9 pins to pypi relics). A
    # pip-only pipeline (the current fleet) runs no such verb → no finding, silent.
    uv_gaps = _pipeline.uv_frozen_gap(text)
    if uv_gaps:
        items.append(
            DriftItem(
                "ci-uv-frozen",
                f"{_WOODPECKER} has CI `uv` verb(s) that re-resolve: {'; '.join(uv_gaps)} — "
                f"{_pipeline.UV_FROZEN_FIX}",
            )
        )
    if member.lang == "python":
        missing = _pipeline.missing_commands(text, _pipeline.required_commands(policy))
        if missing:
            items.append(
                DriftItem(
                    "ci-command",
                    f"{_WOODPECKER} does not run required gate command(s): "
                    f"{', '.join(repr(c) for c in missing)} — a step NAME is not a gate "
                    f"(exempt from generation-currency ≠ exempt from the gate floor)",
                )
            )
    pyproject = _load_pyproject(d)
    if _pipeline.coverage_patch_gap(text, pyproject):
        items.append(DriftItem("ci-coverage", _pipeline.COVERAGE_PATCH_FIX))
    if _pipeline.ui_gate_gap(text, pyproject):
        items.append(DriftItem("ci-ui-gate", _pipeline.UI_GATE_FIX))
    return items


def check(member: Member, policy: dict[str, Any], root: Path | None = None) -> RepoDrift:
    d = member.repo_dir
    items: list[DriftItem] = []
    # The one policy is python-shaped; a non-python member (a Zig sibling) carries no
    # PUBLIC_API.json / py.typed / pyproject and must not be flagged for their absence.
    # `--shape python` excuses out-of-shape members upstream (run_drift_check); an
    # explicit `--shape zig` / `--shape all` reaches here, so the Python-packaging
    # checks are gated on lang. The language-agnostic scaffolding (gitleaks, hooks,
    # the conformance ledger, SCOPE/DEPS, .woodpecker.yml currency, install-safety)
    # still applies to every shape.
    is_python = member.lang == "python"
    # TIER, the second question (PAPER-151): `lang` says what a member is written in,
    # `kind` says what it is FOR. Both gate the one python-shaped, library-shaped policy.
    app_tier = member.kind == "app"
    pyproject = _load_pyproject(d)  # loaded once; reused by the install-safety + pyproject checks
    # presence — `.woodpecker.yml` is excluded here and handled by `gate_floor` below,
    # which says WHY its absence is fatal (no gate has ever run) instead of listing it
    # as one more missing file, and which reports it for every member, not just the ones
    # in the current `--shape`.
    #
    # The `present` list is LIBRARY-shaped as well as python-shaped: it demands the
    # public-contract surface (PUBLIC_API.json + its generator) and the scope policy
    # (SCOPE.md) of every member. A consumer app has no public surface to publish — the
    # canon says so outright (DOC-01 v2.11 §2.0.1) — so the app tier was carrying
    # standing `missing` findings for artifacts it is not owed and cannot honestly
    # produce, the successor of the defect PAPER-151 fixed one layer down
    # (PAPER-150(ii)). Same remedy, same field: key the expectation on tier.
    for rel in policy.get("present", []):
        if rel == _WOODPECKER:
            continue
        if rel in _carveout(policy, "python_only_present", _PYTHON_ONLY_PRESENT) and not is_python:
            continue
        if rel in _carveout(policy, "library_only_present", _LIBRARY_ONLY_PRESENT) and app_tier:
            continue
        if not (d / rel).is_file():
            items.append(DriftItem("missing", rel))
    if is_python and policy.get("py_typed") and not list(d.glob("src/**/py.typed")):
        items.append(DriftItem("missing", "src/**/py.typed"))
    # shared files: byte-exact — EXCEPT gitleaks policy files, which are compared
    # shape-aware (§3.6: additive consumer allowlists tolerated; removals/edits of
    # standard entries still fail — see _gitleaks_shape_drift).
    for rel, tmpl in (policy.get("exact", {}) or {}).items():
        f = d / rel
        if not f.is_file():
            continue  # covered by the `present` check when the policy lists it
        got = f.read_text(encoding="utf-8")
        want = canonical_text(tmpl, root)
        if Path(rel).name == ".gitleaks.toml":
            detail = _gitleaks_shape_drift(got, want)
            if detail:
                items.append(DriftItem("exact", f"{rel} {detail} (vs template/{tmpl})"))
        elif got != want:
            items.append(DriftItem("exact", f"{rel} differs from template/{tmpl}"))
    # ci pipeline — the gate floor (missing pipeline / required commands / the
    # coverage coupling) runs first and applies exempt-or-not; the shape checks below
    # only apply when there IS a pipeline.
    items += gate_floor(member, policy)
    wp = d / _WOODPECKER
    if wp.is_file():
        on_disk = wp.read_text(encoding="utf-8")
        # A Python LIBRARY's pipeline owes the full policy step-list. Two shapes owe
        # only the language-agnostic governance subset:
        #   - a non-Python member (web/assets — a deploy or asset-lint pipeline), whose
        #     language never produces typecheck / test-3.x / build / sbom;
        #   - an APP-tier member (`kind == "app"`), WHATEVER its language. The full list
        #     is library-shaped — build, sbom, publish, verify-devpi, tag-coherence — and
        #     a consumer app publishes nothing to devpi, builds no wheel and cuts no tag
        #     to verify, so it can never satisfy it. Selecting on `lang` alone made the
        #     escape hatch grade an exempt Python app WORSE than an unexempted one: one
        #     `ci-generated` finding became twelve `ci-step` findings (PAPER-151).
        # This is the step-NAME list only. The gate floor above (`ci-command`, the
        # required_commands of PAPER-120/121) keys on `lang` and still holds every
        # Python member, app or library: exempt from a library's step list is not
        # exempt from the gates that actually run.
        library_shaped = is_python and not app_tier
        required_steps: Iterable[str] = (
            policy.get("woodpecker_required_steps", [])
            if library_shaped
            else _UNIVERSAL_REQUIRED_STEPS
        )
        woodpecker_exempt = member.repo in (policy.get("exempt", {}) or {}).get("woodpecker", [])
        if policy.get("woodpecker_generated") and not woodpecker_exempt:
            # Generation-currency: the on-disk pipeline MUST equal a fresh render of
            # the canonical template. If the render raises (unparseable repo), fall
            # back to the name-list check so an un-renderable repo is still gated.
            try:
                want = _woodpecker.render_woodpecker(member, root)
            except Exception:  # degrade to the name-list fallback
                items += _required_steps_drift(on_disk, required_steps)
            else:
                if on_disk != want:
                    items.append(
                        DriftItem(
                            "ci-generated",
                            ".woodpecker.yml differs from generated (run `mgf-fed scaffold`)",
                        )
                    )
        else:
            # Generation off, OR a legitimately-bespoke pipeline excused from
            # generation-currency ([exempt].woodpecker, WF-13): hold it to the
            # step-name list instead of a byte-identical render comparison.
            items += _required_steps_drift(on_disk, required_steps)
        # Install-command safety — ALWAYS, regardless of generated/exempt: the
        # checks above gate names/bytes, never what the install commands DO.
        items += _ci_install_safety_drift(on_disk, pyproject)
    # Unrecognised [tool.mgf-fed] carve-out keys — python-only (a Zig member has no
    # pyproject), and independent of whether a .woodpecker.yml exists: the defect is
    # in the DECLARATION, so a repo whose pipeline is missing entirely still owns it.
    #
    # This is the one class the byte-render check structurally cannot catch: a key
    # the renderer does not read shapes nothing, so the render is byte-identical to
    # itself and `ci-generated` stays silent while the author believes a knob is on.
    # mgf-uitest-desktop's `qt = true` survived that way until its CI went red
    # (2026-07-29). `scaffold` cannot fix it — the typo is in the repo's own
    # pyproject — so it lands in `residual` as manual work and returns 1.
    if is_python and (unknown := _woodpecker.unknown_ci_keys(pyproject)):
        items.append(DriftItem("ci-unknown-key", _woodpecker.unknown_ci_key_detail(unknown)))
    # pyproject policy — python-only: a Zig member has no pyproject.toml, and requiring
    # one (or its ruff/import-linter/coverage sub-checks) of a Zig repo is the false
    # positive this shape-gate exists to prevent.
    if is_python:
        items += _check_pyproject(
            pyproject,
            policy.get("pyproject", {}) or {},
            member.repo,
            policy.get("exempt", {}) or {},
        )
    return RepoDrift(repo=member.repo, items=tuple(items))


def ungoverned_repos(root: Path, policy: dict[str, Any], known: set[str]) -> list[RepoDrift]:
    """Cloned ``mgf-*`` repos that the registry does not list AND that ship no pipeline
    (``ci-ungoverned``) — the hole mgf-brand actually fell through.

    The gate floor in :func:`gate_floor` holds every *registry member* to a pipeline.
    mgf-brand is not a registry member: it carries no publishable package, so
    ``MGF_LIBRARIES.json`` never listed it — and therefore neither drift-check nor any
    other tool we own ever looked at it. Meanwhile it IS activated on the forge, so every
    Woodpecker run errored ("could not load config from forge") and NO gate has ever run
    on it. Being outside the *package* registry is fine; being outside every *gate* is not.

    Deliberately narrow, so this reports the harm and not the shape:

    - only ``mgf-*`` directories that are real checkouts (a ``.git`` entry) — a scratch
      dir or a build output is not a repo. (A path test, not a ``git`` call: the CI image
      has no ``git`` binary, and this is a read-only classification.)
    - only repos with **no** ``.woodpecker.yml`` at all. A non-registry repo that *has* a
      pipeline (mgf-website, mgf-cost-watch) is running gates and is none of drift-check's
      business — today that keeps this check at exactly one finding, fleet-wide: mgf-brand.
    - excusable, like any member, by an explicit ``[exempt].no_pipeline`` entry.
    """
    out: list[RepoDrift] = []
    exempt = _pipeline.pipeline_missing_exempt(policy)
    for d in sorted(root.glob("mgf-*")):
        name = d.name
        if not d.is_dir() or name in known or name in exempt:
            continue
        if not (d / ".git").exists() or (d / _WOODPECKER).is_file():
            continue
        out.append(
            RepoDrift(
                repo=name,
                items=(
                    DriftItem(
                        "ci-ungoverned",
                        f"cloned repo with NO {_WOODPECKER} and NOT in the registry — every "
                        f"Woodpecker run errors ('could not load config from forge') and NO gate "
                        f"has ever run on it, yet nothing we own was looking. Give it a pipeline "
                        f"(`mgf-fed scaffold`), or add it to [exempt].no_pipeline if it is "
                        f"deliberately CI-less (then de-activate it on the forge).",
                    ),
                ),
            )
        )
    return out


def run_drift_check(*, shape: str = "python", root: Path | None = None) -> int:
    policy = load_policy(root)
    root = root or federation_root()
    # PAPER-152 — every finding below is measured against the working trees on THIS
    # bench, including the mgf-standard checkout the registry and the policy templates
    # are read from (`_roster._registry_path`). Name the diverged ones up front so a
    # false RED labels itself; the verdict + exit code are unchanged.
    consulted = dict.fromkeys([*(m.repo_dir for m in members(root)), root / "mgf-standard"])
    print_divergence_banner(consulted, subject="drift-check")
    rows: list[RepoDrift] = []
    for m in members(root):
        if shape == "all" or m.lang == shape:
            rows.append(check(m, policy, root))
            continue
        # Out-of-shape members (a Zig repo under the default `--shape python`) are
        # excused the Python scaffolding policy — but NEVER the gate floor. A member
        # with no pipeline at all runs no gates in ANY language, and under the old
        # shape filter nothing we own would ever have said so (PAPER-122).
        out_of_shape = gate_floor(m, policy)
        if out_of_shape:
            rows.append(RepoDrift(repo=m.repo, items=tuple(out_of_shape)))
    rows += ungoverned_repos(root, policy, {m.repo for m in members(root)})
    clean = [r for r in rows if r.ok]
    drifted = [r for r in rows if not r.ok]
    for r in drifted:
        print(f"✗ {r.repo}  ({len(r.items)})")
        for it in r.items:
            print(f"    [{it.kind}] {it.detail}")
    for r in clean:
        print(f"✓ {r.repo}")
    total = sum(len(r.items) for r in drifted)
    print(f"\n{len(clean)}/{len(rows)} clean · {len(drifted)} with drift · {total} items")
    # The gate floor is called out separately: these are not "your file drifted from the
    # template" findings, they are "a federation gate is NOT RUNNING on this repo".
    floor = [
        (r.repo, it)
        for r in drifted
        for it in r.items
        if it.kind in _GATE_FLOOR_KINDS  # ci-missing | ci-command | ci-coverage | ci-clone …
    ]
    if floor:
        print(f"\nGATE FLOOR — {len(floor)} gate(s) not running (PAPER-121/122):")
        for repo, it in floor:
            print(f"    [{it.kind}] {repo}: {it.detail.splitlines()[0]}")
    return 1 if drifted else 0
