#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""wire_contract_gate.py — a field one side emits and the other cannot receive is a DEFECT (G-1).

Run from the repo root:  python3 scripts/wire_contract_gate.py
Exit 0 clean · 1 convicted (an emitted field has no receiver) · 2 inconclusive (a root or a sibling
clone could not be resolved — never a silent pass).

Self-test:               python3 scripts/wire_contract_gate.py --selftest
  Plants an unreachable tag on a real root and asserts this gate convicts it, then asserts a clean
  run is clean. A gate nobody has watched fail has not been shown to work.

WHY THIS EXISTS.

Campaign 12 (2026-08-08, `documentation/audits/CAMPAIGN-12-class-sweep-2026-08-08.md`) swept seven
recurring defect classes. This one — "a field one side sends and the other silently drops" — had
already been found by hand three times, most recently R-247: the hub sends `escrow_stale` in the
report ACK, the controller's `report.EscrowStatus` has no field for it, `encoding/json` discards it,
and the box then told a customer something false about their own recovery package.

The sweep found more of the same, and Part 4 ranked a gate for this class **first of eight
candidates** — cheapest, definitive, and `--fast`-eligible. The sharpest instance it found:

    the agent emits `operator_key_configured` on every heartbeat (agent hub/report.go, commented
    "operator authorized_key installed"); the hub's OOB decoder had no field for it; so
    `oobDegraded` — the check that answers "can the operator get into this box right now" —
    returned ok for a box with NO OPERATOR KEY INSTALLED.

The agent knew and said so. The hub threw it away on arrival.

THE TEST, AND WHY IT IS DEFINITIVE.

For every json tag reachable from a declared wire ROOT, ask whether that literal tag string occurs
ANYWHERE in the receiving repo's production Go or templates. **A tag that occurs nowhere cannot be
decoded by any struct, named or anonymous.** That last clause is the whole reason this test is used
instead of comparing struct to struct: the campaign's first attempt paired types by shape and
false-positived badly, because the hub decodes one report through SEVERAL ad-hoc anonymous structs.
A string test cannot be fooled that way.

⚠ WHAT THIS GATE DOES NOT SEE — stated here so nobody reads a green as coverage. Campaign 12's own
C1 guard turned out blind to one of the three shapes it was written for; the lesson taken is that a
gate must publish its holes.

  1. GENERIC TAG NAMES ARE SKIPPED. `name`, `type`, `state`, `status` and the like occur in every
     repo for unrelated reasons, so the string test cannot say anything about them. They are listed
     in GENERIC below and are NOT checked — a genuine drop of a generically-named field is MISSED.
     This makes the gate conservative: it under-reports and does not over-report.
  2. IT PROVES REACHABILITY OF A NAME, NOT THAT ANYTHING ACTS ON THE VALUE. A tag mentioned once in
     a struct nobody consults passes. It answers "can this be decoded at all", not "is it used".
  3. ROOTS ARE DECLARED, NOT DISCOVERED. Only the wires in ROOTS are covered. The hub's
     desired-state is served as raw stored JSON (`host.DesiredJSON`) with no typed emitter to walk,
     and the agent's local API has no single root type — NEITHER IS COVERED.
  4. IT READS SOURCE, NOT TRAFFIC. A field populated only at runtime under a key built by string
     concatenation is invisible.
  5. TEST FILES AND testdata/ ARE EXCLUDED ON THE RECEIVING SIDE, deliberately. A tag present only
     in a fixture is not decodable by production — and R-262 is exactly that: the hub's
     host-report golden carries `cpu_temp_c` and `loadavg`, which no hub struct reads.
