# Copyright 2026 Nikolay Petrov
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Intra-version cross-source validation engine (ADR-035 D4, phase 2 / G19.2).

Unlike every other diff in abicheck, this engine consumes **one** merged
:class:`~abicheck.model.AbiSnapshot` and diffs its evidence *sources against
each other within a single version* — no baseline compare. It surfaces a class
of "bad ABI hygiene" findings that only become visible when the binary export
table, the public-header AST, the build flags, and the include/provenance graph
are checked for *mutual consistency*:

================================  =====================================  ==========
Check                             Inputs                                  Tier
================================  =====================================  ==========
``exported_not_public``           binary exports ↔ L2 header decls         RISK
``public_not_exported``           L2 header decls ↔ binary exports         RISK
``header_build_context_mismatch`` L2 header context ↔ L3 build flags       API_BREAK
``private_header_leak``           public API ↔ private-header provenance   RISK
``odr_type_variant``              L4 per-TU layouts of one type            API_BREAK
``public_to_internal_dependency`` L5 reachability ↔ changed files          RISK
``unversioned_exported_symbol``   binary exports ↔ ELF version table       RISK
``rtti_for_internal_type``        typeinfo exports ↔ header provenance     RISK
================================  =====================================  ==========

The last two are the ADR-035 D8 single-release hygiene audit: intra-version "bad
ABI hygiene" surfaced from one build (no baseline), exposed through ``scan
--audit`` / ``surface-report --audit``.

Per ADR-035 D1/D4 the findings are **never** ``BREAKING`` on their own (an
artifact diff still proves a shipped break); they default to ``RISK`` or
``API_BREAK`` and are advisory/suppressible until a check earns its FP-rate-gate
corpus and is promoted.

**Coverage honesty (ADR-035 D4).** A check whose required evidence is absent
(e.g. no public-header provenance, or no L3 build evidence) is reported as a
``NOT_COLLECTED`` coverage row naming what to enable — it is **never** counted
as clean and **never** emits a finding. With sources + provenance present the
check runs for real. This keeps the engine false-positive-free on an ELF-only
snapshot: it simply reports every check as skipped.

Everything here is a pure function over an in-memory snapshot — no binaries are
parsed and no external tools are run — so the whole module is exercised by fast
unit tests.
"""

from __future__ import annotations

import re
from dataclasses import dataclass, field
from typing import Any

from ..checker_policy import ChangeKind, Confidence
from ..checker_types import Change
from ..model import (
    AbiSnapshot,
    AccessLevel,
    Function,
    ScopeOrigin,
    Variable,
)

#: Cross-check fact-schema version. Independent of every other buildsource
#: schema version (see ``buildsource/CLAUDE.md`` "Versioning").
CROSSCHECK_VERSION: int = 1

# -- check + provider vocabulary ---------------------------------------------

CHECK_EXPORTED_NOT_PUBLIC = "exported_not_public"
CHECK_PUBLIC_NOT_EXPORTED = "public_not_exported"
CHECK_HEADER_BUILD_CONTEXT_MISMATCH = "header_build_context_mismatch"
CHECK_PRIVATE_HEADER_LEAK = "private_header_leak"
CHECK_ODR_TYPE_VARIANT = "odr_type_variant"
CHECK_PUBLIC_TO_INTERNAL_DEPENDENCY = "public_to_internal_dependency"
CHECK_UNVERSIONED_EXPORTED_SYMBOL = "unversioned_exported_symbol"
CHECK_RTTI_FOR_INTERNAL_TYPE = "rtti_for_internal_type"

#: Every check the engine knows, in cheapest-first order (ADR-035 D4 table).
ALL_CHECKS: tuple[str, ...] = (
    CHECK_EXPORTED_NOT_PUBLIC,
    CHECK_PUBLIC_NOT_EXPORTED,
    CHECK_HEADER_BUILD_CONTEXT_MISMATCH,
    CHECK_PRIVATE_HEADER_LEAK,
    CHECK_ODR_TYPE_VARIANT,
    CHECK_PUBLIC_TO_INTERNAL_DEPENDENCY,
    CHECK_UNVERSIONED_EXPORTED_SYMBOL,
    CHECK_RTTI_FOR_INTERNAL_TYPE,
)

#: The §6.8 provider-agreement vocabulary (ADR-035 D4) — which evidence source
#: corroborates a finding, driving its confidence tag.
PROVIDER_BINARY_EXPORTS = "binary_exports"
PROVIDER_PUBLIC_HEADER_AST = "public_header_ast"
PROVIDER_DEBUG_INFO = "debug_info"
PROVIDER_BUILD_CONFIG = "build_config"
PROVIDER_SOURCE_INDEX = "source_index"


@dataclass(frozen=True)
class CrosscheckConfig:
    """Which cross-checks run, and the per-check finding cap.

    ``enabled`` defaults to every check; the orchestrator (Phase 3 ``scan``)
    narrows it from the ``crosschecks:`` config block. ``max_per_check`` caps a
    single check's findings so a pathological library cannot flood the report;
    0 disables the cap. ``changed_paths`` is the optional PR/revision changed-file
    set: ``public_to_internal_dependency`` elevates a finding whose internal
    target was changed (ADR-035 D4 "L5 reachability ↔ PR changed files"). It only
    refines the message/confidence — the base finding fires regardless.
    """

    enabled: frozenset[str] = frozenset(ALL_CHECKS)
    max_per_check: int = 200
    changed_paths: frozenset[str] = frozenset()


@dataclass(frozen=True)
class _CheckOutput:
    """One check's result: findings, its coverage row, and the providers used."""

    findings: list[Change]
    status: str  # "present" | "skipped"
    detail: str
    providers: list[str]
    #: Optional ADR-035 D4 source-surface boundary integrity numbers (e.g.
    #: exported/matched/unmatched symbols) carried onto the coverage row so a
    #: degraded link is named, never folded in as clean. ``facts`` anchors the row.
    facts: int = 0
    counters: dict[str, int] = field(default_factory=dict)


@dataclass
class CrosscheckResult:
    """Outcome of an intra-version cross-source validation pass (ADR-035 D4).

    ``findings`` are ordinary :class:`Change` objects ready to fold into a
    ``DiffResult`` / audit report. ``coverage`` carries one row per check (run
    or skipped) so a partial pass is legible — never read as clean. ``providers``
    maps each *run* check to the evidence sources that corroborated it (the
    §6.8 provider-agreement matrix).
    """

    findings: list[Change] = field(default_factory=list)
    coverage: list[dict[str, Any]] = field(default_factory=list)
    providers: dict[str, list[str]] = field(default_factory=dict)
    version: int = CROSSCHECK_VERSION

    def counts_by_check(self) -> dict[str, int]:
        counts: dict[str, int] = {}
        for c in self.findings:
            counts[c.kind.value] = counts.get(c.kind.value, 0) + 1
        return counts

    def to_dict(self) -> dict[str, Any]:
        return {
            "version": self.version,
            "findings": len(self.findings),
            "counts_by_check": self.counts_by_check(),
            "coverage": list(self.coverage),
            "providers": {k: list(v) for k, v in self.providers.items()},
        }


