type_base_changed declines on incomplete bases facts but still fires on a PRESENT-but-empty capture gap¶
Index: Known gaps
Status (re-verified 2026-10-09 at 456989f): PARTIAL. The vtable half is fixed. compare/base_class_diff.py:36-71 diff_bases now gates bases_fact/virtual_bases_fact through compare_facts and declines when a side is NOT_COLLECTED/FAILED/PARTIAL. A PRESENT-but-empty capture gap can still fabricate type_base_changed, as the entry describes.
A list-valued
RecordType field cannot express "not captured", so an empty-vs-non-empty
difference conflates a real change with one side's debug info simply not
covering the declaring TU. Confirmed for vtable: identical headers, no
DWARF vtable, and zero _ZTV symbols on either side still produced a
BREAKING type_vtable_changed, because _diff_type_vtable guarded on
t_old.vtable == t_new.vtable and nothing else. Fixed by requiring an
independent layout signal (a size change — the vptr a genuinely polymorphic
class gains — or a virtual-base change) before an empty↔non-empty
transition is reported; both-sides-captured differences are untouched, and
an unknown size on either side keeps the finding, since the suppression
needs positive evidence that layout held still rather than being a fallback
for missing information. This is the discipline diff_vtable_layout.py
(tri-state None, "degrading to B1's L0 view rather than fabricating a
break") and diff_elf_layout.py (compare only a _ZTV present on both
sides) already state in their own docstrings; the type-level detector had
neither. Two things remain open. (1) _diff_type_bases
(set(t_old.bases) != set(t_new.bases), and its virtual-base half) has the
same unguarded shape and stays that way — an attempted guard was written
and reverted before merge, and the reason is worth not rediscovering.
Every layout-based premise for it is false: an empty base is invisible by
the empty-base optimization, and — the one that killed the attempt — a
storage-contributing base can be added without moving the derived class's
size at all when the class is over-aligned (verified against g++:
struct alignas(8) D {} and struct alignas(8) D : B {} with
struct B { int y; } are both 8 bytes, as are the alignas(16) pair at
16). So "size held still" proves nothing about a base list, in either
direction. Unlike the vtable case there is no independent evidence stream
to fall back on — snapshot.functions answers "did this class's virtuals
change" but nothing answers "did this class's bases change" except
RecordType.bases itself. Guarding it therefore needs evidence the model
does not currently carry (per-finding provenance, or a captured base-layout
fact such as base_offsets corroboration), not a cleverer reading of
size_bits. Until then a fabricated type_base_changed from a capture gap
is the accepted cost, because the alternative — suppressing a real
hierarchy change, which is sometimes the only breaking finding a
same-size base addition produces — is strictly worse. (2) The vtable guard is narrower than
a first reading suggests, and its own docstring used to overclaim it. The
class's virtual functions and its RecordType.vtable are two projections of
the same DWARF evidence (both trace to DW_TAG_subprogram), not independent
streams — so a translation unit whose coverage vanishes can take both, the
two sides' signature sets then differ, and the guard declines to suppress.
The false positive survives in that shape. That is the failure direction to
have (it leaves a pre-existing false positive standing rather than hiding a
real break), but it means the guard covers the reported case — identical
headers, no DWARF vtable, no _ZTV anywhere, virtuals absent from both
sides — and not every capture gap. Closing the rest needs artifact or
provenance evidence (_ZTV presence, per-finding providers) the type-level
detector does not receive today. (3) One accepted
false negative in the
fix itself: a class already polymorphic through a base, declaring no
virtuals of its own, that gains one — its vtable grows while its object
size does not, making it indistinguishable from capture noise without a
real polymorphism walk over both sides' base chains
(diff_vtable_layout._is_polymorphic) plus the per-finding provenance the
entry below describes. (4) A pure virtual reaches that same size backstop
for a different reason: it has no out-of-line definition, so
dwarf_snapshot drops its declaration-only DIE from snapshot.functions
while still counting it as a vtable child of the class, leaving both
owned-signature sets empty — and with alignas absorbing the new vptr the
size does not move either. Reproduced against g++
(struct alignas(8) A { virtual void f() = 0; } compiled alongside a
concrete derived class, which is what makes GCC emit A's complete DIE
rather than a DW_AT_declaration stub): old vtable [], new vtable
['_ZN1A1fEv'], both 8 bytes, _ZN1A1fEv absent from the function map on
both sides. An attempt to close this one was written, merged into the
branch, and then reverted — the reason is the most useful thing in this
entry. The fix consulted RecordType.vptr_offset_bits as "the layout
descriptor's own witness, the only signal here that is not another
projection of the same subprogram DIEs." That claim was false, and
checkably so: both producers assign the field as 0 if vtable else
None (dwarf_snapshot.py, dumper_castxml.py), so on every real DWARF
or CastXML snapshot (old.vptr is None) != (new.vptr is None) is
identical to the empty↔non-empty vtable transition being guarded. It was
therefore true by construction for every input that reached it, which did
not merely weaken the guard — it made the guard inert, restoring the
original capture-gap false positive in full (Codex review). Only the
optional ABICHECK_CLANG_LAYOUT_TOOL path computes a real vptr, and
nothing in the model distinguishes that value from the derived one, so the
field cannot be trusted here at all. Reverted; case (4) joins case (3) as
an accepted false negative. The tests are the second lesson: the
guard's unit tests built RecordTypes with vptr_offset_bits left None
on both sides — a record no backend can emit — so the entire suite,
including the pre-existing test for the guard's whole purpose, passed
against a guard that suppressed nothing on real input. The helper now
derives the field the way the producers do, one test pins that derivation
in the producers' own source (so the reasoning fails loudly if a producer
ever computes a real vptr), and five of these tests fail against the
reverted revision. Worth recording for both (3) and (4) that the break is
not hidden: diff_layout._check_vptr_introduced fires independently
on the same None → 0 transition and the verdict stays BREAKING
(verified end to end, and now pinned by its own test) — which is also why
the FP-rate corpus, a verdict-level gate, could not catch either the
original false positive or the inert-guard regression, and why the
regression coverage is a direct unit test on the predicate
(tests/test_vtable_evidence_guard.py) instead. Leaning on a sibling
detector remains uncomfortable, but the alternative on offer was a witness
that only appeared independent. Guarded by four FP-corpus cases under the
evidence-absence axis (one FP guard, three FN sentinels), the unit tests
above, and three Hypothesis properties in
tests/test_detector_properties.py.
A named follow-on, found by a user rather than by any gate in this
repo, and worth being explicit about why: two detectors reading the
identical evidence gap and reaching two different severities is its own
bug class, distinct from either detector being individually wrong.
LAYOUT_UNVERIFIABLE (diff_layout.py) answers "was there real layout
evidence for this type" from the exact same asymmetric-evidence condition
_vtable_transition_is_evidenced above declines to suppress on. Both
answers are individually correct and individually documented (this entry
is the vtable side's own justification) — but a type carrying both at
once reads as a hard BREAKING verdict sitting next to a finding that
already says the evidence for that type couldn't be verified either way,
reproducible with zero real ABI change (scanning a binary against a dump
of itself; reported against real oneDAL binaries, three types, two
libraries). None of this repo's existing quality gates were positioned to
catch it: the FP-rate/tier-accuracy gates are verdict-level (the overall
verdict was already correctly BREAKING, dominated by the vtable
finding, so nothing there looked wrong), and every existing test —
including the Hypothesis properties this entry already describes — is
scoped to one detector's own correctness in isolation, never to whether
two detectors' outputs are mutually legible when they land on the same
subject.
The fix went through four designs before landing, and the last pivot is
the one worth reading closely — it generalizes past this one pair of
detectors. (1) Demoting TYPE_VTABLE_CHANGED was tried first and
rejected immediately: a real last-virtual-method removal with a new-side
capture gap is indistinguishable from this same evidence gap, so
softening its severity risks a false negative on a genuine break. (2)
Folding the now-redundant LAYOUT_UNVERIFIABLE finding into
redundant_changes unconditionally was tried next, and review found four
real defects in it in turn: correlating on bare Change.symbol (two
distinct same-named records in different namespaces can share it, so
fold on qualified_name); folding on mere co-occurrence rather than a
marker recording why the stronger finding was kept (an
independently-evidenced TYPE_VTABLE_CHANGED — a real reorder, a real
size delta, a real virtual-base change — must never trigger the fold);
reusing Change.modulation_reason/modulation_rule for internal
signaling (those are a public, report-facing audit trail for an actual
verdict override — tagging an unmodulated finding with them is a false
audit entry a downstream consumer would read as real); and running the
fold before FilterNonPublicSurface/ApplySuppression had settled
both findings, which both hid the redundant finding from a suppression
rule targeting it directly and let it outlive its covering finding's
later exclusion. (3) Fixing all four and gating the fold on a
policy-resolved-verdict subsumption check (checker.
_vtable_gap_finding_is_verdict_subsumed) closed the PolicyFile-override
case, but review found the fix was still solving the wrong layer: the
severity-scheme axis (severity.SeverityConfig — abi_breaking/
potential_breaking/etc., each independently error/warning/info)
is chosen by the CLI/reporter caller entirely after compare()
returns, so no compare()-time decision to remove a finding from
changes can ever be correct for it. Concretely: a caller configuring
abi_breaking=info / potential_breaking=error wants
LAYOUT_UNVERIFIABLE's error-level contribution to reach
severity.compute_exit_code, but that function reads result.changes
directly — if the fold already removed the finding because the
policy axis (a completely orthogonal gate, resolved before
compare() returns) ranked the covering TYPE_VTABLE_CHANGED as more
severe, the severity axis never gets a chance to see it, and this
reproduces with zero policy override involved (Codex review, fresh
evidence). The same review round also found the fold's pipeline
ordering relative to DemoteUnreachableInternalChurn (which runs later,
to demote a confirmed-unreachable internal-namespace type) could orphan
an already-made fold decision when the covering finding was itself
demoted afterward. (4) The only structurally-safe fix was to stop
removing information from changes for this reason at all.
post_processing.AnnotateLayoutUnverifiableCoveredByVtableChanged
leaves both findings exactly where they always were and only sets the
already-existing, generic Change.correlated_change_kind field (reused
from its original ADR-041 purpose, not a new one) on the redundant
LAYOUT_UNVERIFIABLE finding, pointing at "type_vtable_changed".
Change.vtable_covers_unverifiable_layout_gap (set in
diff_types._diff_type_vtable) still records which TYPE_VTABLE_CHANGED
findings rest purely on the ambiguous evidence gap versus real,
independent evidence — that detection logic is exactly what survived all
four designs unchanged; only the action taken on it changed. This
design is immune by construction to every one of the defects above and
to any future one shaped like them, because nothing is ever hidden from
a consumer that reads changes — there is no "was this the right time
to remove it" question left to get wrong. The general principle this
leaves behind: never remove a finding from a shared, multiply-consumed
list (DiffResult.changes) to resolve what is fundamentally a
presentation problem (two findings reading as contradictory) when that
list has independent downstream consumers — a legacy verdict, a
policy-file override, a severity-scheme exit code, a future pipeline
step — that this fix cannot fully enumerate and whose configuration is
chosen after the removal decision was made. Annotate; never remove.
Regression coverage: tests/test_vtable_severity.py's
TestLayoutUnverifiableCorrelatedWithVtableChanged (hand-picked example
cases, including one exercising the severity-axis gap directly via
severity.compute_exit_code) and a generalized Hypothesis property,
test_layout_unverifiable_always_correlated_when_vtable_change_shares_its_gap
in tests/test_detector_properties.py, checking the annotation holds
over arbitrarily generated classes and evidence-descriptor shapes.
A whole-class structural fix was attempted mid-way through the fold
design (2) above, for the field-ordering half of it, and reverted — the
in-repo audit that justified it was the wrong audit. (This sub-entry
predates the pivot to design (4) and is kept because the lesson is
independent of which design won.) The field-ordering bug (found twice
now: first on AbiSnapshot in PR #582, again on the new Change field
this fix originally added mid-list) was first "fixed" by making Change
keyword-only from old_value onward via the dataclasses.KW_ONLY
sentinel, on the strength of an AST-parse of every Change(...) call
site in this repo confirming every positional call anywhere passes
exactly the three required leading fields and never more. That audit
answers the wrong question for a type this codebase itself documents as
public API (checker_types.py's own module docstring; CLAUDE.md:
"changing their public surface is a breaking change to the Python API —
coordinate it"): it proves this repo's own call sites are safe, and says
nothing about an external consumer who previously called Change(kind,
symbol, description, old_value, new_value, ...) positionally — for whom
the whole-class sentinel is exactly the same breaking shift the fix
exists to prevent, just moved to a boundary this repo cannot see or test
(Codex review, fresh evidence). Reverted in favor of the same per-field
field(kw_only=True) PR #582 already used for AbiSnapshot, applied
only to the one new field (vtable_covers_unverifiable_layout_gap) and
appended at the true end of the dataclass (not mid-list) so every
pre-existing field's position — and therefore every pre-existing
positional caller's behavior, in or out of this repo — is completely
unchanged. The lesson generalizes beyond this one field: for a type
documented as public API, "no in-repo caller breaks" is not the bar:
prefer the narrowest change that cannot break a caller this repo cannot
see, even when a broader structural fix looks more complete. The
KW_ONLY sentinel is still the right tool for a genuinely internal
dataclass with no external-construction contract (nothing here argues
against it in general) — the mistake was applying it to one that has
such a contract without checking for one first.