"""Tests for ``mgf-common catch-up`` subcommand."""

from __future__ import annotations

import argparse
import io
import json
from contextlib import redirect_stdout
from typing import TYPE_CHECKING, ClassVar

import pytest

from mgf.fed.cli._catch_up import (
    _apply_bootstrap_claude_md_markers,
    _apply_bump_pin,
    _apply_create_agents_md,
    _apply_create_memory_md,
    _apply_fix_gitignore,
    _check_agents_md,
    _check_claude_md,
    _check_claude_settings,
    _check_closed_box_leaks,
    _check_commit_scaffold,
    _check_federation_contract,
    _check_federation_markers,
    _check_four_gates,
    _check_gitignore,
    _check_memory_md,
    _check_pyproject_pin,
    _check_test_supervisor_adoption,
    _check_v01_patterns,
    _check_woodpecker_ci,
    _collect_findings,
    _detect_kind,
    _extract_auto_block,
    _has_pip_tools_layout,
    _is_non_python_repo,
    _is_private_module,
    _pin_is_stale,
    _splice_auto_block,
    _write_commit_scaffold,
    add_catch_up_args,
    run_catch_up,
)
from mgf.fed.cli._init_project import (
    AGENTS_MD_CONTENT,
    CLAUDE_MD_BEGIN_MARKER,
    CLAUDE_MD_END_MARKER,
    _render_claude_md,
)

if TYPE_CHECKING:
    from pathlib import Path


def _make_args(
    path: Path,
    *,
    apply: bool = False,
    name: str | None = None,
    kind: str | None = None,
) -> argparse.Namespace:
    parser = argparse.ArgumentParser()
    add_catch_up_args(parser)
    argv = ["--path", str(path)]
    if apply:
        argv.append("--apply")
    if name is not None:
        argv.extend(["--name", name])
    if kind is not None:
        argv.extend(["--kind", kind])
    return parser.parse_args(argv)


def _seed_minimal_project(target: Path, *, with_pyproject: bool = True) -> None:
    """Create a minimal project layout."""
    if with_pyproject:
        (target / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            'dependencies = ["mgf-common>=0.5.0,<0.6"]\n',
            encoding="utf-8",
        )


# ---------------------------------------------------------------------------
# Auto-block extraction + splicing.
# ---------------------------------------------------------------------------


class TestAutoBlockExtraction:
    def test_extract_returns_content_between_markers(self) -> None:
        rendered = _render_claude_md("demo", "cli")
        block = _extract_auto_block(rendered)
        # Block must be non-empty and contain the standing-instructions
        # heading + the rule index.
        assert "Standing instructions" in block
        assert "DP-01" in block
        # Markers themselves should NOT be inside the extracted block.
        assert CLAUDE_MD_BEGIN_MARKER not in block
        assert CLAUDE_MD_END_MARKER not in block

    def test_extract_empty_when_markers_missing(self) -> None:
        assert _extract_auto_block("# No markers here\nJust content") == ""

    def test_splice_replaces_block_in_place(self) -> None:
        rendered = _render_claude_md("demo", "cli")
        new_block = "\nNEW BLOCK CONTENT\n"
        spliced = _splice_auto_block(rendered, new_block)
        # Markers preserved.
        assert CLAUDE_MD_BEGIN_MARKER in spliced
        assert CLAUDE_MD_END_MARKER in spliced
        # New content appears between them.
        assert "NEW BLOCK CONTENT" in spliced
        # Old content (rules, etc.) gone.
        assert "DP-01" not in spliced

    def test_splice_preserves_text_around_markers(self) -> None:
        before = "# Project Title\n\nProject-specific stuff above.\n\n"
        original = (
            before
            + CLAUDE_MD_BEGIN_MARKER
            + "\n"
            + "OLD CONTENT\n"
            + CLAUDE_MD_END_MARKER
            + " -->\n"
            + "## Project-specific lookup\n\nTeam content.\n"
        )
        spliced = _splice_auto_block(original, "\nNEW\n")
        assert "Project-specific stuff above." in spliced
        assert "Team content." in spliced
        assert "OLD CONTENT" not in spliced
        assert "\nNEW\n" in spliced


# ---------------------------------------------------------------------------
# Individual audit checks.
# ---------------------------------------------------------------------------


class TestPinCheck:
    def test_no_pyproject_warns(self, tmp_path: Path) -> None:
        finding = _check_pyproject_pin(tmp_path)
        assert finding.severity == "warn"
        assert "no pyproject.toml" in finding.detail.lower()

    def test_current_pin_returns_ok(self, tmp_path: Path) -> None:
        # v0.12.0+: the seed uses ``>=0.5.0,<0.6`` which IS stale
        # against today's running mgf-common (>=0.11) — so we
        # need a pin that's actually current. Use the running
        # version's window.
        from mgf.common import __version__ as current

        major, minor = current.split(".")[:2]
        upper_minor = int(minor) + 1
        (tmp_path / "pyproject.toml").write_text(
            f'[project]\nname = "demo"\ndependencies = '
            f'["mgf-common>={current},<{major}.{upper_minor}"]\n',
            encoding="utf-8",
        )
        finding = _check_pyproject_pin(tmp_path)
        assert finding.severity == "ok"
        assert finding.fix is None

    def test_stale_pin_warns_with_fix(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        # The pin <0.2 cannot accommodate the running 0.11.x. Stale.
        # Force non-editable so the auto-rewrite path applies — our
        # own dev install IS editable, but the existing test pins
        # the PyPI-install behaviour, so we simulate that. The
        # editable-refusal path is covered by the dedicated
        # TestEditableInstallPinGuard class below.
        from mgf.fed.cli import _catch_up as cu_module

        monkeypatch.setattr(cu_module, "_is_editable_install", lambda: False)
        _seed_minimal_project(tmp_path)
        finding = _check_pyproject_pin(tmp_path)
        assert finding.severity == "warn"
        assert "STALE" in finding.detail
        assert finding.fix is not None  # has a mechanical fix

    def test_no_mgf_dep_warns(self, tmp_path: Path) -> None:
        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "x"\ndependencies = []\n',
            encoding="utf-8",
        )
        finding = _check_pyproject_pin(tmp_path)
        assert finding.severity == "warn"


class TestPinStaleness:
    """``_pin_is_stale`` core staleness detection."""

    def test_pin_with_no_upper_bound_not_flagged(self) -> None:
        # A pin like `>=0.5.0` (no upper bound) should NOT be flagged
        # as stale — we don't know the user's intent.
        assert _pin_is_stale(">=0.5.0", "0.11.3") is False

    def test_pin_excluding_current_is_stale(self) -> None:
        assert _pin_is_stale(">=0.1.0,<0.2", "0.11.3") is True
        assert _pin_is_stale(">=0.6.0,<0.8", "0.11.3") is True
        assert _pin_is_stale(">=0.10.0,<0.11", "0.11.0") is True

    def test_pin_including_current_not_stale(self) -> None:
        assert _pin_is_stale(">=0.11.0,<0.13", "0.11.3") is False
        assert _pin_is_stale(">=0.5.0,<1.0", "0.11.3") is False

    def test_pin_at_exact_upper_bound_is_stale(self) -> None:
        # `<0.11` excludes 0.11.0 (strict-less-than).
        assert _pin_is_stale(">=0.10.0,<0.11", "0.11.0") is True

    def test_unparseable_constraint_not_flagged(self) -> None:
        # Non-numeric, weird shape, etc. — better to under-flag.
        assert _pin_is_stale("== latest", "0.11.3") is False
        assert _pin_is_stale("garbage", "0.11.3") is False


class TestApplyBumpPin:
    """``_apply_bump_pin`` rewrites the pin line conservatively."""

    def test_simple_pin_rewrite(self, tmp_path: Path) -> None:
        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "x"\ndependencies = [\n    "mgf-common>=0.1.0,<0.2",\n]\n',
            encoding="utf-8",
        )
        result = _apply_bump_pin(
            tmp_path,
            extras_part="mgf-common",
            old_constraint=">=0.1.0,<0.2",
            new_constraint=">=0.11.0,<0.13",
        )
        content = (tmp_path / "pyproject.toml").read_text()
        assert "mgf-common>=0.11.0,<0.13" in content
        assert "mgf-common>=0.1.0,<0.2" not in content
        assert "bumped pin" in result

    def test_extras_in_pin_preserved(self, tmp_path: Path) -> None:
        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "x"\ndependencies = ["mgf-common[fastapi]>=0.6.0,<0.8"]\n',
            encoding="utf-8",
        )
        _apply_bump_pin(
            tmp_path,
            extras_part="mgf-common[fastapi]",
            old_constraint=">=0.6.0,<0.8",
            new_constraint=">=0.11.0,<0.13",
        )
        content = (tmp_path / "pyproject.toml").read_text()
        assert "mgf-common[fastapi]>=0.11.0,<0.13" in content
        assert "[fastapi]" in content  # extras preserved

    def test_backup_file_written(self, tmp_path: Path) -> None:
        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "x"\ndependencies = ["mgf-common>=0.1,<0.2"]\n',
            encoding="utf-8",
        )
        _apply_bump_pin(
            tmp_path,
            extras_part="mgf-common",
            old_constraint=">=0.1,<0.2",
            new_constraint=">=0.11.0,<0.13",
        )
        backups = list(tmp_path.glob("pyproject.toml.bak.*"))
        assert len(backups) == 1
        # Backup contains the original.
        assert ">=0.1,<0.2" in backups[0].read_text()
        # Live file has the new pin.
        assert ">=0.11.0,<0.13" in (tmp_path / "pyproject.toml").read_text()

    def test_other_lines_untouched(self, tmp_path: Path) -> None:
        # Conservative: only the mgf-common line changes; everything
        # else stays byte-for-byte.
        (tmp_path / "pyproject.toml").write_text(
            "[project]\n"
            'name = "x"\n'
            "dependencies = [\n"
            '    "click>=8.1",\n'
            '    "pyyaml>=6.0",\n'
            '    "mgf-common>=0.1.0,<0.2",\n'
            '    "rich>=13.7",\n'
            "]\n"
            "[tool.ruff]\n"
            "line-length = 100\n",
            encoding="utf-8",
        )
        _apply_bump_pin(
            tmp_path,
            extras_part="mgf-common",
            old_constraint=">=0.1.0,<0.2",
            new_constraint=">=0.11.0,<0.13",
        )
        content = (tmp_path / "pyproject.toml").read_text()
        # Other deps untouched.
        assert '"click>=8.1"' in content
        assert '"pyyaml>=6.0"' in content
        assert '"rich>=13.7"' in content
        # Ruff section untouched.
        assert "[tool.ruff]" in content
        assert "line-length = 100" in content
        # mgf-common line bumped.
        assert "mgf-common>=0.11.0,<0.13" in content

    def test_pin_line_with_comments_around_preserved(self, tmp_path: Path) -> None:
        # vm-vmanager-app-shape: the dep line is surrounded by a long comment.
        # Both the comment and adjacent context must survive.
        (tmp_path / "pyproject.toml").write_text(
            "[project]\n"
            'name = "vmanager"\n'
            "dependencies = [\n"
            "    # Comment above explaining the dep.\n"
            "    # Multiple lines of context.\n"
            '    "mgf-common>=0.1.0,<0.2",\n'
            "    # Comment below.\n"
            "]\n",
            encoding="utf-8",
        )
        _apply_bump_pin(
            tmp_path,
            extras_part="mgf-common",
            old_constraint=">=0.1.0,<0.2",
            new_constraint=">=0.11.0,<0.13",
        )
        content = (tmp_path / "pyproject.toml").read_text()
        assert "Comment above" in content
        assert "Multiple lines of context." in content
        assert "Comment below" in content
        assert "mgf-common>=0.11.0,<0.13" in content


