"""``mgf-fed catch-up`` — audit + update an existing project against current standards.

Closes the v0.11.0 deliverable: existing projects (vm-vmanager-app,
inthewords, future adopters) need an audit-and-update flow, not
the create-from-scratch shape ``init-project`` ships. ``catch-up``:

  - Reads the project's pyproject.toml + docs/CLAUDE.md +
    .claude/settings.json + (optionally) src/.
  - Compares against the current init-project templates +
    standards.
  - Prints a drift report (✅ / ⚠️ / 🔴 per row).
  - With ``--apply``, performs ONLY safe mechanical updates:
    creates missing files, refreshes the auto-managed CLAUDE.md
    block (between BEGIN/END markers), backs up originals.
    Pyproject snippets and v0.1-pattern migrations are surfaced
    as suggestions, not auto-modified — too easy to clobber
    project-specific config.

Read-only by default. ``--apply`` is the explicit opt-in.
"""

from __future__ import annotations

import ast
import re
import sys
import time
import tomllib
from pathlib import Path
from typing import TYPE_CHECKING, Literal

from mgf.fed.cli._init_project import (
    AGENTS_MD_CONTENT,
    CLAUDE_MD_BEGIN_MARKER,
    CLAUDE_MD_END_MARKER,
    _render_claude_md,
    _render_claude_settings,
)
from mgf.fed.cli._standards import _resolve_libraries

if TYPE_CHECKING:
    import argparse
    from collections.abc import Callable


_Severity = Literal["ok", "warn", "fail", "info"]
_SEVERITY_ICON: dict[_Severity, str] = {
    "ok": "✅",
    "warn": "⚠️ ",
    "fail": "🔴",
    "info": "ℹ️ ",
}


class _Finding:
    """One row in the audit report."""

    __slots__ = ("detail", "fix", "severity", "title")

    def __init__(
        self,
        severity: _Severity,
        title: str,
        detail: str,
        fix: Callable[[Path], str] | None = None,
    ) -> None:
        self.severity = severity
        self.title = title
        self.detail = detail
        self.fix = fix


def add_catch_up_args(parser: argparse.ArgumentParser) -> None:
    """Register the ``catch-up`` subcommand's arguments."""
    parser.add_argument(
        "--path",
        type=Path,
        default=Path.cwd(),
        help=(
            "Target project root (default: current working directory). "
            "Must contain a pyproject.toml or be the root of a project "
            "that depends on mgf-common."
        ),
    )
    parser.add_argument(
        "--apply",
        action="store_true",
        help=(
            "Apply mechanical fixes (create missing CLAUDE.md / "
            ".claude/settings.json; refresh CLAUDE.md auto-block "
            "between BEGIN/END markers). Originals are backed up to "
            "<file>.bak.<timestamp>. Read-only without this flag."
        ),
    )
    parser.add_argument(
        "--name",
        help=(
            "Project name (used when CLAUDE.md is missing entirely "
            "and we need to render a fresh one). Defaults to the "
            "project's name from pyproject.toml."
        ),
    )
    parser.add_argument(
        "--kind",
        choices=("cli", "fastapi", "django", "library", "service", "docs"),
        help=(
            "Project kind (used when CLAUDE.md is missing entirely). "
            "Defaults to 'library' if it can't be guessed; a repo with no "
            "Python at all is detected as 'docs' (PAPER-97)."
        ),
    )
    # The mgf-* version/pin checks resolve OFFLINE — version currency from
    # the federation registry data (`mgf.standard.libraries()`), the
    # cross-sibling runtime pin from the installed siblings' own metadata —
    # never a pypi.org scrape (PKG-11 / PKG-12, the Codeberg cutover at
    # v0.45). The old `--check-pypi` / `--no-check-pypi` tristate (PAPER-65 /
    # PAPER-68) gated an outbound PyPI HTTP call that no longer exists, so
    # the flags were removed.


def run_catch_up(args: argparse.Namespace) -> int:
    """Run the audit, optionally apply fixes."""
    target_dir = args.path.resolve()
    if not target_dir.is_dir():
        print(
            f"error: target path {target_dir} is not a directory.",
            file=sys.stderr,
        )
        return 1

    project_name, project_kind = _resolve_project_identity(target_dir, args)

    findings = _collect_findings(target_dir, project_name, project_kind)

    _print_report(findings, target_dir, project_name, project_kind)

    if not args.apply:
        # Read-only mode. Done.
        return _exit_code_for(findings)

    # --apply mode: run any fixes that are safe + automatic.
    print()
    print("Applying mechanical fixes …")
    # PAPER-44: snapshot existing `.bak.<unix-timestamp>` files
    # BEFORE the apply loop so we can identify (and clean up)
    # only the backups WE wrote, not pre-existing ones.
    baks_before = _scan_timestamp_backups(target_dir)

    applied = 0
    for finding in findings:
        if finding.fix is None:
            continue
        result = finding.fix(target_dir)
        print(f"  {_SEVERITY_ICON['info']} {finding.title}: {result}")
        applied += 1
    if applied == 0:
        print("  (nothing to apply — everything mechanical is already current.)")
        return _exit_code_for(findings)

    # PAPER-44: auto-clean .bak.<unix-timestamp> backups on
    # verified success. Re-run the same checks; if no finding
    # carries a `fix` (i.e., the mechanical issues are now
    # resolved), the safety-net backups served their purpose and
    # can be removed. If verification fails, retain the backups
    # and tell the consumer where they are.
    new_baks = _scan_timestamp_backups(target_dir) - baks_before
    verify_findings = _collect_findings(target_dir, project_name, project_kind)
    verify_clean = all(f.fix is None for f in verify_findings)

    print()
    print(f"Applied {applied} mechanical fix(es).")
    if new_baks and verify_clean:
        cleaned = 0
        for bak in sorted(new_baks):
            try:
                bak.unlink()
                cleaned += 1
            except OSError as exc:
                print(f"  warning: failed to clean {bak.name}: {exc}", file=sys.stderr)
        print(
            f"Verified clean — cleaned up {cleaned} backup file"
            f"{'s' if cleaned != 1 else ''} (.bak.<timestamp>)."
        )
    elif new_baks:
        print(
            f"Retained {len(new_baks)} backup file"
            f"{'s' if len(new_baks) != 1 else ''} — re-audit is not yet "
            f"clean. Inspect:"
        )
        for bak in sorted(new_baks):
            print(f"    {bak.relative_to(target_dir)}")
        print(
            "Re-run `mgf-fed catch-up` after addressing the "
            "remaining findings; the next clean run will sweep the "
            "backups."
        )
    print(
        "Tip: add `**/*.bak.[0-9]*` to your `.gitignore` so future "
        "catch-up backups can't be accidentally committed."
    )

    return _exit_code_for(verify_findings)


def _na(title: str, detail: str) -> _Finding:
    """An ``info`` (ℹ️, non-failing) finding marking a check Not Applicable.

    PAPER-97: used for the language-specific rows on a ``docs`` repo so a
    non-Python tree reads as N/A, not as a wall of 🔴/⚠ false positives.
    """
    return _Finding(
        "info",
        title,
        f"N/A — {detail} (non-Python `docs` repo; PAPER-97).",
    )


_SCAFFOLD_DEST = {
    "prepare-commit-msg": Path(".githooks") / "prepare-commit-msg",
    "check_commit_trailers.py": Path("scripts") / "check_commit_trailers.py",
}


def _commit_scaffold_artifacts() -> dict[str, str] | None:
    """The canonical AG-03 hook + CI checker text, resolved from the pinned corpus.

    Returns ``None`` when the installed ``mgf.standard`` predates ``read_scaffold``
    (mgf-standard < 0.1.57), so the caller degrades gracefully instead of crashing.
    """
    try:
        import mgf.standard as std
    except (ModuleNotFoundError, ImportError):
        return None
    # getattr (not a direct import) so a corpus predating these accessors
    # (mgf-standard < 0.1.57) resolves to None instead of failing the import /
    # the static attribute check.
    list_scaffold = getattr(std, "list_scaffold", None)
    read_scaffold = getattr(std, "read_scaffold", None)
    if list_scaffold is None or read_scaffold is None:
        return None
    try:
        return {str(name): str(read_scaffold(name)) for name in list_scaffold()}
    except (FileNotFoundError, OSError):
        return None


def _read_or_none(path: Path) -> str | None:
    try:
        return path.read_text(encoding="utf-8")
    except OSError:
        return None


def _write_commit_scaffold(target_dir: Path) -> str:
    """``--apply`` fix — write the canonical hook + CI checker into the repo."""
    canonical = _commit_scaffold_artifacts()
    if canonical is None:
        return "skipped (read_scaffold unavailable — update mgf-standard >= 0.1.57)"
    written: list[str] = []
    for name, text in canonical.items():
        dest = target_dir / _SCAFFOLD_DEST[name]
        dest.parent.mkdir(parents=True, exist_ok=True)
        dest.write_text(text, encoding="utf-8")
        if name == "prepare-commit-msg":
            dest.chmod(0o755)
        written.append(str(_SCAFFOLD_DEST[name]))
    return (
        f"wrote {', '.join(sorted(written))}; "
        "activate with `git config core.hooksPath .githooks` (per-clone)"
    )


def _check_commit_scaffold(target_dir: Path) -> _Finding:
    """AG-03/AG-18 — distribute the canonical ``prepare-commit-msg`` hook + the
    ``check_commit_trailers.py`` CI checker into the repo (mgf-common FEEDBACK PAPER-102).

    Advisory (``info``) for now — the AG-18 rule text is not yet tightened, so this
    is a forward-looking scaffold offer, not a hard fail; once tightened,
    ``doctor --standards`` enforces presence. Degrades to a no-fix ``info`` when the
    pinned corpus predates ``read_scaffold`` (mgf-standard < 0.1.57).
    """
    title = "AG-03 commit-trailer scaffold (.githooks + scripts)"
    canonical = _commit_scaffold_artifacts()
    if canonical is None:
        return _Finding(
            "info",
            title,
            "pinned corpus predates read_scaffold — update mgf-common[standards] "
            "(mgf-standard >= 0.1.57) to scaffold the AG-03 hook + CI checker.",
        )
    stale = sorted(
        name
        for name, text in canonical.items()
        if _read_or_none(target_dir / _SCAFFOLD_DEST[name]) != text
    )
    if not stale:
        return _Finding("ok", title, "prepare-commit-msg hook + CI checker present + canonical.")
    return _Finding(
        "info",
        title,
        f"missing/stale: {', '.join(stale)} — run `--apply` to write the canonical files "
        "(then `git config core.hooksPath .githooks`).",
        fix=_write_commit_scaffold,
    )


def _collect_findings(
    target_dir: Path,
    project_name: str,
    project_kind: str,
) -> list[_Finding]:
    """Assemble the audit findings (single source for the run + the
    ``--apply`` verify pass, so the two can't drift).

    PAPER-97: for a ``docs`` (non-Python) repo the language-specific
    checks — the pyproject pin, the four gates, the DOC-17 library
    doc-contract, the test-supervisor, the marker taxonomy, and the
    Woodpecker pipeline — are reported **N/A** (``info``) instead of
    being audited as if the repo were a Python library. The
    language-agnostic rows (CLAUDE.md, AGENTS.md, settings, federation
    position, uv.sources, private-import leaks) run unchanged.
    """
    is_docs = project_kind == "docs"
    findings: list[_Finding] = []
    findings.append(
        _na("pyproject.toml — mgf-common pin", "no pyproject.toml / Python package")
        if is_docs
        else _check_pyproject_pin(target_dir)
    )
    findings.append(
        _na("Four gates (ruff / mypy / pytest / coverage)", "no Python to gate")
        if is_docs
        else _check_four_gates(target_dir)
    )
    findings.append(_check_claude_md(target_dir, project_name, project_kind))
    findings.append(_check_claude_md_pin_literals(target_dir))
    findings.append(_check_claude_settings(target_dir, project_kind))
    findings.append(_check_agents_md(target_dir))
    findings.append(_check_memory_md(target_dir))
    findings.append(_check_gitignore(target_dir))
    findings.append(
        _na("AG-03 commit-trailer scaffold (.githooks + scripts)", "non-Python build/CI")
        if is_docs
        else _check_commit_scaffold(target_dir)
    )
    findings.append(_check_v01_patterns(target_dir))
    findings.append(_check_closed_box_leaks(target_dir))
    findings.append(
        _na("Federation contract (DOC-17 / FEDERATION.md)", "library doc-contract")
        if is_docs
        else _check_federation_contract(target_dir, project_name)
    )
    findings.append(
        _na("Federation test supervisor (TS-22..TS-27)", "no test suite")
        if is_docs
        else _check_test_supervisor_adoption(target_dir, project_name)
    )
    findings.append(
        _na("Federation marker taxonomy (TS-25)", "no pytest markers")
        if is_docs
        else _check_federation_markers(target_dir)
    )
    findings.append(_check_federation_position(target_dir, project_name))
    findings.append(_check_uv_sources_paths(target_dir))
    findings.append(
        _na("CI/CD — Woodpecker config (WF-01..WF-12)", "no Python build/test pipeline")
        if is_docs
        else _check_woodpecker_ci(target_dir)
    )
    return findings


def _scan_timestamp_backups(target_dir: Path) -> set[Path]:
    """Return the set of ``.bak.<unix-timestamp>`` files under ``target_dir``.

    Used by ``--apply`` (PAPER-44) to identify which backups were
    written by the current run vs pre-existing ones. The unix-
    timestamp suffix pattern is specific enough that it won't match
    consumer-authored ``.bak`` files (which typically have no suffix
    or a human-readable one like ``.bak-2024-01-15``).

    Skips ``_AUDIT_SKIP_DIRS`` so the scan stays bounded — same
    discipline as the audit walks elsewhere in this module.
    """
    out: set[Path] = set()
    if not target_dir.is_dir():
        return out
    for path in target_dir.rglob("*.bak.*"):
        if any(part in _AUDIT_SKIP_DIRS for part in path.parts):
            continue
        # `Path("foo.toml.bak.1747000000").suffix` is ".1747000000".
        # If the suffix is all digits after the leading dot, it
        # matches the catch-up backup naming convention.
        if path.suffix.lstrip(".").isdigit():
            out.add(path)
    return out


# ---------------------------------------------------------------------------
# Identity resolution.
# ---------------------------------------------------------------------------


def _resolve_project_identity(
    target_dir: Path,
    args: argparse.Namespace,
) -> tuple[str, str]:
    """Resolve project name + kind from CLI args + pyproject.toml.

    v0.17.2+: kind detection is heuristic when not given on the
    CLI. Closes PAPER-13 (no pyproject case) + PAPER-17 (with
    pyproject case). Returns one of the existing init-project
    template kinds: ``cli``, ``fastapi``, ``django``, ``library``,
    ``service``. Default fall-through is ``library`` when no
    framework / scripts / Django-marker is found.
    """
    name = args.name
    kind = args.kind

    if name is None:
        name = _read_pyproject_name(target_dir) or target_dir.name
    if kind is None:
        kind = _detect_kind(target_dir)

    return name, kind


def _detect_kind(target_dir: Path) -> str:
    """Heuristic kind detection. Returns one of cli / fastapi /
    django / library / service. Closes PAPER-13 + PAPER-17.

    Resolution ladder, most-specific first:

      0. No Python at all (PAPER-97) → ``docs``. A repo with no
         ``pyproject.toml`` / ``setup.py`` / ``setup.cfg`` AND no
         ``*.py`` anywhere is a documentation / brand / content repo,
         not a library — audited with the Python-standards corpus
         marked N/A instead of mis-defaulting to ``library``.
      1. ``pyproject.toml`` deps + scripts (PAPER-17). Reads
         ``[project] dependencies`` and ``[project.scripts]``;
         framework markers in deps → ``fastapi`` / ``django``;
         non-empty scripts without framework → ``cli``;
         otherwise → ``library``.
      2. No-pyproject markers (PAPER-13). ``manage.py`` at the
         project root → ``django``. Other no-pyproject project
         shapes fall through to ``library`` (the default).
    """
    if _is_non_python_repo(target_dir):
        return "docs"
    pp_kind = _detect_kind_from_pyproject(target_dir)
    if pp_kind is not None:
        return pp_kind
    np_kind = _detect_kind_no_pyproject(target_dir)
    if np_kind is not None:
        return np_kind
    return "library"


def _is_non_python_repo(target_dir: Path) -> bool:
    """PAPER-97 — True when the repo contains no Python at all.

    No ``pyproject.toml`` / ``setup.py`` / ``setup.cfg`` AND no
    ``*.py`` anywhere in the tree (``_AUDIT_SKIP_DIRS`` excluded, and
    short-circuits on the first ``.py`` found so big trees stay cheap).
    Such a repo is a documentation / brand / content repo; ``catch-up``
    audits it as ``kind: docs`` with the language-specific checks
    reported N/A rather than as a wall of inapplicable findings.
    """
    for marker in ("pyproject.toml", "setup.py", "setup.cfg"):
        if (target_dir / marker).is_file():
            return False
    for path in target_dir.rglob("*.py"):
        if any(part in _AUDIT_SKIP_DIRS for part in path.parts):
            continue
        return False
    return True


def _detect_kind_from_pyproject(target_dir: Path) -> str | None:
    """PAPER-17: parse pyproject.toml deps + scripts to detect kind.

    Returns ``None`` when there's no pyproject.toml — the caller
    falls through to the no-pyproject heuristic.
    """
    pp = target_dir / "pyproject.toml"
    if not pp.is_file():
        return None
    try:
        text = pp.read_text(encoding="utf-8")
    except (OSError, UnicodeDecodeError):
        return None

    # Cheap line-grep: pyproject can't be parsed without tomllib /
    # tomli, and we want to stay stdlib-only on 3.11+. The heuristic
    # is forgiving — match the canonical declaration shape.
    deps_block = _extract_block(text, "dependencies")
    scripts_block = _extract_block(text, "[project.scripts]")

    deps_lower = deps_block.lower() if deps_block else ""

    # Framework markers (most-specific first).
    if re.search(r'["\']fastapi(?:\[|>=|<|=|~|"|\')', deps_lower):
        return "fastapi"
    if re.search(r'["\']django(?:\[|>=|<|=|~|"|\')', deps_lower):
        return "django"
    if re.search(r'["\']flask(?:\[|>=|<|=|~|"|\')', deps_lower):
        # No 'flask' in init-project's _KINDS today; closest match
        # is 'service' (generic web/service shape).
        return "service"

    # CLI shape — has console scripts, no web framework above.
    if scripts_block and "=" in scripts_block:
        return "cli"

    # No framework, no scripts → real library.
    return "library"