"""
import os
import re
import shutil
import sys
import tempfile

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
WORKSPACE = os.path.dirname(ROOT)

REPOS = {
    "hub": os.path.join(ROOT, "hub"),
    "controller": os.path.join(WORKSPACE, "felhom-controller", "controller"),
    "agent": os.path.join(WORKSPACE, "felhom-agent"),
}

# (label, emitter repo, package dir relative to that repo, root type, receiver repo)
# Each row is a CLAIM that this type's json is put on this wire. An unresolvable root is
# INCONCLUSIVE, never a pass.
ROOTS = [
    ("agent -> hub   (POST /host-report)",
     "agent", "internal/hub", "HostReport", "hub"),
    ("controller -> hub   (POST /report)",
     "controller", "internal/report", "Report", "hub"),
    ("hub -> controller  (report ACK, `escrow` object)",
     "hub", "internal/store", "EscrowStatus", "controller"),
    # R-311. Declared the moment the wire was created, because the gate covers only what is DECLARED
    # and a silent pass is indistinguishable from coverage. The hub response is a named type rather
    # than a map[string]any precisely so this root can resolve — an untyped map is a cross-repo
    # contract nothing can check.
    ("hub -> agent   (GET /hosts/<id>/escrow/retained)",
     "hub", "internal/api", "RetainedEscrowResponse", "agent"),
]

# Tag names whose literal string carries no information in a repo-wide search. NOT CHECKED.
# Listed rather than silently skipped: each one is a hole.
GENERIC = {
    "name", "type", "state", "status", "error", "id", "path", "size_bytes", "enabled",
    "message", "value", "version", "code", "label", "mode", "total_bytes", "used_bytes",
    "avail_bytes", "reason", "detail", "where", "vmid", "target", "content", "apps", "app",
    "phase", "warn", "count", "host", "guests", "kind", "url", "port", "user", "role",
    "created_at", "updated_at", "started_at", "timestamp", "time", "percent", "used_fraction",
}

# A tag that is emitted and NOT consumed, with the reason it is acceptable. Every entry is a claim
# someone must be able to re-check later, so none of them is bare. A quiet exclusion would be a
# dropped field with paperwork, which is worse than the defect.
#
# THREE KINDS OF ENTRY, and the differences are deliberate:
#   * "redundant"  — the hub already decodes something that answers the same question. No consumer
#                    is wanted; the entry is the end of the matter.
#   * "R-264"      — a fact with no consumer that ARGUABLY should have one. The entry does NOT
#                    close the question; it records it against an OPEN register row so that
#                    allowlisting cannot be mistaken for deciding. R-260 is closed by the gate plus
#                    the operator-access fix; the leftover appetite is R-264.
#   * "not consumed, deliberately" — RULED by the operator, with the date. The question IS closed:
#                    a reader was considered and declined on stated grounds. This kind was added on
#                    2026-08-13 because "arguably owed" had been carried for five days as though it
#                    were a decision, and an undecided fact and a decided one must not read alike.
#                    THE EMITTER IS DELIBERATELY LEFT ALONE: removing it is a coordinated two-repo
#                    change and it also breaks the byte-identical cross-repo host-report golden. The
#                    honest end here is a recorded decision, not a deletion.
#
# Keyed by (wire label, dotted emit path).
_AH = "agent -> hub   (POST /host-report)"
_CH = "controller -> hub   (POST /report)"

_REDUNDANT_HOST_METRICS = (
    "redundant: the hub decodes host.cpu_percent / memory_percent / disk_percent from the same "
    "stanza and every host-health threshold is expressed on those. The absolute figure answers no "
    "question the hub asks.")
_R264 = (
    "no consumer today, and one is arguably owed — recorded against R-264 (OPEN) rather than "
    "decided here. Allowlisting is not deciding.")

# RULED 2026-08-13 by the operator against R-264: these five were considered for a reader and
# declined. The reason each is declined is per-entry below — a bare "not wanted" would be the quiet
# exclusion this gate exists to prevent.
_NOREADER = (
    "not consumed, DELIBERATELY — ruled 2026-08-13 (operator, against R-264). A reader was "
    "considered and declined; the question is closed, not open. The emitter stays (removing it is a "
    "two-repo change and breaks the host-report golden). ")

ALLOWLIST = {
    # ---- redundant: the hub already decodes an equivalent ----
    (_AH, "host.cpu_temp_c"): _REDUNDANT_HOST_METRICS,
    (_AH, "host.loadavg"): _REDUNDANT_HOST_METRICS,
    (_AH, "host.memory_total_bytes"): _REDUNDANT_HOST_METRICS,
    (_AH, "host.memory_used_bytes"): _REDUNDANT_HOST_METRICS,
    (_AH, "host.uptime_seconds"): _REDUNDANT_HOST_METRICS,
    (_CH, "system.load_avg_1"): _REDUNDANT_HOST_METRICS,
    (_CH, "system.load_avg_5"): _REDUNDANT_HOST_METRICS,
    (_CH, "system.load_avg_15"): _REDUNDANT_HOST_METRICS,
    (_CH, "system.memory_total_mb"): _REDUNDANT_HOST_METRICS,
    (_CH, "system.memory_used_mb"): _REDUNDANT_HOST_METRICS,
    (_CH, "system.temperature_celsius"): _REDUNDANT_HOST_METRICS,
    (_CH, "system.uptime_seconds"): _REDUNDANT_HOST_METRICS,
    (_AH, "guests.spec.disk_bytes"): (
        "redundant: guest SIZING is hub-owned intent (the manifest), not box reality. The hub "
        "decodes vmid/name/status/controller_version from the guest list and nothing more."),
    (_AH, "guests.spec.memory_bytes"): (
        "redundant: as guests.spec.disk_bytes — sizing is hub-owned intent, not mirrored reality."),
    (_AH, "storage_targets.smart.model_name"): (
        "redundant for a verdict: the hub decodes smart.health plus every counter it bands on. The "
        "model string is a display label with no threshold attached to it."),
    (_AH, "wireguard.last_handshake_age_s"): (
        "redundant: hub-side wgsync reconciles peers from its own state, and the OOB path's own "
        "wg_handshake_age_s IS now decoded (into HostOOBRow, for the alert text)."),

    # ---- R-319: guest_net AND ALL SEVEN CHILDREN ARE GONE FROM THIS LIST ----
    # They are no longer allowlisted because they are no longer unconsumed: the hub models the R-54
    # watchdog stanza (web/hosts.go parseGuestNet → the host-detail Guest network card), including
    # heals_last_hour and heal_succeeded. Removing the entries is the POINT — an allowlisted tag is
    # SKIPPED by this gate, so leaving them here would mean the new reader's fields were never
    # actually checked for reachability, and the gate would report a coverage it did not have.
    # The first of R-264's readers; three groups remain owed one.

    # ---- no consumer, and one is arguably owed: R-264, STILL OPEN ----
    (_AH, "selfupdate_pending"): _R264 + (
        " NOTE: the agent's own comment beside this field claimed 'the hub reads an absent field as "
        "pending=false, the correct default'. The hub had no field at all, so it read nothing "
        "either way. The comment was corrected in the same change as this entry."),
    (_AH, "selfupdate_pending_version"): _R264,
    (_AH, "mgmt_plane.healed_recently"): _NOREADER + (
        "The recurring-clobber signal is NOT lost: the hub already alarms on "
        "mgmt_plane.privsep_healed_at, the timestamp sitting beside this boolean. A second reader "
        "for the flag would add a second way to say the same thing and no new fact."),
    (_AH, "pbs_dr.applied_at"): _NOREADER + (
        "A tier-applied TIMESTAMP, and the hub already decodes pbs_dr.state — which is the verdict. "
        "Presence is not success: consuming the timestamp without the state is precisely the "
        "attempt-read-as-result trap, and with the state it answers nothing further."),
    (_AH, "restore_tests.mount_parity"): _R264 + (
        " R-262: the hub's own comment claims this contract is mirrored field-for-field and that a "
        "key-set test guards drift; it is two fields short and the fixture omits the same two."),
    (_AH, "restore_tests.mount_inventory"): _R264 + " R-262, as mount_parity.",
    (_CH, "config_hash"): _NOREADER + (
        "A config FINGERPRINT. The hub authors the config and knows its own generation "
        "(desired_generation, config_version), which is the convergence question it actually asks. "
        "A hash it did not compute answers a question nobody has posed."),
    (_CH, "reporting_disabled"): (
        "redundant: the box sends health.status = \"disabled\" in the SAME minimal report "
        "(controller cmd/controller/main.go:1254), the hub decodes it into reports.health_status, and "
        "web/rollup.go:25 renders that customer as 'disabled'. The state IS visible; this flag is a "
        "second spelling of a fact already read. ⚠ DECIDED ON ITS OWN MERITS 2026-08-13, and the "
        "decision came with a REAL finding that a second decoded flag would NOT have fixed: "
        "StalenessChecker.Check (monitor/staleness.go:88+) is age-only — it skips BLOCKED customers "
        "and nothing else — so a deliberately-silent box still goes stale at 30 min and down at 60. "
        "Filed as R-321. The fix belongs in the checker, which already has the health status it "
        "needs; adding a field here would have felt like progress and left the alarm firing."),
    (_CH, "stacks"): _NOREADER + (
        "The whole per-stack report object. The hub's app view is built from app_telemetry, which is "
        "a purpose-built wire with its own table; a second, differently-shaped source for the same "
        "screen is how two answers to one question get shipped."),
    (_CH, "storage.migrated_to"): _NOREADER + (
        "A drive-migration marker: box-local bookkeeping about where data was moved ON that box. The "
        "hub holds no drive-layout intent to reconcile it against, so it could only be displayed, "
        "and a fact displayed with nothing to compare it to is decoration."),
    (_CH, "backup.last_db_dump"): _R264,
    (_CH, "backup.last_integrity_check"): _R264,
}

# A node whose IMMEDIATE CHILDREN are still checked but whose DEEPER descendants are not, because the
# receiver passes the subtree through without decoding it. Keyed by (wire label, dotted path).
#
# ⚠ THE DEPTH IS THE WHOLE POINT AND IT IS NOT COSMETIC. The hub stores each DR-recipe half as
# json.RawMessage and re-emits nested shapes verbatim — so the LEAVES genuinely are not on this wire
# in the decoded sense. But the TOP-LEVEL SECTION KEYS of each half are decoded, by
# `hostHalfShape` / `appHalfShape` in hub/internal/store/dr_recipe.go, and those two structs are
# ALLOW-LISTS: a section an emitter adds is silently discarded until it is named in BOTH the shape
# struct and AssembledRecipe. That has already cost one shipped section — `offsite_restic` was
# emitted by the controller from fork-4, stored intact, and never appeared in a delivered recipe
# (R-122). Treating `dr_recipe` as wholly opaque would put exactly that defect back outside this
# gate's reach.
OPAQUE_BELOW = {
    ("agent -> hub   (POST /host-report)", "dr_recipe"): (
        "the hub stores the agent's half verbatim (`DRRecipe json.RawMessage`) and re-emits nested "
        "shapes untouched; its TOP-LEVEL sections are decoded by hostHalfShape and ARE checked"),
    ("controller -> hub   (POST /report)", "dr_recipe"): (
        "the hub stores the controller's half verbatim (`SaveDRRecipeAppHalf`) and re-emits nested "
        "shapes untouched; its TOP-LEVEL sections are decoded by appHalfShape and ARE checked"),
}

STRUCT_RE = re.compile(r"^type\s+(\w+)\s+struct\s*\{", re.M)
FIELD_RE = re.compile(
    r"^\s*(?:(\w+)\s+)?([\[\]\*\w\.]+)\s+`[^`]*json:\"([^\"]*)\"[^`]*`")
INLINE_STRUCT_RE = re.compile(r"^\s*(\w+)\s+(\*?)struct\s*\{")


def die(msg, code=2):
    print(msg, file=sys.stderr)
    sys.exit(code)


def go_files(root, include_tests=False):
    out = []
    for dp, dn, fn in os.walk(root):
        dn[:] = [d for d in dn if d not in (".git", "vendor", "node_modules")]
        for f in fn:
            if not f.endswith(".go"):
                continue
            if not include_tests and f.endswith("_test.go"):
                continue
            out.append(os.path.join(dp, f))
    return out


def parse_structs(path):
    """name -> (body, dir). Anonymous inline structs are inlined into the parent's field list."""
    src = open(path, encoding="utf-8", errors="replace").read()
    out = {}
    for m in STRUCT_RE.finditer(src):
        i, depth = m.end(), 1
        while i < len(src) and depth:
            if src[i] == "{":
                depth += 1
            elif src[i] == "}":
                depth -= 1
            i += 1
        out[m.group(1)] = src[m.end():i - 1]
    return out