class TestEndToEndPinBump:
    """Verify catch-up's apply path bumps the pin with a real audit run."""

    def test_apply_bumps_stale_pin(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        # Force non-editable so the auto-rewrite path applies — our
        # dev install is editable but PAPER-20 refuses rewrites for
        # editable installs to avoid producing an unsatisfiable pin.
        from mgf.fed.cli import _catch_up as cu_module

        monkeypatch.setattr(cu_module, "_is_editable_install", lambda: False)
        _seed_minimal_project(tmp_path)  # pin = >=0.5.0,<0.6 (stale vs 0.11.x)
        buf = io.StringIO()
        with redirect_stdout(buf):
            run_catch_up(_make_args(tmp_path, apply=True, kind="cli"))
        # Pin in pyproject.toml is now bumped to a current 2-minor window.
        content = (tmp_path / "pyproject.toml").read_text()
        assert ">=0.5.0,<0.6" not in content
        # New constraint contains current major + minor.
        from mgf.common import __version__ as current

        assert f">={current}" in content


class TestFourGatesCheck:
    def test_no_pyproject_warns(self, tmp_path: Path) -> None:
        finding = _check_four_gates(tmp_path)
        assert finding.severity == "warn"

    def test_partial_config_lists_missing(self, tmp_path: Path) -> None:
        (tmp_path / "pyproject.toml").write_text(
            "[project]\nname = 'x'\n[tool.ruff]\nline-length = 100\n",
            encoding="utf-8",
        )
        finding = _check_four_gates(tmp_path)
        assert finding.severity == "warn"
        # ruff present, others not.
        assert "[tool.mypy]" in finding.detail
        assert "filterwarnings" in finding.detail

    def test_full_config_returns_ok(self, tmp_path: Path) -> None:
        (tmp_path / "pyproject.toml").write_text(
            "[project]\nname = 'x'\n"
            "[tool.ruff]\n\n"
            "[tool.mypy]\nstrict = true\n\n"
            '[tool.pytest.ini_options]\nfilterwarnings = ["error"]\n\n'
            "[tool.coverage.report]\nfail_under = 80\n",
            encoding="utf-8",
        )
        finding = _check_four_gates(tmp_path)
        assert finding.severity == "ok"

    def test_multiline_filterwarnings_array_returns_ok(self, tmp_path: Path) -> None:
        # v0.11.2 fix: parse with tomllib so the multi-line array form
        # (which apiprobe + many real projects use) is recognized.
        # The pre-fix substring-match required the exact single-line
        # `filterwarnings = ["error"]` form and gave a false positive
        # warning on multi-line.
        (tmp_path / "pyproject.toml").write_text(
            "[project]\nname = 'x'\n"
            "[tool.ruff]\n\n"
            "[tool.mypy]\nstrict = true\n\n"
            "[tool.pytest.ini_options]\n"
            "filterwarnings = [\n"
            '    "error",\n'
            '    "ignore::DeprecationWarning:third_party.*",\n'
            "]\n\n"
            "[tool.coverage.report]\nfail_under = 80\n",
            encoding="utf-8",
        )
        finding = _check_four_gates(tmp_path)
        assert finding.severity == "ok", (
            f"multi-line filterwarnings array should pass, got {finding.severity}: {finding.detail}"
        )

    def test_filterwarnings_without_error_string_warns(self, tmp_path: Path) -> None:
        # If filterwarnings is configured but doesn't contain "error",
        # the gate should fail (TS-04 requires error-level).
        (tmp_path / "pyproject.toml").write_text(
            "[project]\nname = 'x'\n"
            "[tool.ruff]\n\n"
            "[tool.mypy]\nstrict = true\n\n"
            '[tool.pytest.ini_options]\nfilterwarnings = ["default"]\n\n'
            "[tool.coverage.report]\nfail_under = 80\n",
            encoding="utf-8",
        )
        finding = _check_four_gates(tmp_path)
        assert finding.severity == "warn"
        assert "filterwarnings" in finding.detail

    def test_corrupt_pyproject_returns_fail(self, tmp_path: Path) -> None:
        (tmp_path / "pyproject.toml").write_text(
            "[project\nthis is not valid toml\n",
            encoding="utf-8",
        )
        finding = _check_four_gates(tmp_path)
        assert finding.severity == "fail"
        assert "could not parse" in finding.detail.lower()


class TestClaudeMdCheck:
    def test_missing_returns_fail_with_fix(self, tmp_path: Path) -> None:
        finding = _check_claude_md(tmp_path, "demo", "cli")
        assert finding.severity == "fail"
        assert finding.fix is not None  # has mechanical fix

    def test_existing_with_markers_current_returns_ok(self, tmp_path: Path) -> None:
        (tmp_path / "docs").mkdir()
        (tmp_path / "docs" / "CLAUDE.md").write_text(
            _render_claude_md("demo", "cli"),
            encoding="utf-8",
        )
        finding = _check_claude_md(tmp_path, "demo", "cli")
        assert finding.severity == "ok"

    def test_existing_without_markers_warns(self, tmp_path: Path) -> None:
        (tmp_path / "docs").mkdir()
        (tmp_path / "docs" / "CLAUDE.md").write_text(
            "# Demo Project\n\nNo markers anywhere here.\n",
            encoding="utf-8",
        )
        finding = _check_claude_md(tmp_path, "demo", "cli")
        assert finding.severity == "warn"
        assert "BEGIN/END" in finding.detail
        # PAPER-16 (v0.17.2+): catch-up now offers a fix-callable
        # that bootstraps markers + auto-block at the top of the
        # existing file (with backup), instead of pointing at the
        # destructive --force option.
        assert finding.fix is not None

    def test_stale_auto_block_warns_with_fix(self, tmp_path: Path) -> None:
        (tmp_path / "docs").mkdir()
        # Use library kind in the file but check for cli kind — kind
        # affects the quick-start block, so the auto-block content
        # differs.
        (tmp_path / "docs" / "CLAUDE.md").write_text(
            _render_claude_md("demo", "library"),
            encoding="utf-8",
        )
        finding = _check_claude_md(tmp_path, "demo", "cli")
        assert finding.severity == "warn"
        assert "stale" in finding.detail
        assert finding.fix is not None  # refreshable

    # PAPER-54: catch-up now accepts BOTH docs/claude/CLAUDE.md (DOC-07
    # canonical) and docs/CLAUDE.md (legacy). When both exist, the
    # canonical wins and the duplicate is flagged in the detail.
    def test_canonical_path_audited_when_only_canonical_exists(
        self,
        tmp_path: Path,
    ) -> None:
        (tmp_path / "docs" / "claude").mkdir(parents=True)
        (tmp_path / "docs" / "claude" / "CLAUDE.md").write_text(
            _render_claude_md("demo", "cli"),
            encoding="utf-8",
        )
        finding = _check_claude_md(tmp_path, "demo", "cli")
        assert finding.severity == "ok"
        assert finding.title == "docs/claude/CLAUDE.md"
        assert "legacy" not in finding.detail.lower()

    def test_legacy_path_audited_when_only_legacy_exists(
        self,
        tmp_path: Path,
    ) -> None:
        # PAPER-54 fallback: a pre-DOC-07 consumer keeps working.
        (tmp_path / "docs").mkdir()
        (tmp_path / "docs" / "CLAUDE.md").write_text(
            _render_claude_md("demo", "cli"),
            encoding="utf-8",
        )
        finding = _check_claude_md(tmp_path, "demo", "cli")
        assert finding.severity == "ok"
        assert finding.title == "docs/CLAUDE.md"

    def test_canonical_wins_when_both_paths_exist_with_duplicate_note(
        self,
        tmp_path: Path,
    ) -> None:
        # PAPER-54: when both exist, audit canonical; surface the
        # duplicate in detail so the consumer notices.
        (tmp_path / "docs" / "claude").mkdir(parents=True)
        (tmp_path / "docs" / "claude" / "CLAUDE.md").write_text(
            _render_claude_md("demo", "cli"),
            encoding="utf-8",
        )
        (tmp_path / "docs" / "CLAUDE.md").write_text(
            "# Legacy duplicate\nleftover content\n",
            encoding="utf-8",
        )
        finding = _check_claude_md(tmp_path, "demo", "cli")
        assert finding.severity == "ok"
        assert finding.title == "docs/claude/CLAUDE.md"
        assert "legacy" in finding.detail.lower()
        assert "docs/CLAUDE.md" in finding.detail

    def test_missing_finding_points_at_canonical_path(
        self,
        tmp_path: Path,
    ) -> None:
        # PAPER-54: the "missing" finding nudges new consumers toward
        # the DOC-07 canonical path going forward.
        finding = _check_claude_md(tmp_path, "demo", "cli")
        assert finding.severity == "fail"
        assert finding.title == "docs/claude/CLAUDE.md"
        assert "DOC-07" in finding.detail


class TestClaudeSettingsCheck:
    def test_missing_warns_with_fix(self, tmp_path: Path) -> None:
        finding = _check_claude_settings(tmp_path, "cli")
        assert finding.severity == "warn"
        assert finding.fix is not None

    def test_existing_returns_ok(self, tmp_path: Path) -> None:
        (tmp_path / ".claude").mkdir()
        (tmp_path / ".claude" / "settings.json").write_text("{}", encoding="utf-8")
        finding = _check_claude_settings(tmp_path, "cli")
        assert finding.severity == "ok"


class TestAgentsMdCheck:
    """AGENTS.md — the federation-wide cross-tool agent-discovery pointer."""

    def test_missing_warns_with_fix(self, tmp_path: Path) -> None:
        finding = _check_agents_md(tmp_path)
        assert finding.severity == "warn"
        assert finding.fix is not None

    def test_apply_creates_canonical_pointer(self, tmp_path: Path) -> None:
        _apply_create_agents_md(tmp_path)
        agents = tmp_path / "AGENTS.md"
        assert agents.is_file()
        assert agents.read_text(encoding="utf-8").strip() == AGENTS_MD_CONTENT.strip()
        # After apply, the check is clean.
        assert _check_agents_md(tmp_path).severity == "ok"

    def test_canonical_content_returns_ok(self, tmp_path: Path) -> None:
        (tmp_path / "AGENTS.md").write_text(AGENTS_MD_CONTENT, encoding="utf-8")
        assert _check_agents_md(tmp_path).severity == "ok"

    def test_drifted_content_warns_with_fix(self, tmp_path: Path) -> None:
        # AGENTS.md is fully managed — hand-edited content is drift,
        # and catch-up restores the canonical pointer.
        (tmp_path / "AGENTS.md").write_text(
            "# AGENTS.md\n\nsomeone hand-edited this\n",
            encoding="utf-8",
        )
        finding = _check_agents_md(tmp_path)
        assert finding.severity == "warn"
        assert "drifted" in finding.detail
        assert finding.fix is not None
        finding.fix(tmp_path)
        assert _check_agents_md(tmp_path).severity == "ok"


class TestMemoryMdCheck:
    """docs/memory/MEMORY.md — committed in-repo agent memory (AG-17)."""

    def test_missing_warns_with_fix(self, tmp_path: Path) -> None:
        finding = _check_memory_md(tmp_path)
        assert finding.severity == "warn"
        assert finding.fix is not None
        assert "AG-17" in finding.detail

    def test_apply_seeds_memory_index(self, tmp_path: Path) -> None:
        _apply_create_memory_md(tmp_path)
        memory = tmp_path / "docs" / "memory" / "MEMORY.md"
        assert memory.is_file()
        text = memory.read_text(encoding="utf-8")
        # Seeded index: carries the AG-17 rationale + a display name + the
        # "no memories yet" placeholder. A seeded file is conformant.
        assert "AG-17" in text
        assert tmp_path.name in text
        assert "No memories yet" in text
        # After apply, the check is clean.
        assert _check_memory_md(tmp_path).severity == "ok"

    def test_present_returns_ok(self, tmp_path: Path) -> None:
        mem = tmp_path / "docs" / "memory"
        mem.mkdir(parents=True)
        (mem / "MEMORY.md").write_text("# Memory index\n", encoding="utf-8")
        assert _check_memory_md(tmp_path).severity == "ok"


class TestGitignoreCheck:
    """.gitignore — must ignore the local secrets dir my_stuff/ (AG-18)."""

    def test_missing_warns_with_fix(self, tmp_path: Path) -> None:
        finding = _check_gitignore(tmp_path)
        assert finding.severity == "warn"
        assert finding.fix is not None
        assert "AG-18" in finding.detail

    def test_apply_creates_gitignore_with_my_stuff(self, tmp_path: Path) -> None:
        _apply_fix_gitignore(tmp_path)
        gitignore = tmp_path / ".gitignore"
        assert gitignore.is_file()
        lines = [ln.strip() for ln in gitignore.read_text(encoding="utf-8").splitlines()]
        assert "my_stuff/" in lines
        # After apply, the check is clean.
        assert _check_gitignore(tmp_path).severity == "ok"

    def test_apply_preserves_existing_content(self, tmp_path: Path) -> None:
        # An existing .gitignore without my_stuff/ gets the block appended,
        # and the consumer's own ignores are preserved.
        (tmp_path / ".gitignore").write_text("*.pyc\n__pycache__/\n", encoding="utf-8")
        result = _apply_fix_gitignore(tmp_path)
        assert "updated" in result
        text = (tmp_path / ".gitignore").read_text(encoding="utf-8")
        assert "*.pyc" in text  # original content preserved
        lines = [ln.strip() for ln in text.splitlines()]
        assert "my_stuff/" in lines
        assert _check_gitignore(tmp_path).severity == "ok"

    def test_present_returns_ok(self, tmp_path: Path) -> None:
        (tmp_path / ".gitignore").write_text("foo/\nmy_stuff/\nbar/\n", encoding="utf-8")
        assert _check_gitignore(tmp_path).severity == "ok"


class TestV01PatternsCheck:
    def test_clean_project_returns_ok(self, tmp_path: Path) -> None:
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "demo.py").write_text(
            "from mgf.common import bootstrap\n"
            "with bootstrap(app_name='x', app_version='0.1') as ctx:\n"
            "    pass\n",
            encoding="utf-8",
        )
        finding = _check_v01_patterns(tmp_path)
        assert finding.severity == "ok"

    def test_legacy_calls_warn(self, tmp_path: Path) -> None:
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "legacy.py").write_text(
            "import mgf.common\n"
            "mgf.common.configure(app_name='x', app_version='0.1')\n"
            "from mgf.common.observability import setup_logging\n"
            "setup_logging(level='INFO')\n",
            encoding="utf-8",
        )
        finding = _check_v01_patterns(tmp_path)
        assert finding.severity == "warn"
        assert "configure(" in finding.detail
        assert "setup_logging(" in finding.detail
        # Catch-up does NOT auto-fix structural migrations.
        assert finding.fix is None

    def test_audit_skips_venv_and_caches(self, tmp_path: Path) -> None:
        # PAPER-14: without skip-dirs, the audit walked into .venv
        # where mgf-common's own deprecated shim definitions live
        # (def setup_logging(...) etc.) and counted them as user-
        # source v0.1 call sites. Skip-dirs eliminates the false
        # positives.
        (tmp_path / ".venv").mkdir()
        (tmp_path / ".venv" / "shim.py").write_text(
            "def setup_logging(*a, **kw):\n    pass\n"
            "def setup_otel(*a, **kw):\n    pass\n"
            "def install_excepthook():\n    pass\n",
            encoding="utf-8",
        )
        (tmp_path / "__pycache__").mkdir()
        (tmp_path / "__pycache__" / "x.py").write_text(
            "setup_logging(level='INFO')\n",
            encoding="utf-8",
        )
        # No real consumer source — fall-back walks tmp_path itself.
        finding = _check_v01_patterns(tmp_path)
        assert finding.severity == "ok", (
            f"audit should skip .venv/ and __pycache__/, got: {finding.detail}"
        )

    def test_audit_skips_tests_dir(self, tmp_path: Path) -> None:
        # tests/ is intentionally exempt — same convention as the
        # closed-box-leak audit.
        (tmp_path / "tests").mkdir()
        (tmp_path / "tests" / "test_legacy.py").write_text(
            "from mgf.common.observability import setup_logging\nsetup_logging(level='INFO')\n",
            encoding="utf-8",
        )
        finding = _check_v01_patterns(tmp_path)
        assert finding.severity == "ok"


# ---------------------------------------------------------------------------
# Editable-install pin handling (PAPER-20).
# ---------------------------------------------------------------------------


class TestEditableInstallPinGuard:
    """When mgf-common is installed editable, catch-up MUST NOT
    auto-rewrite a consumer's pin to the editable __version__ —
    that pin would be unsatisfiable until the published roster catches
    up. Closes PAPER-20.
    """

    def test_is_editable_install_predicate(self) -> None:
        # The predicate is testable in isolation. We don't pin its
        # value here (depends on how this test run installed
        # mgf-common — likely True in dev, False in CI from wheel).
        # We just confirm the function returns a bool without raising.
        from mgf.fed.cli._catch_up import _is_editable_install

        result = _is_editable_install()
        assert isinstance(result, bool)

    def test_stale_pin_under_editable_registry_unresolvable_refuses(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        # PAPER-20 guarded: editable+stale MUST NOT auto-rewrite a
        # consumer's pin to the editable __version__ — that pin would
        # be unsatisfiable until the published roster catches up.
        #
        # v0.45 gates the editable rewrite on the federation registry
        # snapshot (offline, no pypi.org). When the registry can't be
        # resolved at all (mgf-standard missing/too old), the
        # original PAPER-20 contract holds: no rewrite, manual
        # remediation surfaced.
        from mgf.fed.cli import _catch_up as cu_module

        monkeypatch.setattr(cu_module, "_is_editable_install", lambda: True)
        monkeypatch.setattr(
            cu_module,
            "_registry_latest_version",
            lambda _pkg: None,
        )

        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            'dependencies = ["mgf-common>=0.3,<0.4"]\n',
            encoding="utf-8",
        )
        finding = _check_pyproject_pin(tmp_path)
        assert finding.severity == "warn"
        assert "STALE" in finding.detail
        assert "editable" in finding.detail.lower()
        assert "NOT auto-rewriting" in finding.detail
        assert "unresolvable" in finding.detail
        assert finding.fix is None, (
            "editable-install + registry-unresolvable path MUST refuse "
            "the auto-rewrite; no fix callable should be attached"
        )

    def test_stale_pin_under_editable_registry_lag_refuses_rewrite(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        # Companion to the test above: when the registry snapshot is
        # reachable but BEHIND the editable version (the case that would
        # produce an unsatisfiable pin if we rewrote), PAPER-20's
        # discipline still holds — no fix callable.
        from mgf.fed.cli import _catch_up as cu_module

        monkeypatch.setattr(cu_module, "_is_editable_install", lambda: True)
        # Force the registry to report a version older than the current.
        monkeypatch.setattr(
            cu_module,
            "_registry_latest_version",
            lambda _pkg: "0.1.0",  # arbitrarily old
        )

        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            'dependencies = ["mgf-common>=0.3,<0.4"]\n',
            encoding="utf-8",
        )
        finding = _check_pyproject_pin(tmp_path)
        assert finding.severity == "warn"
        assert "AHEAD of the registry snapshot" in finding.detail
        assert finding.fix is None, (
            "editable + registry-lags path MUST refuse the auto-rewrite "
            "(would produce an unsatisfiable pin); PAPER-20 contract."
        )

    def test_stale_pin_under_pypi_install_offers_rewrite(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        # The non-editable path retains the auto-rewrite behaviour.
        from mgf.fed.cli import _catch_up as cu_module

        monkeypatch.setattr(cu_module, "_is_editable_install", lambda: False)

        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            'dependencies = ["mgf-common>=0.3,<0.4"]\n',
            encoding="utf-8",
        )
        finding = _check_pyproject_pin(tmp_path)
        assert finding.severity == "warn"
        assert finding.fix is not None, "PyPI-install path MUST keep the auto-rewrite fix-callable"


# ---------------------------------------------------------------------------
# Closed-box leak detection (PAPER-08).
# ---------------------------------------------------------------------------


class TestIsPrivateModule:
    """Pin the `_is_private_module` predicate against every shape the
    audit needs to recognise — leak detection lives or dies on it."""

    @pytest.mark.parametrize(
        "module",
        [
            "mgf.common._identity",
            "mgf.common._lifecycle",
            "mgf.common._warnings",
            "mgf.common.observability._helpers",
            "mgf.common.observability.crash_reporter",  # public module
            "mgf.common.config._loader",  # nested private
            "mgf.common.exceptions._well_known",
        ],
    )
    def test_classifies_correctly(self, module: str) -> None:
        # All modules above with an underscore-prefixed segment are private.
        has_private_segment = any(
            p.startswith("_") and not p.startswith("__") for p in module.split(".")[2:]
        )
        assert _is_private_module(module) is has_private_segment

    def test_public_modules_not_flagged(self) -> None:
        for m in (
            "mgf.common",
            "mgf.common.exceptions",
            "mgf.common.observability",
            "mgf.common.config",
            "mgf.common.versioned",
        ):
            assert _is_private_module(m) is False

    def test_non_mgf_modules_not_flagged(self) -> None:
        for m in ("os", "pydantic._internal", "_pytest.something"):
            assert _is_private_module(m) is False


class TestClosedBoxLeaksCheck:
    def test_clean_project_returns_ok(self, tmp_path: Path) -> None:
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "demo.py").write_text(
            "from mgf.common import bootstrap\n"
            "from mgf.common.observability import default_log_path\n"
            "from mgf.common.exceptions import AppConfigError, AppConfigErrorKind\n",
            encoding="utf-8",
        )
        finding = _check_closed_box_leaks(tmp_path)
        assert finding.severity == "ok"

    def test_private_module_import_warns(self, tmp_path: Path) -> None:
        # Case 1: `from mgf.common._x import Y` — module itself private.
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "leak.py").write_text(
            "from mgf.common._identity import _APP_NAME\n",
            encoding="utf-8",
        )
        finding = _check_closed_box_leaks(tmp_path)
        assert finding.severity == "warn"
        assert "src/leak.py" in finding.detail
        assert "mgf.common._identity" in finding.detail

    def test_private_name_import_warns(self, tmp_path: Path) -> None:
        # Case 2: `from mgf.common.X import _y` — public module, private name.
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "leak.py").write_text(
            "from mgf.common.observability.logging_setup import _default_log_path\n",
            encoding="utf-8",
        )
        finding = _check_closed_box_leaks(tmp_path)
        assert finding.severity == "warn"
        assert "_default_log_path" in finding.detail
        # The known-replacement table fires; suggestion names the public API.
        assert "default_log_path" in finding.detail

    def test_direct_private_module_import_warns(self, tmp_path: Path) -> None:
        # Case 3: `import mgf.common._x` — direct private module import.
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "leak.py").write_text(
            "import mgf.common._lifecycle\n",
            encoding="utf-8",
        )
        finding = _check_closed_box_leaks(tmp_path)
        assert finding.severity == "warn"
        assert "mgf.common._lifecycle" in finding.detail

    def test_nested_private_module_warns(self, tmp_path: Path) -> None:
        # `mgf.common.observability._helpers` — segment after a public
        # module is private. Catches the deeper case `mgf.common._x` misses.
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "leak.py").write_text(
            "from mgf.common.observability._helpers import some_helper\n",
            encoding="utf-8",
        )
        finding = _check_closed_box_leaks(tmp_path)
        assert finding.severity == "warn"
        assert "_helpers" in finding.detail

    def test_tests_directory_not_walked(self, tmp_path: Path) -> None:
        # Per spec: tests/ is exempt — `_internal_test_helpers/` style
        # imports inside the consumer's own test tree are legitimate.
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "tests").mkdir()
        (tmp_path / "src" / "tests" / "test_something.py").write_text(
            "from mgf.common._identity import _APP_NAME\n",
            encoding="utf-8",
        )
        finding = _check_closed_box_leaks(tmp_path)
        assert finding.severity == "ok"

    def test_venv_and_caches_not_walked(self, tmp_path: Path) -> None:
        # .venv / __pycache__ / .pytest_cache shouldn't surface fake
        # leaks from vendored / cached source.
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / ".venv").mkdir()
        (tmp_path / "src" / ".venv" / "vendored.py").write_text(
            "from mgf.common._lifecycle import _Stuff\n",
            encoding="utf-8",
        )
        (tmp_path / "src" / "__pycache__").mkdir()
        (tmp_path / "src" / "__pycache__" / "x.py").write_text(
            "from mgf.common._lifecycle import _Stuff\n",
            encoding="utf-8",
        )
        finding = _check_closed_box_leaks(tmp_path)
        assert finding.severity == "ok"

    def test_falls_back_to_root_when_no_src(self, tmp_path: Path) -> None:
        # No src/ → walk project root instead (matches _check_v01_patterns).
        (tmp_path / "leak.py").write_text(
            "from mgf.common._identity import _APP_NAME\n",
            encoding="utf-8",
        )
        finding = _check_closed_box_leaks(tmp_path)
        assert finding.severity == "warn"

    def test_syntax_error_in_one_file_does_not_abort_audit(self, tmp_path: Path) -> None:
        # A malformed .py file shouldn't crash the audit — skip + continue.
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "broken.py").write_text(
            "this is not valid python (",
            encoding="utf-8",
        )
        (tmp_path / "src" / "leak.py").write_text(
            "from mgf.common._identity import _APP_NAME\n",
            encoding="utf-8",
        )
        finding = _check_closed_box_leaks(tmp_path)
        assert finding.severity == "warn"
        assert "leak.py" in finding.detail

    def test_known_replacement_named_in_warning(self, tmp_path: Path) -> None:
        # The PAPER-07 mapping table: `_default_log_path` should
        # surface its public replacement by name.
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "old.py").write_text(
            "from mgf.common.observability.logging_setup import _default_log_path\n"
            "from mgf.common.observability.crash_reporter import _crash_dir\n",
            encoding="utf-8",
        )
        finding = _check_closed_box_leaks(tmp_path)
        assert finding.severity == "warn"
        assert "default_log_path" in finding.detail
        assert "default_crash_dir" in finding.detail

    def test_no_fix_callable(self, tmp_path: Path) -> None:
        # Like _check_v01_patterns: catch-up doesn't auto-fix structural
        # imports. The finding lists violations; the consumer migrates.
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "leak.py").write_text(
            "from mgf.common._identity import _APP_NAME\n",
            encoding="utf-8",
        )
        finding = _check_closed_box_leaks(tmp_path)
        assert finding.fix is None


