#!/usr/bin/env bash
# Conformance-coverage guard (#portconformancecoverage).
#
# Fails the build when the canonical corpus in ../lazily-spec/conformance/ grows a
# fixture that no test in this repo replays. That is the drift this guard exists
# for: a fixture lands upstream, every binding stays green, and nobody learns that
# one of them is not replaying it.
#
# This binding uses the RUNTIME manifest (#lazilyupgradeconformance), not the
# static grep it started with. The suite records every file it actually opens from
# the conformance corpus (see tests/common/mod.rs), so a fixture named in a comment
# and hand-transcribed — the drift found in lazily-cpp's queue tests, and in this
# repo's own topic_conformance.rs — is caught here. A source grep cannot see that
# case at all: `present in a grep` is not proof of replay; only observing the read
# is.
#
# A missing manifest is missing EVIDENCE and fails. It does not mean "no fixtures
# were read"; it means the suite ran without the recorder attached, and passing in
# that state is the vacuous green this guard exists to prevent.
set -euo pipefail

# ---------------------------------------------------------------------------
# Corpus-root regression guard (#lzcorpusrootguards)
# ---------------------------------------------------------------------------
#
# THE MEASUREMENT. `LAZILY_SPEC_CONFORMANCE_DIR` repoints every replay at a
# scratch copy of the corpus, which is how a binding is checked for reading the
# bytes it claims to read: truncate one fixture per area in the scratch copy and
# every area must redden. lazily-rs honoured NO environment variable at all, so
# that probe reddened 0 of 25 areas while the same truncated bytes read directly
# redden all 25. c6c50b7 fixed the READS by redirecting at
# `tests/common/mod.rs::spec_read_to_string` -- the single seam every corpus read
# already passes through -- rather than threading the override into each runner.
#
# WHAT THIS RUNG GUARDS. The seam is a redirect, not a prohibition: 45 sites in
# 41 test files still spell the DEFAULT root themselves
# (`const SPEC_DIR: &str = "../lazily-spec/conformance/<area>"`, plus the inline
# uses in durable_outbox.rs, stdlib_conformance.rs,
# dependency_availability_conformance.rs and schema_compliance.rs). With
# tests/common/mod.rs's own two, that is the 47 the seam's doc comment cites.
# None of them is a correctness bug TODAY, because the seam rewrites them
# on the way through, so a guard that failed on all 45 would be unlandable. What
# a guard can do is make the debt countable and stop it growing: the 45 are
# enumerated below, a NEW one FAILS, and a listed one that disappears also FAILS
# as a stale entry. `#lzrsspecdirconsts` is the companion item that retires them;
# this baseline is expected to SHRINK, and shrinking it is a deliberate edit here
# rather than a silent drift.
#
# WHAT IT DOES NOT COVER, stated so nobody reads a green run as more than it is:
# it proves nothing about whether a site's reads go through the seam. A runner
# that spells no path at all and calls `std::fs::read_to_string` on a value
# handed to it is invisible here. The runtime manifest below is the rung that
# sees that, because it observes reads rather than source text.
#
# The scan is over SOURCES, so it deliberately runs BEFORE the corpus-presence
# check: a checkout without the lazily-spec sibling can still grow a new default
# root, and skipping this rung there would be the same look-away the guard exists
# to prevent.
#
# Entries are `path|corpus-root-reference|count`, keyed by CONTENT rather than by
# line number so an unrelated edit above a site does not churn the baseline, and
# counted so a second copy of an already-listed reference in the same file is a
# new site rather than a free one.
#
# SCOPE. As of #lzspecschemasoverride this rung covers BOTH lazily-spec roots a
# test may reach for: `../lazily-spec/conformance` and `../lazily-spec/schemas`.
# The schemas were previously matched by nothing on the grounds that they are not
# the corpus, which was true and beside the point — they had no override either,
# so perturbing a schema meant editing the shared checkout and reddening all ten
# bindings. They now resolve through the same seam
# (`common::schemas_root`/`schema_path`/`read_schema`, honouring
# LAZILY_SPEC_SCHEMAS_DIR), and a respelled default root opts back out of it just
# as a respelled corpus root does. One baseline covers both, and it is EMPTY.
CORPUS_ROOT_BASELINE=(
  # EMPTY, and that is the point (#lzrsspecdirconsts). This list carried 45
  # references across 41 files: every conformance test declared its own
  # `const SPEC_DIR: &str = "../lazily-spec/conformance/<area>"`. They are now
  # `common::SpecDir("<area>")`, which resolves the root at Display time, so a
  # replay follows LAZILY_SPEC_CONFORMANCE_DIR without any call site changing
  # shape. An entry added back here is debt being re-taken on, not a fix.
)

if ! command -v python3 >/dev/null 2>&1; then
  echo "FAIL: python3 is required by the corpus-root guard (#lzcorpusrootguards)." >&2
  exit 1
fi