def run_crosschecks(
    snapshot: AbiSnapshot, config: CrosscheckConfig | None = None
) -> CrosscheckResult:
    """Run the enabled intra-version cross-source checks over one merged snapshot.

    Returns a :class:`CrosscheckResult`; each disabled-or-skipped check still
    produces a coverage row so the caller can tell "ran and clean" from "could
    not run" (ADR-035 D4 coverage honesty).
    """
    cfg = config or CrosscheckConfig()
    result = CrosscheckResult()
    runners = {
        CHECK_EXPORTED_NOT_PUBLIC: _check_exported_not_public,
        CHECK_PUBLIC_NOT_EXPORTED: _check_public_not_exported,
        CHECK_HEADER_BUILD_CONTEXT_MISMATCH: _check_header_build_context_mismatch,
        CHECK_PRIVATE_HEADER_LEAK: _check_private_header_leak,
        CHECK_ODR_TYPE_VARIANT: _check_odr_type_variant,
        CHECK_PUBLIC_TO_INTERNAL_DEPENDENCY: _check_public_to_internal_dependency,
        CHECK_UNVERSIONED_EXPORTED_SYMBOL: _check_unversioned_exported_symbol,
        CHECK_RTTI_FOR_INTERNAL_TYPE: _check_rtti_for_internal_type,
    }
    for name in ALL_CHECKS:
        if name not in cfg.enabled:
            result.coverage.append(
                _coverage_row(name, "not_collected", "disabled by configuration")
            )
            continue
        out = runners[name](snapshot, cfg)
        capped = (
            out.findings[: cfg.max_per_check]
            if cfg.max_per_check and len(out.findings) > cfg.max_per_check
            else out.findings
        )
        result.findings.extend(capped)
        status = out.status
        detail = out.detail
        if len(capped) < len(out.findings):
            status = "partial"
            detail += f" (capped at {cfg.max_per_check} of {len(out.findings)})"
        result.coverage.append(
            _coverage_row(name, status, detail, facts=out.facts, counters=out.counters)
        )
        if out.status == "present":
            result.providers[name] = out.providers
    return result


# ---------------------------------------------------------------------------
# exported_not_public — a symbol is exported but no public header declares it.
# ---------------------------------------------------------------------------


def _check_exported_not_public(
    snapshot: AbiSnapshot, cfg: CrosscheckConfig
) -> _CheckOutput:
    """Exported symbols with no *public* declaration, RISK.

    Driven from the **binary export table**, not the declaration list: in a
    header-backed dump castxml only creates ``Function``/``Variable`` entries for
    declarations it parsed, so a symbol that exists *only* in the export table
    (the canonical accidental-ABI case) has no decl object to iterate — the check
    must start from ``exported`` and ask which exports no public header declares
    (Codex review).

    A public header "declares" an export when a ``PUBLIC_HEADER`` decl maps to
    that symbol. Itanium constructor/destructor exports (``…C1Ev``/``…D1Ev``) are
    skipped: castxml routinely leaves members unmangled, so they would not match
    a public class's decls and would false-positive; the class itself, if it
    leaks, is caught by ``private_header_leak``.

    Provenance only runs when a public-header set was supplied, so the check
    skips cleanly on an ELF-only / no-header snapshot; it also needs an export
    table to compare against.
    """
    providers = [PROVIDER_BINARY_EXPORTS, PROVIDER_PUBLIC_HEADER_AST]
    if not _origin_resolvable(snapshot):
        return _CheckOutput([], "skipped", _NO_PROVENANCE, providers)
    exported = _exported_symbol_names(snapshot)
    if exported is None:
        return _CheckOutput(
            [], "skipped", "no binary export table on the snapshot", providers
        )

    # Symbols a public header declares (so an export of them is documented), and
    # a decl lookup for enriching the message when one exists in a non-public
    # header.
    public_syms: set[str] = set()
    decl_by_sym: dict[str, Function | Variable] = {}
    all_decls: list[Function | Variable] = [*snapshot.functions, *snapshot.variables]
    for d in all_decls:
        for sym in _candidate_symbols(d):
            decl_by_sym.setdefault(sym, d)
            if d.origin == ScopeOrigin.PUBLIC_HEADER:
                public_syms.add(sym)

    findings: list[Change] = []
    for sym in sorted(exported):
        if sym in public_syms or _is_cxx_implementation_symbol(sym):
            continue
        decl: Function | Variable | None = decl_by_sym.get(sym)
        where = ""
        if decl is not None:
            kind = "function" if isinstance(decl, Function) else "variable"
            where = f" (declared as {kind} {decl.name!r} in a non-public header)"
        findings.append(
            _change(
                ChangeKind.EXPORTED_NOT_PUBLIC,
                sym,
                f"Symbol {sym!r} is exported by the binary but declared in no "
                f"public header{where}. It is accidental ABI surface — hide it "
                "(visibility/version script) or document it.",
                new_value=sym,
                confidence=Confidence.HIGH,
            )
        )
    detail = (
        f"binary exports ↔ public headers: {len(findings)} exported symbol(s) "
        "with no public declaration"
    )
    return _CheckOutput(findings, "present", detail, providers)


# ---------------------------------------------------------------------------
# public_not_exported — a public header promises a symbol the binary lacks.
# ---------------------------------------------------------------------------


def _check_public_not_exported(
    snapshot: AbiSnapshot, cfg: CrosscheckConfig
) -> _CheckOutput:
    """Public declarations with an export obligation absent from the binary, RISK.

    Intentionally narrow (ADR-035 D4): only declarations that *promise a dynamic
    symbol* are compared — default-visibility, non-inline, non-pure-virtual,
    non-deleted, non-template free functions / methods / extern data with a
    mangled name. Inline / templated / constexpr / hidden-visibility decls are
    public source surface that legitimately emit no symbol and are excluded, so
    the check does not light up a healthy header-only API.
    """
    providers = [PROVIDER_PUBLIC_HEADER_AST, PROVIDER_BINARY_EXPORTS]
    exported = _exported_symbol_names(snapshot)
    if not _origin_resolvable(snapshot):
        return _CheckOutput([], "skipped", _NO_PROVENANCE, providers)
    if exported is None:
        return _CheckOutput(
            [], "skipped", "no binary export table on the snapshot", providers
        )

    findings: list[Change] = []
    for fn in snapshot.functions:
        if not _has_export_obligation(fn):
            continue
        if fn.mangled not in exported:
            findings.append(
                _change(
                    ChangeKind.PUBLIC_NOT_EXPORTED,
                    fn.mangled or fn.name,
                    f"Public header declares {fn.name!r} (expected symbol "
                    f"{fn.mangled!r}) but the binary does not export it. Code that "
                    "compiles against the header gets an undefined-symbol link error.",
                    old_value=fn.mangled,
                    confidence=Confidence.HIGH,
                    source_location=fn.source_location,
                )
            )
    for var in snapshot.variables:
        if not _var_has_export_obligation(var):
            continue
        if var.mangled not in exported:
            findings.append(
                _change(
                    ChangeKind.PUBLIC_NOT_EXPORTED,
                    var.mangled or var.name,
                    f"Public header declares extern variable {var.name!r} (expected "
                    f"symbol {var.mangled!r}) but the binary does not export it. "
                    "Consumers linking against it get an undefined-symbol error.",
                    old_value=var.mangled,
                    confidence=Confidence.HIGH,
                    source_location=var.source_location,
                )
            )
    findings.sort(key=lambda c: c.symbol)
    detail = (
        f"public headers ↔ binary exports: {len(findings)} declaration(s) with an "
        "export obligation the binary does not satisfy"
    )
    return _CheckOutput(findings, "present", detail, providers)


# ---------------------------------------------------------------------------
# header_build_context_mismatch — headers parsed without the build's context.
# ---------------------------------------------------------------------------