def build_index(repo_root):
    """(dir, name) -> body, plus name -> [(dir, body)] for unqualified fallback."""
    by_dir, by_name = {}, {}
    for p in go_files(repo_root):
        d = os.path.relpath(os.path.dirname(p), repo_root)
        for name, body in parse_structs(p).items():
            by_dir[(d, name)] = body
            by_name.setdefault(name, []).append((d, body))
    return by_dir, by_name


def split_fields(body):
    """Yield (json_tag, go_type, inline_body_or_None) for one struct body, handling nested
    anonymous structs by capturing their body."""
    lines = body.split("\n")
    i = 0
    while i < len(lines):
        line = lines[i]
        m = INLINE_STRUCT_RE.match(line)
        if m:
            # capture the inline struct body, then read the tag off its closing line
            depth, j, buf = 1, i + 1, []
            while j < len(lines) and depth:
                depth += line_delta(lines[j])
                if depth:
                    buf.append(lines[j])
                j += 1
            closing = lines[j - 1] if j - 1 < len(lines) else ""
            tm = re.search(r'json:"([^"]*)"', closing)
            tag = tm.group(1).split(",")[0] if tm else None
            yield (tag, "INLINE", "\n".join(buf))
            i = j
            continue
        fm = FIELD_RE.match(line)
        if fm:
            tag = fm.group(3).split(",")[0]
            if tag and tag != "-":
                yield (tag, fm.group(2), None)
        i += 1