CORPUS_ROOT_BASELINE_TEXT="$(printf '%s\n' "${CORPUS_ROOT_BASELINE[@]:-}")" \
python3 - ${LAZILY_CORPUS_ROOT_SCAN_DIRS:-tests benches examples} <<'PY'
"""Fail on a NEW site spelling the default conformance root, or a stale one.

Three properties this scan is built for, each of which a plainer one has already
failed somewhere in the family:

* Comments are stripped, because many files legitimately quote the path while
  explaining it, and a guard that fired on prose would be reverted within a day.
  Stripping happens in the SAME pass that finds the literals, so a `//` inside a
  string does not open a comment.
* The JOINED-SEGMENT form is caught. lazily-go's and lazily-js's guards were both
  proven evadable by `Path::new("..").join("lazily-spec").join("conformance")`,
  and lazily-dart's first draft was too. Consecutive literals are concatenated
  and the concatenation is matched, so splitting a path across `.join(...)` calls
  or across `"../lazily-spec"` + `"/conformance"` buys nothing.
* Examining nothing FAILS. A scan that walks an empty tree, matches nothing and
  prints OK is the vacuous green (#lzvacuousrun) every rung in this file exists
  to refuse.

`../lazily-spec/schemas` used to be a deliberate NON-corpus sibling, matched by
nothing: schema_compliance.rs and lossless_tree_schema.rs each spelled it as
their own `SPEC_SCHEMAS_DIR`. It is now covered (#lzspecschemasoverride). The
schemas got the same seam the corpus has — `common::schemas_root` /
`schema_path` / `read_schema`, honouring LAZILY_SPEC_SCHEMAS_DIR — for the same
reason the corpus needed one: perturbing a schema to check that a runner
validates against its BYTES had nowhere to point but the shared sibling
checkout, which reddens every binding at once. A site that respells the default
root would silently opt back out of that override, exactly as a respelled
corpus root opts out of LAZILY_SPEC_CONFORMANCE_DIR, so both roots are scanned
by the same rung and both baselines are EMPTY.
"""
import os
import sys

# Both lazily-spec roots a test may reach for. The `lazily-spec/` prefix is
# carried in each so a repo-local `conformance/` or `schemas/` directory is not
# matched.
MARKERS = ("lazily-spec/conformance", "lazily-spec/schemas")


def marks(text):
    return any(marker in text for marker in MARKERS)
# The seam itself (#lzoverrideallrunnersaudit). It MUST name the default root:
# that is where the override is resolved and where the fallback lives.
ALLOWLIST = {"tests/common/mod.rs"}
# How many consecutive literals may be concatenated when looking for the joined
# form. Four segments reach `".." + "lazily-spec" + "conformance" + "<area>"`.
WINDOW = 5


def literals(text):
    """`(line, value)` for every Rust string literal OUTSIDE comments.

    Char literals are not tokenised: in this corpus `'` is a lifetime far more
    often than a quote, and no char literal carries a path. Raw strings and
    nested block comments are handled, because both appear in these files.
    """
    out = []
    i = 0
    line = 1
    n = len(text)
    while i < n:
        c = text[i]
        if c == "\n":
            line += 1
            i += 1
        elif c == "/" and i + 1 < n and text[i + 1] == "/":
            while i < n and text[i] != "\n":
                i += 1
        elif c == "/" and i + 1 < n and text[i + 1] == "*":
            depth = 1
            i += 2
            while i < n and depth:
                if text.startswith("/*", i):
                    depth += 1
                    i += 2
                elif text.startswith("*/", i):
                    depth -= 1
                    i += 2
                else:
                    if text[i] == "\n":
                        line += 1
                    i += 1
        elif c == "r" and i + 1 < n and text[i + 1] in '#"':
            j = i + 1
            hashes = 0
            while j < n and text[j] == "#":
                hashes += 1
                j += 1
            if j < n and text[j] == '"':
                start_line = line
                j += 1
                close = '"' + "#" * hashes
                buf = []
                while j < n and not text.startswith(close, j):
                    if text[j] == "\n":
                        line += 1
                    buf.append(text[j])
                    j += 1
                out.append((start_line, "".join(buf)))
                i = j + len(close)
            else:
                i += 1
        elif c == '"':
            start_line = line
            i += 1
            buf = []
            while i < n and text[i] != '"':
                if text[i] == "\\" and i + 1 < n:
                    esc = text[i + 1]
                    buf.append({"n": "\n", "t": "\t", "r": "\r"}.get(esc, esc))
                    if esc == "\n":
                        line += 1
                    i += 2
                    continue
                if text[i] == "\n":
                    line += 1
                buf.append(text[i])
                i += 1
            out.append((start_line, "".join(buf)))
            i += 1
        else:
            i += 1
    return out