def _check_header_build_context_mismatch(
    snapshot: AbiSnapshot, cfg: CrosscheckConfig
) -> _CheckOutput:
    """L2 header AST captured without the L3 build's ABI-relevant context, API_BREAK.

    When the build evidence records ABI-affecting flags/macros but the public
    headers were parsed *context-free* (``parsed_with_build_context`` is False),
    the declared API surface may not match what the shipped translation units
    compile to (a macro-conditional field, a packing pragma, an ABI-tag flag).
    Emits a single aggregate finding naming the divergent flags; stays silent
    when the headers *were* parsed with the build context.
    """
    providers = [PROVIDER_BUILD_CONFIG, PROVIDER_PUBLIC_HEADER_AST]
    abi_flags = _abi_relevant_build_flags(snapshot)
    if not snapshot.from_headers:
        return _CheckOutput(
            [], "skipped", "snapshot has no public-header AST (L2)", providers
        )
    if abi_flags is None:
        return _CheckOutput(
            [], "skipped", "no L3 build evidence on the snapshot", providers
        )
    if not abi_flags:
        return _CheckOutput(
            [], "present", "build evidence carries no ABI-relevant flags", providers
        )
    if snapshot.parsed_with_build_context:
        return _CheckOutput(
            [],
            "present",
            f"headers parsed with the build context ({len(abi_flags)} ABI flag(s))",
            providers,
        )

    sample = ", ".join(abi_flags[:6])
    if len(abi_flags) > 6:
        sample += ", …"
    finding = _change(
        ChangeKind.HEADER_BUILD_CONTEXT_MISMATCH,
        "",
        "Public headers were parsed without the build's ABI-relevant context: the "
        f"build records {len(abi_flags)} ABI-affecting flag(s) ({sample}) but the "
        "header AST was captured context-free, so the declared API surface may not "
        "match the shipped translation units. Re-dump the headers with the build's "
        "compile_commands.json.",
        new_value=sample,
        confidence=Confidence.MEDIUM,
        evidence_category="build_context",
    )
    detail = (
        f"header context ↔ build flags: {len(abi_flags)} ABI flag(s) not reflected "
        "in the context-free header parse"
    )
    return _CheckOutput([finding], "present", detail, providers)


# ---------------------------------------------------------------------------
# private_header_leak — public API exposes a private-header-only type.
# ---------------------------------------------------------------------------


def _check_private_header_leak(
    snapshot: AbiSnapshot, cfg: CrosscheckConfig
) -> _CheckOutput:
    """Public API surface that references a private-header type, RISK.

    A public-header function/variable whose signature names a type declared
    *only* in a private (non-installed) header transitively pulls that header
    into a consumer's build; once the private header is absent from the install
    tree the consumer fails to compile. Detected from declaration provenance
    (``origin``) — the strongest always-available signal in a merged snapshot;
    when an L5 include graph is present it can refine the localization. Skips
    cleanly when no private-header provenance is available.
    """
    providers = [PROVIDER_PUBLIC_HEADER_AST]
    # Only claim L5 (source-index) corroboration when the attached graph actually
    # indexed something — an empty ``SourceGraphSummary`` is still a truthy object,
    # so checking presence alone would record a provider with no fact behind it and
    # mask regressions in real source-graph extraction (ADR-035 D4 coverage honesty
    # — Codex review).
    sg = snapshot.build_source.source_graph if snapshot.build_source is not None else None
    if sg is not None and sg.nodes:
        providers.append(PROVIDER_SOURCE_INDEX)
    if not _origin_resolvable(snapshot):
        return _CheckOutput([], "skipped", _NO_PROVENANCE, providers)

    private_types = _private_type_names(snapshot)
    if not private_types:
        return _CheckOutput(
            [],
            "present",
            "no private-header types declared in the snapshot",
            providers,
        )

    findings: list[Change] = []
    seen: set[tuple[str, str]] = set()
    for fn in snapshot.functions:
        if fn.origin != ScopeOrigin.PUBLIC_HEADER:
            continue
        for leaked in _referenced_private_types(_function_type_refs(fn), private_types):
            key = (fn.mangled or fn.name, leaked)
            if key in seen:
                continue
            seen.add(key)
            findings.append(
                _change(
                    ChangeKind.PRIVATE_HEADER_LEAK,
                    fn.mangled or fn.name,
                    f"Public API {fn.name!r} exposes type {leaked!r}, which is "
                    "declared only in a private (non-installed) header. Consumers "
                    "including the public header pull in an unshipped declaration. "
                    "Make the header self-contained or install the leaked header.",
                    new_value=leaked,
                    confidence=Confidence.MEDIUM,
                    caused_by_type=leaked,
                )
            )
    for var in snapshot.variables:
        if var.origin != ScopeOrigin.PUBLIC_HEADER:
            continue
        for leaked in _referenced_private_types({var.type}, private_types):
            key = (var.mangled or var.name, leaked)
            if key in seen:
                continue
            seen.add(key)
            findings.append(
                _change(
                    ChangeKind.PRIVATE_HEADER_LEAK,
                    var.mangled or var.name,
                    f"Public variable {var.name!r} exposes type {leaked!r}, declared "
                    "only in a private (non-installed) header.",
                    new_value=leaked,
                    confidence=Confidence.MEDIUM,
                    caused_by_type=leaked,
                )
            )
    findings.sort(key=lambda c: (c.symbol, c.new_value or ""))
    n_private = len(set(private_types.values()))
    detail = (
        f"public API ↔ private-header provenance: {len(findings)} public "
        f"declaration(s) exposing one of {n_private} private type(s)"
    )
    return _CheckOutput(findings, "present", detail, providers)


# ---------------------------------------------------------------------------
# odr_type_variant — one type has divergent per-TU layouts (L4 ODR conflict).
# ---------------------------------------------------------------------------


def _surface_has_l4_facts(surface: Any) -> bool:
    """Whether a linked L4 source surface carries any real replay evidence.

    An ``inline`` collector attaches an empty :class:`SourceAbiSurface` when L4
    replay ran but parsed zero TUs (missing clang/castxml), so the presence of a
    surface object is not proof of evidence — check for actual content.
    """
    if surface.odr_conflicts:
        return True
    if any(
        (
            surface.reachable_declarations,
            surface.reachable_types,
            surface.reachable_macros,
            surface.reachable_templates,
            surface.reachable_inline_bodies,
        )
    ):
        return True
    mappings = surface.mappings or {}
    if any(
        mappings.get(k)
        for k in ("source_decl_to_binary_symbol", "source_type_to_debug_type")
    ):
        return True
    # Only *parsed-TU* coverage counts. ``exported_symbols``/``matched_symbols``
    # are recorded from the binary export table even when replay parsed zero TUs,
    # so counting them would re-mask an all-failed/zero-TU L4 run as a clean ODR
    # audit (Codex review). Require a real parsed-compile-unit count.
    coverage = surface.coverage or {}
    return any(
        coverage.get(k) for k in ("parsed_tus", "compile_units_parsed", "parsed")
    )


def _surface_boundary_counters(surface: Any) -> tuple[int, dict[str, int]]:
    """The L4 source-link boundary integrity numbers (ADR-035 D4) for a surface.

    Returns ``(facts, counters)`` where ``counters`` names exported vs matched vs
    unmatched symbols, so a degraded link (parsed decls — or even zero TUs — yet
    zero matched exports, the oneDAL shape) is named on the coverage row rather
    than read as clean. Emitted on the present *and* the empty-surface skip path.
    """
    cov = surface.coverage or {}
    unmatched = surface.unmatched or {}
    counters = {
        "exported_symbols": int(cov.get("exported_symbols", 0) or 0),
        "matched_symbols": int(cov.get("matched_symbols", 0) or 0),
        "unmatched_symbols": len(unmatched.get("symbols_without_decl", []) or []),
    }
    facts = int(cov.get("reachable_declarations", 0) or 0) or len(
        surface.reachable_declarations or []
    )
    return facts, counters


