"""Tests for the drift checker."""

from __future__ import annotations

import json
import shutil
import tomllib
from pathlib import Path

import pytest

from mgf.fed._drift import (
    _LIBRARY_ONLY_PRESENT,
    _PYTHON_ONLY_PRESENT,
    _carveout,
    check,
)
from mgf.fed._roster import federation_root, members
from mgf.fed._template import load_policy

# The gate-floor commands every Python member's pipeline must RUN (PAPER-121) —
# `mgf.fed._pipeline.DEFAULT_REQUIRED_COMMANDS`, in a step-shaped pipeline.
# (The gate floor itself is tested in tests/unit/test_gate_floor.py, which needs no
# sibling mgf-standard and therefore runs in a single-repo CI clone too.)
_GATE_COMMANDS = (
    "    commands:\n"
    "      - ruff check src tests\n"
    "      - ruff format --check src tests\n"
    "      - python scripts/check_commit_trailers.py\n"
)

# The hardened clone block the gate floor now asserts on every pipeline (CIR-03).
_CLONE = (
    "clone:\n  - name: clone\n    image: woodpeckerci/plugin-git:2.9.2\n"
    "    settings:\n      partial: false\n      use-ssh: true\n"
)

_REAL_TEMPLATES = Path(__file__).resolve().parents[3] / "mgf-standard" / "templates"

# Federation-INTEGRATION tests: read the REAL mgf-standard/templates, present only
# when the sibling repo is checked out alongside (federation-root layout). A
# single-repo CI clone has no sibling mgf-standard, so skip there — drift is still
# validated by `mgf-fed drift-check` (supervisor) and federation-root/local runs.
pytestmark = pytest.mark.skipif(
    not _REAL_TEMPLATES.exists(),
    reason="needs sibling mgf-standard/templates (federation-root); validated by drift-check",
)

_POLICY = """\
schema = 1
present = ["scripts/verify_codeberg.py", "SCOPE.md"]
py_typed = true
woodpecker_required_steps = ["lint", "publish"]
[exact]
"scripts/verify_codeberg.py" = "verify_codeberg.py"
[pyproject]
mypy_strict = true
ruff_spec = "ruff>=0.4,<0.16"
standards_extra = true
"""


def _root(tmp_path: Path) -> Path:
    reg = {
        "release_waves": [["mgf-clean", "mgf-drift"]],
        "libraries": [
            {
                "repo": "mgf-clean",
                "lang": "python",
                "version": "1.0.0",
                "extras": ["dev", "standards"],
            },
            {"repo": "mgf-drift", "lang": "python", "version": "0.1.0", "extras": ["dev"]},
        ],
    }
    std = tmp_path / "mgf-standard"
    (std / "templates").mkdir(parents=True)
    (std / "MGF_LIBRARIES.json").write_text(json.dumps(reg), encoding="utf-8")
    (std / "templates" / "policy.toml").write_text(_POLICY, encoding="utf-8")
    (std / "templates" / "verify_codeberg.py").write_text("# canonical\n", encoding="utf-8")

    # mgf-clean: satisfies the policy
    clean = tmp_path / "mgf-clean"
    (clean / "scripts").mkdir(parents=True)
    (clean / "src" / "mgf" / "clean").mkdir(parents=True)
    (clean / "src" / "mgf" / "clean" / "py.typed").touch()
    (clean / "scripts" / "verify_codeberg.py").write_text("# canonical\n", encoding="utf-8")
    (clean / "SCOPE.md").write_text("scope\n")
    (clean / ".woodpecker.yml").write_text(
        f"{_CLONE}steps:\n  - name: lint\n{_GATE_COMMANDS}  - name: publish\n"
    )
    (clean / "pyproject.toml").write_text(
        '[project]\nname="mgf-clean"\nversion="1.0.0"\n'
        '[project.optional-dependencies]\ndev=["ruff>=0.4,<0.16"]\nstandards=["mgf-common[standards]"]\n'
        "[tool.mypy]\nstrict=true\n"
    )
    # mgf-drift: violates several rules
    drift = tmp_path / "mgf-drift"
    (drift / "scripts").mkdir(parents=True)
    (drift / "scripts" / "verify_codeberg.py").write_text("# DIFFERENT\n", encoding="utf-8")
    (drift / ".woodpecker.yml").write_text("steps:\n  - name: lint\n")  # missing 'publish'
    (drift / "pyproject.toml").write_text(
        '[project]\nname="mgf-drift"\nversion="0.1.0"\n'
        '[project.optional-dependencies]\ndev=["ruff>=0.4"]\n'  # no upper bound, no standards
        "[tool.mypy]\nstrict=false\n"
    )
    return tmp_path