def scan_file(text):
    """`(line, form, reference)` for every default-root reference in `text`."""
    lits = literals(text)
    hits = []
    single = set()
    for idx, (line, value) in enumerate(lits):
        if marks(value.replace("\\", "/")):
            hits.append((line, "literal", value))
            single.add(idx)
    # Joined-segment form. Windows containing a literal that already matches on
    # its own are skipped, so one site is never counted twice, and a matched
    # window is CONSUMED so overlapping windows do not report the same site
    # several times. Segments are normalised by stripping separators, which
    # collapses `Path::new("..").join("lazily-spec").join("conformance")` and
    # `"../lazily-spec"` + `"/conformance"` onto the same joined reference.
    #
    # There is deliberately NO limit on how far apart the segments may sit. Two
    # consts declared at opposite ends of a file concatenate exactly as well as a
    # `.join()` chain, and a proximity rule would be an evasion route. The cost is
    # that a window can pick up an unrelated leading segment, so the match is
    # trimmed to the LAST starting segment that still reaches the marker before it
    # is reported -- that is the segment the path really begins at, and its line
    # is the one worth naming.
    start = 0
    while start < len(lits):
        if start in single:
            start += 1
            continue
        parts = []
        matched = False
        for end in range(start, min(start + WINDOW, len(lits))):
            if end in single:
                break
            seg = lits[end][1].replace("\\", "/").strip("/")
            if not seg:
                break
            parts.append(seg)
            if len(parts) < 2:
                continue
            if marks("/".join(parts)):
                first = max(
                    p for p in range(len(parts)) if marks("/".join(parts[p:]))
                )
                hits.append(
                    (lits[start + first][0], "joined", "/".join(parts[first:]))
                )
                start = end + 1
                matched = True
                break
        if not matched:
            start += 1
    return hits


baseline = {}
for raw in os.environ.get("CORPUS_ROOT_BASELINE_TEXT", "").splitlines():
    raw = raw.strip()
    if not raw:
        continue
    parts = raw.split("|")
    if len(parts) != 3 or not parts[2].isdigit():
        sys.stderr.write(
            "ERROR: malformed CORPUS_ROOT_BASELINE entry %r "
            "(want 'path|reference|count').\n" % raw
        )
        sys.exit(1)
    baseline[(parts[0], parts[1])] = int(parts[2])

sources = []
for directory in sys.argv[1:]:
    if not os.path.isdir(directory):
        continue
    for root, _, names in os.walk(directory):
        for name in sorted(names):
            if name.endswith(".rs"):
                sources.append(os.path.join(root, name))
sources.sort()

# ---- Positive-evidence floor (#lzvacuousrun) ----
# Everything below reasons about files this scan opened. Zero files means zero
# references, which means zero unlisted references, which reports OK having read
# nothing at all. Refuse before comparing.
if not sources:
    sys.stderr.write(
        "ERROR: the corpus-root scan examined ZERO Rust sources under %s.\n"
        "       That is missing EVIDENCE, not evidence of absence: a scan over an\n"
        "       empty tree matches nothing and would otherwise report OK\n"
        "       (#lzvacuousrun). Check the working directory and\n"
        "       LAZILY_CORPUS_ROOT_SCAN_DIRS.\n" % (" ".join(sys.argv[1:]) or "(none)")
    )
    sys.exit(1)

found = {}
for path in sources:
    rel = os.path.relpath(path).replace(os.sep, "/")
    if rel in ALLOWLIST:
        continue
    with open(path, encoding="utf-8", errors="replace") as handle:
        text = handle.read()
    for line, form, reference in scan_file(text):
        found.setdefault((rel, reference), []).append((line, form))

problems = 0

# Direction 1: a reference nobody listed. This is the growth this rung exists to
# stop -- every new one of these is another site the #lzrsspecdirconsts cleanup
# has to find, and another place the default root can be reintroduced after the
# seam is simplified away.
for (rel, reference), sites in sorted(found.items()):
    allowed = baseline.get((rel, reference), 0)
    if len(sites) <= allowed:
        continue
    for line, form in sites[allowed:]:
        shape = "joined-segment" if form == "joined" else "literal"
        sys.stderr.write(
            "ERROR: %s:%d spells a DEFAULT lazily-spec root as a %s (%s),\n"
            "       and CORPUS_ROOT_BASELINE does not list it (%d listed for this\n"
            "       file/reference, %d found).\n"
            "       Read the fixture through `common::spec_read_to_string` /\n"
            "       `common::spec_path` / `common::spec_root`, which honour\n"
            "       LAZILY_SPEC_CONFORMANCE_DIR, and the schema through\n"
            "       `common::read_schema` / `common::schema_path` /\n"
            "       `common::schemas_root`, which honour LAZILY_SPEC_SCHEMAS_DIR.\n"
            "       Do NOT add an entry here to make a\n"
            "       new site pass -- the baseline is a record of debt being retired\n"
            "       under #lzrsspecdirconsts, not a place to park more of it\n"
            "       (#lzcorpusrootguards).\n"
            % (rel, line, shape, reference, allowed, len(sites))
        )
        problems += 1

# Direction 2: a listed reference that is gone or thinned out, the same
# stale-entry check KNOWN_UNCOVERED runs (#lzcovallowlistrot). A baseline that
# outlives the debt it describes silently absorbs the next real site.
for (rel, reference), allowed in sorted(baseline.items()):
    actual = len(found.get((rel, reference), []))
    if actual >= allowed:
        continue
    sys.stderr.write(
        "ERROR: CORPUS_ROOT_BASELINE lists %d x '%s' in %s, but the scan found %d.\n"
        "       The entry is STALE -- the site was retired or moved. Lower the count\n"
        "       (or delete the entry) so the baseline keeps describing the real\n"
        "       debt; leaving it inflated buys a free slot for the next new site\n"
        "       (#lzcorpusrootguards).\n" % (allowed, reference, rel, actual)
    )
    problems += 1