def _check_odr_type_variant(
    snapshot: AbiSnapshot, cfg: CrosscheckConfig
) -> _CheckOutput:
    """A type with divergent per-TU definitions in the L4 source surface, API_BREAK.

    The L4 linker (``source_link.link_source_abi``) already records, per merged
    library surface, every ``(qualified_name, header)`` whose ``type_hash``
    diverged between the translation units that defined it — an ODR conflict.
    This check turns each recorded conflict into a finding: mixing those
    definitions at link time is undefined behavior (a consumer compiled against
    one layout reads a struct laid out the other way), and it is almost always a
    macro/flag that changes the type per TU.

    Needs the L4 source-ABI surface; skips cleanly (never false-positives) on a
    snapshot that carries no source-replay evidence.
    """
    providers = [PROVIDER_SOURCE_INDEX]
    surface = (
        snapshot.build_source.source_abi if snapshot.build_source is not None else None
    )
    if surface is None:
        return _CheckOutput(
            [],
            "skipped",
            "no L4 source-ABI surface on the snapshot (run --source-method s5)",
            providers,
        )
    # An empty surface is attached when L4 replay ran but parsed zero TUs (e.g.
    # clang/castxml unavailable). Reading that as a clean ODR audit would mask the
    # missing evidence, so skip with an honest reason instead of "present"
    # (ADR-035 D4 coverage honesty — Codex review).
    if not _surface_has_l4_facts(surface):
        # Still surface the boundary counters: a zero-TU L4 run with exports on
        # record is a degraded link, named on the skipped row, never read clean.
        facts, counters = _surface_boundary_counters(surface)
        return _CheckOutput(
            [],
            "skipped",
            "L4 source surface present but empty (no TUs parsed — clang/castxml "
            "unavailable?); ODR audit not run",
            providers,
            facts,
            counters,
        )

    findings: list[Change] = []
    for conflict in surface.odr_conflicts:
        name = str(conflict.get("qualified_name", "")) or "<anonymous>"
        header = str(conflict.get("header", ""))
        where = f" in {header!r}" if header else ""
        findings.append(
            _change(
                ChangeKind.ODR_TYPE_VARIANT,
                name,
                f"Type {name!r} has divergent per-translation-unit definitions"
                f"{where}: the source-replay surface recorded different layouts for "
                "the same type. Linking code that mixes them is undefined behavior — "
                "a consumer compiled against one layout silently reads the other. "
                "Reconcile the definitions (usually a macro/flag that changes the "
                "type per TU).",
                new_value=name,
                confidence=Confidence.MEDIUM,
                caused_by_type=name,
                source_location=header or None,
            )
        )
    findings.sort(key=lambda c: (c.symbol, c.source_location or ""))
    detail = (
        f"L4 per-TU type layouts: {len(findings)} type(s) with divergent "
        "cross-TU definitions (ODR conflict)"
    )
    # Carry the boundary integrity counters (ADR-035 D4) onto the coverage row so
    # the rendered ScanResult names a degraded link: an L4 surface can carry parsed
    # decls yet match zero exports (the oneDAL shape) — a clean ODR pass over it
    # must still show matched_symbols == 0.
    facts, counters = _surface_boundary_counters(surface)
    return _CheckOutput(findings, "present", detail, providers, facts, counters)


# ---------------------------------------------------------------------------
# public_to_internal_dependency — public API reaches an internal entity (L5).
# ---------------------------------------------------------------------------

#: L5 edge kinds that express a decl→decl/type dependency. They are produced
#: only by an S4/S5 semantic pass (``call_graph``/AST augmentation), so a
#: structural-only graph carries none of them — the check then skips with a soft
#: advisory rather than reading clean (ADR-035 D4 coverage honesty).
_DEPENDENCY_EDGE_KINDS = frozenset(
    {"DECL_CALLS_DECL", "DECL_REFERENCES_DECL", "DECL_HAS_TYPE"}
)

#: Graph node kinds that carry a declaration/type visibility we can classify.
_DECL_NODE_KINDS = frozenset({"source_decl", "record_type", "enum_type", "typedef"})

#: Node visibilities that put an entity *on* the public source surface. Mirrors
#: ``source_link._is_public`` (which the L5 graph's ``visibility`` attr is derived
#: from): ``generated`` means a generated header **under the public roots** — a
#: public, consumer-visible entity — so it is NOT an internal dependency (Codex
#: review).
_PUBLIC_VISIBILITIES = frozenset({"public_header", "generated"})

#: Node visibilities that make an entity *internal* (not public surface): a
#: project-private header or an implementation ("source") file. System headers
#: are third-party (excluded), and ``generated`` is public (above).
_INTERNAL_VISIBILITIES = frozenset({"private_header", "source"})

#: Visibilities that carry no provenance. The built-in call-graph extractor
#: (``augment_graph_with_calls``) creates callee ``source_decl`` nodes with **no**
#: ``visibility`` attr. Such a node is internal *only when the project also
#: declares it* via a ``SOURCE_DECLARES`` edge — caller-presence alone is unsound
#: (an inline third-party header function whose body calls something also appears
#: as a caller), so a bare call-graph node with no project provenance is treated
#: as a third-party/system call target and not flagged (Codex review).
_UNANNOTATED_VISIBILITIES = frozenset({"", "unknown"})

#: Mangled-name prefixes / substrings that mark a standard-library or
#: compiler-internal decl. The call graph resolves callees into ``std::`` /
#: ``__gnu_cxx`` / cxxabi helpers, which carry no visibility too; without this an
#: unannotated stdlib callee would be mis-read as a project-internal dependency
#: and a public API calling ``std::`` would light up (Codex review). Mirrors the
#: stdlib/compiler filtering the dumper already applies to exported symbols.
_SYSTEM_NAME_PREFIXES = (
    "_ZSt",
    "_ZNSt",
    "_ZNKSt",
    "_ZNSa",
    "_ZN9__gnu_cxx",
    "_ZNK9__gnu_cxx",
    "_ZN6__cxxabiv",
    "_Znw",
    "_Zna",
    "_Zdl",
    "_Zda",
    "__",
)
_SYSTEM_NAME_SUBSTRINGS = ("std::", "__gnu_cxx::", "__cxxabiv")


def _looks_system(name: str) -> bool:
    """Whether *name* is a standard-library / compiler-internal decl spelling."""
    if name.startswith(_SYSTEM_NAME_PREFIXES):
        return True
    return any(sub in name for sub in _SYSTEM_NAME_SUBSTRINGS)


def _norm_path(path: str) -> str:
    """Normalize a path for cross-source comparison: forward slashes, no ``./``."""
    p = path.replace("\\", "/")
    while p.startswith("./"):
        p = p[2:]
    return p


def _path_matches(candidate: str, changed: frozenset[str]) -> bool:
    """Whether *candidate* refers to one of the *changed* paths.

    Graph node labels are often absolute build/clang paths (``/work/src/foo.cc``)
    while ``scan`` passes ``git diff --name-only`` repo-relative paths
    (``src/foo.cc``); match when either is a path-component suffix of the other,
    so the two spellings line up without a basename-only false hit (mirrors
    ``source_replay._path_matches`` — kept local to avoid importing that heavy,
    castxml-dependent module into this pure engine; Codex review).
    """
    if not candidate:
        return False
    c = _norm_path(candidate)
    for ch in changed:
        n = _norm_path(ch)
        if c == n or c.endswith("/" + n) or n.endswith("/" + c):
            return True
    return False