def test_clean_repo_has_no_drift(tmp_path: Path) -> None:
    root = _root(tmp_path)
    m = next(x for x in members(root) if x.repo == "mgf-clean")
    assert check(m, load_policy(root), root).ok


def test_drift_repo_flags_every_violation(tmp_path: Path) -> None:
    root = _root(tmp_path)
    m = next(x for x in members(root) if x.repo == "mgf-drift")
    items = check(m, load_policy(root), root).items
    kinds = {(i.kind, i.detail) for i in items}
    details = " ".join(i.detail for i in items)
    assert any(k == "missing" and "SCOPE.md" in d for k, d in kinds)
    assert "py.typed" in details
    assert any(k == "exact" for k, _ in kinds)  # verify_codeberg.py differs
    assert any(k == "ci-step" for k, _ in kinds)  # missing publish
    # PAPER-121: the pipeline runs no gate COMMANDS at all, only a step name.
    assert any(k == "ci-command" and "ruff format --check" in d for k, d in kinds)
    assert "!= required" in details and "[standards]" in details and "strict is not true" in details


# --------------------------------------------------------------------------------
# [exempt].woodpecker — bespoke pipelines skip generation-currency (WF-13),
# falling back to the step-name list instead.
# --------------------------------------------------------------------------------

# Generation-on policy with one bespoke repo excused from generation-currency.
_POLICY_GEN_EXEMPT = """\
schema = 1
present = []
woodpecker_generated = true
woodpecker_required_steps = ["lint", "publish"]
[exact]
[pyproject]
[exempt]
woodpecker = ["mgf-bespoke"]
"""


def _gen_exempt_root(tmp_path: Path, *, pipeline: str) -> Path:
    """Root whose policy generates pipelines but excuses ``mgf-bespoke``."""
    reg = {
        "release_waves": [["mgf-bespoke"]],
        "libraries": [
            {"repo": "mgf-bespoke", "lang": "python", "version": "1.0.0", "extras": ["dev"]}
        ],
    }
    std = tmp_path / "mgf-standard"
    std.mkdir(parents=True)
    (std / "MGF_LIBRARIES.json").write_text(json.dumps(reg), encoding="utf-8")
    shutil.copytree(_REAL_TEMPLATES, std / "templates")
    (std / "templates" / "policy.toml").write_text(_POLICY_GEN_EXEMPT, encoding="utf-8")
    repo = tmp_path / "mgf-bespoke"
    (repo / "src" / "mgf" / "bespoke").mkdir(parents=True)
    (repo / "src" / "mgf" / "bespoke" / "__init__.py").write_text("__version__ = '1.0.0'\n")
    (repo / "pyproject.toml").write_text(
        '[project]\nname = "mgf-bespoke"\nversion = "1.0.0"\n'
        "[project.optional-dependencies]\ndev = []\n"
    )
    (repo / ".woodpecker.yml").write_text(pipeline, encoding="utf-8")
    return tmp_path


def test_exempt_repo_not_flagged_ci_generated(tmp_path: Path) -> None:
    """A bespoke (off-render) pipeline with all required steps + gate commands is clean."""
    # Deliberately NOT a fresh render (a generation-currency check would flag it),
    # but it carries every required step name + gate command + a bespoke extra step.
    pipeline = (
        f"{_CLONE}steps:\n"
        f"  - name: lint\n{_GATE_COMMANDS}"
        "  - name: zig-build-and-test\n"  # bespoke extra
        "  - name: publish\n"
    )
    root = _gen_exempt_root(tmp_path, pipeline=pipeline)
    m = next(x for x in members(root) if x.repo == "mgf-bespoke")
    items = check(m, load_policy(root), root).items
    kinds = {i.kind for i in items}
    assert "ci-generated" not in kinds  # generation-currency skipped for the exempt repo
    assert "ci-step" not in kinds  # name-list fallback satisfied
    assert not items  # fully clean


def test_exempt_repo_uses_name_list_fallback(tmp_path: Path) -> None:
    """The exempt repo is still gated by the step-name list: a missing step flags ci-step."""
    pipeline = f"steps:\n  - name: lint\n{_GATE_COMMANDS}"  # missing 'publish'
    root = _gen_exempt_root(tmp_path, pipeline=pipeline)
    m = next(x for x in members(root) if x.repo == "mgf-bespoke")
    items = check(m, load_policy(root), root).items
    kinds = {i.kind for i in items}
    assert "ci-generated" not in kinds  # never the generation-currency check
    assert "ci-step" in kinds  # the name-list fallback caught the missing step