# ---------------------------------------------------------------------------
# End-to-end: run + apply.
# ---------------------------------------------------------------------------


class TestRunCatchUp:
    def test_audit_only_creates_no_files(self, tmp_path: Path) -> None:
        _seed_minimal_project(tmp_path)
        buf = io.StringIO()
        with redirect_stdout(buf):
            exit_code = run_catch_up(_make_args(tmp_path, kind="cli"))
        # Critical fail (missing CLAUDE.md) → exit 1.
        assert exit_code == 1
        # No files created.
        assert not (tmp_path / "docs").exists()
        assert not (tmp_path / ".claude").exists()
        # Report mentions the fail.
        assert "CLAUDE.md" in buf.getvalue()
        assert "missing" in buf.getvalue().lower()

    def test_apply_creates_missing_files(self, tmp_path: Path) -> None:
        _seed_minimal_project(tmp_path)
        buf = io.StringIO()
        with redirect_stdout(buf):
            run_catch_up(_make_args(tmp_path, apply=True, kind="cli"))

        # PAPER-54: --apply creates at the DOC-07 canonical path.
        claude_md = tmp_path / "docs" / "claude" / "CLAUDE.md"
        settings = tmp_path / ".claude" / "settings.json"
        assert claude_md.is_file()
        assert settings.is_file()
        # CLAUDE.md has the markers + project name.
        content = claude_md.read_text()
        assert CLAUDE_MD_BEGIN_MARKER in content
        assert CLAUDE_MD_END_MARKER in content
        assert "demo — Claude Reference" in content
        # settings.json is valid JSON.
        json.loads(settings.read_text())

    def test_apply_refresh_preserves_project_specific_content(
        self,
        tmp_path: Path,
    ) -> None:
        _seed_minimal_project(tmp_path)
        # First apply creates CLAUDE.md.
        buf = io.StringIO()
        with redirect_stdout(buf):
            run_catch_up(_make_args(tmp_path, apply=True, kind="cli"))

        # PAPER-54: created at the DOC-07 canonical path.
        claude_md = tmp_path / "docs" / "claude" / "CLAUDE.md"
        # Append project-specific content below the END marker.
        claude_md.write_text(
            claude_md.read_text() + "\n\n## Custom team note\n\nKeep me!\n",
            encoding="utf-8",
        )

        # Tamper with the auto-block to make catch-up detect drift.
        content = claude_md.read_text()
        content = content.replace("Standing instructions", "TAMPERED HEADING")
        claude_md.write_text(content, encoding="utf-8")

        # Re-apply.
        buf = io.StringIO()
        with redirect_stdout(buf):
            run_catch_up(_make_args(tmp_path, apply=True, kind="cli"))

        refreshed = claude_md.read_text()
        # Auto-block restored.
        assert "Standing instructions" in refreshed
        assert "TAMPERED HEADING" not in refreshed
        # Project-specific content survives.
        assert "## Custom team note" in refreshed
        assert "Keep me!" in refreshed

    def test_apply_refreshes_claude_md_and_auto_cleans_backup(
        self,
        tmp_path: Path,
    ) -> None:
        # PAPER-44: a successful `--apply` writes a .bak.<timestamp>
        # for safety during the rewrite, then deletes it when the
        # re-audit reports clean. Net effect: the consumer's docs/
        # tree is clean of orphan .bak.* files after a happy-path
        # apply. The safety-net behaviour during apply still holds —
        # see test_retains_backups_when_reaudit_unclean below for
        # the case where the cleanup is skipped.
        _seed_minimal_project(tmp_path)
        # Apply once (creates fresh CLAUDE.md).
        buf = io.StringIO()
        with redirect_stdout(buf):
            run_catch_up(_make_args(tmp_path, apply=True, kind="cli"))
        # Tamper to induce drift.
        # PAPER-54: created at the DOC-07 canonical path.
        claude_md = tmp_path / "docs" / "claude" / "CLAUDE.md"
        claude_md.write_text(
            claude_md.read_text().replace(
                "Standing instructions",
                "TAMPERED",
            ),
            encoding="utf-8",
        )
        # Apply again — refreshes CLAUDE.md.
        buf = io.StringIO()
        with redirect_stdout(buf):
            run_catch_up(_make_args(tmp_path, apply=True, kind="cli"))
        # The refresh happened (TAMPERED is gone).
        refreshed = claude_md.read_text()
        assert "TAMPERED" not in refreshed
        assert "Standing instructions" in refreshed
        # PAPER-44: the .bak.<timestamp> safety-net file was cleaned
        # up because the re-audit reports clean. No orphan backups
        # left in the docs/ tree for the consumer to `git add` by
        # accident. PAPER-54: backup now lives alongside the
        # canonical file at docs/claude/.
        backups = list((tmp_path / "docs" / "claude").glob("CLAUDE.md.bak.*"))
        assert len(backups) == 0, (
            "expected the .bak.<timestamp> safety-net file to be "
            "auto-cleaned after a verified-clean re-audit (PAPER-44)"
        )
        # And the stdout output mentions both the cleanup and the
        # .gitignore tip — operator-visible confirmation that the
        # auto-clean ran.
        out = buf.getvalue()
        assert "cleaned up" in out.lower()
        assert "*.bak.[0-9]*" in out

    def test_apply_does_not_clean_pre_existing_backups(
        self,
        tmp_path: Path,
    ) -> None:
        # PAPER-44: the auto-clean only removes backups WE wrote in
        # this apply run — pre-existing .bak.<timestamp> files
        # (e.g. left over from an older catch-up run that crashed
        # mid-cleanup, or from another tool) must NOT be touched.
        _seed_minimal_project(tmp_path)

        # Plant a pre-existing backup with the catch-up naming
        # shape. The scanner pattern (.bak.<digits>) will match it,
        # so this test verifies the before/after diff logic
        # correctly excludes it from the cleanup set.
        (tmp_path / "docs").mkdir(exist_ok=True)
        pre_existing = tmp_path / "docs" / "CLAUDE.md.bak.1000000000"
        pre_existing.write_text("an older backup, not ours")

        # PAPER-54: --apply creates at docs/claude/CLAUDE.md going forward.
        # The pre-existing backup at the legacy docs/ root is unrelated to
        # this apply run's writes and must survive the auto-clean.
        claude_md = tmp_path / "docs" / "claude" / "CLAUDE.md"
        # First run creates the file; second run with tampering
        # forces a refresh.
        buf = io.StringIO()
        with redirect_stdout(buf):
            run_catch_up(_make_args(tmp_path, apply=True, kind="cli"))
        claude_md.write_text(
            claude_md.read_text().replace(
                "Standing instructions",
                "TAMPERED",
            ),
            encoding="utf-8",
        )

        buf = io.StringIO()
        with redirect_stdout(buf):
            run_catch_up(_make_args(tmp_path, apply=True, kind="cli"))

        # The pre-existing backup survives the auto-clean.
        assert pre_existing.exists(), (
            "pre-existing .bak.<timestamp> files must not be touched "
            "by the PAPER-44 auto-cleanup — only backups written by "
            "the current apply run are eligible"
        )
        # And the THIS-RUN backup got cleaned (no orphan from this
        # apply remains anywhere under docs/).
        all_backups = sorted((tmp_path / "docs").rglob("CLAUDE.md.bak.*"))
        assert all_backups == [pre_existing], (
            f"expected only the pre-existing backup to remain; got: {[p.name for p in all_backups]}"
        )

    def test_invalid_target_dir(self, tmp_path: Path) -> None:
        from pathlib import Path

        bad = Path("/nonexistent/path/v11")
        out_buf = io.StringIO()
        # Don't redirect stderr — argparse error path uses different shape.
        with redirect_stdout(out_buf):
            from contextlib import redirect_stderr

            err_buf = io.StringIO()
            with redirect_stderr(err_buf):
                exit_code = run_catch_up(_make_args(bad, kind="cli"))
        assert exit_code == 1


@pytest.mark.parametrize("kind", ["cli", "fastapi", "django", "library", "service"])
def test_apply_works_for_all_kinds(tmp_path: Path, kind: str) -> None:
    _seed_minimal_project(tmp_path)
    buf = io.StringIO()
    with redirect_stdout(buf):
        run_catch_up(_make_args(tmp_path, apply=True, kind=kind))
    # PAPER-54: --apply creates at docs/claude/CLAUDE.md (DOC-07 canonical).
    assert (tmp_path / "docs" / "claude" / "CLAUDE.md").is_file()
    assert (tmp_path / ".claude" / "settings.json").is_file()


# ---------------------------------------------------------------------------
# Round 1b: kind detection (PAPER-13 + PAPER-17), pip-tools layout
# detection (PAPER-15 partial), CLAUDE.md marker bootstrap (PAPER-16).
# ---------------------------------------------------------------------------


class TestKindDetection:
    """Heuristic kind detection — closes PAPER-13 + PAPER-17."""

    def test_no_pyproject_but_has_python_returns_library(self, tmp_path: Path) -> None:
        # PAPER-97: a no-pyproject repo that still has Python (a `.py`
        # file) but no manage.py / scripts → library default. (An empty /
        # no-Python dir is now `docs` — see TestNonPythonDocsKind.)
        (tmp_path / "script.py").write_text("print('hi')\n", encoding="utf-8")
        assert _detect_kind(tmp_path) == "library"

    def test_no_pyproject_with_manage_py_returns_django(self, tmp_path: Path) -> None:
        # Closes PAPER-13: Django consumers without pyproject.toml
        # (the inthewords shape).
        (tmp_path / "manage.py").write_text(
            "#!/usr/bin/env python\nimport django\n",
            encoding="utf-8",
        )
        assert _detect_kind(tmp_path) == "django"

    def test_pyproject_with_fastapi_dep_returns_fastapi(self, tmp_path: Path) -> None:
        # Closes PAPER-17: FastAPI consumers WITH pyproject.toml
        # (the PlasmaMapper shape).
        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            "dependencies = [\n"
            '    "fastapi>=0.115",\n'
            '    "uvicorn[standard]>=0.30",\n'
            "]\n",
            encoding="utf-8",
        )
        assert _detect_kind(tmp_path) == "fastapi"

    def test_pyproject_with_django_dep_returns_django(self, tmp_path: Path) -> None:
        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            "dependencies = [\n"
            '    "django>=5.0",\n'
            "]\n",
            encoding="utf-8",
        )
        assert _detect_kind(tmp_path) == "django"

    def test_pyproject_with_flask_dep_returns_service(self, tmp_path: Path) -> None:
        # No flask in init-project's _KINDS today; service is the closest fit.
        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\ndependencies = [\n    "flask>=3.0",\n]\n',
            encoding="utf-8",
        )
        assert _detect_kind(tmp_path) == "service"

    def test_pyproject_with_scripts_no_framework_returns_cli(self, tmp_path: Path) -> None:
        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            "dependencies = [\n"
            '    "click>=8.0",\n'
            "]\n"
            "[project.scripts]\n"
            'demo-cli = "demo:main"\n',
            encoding="utf-8",
        )
        assert _detect_kind(tmp_path) == "cli"

    def test_pyproject_no_deps_no_scripts_returns_library(self, tmp_path: Path) -> None:
        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "lib"\nversion = "0.1.0"\n'
            "dependencies = [\n"
            '    "pydantic>=2.0",\n'
            "]\n",
            encoding="utf-8",
        )
        assert _detect_kind(tmp_path) == "library"

    def test_pyproject_dep_substring_matches_carefully(self, tmp_path: Path) -> None:
        # `flask-restful` HAS `flask` as a substring; the heuristic
        # may match either `service` (regex broad) or `library`
        # (no match). Either is acceptable until a real consumer
        # files concrete pain. The test pins the acceptable set.
        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            "dependencies = [\n"
            '    "flask-restful>=0.3.10",\n'
            "]\n",
            encoding="utf-8",
        )
        assert _detect_kind(tmp_path) in ("service", "library")


class TestNonPythonDocsKind:
    """PAPER-97 — a repo with no Python at all detects as ``docs`` and
    reports the language-specific checks N/A, instead of mis-defaulting
    to ``library`` and surfacing the whole Python corpus as false findings."""

    def _seed_docs_repo(self, target: Path) -> None:
        (target / "README.md").write_text("# Docs repo\n", encoding="utf-8")
        (target / "guide").mkdir()
        (target / "guide" / "intro.md").write_text("hello\n", encoding="utf-8")

    def test_is_non_python_true_for_markdown_only(self, tmp_path: Path) -> None:
        self._seed_docs_repo(tmp_path)
        assert _is_non_python_repo(tmp_path) is True

    def test_is_non_python_false_with_pyproject(self, tmp_path: Path) -> None:
        self._seed_docs_repo(tmp_path)
        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "x"\nversion = "0.1.0"\n',
            encoding="utf-8",
        )
        assert _is_non_python_repo(tmp_path) is False

    def test_is_non_python_false_with_py_file(self, tmp_path: Path) -> None:
        self._seed_docs_repo(tmp_path)
        (tmp_path / "tool.py").write_text("x = 1\n", encoding="utf-8")
        assert _is_non_python_repo(tmp_path) is False

    def test_is_non_python_false_with_setup_cfg(self, tmp_path: Path) -> None:
        self._seed_docs_repo(tmp_path)
        (tmp_path / "setup.cfg").write_text("[metadata]\nname = x\n", encoding="utf-8")
        assert _is_non_python_repo(tmp_path) is False

    def test_detect_kind_docs(self, tmp_path: Path) -> None:
        self._seed_docs_repo(tmp_path)
        assert _detect_kind(tmp_path) == "docs"

    def test_collect_findings_marks_python_checks_na(self, tmp_path: Path) -> None:
        self._seed_docs_repo(tmp_path)
        findings = _collect_findings(tmp_path, "demo", "docs")
        na_prefixes = (
            "pyproject.toml — mgf-common pin",
            "Four gates",
            "Federation contract",
            "Federation test supervisor",
            "Federation marker taxonomy",
            "CI/CD — Woodpecker",
        )
        seen = 0
        for f in findings:
            if any(f.title.startswith(p) for p in na_prefixes):
                seen += 1
                assert f.severity == "info", f"{f.title} should be N/A info"
                assert "N/A" in f.detail
        assert seen == len(na_prefixes)  # all six gated

    def test_same_repo_as_library_would_fail(self, tmp_path: Path) -> None:
        # Control: the *gating* (not absence of files) suppresses the
        # findings — audited as `library`, the same repo DOES fail (proves
        # the docs N/A isn't vacuous).
        self._seed_docs_repo(tmp_path)
        findings = _collect_findings(tmp_path, "demo", "library")
        assert any(f.severity == "fail" for f in findings)

    def test_render_claude_md_docs_is_thin_pointer(self) -> None:
        out = _render_claude_md("demo", "docs")
        assert CLAUDE_MD_BEGIN_MARKER in out  # refreshable by catch-up
        assert CLAUDE_MD_END_MARKER in out
        assert "mgf-fed agents" in out  # orientation kept
        assert "non-Python" in out  # states the N/A scope
        assert "bootstrap(" not in out  # NOT the Python template
        assert "ruff" not in out

    def test_apply_writes_docs_template_then_clean(self, tmp_path: Path) -> None:
        # --apply on a docs repo creates the orientation files using the
        # DOCS template (not the Python one); the re-audit is then clean.
        self._seed_docs_repo(tmp_path)
        buf = io.StringIO()
        with redirect_stdout(buf):
            run_catch_up(_make_args(tmp_path, apply=True))
        claude = (tmp_path / "docs" / "claude" / "CLAUDE.md").read_text(
            encoding="utf-8",
        )
        assert "non-Python" in claude
        assert "bootstrap(" not in claude  # the Python recipes did NOT land
        buf2 = io.StringIO()
        with redirect_stdout(buf2):
            code = run_catch_up(_make_args(tmp_path))
        assert "kind: docs" in buf2.getvalue()
        assert code == 0  # no 🔴 left — Python rows are N/A


class TestPipToolsLayoutDetection:
    """PAPER-15: detect pip-tools layouts (the precondition for the
    full pin + four-gate audit in TestPipToolsPinCheck +
    TestPipToolsFourGates below)."""

    def test_has_pip_tools_layout_detects_requirements_dir(
        self,
        tmp_path: Path,
    ) -> None:
        (tmp_path / "requirements").mkdir()
        (tmp_path / "requirements" / "base.txt").write_text("x\n")
        assert _has_pip_tools_layout(tmp_path) is True

    def test_has_pip_tools_layout_detects_flat_file(self, tmp_path: Path) -> None:
        (tmp_path / "requirements.txt").write_text("x\n")
        assert _has_pip_tools_layout(tmp_path) is True

    def test_has_pip_tools_layout_false_when_neither(self, tmp_path: Path) -> None:
        assert _has_pip_tools_layout(tmp_path) is False

    def test_has_pip_tools_layout_false_for_empty_requirements_dir(
        self,
        tmp_path: Path,
    ) -> None:
        (tmp_path / "requirements").mkdir()
        # empty dir — no .txt / .lock files
        assert _has_pip_tools_layout(tmp_path) is False

    def test_no_pyproject_no_requirements_keeps_old_warning(
        self,
        tmp_path: Path,
    ) -> None:
        # No pyproject, no pip-tools — keep the original warning.
        finding = _check_pyproject_pin(tmp_path)
        assert finding.severity == "warn"
        assert "no pyproject.toml found" in finding.detail
        assert "pip-tools" not in finding.detail