def _check_public_to_internal_dependency(
    snapshot: AbiSnapshot, cfg: CrosscheckConfig
) -> _CheckOutput:
    """A public/exported decl that reaches an internal entity via the L5 graph, RISK.

    Reads the source graph's decl-dependency edges (``DECL_CALLS_DECL`` /
    ``DECL_REFERENCES_DECL`` / ``DECL_HAS_TYPE``): when a *public* declaration
    (public-header visibility, or one mapped to an exported binary symbol) points
    at an *internal* declaration/type (private-header or source-file visibility;
    ``generated`` is a public generated header and excluded), the public surface
    depends on something consumers cannot see —
    a behavioral risk, elevated when the internal entity is among the revision's
    changed files (``cfg.changed_paths``).

    The dependency edges only exist after an S4/S5 semantic pass. With a
    structural-only graph (or no graph at all) the check skips with a soft
    advisory naming what to enable — it is never counted clean.
    """
    providers = [PROVIDER_SOURCE_INDEX]
    graph = (
        snapshot.build_source.source_graph
        if snapshot.build_source is not None
        else None
    )
    if graph is None:
        return _CheckOutput(
            [],
            "skipped",
            "no L5 source graph on the snapshot (run --source-method s5/--depth graph)",
            providers,
        )
    if not any(e.kind in _DEPENDENCY_EDGE_KINDS for e in graph.edges):
        return _CheckOutput(
            [],
            "skipped",
            "L5 source graph has no decl-dependency edges — run a semantic source "
            "mode with clang++ available (`--source-method s5` / `--mode pr-deep`) "
            "or fold a call graph (`collect --call-graph`, Kythe/CodeQL); the "
            "structural-only `--depth graph` (s4) mode emits no call edges",
            providers,
        )

    node_by_id = {n.id: n for n in graph.nodes}
    # A decl is definitively public when it maps to an exported binary symbol.
    exported_decls = {
        e.src for e in graph.edges if e.kind == "SOURCE_DECL_MAPS_TO_SYMBOL"
    }
    # Reverse the SOURCE_DECLARES edge (header → decl/type) to learn each
    # entity's declaring file, so changed-path elevation can fire.
    decl_to_file: dict[str, str] = {}
    for e in graph.edges:
        if e.kind != "SOURCE_DECLARES":
            continue
        header = node_by_id.get(e.src)
        if header is not None and header.label:
            decl_to_file.setdefault(e.dst, header.label)

    def _visibility(node_id: str) -> str:
        node = node_by_id.get(node_id)
        if node is None or node.kind not in _DECL_NODE_KINDS:
            return ""
        return str(node.attrs.get("visibility", ""))

    def _is_public(node_id: str) -> bool:
        if node_id in exported_decls:
            return True
        return _visibility(node_id) in _PUBLIC_VISIBILITIES

    def _is_internal(node_id: str) -> bool:
        node = node_by_id.get(node_id)
        if node is None or node.kind not in _DECL_NODE_KINDS:
            return False
        if node_id in exported_decls:
            return False
        vis = str(node.attrs.get("visibility", ""))
        if vis in _INTERNAL_VISIBILITIES:
            return True
        # An unannotated (call-graph-only) decl is internal only with explicit
        # *project provenance*: either a SOURCE_DECLARES edge from one of the
        # project's own files (``decl_to_file``), or the call-graph extractor's
        # ``defined_in_project`` marker (the decl's body lives in a project
        # compile-unit source — sound source-location provenance, not mere
        # caller-presence). This catches a public→impl-helper dependency the
        # built-in call graph produced without L4 SOURCE_DECLARES evidence, while
        # a third-party header-inline or extern/system call target (no project
        # source-file body) still has neither marker and is not flagged (Codex
        # review).
        if vis in _UNANNOTATED_VISIBILITIES:
            has_provenance = node_id in decl_to_file or bool(
                node.attrs.get("defined_in_project")
            )
            if not has_provenance:
                return False
            return not _looks_system(node.label or "")
        return False

    def _label(node_id: str) -> str:
        node = node_by_id.get(node_id)
        return node.label if node and node.label else node_id

    findings: list[Change] = []
    seen: set[tuple[str, str]] = set()
    for e in graph.edges:
        if e.kind not in _DEPENDENCY_EDGE_KINDS:
            continue
        if not _is_public(e.src) or not _is_internal(e.dst):
            continue
        pub, internal = _label(e.src), _label(e.dst)
        key = (pub, internal)
        if key in seen:
            continue
        seen.add(key)
        # Declaring file from SOURCE_DECLARES (L4), else the call-graph node's
        # source-location ``def_file`` — so changed-file elevation also works for
        # a call-graph-only internal helper (Codex review).
        changed_file = decl_to_file.get(e.dst, "")
        if not changed_file:
            dst_node = node_by_id.get(e.dst)
            if dst_node is not None:
                changed_file = str(dst_node.attrs.get("def_file", ""))
        is_changed = _path_matches(changed_file, cfg.changed_paths)
        note = (
            f" — {internal!r} is declared in changed file {changed_file!r}, so the "
            "API's behavior may have shifted this revision"
            if is_changed
            else ""
        )
        findings.append(
            _change(
                ChangeKind.PUBLIC_TO_INTERNAL_DEPENDENCY,
                pub,
                f"Public API {pub!r} depends on internal entity {internal!r} "
                "(declared in a private header / source file, not the public "
                f"surface){note}. Consumers cannot see it, so a change to it is an "
                "undeclared behavioral risk. Make the dependency public or sever it.",
                new_value=internal,
                confidence=Confidence.HIGH if is_changed else Confidence.MEDIUM,
                # Only stamp source_location when the internal entity was actually
                # changed this revision — otherwise the finding is about the public
                # API (``symbol``), and pointing the location at the unchanged
                # private file would mislead SARIF/suppression matching (review).
                source_location=changed_file if is_changed else None,
            )
        )
    findings.sort(key=lambda c: (c.symbol, c.new_value or ""))
    detail = (
        f"L5 reachability: {len(findings)} public declaration(s) depending on an "
        "internal entity"
    )
    if cfg.changed_paths:
        detail += f" ({len(cfg.changed_paths)} changed path(s) considered)"
    return _CheckOutput(findings, "present", detail, providers)


# ---------------------------------------------------------------------------
# unversioned_exported_symbol — export with no version though a scheme exists.
# ---------------------------------------------------------------------------


def _check_unversioned_exported_symbol(
    snapshot: AbiSnapshot, cfg: CrosscheckConfig
) -> _CheckOutput:
    """Exported symbols with no version node though the library is versioned, RISK.

    When the ELF carries a symbol-versioning scheme (``.gnu.version_d`` defines at
    least one real version node — ``snapshot.elf.versions_defined``, which already
    excludes the ``VER_FLG_BASE`` soname entry), an exported symbol that carries no
    version is a hygiene smell: it cannot be evolved compatibly later, since
    consumers bind the bare name with no version guarantee. ELF-only — PE/Mach-O
    have no GNU symbol versioning — so it skips cleanly on every other format and
    on an unversioned ELF.
    """
    providers = [PROVIDER_BINARY_EXPORTS]
    elf = snapshot.elf
    if elf is None:
        return _CheckOutput(
            [],
            "skipped",
            "symbol versioning is ELF-only; no ELF symbol table",
            providers,
        )
    if not elf.versions_defined:
        return _CheckOutput(
            [], "present", "library defines no symbol-versioning scheme", providers
        )

    from ..elf_symbol_filter import is_abi_relevant_elf_symbol

    findings: list[Change] = []
    for sym in elf.symbols:
        if not sym.name or not sym.is_default or sym.version:
            continue
        if sym.visibility not in ("default", "protected"):
            continue
        # Reuse the shared ELF ABI-relevance filter so linker/runtime artifacts
        # (`_edata`/`_end`/`_init`, GCC internals, weak transitive stdlib, private
        # `__` C symbols) don't produce noisy hygiene failures (Codex review), and
        # skip C++ structor artifacts the same way the public-export check does.
        if not is_abi_relevant_elf_symbol(sym.name):
            continue
        if _is_cxx_implementation_symbol(sym.name):
            continue
        findings.append(
            _change(
                ChangeKind.UNVERSIONED_EXPORTED_SYMBOL,
                sym.name,
                f"Symbol {sym.name!r} is exported with no version node even though the "
                f"library defines a versioning scheme ({len(elf.versions_defined)} "
                "version(s)). Add it to the version script so it can be evolved "
                "compatibly — or hide it if it is not public API.",
                new_value=sym.name,
                confidence=Confidence.MEDIUM,
            )
        )
    findings.sort(key=lambda c: c.symbol)
    detail = (
        f"binary exports ↔ version table: {len(findings)} exported symbol(s) with no "
        f"version under a {len(elf.versions_defined)}-node scheme"
    )
    return _CheckOutput(findings, "present", detail, providers)