if problems:
    sys.stderr.write("corpus-root guard FAILED: %d problem(s)\n" % problems)
    sys.exit(1)

total = sum(len(sites) for sites in found.values())
# Second positive-evidence check. The baseline is non-empty, so a scan that
# suddenly matches nothing has stopped working rather than found a clean tree --
# and direction 2 above would already have said so. Assert the magnitude anyway,
# because the day the baseline reaches zero this is the only thing standing
# between a broken matcher and a green run.
if baseline and total == 0:
    sys.stderr.write(
        "ERROR: %d Rust sources examined and NOT ONE default-root reference found,\n"
        "       while CORPUS_ROOT_BASELINE lists %d. The matcher is broken.\n"
        % (len(sources), sum(baseline.values()))
    )
    sys.exit(1)

print(
    "corpus-root guard OK: %d default-root reference(s) across %d file(s), all "
    "baselined (%d Rust sources scanned; #lzcorpusrootguards)"
    % (total, len({rel for rel, _ in found}), len(sources))
)
PY

SPEC_DIR="${LAZILY_SPEC_CONFORMANCE_DIR:-../lazily-spec/conformance}"

# A missing corpus is a legitimate local state (no sibling checkout) and an
# illegitimate CI state (#lzvacuousrun). Skipping under CI is the vacuous green
# this guard exists to prevent: every rung below reasons about fixtures the run
# OPENED, so an absent corpus reports OK over nothing at all. Locally it stays a
# skip, because a contributor without the sibling is not making a false claim.
if [ ! -d "$SPEC_DIR" ]; then
  if [ -n "${CI:-}" ]; then
    echo "ERROR: canonical corpus not found at $SPEC_DIR, and CI is set." >&2
    echo "       Under CI this is missing EVIDENCE, not evidence of absence: the" >&2
    echo "       checkout is wrong, not the corpus. Exiting 0 here would report" >&2
    echo "       conformance OK having examined zero fixtures (#lzvacuousrun)." >&2
    exit 1
  fi
  echo "SKIP: canonical corpus not found at $SPEC_DIR (clone the lazily-spec sibling)" >&2
  echo "      Local checkout only — this would be a hard failure under CI." >&2
  exit 0
fi

# Fixtures deliberately not covered by this binding yet. Each entry is a claim that
# someone looked; shrinking this list is the work. Adding to it silently is how the
# guard rots, so keep a reason with any new entry.
KNOWN_UNCOVERED=(
  # No runner at all — these were already excused under the static guard.
  "agent-doc/delta_agent_doc_state.json"
  "agent-doc/snapshot_agent_doc_state.json"
"receipts/causal_receipts.json"
"reliable-sync/coalesce_bounds_outbox.json"
"reliable-sync/liveness_lease_eviction.json"
# Rust's durable outbox is SQLite-backed; it has no append-only journal decoder.
"reliable-sync/outbox_journal_decode.json"
)

# Scenarios of an OPENED fixture that this binding deliberately does not replay
# (#lzscenariocoverage). One rung below KNOWN_UNCOVERED and deliberately kept
# beside it: a fixture with four scenarios of which the suite replays three is
# green under the fixture manifest alone, so this is the second place to read
# what this binding does not prove.
#
# Format: "fixture|scenario id|reason". The reason is REQUIRED — an excuse with
# no reason is an unexplained gap wearing a green badge. Prefer implementing the
# scenario; excuse only what this binding genuinely cannot express.
#
# Runs in both directions (see the python phase below): an excuse for a scenario
# the run DID replay, or for an id the fixture does not carry, is a failure.
KNOWN_UNREPLAYED_SCENARIOS=(
)

MANIFEST="${LAZILY_CONFORMANCE_MANIFEST:-build/conformance-fixtures-loaded.txt}"
SCENARIO_LEDGER="${LAZILY_CONFORMANCE_SCENARIOS:-build/conformance-scenarios-replayed.txt}"

if [ ! -s "$MANIFEST" ]; then
  echo "FAIL: no conformance manifest at $MANIFEST." >&2
  echo "      Run the suite with LAZILY_CONFORMANCE_MANIFEST set to an ABSOLUTE" >&2
  echo "      path so the recorder attaches (\`make check\` does this). An absent" >&2
  echo "      manifest is missing evidence, not evidence of absence." >&2
  exit 1
fi
OPENED="$(sort -u "$MANIFEST")"

missing=0
total=0
covered=0
while IFS= read -r fixture; do
  total=$((total + 1))
  # Here-string, NOT a pipe. With `set -o pipefail`, `printf ... | grep -q` reports
  # FAILURE when grep matches: grep -q exits immediately on the first hit, printf
  # takes SIGPIPE writing the rest, and pipefail surfaces printf's death as the
  # pipeline's status. The check then inverts — every covered fixture is reported
  # missing. That is exactly how it behaved before this line changed.
  if grep -qxF "$fixture" <<< "$OPENED"; then
    covered=$((covered + 1))
    continue
  fi
  excused=0
  for known in "${KNOWN_UNCOVERED[@]:-}"; do
    if [ "$known" = "$fixture" ]; then excused=1; break; fi
  done
  if [ "$excused" -eq 0 ]; then
    echo "ERROR: canonical fixture '$fixture' was NOT opened by the suite." >&2
    echo "       A runner may still name it in source while no longer reading it —" >&2
    echo "       that is the drift this manifest exists to catch. Replay it, or add" >&2
    echo "       it to KNOWN_UNCOVERED with a reason." >&2
    missing=$((missing + 1))
  fi