# ---------------------------------------------------------------------------
# PAPER-15: full pip-tools manifest audit (v0.32.0)
# ---------------------------------------------------------------------------


def _seed_pip_tools_project(
    tmp_path: Path,
    *,
    pin_constraint: str = ">=0.16,<0.18",
    pin_file: str = "requirements/base.txt",
) -> Path:
    """Seed a pip-tools project at ``tmp_path`` with ``mgf-common`` pinned.

    Returns the path of the file containing the pin (so tests can read it
    back to verify a rewrite landed there).
    """
    pin_path = tmp_path / pin_file
    pin_path.parent.mkdir(parents=True, exist_ok=True)
    pin_path.write_text(
        f"mgf-common{pin_constraint}\nrequests>=2.31\n",
        encoding="utf-8",
    )
    return pin_path


class TestPipToolsPinCheck:
    """``_check_pyproject_pin`` dispatches to the pip-tools path when
    no pyproject is present and a requirements layout is."""

    def test_current_pin_in_base_txt_returns_ok(self, tmp_path: Path) -> None:
        from mgf.common import __version__ as current

        major, minor = current.split(".")[:2]
        upper_minor = int(minor) + 1
        _seed_pip_tools_project(
            tmp_path,
            pin_constraint=f">={current},<{major}.{upper_minor}",
        )
        finding = _check_pyproject_pin(tmp_path)
        assert finding.severity == "ok"
        assert "requirements/base.txt" in finding.detail
        assert finding.fix is None

    def test_stale_pin_warns_with_fix(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        from mgf.fed.cli import _catch_up as cu_module

        monkeypatch.setattr(cu_module, "_is_editable_install", lambda: False)
        _seed_pip_tools_project(tmp_path, pin_constraint=">=0.1,<0.2")
        finding = _check_pyproject_pin(tmp_path)
        assert finding.severity == "warn"
        assert "STALE" in finding.detail
        assert "requirements/base.txt" in finding.detail
        assert finding.fix is not None

    def test_unpinned_emits_info(self, tmp_path: Path) -> None:
        # `mgf-common` with no constraint — info suggesting a pin.
        _seed_pip_tools_project(tmp_path, pin_constraint="")
        finding = _check_pyproject_pin(tmp_path)
        assert finding.severity == "info"
        assert "unpinned" in finding.detail
        assert "requirements/base.txt" in finding.detail

    def test_no_mgf_dep_warns(self, tmp_path: Path) -> None:
        # pip-tools layout but no mgf-common — different from "no manifest".
        (tmp_path / "requirements").mkdir()
        (tmp_path / "requirements" / "base.txt").write_text(
            "requests>=2.31\nhttpx>=0.27\n",
            encoding="utf-8",
        )
        finding = _check_pyproject_pin(tmp_path)
        assert finding.severity == "warn"
        assert "no `mgf-common` dep found" in finding.detail

    def test_flat_requirements_txt_works(self, tmp_path: Path) -> None:
        # Flat layout: requirements.txt at root (no requirements/ dir).
        _seed_pip_tools_project(
            tmp_path,
            pin_constraint=">=0.1,<0.2",
            pin_file="requirements.txt",
        )
        finding = _check_pyproject_pin(tmp_path)
        assert finding.severity == "warn"
        assert "STALE" in finding.detail
        assert "requirements.txt" in finding.detail

    def test_main_txt_is_recognised(self, tmp_path: Path) -> None:
        # `requirements/main.txt` is a common alternative naming.
        _seed_pip_tools_project(
            tmp_path,
            pin_constraint=">=0.1,<0.2",
            pin_file="requirements/main.txt",
        )
        finding = _check_pyproject_pin(tmp_path)
        assert finding.severity == "warn"
        assert "requirements/main.txt" in finding.detail

    def test_base_txt_wins_over_main_txt(self, tmp_path: Path) -> None:
        # Probe order: base.txt before main.txt. If both contain the
        # pin, base.txt is reported.
        from mgf.common import __version__ as current

        major, minor = current.split(".")[:2]
        upper_minor = int(minor) + 1
        good = f">={current},<{major}.{upper_minor}"
        _seed_pip_tools_project(
            tmp_path,
            pin_constraint=good,
            pin_file="requirements/base.txt",
        )
        # main.txt has a STALE pin — but base.txt should win.
        (tmp_path / "requirements" / "main.txt").write_text(
            "mgf-common>=0.1,<0.2\n",
            encoding="utf-8",
        )
        finding = _check_pyproject_pin(tmp_path)
        assert finding.severity == "ok"
        assert "base.txt" in finding.detail

    def test_apply_rewrites_pin_in_requirements_file(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        from mgf.fed.cli import _catch_up as cu_module

        monkeypatch.setattr(cu_module, "_is_editable_install", lambda: False)
        from mgf.common import __version__ as current

        pin_path = _seed_pip_tools_project(
            tmp_path,
            pin_constraint=">=0.1,<0.2",
        )
        finding = _check_pyproject_pin(tmp_path)
        assert finding.fix is not None
        msg = finding.fix(tmp_path)
        assert "bumped pin" in msg
        # Verify the actual file was rewritten + a backup exists.
        new_content = pin_path.read_text(encoding="utf-8")
        assert f">={current}" in new_content
        assert ">=0.1,<0.2" not in new_content
        # Backup files live alongside the original (suffix .bak.<timestamp>).
        backups = list(pin_path.parent.glob(f"{pin_path.name}.bak.*"))
        assert len(backups) == 1
        assert ">=0.1,<0.2" in backups[0].read_text(encoding="utf-8")

    def test_editable_install_refuses_auto_rewrite(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        from mgf.fed.cli import _catch_up as cu_module

        monkeypatch.setattr(cu_module, "_is_editable_install", lambda: True)
        _seed_pip_tools_project(tmp_path, pin_constraint=">=0.1,<0.2")
        finding = _check_pyproject_pin(tmp_path)
        assert finding.severity == "warn"
        assert "STALE" in finding.detail
        assert "editable" in finding.detail.lower()
        assert finding.fix is None


class TestPipToolsFourGates:
    """``_check_four_gates`` dispatches to the standalone-config path
    when no pyproject is present and a requirements layout is."""

    def _seed_minimal_pip_tools(self, tmp_path: Path) -> None:
        (tmp_path / "requirements").mkdir()
        (tmp_path / "requirements" / "base.txt").write_text(
            "mgf-common>=0.16,<0.18\n",
            encoding="utf-8",
        )

    def test_no_gates_configured_warns(self, tmp_path: Path) -> None:
        self._seed_minimal_pip_tools(tmp_path)
        finding = _check_four_gates(tmp_path)
        assert finding.severity == "warn"
        assert "ruff" in finding.detail
        assert "mypy" in finding.detail
        assert "pytest" in finding.detail
        assert "coverage" in finding.detail.lower()

    def test_all_four_gates_configured_returns_ok(self, tmp_path: Path) -> None:
        self._seed_minimal_pip_tools(tmp_path)
        (tmp_path / "ruff.toml").write_text("line-length = 100\n", encoding="utf-8")
        (tmp_path / "mypy.ini").write_text(
            "[mypy]\nstrict = True\n",
            encoding="utf-8",
        )
        (tmp_path / "pytest.ini").write_text(
            "[pytest]\nfilterwarnings = error\n",
            encoding="utf-8",
        )
        (tmp_path / ".coveragerc").write_text(
            "[run]\nbranch = True\n",
            encoding="utf-8",
        )
        finding = _check_four_gates(tmp_path)
        assert finding.severity == "ok"
        assert "all present" in finding.detail.lower()

    def test_setup_cfg_with_mypy_strict_is_accepted(self, tmp_path: Path) -> None:
        # mypy reads [mypy] from setup.cfg too.
        self._seed_minimal_pip_tools(tmp_path)
        (tmp_path / "setup.cfg").write_text(
            "[mypy]\nstrict = True\n",
            encoding="utf-8",
        )
        finding = _check_four_gates(tmp_path)
        # Other gates still missing, but at least the mypy missing-line
        # is NOT in the detail.
        assert finding.severity == "warn"
        assert "mypy.ini" not in finding.detail

    def test_pytest_filterwarnings_error_in_setup_cfg(self, tmp_path: Path) -> None:
        self._seed_minimal_pip_tools(tmp_path)
        (tmp_path / "setup.cfg").write_text(
            "[tool:pytest]\nfilterwarnings = error\n",
            encoding="utf-8",
        )
        finding = _check_four_gates(tmp_path)
        # The phrase "pytest.ini (or" only appears in the missing-gate
        # bullet; the assertion is tightened to that substring so the
        # mention of `[tool.pytest.ini_options]` in the trailing
        # suggestion (which contains "pytest.ini" as a substring)
        # doesn't trigger a false positive.
        assert "pytest.ini (or" not in finding.detail

    def test_pytest_filterwarnings_with_multi_line_value(
        self,
        tmp_path: Path,
    ) -> None:
        # Multi-line filterwarnings value (each on its own line, INI
        # continuation). pytest accepts this; we should too.
        self._seed_minimal_pip_tools(tmp_path)
        (tmp_path / "pytest.ini").write_text(
            "[pytest]\nfilterwarnings =\n    error\n    ignore::DeprecationWarning\n",
            encoding="utf-8",
        )
        finding = _check_four_gates(tmp_path)
        # Tighter substring (see sibling test). The mention of
        # `[tool.pytest.ini_options]` in the suggestion text contains
        # "pytest.ini" — match the gate-bullet shape instead.
        assert "pytest.ini (or" not in finding.detail

    def test_mypy_strict_must_be_truthy(self, tmp_path: Path) -> None:
        # `strict = False` does NOT satisfy the gate; the warn must
        # still mention mypy.
        self._seed_minimal_pip_tools(tmp_path)
        (tmp_path / "mypy.ini").write_text(
            "[mypy]\nstrict = False\n",
            encoding="utf-8",
        )
        finding = _check_four_gates(tmp_path)
        assert "mypy" in finding.detail

    def test_dotted_ruff_toml_recognised(self, tmp_path: Path) -> None:
        # `.ruff.toml` is the alternative (dotfile) shape.
        self._seed_minimal_pip_tools(tmp_path)
        (tmp_path / ".ruff.toml").write_text("line-length = 100\n", encoding="utf-8")
        finding = _check_four_gates(tmp_path)
        assert "ruff" not in finding.detail or "ruff.toml" not in finding.detail


class TestClaudeMdMarkerBootstrap:
    """PAPER-16: catch-up --apply auto-inserts BEGIN/END markers + auto-block
    into an existing CLAUDE.md without clobbering project content."""

    def test_check_claude_md_no_markers_attaches_fix_callable(
        self,
        tmp_path: Path,
    ) -> None:
        # Pre-existing CLAUDE.md without markers (the inthewords shape:
        # 1100 lines of project content, no auto-managed block).
        docs = tmp_path / "docs"
        docs.mkdir()
        (docs / "CLAUDE.md").write_text(
            "# MyProject — Claude Reference\n\n"
            "Standing instructions: be concise.\n\n"
            "Phase 8D critical fact: ...\n",
            encoding="utf-8",
        )
        finding = _check_claude_md(tmp_path, "myproject", "django")
        assert finding.severity == "warn"
        assert "lacks BEGIN/END" in finding.detail
        # PAPER-16: now has a fix callable instead of pointing at the
        # destructive --force option.
        assert finding.fix is not None

    def test_apply_bootstraps_markers_and_preserves_project_content(
        self,
        tmp_path: Path,
    ) -> None:
        # Seed an existing CLAUDE.md with multi-line project content.
        docs = tmp_path / "docs"
        docs.mkdir()
        original_content = (
            "# MyProject — Claude Reference\n\n"
            "Standing instructions: be concise.\n\n"
            "Phase 8D critical fact: AppContext is the source of truth.\n"
        )
        (docs / "CLAUDE.md").write_text(original_content, encoding="utf-8")

        result = _apply_bootstrap_claude_md_markers(
            tmp_path,
            "myproject",
            "django",
        )

        # Result message names what happened.
        assert "bootstrapped" in result.lower()
        assert "backup" in result.lower()

        # Markers now present at the top.
        new_content = (docs / "CLAUDE.md").read_text(encoding="utf-8")
        assert CLAUDE_MD_BEGIN_MARKER in new_content
        assert CLAUDE_MD_END_MARKER in new_content
        # The marker block precedes the original content.
        begin_idx = new_content.find(CLAUDE_MD_BEGIN_MARKER)
        end_idx = new_content.find(CLAUDE_MD_END_MARKER)
        original_idx = new_content.find("# MyProject — Claude Reference")
        assert begin_idx < end_idx < original_idx, (
            "BEGIN/END markers MUST precede the original project content"
        )

        # Original project content is still present, verbatim.
        assert "Standing instructions: be concise." in new_content
        assert "Phase 8D critical fact:" in new_content

    def test_apply_creates_backup_with_original_content(
        self,
        tmp_path: Path,
    ) -> None:
        docs = tmp_path / "docs"
        docs.mkdir()
        original = "# Project\n\n1100 lines of stuff here.\n"
        (docs / "CLAUDE.md").write_text(original, encoding="utf-8")

        _apply_bootstrap_claude_md_markers(tmp_path, "demo", "library")

        backups = list(docs.glob("CLAUDE.md.bak.*"))
        assert len(backups) == 1, f"expected exactly 1 backup, got {backups}"
        assert backups[0].read_text(encoding="utf-8") == original

    def test_apply_then_refresh_works_in_place(self, tmp_path: Path) -> None:
        # End-to-end: bootstrap markers → next catch-up --apply
        # refreshes the auto-block in place (rather than re-bootstrapping).
        docs = tmp_path / "docs"
        docs.mkdir()
        (docs / "CLAUDE.md").write_text(
            "# Demo — Claude Reference\n\nProject content.\n",
            encoding="utf-8",
        )

        # First apply: bootstrap markers.
        _apply_bootstrap_claude_md_markers(tmp_path, "demo", "library")
        first_content = (docs / "CLAUDE.md").read_text(encoding="utf-8")
        assert CLAUDE_MD_BEGIN_MARKER in first_content

        # Now check_claude_md — the auto-block matches the template,
        # so this should be ✅. (If template content changes, would
        # be ⚠️ + refresh-fix-callable.)
        finding = _check_claude_md(tmp_path, "demo", "library")
        assert finding.severity in ("ok", "warn"), (
            f"after bootstrap, should be ok or warn (refresh-needed); got {finding.severity}"
        )


# ---------------------------------------------------------------------------
# Federation contract (DOC-17 / FEDERATION.md) — enforce mode.
# ---------------------------------------------------------------------------


def _seed_universal_docs(target: Path) -> None:
    """Drop the DOC-01 v2.11 universal root-doc baseline at repo root."""
    for fname in (
        "README.md",
        "CHANGELOG.md",
        "CONTRIBUTING.md",
        "SECURITY.md",
        "FEEDBACK.md",
        "STARTHERE.md",
        "FEDERATION.md",
        "DEPS.md",
    ):
        (target / fname).write_text(f"# {fname}\n", encoding="utf-8")
    (target / "LICENSE").write_text("MIT\n", encoding="utf-8")
    (target / "docs").mkdir(exist_ok=True)


def _seed_conformance_tracker(target: Path) -> None:
    docs_dir = target / "docs" / "inprogress"
    docs_dir.mkdir(parents=True, exist_ok=True)
    (docs_dir / "MGF_STANDARDS_CONFORMANCE.md").write_text(
        "# Conformance\n\nLevel: L2\n",
        encoding="utf-8",
    )


def _seed_adoption_tracker(target: Path) -> None:
    docs_dir = target / "docs" / "inprogress"
    docs_dir.mkdir(parents=True, exist_ok=True)
    (docs_dir / "MGF_ADOPTION.md").write_text(
        "# Adoption\n\nPhase: 2\n",
        encoding="utf-8",
    )


class TestFederationContractCheck:
    def test_empty_consumer_fails_with_all_missing(self, tmp_path: Path) -> None:
        finding = _check_federation_contract(tmp_path, "demo")
        assert finding.severity == "fail"
        assert "README.md" in finding.detail
        assert "LICENSE" in finding.detail
        assert "CHANGELOG.md" in finding.detail
        assert "SECURITY.md" in finding.detail
        assert "FEEDBACK.md" in finding.detail
        assert "MGF_ADOPTION.md" in finding.detail
        assert "MGF_STANDARDS_CONFORMANCE.md" in finding.detail
        assert "consumer application" in finding.detail

    def test_consumer_with_everything_is_ok(self, tmp_path: Path) -> None:
        _seed_universal_docs(tmp_path)
        _seed_conformance_tracker(tmp_path)
        _seed_adoption_tracker(tmp_path)
        finding = _check_federation_contract(tmp_path, "demo")
        assert finding.severity == "ok"

    def test_consumer_missing_only_adoption_tracker_fails(self, tmp_path: Path) -> None:
        _seed_universal_docs(tmp_path)
        _seed_conformance_tracker(tmp_path)
        # No adoption tracker.
        finding = _check_federation_contract(tmp_path, "demo")
        assert finding.severity == "fail"
        assert "MGF_ADOPTION.md" in finding.detail
        # Universal-5 should NOT appear as missing.
        assert "README.md" not in finding.detail
        assert "LICENSE" not in finding.detail

    def test_consumer_accepts_mgf_common_adoption_alias(self, tmp_path: Path) -> None:
        _seed_universal_docs(tmp_path)
        _seed_conformance_tracker(tmp_path)
        # inthewords-style alternate filename — also acceptable.
        docs_dir = tmp_path / "docs"
        docs_dir.mkdir(parents=True, exist_ok=True)
        (docs_dir / "MGF_COMMON_ADOPTION.md").write_text("# Adoption\n", encoding="utf-8")
        finding = _check_federation_contract(tmp_path, "demo")
        assert finding.severity == "ok"

    def test_missing_deps_md_fails(self, tmp_path: Path) -> None:
        """DEPS.md is universal-MUST (DOC-01 v2.12, replaces USERS.md)."""
        _seed_universal_docs(tmp_path)
        (tmp_path / "DEPS.md").unlink()
        _seed_conformance_tracker(tmp_path)
        finding = _check_federation_contract(tmp_path, "mgf-foo")
        assert finding.severity == "fail"
        assert "DEPS.md" in finding.detail
        assert "federation sibling" in finding.detail

    def test_missing_new_baseline_files_fail(self, tmp_path: Path) -> None:
        """DOC-01 v2.11: CONTRIBUTING/STARTHERE/FEDERATION/USERS + docs/ are
        universal-MUST. Seed only the old five and confirm the new ones
        surface as missing."""
        for fname in ("README.md", "CHANGELOG.md", "SECURITY.md", "FEEDBACK.md"):
            (tmp_path / fname).write_text("x\n", encoding="utf-8")
        (tmp_path / "LICENSE").write_text("MIT\n", encoding="utf-8")
        finding = _check_federation_contract(tmp_path, "mgf-foo")
        assert finding.severity == "fail"
        for expected in ("CONTRIBUTING.md", "STARTHERE.md", "FEDERATION.md", "DEPS.md", "docs/"):
            assert expected in finding.detail, f"{expected} not flagged missing"

    def test_sibling_with_full_baseline_is_ok_without_adoption(self, tmp_path: Path) -> None:
        """ADP-04 carve-out: siblings don't need MGF_ADOPTION.md."""
        _seed_universal_docs(tmp_path)
        _seed_conformance_tracker(tmp_path)
        finding = _check_federation_contract(tmp_path, "mgf-foo")
        assert finding.severity == "ok"

    def test_sibling_with_underscore_name_recognised(self, tmp_path: Path) -> None:
        """Both ``mgf-foo`` and ``mgf_foo`` are sibling names (Codeberg-naming memory)."""
        _seed_universal_docs(tmp_path)
        _seed_conformance_tracker(tmp_path)
        finding = _check_federation_contract(tmp_path, "mgf_foo")
        assert finding.severity == "ok"

    def test_license_md_variant_accepted(self, tmp_path: Path) -> None:
        """LICENSE.md or LICENSE.txt should satisfy the LICENSE slot."""
        _seed_universal_docs(tmp_path)
        (tmp_path / "LICENSE").unlink()
        (tmp_path / "LICENSE.md").write_text("MIT\n", encoding="utf-8")
        _seed_conformance_tracker(tmp_path)
        _seed_adoption_tracker(tmp_path)
        finding = _check_federation_contract(tmp_path, "demo")
        assert finding.severity == "ok"

    def test_run_catch_up_reports_contract_in_drift_output(
        self,
        tmp_path: Path,
    ) -> None:
        """Integration: contract check shows up in the catch-up drift report."""
        _seed_minimal_project(tmp_path)
        buf = io.StringIO()
        with redirect_stdout(buf):
            exit_code = run_catch_up(_make_args(tmp_path, kind="cli"))
        # CLAUDE.md missing + Federation contract missing → exit non-zero.
        assert exit_code == 1
        out = buf.getvalue()
        assert "Federation contract" in out
        assert "FEDERATION.md" in out


# ---------------------------------------------------------------------------
# TS-22..TS-27 — federation test supervisor adoption check
# ---------------------------------------------------------------------------


class TestTestSupervisorAdoptionCheck:
    """`_check_test_supervisor_adoption` audits whether the project
    consumes `mgf-test-supervisor` AND `pytest-timeout` as dev deps
    AND wires `timeout_method = "thread"` per TS-23. PAPER-77 added
    pytest-timeout as a required third signal (without it, the
    supervisor's timeout keys produce silent CRASHED outcomes).

    Outcomes: ok / warn (partial) / warn (missing). Self-skip when
    project IS the supervisor library."""

    def _write_pyproject(
        self,
        target: Path,
        *,
        dep: bool,
        pytest_timeout_dep: bool | None = None,
        timeout: bool,
        name: str = "demo",
    ) -> None:
        # Default: pytest_timeout follows the supervisor-dep flag
        # (matches the natural adoption pattern). Callers override
        # to test partial states.
        if pytest_timeout_dep is None:
            pytest_timeout_dep = dep
        deps_list = []
        if dep:
            deps_list.append("mgf-test-supervisor @ git+ssh://example/x.git")
        if pytest_timeout_dep:
            deps_list.append("pytest-timeout>=2.3,<3")
        deps_block = ""
        if deps_list:
            deps_lines = ", ".join(f'"{d}"' for d in deps_list)
            deps_block = f"[project.optional-dependencies]\ndev = [{deps_lines}]\n"
        pytest_block = ""
        if timeout:
            pytest_block = '[tool.pytest.ini_options]\ntimeout_method = "thread"\n'
        (target / "pyproject.toml").write_text(
            f'[project]\nname = "{name}"\nversion = "0.1.0"\n'
            f"dependencies = []\n\n"
            f"{deps_block}\n{pytest_block}",
            encoding="utf-8",
        )

    def test_self_skip_for_supervisor_library(self, tmp_path: Path) -> None:
        # mgf-test-supervisor doesn't depend on itself — silently OK.
        self._write_pyproject(
            tmp_path,
            dep=False,
            timeout=False,
            name="mgf-test-supervisor",
        )
        finding = _check_test_supervisor_adoption(
            tmp_path,
            "mgf-test-supervisor",
        )
        assert finding.severity == "ok"
        assert "self-dependency" in finding.detail.lower()

    def test_all_three_signals_present_returns_ok(
        self,
        tmp_path: Path,
    ) -> None:
        self._write_pyproject(tmp_path, dep=True, timeout=True)
        finding = _check_test_supervisor_adoption(tmp_path, "demo")
        assert finding.severity == "ok"
        assert "declared in dev extras" in finding.detail

    def test_supervisor_dep_only_warns_about_pytest_timeout_and_timeout_method(
        self,
        tmp_path: Path,
    ) -> None:
        # mgf-test-supervisor present; pytest-timeout absent; timeout
        # config absent.
        self._write_pyproject(
            tmp_path,
            dep=True,
            pytest_timeout_dep=False,
            timeout=False,
        )
        finding = _check_test_supervisor_adoption(tmp_path, "demo")
        assert finding.severity == "warn"
        # PAPER-77 footgun message present.
        assert "pytest-timeout>=2.3,<3" in finding.detail
        assert "TS-23 timeout config" in finding.detail

    def test_both_deps_only_warns_about_timeout_method(
        self,
        tmp_path: Path,
    ) -> None:
        # Both deps declared; only the timeout config is missing.
        self._write_pyproject(
            tmp_path,
            dep=True,
            pytest_timeout_dep=True,
            timeout=False,
        )
        finding = _check_test_supervisor_adoption(tmp_path, "demo")
        assert finding.severity == "warn"
        assert "TS-23 timeout config" in finding.detail
        # Both deps OK → no "Missing dev dep" line in remediation.
        assert "Missing dev dep" not in finding.detail

    def test_timeout_only_returns_warn_with_both_dep_fixes(
        self,
        tmp_path: Path,
    ) -> None:
        self._write_pyproject(
            tmp_path,
            dep=False,
            pytest_timeout_dep=False,
            timeout=True,
        )
        finding = _check_test_supervisor_adoption(tmp_path, "demo")
        assert finding.severity == "warn"
        assert "Missing dev dep" in finding.detail
        # mgf-common 0.41.1+: plain PyPI dep (was git+ssh pre-0.1.2
        # PyPI publish).
        assert "mgf-test-supervisor>=0.1.2,<0.2" in finding.detail
        assert "pytest-timeout>=2.3,<3" in finding.detail
        # Timeout half is OK; only the dep halves are flagged.
        assert "TS-23 timeout config" not in finding.detail

    def test_no_signals_returns_warn_with_all_fixes(
        self,
        tmp_path: Path,
    ) -> None:
        self._write_pyproject(
            tmp_path,
            dep=False,
            pytest_timeout_dep=False,
            timeout=False,
        )
        finding = _check_test_supervisor_adoption(tmp_path, "demo")
        assert finding.severity == "warn"
        assert "Missing dev dep" in finding.detail
        assert "pytest-timeout>=2.3,<3" in finding.detail
        assert "TS-23 timeout config" in finding.detail

    def test_no_pyproject_warns(self, tmp_path: Path) -> None:
        finding = _check_test_supervisor_adoption(tmp_path, "demo")
        assert finding.severity == "warn"
        assert "no pyproject.toml" in finding.detail.lower()

    def test_finds_deps_in_test_group_too(self, tmp_path: Path) -> None:
        # Some projects declare deps in [optional-dependencies.test]
        # instead of dev — accept both groups for BOTH required deps.
        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            "dependencies = []\n\n"
            "[project.optional-dependencies]\n"
            "test = [\n"
            '    "mgf-test-supervisor @ git+ssh://x.git",\n'
            '    "pytest-timeout>=2.3,<3",\n'
            "]\n\n"
            "[tool.pytest.ini_options]\n"
            'timeout_method = "thread"\n',
            encoding="utf-8",
        )
        finding = _check_test_supervisor_adoption(tmp_path, "demo")
        assert finding.severity == "ok"


# ---------------------------------------------------------------------------
# WF-01..WF-11 — Woodpecker CI presence check
# ---------------------------------------------------------------------------


class TestWoodpeckerCICheck:
    """`_check_woodpecker_ci` looks for `.woodpecker.yml` at repo root
    OR `.woodpecker/*.yml`. Warn (not fail) when missing — some
    projects legitimately use other CI platforms."""

    def test_woodpecker_yml_at_root_returns_ok(self, tmp_path: Path) -> None:
        (tmp_path / ".woodpecker.yml").write_text(
            "pipeline:\n  test:\n    image: python:3.12\n",
            encoding="utf-8",
        )
        finding = _check_woodpecker_ci(tmp_path)
        assert finding.severity == "ok"
        assert ".woodpecker.yml" in finding.detail

    def test_woodpecker_dir_with_yml_returns_ok(self, tmp_path: Path) -> None:
        wp_dir = tmp_path / ".woodpecker"
        wp_dir.mkdir()
        (wp_dir / "test.yml").write_text("pipeline:\n", encoding="utf-8")
        finding = _check_woodpecker_ci(tmp_path)
        assert finding.severity == "ok"
        assert ".woodpecker/*.yml" in finding.detail

    def test_woodpecker_dir_with_yaml_extension_returns_ok(
        self,
        tmp_path: Path,
    ) -> None:
        # Accept .yaml as well as .yml (Woodpecker accepts either).
        wp_dir = tmp_path / ".woodpecker"
        wp_dir.mkdir()
        (wp_dir / "test.yaml").write_text("pipeline:\n", encoding="utf-8")
        finding = _check_woodpecker_ci(tmp_path)
        assert finding.severity == "ok"

    def test_empty_woodpecker_dir_warns(self, tmp_path: Path) -> None:
        # Empty .woodpecker/ directory is not a valid config.
        (tmp_path / ".woodpecker").mkdir()
        finding = _check_woodpecker_ci(tmp_path)
        assert finding.severity == "warn"

    def test_no_woodpecker_config_warns(self, tmp_path: Path) -> None:
        finding = _check_woodpecker_ci(tmp_path)
        assert finding.severity == "warn"
        assert "Woodpecker" in finding.detail
        assert "WORKFLOWS.md" in finding.detail
        # Mentions carve-out option for projects using other CI.
        assert "MGF_STANDARDS_CONFORMANCE.md" in finding.detail

    def test_warn_message_points_at_local_execution(
        self,
        tmp_path: Path,
    ) -> None:
        # The whole point of the federation choosing Woodpecker is
        # that it runs on Codeberg AND locally — surface that in
        # the warn message.
        finding = _check_woodpecker_ci(tmp_path)
        assert "woodpecker-cli exec" in finding.detail


# ---------------------------------------------------------------------------
# TS-25 — federation 13-marker taxonomy audit (PAPER-66)
# ---------------------------------------------------------------------------


class TestFederationMarkersCheck:
    """`_check_federation_markers` audits that the project's pyproject
    `[tool.pytest.ini_options].markers` block registers all 13
    federation-canonical markers. Missing ones surface in the warn
    message with a paste-ready snippet (PAPER-66)."""

    _CANONICAL_13: ClassVar[list[str]] = [
        "smoke",
        "e2e",
        "integration",
        "contract",
        "property",
        "regression",
        "fuzz",
        "concurrency",
        "conformance",
        "use_case",
        "perf",
        "stress",
        "slow",
    ]

    def _write_pyproject_with_markers(
        self,
        target: Path,
        markers: list[str],
    ) -> None:
        marker_lines = ",\n    ".join(f'"{m}: desc"' for m in markers)
        (target / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            "dependencies = []\n\n"
            "[tool.pytest.ini_options]\n"
            f"markers = [\n    {marker_lines}\n]\n",
            encoding="utf-8",
        )

    def test_all_13_registered_returns_ok(self, tmp_path: Path) -> None:
        self._write_pyproject_with_markers(tmp_path, self._CANONICAL_13)
        finding = _check_federation_markers(tmp_path)
        assert finding.severity == "ok"
        assert "All 13 federation markers registered" in finding.detail

    def test_missing_three_markers_warns_with_snippet(
        self,
        tmp_path: Path,
    ) -> None:
        # mgf-hrb's exact pre-migration shape per PAPER-66:
        # missing integration + concurrency + slow.
        partial = [m for m in self._CANONICAL_13 if m not in {"integration", "concurrency", "slow"}]
        self._write_pyproject_with_markers(tmp_path, partial)
        finding = _check_federation_markers(tmp_path)
        assert finding.severity == "warn"
        assert "MISSING 3 of 13 federation markers" in finding.detail
        assert "integration" in finding.detail
        assert "concurrency" in finding.detail
        assert "slow" in finding.detail
        # Paste-ready snippet present with canonical descriptions:
        assert '"integration: cross-module integration test' in finding.detail
        assert '"concurrency: real-threads contention test' in finding.detail
        assert '"slow: legacy marker' in finding.detail

    def test_missing_all_13_warns_with_full_snippet(
        self,
        tmp_path: Path,
    ) -> None:
        # Project has pyproject but no markers section at all.
        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\ndependencies = []\n',
            encoding="utf-8",
        )
        finding = _check_federation_markers(tmp_path)
        assert finding.severity == "warn"
        assert "MISSING 13 of 13 federation markers" in finding.detail
        for marker in self._CANONICAL_13:
            assert marker in finding.detail

    def test_extra_project_specific_markers_noted_not_flagged(
        self,
        tmp_path: Path,
    ) -> None:
        # All 13 canonical + 2 project-specific. Should still be ok.
        with_extras = [*self._CANONICAL_13, "live", "expensive"]
        self._write_pyproject_with_markers(tmp_path, with_extras)
        finding = _check_federation_markers(tmp_path)
        assert finding.severity == "ok"
        assert "project-specific" in finding.detail
        assert "live" in finding.detail
        assert "expensive" in finding.detail

    def test_extra_markers_noted_even_when_some_missing(
        self,
        tmp_path: Path,
    ) -> None:
        partial_plus_extra = [m for m in self._CANONICAL_13 if m != "slow"] + ["live"]
        self._write_pyproject_with_markers(tmp_path, partial_plus_extra)
        finding = _check_federation_markers(tmp_path)
        assert finding.severity == "warn"
        assert "MISSING 1 of 13" in finding.detail
        assert "live" in finding.detail

    def test_no_pyproject_warns(self, tmp_path: Path) -> None:
        finding = _check_federation_markers(tmp_path)
        assert finding.severity == "warn"
        assert "no pyproject.toml" in finding.detail.lower()

    def test_parses_markers_without_description_colon(
        self,
        tmp_path: Path,
    ) -> None:
        # Pytest accepts markers without descriptions, though all
        # federation markers ship with them. Make sure the parser
        # doesn't choke on bare names.
        markers = list(self._CANONICAL_13)
        marker_lines = ",\n    ".join(f'"{m}"' for m in markers)  # NO colons
        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            "dependencies = []\n\n"
            "[tool.pytest.ini_options]\n"
            f"markers = [\n    {marker_lines}\n]\n",
            encoding="utf-8",
        )
        finding = _check_federation_markers(tmp_path)
        assert finding.severity == "ok"

    def test_canonical_constant_has_exactly_13_markers(self) -> None:
        # Pin the count so future drift in the constant breaks here,
        # not in production audits.
        from mgf.fed.cli._catch_up import _FEDERATION_MARKERS

        assert len(_FEDERATION_MARKERS) == 13