# ---------------------------------------------------------------------------
# rtti_for_internal_type — typeinfo/vtable exported for a private-header type.
# ---------------------------------------------------------------------------

#: Itanium RTTI symbol prefixes: typeinfo (``_ZTI``), vtable (``_ZTV``), VTT
#: (``_ZTT``), typeinfo-name (``_ZTS``). Each is followed by the encoded type.
_RTTI_PREFIXES = ("_ZTI", "_ZTV", "_ZTT", "_ZTS")


def _typeinfo_type_tokens(symbol: str) -> tuple[str, ...]:
    """Matchable type tokens an Itanium RTTI symbol names, most-specific first.

    Pure (no demangler subprocess): strips the RTTI prefix and an optional nested
    ``N…E`` wrapper, then parses the length-prefixed ``<len><name>`` segments.
    Returns the **fully-qualified** spelling first, then the bare leaf, so a
    private ``detail::Internal`` is matched on its qualified name even when a
    public ``api::Internal`` shares the leaf token (``_private_type_names``
    suppresses the bare alias in that collision — Codex review):
    ``_ZTI6Widget`` → ``("Widget",)``; ``_ZTIN6detail8InternalE`` →
    ``("detail::Internal", "Internal")``. Empty for forms it cannot parse.
    """
    rest = ""
    for p in _RTTI_PREFIXES:
        if symbol.startswith(p):
            rest = symbol[len(p) :]
            break
    else:
        return ()
    if rest.startswith("N"):
        rest = rest[1:]
    segments: list[str] = []
    i = 0
    while i < len(rest) and rest[i].isdigit():
        j = i
        while j < len(rest) and rest[j].isdigit():
            j += 1
        n = int(rest[i:j])
        if j + n > len(rest):
            break
        segments.append(rest[j : j + n])
        i = j + n
    if not segments:
        return ()
    qualified = "::".join(segments)
    leaf = segments[-1]
    return (qualified,) if qualified == leaf else (qualified, leaf)


def _check_rtti_for_internal_type(
    snapshot: AbiSnapshot, cfg: CrosscheckConfig
) -> _CheckOutput:
    """Exported RTTI (typeinfo/vtable) for a private-header type, RISK.

    A polymorphic type declared only in a private / non-installed header should
    not leak its run-time type information onto the ABI surface: consumers cannot
    name the type, yet its ``_ZTI``/``_ZTV`` is exported, bloating the export set
    and risking cross-module RTTI/``dynamic_cast`` coupling to an internal class.
    Reuses :func:`_private_type_names` (private/non-public-generated origin,
    public-collision-safe) so it only fires for a genuinely internal type, and
    needs provenance + an export table — skips cleanly otherwise.
    """
    providers = [PROVIDER_BINARY_EXPORTS, PROVIDER_PUBLIC_HEADER_AST]
    if not _origin_resolvable(snapshot):
        return _CheckOutput([], "skipped", _NO_PROVENANCE, providers)
    exported = _exported_symbol_names(snapshot)
    if exported is None:
        return _CheckOutput(
            [], "skipped", "no binary export table on the snapshot", providers
        )
    private_types = _private_type_names(snapshot)
    if not private_types:
        return _CheckOutput(
            [], "present", "no private-header types declared in the snapshot", providers
        )

    findings: list[Change] = []
    seen: set[tuple[str, str]] = set()
    for sym in sorted(exported):
        if not sym.startswith(_RTTI_PREFIXES):
            continue
        # Try the fully-qualified spelling first, then the bare leaf, so a private
        # ``detail::Internal`` matches even when a public ``api::Internal`` shares
        # the leaf (Codex review).
        canonical = next(
            (
                c
                for tok in _typeinfo_type_tokens(sym)
                if (c := private_types.get(tok)) is not None
            ),
            None,
        )
        if canonical is None:
            continue
        key = (sym, canonical)
        if key in seen:
            continue
        seen.add(key)
        findings.append(
            _change(
                ChangeKind.RTTI_FOR_INTERNAL_TYPE,
                sym,
                f"Symbol {sym!r} exports run-time type information for type "
                f"{canonical!r}, which is declared only in a private (non-installed) "
                "header. Its typeinfo leaks onto the ABI surface though consumers "
                "cannot name the type — hide the type or stop exporting its RTTI.",
                new_value=canonical,
                confidence=Confidence.MEDIUM,
                caused_by_type=canonical,
            )
        )
    n_private = len(set(private_types.values()))
    detail = (
        f"typeinfo exports ↔ private-header provenance: {len(findings)} RTTI symbol(s) "
        f"for one of {n_private} private type(s)"
    )
    return _CheckOutput(findings, "present", detail, providers)


# ---------------------------------------------------------------------------
# shared helpers
# ---------------------------------------------------------------------------

_NO_PROVENANCE = (
    "no public-header provenance (supply --public-header/--public-header-dir so "
    "declarations are classified)"
)


def _origin_resolvable(snapshot: AbiSnapshot) -> bool:
    """Whether provenance classification ran (any non-UNKNOWN origin present).

    ``ScopeOrigin`` is only populated when a public-header set was supplied
    (ADR-024 D1); without one every declaration is ``UNKNOWN`` and the
    origin-based checks must skip rather than emit noise.
    """
    if not snapshot.from_headers:
        return False
    for fn in snapshot.functions:
        if fn.origin != ScopeOrigin.UNKNOWN:
            return True
    for var in snapshot.variables:
        if var.origin != ScopeOrigin.UNKNOWN:
            return True
    for rec in snapshot.types:
        if rec.origin != ScopeOrigin.UNKNOWN:
            return True
    return False


#: Mangling sigils: Itanium C++ (``_Z…``) and MSVC (``?…``). A non-extern-C
#: declaration whose ``mangled`` lacks one of these is a castxml fallback to the
#: display name (notably for constructors/destructors), not a comparable symbol.
_MANGLE_SIGILS = ("_Z", "?")

#: Itanium constructor (``C1``/``C2``/``C3``) and destructor (``D0``/``D1``/``D2``)
#: encodings — used to skip structor exports castxml cannot reliably mangle.
_STRUCTOR_RE = re.compile(r"_ZN.*?[CD][0-4]E")