done < <(cd "$SPEC_DIR" && find . -name '*.json' | sed 's|^\./||' | sort)

# The evidence channel guards itself. Every recorded id must resolve against the
# corpus root; otherwise the manifest was truncated or interleaved in transit,
# and coverage computed from it cannot be trusted.
while IFS= read -r id; do
  [ -n "$id" ] || continue
  if [ ! -f "$SPEC_DIR/$id" ]; then
    echo "ERROR: manifest records '$id', which names no file in $SPEC_DIR." >&2
    echo "       The recorder is dropping or interleaving writes; coverage computed" >&2
    echo "       from this manifest cannot be trusted." >&2
    missing=$((missing + 1))
  fi
done <<< "$OPENED"

# A stale allowlist is its own drift, in two directions (#lzcovallowlistrot).
#
#   1. An entry naming a fixture that no longer EXISTS means the corpus moved and
#      nobody updated the excuse.
#   2. An entry naming a fixture the suite DOES open means the excuse outlived the
#      gap it described. Nothing else catches this: the covered-check `continue`s
#      before it ever consults the allowlist, so a stale excuse costs nothing and
#      is invisible. Left alone it understates coverage — the ledger reports a gap
#      the binding closed — and it also re-arms the original drift, because if that
#      fixture later stops being replayed the excuse silently absorbs the failure.
#
# The presence test below is byte-identical to the covered-check above
# (`grep -qxF <needle> <<< "$OPENED"`) on purpose: two spellings of "is this
# fixture in the opened set" can drift apart, and then the guard contradicts
# itself about the same string.
for known in "${KNOWN_UNCOVERED[@]:-}"; do
  # `"${arr[@]:-}"` on an EMPTY array expands to one empty string, not to
  # nothing. Without this guard the loop then tests `-f "$SPEC_DIR/"` — a
  # directory, so not a file — and reports `KNOWN_UNCOVERED lists ''`. That
  # fires exactly when this list finally reaches zero entries, which is the
  # stated goal of shrinking it. The KNOWN_UNREPLAYED_SCENARIOS leg already
  # guards this; this one did not.
  [ -n "$known" ] || continue
  if [ ! -f "$SPEC_DIR/$known" ]; then
    echo "ERROR: KNOWN_UNCOVERED lists '$known', which is not in the canonical corpus." >&2
    missing=$((missing + 1))
    continue
  fi
  if grep -qxF "$known" <<< "$OPENED"; then
    echo "ERROR: KNOWN_UNCOVERED lists '$known', but the suite DID open it." >&2
    echo "       The excuse is stale — the gap it described is closed. Delete the" >&2
    echo "       entry from KNOWN_UNCOVERED. Keeping it understates coverage and" >&2
    echo "       silently absorbs the failure if replay ever stops." >&2
    missing=$((missing + 1))
  fi
done

if [ "$missing" -gt 0 ]; then
  echo "conformance coverage FAILED: $missing problem(s)" >&2
  exit 1
fi

# ---- Positive-evidence floor (#lzvacuousrun) ----
# Everything above reasons about fixtures this run OPENED, so all of it is
# vacuously satisfied by an empty population: zero fixtures means zero uncovered
# fixtures and zero stale excuses. The loop cannot distinguish "nothing is
# wrong" from "nothing was examined", so assert the magnitude explicitly before
# reporting OK. Do not lower these to fix a red run — a drop here means the
# corpus or the recorder shrank, which is the finding.
#
# PINNED TO REALITY (#lzscenariofloordrift). This floor equals what CI actually
# replays, with NO margin: the run that pinned it OPENED exactly 144 fixtures,
# and 145 fails. The older convention — "the corpus grew by N, so raise the
# floor by N and keep the existing margin" — is the bug it replaces. It let the
# gap reach 13, and a floor 13 below reality tolerates 13 fixtures silently
# detaching, which is the exact failure this floor exists to catch.
#
# So when a change moves the count, re-derive the floor from the gate's own
# output rather than adding a delta: run `make check`, read the "conformance
# coverage OK: <n>/..." line, set this to that <n>, then prove it is exact by
# temporarily setting it to <n>+1 and watching this guard fail. A floor you
# never watched fail is a floor you have not verified.
MIN_FIXTURES="${MIN_FIXTURES:-144}"
if [ "$total" -eq 0 ]; then
  echo "ERROR: the corpus at $SPEC_DIR listed ZERO fixtures." >&2
  echo "       Every check above is vacuously green over an empty population." >&2
  exit 1
fi
if [ "$covered" -lt "$MIN_FIXTURES" ]; then
  echo "ERROR: only $covered distinct canonical fixtures were OPENED, expected >= $MIN_FIXTURES." >&2
  echo "       A replay was removed, renamed, or short-circuited, or the recorder" >&2
  echo "       detached mid-run. Do not lower MIN_FIXTURES to fix this." >&2
  exit 1