# --- shape-scoping: a Zig member is not flagged for Python-packaging artifacts ---

_SHAPE_POLICY = """\
schema = 1
present = ["PUBLIC_API.json", "tools/generate_public_api_json.py", "SCOPE.md"]
py_typed = true
woodpecker_required_steps = ["lint"]
[pyproject]
mypy_strict = true
"""


def _shape_root(tmp_path: Path) -> Path:
    """A python member and a zig member, both BARE (missing every present file, no
    pyproject, no py.typed) — so the only thing separating their drift is lang."""
    reg = {
        "release_waves": [["mgf-py", "mgf-zig"]],
        "libraries": [
            {"repo": "mgf-py", "lang": "python", "version": "1.0.0"},
            {"repo": "mgf-zig", "lang": "zig", "version": "1.0.0"},
        ],
    }
    std = tmp_path / "mgf-standard"
    (std / "templates").mkdir(parents=True)
    (std / "MGF_LIBRARIES.json").write_text(json.dumps(reg), encoding="utf-8")
    (std / "templates" / "policy.toml").write_text(_SHAPE_POLICY, encoding="utf-8")
    for repo in ("mgf-py", "mgf-zig"):
        (tmp_path / repo).mkdir()  # bare: no SCOPE.md, no PUBLIC_API.json, no pyproject
    return tmp_path


def test_zig_member_is_not_flagged_for_python_packaging_artifacts(tmp_path: Path) -> None:
    """`--shape zig` / `--shape all` reach check() for a Zig member; it must NOT be
    flagged for PUBLIC_API.json, its generator, `src/**/py.typed`, or a pyproject —
    a Zig repo carries none of those, and the default `--shape python` already
    excuses out-of-shape members the whole scaffolding policy upstream."""
    root = _shape_root(tmp_path)
    m = next(x for x in members(root) if x.repo == "mgf-zig")
    blob = " ".join(f"{i.kind} {i.detail}" for i in check(m, load_policy(root), root).items)
    assert "PUBLIC_API.json" not in blob
    assert "generate_public_api_json.py" not in blob
    assert "py.typed" not in blob
    assert "pyproject" not in blob
    # ...but the language-agnostic scaffolding still applies — SCOPE.md is universal.
    assert "SCOPE.md" in blob


def test_python_member_IS_still_flagged_for_the_same_artifacts(tmp_path: Path) -> None:
    """Regression guard: the shape-gate must not disable the checks for Python members.
    The bare python member is missing exactly the artifacts the zig member is excused."""
    root = _shape_root(tmp_path)
    m = next(x for x in members(root) if x.repo == "mgf-py")
    blob = " ".join(f"{i.kind} {i.detail}" for i in check(m, load_policy(root), root).items)
    assert "PUBLIC_API.json" in blob
    assert "generate_public_api_json.py" in blob
    assert "py.typed" in blob
    assert "pyproject" in blob
    assert "SCOPE.md" in blob


# --- required-steps are shape-scoped: a web/assets member's bespoke pipeline is
# --- held to the universal governance subset, not the Python step-list ---

_EXEMPT_STEPS_POLICY = """\
schema = 1
present = ["SCOPE.md"]
woodpecker_required_steps = [
    "lint", "typecheck", "test-3.12", "build", "sbom", "commit-trailers", "secret-scan",
]
[exempt]
woodpecker = ["mgf-web", "mgf-py"]
"""


def _steps_root(tmp_path: Path, *, web_pipeline: str, py_pipeline: str) -> Path:
    reg = {
        "release_waves": [["mgf-web", "mgf-py"]],
        "libraries": [
            {"repo": "mgf-web", "lang": "web", "version": "0.1.0"},
            {"repo": "mgf-py", "lang": "python", "version": "1.0.0"},
        ],
    }
    std = tmp_path / "mgf-standard"
    (std / "templates").mkdir(parents=True)
    (std / "MGF_LIBRARIES.json").write_text(json.dumps(reg), encoding="utf-8")
    (std / "templates" / "policy.toml").write_text(_EXEMPT_STEPS_POLICY, encoding="utf-8")
    for repo, pipe in (("mgf-web", web_pipeline), ("mgf-py", py_pipeline)):
        d = tmp_path / repo
        d.mkdir()
        (d / "SCOPE.md").write_text("scope\n")
        (d / ".woodpecker.yml").write_text(pipe)
    return tmp_path