#: Compiler-generated C++ ABI artifacts that belong to a class, not to a
#: free function/variable: vtables/typeinfo/VTT/thunks (Itanium ``_ZTV``/``_ZTI``/
#: ``_ZTS``/``_ZTT``/``_ZTh``/``_ZTv``/``_ZTc``) and MSVC ``??_`` vftable/vbtable/
#: RTTI/deleting-dtor names. castxml records the owning class as a ``RecordType``
#: (not a ``Function``/``Variable``), so these would never be in the documented
#: symbol set and must be exempted from ``exported_not_public`` (Codex review).
_CXX_ARTIFACT_PREFIXES = (
    "_ZTV",
    "_ZTI",
    "_ZTS",
    "_ZTT",
    "_ZTh",
    "_ZTv",
    "_ZTc",
    "??_",
)


def _bare_name_exports(decl: Function | Variable) -> bool:
    """Whether *decl* legitimately exports under its bare (un-mangled) name.

    Data (variables) and ``extern "C"`` functions do; a C++ function must carry a
    real mangled symbol.
    """
    return isinstance(decl, Variable) or getattr(decl, "is_extern_c", False)


def _looks_mangled(decl: Function | Variable) -> bool:
    """Whether *decl* carries a real export symbol (not a castxml display-name fallback).

    castxml leaves some C++ members (notably ctors/dtors) unmangled and falls
    back to the bare display name, so ``mangled`` then equals a source spelling
    rather than ``_ZN…``/``?…``. Bare-name exporters (data, ``extern "C"``) are
    fine with any non-empty name (Codex review).
    """
    if _bare_name_exports(decl):
        return bool(decl.mangled)
    return decl.mangled.startswith(_MANGLE_SIGILS)


def _is_cxx_implementation_symbol(symbol: str) -> bool:
    """Whether *symbol* is a compiler-generated C++ class artifact, not free surface.

    Covers constructors/destructors (Itanium ``_ZN…C1Ev``/``…D1Ev``, MSVC
    ``??0…``/``??1…``) — which castxml leaves unmangled on the header side — and
    vtable/typeinfo/thunk artifacts (``_CXX_ARTIFACT_PREFIXES``), which belong to
    a ``RecordType`` rather than a function/variable. Both classes of symbol would
    otherwise false-positive in ``exported_not_public`` (Codex review).
    """
    if symbol.startswith(_CXX_ARTIFACT_PREFIXES):
        return True
    if symbol.startswith("_ZN") and _STRUCTOR_RE.match(symbol):
        return True
    return symbol.startswith(("??0", "??1"))


def _candidate_symbols(decl: Function | Variable) -> tuple[str, ...]:
    """Export symbols *decl* could provide, for matching against the export table.

    Keyed on whether ``mangled`` is a *real* mangling (``_Z…`` / ``?…``): a C++
    function or namespace/global variable exports under its mangled name only, so
    its bare source spelling must **not** be added (an unrelated accidental export
    sharing that spelling would otherwise look documented — Codex review). An
    un-mangled decl (C / ``extern "C"`` / C data, where the extractor left the
    bare name) exports under that bare name.
    """
    if decl.mangled.startswith(_MANGLE_SIGILS):
        return (decl.mangled,)
    return tuple({s for s in (decl.mangled, decl.name) if s})


def _exported_symbol_names(snapshot: AbiSnapshot) -> set[str] | None:
    """The binary's exported symbol names, or ``None`` if no export table exists.

    Only **default/unversioned** ELF exports count toward the obligation set: a
    symbol that exists *only* as a non-default version alias (``foo@LIB_1``,
    ``is_default == False``) does not satisfy an unversioned consumer link
    (which needs ``foo@@…``), so including it would mask the exact
    missing-export case this set feeds (Codex review).

    Mach-O names are normalized the same way the dumper normalizes
    ``Function.mangled`` (strip the platform's single leading underscore:
    ``_foo`` → ``foo``, ``__Z...`` → ``_Z...``) so the comparison set matches the
    header-side mangled spelling instead of flagging every C/C++ symbol as
    missing (Codex review).
    """
    if snapshot.elf is not None:
        return {s.name for s in snapshot.elf.symbols if s.name and s.is_default}
    if snapshot.pe is not None:
        return {e.name for e in snapshot.pe.exports if e.name}
    if snapshot.macho is not None:
        return {
            e.name[1:] if e.name.startswith("_") else e.name
            for e in snapshot.macho.exports
            if e.name
        }
    return None


def _has_export_obligation(fn: Function) -> bool:
    """Whether *fn* promises a dynamic symbol (so absence from exports is a risk).

    Conservative on purpose (ADR-035 D4): exclude everything that legitimately
    emits no exported symbol — inline, pure-virtual, deleted, static, non-public
    access, mangle-less, and template-shaped declarations.

    Deliberately **not** gated on ``visibility``: castxml derives
    ``Visibility.PUBLIC`` from the export table, so the very decl this check looks
    for (declared public, *not* exported) is ``HIDDEN``/``ELF_ONLY`` here —
    gating on PUBLIC would skip every real case (Codex review). The obligation
    rests on the public-header source declaration instead.
    """
    if fn.access != AccessLevel.PUBLIC:
        return False
    if fn.origin != ScopeOrigin.PUBLIC_HEADER:
        return False
    # ``static`` free functions have internal linkage and emit no dynamic
    # symbol, so a static header helper must not be read as a missing export
    # (Codex review).
    if fn.is_static:
        return False
    if fn.is_inline or fn.is_pure_virtual or fn.is_deleted:
        return False
    if not fn.mangled:
        return False
    # A C++ member whose ``mangled`` is just the display name is a castxml
    # fallback (notably ctors/dtors); comparing that bare name against the
    # binary's real ``_ZN…`` symbols would false-positive (Codex review).
    if not _looks_mangled(fn):
        return False
    # Template instantiations are spelled with angle brackets; an uninstantiated
    # template emits no symbol, so skip anything template-shaped to stay low-FP.
    if _looks_templated(fn.name):
        return False
    return True


def _var_has_export_obligation(var: Variable) -> bool:
    """Whether *var* is genuine extern data that must export a symbol.

    ``const``/``constexpr`` header constants are excluded: at namespace scope a
    ``const`` variable has internal linkage in C++ (and a ``constexpr`` is
    ``const``), so it is inlined into consumers and emits no exported symbol. The
    exclusion keys off ``is_const`` alone — castxml stores the initializer in
    ``snapshot.constants`` rather than ``Variable.value``, so ``value`` is often
    ``None`` for a real parsed constant and must not be required (Codex review).
    Not gated on ``visibility`` — same export-table-derived-visibility reason as
    :func:`_has_export_obligation`.
    """
    if var.access != AccessLevel.PUBLIC:
        return False
    if var.origin != ScopeOrigin.PUBLIC_HEADER:
        return False
    if not var.mangled:
        return False
    if not _looks_mangled(var):
        return False
    if var.is_const:
        return False
    if _looks_templated(var.name):
        return False
    return True


def _looks_templated(name: str) -> bool:
    """Whether *name* is a template instantiation spelling (``Foo<int>``), not an operator.

    A bare ``<`` is not enough: ``operator<``, ``operator<<``, and ``operator<=>``
    legitimately contain one but are ordinary (non-template) functions with a real
    exported symbol, so testing ``"<" in name`` would wrongly skip a genuinely
    missing exported operator (Codex review). A template's ``<`` opens an argument
    list immediately after the template name, so the token right before the first
    ``<`` is the template's name — never ``operator``.
    """
    idx = name.find("<")
    if idx == -1:
        return False
    return not name[:idx].rstrip().endswith("operator")