def _extract_block(text: str, marker: str) -> str | None:
    """Pull the ``[project] dependencies = [ ... ]`` array text or a
    section block following ``[project.scripts]``. Returns None when
    the marker isn't found. Cheap; doesn't validate TOML structure
    beyond what the heuristic needs."""
    if marker == "dependencies":
        # `dependencies = [ ... ]` — multiline array.
        m = re.search(r"^\s*dependencies\s*=\s*\[", text, re.MULTILINE)
        if m is None:
            return None
        # Find the matching closing bracket. Naive but fine for our
        # use — pyproject deps don't nest arrays.
        start = m.end()
        depth = 1
        i = start
        while i < len(text) and depth > 0:
            if text[i] == "[":
                depth += 1
            elif text[i] == "]":
                depth -= 1
            i += 1
        return text[start:i]
    # Section header marker like "[project.scripts]" — return the body
    # until the next section header.
    idx = text.find(marker)
    if idx == -1:
        return None
    body_start = text.find("\n", idx) + 1
    next_section = re.search(r"^\s*\[", text[body_start:], re.MULTILINE)
    body_end = body_start + next_section.start() if next_section else len(text)
    return text[body_start:body_end]


def _detect_kind_no_pyproject(target_dir: Path) -> str | None:
    """PAPER-13: detect kind when pyproject.toml is absent.

    ``manage.py`` at the project root → ``django`` (Django's
    canonical project layout marker). Other no-pyproject shapes
    return None (let the caller fall through to ``library``).
    """
    if (target_dir / "manage.py").is_file():
        return "django"
    return None


def _has_pip_tools_layout(target_dir: Path) -> bool:
    """PAPER-15: detect a pip-tools-style requirements layout.

    True if ``requirements/`` exists with at least one ``.txt`` /
    ``.lock`` inside, OR if a top-level ``requirements.txt`` /
    ``requirements.lock`` exists. v0.32.0 onwards, detection drives
    the full pin + four-gate audit via ``_check_pin_in_requirements``
    and ``_check_four_gates_standalone``.
    """
    req_dir = target_dir / "requirements"
    if req_dir.is_dir() and any(
        p.is_file() and p.suffix in (".txt", ".lock") for p in req_dir.iterdir()
    ):
        return True
    return (target_dir / "requirements.txt").is_file() or (
        target_dir / "requirements.lock"
    ).is_file()


def _read_pyproject_name(target_dir: Path) -> str | None:
    """Pull `[project] name = "..."` from pyproject.toml."""
    pyproject = target_dir / "pyproject.toml"
    if not pyproject.is_file():
        return None
    try:
        text = pyproject.read_text(encoding="utf-8")
    except OSError:
        return None
    match = re.search(
        r'^\s*name\s*=\s*"([^"]+)"',
        text,
        re.MULTILINE,
    )
    return match.group(1) if match else None


# ---------------------------------------------------------------------------
# Individual audit checks. Each returns one _Finding.
# ---------------------------------------------------------------------------


def _is_editable_install() -> bool:
    """Return True if the running mgf-common is installed editable.

    Editable installs (``pip install -e ../mgf_common``,
    ``[tool.uv.sources]`` workspace pins) can run AHEAD of the
    latest published version in the federation registry. Auto-rewriting
    a consumer's pin against an editable install produces a pin that no
    resolver can satisfy until the published roster catches up (closes
    PAPER-20).

    Detection uses ``importlib.metadata.distribution(...).read_text(
    "direct_url.json")`` — the marker PEP 660 editable installers
    write to dist-info. Any ``"dir_info": {"editable": true}`` flag
    in the JSON means we're running from an editable.
    """
    import importlib.metadata
    import json

    try:
        dist = importlib.metadata.distribution("mgf-common")
    except importlib.metadata.PackageNotFoundError:
        return False
    try:
        raw = dist.read_text("direct_url.json")
    except (OSError, FileNotFoundError):
        return False
    if raw is None:
        return False
    try:
        payload = json.loads(raw)
    except json.JSONDecodeError:
        return False
    return bool(payload.get("dir_info", {}).get("editable"))


class _NoRuntimePinSentinel:
    """PAPER-86 (v0.41.5+). Tri-state sentinel for
    :func:`_installed_sibling_mgf_common_pin`: the sibling is installed
    but declares no unconditional runtime ``mgf-common`` dependency —
    ``mgf-common`` appears only behind an ``extra == ...`` marker, or not
    at all. Such a sibling cannot be broken by ANY ``mgf-common`` bump;
    the cross-sibling check must treat it as a no-op rather than as an
    unverifiable refuse.

    Distinct from ``None`` (which still means *unverifiable*: the sibling
    is not installed in this environment, or its metadata can't be parsed
    → conservative refuse).
    """

    __slots__ = ()

    def __repr__(self) -> str:
        return "<_NO_RUNTIME_PIN>"


_NO_RUNTIME_PIN = _NoRuntimePinSentinel()


def _installed_sibling_mgf_common_pin(
    package: str,
) -> str | _NoRuntimePinSentinel | None:
    """Return ``package``'s unconditional runtime ``mgf-common`` pin, read
    from the *installed* sibling's own metadata (offline; never pypi.org).

    Three states (PAPER-86 v0.41.5+; v0.45 reads installed metadata instead
    of the pypi.org JSON API — PKG-11 / PKG-12, the Codeberg cutover):

    - ``str`` — the sibling has an unconditional runtime ``mgf-common`` dep;
      the value is the constraint (e.g., ``">=0.44,<0.46"``). An empty string
      means it depends on ``mgf-common`` at any version (vacuously satisfied).
    - :data:`_NO_RUNTIME_PIN` — the sibling is installed but has no
      unconditional ``mgf-common`` dependency (it appears only behind an
      ``extra == ...`` marker, or not at all). It cannot block a bump.
      ``mgf-test-supervisor`` is the canonical case: it depends on
      ``mgf-common`` only in its ``[dev]`` extra.
    - ``None`` — unverifiable: the sibling is not installed in this
      environment, or its metadata can't be parsed. The caller refuses the
      bump conservatively (Approach C fallback).

    Reads ``importlib.metadata.requires(package)`` — the ``Requires-Dist``
    records of the installed sibling, which (unlike the flat registry
    ``deps`` map) preserve the ``extra ==`` markers needed to tell a runtime
    pin from an extras-only one. Stdlib-only.
    """
    import importlib.metadata
    import re as _re

    if package == "mgf-common":
        # Self-reference: no pin to fetch.
        return _NO_RUNTIME_PIN
    try:
        requires = importlib.metadata.requires(package)
    except importlib.metadata.PackageNotFoundError:
        return None
    if requires is None:
        # Installed, but declares no dependencies at all.
        return _NO_RUNTIME_PIN
    # `Requires-Dist` entries look like:
    #   "mgf-common"                          — unconditional, any version
    #   "mgf-common (>=0.44,<0.46)"           — old form
    #   "mgf-common<0.46,>=0.44"              — new form
    #   "mgf-common; extra == 'dev'"          — extras-only, no constraint
    #   "mgf-common>=0.44 ; extra == 'dev'"   — extras-only, constrained
    # `[^;]*` in the bare branch allows an empty constraint so we still
    # match the bare ``mgf-common`` and ``mgf-common; extra`` cases; the
    # marker test below decides whether they count.
    for spec in requires:
        if not isinstance(spec, str):
            continue
        m = _re.match(
            r"^mgf-common(?:\[[^\]]+\])?\s*(?:\((?P<paren>[^)]+)\)|(?P<bare>[^;]*))",
            spec,
        )
        if m is None:
            continue
        # Skip entries gated on an ``extra == ...`` environment marker —
        # they bind only when the consumer installs that extra and don't
        # establish a runtime constraint.
        if _re.search(r";\s*extra\s*==", spec):
            continue
        # First unconditional match wins. An empty constraint is a
        # legitimate "depends at any version" — `_version_satisfies_constraint`
        # accepts it vacuously.
        return (m.group("paren") or m.group("bare") or "").strip().strip(",")
    # Installed, but no unconditional mgf-common dep found.
    return _NO_RUNTIME_PIN


def _version_satisfies_constraint(version: str, constraint: str) -> bool:
    """Return True if ``version`` satisfies the comma-separated
    ``constraint`` (e.g., ``">=0.40,<0.41"``).

    Stdlib-only: parses each clause's comparator + version tuple,
    compares lexicographically on integer tuples. Handles the
    federation's PEP 440 subset (``>=``, ``<=``, ``<``, ``>``, ``==``,
    ``!=``) — does NOT handle ``~=``, pre-releases, post-releases,
    epochs, local versions, or wildcards. The federation never
    uses those.
    """

    def _tup(v: str) -> tuple[int, ...]:
        # Strip whitespace + trailing punct.
        v = v.strip().strip(",.")
        parts: list[int] = []
        for p in v.split("."):
            try:
                parts.append(int(p))
            except ValueError:
                # Non-numeric component (e.g. ".dev0", ".rc1") —
                # truncate; not handled in federation pins.
                break
        return tuple(parts)

    ver_t = _tup(version)
    if not ver_t:
        return False
    for clause in constraint.split(","):
        clause = clause.strip()
        if not clause:
            continue
        op = ""
        for candidate_op in (">=", "<=", "!=", "==", ">", "<"):
            if clause.startswith(candidate_op):
                op = candidate_op
                target_t = _tup(clause[len(candidate_op) :])
                break
        if not op or not target_t:
            continue  # malformed clause, skip
        # Pad shorter tuple with zeros for comparison.
        max_len = max(len(ver_t), len(target_t))
        v_padded = ver_t + (0,) * (max_len - len(ver_t))
        t_padded = target_t + (0,) * (max_len - len(target_t))
        if op == ">=" and not v_padded >= t_padded:
            return False
        if op == "<=" and not v_padded <= t_padded:
            return False
        if op == ">" and not v_padded > t_padded:
            return False
        if op == "<" and not v_padded < t_padded:
            return False
        if op == "==" and v_padded != t_padded:
            return False
        if op == "!=" and v_padded == t_padded:
            return False
    return True


def _check_cross_sibling_pin_compatibility(
    target_dir: Path,
    proposed_mgf_common_version: str,
) -> tuple[bool, list[str]]:
    """PAPER-84 — check if bumping mgf-common to ``proposed_mgf_common_version``
    would conflict with the project's other federation sibling pins.

    Approach A: for each `mgf-*` dep in the project's pyproject, read that
    sibling's runtime mgf-common pin from its *installed* metadata (offline;
    never pypi.org — PKG-11 / PKG-12). If the proposed bump target violates
    ANY sibling's runtime pin, return a conflict report.

    Approach C fallback: if a sibling's pin can't be verified (not installed
    in this environment, or unparseable), refuse the bump too — preserving
    the federation invariant "no consumer breakage from catch-up's mechanical
    fixes."

    Returns ``(safe_to_bump, [conflict_messages])``. Safe to bump iff
    list is empty.
    """
    import tomllib

    pyproject = target_dir / "pyproject.toml"
    if not pyproject.is_file():
        return (True, [])

    try:
        with pyproject.open("rb") as fh:
            data = tomllib.load(fh)
    except (OSError, tomllib.TOMLDecodeError):
        return (True, [])

    # Collect all mgf-* sibling specs (excluding mgf-common itself).
    sibling_specs: list[str] = []
    sibling_re = re.compile(
        r"^mgf-(common|" + "|".join(_FEDERATION_LIB_NAMES) + r")",
    )
    all_specs: list[str] = []
    all_specs.extend(data.get("project", {}).get("dependencies", []))
    for group_specs in data.get("project", {}).get("optional-dependencies", {}).values():
        all_specs.extend(group_specs)
    for spec in all_specs:
        s = str(spec).strip()
        m = sibling_re.match(s)
        if m and m.group(1) != "common":
            # A `@ git+...` direct-ref dep is still introspected by name from
            # its installed metadata below; if it isn't installed there, the
            # Approach C fallback (refuse) applies.
            sibling_specs.append(s)

    if not sibling_specs:
        return (True, [])

    conflicts: list[str] = []
    for spec in sibling_specs:
        # Extract sibling name (strip extras + version constraint).
        name_match = re.match(r"^(mgf-[a-z-]+?)(?:\[[^\]]+\])?(?:\s*[<>=!~@]|$)", spec)
        if not name_match:
            continue
        sibling_name = name_match.group(1).rstrip("-")
        # Read the sibling's runtime mgf-common pin from its installed
        # metadata (offline; never pypi.org — PKG-11 / PKG-12).
        sibling_mgf_common_pin = _installed_sibling_mgf_common_pin(sibling_name)
        if sibling_mgf_common_pin is _NO_RUNTIME_PIN:
            # PAPER-86 (v0.41.5+): the sibling is installed but has no
            # unconditional mgf-common runtime dependency (mgf-common
            # appears only behind an `extra == ...` marker, or not at
            # all). Cannot block the bump — skip silently.
            # mgf-test-supervisor is the canonical example: mgf-common
            # appears only in its `dev` extra.
            continue
        if sibling_mgf_common_pin is None:
            # PAPER-84 Approach C fallback: refuse the bump when we can't
            # verify a sibling's pin (the sibling is not installed in this
            # environment, or its metadata can't be parsed). Better
            # safe-refuse than surprise-breakage.
            conflicts.append(
                f"Could not read `{sibling_name}`'s mgf-common pin from its "
                f"installed metadata (not installed in this environment, or "
                f"unparseable). Refusing the pin bump until sibling pins can "
                f"be verified — run catch-up inside the project's own "
                f"environment (where its mgf-* deps are installed), or check "
                f"each sibling's mgf-common pin against the proposed target "
                f"`{proposed_mgf_common_version}` and bump by hand."
            )
            continue
        # The two guards above (is _NO_RUNTIME_PIN / is None -> continue)
        # leave only the str case; assert it so mypy narrows the union
        # (the `is`-against-instance-sentinel check is not narrowable on
        # its own). F-018.
        assert isinstance(sibling_mgf_common_pin, str)
        if not _version_satisfies_constraint(
            proposed_mgf_common_version,
            sibling_mgf_common_pin,
        ):
            conflicts.append(
                f"`{sibling_name}` (installed) pins "
                f"`mgf-common{sibling_mgf_common_pin}`, which EXCLUDES "
                f"the proposed bump target `{proposed_mgf_common_version}`. "
                f"Bumping anyway would make `uv sync` unresolvable for "
                f"this project. Upgrade `{sibling_name}` to a release whose "
                f"mgf-common pin includes `{proposed_mgf_common_version}` "
                f"first; the federation cohort-release wave tracks sibling "
                f"pin-bumps."
            )

    return (len(conflicts) == 0, conflicts)


def _registry_latest_version(package: str) -> str | None:
    """Return ``package``'s current published version from the federation
    registry data (``mgf.standard.libraries()``), or ``None``.

    The registry is the Codeberg-sourced federation contract shipped as data
    in the pinned ``mgf-standard`` wheel (PKG-11 — version checks resolve
    against the Codeberg registry, never pypi.org). It is a *snapshot* of the
    installed ``mgf-standard``: it sees as far as that pin and no further,
    so a release newer than the snapshot is reported only after the
    ``mgf-standard`` pin is bumped. Offline, no token. PAPER-65 (v0.45+
    reads the registry instead of the pypi.org JSON API).
    """
    libs = _resolve_libraries()
    if not libs:
        return None
    for lib in libs:
        if str(lib.get("repo")) == package:
            version = lib.get("version")
            return version if isinstance(version, str) and version else None
    return None