fi

echo "conformance coverage OK: $covered/$total canonical fixtures OPENED by the suite" \
     "(${#KNOWN_UNCOVERED[@]} listed as known-uncovered; runtime manifest — these bytes were really read)"

# ---------------------------------------------------------------------------
# Per-scenario replay accounting (#lzscenariocoverage)
# ---------------------------------------------------------------------------
#
# The phase above proves the FILE was opened; one scenario out of four is enough
# to satisfy it. This phase compares the runtime scenario ledger
# (tests/common/mod.rs `record_scenario`) against the scenarios each opened
# fixture actually carries on disk. JSON parsing is why this leg is python — the
# ids live inside the fixtures, and `grep` cannot resolve `id` -> `name` without
# reading the structure.
if ! command -v python3 >/dev/null 2>&1; then
  echo "FAIL: python3 is required to read scenario ids out of the corpus." >&2
  exit 1
fi

SCENARIO_EXCUSES="$(printf '%s\n' "${KNOWN_UNREPLAYED_SCENARIOS[@]:-}")" \
python3 - "$SPEC_DIR" "$MANIFEST" "$SCENARIO_LEDGER" <<'PY'
import json
import os
import sys

spec_dir, manifest_path, ledger_path = sys.argv[1:4]

if not os.path.isfile(ledger_path) or os.path.getsize(ledger_path) == 0:
    sys.stderr.write(
        "FAIL: no scenario ledger at %s.\n"
        "      Run the suite with LAZILY_CONFORMANCE_SCENARIOS set to an ABSOLUTE\n"
        "      path so the recorder attaches (`make check` does this). An absent\n"
        "      ledger is missing evidence, not evidence of absence.\n" % ledger_path
    )
    sys.exit(1)

opened = set()
with open(manifest_path) as handle:
    for line in handle:
        line = line.strip()
        if line:
            opened.add(line)

# fixture -> {scenario id: source} as RECORDED at the point of replay.
ledger = {}
for line in open(ledger_path):
    line = line.rstrip("\n")
    if not line:
        continue
    parts = line.split("\t")
    if len(parts) != 3:
        sys.stderr.write("ERROR: malformed scenario ledger line %r\n" % line)
        sys.exit(1)
    fixture, scenario_id, source = parts
    ledger.setdefault(fixture, {})[scenario_id] = source


def scenario_ids(path):
    """`id` -> `name`. There is no third option (#lzspecscenarioids).

    The positional `#<n>` fallback is gone. It let the ledger record a scenario
    BY POSITION, where inserting one ahead of it silently rebinds that entry --
    and any excuse naming it -- to a different scenario with nothing turning red.
    The corpus now identifies every scenario and lazily-spec's
    `scenario-identity-check` keeps it that way, so an unidentified scenario here
    is a hard failure rather than a note.
    """
    try:
        with open(path) as handle:
            doc = json.load(handle)
    except (ValueError, OSError):
        return None
    if not isinstance(doc, dict):
        return None
    scenarios = doc.get("scenarios")
    if not isinstance(scenarios, list):
        return None
    out = []
    for index, scenario in enumerate(scenarios):
        identifier = None
        if isinstance(scenario, dict):
            for key in ("id", "name"):
                value = scenario.get(key)
                if isinstance(value, str) and value.strip():
                    identifier = (value, key)
                    break
        if identifier is None:
            sys.stderr.write(
                "ERROR: %s scenario at index %d carries neither `id` nor `name`.\n"
                "       The ledger would record it by POSITION, which silently rebinds\n"
                "       on a corpus reorder. Give it a stable id upstream in lazily-spec\n"
                "       (#lzspecscenarioids).\n" % (path, index)
            )
            sys.exit(1)
        out.append(identifier)
    return out


problems = 0
replayed = 0
total = 0

excuses = []
for raw in os.environ.get("SCENARIO_EXCUSES", "").splitlines():
    raw = raw.strip()
    if not raw:
        continue
    parts = raw.split("|")
    if len(parts) != 3:
        sys.stderr.write(
            "ERROR: KNOWN_UNREPLAYED_SCENARIOS entry %r is not "
            "'fixture|scenario id|reason'.\n" % raw
        )
        problems += 1
        continue
    fixture, scenario_id, reason = (part.strip() for part in parts)
    if not reason:
        sys.stderr.write(
            "ERROR: KNOWN_UNREPLAYED_SCENARIOS entry for '%s' scenario '%s' has no "
            "reason.\n"
            "       An excuse with no reason is an unexplained gap wearing a green "
            "badge.\n" % (fixture, scenario_id)
        )
        problems += 1
        continue
    excuses.append((fixture, scenario_id, reason))

excused_ids = {}
for fixture, scenario_id, _reason in excuses:
    excused_ids.setdefault(fixture, set()).add(scenario_id)

