#!/usr/bin/env python3
"""Guards for `plugin/` — the one part of this repo that ships straight to users.

WHY THIS EXISTS. `plugin/` is not built into the Docker image. Installs fetch it
from this git repo via `.claude-plugin/marketplace.json`, so a push IS the
release for plugin content: no build, no gate, immediately fetchable. Two
separate defects have reached a live install through that path:

  - #2198 — all four hook scripts were inert (wrong env-var case, line-oriented
    `jq -rR`, line-oriented `cut -c`). No CI ran, because `plugin/**` wasn't in
    the workflow's `paths:` filter at all.
  - #2209 — the fix for #2198 shipped to `main` and still couldn't reach an
    install, because `plugin.json`'s version wasn't bumped and the installer
    compares versions to decide whether to refresh its cache.

The rule for the second one was already written down and was still missed. A
written rule that depends on being remembered during a long session is not a
control; this is.

shellcheck and jq are NOT in `ci-python` (verified against CI-runner's Dockerfile
and scripts/install-common.sh, not from memory — rule #37). CI installs both
per-job, which is what CI-runner's own docs/process.md prescribes for a dep with
a single consumer: "If only one project needs the dep, prefer that project
installing it per-job in their workflow — at least until a second consumer
arrives." Promotion into the image is filed as an issue there rather than
assumed here.

Both are optional at runtime: without shellcheck the lint step is SKIPPED and
says so, and without jq the smoke test is skipped. A skipped check announces
itself loudly, because a check that quietly no-ops is the failure mode this
whole file exists to prevent.

Usage:
    python3 scripts/check_plugin.py            # all checks
    python3 scripts/check_plugin.py --no-version   # skip the bump check
"""
from __future__ import annotations

import argparse
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
PLUGIN_DIR = ROOT / "plugin"
HOOKS_DIR = PLUGIN_DIR / "hooks"
MANIFEST = PLUGIN_DIR / ".claude-plugin" / "plugin.json"

# Paths whose contents reach an install. Keep in step with the workflow's
# `paths:` filter — a path that ships but isn't checked here is the gap again.
SHIPPED = ("plugin", ".claude-plugin")

failures: list[str] = []


def fail(msg: str) -> None:
    failures.append(msg)
    print(f"FAIL  {msg}")


def ok(msg: str) -> None:
    print(f"ok    {msg}")


def skip(msg: str) -> None:
    # Loud on purpose. A check that quietly does nothing is indistinguishable
    # from a check that passed — the exact confusion that let #2198 survive.
    print(f"SKIP  {msg}")


def hook_scripts() -> list[Path]:
    return sorted(HOOKS_DIR.glob("*.sh"))


# --- syntax ----------------------------------------------------------------

def check_syntax() -> None:
    """`bash -n` every hook. Catches nothing subtle, costs nothing, and a syntax
    error here means a hook that silently never runs."""
    for script in hook_scripts():
        proc = subprocess.run(
            ["bash", "-n", str(script)], capture_output=True, text=True
        )
        if proc.returncode != 0:
            fail(f"{script.relative_to(ROOT)}: bash -n — {proc.stderr.strip()}")
        else:
            ok(f"{script.relative_to(ROOT)}: syntax")


# --- known-bad patterns ----------------------------------------------------

# Each entry: (compiled pattern, short label, why it's wrong).
# These are the exact classes from #2198. They are deliberately specific — a
# broad shell linter belongs in the image, not hand-rolled here.
PATTERNS: list[tuple[re.Pattern, str, str]] = [
    (
        re.compile(r"CLAUDE_PLUGIN_OPTION_[a-z]"),
        "lowercase userConfig env var",
        "Claude Code exports userConfig to hooks as CLAUDE_PLUGIN_OPTION_<KEY> "
        "with the key UPPERCASED. The lowercase spelling reads as empty and the "
        "hook then does nothing, silently.",
    ),
    (
        # -R without -s: reads input line by line, so a multi-line payload is
        # encoded per line and joined with raw newlines. The class is a-r + t-z
        # (i.e. every letter EXCEPT `s`) so `-rR` is caught and `-sRr` is not —
        # an earlier a-q spelling silently excluded `r` and missed the real
        # defect, which is exactly the flag combination that shipped.
        re.compile(r"jq\s+-(?:[a-rt-zA-Z]*R[a-rt-zA-Z]*)\s"),
        "line-oriented jq -R",
        "jq -R reads input LINE BY LINE. Encoding a multi-line payload that way "
        "produces separate encoded lines joined by raw newlines — an invalid "
        "URL. Use -s (slurp) as well, e.g. `jq -sRr '@uri'`.",
    ),
    (
        re.compile(r"\|\s*cut\s+-c"),
        "line-oriented cut for a payload cap",
        "cut -c truncates EACH LINE, so it does not cap total size. Use "
        "`head -c N` to bound a payload.",
    ),
]