def test_web_member_owes_only_the_universal_governance_steps(tmp_path: Path) -> None:
    """A bespoke web/assets pipeline (deploy / asset-lint) must NOT be flagged for the
    Python step-list (typecheck/test-3.x/build/sbom) — only commit-trailers + secret-scan."""
    root = _steps_root(
        tmp_path,
        # a deploy pipeline: has the two universal gates, none of the python ones
        web_pipeline="steps:\n  - name: commit-trailers\n  - name: secret-scan\n  - name: deploy\n",
        py_pipeline="steps:\n  - name: lint\n",
    )
    m = next(x for x in members(root) if x.repo == "mgf-web")
    items = check(m, load_policy(root), root).items
    steps = " ".join(i.detail for i in items if i.kind == "ci-step")
    assert steps == "", f"a web member was flagged for python steps: {steps}"


def test_web_member_IS_flagged_when_it_lacks_a_universal_step(tmp_path: Path) -> None:
    """The universal governance gates are still enforced — a web pipeline missing
    commit-trailers is flagged (that is exactly why mgf-website gains the step)."""
    root = _steps_root(
        tmp_path,
        web_pipeline="steps:\n  - name: secret-scan\n  - name: deploy\n",  # no commit-trailers
        py_pipeline="steps:\n  - name: lint\n",
    )
    m = next(x for x in members(root) if x.repo == "mgf-web")
    items = check(m, load_policy(root), root).items
    assert any(i.kind == "ci-step" and "commit-trailers" in i.detail for i in items)


def test_python_member_still_owes_the_full_step_list(tmp_path: Path) -> None:
    """Regression guard: a python exempt member is still held to the FULL list."""
    root = _steps_root(
        tmp_path,
        web_pipeline="steps:\n  - name: commit-trailers\n  - name: secret-scan\n",
        py_pipeline="steps:\n  - name: lint\n",  # missing typecheck/test/build/sbom/...
    )
    m = next(x for x in members(root) if x.repo == "mgf-py")
    items = check(m, load_policy(root), root).items
    missing = " ".join(i.detail for i in items if i.kind == "ci-step")
    assert "typecheck" in missing and "build" in missing


# --- required-steps are TIER-scoped too: a Python APP is held to the universal
# --- subset, not to the library step-list it can never satisfy (PAPER-151) ---
#
# `lang` was the only selector, so an exempt Python app was measured against
# build/sbom/publish/verify-devpi/tag-coherence/test-3.11 — steps a consumer app
# structurally cannot have. Exempting it traded one finding for twelve, and the
# escape hatch graded the exempt member WORSE than the unexempted one. The registry
# now carries `kind`, and the fallback selects on tier.

_APP_TIER_POLICY = """\
schema = 1
present = ["SCOPE.md"]
woodpecker_required_steps = [
    "lint", "typecheck", "test-3.11", "build", "sbom", "publish", "verify-devpi",
    "tag-coherence", "commit-trailers", "secret-scan",
]
[exempt]
woodpecker = ["mgf-app", "mgf-lib"]
"""


def _app_tier_root(tmp_path: Path, *, app_pipeline: str, lib_pipeline: str) -> Path:
    """Root with an app-tier member and a library member, both exempt.

    ``mgf-app`` carries ``kind = "app"``; ``mgf-lib`` declares NO kind at all — it is
    the control for the default, and for every member of the real registry, which
    declares `kind` for nobody.
    """
    reg = {
        "release_waves": [["mgf-app", "mgf-lib"]],
        "libraries": [
            {"repo": "mgf-app", "lang": "python", "version": "0.1.0", "kind": "app"},
            {"repo": "mgf-lib", "lang": "python", "version": "1.0.0"},
        ],
    }
    std = tmp_path / "mgf-standard"
    (std / "templates").mkdir(parents=True)
    (std / "MGF_LIBRARIES.json").write_text(json.dumps(reg), encoding="utf-8")
    (std / "templates" / "policy.toml").write_text(_APP_TIER_POLICY, encoding="utf-8")
    for repo, pipe in (("mgf-app", app_pipeline), ("mgf-lib", lib_pipeline)):
        d = tmp_path / repo
        d.mkdir()
        (d / "SCOPE.md").write_text("scope\n")
        (d / ".woodpecker.yml").write_text(pipe)
        # An app pins a floor its library step-list cannot honour: `test-3.11` can
        # never exist for a member that requires >=3.12 (PAPER-151's own example).
        floor = ">=3.12" if repo == "mgf-app" else ">=3.11"
        (d / "pyproject.toml").write_text(
            f'[project]\nname = "{repo}"\nversion = "0.1.0"\nrequires-python = "{floor}"\n',
            encoding="utf-8",
        )
    return tmp_path


