Skip to content

Header-Backend Capabilities (CastXML vs. direct-clang vs. hybrid)

abicheck's L2 (header) evidence layer has two interchangeable parsers behind one backend selector, plus a merge of the two.

There is no --ast-frontend flag. The backend selector, and the whole compile-context family this page's exception box discusses, are .abicheck.yml keys only — compile.frontend, compile.ast_frontend_fallback, compile.allow_unsupported_castxml. No CLI override survives on any command, and there is no per-side spelling. The resolution semantics below (auto/fallback/device-context rejection) are unchanged; only the spelling moved. The column headings below keep the historical flag spelling as the name of each backend; read compile.frontend: castxml wherever a table says --ast-frontend castxml. See Dump & Compare Flags and the Config File Reference.

--ast-frontend What runs When to pick it
castxml (default) CastXML's XML dump, produced by its own bundled real compiler The default. It is the only backend that computes record layout on its own.
clang clang -ast-dump=json, parsed by dumper_clang.py A host with clang but no CastXML, or headers CastXML chokes on.
hybrid Both, merged castxml-first with per-fact provenance Recovering facts each backend alone gets wrong — notably CastXML's synthesized constructor/destructor keys.
auto Resolves to castxml (or the ABICHECK_AST_FRONTEND pin) and never silently switches producer; it falls back to clang for a recognized toolchain-version mismatch, an unsupported CastXML release, or a direct-include-guard failure only when you opt in with --allow-ast-frontend-fallback (or ABICHECK_ALLOW_AST_FALLBACK=1) The usual choice when you don't care, as long as CastXML is installed — with no opt-in and no CastXML at all, it fails asking you to install one or pick clang explicitly.

Exception: --frontend-context device. CastXML has no SYCL/DPC++ host/device context concept, and hybrid (castxml+clang merge) has none either, so a non-host --frontend-context under auto resolving to castxml — whether that's --ast-frontend auto spelled out, or no --ast-frontend flag at all, with no ABICHECK_AST_FRONTEND pin — skips CastXML and routes straight to clang, no --allow-ast-frontend-fallback opt-in needed, since this isn't the toolchain-error fallback above, it's auto recognizing CastXML can't satisfy the request at all. Any way of pinning the resolved backend away from plain castxml-or-clang is rejected outright instead: an explicit --ast-frontend castxml, auto pinned to castxml via ABICHECK_AST_FRONTEND=castxml (an env pin is honored the same as an explicit flag, not silently overridden by the device request), and hybrid — explicit or auto pinned via ABICHECK_AST_FRONTEND=hybrid — all reject a non-host context. Explicit --ast-frontend clang satisfies it directly, and so does auto pinned to clang via ABICHECK_AST_FRONTEND=clang — resolving to clang either way skips the castxml/hybrid rejection paths entirely, the same as picking clang outright. On compare, the per-side --ast-frontend old=/ --ast-frontend new= overrides count the same way for their own side only: --ast-frontend old=castxml pins the old side exactly like a shared --ast-frontend castxml would, rejecting a device context on that side even while the new side (left at auto, unpinned) routes to clang normally. See ADR-050 D5.

The two parsers produce the same AbiSnapshot fields, but not the same values for every field — and a comparison that mixes producers is a real, supported scenario (a persisted baseline dumped on one host, compared on another). This page is the per-fact contract for that.

Comparability, not just curiosity. Where the two backends disagree about whether a fact is knowable, a naive comparison reports the difference as an ABI change. Every such fact is either populated on both sides, gated by a per-declaration producer check (fact_provenance.py), or gated by a snapshot-level reliability flag — see Snapshot Format's schema-version history for which mechanism guards which fact.

Fact matrix

The tables below are generated from scripts/backend_capabilities.py, which also carries the reasoning for each row. tests/test_backend_capability_matrix.py re-derives what each parser actually populates — by reading dumper_castxml.py/dumper_clang.py with ast — and fails if the published claim and the code disagree, so this page cannot silently go stale the way a hand-typed table would.

Across the 149 fields of the 6 declaration types the two header-AST backends build, 124 are facts a header parse can carry at all. Of those, 19 differ between the two backends: 8 only CastXML's own parse populates, 6 only direct-clang's does, and the rest a difference of precision rather than presence. (A 🔧 row counts for neither — the plain parse computes it on neither backend.)

Cell Meaning
✅ Yes The backend's own parse populates this fact.
⚠️ Partial Populated, but narrower or less precise than the sibling backend's value — the note says how.
🔧 Companion tool Populated only when the optional LibTooling layout companion (ABICHECK_CLANG_LAYOUT_TOOL, G28 Phase 4) is configured; the plain clang -ast-dump=json parse computes no record layout.
❌ No Never populated by this backend; the field keeps its model default.
— n/a No header-AST backend populates it — another layer owns the fact (DWARF, the ELF symbol table, the provenance pass), or nothing does.

Function