def check_patterns() -> None:
    for script in hook_scripts():
        text = script.read_text(encoding="utf-8", errors="replace")
        rel = script.relative_to(ROOT)
        hits = 0
        for line_no, line in enumerate(text.splitlines(), 1):
            # A line that only *documents* the trap is fine — several hooks now
            # carry a comment naming the wrong form so the next reader knows.
            if line.lstrip().startswith("#"):
                continue
            for pattern, label, why in PATTERNS:
                if pattern.search(line):
                    hits += 1
                    fail(f"{rel}:{line_no}: {label}\n        {line.strip()}\n        {why}")
        if not hits:
            ok(f"{rel}: no known-bad patterns")


# --- the version bump ------------------------------------------------------

# --- shellcheck ------------------------------------------------------------

def check_shellcheck() -> None:
    """Real shell linting, where available.

    The hand-rolled patterns above only know the bugs that already happened.
    This is what catches the next one.
    """
    exe = shutil.which("shellcheck")
    if not exe:
        skip("shellcheck not installed — install it to lint the hooks properly")
        return
    for script in hook_scripts():
        proc = subprocess.run(
            [exe, "--severity=warning", "--shell=bash", str(script)],
            capture_output=True, text=True,
        )
        rel = script.relative_to(ROOT)
        if proc.returncode != 0:
            fail(f"{rel}: shellcheck\n{proc.stdout.strip()}")
        else:
            ok(f"{rel}: shellcheck")


# --- the fail-open contract ------------------------------------------------

# Every hook promises never to break the operator's session: unconfigured or
# unreachable, it exits 0. Unconfigured, the enrichment hooks are SILENT — no
# call was owed. scribe_session_context.sh is the exception by design — it
# always emits a static behavioural floor that needs no credentials and no
# network, so "silent" would be the wrong assertion for it.
#
# UNREACHABLE is different for the two write-path hooks since #2932: a call
# that was owed and did not come back is SAID, once per outage ("> Scribe did
# not answer …"), so a session can tell "checked, nothing there" from "never
# checked". That line — or silence, when the once-per-outage marker in
# ${TMPDIR:-/tmp}/scribe-priorart/ was set by a run in the last ten minutes —
# is the only output allowed with no working instance; anything else is a hook
# speaking on data it cannot have.
#
# This is the contract that made #2198 invisible for weeks, so it is worth
# pinning: the bug and the healthy no-results case looked identical from
# outside. #2932 is what finally makes the failure visible at the write; this
# check makes sure the fail-open behaviour stays deliberate rather than
# accidental.
# A symbol that exists nowhere, ASSEMBLED rather than written literally.
# The prior-art hook's local arm (#2280) fires with no credentials, so the
# silence assertion below needs a name the repo genuinely lacks. Two traps,
# both hit while writing this:
#   - `def f` matched real code, so the hook spoke and "silent" was asserting
#     the wrong thing;
#   - spelling the replacement out in full put `def <name>(` INTO this file,
#     so the smoke event defined the very symbol it claimed was absent.
# Concatenating keeps the contiguous string out of the source.
_ABSENT_SYM = "zz" + "_absent_" + "9f3a2b"

SMOKE_EVENTS: dict[str, str] = {
    "scribe_autoinject.sh": json.dumps(
        {"session_id": "smoke", "cwd": ".", "prompt": "a multi-line\nprompt\nhere"}
    ),
    "scribe_prior_art.sh": json.dumps(
        {"session_id": "smoke", "cwd": ".", "tool_name": "Edit",
         "tool_input": {"file_path": "src/x.py",
                        "new_string": f"def {_ABSENT_SYM}():\n    pass\n"}}
    ),
    "scribe_sync_processes.sh": json.dumps({"source": "startup"}),
    "scribe_session_context.sh": json.dumps({"source": "startup"}),
    # The after-write hook (#2901) diffs the working tree; on CI's clean
    # checkout there is nothing to report, so silence is the right assertion.
    # (On a dirty local tree with a definition just written it may speak —
    # that is the hook working, not a failure of the contract.)
    "scribe_after_write.sh": json.dumps(
        {"session_id": "smoke", "cwd": ".", "tool_name": "Bash",
         "tool_input": {"command": "true"}, "tool_response": {}}
    ),
    # The shared library is sourced, never run; executed bare it defines
    # functions and exits — silent by construction.
    "scribe_defs.sh": "",
}

# The one hook that legitimately produces output with no credentials.
STATIC_FLOOR = "scribe_session_context.sh"
# The hooks that say so when a configured instance does not answer (#2932).
OUTAGE_SPEAKERS = {"scribe_prior_art.sh", "scribe_after_write.sh"}
OUTAGE_LINE = "> Scribe did not answer the prior-art check"