# Direction 1: every scenario of an opened fixture must appear in the ledger.
for fixture in sorted(opened):
    ids = scenario_ids(os.path.join(spec_dir, fixture))
    if ids is None:
        continue
    seen = ledger.get(fixture, {})
    for scenario_id, source in ids:
        total += 1
        if scenario_id in seen:
            replayed += 1
            continue
        if scenario_id in excused_ids.get(fixture, ()):  # excused, see below
            continue
        sys.stderr.write(
            "ERROR: '%s' scenario '%s' was OPENED but never REPLAYED "
            "(#lzscenariocoverage).\n"
            "       The fixture is counted as covered because a SIBLING scenario "
            "ran.\n"
            "       Replay it, or add '%s|%s|<reason>' to "
            "KNOWN_UNREPLAYED_SCENARIOS.\n" % (fixture, scenario_id, fixture, scenario_id)
        )
        problems += 1

# The evidence channel guards itself, exactly as the fixture manifest does: an
# id the corpus does not carry means the recorder and the corpus disagree, and
# coverage computed from the ledger cannot be trusted.
for fixture, seen in sorted(ledger.items()):
    ids = scenario_ids(os.path.join(spec_dir, fixture))
    if ids is None:
        sys.stderr.write(
            "ERROR: scenario ledger records '%s', which is not a scenario-bearing "
            "fixture in %s.\n" % (fixture, spec_dir)
        )
        problems += 1
        continue
    known = {scenario_id for scenario_id, _ in ids}
    for scenario_id in sorted(seen):
        if scenario_id not in known:
            sys.stderr.write(
                "ERROR: scenario ledger records '%s :: %s', which the fixture does "
                "not carry.\n"
                "       The recorder is resolving ids differently from the corpus; "
                "coverage\n"
                "       computed from this ledger cannot be trusted.\n"
                % (fixture, scenario_id)
            )
            problems += 1

# Direction 2: a stale excuse is its own drift, in the same two shapes the
# KNOWN_UNCOVERED allowlist guards (#lzcovallowlistrot).
for fixture, scenario_id, _reason in excuses:
    ids = scenario_ids(os.path.join(spec_dir, fixture))
    if ids is None:
        sys.stderr.write(
            "ERROR: KNOWN_UNREPLAYED_SCENARIOS names '%s', which is not a "
            "scenario-bearing fixture in %s.\n" % (fixture, spec_dir)
        )
        problems += 1
        continue
    if scenario_id not in {sid for sid, _ in ids}:
        sys.stderr.write(
            "ERROR: KNOWN_UNREPLAYED_SCENARIOS excuses '%s :: %s', which the fixture "
            "does not carry.\n"
            "       The excuse is stale — the corpus renamed or dropped that "
            "scenario. Delete it.\n" % (fixture, scenario_id)
        )
        problems += 1
        continue
    if scenario_id in ledger.get(fixture, {}):
        sys.stderr.write(
            "ERROR: KNOWN_UNREPLAYED_SCENARIOS excuses '%s :: %s', but the suite DID "
            "replay it.\n"
            "       The excuse is stale — the gap it described is closed. Delete the "
            "entry.\n"
            "       Keeping it understates coverage and silently absorbs the failure "
            "if replay ever stops.\n" % (fixture, scenario_id)
        )
        problems += 1

if problems:
    sys.stderr.write("scenario coverage FAILED: %d problem(s)\n" % problems)
    sys.exit(1)

# ---- Positive-evidence floor (#lzvacuousrun) ----
# The rung above walks the scenarios of OPENED fixtures. Zero opened fixtures
# means zero scenarios, which means zero unreplayed scenarios, which reports OK
# having compared nothing. Assert the magnitude before claiming green.
#
# PINNED TO REALITY (#lzscenariofloordrift). This equals what CI actually
# replays, with NO margin: the run that pinned it REPLAYED exactly 162
# scenarios, and 163 fails. It is deliberately NOT the old "raise by however
# many this change added, keeping the margin" convention — that convention only
# ever widens the gap, and it had reached 24 here. A floor 24 below reality
# tolerates 24 scenarios silently detaching, which is precisely what this floor
# is for.
#
# When the corpus moves, re-derive from the gate's own output instead of adding
# a delta: run `make check`, read the "scenario coverage OK: <n>/..." line, set
# this to that <n>, then prove it exact by setting it to <n>+1 and watching this
# guard fail. A floor you never watched fail is a floor you have not verified.
MIN_SCENARIOS = int(os.environ.get("MIN_SCENARIOS", "162"))
if total == 0:
    sys.stderr.write(
        "ERROR: ZERO scenarios were found across the opened fixtures.\n"
        "       The rung above is vacuously green over an empty population.\n"
    )
    sys.exit(1)
if replayed < MIN_SCENARIOS:
    sys.stderr.write(
        "ERROR: only %d distinct scenarios were REPLAYED, expected >= %d.\n"
        "       A scenario dispatch stopped matching, or the ledger detached.\n"
        "       Do not lower MIN_SCENARIOS to fix this.\n" % (replayed, MIN_SCENARIOS)
    )
    sys.exit(1)

print(
    "scenario coverage OK: %d/%d scenarios of OPENED fixtures REPLAYED by the suite "
    "(%d excused; runtime ledger — these scenarios really ran)"
    % (replayed, total, len(excuses))
)
PY