# ---------------------------------------------------------------------------
# PAPER-65 / v0.45 — registry-snapshot pin auditing (offline; no pypi.org)
# ---------------------------------------------------------------------------


class TestRegistryLatestVersion:
    """`_registry_latest_version` reads the federation registry data
    (mgf.standard.libraries()) — offline, never pypi.org (PKG-11).
    Tests patch `_resolve_libraries` so they don't depend on the
    installed mgf-standard snapshot."""

    def test_returns_version_from_registry(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        from mgf.fed.cli._catch_up import _registry_latest_version

        monkeypatch.setattr(
            "mgf.fed.cli._catch_up._resolve_libraries",
            lambda: [
                {"repo": "mgf-http", "version": "0.6.0"},
                {"repo": "mgf-common", "version": "0.42.0"},
            ],
        )
        assert _registry_latest_version("mgf-common") == "0.42.0"

    def test_returns_none_when_not_in_registry(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        from mgf.fed.cli._catch_up import _registry_latest_version

        monkeypatch.setattr(
            "mgf.fed.cli._catch_up._resolve_libraries",
            lambda: [{"repo": "mgf-http", "version": "0.6.0"}],
        )
        assert _registry_latest_version("mgf-common") is None

    def test_returns_none_when_registry_unresolvable(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        from mgf.fed.cli._catch_up import _registry_latest_version

        monkeypatch.setattr(
            "mgf.fed.cli._catch_up._resolve_libraries",
            lambda: None,
        )
        assert _registry_latest_version("mgf-common") is None

    def test_returns_none_when_version_field_missing(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        from mgf.fed.cli._catch_up import _registry_latest_version

        monkeypatch.setattr(
            "mgf.fed.cli._catch_up._resolve_libraries",
            lambda: [{"repo": "mgf-common"}],
        )
        assert _registry_latest_version("mgf-common") is None


class TestEditableRegistryGate:
    """The editable-install pin finding gates the auto-rewrite on the
    federation registry snapshot (PKG-11; v0.45 replaced the old
    --check-pypi pypi.org lookup with an offline registry read). Tests
    mock the registry-version helper + the editable-install detector to
    avoid network + filesystem coupling."""

    def _write_stale_pyproject(self, target: Path, *, current_minor: int = 39) -> str:
        from mgf.common import __version__ as current

        cur_major = current.split(".")[0]
        stale_window = f">={cur_major}.{current_minor}.0,<{cur_major}.{current_minor + 1}"
        (target / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            f'dependencies = ["mgf-common{stale_window}"]\n',
            encoding="utf-8",
        )
        return stale_window

    def test_registry_caught_up_offers_apply(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        # Editable + the registry has caught up (≥ editable) → safe to
        # rewrite, fix callable attached.
        from mgf.common import __version__ as current

        self._write_stale_pyproject(tmp_path)
        monkeypatch.setattr(
            "mgf.fed.cli._catch_up._is_editable_install",
            lambda: True,
        )
        monkeypatch.setattr(
            "mgf.fed.cli._catch_up._registry_latest_version",
            lambda _pkg: current,  # registry has caught up
        )
        finding = _check_pyproject_pin(tmp_path)
        assert finding.severity == "warn"
        assert "safe to rewrite" in finding.detail
        # Fix callable attached so --apply can run the rewrite.
        assert finding.fix is not None

    def test_registry_lags_says_wait(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        # Editable + registry snapshot BELOW the editable version → the
        # editable is ahead of the published roster; no fix.
        self._write_stale_pyproject(tmp_path)
        monkeypatch.setattr(
            "mgf.fed.cli._catch_up._is_editable_install",
            lambda: True,
        )
        monkeypatch.setattr(
            "mgf.fed.cli._catch_up._registry_latest_version",
            lambda _pkg: "0.1.0",  # arbitrarily old
        )
        finding = _check_pyproject_pin(tmp_path)
        assert finding.severity == "warn"
        assert "AHEAD of the registry snapshot" in finding.detail
        # No fix offered — wait for the roster to catch up.
        assert finding.fix is None

    def test_registry_unresolvable_refuses(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        # Editable + registry unresolvable (None) → can't confirm; refuse.
        self._write_stale_pyproject(tmp_path)
        monkeypatch.setattr(
            "mgf.fed.cli._catch_up._is_editable_install",
            lambda: True,
        )
        monkeypatch.setattr(
            "mgf.fed.cli._catch_up._registry_latest_version",
            lambda _pkg: None,
        )
        finding = _check_pyproject_pin(tmp_path)
        assert finding.severity == "warn"
        assert "unresolvable" in finding.detail
        assert finding.fix is None

    def test_non_editable_does_not_query_registry(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        # Non-editable stale path takes the standard rewrite route and
        # never calls the registry-version helper.
        self._write_stale_pyproject(tmp_path)
        monkeypatch.setattr(
            "mgf.fed.cli._catch_up._is_editable_install",
            lambda: False,
        )
        call_count = {"n": 0}

        def _spy(_pkg):
            call_count["n"] += 1
            return "9.9.9"

        monkeypatch.setattr(
            "mgf.fed.cli._catch_up._registry_latest_version",
            _spy,
        )
        finding = _check_pyproject_pin(tmp_path)
        # No registry-version call — only the editable path consults it.
        assert call_count["n"] == 0
        # Standard non-editable rewrite path — fix= attached.
        assert finding.severity == "warn"
        assert finding.fix is not None


class TestCheckPypiFlagsRemoved:
    """The --check-pypi / --no-check-pypi flags were removed at the v0.45
    Codeberg cutover — catch-up's version/pin checks resolve offline from
    the registry data + installed metadata, so there is no pypi.org call
    to gate. Pin the removal so the flags can't silently creep back."""

    def test_check_pypi_flags_rejected(self, tmp_path: Path) -> None:
        for flag in ("--check-pypi", "--no-check-pypi"):
            parser = argparse.ArgumentParser()
            add_catch_up_args(parser)
            with pytest.raises(SystemExit):
                parser.parse_args(["--path", str(tmp_path), flag])

    def test_namespace_has_no_check_pypi(self, tmp_path: Path) -> None:
        # run_catch_up no longer reads a check_pypi attribute.
        ns = _make_args(tmp_path)
        assert not hasattr(ns, "check_pypi")


# ---------------------------------------------------------------------------
# PAPER-69 — --apply auto-adds missing federation markers
# ---------------------------------------------------------------------------


class TestApplyAddFederationMarkers:
    """`_apply_add_federation_markers` inserts missing canonical markers
    into pyproject's markers array — stdlib-only structured-text edit
    that preserves existing entry formatting."""

    _ALL_13: ClassVar[list[str]] = [
        "smoke",
        "e2e",
        "integration",
        "contract",
        "property",
        "regression",
        "fuzz",
        "concurrency",
        "conformance",
        "use_case",
        "perf",
        "stress",
        "slow",
    ]

    def _write_pyproject_with_markers(
        self,
        target: Path,
        markers: list[str],
        *,
        indent: str = "    ",
    ) -> None:
        marker_lines = (",\n" + indent).join(f'"{m}: desc"' for m in markers)
        (target / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            "dependencies = []\n\n"
            "[tool.pytest.ini_options]\n"
            f"markers = [\n{indent}{marker_lines},\n]\n",
            encoding="utf-8",
        )

    def test_inserts_missing_markers_and_writes_backup(
        self,
        tmp_path: Path,
    ) -> None:
        from mgf.fed.cli._catch_up import _apply_add_federation_markers

        # Start with 10/13 markers — missing integration / concurrency / slow.
        partial = [m for m in self._ALL_13 if m not in {"integration", "concurrency", "slow"}]
        self._write_pyproject_with_markers(tmp_path, partial)

        result = _apply_add_federation_markers(tmp_path)

        assert "added 3 federation marker(s)" in result
        assert "integration" in result
        assert "concurrency" in result
        assert "slow" in result
        # Backup written.
        baks = list(tmp_path.glob("pyproject.toml.bak.*"))
        assert len(baks) == 1

        # File now contains all 13 markers.
        content = (tmp_path / "pyproject.toml").read_text(encoding="utf-8")
        for marker in self._ALL_13:
            assert f'"{marker}: ' in content or f'"{marker}:' in content

        # Re-running is a no-op (all 13 present).
        result2 = _apply_add_federation_markers(tmp_path)
        assert "no missing federation markers" in result2

    def test_inserted_markers_use_canonical_descriptions(
        self,
        tmp_path: Path,
    ) -> None:
        from mgf.fed.cli._catch_up import (
            _CANONICAL_MARKER_DESCRIPTIONS,
            _apply_add_federation_markers,
        )

        # Empty markers — every inserted entry should use the
        # canonical description.
        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            "dependencies = []\n\n"
            "[tool.pytest.ini_options]\n"
            "markers = [\n]\n",
            encoding="utf-8",
        )

        result = _apply_add_federation_markers(tmp_path)
        assert "added 13 federation marker(s)" in result

        content = (tmp_path / "pyproject.toml").read_text(encoding="utf-8")
        for marker, desc in _CANONICAL_MARKER_DESCRIPTIONS.items():
            assert f'"{marker}: {desc}"' in content, (
                f"marker `{marker}` not inserted with its canonical description"
            )

    def test_preserves_existing_entries_verbatim(
        self,
        tmp_path: Path,
    ) -> None:
        # The apply path MUST NOT rewrite existing entries — only
        # insert new ones. If a project has a custom description on
        # an existing marker, that description survives.
        from mgf.fed.cli._catch_up import _apply_add_federation_markers

        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            "dependencies = []\n\n"
            "[tool.pytest.ini_options]\n"
            "markers = [\n"
            '    "smoke: PROJECT_CUSTOM smoke description",\n'
            '    "live: project-specific marker",\n'
            "]\n",
            encoding="utf-8",
        )

        result = _apply_add_federation_markers(tmp_path)
        assert "added 12 federation marker(s)" in result

        content = (tmp_path / "pyproject.toml").read_text(encoding="utf-8")
        # The custom `smoke` description was NOT replaced.
        assert '"smoke: PROJECT_CUSTOM smoke description"' in content
        # The custom `live` marker survives.
        assert '"live: project-specific marker"' in content
        # And the 12 missing canonical markers are now present.
        for marker in self._ALL_13:
            if marker == "smoke":
                continue
            assert f'"{marker}: ' in content

    def test_no_op_when_no_missing(self, tmp_path: Path) -> None:
        from mgf.fed.cli._catch_up import _apply_add_federation_markers

        self._write_pyproject_with_markers(tmp_path, self._ALL_13)
        result = _apply_add_federation_markers(tmp_path)
        assert "no missing federation markers" in result
        # No backup written for a no-op.
        baks = list(tmp_path.glob("pyproject.toml.bak.*"))
        assert len(baks) == 0

    def test_no_pyproject_returns_noop_message(
        self,
        tmp_path: Path,
    ) -> None:
        from mgf.fed.cli._catch_up import _apply_add_federation_markers

        result = _apply_add_federation_markers(tmp_path)
        assert "no pyproject.toml" in result

    def test_single_line_layout_refuses_safely(
        self,
        tmp_path: Path,
    ) -> None:
        # Single-line markers array. The multi-line regex fails to
        # match; we MUST refuse rather than risk a malformed file.
        from mgf.fed.cli._catch_up import _apply_add_federation_markers

        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            "dependencies = []\n\n"
            "[tool.pytest.ini_options]\n"
            'markers = ["smoke: desc", "contract: desc"]\n',
            encoding="utf-8",
        )

        result = _apply_add_federation_markers(tmp_path)
        assert "single-line" in result
        assert "by hand" in result
        # File NOT modified.
        baks = list(tmp_path.glob("pyproject.toml.bak.*"))
        assert len(baks) == 0

    def test_no_pytest_section_creates_one(
        self,
        tmp_path: Path,
    ) -> None:
        # No `[tool.pytest.ini_options]` section at all → apply
        # creates the section + a markers block with all 13 canonical
        # entries. The minimal-pyproject case the previous version
        # of this helper refused on.
        from mgf.fed.cli._catch_up import _apply_add_federation_markers

        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\ndependencies = []\n',
            encoding="utf-8",
        )

        result = _apply_add_federation_markers(tmp_path)
        assert "created" in result
        assert "13 federation marker" in result
        content = (tmp_path / "pyproject.toml").read_text(encoding="utf-8")
        assert "[tool.pytest.ini_options]" in content
        for marker in self._ALL_13:
            assert f'"{marker}: ' in content

    def test_pytest_section_without_markers_key_inserts_markers(
        self,
        tmp_path: Path,
    ) -> None:
        # `[tool.pytest.ini_options]` exists with other keys but no
        # `markers = ...` → apply inserts the markers block inside
        # the existing section, preserving its other contents.
        from mgf.fed.cli._catch_up import _apply_add_federation_markers

        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            "dependencies = []\n\n"
            "[tool.pytest.ini_options]\n"
            "timeout = 30\n"
            'timeout_method = "thread"\n',
            encoding="utf-8",
        )

        result = _apply_add_federation_markers(tmp_path)
        assert "created" in result
        content = (tmp_path / "pyproject.toml").read_text(encoding="utf-8")
        # The pre-existing keys survive.
        assert "timeout = 30" in content
        assert 'timeout_method = "thread"' in content
        # All 13 markers present.
        for marker in self._ALL_13:
            assert f'"{marker}: ' in content

    def test_apply_loop_picks_up_the_fix_callable(
        self,
        tmp_path: Path,
    ) -> None:
        # Integration: end-to-end run via run_catch_up + --apply
        # should call _apply_add_federation_markers for a project
        # with missing markers.
        partial = [m for m in self._ALL_13 if m != "slow"]
        self._write_pyproject_with_markers(tmp_path, partial)

        # Minimal artifacts so other catch-up checks don't fail-hard.
        (tmp_path / "README.md").write_text("# demo\n", encoding="utf-8")
        (tmp_path / "LICENSE").write_text("MIT\n", encoding="utf-8")
        (tmp_path / "CHANGELOG.md").write_text("# Changelog\n", encoding="utf-8")
        (tmp_path / "SECURITY.md").write_text("# Security\n", encoding="utf-8")
        (tmp_path / "FEEDBACK.md").write_text("# Feedback\n", encoding="utf-8")

        from contextlib import redirect_stdout
        from io import StringIO

        buf = StringIO()
        with redirect_stdout(buf):
            run_catch_up(_make_args(tmp_path, apply=True))
        out = buf.getvalue()
        # The apply step ran for our marker-add fix.
        assert "Federation marker taxonomy" in out
        assert "added 1 federation marker" in out
        # And the file is now complete.
        content = (tmp_path / "pyproject.toml").read_text(encoding="utf-8")
        assert '"slow: ' in content


# ---------------------------------------------------------------------------
# PAPER-73 — --apply for the TS-22..27 supervisor wiring
# ---------------------------------------------------------------------------


class TestApplySupervisorWiring:
    """`_apply_supervisor_wiring` inserts three independent TOML
    fragments: dev deps (mgf-test-supervisor + pytest-timeout),
    hatch direct-refs opt-in (only when hatchling), and the pytest
    timeout config. Stdlib-only, idempotent, backup-first."""

    def _minimal_hatchling_pyproject(self, target: Path) -> None:
        (target / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            'dependencies = ["mgf-common>=0.40.0,<0.41"]\n\n'
            "[build-system]\n"
            'requires = ["hatchling>=1.21"]\n'
            'build-backend = "hatchling.build"\n',
            encoding="utf-8",
        )

    def test_wiring_from_minimal_hatchling_skips_hatch_block(
        self,
        tmp_path: Path,
    ) -> None:
        # PAPER-88 (v0.41.5+): mgf-test-supervisor is now a plain
        # PyPI dep (no `@ git+ssh://...` form). The hatch
        # `allow-direct-references` block has no purpose when no
        # direct-reference dep is present, so the inserter must NOT
        # add it. Pre-PAPER-88 behaviour added it unconditionally
        # whenever hatchling was the build backend, leaving every
        # new adopter with a dead config block.
        from mgf.fed.cli._catch_up import _apply_supervisor_wiring

        self._minimal_hatchling_pyproject(tmp_path)
        result = _apply_supervisor_wiring(tmp_path)
        assert "wired supervisor" in result
        # Dev deps + pytest keys ran; hatch block was correctly skipped.
        assert "2 dev dep(s)" in result
        assert "allow-direct-references" not in result
        assert "3 pytest config key(s)" in result
        # Adoption-next-step reminder.
        assert "uv sync" in result
        assert "self-check" in result
        # Backup file written.
        baks = list(tmp_path.glob("pyproject.toml.bak.*"))
        assert len(baks) == 1
        # Resulting pyproject contains the dev deps + pytest keys but
        # NOT the hatch.metadata block.
        content = (tmp_path / "pyproject.toml").read_text(encoding="utf-8")
        assert "mgf-test-supervisor>=0.1.2,<0.2" in content
        assert "pytest-timeout>=2.3,<3" in content
        assert "[tool.hatch.metadata]" not in content
        assert "allow-direct-references" not in content
        assert "timeout = 30" in content
        assert 'timeout_method = "thread"' in content
        assert "faulthandler_timeout = 60" in content

    def test_hatch_block_added_when_direct_ref_dep_present(
        self,
        tmp_path: Path,
    ) -> None:
        # PAPER-88 (v0.41.5+): a project that ALREADY carries a
        # direct-reference dev dep (e.g., a legacy mgf-test-supervisor
        # @ git+ssh form, or any other direct-ref dep) genuinely
        # needs the hatch.metadata block — the insertion fires here.
        from mgf.fed.cli._catch_up import _apply_supervisor_wiring

        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            'dependencies = ["mgf-common>=0.40.0,<0.41"]\n\n'
            "[project.optional-dependencies]\n"
            "dev = [\n"
            '    "some-pkg @ git+ssh://example.org/repo.git",\n'
            "]\n\n"
            "[build-system]\n"
            'requires = ["hatchling>=1.21"]\n'
            'build-backend = "hatchling.build"\n',
            encoding="utf-8",
        )
        result = _apply_supervisor_wiring(tmp_path)
        assert "wired supervisor" in result
        # Plain dev deps still added; hatch block ALSO added because
        # the project already carries a direct-ref dep.
        assert "2 dev dep(s)" in result
        assert "allow-direct-references" in result
        content = (tmp_path / "pyproject.toml").read_text(encoding="utf-8")
        assert "[tool.hatch.metadata]" in content
        assert "allow-direct-references = true" in content

    def test_non_hatchling_skips_hatch_block(
        self,
        tmp_path: Path,
    ) -> None:
        # Non-hatchling build backend (e.g., setuptools, poetry) →
        # don't write the hatch direct-refs section. (Hatch-specific
        # opt-in shouldn't pollute non-hatch projects.)
        from mgf.fed.cli._catch_up import _apply_supervisor_wiring

        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            'dependencies = ["mgf-common>=0.40.0,<0.41"]\n\n'
            "[build-system]\n"
            'requires = ["setuptools>=61"]\n'
            'build-backend = "setuptools.build_meta"\n',
            encoding="utf-8",
        )
        result = _apply_supervisor_wiring(tmp_path)
        assert "wired supervisor" in result
        # Only dev deps + pytest keys; no hatch block.
        assert "allow-direct-references" not in result
        content = (tmp_path / "pyproject.toml").read_text(encoding="utf-8")
        assert "[tool.hatch.metadata]" not in content

    def test_idempotent_re_run_is_noop(self, tmp_path: Path) -> None:
        from mgf.fed.cli._catch_up import _apply_supervisor_wiring

        self._minimal_hatchling_pyproject(tmp_path)
        _apply_supervisor_wiring(tmp_path)  # First run
        result = _apply_supervisor_wiring(tmp_path)  # Second run
        assert "already complete" in result
        # No backup from the second run.
        baks = list(tmp_path.glob("pyproject.toml.bak.*"))
        assert len(baks) == 1  # only the first run's backup

    def test_partial_existing_state_adds_only_missing(
        self,
        tmp_path: Path,
    ) -> None:
        # Pre-existing: dev extras has mgf-test-supervisor but NOT
        # pytest-timeout; pytest config has timeout but not
        # timeout_method. Apply should add only the missing pieces.
        from mgf.fed.cli._catch_up import _apply_supervisor_wiring

        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            'dependencies = ["mgf-common>=0.40.0,<0.41"]\n\n'
            "[project.optional-dependencies]\n"
            "dev = [\n"
            '    "mgf-test-supervisor @ git+ssh://example/x.git",\n'
            "]\n\n"
            "[tool.pytest.ini_options]\n"
            "timeout = 30\n\n"
            "[tool.hatch.metadata]\n"
            "allow-direct-references = true\n",
            encoding="utf-8",
        )
        result = _apply_supervisor_wiring(tmp_path)
        # Only pytest-timeout + timeout_method + faulthandler_timeout
        # should be added.
        assert "wired supervisor" in result
        assert "1 dev dep(s)" in result  # pytest-timeout only
        # hatch already present → not re-added.
        assert "allow-direct-references" not in result
        assert "2 pytest config key(s)" in result  # method + faulthandler
        content = (tmp_path / "pyproject.toml").read_text(encoding="utf-8")
        assert "pytest-timeout>=2.3,<3" in content
        assert 'timeout_method = "thread"' in content

    def test_no_pyproject_returns_noop(self, tmp_path: Path) -> None:
        from mgf.fed.cli._catch_up import _apply_supervisor_wiring

        result = _apply_supervisor_wiring(tmp_path)
        assert "no pyproject.toml" in result

    def test_catch_up_audit_attaches_the_fix_callable(
        self,
        tmp_path: Path,
    ) -> None:
        # End-to-end: the audit row returns fix=_apply_supervisor_wiring
        # so the existing --apply loop picks it up.
        from mgf.fed.cli._catch_up import (
            _apply_supervisor_wiring,
            _check_test_supervisor_adoption,
        )

        self._minimal_hatchling_pyproject(tmp_path)
        finding = _check_test_supervisor_adoption(tmp_path, "demo")
        assert finding.severity == "warn"
        assert finding.fix is _apply_supervisor_wiring

    def test_audit_message_mentions_pytest_timeout(
        self,
        tmp_path: Path,
    ) -> None:
        # PAPER-77 part 2: when dep is missing, the warn message
        # specifically calls out pytest-timeout as a separate dep
        # (preventing the silent-CRASHED footgun).
        from mgf.fed.cli._catch_up import _check_test_supervisor_adoption

        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            'dependencies = ["mgf-common>=0.40.0,<0.41"]\n',
            encoding="utf-8",
        )
        finding = _check_test_supervisor_adoption(tmp_path, "demo")
        assert finding.severity == "warn"
        assert "pytest-timeout>=2.3,<3" in finding.detail
        # Footgun cross-reference.
        assert "PAPER-77" in finding.detail