def _run_hook(script: Path, event: str, env_extra: dict[str, str]) -> subprocess.CompletedProcess:
    env = {k: v for k, v in os.environ.items()
           if not k.startswith(("SCRIBE_", "CLAUDE_PLUGIN_OPTION_"))}
    env.update(env_extra)
    return subprocess.run(
        ["bash", str(script)], input=event, capture_output=True, text=True,
        env=env, timeout=30,
    )


def check_fail_open() -> None:
    if not shutil.which("jq"):
        # Without jq every hook bails at its first line, so this would pass
        # while exercising nothing. Say so rather than bank a green tick.
        skip("jq not installed — the hooks would exit at line 1, so this "
             "check would pass without testing anything")
        return

    scenarios = [
        ("unconfigured", {}),
        # Connection refused immediately — exercises the unreachable-instance
        # path without waiting on a real network timeout.
        ("unreachable", {"SCRIBE_URL": "http://127.0.0.1:1", "SCRIBE_TOKEN": "x"}),
    ]
    for script in hook_scripts():
        rel = script.relative_to(ROOT)
        event = SMOKE_EVENTS.get(script.name)
        if event is None:
            skip(f"{rel}: no smoke event defined")
            continue
        for label, env_extra in scenarios:
            try:
                proc = _run_hook(script, event, env_extra)
            except subprocess.TimeoutExpired:
                fail(f"{rel} [{label}]: hung — a hook must never block a session")
                continue
            if proc.returncode != 0:
                fail(f"{rel} [{label}]: exited {proc.returncode}, must be 0 — "
                     f"a recall aid may never fail the operator's action")
                continue
            out = proc.stdout.strip()
            if script.name == STATIC_FLOOR:
                # Emits its bundled static tier regardless; that floor is the
                # whole point of the two-tier design.
                if not out:
                    fail(f"{rel} [{label}]: emitted nothing — the static "
                         f"behavioural floor must survive having no credentials")
                else:
                    ok(f"{rel} [{label}]: exit 0, static floor present")
            elif out and label == "unreachable" and script.name in OUTAGE_SPEAKERS:
                # The only thing allowed here is the outage line itself.
                try:
                    ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
                except (ValueError, KeyError, TypeError):
                    ctx = ""
                if ctx.startswith(OUTAGE_LINE):
                    ok(f"{rel} [{label}]: exit 0, says the instance did not answer")
                else:
                    fail(f"{rel} [{label}]: emitted output with no working instance "
                         f"that is not the outage line:\n        {out[:200]}")
            elif out:
                fail(f"{rel} [{label}]: emitted output with no working instance:\n"
                     f"        {out[:200]}")
            else:
                ok(f"{rel} [{label}]: exit 0, silent")


def check_local_prior_art_needs_no_instance() -> None:
    """The prior-art hook's local arm must answer with no credentials (#2280).

    The other arms ask Scribe what was RECORDED. This one asks the repo what
    EXISTS, which needs no instance — and that is the whole reason it catches
    the case the recorded arms structurally cannot: a helper nobody thought to
    record. If it ever silently starts depending on configuration, it stops
    covering that case and nothing else would notice.

    Paired with the silence assertion in check_fail_open, which uses a symbol
    that cannot exist. Together they pin both halves: silent when there is
    nothing to say, and speaking when there is — both with no instance at all.
    """
    script = HOOKS_DIR / "scribe_prior_art.sh"
    if not script.is_file() or not shutil.which("jq"):
        skip("prior-art local arm: hook or jq missing")
        return

    # A definition this repo really does contain, written into a DIFFERENT file
    # so the self-match exclusion doesn't suppress it.
    event = json.dumps({
        "session_id": "smoke", "cwd": ".", "tool_name": "Write",
        "tool_input": {
            "file_path": "scripts/_probe_not_real.py",
            "content": "def check_local_prior_art_needs_no_instance():\n    pass\n",
        },
    })
    try:
        proc = _run_hook(script, event, {})       # NO credentials, on purpose
    except subprocess.TimeoutExpired:
        fail("prior-art local arm: hung")
        return
    if proc.returncode != 0:
        fail(f"prior-art local arm: exited {proc.returncode}, must be 0")
    elif "already defined" not in proc.stdout:
        fail("prior-art local arm: found nothing for a symbol this repo "
             "defines, with no credentials — the arm that needs no instance "
             "has stopped working, and the recorded arms cannot cover for it")
    else:
        ok("prior-art local arm: answers with no instance configured")