# ---- RUNG 0: assertion-block BIND ledger (#lznullformblind) ----
#
# Every rung above is scoped to blocks a runner already BOUND to the tracker.
# The unconsumed-key guard fires on a key nothing read; the unasserted-key guard
# fires on a key read and discarded; the prose ledger fires on a discharge naming
# nothing. NONE of them can fire for a block no runner ever bound, because there
# is no tracker: its keys are not unread, nothing reads them, and the fixture
# reports exactly nothing. lazily-dart found two such blocks carrying eight
# silent keys, one of them the anti-spoof invariant its fixture exists for.
#
# So the loader inventories every `assertions` block at READ time and
# `Expect::new` books one as BOUND. The two sides are matched by the block's
# CONTENT digest, never by its `where` label — runners spell those labels
# inconsistently, and a label-keyed ledger would silently miss the mismatch
# rather than report it.
#
# An unbindable block belongs HERE, as a documented excuse the guard reads every
# run, not as a runner fabricated to manufacture coverage.
# Format: "fixture|where|reason".
KNOWN_UNBOUND_BLOCKS=(
)

BLOCK_LEDGER="${LAZILY_CONFORMANCE_BLOCKS:-build/conformance-assertion-blocks.txt}"

BLOCK_EXCUSES="$(printf '%s\n' "${KNOWN_UNBOUND_BLOCKS[@]:-}")" \
python3 - "$BLOCK_LEDGER" <<'PY'
import os
import sys

ledger_path = sys.argv[1]
if not os.path.isfile(ledger_path) or os.path.getsize(ledger_path) == 0:
    sys.stderr.write(
        "FAIL: no assertion-block ledger at %s.\n"
        "      Run the suite with LAZILY_CONFORMANCE_BLOCKS set to an ABSOLUTE\n"
        "      path so the recorder attaches (`make check` does this). An absent\n"
        "      ledger is missing evidence, not evidence of absence.\n" % ledger_path
    )
    sys.exit(1)

declared = {}   # digest -> set of "fixture|where"
bound = set()   # digest
for line in open(ledger_path):
    parts = line.rstrip("\n").split("\t")
    if parts[0] == "declared" and len(parts) == 4:
        declared.setdefault(parts[2], set()).add("%s|%s" % (parts[1], parts[3]))
    elif parts[0] == "bound" and len(parts) == 2:
        bound.add(parts[1])

excuses = {}
for raw in os.environ.get("BLOCK_EXCUSES", "").splitlines():
    raw = raw.strip()
    if not raw:
        continue
    parts = raw.split("|", 2)
    if len(parts) != 3 or not parts[2].strip():
        sys.stderr.write(
            "ERROR: KNOWN_UNBOUND_BLOCKS entry %r must be 'fixture|where|reason'.\n"
            "       An excuse with no reason is an unexplained gap wearing a green badge.\n"
            % raw
        )
        sys.exit(1)
    excuses["%s|%s" % (parts[0], parts[1])] = parts[2]

unbound = []
for digest, sites in sorted(declared.items()):
    if digest in bound:
        continue
    for site in sorted(sites):
        if site in excuses:
            continue
        unbound.append(site)

if unbound:
    sys.stderr.write(
        "FAIL: %d assertion block(s) were carried by an OPENED fixture and bound by\n"
        "      no runner. Every other guard is scoped to blocks a runner bound, so\n"
        "      these report nothing at all rather than reporting a gap:\n" % len(unbound)
    )
    for site in unbound:
        sys.stderr.write("        %s\n" % site)
    sys.stderr.write(
        "      Bind each with `Expect::new(..., \"assertions\", &block)` and assert its\n"
        "      keys, or add it to KNOWN_UNBOUND_BLOCKS with a reason so the gap is\n"
        "      visible every run instead of invisible.\n"
    )
    sys.exit(1)

# Positive-evidence floor (#lzvacuousrun): zero declared blocks means zero
# unbound blocks, which reports OK having compared nothing.
#
# PINNED TO REALITY (#lzscenariofloordrift). This equals what CI actually
# inventories, with NO margin: the run that pinned it declared exactly 30
# blocks, and 31 fails. It previously sat at 20 — ten below reality, so ten
# blocks could have detached without reddening anything.
#
# When the corpus moves, re-derive from the gate's own output instead of adding
# a delta: run `make check`, read the "assertion-block bind OK: <n> distinct
# blocks" line, set this to that <n>, then prove it exact by setting it to <n>+1
# and watching this guard fail.
MIN_BLOCKS = int(os.environ.get("MIN_BLOCKS", "30"))
if len(declared) < MIN_BLOCKS:
    sys.stderr.write(
        "ERROR: only %d distinct assertion blocks were inventoried, expected >= %d.\n"
        "       The loader-side inventory detached, or fixtures stopped being read.\n"
        "       Do not lower MIN_BLOCKS to fix this.\n" % (len(declared), MIN_BLOCKS)
    )
    sys.exit(1)

print(
    "assertion-block bind OK: %d distinct blocks inventoried from OPENED fixtures, "
    "every one BOUND by a runner (%d excused; matched by content digest, not by label)"
    % (len(declared), len(excuses))
)
PY