def _check_pyproject_pin(target_dir: Path) -> _Finding:
    """Is the mgf-common pin in the project's manifest current?

    Despite the name (kept stable for the historical test surface),
    this dispatches across the dep-manifest types this audit recognises:

      - ``pyproject.toml`` (PEP 621)
      - pip-tools layout (``requirements/*.txt`` + ``requirements.txt``
        at root) — PAPER-15 full support, v0.32.0

    v0.12.0+: when the pin's upper bound excludes the running
    mgf-common version (i.e., pin is stale), the finding gets a
    ``fix=`` callable that ``--apply`` can invoke to rewrite the
    line to a current 2-minor window (``>=<current>,<<major>.<minor+2>``).
    Conservative: only rewrites lines whose shape we fully understand.

    v0.17.1+: when the running mgf-common is an editable install
    (`pip install -e ../mgf_common`), the auto-rewrite is gated on the
    federation registry — editable installs can be ahead of the published
    roster, and rewriting a consumer's pin to ``>=<editable_version>``
    produces a pin no resolver can satisfy until the roster catches up
    (PKG-11; v0.45 replaced the old pypi.org lookup with the registry
    snapshot). Closes PAPER-20.

    v0.32.0+: when no ``pyproject.toml`` is present, a pip-tools
    ``requirements/*.{txt,lock}`` layout is audited as a first-class
    manifest. The pin is read from (in order) ``requirements/base.txt``,
    ``requirements/main.txt``, ``requirements/runtime.txt``, or the
    flat ``requirements.txt`` at the project root. Staleness +
    auto-rewrite + editable-install guard all apply uniformly. Closes
    PAPER-15 (the partial v0.17.2 message-only treatment is replaced
    with a real audit row).
    """
    pyproject = target_dir / "pyproject.toml"
    if not pyproject.is_file():
        # PAPER-15 (v0.32.0): full pip-tools manifest audit.
        if _has_pip_tools_layout(target_dir):
            return _check_pin_in_requirements(target_dir)
        return _Finding(
            "warn",
            "pyproject.toml — mgf-common pin",
            "no pyproject.toml found; standards adoption usually needs one.",
        )

    try:
        text = pyproject.read_text(encoding="utf-8")
    except OSError as exc:
        return _Finding(
            "fail",
            "pyproject.toml — mgf-common pin",
            f"could not read pyproject.toml: {exc}",
        )

    pin_match = re.search(
        r'"(mgf-common(?:\[[^\]]+\])?)\s*([^"]*)"',
        text,
    )
    if pin_match is None:
        return _Finding(
            "warn",
            "pyproject.toml — mgf-common pin",
            "no `mgf-common` dep found. Add it to "
            "`[project] dependencies`. See `mgf-fed standards "
            "components` for the canonical pin shape.",
        )

    from mgf.common import __version__ as current

    pin_extras_part = pin_match.group(1)  # e.g., "mgf-common" or "mgf-common[fastapi]"
    pin_constraint = pin_match.group(2).strip(", ")
    if not pin_constraint:
        return _Finding(
            "info",
            "pyproject.toml — mgf-common pin",
            f"unpinned dep on `mgf-common`. Suggest pinning to "
            f"`>={current},<{_next_major(current)}`.",
        )

    # Determine if the pin is stale: does the upper bound exclude
    # the running mgf-common version?
    is_stale = _pin_is_stale(pin_constraint, current)
    if not is_stale:
        return _Finding(
            "ok",
            "pyproject.toml — mgf-common pin",
            f"declared (constraint: `{pin_constraint}`; current "
            f"mgf-common: `{current}`). Pin window includes "
            f"current; nothing to do.",
        )

    new_constraint = f">={current},<{_next_major(current)}"

    if _is_editable_install():
        # Editable installs can run ahead of the published roster; rewriting
        # a consumer's pin to `>=<editable_version>` would otherwise produce
        # a pin no resolver can satisfy (PAPER-20). Close the loop against the
        # federation registry snapshot — offline, no token, never pypi.org
        # (PKG-11; v0.45 replaced the old pypi.org JSON lookup + the
        # --check-pypi / --no-check-pypi flags with this).
        base_detail = (
            f"STALE: declared `{pin_constraint}` but current "
            f"(editable-install) mgf-common is `{current}`."
        )
        registry_version = _registry_latest_version("mgf-common")
        if registry_version is None:
            # Registry unresolvable (mgf-standard missing / too old).
            return _Finding(
                "warn",
                "pyproject.toml — mgf-common pin",
                f"{base_detail} Can't confirm the published mgf-common "
                f"version — the federation registry is unresolvable "
                f"(mgf-standard missing or older than 0.1.2). NOT "
                f"auto-rewriting; check the current published window on the "
                f"Codeberg package page and bump the pin by hand.",
            )
        if _pin_is_stale(f">={registry_version},<{_next_major(registry_version)}", current):
            # The registry snapshot is BELOW the editable version — the
            # editable install is ahead of what the pinned mgf-standard
            # roster records as published.
            return _Finding(
                "warn",
                "pyproject.toml — mgf-common pin",
                f"{base_detail} The federation registry records mgf-common "
                f"at `{registry_version}` — the editable install is AHEAD of "
                f"the registry snapshot. Rewriting now would produce a pin no "
                f"resolver can satisfy until the published roster catches up. "
                f"Bump the `mgf-standard` pin (so the registry snapshot "
                f"includes `{current}`), then re-run `mgf-fed catch-up "
                f"--apply`.",
            )
        # The registry has caught up to (or past) the editable version. Safe
        # to rewrite — UNLESS a cross-sibling pin conflict blocks it (PAPER-84).
        new_constraint = f">={current},<{_next_major(current)}"
        safe, conflicts = _check_cross_sibling_pin_compatibility(
            target_dir,
            current,
        )
        if not safe:
            return _Finding(
                "warn",
                "pyproject.toml — mgf-common pin",
                f"{base_detail} The federation registry is at "
                f"`{registry_version}` (≥ editable `{current}`), BUT a "
                f"cross-sibling pin compatibility check (PAPER-84) blocks "
                f"the bump:\n"
                + "\n".join(f"  - {c}" for c in conflicts)
                + "\n\nNot offering `--apply` for this finding — bumping "
                "would make `uv sync` unresolvable.",
            )
        return _Finding(
            "warn",
            "pyproject.toml — mgf-common pin",
            f"{base_detail} The federation registry is at `{registry_version}` "
            f"(≥ editable `{current}`) — safe to rewrite to `{new_constraint}`. "
            f"Cross-sibling pin check (PAPER-84) passed. Run with `--apply` to "
            f"perform the rewrite; backup written.",
            fix=lambda root: _apply_bump_pin(
                root,
                extras_part=pin_extras_part,
                old_constraint=pin_constraint,
                new_constraint=new_constraint,
            ),
        )

    # PAPER-84 (v0.41.2+): before offering the bump, check that all
    # federation siblings the consumer also depends on will tolerate
    # the new mgf-common version. Refuses the bump on conflict to
    # avoid the "catch-up --apply broke uv sync" failure mode.
    safe, conflicts = _check_cross_sibling_pin_compatibility(
        target_dir,
        current,
    )
    if not safe:
        return _Finding(
            "warn",
            "pyproject.toml — mgf-common pin",
            f"STALE: declared `{pin_constraint}` but current "
            f"mgf-common is `{current}` — the upper bound excludes "
            f"it. BUT a cross-sibling pin compatibility check "
            f"(PAPER-84) blocks the bump:\n"
            + "\n".join(f"  - {c}" for c in conflicts)
            + f"\n\nNot offering `--apply` for this finding — bumping "
            f"would make `uv sync` unresolvable. Manual workflow: "
            f"wait for the conflicting sibling(s) to release a "
            f"version that accepts mgf-common `{current}`, then "
            f"re-run catch-up.",
        )
    return _Finding(
        "warn",
        "pyproject.toml — mgf-common pin",
        f"STALE: declared `{pin_constraint}` but current "
        f"mgf-common is `{current}` — the upper bound excludes it. "
        f"Suggest bumping to `{new_constraint}` (2-minor window). "
        f"Cross-sibling pin check (PAPER-84) passed: bump won't "
        f"break sibling resolution. "
        f"Run with --apply to rewrite the line; backup written.",
        fix=lambda root: _apply_bump_pin(
            root,
            extras_part=pin_extras_part,
            old_constraint=pin_constraint,
            new_constraint=new_constraint,
        ),
    )


def _pin_is_stale(constraint: str, current_version: str) -> bool:
    """Return True if ``current_version`` is excluded by the pin's upper bound.

    Parses the constraint string (PEP 440-ish, comma-separated). Recognises
    ``<X.Y[.Z]`` upper bounds (the canonical shape mgf-common projects
    use). Anything else (no upper bound, unparseable, or non-`<` bound)
    returns False — better to under-flag than to rewrite blindly.
    """
    upper_match = re.search(r"<\s*([0-9]+)\.([0-9]+)(?:\.([0-9]+))?", constraint)
    if upper_match is None:
        return False  # No `<X.Y` upper bound — can't reason; trust the user.

    upper_major = int(upper_match.group(1))
    upper_minor = int(upper_match.group(2))
    upper_patch = int(upper_match.group(3)) if upper_match.group(3) else 0

    cur_parts = current_version.split(".")
    if len(cur_parts) < 2:
        return False
    try:
        cur_major = int(cur_parts[0])
        cur_minor = int(cur_parts[1])
        cur_patch = int(cur_parts[2]) if len(cur_parts) >= 3 else 0
    except ValueError:
        return False

    # Strict-less than comparison: stale iff (cur_major, cur_minor, cur_patch)
    # >= upper bound.
    return (cur_major, cur_minor, cur_patch) >= (upper_major, upper_minor, upper_patch)


def _project_mgf_deps(target_dir: Path) -> dict[str, str]:
    """Return the project's ``mgf-*`` dependency pins (name -> spec) from
    pyproject.toml's runtime + optional dependencies. Empty if there's no
    parseable manifest."""
    pyproject = target_dir / "pyproject.toml"
    if not pyproject.is_file():
        return {}
    try:
        data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
    except (OSError, ValueError):
        return {}
    project = data.get("project", {})
    specs: list[str] = list(project.get("dependencies", []) or [])
    for grp in (project.get("optional-dependencies", {}) or {}).values():
        specs.extend(grp or [])
    deps: dict[str, str] = {}
    for spec in specs:
        if not isinstance(spec, str):
            continue
        m = re.match(r"^\s*(mgf-[a-z0-9-]+)\s*(.*)$", spec)
        if m:
            deps[m.group(1)] = (m.group(2) or "").strip()
    return deps


def _check_federation_position(target_dir: Path, project_name: str | None) -> _Finding:
    """Audit the project's place in the federation + whether its ``mgf-*``
    dependency pins keep pace with the current roster.

    Reads the roster from ``mgf.standard.libraries()`` — the federation
    registry shipped as data, resolved from the ``mgf-standard`` version
    this environment pins, never a scrape or a hand-copied list. Advisory +
    read-only: catch-up never auto-bumps a dependency pin (a deliberate
    human call), so this finding carries no ``fix``.

    The registry is a *snapshot* pinned to the installed ``mgf-standard``;
    for the very latest, upgrade it first (the freshness half), then re-pin.
    """
    libs = _resolve_libraries()
    if libs is None:
        return _Finding(
            "info",
            "Federation position",
            "federation registry not resolvable (mgf-standard missing or "
            "older than 0.1.2) — skipping the roster/currency check. Install "
            "it to enable the check: add the `mgf-common[standards]` extra, "
            "or — to track the latest from Codeberg — depend on "
            "`mgf-standard` directly and map it to the `codeberg-mgf` "
            "index (uv does not apply index pins to transitive deps; "
            "PACKAGE_DISTRIBUTION.md §6).",
        )
    by_repo = {str(lib.get("repo")): lib for lib in libs}
    deps = _project_mgf_deps(target_dir)
    lagging: list[str] = []
    for dep, pin in sorted(deps.items()):
        entry = by_repo.get(dep)
        if entry is None or not pin:
            continue
        current = str(entry.get("version", ""))
        if current and _pin_is_stale(pin, current):
            lagging.append(f"`{dep} {pin}` (federation at {current})")

    me = by_repo.get(project_name or "")
    place = ""
    if me is not None:
        used = ", ".join(me.get("used_by", [])) or "no federation consumers yet"
        place = f" In the roster as `{project_name}` — used by: {used}."

    # PAPER-98: the roster we just compared against is a SNAPSHOT of the
    # installed mgf-standard — it sees only as far as that pin. State that
    # provenance EXPLICITLY (even on the ok path) so a green verdict is never
    # silently mistaken for "current with the latest published." catch-up stays
    # OFFLINE by design (PAPER-65/68); the live registry-vs-installed check is
    # performed agent-side by `mgf-fed sync-prompt` STEP-0 ASSERT-LATEST.
    snap = by_repo.get("mgf-standard")
    snap_ver = snap.get("version", "") if isinstance(snap, dict) else ""
    snap_who = f"mgf-standard {snap_ver}" if snap_ver else "the installed mgf-standard"
    snapshot_note = (
        f" Registry snapshot = {snap_who}; this audit sees only as far as that "
        "pin — to confirm it is the latest PUBLISHED, run `mgf-common "
        "sync-prompt` STEP-0 ASSERT-LATEST or check the Codeberg index "
        "(catch-up stays offline by design)."
    )

    if lagging:
        return _Finding(
            "warn",
            "Federation position",
            "mgf-* pins behind the current federation roster: "
            + "; ".join(lagging)
            + f".{place} Re-pin against the current versions.{snapshot_note}",
        )
    return _Finding(
        "ok",
        "Federation position",
        f"mgf-* dependency pins keep pace with the federation roster "
        f"({len(libs)} members).{place}{snapshot_note}",
    )


def _apply_bump_pin(
    target_dir: Path,
    *,
    extras_part: str,
    old_constraint: str,
    new_constraint: str,
) -> str:
    """Rewrite the mgf-common pin line in pyproject.toml.

    Conservative: targets ONLY the specific
    ``"mgf-common[…]<old_constraint>"`` substring, leaves the rest of
    the file untouched. Writes a backup before modifying.
    """
    pyproject = target_dir / "pyproject.toml"
    original = pyproject.read_text(encoding="utf-8")

    # Build the exact substring to find (so we don't accidentally
    # rewrite mentions of mgf-common in comments / urls).
    old_pin_str = f'"{extras_part}{old_constraint}"'
    # Some projects write "mgf-common>=0.1.0,<0.2" with no space; some
    # might have spaces. Try the exact form first.
    if old_pin_str not in original:
        # The match-group may have captured a slightly different form
        # (with spaces stripped vs preserved). Re-derive from the file.
        regex = re.compile(
            r'"(' + re.escape(extras_part) + r')\s*([^"]*)"',
        )
        match = regex.search(original)
        if match is None:
            return "could not locate the mgf-common pin line; manual edit required."
        old_pin_str = match.group(0)

    new_pin_str = f'"{extras_part}{new_constraint}"'
    rewritten = original.replace(old_pin_str, new_pin_str, 1)

    if rewritten == original:
        return "could not locate the mgf-common pin line; manual edit required."

    backup = pyproject.with_suffix(f".toml.bak.{int(time.time())}")
    backup.write_text(original, encoding="utf-8")
    # SH-04: atomic write of the consumer's pyproject.toml. The
    # backup-first ordering means a crash mid-write is already
    # recoverable, but the helper exists; use it for consistency
    # and so the consumer never sees a truncated pyproject.toml
    # window. Preserve the original mode (default 0o644 if stat
    # fails — pyproject.toml is normally world-readable).
    try:
        original_mode = pyproject.stat().st_mode & 0o777
    except OSError:
        original_mode = 0o644
    from mgf.common.io import atomic_write

    atomic_write(pyproject, rewritten, mode=original_mode)

    return f"bumped pin to `{new_constraint}` in {pyproject} (backup: {backup.name})"


def _check_four_gates(target_dir: Path) -> _Finding:
    """Are the four gates configured in the project's manifest?

    Despite the name (kept stable for the historical test surface),
    this dispatches across manifest types:

      - ``pyproject.toml`` — uses ``tomllib`` (stdlib in 3.11+) to
        parse the relevant ``[tool.*]`` tables.
      - pip-tools layout (PAPER-15, v0.32.0) — reads the four gates
        from standalone config files: ``pytest.ini`` / ``setup.cfg``
        for pytest, ``ruff.toml`` / ``.ruff.toml`` for ruff,
        ``mypy.ini`` / ``.mypy.ini`` / ``setup.cfg`` for mypy,
        ``.coveragerc`` / ``coverage.ini`` for coverage.

    Either path produces the same ``_Finding`` shape; consumers
    don't need to know which manifest type drove the answer.
    """
    import tomllib

    pyproject = target_dir / "pyproject.toml"
    if not pyproject.is_file():
        # PAPER-15 (v0.32.0): full pip-tools four-gate audit.
        if _has_pip_tools_layout(target_dir):
            return _check_four_gates_standalone(target_dir)
        return _Finding(
            "warn",
            "pyproject.toml — four gates",
            "no pyproject.toml; cannot check gate config.",
        )
    try:
        with pyproject.open("rb") as fh:
            data = tomllib.load(fh)
    except (OSError, tomllib.TOMLDecodeError) as exc:
        return _Finding(
            "fail",
            "pyproject.toml — four gates",
            f"could not parse pyproject.toml: {exc}",
        )

    tool = data.get("tool", {})
    missing: list[str] = []

    # Ruff: presence of the table is enough; specific rule selection
    # is consumer-customisable.
    if "ruff" not in tool:
        missing.append("[tool.ruff]")

    # mypy: requires the table AND `strict = true` (per DP-04).
    mypy_cfg = tool.get("mypy", {})
    if not mypy_cfg or mypy_cfg.get("strict") is not True:
        missing.append("[tool.mypy] with strict = true")

    # pytest: requires the table AND filterwarnings contains "error"
    # (per TS-04). Accepts both the single-line and multi-line array
    # forms — they produce the same parsed list.
    pytest_cfg = tool.get("pytest", {}).get("ini_options", {})
    fw = pytest_cfg.get("filterwarnings", [])
    if not pytest_cfg or "error" not in fw:
        missing.append("[tool.pytest.ini_options] with filterwarnings = ['error']")

    # coverage: any of the coverage tables (run / report / paths) is
    # acceptable as a signal that coverage is configured. fail_under
    # specifically is in [tool.coverage.report].
    coverage_cfg = tool.get("coverage", {})
    if not coverage_cfg:
        missing.append("[tool.coverage.report] with fail_under")

    if not missing:
        return _Finding(
            "ok",
            "pyproject.toml — four gates",
            "ruff / mypy strict / pytest filterwarnings=error / coverage threshold all configured.",
        )

    return _Finding(
        "warn",
        "pyproject.toml — four gates",
        "missing or partial: "
        + "; ".join(missing)
        + ". Run `mgf-fed init-project --kind <kind> --dry-run "
        "--path <tmp>` to print a fresh four-gate snippet, or paste "
        "from the recipe at `mgf-fed standards components`.",
    )


# ---------------------------------------------------------------------------
# PAPER-15: pip-tools manifest audit (v0.32.0)
# ---------------------------------------------------------------------------
#
# When a project declares deps via the pip-tools layout
# (``requirements/{base,dev,prod}.{txt,lock}`` + standalone gate
# config files), the pyproject-only audit can't reach them. These
# helpers give the same audit experience for those projects:
#
#   - pin reading + staleness + auto-rewrite from
#     ``requirements/base.txt`` (or main.txt / runtime.txt / flat
#     requirements.txt fallbacks)
#   - four-gate audit from standalone ``pytest.ini`` /
#     ``setup.cfg`` / ``ruff.toml`` / ``mypy.ini`` / ``.coveragerc``
#
# The findings produced match the pyproject path's shape so
# downstream rendering and the ``--apply`` runner don't care which
# manifest type drove the row.