# ---------------------------------------------------------------------------
# PAPER-72 — CLAUDE.md pin-literal drift audit
# ---------------------------------------------------------------------------


class TestCheckClaudeMdPinLiterals:
    """`_check_claude_md_pin_literals` (PAPER-72) scans the
    project-specific prose of CLAUDE.md for federation pin literals
    that contradict the actual pyproject pins. Read-only — no fix
    callable, since prose is user-authored."""

    def _write_project(
        self,
        target: Path,
        *,
        pyproject_pin: str = ">=0.40,<0.41",
        claude_md_prose: str = "",
        canonical_layout: bool = True,
    ) -> None:
        (target / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            f'dependencies = ["mgf-common{pyproject_pin}"]\n',
            encoding="utf-8",
        )
        claude_dir = target / "docs" / "claude" if canonical_layout else target / "docs"
        claude_dir.mkdir(parents=True)
        (claude_dir / "CLAUDE.md").write_text(
            "# demo — Claude Reference\n"
            "<!-- BEGIN mgf-common -->\n"
            "(auto-managed block — catch-up controls this)\n"
            "<!-- END mgf-common auto-generated -->\n"
            "\n"
            "## Project-specific lookup\n"
            "\n" + claude_md_prose,
            encoding="utf-8",
        )

    def test_no_pin_literals_returns_ok(self, tmp_path: Path) -> None:
        from mgf.fed.cli._catch_up import _check_claude_md_pin_literals

        self._write_project(
            tmp_path,
            claude_md_prose=("- Cornerstone: mgf-common. Pin window declared in pyproject.toml.\n"),
        )
        finding = _check_claude_md_pin_literals(tmp_path)
        assert finding.severity == "ok"
        assert "no pin literals" in finding.detail

    def test_matching_pin_literal_returns_ok(self, tmp_path: Path) -> None:
        from mgf.fed.cli._catch_up import _check_claude_md_pin_literals

        self._write_project(
            tmp_path,
            pyproject_pin=">=0.40,<0.41",
            claude_md_prose=("- Cornerstone: mgf-common (pinned >=0.40,<0.41).\n"),
        )
        finding = _check_claude_md_pin_literals(tmp_path)
        assert finding.severity == "ok"

    def test_drifted_pin_literal_warns_with_line_number(
        self,
        tmp_path: Path,
    ) -> None:
        # The canonical PAPER-72 scenario: prose says >=0.38, pyproject says >=0.40.
        from mgf.fed.cli._catch_up import _check_claude_md_pin_literals

        self._write_project(
            tmp_path,
            pyproject_pin=">=0.40,<0.41",
            claude_md_prose=("- Cornerstone: mgf-common (pinned >=0.38,<0.39).\n"),
        )
        finding = _check_claude_md_pin_literals(tmp_path)
        assert finding.severity == "warn"
        assert "mgf-common" in finding.detail
        assert ">=0.38,<0.39" in finding.detail
        assert ">=0.40,<0.41" in finding.detail
        # Line number reported.
        assert "L" in finding.detail
        # Cross-reference suggestion present.
        assert (
            "see pyproject" in finding.detail.lower() or "cross-reference" in finding.detail.lower()
        )

    def test_legacy_layout_also_audited(self, tmp_path: Path) -> None:
        # PAPER-54: catch-up accepts both `docs/CLAUDE.md` (legacy)
        # and `docs/claude/CLAUDE.md` (DOC-07 canonical). The
        # pin-literal audit must work on both.
        from mgf.fed.cli._catch_up import _check_claude_md_pin_literals

        self._write_project(
            tmp_path,
            pyproject_pin=">=0.40,<0.41",
            claude_md_prose=("- Cornerstone: mgf-common (pinned >=0.38,<0.39).\n"),
            canonical_layout=False,
        )
        finding = _check_claude_md_pin_literals(tmp_path)
        assert finding.severity == "warn"

    def test_canonical_path_wins_when_both_present(
        self,
        tmp_path: Path,
    ) -> None:
        # When both layouts exist, audit the canonical path
        # (matches `_check_claude_md`'s PAPER-54 precedence).
        from mgf.fed.cli._catch_up import _check_claude_md_pin_literals

        self._write_project(
            tmp_path,
            pyproject_pin=">=0.40,<0.41",
            claude_md_prose=("- Cornerstone: mgf-common (pinned >=0.40,<0.41).\n"),
            canonical_layout=True,
        )
        # Also create a stale legacy file — should NOT be audited.
        (tmp_path / "docs" / "CLAUDE.md").write_text(
            "stale: mgf-common (pinned >=0.10,<0.11)\n",
            encoding="utf-8",
        )
        finding = _check_claude_md_pin_literals(tmp_path)
        # Canonical's prose matches → ok despite legacy claim.
        assert finding.severity == "ok"

    def test_auto_block_text_not_audited(self, tmp_path: Path) -> None:
        # The auto-managed block is catch-up's own; refreshed on
        # apply. Pin literals INSIDE the auto-block should be
        # ignored — only the user-authored prose is audited.
        from mgf.fed.cli._catch_up import _check_claude_md_pin_literals

        # Create a project where the auto-block contains a literal
        # (catch-up's templating may include one) that contradicts
        # pyproject — but the user-authored prose is clean.
        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            'dependencies = ["mgf-common>=0.40,<0.41"]\n',
            encoding="utf-8",
        )
        (tmp_path / "docs" / "claude").mkdir(parents=True)
        (tmp_path / "docs" / "claude" / "CLAUDE.md").write_text(
            "# demo\n"
            "<!-- BEGIN mgf-common -->\n"
            "Auto: mgf-common (pinned >=0.38,<0.39)\n"
            "<!-- END mgf-common auto-generated -->\n"
            "\n"
            "## Project-specific\n"
            "Pin window declared in pyproject.toml.\n",
            encoding="utf-8",
        )
        finding = _check_claude_md_pin_literals(tmp_path)
        assert finding.severity == "ok"

    def test_no_claude_md_returns_ok(self, tmp_path: Path) -> None:
        # Existence is a separate audit; this one just no-ops when
        # there's nothing to scan.
        from mgf.fed.cli._catch_up import _check_claude_md_pin_literals

        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n',
            encoding="utf-8",
        )
        finding = _check_claude_md_pin_literals(tmp_path)
        assert finding.severity == "ok"
        assert "no CLAUDE.md" in finding.detail

    def test_unknown_lib_skipped_not_warned(self, tmp_path: Path) -> None:
        # If CLAUDE.md mentions `mgf-foo` (not in the federation
        # lib list), the regex doesn't match → no warn.
        from mgf.fed.cli._catch_up import _check_claude_md_pin_literals

        self._write_project(
            tmp_path,
            pyproject_pin=">=0.40,<0.41",
            claude_md_prose=(
                "- Cornerstone: mgf-common (pinned >=0.40,<0.41).\n"
                "- mgf-foo (pinned >=99.0,<100.0) — not real.\n"
            ),
        )
        finding = _check_claude_md_pin_literals(tmp_path)
        assert finding.severity == "ok"

    def test_lib_not_in_pyproject_skipped(self, tmp_path: Path) -> None:
        # CLAUDE.md mentions `mgf-hrb` with a pin literal, but
        # the project doesn't depend on mgf-hrb. Don't false-positive
        # (could be a "we plan to add this" note).
        from mgf.fed.cli._catch_up import _check_claude_md_pin_literals

        self._write_project(
            tmp_path,
            pyproject_pin=">=0.40,<0.41",
            claude_md_prose=(
                "- Cornerstone: mgf-common (pinned >=0.40,<0.41).\n"
                "- Future: will depend on mgf-hrb (>=0.1,<0.2).\n"
            ),
        )
        finding = _check_claude_md_pin_literals(tmp_path)
        assert finding.severity == "ok"