def line_delta(line):
    return line.count("{") - line.count("}")


def base_type(t):
    return t.lstrip("*[]").split(".")[-1]


def qualifier(t):
    parts = t.lstrip("*[]").split(".")
    return parts[0] if len(parts) > 1 else None


def walk(by_dir, by_name, start_dir, start_type, seen=None, prefix=""):
    """Transitively collect (tag, dotted_path) reachable from a root type."""
    if seen is None:
        seen = set()
    key = (start_dir, start_type)
    if key in seen:
        return []
    seen.add(key)
    body = by_dir.get(key)
    if body is None:
        cands = by_name.get(start_type, [])
        if not cands:
            return []
        body = cands[0][1]
        start_dir = cands[0][0]
    out = []
    for tag, gotype, inline in split_fields(body):
        if tag is None:
            continue
        dotted = (prefix + "." + tag) if prefix else tag
        out.append((tag, dotted))
        # NOTE: the walk descends everywhere; OPAQUE_BELOW filtering happens in run(), so that the
        # immediate children of an opaque node are still collected and checked. See OPAQUE_BELOW.
        if inline is not None:
            for t2, g2, i2 in split_fields(inline):
                if t2 is None:
                    continue
                out.append((t2, dotted + "." + t2))
                bt = base_type(g2)
                if i2 is None and (start_dir, bt) in by_dir or bt in by_name:
                    out += walk(by_dir, by_name, start_dir, bt, seen, dotted + "." + t2)
            continue
        bt = base_type(gotype)
        if bt in ("string", "int", "int64", "bool", "float64", "byte", "any",
                  "RawMessage", "Time", "interface{}", "uint64", "int32", "uint32"):
            continue
        q = qualifier(gotype)
        nd = start_dir
        if q:
            for (d, n) in by_dir:
                if n == bt and os.path.basename(d) == q:
                    nd = d
                    break
        if (nd, bt) in by_dir or bt in by_name:
            out += walk(by_dir, by_name, nd, bt, seen, dotted)
    return out