# Probed in declared order. base.txt is the pip-tools convention for
# runtime deps; main / runtime are commonly-seen synonyms; flat
# requirements.txt is the legacy / simple-project shape.
_REQUIREMENTS_PIN_CANDIDATES = (
    ("requirements", "base.txt"),
    ("requirements", "main.txt"),
    ("requirements", "runtime.txt"),
    ("requirements.txt",),
)


def _check_pin_in_requirements(target_dir: Path) -> _Finding:
    """Read + audit the mgf-common pin from a pip-tools requirements file.

    Probes the candidate files in order; the first one containing a
    ``mgf-common`` line wins. Staleness detection + auto-rewrite +
    editable-install guard are identical to the pyproject path so the
    audit experience is uniform across manifest types.
    """
    pin_file: Path | None = None
    pin_line: str | None = None
    extras_part: str | None = None
    constraint: str | None = None

    for candidate_parts in _REQUIREMENTS_PIN_CANDIDATES:
        candidate = target_dir.joinpath(*candidate_parts)
        if not candidate.is_file():
            continue
        try:
            text = candidate.read_text(encoding="utf-8")
        except OSError:
            continue
        # Match `mgf-common[extra]>=X,<Y` at the start of a line.
        # `[ \t]*` (not `\s*`) constrains the gap to within the same
        # line — otherwise `\s*` would consume the newline and the
        # following `[^\s#]*` would slurp the NEXT line's content
        # into the constraint. Stops at whitespace or `#`; doesn't
        # capture `-r base.txt` shaped references.
        match = re.search(
            r"^(mgf-common(?:\[[^\]]+\])?)[ \t]*([^\s#]*)",
            text,
            re.MULTILINE,
        )
        if match is not None:
            pin_file = candidate
            pin_line = match.group(0)
            extras_part = match.group(1)
            constraint = match.group(2).strip(", ")
            break

    if pin_file is None or extras_part is None or pin_line is None:
        return _Finding(
            "warn",
            "requirements/* — mgf-common pin",
            "pip-tools layout detected but no `mgf-common` dep found. "
            "Add an `mgf-common>=X,<Y` line to "
            "`requirements/base.txt` (or your runtime manifest). See "
            "`mgf-fed standards components` for the canonical pin shape.",
        )
    # All three are now narrowed to non-None — mypy follows the guard.

    from mgf.common import __version__ as current

    rel_path = pin_file.relative_to(target_dir)

    if not constraint:
        return _Finding(
            "info",
            "requirements/* — mgf-common pin",
            f"unpinned dep on `mgf-common` in `{rel_path}`. Suggest "
            f"pinning to `>={current},<{_next_major(current)}`.",
        )

    is_stale = _pin_is_stale(constraint, current)
    if not is_stale:
        return _Finding(
            "ok",
            "requirements/* — mgf-common pin",
            f"declared in `{rel_path}` (constraint: `{constraint}`; "
            f"current mgf-common: `{current}`). Pin window includes "
            f"current; nothing to do.",
        )

    new_constraint = f">={current},<{_next_major(current)}"

    if _is_editable_install():
        # Same editable-install guard as the pyproject path. PAPER-20.
        return _Finding(
            "warn",
            "requirements/* — mgf-common pin",
            f"STALE: declared `{constraint}` in `{rel_path}` but "
            f"current (editable-install) mgf-common is `{current}`. "
            f"NOT auto-rewriting — editable installs can be AHEAD of the "
            f"published roster, and rewriting to "
            f"`>={current},<{_next_major(current)}` would produce a pin no "
            f"resolver can satisfy until the roster catches up. Check the "
            f"current published window on the Codeberg package page (or "
            f"bump the `mgf-standard` pin so the registry snapshot "
            f"includes `{current}`) and bump the pin by hand to that "
            f"window.",
        )

    # Bind the file + line + new-constraint into a named closure
    # rather than a lambda — keeps mypy happy under strict mode
    # (lambdas with default-arg captures can't be inferred) and reads
    # more clearly at the call site.
    captured_pin_file = pin_file
    captured_pin_line = pin_line
    captured_new_constraint = new_constraint

    def _fix(_root: Path) -> str:
        return _apply_bump_pin_in_requirements(
            captured_pin_file,
            captured_pin_line,
            captured_new_constraint,
        )

    return _Finding(
        "warn",
        "requirements/* — mgf-common pin",
        f"STALE: declared `{constraint}` in `{rel_path}` but current "
        f"mgf-common is `{current}` — the upper bound excludes it. "
        f"Suggest bumping to `{new_constraint}` (2-minor window). "
        f"Run with --apply to rewrite the line; backup written.",
        fix=_fix,
    )


def _apply_bump_pin_in_requirements(
    pin_file: Path,
    old_pin_line: str,
    new_constraint: str,
) -> str:
    """Rewrite the mgf-common pin line in a pip-tools requirements file.

    Mirrors :func:`_apply_bump_pin` for pyproject: writes a backup,
    targets exactly the matched substring, leaves comments / other
    deps untouched.
    """
    original = pin_file.read_text(encoding="utf-8")

    # Reconstruct the new line by replacing the old constraint portion
    # in-place — keeps the extras part (``mgf-common[fastapi]``) and
    # any inline comment after the constraint untouched. Same
    # `[ \t]*` constraint as the reader so the rewriter doesn't span
    # newlines on a `mgf-common\nother-dep` shaped line.
    match = re.match(
        r"^(mgf-common(?:\[[^\]]+\])?)[ \t]*([^\s#]*)",
        old_pin_line,
    )
    if match is None:
        return "could not locate the mgf-common pin line; manual edit required."
    new_pin_line = f"{match.group(1)}{new_constraint}"

    rewritten = original.replace(old_pin_line, new_pin_line, 1)
    if rewritten == original:
        return "could not locate the mgf-common pin line; manual edit required."

    backup = pin_file.with_suffix(f"{pin_file.suffix}.bak.{int(time.time())}")
    backup.write_text(original, encoding="utf-8")
    # SH-04: atomic write — same shape as _apply_bump_pin above.
    try:
        original_mode = pin_file.stat().st_mode & 0o777
    except OSError:
        original_mode = 0o644
    from mgf.common.io import atomic_write

    atomic_write(pin_file, rewritten, mode=original_mode)

    return f"bumped pin to `{new_constraint}` in {pin_file} (backup: {backup.name})"


def _check_four_gates_standalone(target_dir: Path) -> _Finding:
    """Four-gate audit for pip-tools projects (standalone config files).

    Each gate has a list of recognised config-file locations; presence
    of any one with the required setting is enough. The "required
    setting" criteria mirror the pyproject path:

      - ``ruff``: presence of a ruff config file at all.
      - ``mypy``: presence + ``strict = True``.
      - ``pytest``: presence + ``filterwarnings`` includes ``error``.
      - ``coverage``: presence of any coverage config.
    """
    missing: list[str] = []

    if not _has_ruff_config_standalone(target_dir):
        missing.append("ruff.toml (or .ruff.toml)")

    if not _has_strict_mypy_standalone(target_dir):
        missing.append("mypy.ini (or setup.cfg [mypy]) with strict = True")

    if not _has_pytest_filterwarnings_error_standalone(target_dir):
        missing.append("pytest.ini (or setup.cfg [tool:pytest]) with filterwarnings = error")

    if not _has_coverage_config_standalone(target_dir):
        missing.append(".coveragerc (or coverage.ini)")

    if not missing:
        return _Finding(
            "ok",
            "requirements/* — four gates",
            "ruff / mypy strict / pytest filterwarnings=error / "
            "coverage config all present in standalone files.",
        )

    return _Finding(
        "warn",
        "requirements/* — four gates",
        "missing or partial: "
        + "; ".join(missing)
        + ". Either add standalone config files for the missing "
        "gates, or migrate the gates to a single pyproject.toml "
        "(`[tool.ruff]` / `[tool.mypy]` / `[tool.pytest.ini_options]` "
        "/ `[tool.coverage.report]`) — both layouts are recognised "
        "by this audit.",
    )


def _has_ruff_config_standalone(target_dir: Path) -> bool:
    """ruff reads ``ruff.toml`` or ``.ruff.toml`` if no pyproject section."""
    return (target_dir / "ruff.toml").is_file() or (target_dir / ".ruff.toml").is_file()


def _has_strict_mypy_standalone(target_dir: Path) -> bool:
    """mypy `strict = True` in mypy.ini / .mypy.ini / setup.cfg [mypy]."""
    for name in ("mypy.ini", ".mypy.ini"):
        path = target_dir / name
        if path.is_file() and _ini_has_strict_mypy(path, section="mypy"):
            return True
    # setup.cfg's [mypy] section is also a recognised location.
    setup_cfg = target_dir / "setup.cfg"
    return setup_cfg.is_file() and _ini_has_strict_mypy(setup_cfg, section="mypy")


def _ini_has_strict_mypy(path: Path, section: str) -> bool:
    """Return True if the named INI ``section`` has ``strict = True``."""
    import configparser

    parser = configparser.ConfigParser()
    try:
        parser.read(path, encoding="utf-8")
    except (OSError, configparser.Error):
        return False
    if section not in parser:
        return False
    strict_value = parser[section].get("strict", "").strip().lower()
    return strict_value in ("true", "1", "yes", "on")


def _has_pytest_filterwarnings_error_standalone(target_dir: Path) -> bool:
    """pytest's filterwarnings=error in pytest.ini / setup.cfg / tox.ini.

    The pytest config supports both formats:
      - INI: ``filterwarnings = error`` (or a list across lines)
      - TOML: ``filterwarnings = ["error", ...]`` (handled by the
        pyproject path, not here)

    We accept any line in the section that contains ``error`` as a
    value — pytest's matcher is itself substring-y.
    """
    candidates = [
        (target_dir / "pytest.ini", "pytest"),
        (target_dir / "setup.cfg", "tool:pytest"),
        (target_dir / "tox.ini", "pytest"),
    ]
    for path, section in candidates:
        if not path.is_file():
            continue
        if _ini_filterwarnings_has_error(path, section):
            return True
    return False


def _ini_filterwarnings_has_error(path: Path, section: str) -> bool:
    """Return True if the named INI ``section`` has ``filterwarnings``
    that includes the ``error`` action."""
    import configparser

    parser = configparser.ConfigParser()
    try:
        parser.read(path, encoding="utf-8")
    except (OSError, configparser.Error):
        return False
    if section not in parser:
        return False
    raw = parser[section].get("filterwarnings", "")
    # The value can be a single line (`error`) or multi-line (one
    # filter per line). pytest matches any line where the action is
    # `error` (possibly followed by `:` and a more specific filter).
    # The variable is called `action` (not `token`) so ruff's S105
    # "possible hardcoded password" rule doesn't false-positive on
    # the `action == "error"` comparison.
    for line in raw.splitlines():
        action = line.strip().split(":", 1)[0].strip().lower()
        if action == "error":
            return True
    return False


def _has_coverage_config_standalone(target_dir: Path) -> bool:
    """coverage.py reads ``.coveragerc`` or ``coverage.ini`` by default."""
    return (target_dir / ".coveragerc").is_file() or (target_dir / "coverage.ini").is_file()


def _resolve_claude_md_path(target_dir: Path) -> tuple[Path | None, bool]:
    """Resolve the CLAUDE.md location, honouring DOC-07's canonical path.

    Closes PAPER-54. Returns ``(active_path, has_legacy_duplicate)``:

    - ``docs/claude/CLAUDE.md`` (DOC-07 canonical) wins when present.
    - ``docs/CLAUDE.md`` (legacy / pre-DOC-07) is the fallback when only
      the legacy file exists.
    - When BOTH exist, the canonical wins for audit + apply purposes and
      ``has_legacy_duplicate=True`` is returned so the caller can surface
      the duplication as an info-row in the finding's detail.
    - When NEITHER exists, returns ``(None, False)`` and the caller
      decides the creation path (currently the canonical DOC-07 location).
    """
    canonical = target_dir / "docs" / "claude" / "CLAUDE.md"
    legacy = target_dir / "docs" / "CLAUDE.md"
    if canonical.is_file():
        return canonical, legacy.is_file()
    if legacy.is_file():
        return legacy, False
    return None, False


def _claude_md_label(path: Path, target_dir: Path) -> str:
    """Format the relative finding label for a CLAUDE.md path."""
    try:
        return str(path.relative_to(target_dir))
    except ValueError:
        return str(path)


def _check_claude_md(
    target_dir: Path,
    project_name: str,
    project_kind: str,
) -> _Finding:
    """Does the project's CLAUDE.md exist + carry the auto-managed block?

    Accepts both ``docs/claude/CLAUDE.md`` (DOC-07 canonical) and
    ``docs/CLAUDE.md`` (legacy). Closes PAPER-54.
    """
    active, has_legacy_duplicate = _resolve_claude_md_path(target_dir)
    if active is None:
        # Neither path exists. Suggest creating at the DOC-07 canonical
        # location going forward.
        return _Finding(
            "fail",
            "docs/claude/CLAUDE.md",
            "missing. Claude won't auto-load any standards on session "
            "start — manual prompting required for every task. "
            "Run with --apply to create a fresh CLAUDE.md at the "
            "DOC-07 canonical path from the current template.",
            fix=lambda root: _apply_create_claude_md(
                root,
                project_name,
                project_kind,
            ),
        )

    label = _claude_md_label(active, target_dir)
    duplicate_note = (
        " Note: a legacy `docs/CLAUDE.md` is also present — the canonical "
        "DOC-07 file wins for audit + --apply, but consider removing the "
        "duplicate to avoid confusion (it will diverge if Claude refreshes "
        "this file)."
        if has_legacy_duplicate
        else ""
    )

    try:
        content = active.read_text(encoding="utf-8")
    except OSError as exc:
        return _Finding(
            "fail",
            label,
            f"could not read: {exc}{duplicate_note}",
        )

    has_begin = CLAUDE_MD_BEGIN_MARKER in content
    has_end = CLAUDE_MD_END_MARKER in content

    if not has_begin or not has_end:
        # PAPER-16: catch-up --apply auto-inserts the BEGIN/END
        # markers + auto-block at the top of the existing CLAUDE.md
        # (with backup), rather than forcing the consumer to choose
        # between init-project --force (which clobbers project-
        # specific content) and manual marker insertion (which is
        # undocumented).
        return _Finding(
            "warn",
            label,
            "exists but lacks BEGIN/END auto-managed markers — "
            "either pre-v0.11 init-project output or hand-written. "
            "Run with --apply to bootstrap the markers + auto-block "
            "at the top of the existing file (your project-specific "
            "content stays below the END marker; original is backed "
            "up to .bak.<timestamp>). Closes PAPER-16." + duplicate_note,
            fix=lambda root: _apply_bootstrap_claude_md_markers(
                root,
                project_name,
                project_kind,
            ),
        )

    # Compute the current expected auto-block; compare to existing.
    expected = _render_claude_md(project_name, project_kind)
    expected_block = _extract_auto_block(expected)
    actual_block = _extract_auto_block(content)

    if expected_block == actual_block:
        return _Finding(
            "ok",
            label,
            "exists with current auto-managed block. "
            "Project-specific lookup preserved." + duplicate_note,
        )

    return _Finding(
        "warn",
        label,
        "exists with auto-block markers, but the auto-block content "
        "is stale relative to the current template (rules may have "
        "been added / wording changed in newer mgf-common). Run with "
        "--apply to refresh just the auto-block." + duplicate_note,
        fix=lambda root: _apply_refresh_claude_md(
            root,
            project_name,
            project_kind,
        ),
    )


# Pin-literal scan: regex matches federation deps with explicit
# pin constraints in CLAUDE.md prose. PAPER-72.
#
# The pattern is the trade-off between specificity and recall:
#   - `mgf-X` library name (anchored to our federation namespace)
#   - optional surrounding context ("Pinned", "pinned", parens)
#   - explicit pin constraint like `>=X.Y` or `>=X.Y,<X.Z`
#
# We deliberately do NOT match bare version mentions ("0.40.0")
# without `>=` because those could be release notes / changelog
# bullets that legitimately reference a past or future version.
# The `>=` anchor is what makes the prose's claim *about the
# current pin* unambiguous.
_FEDERATION_LIB_NAMES = (
    "common",
    "hrb",
    "http",
    "fastapi",
    "django",
    "sqlalchemy",
    "alembic",
    "apiprobe",
    "test-supervisor",
)
_CLAUDE_MD_PIN_LITERAL_RE = re.compile(
    r"\bmgf-(?P<lib>" + "|".join(_FEDERATION_LIB_NAMES) + r")"
    # Up to 60 chars of intervening context ("pinned", parens,
    # backticks, etc.) so the regex matches both compact forms
    # ("mgf-common>=0.40,<0.41") and prose forms ("mgf-common
    # (pinned `>=0.40,<0.41`)").
    r"[^\n`]{0,60}?"
    # The constraint itself: a `>=X.Y` floor optionally followed by
    # `,<X.Z` ceiling. Excluding `,` from the constraint's tail
    # would split "`>=0.38,<0.39`" into "`>=0.38`" which is wrong;
    # the explicit alternation keeps the comma INSIDE the match.
    r"`?(?P<constraint>>=\s*[0-9][0-9.]*\.[0-9]+(?:\s*,\s*<\s*[0-9][0-9.]*)?)`?",
    re.MULTILINE,
)