def check_session_context_reports_its_version() -> None:
    """The SessionStart context must name the plugin version it is running.

    An install has two halves and only one self-updates: the marketplace clone
    pulls on its own, while the CACHE is what executes and refreshes only when
    the manifest version changes. So a shipped fix can sit unreached while
    inspecting the clone shows it present — the obvious debugging move
    misleads, and twice the only detector was a human saying "I don't think it
    updated" (#2209, #2220).

    Asserted WITHOUT credentials on purpose. The state most needing diagnosis
    is the one where the token never arrives, and a marker that vanished there
    would be missing exactly when it is wanted.
    """
    script = HOOKS_DIR / "scribe_session_context.sh"
    if not script.is_file() or not shutil.which("jq"):
        skip("version marker: hook or jq missing")
        return

    manifest_v = manifest_version()
    if manifest_v is None:
        fail("version marker: could not read the manifest version")
        return

    try:
        proc = _run_hook(script, json.dumps({"source": "startup"}), {})
    except subprocess.TimeoutExpired:
        fail("version marker: hook hung")
        return
    if proc.returncode != 0:
        fail(f"version marker: hook exited {proc.returncode}")
    elif manifest_v not in proc.stdout:
        fail(f"version marker: session context never names v{manifest_v} — "
             f"a stale install would be undetectable from the transcript")
    else:
        ok(f"version marker: session context reports v{manifest_v}, no credentials needed")


def _git(*args: str) -> tuple[int, str]:
    proc = subprocess.run(
        ["git", *args], capture_output=True, text=True, cwd=ROOT
    )
    return proc.returncode, (proc.stdout or proc.stderr).strip()


def manifest_version(ref: str | None = None) -> str | None:
    """The manifest version at `ref`, or in the working tree when ref is None."""
    if ref is None:
        try:
            return json.loads(MANIFEST.read_text()).get("version")
        except Exception:
            return None
    rel = MANIFEST.relative_to(ROOT).as_posix()
    code, out = _git("show", f"{ref}:{rel}")
    if code != 0:
        return None
    try:
        return json.loads(out).get("version")
    except Exception:
        return None


def check_version_bump(base: str = "origin/main") -> None:
    """If shipped plugin content differs from `base`, the version must too.

    Stated against the BASE BRANCH rather than the last commit on purpose. A
    per-commit rule would demand a bump from every commit in a batch; what
    actually matters is that whatever reaches an install carries a version the
    installer can tell apart from the one already cached. One bump per batch,
    which is also how a human would do it.
    """
    code, _ = _git("rev-parse", "--verify", base)
    if code != 0:
        # Do NOT pass silently — a check that quietly no-ops is how this class
        # of bug survives in the first place.
        fail(
            f"cannot resolve {base}, so the version-bump check could not run. "
            f"Fetch it first — `git fetch --depth=1 origin main:refs/remotes/"
            f"origin/main` is enough, since this diffs two trees and needs no "
            f"common ancestor — or pass --no-version deliberately."
        )
        return

    code, changed = _git("diff", "--name-only", base, "--", *SHIPPED)
    if code != 0:
        fail(f"git diff against {base} failed: {changed}")
        return
    if not changed.strip():
        ok(f"no shipped plugin changes against {base} — version bump not required")
        return

    here, there = manifest_version(), manifest_version(base)
    if here is None:
        fail(f"could not read a version from {MANIFEST.relative_to(ROOT)}")
        return
    if there is None:
        ok(f"no manifest on {base} — treating as a new plugin (version {here})")
        return
    if here == there:
        files = "\n          ".join(changed.splitlines())
        fail(
            f"plugin content changed but the manifest version is still {here}.\n"
            f"        The installer compares versions to decide whether to refresh "
            f"its cache, so an unchanged version means these edits reach the repo "
            f"and stop there — the marketplace clone updates, the cache that "
            f"actually executes does not (issue #2209).\n"
            f"        Bump `version` in {MANIFEST.relative_to(ROOT)}.\n"
            f"        Changed:\n          {files}"
        )
    else:
        ok(f"plugin content changed and version moved {there} -> {here}")


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--no-version", action="store_true",
                        help="skip the manifest version-bump check")
    parser.add_argument("--base", default="origin/main",
                        help="branch the version bump is measured against")
    args = parser.parse_args()

    if not HOOKS_DIR.is_dir():
        print(f"FAIL  no hooks directory at {HOOKS_DIR}")
        return 1

    check_syntax()
    check_patterns()
    check_shellcheck()
    check_fail_open()
    check_local_prior_art_needs_no_instance()
    check_session_context_reports_its_version()
    if not args.no_version:
        check_version_bump(args.base)

    print()
    if failures:
        print(f"{len(failures)} problem(s) found.")
        return 1
    print("All plugin checks passed.")
    return 0


if __name__ == "__main__":
    sys.exit(main())