Fact CastXML direct-clang hybrid Notes
name ✅ Yes ✅ Yes ✅ Yes
mangled ✅ Yes ✅ Yes ✅ Yes castxml cannot always recover a real mangled name for a constructor/destructor and synthesizes a placeholder key; a hybrid merge reconciles those against clang's real Itanium name (dumper_hybrid._match_synthetic_ctor_dtor).
return_type ✅ Yes ✅ Yes ✅ Yes
params ✅ Yes ✅ Yes ✅ Yes
visibility ✅ Yes ✅ Yes ✅ Yes
is_virtual ✅ Yes ✅ Yes ✅ Yes
is_noexcept ✅ Yes ✅ Yes ✅ Yes
is_extern_c ✅ Yes ✅ Yes ✅ Yes
vtable_index ✅ Yes ❌ No ✅ Yes castxml numbers each virtual's slot; the clang backend passes None even though it reconstructs the vtable itself (dumper_clang_vtable.py), so slot-order findings need castxml.
source_location ✅ Yes ✅ Yes ✅ Yes
is_static ✅ Yes ✅ Yes ✅ Yes
is_const ✅ Yes ✅ Yes ✅ Yes
is_volatile ✅ Yes ✅ Yes ✅ Yes
is_pure_virtual ✅ Yes ✅ Yes ✅ Yes
is_deleted ✅ Yes ✅ Yes ✅ Yes
deleted_from_dwarf — n/a — n/a — n/a Set from DWARF's DW_AT_deleted (dwarf_snapshot.py).
is_inline ✅ Yes ✅ Yes ✅ Yes Inline linkage, explicit or implicit. castxml's frontend resolves implicit inline before emitting; clang's JSON emits its inline key only for the written keyword, so the clang backend reconstructs the implicit forms (extract/headers/clang/inline_semantics.py). This row read _FULL/_FULL while the two actually disagreed on every constexpr/in-class declaration.
access ✅ Yes ✅ Yes ✅ Yes
return_pointer_depth ✅ Yes ✅ Yes ✅ Yes
elf_visibility — n/a — n/a — n/a Read from the binary's own symbol table (dumper_elf_symbols.py).
ref_qualifier ✅ Yes ✅ Yes ✅ Yes
is_explicit ✅ Yes ✅ Yes ✅ Yes
is_explicit_fact ✅ Yes ✅ Yes ✅ Yes ADR-063 Phase 5: Fact[bool | None] sibling of is_explicit. Both backends now construct it directly as an explicit kwarg -- Fact.present(is_explicit) for a Constructor/Method/Converter (castxml) or CXXConstructorDecl/CXXConversionDecl (clang), Fact.not_applicable() otherwise -- since a kind where explicit is conceptually inapplicable is a confirmed non-gap, not missing evidence the generic bridge should read as NOT_COLLECTED (Codex review, PR #982).
is_hidden_friend ✅ Yes ✅ Yes ✅ Yes
is_hidden_friend_fact ❌ No ❌ No ❌ No Same shape as is_explicit_fact -- see that row's own note.
source_header — n/a — n/a — n/a Set after parsing by provenance.apply_provenance() from the public-header set (-H/--header, plus .abicheck.yml's scope.public_header_dirs), not by either backend.
source_header_fact — n/a — n/a — n/a ADR-063 Phase 5 (fifth batch): Fact[str | None] sibling of source_header, mirroring RecordType/EnumType/Variable.source_header_fact exactly -- another layer (provenance.tag_provenance()) owns it.
origin — n/a — n/a — n/a Set after parsing by provenance.apply_provenance() from the public-header set (-H/--header, plus .abicheck.yml's scope.public_header_dirs), not by either backend.
is_variadic ✅ Yes ✅ Yes ✅ Yes
is_variadic_fact ❌ No ❌ No ❌ No Same shape as is_explicit_fact -- see that row's own note.
contract_attributes ✅ Yes ✅ Yes ✅ Yes
contract_attributes_fact ❌ No ❌ No ❌ No Same shape as is_explicit_fact -- see that row's own note.
exception_spec ✅ Yes ✅ Yes ✅ Yes
exception_spec_fact ❌ No ❌ No ❌ No Same shape as is_explicit_fact -- see that row's own note.
deprecated ✅ Yes ✅ Yes ✅ Yes clang side wired in G31 Phase C (schema v19).
deprecated_fact ❌ No ❌ No ❌ No ADR-063 Phase 5 (ninth batch): Fact[str | None] sibling of deprecated. NONE for both backends because neither names the keyword -- each passes the real value and the dataclass's own post_init bridge derives the Fact, the same honest reading this matrix already records for every other bridge-derived sibling. Availability is carried by AbiSnapshot.clang_deprecation_facts_reliable.
is_override ✅ Yes ✅ Yes ✅ Yes clang side wired in G31 Phase C backend audit, from a real OverrideAttr child node (dumper_clang._clang_method_is_override).
is_override_fact ✅ Yes ✅ Yes ✅ Yes Same shape as is_explicit_fact above: both backends now construct it directly as Fact.present(is_override) for a kind in their own OVERRIDE_ELIGIBLE_KINDS set, Fact.not_applicable() otherwise (Codex review, PR #982).
hidden_friend_owner ✅ Yes ✅ Yes ✅ Yes
hidden_friend_owner_fact ✅ Yes ✅ Yes ✅ Yes Same shape as is_explicit_fact -- both backends now construct it directly as Fact.present(owner) for a real hidden friend, Fact.not_applicable() otherwise (Codex review, PR #982).
elf_binding — n/a — n/a — n/a Read from the binary's own symbol table (dumper_elf_symbols.py).
declared_in_headers_fact — n/a — n/a — n/a The three facts Visibility used to conflate, split (see abicheck/model/surface_facts.py): this one is "a header this run parsed declares this entity". Both header-AST backends assert it (extract/surface_fact_producers.header_ast_surface_facts); a producer with no header evidence leaves it unknown, never False.
in_public_contract_fact — n/a — n/a — n/a "Belongs to the promised public contract for this run's scope selection." Both backends assert it positively from a confirmed export; the stronger, scope-aware evidence is added by provenance.tag_provenance from a real public-header set, which is another layer's contribution, and a non-public origin is never written as a confirmed negative.
binary_exported_fact — n/a — n/a — n/a "The artifact's export table carries a symbol for this entity." Both backends resolve it from the same export-table lookup their visibility already used; unknown -- not False -- for a header-only dump, which has no export table to have been absent from.
elf_binding_fact — n/a — n/a — n/a ADR-063 Phase 5 (fifth batch): Fact[SymbolBinding | None] sibling of elf_binding, mirroring Variable.elf_binding_fact exactly -- another layer (dumper_elf_symbols._populate_elf_visibility) owns it, kept in sync explicitly since it sets elf_binding by attribute assignment, never re-running post_init.
is_compiler_generated ✅ Yes ❌ No ✅ Yes castxml reads its own artificial="1" XML attribute (any function-like element, not just Constructor/Destructor). clang never derives this per-declaration -- it hardcodes False for every Function it emits, which is still a correct answer, not a gap: its own AST walk skips a node entirely whenever it is isImplicit, so a node reaching parse_functions() is structurally guaranteed to have been written by the user.
is_compiler_generated_fact ❌ No ❌ No ❌ No Same shape as is_explicit_fact -- see that row's own note.
entity_id ✅ Yes ✅ Yes ✅ Yes ADR-063 Phase 2: the parse-time model.identity.EntityId carrier. Both backends resolve one from the typed scope path they record during their own walk. Runtime-only -- never serialized, so a reloaded snapshot carries None; a hybrid merge does not backfill it, so a hybrid snapshot keeps castxml's.

Variable

Fact CastXML direct-clang hybrid Notes
name ✅ Yes ✅ Yes ✅ Yes
mangled ✅ Yes ✅ Yes ✅ Yes
type ✅ Yes ✅ Yes ✅ Yes
visibility ✅ Yes ✅ Yes ✅ Yes
source_location ✅ Yes ✅ Yes ✅ Yes
is_const ✅ Yes ✅ Yes ✅ Yes
is_static ✅ Yes ✅ Yes ✅ Yes PR #1024 review: mirrors Function.is_static exactly (same castxml static="1" XML attribute / clang storageClass == "static" AST field) -- closes tu_merge._variable_key's own documented plain-C/extern "C" static-vs-external identity gap.
value ⚠️ Partial ❌ No ⚠️ Partial castxml side wired in G31 Phase C continued (schema v24), restricted to is_const (mirroring _iter_public_constants's own filter a few methods below — an unevaluated, non-const initializer can be an arbitrary runtime expression like init="f()", verified empirically, which is not the "compile-time constant" this field's own docstring promises). No reliability flag needed: diff_types_abicc_parity._diff_var_values already declines per-pair unless BOTH sides are non-None, so a legacy blanket-None side is silently skipped rather than misread. A public constant's value ALSO reaches a snapshot through AbiSnapshot.constants — the two are independent paths, not a duplication of one another.
access ✅ Yes ❌ No ✅ Yes castxml side wired in G31 Phase C continued (schema v24), reusing the same structured access attribute already read for Function/TypeField — verified against real castxml output that a static class member's <Variable> element carries it too. diff_symbols._diff_var_access requires ast_producer == "castxml" specifically (not "hybrid" — see AbiSnapshot.castxml_var_access_facts_reliable's own docstring) and gates on that reliability flag for the pre-v24-legacy-baseline case.
access_fact ❌ No ❌ No ❌ No ADR-063 Phase 5 (tenth batch): Fact[AccessLevel] sibling of access. NONE for both backends because neither names the keyword -- each passes the real value and Variable.post_init's bridge derives the Fact. Availability is carried by AbiSnapshot.castxml_var_access_facts_reliable.
elf_visibility — n/a — n/a — n/a Read from the binary's own symbol table (dumper_elf_symbols.py).
source_header — n/a — n/a — n/a Set after parsing by provenance.apply_provenance() from the public-header set (-H/--header, plus .abicheck.yml's scope.public_header_dirs), not by either backend.
source_header_fact — n/a — n/a — n/a ADR-063 Phase 5 (fourth batch): Fact[str | None] sibling of source_header, mirroring RecordType.source_header_fact/EnumType.source_header_fact exactly -- another layer (provenance.tag_provenance()) owns it.
origin — n/a — n/a — n/a Set after parsing by provenance.apply_provenance() from the public-header set (-H/--header, plus .abicheck.yml's scope.public_header_dirs), not by either backend.
alignment_bits ✅ Yes ⚠️ Partial ✅ Yes castxml reports the compiler-computed alignment for any variable; clang reads only an explicit alignas/__attribute__((aligned)) override, never a type's natural alignment — a tracked gap that leaves exported_object_alignment_reduced without corroboration on a clang-only host (see dumper_clang.py's module docstring).
alignment_bits_fact ❌ No ❌ No ❌ No ADR-063 Phase 5 (fourth batch): Fact[int | None] sibling of alignment_bits. Deliberately NOT constructed as an explicit keyword the way qualified_name_fact is -- neither backend passes it literally at Variable(...) construction; it is correctly derived by the generic bridge_legacy_and_fact bridge in post_init from whatever legacy value the constructor call actually supplies, so NONE here reflects literal-keyword evidence only, not runtime behavior (same shape as RecordType.data_size_bits_fact/is_abstract_fact).
deprecated ✅ Yes ✅ Yes ✅ Yes clang side wired in G31 Phase C (schema v19).
deprecated_fact ❌ No ❌ No ❌ No Same shape as Function.deprecated_fact -- see that row's own note.
elf_binding — n/a — n/a — n/a Read from the binary's own symbol table (dumper_elf_symbols.py).
declared_in_headers_fact — n/a — n/a — n/a The three facts Visibility used to conflate, split (see abicheck/model/surface_facts.py): this one is "a header this run parsed declares this entity". Both header-AST backends assert it (extract/surface_fact_producers.header_ast_surface_facts); a producer with no header evidence leaves it unknown, never False.
in_public_contract_fact — n/a — n/a — n/a "Belongs to the promised public contract for this run's scope selection." Both backends assert it positively from a confirmed export; the stronger, scope-aware evidence is added by provenance.tag_provenance from a real public-header set, which is another layer's contribution, and a non-public origin is never written as a confirmed negative.
binary_exported_fact — n/a — n/a — n/a "The artifact's export table carries a symbol for this entity." Both backends resolve it from the same export-table lookup their visibility already used; unknown -- not False -- for a header-only dump, which has no export table to have been absent from.
elf_binding_fact — n/a — n/a — n/a ADR-063 Phase 5 (fourth batch): Fact[SymbolBinding | None] sibling of elf_binding -- another layer (dumper_elf_symbols._populate_elf_visibility) owns it, kept in sync explicitly since it sets elf_binding by attribute assignment, never re-running post_init.
entity_id ✅ Yes ✅ Yes ✅ Yes ADR-063 Phase 2: the parse-time model.identity.EntityId carrier. Both backends resolve one from the typed scope path they record during their own walk. Runtime-only -- never serialized, so a reloaded snapshot carries None; a hybrid merge does not backfill it, so a hybrid snapshot keeps castxml's.

TypeField

Fact CastXML direct-clang hybrid Notes
name ✅ Yes ✅ Yes ✅ Yes
type ✅ Yes ✅ Yes ✅ Yes
offset_bits ✅ Yes 🔧 Companion tool ✅ Yes clang's JSON AST computes no record layout; the field offset arrives only from the optional layout companion tool, or from DWARF via dumper_layout_backfill.py.
is_bitfield ✅ Yes ✅ Yes ✅ Yes
bitfield_bits ✅ Yes ✅ Yes ✅ Yes
is_const ✅ Yes ✅ Yes ✅ Yes
is_const_fact ❌ No ❌ No ❌ No ADR-063 Phase 5 (eighth batch): Fact[bool] sibling of is_const, and the phase's first case-(a) conversion. NONE for both backends because neither names the keyword: each passes the real is_const value and TypeField.post_init's generic bridge derives Fact.present(...) from it -- the same honest reading this matrix already records for RecordType's own bridge-derived case-(b) siblings. Availability for this field is carried by AbiSnapshot.header_cv_facts_reliable, not by either backend's own construction.
is_volatile ✅ Yes ✅ Yes ✅ Yes
is_volatile_fact ❌ No ❌ No ❌ No Same shape as is_const_fact -- see that row's own note.
is_mutable ✅ Yes ✅ Yes ✅ Yes
is_mutable_fact ❌ No ❌ No ❌ No Same shape as is_const_fact -- see that row's own note. DWARF (outside this matrix's own header-AST scope) states Fact.unsupported() explicitly here: it has no DW_AT for mutable at all, so its blanket False is a producer limitation, not a determined fact.
access ✅ Yes ✅ Yes ✅ Yes
default ✅ Yes ⚠️ Partial ✅ Yes Both populate it (clang side in G31 Phase C, schema v20), but the VALUES are not cross-comparable: castxml keeps the verbatim source expression where clang falls back to a structural fingerprint, so the detector requires the same producer on both sides rather than merely a known one.
default_fact ❌ No ❌ No ❌ No ADR-063 Phase 5 (eighth batch): Fact[str | None] sibling of default. NONE for both backends for the same reason as is_const_fact -- each passes the real value and TypeField.post_init's bridge derives the Fact. Availability is carried by AbiSnapshot.clang_field_initializer_facts_reliable.
deprecated ✅ Yes ✅ Yes ✅ Yes clang side wired in G31 Phase C (schema v19).
deprecated_fact ❌ No ❌ No ❌ No Same shape as default_fact -- see that row's own note; guarded by AbiSnapshot.clang_deprecation_facts_reliable instead.

RecordType

Fact CastXML direct-clang hybrid Notes
name ✅ Yes ✅ Yes ✅ Yes
kind ✅ Yes ✅ Yes ✅ Yes
size_bits ✅ Yes 🔧 Companion tool ✅ Yes castxml runs a real compiler and computes layout; the plain clang AST parse leaves it None so the layout detectors skip an unknown-vs-unknown comparison (DWARF stays the layout authority).
alignment_bits ✅ Yes 🔧 Companion tool ✅ Yes Same layout split as size_bits.
fields ✅ Yes ✅ Yes ✅ Yes
bases ✅ Yes ✅ Yes ✅ Yes
bases_fact ✅ Yes ✅ Yes ✅ Yes ADR-063 Phase 0: Fact[list[str]] sibling of bases. Both backends construct it directly (model.record_layout_facts()) alongside bases itself, opaque records included.
virtual_bases ✅ Yes ✅ Yes ✅ Yes
virtual_bases_fact ✅ Yes ✅ Yes ✅ Yes ADR-063 Phase 0: Fact[list[str]] sibling of virtual_bases — see bases_fact.
vtable ✅ Yes ✅ Yes ✅ Yes clang's is a reconstruction over the AST (dumper_clang_vtable.py, G31 Phase C, schema v21) rather than a compiler-emitted table; castxml's comes from its own bundled compiler. Both are transitively inherited across bases.
vtable_fact ✅ Yes ✅ Yes ✅ Yes ADR-063 Phase 0: Fact[list[str]] sibling of vtable — see bases_fact.
source_location ✅ Yes ✅ Yes ✅ Yes
is_union ✅ Yes ✅ Yes ✅ Yes
is_opaque ✅ Yes ✅ Yes ✅ Yes Closed in PR #719: parse_types previously skipped every non-definition record entirely (so an opaque handle type struct A; alone was absent from a clang snapshot rather than present with is_opaque=True, unlike castxml). Now emits an opaque stub for a forward-declaration-only identity, collapsing to the definition (never opaque) when both a forward decl and a definition share one identity in the same TU — verified against real clang 18 output that both land as separate CXXRecordDecl/RecordDecl nodes. No explicit hybrid backfill needed: castxml sees the identical header text, so its own real is_opaque already answers correctly for a type present on both backends; a clang-only opaque entity reaches a hybrid snapshot correctly via the ordinary clang-only-append path.
is_final ✅ Yes ✅ Yes ✅ Yes
is_final_fact ✅ Yes ✅ Yes ✅ Yes ADR-063 Phase 5: Fact[bool \| None] sibling of is_final. Same convention as bases_fact/vtable_fact above: both backends construct it directly, as an explicit kwarg (Fact.present(is_final)) rather than relying on RecordType.__post_init__'s generic legacy-value bridge, since is_final is always a concrete bool on the header-AST path (never a placeholder), matching is_final's own row.
is_template_pattern ❌ No ✅ Yes ✅ Yes castxml emits template instantiations, never the uninstantiated pattern, so it has nothing to mark. OR-merged by the hybrid merge since PR #719 (a plain bool, not an Optional tri-state -- castxml's own False is never itself the backfill trigger the way a None is elsewhere in this table) -- verified against real castxml 0.6.3 + clang 18 output that this is empirically inert for the current producer pair, since a clang template pattern never shares a type_map_key with any castxml-matched concrete type; a pattern reaches a hybrid snapshot only through the clang-only append path, which already preserves the flag on its own.
has_anonymous_aggregate_fields ❌ No ✅ Yes ✅ Yes clang flattens an anonymous struct/union into its parent and records that it did, which dumper_layout_backfill.py uses when matching DWARF fields. OR-merged by the hybrid merge since PR #719 (same plain-bool OR-merge as is_template_pattern above, not the None-check pattern) -- unlike that field this one is not provably inert: an opaque/incomplete castxml record could legitimately reach the merge with an empty fields list for a genuinely anonymous-aggregate-only record, where clang's True is the only signal available.
source_header — n/a — n/a — n/a Set after parsing by provenance.apply_provenance() from the public-header set (-H/--header, plus .abicheck.yml's scope.public_header_dirs), not by either backend.
source_header_fact — n/a — n/a — n/a ADR-063 Phase 5: Fact[str \| None] sibling of source_header. Same non-header ownership as the legacy field — provenance.tag_provenance() keeps both representations in sync via an explicit post-construction update (mirroring resolve_vptr_offset_bits()'s pattern), since it sets source_header by plain attribute assignment, which never re-runs RecordType.__post_init__'s bridge.
origin — n/a — n/a — n/a Set after parsing by provenance.apply_provenance() from the public-header set (-H/--header, plus .abicheck.yml's scope.public_header_dirs), not by either backend.
data_size_bits ❌ No 🔧 Companion tool 🔧 Companion tool The tail-padding-excluded size. Neither backend's own parse computes it; it arrives from the layout companion tool or DWARF.
data_size_bits_fact ❌ No ❌ No ❌ No ADR-063 Phase 5: Fact[int \| None] sibling of data_size_bits. Unlike bases_fact/vtable_fact/is_final_fact, neither backend passes this keyword literally at RecordType(...) construction — it is correctly derived by the generic bridge_legacy_and_fact bridge in __post_init__ from whatever legacy value the constructor call (or a later replace_with_fact_sync layout backfill — dumper_layout_backfill.py, clang_layout_tool.py) actually supplies, so NONE here reflects literal-keyword evidence, not runtime behavior (see fact_registry.py's own entry for this field's real, correct availability semantics).
is_standard_layout ❌ No ✅ Yes ✅ Yes A semantic trait clang computes independent of any layout pass (definitionData.isStandardLayout, present only when true), and one castxml's schema genuinely does not expose. Wiring it in G31 Phase C activated STANDARD_LAYOUT_LOST, dead code until then.
is_standard_layout_fact ❌ No ❌ No ❌ No ADR-063 Phase 5: Fact[bool \| None] sibling of is_standard_layout. Same shape as data_size_bits_fact above — derived by the generic __post_init__ bridge, not a literal constructor keyword, so NONE reflects scan evidence only.
is_trivially_copyable ❌ No ✅ Yes ✅ Yes Same shape as is_standard_layout; activated TRIVIALLY_COPYABLE_LOST.
is_trivially_copyable_fact ❌ No ❌ No ❌ No Same shape as is_standard_layout_fact — see that row's own note.
vptr_offset_bits ⚠️ Partial ⚠️ Partial ⚠️ Partial Both backends apply the same 0-if-polymorphic heuristic — the Itanium primary-base rule — so neither tracks a secondary vtable's placement under multiple inheritance. Only DWARF reads the compiler's own artificial vptr member for a real offset.
vptr_offset_bits_fact ⚠️ Partial ⚠️ Partial ⚠️ Partial ADR-063 Phase 0: Fact[int \| None] sibling of vptr_offset_bits. Unlike bases_fact/virtual_bases_fact/vtable_fact, this one is PARTIAL on both backends, not FULLFact.partial(...), not Fact.present(...) — matching vptr_offset_bits's own row exactly: the wrapped value is still the 0-if-polymorphic Itanium primary-base heuristic, which does not track a secondary vtable's placement under multiple inheritance, so the wrapper states that caveat rather than asserting full determination (a review round on the PR that added this row caught the earlier FULL claim contradicting the legacy field's own row one line above it).
base_offsets ✅ Yes 🔧 Companion tool ✅ Yes Same layout split as size_bits.
qualified_name ✅ Yes ✅ Yes ✅ Yes
qualified_name_fact ✅ Yes ✅ Yes ✅ Yes ADR-063 Phase 5 (Codex review, second pass): Fact[str \| None] sibling of qualified_name. Both backends construct it directly (Fact.present(qualified_name)), unlike data_size_bits_fact/is_standard_layout_fact/etc — qualified_name's own None return is overwhelmingly a genuine, confirmed 'no enclosing scope' determination on both header-AST paths (a real cycle/depth-cap walk failure on the castxml side is a pathological, essentially unobserved edge case — see the construction site's own comment), so relying on the generic bridge's coarser None-means-omitted default would misreport confirmed evidence as not collected for the common case.
is_abstract ✅ Yes ✅ Yes ✅ Yes clang side wired in G31 Phase C backend audit, from definitionData.isAbstract (dumper_clang._clang_record_is_abstract) — real semantic computation (an inherited-and-unoverridden pure virtual counts), not just a direct-declaration check.
is_abstract_fact ❌ No ❌ No ❌ No ADR-063 Phase 5: Fact[bool \| None] sibling of is_abstract. Deliberately NOT constructed as an explicit Fact.present(is_abstract) keyword the way is_final_fact is -- is_abstract is genuinely None on castxml for an opaque/incomplete record (no member list to judge from), so an explicit Fact.present(None) there would misreport a real not-collected case as a confirmed determination. The generic __post_init__ bridge already gets this right (None legacy value -> not_collected(), a real bool -> present(...)), so NONE here reflects literal-keyword scan evidence only, not runtime behavior.
deprecated ✅ Yes ✅ Yes ✅ Yes clang side wired in G31 Phase C (schema v19).
deprecated_fact ❌ No ❌ No ❌ No Same shape as Function.deprecated_fact -- see that row's own note.
entity_id ✅ Yes ✅ Yes ✅ Yes ADR-063 Phase 2: the parse-time model.identity.EntityId carrier. Both backends resolve one from the typed scope path they record during their own walk. Runtime-only -- never serialized, so a reloaded snapshot carries None; a hybrid merge does not backfill it, so a hybrid snapshot keeps castxml's.

EnumType

Fact CastXML direct-clang hybrid Notes
name ✅ Yes ✅ Yes ✅ Yes
members ✅ Yes ✅ Yes ✅ Yes
underlying_type ✅ Yes ⚠️ Partial ✅ Yes clang reads fixedUnderlyingType — the REAL, correct value for a fixed enum (enum E : short) — but hard-codes "int" for an unfixed enum (Codex review, PR #719, follow-up), since clang's AST JSON exposes no compiler-selected-underlying-type fact for the unfixed case at all; the true value can differ (e.g. unsigned int, chosen from the member value range). castxml reads the <Enumeration type=...> id, which resolves to the real compiler-picked underlying integer type either way — fixed or implementation-chosen (verified against real castxml 0.6.3 output) — so it stays _FULL. Not hybrid-backfilled (see hybrid_backfilled above), but since castxml itself is now a real producer, hybrid inherits a real answer instead of the previous silent int default. No diff detector reads this field — enum_underlying_size_changed is computed from DWARF — but tu_merge.py's ODR conflict check does.
source_location ✅ Yes ✅ Yes ✅ Yes
source_header — n/a — n/a — n/a Set after parsing by provenance.apply_provenance() from the public-header set (-H/--header, plus .abicheck.yml's scope.public_header_dirs), not by either backend.
source_header_fact — n/a — n/a — n/a ADR-063 Phase 5 (third batch): Fact[str | None] sibling of source_header, mirroring RecordType.source_header_fact -- another layer (provenance.tag_provenance()) owns it, kept in sync explicitly since it sets source_header by attribute assignment, never re-running post_init.
origin — n/a — n/a — n/a Set after parsing by provenance.apply_provenance() from the public-header set (-H/--header, plus .abicheck.yml's scope.public_header_dirs), not by either backend.
is_scoped ✅ Yes ✅ Yes ✅ Yes clang side wired in G31 Phase C (schema v19).
is_scoped_fact ❌ No ❌ No ❌ No ADR-063 Phase 5 (ninth batch): Fact[bool | None] sibling of is_scoped, guarded by the same clang_deprecation_facts_reliable flag its own conversion batch shares. NONE for the same bridge-derived reason as Function.deprecated_fact.
deprecated ✅ Yes ✅ Yes ✅ Yes clang side wired in G31 Phase C (schema v19).
deprecated_fact ❌ No ❌ No ❌ No Same shape as Function.deprecated_fact -- see that row's own note.
qualified_name ✅ Yes ✅ Yes ✅ Yes
qualified_name_fact ✅ Yes ✅ Yes ✅ Yes ADR-063 Phase 5 (third batch): Fact[str | None] sibling of qualified_name, mirroring RecordType.qualified_name_fact -- both backends construct it directly as Fact.present(qualified_name).
entity_id ✅ Yes ✅ Yes ✅ Yes ADR-063 Phase 2: the parse-time model.identity.EntityId carrier. Both backends resolve one from the typed scope path they record during their own walk. Runtime-only -- never serialized, so a reloaded snapshot carries None; a hybrid merge does not backfill it, so a hybrid snapshot keeps castxml's.

Param

Fact CastXML direct-clang hybrid Notes
name ✅ Yes ✅ Yes ✅ Yes
type ✅ Yes ✅ Yes ✅ Yes
kind ✅ Yes ✅ Yes ✅ Yes ADR-063 Phase 5 (eleventh batch, schema v45): the value/pointer/reference/rvalue-ref classification. castxml resolves it structurally from its own type graph (extract/headers/castxml/type_resolution.top_level_param_kind); clang has only the rendered qualType spelling to work from (extract/headers/clang/param_kind.py, same spelling-heuristic status as its sibling pointer_depth). Before this batch, neither backend populated it at all -- see AbiSnapshot.param_kind_facts_reliable.
kind_fact ❌ No ❌ No ❌ No ADR-063 Phase 5 (eleventh batch): Fact[ParamKind] sibling of kind, bridge-derived on both backends the same way is_restrict_fact is; guarded by AbiSnapshot.param_kind_facts_reliable.
default ✅ Yes ⚠️ Partial ✅ Yes Same representation split as TypeField.default: a real source expression from castxml, a placeholder/fingerprint from clang. Parameters are never merged field-by-field, so a hybrid snapshot carries castxml's params verbatim for any matched function.
pointer_depth ✅ Yes ✅ Yes ✅ Yes
is_restrict ✅ Yes ✅ Yes ✅ Yes clang side wired in G31 Phase C (schema v22). castxml was the only producer before that, so a cross-backend comparison of unchanged headers reported param_restrict_changed for every restrict parameter.
is_restrict_fact ❌ No ❌ No ❌ No ADR-063 Phase 5 (tenth batch): Fact[bool] sibling of is_restrict, bridge-derived on both backends the same way Variable.access_fact is; guarded by AbiSnapshot.clang_restrict_facts_reliable.
is_va_list ❌ No ⚠️ Partial ❌ No clang side wired in G31 Phase C continued (schema v23), x86-64 System V spelling only — the one ABI verified there; an unrecognized target's real va_list still reads False. castxml has never populated this fact, so diff_symbols._diff_param_va_list only trusts a "clang"-producer pair — NOT "hybrid" either, unlike the now-symmetric is_restrict above, since a hybrid merge keeps castxml's own params verbatim for every matched function and castxml never populates this fact at all (see its own docstring). (hybrid keeps castxml's answer: the merge does not backfill this fact, so only a clang-only declaration carries it.)
is_va_list_fact ❌ No ⚠️ Partial ❌ No ADR-063 Phase 0: Fact[bool] sibling of is_va_list, now constructed directly by both backends. castxml is NONE: it explicitly states Fact.unsupported() — a deliberate status, not a hardcoded default, but a status carrying no determined value is not extraction, the same distinction this matrix already draws for a hardcoded False/None legacy field (a review round on the PR that added this row caught the earlier PARTIAL claim conflating 'the wrapper is constructed' with 'a fact was determined'). clang is PARTIAL, matching is_va_list above exactly: the wrapped value is the same x86-64-System-V-only determination, Fact.present(bool) around it doesn't change its precision. (hybrid keeps castxml's answer: the merge does not backfill this fact, so only a clang-only declaration carries it.)

Graph edges: what each backend can see

The L2 header-only semantic graph (buildsource/header_graph.py, always built since G31 Phase A) is not produced by whichever backend produced the snapshot. service._attach_header_graph always folds type_graph.parse_clang_ast_types() / call_graph.parse_clang_ast_calls() over a clang -ast-dump=json tree:

Snapshot backend Where the graph's edges come from Cost
clang The same AST the snapshot was parsed from — returned by dumper_cache's in-process memo (G31 Phase C), not re-read. No second parse.
castxml A genuine second clang invocation, since the castxml path never calls _clang_header_dump. One extra AST pass per dump.
hybrid The clang sub-dump runs inside dumper_cache.ast_memoize_scope(), and the memo slot outlives that scope (it is cleared only on failure), so the attach that follows consumes the same parsed AST whenever its own resolved headers/includes/toolchain hash to the same key. Usually none; a second invocation only if that key differs.
(clang absent) Nothing: the graph degrades to declaration-visibility nodes with no type or call edges, and the dump still succeeds (ADR-028 D3).

Which edge kinds are reachable from headers at all is a separate question from which backend supplies them, and is bounded by what a declaration can show:

Edge kind Available from headers alone?
SOURCE_DECLARES, DECL_HAS_TYPE, TYPE_INHERITS, TYPE_HAS_FIELD_TYPE Yes — a base class, a field type, and a parameter/return type are declaration-level facts.
DECL_CALLS_DECL, DECL_REFERENCES_DECL Only for declarations whose body is written in a header (inline, template, constexpr). An out-of-line function contributes no call edges.
ADR-031 build-level kinds (TARGET_HAS_SOURCE, compile_unit/build_option nodes, …) No — there is no BuildEvidence in a header-only world. Use --sources/--build-info (L3–L5).

Why CastXML can't do all graph edges

Not a schema oversight — a difference in what the two tools are for.

CastXML emits a declaration dump: every class, function, typedef and enum in the translation unit, with types resolved and layout computed, and nothing at all about statements or expressions. Function bodies are not represented in its XML in any form. So the declaration-level edge kinds in the table above are all derivable from a CastXML dump in principle, but the two body-dependent ones (DECL_CALLS_DECL, DECL_REFERENCES_DECL) have no possible source there: the call being recorded is a statement inside an inline function, and that statement does not exist in the output.

That is why abicheck does not attempt a CastXML graph extractor and instead sources every edge from clang, which keeps full statement/expression nodes in its AST. The consequence to plan around is the "(clang absent)" row above: a CastXML-only host produces a snapshot with complete layout facts and a graph with no edges, not a graph built a different way.

The same reasoning bounds the fact matrix. CastXML's schema exposes what its own compiler computed about declarations, which is why it wins on layout (size_bits, offset_bits, base_offsets) and loses on semantic traits clang computes but never lays out (is_standard_layout, is_trivially_copyable) — the latter were dead detectors on every backend until the clang parser was wired to them in G31 Phase C.

How to extend clang parsing

Five ways to get more out of clang, in the order it is usually worth trying them. All but the first are additions to today's design, not replacements.

Approach What it buys What it costs Use it when
JSON AST dump (clang -ast-dump=json, today's backend) Everything the AST prints: declarations, qualifiers, attributes, initializer expressions, statement nodes for header-defined bodies. Pure-Python consumer, unit-testable with no clang installed. Output is a printed form — some facts are absent (record layout) and others are spelled rather than structured. Large trees are expensive to serialize and re-parse. The default. Nearly every fact in the matrix above was closed this way.
clang.cindex (libclang Python bindings) A cursor API over the same AST, plus a few computed queries the printer never emits. Adds a hard dependency on a matching libclang shared library — precisely the install problem the pure-JSON backend exists to avoid. Rarely worth it here; prefer the companion tool below when a computed fact is needed.
LibTooling companion (tools/clang-layout-tool/, G28 Phase 4) Anything the real compiler computes: ASTRecordLayout sizes/offsets, VTableContext slot indices and secondary-vtable placement. A C++ tool to build, version, and ship; opt-in via ABICHECK_CLANG_LAYOUT_TOOL. A fact that is computed, not printed. This is the only route to the 🔧 rows in the matrix.
Preprocessor callbacks (PPCallbacks, via the tool above) Macro definitions/uses and #if conditionals as first-class facts, rather than reconstructed from a clang -M depfile. Same C++ tool cost; needs a decision about how macro facts enter the model. The macro/config dependency work (G29 Phase 5) and Phase C's open "preprocessor/build-context reconciliation" item.
VTableContext (via the tool above) The real vtable layout, including a secondary base's vptr offset under multiple inheritance — the one thing the matrix's vptr_offset_bits row says neither backend knows. Same C++ tool cost, plus a model field that can express more than one vptr. Closing the multiple-inheritance vptr gap listed below.

The rule of thumb the matrix bears out: if clang prints the fact, close it in dumper_clang.py; if clang only computes it, it belongs in the LibTooling companion. Guessing which one applies is what produced the two reverted G31 Phase C attempts recorded in AGENTS.md — verify against real clang -ast-dump=json output before wiring a fact up.

Known gaps

Each of these is a documented consequence of the matrix, not a bug awaiting a quick patch. They are listed here so a reader can tell "abicheck cannot see this" apart from "abicheck saw no change".

  • Secondary vtable placement under multiple inheritance. Both backends apply the same 0-if-polymorphic heuristic for vptr_offset_bits, so a secondary base's own vtable pointer at a non-zero offset is invisible to either. DWARF reads the compiler's artificial vptr member and does know the primary offset for real; the multiple-inheritance case needs both a model field that can hold more than one vptr and the VTableContext route above.
  • EnumType.underlying_type on an unfixed enum, clang backend only. CastXML (and therefore hybrid, which inherits castxml's real answer here) reads the compiler-picked underlying integer type correctly either way — fixed or implementation-chosen. clang reads the real value only for a fixed enum (enum E : short); for an unfixed enum it hard-codes "int", since clang's AST JSON exposes no compiler-selected-underlying-type fact for that case at all (Codex review, PR #719 follow-up) — the true value can differ (e.g. unsigned int, chosen from the member value range). No diff detector reads the field (enum_underlying_size_changed is computed from DWARF), but tu_merge.py's ODR-conflict check does, so a multi-TU "clang"-producer dump can still miss an underlying-type conflict on an unfixed enum.
  • Facts a hybrid merge does not inherit from clang. A hybrid snapshot is castxml-based: only the fields dumper_hybrid.py explicitly backfills take clang's value. is_template_pattern, has_anonymous_aggregate_fields and underlying_type are not on that list, so for a declaration both backends saw, clang's answer is dropped. (For a declaration only clang saw, the whole entry is appended verbatim and the facts survive.)
  • Opaque types — closed. clang's parse_types previously skipped every non-definition record entirely, so a forward-declared handle type (struct Session; with no definition in the public headers) was simply absent from a clang snapshot, where CastXML emitted it with is_opaque=True. Closed in PR #719: clang now emits an opaque stub for a forward-declaration-only identity too (see the is_opaque matrix row above for the exact collapsing rule when both a forward decl and a definition share one identity in the same TU).
  • All three model fields this section used to flag as "nothing populates" now have a producer. Param.is_va_list (schema v23) and Variable.value/Variable.access (schema v24), each closed in a G31 Phase C continued pass — see the matrix rows above for the exact scope and gates each carries. param_became_va_list/param_lost_va_list are reachable on real input for a "clang"-producer pair specifically (NOT "hybrid" — see diff_symbols._diff_param_va_list's own docstring), though still unreachable for castxml (no producer there) or on an unrecognized target's real va_list. var_access_changed/ var_access_widened are reachable for a "castxml"-producer pair specifically (also not "hybrid", for the identical coverage-shift reason). var_value_changed needed no equivalent gate — it was already protected by diff_types_abicc_parity._diff_var_values's existing per-pair None-skip.

See also

  • Snapshot Format — the schema version history, including which version each backend-parity fix landed in and the reliability flag it brought with it.
  • Evidence and detectability — what each evidence layer (L0–L5) buys, of which this page details one layer's two producers.
  • Platform support — the sibling host-OS × binary-format matrix, generated the same way.