def _check_claude_md_pin_literals(target_dir: Path) -> _Finding:
    r"""PAPER-72 — scan CLAUDE.md prose for federation pin literals
    that contradict the project's actual pyproject pins.

    The project-specific section of ``docs/(claude/)?CLAUDE.md`` is
    user-authored prose. Returning agents grep CLAUDE.md for
    federation-pin facts; when those facts are restated as
    literals (e.g., ``- **Cornerstone:** mgf-common (pinned
    >=0.38,<0.39)``), pin bumps to ``pyproject.toml`` silently
    leave the prose stale. The first agent to trust CLAUDE.md
    gets a wrong answer.

    This audit:
      1. Reads `docs/claude/CLAUDE.md` (DOC-07 canonical) OR
         `docs/CLAUDE.md` (legacy) — whichever exists.
      2. Strips the auto-managed block (the BEGIN/END marker
         pair catch-up itself controls).
      3. In the remaining (user-authored) text, regex-matches
         federation lib names followed by explicit `>=X.Y` pin
         constraints.
      4. Compares each matched constraint against the actual
         pyproject pin for that library.
      5. Surfaces drift as a warn with line numbers + the prose
         claim + the actual pyproject claim.

    Read-only — prose is human-authored, no `fix=` callable. The
    visibility IS the fix: returning agent sees the warning, the
    agent (or human) edits CLAUDE.md or swaps the literal for a
    "see pyproject.toml" cross-reference.

    Severity: warn (drift is real but cosmetic).
    """
    import tomllib

    # Locate the CLAUDE.md to audit.
    candidates = (
        target_dir / "docs" / "claude" / "CLAUDE.md",
        target_dir / "docs" / "CLAUDE.md",
    )
    claude_md = next((c for c in candidates if c.is_file()), None)
    if claude_md is None:
        # No CLAUDE.md → no prose to audit. (Distinct finding
        # already handled by _check_claude_md.)
        return _Finding(
            "ok",
            "CLAUDE.md pin-literal drift (PAPER-72)",
            "no CLAUDE.md to audit (separate finding handles existence).",
        )

    try:
        full_text = claude_md.read_text(encoding="utf-8")
    except OSError as exc:
        return _Finding(
            "warn",
            "CLAUDE.md pin-literal drift (PAPER-72)",
            f"could not read {claude_md}: {exc}",
        )

    # Strip the auto-managed block — catch-up controls that text
    # itself; we only audit user-authored prose below the END
    # marker (and above the BEGIN marker, though typical templates
    # put auto-block at the top).
    auto_block_re = re.compile(
        r"<!-- BEGIN mgf-common[\s\S]*?<!-- END mgf-common[^>]*-->",
        re.MULTILINE,
    )
    user_text = auto_block_re.sub("", full_text)

    # Parse pyproject pins.
    pyproject = target_dir / "pyproject.toml"
    actual_pins: dict[str, str] = {}
    if pyproject.is_file():
        try:
            with pyproject.open("rb") as fh:
                data = tomllib.load(fh)
        except (OSError, tomllib.TOMLDecodeError):
            data = {}
        # Collect specs from [project].dependencies +
        # [project.optional-dependencies].*.
        all_specs: list[str] = []
        all_specs.extend(data.get("project", {}).get("dependencies", []))
        for group_specs in data.get("project", {}).get("optional-dependencies", {}).values():
            all_specs.extend(group_specs)
        # Match each spec against our federation lib names.
        spec_re = re.compile(
            r"^mgf-(" + "|".join(_FEDERATION_LIB_NAMES) + r")"
            r"(?:\[[^\]]*\])?\s*([^\"]*?)$"
        )
        for spec in all_specs:
            m = spec_re.match(str(spec).strip())
            if m:
                lib = m.group(1)
                constraint = m.group(2).strip()
                # Strip out the @ git+... part for direct refs.
                if "@" in constraint:
                    constraint = constraint.split("@")[0].strip()
                if constraint:
                    actual_pins[lib] = constraint

    # Scan user_text for pin-literal claims.
    drifts: list[str] = []
    seen_line_nums: set[int] = set()
    for match in _CLAUDE_MD_PIN_LITERAL_RE.finditer(user_text):
        lib = match.group("lib")
        claimed = match.group("constraint").strip().rstrip(",")
        # Cross-reference against pyproject.
        actual = actual_pins.get(lib)
        if actual is None:
            # Lib not declared in pyproject — possible drift but
            # also possibly a "we plan to add this" note; don't
            # flag aggressively.
            continue
        # Normalise whitespace before comparing.
        if _pins_match(claimed, actual):
            continue
        # Drift. Find line number for the user-facing message.
        line_num = full_text[: match.start()].count("\n") + 1
        if line_num in seen_line_nums:
            continue
        seen_line_nums.add(line_num)
        drifts.append(
            f"  - L{line_num}: CLAUDE.md says `mgf-{lib} {claimed}`; "
            f"pyproject says `mgf-{lib} {actual}`"
        )

    if not drifts:
        return _Finding(
            "ok",
            "CLAUDE.md pin-literal drift (PAPER-72)",
            "no pin literals in CLAUDE.md prose contradict pyproject's pins.",
        )

    return _Finding(
        "warn",
        "CLAUDE.md pin-literal drift (PAPER-72)",
        "CLAUDE.md project-specific prose contains pin literals "
        "that differ from pyproject.toml's actual pins. Returning "
        "agents grep CLAUDE.md for federation pin facts; stale "
        "literals misinform them.\n\n"
        + "\n".join(drifts)
        + f"\n\nFix by hand: update {claude_md.relative_to(target_dir)} "
        f"to either match pyproject OR replace the literal with a "
        f"cross-reference (e.g., `Pin window declared in "
        f"pyproject.toml`). Not auto-applied — prose is "
        f"user-authored.",
    )


def _pins_match(claimed: str, actual: str) -> bool:
    """Compare two pin-constraint strings for semantic equality.

    Strips whitespace + backticks. Compares the normalized form.
    Cheap enough for the catch-up audit; full PEP 508 parsing
    would be overkill given we already know both strings came
    from the federation's `>=X.Y,<X.Z` convention.
    """

    def _norm(s: str) -> str:
        return re.sub(r"\s+", "", s).strip("`,. ")

    return _norm(claimed) == _norm(actual)


def _check_claude_settings(
    target_dir: Path,
    project_kind: str,
) -> _Finding:
    """Does .claude/settings.json exist?"""
    settings = target_dir / ".claude" / "settings.json"
    if not settings.is_file():
        return _Finding(
            "warn",
            ".claude/settings.json",
            "missing. Without it, Claude prompts for every Bash "
            "command — friction in normal dev. Run with --apply to "
            "create one with safe defaults for this project kind.",
            fix=lambda root: _apply_create_claude_settings(root, project_kind),
        )
    return _Finding(
        "ok",
        ".claude/settings.json",
        "exists. (catch-up doesn't audit allow-list contents — your team customizes them.)",
    )


def _check_agents_md(target_dir: Path) -> _Finding:
    """Does AGENTS.md exist at the project root + match the canonical pointer?

    AGENTS.md is the cross-tool agent-discovery file — a thin,
    project-agnostic pointer to the canonical Magogi briefing,
    identical in every federation project. It is fully managed
    (no project-specific content; project notes live in CLAUDE.md),
    so catch-up restores it on any drift.
    """
    agents = target_dir / "AGENTS.md"
    if not agents.is_file():
        return _Finding(
            "warn",
            "AGENTS.md",
            "missing. AGENTS.md is the cross-tool agent-discovery "
            "pointer to the canonical Magogi agent briefing — agents "
            "that look for `AGENTS.md` get routed to it. Run with "
            "--apply to create it.",
            fix=_apply_create_agents_md,
        )
    try:
        content = agents.read_text(encoding="utf-8")
    except OSError as exc:
        return _Finding("fail", "AGENTS.md", f"could not read: {exc}")
    if content.strip() != AGENTS_MD_CONTENT.strip():
        return _Finding(
            "warn",
            "AGENTS.md",
            "exists but its content has drifted from the canonical "
            "pointer. AGENTS.md is fully managed — project-specific "
            "agent notes belong in CLAUDE.md, not here. Run with "
            "--apply to restore the canonical pointer.",
            fix=_apply_create_agents_md,
        )
    return _Finding(
        "ok",
        "AGENTS.md",
        "exists with the canonical agent-discovery pointer.",
    )


def _check_memory_md(target_dir: Path) -> _Finding:
    """Does docs/memory/MEMORY.md exist (AG-17 — committed in-repo memory)?

    AG-17 requires every repo to carry committed in-repo agent memory at
    ``docs/memory/MEMORY.md`` — the durable, federation-scoped facts that
    travel with the repo (the committed tier alongside docs/claude/CLAUDE.md
    and LESSON_LEARNED.md). Presence is the bar; a seeded index with no
    memories yet is conformant. Catch-up seeds the index on ``--apply``.
    """
    memory = target_dir / "docs" / "memory" / "MEMORY.md"
    if not memory.is_file():
        return _Finding(
            "warn",
            "docs/memory/MEMORY.md",
            "missing — in-repo agent memory (AG-17); run --apply to seed it.",
            fix=_apply_create_memory_md,
        )
    return _Finding(
        "ok",
        "docs/memory/MEMORY.md",
        "present.",
    )


def _check_gitignore(target_dir: Path) -> _Finding:
    """Does .gitignore ignore my_stuff/ (AG-18 — local secrets dir)?

    AG-18 requires every repo's ``.gitignore`` to ignore ``my_stuff/`` — the
    local secrets / tokens dir, which MUST never be committed. Catch-up adds
    the standardized ignore block on ``--apply``.
    """
    gitignore = target_dir / ".gitignore"
    try:
        text = gitignore.read_text(encoding="utf-8")
    except OSError:
        text = ""
    if any(line.strip() == "my_stuff/" for line in text.splitlines()):
        return _Finding(
            "ok",
            ".gitignore",
            "ignores my_stuff/ (AG-18).",
        )
    return _Finding(
        "warn",
        ".gitignore",
        "does not ignore my_stuff/ (AG-18) — local secrets dir; run --apply "
        "to add the standard ignore block.",
        fix=_apply_fix_gitignore,
    )


def _check_v01_patterns(target_dir: Path) -> _Finding:
    """Grep src/ for v0.1-pattern usages that should migrate to bootstrap()."""
    src = target_dir / "src"
    if not src.is_dir():
        # Try the project root as a fallback.
        src = target_dir

    patterns: list[tuple[str, str]] = [
        ("mgf.common.configure(", "→ `with mgf.common.bootstrap(...)` (v0.3+)"),
        ("make_settings_config(", "→ `settings_config(...)` (v0.3+)"),
        ("setup_logging(", "→ folded into `bootstrap()` (v0.3+)"),
        ("setup_otel(", "→ folded into `bootstrap()` (v0.3+)"),
        ("install_excepthook(", "→ folded into `bootstrap()` (v0.3+)"),
        ("install_threading_excepthook(", "→ folded into `bootstrap()` (v0.3+)"),
    ]

    findings: list[str] = []
    for needle, suggestion in patterns:
        count = _count_matches(src, needle)
        if count > 0:
            findings.append(f"`{needle}` x {count}: {suggestion}")

    if not findings:
        return _Finding(
            "ok",
            "v0.1-pattern usage",
            "no legacy `configure() / setup_logging() / install_excepthook()` calls found.",
        )

    return _Finding(
        "warn",
        "v0.1-pattern usage",
        "legacy v0.1 call sites found: "
        + "; ".join(findings)
        + ". These still work via deprecation cycle, but the "
        "v0.3+ `bootstrap()` shape is the canonical replacement. "
        "Migration is NOT mechanical (the call shape changes from "
        "5 separate calls to 1 context manager); catch-up will not "
        "auto-modify. See `mgf-fed standards design` for the "
        "bootstrap() pattern.",
    )


def _count_matches(root: Path, needle: str) -> int:
    """Count how many times ``needle`` appears across .py files under ``root``.

    Skips standard cache / build / vendor / venv directories per
    ``_AUDIT_SKIP_DIRS`` (closes PAPER-14 — without the skip, the
    audit walked into ``.venv/`` and counted mgf-common's own
    deprecated shim definitions as user-source v0.1 call sites,
    producing dozens of false positives on adopter projects).
    """
    if not root.is_dir():
        return 0
    total = 0
    for path in root.rglob("*.py"):
        if any(part in _AUDIT_SKIP_DIRS for part in path.parts):
            continue
        try:
            text = path.read_text(encoding="utf-8", errors="ignore")
        except OSError:
            continue
        total += text.count(needle)
    return total


# ---------------------------------------------------------------------------
# Closed-box leak detection (rule AP-14 / DP-13). Closes FEEDBACK PAPER-08.
# ---------------------------------------------------------------------------

# Skip these directory names anywhere in a .py file's path during
# any consumer-source audit walk. Tests are intentionally exempt:
# ``tests/unit/_internal_test_helpers/`` style imports are legitimate
# consumer-side per the PAPER-08 spec. ``.venv/`` exemption is what
# closed PAPER-14 (audit was walking installed mgf-common's own
# deprecated shim definitions and counting them as user-source v0.1
# call sites).
_AUDIT_SKIP_DIRS: frozenset[str] = frozenset(
    {
        "tests",
        "test",
        ".venv",
        "venv",
        "__pycache__",
        "node_modules",
        "dist",
        "build",
        ".tox",
        ".pytest_cache",
        ".mypy_cache",
        ".ruff_cache",
    }
)

# Backward-compat alias for the original PAPER-08 name. New code uses
# _AUDIT_SKIP_DIRS.
_LEAK_SKIP_DIRS = _AUDIT_SKIP_DIRS

# Map known private symbols to their public-API replacement so the
# audit row can name the fix, not just the violation. Extend as new
# private→public promotions happen.
_PRIVATE_TO_PUBLIC: dict[str, str] = {
    "_default_log_path": "mgf.common.observability.default_log_path  (since v0.13.0)",
    "_crash_dir": "mgf.common.observability.default_crash_dir  (since v0.13.0)",
}


def _is_private_module(module: str) -> bool:
    """True when any path segment after ``mgf.common`` starts with ``_``.

    Catches:
      - ``mgf.common._x``                — module itself private
      - ``mgf.common.observability._helpers`` — nested private module
      - ``mgf.common.X.Y._z``            — deeply nested

    Excludes ``__init__`` / ``__main__`` style dunders (those aren't
    "private" in the closed-box sense).
    """
    if not module.startswith("mgf.common"):
        return False
    parts = module.split(".")
    # Skip "mgf", "common" — only the post-prefix segments can be private.
    return any(p.startswith("_") and not p.startswith("__") for p in parts[2:])


def _check_closed_box_leaks(target_dir: Path) -> _Finding:
    """AST-walk consumer src/ for imports of mgf.common's private surface.

    Closes FEEDBACK PAPER-08. Pairs with ``tests/public_surface/
    test_no_private_imports.py`` (the upstream-side AST pin); this
    is the consumer-side runtime check, surfaced as a catch-up
    audit row so consumers don't have to copy the test.

    Three leak shapes detected:
      1. ``from mgf.common._x import Y``     — module itself private
      2. ``from mgf.common.X import _y``     — public module, private name
      3. ``import mgf.common._x``            — direct private-module import

    The consumer's ``tests/`` directory is NOT walked: legitimate
    test-helper imports inside ``tests/unit/_internal_test_helpers/``
    style directories aren't violations of the closed-box rule.
    """
    src = target_dir / "src"
    if not src.is_dir():
        # Match ``_check_v01_patterns`` — fall back to project root.
        src = target_dir

    leaks: list[str] = []

    for path in src.rglob("*.py"):
        if any(part in _LEAK_SKIP_DIRS for part in path.parts):
            continue
        try:
            text = path.read_text(encoding="utf-8")
        except (OSError, UnicodeDecodeError):
            continue
        try:
            tree = ast.parse(text, filename=str(path))
        except SyntaxError:
            # Malformed source — skip rather than abort the whole audit.
            continue

        try:
            rel = path.relative_to(target_dir).as_posix()
        except ValueError:
            rel = path.as_posix()

        for node in ast.walk(tree):
            if isinstance(node, ast.ImportFrom):
                if not node.module or not node.module.startswith("mgf.common"):
                    continue
                if _is_private_module(node.module):
                    # Case 1: from mgf.common._x import ...
                    name_list = ", ".join(a.name for a in node.names)
                    leaks.append(
                        f"{rel}:{node.lineno}: from {node.module} import {name_list}",
                    )
                    continue
                # Case 2: from mgf.common.X import _private
                for alias in node.names:
                    if alias.name.startswith("_") and not alias.name.startswith("__"):
                        suggestion = _PRIVATE_TO_PUBLIC.get(
                            alias.name,
                            "use the public alternative from `mgf-fed standards api`",
                        )
                        leaks.append(
                            f"{rel}:{node.lineno}: "
                            f"from {node.module} import {alias.name} → {suggestion}",
                        )
            elif isinstance(node, ast.Import):
                # Case 3: import mgf.common._x
                for alias in node.names:
                    if _is_private_module(alias.name):
                        leaks.append(f"{rel}:{node.lineno}: import {alias.name}")

    if not leaks:
        return _Finding(
            "ok",
            "closed-box leaks (private mgf.common imports)",
            "no `mgf.common._*` or `mgf.common.X._private` imports found in src/.",
        )

    leaks.sort()
    shown = leaks[:8]
    extra = len(leaks) - len(shown)
    body = "; ".join(shown)
    if extra > 0:
        body += f"; … and {extra} more"

    return _Finding(
        "warn",
        "closed-box leaks (private mgf.common imports)",
        f"private mgf.common imports found ({len(leaks)} site(s)): "
        f"{body}. Closed-box rule (AP-14 / DP-13): consumers MUST NOT "
        "import `mgf.common._*` modules or names starting with `_` "
        "from public modules — the private surface MAY change in any "
        "release. Replace with the public equivalent (printed inline "
        "above when known).",
    )


# ---------------------------------------------------------------------------
# Apply functions. Called only when --apply is passed.
# ---------------------------------------------------------------------------


def _apply_create_claude_md(
    target_dir: Path,
    project_name: str,
    project_kind: str,
) -> str:
    """Create a fresh CLAUDE.md at the DOC-07 canonical path.

    PAPER-54: when neither path exists, create at
    ``docs/claude/CLAUDE.md`` (DOC-07 canonical). If a legacy
    ``docs/CLAUDE.md`` exists alongside (it shouldn't if catch-up
    routed us here, but be defensive), refresh that one in place
    via :func:`_apply_refresh_claude_md` instead of duplicating
    — closes the spirit of PAPER-55 sister-paper for the create
    path.
    """
    active, _has_legacy_duplicate = _resolve_claude_md_path(target_dir)
    if active is not None:
        # Active file already exists. Don't duplicate-create; refresh.
        return _apply_refresh_claude_md(target_dir, project_name, project_kind)

    path = target_dir / "docs" / "claude" / "CLAUDE.md"
    path.parent.mkdir(parents=True, exist_ok=True)
    # SH-04: atomic write — this is a CREATE (no original to
    # preserve mode for); use the typical doc default 0o644.
    from mgf.common.io import atomic_write

    atomic_write(
        path,
        _render_claude_md(project_name, project_kind),
        mode=0o644,
    )
    return f"created {path}"