#: An app-tier pipeline that satisfies the contract it SHOULD be held to: both
#: universal governance steps, the hardened clone, and all three gate-floor commands.
_APP_PIPELINE_CLEAN = (
    f"{_CLONE}steps:\n"
    f"  - name: commit-trailers\n{_GATE_COMMANDS}"
    "  - name: secret-scan\n"
    "  - name: e2e\n"  # a consumer app's own bespoke step
)


def test_app_member_owes_only_the_universal_governance_steps(tmp_path: Path) -> None:
    """The paper's exact scenario: a Python app whose pipeline carries commit-trailers
    + secret-scan + the gate-floor commands grades CLEAN. Before `kind`, this same
    tree produced twelve `ci-step` findings for library steps it cannot have."""
    root = _app_tier_root(
        tmp_path,
        app_pipeline=_APP_PIPELINE_CLEAN,
        lib_pipeline=f"{_CLONE}steps:\n  - name: lint\n{_GATE_COMMANDS}",
    )
    m = next(x for x in members(root) if x.repo == "mgf-app")
    assert m.kind == "app"
    items = check(m, load_policy(root), root).items
    steps = " ".join(i.detail for i in items if i.kind == "ci-step")
    assert steps == "", f"an app member was flagged for library steps: {steps}"
    residue = [(i.kind, i.detail) for i in items]
    assert not residue, f"the app tier's clean case is not clean: {residue}"


def test_app_member_IS_flagged_when_it_lacks_a_universal_step(tmp_path: Path) -> None:
    """The NEGATIVE control: the universal governance gates still bind the app tier.
    Drop `secret-scan` from the same pipeline and the finding must NAME it — an app
    tier that could not be flagged at all would be an exemption, not a tier."""
    root = _app_tier_root(
        tmp_path,
        app_pipeline=_APP_PIPELINE_CLEAN.replace("  - name: secret-scan\n", ""),
        lib_pipeline=f"{_CLONE}steps:\n  - name: lint\n{_GATE_COMMANDS}",
    )
    m = next(x for x in members(root) if x.repo == "mgf-app")
    items = check(m, load_policy(root), root).items
    hits = [i for i in items if i.kind == "ci-step"]
    assert len(hits) == 1, f"expected exactly the one missing universal step: {hits}"
    assert "secret-scan" in hits[0].detail
    # ...and NOT dragged back to the library list by the same finding.
    assert "build" not in hits[0].detail and "publish" not in hits[0].detail


def test_app_member_is_STILL_held_to_the_required_commands_gate_floor(tmp_path: Path) -> None:
    """The floor is not part of the trade. `ci-command` keys on `lang`, never on
    `kind`: an app that drops a gate COMMAND is flagged exactly like a library
    (PAPER-120/121 — a step name is not a gate)."""
    root = _app_tier_root(
        tmp_path,
        app_pipeline=_APP_PIPELINE_CLEAN.replace("      - ruff format --check src tests\n", ""),
        lib_pipeline=f"{_CLONE}steps:\n  - name: lint\n{_GATE_COMMANDS}",
    )
    m = next(x for x in members(root) if x.repo == "mgf-app")
    items = check(m, load_policy(root), root).items
    assert any(i.kind == "ci-command" and "ruff format --check" in i.detail for i in items)


def test_a_python_library_without_kind_still_owes_the_full_step_list(tmp_path: Path) -> None:
    """Regression guard, and the reason the default matters: `mgf-lib` declares no
    `kind`, so it defaults to library and stays held to the WHOLE list. Every member
    of the installed registry is in exactly this position."""
    root = _app_tier_root(
        tmp_path,
        app_pipeline=_APP_PIPELINE_CLEAN,
        # missing typecheck/test-3.11/build/sbom/publish/verify-devpi/tag-coherence/...
        lib_pipeline=f"{_CLONE}steps:\n  - name: lint\n{_GATE_COMMANDS}",
    )
    m = next(x for x in members(root) if x.repo == "mgf-lib")
    assert m.kind == "library"
    items = check(m, load_policy(root), root).items
    missing = " ".join(i.detail for i in items if i.kind == "ci-step")
    assert "typecheck" in missing and "build" in missing and "verify-devpi" in missing


