#!/usr/bin/env python3
"""Generate the umbrella V2 -> V3 disposition ledger from repository evidence.

This tool is deliberately read-only outside this directory.  It never calls an
RPC, wallet, deployment command, or network service.
"""

from __future__ import annotations

import ast
import hashlib
import json
import re
from collections import Counter
from pathlib import Path
from typing import Any, Iterable

HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
OUT = HERE / "ledger.json"

SKIP_PARTS = {
    ".git", "node_modules", "_next", "dist", "target", "out", "cache",
    "coverage", ".pytest_cache", "__pycache__", "audit-evidence",
    "release-evidence", "historical",
}

CANONICAL = {
    "deployments": "baseline/deployments.json",
    "runtime": "ci/manifests/v2-bytecode-manifest.json",
    "abi": "legacy/v2/manifest.json",
    "abi_sources": "baseline/v2_abi_sources.json",
    "source_routes": "baseline/extracted/v2_api_routes.txt",
    "route_registry": "pr7/generated/route-registry.json",
    "route_alignment": "pr7/generated/v2-inventory-alignment.json",
    "x402_live_evidence": (
        "audit-evidence/phase3-v2-compatibility-20260822/"
        "public-readonly-evidence/discovery-and-read-responses.json"
    ),
    "recovered_x402_identities": (
        "pr7/compat/recovered-97-public-x402-identities.json"
    ),
    "v2_dispositions": "v3-migration/completeness/v2-contract-dispositions.json",
    "supported_v3_modules": "v3-migration/completeness/supported-v3-modules.json",
    "mission_boundary_graph": "v3-migration/completeness/mission-boundary-graph.json",
    "base_security_dispositions": (
        "v3-migration/completeness/BASE_V3_SECURITY_DISPOSITIONS_20260825.md"
    ),
    "base_source_snapshot": "ci/manifests/base-v3-remediation-source.sha256",
    "base_pilot_manifest": "pr8/pilot/base_v3_deployment_manifest.json",
    "sdk_supported_modules": "repos/vaultfire-sdk/src/v3/supported.ts",
    "sdk_final_reconciliation": (
        "repos/vaultfire-sdk/V3_SDK_ABI_RECONCILIATION_20260826.txt"
    ),
    "sdk_identity_manifest": (
        "repos/vaultfire-sdk/tests/fixtures/v3-reviewed-abi-signatures.json"
    ),
    "sdk_accountability_artifact": (
        "repos/vaultfire-sdk/tests/fixtures/reviewed-artifacts/"
        "AccountabilityBondV3Safe.json"
    ),
    "sdk_partnership_artifact": (
        "repos/vaultfire-sdk/tests/fixtures/reviewed-artifacts/"
        "PartnershipBondV3Safe.json"
    ),
    "sdk_governance_artifact": (
        "repos/vaultfire-sdk/tests/fixtures/reviewed-artifacts/"
        "MultisigGovernanceV3.json"
    ),
    "sdk_mandate_artifact": (
        "repos/vaultfire-sdk/tests/fixtures/reviewed-artifacts/"
        "MandateRegistryV3.json"
    ),
    "x402_final_validation": "pr7/evidence/v3-x402-foundation-validation-20260825.json",
    "solana_final_closure": (
        "repos/vaultfire-solana/SOLANA_FINAL_GATE_POSTFIX_CLOSURE_2026-08-25.md"
    ),
    "open_spec_handoff": "v3-open-spec/HANDOFF.md",
    "open_spec_report": "v3-open-spec/reports/conformance-report.json",
    "open_spec_mapping": "v3-open-spec/mappings/canonical-v3.json",
    "open_spec_interface": "v3-open-spec/interfaces/canonical-v3-interfaces.v1.json",
    "partner_blocked_evidence": (
        "pr10/partner-package/THIRTEENTH_INDEPENDENT_AUDIT_CLOSURE_20260826.json"
    ),
    "current_authority_facts": (
        "v3-migration/completeness/current-authority-facts.json"
    ),
}

DISPOSITIONS = {
    "ported", "compatibility-wrapper", "intentionally-retired", "blocked",
    "not-applicable", "redesigned", "retired", "deferred",
}
FORBIDDEN_SIGNALS = {
    "behavioral", "flourishing", "reputation", "loyalty", "belief",
    "biometric", "profiling", "surveillance-derived",
}
HIGH_IMPACT_CONTROLS = {
    "custody", "access", "eligibility", "pricing", "slashing", "settlement",
    "disputes", "routing",
}