TOKEN_RE = re.compile(r"[A-Za-z0-9_]+")


def receiver_tokens(repo_root):
    """Every `[A-Za-z0-9_]+` token in the receiver's PRODUCTION Go and templates, as a set.

    WHOLE-TOKEN, NOT SUBSTRING. A substring test reports a genuinely dropped field as received:
    `healed_at` occurs inside `privsep_healed_at`. That false negative was caught by this gate's own
    control — R-260 named `healed_at`, so its absence from the first run's output was the tell.

    PURE PYTHON, NO `grep`. The first version shelled out to `grep -rnE --include=...`, which works
    on a workstation and returns NOTHING on the CI runner, whose image carries python3 and git and
    deliberately little else — its grep does not support `--include`. Empty stdout was then read as
    "the tag is absent", so the gate convicted ALL 174 checked tags and CI went red while the
    pre-push hook was green. **A gate that silently reads a tool failure as a finding is worse than
    no gate**, and swallowing that error is the exact thing this repo's rules forbid. Reading the
    files here removes the dependency, is portable, and is one pass instead of ~350 subprocesses.

    Test files and `testdata/` are excluded deliberately: a tag present only in a fixture is not
    decodable by production — and that is R-262 exactly, where the cross-repo golden carries two
    fields no hub struct reads.
    """
    toks = set()
    n = 0
    for dp, dn, fn in os.walk(repo_root):
        dn[:] = [d for d in dn if d not in (".git", "vendor", "node_modules", "testdata")]
        for f in fn:
            if f.endswith("_test.go"):
                continue
            if not (f.endswith(".go") or f.endswith(".html")):
                continue
            p = os.path.join(dp, f)
            try:
                with open(p, encoding="utf-8", errors="replace") as fh:
                    toks.update(TOKEN_RE.findall(fh.read()))
            except OSError as e:
                # never swallowed: an unreadable source file makes the answer unknown, not "absent"
                die("wire-contract gate INCONCLUSIVE: cannot read %s: %s" % (p, e))
            n += 1
    if n == 0:
        die("wire-contract gate INCONCLUSIVE: no production .go/.html found under %s — a receiver "
            "with no source cannot be searched, and an empty search is not evidence of absence."
            % repo_root)
    return toks