# ---------------------------------------------------------------------------
# PAPER-84 — cross-sibling pin consistency check
# ---------------------------------------------------------------------------


class TestVersionSatisfiesConstraint:
    """Unit tests for `_version_satisfies_constraint` — the stdlib
    pin-checker that drives PAPER-84's conflict detection."""

    def test_simple_lower_bound_satisfied(self) -> None:
        from mgf.fed.cli._catch_up import _version_satisfies_constraint

        assert _version_satisfies_constraint("0.41.1", ">=0.40")

    def test_simple_upper_bound_violated(self) -> None:
        from mgf.fed.cli._catch_up import _version_satisfies_constraint

        assert not _version_satisfies_constraint("0.41.1", "<0.41")

    def test_compound_window(self) -> None:
        from mgf.fed.cli._catch_up import _version_satisfies_constraint

        assert _version_satisfies_constraint("0.40.0", ">=0.40,<0.41")
        assert not _version_satisfies_constraint("0.41.0", ">=0.40,<0.41")
        assert not _version_satisfies_constraint("0.39.0", ">=0.40,<0.41")

    def test_iosrecovery_canonical_scenario(self) -> None:
        # The exact PAPER-84 conflict: mgf-http 0.4.1 pins
        # `mgf-common<0.38,>=0.36`; proposed bump target 0.41.1
        # violates the upper bound.
        from mgf.fed.cli._catch_up import _version_satisfies_constraint

        assert not _version_satisfies_constraint("0.41.1", "<0.38,>=0.36")

    def test_three_part_versions(self) -> None:
        from mgf.fed.cli._catch_up import _version_satisfies_constraint

        assert _version_satisfies_constraint("0.41.1", ">=0.41.0,<0.42")
        assert not _version_satisfies_constraint("0.40.9", ">=0.41.0")


class TestCheckCrossSiblingPinCompatibility:
    """PAPER-84: catch-up's cross-sibling pin check refuses bumps
    that would make uv unable to resolve the project."""

    def test_no_other_siblings_returns_safe(self, tmp_path: Path) -> None:
        from mgf.fed.cli._catch_up import (
            _check_cross_sibling_pin_compatibility,
        )

        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            'dependencies = ["mgf-common>=0.40,<0.41"]\n',
            encoding="utf-8",
        )
        safe, conflicts = _check_cross_sibling_pin_compatibility(
            tmp_path,
            "0.41.1",
        )
        assert safe is True
        assert conflicts == []

    def test_sibling_pin_includes_target_returns_safe(
        self,
        tmp_path: Path,
        monkeypatch,
    ) -> None:
        # Sibling declares a wide pin that includes the target —
        # safe to bump.
        from mgf.fed.cli._catch_up import (
            _check_cross_sibling_pin_compatibility,
        )

        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            "dependencies = [\n"
            '    "mgf-common>=0.40,<0.41",\n'
            '    "mgf-http>=0.5,<0.6",\n'
            "]\n",
            encoding="utf-8",
        )
        # Mock PyPI lookup: mgf-http says it accepts mgf-common >=0.40
        # (no upper-bound conflict).
        monkeypatch.setattr(
            "mgf.fed.cli._catch_up._installed_sibling_mgf_common_pin",
            lambda _pkg: ">=0.40",
        )
        safe, conflicts = _check_cross_sibling_pin_compatibility(
            tmp_path,
            "0.41.1",
        )
        assert safe is True
        assert conflicts == []

    def test_sibling_pin_excludes_target_blocks_bump(
        self,
        tmp_path: Path,
        monkeypatch,
    ) -> None:
        # The iosrecovery canonical scenario.
        from mgf.fed.cli._catch_up import (
            _check_cross_sibling_pin_compatibility,
        )

        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "iosrecovery-sim"\nversion = "0.1.0"\n'
            "dependencies = [\n"
            '    "mgf-common>=0.38,<0.39",\n'
            '    "mgf-http>=0.4,<0.5",\n'
            "]\n",
            encoding="utf-8",
        )
        # Mock PyPI: mgf-http 0.4.1 pins mgf-common<0.38,>=0.36
        monkeypatch.setattr(
            "mgf.fed.cli._catch_up._installed_sibling_mgf_common_pin",
            lambda _pkg: "<0.38,>=0.36",
        )
        safe, conflicts = _check_cross_sibling_pin_compatibility(
            tmp_path,
            "0.41.1",
        )
        assert safe is False
        assert len(conflicts) == 1
        # The conflict message names the sibling + the specific pin.
        assert "mgf-http" in conflicts[0]
        assert "<0.38,>=0.36" in conflicts[0]
        assert "0.41.1" in conflicts[0]
        assert "uv sync" in conflicts[0]

    def test_unverifiable_pin_falls_back_to_refuse(
        self,
        tmp_path: Path,
        monkeypatch,
    ) -> None:
        # Approach C fallback: if we CAN'T read a sibling's pin (not
        # installed / unparseable), refuse the bump (better safe-refuse
        # than surprise-breakage).
        from mgf.fed.cli._catch_up import (
            _check_cross_sibling_pin_compatibility,
        )

        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            "dependencies = [\n"
            '    "mgf-common>=0.40,<0.41",\n'
            '    "mgf-fastapi>=0.5,<0.6",\n'
            "]\n",
            encoding="utf-8",
        )
        # Mock an unverifiable sibling (returns None).
        monkeypatch.setattr(
            "mgf.fed.cli._catch_up._installed_sibling_mgf_common_pin",
            lambda _pkg: None,
        )
        safe, conflicts = _check_cross_sibling_pin_compatibility(
            tmp_path,
            "0.41.1",
        )
        assert safe is False
        # Conflict message explains the conservative-refuse rationale.
        assert "Could not read" in conflicts[0]
        assert "mgf-fastapi" in conflicts[0]

    def test_multiple_siblings_all_compatible_returns_safe(
        self,
        tmp_path: Path,
        monkeypatch,
    ) -> None:
        # All siblings accept the target → bump is allowed.
        from mgf.fed.cli._catch_up import (
            _check_cross_sibling_pin_compatibility,
        )

        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            "dependencies = [\n"
            '    "mgf-common>=0.40,<0.41",\n'
            '    "mgf-http>=0.5,<0.6",\n'
            '    "mgf-fastapi>=0.7,<0.8",\n'
            "]\n",
            encoding="utf-8",
        )
        monkeypatch.setattr(
            "mgf.fed.cli._catch_up._installed_sibling_mgf_common_pin",
            lambda _pkg: ">=0.40,<0.50",  # all siblings accept 0.41.x
        )
        safe, conflicts = _check_cross_sibling_pin_compatibility(
            tmp_path,
            "0.41.1",
        )
        assert safe is True
        assert conflicts == []

    def test_multiple_siblings_one_conflicting_blocks(
        self,
        tmp_path: Path,
        monkeypatch,
    ) -> None:
        # 2 siblings present; one accepts target, one excludes it.
        # ANY conflict blocks.
        from mgf.fed.cli._catch_up import (
            _check_cross_sibling_pin_compatibility,
        )

        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            "dependencies = [\n"
            '    "mgf-common>=0.40,<0.41",\n'
            '    "mgf-http>=0.5,<0.6",\n'
            '    "mgf-fastapi>=0.7,<0.8",\n'
            "]\n",
            encoding="utf-8",
        )
        # First call (mgf-http): compatible.
        # Second call (mgf-fastapi): incompatible.
        call_log = []

        def _mock_pin(pkg: str) -> str | None:
            call_log.append(pkg)
            return ">=0.40" if pkg == "mgf-http" else "<0.40,>=0.39"

        monkeypatch.setattr(
            "mgf.fed.cli._catch_up._installed_sibling_mgf_common_pin",
            _mock_pin,
        )
        safe, conflicts = _check_cross_sibling_pin_compatibility(
            tmp_path,
            "0.41.1",
        )
        assert safe is False
        assert len(conflicts) == 1
        assert "mgf-fastapi" in conflicts[0]
        # Both siblings were checked.
        assert "mgf-http" in call_log
        assert "mgf-fastapi" in call_log