def rel(path: Path) -> str:
    return path.relative_to(ROOT).as_posix()


def digest(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()


def slug(value: str) -> str:
    base = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
    suffix = hashlib.sha256(value.encode()).hexdigest()[:10]
    return f"{base[:70]}-{suffix}"


def evidence(path: str, locator: str = "") -> list[dict[str, str]]:
    p = ROOT / path
    record = {"path": path, "sha256": digest(p), "kind": "repository-evidence"}
    if locator:
        record["locator"] = locator
    return [record]


def classify(text: str) -> tuple[dict[str, str], list[str], list[str], str]:
    lower = text.lower()
    signals = sorted(s for s in FORBIDDEN_SIGNALS if s in lower)
    controls = sorted(c for c in HIGH_IMPACT_CONTROLS if c in lower)
    privacy = "sensitive" if any(
        k in lower for k in ("privacy", "biometric", "profile", "surveillance")
    ) else "public"
    mission = "mission-sensitive" if signals or any(
        k in lower for k in ("mission", "consent", "human")
    ) else "neutral"
    risk = (
        "Potential prohibited signal/control coupling; remains blocked pending "
        "objective-input decoupling proof."
        if signals and controls
        else "V2 behavior and assumptions require explicit equivalence or retirement review."
    )
    return {"mission": mission, "privacy": privacy}, signals, controls, risk


def item(
    category: str,
    key: str,
    provenance: dict[str, Any],
    *,
    target: str = "TBD after owner-approved V3 mapping",
    disposition: str = "blocked",
    tests: Iterable[str] = ("provenance-exists", "owner-disposition-review"),
    owner: str = "Unassigned; release authority decision required",
    gate: str = "Owner maps target and supplies passing evidence",
    evidence_records: list[dict[str, str]] | None = None,
    text: str = "",
    risk: str | None = None,
    implementation_obligation: str = "inventory-review",
    support_status: str | None = None,
    evidence_status: str | None = None,
    deployment_status: str = "not-applicable",
    mission_boundary_rationale: str | None = None,
    exact_target_modules: Iterable[str] = (),
) -> dict[str, Any]:
    classification, signals, controls, inferred_risk = classify(f"{key} {text}")
    return {
        "id": f"{category}:{slug(key)}",
        "category": category,
        "v2Source": provenance,
        "v3Disposition": disposition,
        "v3Target": target,
        "implementationObligation": implementation_obligation,
        "v3SupportStatus": support_status or (
            disposition
            if disposition in {"ported", "redesigned", "retired", "deferred", "not-applicable"}
            else "unresolved"
        ),
        "evidenceStatus": evidence_status or (
            "repository-evidence" if evidence_records else "missing"
        ),
        "deploymentStatus": deployment_status,
        "missionBoundaryRationale": mission_boundary_rationale or inferred_risk,
        "exactTargetModules": list(exact_target_modules),
        "bugOrRiskCarriedFromV2": risk or inferred_risk,
        "missionPrivacyClassification": classification,
        "requiredTests": list(tests),
        "evidence": evidence_records or [],
        "ownerAuthorityDependency": owner,
        "activationGate": gate,
        "influencePolicy": {
            "signals": signals,
            "controls": controls,
            "controlAllowed": False,
            "decision": "blocked-pending-decoupling" if signals and controls else "not-asserted",
        },
    }


def candidate_files() -> list[Path]:
    files = []
    for p in ROOT.rglob("*"):
        if not p.is_file() or HERE in p.parents:
            continue
        relative = p.relative_to(ROOT).as_posix()
        if relative.startswith("v3-operations/"):
            continue
        if any(part in SKIP_PARTS for part in p.parts):
            continue
        files.append(p)
    return sorted(files)


def parse_ts_exports(path: Path) -> set[str]:
    text = path.read_text(errors="ignore")
    names: set[str] = set()
    # Export lists, including multiline `export type { ... }`.
    for match in re.finditer(r"export\s+(?:type\s+)?\{(.*?)\}\s+from", text, re.S):
        body = re.sub(r"//.*", "", match.group(1))
        for token in body.split(","):
            token = token.strip()
            if not token:
                continue
            names.add(re.split(r"\s+as\s+", token)[-1].strip())
    # Direct exported declarations and default exports.
    for match in re.finditer(
        r"^\s*export\s+(?:default\s+)?(?:declare\s+)?"
        r"(?:const|let|var|class|function|interface|type|enum)\s+([A-Za-z_$][\w$]*)",
        text, re.M
    ):
        names.add(match.group(1))
    if re.search(r"^\s*export\s+default\b", text, re.M):
        names.add("default")
    return names


def parse_python_exports(path: Path) -> set[str]:
    text = path.read_text(errors="ignore")
    match = re.search(r"__all__\s*=\s*(\[[\s\S]*?\])", text)
    if match:
        try:
            return set(ast.literal_eval(match.group(1)))
        except (ValueError, SyntaxError):
            pass
    names = set(re.findall(r"^from\s+\S+\s+import\s+([A-Za-z_]\w*)", text, re.M))
    return {n for n in names if not n.startswith("_")}


def sdk_entrypoints(files: list[Path]) -> list[Path]:
    entries: set[Path] = set()
    for package in ROOT.glob("repos/*/package.json"):
        try:
            data = json.loads(package.read_text())
        except (json.JSONDecodeError, OSError):
            continue
        if "vaultfire" not in str(data.get("name", "")).lower():
            continue
        base = package.parent
        for candidate in (base / "src/index.ts", base / "src/index.js"):
            if candidate.exists():
                entries.add(candidate)
    for pyproject in ROOT.glob("repos/*/pyproject.toml"):
        if "vaultfire" not in pyproject.read_text(errors="ignore").lower():
            continue
        for init in pyproject.parent.glob("*/__init__.py"):
            entries.add(init)
    for init in ROOT.glob("repos/*/python/__init__.py"):
        entries.add(init)
    for init in ROOT.glob("repos/*/sdk/*/__init__.py"):
        if "tests" not in init.parts:
            entries.add(init)
    # x402-guard is a package even though its public file is not re-export-only.
    guard = ROOT / "repos/vaultfire-x402-guard/src/index.ts"
    if guard.exists():
        entries.add(guard)
    return sorted(entries)


def build() -> dict[str, Any]:
    files = candidate_files()
    items: list[dict[str, Any]] = []

    deployments = json.loads((ROOT / CANONICAL["deployments"]).read_text())
    runtime = json.loads((ROOT / CANONICAL["runtime"]).read_text())
    abi = json.loads((ROOT / CANONICAL["abi"]).read_text())
    route_registry = json.loads((ROOT / CANONICAL["route_registry"]).read_text())
    alignment = json.loads((ROOT / CANONICAL["route_alignment"]).read_text())
    disposition_manifest = json.loads(
        (ROOT / CANONICAL["v2_dispositions"]).read_text()
    )
    dispositions_by_contract = {
        record["v2ContractIdentity"]: record
        for record in disposition_manifest["records"]
    }
    source_routes = [
        line.strip() for line in (ROOT / CANONICAL["source_routes"]).read_text().splitlines()
        if line.strip()
    ]

    # V2 contract identities.
    by_contract = {record["contract"]: record for record in abi["records"]}
    for name in sorted({c["name"] for c in deployments["contracts"]}):
        rec = by_contract.get(name, {})
        decision = dispositions_by_contract[name]
        ev = evidence(CANONICAL["abi"], f"records[contract={name}]")
        if rec.get("file"):
            ev += evidence(f"legacy/v2/{rec['file']}")
        ev += evidence(
            CANONICAL["v2_dispositions"],
            f"records[v2ContractIdentity={name}]",
        )
        if decision["evidenceStatus"] == "retirement-enforced":
            ev += evidence(
                CANONICAL["base_security_dispositions"],
                f"retirement decision for {name}",
            )
            ev += evidence(
                CANONICAL["base_pilot_manifest"],
                "scope.disabled and deployments",
            )
            ev += evidence(
                CANONICAL["sdk_supported_modules"],
                "V3_SUPPORTED_MODULES",
            )
        for target_module in decision["exactTargetModules"]:
            ev += evidence(target_module)
        items.append(item(
            "v2-contract", name,
            {"canonicalInventory": CANONICAL["deployments"], "contract": name},
            disposition=decision["v3SupportStatus"],
            target=(
                ", ".join(decision["exactTargetModules"])
                if decision["exactTargetModules"]
                else f"No V3 module ({decision['v3SupportStatus']})"
            ),
            evidence_records=ev,
            tests=("abi-freeze-check", "runtime-identity-check", "v3-behavioral-equivalence"),
            text=json.dumps(rec, sort_keys=True),
            implementation_obligation=decision["implementationObligation"],
            support_status=decision["v3SupportStatus"],
            evidence_status=decision["evidenceStatus"],
            deployment_status=decision["deploymentStatus"],
            mission_boundary_rationale=decision["missionBoundaryRationale"],
            exact_target_modules=decision["exactTargetModules"],
            gate=(
                "No deployment authorization; source status is tracked independently"
                if decision["v3SupportStatus"] in {"ported", "redesigned"}
                else "Capability remains unavailable in the supported V3 release"
            ),
        ))

    # Every deployed address and every independently frozen runtime identity.
    runtime_keyed = {
        (r["chain"], r["address"].lower()): r for r in runtime["records"]
    }
    for contract in deployments["contracts"]:
        key = f"{contract['chain']}:{contract['address'].lower()}"
        runtime_rec = runtime_keyed.get((contract["chain"], contract["address"].lower()))
        common_text = json.dumps(contract, sort_keys=True)
        items.append(item(
            "v2-deployed-address", key,
            {
                "canonicalInventory": CANONICAL["deployments"],
                "name": contract["name"], "chain": contract["chain"],
                "chainId": contract["chainId"], "address": contract["address"],
            },
            evidence_records=evidence(
                CANONICAL["deployments"],
                f"contracts[{contract['chain']}:{contract['address']}]"
            ),
            tests=("address-manifest-reconciliation", "runtime-identity-check", "read-only-compatibility"),
            target=f"V3 deployment mapping for {contract['chain']} (not authorized)",
            text=common_text,
            risk="Deployed V2 address remains immutable; migration must not mutate or relabel it.",
        ))
        items.append(item(
            "v2-runtime-identity", key,
            {
                "canonicalInventory": CANONICAL["runtime"],
                "name": contract["name"], "chain": contract["chain"],
                "address": contract["address"],
                "runtimeBytecodeSha256": runtime_rec and runtime_rec["runtimeBytecodeSha256"],
            },
            disposition="not-applicable",
            target="Immutable frozen V2 runtime reference",
            evidence_records=evidence(
                CANONICAL["runtime"],
                f"records[{contract['chain']}:{contract['address']}]"
            ),
            tests=("runtime-identity-check",),
            gate="Must remain byte-for-byte unchanged",
            owner="V2 freeze authority",
            risk="Any hash drift is a release-blocking V2 mutation.",
            text=common_text,
        ))

    # Every frozen ABI.
    for rec in abi["records"]:
        abi_path = f"legacy/v2/{rec['file']}"
        items.append(item(
            "v2-frozen-abi", rec["contract"],
            {
                "canonicalInventory": CANONICAL["abi"], "contract": rec["contract"],
                "file": abi_path, "entries": rec["entries"],
            },
            disposition="not-applicable",
            target="Immutable frozen V2 ABI reference",
            evidence_records=evidence(abi_path) + evidence(
                CANONICAL["abi"], f"records[contract={rec['contract']}]"
            ),
            tests=("abi-freeze-check",),
            gate="ABI checksum must remain unchanged",
            owner="V2 compatibility authority",
            risk="ABI drift would invalidate downstream compatibility evidence.",
            text=json.dumps(rec, sort_keys=True),
        ))

    # Base and all cross-chain deployment surfaces.
    for chain in deployments["chains"]:
        contracts = [c for c in deployments["contracts"] if c["chain"] == chain]
        items.append(item(
            "deployment-surface", chain,
            {
                "canonicalInventory": CANONICAL["deployments"], "chain": chain,
                "chainId": deployments["chainMeta"][chain]["chainId"],
                "v2ContractCount": len(contracts),
            },
            evidence_records=evidence(CANONICAL["deployments"], f"chainMeta.{chain}"),
            tests=("chain-address-coverage", "cross-chain-equivalence", "owner-role-verification"),
            target=f"V3 {chain} deployment surface (no deployment authorized)",
            risk="Chain-specific addresses, finality, assets, and authority assumptions may diverge.",
        ))

    # All 132 canonical V2 source routes.
    alignment_by_path = {entry["path"]: entry for entry in alignment["entries"]}
    for path in source_routes:
        aligned = alignment_by_path[path]
        items.append(item(
            "v2-source-route", path,
            {
                "canonicalInventory": CANONICAL["source_routes"], "path": path,
                "registryStatus": aligned["registryStatus"],
                "registryRouteIds": aligned["registryRouteIds"],
            },
            evidence_records=evidence(CANONICAL["source_routes"], path)
            + evidence(CANONICAL["route_alignment"], path),
            tests=("route-inventory-alignment", "request-response-contract", "privacy-policy"),
            target=f"V3 route disposition for {path}",
            text=json.dumps(aligned, sort_keys=True),
        ))

    # The settled recovery authority enumerates all 97 public V2 identities:
    # 86 core x402 routes, two well-known routes, eight Solana source-only
    # read/receive lanes, and one cross-chain handler.  Qualification remains
    # explicit; exact identity recovery is not deployment/payment evidence.
    recovered_x402 = json.loads(
        (ROOT / CANONICAL["recovered_x402_identities"]).read_text()
    )
    recovered_identities = recovered_x402["identities"]
    for route in recovered_identities:
        key = f"{route['group']}:{route['method']}:{route['path']}"
        items.append(item(
            "v2-public-x402-endpoint", key,
            {
                "canonicalInventory": CANONICAL["recovered_x402_identities"],
                "identity": route["identity"], "group": route["group"],
                "method": route["method"], "path": route["path"],
                "qualification": recovered_x402["qualification"],
            },
            evidence_records=evidence(
                CANONICAL["recovered_x402_identities"], route["identity"]
            ),
            tests=("x402-contract-test", "payment-safety", "bazaar-metadata-validation"),
            target=f"V3 x402 endpoint {route['method']} {route['path']}",
            text=json.dumps(route, sort_keys=True),
            risk=(
                "Identity is recovered exactly, but public deployment, payment, "
                "and settlement behavior remain separate evidence obligations."
            ),
            gate="Exact identity retained; activation still requires live behavior evidence",
        ))

    # Every public SDK export from package entry points.
    for entry in sdk_entrypoints(files):
        names = parse_python_exports(entry) if entry.suffix == ".py" else parse_ts_exports(entry)
        for name in sorted(names):
            path = rel(entry)
            items.append(item(
                "sdk-export", f"{path}:{name}",
                {"entrypoint": path, "export": name},
                evidence_records=evidence(path, name),
                tests=("sdk-export-snapshot", "v2-import-compatibility", "typed-api-contract"),
                target=f"V3 compatibility decision for {name}",
                text=entry.read_text(errors="ignore"),
            ))

    # All 15 Solana programs, read only despite concurrent Solana work.
    for lib in sorted((ROOT / "repos/vaultfire-solana/programs").glob("*/src/lib.rs")):
        program = lib.parents[1].name
        items.append(item(
            "solana-program", program,
            {"path": rel(lib), "program": program},
            evidence_records=evidence(rel(lib)),
            tests=("solana-program-inventory", "idl-compatibility", "program-security-regression"),
            target=f"V3 Solana program disposition for {program}",
            owner="Solana program authority; concurrent worker changes must settle first",
            gate="Solana owner freezes source/IDL and supplies final validation evidence",
            text=lib.read_text(errors="ignore"),
        ))

    # Repository surfaces: pages/nav/docs, discovery/Bazaar metadata,
    # governance roles, monitoring/recovery, schemas/manifests.
    page_docs: set[Path] = set()
    governance: set[Path] = set()
    operations: set[Path] = set()
    schemas: set[Path] = set()
    discovery: set[Path] = set()
    for p in files:
        low = rel(p).lower()
        suffix = p.suffix.lower()
        if (
            ("app" in p.parts and p.name.startswith("page."))
            or ("pages" in p.parts and suffix in {".ts", ".tsx", ".js", ".jsx", ".md", ".mdx", ".html"})
            or (("docs" in p.parts or "reference-docs" in p.parts) and suffix in {".md", ".mdx", ".txt", ".html"})
            or any(k in p.name.lower() for k in ("nav", "sidebar", "header"))
        ):
            page_docs.add(p)
        if any(k in low for k in ("/governance/", "governance_runbook", "multisig", "timelock")):
            governance.add(p)
        if any(k in low for k in ("/monitoring/", "/ops/", "/runbooks/", "incident", "recovery", "rollback")):
            operations.add(p)
        if ("schema" in p.name.lower() or "manifest" in p.name.lower()) and suffix in {
            ".json", ".yaml", ".yml", ".ts", ".py", ".md"
        }:
            schemas.add(p)
        if any(k in low for k in ("bazaar", "discovery", "agent-card", "well-known")) and suffix in {
            ".json", ".ts", ".tsx", ".js", ".py", ".md", ".yaml", ".yml"
        }:
            discovery.add(p)

    def add_file_items(category: str, paths: Iterable[Path], tests: tuple[str, ...], owner: str) -> None:
        for p in sorted(paths):
            path = rel(p)
            text = p.read_text(errors="ignore")[:200_000]
            items.append(item(
                category, path, {"path": path},
                evidence_records=evidence(path),
                tests=tests,
                target=f"V3 disposition for {path}",
                owner=owner,
                text=text,
            ))

    add_file_items(
        "site-page-nav-doc", page_docs,
        ("link-and-route-check", "address-version-isolation", "claim-truthfulness"),
        "Web/docs owner and release communications authority",
    )
    add_file_items(
        "discovery-bazaar-metadata", discovery,
        ("metadata-schema", "endpoint-ledger-reconciliation", "no-false-live-claim"),
        "Discovery/Bazaar metadata owner",
    )
    add_file_items(
        "governance-role-control", governance,
        ("role-matrix-review", "least-privilege", "timelock-and-recovery"),
        "Governance and security authority",
    )
    add_file_items(
        "monitoring-incident-recovery-control", operations,
        ("alert-coverage", "incident-drill", "recovery-and-rollback-proof"),
        "Operations/security incident commander",
    )
    add_file_items(
        "schema-manifest", schemas,
        ("schema-validation", "deterministic-generation", "checksum-verification"),
        "Schema/manifest owner",
    )

    # Partner-facing readiness claims: one stable item per matching source line.
    claim_pattern = re.compile(
        r"\b(mainnet|production[- ]ready|live|deployed|verified|complete|supports?|compatible|ready)\b",
        re.I,
    )
    for p in files:
        low = rel(p).lower()
        if "partner" not in low or p.suffix.lower() not in {
            ".md", ".mdx", ".json", ".yaml", ".yml", ".ts", ".tsx", ".js", ".py"
        }:
            continue
        for line_no, line in enumerate(p.read_text(errors="ignore").splitlines(), 1):
            if not claim_pattern.search(line):
                continue
            path = rel(p)
            key = f"{path}:{line_no}:{line.strip()}"
            items.append(item(
                "partner-readiness-claim", key,
                {"path": path, "line": line_no, "claim": line.strip()[:1000]},
                evidence_records=evidence(path, f"line {line_no}"),
                tests=("claim-evidence-link", "external-blocker-disclosure", "version-label-check"),
                target="V3 partner publication claim review",
                owner="Partner communications and release authority",
                gate="Claim is proven by final-scope evidence or rewritten as unresolved",
                text=line,
                risk="Stale or overbroad readiness language may misrepresent V3 or external state.",
            ))

    items.sort(key=lambda x: x["id"])
    counts = Counter(x["category"] for x in items)
    source_hashes = {name: digest(ROOT / path) for name, path in CANONICAL.items()}
    return {
        "schemaVersion": "1.1.0",
        "generatedBy": rel(Path(__file__).resolve()),
        "scope": "V2-to-V3 migration completeness; no deployment or V2 mutation",
        "supportedDispositions": sorted(DISPOSITIONS),
        "canonicalSources": CANONICAL,
        "canonicalSourceSha256": source_hashes,
        "reconciliation": {
            "v2ContractIdentities": {"expected": 36, "actual": counts["v2-contract"]},
            "deployedAddressEntries": {"expected": 134, "actual": counts["v2-deployed-address"]},
            "runtimeIdentities": {"expected": 134, "actual": counts["v2-runtime-identity"]},
            "frozenAbis": {"expected": 36, "actual": counts["v2-frozen-abi"]},
            "v2SourceRoutes": {"expected": 132, "actual": counts["v2-source-route"]},
            "publicX402NetworkEndpoints": {
                "expected": 97, "actual": counts["v2-public-x402-endpoint"],
                "composition": {
                    **recovered_x402["counts"],
                    "exactMethodPathIdentities": len({
                        (entry["method"], entry["path"])
                        for entry in recovered_identities
                    }),
                    "qualification": recovered_x402["qualification"],
                },
            },
            "solanaPrograms": {"expected": 15, "actual": counts["solana-program"]},
        },
        "categoryCounts": dict(sorted(counts.items())),
        "items": items,
    }


def main() -> None:
    ledger = build()
    OUT.write_text(json.dumps(ledger, indent=2, sort_keys=True) + "\n")
    print(f"wrote {OUT.relative_to(ROOT)} with {len(ledger['items'])} items")
    print(json.dumps(ledger["categoryCounts"], indent=2, sort_keys=True))


if __name__ == "__main__":
    main()