def _apply_refresh_claude_md(
    target_dir: Path,
    project_name: str,
    project_kind: str,
) -> str:
    """Refresh just the auto-managed block of an existing CLAUDE.md.

    PAPER-54: operates on whichever path is the active CLAUDE.md
    (canonical DOC-07 or legacy).
    """
    active, _has_legacy_duplicate = _resolve_claude_md_path(target_dir)
    if active is None:
        # Caller mis-routed us here; fall back to creating fresh.
        return _apply_create_claude_md(target_dir, project_name, project_kind)
    path = active
    original = path.read_text(encoding="utf-8")

    # Backup before modifying.
    backup = path.with_suffix(f".md.bak.{int(time.time())}")
    backup.write_text(original, encoding="utf-8")

    new_full = _render_claude_md(project_name, project_kind)
    new_auto_block = _extract_auto_block(new_full)

    # Splice: keep everything BEFORE the BEGIN marker + the new
    # auto-block + everything AFTER the END marker.
    refreshed = _splice_auto_block(original, new_auto_block)
    # SH-04: atomic write. Preserve the original mode in case the
    # consumer chmod'd their CLAUDE.md.
    try:
        original_mode = path.stat().st_mode & 0o777
    except OSError:
        original_mode = 0o644
    from mgf.common.io import atomic_write

    atomic_write(path, refreshed, mode=original_mode)
    return f"refreshed auto-block in {path} (backup: {backup.name})"


def _apply_bootstrap_claude_md_markers(
    target_dir: Path,
    project_name: str,
    project_kind: str,
) -> str:
    """PAPER-16: bootstrap BEGIN/END markers + auto-block into an
    existing CLAUDE.md that lacks them.

    Reads the existing file, backs it up to ``.bak.<timestamp>``,
    writes a new file with:

      1. The auto-managed BEGIN marker.
      2. The current standards auto-block content.
      3. The auto-managed END marker.
      4. A blank separator line.
      5. The original CLAUDE.md content, verbatim.

    The consumer ends up with the auto-block at the top + their
    existing content below the END marker — exactly the shape
    ``init-project`` produces on a greenfield run, but
    non-destructively. Subsequent ``catch-up --apply`` invocations
    can refresh the auto-block in place via
    :func:`_apply_refresh_claude_md` (the markers are now present).

    PAPER-54: operates on whichever path is the active CLAUDE.md
    (canonical DOC-07 or legacy).
    """
    active, _has_legacy_duplicate = _resolve_claude_md_path(target_dir)
    if active is None:
        # Caller mis-routed us here; fall back to creating fresh.
        return _apply_create_claude_md(target_dir, project_name, project_kind)
    path = active
    original = path.read_text(encoding="utf-8")

    # Backup before modifying. Suffix matches _apply_refresh_claude_md
    # so the backup naming is consistent across catch-up apply paths.
    backup = path.with_suffix(f".md.bak.{int(time.time())}")
    backup.write_text(original, encoding="utf-8")

    # Render a fresh full template; extract just the auto-block
    # (including its BEGIN/END markers) — that's what we prepend.
    fresh_template = _render_claude_md(project_name, project_kind)
    auto_block_with_markers = _extract_full_auto_block(fresh_template)
    if not auto_block_with_markers:
        # Defensive: if the template's structure changes such that
        # we can't extract the marker block, fall back to the full
        # template (project content prepended below).
        auto_block_with_markers = fresh_template.rstrip() + "\n"

    new_content = auto_block_with_markers.rstrip() + "\n\n" + original
    # SH-04: atomic write, preserve mode.
    try:
        original_mode = path.stat().st_mode & 0o777
    except OSError:
        original_mode = 0o644
    from mgf.common.io import atomic_write

    atomic_write(path, new_content, mode=original_mode)
    return (
        f"bootstrapped BEGIN/END markers + auto-block at top of "
        f"{path} (backup: {backup.name}). Project-specific content "
        f"preserved below the END marker."
    )


def _extract_full_auto_block(content: str) -> str:
    """Return the BEGIN..END marker block (markers included). Used
    by :func:`_apply_bootstrap_claude_md_markers` to lift the
    auto-block out of a freshly-rendered template and prepend it
    onto an existing CLAUDE.md."""
    begin_idx = content.find(CLAUDE_MD_BEGIN_MARKER)
    end_idx = content.find(CLAUDE_MD_END_MARKER)
    if begin_idx == -1 or end_idx == -1 or end_idx < begin_idx:
        return ""
    end_line_end = content.find("\n", end_idx)
    if end_line_end == -1:
        end_line_end = len(content)
    return content[begin_idx : end_line_end + 1]


def _apply_create_claude_settings(target_dir: Path, project_kind: str) -> str:
    """Create a fresh .claude/settings.json."""
    path = target_dir / ".claude" / "settings.json"
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(_render_claude_settings(project_kind), encoding="utf-8")
    return f"created {path}"


def _apply_create_agents_md(target_dir: Path) -> str:
    """Write (or restore) the canonical AGENTS.md agent-discovery pointer.

    AGENTS.md is fully managed — the same project-agnostic content
    in every federation project — so this overwrites unconditionally
    when called (catch-up only calls it when the file is missing or
    has drifted). SH-04: atomic write.
    """
    path = target_dir / "AGENTS.md"
    from mgf.common.io import atomic_write

    existed = path.is_file()
    atomic_write(path, AGENTS_MD_CONTENT, mode=0o644)
    return f"{'restored' if existed else 'created'} {path}"


# The standardized .gitignore block catch-up appends when my_stuff/ isn't
# already ignored (AG-18). Local secrets dir + the usual IDE / Claude-local
# noise that must never be committed.
_GITIGNORE_STD_BLOCK = """\
# MGF standardized ignores (AG-18) — local secrets, IDE, Claude-local
my_stuff/
.idea/
.vscode/
.claude/settings.local.json
"""


def _apply_create_memory_md(target_dir: Path) -> str:
    """Seed docs/memory/MEMORY.md — committed in-repo agent memory (AG-17).

    Creates ``docs/memory/`` and writes a seeded MEMORY.md index. The seed is
    conformant on its own (presence is the bar); the agent fills it in over
    time. SH-04: atomic write at the doc default 0o644.
    """
    path = target_dir / "docs" / "memory" / "MEMORY.md"
    existed = path.is_file()
    path.parent.mkdir(parents=True, exist_ok=True)
    content = f"""\
# Memory index — {target_dir.name}

> In-repo agent memory (AG-17). Durable, federation-scoped facts that travel
> with this repo — the committed tier alongside docs/claude/CLAUDE.md (DOC-07)
> and LESSON_LEARNED.md (DOC-19). One line per entry; the fact's detail lives in
> the linked topic file. Keep it current (AG-05); durable facts only, never
> session logs (AG-08). Recipe:
> https://codeberg.org/magogi-admin/mgf-standard/src/branch/main/docs/recipes/in-repo-memory.md

_No memories yet. Add one: create `<topic>.md` (with name/description/type
frontmatter) and link it above._
"""
    from mgf.common.io import atomic_write

    atomic_write(path, content, mode=0o644)
    return f"{'restored' if existed else 'created'} {path}"


def _apply_fix_gitignore(target_dir: Path) -> str:
    """Add the standardized ignore block to .gitignore (AG-18).

    Appends ``_GITIGNORE_STD_BLOCK`` (preserving any existing content, with
    exactly one trailing newline before the block) so ``my_stuff/`` and the
    usual local-secret / IDE noise are ignored. Only appends the lines from
    the block that aren't already present — if ``my_stuff/`` is already there,
    the file's other ignores are left untouched. SH-04: atomic write at 0o644.
    """
    path = target_dir / ".gitignore"
    existed = path.is_file()
    try:
        existing = path.read_text(encoding="utf-8") if existed else ""
    except OSError:
        existing = ""

    present = {line.strip() for line in existing.splitlines()}
    # Append only the block lines not already present (comment + each ignore).
    # `my_stuff/` is the load-bearing addition; the rest are best-effort.
    to_add = [line for line in _GITIGNORE_STD_BLOCK.splitlines() if line.strip() not in present]
    if not to_add:
        # Everything in the block is already present (my_stuff/ included);
        # nothing to do. (This branch is unreachable via _check_gitignore,
        # which only attaches the fix when my_stuff/ is absent — but stay safe.)
        return f"no change needed in {path}"

    # Preserve existing content; ensure exactly one trailing newline before
    # the appended block.
    if existing and not existing.endswith("\n"):
        existing += "\n"
    new_content = existing + "\n".join(to_add) + "\n"

    from mgf.common.io import atomic_write

    atomic_write(path, new_content, mode=0o644)
    return f"{'updated' if existed else 'created'} {path}"


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


def _extract_auto_block(content: str) -> str:
    """Return the substring between BEGIN and END markers (markers excluded)."""
    begin_idx = content.find(CLAUDE_MD_BEGIN_MARKER)
    end_idx = content.find(CLAUDE_MD_END_MARKER)
    if begin_idx == -1 or end_idx == -1 or end_idx < begin_idx:
        return ""
    # Slice from end-of-BEGIN-marker-line to start-of-END-marker-line.
    begin_line_end = content.find("\n", begin_idx)
    if begin_line_end == -1:
        return ""
    return content[begin_line_end + 1 : end_idx]


def _splice_auto_block(original: str, new_block: str) -> str:
    """Replace original's auto-block with new_block (keep markers + outside)."""
    begin_idx = original.find(CLAUDE_MD_BEGIN_MARKER)
    end_idx = original.find(CLAUDE_MD_END_MARKER)
    if begin_idx == -1 or end_idx == -1 or end_idx < begin_idx:
        # Shouldn't be called when markers are missing, but be safe.
        return original

    # Find the end of the BEGIN marker's line (so we keep the marker
    # itself but replace everything below it up to the END marker).
    begin_line_end = original.find("\n", begin_idx)
    return original[: begin_line_end + 1] + new_block + original[end_idx:]


# ---------------------------------------------------------------------------
# Reporting.
# ---------------------------------------------------------------------------


def _check_federation_contract(target_dir: Path, project_name: str) -> _Finding:
    """DOC-17 — verify the FEDERATION.md contract artifacts exist.

    Required for every project (cornerstone, sibling, AND application)
    per DOC-01 v2.11 (2026-05-28) — the uniform root-doc baseline:
      - README, LICENSE, CHANGELOG, CONTRIBUTING, SECURITY, FEEDBACK,
        AGENTS (checked separately), STARTHERE, FEDERATION (pointer stub
        for non-cornerstone projects), DEPS (bidirectional manifest —
        §2 is the former USERS.md roster)
      - a docs/ directory at the repo root
      - MGF_STANDARDS_CONFORMANCE.md under docs/inprogress/ or docs/

    Required for consumer applications only (ADP-04 carves siblings out):
      - MGF_ADOPTION.md (or MGF_COMMON_ADOPTION.md) under docs/inprogress/ or docs/
    """
    is_sibling = project_name.startswith(("mgf-", "mgf_"))

    missing: list[str] = []

    # Universal root-doc baseline (DOC-01 v2.11). AGENTS.md has its own
    # dedicated pointer check; FEDERATION.md may be a pointer stub.
    for required in (
        "README.md",
        "CHANGELOG.md",
        "CONTRIBUTING.md",
        "SECURITY.md",
        "FEEDBACK.md",
        "STARTHERE.md",
        "FEDERATION.md",
        "DEPS.md",
    ):
        if not (target_dir / required).is_file():
            missing.append(required)
    # LICENSE has no extension in this repo; accept LICENSE/.md/.txt.
    license_candidates = (target_dir / name for name in ("LICENSE", "LICENSE.md", "LICENSE.txt"))
    if not any(p.is_file() for p in license_candidates):
        missing.append("LICENSE")
    # DOC-01 v2.11: a docs/ directory MUST exist at the repo root.
    if not (target_dir / "docs").is_dir():
        missing.append("docs/ (directory)")

    # DOC-15 conformance tracker (both kinds).
    conformance_candidates = (
        target_dir / "docs" / "inprogress" / "MGF_STANDARDS_CONFORMANCE.md",
        target_dir / "docs" / "MGF_STANDARDS_CONFORMANCE.md",
    )
    if not any(p.is_file() for p in conformance_candidates):
        missing.append("docs/inprogress/MGF_STANDARDS_CONFORMANCE.md (DOC-15)")

    # DOC-14 adoption tracker (consumers only — ADP-04 carve-out).
    if not is_sibling:
        adoption_candidates = (
            target_dir / "docs" / "inprogress" / "MGF_ADOPTION.md",
            target_dir / "docs" / "MGF_ADOPTION.md",
            target_dir / "docs" / "inprogress" / "MGF_COMMON_ADOPTION.md",
            target_dir / "docs" / "MGF_COMMON_ADOPTION.md",
        )
        if not any(p.is_file() for p in adoption_candidates):
            missing.append("docs/inprogress/MGF_ADOPTION.md (DOC-14)")

    if not missing:
        return _Finding(
            "ok",
            "Federation contract (DOC-17 / FEDERATION.md)",
            "All required artifacts present.",
        )

    role = "federation sibling" if is_sibling else "consumer application"
    detail_lines = [
        f"Missing required artifact(s) for this {role}:",
        *(f"  - {m}" for m in missing),
        "",
        "See FEDERATION.md §3 for the full contract:",
        "  https://codeberg.org/magogi-admin/mgf-standard/src/branch/main/FEDERATION.md",
        "Templates for trackers: mgf-standard/docs/recipes/",
    ]
    return _Finding(
        "fail",
        "Federation contract (DOC-17 / FEDERATION.md)",
        "\n".join(detail_lines),
    )


# Federation test-supervisor wiring constants (PAPER-73). Mirror the
# canonical adoption pattern every sibling's pyproject ships. Shared
# between the audit's printed remediation prose and the --apply
# inserter so both surfaces use the same source-of-truth strings.
_SUPERVISOR_DEV_DEPS: tuple[str, ...] = (
    # Resolved from the Codeberg PyPI registry via the `codeberg-mgf`
    # index (PKG-01 / PKG-06), not pypi.org. Previously this was a
    # `git+ssh://...` direct-reference dep; consumers who ran
    # `catch-up --apply` before mgf-common 0.41.1 carry that form +
    # `[tool.hatch.metadata] allow-direct-references = true` from
    # the same era. Both are still valid (`>=0.1.2` window includes
    # everything 0.1.x); catch-up doesn't auto-rewrite existing
    # dev-dep entries, only inserts missing ones.
    "mgf-test-supervisor>=0.1.2,<0.2",
    "pytest-timeout>=2.3,<3",
)
_SUPERVISOR_PYTEST_KEYS: tuple[tuple[str, object], ...] = (
    ("timeout", 30),
    ("timeout_method", "thread"),
    ("faulthandler_timeout", 60),
)


def _apply_supervisor_wiring(target_dir: Path) -> str:
    """Insert the three TS-22..27 supervisor-wiring fragments into pyproject.

    PAPER-73 (v0.41+). Three idempotent TOML inserts; stdlib-only
    (no `tomli-w` / `tomlkit` dep — same discipline as
    `_apply_add_federation_markers`). Each sub-fix is wrapped in
    "is it already present?" so re-running is always safe.

    The three sub-fixes:

    1. **Dev deps.** Add `mgf-test-supervisor @ git+ssh://...` AND
       `pytest-timeout>=2.3,<3` to `[project.optional-dependencies].dev`.
       Two deps because PAPER-77 — adding the supervisor without
       `pytest-timeout` produces the silent-CRASHED footgun.
    2. **Hatch direct-references opt-in.** Add
       `[tool.hatch.metadata]\\nallow-direct-references = true` when
       hatchling is the build backend (required for the `git+ssh://`
       ref above). Detected by checking `[build-system].build-backend`.
    3. **Pytest timeout config.** Add `timeout = 30`,
       `timeout_method = "thread"`, `faulthandler_timeout = 60` to
       `[tool.pytest.ini_options]`. Reuses the "create section if
       missing" logic from `_apply_add_federation_markers`.

    Backup + atomic write mirror the existing apply functions.
    Returns a one-line summary listing what was added (matches the
    apply-loop's expected output shape).
    """
    import tomllib

    pyproject = target_dir / "pyproject.toml"
    if not pyproject.is_file():
        return "no pyproject.toml; nothing to apply"

    try:
        with pyproject.open("rb") as fh:
            data = tomllib.load(fh)
    except (OSError, tomllib.TOMLDecodeError) as exc:
        return f"could not parse pyproject.toml: {exc}"

    actions: list[str] = []
    original = pyproject.read_text(encoding="utf-8")
    rewritten = original

    # --- Sub-fix 1: dev deps ---
    opt_deps = data.get("project", {}).get("optional-dependencies", {})
    dev_extras = opt_deps.get("dev", [])
    dev_text = " ".join(str(x) for x in dev_extras)
    deps_to_add: list[str] = []
    if "mgf-test-supervisor" not in dev_text:
        deps_to_add.append(_SUPERVISOR_DEV_DEPS[0])
    if "pytest-timeout" not in dev_text:
        deps_to_add.append(_SUPERVISOR_DEV_DEPS[1])
    if deps_to_add:
        rewritten, dev_added = _insert_into_dev_extras(rewritten, deps_to_add)
        if dev_added:
            actions.append(
                f"added {len(deps_to_add)} dev dep(s): "
                f"{[d.split(' ')[0].split('@')[0].strip() for d in deps_to_add]}"
            )

    # --- Sub-fix 2: hatch direct-references opt-in ---
    # PAPER-88 (v0.41.5+): only insert the
    # `[tool.hatch.metadata] allow-direct-references = true` block
    # when at least one dev dep actually uses the PEP 508 direct-
    # reference form (`name @ url`). Hatchling ignores
    # `allow-direct-references` when no direct-ref dep is present,
    # but inserting it anyway leaves the consumer's pyproject with
    # a dead, misleading config block that contradicts catch-up's
    # own narrative ("new adopters get the plain form"). The plain
    # dep form (now the default for mgf-test-supervisor, resolved via
    # the `codeberg-mgf` index) does not need the block.
    build_backend = data.get("build-system", {}).get("build-backend", "")
    combined_dev_deps = [str(d) for d in dev_extras] + deps_to_add
    has_direct_ref_dep = any("@" in d for d in combined_dev_deps)
    if "hatchling" in build_backend and has_direct_ref_dep:
        hatch_meta = data.get("tool", {}).get("hatch", {}).get("metadata", {})
        if not hatch_meta.get("allow-direct-references"):
            rewritten, hatch_added = _insert_hatch_direct_refs(rewritten)
            if hatch_added:
                actions.append("added `[tool.hatch.metadata] allow-direct-references = true`")

    # --- Sub-fix 3: pytest timeout config ---
    pytest_cfg = data.get("tool", {}).get("pytest", {}).get("ini_options", {})
    pytest_keys_to_add: list[tuple[str, object]] = []
    for key, value in _SUPERVISOR_PYTEST_KEYS:
        if pytest_cfg.get(key) != value:
            pytest_keys_to_add.append((key, value))
    if pytest_keys_to_add:
        rewritten, pytest_added = _insert_pytest_timeout_keys(
            rewritten,
            pytest_keys_to_add,
        )
        if pytest_added:
            actions.append(
                f"added {len(pytest_keys_to_add)} pytest config "
                f"key(s): {[k for k, _ in pytest_keys_to_add]}"
            )

    if not actions:
        return "supervisor wiring already complete; nothing to apply"

    if rewritten == original:
        return "wiring inserts produced no change; manual edit required"

    # Backup + atomic write.
    backup = pyproject.with_suffix(f".toml.bak.{int(time.time())}")
    backup.write_text(original, encoding="utf-8")
    try:
        original_mode = pyproject.stat().st_mode & 0o777
    except OSError:
        original_mode = 0o644
    from mgf.common.io import atomic_write

    atomic_write(pyproject, rewritten, mode=original_mode)

    # Adoption-next-step reminder (the --apply path can't run uv sync
    # itself in an editable-install context; tell the consumer).
    return (
        f"wired supervisor: {', '.join(actions)}. "
        f"Now run `uv sync` + `mgf-test-supervisor --self-check` "
        f"to verify. (backup: {backup.name})"
    )