def _abi_relevant_build_flags(snapshot: AbiSnapshot) -> list[str] | None:
    """ABI-relevant build-option keys, or ``None`` when there is no L3 evidence."""
    pack = snapshot.build_source
    if pack is None or pack.build_evidence is None:
        return None
    return sorted(
        opt.key for opt in pack.build_evidence.build_options if opt.abi_relevant
    )


#: Pointer/reference/array/cv decorators stripped to reach a base type spelling.
_DECORATOR_RE = re.compile(r"[*&\[\]]")
_BUILTIN_WORDS = frozenset(
    {
        "const",
        "volatile",
        "struct",
        "class",
        "union",
        "enum",
        "unsigned",
        "signed",
        "void",
        "bool",
        "char",
        "wchar_t",
        "char8_t",
        "char16_t",
        "char32_t",
        "short",
        "int",
        "long",
        "float",
        "double",
        "auto",
    }
)


def _function_type_refs(fn: Function) -> set[str]:
    """All type strings named in *fn*'s signature (return + parameters)."""
    refs: set[str] = set()
    if fn.return_type:
        refs.add(fn.return_type)
    for p in fn.params:
        if p.type:
            refs.add(p.type)
    return refs


def _base_type_tokens(type_str: str) -> set[str]:
    """Reduce a type spelling to the identifier tokens it could be naming.

    Yields both the full canonical spelling (``ns::Widget``) and its trailing
    segment (``Widget``) so a private record named either way is matched, while
    builtin keywords and template-argument punctuation are dropped.
    """
    cleaned = _DECORATOR_RE.sub(" ", type_str)
    cleaned = cleaned.replace("<", " ").replace(">", " ").replace(",", " ")
    tokens: set[str] = set()
    for raw in cleaned.split():
        tok = raw.strip()
        if not tok or tok in _BUILTIN_WORDS:
            continue
        tokens.add(tok)
        if "::" in tok:
            tokens.add(tok.rsplit("::", 1)[1])
    return tokens


def _referenced_private_types(
    type_refs: set[str], private_types: dict[str, str]
) -> list[str]:
    """Canonical private type names referenced by any of *type_refs*, deduped.

    *private_types* maps every matchable token (canonical spelling *and* its
    trailing segment) to the one canonical name, so a reference to
    ``ns::detail::Impl`` and a bare ``Impl`` both resolve to a single finding.
    """
    hit: set[str] = set()
    for ref in type_refs:
        for tok in _base_type_tokens(ref):
            canonical = private_types.get(tok)
            if canonical is not None:
                hit.add(canonical)
    return sorted(hit)


#: Origins that put a type *on* the public surface, so a same-named definition
#: elsewhere is not a leak. Only ``PUBLIC_HEADER`` qualifies — provenance maps a
#: public generated header to ``PUBLIC_HEADER`` first, so a bare ``GENERATED``
#: origin is a non-public generated header (Codex review).
_PUBLIC_TYPE_ORIGINS = frozenset({ScopeOrigin.PUBLIC_HEADER})

#: Origins whose type, if exposed by a public API, is a private-header leak: a
#: project-private header or a *non-public* generated header (e.g.
#: ``build/generated/internal_config.h``). System headers are excluded (they are
#: third-party, not the library's own un-shipped surface).
_PRIVATE_TYPE_ORIGINS = frozenset({ScopeOrigin.PRIVATE_HEADER, ScopeOrigin.GENERATED})


def _private_type_names(snapshot: AbiSnapshot) -> dict[str, str]:
    """Map matchable token → canonical name for records/enums only in private headers.

    Each private type contributes its canonical name and (when namespaced) its
    trailing segment, both pointing at the canonical name so a match on either
    spelling collapses to one finding. "Private" here is a project-private or
    non-public generated header (``_PRIVATE_TYPE_ORIGINS``).

    A type that *also* has a public-header declaration is excluded — the common
    opaque-handle/PIMPL pattern forward-declares ``class Impl;`` in a public
    header and defines it privately, so a public API taking ``Impl *`` is not a
    leak (Codex review).

    Public-type *tokens* (canonical names **and** their trailing segments) are
    collected first, and any private canonical name or trailing-segment alias that
    collides with one is skipped. That stops a public ``Impl`` and a private
    ``detail::Impl`` from registering the bare token ``Impl`` as a leak candidate,
    which would mis-flag a public ``Impl *`` signature (Codex review).
    """
    # (name, origin) for every record and enum, so both type kinds are scanned
    # with one pass and no `object`-typed merged iteration.
    type_decls = [(rec.name, rec.origin) for rec in snapshot.types] + [
        (en.name, en.origin) for en in snapshot.enums
    ]

    public_tokens: set[str] = set()

    def _add_public(name: str) -> None:
        if not name:
            return
        public_tokens.add(name)
        if "::" in name:
            public_tokens.add(name.rsplit("::", 1)[1])
        # Template instantiations: also reserve the base spelling (``ns::Box`` and
        # ``Box`` for ``ns::Box<int>``) so a private ``detail::Box<…>`` cannot
        # register a bare alias that collides with a public template's base
        # (Codex review — template RTTI matching).
        if "<" in name:
            _add_public(name.split("<", 1)[0])

    for name, origin in type_decls:
        if origin in _PUBLIC_TYPE_ORIGINS:
            _add_public(name)

    names: dict[str, str] = {}

    def _register(name: str) -> None:
        # A type that *also* has a public-header declaration (PIMPL forward-decl)
        # is on the public surface — register nothing (Codex review).
        if name in public_tokens:
            return
        canonical = name

        def _alias(token: str) -> None:
            if token and token not in public_tokens:
                names.setdefault(token, canonical)

        names[name] = canonical
        if "::" in name:
            _alias(name.rsplit("::", 1)[1])
        # Template instantiation (``detail::Box<int>``): also match its base
        # spelling, because an Itanium RTTI symbol (``_ZTIN6detail3BoxIiEE``) and
        # a signature token reduce to ``detail::Box``/``Box`` without the template
        # arguments — so the base must resolve back to the instantiation to catch
        # exported RTTI / leaked references to internal template types (Codex
        # review). Public-collision-guarded via ``public_tokens`` above.
        if "<" in name:
            base = name.split("<", 1)[0]
            _alias(base)
            if "::" in base:
                _alias(base.rsplit("::", 1)[1])

    for name, origin in type_decls:
        if origin in _PRIVATE_TYPE_ORIGINS and name:
            _register(name)
    return names


def _change(
    kind: ChangeKind,
    symbol: str,
    description: str,
    *,
    old_value: str | None = None,
    new_value: str | None = None,
    source_location: str | None = None,
    confidence: Confidence = Confidence.MEDIUM,
    caused_by_type: str | None = None,
    evidence_category: str = "source_only",
) -> Change:
    """Build a cross-check :class:`Change` with the shared metadata defaults."""
    return Change(
        kind=kind,
        symbol=symbol,
        description=description,
        old_value=old_value,
        new_value=new_value,
        source_location=source_location,
        confidence=confidence,
        caused_by_type=caused_by_type,
        evidence_category=evidence_category,
    )


def _coverage_row(
    check: str,
    status: str,
    detail: str,
    *,
    facts: int = 0,
    counters: dict[str, int] | None = None,
) -> dict[str, Any]:
    """One serialized coverage row for a check (ADR-035 D4 coverage honesty).

    ``facts``/``counters`` are optional D4 boundary-integrity numbers, added only
    when non-empty so existing rows stay byte-for-byte unchanged.
    """
    row: dict[str, Any] = {
        "layer": f"crosscheck:{check}",
        "status": status,
        "detail": detail,
    }
    if facts:
        row["facts"] = facts
    if counters:
        row["counters"] = dict(counters)
    return row