# --- the `present` list is TIER-scoped too: an app tier is not owed the
# --- LIBRARY-only artifacts it structurally cannot produce (PAPER-150(ii)) ---
#
# PAPER-151 fixed the step-name fallback; the same library-shaped assumption survived
# one layer up, in the presence check. `policy.present` demands PUBLIC_API.json, its
# generator, and SCOPE.md of EVERY member — so a consumer app carried three standing
# `missing` findings for artifacts the canon says it does not have, and that no
# `scaffold` run could ever clear. Same remedy, same field: key the expectation on
# tier (`kind`), not on membership.
#
# WHICH artifacts are library-only is the CANON's call, not this test's. Verified
# against mgf-standard `docs/standards/` at 0.1.78 before the set was written:
#
#   - PUBLIC_API.json (+ generator) — DOC-01 v2.11 §2.0.1 states it outright:
#     "`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 — absent from the DOC-01 universal baseline entirely (neither the
#     §2.0.1 matrix nor FEDERATION.md §3 lists it), and SP-01/SP-02 name their
#     subject: "**The library** MUST publish an explicit IN-scope list";
#     docs/standards/SCOPE.md §1: "Other **libraries** ... ship their own SCOPE.md."
#     So it is library-tier — the fix is three files, not two.
#   - DEPS.md — universal (DOC-01/DOC-16: "A leaf app fills §1 and leaves §2 empty;
#     the file MUST still exist"), so it stays owed by the app tier. That row is the
#     one that keeps this a CORRECTION and not an exemption, and it is asserted below.

_PRESENT_TIER_POLICY = """\
schema = 1
present = [
    "PUBLIC_API.json",
    "tools/generate_public_api_json.py",
    "SCOPE.md",
    "DEPS.md",
]
woodpecker_required_steps = ["lint", "commit-trailers", "secret-scan"]
[exempt]
woodpecker = ["mgf-app", "mgf-lib", "mgf-zig-lib"]
"""


def _present_tier_root(tmp_path: Path, *, seed_library_artifacts: bool) -> Path:
    """Three members that differ ONLY in tier/language, each carrying DEPS.md and
    nothing else unless ``seed_library_artifacts``.

    ``mgf-app`` is `kind = "app"`; ``mgf-lib`` declares NO kind (the default control,
    and the position of every member of the installed registry); ``mgf-zig-lib`` is a
    non-Python LIBRARY, which separates the two gates — it must still owe SCOPE.md
    while being excused the Python-packaging pair.
    """
    reg = {
        "release_waves": [["mgf-app", "mgf-lib", "mgf-zig-lib"]],
        "libraries": [
            {"repo": "mgf-app", "lang": "python", "version": "0.1.0", "kind": "app"},
            {"repo": "mgf-lib", "lang": "python", "version": "1.0.0"},
            {"repo": "mgf-zig-lib", "lang": "zig", "version": "1.0.0"},
        ],
    }
    std = tmp_path / "mgf-standard"
    (std / "templates").mkdir(parents=True)
    (std / "MGF_LIBRARIES.json").write_text(json.dumps(reg), encoding="utf-8")
    (std / "templates" / "policy.toml").write_text(_PRESENT_TIER_POLICY, encoding="utf-8")
    pipeline = f"{_CLONE}steps:\n  - name: commit-trailers\n{_GATE_COMMANDS}  - name: secret-scan\n"
    for repo in ("mgf-app", "mgf-lib", "mgf-zig-lib"):
        d = tmp_path / repo
        d.mkdir()
        (d / "DEPS.md").write_text("deps\n", encoding="utf-8")  # universal — always owed
        (d / ".woodpecker.yml").write_text(pipeline, encoding="utf-8")
        if repo != "mgf-zig-lib":
            (d / "pyproject.toml").write_text(
                f'[project]\nname = "{repo}"\nversion = "0.1.0"\n', encoding="utf-8"
            )
        if seed_library_artifacts:
            (d / "PUBLIC_API.json").write_text("{}\n", encoding="utf-8")
            (d / "SCOPE.md").write_text("scope\n", encoding="utf-8")
            (d / "tools").mkdir()
            (d / "tools" / "generate_public_api_json.py").write_text("#\n", encoding="utf-8")
    return tmp_path