def run(root_override=None, quiet=False):
    """Returns (exit_code, convictions)."""
    repos = dict(REPOS)
    if root_override:
        repos.update(root_override)
    for label, path in repos.items():
        if not os.path.isdir(path):
            die("wire-contract gate INCONCLUSIVE: sibling clone %r not found at %s\n"
                "  This gate compares two repositories; it cannot pass without both." % (label, path))

    indexes = {k: build_index(v) for k, v in repos.items()}
    # one pass per receiving repo, not one subprocess per tag
    rtokens = {k: receiver_tokens(v) for k, v in repos.items()}
    convictions = []
    checked = skipped = 0

    for label, emitter, pkgdir, rootname, receiver in ROOTS:
        by_dir, by_name = indexes[emitter]
        if (pkgdir, rootname) not in by_dir:
            die("wire-contract gate INCONCLUSIVE: declared root %s.%s not found in %s/%s\n"
                "  A root that cannot be resolved is not a pass — fix the ROOTS table or the type."
                % (emitter, rootname, emitter, pkgdir))
        tags = walk(by_dir, by_name, pkgdir, rootname)
        seen_tags = {}
        for tag, dotted in tags:
            seen_tags.setdefault(tag, dotted)
        opaque_roots = [p for (lbl, p) in OPAQUE_BELOW if lbl == label]
        missing = []
        for tag, dotted in sorted(seen_tags.items()):
            if tag in GENERIC:
                skipped += 1
                continue
            # opaque BELOW: the node itself and its immediate children are checked, deeper is not
            if any(dotted.startswith(o + ".") and dotted.count(".") > o.count(".") + 1
                   for o in opaque_roots):
                skipped += 1
                continue
            if (label, dotted) in ALLOWLIST:
                skipped += 1
                continue
            checked += 1
            if tag not in rtokens[receiver]:
                missing.append((tag, dotted))
        if missing:
            convictions.append((label, receiver, missing))

    if not quiet:
        print("wire-contract gate — %d tag(s) checked across %d declared wire(s); "
              "%d skipped (generic / opaque / allowlisted)" % (checked, len(ROOTS), skipped))
    if convictions:
        if not quiet:
            for label, receiver, missing in convictions:
                print("\n  %s" % label)
                print("  the RECEIVER (%s) contains no occurrence of:" % receiver)
                for tag, dotted in missing:
                    print("      %-32s (emitted at %s)" % (tag, dotted))
            total = sum(len(m) for _, _, m in convictions)
            print("\nWIRE-CONTRACT GATE FAILED: %d emitted field(s) cannot be received." % total)
            print("A tag whose literal string occurs nowhere in the receiving repo cannot be decoded")
            print("by any struct, named or anonymous — encoding/json discards it on arrival.")
            print("Fix: model the field on the receiving side and say what consults it — or, if it is")
            print("deliberately not consumed, add it to ALLOWLIST WITH A REASON. Never a quiet skip.")
            print("\nBlind spots (a green is not full coverage — see the module docstring):")
            print("  generic tag names are not checked; reachability of a NAME is not use of a VALUE;")
            print("  only the declared ROOTS are covered (hub desired-state and the agent local API")
            print("  are NOT).")
        return 1, convictions

    if not quiet:
        print("wire-contract gate OK — every emitted field is at least decodable by its receiver")
        print("  (BLIND SPOTS: generic tag names skipped; name-reachability is not use; only the")
        print("   declared ROOTS are covered — hub desired-state and the agent local API are not.)")
    return 0, []