class TestInstalledSiblingMgfCommonPin:
    """PAPER-86 (v0.41.5+; v0.45 reads installed metadata, not pypi.org):
    the sibling pin probe must distinguish

      * ``constraint`` (str) — sibling has an unconditional runtime pin
      * ``_NO_RUNTIME_PIN`` — installed, but mgf-common only in extras / absent
      * ``None`` — unverifiable (the sibling is not installed here)

    A sibling with no runtime mgf-common dep (e.g.,
    mgf-test-supervisor pins it only behind `extra == 'dev'`) cannot
    be broken by any mgf-common bump and must NOT block the bump.
    """

    def _patch_requires(
        self,
        monkeypatch,
        requires: list[str] | None,
        *,
        installed: bool = True,
    ) -> None:
        """Patch ``importlib.metadata.requires`` to return ``requires``.

        ``installed=False`` simulates the sibling not being installed in
        this environment (raises PackageNotFoundError).
        """
        import importlib.metadata

        def _fake_requires(_name: str) -> list[str] | None:
            if not installed:
                raise importlib.metadata.PackageNotFoundError(_name)
            return requires

        monkeypatch.setattr(importlib.metadata, "requires", _fake_requires)

    def test_unconditional_pin_returns_constraint_string(
        self,
        monkeypatch,
    ) -> None:
        from mgf.fed.cli._catch_up import _installed_sibling_mgf_common_pin

        self._patch_requires(monkeypatch, ["mgf-common (>=0.44,<0.46)"])
        result = _installed_sibling_mgf_common_pin("mgf-http")
        assert result == ">=0.44,<0.46"

    def test_extras_only_returns_no_runtime_pin(
        self,
        monkeypatch,
    ) -> None:
        # mgf-test-supervisor canonical shape: mgf-common in `dev` extra only.
        from mgf.fed.cli._catch_up import (
            _NO_RUNTIME_PIN,
            _installed_sibling_mgf_common_pin,
        )

        self._patch_requires(
            monkeypatch,
            ['mgf-common; extra == "dev"', 'pytest>=8.0; extra == "dev"'],
        )
        result = _installed_sibling_mgf_common_pin("mgf-test-supervisor")
        assert result is _NO_RUNTIME_PIN

    def test_extras_only_with_constraint_returns_no_runtime_pin(
        self,
        monkeypatch,
    ) -> None:
        # Same shape as above but with an explicit constraint inside the
        # extras-gated line. Still no runtime pin.
        from mgf.fed.cli._catch_up import (
            _NO_RUNTIME_PIN,
            _installed_sibling_mgf_common_pin,
        )

        self._patch_requires(
            monkeypatch,
            ['mgf-common>=0.44,<0.46; extra == "dev"'],
        )
        result = _installed_sibling_mgf_common_pin("some-sibling")
        assert result is _NO_RUNTIME_PIN

    def test_no_mgf_common_at_all_returns_no_runtime_pin(
        self,
        monkeypatch,
    ) -> None:
        # Sibling installed, doesn't depend on mgf-common at all.
        from mgf.fed.cli._catch_up import (
            _NO_RUNTIME_PIN,
            _installed_sibling_mgf_common_pin,
        )

        self._patch_requires(monkeypatch, ["requests>=2.0", "click>=8.0"])
        result = _installed_sibling_mgf_common_pin("some-sibling")
        assert result is _NO_RUNTIME_PIN

    def test_no_dependencies_returns_no_runtime_pin(
        self,
        monkeypatch,
    ) -> None:
        # importlib.metadata.requires returns None when a package declares
        # no dependencies — installed, so not blocking, not unverifiable.
        from mgf.fed.cli._catch_up import (
            _NO_RUNTIME_PIN,
            _installed_sibling_mgf_common_pin,
        )

        self._patch_requires(monkeypatch, None)
        result = _installed_sibling_mgf_common_pin("leaf-sibling")
        assert result is _NO_RUNTIME_PIN

    def test_not_installed_returns_none(self, monkeypatch) -> None:
        from mgf.fed.cli._catch_up import _installed_sibling_mgf_common_pin

        self._patch_requires(monkeypatch, None, installed=False)
        result = _installed_sibling_mgf_common_pin("mgf-http")
        assert result is None

    def test_unconditional_wins_over_extras(self, monkeypatch) -> None:
        # If a sibling lists mgf-common BOTH unconditionally AND under
        # an extra, the unconditional pin is the binding constraint.
        from mgf.fed.cli._catch_up import _installed_sibling_mgf_common_pin

        self._patch_requires(
            monkeypatch,
            ['mgf-common; extra == "dev"', "mgf-common>=0.44,<0.46"],
        )
        result = _installed_sibling_mgf_common_pin("hybrid-sibling")
        assert result == ">=0.44,<0.46"

    def test_bare_mgf_common_returns_empty_constraint(
        self,
        monkeypatch,
    ) -> None:
        # `mgf-common` with no version pin means "any version" —
        # an empty constraint string, which the satisfies-check
        # treats as vacuously satisfied.
        from mgf.fed.cli._catch_up import (
            _installed_sibling_mgf_common_pin,
            _version_satisfies_constraint,
        )

        self._patch_requires(monkeypatch, ["mgf-common"])
        result = _installed_sibling_mgf_common_pin("any-version-sibling")
        assert result == ""
        # Sanity: an empty constraint accepts any version.
        assert _version_satisfies_constraint("0.41.5", result) is True


class TestCrossSiblingCheckHandlesNoRuntimePin:
    """PAPER-86 (v0.41.5+): the cross-sibling gate must skip siblings
    with no runtime mgf-common dep instead of treating them as
    unverifiable + refusing the bump.
    """

    def test_sibling_with_no_runtime_pin_does_not_block(
        self,
        tmp_path: Path,
        monkeypatch,
    ) -> None:
        # The PAPER-86 canonical scenario: project depends on
        # mgf-test-supervisor, which pins mgf-common only in `dev`
        # extra. The bump must NOT be blocked.
        from mgf.fed.cli._catch_up import (
            _NO_RUNTIME_PIN,
            _check_cross_sibling_pin_compatibility,
        )

        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            "dependencies = [\n"
            '    "mgf-common>=0.40,<0.41",\n'
            '    "mgf-test-supervisor>=0.1.2,<0.2",\n'
            "]\n",
            encoding="utf-8",
        )
        monkeypatch.setattr(
            "mgf.fed.cli._catch_up._installed_sibling_mgf_common_pin",
            lambda _pkg: _NO_RUNTIME_PIN,
        )
        safe, conflicts = _check_cross_sibling_pin_compatibility(
            tmp_path,
            "0.41.5",
        )
        assert safe is True
        assert conflicts == []

    def test_mixed_siblings_no_runtime_and_compatible(
        self,
        tmp_path: Path,
        monkeypatch,
    ) -> None:
        # One sibling has no runtime pin (skip); the other has a
        # compatible pin (pass). Bump proceeds.
        from mgf.fed.cli._catch_up import (
            _NO_RUNTIME_PIN,
            _check_cross_sibling_pin_compatibility,
        )

        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            "dependencies = [\n"
            '    "mgf-common>=0.40,<0.41",\n'
            '    "mgf-http>=0.5,<0.6",\n'
            '    "mgf-test-supervisor>=0.1.2,<0.2",\n'
            "]\n",
            encoding="utf-8",
        )

        def _mock_pin(pkg):
            if pkg == "mgf-test-supervisor":
                return _NO_RUNTIME_PIN
            return ">=0.40,<0.50"

        monkeypatch.setattr(
            "mgf.fed.cli._catch_up._installed_sibling_mgf_common_pin",
            _mock_pin,
        )
        safe, conflicts = _check_cross_sibling_pin_compatibility(
            tmp_path,
            "0.41.5",
        )
        assert safe is True
        assert conflicts == []

    def test_mixed_no_runtime_and_conflicting_blocks(
        self,
        tmp_path: Path,
        monkeypatch,
    ) -> None:
        # No-runtime-pin sibling is silent; conflicting sibling still
        # blocks. The conflict message is for the conflicting one only.
        from mgf.fed.cli._catch_up import (
            _NO_RUNTIME_PIN,
            _check_cross_sibling_pin_compatibility,
        )

        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            "dependencies = [\n"
            '    "mgf-common>=0.40,<0.41",\n'
            '    "mgf-http>=0.4,<0.5",\n'
            '    "mgf-test-supervisor>=0.1.2,<0.2",\n'
            "]\n",
            encoding="utf-8",
        )

        def _mock_pin(pkg):
            if pkg == "mgf-test-supervisor":
                return _NO_RUNTIME_PIN
            return "<0.41,>=0.40"  # excludes 0.41.5

        monkeypatch.setattr(
            "mgf.fed.cli._catch_up._installed_sibling_mgf_common_pin",
            _mock_pin,
        )
        safe, conflicts = _check_cross_sibling_pin_compatibility(
            tmp_path,
            "0.41.5",
        )
        assert safe is False
        assert len(conflicts) == 1
        assert "mgf-http" in conflicts[0]
        assert "mgf-test-supervisor" not in conflicts[0]


# ---------------------------------------------------------------------------
# PAPER-83 — uv sources path existence audit
# ---------------------------------------------------------------------------


class TestCheckUvSourcesPaths:
    """PAPER-83: `[tool.uv.sources]` entries with `path =` values can
    silently break when the federation renames a sibling and the
    editable-install path stops existing. This audit surfaces broken
    paths + suggests separator-drift fixes when unambiguous."""

    def test_no_uv_sources_returns_ok(self, tmp_path: Path) -> None:
        from mgf.fed.cli._catch_up import _check_uv_sources_paths

        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\ndependencies = []\n',
            encoding="utf-8",
        )
        finding = _check_uv_sources_paths(tmp_path)
        assert finding.severity == "ok"
        assert "no `[tool.uv.sources]`" in finding.detail

    def test_existing_path_returns_ok(self, tmp_path: Path) -> None:
        from mgf.fed.cli._catch_up import _check_uv_sources_paths

        # Create a directory that the sources entry points at.
        sibling = tmp_path / "sibling"
        sibling.mkdir()
        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            "dependencies = []\n\n"
            "[tool.uv.sources]\n"
            'mgf-common = { path = "./sibling", editable = true }\n',
            encoding="utf-8",
        )
        finding = _check_uv_sources_paths(tmp_path)
        assert finding.severity == "ok"
        assert "1 `[tool.uv.sources]` path entries" in finding.detail

    def test_missing_path_warns(self, tmp_path: Path) -> None:
        from mgf.fed.cli._catch_up import _check_uv_sources_paths

        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            "dependencies = []\n\n"
            "[tool.uv.sources]\n"
            'mgf-common = { path = "./does-not-exist", editable = true }\n',
            encoding="utf-8",
        )
        finding = _check_uv_sources_paths(tmp_path)
        assert finding.severity == "warn"
        assert "1 `[tool.uv.sources]` entry" in finding.detail
        assert "mgf-common" in finding.detail
        assert "does not exist" in finding.detail

    def test_separator_drift_suggestion(self, tmp_path: Path) -> None:
        # The NM-04 canonical scenario: the entry points at
        # underscore form but only the hyphen form exists.
        from mgf.fed.cli._catch_up import _check_uv_sources_paths

        # The directory the path-with-hyphen would resolve to:
        (tmp_path / "sibling-name").mkdir()
        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            "dependencies = []\n\n"
            "[tool.uv.sources]\n"
            'mgf-foo = { path = "./sibling_name", editable = true }\n',
            encoding="utf-8",
        )
        finding = _check_uv_sources_paths(tmp_path)
        assert finding.severity == "warn"
        # Suggestion present + names the corrected path.
        assert "did you mean" in finding.detail
        assert "sibling-name" in finding.detail
        assert "separator drift" in finding.detail

    def test_multiple_broken_entries(self, tmp_path: Path) -> None:
        from mgf.fed.cli._catch_up import _check_uv_sources_paths

        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            "dependencies = []\n\n"
            "[tool.uv.sources]\n"
            'a = { path = "./missing1" }\n'
            'b = { path = "./missing2" }\n',
            encoding="utf-8",
        )
        finding = _check_uv_sources_paths(tmp_path)
        assert finding.severity == "warn"
        assert "2 `[tool.uv.sources]` entry" in finding.detail
        assert "missing1" in finding.detail
        assert "missing2" in finding.detail

    def test_non_path_entries_skipped(self, tmp_path: Path) -> None:
        # Entries with `git = ` or `url = ` (not `path =`) aren't
        # filesystem-checkable; should be skipped.
        from mgf.fed.cli._catch_up import _check_uv_sources_paths

        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "demo"\nversion = "0.1.0"\n'
            "dependencies = []\n\n"
            "[tool.uv.sources]\n"
            'mgf-test-supervisor = { git = "ssh://example/x.git" }\n',
            encoding="utf-8",
        )
        finding = _check_uv_sources_paths(tmp_path)
        # No `path =` entries → ok with "all 0 entries resolve".
        # (We don't strictly say "0" but the count is right.)
        assert finding.severity == "ok"


# A deterministic two-member roster for the federation-position tests, so they
# don't depend on the live on-disk federation / installed registry.
_FED_ROSTER = [
    {"repo": "mgf-common", "version": "0.44.2", "deps": {}, "used_by": ["mgf-x"]},
    {
        "repo": "mgf-x",
        "version": "0.3.0",
        "deps": {"mgf-common": ">=0.44,<0.45"},
        "used_by": ["mgf-y"],
    },
]


class TestCheckFederationPosition:
    """`_check_federation_position` — the catch-up wiring onto the
    mgf-standard registry seam (`libraries()`): reports the project's
    place in the roster and flags mgf-* pins that lag the current versions.
    Read-only / advisory — it never carries a fix (catch-up doesn't
    auto-bump dependency pins)."""

    def test_info_when_registry_unavailable(self, tmp_path, monkeypatch) -> None:
        from mgf.fed.cli import _catch_up

        monkeypatch.setattr(_catch_up, "_resolve_libraries", lambda: None)
        finding = _catch_up._check_federation_position(tmp_path, "mgf-x")
        assert finding.severity == "info"
        assert finding.fix is None
        assert "mgf-standard" in finding.detail

    def test_ok_when_pins_keep_pace(self, tmp_path, monkeypatch) -> None:
        from mgf.fed.cli import _catch_up

        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "mgf-x"\ndependencies = ["mgf-common>=0.44,<0.45"]\n',
            encoding="utf-8",
        )
        monkeypatch.setattr(_catch_up, "_resolve_libraries", lambda: _FED_ROSTER)
        finding = _catch_up._check_federation_position(tmp_path, "mgf-x")
        assert finding.severity == "ok"
        assert "keep pace" in finding.detail
        assert "used by: mgf-y" in finding.detail  # reverse-dep surfaced

    def test_warn_when_a_pin_lags(self, tmp_path, monkeypatch) -> None:
        from mgf.fed.cli import _catch_up

        # Pin caps mgf-common <0.44 but the roster is at 0.44.2 → stale.
        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "mgf-x"\ndependencies = ["mgf-common>=0.41,<0.44"]\n',
            encoding="utf-8",
        )
        monkeypatch.setattr(_catch_up, "_resolve_libraries", lambda: _FED_ROSTER)
        finding = _catch_up._check_federation_position(tmp_path, "mgf-x")
        assert finding.severity == "warn"
        assert "mgf-common" in finding.detail
        assert "0.44.2" in finding.detail
        assert finding.fix is None  # advisory — never auto-bumps

    def test_snapshot_provenance_named_when_standard_in_roster(self, tmp_path, monkeypatch) -> None:
        # PAPER-98: the offline audit must state WHICH mgf-standard snapshot
        # it read, and point at the agent-side live check — so a green verdict
        # is never silently mistaken for "current with the latest published."
        from mgf.fed.cli import _catch_up

        roster = [
            *_FED_ROSTER,
            {"repo": "mgf-standard", "version": "0.1.18", "deps": {}, "used_by": []},
        ]
        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "mgf-x"\ndependencies = ["mgf-common>=0.44,<0.45"]\n',
            encoding="utf-8",
        )
        monkeypatch.setattr(_catch_up, "_resolve_libraries", lambda: roster)
        finding = _catch_up._check_federation_position(tmp_path, "mgf-x")
        assert finding.severity == "ok"
        assert "Registry snapshot = mgf-standard 0.1.18" in finding.detail
        assert "ASSERT-LATEST" in finding.detail

    def test_snapshot_provenance_degrades_when_standard_absent(self, tmp_path, monkeypatch) -> None:
        # The fixture roster has no mgf-standard entry → the note still
        # appears, naming "the installed mgf-standard" rather than a version.
        from mgf.fed.cli import _catch_up

        (tmp_path / "pyproject.toml").write_text(
            '[project]\nname = "mgf-x"\ndependencies = ["mgf-common>=0.44,<0.45"]\n',
            encoding="utf-8",
        )
        monkeypatch.setattr(_catch_up, "_resolve_libraries", lambda: _FED_ROSTER)
        finding = _catch_up._check_federation_position(tmp_path, "mgf-x")
        assert "Registry snapshot = the installed mgf-standard" in finding.detail


class TestCommitScaffold:
    """PAPER-102 — catch-up distributes the canonical AG-03 hook + CI checker,
    resolved from mgf-standard via read_scaffold (single source of truth)."""

    _LS = "mgf.standard.list_scaffold"
    _RS = "mgf.standard.read_scaffold"

    def _seed_corpus(self, monkeypatch) -> dict[str, str]:
        canonical = {
            "prepare-commit-msg": "#!/bin/sh\n# canonical hook\n",
            "check_commit_trailers.py": "# canonical checker\n",
        }
        monkeypatch.setattr(self._LS, lambda: sorted(canonical), raising=False)
        monkeypatch.setattr(self._RS, lambda name: canonical[name], raising=False)
        return canonical

    def test_degrades_when_read_scaffold_absent(self, tmp_path, monkeypatch) -> None:
        # An old corpus has no list_scaffold/read_scaffold → getattr returns None.
        import mgf.standard as std

        monkeypatch.delattr(std, "list_scaffold", raising=False)
        monkeypatch.delattr(std, "read_scaffold", raising=False)
        f = _check_commit_scaffold(tmp_path)
        assert f.severity == "info"
        assert f.fix is None
        assert "read_scaffold" in f.detail

    def test_missing_offers_a_fix_that_writes_canonical(self, tmp_path, monkeypatch) -> None:
        canonical = self._seed_corpus(monkeypatch)
        f = _check_commit_scaffold(tmp_path)  # empty repo
        assert f.severity == "info"
        assert f.fix is not None
        # Apply the fix → both files written with canonical content.
        _write_commit_scaffold(tmp_path)
        assert (tmp_path / ".githooks" / "prepare-commit-msg").read_text() == canonical[
            "prepare-commit-msg"
        ]
        assert (tmp_path / "scripts" / "check_commit_trailers.py").read_text() == canonical[
            "check_commit_trailers.py"
        ]

    def test_ok_when_present_and_canonical(self, tmp_path, monkeypatch) -> None:
        self._seed_corpus(monkeypatch)
        _write_commit_scaffold(tmp_path)  # seed the canonical files
        f = _check_commit_scaffold(tmp_path)
        assert f.severity == "ok"
        assert f.fix is None

    def test_stale_copy_is_flagged(self, tmp_path, monkeypatch) -> None:
        self._seed_corpus(monkeypatch)
        hook = tmp_path / ".githooks" / "prepare-commit-msg"
        hook.parent.mkdir(parents=True)
        hook.write_text("#!/bin/sh\n# OLD divergent hook\n")
        f = _check_commit_scaffold(tmp_path)
        assert f.severity == "info"
        assert f.fix is not None
        assert "prepare-commit-msg" in f.detail