def _insert_into_dev_extras(text: str, deps: list[str]) -> tuple[str, bool]:
    """Insert one or more dep specs into `[project.optional-dependencies].dev`.

    Three layout cases (mirrors `_apply_add_federation_markers`):
      1. Multi-line `dev = [...]` exists: append before the closing `]`.
      2. `[project.optional-dependencies]` section exists but no
         `dev = ...` key: insert a fresh `dev = [...]` block.
      3. `[project.optional-dependencies]` section absent: append a
         complete fresh section at end of file.

    Returns ``(new_text, was_added)``. ``was_added=False`` only when
    the insertion path failed (unusual single-line layout), in which
    case the original text is returned unchanged.
    """
    # Pattern: multi-line `dev = [...]` under any indentation.
    pattern = re.compile(
        r"^(?P<open_indent>\s*)dev\s*=\s*\[(?P<contents>.*?)\n(?P<close_indent>[ \t]*)\]",
        re.MULTILINE | re.DOTALL,
    )
    match = pattern.search(text)
    if match:
        # Case 1: append to existing array.
        contents = match.group("contents")
        close_indent = match.group("close_indent")
        entry_indent_match = re.search(
            r'^([ \t]+)"',
            contents,
            re.MULTILINE,
        )
        entry_indent = entry_indent_match.group(1) if entry_indent_match else close_indent + "    "
        contents_rstripped = contents.rstrip()
        if contents_rstripped and not contents_rstripped.endswith(","):
            prefix = contents_rstripped + ","
        else:
            prefix = contents_rstripped
        new_lines = "\n".join(f'{entry_indent}"{d}",' for d in deps)
        new_block = f"{match.group('open_indent')}dev = [{prefix}\n{new_lines}\n{close_indent}]"
        return text[: match.start()] + new_block + text[match.end() :], True

    # Case 2 / 3: locate `[project.optional-dependencies]` section.
    section_marker = "[project.optional-dependencies]"
    new_lines = "\n".join(f'    "{d}",' for d in deps)
    if section_marker in text:
        new_block = f"\ndev = [\n{new_lines}\n]"
        return text.replace(
            section_marker,
            section_marker + new_block,
            1,
        ), True
    # Case 3: append the full section.
    sep = "" if text.endswith("\n") else "\n"
    new_text = text + sep + f"\n{section_marker}\n" + f"dev = [\n{new_lines}\n]\n"
    return new_text, True


def _insert_hatch_direct_refs(text: str) -> tuple[str, bool]:
    """Insert `[tool.hatch.metadata]\\nallow-direct-references = true`.

    Two cases:
      1. `[tool.hatch.metadata]` exists: insert
         `allow-direct-references = true` just after the section
         header (unless already present, in which case caller
         should not have called us).
      2. Section absent: append a fresh section at end of file.
    """
    section_marker = "[tool.hatch.metadata]"
    if section_marker in text:
        # Insert the key just after the section header.
        new_block = "\nallow-direct-references = true"
        return text.replace(
            section_marker,
            section_marker + new_block,
            1,
        ), True
    sep = "" if text.endswith("\n") else "\n"
    new_text = text + sep + f"\n{section_marker}\n" + "allow-direct-references = true\n"
    return new_text, True


def _insert_pytest_timeout_keys(
    text: str,
    keys: list[tuple[str, object]],
) -> tuple[str, bool]:
    """Insert pytest timeout keys into `[tool.pytest.ini_options]`.

    Inserts each missing key as a top-level pyproject TOML entry
    inside the pytest section. Cases:
      1. `[tool.pytest.ini_options]` exists: insert keys after the
         section header. (We don't try to interleave with existing
         keys — just put new ones at the top of the section.)
      2. Section absent: append a complete fresh section.

    TOML formatting: strings get double-quoted; everything else
    rendered via `repr` (works for ints, bools).
    """

    def _toml_value(v: object) -> str:
        if isinstance(v, str):
            return f'"{v}"'
        if isinstance(v, bool):
            return "true" if v else "false"
        return repr(v)

    section_marker = "[tool.pytest.ini_options]"
    key_lines = "\n".join(f"{k} = {_toml_value(v)}" for k, v in keys)
    if section_marker in text:
        new_block = "\n" + key_lines
        return text.replace(
            section_marker,
            section_marker + new_block,
            1,
        ), True
    sep = "" if text.endswith("\n") else "\n"
    new_text = text + sep + f"\n{section_marker}\n" + key_lines + "\n"
    return new_text, True


def _check_test_supervisor_adoption(
    target_dir: Path,
    project_name: str,
) -> _Finding:
    """TS-22..TS-27 — verify the federation test supervisor is wired.

    The supervisor lives in the published sibling library
    ``mgf-test-supervisor``, resolved (like every ``mgf-*`` package) from
    the Codeberg PyPI registry via the ``codeberg-mgf`` index, never
    pypi.org (PKG-01 / PKG-06). Consumers + siblings declare it in dev
    extras + wire ``pytest-timeout`` config (``timeout_method = "thread"``,
    per TS-23) so per-test hang detection works.

    Skip: the supervisor itself (``mgf-test-supervisor``) does not
    depend on itself — silently OK.

    Three outcomes:
      - ok: dev-dep declared AND timeout_method wired.
      - warn (partial): one of the two missing.
      - warn (missing): neither — suggest install + config.

    Note: severity is ``warn`` not ``fail`` because some projects
    (early greenfields, single-file demos) legitimately have no
    test layer yet. Catch-up flags drift but does not block.
    """
    import tomllib

    # Self-skip: the supervisor library doesn't depend on itself.
    if project_name == "mgf-test-supervisor":
        return _Finding(
            "ok",
            "Federation test supervisor (TS-22..TS-27)",
            "this IS the supervisor — self-dependency not required.",
        )

    pyproject = target_dir / "pyproject.toml"
    if not pyproject.is_file():
        # No pyproject — can't audit. Pip-tools layout could carry
        # the dep in requirements/dev.txt; left as a follow-up
        # (mirrors _check_pin_in_requirements pattern).
        return _Finding(
            "warn",
            "Federation test supervisor (TS-22..TS-27)",
            "no pyproject.toml; cannot check test-supervisor wiring. "
            "Pip-tools layouts are not yet audited for this check.",
        )

    try:
        with pyproject.open("rb") as fh:
            data = tomllib.load(fh)
    except (OSError, tomllib.TOMLDecodeError) as exc:
        return _Finding(
            "fail",
            "Federation test supervisor (TS-22..TS-27)",
            f"could not parse pyproject.toml: {exc}",
        )

    # Signals 1+2 — dev deps declared. Scan optional-dependencies.dev
    # (and a couple of common alternate names) for both
    # `mgf-test-supervisor` (the supervisor itself) AND `pytest-timeout`
    # (the pytest plugin that honors the TS-23 timeout keys —
    # PAPER-77).
    #
    # Without `pytest-timeout`, the consumer's pytest config keys
    # `timeout = 30` / `timeout_method = "thread"` /
    # `faulthandler_timeout = 60` are unknown options. TS-04's
    # `filterwarnings = ["error"]` promotes the resulting
    # `PytestConfigWarning: Unknown config option: timeout` to a
    # fatal error AT PYTEST STARTUP, so every chunk the supervisor
    # spawns crashes immediately with a single-line `0 passed, 1
    # CRASHED` summary — opaque to first-time adopters.
    opt_deps = data.get("project", {}).get("optional-dependencies", {})
    dev_deps_text = ""
    for group in ("dev", "test", "tests"):
        for spec in opt_deps.get(group, []):
            dev_deps_text += " " + str(spec)
    dep_declared = "mgf-test-supervisor" in dev_deps_text
    pytest_timeout_declared = "pytest-timeout" in dev_deps_text

    # Signal 3 — pytest-timeout config wired per TS-23. The supervisor
    # subprocess relies on `pytest-timeout` being active in the
    # consumer's pytest config to enforce the per-test budget.
    pytest_cfg = data.get("tool", {}).get("pytest", {}).get("ini_options", {})
    timeout_method_wired = pytest_cfg.get("timeout_method") == "thread"

    if dep_declared and pytest_timeout_declared and timeout_method_wired:
        return _Finding(
            "ok",
            "Federation test supervisor (TS-22..TS-27)",
            "`mgf-test-supervisor` + `pytest-timeout` declared in dev "
            'extras; `timeout_method = "thread"` set in pytest config.',
        )

    fix_lines: list[str] = []
    if not dep_declared or not pytest_timeout_declared:
        # PAPER-77: emit BOTH deps together — a consumer who adds
        # only `mgf-test-supervisor` and the timeout keys (but not
        # `pytest-timeout`) gets a silent CRASHED outcome from
        # every chunk in the supervisor self-check.
        fix_lines.append("  - Missing dev dep(s). Add to [project.optional-dependencies.dev]:")
        if not dep_declared:
            fix_lines.append('      "mgf-test-supervisor>=0.1.2,<0.2",')
        if not pytest_timeout_declared:
            fix_lines.append('      "pytest-timeout>=2.3,<3",')
        fix_lines.append(
            "    (mgf-test-supervisor resolves from the Codeberg PyPI "
            "registry via the `codeberg-mgf` index, not pypi.org — add "
            "the index + `[tool.uv.sources]` mapping per "
            "PACKAGE_DISTRIBUTION.md §6 (PKG-06) if the project lacks it. "
            "Projects on mgf-common <0.41.1 may carry a legacy "
            "`@ git+ssh://...` form + a `[tool.hatch.metadata] "
            "allow-direct-references = true` block — both still work; new "
            "adopters get the plain form. `pytest-timeout` is required "
            "separately — without it, the supervisor's `timeout` keys "
            "below are rejected by pytest at startup as unknown config "
            "options, producing opaque CRASHED outcomes — PAPER-77.)"
        )
    if not timeout_method_wired:
        fix_lines.append("  - Missing TS-23 timeout config. Add to [tool.pytest.ini_options]:")
        fix_lines.append(
            '      timeout = 30\n      timeout_method = "thread"\n      faulthandler_timeout = 60'
        )
        fix_lines.append(
            "    (Per-test budget + thread-stack dump on hang. The "
            "supervisor relies on this when invoking pytest as a "
            "subprocess. Requires `pytest-timeout` in dev extras "
            "above — adding the keys without the plugin is the "
            "silent-CRASHED footgun PAPER-77 closed.)"
        )

    return _Finding(
        "warn",
        "Federation test supervisor (TS-22..TS-27)",
        "Federation test supervisor not fully wired:\n"
        + "\n".join(fix_lines)
        + "\n\nFull adoption guide: "
        "https://codeberg.org/magogi-admin/mgf-test-supervisor/src/branch/main/README.md\n"
        "Standards rules: `mgf-fed standards testing` "
        "(TS-22..TS-27 + §19).\n"
        "Re-run with `--apply` to insert all three sub-fixes "
        "(PAPER-73).",
        fix=_apply_supervisor_wiring,
    )


def _check_uv_sources_paths(target_dir: Path) -> _Finding:
    """PAPER-83 — audit `[tool.uv.sources]` entries' `path =` values.

    `[tool.uv.sources]` entries can map a dep name to a local
    filesystem path for editable development (e.g.,
    `mgf-common = { path = "../mgf-common", editable = true }`).
    When the federation renames a sibling (most commonly underscore
    → hyphen during the NM-04 cutover), these entries silently break
    — the path points at a directory that no longer exists or has
    become an empty husk. `uv sync` then either fails or silently
    resolves the dep from the configured index (the Codeberg registry
    for `mgf-*`) instead of the source tree the consumer thought they
    were using.

    This audit:
      1. Reads `[tool.uv.sources]` from pyproject.toml.
      2. For each entry with a `path = "..."` value, resolves the
         path (relative to target_dir) and checks it exists as a
         directory.
      3. If the path is missing, attempts to detect the obvious
         "separator drift" fix: same name with `_` ↔ `-` flipped.
      4. Reports broken entries with the suggested fix when
         unambiguous; otherwise just flags the broken path.

    Severity: warn (drift is real; consumer's `uv sync` may be
    silently doing the wrong thing).
    """
    import tomllib

    pyproject = target_dir / "pyproject.toml"
    if not pyproject.is_file():
        return _Finding(
            "ok",
            "uv sources — path existence (PAPER-83)",
            "no pyproject.toml; nothing to audit.",
        )
    try:
        with pyproject.open("rb") as fh:
            data = tomllib.load(fh)
    except (OSError, tomllib.TOMLDecodeError) as exc:
        return _Finding(
            "warn",
            "uv sources — path existence (PAPER-83)",
            f"could not parse pyproject.toml: {exc}",
        )
    uv_sources = data.get("tool", {}).get("uv", {}).get("sources", {})
    if not uv_sources:
        return _Finding(
            "ok",
            "uv sources — path existence (PAPER-83)",
            "no `[tool.uv.sources]` entries; nothing to audit.",
        )

    broken: list[str] = []
    for source_key, source_value in uv_sources.items():
        if not isinstance(source_value, dict):
            continue
        path_str = source_value.get("path")
        if not isinstance(path_str, str):
            continue  # git, url, etc. — different schemes
        # Resolve relative to target_dir.
        resolved = (target_dir / path_str).resolve()
        if resolved.is_dir():
            continue  # OK.
        # Path missing or not a directory. Check for separator-
        # drift: the most common rename in this federation is
        # `_` ↔ `-` (the NM-04 slug cutover left a long tail of
        # editable installs pointing at the underscore form).
        suggestion = ""
        # Try flipping separators in the leaf only (safer than
        # full-path flip).
        candidate_names = []
        # Get the basename + try variants.
        path_obj = Path(path_str)
        leaf = path_obj.name
        if "_" in leaf:
            candidate_names.append(leaf.replace("_", "-"))
        if "-" in leaf:
            candidate_names.append(leaf.replace("-", "_"))
        for cand in candidate_names:
            cand_path = (target_dir / path_obj.parent / cand).resolve()
            if cand_path.is_dir():
                suggestion = (
                    f"      did you mean `path = "
                    f'"{path_obj.parent}/{cand}"`? '
                    f"({cand_path} exists; separator drift from "
                    f"the NM-04 slug cutover)"
                )
                break
        line = (
            f'  - `{source_key}` → `path = "{path_str}"` '
            f"resolves to `{resolved}` which does not exist."
        )
        if suggestion:
            line += "\n" + suggestion
        broken.append(line)

    if not broken:
        return _Finding(
            "ok",
            "uv sources — path existence (PAPER-83)",
            f"all {len(uv_sources)} `[tool.uv.sources]` path entries "
            f"resolve to existing directories.",
        )

    return _Finding(
        "warn",
        "uv sources — path existence (PAPER-83)",
        f"{len(broken)} `[tool.uv.sources]` entry(ies) point at "
        f"non-existent paths:\n\n"
        + "\n".join(broken)
        + "\n\nFix by hand: update the pyproject `[tool.uv.sources]` "
        "path entries to point at the correct paths, or remove the "
        "path entries to let `uv` resolve the dep from its index (for "
        "`mgf-*`, the Codeberg `codeberg-mgf` index — PKG-06). Not "
        "auto-applied — path choice is project-specific (a separator-"
        "drift fix may LOOK right but actually point at a stale "
        "checkout).",
    )