def selftest():
    """Plant an unreachable tag on a real root in a THROWAWAY copy and assert conviction.

    A gate nobody has watched fail has not been shown to work — and last night an off-the-shelf tool
    for a neighbouring class was rejected because it was made to prove itself and could not.
    """
    tmp = tempfile.mkdtemp(prefix="wirecontract-selftest-")
    try:
        agent = os.path.join(tmp, "agent")
        shutil.copytree(REPOS["agent"], agent, symlinks=True,
                        ignore=shutil.ignore_patterns(".git"))
        rp = os.path.join(agent, "internal", "hub", "report.go")
        src = open(rp, encoding="utf-8").read()
        marker = "type HostReport struct {"
        if marker not in src:
            die("SELFTEST INCONCLUSIVE: could not find %r to plant into" % marker)
        planted = src.replace(
            marker,
            marker + "\n\tZZProbeUnreceivable string `json:\"zz_probe_unreceivable\"`", 1)
        if planted == src:
            die("SELFTEST INCONCLUSIVE: the plant did not apply")
        open(rp, "w", encoding="utf-8").write(planted)

        code, convictions = run({"agent": agent}, quiet=True)
        found = any("zz_probe_unreceivable" == t
                    for _, _, miss in convictions for t, _ in miss)
        print("  planted an unreachable tag on agent HostReport -> gate exit %d, "
              "planted tag convicted: %s" % (code, found))
        if code != 1 or not found:
            print("SELFTEST FAILED: the gate did not convict a planted unreachable tag.")
            return 1

        code_clean, _ = run(quiet=True)
        print("  unplanted tree -> gate exit %d" % code_clean)
        if code_clean == 2:
            print("SELFTEST FAILED: the unplanted run was INCONCLUSIVE.")
            return 1
        print("SELFTEST OK — the gate convicts a planted unreachable tag and the plant is the only "
              "difference.")
        return 0
    finally:
        shutil.rmtree(tmp, ignore_errors=True)


if __name__ == "__main__":
    if "--selftest" in sys.argv:
        sys.exit(selftest())
    sys.exit(run()[0])