def _missing(tmp_path: Path, repo: str) -> list[str]:
    root = tmp_path
    m = next(x for x in members(root) if x.repo == repo)
    return sorted(i.detail for i in check(m, load_policy(root), root).items if i.kind == "missing")


def test_app_member_is_not_owed_the_library_only_present_artifacts(tmp_path: Path) -> None:
    """The paper's exact scenario, and TelePlasma's three real findings: an app with
    none of the library artifacts is CLEAN on presence. Before `kind` reached the
    present list this same tree reported three `missing` items it could never clear."""
    root = _present_tier_root(tmp_path, seed_library_artifacts=False)
    assert _missing(root, "mgf-app") == []


def test_app_member_IS_still_owed_the_UNIVERSAL_artifacts(tmp_path: Path) -> None:
    """The negative control. DEPS.md is universal-MUST for every shape (DOC-16), so
    deleting it must flag the app exactly as it would flag a library — a tier that
    could not be flagged at all would be an exemption, not a tier."""
    root = _present_tier_root(tmp_path, seed_library_artifacts=False)
    (root / "mgf-app" / "DEPS.md").unlink()
    assert _missing(root, "mgf-app") == ["DEPS.md"]


def test_a_python_library_without_kind_is_STILL_owed_all_three(tmp_path: Path) -> None:
    """Regression guard, and the reason the default is load-bearing: `mgf-lib`
    declares no `kind`, so it defaults to library and owes the whole list. Every
    member of the installed registry except the app rows is in this position."""
    root = _present_tier_root(tmp_path, seed_library_artifacts=False)
    assert _missing(root, "mgf-lib") == [
        "PUBLIC_API.json",
        "SCOPE.md",
        "tools/generate_public_api_json.py",
    ]


def test_the_language_gate_and_the_tier_gate_are_INDEPENDENT(tmp_path: Path) -> None:
    """A Zig LIBRARY is excused the Python-packaging pair (they introspect Python
    modules) and still owes SCOPE.md, which is language-agnostic. Collapsing the two
    gates into one would silently drop that row."""
    root = _present_tier_root(tmp_path, seed_library_artifacts=False)
    assert _missing(root, "mgf-zig-lib") == ["SCOPE.md"]


def test_a_fully_seeded_tree_is_clean_for_every_tier(tmp_path: Path) -> None:
    """Nothing above is achieved by making the check unable to fire."""
    root = _present_tier_root(tmp_path, seed_library_artifacts=True)
    for repo in ("mgf-app", "mgf-lib", "mgf-zig-lib"):
        assert _missing(root, repo) == [], repo


# --------------------------------------------------------------------------------
# ci-unknown-key — an unrecognised [tool.mgf-fed] carve-out.
#
# The class the byte-render check structurally CANNOT catch: a key the renderer
# does not read shapes nothing, so the render equals itself and `ci-generated`
# stays silent while the author believes a knob is on. mgf-uitest-desktop's
# `qt = true` lived that way until CI went red (2026-07-29).
# --------------------------------------------------------------------------------


def test_unknown_tool_mgf_fed_key_is_reported_as_drift(tmp_path: Path) -> None:
    root = _root(tmp_path)
    pyproject = root / "mgf-clean" / "pyproject.toml"
    pyproject.write_text(
        pyproject.read_text(encoding="utf-8") + "\n[tool.mgf-fed]\nqt = true\n", encoding="utf-8"
    )
    m = next(x for x in members(root) if x.repo == "mgf-clean")
    items = check(m, load_policy(root), root).items
    hits = [i for i in items if i.kind == "ci-unknown-key"]
    assert len(hits) == 1
    assert "`qt`" in hits[0].detail
    assert "did you mean `apt_packages`?" in hits[0].detail


def test_a_repo_with_only_known_carve_outs_is_not_flagged(tmp_path: Path) -> None:
    root = _root(tmp_path)
    pyproject = root / "mgf-clean" / "pyproject.toml"
    pyproject.write_text(
        pyproject.read_text(encoding="utf-8")
        + '\n[tool.mgf-fed]\nbrowser = true\ncoverage_floor = 85\napt_packages = ["libgl1"]\n',
        encoding="utf-8",
    )
    m = next(x for x in members(root) if x.repo == "mgf-clean")
    assert check(m, load_policy(root), root).ok


def test_the_baseline_clean_repo_stays_clean(tmp_path: Path) -> None:
    # Guards the false-positive direction: a repo with NO [tool.mgf-fed] table at
    # all must not acquire a finding (this check runs on every python member).
    root = _root(tmp_path)
    m = next(x for x in members(root) if x.repo == "mgf-clean")
    assert check(m, load_policy(root), root).ok