def _check_woodpecker_ci(target_dir: Path) -> _Finding:
    """WF-01..WF-13 — verify a Woodpecker CI config is present.

    The federation uses Woodpecker CI on Codeberg/Forgejo
    (`docs/standards/WORKFLOWS.md`). Same `.woodpecker.yml` runs on
    Codeberg natively AND locally via `woodpecker-cli exec`. Other
    CI platforms (GitHub Actions, GitLab CI) are valid carve-outs,
    but the federation's primary CI is Woodpecker.

    This is a **presence** check only. The `.woodpecker.yml` is a
    GENERATED artifact (WF-13): `mgf-fed scaffold` renders it from the
    canonical template and `mgf-fed drift-check` enforces that the
    on-disk file stays current — so a present-but-stale pipeline is the
    federation tool's concern, not this presence audit's.

    Checks (in order):
      1. `.woodpecker.yml` at repo root, OR
      2. `.woodpecker/*.yml` (multi-pipeline layout).

    Severity: warn (not fail) — some legitimately-CI-less projects
    exist (templates, scratch repos). Catch-up surfaces the drift
    so the maintainer can confirm intentional vs accidental.
    """
    # Common shapes (Woodpecker accepts both).
    single = target_dir / ".woodpecker.yml"
    multi_dir = target_dir / ".woodpecker"

    has_single = single.is_file()
    has_multi = multi_dir.is_dir() and any(
        p.suffix in (".yml", ".yaml") for p in multi_dir.iterdir() if p.is_file()
    )

    if has_single or has_multi:
        loc = ".woodpecker.yml" if has_single else ".woodpecker/*.yml"
        return _Finding(
            "ok",
            "CI/CD — Woodpecker config (WF-01..WF-13)",
            f"Woodpecker config present at `{loc}`. (It's a generated "
            "artifact — `mgf-fed drift-check` enforces it stays current; "
            "regenerate with `mgf-fed scaffold`, WF-13.)",
        )

    # No Woodpecker config. Note: a project might legitimately use a
    # different CI (Forgejo Actions, GitHub Actions during a transition
    # period, etc.). We can't reliably detect every CI platform, so we
    # warn rather than fail — and surface the federation default.
    return _Finding(
        "warn",
        "CI/CD — Woodpecker config (WF-01..WF-13)",
        "No `.woodpecker.yml` (or `.woodpecker/*.yml`) found. The "
        "federation's primary CI is Woodpecker on Codeberg "
        "(`docs/standards/WORKFLOWS.md`). It runs on Codeberg "
        "natively AND locally via `woodpecker-cli exec` — same "
        "pipeline file in both contexts.\n\n"
        "If this project uses a different CI (Forgejo Actions, "
        "GitHub Actions), document the carve-out in "
        "`MGF_STANDARDS_CONFORMANCE.md` so future agents know it's "
        "intentional. Otherwise, generate it with `mgf-fed scaffold "
        "<repo>` — the pipeline is a generated artifact rendered from "
        "the canonical template (WF-13), not hand-authored; see the "
        "`run-ci` recipe. The standards doc has WF-01..WF-13 rules + "
        "example.\n\n"
        "Standards rules: `mgf-fed standards workflows`.",
    )


# Canonical federation 13-marker taxonomy (TESTING.md §13.11 / TS-25).
# Every Magogi project that adopts the supervisor MUST register these in
# its `[tool.pytest.ini_options].markers` — otherwise `--strict-markers`
# rejects `@pytest.mark.<name>` at collection time with `PytestUnknownMarkWarning
# → Error` (TS-04 `filterwarnings = ["error"]` makes the warning fatal).
#
# Drift between this list + docs/standards/TESTING.md §13.11 is itself
# drift — the constant is the source of truth at audit time; the doc is
# the prose contract. If they diverge, fix both in the same commit.
#
# `slow` is listed despite its deprecation marker (per TS-18,
# scheduled for v2.0 removal) — projects still in flight may have
# historical `@pytest.mark.slow` uses, and requiring registration is
# gentler than rejecting at collection.
#
# `_CANONICAL_MARKER_DESCRIPTIONS` is the prose description for each
# marker, matching the strings used by every sibling's pyproject's
# `[tool.pytest.ini_options].markers` block. Shared between the
# audit (read) path and the --apply (write) path so a project that
# accepts the auto-add gets the same description text every sibling
# already ships.
_CANONICAL_MARKER_DESCRIPTIONS: dict[str, str] = {
    "smoke": "minimal post-install sanity (fast; no fixtures); tier-0",
    "e2e": "end-to-end test exercising bootstrap + multiple subsystems; tier-3",
    "integration": "cross-module integration test (slower; may launch real subprocesses)",
    "contract": "wire-format + SDK-surface pins; tier-1",
    "property": "invariant-under-all-inputs test (Hypothesis); tier-1",
    "regression": "pin for a specific past bug; docstring cites the fixing commit; tier-1",
    "fuzz": "random / generated / hostile input — asserts safety properties; tier-1",
    "concurrency": "real-threads contention test; tier-2",
    "conformance": "design-promise tests (G/S guarantees); release-gated; tier-2",
    "use_case": "reproduces a consumer scenario from FEEDBACK paper or recipe doc; tier-2",
    "perf": "performance pin — pins a hot path's per-call cost (PF-* + TS-14). tier-3",
    "stress": "sustained high-load test; tier-3; nightly only",
    "slow": "legacy marker for tests >1s wall-clock (TS-18). Deprecated. Removed in v2.0.",
}

_FEDERATION_MARKERS: frozenset[str] = frozenset(_CANONICAL_MARKER_DESCRIPTIONS.keys())


def _extract_pyproject_marker_names(target_dir: Path) -> set[str] | None:
    """Read `[tool.pytest.ini_options].markers` + extract just the names.

    Each entry is conventionally `"name: human description; tier-N"` —
    we take everything before the first `:`. Entries without a `:`
    are accepted as a bare name (rare but legal).

    Returns None if pyproject.toml is missing or unparseable (callers
    should classify that as a separate failure mode, not as
    "all 13 markers missing").
    """
    import tomllib

    pyproject = target_dir / "pyproject.toml"
    if not pyproject.is_file():
        return None
    try:
        with pyproject.open("rb") as fh:
            data = tomllib.load(fh)
    except (OSError, tomllib.TOMLDecodeError):
        return None
    pytest_cfg = data.get("tool", {}).get("pytest", {}).get("ini_options", {})
    raw_markers = pytest_cfg.get("markers", [])
    if not isinstance(raw_markers, list):
        return None
    names: set[str] = set()
    for entry in raw_markers:
        if not isinstance(entry, str):
            continue
        # "name: description" → "name"; "bare_name" → "bare_name".
        name, _, _ = entry.partition(":")
        name = name.strip()
        if name:
            names.add(name)
    return names


def _apply_add_federation_markers(target_dir: Path) -> str:
    """Insert missing federation markers into pyproject's markers array.

    PAPER-69 (v0.41+). Strategy: stdlib-only structured-text edit.

    1. Parse pyproject.toml via `tomllib` to compute the missing set
       (reuses `_extract_pyproject_marker_names` for symmetry with
       the audit row).
    2. If no missing, no-op (return early; the apply-loop will report
       "nothing to apply").
    3. Multi-line-aware regex locates the
       `[tool.pytest.ini_options].markers` array. The regex captures
       the indentation of `markers = [` AND the indentation of the
       closing `]` so the inserted entries match the surrounding
       block exactly.
    4. Existing entries' formatting is preserved verbatim — we only
       insert NEW lines before the closing `]`, never rewrite
       existing ones.
    5. If the regex can't locate a multi-line markers block (e.g.,
       project uses an unusual single-line format), refuse the
       auto-edit and surface a clear "edit by hand" message instead
       of risking a malformed pyproject.

    Backup + atomic write mirror `_apply_bump_pin`'s shape: a
    `.toml.bak.<unix>` is written before the atomic replacement;
    file mode is preserved.

    No new dep: `tomllib` (stdlib, 3.11+) for the read; `re` for the
    locate; `atomic_write` (public `mgf.common.io`) for the write.
    """
    pyproject = target_dir / "pyproject.toml"
    if not pyproject.is_file():
        return "no pyproject.toml; nothing to apply"

    existing = _extract_pyproject_marker_names(target_dir)
    if existing is None:
        return "pyproject.toml unparseable; manual edit required"
    missing = sorted(_FEDERATION_MARKERS - existing)
    if not missing:
        return "no missing federation markers"

    original = pyproject.read_text(encoding="utf-8")

    # Multi-line-aware regex: capture indent of `markers = [`, the
    # array contents, and the indent of the closing `]`. The
    # non-greedy `(.*?)` inside `re.DOTALL` consumes the array body
    # up to the first dedented `]` line — typical pyproject layout.
    pattern = re.compile(
        r"^(?P<open_indent>\s*)markers\s*=\s*\[(?P<contents>.*?)\n(?P<close_indent>[ \t]*)\]",
        re.MULTILINE | re.DOTALL,
    )
    match = pattern.search(original)
    if match is None:
        # No multi-line markers array. Two sub-cases:
        #   (a) `[tool.pytest.ini_options]` exists but has no
        #       `markers = ...` key → append a fresh markers block
        #       inside that section.
        #   (b) Neither the section nor the key exists → append the
        #       full `[tool.pytest.ini_options]\nmarkers = [...]`
        #       section at end of file (safe: pyproject is tolerant
        #       of extra trailing sections, and a project missing
        #       this section needs the federation defaults regardless).
        # We refuse only on the truly-unusual case (single-line
        # markers array that the multi-line regex can't parse).
        single_line_pattern = re.compile(
            r"^(\s*)markers\s*=\s*\[[^\n]*\]",
            re.MULTILINE,
        )
        if single_line_pattern.search(original):
            return (
                "found a single-line `markers = [...]` array — auto-"
                "edit refuses single-line for safety; reformat to "
                "multi-line and re-run, or add the missing "
                f"{len(missing)} marker(s) {missing} by hand"
            )

        # (a) or (b): create a fresh multi-line markers block.
        marker_lines = "\n".join(
            f'    "{name}: {_CANONICAL_MARKER_DESCRIPTIONS.get(name, "")}",' for name in missing
        )
        ini_section_marker = "[tool.pytest.ini_options]"
        if ini_section_marker in original:
            # Insert `markers = [...]` just after the section header.
            new_block = f"\nmarkers = [\n{marker_lines}\n]"
            rewritten = original.replace(
                ini_section_marker,
                ini_section_marker + new_block,
                1,
            )
        else:
            # Append a complete fresh section at end of file.
            sep = "" if original.endswith("\n") else "\n"
            rewritten = (
                original + sep + f"\n{ini_section_marker}\n" + f"markers = [\n{marker_lines}\n]\n"
            )

        backup = pyproject.with_suffix(f".toml.bak.{int(time.time())}")
        backup.write_text(original, encoding="utf-8")
        try:
            original_mode = pyproject.stat().st_mode & 0o777
        except OSError:
            original_mode = 0o644
        from mgf.common.io import atomic_write

        atomic_write(pyproject, rewritten, mode=original_mode)
        return (
            f"created `[tool.pytest.ini_options].markers` with "
            f"{len(missing)} federation marker(s): {missing} "
            f"(backup: {backup.name})"
        )

    contents = match.group("contents")
    close_indent = match.group("close_indent")

    # Detect the inner indent from the FIRST existing entry. If the
    # array is empty, derive from close_indent + 4 spaces.
    entry_indent_match = re.search(
        r'^([ \t]+)"',
        contents,
        re.MULTILINE,
    )
    entry_indent = entry_indent_match.group(1) if entry_indent_match else close_indent + "    "

    # Ensure the existing contents end with a trailing comma so our
    # appended entries don't produce a syntax error. The contents end
    # with EITHER:
    #   - a closing comma + optional whitespace + newline (good)
    #   - an entry without trailing comma + optional whitespace + newline (needs comma)
    #   - empty (just whitespace; markers = []) — append directly
    # `rstrip` away the trailing whitespace, inspect the last non-WS char.
    contents_rstripped = contents.rstrip()
    if contents_rstripped and not contents_rstripped.endswith(","):
        # Last existing entry lacks a trailing comma. Add one.
        new_contents_prefix = contents_rstripped + ","
    else:
        new_contents_prefix = contents_rstripped

    # Build new entries.
    new_entry_lines = []
    for name in missing:
        desc = _CANONICAL_MARKER_DESCRIPTIONS.get(name, "")
        new_entry_lines.append(f'{entry_indent}"{name}: {desc}",')

    # Assemble the replacement: `markers = [` + (existing entries +
    # trailing comma if needed) + newline + new entries + newline +
    # close-indent + `]`. We rebuild the entire matched span.
    new_block = (
        f"{match.group('open_indent')}markers = ["
        f"{new_contents_prefix}\n" + "\n".join(new_entry_lines) + f"\n{close_indent}]"
    )
    rewritten = original[: match.start()] + new_block + original[match.end() :]

    if rewritten == original:
        # Sanity guard — should not happen given the regex matched and
        # missing is non-empty, but defensive.
        return "rewrite produced no change; manual edit required"

    # Backup + atomic write — mirrors _apply_bump_pin's shape.
    backup = pyproject.with_suffix(f".toml.bak.{int(time.time())}")
    backup.write_text(original, encoding="utf-8")
    try:
        original_mode = pyproject.stat().st_mode & 0o777
    except OSError:
        original_mode = 0o644
    from mgf.common.io import atomic_write

    atomic_write(pyproject, rewritten, mode=original_mode)

    return (
        f"added {len(missing)} federation marker(s) to "
        f"`[tool.pytest.ini_options].markers`: {missing} "
        f"(backup: {backup.name})"
    )


def _check_federation_markers(target_dir: Path) -> _Finding:
    """TS-25 — verify the federation 13-marker taxonomy is registered.

    The supervisor needs every marker in :data:`_FEDERATION_MARKERS`
    registered in the project's pyproject for two reasons:
      1. `--strict-markers` + `filterwarnings = ["error"]` (TS-04)
         turn unregistered markers into collection-time errors.
      2. The supervisor's tier-based selection relies on consistent
         marker semantics across siblings (a `@pytest.mark.integration`
         test in any project means the same federation thing).

    Missing markers are listed in the warn message; extra
    (project-specific) markers are noted gently — they're legitimate.

    Severity: warn — projects that don't (yet) use the supervisor or
    have no test layer still benefit from seeing the gap.
    """
    project_markers = _extract_pyproject_marker_names(target_dir)
    if project_markers is None:
        # No pyproject (or unparseable). Distinct failure mode handled
        # elsewhere; here we just say "skipped."
        return _Finding(
            "warn",
            "Federation marker taxonomy (TS-25)",
            "no pyproject.toml or unparseable; cannot audit marker set. "
            "(Distinct from the test-supervisor check above; this row "
            "verifies the 13-marker registration that `--strict-markers` "
            "requires.)",
        )

    missing = _FEDERATION_MARKERS - project_markers
    extra = project_markers - _FEDERATION_MARKERS

    if not missing:
        detail = "All 13 federation markers registered (per `mgf-fed standards testing` §13.11)."
        if extra:
            detail += (
                f" Project also carries {len(extra)} project-specific "
                f"marker(s): {sorted(extra)}. Intentional? — these "
                f"don't break federation conformance but won't be "
                f"recognised cross-sibling."
            )
        return _Finding("ok", "Federation marker taxonomy (TS-25)", detail)

    # Missing one or more. Print the canonical snippet so the operator
    # can paste it into pyproject.toml — OR, with --apply, the
    # _apply_add_federation_markers fix callable does it for them
    # (PAPER-69). Descriptions read from the module-level
    # _CANONICAL_MARKER_DESCRIPTIONS so both code paths use the same
    # canonical strings.
    snippet_lines = [
        "Add these entries to [tool.pytest.ini_options].markers "
        "(or re-run with --apply to insert them automatically — "
        "PAPER-69):",
        "",
    ]
    for name in sorted(missing):
        desc = _CANONICAL_MARKER_DESCRIPTIONS.get(name, "")
        snippet_lines.append(f'  "{name}: {desc}",')

    detail_lines = [
        f"MISSING {len(missing)} of 13 federation markers: {sorted(missing)}.",
        "",
        "Per TS-25 + `--strict-markers`, an unregistered marker becomes "
        'a collection-time error (TS-04 `filterwarnings = ["error"]` '
        "promotes `PytestUnknownMarkWarning`). A test decorated "
        "`@pytest.mark.integration` would be rejected, not classified.",
        "",
        *snippet_lines,
        "",
        "Standards rules: `mgf-fed standards testing` (TS-25 + §13.11).",
    ]
    if extra:
        detail_lines.append(
            f"\nProject also carries {len(extra)} project-specific "
            f"marker(s): {sorted(extra)} — fine; just noting."
        )
    return _Finding(
        "warn",
        "Federation marker taxonomy (TS-25)",
        "\n".join(detail_lines),
        fix=_apply_add_federation_markers,
    )


def _print_report(
    findings: list[_Finding],
    target_dir: Path,
    project_name: str,
    project_kind: str,
) -> None:
    """Print the audit report."""
    from mgf.common import __version__

    print(
        f"mgf-fed catch-up — auditing {target_dir}",
    )
    print(
        f"  Project:        {project_name} (kind: {project_kind})",
    )
    print(
        f"  mgf-common:     {__version__}",
    )
    print()
    print("Drift report:")
    print()
    for finding in findings:
        icon = _SEVERITY_ICON[finding.severity]
        print(f"  {icon} {finding.title}")
        # Indent the detail.
        for line in finding.detail.splitlines() or [""]:
            print(f"      {line}")
        print()

    fixable = sum(1 for f in findings if f.fix is not None)
    if fixable > 0:
        print(
            f"{fixable} finding(s) have mechanical fixes. "
            f"Re-run with `--apply` to apply (originals backed up).",
        )


def _exit_code_for(findings: list[_Finding]) -> int:
    """Exit non-zero if anything failed; warnings are zero (advisory)."""
    if any(f.severity == "fail" for f in findings):
        return 1
    return 0


def _next_major(version: str) -> str:
    """Return ``X.<Y+1>`` for an ``X.Y[.Z]`` version (rough upper bound)."""
    parts = version.split(".")
    if len(parts) < 2:
        return version
    try:
        major = int(parts[0])
        minor = int(parts[1])
    except ValueError:
        return version
    return f"{major}.{minor + 1}"


__all__ = ["add_catch_up_args", "run_catch_up"]