def test_unknown_key_is_flagged_even_when_the_pipeline_is_missing(tmp_path: Path) -> None:
    # The defect is in the DECLARATION, not the pipeline — a repo with no
    # .woodpecker.yml still owns its bad carve-out.
    root = _root(tmp_path)
    (root / "mgf-clean" / ".woodpecker.yml").unlink()
    pyproject = root / "mgf-clean" / "pyproject.toml"
    pyproject.write_text(
        pyproject.read_text(encoding="utf-8") + "\n[tool.mgf-fed]\nqt = true\n", encoding="utf-8"
    )
    m = next(x for x in members(root) if x.repo == "mgf-clean")
    assert any(i.kind == "ci-unknown-key" for i in check(m, load_policy(root), root).items)


# ---------------------------------------------------------------------------
# Board item 71 — canon said one thing and this tool did another, silently.
#
# `policy.present` listed all seven files as owed by everyone. `_drift` carried two
# hardcoded frozensets exempting some of them by tier and by language — and the
# library-tier set's own comment read "The classification is the canon's, not this
# file's", which was the intent and not the fact: canon's machine-readable form
# carried no carve-out at all.
#
# Measured before the fix: two members identical but for `kind`, both missing every
# present file, produced SEVEN findings as a library and FOUR as an app — three files
# vanishing with no finding and no note.
#
# The carve-outs are now DECLARED in `templates/policy.toml` and this tool reads them.
# The built-in sets remain only as a compatibility fallback for a bench whose
# mgf-standard predates the declaration — and these tests pin the fallback against
# what canon actually says, so it cannot quietly disagree again.
# ---------------------------------------------------------------------------

_CARVEOUTS = (
    ("library_only_present", _LIBRARY_ONLY_PRESENT),
    ("python_only_present", _PYTHON_ONLY_PRESENT),
)


def _canon_policy() -> dict[str, object]:
    """The real `templates/policy.toml` from the mgf-standard sibling, or skip."""
    std = federation_root() / "mgf-standard" / "templates" / "policy.toml"
    if not std.is_file():
        pytest.skip(f"no mgf-standard sibling at {std} — cannot check against canon")
    return tomllib.loads(std.read_text(encoding="utf-8"))


@pytest.mark.parametrize(("key", "fallback"), _CARVEOUTS)
def test_canon_declares_the_carveout(key: str, fallback: frozenset[str]) -> None:
    """Canon must SAY who owes what. A tool-only carve-out is an undeclared rule."""
    policy = _canon_policy()
    assert key in policy, (
        f"templates/policy.toml declares no `{key}`, so canon still says every member "
        "owes every `present` file while this tool exempts some. That is board item 71 "
        "reopening — declare it in canon, do not restate it here."
    )


@pytest.mark.parametrize(("key", "fallback"), _CARVEOUTS)
def test_fallback_matches_canon(key: str, fallback: frozenset[str]) -> None:
    """The compatibility fallback and canon must agree, or the fallback is a lie."""
    policy = _canon_policy()
    declared = frozenset(str(x) for x in policy.get(key, ()))
    assert declared == fallback, (
        f"`{key}`: canon declares {sorted(declared)} but this module's fallback is "
        f"{sorted(fallback)}. A bench with an older mgf-standard would be graded by the "
        "fallback, so the two must say the same thing."
    )


@pytest.mark.parametrize(("key", "_fallback"), _CARVEOUTS)
def test_carveouts_are_subsets_of_present(key: str, _fallback: frozenset[str]) -> None:
    """Exempting a file that is not required of anyone is dead policy."""
    policy = _canon_policy()
    present = {str(x) for x in policy.get("present", ())}
    declared = {str(x) for x in policy.get(key, ())}
    assert declared <= present, (
        f"`{key}` names {sorted(declared - present)}, which is not in `present` at all."
    )


def test_carveout_prefers_canon_over_the_builtin() -> None:
    """A declared list must actually be used — otherwise the fallback is the rule."""
    assert _carveout(
        {"library_only_present": ["ONLY_THIS.md"]}, "library_only_present", _LIBRARY_ONLY_PRESENT
    ) == frozenset({"ONLY_THIS.md"})
    assert _carveout({}, "library_only_present", _LIBRARY_ONLY_PRESENT) == _LIBRARY_ONLY_PRESENT
